diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 25ed7a3c7..2cbd9de56 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,6 +5,7 @@ # - No re-review on every push: each fix commit used to start a new pass over a new diff. # - No automatic reply inside review threads: every in-thread answer used to wake another pass. # - Vendored parsers and generated/pinned files are excluded; a review of them cannot change them. +# - No docstring-coverage pre-merge check: the house style documents contracts where they bite, not every symbol. # CodeRabbit reads this file from the PR's head branch, so a fork PR picks it up once it merges main. reviews: profile: chill # the default; "quiet" is the lever if noise persists @@ -31,5 +32,8 @@ reviews: labelled a floor; a zero means none found. Flag silent zeros, undisclosed truncation, and gate arms that cannot fail. sequence_diagrams: false + pre_merge_checks: + docstrings: + mode: "off" # quoted: a bare off is a YAML 1.1 boolean, and the schema wants the string chat: auto_reply: false # thread replies no longer wake it diff --git a/.github/pargates-shard-weights.json b/.github/pargates-shard-weights.json index 3c2f136c5..0020d2672 100644 --- a/.github/pargates-shard-weights.json +++ b/.github/pargates-shard-weights.json @@ -135,6 +135,7 @@ "duprowcheck.sh": 1.5, "dynmapsimdcheck.sh": 36.4, "editcheckcheck.sh": 17.8, +"editcheckdeclcheck.sh": 46.2, "editchecknotecheck.sh": 5.1, "edithandlehintcheck.sh": 3.0, "editpayloadbinarycheck.sh": 2.0, @@ -285,6 +286,7 @@ "luacheck.sh": 3.5, "macroedgecheck.sh": 3.6, "manifestcheck.sh": 62.9, +"mapdatasectioncheck.sh": 55.0, "mapdiffcheck.sh": 3.3, "matchcapturecheck.sh": 11.8, "matchgrammarcheck.sh": 0.6, diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 324836bf5..76c57dbfa 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -1456,6 +1456,7 @@ ack short-horizon-churn e8abfc6e8faace3b 5 cid=ba58d27ecdba1f2f OPTREMARKS F3 (d ack short-horizon-churn e98afe9f7356dca7 116 cid=9ff08b0551bdf16e macro-vocabulary rename (VERIFY/DEGRADED_PATH_ALERT family -> ASSUME/EXPECTS/ENSURES/DASSERT/UNREACHABLE/VALIDATE/DISCLOSE): identifier-only churn across 179 files, no logic change (rename_selfcheck.py, --check idempotent; ripwire --no-cache byte-identical old binary vs new binary on 3 trees) ack short-horizon-churn e9db97026e7265d2 145 cid=5e8fa13c43d29808 macro-vocabulary rename (VERIFY/DEGRADED_PATH_ALERT family -> ASSUME/EXPECTS/ENSURES/DASSERT/UNREACHABLE/VALIDATE/DISCLOSE): identifier-only churn across 179 files, no logic change (rename_selfcheck.py, --check idempotent; ripwire --no-cache byte-identical old binary vs new binary on 3 trees) ack short-horizon-churn ea9e556ae2c5b78f 3 cid=8f4414705d24d6f7 by=src/* member-variable round (card A3), side-table rule: symbols this round created (collectFieldUseSites, FieldUseAnswer, memberOwnerRefusal, declaredFieldSet, isInstanceFieldSite, dropFieldDefinitionSites, fieldCaptureKept) and touched twice within it while fields moved from ing.symbols to the IngestResult::fields side table under the orchestrator's rule; collectFacts/buildDefSpanIndex each carry ONE deliberate edit | prior: member-variable round (card A3): kUsesLegendOpen gains the one-sentence pointer to the member form, buildDefSpanIndex zero-widths field spans so containment attribution is byte-identical, kParserVer 74->75 for the new SymKind::Field + member use-site capture — each a single deliberate edit on a symbol other rounds touched recently +ack short-horizon-churn eac088e77e30bd57 11 cid=4b0efd60def2f3b3 now=11 was=0 facet=self p=src/editcheck.h:281 review fix round (M2/M3): editCheckGroups is edited again on this branch to stop offering declaration-only handles and to verify only offered spellings; the churn is the review loop itself ack short-horizon-churn ec2848a4801493a2 153 cid=e95af915562f1c12 every short-horizon-churn row in this delta names a symbol this lane wrote or rewrote under review (the two compact-legend tables, the four map legends, MapAnnotations, serialize/writeRecentRows, pagingDisclosure, the cli.h guards, scopedRecentPage, refuseInertMainModifiers). The kind measures 2+ committed in-window rewrites of the same lines, which is what a reviewed lane does to its own code: round 1 added the block, round 2 answered CodeRabbit, round 3 answered the Fable review (the paging half, the stub vocabulary, the derived preemption, the crawl check). The churn here is the review cycle, not an unstable design, and it decays out of the window on its own. | prior: L7 lint-catalog: short-horizon-churn=self is ambient repo-wide churn on cli.h/main.cpp/didyoumean.h (49-92 commits/14d, unrelated lanes) inherited by any edit to these hot, central dispatch symbols, not fixable by reshaping this change; runLint/printUsage complexity+verbosity residual is the minimum new dispatch glue (4 extracted helper calls + 3 new flags' --help text) after extracting emitLintCatalog/resolveLintSelection/computeLintApplicability/printLintRuleTallyRow out of runLint, which cut the original complexity delta from +121 to +16 ack short-horizon-churn ec9ec1cc3c01feb4 113 cid=691a45e7025a3510 round 3 test-script iteration: pymodulealiascheck.sh helper functions touched across three consecutive review-response rounds — expected churn from addressing review feedback quickly, not a structural defect ack short-horizon-churn ed0cbdfe7fa72e8c 154 cid=f42bf8683228ffff C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. diff --git a/CHANGELOG.md b/CHANGELOG.md index c061675a6..1a48945e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Added — `Class.method` and `Class#method` are selectors wherever `Class::method` is + +Agents and documentation name a method `Class.method` (Python, JS, Java) or `Class#method` (Ruby, JSDoc), and every +symbol-taking verb (`--callers`, `--callees`, `--impact`, `--uses`, `--edit-check`, `--expand`, …) answered "symbol not +found" while `Class::method` resolved. When no other spelling matches, a selector with no `:` or `/` now has each `.` and +`#` read as `::` and goes through the existing `Scope::name` tier, so it resolves exactly where the `::` spelling does +(a namespace-qualified `ns.Class.method` does not, as `ns::Class::method` does not). Several matches are disclosed as +usual (`defs=N`, or `--edit-check`'s refusal). A selector that resolved before resolves identically. `--whereis` is a +lexical scan of every ref and is unchanged. Gate: `test/selectorscopecheck.sh` arms (i)-(m). ### Added — a memory guard on every root: zero-config, silent on normal runs, a disclosed partial answer past its line (#350, layer 3) @@ -207,6 +216,39 @@ corpora, with this repository's `--report` totals unchanged. `kParserVer` 129 in inheritance records; 99 dropped a computed superclass's stray receiver ref); the record layout is unchanged, and the `quality.h` mirror and `test/qschemetrip.hash` move with it. +### Changed — data Sections no longer crowd code out of the default map (#339 F1): a code-first row pick, disclosed + +A data file of a few hundred keys or headings indexes as `SymKind::Section` rows with no call edges. Their rank is their +share of the teleport prior, and priorwt's x1.7 specific-name boost fires on data names (`database_url_12`, "Installation +step 12 details"), so they pushed called code out of the top-K the map emits: on the gate's fixture (eight Python functions +plus one data file), 1 of 8 code rows survived at K=200 beside 200 long-named markdown headings, 220 YAML keys or 220 JSON +keys, and 7/8 and 1/8 beside a #339 Rails `db/schema.rb` of 20 and 40 tables. The default map now picks its rows +**code-first**: while the rank-order top-K keeps a data Section and leaves a non-Section row out, the lowest-ranked kept +Section is swapped for the highest-ranked excluded non-Section row; survivors keep rank order. The rank vector and every +`k=` are unchanged, and a map whose top-K left no code row out is byte-identical (django, webpack and this repository at +`--top-k=16`; `test/golden.xml`). Scope: the plain map at the default `--rank-by` with no payload verb (XML, `--json`, +`--html`, `--max-tokens`), MCP `analyze` on a clean working set and MCP `rank_by=pagerank`. `--tree`, `--query`, +`--map-diff`, churn, authority/hub/rrf and the `--expand`/`--outline`/`--pack-signatures`/`--pack-top-n` ride-along maps keep the rank-order cut. +A map that swapped carries `data_sections_cut="N"` (the swaps) and `next="--graph-query='kind(all,sec)' --offset=M +--limit=K"`, which pages the Sections past the M still shown, the swapped ones first; both are defined in the full and +compact legends and `--help=all`. This repository's own default map swaps its one `sec` row (`data_sections_cut="1"`). +Gate: `test/mapdatasectioncheck.sh` (8/8 code rows on 40 fixture cells x 6 surfaces and on the 12 S1 cells of a #339 +build; red on `a5229aca`). + +Pre-registered (docs/EVALS.md "Map data Sections never crowd code out of the default map"), the registered first arm — +a Section teleport prior x0.1 that made every code row outrank every Section no call reaches — passed every gate (I2 +read at the emitted 4-dp precision and Gate S with two non-ranking fields masked, both decided after A's run) and +**failed** the registered `--eval` margin: on code-seeded commits of three corpora its `map`-column recall@20 fell +0.39 pp, 95% CI [−1.07, +0.07] pp, past the −1.0 pp bound, and outside the range of 20 matched placebos. By the +registered rule this release ships the second arm, which leaves the ranking alone. Numbers and losses are in EVALS. + +### Changed — a not-found refusal's stdout is no longer empty (compatibility) + +`--callers`, `--callees`, `--uses`, `--impact` and `--path` still exit 1 when the selector matches no indexed +definition, but stdout now carries a `found="0"` answer instead of nothing. A script that detected a miss with +`[ -z "$(ripwire … --callers=X)" ]` must test the exit status instead. Tools that count stdout tokens see about 100 +tokens on a miss instead of 0. + ### Changed — `ripwire-quality-bar` gains a bounded debt fix loop A new section drives paying down EXISTING debt: pick the top `--quality-panel=strict` row, write the test first when @@ -257,10 +299,54 @@ per page instead. Gate: `test/impactdepthcheck.sh`. ### Changed — the versions this release moves, stated once -`kParserVer` 124 → 129 (the function-literal fix takes 128; #338 and #325 take one more), `kCacheVersion` 25 → 27 +`kParserVer` 124 → 132 (the function-literal fix takes 128; #338 and #325 take 129; 132 then sits above every number +a branch build of unreleased work has used, so no cache such a build wrote is read as this release's), `kCacheVersion` 25 → 27 (the function-literal fix's record changes) and `kQSnapCacheScheme` 15 → 16 (the `--quality-delta` error-masking and placeholder changes). Every ingest cache written by an earlier build is refused and re-indexed once, and every -cached quality snapshot is recomputed. The session legend dictionary is `dictv=0e543e1e6a3fe37d entries=750`. +cached quality snapshot is recomputed. The session legend dictionary is `dictv=b5c64a12f0346e6c entries=771`. + +### Fixed — crashsweepcheck and hazardpatterncheck no longer depend on the size of `src/` + +Both gates run their static rules with `--match` over `src/`, and refused a scan the engine capped at its 5000-hit budget. +The function-definition query crossed that budget as `src/` grew (5000+ once 0.6.7 lanes merged), so both went red for a +reason unrelated to the rules. The scan now shards instead (`test/lib/shardmatch.py`): a capped whole-tree scan is +re-run over path-ordered halves of the tree, recursively, until every shard answers uncapped; a single file that alone +reaches the budget still fails the gate by name. A new arm (Z) proves completeness on a generated 6000-definition tree +past the budget and, in crashsweepcheck, that a forced split returns the same rows as the whole-tree scan on `src/`. The +engine's budget is unchanged. + +### Fixed — the handles an `--edit-check` ambiguity refusal prints name one contract each, and none is a declaration + +The refusal's "Qualify one contract:" list offered `file:name` for a header declaration. The `file:name` tier widens a +header's declaration to the definitions it stands for, so `./lib.h:scale` was refused again with the same list. Each handle +shown is now re-resolved before it is printed and must land on exactly its own contract; otherwise the canonical id, then the +`@FILE:LINE` seed is tried, and a handle that still cannot be made unique is marked `[no selector names this contract alone]` +and never used as the example. A declaration-only contract beside a definition is counted but never listed: a declaration has +no call edges, so its own answer reads `callers="0"` while the definition holds the calls. A cwd-spelled file half that names +one indexed file exactly (`./lib.cpp`, the form the tool prints) now means that file even when another path contains it +(`./sub/lib.cpp`), for every verb that takes a `file:name` selector or an `@FILE:LINE` seed; a bare `lib.cpp` keeps the +substring reading. A CLI handle holding a space is printed single-quoted. Gate: `test/editcheckdeclcheck.sh` arms (H), (J), (K). + +### Fixed — `--edit-check` on C/C++: a declaration and its definition are one contract, and the declaration's defaults count + +A header declaring `int scale( int x, int factor = 2 )` and a `.cpp` defining it were two "contracts" to `--edit-check`: +the bare name was refused as ambiguous, and the definition's handle flagged every caller that relied on a default +(`incompatible="3"` on three calls that all compile), because the arity test read only the definition's parameter list. +A bodyless C/C++ declaration now stands for a definition when the name matches, the FULL scope chain matches (every +enclosing namespace and class plus the declarator's written qualifier, so `outer::detail::f` never stands for +`::detail::f`), the member's cv/ref qualifiers match, the definition's file is the declaration's or `#include`s it directly, +the definition has external linkage (or shares the file), and the parameter-type lists are equal (names, default values and +comments stripped, read from each signature's source text). Such a declaration's group folds into the definition's, so the +bare name and the header's `file:name` answer about the definition, and a call passing between `params` minus that +declaration's defaults and `params` arguments is never flagged. The root then carries `defaults_from="decl"`. Real +overloads stay separate: each definition takes defaults only from the declaration that matches it. Anything unproven fails +closed and keeps today's answer — including a scope opened or closed by a macro (`NS_BEGIN`, `QT_BEGIN_NAMESPACE`) and a +qualified definition after `using namespace`, whose chains are unreadable; when such a declaration may carry a default (a +transitive include, an unreadable scope chain or file, a signature past 16 KiB or one this reader cannot parse, such as a function-pointer parameter) and a caller is flagged, the root says so with +`defaults_untied="N"`. Both attributes are defined in the full and compact legends. The fold applies to the post-hoc verb +(CLI and MCP `edit_check`); the `--dry-run`/`new_body` preview keeps one contract per file. The test fixture's own +`--edit-check=distance` (declared in `geometry.h`, defined in `geometry.cpp`) now answers instead of refusing. +Gate: `test/editcheckdeclcheck.sh`. ### Fixed — `--scan-skill(s)`: quoting a sensitive path no longer hides its upload (sensitive-read-upload) @@ -293,6 +379,64 @@ two-letter prefix counts (so clog, flog and clogging do too). Longer English wor dialog, backlog, analog, changelog), blog and technology still log nothing. Still a miss: a logger behind a longer one-word prefix (`auditlog`). +### Fixed — `--whereis` answers for the checkout, and a not-found refusal also answers on stdout + +`--whereis` scanned committed trees only (HEAD and the local branches). On a checkout with uncommitted changes it +still printed `complete="1"` and a bare `at=`: a function the edit had just added read `hits="0"`, and one it had +renamed or deleted was listed at its old HEAD lines. `--callers` on the same tree saw the edit. Every path under the +root that differs from HEAD (modified, staged, deleted, or untracked and not ignored) is now read from the working +tree. Its rows say `ref="worktree"` (`tip=` and `date=` name the HEAD commit it overlays) and replace HEAD's rows for +that path, so on-head=, hits= and the index-backed `head_labels=` count the checkout (on-head="0" there means the +checkout lacks the name, which HEAD's commit may still hold). The stamp gains `+dirty`, and +the root says `worktree="read"`. A changed path that cannot be read (permission, over the 2 MB blob ceiling, a name +git quotes, a directory such as an untracked nested repository or a submodule, or more than 8192 changed paths) keeps +its HEAD rows, and the root says `worktree="partial"`. A changed path beyond a symbolic link to a directory is not in +the checkout, as git reads it, and is never read through the link, so a file outside the root never answers. When git +cannot list the changes, the root says `worktree="unlisted"`. Neither claims `complete=`. Other refs are still read +as their committed trees. A clean checkout's answer is byte-identical. The MCP `whereis` tool shares the code. Both +legends define `worktree=` and print it only when the root carries it. The full legend's sentence "at= is sha-only +here (never +dirty)" is replaced. Gates: `test/completecheck.sh` §18 (add, rename, delete, deleted file, untracked +file, the no-stale-row invariant, MCP twin, an unreadable changed path) and `test/gitstampcheck.sh` (the documented +whereis exception is closed). + +With `--with-history`, a name that HEAD's commit still holds but that only the uncommitted working tree removed gets +``. It used to get the history oracle's `never`, which says the repository never had the name. +On a zero over a dirty checkout, `--whereis` and its MCP twin name the working tree's rename as +``. That note replaces the spelling near-miss. + +`--whereis=Class.method` and `--whereis=Class#method` searched that spelling as a literal, and no tree spells a +method's definition that way, so they answered `hits="0" on-head="0" complete="1"` with no note. When the index defines +a symbol named by the last segment, such an answer now carries ``. +Its `retry=` is that bare method name, and the answer claims no `complete=`. A dotted literal whose last segment the +index does not define, such as `setup.py` or `os.path`, stays an ordinary literal search with its `complete=`. The full +legend names the reasons. Gate: `test/completecheck.sh` §18k and §18u. + +A selector that matches no indexed definition is still a refusal (exit 1) on `--callers`, `--callees`, `--uses`, +`--impact` and `--path`, as README §6.2 and the `--callers` example in docs/COMMANDS.md document. The refusal now also +prints an answer on stdout, so an agent that reads stdout gets an answer instead of nothing. The answer is the verb's +own element with the selector echoed, `found="0"`, and `near=` (the name to retry with), plus `missing="from|to|both"` on +`--path`. Under `--json` it is one JSON object. Both legends define it. + +When the working tree renamed the selector, the rename is offered first. A rename needs three things: +- a definition-shaped line naming the old name in a changed file's HEAD copy that the working copy no longer contains; +- the new name absent from that HEAD copy; +- the two names sharing most of their identifier words. + +A mention alone (an import, a call or a comment) is not a rename. In that case +`near_renamed="1"` is set, and stderr gains "(renamed in the working tree: did you mean 'X'?)" ahead of the spelling +near-miss. `line_trim` → `trim_line` used to get a suggestion of `line_type`. stderr is otherwise unchanged. + +The MCP twins `find_referencing_symbols`, `find_symbol`, `uses`, `impact` and `path_between` keep their -32602 +refusal and carry the same document in `error.data.answer`. A `batch` item's not-found `err=` names the rename the +same way. + +Gates: `test/completecheck.sh` §18j, §18l, §18m, §18o, §18p, §18q, §18r, §18s, §18t, §18v and §18w. + +`--whereis` orders a test-local definition after the production definition of the same name when the answer holds +both, and marks it `test_local="1"`. A test-local definition is one in a test scope such as Rust `#[cfg(test)]`, or one under a +test, bench or fixture path (`test/`, `bench/`, `fixtures/`, `testdata/` and the test-file name patterns). Nothing is dropped, and an answer with only one kind is unchanged. Gate: `test/completecheck.sh` +§18n. + ### Fixed — review round on train 22: false gates, a stack overflow, silent cuts - `--quality-delta` log-only handlers: a receiver is a logger only when the first or last word of its last segment is `log`, `logger` or `logging` (words split on `_`, `-` and camelCase), or a known logger package, not whenever it contains "log". A Python `except Exception: store.catalog.write(x)` no longer gates as log-only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1cef1c55d..58c205802 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -544,7 +544,7 @@ contract and well-formedness. The GCC/Clang language extensions this tree uses g refuses a new `__builtin_*`, inline asm or `__attribute__` outside that pair, so a Windows break is caught on every POSIX leg rather than discovered on Windows. -A green Windows matrix is **not** the same as a validated platform. The 658-gate suite does not run there — it needs +A green Windows matrix is **not** the same as a validated platform. The 660-gate suite does not run there — it needs the harness on #44 — and the ASan flavour is compiled on Windows but never executed. The **windows-x64 release zip** (a preview from 0.6.3) is built by `.github/workflows/windows-package.yml`, which diff --git a/README.md b/README.md index 312f10ffe..9044c590d 100644 --- a/README.md +++ b/README.md @@ -2063,9 +2063,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published ### In the tests
-658 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures +660 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures -`test/regression.sh` names **658 gate scripts** and is the authoritative list; +`test/regression.sh` names **660 gate scripts** and is the authoritative list; `python3 test/pargates.py . ./build/ripwire -j 6` runs the same set in parallel. On top of them sit the contracts that do not fit a unit test: two runs byte-identical, warm output identical to cold, output that pipes clean through `xmllint --noout`, a sanitizer build with `-fno-sanitize-recover=all`, and a @@ -2415,7 +2415,7 @@ same renderer. One computation has one output shape. | Item | Requirement | | --- | --- | -| Operating system | macOS (arm64 or x86-64) or Linux (arm64 or x86-64). Native Windows x64 **builds** with both clang-cl and MSVC `cl.exe` — CI builds both on `windows-latest` every full matrix and smoke-tests each binary (`--version`, `ctest`, a real crawl, the two-run byte-identical contract, well-formed XML); the 658-gate suite does not run there, and ASan is compiled but never executed, so treat it as a build, not a validated platform. From 0.6.3 a prebuilt `windows-x64` zip ships as a **preview** ([Windows](#windows)): CI unzips it and compares its output with Linux's byte for byte, but no maintainer runs Windows, so WSL2 remains the fully supported way to run it on a Windows machine. | +| Operating system | macOS (arm64 or x86-64) or Linux (arm64 or x86-64). Native Windows x64 **builds** with both clang-cl and MSVC `cl.exe` — CI builds both on `windows-latest` every full matrix and smoke-tests each binary (`--version`, `ctest`, a real crawl, the two-run byte-identical contract, well-formed XML); the 660-gate suite does not run there, and ASan is compiled but never executed, so treat it as a build, not a validated platform. From 0.6.3 a prebuilt `windows-x64` zip ships as a **preview** ([Windows](#windows)): CI unzips it and compares its output with Linux's byte for byte, but no maintainer runs Windows, so WSL2 remains the fully supported way to run it on a Windows machine. | | Prebuilt Linux floor | RHEL 8 or later (glibc 2.28) | | Prebuilt macOS floor | macOS 14 or later, Apple silicon. 0.6.1 is the last release with an Intel macOS binary; on an Intel Mac, pin `RIPWIRE_VERSION=v0.6.1` or build from source. | | x86-64 floor | x86-64-v3 (Intel Haswell, 2013, or later), for a prebuilt binary and a source build alike | @@ -2692,7 +2692,7 @@ This is the part to wire into a script. | Code | Meaning | | --- | --- | | 0 | The command completed. | -| 1 | The command refused the request. A refusal names the reason on stderr. | +| 1 | The command refused the request. A refusal names the reason on stderr. When `--callers`, `--callees`, `--uses`, `--impact` or `--path` refuse a selector that matches no indexed definition, they also print an answer on stdout: the verb's element with `found="0"` and the name to retry with. | | 2 | A policy gate fired: `--arch` found a layering violation, `--scan-skill` found a CRITICAL, `--quality-delta` found new debt. | | 3 | The output exceeded the token budget that you set. | | 4 | `--test-gate` found an open obligation. | @@ -2817,7 +2817,7 @@ python3 test/pargates.py . ./build/ripwire -j 6 A new gate script must be added to `test/regression.sh` in the same change. The gate `test/manifestcheck.sh` enforces this rule. -Another gate derives the cap inventory. The tool has 230 compile-time caps and 7 ranking parameters. +Another gate derives the cap inventory. The tool has 233 compile-time caps and 7 ranking parameters. `docs/LIMITS.md` lists each cap, its value, and whether the file discloses a truncation when the cap fires, and `python3 docs/limits_build.py --check` proves that list against `src/`. `docs/TUNING.md` lists the measured cost of each cap. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 01fa772e1..2d06e364b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -748,7 +748,7 @@ _Unknown-symbol REFUSAL shape (exit 1) with a did-you-mean from real edit distan ``` $ ./build/ripwire . --callers=DoesNotExist -(empty) + ``` **Shaped by:** `--no-route`, `--callees`, `--uses`, `--impact`, `--expand`, `--edit-check`, `--slice-flow`, `--at` @@ -1575,7 +1575,7 @@ $ ./build/ripwire . --outline=rankGraphTeleport --top-k=0 **Answers:** print these symbols' full bodies, plus the signatures of what they call full bodies of A,B,... -Selector grammar per item (the tail after the LAST ':' decides; a tail STARTING WITH A DIGIT is a range, anything else is a name): NAME every def of that name | FILE:NAME that file's def NAME:START-END body slice | FILE:NAME:START-END selector + slice FILE:LINE:NAME paste a row's p="path:line" straight from --callers/--lint/--grep (NOT --hotspots: its p= is a BARE path — build FILE:LINE:NAME from its own p=/top_l=/top= instead, since top= is just the worst function's name) path::scope::name the id composed from a row's p= sc= n= (the map, --for, --pack-task) Scope::name the sym= spelling edit-check and grep's in= rows print — matches the name under any scope whose ::-boundary SUFFIX is Scope (Box::lid, deep::Box::lid); a wrong scope refuses, it never falls back to the bare-name union (START-END is 1-based within the def's OWN body — lines="lo-hi/total" marks the slice partial; out-of-range clamps. FILE matches any path substring, like --callers/--lego.) EXACT-NAME DEFAULT (one token, one unambiguous match, no explicit --top-k): the ranked map defaults to top-k=0 — you already named the exact symbol, so the ~200-row orientation map is pure overhead in front of the one body it exists to summarize. Disclosed on the root as topk_default="0" (self-describing: the change is visible without reading source). A MULTI-match name (an ambiguous bare name) or a multi-token --expand keeps the map — there IS something to disambiguate. An EXPLICIT --top-k=N (0 included) always overrides this default. Each body also carries sibs="a,b,..." sibs_total="N" [sibs_capped="1"] (the file's OTHER symbols, names only, capped at 40) and inc="x.h,..." inc_total="N" [inc_capped="1"] (the file's own #include/import targets, capped at 24) — both absent when the count is 0 (a documented zero, not a degrade), so a body no longer needs a second --outline call just to learn what else lives in its file. CHEAPEST-COMPLETE-ANSWER SERVING (no explicit --top-k, no range slice): the verb ALSO measures the (possibly map-less) bundle against the requested symbols' whole FILE(s) and emits the SMALLER, disclosed on the root as mode="bundle|whole-file" reason="the two byte counts" — on a small file the old bundle was 5.65x the file itself; on a big file the bundle saves ~26x. The whole-file form is with the file CDATA-wrapped (redacted as usual) and every requested symbol's line anchor kept. An EXPLICIT --top-k=N (including 0) opts out of BOTH the exact-name default and mode= auto-selection and keeps the classic undecorated shape; a SYM:START-END slice opts out of mode= auto-selection only (serving the whole file would invert an explicit narrowing) but still gets the exact-name top-k=0 default when it applies. When the bundle wins AND no explicit --top-k was given, the requested bodies are served BEFORE the ranked map (not buried after it), and the map's escape hatch also rides the root as note="...--top-k=0 for the payload alone..." (not stderr-only): a caller reading only stdout sees both the answer and how to drop the map on the next call. Absent whenever the bundle carries no map at all (top-k=0, exact-name default) or loses to whole-file, and whenever --top-k was explicit — the classic shape above carries no note= either. +Selector grammar per item (the tail after the LAST ':' decides; a tail STARTING WITH A DIGIT is a range, anything else is a name): NAME every def of that name | FILE:NAME that file's def NAME:START-END body slice | FILE:NAME:START-END selector + slice FILE:LINE:NAME paste a row's p="path:line" straight from --callers/--lint/--grep (NOT --hotspots: its p= is a BARE path — build FILE:LINE:NAME from its own p=/top_l=/top= instead, since top= is just the worst function's name) path::scope::name the id composed from a row's p= sc= n= (the map, --for, --pack-task) Scope::name the sym= spelling edit-check and grep's in= rows print — matches the name under any scope whose ::-boundary SUFFIX is Scope (Box::lid, deep::Box::lid); a wrong scope refuses, it never falls back to the bare-name union Scope.name|Scope#name the same match, dotted (Python/JS/Java) or Ruby-style; tried only when no other spelling matched (START-END is 1-based within the def's OWN body — lines="lo-hi/total" marks the slice partial; out-of-range clamps. FILE matches any path substring, like --callers/--lego.) EXACT-NAME DEFAULT (one token, one unambiguous match, no explicit --top-k): the ranked map defaults to top-k=0 — you already named the exact symbol, so the ~200-row orientation map is pure overhead in front of the one body it exists to summarize. Disclosed on the root as topk_default="0" (self-describing: the change is visible without reading source). A MULTI-match name (an ambiguous bare name) or a multi-token --expand keeps the map — there IS something to disambiguate. An EXPLICIT --top-k=N (0 included) always overrides this default. Each body also carries sibs="a,b,..." sibs_total="N" [sibs_capped="1"] (the file's OTHER symbols, names only, capped at 40) and inc="x.h,..." inc_total="N" [inc_capped="1"] (the file's own #include/import targets, capped at 24) — both absent when the count is 0 (a documented zero, not a degrade), so a body no longer needs a second --outline call just to learn what else lives in its file. CHEAPEST-COMPLETE-ANSWER SERVING (no explicit --top-k, no range slice): the verb ALSO measures the (possibly map-less) bundle against the requested symbols' whole FILE(s) and emits the SMALLER, disclosed on the root as mode="bundle|whole-file" reason="the two byte counts" — on a small file the old bundle was 5.65x the file itself; on a big file the bundle saves ~26x. The whole-file form is with the file CDATA-wrapped (redacted as usual) and every requested symbol's line anchor kept. An EXPLICIT --top-k=N (including 0) opts out of BOTH the exact-name default and mode= auto-selection and keeps the classic undecorated shape; a SYM:START-END slice opts out of mode= auto-selection only (serving the whole file would invert an explicit narrowing) but still gets the exact-name top-k=0 default when it applies. When the bundle wins AND no explicit --top-k was given, the requested bodies are served BEFORE the ranked map (not buried after it), and the map's escape hatch also rides the root as note="...--top-k=0 for the payload alone..." (not stderr-only): a caller reading only stdout sees both the answer and how to drop the map on the next call. Absent whenever the bundle carries no map at all (top-k=0, exact-name default) or loses to whole-file, and whenever --top-k was explicit — the classic shape above carries no note= either. **Try it** @@ -1604,7 +1604,7 @@ $ ./build/ripwire . --top-k=0 --expand=rankGraphTeleport **Caveats (stated by the binary):** -- a wrong scope refuses, it never falls back to the bare-name union (START-END is 1-based within the def's OWN body — lines="lo-hi/total" marks the slice partial; +- a wrong scope refuses, it never falls back to the bare-name union Scope.name|Scope#name the same match, dotted (Python/JS/Java) or Ruby-style; - FILE matches any path substring, like --callers/--lego.) EXACT-NAME DEFAULT (one token, one unambiguous match, no explicit --top-k): the ranked map defaults to top-k=0 — you already named the exact symbol, so the ~200-row orientation map is pure overhead in front of the one body it exists to summarize. - A MULTI-match name (an ambiguous bare name) or a multi-token --expand keeps the map — there IS something to disambiguate. @@ -3546,7 +3546,7 @@ $ ./build/ripwire . --stray-content=main --abi **Answers:** find which branch's tree defines or mentions SYM, HEAD first which REF's tree defines or mentions SYM — HEAD first, then every local branch, with on-head="0" naming the case the verb exists for: content that lives only on a branch. -Each distinct blob is read ONCE (content-addressed), so N branches cost ~one tree. kind="def" on a HEAD row is the PARSED index's answer (head_labels="index"); on a REF row it is a LEXICAL heuristic — ref blobs are raw text, never ingested, so a doc quoting a signature still reads as a definition. head_labels="lexical" ⇒ HEAD fell back to that heuristic too (no indexed def of the name, or a working tree that drifted from HEAD). refs_scanned= is the SCAN denominator (refs read besides HEAD), not a matched count. Read-only; single-root only. LIMITS: a TREE scan finds only what some ref STILL carries, so hits="0" alone cannot tell a name this repo never had from one it deleted, and content dropped by every tree is invisible. Add --with-history: a row then says v="never" or v="removed" with the commit, date and file that removed it. Remote-tracking refs are excluded (they mirror local ones); refs are capped, narrow with --stray-content=SUBSTR. +Each distinct blob is read ONCE (content-addressed), so N branches cost ~one tree. kind="def" on a HEAD row is the PARSED index's answer (head_labels="index"); on a REF row it is a LEXICAL heuristic — ref blobs are raw text, never ingested, so a doc quoting a signature still reads as a definition. head_labels="lexical" ⇒ HEAD fell back to that heuristic too (no indexed def of the name, or a working tree that drifted from HEAD). refs_scanned= is the SCAN denominator (refs read besides HEAD), not a matched count. Read-only; single-root only. A checkout that differs from HEAD is read from disk: each changed path's rows say ref="worktree" and replace HEAD's, at= gains +dirty, and worktree= says whether every one was read. LIMITS: a TREE scan finds only what some ref STILL carries, so hits="0" alone cannot tell a name this repo never had from one it deleted, and content dropped by every tree is invisible. Add --with-history: a row then says v="never" or v="removed" with the commit, date and file that removed it. Remote-tracking refs are excluded (they mirror local ones); refs are capped, narrow with --stray-content=SUBSTR. **Try it** @@ -4398,7 +4398,7 @@ _The session legend dictionary the MCP server serves as ripwire://legend-dict/fu ``` $ ./build/ripwire . --legend-dict -ripwire legend dictionary ripwire.dict/v1 dictv=0e543e1e6a3fe37d entries=750 +ripwire legend dictionary ripwire.dict/v1 dictv=b5c64a12f0346e6c entries=771 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) diff --git a/docs/EVALS.md b/docs/EVALS.md index e338af94f..d56438652 100644 --- a/docs/EVALS.md +++ b/docs/EVALS.md @@ -21,7 +21,7 @@ section, and it is not an afterthought. | **Co-change / known-item evals** | `--eval`, `--eval-retrieval` (see `bench/ANSWERQUALITY.md`) | Whether the tool surfaces the other files a real historical commit touched; and known-item retrieval across four rankers. | | **Ensemble calibration harness** | `bench/ensemblecal/` | Whether `--ensemble`'s four evidence families are actually orthogonal, how often each fires, how stable each is across commits — and the preset ladder derived from that (§9). | | **Differential argv harness** | `test/argvdiffcheck.sh` | That a refactor changed *nothing observable*: two binaries, every argv vector, stdout + stderr + exit code byte-identical. | -| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 658 gate scripts plus the determinism, cache-transparency and golden contracts. | +| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 660 gate scripts plus the determinism, cache-transparency and golden contracts. | | **`--quality-delta`** | `src/quality.h` | Ten measured code-quality failure modes, reported only where a change made them worse. | ### The labeling protocol (why the held-out eval is allowed to disagree with the ranker) @@ -5866,7 +5866,7 @@ copy here would be exactly the dialect divergence that gate exists to catch. Com tags, wrap, stable-order defaults), seven individually invoked standalone gates (`g1freshcheck`, `skillscan`, `htmlexport`, `compresscheck`, `handoffcheck`, `releaseinstallcheck`, `taskroutecheck`), and a single loop -naming **658 gate scripts**, all of which exist on disk. +naming **660 gate scripts**, all of which exist on disk. `python3 test/pargates.py . ./build/ripwire -j 6` runs the same scripts in parallel so a full verification fits in one sitting. It does not modify `regression.sh`. @@ -6969,7 +6969,7 @@ Listed because the reason is more useful than the silence. shipped**. See `bench/locbench/anchorhop_calib.json`. The mention anchor's reproducible numbers are the ablations in §4. - **A single round gate-count.** Two in-tree numbers disagree (`test/pargates.py`'s docstring says - ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 658. The + ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 660. The loop is the authority; the stale docstrings are a known drift. Since 2026-09-10 the number is not written by hand anywhere: `docs/gatecount_build.py` derives it from the loop and rewrites every published site, `test/gatecountcheck.sh` fails if any of them drifts, and `test/manifestcheck.sh` @@ -14195,3 +14195,278 @@ that `--slice=SYM:VAR` ranks lines by relevance across a whole function is super rule is proven only to order the rows it already emits, never to find the most relevant line in a function it has not narrowed down first. `src/compactlegend.h`'s `order` reading, `src/slice.h`'s v1/v2 legends, and `--help=slice` are worded to this scope. + +## Map data Sections never crowd code out of the default map (#339 F1) — PRE-REGISTERED 2026-09-30 (before any fix code or arm number) + +The registration below was frozen on 2026-09-30 (frozen text sha256 `e731a459614b32eda140c5c424beb8867ff8bbc24cdbf8ddea524cf47d38ae93`) +and lands here with the gate, `test/mapdatasectioncheck.sh` (red on `a5229aca`), before any fix code or arm number. It is +reproduced verbatim, except that its headings are demoted one level to sit under this section and three references to files +outside this repository (a design note, a decision log, a scratch directory) are replaced by neutral words. Deviations decided +after the freeze are listed with the result, never edited into this text. + +This supersedes the DRAFT, which is left unchanged. Design: the lane's design note (outside this repository). It lands verbatim in `docs/EVALS.md` with the gate commit, before any fix code or arm number exists. Line refs are at BASE `b90547fa`. + +**Owner rulings, 2026-09-30:** +- **R1:** A touches only the map's uniform prior. Every other rank consumer stays byte-identical. There is no per-verb census. +- **R2:** `--eval` decides on code-seeded commits. +- **R3:** paired, stratified bootstrap margins on three repos replace the priorwt precedent. +- **R4:** FIND terminality is a post-release readout with a revert guard. + +**Hypothesis.** Under A, every non-Section node outranks every in-degree-0 Section in the map. Then code rows survive the #339 shapes, the non-Section order is unchanged when there are no Section→non-Section edges, and nothing outside the map scope moves. + +### 0. Arms and pins + +**A.** A new entry point, `rw::rankDefaultMap(…)`, whose name is frozen for MEAS. +- It is the uniform teleport with Section entries ×`kSectionPriorMul`=0.1, followed by the unchanged `biasPrior`. +- `static_assert(kSectionPriorMul*kSpecificNameMul < kCommonNameMul*kPrivateNameMul)` re-reads the invariant (0.17 < 0.25) from BASE's constants. β is set by it; it is never fit and never swept. + +**Map scope.** The list is exhaustive: +- the plain map: main.cpp:1762 with no payload verb and the default `--rank-by` (XML, `--json`, `--html`, `--max-tokens`); +- `--tree` (verbs_report.h:3521); +- MCP rank_by pagerank (mcpverbs.h:479, the CLI-parity twin); +- MCP analyze when `changed==0` (mcpindex.h:1198). Analyze is `ix.rank`'s only reader (mcpverbs.h:438). + +Everything else keeps BASE bytes, including every payload verb's whole output. + +**Disclosure.** +- **Attribute:** `data_sections_cut="N"` on each map-scope root, where N = Sections indexed − Section rows shown. It is omitted at 0. +- **`next=`:** `next="--graph-query='kind(all,sec)' --offset=M --limit=K"`, where M = Section rows shown and K = the effective top-k. + - `kind(…,sec)` (query.h:63) is main's only kind filter. `--exclude/--include/--path/--scope` filter paths. + - graph-query is out of scope. It keeps BASE order (rank desc, id asc; verbs_navigate.h:335–342) and pages itself (`pageDisclosure`, `next_offset=`). So no page exceeds K rows at any N (#339: ≈2,000). + - Shown rows are skipped exactly when A's Section-relative order equals BASE's. That holds when no non-Section→Section edge exists, because Section ranks then scale by one factor. +- **Pre-specified fallback:** if Gate D's set equality fails anywhere, the attribute ships without `next=`. This is a §9.1 dead-end cut, counted apart. No other spelling is tried after data. + +**B (fallback).** Code-first row pick (design §2B). The rank vector is untouched. + +**C.** Rejected. + +**PLACEBO_s (s=1..20).** A, with `kind==Section` replaced by membership in R_s. +- R_s is n_sec non-Section ids drawn without replacement by `std::mt19937_64(s)` over crawl order. +- The draw is stratified on the Sections' `priorwt::weight` values, so the removed mass is within 1% of A's. Any shortfall comes from the nearest stratum and is disclosed. +- It is not degree-matched (disclosed). R_s is dumped and hashed. s is passed by env var. +- It is skipped where n_sec > 0.5 × the non-Section count. + +**Pins.** +- #339: `pr-339`@`221afc08`, used for S1 only. It is re-pinned before measuring, never after. +- Corpora: + - ripwire@`b90547fa`; + - django `03988c5a`/tree `1afcf39f`; + - webpack `a943d69c`/tree `4d708c08` (`extcorpus.lock`); + - the frozen `snapshot.*pack`. + + The clones are full-history, and HEAD and tree are verified on every run. + +**Design numbers are predictions, not data.** +- The 3/8–7/8 table, the `k=` values and "1 `sec` row in ripwire's top 200" came from the installed 0.6.5 (`built_from=3fcd515ff`). That binary is not an arm. +- #339's 6/8 and 4/8 came from its review. +- Step 1 after the freeze re-reads them on BASE (BASE+339 for S1), before any other arm runs. + +**Builds.** 9 builds, run after the 3 now running: BASE, BASE+MEAS, A, A+MEAS, B, PLACEBO+MEAS, and BASE/A/B+339 (throwaway merges, never pushed). +- Each has its own worktree and a Release build, with the same toolchain and flags. Every run uses `--no-cache`. +- The binary table records sha256, `built_from=` (plus the patch sha256) and the compiler. +- **At run time,** each wrapper logs `shasum -a 256 $BIN` immediately before the run. Output whose hash ≠ the arm's pin is rejected. +- Also sha256-pinned before first use: MEAS and PLACEBO (kept outside the repository, never pushed), the fixture generator, Gate S's argv list and MEAS's key/class list. + +### 1. Primary, gating, end-to-end (§7): code rows in the emitted top-K + +**Harness:** `RIPWIRE_BIN=$BIN test/mapdatasectioncheck.sh`, red-first, with a deterministic generator. + +**Fixture:** 8 Python functions (a call chain, an uncalled `main`, `_helper`) plus one data file, with long or short names. The data file is S1 (#339 `db/schema.rb` columns), S2 (md headings), S3 (YAML keys) or S4 (JSON keys). + +- **Primary cells (K=200):** S1 at 20/40 tables; S2/S3/S4 long at N=200/220/220. +- **Grid, gating for A:** shape × {long, short} × N∈{20,40,200} × K∈{200,16}. +- **Surfaces:** XML, `--json`, the `--html` node set, `--tree` (code file first), `--max-tokens`, MCP analyze (clean tree). +- **Statistic:** code rows shown / 8. It is exact, so no CI. +- **BASE must be red** on each primary cell. The predictions are 6, 4, 3, 3; S4 is unmeasured. A cell green on BASE is reported inert and dropped before any A/B number exists, and never replaced. +- **Pass:** 8/8 on every cell × surface. +- **Reported only:** bytes before the 1st and the 8th code row (§9.1 #8). + +### 2. Gates for A + +**Edge census, first:** Section in/out degree by edge type, per corpus and fixture. + +**I1.** At `--top-k=S`, no non-Section row follows an in-degree-0 Section row. S is the header's `symbols=`; `--top-k=0` emits no map (cli.h:34). + +**I2.** A's non-Section row sequence ≡ BASE's (`--top-k=S --json`, three corpora), with every inversion listed. +- Bound: 0 inversions between rows whose BASE scores differ by >1e-6 relative. +- If Section→non-Section edges exist, the bound is instead Kendall τ ≥ 0.99 over the top 200. + +**Gate S (R1).** It replaces the census and the draft's §3(b) margins. There is no acceptance path. +- **(i) Static.** A's diff leaves `priorwt::weight`, graph.h:4378, `biasPrior`, `rankGraphTeleport`, `rankGraph` and `diffTeleport` byte-unchanged. `rankDefaultMap`'s call sites equal the map scope. +- **(ii) Dynamic.** BASE vs A stdout and exit status are `cmp`-identical, on the three corpora and on S2/S3-long-200. MCP analyze also runs with one uncommitted code edit. The argv list is frozen before any A binary runs: + - `--eval`, `--eval-retrieval`, `run_recalleval.py --lane both`, `run_extcorpus.py`; + - `--for` (default, candidates, `--anchor`), `--expand`; + - `--map-diff` (git, no-git), `--rank-by=churn|churn-decay|authority|hub|rrf`; + - `--impact`, `--graph-query`, communities/drill/zoom, `--seams`, `--report`, `--exercises`; + - MCP analyze (dirty), MCP impact. + +**Gate D.** +- N equals the `kind(all,sec)` count= minus the Section rows shown. +- Following `next=` and then `next_offset=` until `capped="0"` yields exactly the cut Sections, each once, with every page ≤ K rows. This must hold on every §1 cell at K∈{200,16} and on the three corpora. +- The attribute is defined in the XML, JSON and compact legends. Legend coverage fails on an undefined one. + +**Byte identity.** Section-free fixtures and goldens do not change. The golden refresh is listed per file, with the reason ("`k=` rescale" or "Section rows moved"). + +### 3. Non-regression and readouts + +#### (a) `--eval` co-change (R2, R3): the `map` column decides +By R1, eval.h:325 (seeded) and graph.h:5148 (anchored) are non-map consumers. So every printed `--eval` row is byte-identical under A (Gate S), and the bar applies to the one vector A changes. + +**MEAS.** +- It is additions only, in eval.h only; for BASE it also adds a ≤3-line shim, `rankDefaultMap → rankGraph`. +- It is active only with `RIPWIRE_MEAS_EVAL_ROWS=` and writes only there. +- It writes one row per scored commit: + - the key (newest-first ordinal, sha256 of the sorted changed paths); + - the seed path and class; + - n_gold and the Section-only gold count; + - hits@5/10/20 for every printed ranker and for `map`. +- `map` = eval.h:326–331's per-file sum over `rankDefaultMap`'s vector, with the seed excluded. + +**Output-neutrality, proven before any A number:** +- `git apply --numstat` shows 0 deletions, and the patch adds no stdout/stderr write. +- Every non-339 build's `--eval` stdout and exit status `cmp`-equal BASE's on the three corpora (env set on MEAS builds). +- In each MEAS arm, the rows re-sum exactly to the dumped accumulators (`%.17g`), which reproduce every printed figure. +- Keys, seeds and classes equal BASE+MEAS's pinned list, or the run is void. + +**Code-seeded (mechanical).** The seed (eval.h:299–306: most symbols, tie → lowest id) holds ≥1 indexed symbol whose kind is neither Section nor ModuleScope. Otherwise the seed is `data` (≥1 Section) or `none`. The class is computed on BASE and is the same in every arm. + +**Deciding test, code-seeded only.** +- Commits are paired. The CI is a 95% percentile bootstrap, resampling within repo: 10,000 resamples, seed 20260930. The pooled Δ is commit-weighted. +- **A fails** if, for any k∈{5,10,20}, the pooled-Δ lower bound is < −1.0 pp or any repo's point Δ is < −2.0 pp. +- A repo with 0 code-seeded commits is not evaluable. + +**Secondary, non-deciding:** `data`/`none` seeds, and gold-kind strata. Ranking is at corpus HEAD, so absolute levels are upper bounds, but the paired Δ is valid. + +#### (b) §8 placebo (gates claims, not the ship) +- It runs on `map`. Fixtures are skipped because they have no matched set. +- A gain is claimed only if A's pooled Δ beats all 20 placebo Δs (one-sided p ≤ 1/21) and its lower bound is > 0. +- A loss inside the placebo range is generic perturbation. + +#### (c) FIND terminality (P1, R4): post-release, not a merge gate +- **Command:** `bench/substitution_report.py `, the §5 map row, per repo via `--tag`. +- **Windows:** baseline = the 21 days before the release that ships the arm; readout = the first ≥200 map rows after it. +- **Revert guard:** Δ < −5 pp with the Newcombe 95% CI upper bound < 0 → revert. +- **Power:** ≈ ±9 pp, so this is a guard, not evidence. + +### 4. Decision rule (in order; nothing re-tuned after data) +1. BASE must be red on §1, with inert cells dropped first. +2. **Ship A** iff all of these hold: + - §1 passes; + - I1 = 0 and I2 is within bound; + - Gate S passes; + - Gate D passes, or its fallback applies; + - byte identity holds; + - §3(a) passes. + + A Gate S or D failure is a wiring defect. Fix only the wiring (β and the kind test unchanged), rerun §1–§3, and log a deviation. +3. **Ship B** iff A fails only §3(a). B is judged on §1 over the surfaces it wires, on byte identity when no swap fires, and on Gate S(ii) vs BASE. +4. **A fails §1, I1 or I2:** the premise is false. Go to step 3. +5. **B also fails §1:** ship the gate and fixtures, revert the feature, and record the negative (EVALS §7). #339 F1 stays open, and C needs its own registration. +6. **§3(c) trips:** revert. + +**What would prove the design wrong:** +- a Section with in-degree > 0 inside a crowded top-K; +- an I2 breach; +- a code-seeded `map` loss beyond margin that the placebo does not reproduce. That would mean demoting data removes co-change signal, which points to B. + +**Blind spots:** doc-seeking tasks in doc-heavy repos. Payload verbs and dirty-tree MCP analyze stay BASE by R1, so they can still crowd. + +### 5. Published +- **EVALS:** "PRE-REGISTERED" (this text), then "RESULT at " with every number above, losses included, and β stated as derived. +- **CHANGELOG:** the change, `data_sections_cut=`/`next=`, the `k=` golden rescale, and the numbers. + +## Map data Sections — RESULT at `4b3ace8a` (2026-10-01): A FAILED §3(a) by its registered margin; **B SHIPS** + +Every number below was measured on Release builds of the nine registered arms (AppleClang 17.0.0), each binary's sha256 +logged immediately before every run and checked against its pin; every run used `--no-cache`; every corpus's HEAD and +tree were verified before and after each run. Deviations decided after the freeze are listed at the end. + +| arm | built from | sha256 (first 16) | +| --- | --- | --- | +| BASE | `a5229aca` (origin/main at lane start) | `2ff8bd0006952fdc` | +| BASE+MEAS | `a5229aca` + MEAS (120 added lines in `src/eval.h`, 0 deleted) | `bc5798eeb6558641` | +| A | `417ba41b` | `dc97c6bcb858ab7a` | +| A+MEAS | `417ba41b` + MEAS | `b8b32df91e0b7f76` | +| PLACEBO+MEAS | `417ba41b` + PLACEBO + MEAS | `89c6900f1dcfccbf` | +| B | `4b3ace8a` | `deab515df2c22f5b` | +| BASE/A/B+339 | each merged with `pr-339`@`221afc08` (throwaway, never pushed) | `a74778c45dc03cb6` / `4754026555efb8a6` / `b06eed61d5fc187b` | + +**Step 1 — BASE is red on every primary cell** (code rows of 8 at K=200; XML, `--json`, `--html` node set, `--max-tokens=1500`, +MCP analyze all agree): S2 long N=200 **1/8**, S3 long N=220 **1/8**, S4 long N=220 **1/8**; S1 (BASE+339) 20 tables **7/8**, +40 tables **1/8**. The design's predictions (3, 3, unmeasured, 6, 4) came from a different fixture and the installed 0.6.5; no +primary cell was inert. `--tree` listed the code file first on every cell at BASE (an inert surface, reported, not evidence). +Short names crowd less: 7/8 at N=200. K=16 reads 1/8 (long) and 7/8 (short) from N=20. + +**Edge census** (node-level, `--graph-query`; the graph has one edge type feeding PageRank): no edge between a Section and a +non-Section in any corpus or fixture. Sections with in-edges have them only from Sections — ripwire 271 of 4,755, webpack 70 of +12,402, django 0 of 724; every fixture Section is edge-less. + +**A** (`rankDefaultMap`: the uniform teleport with Section entries x0.1, then the unchanged `biasPrior`; x0.1 derived from +0.1 x 1.7 = 0.17 < 0.5 x 0.5 = 0.25, held by a `static_assert`): +- §1: **8/8 on all 40 fixture cells x 6 surfaces** and on the 12 S1 cells of A+339. Bytes before the 1st/8th code row on the + primary cells: 1,260/1,602 (S3, S4) and 1,356/1,698 (S2), against BASE's 979/— and 1,075/— (no 8th row): the legend clause and the root + disclosure ride in front of the rows. +- I1: 0 in-degree-0 Sections ahead of a non-Section row, on the fixtures and on all three corpora (ripwire's 17,947 non-Section + rows are all in by K=17,976; django's 47,420 by K=47,420; webpack's 24,353 by K=24,376). +- I2 (at the emitted 4-dp precision, see DEV-10): 0 strict rank reversals on all three corpora. The file-grouped JSON sequences + differ by 182 / 861 / 12 pairwise inversions, every one inside a 4-dp tie or a file-block reorder of tied files. +- Gate S (i): `priorwt::weight`, the `priorWeight` assignment, `biasPrior`, `rankGraphTeleport`, `rankGraph`, `diffTeleport` + byte-identical; `rankDefaultMap`'s four call sites are exactly the map scope. (ii): 132 of 132 (root, argv) rows identical to + BASE with two non-ranking fields masked (DEV-9); 122 of 132 unmasked. +- Gate D: on every fixture cell (K=200, 16; XML and `--max-tokens`) and on the three corpora at K=200 and 16, `next=` then + `next_offset=` paged exactly the cut Sections, each once (ripwire 4,755 cut, 24 pages at K=200; webpack 12,402, 776 pages at + K=16). `--json`, MCP analyze and MCP rank_by carried the XML's disclosure. +- MEAS neutrality: every MEAS build's `--eval` stdout and exit status `cmp`-equal BASE's on the three corpora; the rows re-sum + exactly to the dumped accumulators, which reproduce every printed figure; keys, seeds and classes equal BASE+MEAS's list. +- **§3(a), the deciding test** (the `map` column, code-seeded commits: ripwire 40, django 74, webpack 72; paired A − BASE, pp; + 95% percentile bootstrap within repo, 10,000 resamples, seed 20260930): + + | k | pooled Δ | 95% CI | ripwire | django | webpack | + | --- | --- | --- | --- | --- | --- | + | 5 | +0.927 | [+0.433, +1.521] | +0.714 | 0.000 | +1.997 | + | 10 | 0.000 | [0.000, 0.000] | 0.000 | 0.000 | 0.000 | + | 20 | **−0.386** | **[−1.068, +0.067]** | −1.795 | 0.000 | 0.000 | + + **A FAILS**: at k=20 the pooled lower bound −1.068 pp is below −1.0 pp. No repo's point Δ is below −2.0 pp. Absolute `map` + recall on code-seeded commits is low in both arms (BASE 1.6 / 4.0 / 5.2%, A 2.5 / 4.0 / 4.8% at k=5/10/20): `map` ranks files + by the global, unseeded map vector, and the co-change gold is mostly elsewhere. + Secondary (non-deciding): data-seeded commits (n=53, 40 of them ripwire's, whose most-symbols file is often a long markdown + document) Δ +0.77 / +0.85 / **−12.18** pp; all 240 commits +0.89 / +0.19 / −2.99; code-seeded commits whose gold holds a data + file (n=64) +1.62 / 0.00 / −1.32, whose gold holds none (n=122) +0.56 / 0.00 / +0.10. The k=20 loss is concentrated where the + gold includes documentation, which the demotion pushes down the `map` file ranking. +- §3(b) placebo (ripwire + django, n=114 code-seeded; webpack skipped by the registered rule, 12,402 Sections > 0.5 x 24,353; + every R_s matched A's removed teleport mass with no shortfall): A Δ +0.251 / 0.000 / −0.630 pp against placebo ranges + [−0.418, −0.418] / [+0.439, +0.439] / [0.000, +0.799]. No gain is claimable (k=5 beats all 20 placebos but its lower bound is + 0.000, not > 0), and the k=20 loss lies OUTSIDE the placebo range: it is not generic perturbation. By the registration's own + prove-wrong clause, demoting data removes co-change signal, which points to B. + +**Decision, step 3: A fails only §3(a) → SHIP B.** + +**B** (`serialize.h` `codeFirstKeep`: the code-first row pick; the rank vector untouched; wired on serialize — XML, `--json`, +`--max-tokens`, MCP analyze on a clean tree, MCP rank_by=pagerank — and `--html`; not `--tree`, DEV-11): +- §1: **8/8 on all 40 fixture cells x 6 surfaces** and on the 12 S1 cells of B+339 (20 and 40 tables: 8/8 where BASE+339 read + 7/8 and 1/8). Bytes before the 1st/8th code row on the primary cells: 1,246/1,588 (S3, S4) and 1,342/1,684 (S2). +- The pick's invariant (a map that shows a Section shows every non-Section row) holds on every fixture probed and on the three + corpora at K=200 and 16. +- Byte identity when no swap fires: BASE and B maps (XML and `--json`) are byte-identical on django and webpack at K=200 and 16, + on this repository at K=16, on `test/fixture` (so `test/golden.xml` and every map pin are unchanged) and on the uncrowded + fixture cells. Where a swap fires the root says so: this repository's default map at K=200 swaps its one `sec` row + (`data_sections_cut="1"`), and Gate D's walk pages the swapped Section first, then the rest, each once. +- As shipped, `data_sections_cut=N` counts the Sections the swap displaced from the rank-order top-K (what Gate D checks), + not every indexed Section left unshown; the frozen Disclosure definition above is kept as registered, and the rest + are reached through `next=`. +- Gate S (ii): 132 of 132 rows identical to BASE under the same two masks as A (122 of 132 unmasked, the same 10 rows). +- `--eval` is untouched by construction (B changes neither the rank vector nor `src/eval.h`) and its stdout is in Gate S. + +**§3(c) FIND terminality** is a post-release readout: `bench/substitution_report.py` over the 21 days before the release that +ships B and the first ≥200 map rows after it, per repo; revert if Δ < −5 pp with the Newcombe upper bound < 0. Not yet measured. + +**Deviations after the freeze** (each logged before the data it touches existed, except DEV-9 to DEV-11, logged after A's run): +BASE is `a5229aca`, the lane's branch point (DEV-1); Gate D's walk ends on `has_more="0"`, because graph-query prints +`capped="1"` on every page past offset 0 (DEV-2); A's `--tree` carried the count without `next=` and `--html` carries no +attribute, having no disclosure root (DEV-3, DEV-4); the JSON map has no legend, so the definition rides the full and compact +legends and `--help=all` (DEV-5); this registration's text above names no file outside the repository (DEV-6); `--max-tokens=1500`, +`--tree` = code file first, MCP via `--mcp --top-k=K` (DEV-7); Gate S's argv list gained a `RIPWIRE_DEV=1` `--anchor` row before +any A binary ran, because `--anchor` refuses without it (DEV-12); Gate S masks the MCP `_index` stamp (it folds file mtimes, so BASE differs +from itself there) and the initialize reply's `dictv=` (DEV-9); I2 is read at the emitted 4-dp precision (DEV-10); B does not +re-pick `--tree`, whose own offset paging a window swap would break (DEV-11). diff --git a/docs/LIMITS.md b/docs/LIMITS.md index 21410090c..c09eda81a 100644 --- a/docs/LIMITS.md +++ b/docs/LIMITS.md @@ -16,10 +16,10 @@ it once, marked `×N`. | total caps | files | caps whose file discloses | caps whose file discloses NOTHING | | --- | --- | --- | --- | -| 230 | 91 | 128 | **102** | +| 233 | 92 | 128 | **105** | Plus 7 ranking and apportionment parameters, in their own table below: they are not caps, they -are not counted as caps, and 230 + 7 is the 237 constants this generator parses out of `src/`. +are not counted as caps, and 233 + 7 is the 240 constants this generator parses out of `src/`. ## INDEXING, OUTPUT or BOUNDARY — which half of the answer a cap bounds @@ -37,8 +37,8 @@ None of them truncates anything, so none can be judged by `shown=`/`total=` and a disclosure — labelling them OUTPUT would ask for a `capped="1"` that could never honestly fire. The distinction was named in review on #108 and the rows below now carry it. -The `class` column below carries that answer where it is known. **128 of 230 caps are classified -(43 INDEXING, 41 OUTPUT, 44 BOUNDARY); the remaining 102 render `—`, which means NOT YET +The `class` column below carries that answer where it is known. **130 of 233 caps are classified +(45 INDEXING, 41 OUTPUT, 44 BOUNDARY); the remaining 103 render `—`, which means NOT YET CLASSIFIED — never "neither".** Classifications live in `docs/limits_classes.tsv`, a sidecar with a known expiry: the tag belongs on the declaration itself, and this file exists only because the round that @@ -88,7 +88,7 @@ refuse to write, so the column cannot be satisfied by pointing at nothing. ## Caps, by file -One table for each of the 91 files that declare a cap — the 230 caps counted above, and no parameter. +One table for each of the 92 files that declare a cap — the 233 caps counted above, and no parameter. ### `src/abicheck.h` @@ -186,6 +186,8 @@ Discloses: **none** | --- | --- | --- | --- | | `kMaxGitWorkers` | `12` | — | matches the ingest pool's measured ~12-way; these are | | `kMaxRefs` | `512` | INDEXING | refusal bound — a sweep, not a fork-network crawl | +| `kMaxRenameProbeFiles` | `32` | INDEXING | HEAD copies one rename probe reads; past it the probe stops (a hint, never a claim) | +| `kMaxWorktreePaths` | `8192` | INDEXING | — | | `kWhereisHits` | `60` | OUTPUT | — | ### `src/darkflags.h` @@ -241,6 +243,14 @@ Discloses: `unflagged_capped` | --- | --- | --- | --- | | `kEditCheckSpellingsShown` | `6` | OUTPUT | — | +### `src/editcheckdecl.h` + +Discloses: **none** + +| constant | value | class | note | +| --- | --- | --- | --- | +| `kEditCheckSigCap` | `16384` | — | disclosed as defaults_untied= beside a nonzero incompatible= | + ### `src/editpreview.h` Discloses: **none** diff --git a/docs/captures/COMMANDS_showcase_2026-09-14.md b/docs/captures/COMMANDS_showcase_2026-09-14.md index 76c1f774b..c01cbd82f 100644 --- a/docs/captures/COMMANDS_showcase_2026-09-14.md +++ b/docs/captures/COMMANDS_showcase_2026-09-14.md @@ -795,7 +795,7 @@ Artifact written: **exit code: 1** ````` -(empty) + ````` stderr: @@ -5351,7 +5351,7 @@ ripwire: --run-timeout=SECONDS modifies --run-trace — pass it too (e.g. ripwir *The session legend dictionary the MCP server serves as ripwire://legend-dict/full — one definition per line, headed by its dictv= version; no corpus needed. =roster lists the completeness attributes it defines.* ````` -ripwire legend dictionary ripwire.dict/v1 dictv=0e543e1e6a3fe37d entries=750 +ripwire legend dictionary ripwire.dict/v1 dictv=b5c64a12f0346e6c entries=771 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) @@ -5381,7 +5381,7 @@ ripwire.impact/v1 : transitive blast radius of of=: reach s ripwire.path/v1 : one DIRECTED call path from= to to=, each a hop; reachable=0 hops=0 when none ripwire.connect/v1 : minimal joining subgraph: groups, terminals, joins, edges, ripwire.at/v1 : enclosing-definition chain at p=:l=: sym= innermost, chain= outermost-first, spans -… [721 more display lines; full output is 73872 bytes on 751 raw line(s)] +… [742 more display lines; full output is 76341 bytes on 772 raw line(s)] ````` ## `./build/ripwire . --lint --lint-select=cache-` diff --git a/docs/limits_classes.tsv b/docs/limits_classes.tsv index b749d67ba..60d605bdb 100644 --- a/docs/limits_classes.tsv +++ b/docs/limits_classes.tsv @@ -88,6 +88,7 @@ kMaxProbeCommits INDEXING kMaxQualifierHops INDEXING kMaxQueryNesting BOUNDARY kMaxRefs INDEXING +kMaxRenameProbeFiles INDEXING kMaxSample INDEXING kMaxScopeChars OUTPUT kMaxScored INDEXING @@ -100,6 +101,7 @@ kMaxStructsPerRef OUTPUT kMaxStructsShown OUTPUT kMaxTsModulesWalked BOUNDARY kMaxUniqueQueryTerms INDEXING +kMaxWorktreePaths INDEXING kMeasuredDigitsPricedWidth OUTPUT kMemberSpellingsShown OUTPUT kMentionMaxDirectSymbols INDEXING diff --git a/present/deck5_ripwire_build.js b/present/deck5_ripwire_build.js index 197676b3e..c63ba55c8 100644 --- a/present/deck5_ripwire_build.js +++ b/present/deck5_ripwire_build.js @@ -1214,7 +1214,7 @@ function storyCards(s, { kick, head, stories, footText }){ kicker(s, "// how it stays true", AMBER); title(s, "Proven, not promised"); const cards = [ - ["658 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount + ["660 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount ["byte-identical, always", "two runs over the same tree produce the same bytes; warm equals cold. Enforced in CI, twice — Release AND a plain flavour, because NDEBUG once blinded a whole class of checks"], ["differential refactoring", "a refactor must prove it changed nothing observable: two binaries, hundreds of argv vectors, stdout + stderr + exit codes byte-identical"], ["held-out labels, authored blind", "eval labels were written by reading source before the ranker ever ran on them — so the eval is allowed to say the ranker is wrong. It has."], @@ -1238,7 +1238,7 @@ function storyCards(s, { kick, head, stories, footText }){ title(s, "Claims you can trust, because we publish what failed", { size: 32 }); card(s, MX, 1.72, 3.86, 1.72); - stat(s, "658", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount + stat(s, "660", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount MX+0.15, 1.86, 3.56, CYAN, { bsize: 42, bh: 0.66, lsize: 9.5 }); card(s, 4.68, 1.72, 3.86, 1.72, CARD2); stat(s, "8", "registered NEGATIVES — changes built, gated green, measured against a band written before the code, and reverted rather than tuned", @@ -1540,7 +1540,7 @@ function storyCards(s, { kick, head, stories, footText }){ ["185 long flags · 36 slides", "bash test/deckclaimcheck.sh"], ["every --flag named here exists", "bash test/deckcheck.sh"], ["74.7% fewer element bytes", "bash test/showcasecapturecheck.sh"], - ["658 gate scripts", "bash test/manifestcheck.sh"], // gatecount + ["660 gate scripts", "bash test/manifestcheck.sh"], // gatecount ["49 repos · 71 papers · 237 surveyed","bash test/readmedriftcheck.sh"], ["the ten moments, any row", "ripwire . --callers=SYM | wc -c"], ["the head-to-head table", "bench/headtohead/r4-2026-08-06/"], diff --git a/src/cli.h b/src/cli.h index 3ea2cfa03..2c1db23fc 100644 --- a/src/cli.h +++ b/src/cli.h @@ -1189,6 +1189,14 @@ inline constexpr char kHelpHead[] = " the converged path is the normal one and must cost zero bytes), and absence of pr_iters= itself\n" " means the document was not ordered by a power iteration at all (a lexical query score, or a hub\n" " or authority HITS vector), never that the count is unknown\n" + " data_sections_cut=\"N\" how many data Sections the default map swapped out for code rows, with next= to page them\n" + " on the default map (XML and --json, --max-tokens, MCP analyze on a clean tree, MCP\n" + " rank_by=pagerank): the map's top-K is picked CODE-FIRST — while it keeps a data Section\n" + " (a markdown heading, a YAML/JSON key, a schema column) and leaves a code row out, the\n" + " lowest-ranked kept Section is swapped for the highest-ranked excluded code row, so a\n" + " Section is shown only when every code row is. N counts the swaps; next= pages the Sections\n" + " past the M still shown (--graph-query='kind(all,sec)' --offset=M --limit=K), the N swapped\n" + " ones first, K rows a page. The rank (k=) is untouched. Absent when nothing was swapped\n" " --verify=\"CLAIM\" check a claim about the code in one call: confirmed, refuted, or not-established\n" " VERIFY A CLAIM about the code in ONE call: a CLOSED claim language in, a three-valued verdict out\n" " (confirmed / refuted / not-established) with the evidence rows inline — the collapse of the manual\n" @@ -1324,6 +1332,8 @@ inline constexpr char kHelpHead[] = " Scope::name the sym= spelling edit-check and grep's in= rows print — matches the name\n" " under any scope whose ::-boundary SUFFIX is Scope (Box::lid, deep::Box::lid);\n" " a wrong scope refuses, it never falls back to the bare-name union\n" + " Scope.name|Scope#name the same match, dotted (Python/JS/Java) or Ruby-style; tried only\n" + " when no other spelling matched\n" " (START-END is 1-based within the def's OWN body — lines=\"lo-hi/total\" marks the slice\n" " partial; out-of-range clamps. FILE matches any path substring, like --callers/--lego.)\n" " EXACT-NAME DEFAULT (one token, one unambiguous match, no explicit --top-k): the ranked map\n" @@ -2068,6 +2078,8 @@ inline constexpr char kHelpTail[] = " still reads as a definition. head_labels=\"lexical\" ⇒ HEAD fell back to that heuristic too (no\n" " indexed def of the name, or a working tree that drifted from HEAD). refs_scanned= is the SCAN\n" " denominator (refs read besides HEAD), not a matched count. Read-only; single-root only.\n" + " A checkout that differs from HEAD is read from disk: each changed path's rows say\n" + " ref=\"worktree\" and replace HEAD's, at= gains +dirty, and worktree= says whether every one was read.\n" " LIMITS: a TREE scan finds only what some ref STILL carries, so hits=\"0\" alone cannot\n" " tell a name this repo never had from one it deleted, and content dropped by every tree\n" " is invisible. Add --with-history: a row then says v=\"never\" or v=\"removed\"\n" diff --git a/src/compactlegend.h b/src/compactlegend.h index 8d1ac3d5a..93bbd5724 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -212,6 +212,8 @@ inline constexpr std::string_view kCompactProsePrefixes[] = // its own "", out ); char pab[ kPageDisclosureCap ]; // §A7(iii): refs_scanned=, not refs=. --stray-content and --abi both spell the MATCHED set refs=; this one @@ -2296,38 +2909,26 @@ inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_ // LAST (after at=) so no existing attribute-adjacency assertion can break on it, the same placement rule // the graph verbs' floor marker follows. When either half fails, NOTHING is added: the truncation // vocabulary above already covers every partial shape, and complete-equals-zero would be noise. - const bool completeClaim = res.scanExhaustive && hitPage.begin == 0 && hitPage.end == res.hits.size(); + // A dotted selector was searched as a literal the question did not mean: exhaustive over that literal, but not + // an answer to "where is Class.method", so it never claims (its selector-note below says why). + const bool completeClaim = res.scanExhaustive && hitPage.begin == 0 && hitPage.end == res.hits.size() && res.dottedRetry.empty(); // H14/M6: refs_scanned="80" under a ref-name filter is a total for the FILTER, not for the repo (the // audit measured 80 filtered vs 189 unfiltered) — so the filter is named beside the number it bounds. const std::string whFilterAttr = res.filter.empty() ? std::string() : ( " filter=\"" + ex( res.filter ) + "\"" ); - rw::emitTo( out, "", + // The overlay's two marks, both absent on a clean checkout (so its answer is byte-identical to before): +dirty + // on the stamp whenever a differing path was seen, and worktree= naming what the overlay managed. + static constexpr std::string_view kWorktreeAttr[] = { "", " worktree=\"read\"", " worktree=\"partial\"", " worktree=\"unlisted\"" }; + const bool dirty = res.worktree == WorktreeOverlay::Read || res.worktree == WorktreeOverlay::Partial; + rw::emitTo( out, "", ex( res.sym ).c_str(), res.onHead ? 1 : 0, res.refsScanned, res.distinctBlobs, res.hits.size(), res.headLabelsFromIndex ? "index" : "lexical", whFilterAttr.c_str(), pageDisclosure( pab, sizeof( pab ), hitPage.end - hitPage.begin, res.hits.size(), hitPage.end, pageLimit, pageOffset, true ), - res.headSha.c_str(), + res.headSha.c_str(), dirty ? "+dirty" : "", + kWorktreeAttr[ std::size_t( res.worktree ) ], completeClaim ? " complete=\"1\"" : "" ); - // §B11.2 — a zero that is a SPELLING fact, not a repository fact, says so. Emitted first, before the - // history lane, so it is the first thing after the root on the one shape where it fires: hits="0" AND a - // file-qualified selector. It never appears beside a nonzero hit list, so it cannot dilute a real answer. - if( res.hits.empty() && whereisSpecIsFileQualified( res.sym ) ) - { - rw::emitTo( out, "", - ex( res.sym ).c_str(), ex( whereisBareNameOf( res.sym ) ).c_str() ); - } - // H7: the same element, two more reasons — the line seed that was RESOLVED before the scan (so sym= is a - // name and not the raw @spec), and the near-miss beside a zero the index can explain. - if( !res.seedSpec.empty() ) - { - rw::emitTo( out, "", - ex( res.seedSpec ).c_str(), ex( res.sym ).c_str() ); - } - if( res.hits.empty() && !res.nearMiss.empty() ) - { - rw::emitTo( out, "", - ex( res.sym ).c_str(), ex( res.nearMiss ).c_str() ); - } + writeWhereisSelectorNotes( out, res, ex ); // The history lane, when it was asked for: what the probe did, then this symbol's own verdict. // §L10: the oracle answers "did any line carrying this name ever leave the tree", and a doc that merely @@ -2339,14 +2940,7 @@ inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_ // two is head_labels=: "index" means the PARSED index confirmed a real definition on HEAD (not just a // text hit), which is a claim strong enough to override the line-removal oracle outright. So the fate // row is printed unless the index itself already proved the symbol is defined on HEAD. - if( res.history != nullptr ) - { - gitoracle::writeHistoryProbe( out, *res.history, ex ); - if( res.history->ok && !( res.onHead && res.headLabelsFromIndex ) ) - { - gitoracle::writeNameFate( out, res.sym, res.fate, ex ); - } - } + writeWhereisFate( out, res, ex ); // The contract, restated because it was false here: shown + dropped == hits=, ALWAYS. The count // must be taken against the CAP, not against a loop variable `shown++ >= cap` has already pushed to @@ -2358,9 +2952,9 @@ inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_ { const WhereHit& h = res.hits[ hitIndex ]; ++shownCount; - rw::emitTo( out, "", + rw::emitTo( out, "", ex( h.ref ).c_str(), h.tip.c_str(), ex( h.date ).c_str(), ex( h.path ).c_str(), - h.line, h.isDef ? "def" : "ref", ex( h.text ).c_str() ); + h.line, h.isDef ? "def" : "ref", h.testLocal ? " test_local=\"1\"" : "", ex( h.text ).c_str() ); } ASSUME( shownCount == hitPage.end - hitPage.begin ); // = the rows AFTER this page, so shown + more == the rows from this page's offset on. diff --git a/src/editcheck.h b/src/editcheck.h index b40b3154b..82b990994 100644 --- a/src/editcheck.h +++ b/src/editcheck.h @@ -24,10 +24,12 @@ #include "gitstamp.h" // r26-stamp Task A: gitstamp::atAttr — the at="[+dirty]" root anchor #include "graphlegend.h" // §H4 §3.4: the shared counts_floor= marker + floor/counting-unit legend tail #include "pageview.h" // LB-G: pageWindow / effectiveRowCap / pagingDisclosure — the ONE paging vocabulary +#include "editcheckdecl.h" // the C/C++ declaration/definition identity: editCheckTieDeclaration / editCheckDeclDefaults #include #include #include +#include #include #include #include @@ -107,27 +109,118 @@ inline std::vector editCheckOverloadSet( const IngestResult& ing, const return overloadNodes; } +// The C/C++ declaration/definition identity (one contract, the declaration's defaults) lives in editcheckdecl.h. + // ── §A6a: the DISTINCT contracts one --edit-check selector matched ─────────────────────────────────────── // A contract is per definition site. --callers may honestly UNION the callers of every overload (it says so: // defs="3"); this verb may not — "did I break a contract?" answered about a definition the agent never edited // is worse than no answer, because status="unchanged" reads as reassurance. So a selector that matches more // than one definition SITE is REFUSED, and the refusal hands back the spellings that pick one. // -// The group key is (file, scope) — see editCheckOverloadSet on why canonId alone is not enough. `spelling` is -// what the caller should retype: `file:name` when that file holds exactly one group, else the canonical id -// (both resolve through resolveAllByNameQualified). +// The group key is (file, scope) — see editCheckOverloadSet on why canonId alone is not enough — with ONE exception for +// the post-hoc verb: a C/C++ declaration group folds into the definition group it provably declares (editCheckFoldDeclGroups, +// the identity rule in editcheckdecl.h), because a prototype and its definition are one contract. `spelling` is what the +// caller should retype: `file:name` when that file holds exactly one group, else the canonical id (both resolve through +// resolveAllByNameQualified), verified by editCheckRoundTripSpelling. struct EditCheckGroup { NodeId lowestNode; // the lowest-id definition in the group — the focus resolveFocus would have picked - std::string spelling; // a selector that resolves to THIS group and no other + std::string spelling; // a selector that resolves to THIS group and no other (when `unique`) + bool declOnly; // only C/C++ declarations, beside a group that holds a definition: never offered (M2) + bool unique; // `spelling` was verified to resolve to this group alone }; -inline std::vector editCheckGroups( const IngestResult& ing, const Graph& g, std::span matches ) +// The (file, contract) KEYS of a match list with each key's members, in first-seen order: matches arrive in ascending +// node id (resolveAllByNameQualified walks ing.symbols in order), so members[i][0] IS group i's lowest id. +struct EditCheckGroupKeys { - // one pass, keyed by (fileId, canonId): matches arrive in ascending node id (resolveAllByNameQualified - // walks ing.symbols in order), so the first node seen for a key IS the group's lowest id. - std::vector groups; std::vector> keys; + std::vector> members; +}; + +inline bool editCheckDeclOnlyGroup( const IngestResult& ing, const std::vector& members ) +{ + return std::all_of( members.begin(), members.end(), [ & ]( NodeId m ) + { return langCompatible( ing.symbols[m].lang, Lang::C ) && !isDefinitionNotDeclaration( ing.symbols[m] ); } ); +} + +// THE FOLD (2026-10-01, the identity rule in editcheckdecl.h): a group made only of C/C++ declarations is dropped when +// every one of them DECLARES (editCheckTieDeclaration) a definition in the match list AND all of those definitions sit in +// ONE other group — the declaration and its definition are then one contract, answered at the definition. A declaration +// that declares nothing in the list, or definitions in two groups (one header, a posix.cpp and a win.cpp), keeps its +// group: those are distinct contracts and the refusal still counts them. +// The ONE group every declaration of group `groupIndex` declares into, or groupCount when some declaration declares +// nothing in the list or the declarations land in two groups. `declares( decl, def )` is the identity rule's verdict. +template +inline std::size_t editCheckDeclTargetGroup( const IngestResult& ing, const EditCheckGroupKeys& gk, std::size_t groupIndex, Declares& declares ) +{ + const std::size_t groupCount = gk.keys.size(); + std::size_t target = groupCount; + for( NodeId decl : gk.members[ groupIndex ] ) + { + bool declaresAny = false; + for( std::size_t other = 0; other < groupCount; ++other ) + { + const bool hits = other != groupIndex && std::any_of( gk.members[ other ].begin(), gk.members[ other ].end(), [ & ]( NodeId def ) + { + return isDefinitionNotDeclaration( ing.symbols[ def ] ) && declares( decl, def ); + } ); + if( hits && target != groupCount && target != other ) + { + return groupCount; // definitions in two groups: two contracts, nothing to fold into + } + target = hits ? other : target; + declaresAny = declaresAny || hits; + } + if( !declaresAny ) + { + return groupCount; + } + } + return target; +} + +inline void editCheckFoldDeclGroups( const IngestResult& ing, EditCheckGroupKeys& gk ) +{ + const std::size_t groupCount = gk.keys.size(); + if( groupCount < 2 ) + { + return; + } + EditCheckSources sources( ing, EditCheckSpliced{} ); + std::deque> defSigs; // each definition's signature, read once (a deque: stable refs) + const auto defSigOf = [ & ]( NodeId def ) -> const EditCheckSignature& + { + const auto at = std::find_if( defSigs.begin(), defSigs.end(), [ def ]( const auto& entry ) { return entry.first == def; } ); + if( at != defSigs.end() ) + { + return at->second; + } + defSigs.emplace_back( def, editCheckSignatureOf( sources, ing.symbols[ def ] ) ); + return defSigs.back().second; + }; + const auto declares = [ & ]( NodeId decl, NodeId def ) + { + return editCheckTieDeclaration( ing, sources, decl, def, defSigOf( def ) ).tie == EditCheckTie::Declares; + }; + + EditCheckGroupKeys kept; + for( std::size_t groupIndex = 0; groupIndex < groupCount; ++groupIndex ) + { + if( editCheckDeclOnlyGroup( ing, gk.members[ groupIndex ] ) && editCheckDeclTargetGroup( ing, gk, groupIndex, declares ) < groupCount ) + { + continue; // folded into the definition group it declares + } + kept.keys.push_back( gk.keys[ groupIndex ] ); + kept.members.push_back( gk.members[ groupIndex ] ); + } + ENSURES( !kept.keys.empty() ); // a folded group always names a kept one as its target + gk = std::move( kept ); +} + +inline EditCheckGroupKeys editCheckGroupKeys( const IngestResult& ing, const Graph& g, std::span matches, bool foldDecls ) +{ + EditCheckGroupKeys gk; for( NodeId m : matches ) { if( m >= ing.symbols.size() ) @@ -136,12 +229,65 @@ inline std::vector editCheckGroups( const IngestResult& ing, con } const std::uint32_t fileId = ing.symbols[m].fileId; const std::string canon( editCheckContractId( ( m < g.canonId.size() ) ? g.canonId[m] : ing.symbols[m].name, ing.symbols[m] ) ); - if( std::find( keys.begin(), keys.end(), std::make_pair( fileId, canon ) ) != keys.end() ) + const auto at = std::find( gk.keys.begin(), gk.keys.end(), std::make_pair( fileId, canon ) ); + if( at != gk.keys.end() ) { + gk.members[ std::size_t( at - gk.keys.begin() ) ].push_back( m ); continue; } - keys.emplace_back( fileId, canon ); - groups.push_back( EditCheckGroup{ m, std::string{} } ); + gk.keys.emplace_back( fileId, canon ); + gk.members.push_back( { m } ); + } + if( foldDecls ) + { + editCheckFoldDeclGroups( ing, gk ); + } + return gk; +} + +// E2 (2026-10-01): every spelling the refusal prints must be ACCEPTED when it is pasted back. `file:name` failed that +// for a header whose declaration the file:name tier widens to its definitions (#63): `./lib.h:scale` re-resolved to +// the declaration AND the definition and was refused again. So each shown spelling is re-resolved here and must land on +// exactly this group; otherwise the canonical id, then the `@FILE:LINE` seed (one place, one symbol) is tried. A +// cwd-spelled path the file tier would read as a substring of another (`./lib.cpp` inside `./sub/lib.cpp`) now names +// its own file exactly (graph.h preferExactFile), so it round-trips. When nothing does — a line holding two +// definitions — `unique` stays false and the refusal SAYS so beside that spelling, never offering it as the example. +constexpr std::size_t kEditCheckSpellingsShown = 6; + +inline void editCheckRoundTripSpelling( const IngestResult& ing, const Graph& g, EditCheckGroup& group, const std::string& canon, bool foldDecls ) +{ + const Symbol& s = ing.symbols[ group.lowestNode ]; + std::vector tries{ group.spelling }; + if( canon != s.name && canon != group.spelling ) + { + tries.push_back( canon ); + } + tries.push_back( "@" + ing.files[ s.fileId ] + ":" + std::to_string( s.line ) ); + for( const std::string& spelling : tries ) + { + const std::vector again = resolveAllByNameQualified( ing, spelling ); + const EditCheckGroupKeys gk = editCheckGroupKeys( ing, g, again, foldDecls ); + if( gk.keys.size() == 1 && gk.members[0].front() == group.lowestNode ) + { + group.spelling = spelling; + group.unique = true; + return; + } + } +} + +// `foldDecls` is the post-hoc verb's identity rule (editCheckFoldDeclGroups); the pre-apply preview and the other +// readers of these groups (the write verbs, --slice) keep one group per (file, scope), so their answers are unchanged. +inline std::vector editCheckGroups( const IngestResult& ing, const Graph& g, std::span matches, bool foldDecls = false ) +{ + const EditCheckGroupKeys gk = editCheckGroupKeys( ing, g, matches, foldDecls ); + const bool anyDefinition = !std::all_of( gk.members.begin(), gk.members.end(), + [ & ]( const std::vector& members ) { return editCheckDeclOnlyGroup( ing, members ); } ); + std::vector groups; + for( const std::vector& members : gk.members ) + { + // M2: a declaration-only group beside a definition is never offered — its own answer has no call edges + groups.push_back( EditCheckGroup{ members.front(), std::string{}, anyDefinition && editCheckDeclOnlyGroup( ing, members ), false } ); } // the spelling: file:name is the form an agent can paste from any p="file:line" row, so prefer it and fall @@ -150,9 +296,9 @@ inline std::vector editCheckGroups( const IngestResult& ing, con for( std::size_t groupIndex = 0; groupIndex < groups.size(); ++groupIndex ) { const Symbol& s = ing.symbols[ groups[ groupIndex ].lowestNode ]; - const std::string& canonOfThis = keys[ groupIndex ].second; + const std::string& canonOfThis = gk.keys[ groupIndex ].second; std::size_t groupsInFile = 0; - for( const auto& [fileId, canon] : keys ) + for( const auto& [fileId, canon] : gk.keys ) { if( fileId == s.fileId ) { @@ -163,9 +309,37 @@ inline std::vector editCheckGroups( const IngestResult& ing, con const bool fileIsEnough = ( groupsInFile == 1 ) || ( canonOfThis == s.name ); groups[ groupIndex ].spelling = fileIsEnough ? ing.files[ s.fileId ] + ":" + s.name : canonOfThis; } + // only a refusal prints spellings, and only the first kEditCheckSpellingsShown OFFERED ones + for( std::size_t groupIndex = 0, offered = 0; groups.size() > 1 && groupIndex < groups.size() && offered < kEditCheckSpellingsShown; ++groupIndex ) + { + if( !groups[ groupIndex ].declOnly ) + { + editCheckRoundTripSpelling( ing, g, groups[ groupIndex ], gk.keys[ groupIndex ].second, foldDecls ); + ++offered; + } + } return groups; } +// A spelling as the CLI reader pastes it: wrapped in single quotes when it holds a byte a shell would split or expand +// on (a path with a space). The MCP form (`symbol=`) is a JSON string and is never quoted. +inline std::string editCheckPasteable( std::string_view exampleForm, const std::string& spelling ) +{ + const bool cli = !exampleForm.empty() && exampleForm.front() == '-'; + const bool safe = std::all_of( spelling.begin(), spelling.end(), []( char c ) + { return namesplit::isIdentChar( c ) || std::string_view( "./:@#+,=-~%" ).find( c ) != std::string_view::npos; } ); + if( !cli || safe ) + { + return spelling; + } + std::string quoted = "'"; + for( const char c : spelling ) + { + quoted += ( c == '\'' ) ? std::string( "'\\''" ) : std::string( 1, c ); + } + return quoted + "'"; +} + // The ONE ambiguity refusal both surfaces print, so the CLI and the MCP verb cannot drift apart: name what is // ambiguous, say how many contracts it matched and why this verb cannot union them, list the spellings that // pick one, and give one as a ready-to-run example. `exampleForm` is the surface's own retry syntax @@ -180,7 +354,37 @@ inline std::vector editCheckGroups( const IngestResult& ing, con // about a sibling verb's output, which is the worst kind of wrong — an agent can act on it without re-running // anything. Each number now carries the noun it actually is. definitionCount >= groups.size() always // (collapsing distinct matches into a group can never invent one), which is asserted rather than assumed. -constexpr std::size_t kEditCheckSpellingsShown = 6; +// +// M2 (review of the declaration fold): a declaration-only contract beside a definition is COUNTED, never offered — its +// own answer reads callers="0" while the definition holds the calls, a false reassurance. A spelling that could not be +// verified unique says so, and is never the example; with no verified spelling there is no example at all. +// The offered half of the refusal: the first kEditCheckSpellingsShown offered spellings (declaration-only ones are counted, +// never listed), how many more there are, and the example — the first VERIFIED spelling, or none. +struct EditCheckOffer +{ + std::string list; + const std::string* example; + std::size_t more; + std::size_t declOnly; +}; + +inline EditCheckOffer editCheckOffer( std::span groups, std::string_view exampleForm ) +{ + EditCheckOffer offer{}; + std::size_t shown = 0; + for( const EditCheckGroup& group : groups ) + { + offer.declOnly += group.declOnly ? 1u : 0u; + const bool listed = !group.declOnly && shown < kEditCheckSpellingsShown; + offer.more += ( !group.declOnly && !listed ) ? 1u : 0u; + if( listed ) + { + offer.list += ( shown++ ? ", " : "" ) + editCheckPasteable( exampleForm, group.spelling ) + ( group.unique ? "" : " [no selector names this contract alone]" ); + offer.example = ( offer.example == nullptr && group.unique ) ? &group.spelling : offer.example; + } + } + return offer; +} inline std::string editCheckAmbiguousMessage( std::string_view spec, std::span groups, std::string_view exampleForm, std::size_t definitionCount ) @@ -188,21 +392,17 @@ inline std::string editCheckAmbiguousMessage( std::string_view spec, std::span 1 ); ASSUME( definitionCount >= groups.size() ); + const EditCheckOffer offer = editCheckOffer( groups, exampleForm ); std::string msg = "'" + std::string( spec ) + "' is ambiguous — it matches " + std::to_string( definitionCount ) + " definitions in " + std::to_string( groups.size() ) + " distinct contracts, and a contract is per " "definition SITE (--callers may union overloads and disclose defs=\"" - + std::to_string( definitionCount ) + "\"; this verb cannot). Qualify one contract: "; - const std::size_t shownCount = std::min( groups.size(), 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. " + std::string( exampleForm ) + groups[0].spelling; + + std::to_string( definitionCount ) + "\"; this verb cannot). Qualify one contract: " + offer.list; + msg += offer.more ? " (+" + std::to_string( offer.more ) + " more contracts)" : std::string(); + msg += offer.declOnly ? " (+" + std::to_string( offer.declOnly ) + " declaration-only contract" + ( offer.declOnly > 1 ? "s" : "" ) + + " not listed: a declaration has no call edges of its own, so its answer would read callers=\"0\" while a " + "definition of the name holds the calls)" : std::string(); + msg += offer.example ? " — e.g. " + std::string( exampleForm ) + editCheckPasteable( exampleForm, *offer.example ) + : std::string( " — no listed spelling was verified unique" ); return msg; } @@ -431,6 +631,15 @@ inline bool editCheckImplicitReceiver( const Symbol& s ) noexcept return ( s.lang == Lang::Python || s.lang == Lang::Ruby ) && !s.scope.empty(); } +// The arity half of the incompatibility test for one member of the overload set: can a call passing `argCount` +// arguments bind it? A variadic/defaulted definition (arityExact 0) and an implicit receiver are wildcards; a C/C++ +// definition whose declaration carries defaults accepts [minArity, params] (editCheckDeclDefaults); every other +// definition's minArity IS its params, so for it this is the exact test. +inline bool editCheckArityAccepts( const Symbol& os, std::uint16_t minArity, std::uint16_t argCount ) noexcept +{ + return os.arityExact == 0 || editCheckImplicitReceiver( os ) || ( minArity <= argCount && argCount <= os.params ); +} + // ── THE CALLEE TEST, with the Elixir arity fold ────────────────────────────────────────────────────────── // "Does this call reference name the focus's contract?" For every language but Elixir it is the exact // calleeName == name test the in-edge walk already implied, byte for byte. Elixir keys a callable by `name/N`, @@ -448,9 +657,10 @@ struct EditCheckCalleeTest std::span overloadNodes; std::optional logical; // engaged for an Elixir focus only std::vector scratch; + EditCheckDeclDefaults declDefaults; // per overload: the fewest arguments a matching C/C++ declaration admits EditCheckCalleeTest( const IngestResult& input, const Symbol& focusSymbol, std::span overloads ) - : ing( input ), focus( focusSymbol ), overloadNodes( overloads ) + : ing( input ), focus( focusSymbol ), overloadNodes( overloads ), declDefaults( editCheckDeclDefaults( input, overloads, {} ) ) { if( focus.lang == Lang::Elixir ) { @@ -492,12 +702,14 @@ struct EditCheckCalleeTest inline std::vector editCheckIncompatibleFlags( const IngestResult& ing, std::span overloadNodes, EditCheckCalleeTest& callee, std::span seenCaller ) { + // a C/C++ definition whose matching declaration carries defaults accepts the RANGE [minArity, params] + // (editCheckDeclDefaults); every other definition's minArity is its params, so the test below is the exact one + ASSUME( callee.declDefaults.minArity.size() == overloadNodes.size() ); const auto provenIncompatible = [ & ]( std::uint16_t argCount ) -> bool { - for( NodeId ov : overloadNodes ) + for( std::size_t k = 0; k < overloadNodes.size(); ++k ) { - if( const Symbol& os = ing.symbols[ov]; - os.arityExact == 0 || editCheckImplicitReceiver( os ) || os.params == argCount ) + if( editCheckArityAccepts( ing.symbols[ overloadNodes[k] ], callee.declDefaults.minArity[k], argCount ) ) { return false; // a candidate could still accept it } @@ -708,6 +920,25 @@ inline constexpr const char* kEditCheckWindowLegend = "limit=N (offset=M pages, and a page past the end reads shown_unflagged=\"0\" with has_more=\"0\"); on the root, limit=\"0\" " "means no explicit limit was given and the verb's own default page size shaped the window — never a zero-row page. "; +// defaults_from="decl" (2026-10-01): the clause that reads it, emitted only beside it. No double hyphen (G4). +inline constexpr const char* kEditCheckDefaultsFromDeclLegend = + "defaults_from=\"decl\": a C/C++ definition's own parameter list carries no default, but a DECLARATION of it does: same " + "name, the same full scope chain (every enclosing namespace and class, and the written qualifier) and member qualifiers, " + "the same parameter types, in the definition's file or a file it #includes directly. The declaration " + "and the definition are one contract (the bare name and the declaration's file:name answer about the definition), and " + "a call passing from params minus that declaration's defaults up to params arguments is accepted, so it is never " + "flagged. A declaration that cannot be tied that way lends nothing, and its definition's callers are judged against " + "the definition's own list. "; + +// defaults_untied=N (review S2/S3): the declarations the identity rule could NOT prove or refute, that may carry a default, +// emitted only beside a nonzero incompatible= — a flag one of them might admit is then qualified instead of silent. +inline constexpr const char* kEditCheckDefaultsUntiedLegend = + "defaults_untied=N (only beside a nonzero incompatible=): N C/C++ declarations of this name and scope may carry defaults " + "for this definition but could not be tied to it, so their defaults were NOT applied: the definition's file does not " + "include theirs directly (a transitive include is not followed), a scope chain could not be read (unbalanced braces, a " + "template-qualified name), or a signature could not be parsed (past 16 KiB, a function-pointer parameter). A flagged " + "call may be one such a default admits; open the declaration before acting on the flag. "; + // The root attributes that clause defines: rule 1's noun-prefixed pair plus rule 6's paging half, composed // in ONE place so the pair and the half cannot come apart. Empty when the window is inactive, which is what // keeps an answer that fits byte-identical to what it was. @@ -826,7 +1057,9 @@ inline std::string editCheckBundleText( const IngestResult& ing, const Graph& g, // for emptiness (main.cpp's MainDispatch::notesDegraded), so a sidecar // that left EVERY line unparsed still reaches this root. Defaults false, // matching `ni`'s own nullptr default (the MCP verb passes neither today). - bool notesDegraded = false ) + bool notesDegraded = false, + // the pre-apply preview's spliced file: its spans index these bytes, not the disk + const EditCheckSpliced& spliced = {} ) { const Symbol& fsym = ing.symbols[ focus ]; // R-E (2026-08-17 harvest): same single-root condition every other verb's root= uses (sarif.h) — the ONE @@ -841,6 +1074,10 @@ inline std::string editCheckBundleText( const IngestResult& ing, const Graph& g, const std::vector overloadNodes = editCheckOverloadSet( ing, g, focus ); const EditCheckContract contract = editCheckContractVsHead( ing, g, root, maxFileBytes, excludes, focus, overloadNodes ); EditCheckCalleeTest callee( ing, fsym, overloadNodes ); + if( spliced.engaged ) + { + callee.declDefaults = editCheckDeclDefaults( ing, overloadNodes, spliced ); // the preview's spans index the spliced bytes + } const auto [ callerIds, callerIncompatible ] = editCheckCallers( ing, g, overloadNodes, callee ); // the flagged-caller COUNT, needed BEFORE the headline is written: the verdict joins it with the was/now @@ -949,6 +1186,15 @@ inline std::string editCheckBundleText( const IngestResult& ing, const Graph& g, { out += kEditCheckWindowLegend; } + if( callee.declDefaults.fromDecl ) + { + out += kEditCheckDefaultsFromDeclLegend; + } + const bool defaultsUntied = incompatibleCount > 0 && callee.declDefaults.untied > 0; // only beside flags it may qualify + if( defaultsUntied ) + { + out += kEditCheckDefaultsUntiedLegend; + } if( contract.defsUnmeasured ) { out += "defs_unmeasured=\"1\": the HEAD baseline carries no definition COUNT for this symbol (a cache defect), so that " @@ -1013,6 +1259,14 @@ inline std::string editCheckBundleText( const IngestResult& ing, const Graph& g, char callersOpen[ 64 ]; rw::formatTo( callersOpen, sizeof( callersOpen ), " callers=\"{}\" incompatible=\"{}\"", callerIds.size(), incompatibleCount ); out += callersOpen; + if( callee.declDefaults.fromDecl ) + { + out += " defaults_from=\"decl\""; // beside the incompatible= it widened; defined by the clause emitted only with it + } + if( defaultsUntied ) + { + out += " defaults_untied=\"" + std::to_string( callee.declDefaults.untied ) + "\""; // beside the flags it may qualify + } out += unprovenDefsAttrXml( unprovenDefs ); // H1: beside the incompatible= it qualifies; absent at zero out += declinedCallsAttrXml( declinedCalls ); // callers the resolver declined to bind that could have meant it; absent at zero // r26-stamp Task A: the HEAD baseline this contract compares against is only meaningful pinned to a diff --git a/src/editcheckdecl.h b/src/editcheckdecl.h new file mode 100644 index 000000000..b5efc30d8 --- /dev/null +++ b/src/editcheckdecl.h @@ -0,0 +1,836 @@ +#pragma once +// editcheckdecl.h — the C/C++ DECLARATION/DEFINITION IDENTITY behind --edit-check (editcheck.h). +// +// The field report: `int scale( int x, int factor = 2 );` in lib.h, its definition in lib.cpp, three callers passing 1, +// 1 and 2 arguments, and a trailing `int bias = 0` added to both. Every call still compiles. The verb (1) refused the +// bare name as "2 distinct contracts" — the prototype and its definition sit in two files, and a contract was keyed per +// (file, scope) — (2) answered the definition's handle with incompatible="3", because the arity test read the +// DEFINITION's parameter list, which carries no defaults (C++ puts them on the declaration a caller sees), and (3) +// refused the header handle the refusal itself had suggested. +// +// THE IDENTITY RULE. A bodyless C-family declaration D stands for a bodied definition F when ALL of these hold: +// * same name, and the SAME FULL SCOPE CHAIN — every enclosing namespace and class, outermost first, plus the +// declarator's own written qualifier (`int outer::detail::f(…)`, `int S::f(…)`; a leading `::` is absolute). +// Symbol::scope holds only the INNERMOST label, so `outer::detail::f` and `::detail::f` shared it and one lent its +// default to the other (review M1). The chain is read off the source by editCheckScopeChain; a file whose braces do +// not balance (a macro that opens a namespace, a brace inside an #if/#else pair) or a template-qualified declarator +// yields an UNKNOWN chain, and an unknown chain never pairs; +// * the same cv/ref qualifiers on the member (`int f(…) const` is another function than `int f(…)`); +// * F's file is D's file, or F's file #includes D's file directly — includeProofOfDeclFiles, the SAME positive proof the +// file:name selector widening keeps a candidate by; +// * F has external linkage, unless D sits in F's own file (keepProvenCandidates' clause 3); +// * the PARAMETER-TYPE LISTS are equal — the declared types, names, default values and comments stripped. This is what +// keeps real overloads apart: `scale( int, int = 2 )` never lends its default to `scale( double, int )`. +// The types are read off the SOURCE TEXT of each signature span (no parameter-type fact is indexed). A list the reader +// cannot parse into exactly `params` entries — a function-pointer parameter, a macro-built list, a stale span, one past +// kEditCheckSigCap — matches nothing. +// +// A false "compatible" is worse than a false "incompatible" (the flag is the verb's one call-site signal), so every rule +// fails CLOSED: anything unproven keeps the stricter reading, and editCheckTieDeclaration says WHICH way it failed — +// Different (provably another function) or Unproven (it may declare F; the proof could not be made), so the answer can +// disclose the unproven ones that carry defaults (defaults_untied=) instead of flagging their callers silently. + +#include "model.h" +#include "graph.h" // includeProofOfDeclFiles / langCompatible / isDefinitionNotDeclaration +#include "infra/namesplit.h" // namesplit::isIdentChar — the ONE ASCII identifier-byte test +#include "infra/ownedfile.h" // the source read + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rw +{ + +// The pre-apply preview's one in-memory file: the merged tree's spans for that file index the spliced bytes, not the disk. +struct EditCheckSpliced +{ + std::string_view bytes; + std::uint32_t fileId; + bool engaged; +}; + +// A signature longer than this is not parsed (it does not pair, and an unproven declaration is disclosed as such). +constexpr std::uint32_t kEditCheckSigCap = 16384; // disclosed as defaults_untied= beside a nonzero incompatible= + +// ── the source, with comments blanked ───────────────────────────────────────────────────────────────────────── +// Comments become spaces, BYTE FOR BYTE, so every recorded span still indexes the same text: a `/* = 2 */` reminder no +// longer reads as a default and a trailing `// (a, b)` no longer splits a parameter (review S1). String and character +// literals are skipped whole, so a `"//"` inside one is not a comment. +inline void editCheckBlankComments( std::string& text ) +{ + std::size_t i = 0; + while( i < text.size() ) + { + const char c = text[i]; + const char next = ( i + 1 < text.size() ) ? text[ i + 1 ] : '\0'; + if( c == '"' || c == '\'' ) + { + std::size_t j = i + 1; + while( j < text.size() && text[j] != c && text[j] != '\n' ) + { + j += ( text[j] == '\\' ) ? 2u : 1u; + } + i = j + 1; + continue; + } + if( c != '/' || ( next != '/' && next != '*' ) ) + { + ++i; + continue; + } + const std::size_t close = ( next == '/' ) ? text.find( '\n', i ) : text.find( "*/", i + 2 ); + const std::size_t end = ( close == std::string::npos ) ? text.size() : ( next == '/' ? close : close + 2 ); + for( std::size_t k = i; k < end; ++k ) + { + text[k] = ( text[k] == '\n' ) ? '\n' : ' '; + } + i = end; + } +} + +// One file's comment-blanked text. `whole` is false when the file could not be opened or its read stopped on an error: +// the text is then empty or partial, so a signature read from it proves nothing (editCheckSignatureOf discloses it). +struct EditCheckFileText +{ + std::uint32_t fileId; + std::string body; + bool whole; +}; + +// The comment-blanked text of the files one answer reads, each read once. The preview's spliced file is read from memory. +class EditCheckSources +{ +public: + EditCheckSources( const IngestResult& input, const EditCheckSpliced& splicedFile ) : ing( input ), spliced( splicedFile ) {} + + const EditCheckFileText& text( std::uint32_t fileId ) + { + for( const EditCheckFileText& f : files ) + { + if( f.fileId == fileId ) + { + return f; + } + } + files.push_back( read( fileId ) ); + editCheckBlankComments( files.back().body ); + return files.back(); + } + +private: + EditCheckFileText read( std::uint32_t fileId ) const + { + if( spliced.engaged && spliced.fileId == fileId ) + { + return { fileId, std::string( spliced.bytes ), true }; + } + std::string body; + OwnedFile in = openOwnedFile( diskPath( ing, fileId ).c_str(), "rb" ); + char buf[ 4096 ]; + for( std::size_t n = 0; in && ( n = std::fread( buf, 1, sizeof( buf ), in.file ) ) > 0; ) + { + body.append( buf, n ); + } + const bool whole = in && std::ferror( in.file ) == 0; + return { fileId, std::move( body ), whole }; + } + + const IngestResult& ing; + EditCheckSpliced spliced; + std::deque files; // a deque: a reference text() handed out stays valid while later files are appended +}; + +// ── one parameter's type ────────────────────────────────────────────────────────────────────────────────────── +// Tokens: identifiers (with `::` kept inside them) and single punctuation bytes, so whitespace never decides equality. An +// attribute (`[[maybe_unused]]`) is not part of the type and is skipped. +inline std::vector editCheckTokens( std::string_view param ) +{ + const auto identAt = [ & ]( std::size_t i ) + { + return namesplit::isIdentChar( param[i] ) || ( param[i] == ':' && i + 1 < param.size() && param[ i + 1 ] == ':' ); + }; + std::vector toks; + std::size_t i = 0; + while( i < param.size() ) + { + if( param.substr( i, 2 ) == "[[" ) + { + const std::size_t close = param.find( "]]", i + 2 ); + i = ( close == std::string_view::npos ) ? param.size() : close + 2; + continue; + } + if( !identAt( i ) ) + { + if( param[i] != ' ' && param[i] != '\t' && param[i] != '\n' && param[i] != '\r' ) + { + toks.emplace_back( 1, param[i] ); + } + ++i; + continue; + } + std::string tok; + while( i < param.size() && identAt( i ) ) + { + const std::size_t step = ( param[i] == ':' ) ? 2u : 1u; + tok.append( param.substr( i, step ) ); + i += step; + } + toks.push_back( std::move( tok ) ); + } + return toks; +} + +// One parameter's TYPE: its tokens minus the declarator name, which is dropped when it is the last identifier (before an +// array suffix, if any) and a type token precedes it — `int x` -> `int`, `const Foo& f` -> `const Foo &`, `int a[3]` -> +// `int [ 3 ]`. A keyword type is never mistaken for a name (`unsigned long` keeps both), and a lone qualifier is not a +// type (`const Foo` keeps `Foo`). +inline std::vector editCheckTypeTokens( std::string_view param ) +{ + static constexpr std::string_view kTypeWords[] = { "int", "long", "short", "char", "unsigned", "signed", "double", "float", "bool", + "void", "auto", "wchar_t", "char8_t", "char16_t", "char32_t" }; + static constexpr std::string_view kQualWords[] = { "const", "volatile", "struct", "class", "enum", "union", "typename", "register", + "restrict", "__restrict" }; + const auto in = []( const std::string& t, std::span set ) { return std::find( set.begin(), set.end(), t ) != set.end(); }; + + std::vector toks = editCheckTokens( param ); + const auto suffix = std::find( toks.begin(), toks.end(), std::string( "[" ) ); + const std::size_t nameAt = ( !toks.empty() && toks.back() == "]" ) ? std::size_t( suffix - toks.begin() ) : toks.size(); + if( nameAt < 2 ) + { + return toks; + } + const std::string& name = toks[ nameAt - 1 ]; + const bool typeBefore = std::any_of( toks.begin(), toks.begin() + std::ptrdiff_t( nameAt - 1 ), + [ & ]( const std::string& t ) { return !in( t, kQualWords ); } ); + if( namesplit::isIdentChar( name[0] ) && !in( name, kTypeWords ) && !in( name, kQualWords ) && typeBefore ) + { + toks.erase( toks.begin() + std::ptrdiff_t( nameAt - 1 ) ); + } + return toks; +} + +// Where `name` sits in a signature's text and where its parameter list opens: the first whole-word occurrence followed +// (whitespace allowed) by '('. {npos, npos} when there is none. +inline std::pair editCheckParamOpen( std::string_view text, std::string_view name ) +{ + for( std::size_t at = name.empty() ? std::string_view::npos : text.find( name ); at != std::string_view::npos; at = text.find( name, at + 1 ) ) + { + const std::size_t after = text.find_first_not_of( " \t\r\n", at + name.size() ); + if( ( at == 0 || !namesplit::isIdentChar( text[ at - 1 ] ) ) && after != std::string_view::npos && text[after] == '(' ) + { + return { at, after }; + } + } + return { std::string_view::npos, std::string_view::npos }; +} + +// The raw parameters between the '(' at `open` and its matching ')' (at `closeAt`), each with whether it carries a +// default. Split at depth-0 commas; '<'/'>' nest only in a TYPE, never after its '=' (a default value may compare). +struct EditCheckRawParams +{ + std::vector text; + std::vector defaulted; + std::size_t closeAt; + bool closed; +}; + +inline EditCheckRawParams editCheckSplitParams( std::string_view text, std::size_t open ) +{ + EditCheckRawParams raw{}; + int depth = 0, angle = 0; + bool inDefault = false; + std::size_t start = open + 1; + for( std::size_t i = open + 1; i < text.size() && !raw.closed; ++i ) + { + const char c = text[i]; + const bool atTop = depth == 0 && ( angle == 0 || inDefault ); + const bool assigns = c == '=' && depth == 0 && angle == 0 && text.substr( i - 1, 3 ).find( "==" ) == std::string_view::npos + && std::string_view( "!<>" ).find( text[ i - 1 ] ) == std::string_view::npos; + if( atTop && ( c == ')' || c == ',' ) ) + { + raw.text.push_back( text.substr( start, i - start ) ); + raw.defaulted.push_back( char( inDefault ? 1 : 0 ) ); + start = i + 1; + inDefault = false; + angle = 0; + raw.closed = ( c == ')' ); + raw.closeAt = i; + continue; + } + depth += ( c == '(' || c == '[' || c == '{' ) ? 1 : ( c == ')' || c == ']' || c == '}' ) ? -1 : 0; + angle += ( inDefault || depth != 0 ) ? 0 : ( c == '<' ) ? 1 : ( c == '>' && angle > 0 ) ? -1 : 0; + inDefault = inDefault || assigns; + } + return raw; +} + +// ── macros that may open or close a scope (the brace counter cannot see through them) ────────────────────────── +// A namespace opened by a macro (`#define NS_BEGIN namespace outer {` … `NS_BEGIN` … `NS_END`) leaves the braces balanced +// and the chain reading GLOBAL — a false pairing. So a file's chain is UNREADABLE when, at namespace scope, a statement +// starts with (1) a macro the file #defines with a brace or `namespace` in its body, (2) an ALL-CAPS identifier standing +// alone on its line (an argument list allowed): the shape of a scope macro defined in another header, or (3) an ALL-CAPS +// identifier naming NAMESPACE, BEGIN or END. Each over-reads (a lone `Q_DECLARE_METATYPE( X )` trips it too); every +// over-read fails CLOSED and is disclosed (defaults_untied=). +inline bool editCheckAllCaps( std::string_view w ) noexcept +{ + const auto lower = std::find_if( w.begin(), w.end(), []( char c ) { return !( ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '_' ); } ); + return w.size() >= 2 && lower == w.end() && !( w[0] >= '0' && w[0] <= '9' ); +} + +inline bool editCheckLineStartsAt( std::string_view text, std::size_t i ) noexcept +{ + const std::size_t lineStart = ( i == 0 ) ? std::string_view::npos : text.rfind( '\n', i - 1 ); + const std::size_t from = ( lineStart == std::string_view::npos ) ? 0 : lineStart + 1; + return text.substr( from, i - from ).find_first_not_of( " \t" ) == std::string_view::npos; +} + +// does the identifier [i, end) stand alone on its line (an argument list after it allowed)? +inline bool editCheckAloneOnLine( std::string_view text, std::size_t i, std::size_t end ) noexcept +{ + std::size_t after = text.find_first_not_of( " \t", end ); + if( after != std::string_view::npos && text[ after ] == '(' ) + { + const std::size_t close = text.find( ')', after ); + after = ( close == std::string_view::npos ) ? close : text.find_first_not_of( " \t", close + 1 ); + } + const bool lineEnds = after == std::string_view::npos || text[ after ] == '\n' || text[ after ] == '\r' || text[ after ] == ';'; + return lineEnds && editCheckLineStartsAt( text, i ); +} + +// the NAME of a `#define NAME body` line whose body opens or closes a scope, else empty +inline std::string_view editCheckScopeDefine( std::string_view line ) noexcept +{ + const std::size_t def = line.find( "define" ); + const std::size_t name = ( def == std::string_view::npos ) ? def : line.find_first_not_of( " \t", def + 6 ); + std::size_t end = name; + while( end != std::string_view::npos && end < line.size() && namesplit::isIdentChar( line[ end ] ) ) + { + ++end; + } + const std::string_view body = ( end == std::string_view::npos ) ? std::string_view{} : line.substr( end ); + const bool opensScope = body.find_first_of( "{}" ) != std::string_view::npos || body.find( "namespace" ) != std::string_view::npos; + return ( end != name && opensScope ) ? line.substr( name, end - name ) : std::string_view{}; +} + +// ── the full scope chain ────────────────────────────────────────────────────────────────────────────────────── +// The ENCLOSING half: a brace lexer over the comment-blanked file. Every '{' pushes the labels that a `namespace A::B`, +// `namespace` (anonymous), `struct S`, `class S` or `union S` head left pending (an `enum class` and every other block +// push none); ';', '(' and '=' cancel a pending head. String and character literals and preprocessor lines are +// skipped. The chain at `offset` is the stack's labels, outermost first. UNKNOWN (nullopt) when the file's braces do not +// balance — the shape a macro-opened namespace or an `#if { #else { #endif` pair leaves — because then the stack cannot +// be trusted. +class EditCheckBraceLexer +{ +public: + explicit EditCheckBraceLexer( std::string_view source ) : text( source ) {} + + std::optional> chainAt( std::size_t offset ) + { + std::optional> snapshot; + std::size_t i = 0; + askedOffset = offset; + while( i < text.size() && balanced ) + { + if( !snapshot && i >= offset ) + { + snapshot = current(); + } + i = step( i ); + } + if( !snapshot && balanced && offset >= text.size() ) + { + snapshot = current(); + } + return ( balanced && stack.empty() && !scopeMacro ) ? snapshot : std::nullopt; + } + + // a `using namespace X;` came before the offset asked about: a QUALIFIED definition there (`int S::f`) may name a + // member of X, so its written chain is not certain (the caller then reads the chain as unknown) + bool usingDirectiveBefore() const noexcept { return usingBefore; } + +private: + std::vector current() const + { + std::vector chain; + for( const std::vector& labels : stack ) + { + chain.insert( chain.end(), labels.begin(), labels.end() ); + } + return chain; + } + + // one lexical step from `i`; returns where the next one starts + std::size_t step( std::size_t i ) + { + const char c = text[i]; + if( c == '"' || ( c == '\'' && !( i > 0 && namesplit::isIdentChar( text[ i - 1 ] ) ) ) ) + { + return skipLiteral( i ); + } + if( c == '#' && editCheckLineStartsAt( text, i ) ) + { + stmtStart = true; + return skipDirective( i ); + } + if( namesplit::isIdentChar( c ) ) + { + const std::size_t end = word( i ); + stmtStart = false; + return end; + } + punctuation( c ); + return i + 1; + } + + // a '{' pushes the pending head's labels, a '}' pops; ';', '(' and '=' cancel a pending head + void punctuation( char c ) + { + if( c == '{' ) + { + stack.push_back( pending.value_or( std::vector{} ) ); + stackIsNamespace.push_back( char( pending.has_value() && pendingNamespace ? 1 : 0 ) ); + pending.reset(); + } + else if( c == '}' && stack.empty() ) + { + balanced = false; // a close with nothing open: the file cannot be read + } + else if( c == '}' ) + { + stack.pop_back(); + stackIsNamespace.pop_back(); + } + pending = ( c == ';' || c == '(' || c == '=' ) ? std::nullopt : pending; + stmtStart = ( c == ';' || c == '{' || c == '}' ) || ( stmtStart && std::string_view( " \t\r\n" ).find( c ) != std::string_view::npos ); + } + + // see editCheckScopeDefine above for the three shapes; only at namespace scope, only at a statement's start + bool scopeMacroAt( std::string_view w, std::size_t i, std::size_t end ) const + { + const bool atNamespaceScope = std::find( stackIsNamespace.begin(), stackIsNamespace.end(), char( 0 ) ) == stackIsNamespace.end(); + const bool namesScope = w.find( "NAMESPACE" ) != std::string_view::npos || w.find( "BEGIN" ) != std::string_view::npos + || w.find( "END" ) != std::string_view::npos; + return stmtStart && atNamespaceScope + && ( std::find( braceMacros.begin(), braceMacros.end(), w ) != braceMacros.end() + || ( editCheckAllCaps( w ) && ( namesScope || editCheckAloneOnLine( text, i, end ) ) ) ); + } + + std::size_t skipDirective( std::size_t i ) + { + const std::size_t start = i; + while( i < text.size() && !( text[i] == '\n' && text[ i - 1 ] != '\\' ) ) + { + ++i; + } + if( const std::string_view name = editCheckScopeDefine( text.substr( start, i - start ) ); !name.empty() ) + { + braceMacros.push_back( name ); // a `#define NAME body` whose body opens or closes a scope + } + return i; + } + + // past the balanced parenthesised group that starts at the first non-blank byte from `i` (none: `i` itself) + std::size_t skipParenGroup( std::size_t i ) const + { + const std::size_t open = text.find_first_not_of( " \t\r\n", i ); + if( open == std::string_view::npos || text[open] != '(' ) + { + return i; + } + std::size_t depth = 0; + for( std::size_t j = open; j < text.size(); ++j ) + { + depth += ( text[j] == '(' ) ? 1u : 0u; + depth -= ( text[j] == ')' ) ? 1u : 0u; + if( depth == 0 ) + { + return j + 1; + } + } + return text.size(); + } + + std::size_t skipLiteral( std::size_t i ) const + { + const char quote = text[i]; + std::size_t j = i + 1; + if( quote == '"' && i > 0 && text[ i - 1 ] == 'R' ) + { // a raw string: R"delim( … )delim" + const std::size_t paren = text.find( '(', j ); + const std::string close = ")" + std::string( text.substr( j, paren == std::string_view::npos ? 0 : paren - j ) ) + "\""; + const std::size_t end = ( paren == std::string_view::npos ) ? std::string_view::npos : text.find( close, paren ); + return ( end == std::string_view::npos ) ? text.size() : end + close.size(); + } + while( j < text.size() && text[j] != quote && text[j] != '\n' ) + { + j += ( text[j] == '\\' ) ? 2u : 1u; + } + return j + 1; + } + + // an identifier; a scope head records the labels the next '{' pushes + std::size_t word( std::size_t i ) + { + std::size_t end = i; + while( end < text.size() && namesplit::isIdentChar( text[end] ) ) + { + ++end; + } + const std::string_view w = text.substr( i, end - i ); + if( pending && ( w == "alignas" || w == "__attribute__" || w == "__declspec" ) ) + { + lastWord = w; + return skipParenGroup( end ); // an attribute group inside a class head is part of the head, not a '(' cancel + } + scopeMacro = scopeMacro || scopeMacroAt( w, i, end ); + usingBefore = usingBefore || ( lastWord == "using" && w == "namespace" && i < askedOffset ); + if( ( w == "namespace" && lastWord != "using" ) || ( ( w == "struct" || w == "class" || w == "union" ) && lastWord != "enum" ) ) + { + pending = headLabels( end, w == "namespace" ); + pendingNamespace = w == "namespace"; + } + else if( ( w == "struct" || w == "class" ) && lastWord == "enum" ) + { + pending = std::vector{}; + pendingNamespace = false; + } + lastWord = w; + return end; + } + + // The labels a head keyword opens: the LAST qualified name before the head ends at '{', a base-clause ':', a template + // argument list '<', ';' or '=' — so an export macro or attribute in front (`class API_EXPORT S`, `struct + // alignas( 8 ) S`) does not become the label, and `namespace A::B` / `struct A::B` give {A, B}. A parenthesised or + // bracketed group is skipped whole. Nothing named: one anonymous label for a namespace, none for a struct. + std::vector headLabels( std::size_t from, bool isNamespace ) const + { + std::vector labels; + bool joined = false; // the previous token was `::` + for( std::size_t i = from; i < text.size(); ) + { + const char c = text[i]; + if( text.substr( i, 2 ) == "::" ) + { + joined = true; + i += 2; + } + else if( namesplit::isIdentChar( c ) ) + { + std::size_t end = i; + while( end < text.size() && namesplit::isIdentChar( text[end] ) ) + { + ++end; + } + const std::string_view w = text.substr( i, end - i ); + if( !joined && w != "final" ) + { + labels.clear(); + } + if( w != "final" ) + { + labels.emplace_back( w ); + } + joined = false; + i = end; + } + else if( c == '(' || c == '[' ) + { + const std::size_t close = text.find( c == '(' ? ')' : ']', i ); + i = ( close == std::string_view::npos ) ? text.size() : close + 1; + } + else if( c == '{' || c == ':' || c == '<' || c == ';' || c == '=' ) + { + break; + } + else + { + ++i; + } + } + if( labels.empty() && isNamespace ) + { + labels.emplace_back( "(anonymous)" ); + } + return labels; + } + + std::string_view text; + std::vector> stack; + std::vector stackIsNamespace; // parallel to `stack`: 1 = a namespace's braces + std::optional> pending; + std::vector braceMacros; // this file's #defines whose body opens or closes a scope + std::string_view lastWord; + std::size_t askedOffset = 0; + bool pendingNamespace = false; + bool balanced = true; + bool stmtStart = true; + bool scopeMacro = false; + bool usingBefore = false; +}; + +// The WRITTEN half: the qualifier spelled before the declarator's name, read backwards from `nameAt` — `outer::detail::` +// in `int outer::detail::f(`. `absolute` when it starts with `::`; nullopt for a template-qualified one (`S::f`), +// which this reader does not resolve. +struct EditCheckWrittenQualifier +{ + std::vector names; + bool absolute; +}; + +inline std::optional editCheckWrittenQualifier( std::string_view text, std::size_t nameAt ) +{ + // the last non-blank byte strictly before `limit`, or npos + const auto before = [ & ]( std::size_t limit ) { return limit == 0 ? std::string_view::npos : text.find_last_not_of( " \t\r\n", limit - 1 ); }; + EditCheckWrittenQualifier q{}; + for( std::size_t at = nameAt;; ) + { + const std::size_t colon = before( at ); + if( colon == std::string_view::npos || colon == 0 || text.substr( colon - 1, 2 ) != "::" ) + { + return q; + } + const std::size_t last = before( colon - 1 ); + if( last == std::string_view::npos || !namesplit::isIdentChar( text[ last ] ) ) + { + if( last != std::string_view::npos && text[ last ] == '>' ) + { + return std::nullopt; // `S::f`: not resolved here + } + q.absolute = true; // a leading `::` + return q; + } + std::size_t start = last; + while( start > 0 && namesplit::isIdentChar( text[ start - 1 ] ) ) + { + --start; + } + q.names.insert( q.names.begin(), std::string( text.substr( start, last + 1 - start ) ) ); + at = start; + } +} + +// The member's cv/ref qualifiers after its parameter list, up to the body, a ';', an '=' (`= 0`, `= default`), a +// ctor-initializer ':' or a trailing return type: `const`, `volatile`, `&`, `&&`, in order. +inline std::string editCheckMemberQualifiers( std::string_view text, std::size_t afterParams ) +{ + std::string quals; + for( std::size_t i = afterParams; i < text.size(); ++i ) + { + const char c = text[i]; + if( c == '{' || c == ';' || c == '=' || ( c == ':' && text.substr( i, 2 ) != "::" ) || text.substr( i, 2 ) == "->" ) + { + break; + } + if( c == '&' ) + { + quals += '&'; + } + else if( namesplit::isIdentChar( c ) && ( i == 0 || !namesplit::isIdentChar( text[ i - 1 ] ) ) ) + { + const std::size_t end = text.find_first_not_of( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", i ); + const std::string_view w = text.substr( i, ( end == std::string_view::npos ? text.size() : end ) - i ); + quals += ( w == "const" || w == "volatile" ) ? std::string( w ) + " " : std::string(); + } + } + return quals; +} + +// ── one signature's facts ───────────────────────────────────────────────────────────────────────────────────── +// Each parameter's type and how many carry a default; the full scope chain (nullopt = unknown) and the member qualifiers. +// `ok` is false when the text does not hold `name( … )`, the list does not close, it is variadic, it exceeds +// kEditCheckSigCap, or it does not split into exactly `params` entries — and a list that is not ok matches nothing. +// `mayDefault` is whether the parameter text holds an '=' at all: what an unparsed declaration is disclosed on. +struct EditCheckSignature +{ + std::vector> types; + std::optional> chain; + std::string quals; + std::uint16_t defaulted; + bool ok; + bool mayDefault; + // The DISCLOSE sink: a signature that cannot be read is not a signature without defaults. mayDefault is what the + // answer's defaults_untied= counts, so an unread declaration is disclosed beside the flags it might admit. + enum class DisclosureWhy : std::uint8_t + { + UnreadableFile, // the file could not be opened, or its read stopped on an error + }; + void disclose( DisclosureWhy ) noexcept // one reason: unproven, and it may carry a default + { + ok = false; + mayDefault = true; + } +}; + +inline EditCheckSignature editCheckSignatureOf( EditCheckSources& sources, const Symbol& s ) +{ + EditCheckSignature sig{}; + const EditCheckFileText& source = sources.text( s.fileId ); + const std::string& file = source.body; + if( !source.whole ) + { + DISCLOSE( sig, EditCheckSignature::DisclosureWhy::UnreadableFile, "edit-check: a C/C++ source could not be read whole — its signature is unproven and counted by defaults_untied=" ); + return sig; + } + const std::uint32_t end = std::min( isDefinitionNotDeclaration( s ) ? s.sigEndByte : s.endByte, std::uint32_t( file.size() ) ); + if( end <= s.sigStartByte || end - s.sigStartByte > kEditCheckSigCap ) + { + sig.mayDefault = end > s.sigStartByte; // past the cap: unread, so it may carry a default + return sig; + } + const std::string_view text = std::string_view( file ).substr( s.sigStartByte, end - s.sigStartByte ); + const auto [ nameAt, open ] = editCheckParamOpen( text, s.name ); + const EditCheckRawParams raw = ( open == std::string_view::npos ) ? EditCheckRawParams{} : editCheckSplitParams( text, open ); + sig.mayDefault = open != std::string_view::npos && text.find( '=', open ) != std::string_view::npos; + if( !raw.closed ) + { + return sig; + } + for( std::size_t k = 0; k < raw.text.size(); ++k ) + { + const std::string_view param = raw.defaulted[k] ? raw.text[k].substr( 0, raw.text[k].find( '=' ) ) : raw.text[k]; + if( param.find( "..." ) != std::string_view::npos ) + { + return sig; // variadic: never a fixed list + } + if( param.find( '(' ) != std::string_view::npos ) + { + return sig; // a function-pointer, function-type or decltype parameter: its declarator name is not stripped, + // so the list is unparsed (Unproven, disclosed on mayDefault), never compared as types + } + sig.defaulted += raw.defaulted[k] ? 1u : 0u; + sig.types.push_back( editCheckTypeTokens( param ) ); + } + if( sig.types.size() == 1 && ( sig.types[0].empty() || sig.types[0] == std::vector{ "void" } ) ) + { + sig.types.clear(); // `()` and `(void)`: no parameter + } + sig.ok = sig.types.size() == s.params; + sig.quals = editCheckMemberQualifiers( file, s.sigStartByte + raw.closeAt + 1 ); + + const std::size_t nameInFile = s.sigStartByte + nameAt; + const std::optional written = editCheckWrittenQualifier( file, nameInFile ); + EditCheckBraceLexer lexer( file ); + std::optional> chain = lexer.chainAt( nameInFile ); // also proves the file's braces readable + // a qualified, non-absolute declarator after `using namespace X;` may name X's member: its chain is not certain + const bool usingAmbiguous = written && !written->absolute && !written->names.empty() && lexer.usingDirectiveBefore(); + chain = ( written && written->absolute && chain ) ? std::optional>( std::vector{} ) : chain; + if( written && chain && !usingAmbiguous ) + { + chain->insert( chain->end(), written->names.begin(), written->names.end() ); + sig.chain = std::move( chain ); + } + return sig; +} + +// ── the tie ─────────────────────────────────────────────────────────────────────────────────────────────────── +enum class EditCheckTie : std::uint8_t +{ + Declares, // D declares F: the identity rule held + Different, // provably another function (another name, chain, qualifier, parameter-type list, or linkage) + Unproven, // may declare F, and could not be proven to: an unknown chain, an unparsed list, an unproven #include +}; + +struct EditCheckTieResult +{ + EditCheckTie tie; + std::uint16_t defaulted; // the declaration's default count, on Declares + bool mayDefault; // on Unproven: the declaration may carry a default (what defaults_untied= counts) +}; + +// THE IDENTITY RULE, for one (declaration, definition) pair. `defSig` is the definition's own signature, read once. +inline EditCheckTieResult editCheckTieDeclaration( const IngestResult& ing, EditCheckSources& sources, NodeId decl, NodeId def, + const EditCheckSignature& defSig ) +{ + const Symbol& d = ing.symbols[ decl ]; + const Symbol& f = ing.symbols[ def ]; + const bool candidate = d.name == f.name && d.scope == f.scope && langCompatible( d.lang, Lang::C ) && langCompatible( f.lang, Lang::C ) + && !isDefinitionNotDeclaration( d ) && isDefinitionNotDeclaration( f ) && d.params == f.params + && ( f.internalLinkage == 0 || d.fileId == f.fileId ); + if( !candidate ) + { + return { EditCheckTie::Different, 0, false }; + } + const EditCheckSignature declSig = editCheckSignatureOf( sources, d ); + if( defSig.ok && declSig.ok + && ( declSig.types != defSig.types || declSig.quals != defSig.quals || ( declSig.chain && defSig.chain && *declSig.chain != *defSig.chain ) ) ) + { + return { EditCheckTie::Different, 0, false }; + } + const bool proven = defSig.ok && declSig.ok && declSig.chain && defSig.chain + && ( d.fileId == f.fileId || includeProofOfDeclFiles( ing, { decl }, { def } )[ f.fileId ] != 0 ); + if( !proven ) + { + return { EditCheckTie::Unproven, 0, declSig.ok ? declSig.defaulted > 0 : declSig.mayDefault }; + } + return { EditCheckTie::Declares, declSig.defaulted, false }; +} + +// Per member of the overload set, the FEWEST arguments a call may pass: `params` minus the defaults of the declaration +// that declares it and carries the most of them (C++ lets a redeclaration add defaults; the widest reading is the +// one-sided one). `fromDecl` is true when any member's range was widened that way — the root's defaults_from="decl". +// `untied` counts the Unproven declarations that may carry a default: the root's defaults_untied=. +struct EditCheckDeclDefaults +{ + std::vector minArity; // parallel to the overload set + std::uint32_t untied; + bool fromDecl; +}; + +// One definition's minimum; its untied declarations are appended to `untied` (an overload set may meet one declaration +// from several members, so the caller counts distinct ids). Only a fixed-arity C-family DEFINITION can be widened — +// anything else is already a wildcard (arityExact 0) or has no declaration to read. +inline std::uint16_t editCheckMinArity( const IngestResult& ing, EditCheckSources& sources, NodeId def, std::vector& untied ) +{ + const Symbol& f = ing.symbols[ def ]; + if( !langCompatible( f.lang, Lang::C ) || f.arityExact == 0 || !isDefinitionNotDeclaration( f ) ) + { + return f.params; + } + const EditCheckSignature defSig = editCheckSignatureOf( sources, f ); + std::uint16_t minArity = f.params; + for( NodeId cand = 0; cand < ing.symbols.size(); ++cand ) + { + if( ing.symbols[ cand ].name != f.name || cand == def ) + { + continue; + } + const EditCheckTieResult tie = editCheckTieDeclaration( ing, sources, cand, def, defSig ); + if( tie.tie == EditCheckTie::Declares ) + { + minArity = std::min( minArity, std::uint16_t( f.params - std::min( tie.defaulted, f.params ) ) ); + } + if( tie.tie == EditCheckTie::Unproven && tie.mayDefault ) + { + untied.push_back( cand ); + } + } + return minArity; +} + +inline EditCheckDeclDefaults editCheckDeclDefaults( const IngestResult& ing, std::span overloadNodes, const EditCheckSpliced& spliced ) +{ + EditCheckDeclDefaults res{}; + EditCheckSources sources( ing, spliced ); + res.minArity.reserve( overloadNodes.size() ); + std::vector untiedDecls; // one declaration two overloads both meet is ONE untied declaration + for( NodeId ov : overloadNodes ) + { + const std::uint16_t minArity = editCheckMinArity( ing, sources, ov, untiedDecls ); + res.minArity.push_back( minArity ); + res.fromDecl = res.fromDecl || minArity < ing.symbols[ ov ].params; + } + std::sort( untiedDecls.begin(), untiedDecls.end() ); + res.untied = std::uint32_t( std::unique( untiedDecls.begin(), untiedDecls.end() ) - untiedDecls.begin() ); + ENSURES( res.minArity.size() == overloadNodes.size() ); + return res; +} + +} // namespace rw diff --git a/src/editpreview.h b/src/editpreview.h index 9178905bb..229892885 100644 --- a/src/editpreview.h +++ b/src/editpreview.h @@ -458,7 +458,8 @@ inline Outcome run( const IngestResult& ing, const Graph& g, const std::string& // differently from the answer it predicts would be worth nothing (test/editpreviewcheck.sh compares // the two documents). oc.xml = editCheckBundleText( merged, mg, root, maxFileBytes, excludes, groups[0].lowestNode, ni, true, - pageLimit, pageOffset, previewUnprovenDefs ); + pageLimit, pageOffset, previewUnprovenDefs, /*notesDegraded=*/false, + EditCheckSpliced{ newBytes, fsym.fileId, true } ); // the merged spans index the spliced bytes // E3 (terminality round A, 2026-09-05): the CURRENT span an apply would replace, as the bytes are on disk, so // the Read an agent makes before an edit "to see what I am about to overwrite" is already in the preview. // Appended as the last child of the preview's own root — the post-hoc document cannot carry it (after the diff --git a/src/graph.h b/src/graph.h index 0ddf44071..fabc601c4 100644 --- a/src/graph.h +++ b/src/graph.h @@ -4897,6 +4897,20 @@ inline bool filePathContainsRootRel( const IngestResult& ing, std::uint32_t file return !tail.empty() && filePathContains( rel, tail ); } +// The one indexed file a cwd-spelled path names EXACTLY, or kNoFile — see preferExactFile for the rule and its scope. +inline std::uint32_t exactRootRelFile( const IngestResult& ing, std::string_view needle ) noexcept +{ + const std::string_view tail = needle.empty() ? std::string_view{} : selectorRootTail( ing, needle ); + for( std::uint32_t fileId = 0; !tail.empty() && fileId < std::uint32_t( ing.files.size() ); ++fileId ) + { + if( rootRelPath( ing, fileId ) == tail ) + { + return fileId; + } + } + return kNoFile; +} + // shared "name" | "file:name" spec splitter (X9(b)) — the ONE disambiguation rule --around/--lego/ // --edit-check (via resolveFocus, single lowest-id pick) and --callers/--impact (via // resolveAllByNameQualified, every match) now both route through, so a same-named-across-files symbol @@ -5002,6 +5016,14 @@ inline AtSeed resolveAtSeed( const IngestResult& ing, std::string_view spec ) r.fileMatches.push_back( fileId ); } } + if( r.fileMatches.size() > 1 ) + { // the exact-file preference (preferExactFile): a cwd-spelled path naming one indexed file exactly is that file + const std::uint32_t exact = exactRootRelFile( ing, r.fileHalf ); + if( std::find( r.fileMatches.begin(), r.fileMatches.end(), exact ) != r.fileMatches.end() ) + { + r.fileMatches.assign( 1, exact ); + } + } if( r.fileMatches.empty() ) { r.fault = AtFault::FileUnmatched; return r; } if( r.fileMatches.size() > 1 ) { r.fault = AtFault::FileAmbiguous; return r; } r.fileId = r.fileMatches[0]; @@ -5749,6 +5771,46 @@ inline void declToDefFollowThrough( const IngestResult& ing, std::string_view fi std::sort( sel.begin(), sel.end() ); // NodeId order - the contract every caller of this already relies on } +// THE DOTTED SCOPE TIER (2026-10-01). Agents and docs write a method as `Class.method` (Python, JS, Java, the +// comparison tables) or `Class#method` (Ruby, JSDoc), and every SYM verb answered "symbol not found" for a method the +// Scope::name tier resolves at once. Probed LAST, only when every other tier matched nothing, and only for a spec with +// no ':' (a file:name or Scope::name spelling is never re-read) and no '/' (a path): each '.' and '#' becomes "::" and +// the Scope::name tier answers with exactly its own match (so a dotted spelling resolves precisely where its `::` +// spelling does, and a namespace-qualified one does not, as `ns::Class::method` does not). Purely additive: a spec that resolved before resolves identically, because this runs only on an empty result. +// Several matches are returned as they are (the verbs' defs= discloses a union; --edit-check refuses and lists them). +// `found` is what the earlier tiers matched, returned untouched unless it is empty. +inline std::vector resolveAllByDottedScope( const IngestResult& ing, std::string_view spec, std::vector found ) +{ + const bool dotted = spec.find_first_of( ".#" ) != std::string_view::npos && spec.find_first_of( ":/" ) == std::string_view::npos; + if( !found.empty() || !dotted ) + { + return found; + } + std::string scoped; + for( const char c : spec ) + { + scoped += ( c == '.' || c == '#' ) ? std::string_view( "::" ) : std::string_view( &c, 1 ); + } + return resolveAllByScopeQualified( ing, scoped ); +} + +// THE EXACT-FILE PREFERENCE (2026-10-01). A file half is a path SUBSTRING, so `./lib.cpp:sc` also named +// `./sub/lib.cpp`, and no spelling could pick the root file alone — the --edit-check refusal printed a handle that was +// refused again. A file half spelled from the cwd (the root as typed, then the path — the form the tool prints) that +// equals one indexed file's root-relative path EXACTLY names that file: when it holds a match, the matches in other files +// are dropped. A bare `lib.cpp` (no root prefix) keeps the substring reading, and so does a cwd-spelled path whose exact +// file holds no match, so a spelling that resolved before resolves the same unless it named one file exactly. +inline std::vector preferExactFile( const IngestResult& ing, std::string_view file, std::vector found ) +{ + const std::uint32_t exact = exactRootRelFile( ing, file ); + const auto inExact = [ & ]( NodeId id ) { return ing.symbols[ id ].fileId == exact; }; + if( std::any_of( found.begin(), found.end(), inExact ) && !std::all_of( found.begin(), found.end(), inExact ) ) + { + found.erase( std::remove_if( found.begin(), found.end(), [ & ]( NodeId id ) { return !inExact( id ); } ), found.end() ); + } + return found; +} + // `unprovenDefCountOut` (H1, optional): the residue declToDefFollowThrough dropped — see its contract. Zero // on every path that never reaches the widening (an @FILE:LINE seed, a canonical id, a Scope::name tier, a // bare name), so a reader never has to ask whether the number is stale. @@ -5798,11 +5860,12 @@ inline std::vector resolveAllByNameQualified( const IngestResult& ing, s out.push_back( s.id ); } } + out = preferExactFile( ing, file, std::move( out ) ); // `./lib.cpp` names lib.cpp, not also sub/lib.cpp // #63: a header-qualified selector resolves to DECLARATIONS, which carry no call-graph edges. // Widen to the definitions they stand for. Full contract and its limits: declToDefFollowThrough above. declToDefFollowThrough( ing, file, name, out, unprovenDefCountOut ); - return out; + return resolveAllByDottedScope( ing, spec, std::move( out ) ); // `Class.method` / `Class#method`, only when `out` is empty } // ─── MEMBER VARIABLES: `Owner.field` selection + per-site use resolution (the member-variable round, card A3) ─ diff --git a/src/htmlexport.h b/src/htmlexport.h index 125dee51d..d7e3d2ced 100644 --- a/src/htmlexport.h +++ b/src/htmlexport.h @@ -2468,7 +2468,8 @@ inline void writeDocumentShell( std::FILE* out, const std::string& pageTitle ) // members, sorted (member count desc, id asc) — mirrors --communities' "a lone symbol is not a // module" rule so the wiki and the text verb agree. inline void writeHtml( std::FILE* out, const IngestResult& ing, const std::vector& rank, const Graph& g, int topK, const HtmlColorExtras& color, - std::string_view rootArg = {} ) // R-R: the root FILES[] entries are relative to + std::string_view rootArg = {}, // R-R: the root FILES[] entries are relative to + bool codeFirstRows = false ) // the default map's code-first node pick (serialize.h codeFirstKeep) { const std::vector& outOff = g.outOff; const std::vector& outTargets = g.outTargets; @@ -2494,6 +2495,10 @@ inline void writeHtml( std::FILE* out, const IngestResult& ing, const std::vecto if( rank[a] != rank[b] ) { return rank[a] > rank[b]; } return a < b; } ); + if( codeFirstRows ) + { + codeFirstKeep( ing, order, cap ); // the same pick the XML/JSON map makes over its kept rows (the page has no disclosure root) + } order.resize( cap ); // map original symbol id → selected-array index (kNoNode if not selected) diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 6b0a3d2f4..d6ffb65a8 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -296,7 +296,12 @@ constexpr std::uint32_t kCacheVersion = 27; // 27: same record layout // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 129; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 132; // bump on any grammar/.scm/extraction change + // 132 = 2026-10-02 (train 23): cache-key hygiene, not an extraction change. Every + // branch build of this train's work ran at 129, and unmerged branch builds already + // ran at 130 and 131 with different extraction; 132 is above all of them, so no + // cache one of those builds wrote is ever read as this build's. Same layout: + // kCacheVersion stays 27, kQSnapCacheScheme stays 16. // 129 = 2026-09-30 (train 22): ONE bump past fn-literal's 128 for the two community // PRs that merge after it — #338 (RSpec described_class, its 125 note below) and #325 // (Ruby inheritance edges, its note below). Each note keeps the number its PR carried. diff --git a/src/main.cpp b/src/main.cpp index a7c198832..66f0d609c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1658,6 +1658,13 @@ int runDefaultMap( const MainDispatch& d ) } 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. @@ -1860,6 +1867,7 @@ int runDefaultMap( const MainDispatch& d ) 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.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) // 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). @@ -2122,7 +2130,7 @@ int runDefaultMap( const MainDispatch& d ) htmlColor.atStamp = htmlProv.atStamp; htmlColor.rootName = htmlProv.rootName; htmlColor.version = kRipwireVersion; - writeHtml( htmlOut, ing, rank, g, mapTopK, htmlColor, mapRootArg ); // R-R + writeHtml( htmlOut, ing, rank, g, mapTopK, htmlColor, mapRootArg, /*codeFirstRows=*/isDefaultMapScope ); // R-R if( htmlOut != stdout ) { std::fclose( htmlOut ); diff --git a/src/mcp.h b/src/mcp.h index 68c52470e..b21879afd 100644 --- a/src/mcp.h +++ b/src/mcp.h @@ -1364,7 +1364,7 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo + mcprefuse::toolMetadataFor( "edit_check", pathIsRequired ) + "}," // The cross-branch + dark-content verbs. Read-only git plumbing, no index // coupling for the first two (they read OTHER refs' blobs, which the index never ingested). - + mcprefuse::gitOnlyStanza( omitGitVerbs, "{\"name\":\"whereis\",\"description\":\"WHERE DOES THIS CONTENT LIVE? Which branch's tree defines or mentions a symbol, HEAD first, with on-head=0 naming the case this verb exists for: content that lives only on a branch (a finished fix stranded on 1 of 30 refs). Each distinct blob is read once (content-addressed), so N branches cost about one tree. kind=def on a REF row is a LEXICAL heuristic (ref blobs are raw text, never ingested) — for HEAD's parsed answer use find_symbol/fetch_body. symbol = the name, or an @FILE:LINE seed; kind = optional ref-name substring filter, echoed as filter=; limit/offset page the hits (first 60). at= is HEAD's sha, always sha-only. Single-root; read-only.\"," + + mcprefuse::gitOnlyStanza( omitGitVerbs, "{\"name\":\"whereis\",\"description\":\"WHERE DOES THIS CONTENT LIVE? Which branch's tree defines or mentions a symbol, HEAD first, with on-head=0 naming the case this verb exists for: content that lives only on a branch (a finished fix stranded on 1 of 30 refs). Each distinct blob is read once (content-addressed), so N branches cost about one tree. kind=def on a REF row is a LEXICAL heuristic (ref blobs are raw text, never ingested) — for HEAD's parsed answer use find_symbol/fetch_body. symbol = the name, or an @FILE:LINE seed; kind = optional ref-name substring filter, echoed as filter=; limit/offset page the hits (first 60). at= is HEAD's sha; +dirty = changed paths read from the working tree (worktree=). Single-root; read-only.\"," + mcprefuse::toolMetadataFor( "whereis", pathIsRequired ) + "}," ) + mcprefuse::gitOnlyStanza( omitGitVerbs, "{\"name\":\"stray_content\",\"description\":\"Per branch: the lines its own divergent work AUTHORED (vs its merge-base with HEAD) that the live line does NOT have. Four verdicts (unmerged+superseded+merged+unknown=refs): v=unmerged is genuinely absent; v=superseded means the live line re-implemented the work — the case `git cherry` structurally cannot see; merged branches are omitted and counted; v=unknown is a branch this scan could NOT analyse at all (no merge-base, unrelated history), not a fourth kind of divergence. Every file row carries its raw del/redone/sim evidence. Line-granular, not semantic. kind = optional ref-name substring filter, echoed as filter=; limit/offset page the refs. Single-root; read-only.\"," + mcprefuse::toolMetadataFor( "stray_content", pathIsRequired ) + "}," ) + @@ -1693,6 +1693,24 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo { return mcprefuse::notFound( getIndex( path ).ing, "symbol", spelling, mcprefuse::notFoundHintFor( name, "symbol" ) ); }; const auto notFoundKind = [ & ]( std::string_view noun, std::string_view spelling ) -> std::string { return mcprefuse::notFound( getIndex( path ).ing, noun, spelling ); }; + // Fix list #2 (2026-10-01), the CLI twin's parity: a not-found keeps its -32602 refusal and ALSO carries the + // answer document the CLI prints on stdout (selectorrefuse.h writeNotFoundAnswer) in error.data.answer, with a + // working-tree rename offered first in both the message and the document. `msg` is the verb's own refusal + // sentence; the rename clause goes right after its quoted echo. The document's echo is cappedEcho'd like the + // message's, so a 400 KB selector cannot mint a 400 KB frame (mcpw3fixcheck NIT [symbol]). + const auto notFoundAnswered = [ & ]( std::string msg, NotFoundAnswer answer, bool json ) -> std::string + { + msg = withRenameClause( std::move( msg ), answer.near, answer.missing ); // the batch arm's rule too + std::string doc = captureXml( [ & ]( std::FILE* f ) { writeNotFoundAnswer( f, answer, json ); } ); + while( !doc.empty() && doc.back() == '\n' ) { doc.pop_back(); } + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"error\":{\"code\":-32602,\"message\":\"" + + mcpdetail::jsonEscape( msg + memoryStopSuffix( msg ) ) + "\",\"data\":{\"answer\":\"" + mcpdetail::jsonEscape( doc ) + "\"}}}"; + }; + const auto notFoundNearOf = [ & ]( std::string_view spelling ) -> NotFoundNear + { + const IngestResult& nIng = getIndex( path ).ing; + return notFoundNear( nIng, spelling, nIng.realPaths.empty() ? path : std::string() ); + }; // success envelope for an edit verb: the JSON payload as text content + the fresh _index stamp // (the index was just invalidated, so indexStamp() rebuilds and reports the NEW post-edit state). @@ -2147,7 +2165,11 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo resp = pagedResult( [ & ]( McpPageArgs pg ) { const std::string j = symbolQueryJson( path, symbol, name == "find_referencing_symbols", pg ); - return j.empty() ? errResultMsg( -32602, notFoundSym( symbol ) ) : textResult( j ); + // Review M5: both twins answer a miss — find_referencing_symbols is --callers, find_symbol --callees. + return j.empty() ? notFoundAnswered( notFoundSym( symbol ), + NotFoundAnswer{ name == "find_referencing_symbols" ? "callers" : "callees", + { { "of", mcprefuse::cappedEcho( symbol ) } }, {}, notFoundNearOf( symbol ) }, true ) + : textResult( j ); } ); } else if( name == "grep" && !path.empty() && !pattern.empty() ) @@ -2386,7 +2408,8 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo { return errResult( -32603, "internal error: the impact answer buffer lost bytes — no answer served" ); } - return answer->empty() ? errResultMsg( -32602, notFoundSym( symbol ) ) : textResult( *answer ); + return answer->empty() ? notFoundAnswered( notFoundSym( symbol ), NotFoundAnswer{ "impact", { { "of", mcprefuse::cappedEcho( symbol ) } }, {}, notFoundNearOf( symbol ) }, false ) + : textResult( *answer ); } ); } else if( name == "uses" && !path.empty() && !symbol.empty() ) @@ -2402,7 +2425,9 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo return answer ? textResult( *answer ) // count="0" stays a valid answer : errResult( -32603, "internal error: the uses answer buffer lost bytes — no answer served" ); } ) - : errResultMsg( -32602, refusal ); + : usesRefusalIsNotFound( refusal ) + ? notFoundAnswered( refusal, NotFoundAnswer{ "uses", { { "of", mcprefuse::cappedEcho( symbol ) } }, {}, notFoundNearOf( symbol ) }, false ) + : errResultMsg( -32602, refusal ); // review M5: a not-found answers like --uses } else if( name == "affected" && !path.empty() && !files.empty() ) { @@ -2440,8 +2465,17 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo else if( name == "path_between" && !path.empty() && !from.empty() && !to.empty() ) { const std::optional answer = pathText( path, from, to ); + const auto pathNotFound = [ & ] + { + const IngestResult& pIng = getIndex( path ).ing; + const bool fromBad = resolveAllByNameQualified( pIng, from ).empty(); + const bool toBad = resolveAllByNameQualified( pIng, to ).empty(); + return notFoundAnswered( pathEndpointRefusal( pIng, from, to ), + NotFoundAnswer{ "path", { { "from", mcprefuse::cappedEcho( from ) }, { "to", mcprefuse::cappedEcho( to ) } }, + fromBad && toBad ? "both" : fromBad ? "from" : "to", notFoundNearOf( fromBad ? from : to ) }, false ); + }; resp = !answer ? errResult( -32603, "internal error: the path_between answer buffer lost bytes — no answer served" ) - : answer->empty() ? errResultMsg( -32602, pathEndpointRefusal( getIndex( path ).ing, from, to ) ) + : answer->empty() ? pathNotFound() : textResult( *answer ); } // `connect` — symbols as a JSON string array (the schema form) or a comma-string (lenient); diff --git a/src/mcpindex.h b/src/mcpindex.h index e319881e5..1f0cea6d0 100644 --- a/src/mcpindex.h +++ b/src/mcpindex.h @@ -500,6 +500,8 @@ struct McpIndex // pr_iters= / pr_converged= on every ranked MCP payload. // Held beside the vector it describes so a verb cannot // serve one without the other (src/prconverge.h). + bool isCleanWorkingSet = false; // no uncommitted change: `rank` is the plain uniform one, + // the default map's question — analyze picks code-first std::vector fileMtime; // parallel to ing.files std::vector fileSize; // parallel to ing.files: st_size at index build (staleness fast-path discriminator, // free from the same stat() as mtime — a size change is caught without a read). @@ -1195,6 +1197,9 @@ inline const McpIndex& getIndex( const std::string& root ) } } ix.workingSetHash = workingSetHashOf( changed ); + // The map scope (docs/EVALS.md "Map data Sections never crowd code out of the default map"): a CLEAN working set is + // the default map's own question, so analyze then picks its rows code-first like the CLI map (serialize.h codeFirstKeep). + ix.isCleanWorkingSet = std::none_of( changed.begin(), changed.end(), []( char c ) { return c != 0; } ); const auto [ wsRank, wsIters, wsConverged ] = rankGraphTeleport( ix.g, diffTeleport( ix.ing, changed ) ); ix.rank = wsRank; ix.prDisclosure = RankDisclosure{ wsIters, wsConverged, true }; // W2-F: a teleport variant is still a power iteration diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 27c66da36..cbf7c2ff9 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -447,7 +447,7 @@ inline std::string analyzeToString( const std::string& root, int topK, bool stab // W2-F: the map's convergence disclosure. The CLI map carries pr_iters= and // this one must too — "the clause landed at 3 of its 5 echo sites" is the // §B4 family, and mcpclidiffcheck is the gate that keeps the two surfaces one. - /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure }, + /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure, .codeFirstRows = ix.isCleanWorkingSet }, /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls ); } ); } @@ -514,7 +514,7 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i ix.g.bindLabel.empty() ? nullptr : &ix.g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, /*extraPayloadTokens=*/0, - /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure }, + /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure, .codeFirstRows = rankByLabel == nullptr }, // pagerank: the map scope's code-first pick /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls ); } ); } @@ -579,7 +579,14 @@ inline std::string whereisText( const std::string& root, const std::string& symb res.seedSpec = std::move( seedSpec ); // The tree zero stays an answer; the near-miss only says WHICH zero it is. Computed only on the zero, // so a real hit list costs nothing and is byte-identical to before. - if( res.hits.empty() ) + // Review M3/M7, the CLI twin's rule: the dotted note only for a method the index defines; on a zero over a dirty + // checkout the working tree's rename ahead of (instead of) a spelling neighbour. + res.dottedRetry = crossref::whereisDottedRetryOf( getIndex( root ).ing, sel ); + if( res.hits.empty() && res.worktree != crossref::WorktreeOverlay::Clean ) + { + res.renamedTo = crossref::worktreeRenameOf( getIndex( root ).ing, sel, root ); + } + if( res.hits.empty() && res.renamedTo.empty() ) { res.nearMiss = didYouMean( getIndex( root ).ing, sel ); } @@ -2828,6 +2835,15 @@ inline std::string qualifiedColonSelectorRefusal( const IngestResult& ing, const + "` for the narrowed answer"; } +// Review M5/M6: which usesSelectorRefusal sentences are a NOT-FOUND (no indexed definition, no indexed reference; or a +// file:name / Scope::name spelling that names nothing) rather than a different refusal (several field owners, an +// unserved language, a resolving "::" spelling the verb cannot narrow). Keyed on the shared sentence opener +// mcprefuse::notFound writes, so the two cannot drift. +inline bool usesRefusalIsNotFound( std::string_view refusal ) +{ + return refusal.rfind( "symbol not found: ", 0 ) == 0; +} + inline std::string usesSelectorRefusal( const IngestResult& ing, const std::string& symbol ) { if( !symbol.empty() && symbol.front() == '@' ) @@ -4244,7 +4260,8 @@ inline EditCheckReply editCheckText( const std::string& root, const std::string& mcprefuse::notFoundHintFor( "edit_check", "symbol" ) ) }; } - const std::vector groups = editCheckGroups( ing, g, matches ); + // the CLI's rule: the declaration/definition fold for the post-hoc answer, one group per (file, scope) for new_body + const std::vector groups = editCheckGroups( ing, g, matches, /*foldDecls=*/newBody.empty() ); if( groups.size() > 1 ) { return EditCheckReply { {}, editCheckAmbiguousMessage( symbol, groups, "symbol=", matches.size() ) }; @@ -5189,8 +5206,19 @@ inline BatchSub runBatchSub( const std::string& root, const std::string& obj, in // §B6 M8 + verifier N7: the not-found refusals echo the spelling, carry a near-miss AND carry the verb's // trailing guidance clause — this arm dropped that clause on three verbs while the live arm kept it (one // condition, two lengths). All three halves now come from mcprefusal.h, keyed by the verb. + // Review M6: a batch item's not-found names the working tree's rename first, by the live arm's own insertion rule + // (selectorrefuse.h withRenameClause) — the batch err= served the very did-you-mean fix list #2 removed. + const auto renameNearOf = [ & ]( std::string_view spelling ) -> NotFoundNear + { + const IngestResult& bIng = getIndex( root ).ing; + return notFoundNear( bIng, spelling, bIng.realPaths.empty() ? root : std::string() ); + }; const auto symbolMissing = [ & ]( std::string_view verb, std::string_view spelling ) -> std::string - { return mcprefuse::notFound( getIndex( root ).ing, "symbol", spelling, mcprefuse::notFoundHintFor( verb, "symbol" ) ); }; + { + std::string msg = mcprefuse::notFound( getIndex( root ).ing, "symbol", spelling, mcprefuse::notFoundHintFor( verb, "symbol" ) ); + const bool answering = verb == "find_symbol" || verb == "find_referencing_symbols" || verb == "impact"; + return answering ? withRenameClause( std::move( msg ), renameNearOf( spelling ) ) : msg; + }; // Verifier N2/N8: the paging window, validated ONCE for the two sub-verbs that consume it. const McpPageParse pageParse = mcpPageArgs( obj ); @@ -5316,7 +5344,7 @@ inline BatchSub runBatchSub( const std::string& root, const std::string& obj, in // V2-1: refuse a qualified spelling whose bare name IS defined (shared guard, see usesSelectorRefusal). if( const std::string refusal = usesSelectorRefusal( getIndex( root ).ing, symbol ); !refusal.empty() ) { - return bad( refusal ); + return bad( usesRefusalIsNotFound( refusal ) ? withRenameClause( refusal, renameNearOf( symbol ) ) : refusal ); } std::optional usesAnswer = usesText( root, symbol, pageParse.page ); // LB-G: the batch arm honors the SAME window (the impact precedent) if( !usesAnswer ) @@ -5408,7 +5436,9 @@ inline BatchSub runBatchSub( const std::string& root, const std::string& obj, in r.payload = std::move( *pathAnswer ); if( r.payload.empty() ) { - return bad( pathEndpointRefusal( getIndex( root ).ing, from, to ) ); + const IngestResult& pIng = getIndex( root ).ing; + const bool fromBad = resolveAllByNameQualified( pIng, from ).empty(); + return bad( withRenameClause( pathEndpointRefusal( pIng, from, to ), renameNearOf( fromBad ? from : to ), fromBad ? "from" : "to" ) ); } } else if( r.verb == "exemplar" ) diff --git a/src/quality.h b/src/quality.h index 6643e5214..ae6bf6bfe 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2179,7 +2179,9 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 27; // MUST equal ingest.cpp's kCacheVersion (gated); 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) -constexpr std::uint32_t kIngestParserVerMirror = 129; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 132; // MUST equal ingest.cpp's kParserVer (gated) + // 132 = 2026-10-02 (train 23: cache-key hygiene above every branch + // build's number, see kParserVer note; kIngestCacheVersionMirror stays 27) // 129 = 2026-09-30 (train 22: #338 + #325 after fn-literal's 128, see kParserVer // note; kIngestCacheVersionMirror stays 27) // 128 = 2026-09-29 (fn-literal-bodies, see kParserVer note) diff --git a/src/selectorrefuse.h b/src/selectorrefuse.h index e0a78011d..e15f139e6 100644 --- a/src/selectorrefuse.h +++ b/src/selectorrefuse.h @@ -28,6 +28,11 @@ #include "graph.h" // splitQualifiedSpec / resolveAllByName — the SAME grammar the callers resolve with #include "didyoumean.h" // §P12.1: the near-miss suggester, for the "the name is wrong too" fallback #include "degradedscan.h" // degradedTextHit — the ONE degraded-parse text scan (mcprefusal.h words the same facts for MCP) +#include "crossref.h" // worktreeRenameOf — the not-found answer offers the working tree's rename first +#include "serialize.h" // escapeXml / jsonStr — the not-found answer document + +#include +#include #include #include @@ -221,4 +226,110 @@ inline std::string selectorNotFoundMessage( const IngestResult& ing, std::string return std::move( prefix ) + std::string( spec ) + selectorFaultClause( ing, spec, retryForm ); } +// ── the NOT-FOUND ANSWER (2026-10-01, the comparison table's fix list #2) ───────────────────────────────────── +// +// A selector that matches no indexed definition is a REFUSAL, exit 1 (README §6.2; docs/COMMANDS.md --callers), and +// that contract stays. But a refusal used to print NOTHING on stdout, so an agent that reads stdout got no answer at +// all: on the comparison table a correct "that name is gone" (a rename in the working tree) scored as an ERROR. The +// refusal now ALSO prints a one-element answer document — the verb's own root, the selector echoed in the verb's own +// attribute names, found="0", and the name to retry with — under its own legend. stderr is unchanged except that a +// working-tree rename, when there is one, is offered FIRST: spelling-distance ranks a token swap (line_trim → +// trim_line) far below an unrelated near-spelling, and the working tree holds better evidence than spelling. + +// The name to offer: the working tree's rename of the selector's NAME half first, else the spelling near-miss. +struct NotFoundNear +{ + std::string name; + bool renamed = false; // a changed file's HEAD copy holds the selector's name and not this one +}; + +inline NotFoundNear notFoundNear( const IngestResult& ing, std::string_view spec, const std::string& gitRoot ) +{ + if( spec.empty() || spec.front() == '@' ) + { + return {}; // a line seed is diagnosed by its own fault sentences; it has no name to rename + } + std::string_view file, name; + splitQualifiedSpec( spec, file, name ); + if( !gitRoot.empty() ) + { + std::string renamed = crossref::worktreeRenameOf( ing, name, gitRoot ); + if( !renamed.empty() ) + { + return { std::move( renamed ), true }; + } + } + std::string near = didYouMean( ing, name ); + return near == name ? NotFoundNear{} : NotFoundNear{ std::move( near ), false }; +} + +// The clause that puts a working-tree rename ahead of the shared fault clause on stderr ("" when there is none). +inline std::string notFoundRenameClause( const NotFoundNear& near ) +{ + return near.renamed ? " (renamed in the working tree: did you mean '" + near.name + "'?)" : std::string(); +} + +// The same clause inside an MCP refusal sentence, right after the quoted echo of the spelling that missed. Those +// sentences echo as `'X'` (mcprefuse::notFound) or as `from='A'` / `to='B'` (path_between), so `endpoint` ("from", +// "to", "both" or "") picks which echo it follows. One insertion rule for the live arm and the batch arm. +inline std::string withRenameClause( std::string msg, const NotFoundNear& near, std::string_view endpoint = {} ) +{ + if( !near.renamed ) + { + return msg; + } + const std::string anchor = ( endpoint == "from" || endpoint == "to" ) ? std::string( endpoint ) + "='" : std::string( "'" ); + const std::size_t at = msg.find( anchor ); + const std::size_t open = at == std::string::npos ? std::string::npos : at + anchor.size() - 1; + const std::size_t close = open == std::string::npos ? std::string::npos : msg.find( '\'', open + 1 ); + msg.insert( close == std::string::npos ? msg.size() : close + 1, notFoundRenameClause( near ) ); + return msg; +} + +// The answer document a not-found refusal prints on stdout beside its exit 1. `echo` is the selector in the verb's +// own attribute names (of=, or from=/to= for path), `missing` names the endpoint that matched nothing — "from", "to" or +// "both" ("" for a one-selector verb); near= then retries the first endpoint it names. XML under the verb's root tag with its legend comment, or one JSON object under --json. +struct NotFoundAnswer +{ + std::string_view tag; + std::vector> echo; + std::string_view missing; + NotFoundNear near; +}; + +inline void writeNotFoundAnswer( std::FILE* out, const NotFoundAnswer& a, bool json ) +{ + if( json ) + { + std::string body = "{"; + for( const auto& [ key, value ] : a.echo ) + { + body += "\"" + std::string( key ) + "\":\"" + jsonStr( value ) + "\","; + } + body += "\"found\":0"; + if( !a.missing.empty() ) { body += ",\"missing\":\"" + std::string( a.missing ) + "\""; } + if( !a.near.name.empty() ) { body += ",\"near\":\"" + jsonStr( a.near.name ) + "\""; } + if( a.near.renamed ) { body += ",\"near_renamed\":true"; } + rw::emitTo( out, "{}}}\n", body ); + return; + } + std::vector esc; + std::string root = "<" + std::string( a.tag ); + for( const auto& [ key, value ] : a.echo ) + { + root += " " + std::string( key ) + "=\"" + std::string( escapeXml( value, esc ) ) + "\""; + } + root += " found=\"0\""; + if( !a.missing.empty() ) { root += " missing=\"" + std::string( a.missing ) + "\""; } + if( !a.near.name.empty() ) { root += " near=\"" + std::string( escapeXml( a.near.name, esc ) ) + "\""; } + if( a.near.renamed ) { root += " near_renamed=\"1\""; } + rw::emitTo( out, "{}/>", + a.tag, a.missing.empty() ? "" : " missing=from|to|both: the endpoint(s) that matched nothing; near= retries the first.", root ); +} + } // namespace rw diff --git a/src/serialize.h b/src/serialize.h index cd70f164c..5627a9680 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -28,6 +28,7 @@ #include "gitmine.h" // F3 (H2H-Graft): RecentFile — the map's rows are the churn-decay miner's own product #include +#include // std::iota — codeFirstKeep #include #include #include // §H7 degrade seam: std::getenv for the non-release fault switch @@ -1924,8 +1925,92 @@ struct MapAnnotations // pointer for emptiness (main.cpp), so a fully-degraded read still reaches this root. Absent on a clean read, // the L3 inertness contract's only permitted exception. Filled by assignment, like the trailing fields above. bool notesDegraded = false; + + // The map scope (main.cpp's plain map, MCP analyze on a clean working set, MCP rank_by=pagerank): pick the kept rows + // code-first (codeFirstKeep below) and disclose the Sections that pick swapped out — data_sections_cut="N" plus the + // next= that pages them. Every other map keeps the plain rank-order cut, and a map-scope map whose cut swapped + // nothing carries neither attribute (byte-identical). Filled by assignment, like the trailing fields above. + bool codeFirstRows = false; }; +// ── the code-first row pick: data_sections_cut= / next= (docs/EVALS.md "Map data Sections never crowd code out of the +// default map") ───────────────────────────────────────────────────────────────────────────────────────────────────────── +// A data Section (a markdown heading, a YAML/JSON key, a schema column) with no call edge is ranked by its teleport share +// alone, and priorwt's x1.7 specific-name boost fires on data names, so a data file of a few hundred keys outranked called +// code and pushed it out of the top-K the map emits (#339's schema columns: 196 of 200 rows). The rank vector stays as it +// is; the map's ROW PICK changes: while the rank-ordered top-K keeps a Section and leaves a non-Section row out, the +// lowest-ranked kept Section is swapped for the highest-ranked excluded non-Section row. Survivors keep rank order. So +// a Section is shown only when every non-Section row is, and a map whose top-K cut no non-Section row is unchanged. +// The swapped Sections are disclosed: N = the swaps, and next= is the kind(all,sec) graph-query past the M Sections still +// shown, K rows a page. That listing orders Sections by the same rank, so its first N rows are exactly the swapped ones. +struct DataSectionsCut +{ + std::size_t cut = 0; // N: Sections swapped out of the top-K for lower-ranked code rows + std::size_t shown = 0; // M: Section definitions still among the kept rows + std::size_t topK = 0; // K: the rows this map keeps (its effective top-k) +}; +// `order` is the full (rank desc, id asc) order; its first `keep` entries are rewritten to the code-first pick (rank order +// among the survivors) and the rest to the remaining ids in rank order. Returns the disclosure counts. +inline DataSectionsCut codeFirstKeep( const IngestResult& ing, std::vector& order, std::size_t keep ) +{ + EXPECTS( keep <= order.size(), "the kept prefix lies inside the order" ); + const auto isSection = [ & ]( NodeId id ) { return id < ing.symbols.size() && ing.symbols[ id ].kind == SymKind::Section; }; + std::vector keptSections; // positions in `order`, rank order + std::vector excludedCode; // positions in `order`, rank order + for( std::size_t pos = 0; pos < order.size(); ++pos ) + { + if( pos < keep && isSection( order[ pos ] ) ) + { + keptSections.push_back( pos ); + } + else if( pos >= keep && !isSection( order[ pos ] ) ) + { + excludedCode.push_back( pos ); + } + } + DataSectionsCut c; + c.cut = std::min( keptSections.size(), excludedCode.size() ); + c.shown = keptSections.size() - c.cut; + c.topK = keep; + if( c.cut == 0 ) + { + return c; // nothing crowded: the rank-order cut stands, byte-identical + } + std::vector isKept( order.size(), 0 ); + for( std::size_t pos = 0; pos < keep; ++pos ) + { + isKept[ pos ] = 1; + } + for( std::size_t s = 0; s < c.cut; ++s ) + { + isKept[ keptSections[ keptSections.size() - 1 - s ] ] = 0; // the lowest-ranked kept Sections leave + isKept[ excludedCode[ s ] ] = 1; // the highest-ranked excluded code rows come in + } + // the kept positions first, then the rest, each group in rank order (a stable partition of the positions) + std::vector positions( order.size() ); + std::iota( positions.begin(), positions.end(), std::size_t( 0 ) ); + std::stable_partition( positions.begin(), positions.end(), [ & ]( std::size_t pos ) { return isKept[ pos ] != 0; } ); + std::vector picked( order.size() ); + std::transform( positions.begin(), positions.end(), picked.begin(), [ & ]( std::size_t pos ) { return order[ pos ]; } ); + order = std::move( picked ); + return c; +} +inline std::string dataSectionsNext( const DataSectionsCut& c ) +{ + // The registered spelling, offset before limit (so nextverb.h pagedNext, which writes limit first, is not it). + std::string invocation( "--graph-query='kind(all,sec)'" ); + for( const auto& [ flag, value ] : { std::pair{ " --offset=", c.shown }, { " --limit=", c.topK } } ) + { + invocation.append( flag ).append( std::to_string( value ) ); + } + return invocation; +} +// The XML legend clause, charged only to a map that carries the attribute. No double hyphen inside a comment (G4). +inline constexpr std::string_view kDataSectionsCutLegend = + ""; + // F3: the element — rank_by=churn-decay's file-level answer FIRST, paths + age in days at HEAD's clock + // decayed weight — written before the first group so "what changed recently" is answered before the symbol // map, not buried behind it. Absent (byte-free) on every other map and under multi-root. @@ -2606,6 +2691,8 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto } sortutil::radixSortByScoreDescId( order, rank ); } + // The map scope's code-first row pick (MapAnnotations::codeFirstRows): rewrites the kept prefix, returns its disclosure. + const DataSectionsCut dataSecCut = ( ann.codeFirstRows && !stubbed ) ? codeFirstKeep( ing, order, keep ) : DataSectionsCut{}; // bucket the kept symbols by file, files ordered by their best (first-seen) rank. std::vector> buckets( stubbed ? 0 : ing.files.size() ); @@ -2785,6 +2872,10 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { legend += std::string( notes::kNotesDegradedComment ); } + if( dataSecCut.cut > 0 ) + { + legend += kDataSectionsCutLegend; // charged to the map that carries data_sections_cut= + } // W2-F: the pr_iters= / pr_converged= definition, charged to the maps that carry the attributes — empty // for a lexical or HITS ordering, and the prose half only on the map whose iteration stopped short. legend += renderDisclosure( ann.prDisclosure, DiscloseAs::LegendComment ); @@ -2994,6 +3085,12 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto // L3 follow-up (CodeRabbit 4053600616): TRULY last, past every pre-existing attribute — same placement // rule as lens= just above, so no attribute-adjacency assertion in test/ can break on it. if( ann.notesDegraded ) { h += notes::kNotesDegradedAttr; } + // The code-first pick's swaps, last of all (the same placement rule as the two above): absent when nothing was swapped. + if( dataSecCut.cut > 0 ) + { + h += " data_sections_cut=\""; h += std::to_string( dataSecCut.cut ); h += "\""; + h += nextAttrXml( dataSectionsNext( dataSecCut ) ); + } h += ">"; return h; }; @@ -8247,6 +8344,7 @@ struct JsonMapHeader std::size_t macroBlankedCount = 0; // member-macro re-parse: "macro_blanked_files":N, absent when 0 std::size_t nestRefusedCount = 0; // #157: "nest_refused":N, the JSON twin of the XML nest_refused=, absent when 0 bool isEstModelled = false; // MapEstimate: "est_measured":false, absent when measured + DataSectionsCut dataSectionsCut = {}; // the code-first pick's swaps: "data_sections_cut":N + "next", absent at N=0 }; // §B1.2: the PROVENANCE stamp — the JSON half of the XML `` attributes. Without it @@ -8469,6 +8567,13 @@ inline void writeJsonMapHeader( JsonWriter& w, std::string& esc, const JsonMapHe // side just closed. Same slot, same absent-means-converged rule. w.write( renderDisclosure( h.ann->prDisclosure, DiscloseAs::JsonKeys ) ); + // The JSON twin of the XML root's data_sections_cut= / next= (codeFirstKeep): same keys, same absent-at-zero rule. + if( h.dataSectionsCut.cut > 0 ) + { + w.write( ",\"data_sections_cut\":" + std::to_string( h.dataSectionsCut.cut ) + ",\"next\":" ); // composed, not a fixed buffer + writeJsonStr( w, dataSectionsNext( h.dataSectionsCut ), esc ); + } + // §A4b: the multi-root prologue (A13) — `roots_count` joins the header gauges and a // `roots` table maps each label to its root path, ONLY when N≥2 (single-root output byte-unchanged). // Without it every `"p"` in the payload is an unresolvable root-relative fragment. @@ -8552,6 +8657,7 @@ inline void serializeJson( std::FILE* out, const IngestResult& ing, const std::v sortutil::radixSortByScoreDescId( order, rank ); const std::size_t keep = std::min( topK > 0 ? std::size_t( topK ) : S, S ); + const DataSectionsCut dataSecCut = ann.codeFirstRows ? codeFirstKeep( ing, order, keep ) : DataSectionsCut{}; // serialize()'s pick, same rule std::vector> buckets( ing.files.size() ); std::vector fileOrder; @@ -8626,7 +8732,7 @@ inline void serializeJson( std::FILE* out, const IngestResult& ing, const std::v writeJsonMapHeader( hw, esc, JsonMapHeader{ ing, S, outTargets.size(), keep, estTokens, ambTotal, unresolvedTotal, orderAttr, outProv, &ann, rootArg, locPinTotal, externalCalls, declinedTotal, extentSuspectTotal, macroBlankedFileCount( ing ), ing.crawlSkips.nestRefusedFiles, - estimate.isModelled } ); + estimate.isModelled, dataSecCut } ); hw.write( ",\"r\":[" ); }; diff --git a/src/verbs_change.h b/src/verbs_change.h index 1ffa1997b..9da3174cc 100644 --- a/src/verbs_change.h +++ b/src/verbs_change.h @@ -1616,7 +1616,14 @@ std::optional runCrossRef( const MainDispatch& d ) // The tree zero stays an answer; the near-miss only says WHICH zero it is (a name this repo never // had, or a keystroke away from one it has). Computed only on the zero, so a real hit list is // byte-identical. - if( result.hits.empty() ) + // Review M3/M7: the dotted note fires only for a method the index defines, and on a zero over a dirty checkout + // the working tree's rename of the name is offered ahead of (instead of) a spelling neighbour. + result.dottedRetry = crossref::whereisDottedRetryOf( d.ing, whereisSel ); + if( result.hits.empty() && result.worktree != crossref::WorktreeOverlay::Clean ) + { + result.renamedTo = crossref::worktreeRenameOf( d.ing, whereisSel, root ); + } + if( result.hits.empty() && result.renamedTo.empty() ) { result.nearMiss = didYouMean( d.ing, whereisSel ); } diff --git a/src/verbs_navigate.h b/src/verbs_navigate.h index 0ba2824f6..b7e78553b 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -110,6 +110,23 @@ inline const char* macroRoleAttr( rw::SymKind k ) noexcept using rw::CallHierarchyRows; using rw::HopTestedPartition; +// The not-found refusal of the ANSWERING verbs (--callers/--callees/--uses/--impact/--path), in one shape: stderr +// names the miss (a working-tree rename first, then the shared fault clause), stdout carries the answer document +// (selectorrefuse.h writeNotFoundAnswer), and the exit stays 1, the documented refusal (README §6.2). The rename +// probe reads git, so it runs on a single-root index only; a workspace gets the spelling near-miss alone. +inline std::string notFoundGitRoot( const rw::IngestResult& ing, const rw::Config& cfg ) +{ + return ing.realPaths.empty() && cfg.roots.size() == 1 ? std::string( cfg.roots[0] ) : std::string(); +} + +inline int refuseNotFoundWithAnswer( const rw::IngestResult& ing, const rw::Config& cfg, const rw::NotFoundAnswer& answer, + std::string_view prefix, std::string_view spec, std::string_view retryForm ) +{ + rw::emitTo( stderr, "{}{}{}{}\n", prefix, spec, rw::notFoundRenameClause( answer.near ), rw::selectorFaultClause( ing, spec, retryForm ) ); + rw::writeNotFoundAnswer( stdout, answer, cfg.json ); + return 1; +} + std::optional runCallHierarchy( const MainDispatch& d ) { using namespace rw; @@ -136,9 +153,9 @@ std::optional runCallHierarchy( const MainDispatch& d ) if( matches.empty() ) { const std::string verb = std::string( wantCallers ? "--callers" : "--callees" ); // one arm, two spellings - rw::emitTo( stderr, "{}\n", selectorNotFoundMessage( ing, "ripwire: " + verb + " symbol not found: ", - sym, verb + "=" ).c_str() ); // §B4.2 shared refusal - return 1; + return refuseNotFoundWithAnswer( ing, cfg, rw::NotFoundAnswer{ wantCallers ? "callers" : "callees", { { "of", std::string( sym ) } }, {}, + rw::notFoundNear( ing, sym, notFoundGitRoot( ing, cfg ) ) }, + "ripwire: " + verb + " symbol not found: ", sym, verb + "=" ); // §B4.2 shared refusal } const std::vector& result = chRows.rows; @@ -588,11 +605,11 @@ collectUseSites( const rw::IngestResult& ing, const UsesSelector& sel, std::span // §B4.2: that MESSAGE now lives in selectorrefuse.h and every SYM-taking verb speaks it — this arm is what // it was generalized FROM, so what stays here is only the exit code. The wording is unchanged (a file-list // cap with an explicit remainder is the one addition, shared by all six arms). -inline int refuseUsesFileQualifier( const rw::IngestResult& ing, std::string_view sym, const UsesSelector& ) +inline int refuseUsesFileQualifier( const rw::IngestResult& ing, const rw::Config& cfg, std::string_view sym ) { - rw::emitTo( stderr, "{}\n", rw::selectorNotFoundMessage( ing, "ripwire: --uses symbol not found: ", - sym, "--uses=" ).c_str() ); - return 1; + return refuseNotFoundWithAnswer( ing, cfg, rw::NotFoundAnswer{ "uses", { { "of", std::string( sym ) } }, {}, + rw::notFoundNear( ing, sym, notFoundGitRoot( ing, cfg ) ) }, + "ripwire: --uses symbol not found: ", sym, "--uses=" ); } // §P8 G1 — --uses was the one verb that disclosed NOTHING: it accepted --limit/--offset, ignored both, and @@ -660,7 +677,7 @@ std::optional runUses( const MainDispatch& d ) // three siblings all refuse it, and so does this one now. if( defs.empty() && sel.fileQualified ) { - return refuseUsesFileQualifier( ing, sym, sel ); + return refuseUsesFileQualifier( ing, cfg, sym ); } // r27-emitters T3 / §P10.2: external="1" is a real answer, a typo is not — distinguished by the @@ -670,8 +687,10 @@ std::optional runUses( const MainDispatch& d ) // made defs wrongly empty for a selector that DID resolve). if( defs.empty() && sites.empty() ) { - rw::emitTo( stderr, "{}\n", withDidYouMean( ing, sel.suggestName, - "ripwire: --uses selector matched no indexed definition: " + std::string( sym ) ).c_str() ); + const rw::NotFoundNear near = rw::notFoundNear( ing, sel.suggestName, notFoundGitRoot( ing, cfg ) ); + const std::string msg = "ripwire: --uses selector matched no indexed definition: " + std::string( sym ) + rw::notFoundRenameClause( near ); + rw::emitTo( stderr, "{}\n", near.renamed ? msg : withDidYouMean( ing, sel.suggestName, msg ) ); + rw::writeNotFoundAnswer( stdout, rw::NotFoundAnswer{ "uses", { { "of", std::string( sym ) } }, {}, near }, cfg.json ); return 1; } @@ -2079,9 +2098,10 @@ std::optional runPath( const MainDispatch& d ) // grammar, so the endpoint that missed gets the shared diagnosis (unindexed path vs wrong file half // vs unknown name) instead of a near-miss on the name half alone. const std::string_view missing = srcDefs.empty() ? srcN : dstN; - rw::emitTo( stderr, "{}\n", selectorNotFoundMessage( ing, "ripwire: --path endpoint not found: ", - missing, "--path=" ).c_str() ); - return 1; + return refuseNotFoundWithAnswer( ing, cfg, rw::NotFoundAnswer{ "path", { { "from", std::string( srcN ) }, { "to", std::string( dstN ) } }, + srcDefs.empty() && dstDefs.empty() ? "both" : srcDefs.empty() ? "from" : "to", + rw::notFoundNear( ing, missing, notFoundGitRoot( ing, cfg ) ) }, + "ripwire: --path endpoint not found: ", missing, "--path=" ); } const std::vector path = rw::shortestPathAny( g, srcDefs, dstDefs ); // ONE BFS over every def pair @@ -2394,9 +2414,9 @@ std::optional runImpact( const MainDispatch& d ) const std::vector seeds = resolveAllByNameQualified( ing, cfg.impactSym, &imUnprovenDefs ); if( seeds.empty() ) { - rw::emitTo( stderr, "{}\n", selectorNotFoundMessage( ing, "ripwire: --impact symbol not found: ", - cfg.impactSym, "--impact=" ).c_str() ); // §B4.2 - return 1; + return refuseNotFoundWithAnswer( ing, cfg, rw::NotFoundAnswer{ "impact", { { "of", std::string( cfg.impactSym ) } }, {}, + rw::notFoundNear( ing, cfg.impactSym, notFoundGitRoot( ing, cfg ) ) }, + "ripwire: --impact symbol not found: ", cfg.impactSym, "--impact=" ); // §B4.2 } // 0.6.5: the SAME walk, keeping each node's hop depth — the rows' d= and the root's by_depth=. std::vector imDepth; diff --git a/src/verbs_quality.h b/src/verbs_quality.h index dfcd5de39..0d7273244 100644 --- a/src/verbs_quality.h +++ b/src/verbs_quality.h @@ -2263,7 +2263,9 @@ std::optional runEditCheck( const MainDispatch& d ) return 1; } - const std::vector groups = editCheckGroups( ing, d.g, matches ); + // the C/C++ declaration/definition fold is the post-hoc verb's (editcheck.h editCheckFoldDeclGroups); the preview + // below splices over ONE definition span, so it keeps one group per (file, scope) and refuses as before + const std::vector groups = editCheckGroups( ing, d.g, matches, /*foldDecls=*/!editPreviewRequested( cfg ) ); if( groups.size() > 1 ) { rw::emitTo( stderr, "ripwire: --edit-check: {}\n", diff --git a/test/compactlegendcheck.sh b/test/compactlegendcheck.sh index 084e862ac..2b262cd59 100755 --- a/test/compactlegendcheck.sh +++ b/test/compactlegendcheck.sh @@ -498,9 +498,13 @@ probeFor() # code_not_flow_scanned=); the gate now runs under an empty HOME of its own, so the probe measures the same bytes everywhere. # RE-PINNED 2026-09-30 (review M5): 380 -> 530 (measured 518, hermetic HOME). The probe is a bare --scan-skills, whose answer # now names the directories it walked (dirs=) and whose legend defines it (+149 B); the value is in the root, not the legend. +# PINNED 2026-10-01 (C/C++ declaration/definition fold): ripwire.edit-check/v1 610 (measured 595). The --edit-check=distance +# probe used to be REFUSED — geometry.h declares distance and geometry.cpp defines it, and the verb counted the prototype and +# its definition as two contracts — so the schema had no XML answer to pin. It now answers about the definition. # the pins follow the definitions, measured + 10 rounded up to 10. # schema pin measured PIN_TABLE=' +ripwire.edit-check/v1 610 595 ripwire.map/v1 910 892 ripwire.map-diff/v1 900 885 ripwire.pack-signatures/v1 770 759 diff --git a/test/completecheck.sh b/test/completecheck.sh index af80649ac..873422baf 100755 --- a/test/completecheck.sh +++ b/test/completecheck.sh @@ -31,6 +31,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" # §18 builds throwaway git repos: no inherited GIT_DIR / GIT_WORK_TREE may steer them BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative RIPWIRE_BIN TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -360,5 +361,357 @@ else no "grep (17 control): the readable fixture did not claim complete over 2 hits — the arm is void: $( grep -o ']*>' "$UG/warm.xml" )" fi +# ── 18) whereis on a DIRTY checkout: the working tree is read, or the claim is withheld ─────────────── +# The comparison-table repros (2026-10-01): --whereis scanned committed trees only, so on a dirty checkout it +# answered hits="0" complete="1" for a function the edit had just added, and listed a renamed or deleted +# function at its old HEAD lines, also complete="1", with a bare at= (no +dirty). --callers on the same tree +# saw the edit. Now every path under the root that differs from HEAD (modified, staged, deleted or untracked) +# is read from disk: its rows say ref="worktree" and replace HEAD's rows for that path, at= gains +dirty and +# the root says worktree="read". A changed path that cannot be read keeps its HEAD rows, says +# worktree="partial" and withholds complete=. A clean checkout answers byte-for-byte as before. +WT="$TMP/wtrepo"; mkdir -p "$WT/src" +cat >"$WT/src/main.c" <<'EOF' +int zqKeep( int x ) { return x + 1; } +int zqOldName( int x ) { return x * 2; } +int zqDoomed( void ) { return 3; } +int zqUser( void ) { return zqKeep( 1 ) + zqOldName( 2 ) + zqDoomed(); } +EOF +cat >"$WT/src/other.c" <<'EOF' +int zqOther( void ) { return 4; } +EOF +cat >"$WT/src/gone.c" <<'EOF' +int zqGone( void ) { return 5; } +EOF +( cd "$WT" && git init -q -b main . && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +WSHA="$( git -C "$WT" rev-parse --short=9 HEAD 2>/dev/null )" +wroot(){ grep -o ']*>' | head -1; } +wwhere(){ "$BIN" "$WT" --whereis="$1" --no-cache "${@:2}" 2>/dev/null; } + +# 18a) control: the clean checkout claims, with a bare at= and no worktree= — the pre-fix shape, unchanged +W0="$( wwhere zqOldName | wroot )" +{ printf '%s' "$W0" | grep -q " at=\"$WSHA\" complete=\"1\"" && ! printf '%s' "$W0" | grep -q 'worktree='; } \ + && ok 'whereis (18a control): a clean checkout claims complete="1" with a bare at= and no worktree=' \ + || no "whereis (18a control): the clean shape changed: $W0" + +# the edit: add a function, rename one, delete one, delete a whole tracked file, add an untracked file +cat >"$WT/src/main.c" <<'EOF' +int zqKeep( int x ) { return x + 1; } +int zqNewName( int x ) { return x * 2; } +int zqFresh( void ) { return 6; } +int zqUser( void ) { return zqKeep( 1 ) + zqNewName( 2 ) + zqFresh(); } +EOF +rm -f "$WT/src/gone.c" +printf 'int zqUntracked( void ) { return 7; }\n' >"$WT/src/new.c" + +# 18b) a function the edit ADDED is found, as a worktree definition, and the stamp says the tree is dirty +W1="$( wwhere zqFresh )"; W1R="$( printf '%s' "$W1" | wroot )" +{ printf '%s' "$W1" | grep -q ']*p="src/main.c" l="3" kind="def"' \ + && printf '%s' "$W1R" | grep -q " at=\"$WSHA+dirty\" worktree=\"read\"" && printf '%s' "$W1R" | grep -q ' on-head="1"'; } \ + && ok 'whereis (18b): a function added in the working tree is found (ref="worktree" kind="def"), at= says +dirty, worktree="read"' \ + || { no 'whereis (18b): a function the working tree added is still invisible (the stale-and-silent answer)'; printf '%s\n' "$W1R"; } + +# 18c) a RENAMED-away name no longer lists its old HEAD line; the new name is found +W2R="$( wwhere zqOldName | wroot )"; W2="$( wwhere zqOldName )" +{ printf '%s' "$W2R" | grep -q ' hits="0"' && ! printf '%s' "$W2" | grep -q ']*p="src/main.c" l="2" kind="def"' \ + && ok 'whereis (18c): the rename target is found in the working tree' \ + || no 'whereis (18c): the rename target is not found' + +# 18d) a DELETED definition, and a whole deleted tracked file, drop out +for s in zqDoomed zqGone; do + W3R="$( wwhere "$s" | wroot )" + printf '%s' "$W3R" | grep -q ' hits="0"' \ + && ok "whereis (18d): $s, deleted in the working tree, has no rows" \ + || { no "whereis (18d): $s, deleted in the working tree, still has rows"; printf '%s\n' "$W3R"; } +done + +# 18e) an UNTRACKED file is part of the checkout; an untouched path keeps its HEAD row +wwhere zqUntracked | grep -q ']*p="src/new.c" l="1" kind="def"' \ + && ok 'whereis (18e): a definition in an untracked file is found' \ + || no 'whereis (18e): a definition in an untracked file is invisible' +wwhere zqOther | grep -q ']*p="src/other.c" l="1" kind="def"' \ + && ok 'whereis (18e): a path the edit did not touch still answers from HEAD (ref="HEAD")' \ + || no 'whereis (18e): an untouched path lost its HEAD row' + +# 18f) the INVARIANT: no complete="1" answer carries a HEAD row for a path that differs from HEAD +CHANGED="$( git -C "$WT" diff --name-only HEAD; git -C "$WT" ls-files --others --exclude-standard )" +bad=0 +for s in zqKeep zqFresh zqOldName zqNewName zqDoomed zqGone zqUntracked zqOther zqUser; do + out="$( wwhere "$s" )" + printf '%s' "$out" | wroot | grep -q 'complete="1"' || continue + for p in $CHANGED; do + printf '%s' "$out" | grep -q "]*p=\"$p\"" && { bad=1; no "whereis (18f): $s claims complete=\"1\" beside a stale HEAD row for changed $p"; } + done +done +[ $bad -eq 0 ] && ok 'whereis (18f): no complete="1" answer carries a HEAD row for a path the working tree changed' + +# 18g) the twin: MCP whereis reads the same working tree, row for row +if command -v python3 >/dev/null 2>&1; then + M1="$( python3 - "$BIN" "$WT" <<'PY' +import json, subprocess, sys +msgs = [ { "jsonrpc": "2.0", "id": 1, "method": "initialize" }, + { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "whereis", "arguments": { "path": sys.argv[2], "symbol": "zqFresh" } } } ] +p = subprocess.run( [ sys.argv[1], "--mcp" ], input = "".join( json.dumps( m ) + "\n" for m in msgs ), capture_output = True, text = True, timeout = 300 ) +d = json.loads( [ l for l in p.stdout.splitlines() if l.strip() ][ -1 ] ) +print( d.get( "result", {} ).get( "content", [ {} ] )[ 0 ].get( "text", "" ) ) +PY +)" + C1H="$( printf '%s' "$W1" | grep -o ']*/>' )"; M1H="$( printf '%s' "$M1" | grep -o ']*/>' )" + { [ -n "$C1H" ] && [ "$C1H" = "$M1H" ] && printf '%s' "$M1" | wroot | grep -q " at=\"$WSHA+dirty\" worktree=\"read\""; } \ + && ok 'whereis (18g): the MCP twin serves the same worktree rows, +dirty stamp and worktree="read"' \ + || { no 'whereis (18g): the MCP twin disagrees with the CLI on a dirty checkout'; printf '%s\n' "$M1" | wroot; } +fi + +# 18h) the legend defines worktree= wherever it rides, in both dialects +grep -q 'worktree=read|partial|unlisted' <<<"$W1" \ + && ok 'whereis (18h): the compact legend defines worktree=' || no 'whereis (18h): worktree= rides with no compact definition' +wwhere zqFresh --legend=full | grep -q 'WORKTREE: worktree=' \ + && ok 'whereis (18h): the full legend defines worktree=' || no 'whereis (18h): worktree= rides with no full-legend definition' + +# 18i) MUTATION: a changed path that cannot be READ keeps its HEAD rows, says partial, and never claims +chmod 000 "$WT/src/main.c" +if cat "$WT/src/main.c" >/dev/null 2>&1; then + printf ' SKIP 18i: chmod 000 does not stop this user reading the file (root?) — the arm cannot plant its fault\n' +else + W4R="$( wwhere zqKeep | wroot )" + { printf '%s' "$W4R" | grep -q ' worktree="partial"' && ! printf '%s' "$W4R" | grep -q 'complete='; } \ + && ok 'whereis (18i): an unreadable changed path says worktree="partial" and withholds complete=' \ + || { no 'whereis (18i): an unreadable changed path still claims a complete answer'; printf '%s\n' "$W4R"; } +fi +chmod 644 "$WT/src/main.c" + +# 18j) "none found" is an ANSWER on whereis (rc 0, a document) and stays the documented REFUSAL on callers +# (README §6.2: 1 = refused; docs/COMMANDS.md --callers "Unknown-symbol REFUSAL shape (exit 1)"). +"$BIN" "$WT" --whereis=zqDoomed --no-cache >"$TMP/wt0.xml" 2>/dev/null; rc=$? +{ [ $rc -eq 0 ] && wroot <"$TMP/wt0.xml" | grep -q ' hits="0"'; } \ + && ok 'whereis (18j): a name the working tree deleted answers rc 0 with a hits="0" document' \ + || no "whereis (18j): the deleted name did not answer as a document (rc=$rc)" +"$BIN" "$WT" --callers=zqDoomed --no-cache >"$TMP/wt0c.xml" 2>"$TMP/wt0c.err"; rc=$? +{ [ $rc -eq 1 ] && grep -q 'not found' "$TMP/wt0c.err"; } \ + && ok 'callers (18j): the same name is still the documented refusal (exit 1, stderr names it)' \ + || no "callers (18j): the unknown-symbol contract moved (rc=$rc)" + +# 18l) ...and the refusal now ALSO answers on stdout (fix list #2): the verb's own root, the selector echoed, found="0", +# the legend defining it — so an agent reading stdout gets an answer, not nothing. Exit 1 is unchanged. +for v in callers callees uses impact; do + "$BIN" "$WT" --$v=zqDoomed --no-cache >"$TMP/nf.xml" 2>/dev/null; rc=$? + { [ $rc -eq 1 ] && grep -q "<$v [^>]*of=\"zqDoomed\" found=\"0\"" "$TMP/nf.xml" && grep -q 'found=0: ' "$TMP/nf.xml" \ + && { ! command -v xmllint >/dev/null 2>&1 || xmllint --noout "$TMP/nf.xml" 2>/dev/null; }; } \ + && ok "$v (18l): not found answers on stdout (<$v of= found=\"0\">, legend-defined, well-formed) and still exits 1" \ + || { no "$v (18l): not found printed no answer document (rc=$rc)"; head -c 300 "$TMP/nf.xml"; echo; } +done +"$BIN" "$WT" --path=zqUser,zqDoomed --no-cache >"$TMP/nf.xml" 2>/dev/null; rc=$? +{ [ $rc -eq 1 ] && grep -q ']*from="zqUser" to="zqDoomed" found="0" missing="to"' "$TMP/nf.xml"; } \ + && ok 'path (18l): an endpoint that matched nothing answers and exits 1' \ + || { no "path (18l): no answer document (rc=$rc)"; head -c 300 "$TMP/nf.xml"; echo; } +"$BIN" "$WT" --callers=zqDoomed --json --no-cache 2>/dev/null \ + | python3 -c 'import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if d.get("of")=="zqDoomed" and d.get("found")==0 else 1)' 2>/dev/null \ + && ok 'callers (18l): under --json the not-found answer is one JSON object with found:0' \ + || no 'callers (18l): --json not-found printed no JSON answer' + +# 18m) the near-miss ranks a WORKING-TREE RENAME first: zqOldName became zqNewName in the working tree, so the answer +# offers zqNewName (near_renamed="1"), and stderr names the rename before any spelling near-miss. +"$BIN" "$WT" --callers=zqOldName --no-cache >"$TMP/rn.xml" 2>"$TMP/rn.err"; rc=$? +{ [ $rc -eq 1 ] && grep -q 'near="zqNewName" near_renamed="1"' "$TMP/rn.xml" \ + && grep -q "symbol not found: zqOldName (renamed in the working tree: did you mean 'zqNewName'?)" "$TMP/rn.err"; } \ + && ok 'callers (18m): a name the working tree renamed offers the new name first (near_renamed="1", stderr says renamed)' \ + || { no 'callers (18m): the rename is not offered first'; cat "$TMP/rn.err"; head -c 400 "$TMP/rn.xml"; echo; } +"$BIN" "$WT" --callers=zqNoSuchNameAtAll --no-cache >"$TMP/rn0.xml" 2>/dev/null +grep -q 'near_renamed' "$TMP/rn0.xml" \ + && no 'callers (18m): a name no file ever had was offered as a rename' \ + || ok 'callers (18m): a name no file ever had gets no rename claim' + +# 18n) fix list #9: a TEST-LOCAL definition (a test file's own `def helper`, hono-05's `const serveStatic =`) is +# ordered after the production definition when both exist, and marked test_local="1"; nothing is dropped. An answer +# with only one kind keeps its bytes. +TL="$TMP/testlocal"; mkdir -p "$TL/pkg" "$TL/tests" +printf 'def helper( x ):\n return x\n' >"$TL/pkg/lib.py" +printf 'from pkg.lib import helper as real\n\ndef helper( x ):\n return real( x )\n\ndef only_in_test():\n return helper( 1 )\n' >"$TL/tests/test_lib.py" +( cd "$TL" && git init -q -b main . && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +TLO="$( "$BIN" "$TL" --whereis=helper --no-cache 2>/dev/null )" +FIRSTDEF="$( printf '%s' "$TLO" | grep -o ']*kind="def"[^>]*>' | head -1 )" +TESTDEF="$( printf '%s' "$TLO" | grep -o ']*p="tests/test_lib.py" l="3" kind="def"[^>]*>' )" +{ printf '%s' "$FIRSTDEF" | grep -q 'p="pkg/lib.py" l="1" kind="def" t=' && printf '%s' "$TESTDEF" | grep -q 'kind="def" test_local="1"' \ + && [ "$( printf '%s' "$TLO" | sed 's///' | grep -o '//' | head -c 900; echo; } +printf '%s' "$TLO" | grep -q 'TEST-LOCAL: \|test_local=1: ' \ + && ok 'whereis (18n): test_local= is defined in the legend where it rides' || no 'whereis (18n): test_local= rides undefined' +"$BIN" "$TL" --whereis=only_in_test --no-cache 2>/dev/null | grep -q 'test_local' \ + && no 'whereis (18n): an answer with only test definitions grew test_local=' \ + || ok 'whereis (18n): an answer with one kind of definition carries no test_local= (bytes unchanged)' + +# 18n, the ORDER half: a test-scope def in a source file whose path sorts first (src/a.rs) used to lead the answer; +# the production def (src/z.rs) now does. +TR="$TMP/testscope"; mkdir -p "$TR/src" +printf '#[cfg(test)]\nmod tests {\n fn helper() -> i32 { 2 }\n #[test]\n fn t() { assert_eq!(helper(), 2); }\n}\n' >"$TR/src/a.rs" +printf 'pub fn helper() -> i32 { 1 }\n' >"$TR/src/z.rs" +( cd "$TR" && git init -q -b main . && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +TRF="$( "$BIN" "$TR" --whereis=helper --no-cache 2>/dev/null | sed 's///' | grep -o ']*kind="def"[^>]*>' )" +{ printf '%s\n' "$TRF" | head -1 | grep -q 'p="src/z.rs" l="1" kind="def" t=' && printf '%s\n' "$TRF" | sed -n 2p | grep -q 'p="src/a.rs" l="3" kind="def" test_local="1"'; } \ + && ok 'whereis (18n): a test-scope def in a source file sorts after the production def (test_local="1")' \ + || { no 'whereis (18n): a test-scope def still leads the production def'; printf '%s\n' "$TRF"; } + +# 18o) the MCP twins keep their -32602 refusal and carry the same answer document in error.data.answer +if command -v python3 >/dev/null 2>&1; then + python3 - "$BIN" "$WT" <<'PY' && ok 'MCP (18o): all five twins (find_referencing_symbols, find_symbol, uses, impact, path_between) refuse -32602 AND carry the answer; batch names the rename' \ + || no 'MCP (18o): the twins do not carry the not-found answer' +import json, subprocess, sys +BIN, WT = sys.argv[1], sys.argv[2] +calls = [ ( "find_referencing_symbols", { "path": WT, "symbol": "zqOldName" }, '"found":0', '"near":"zqNewName"' ), + ( "find_symbol", { "path": WT, "symbol": "zqOldName" }, '"found":0', '"near":"zqNewName"' ), + ( "uses", { "path": WT, "symbol": "zqOldName" }, 'found="0"', 'near="zqNewName" near_renamed="1"' ), + ( "impact", { "path": WT, "symbol": "zqDoomed" }, 'found="0"', 'of="zqDoomed"' ), + ( "path_between", { "path": WT, "from": "zqUser", "to": "zqDoomed" }, 'found="0"', 'missing="to"' ), + ( "path_between", { "path": WT, "from": "zqNope1", "to": "zqNope2" }, 'found="0"', 'missing="both"' ) ] +msgs = [ { "jsonrpc": "2.0", "id": 1, "method": "initialize" } ] + [ + { "jsonrpc": "2.0", "id": 10 + i, "method": "tools/call", "params": { "name": n, "arguments": a } } for i, ( n, a, _, _ ) in enumerate( calls ) ] +p = subprocess.run( [ BIN, "--mcp" ], input = "".join( json.dumps( m ) + "\n" for m in msgs ), capture_output = True, text = True, timeout = 300 ) +byId = {} +for line in p.stdout.splitlines(): + try: + d = json.loads( line ) + except ValueError: + continue + byId[ d.get( "id" ) ] = d +bad = 0 +for i, ( n, a, want1, want2 ) in enumerate( calls ): + d = byId.get( 10 + i, {} ) + e = d.get( "error", {} ) + ans = e.get( "data", {} ).get( "answer", "" ) + if e.get( "code" ) != -32602 or want1 not in ans or want2 not in ans: + print( " MCP %s: %s" % ( n, json.dumps( d )[ :300 ] ) ); bad = 1 +for k in ( 10, 11, 12 ): + rn = byId.get( k, {} ).get( "error", {} ).get( "message", "" ) + if "renamed in the working tree: did you mean 'zqNewName'" not in rn: + print( " MCP rename clause missing (id %d): %s" % ( k, rn ) ); bad = 1 +# the batch arm (review M6): each answering sub-verb's err= names the rename first +bq = [ { "verb": v, "symbol": "zqOldName" } for v in ( "callers", "callees", "impact", "uses" ) ] +pb = subprocess.run( [ BIN, "--mcp" ], input = json.dumps( msgs[ 0 ] ) + "\n" + json.dumps( + { "jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": { "name": "batch", "arguments": { "path": WT, "queries": bq } } } ) + "\n", + capture_output = True, text = True, timeout = 300 ) +bt = "" +for line in pb.stdout.splitlines(): + try: + d = json.loads( line ) + except ValueError: + continue + if d.get( "id" ) == 99: + bt = d.get( "result", {} ).get( "content", [ {} ] )[ 0 ].get( "text", "" ) +if bt.count( "renamed in the working tree: did you mean 'zqNewName'" ) != 4: + print( " MCP batch: not every not-found item names the rename: " + bt[ :400 ] ); bad = 1 +sys.exit( bad ) +PY +fi +# ── review round (rv-fresh-067) ──────────────────────────────────────────────────────────────────────────────── +# 18p) M1: near_renamed needs a DEFINITION of the name to have LEFT a changed file. Two negatives that the first cut +# called renames: an external name still imported and called beside an unrelated new def, and a name only a HEAD +# comment ever mentioned. Both must fall back to the spelling near-miss with no near_renamed. +RF="$TMP/renamefp"; mkdir -p "$RF/a" "$RF/b" +printf 'from lib import parse_config\n\ndef run():\n return parse_config("x")\n' >"$RF/a/app.py" +printf '# TODO: retire old_fetch_user once the cache lands\ndef get_user():\n return 1\n' >"$RF/b/svc.py" +for d in a b; do ( cd "$RF/$d" && git init -q -b main . && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1; done +printf 'from lib import parse_config\n\ndef run():\n return parse_config("x")\n\ndef config_parse_v2():\n return 2\n' >"$RF/a/app.py" +printf '# TODO: retire old_fetch_user once the cache lands\ndef get_user():\n return 1\n\ndef fetch_user_old():\n return 0\n' >"$RF/b/svc.py" +for c in 'a parse_config' 'b old_fetch_user'; do + set -- $c + "$BIN" "$RF/$1" --callers="$2" --no-cache >"$TMP/rf.out" 2>"$TMP/rf.err" + { ! grep -q 'near_renamed' "$TMP/rf.out" && ! grep -q 'renamed in the working tree' "$TMP/rf.err"; } \ + && ok "callers (18p): $2 — still mentioned, never a definition that left — is not offered as a rename" \ + || { no "callers (18p): $2 was offered as a working-tree rename"; cat "$TMP/rf.err"; } +done + +# 18q) M7: --whereis's own zero names the working tree's rename first (r="renamed"), not the spelling neighbour. +WR="$( wwhere zqOldName | sed 's///' )" +{ printf '%s' "$WR" | grep -q '' && ! printf '%s' "$WR" | grep -q 'r="near-miss"'; } \ + && ok 'whereis (18q): a renamed-away name offers the rename (r="renamed" retry="zqNewName"), not a spelling near-miss' \ + || { no 'whereis (18q): the renamed-away zero does not name the rename'; printf '%s\n' "$WR"; } +wwhere zqOldName --legend=full | grep -q 'renamed (the scan found nothing and the working tree' \ + && ok 'whereis (18q): the full legend defines r="renamed"' || no 'whereis (18q): r="renamed" rides undefined' + +# 18r) M8: --with-history on a name only the WORKING TREE removed (HEAD's commit still holds it) is not "never". +FA="$( "$BIN" "$WT" --whereis=zqDoomed --with-history --no-cache 2>/dev/null | sed 's///' )" +{ printf '%s' "$FA" | grep -q ']*>'; } +"$BIN" "$WT" --whereis=zqNoSuchNameAtAll --with-history --no-cache 2>/dev/null | grep -q 'v="uncommitted"' \ + && no 'whereis (18r): a name HEAD never held was called an uncommitted removal' \ + || ok 'whereis (18r): a name HEAD never held keeps the history verdict (no v="uncommitted")' + +# 18s) M2: an untracked NESTED repository is a directory the overlay does not read — worktree="partial", no complete=. +NR="$TMP/nested"; mkdir -p "$NR"; printf 'int outer_fn( void ) { return 0; }\n' >"$NR/a.c" +( cd "$NR" && git init -q -b main . && git add -A && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed \ + && mkdir inner && cd inner && git init -q -b main . && printf 'int nested_fn( void ) { return 1; }\n' >n.c && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +NRR="$( "$BIN" "$NR" --whereis=nested_fn --no-cache 2>/dev/null | wroot )" +{ printf '%s' "$NRR" | grep -q ' worktree="partial"' && ! printf '%s' "$NRR" | grep -q 'complete='; } \ + && ok 'whereis (18s): an untracked nested repository is not read, so worktree="partial" and no complete=' \ + || { no 'whereis (18s): an unread nested repository still claims a complete answer'; printf '%s\n' "$NRR"; } + +# 18t) S1: a changed path BEYOND a symlinked directory is not in the checkout (git: deleted); it is never read through +# the link, so a file outside the root never answers. +SL="$TMP/symparent"; mkdir -p "$SL/a" "$TMP/symout/a"; printf 'int inside_fn( void ) { return 1; }\n' >"$SL/a/b.c" +( cd "$SL" && git init -q -b main . && git add -A && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +printf 'int outside_fn( void ) { return 2; }\n' >"$TMP/symout/a/b.c"; rm -rf "$SL/a"; ln -s "$TMP/symout/a" "$SL/a" +SLR="$( "$BIN" "$SL" --whereis=outside_fn --no-cache 2>/dev/null )" +{ printf '%s' "$SLR" | wroot | grep -q ' hits="0"' && ! printf '%s' "$SLR" | grep -q 'p="a/b.c"'; } \ + && ok 'whereis (18t): a path beyond a symlinked directory is never read through the link (no row from outside the root)' \ + || { no 'whereis (18t): the overlay read a file outside the root through a symlinked parent'; printf '%s\n' "$SLR" | sed 's///'; } + +# 18v) M4: both --path endpoints missing says missing="both", on the CLI and (18o) over MCP. +"$BIN" "$WT" --path=zqNope1,zqNope2 --no-cache 2>/dev/null | grep -q ']*from="zqNope1" to="zqNope2" found="0" missing="both"' \ + && ok 'path (18v): both endpoints missing answers missing="both"' || no 'path (18v): both endpoints missing names only one' + +# 18w) S2: the test_local= legend names the path tiers the code uses (test, bench, fixture), and a bench/ def is marked. +TB="$TMP/benchtl"; mkdir -p "$TB/src" "$TB/bench" +printf 'int bench_helper( void ) { return 1; }\n' >"$TB/src/b.c"; printf 'int bench_helper( void ) { return 2; }\n' >"$TB/bench/b.c" +( cd "$TB" && git init -q -b main . && git add -A && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +TBF="$( "$BIN" "$TB" --whereis=bench_helper --no-cache --legend=full 2>/dev/null )" +{ printf '%s' "$TBF" | grep -q 'p="bench/b.c" l="1" kind="def" test_local="1"' && printf '%s' "$TBF" | grep -q 'under a test, bench or fixture path'; } \ + && ok 'whereis (18w): a bench/ def is test_local="1" and the legend says test, bench or fixture path' \ + || no 'whereis (18w): the test_local legend and the code disagree on bench/ and fixture paths' +TBC="$( "$BIN" "$TB" --whereis=bench_helper --no-cache 2>/dev/null )" +printf '%s' "$TBC" | grep -q 'test_local=1: a definition in a test scope or under a test/bench/fixture path' \ + && ok 'whereis (18w): the compact legend says the same' || no 'whereis (18w): the compact test_local reading disagrees with the code' +# 18k) a Class.method / Class#method selector is searched as a LITERAL, and no tree spells a method's definition +# that way. It used to answer hits="0" on-head="0" complete="1" with no note — a zero shaped exactly like a name this +# repo never had (the edit-check lane's finding). The zero now carries a selector-note r="dotted-selector" whose +# retry= is the bare method name, and complete= is withheld: the scan did not answer the question the selector asked. +mkdir -p "$TMP/dotted" && printf 'class Shape:\n def area( self ):\n return 1\n' >"$TMP/dotted/shape.py" +( cd "$TMP/dotted" && git init -q -b main . && git add -A \ + && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 +for sel in 'Shape.area' 'Shape#area'; do + D="$( "$BIN" "$TMP/dotted" --whereis="$sel" --no-cache 2>/dev/null )"; DR="$( printf '%s' "$D" | wroot )" + { printf '%s' "$D" | grep -q "" \ + && ! printf '%s' "$DR" | grep -q 'complete='; } \ + && ok "whereis (18k): $sel says the dotted selector was searched literally (retry=\"area\") and claims no complete=" \ + || { no "whereis (18k): $sel claims a measured zero for a selector it never resolved"; printf '%s\n' "$DR"; } +done +"$BIN" "$TMP/dotted" --whereis=area --no-cache 2>/dev/null | grep -q ']*p="shape.py" l="2" kind="def"' \ + && ok 'whereis (18k): the offered retry (the bare name) finds the method definition' \ + || no 'whereis (18k): the offered retry does not find the definition' +"$BIN" "$TMP/dotted" --whereis=area --no-cache 2>/dev/null | grep -q 'dotted-selector' \ + && no 'whereis (18k): a bare name grew a dotted-selector note' || ok 'whereis (18k): a bare name carries no dotted-selector note' + +# 18u) M3: a dotted LITERAL whose last segment the index does not define (a file name, a module path) is an ordinary +# literal search: no dotted note, and its complete= stands. The method spelling (18k) keeps the note. +printf 'Run setup.py first; see os.path docs.\n' >"$TMP/dotted/README.txt" +( cd "$TMP/dotted" && git add -A && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm readme ) >/dev/null 2>&1 +for sel in setup.py os.path; do + LD="$( "$BIN" "$TMP/dotted" --whereis="$sel" --no-cache 2>/dev/null )" + { printf '%s' "$LD" | wroot | grep -q ' hits="1".* complete="1"' && ! printf '%s' "$LD" | grep -q 'dotted-selector"'; } \ + && ok "whereis (18u): the literal $sel keeps hits=\"1\" complete=\"1\" and carries no dotted note" \ + || { no "whereis (18u): the literal $sel was treated as a Class.method selector"; printf '%s' "$LD" | wroot; } +done + +command -v xmllint >/dev/null 2>&1 && { printf '%s' "$W1" | xmllint --noout - 2>/dev/null \ + && ok 'whereis (18): the dirty-checkout document is well-formed' || no 'whereis (18): the dirty-checkout document fails xmllint'; } + [ $fail -eq 0 ] && printf 'completecheck: ALL PASS\n' || printf 'completecheck: FAILURES ABOVE\n' exit $fail diff --git a/test/crashsweepcheck.sh b/test/crashsweepcheck.sh index 04dc27059..d2b1cc35f 100755 --- a/test/crashsweepcheck.sh +++ b/test/crashsweepcheck.sh @@ -73,6 +73,7 @@ # Usage: bash test/crashsweepcheck.sh [BIN] RIPWIRE_ASAN_BIN=asan/ripwire bash test/crashsweepcheck.sh set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +export RIPWIRE_TESTLIB="$ROOT/test/lib" # shardmatch.py: the --match scan, complete past the engine budget . "$ROOT/test/lib/clean-env.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" @@ -110,26 +111,21 @@ from collections import Counter, defaultdict BIN, SRC, OUT = sys.argv[1], sys.argv[2], sys.argv[3] os.makedirs(OUT, exist_ok=True) +# The scan is SHARDED when the whole tree reaches the engine's hit budget (test/lib/shardmatch.py), so the rules read +# every site of src/ however large it grows; a scan that still cannot be completed exits 3, never a partial list. +sys.path.insert(0, os.environ["RIPWIRE_TESTLIB"]) +from shardmatch import ShardedMatch +_scanEnv = dict(os.environ) +# A sanitizer job may route reports to a log file (log_path=…); the scan's own runs report on stderr instead, +# so a sanitizer abort here names its cause in the gate output rather than as a bare rc=-6. +for key in ("ASAN_OPTIONS", "UBSAN_OPTIONS", "LSAN_OPTIONS"): + _scanEnv[key] = (_scanEnv[key] + ":" if _scanEnv.get(key) else "") + "log_path=stderr" +_budget = int(os.environ["RIPWIRE_SHARD_BUDGET"]) if os.environ.get("RIPWIRE_SHARD_BUDGET") else None +_scanner = ShardedMatch(BIN, SRC, os.path.join(OUT, "shards"), env=_scanEnv, budget=_budget) + def match(query): - """Run one --match over SRC; return [(file, line, fn, text)] and fail loudly if the scan was partial.""" - # A sanitizer job may route reports to a log file (log_path=…); the scan's own runs report on stderr instead, - # so a sanitizer abort here names its cause in the gate output rather than as a bare rc=-6. - env = dict(os.environ) - for key in ("ASAN_OPTIONS", "UBSAN_OPTIONS", "LSAN_OPTIONS"): - env[key] = (env[key] + ":" if env.get(key) else "") + "log_path=stderr" - proc = subprocess.run([BIN, SRC, "--match=" + query, "--limit=5000"], capture_output=True, text=True, env=env) - root = re.search(r"]*>", proc.stdout) - if proc.returncode != 0 or root is None: - print("SCANFAIL rc=%d query=%s stderr=%s" % (proc.returncode, query[:80], proc.stderr[-1200:])) - sys.exit(3) - if 'hits_capped="1"' in root.group(0): - print("SCANFAIL engine hit cap reached — the scan is partial: " + query[:80]) - sys.exit(3) - rows = [] - for p, fn, text in re.findall(r'(.*?)', proc.stdout, re.S): - f, _, ln = p.rpartition(":") - rows.append((f, int(ln), html.unescape(fn), html.unescape(text))) - return rows + """One --match over SRC, complete (sharded past the engine budget); [(file, line, fn, text)].""" + return _scanner.match(query) def pairs(rows): """Queries below bind a predicate capture then the payload capture: rows arrive in that order, two per match.""" @@ -812,5 +808,20 @@ else || note "B3: no sanitizer binary (RIPWIRE_ASAN_BIN) — the overflow this arm exists for is only observable in that build" fi +echo +echo "=== Z: the static scan is complete whatever the size of the tree it reads (test/lib/shardmatch.py) ===" +# The rules above read src/ through one --match per rule, and the engine stops at a fixed hit budget; src/ grows past it. +# The scan shards instead of stopping (shardmatch.py's header). (Z1) proves completeness on a generated tree past the +# budget; (Z2) proves a split changes no row, on src/ itself. +if python3 "$RIPWIRE_TESTLIB/shardmatch.py" selftest "$BIN" "$TMP/shardtest" "$SRC" >"$TMP/shardtest.txt" 2>&1; then :; fi +while IFS= read -r line; do + case "$line" in + PASS\ \ *) ok "${line#PASS }" ;; + FAIL\ \ *) no "${line#FAIL }" ;; + *) no "shard self-test: $line" ;; + esac +done <"$TMP/shardtest.txt" +grep -q '^PASS (Z1) the sharded scan' "$TMP/shardtest.txt" || no "(Z1) the size-independence arm did not run" + echo [ "$fail" -eq 0 ] && { echo "crashsweepcheck: ALL PASS"; exit 0; } || { echo "crashsweepcheck: SOME CHECKS FAILED"; exit 1; } diff --git a/test/deckcheck_allowlist.txt b/test/deckcheck_allowlist.txt index e08a8625e..67bbd49be 100644 --- a/test/deckcheck_allowlist.txt +++ b/test/deckcheck_allowlist.txt @@ -130,3 +130,5 @@ --is-inside-work-tree git's own rev-parse option ("git rev-parse --is-inside-work-tree"), quoted in the CHANGELOG entry for the nudge-hook bare-repo fix (2026-09-25) as the check whose exit-status-only reading missed the bare/`.git`-dir `false` case — not a ripwire flag --test Node's own test-runner flag ("node --test "), quoted in README.md and the CHANGELOG where --test-gate/--affected derive that command for a node:test file (#60) — not a ripwire flag --experimental-strip-types Node's own type-stripping flag ("node --experimental-strip-types --test "), quoted in README.md and the CHANGELOG as the form derived for .ts tests where engines.node admits Node below 22.18 (#60) — not a ripwire flag +--numstat git's own flag ("git apply --numstat") quoted in docs/EVALS.md's map-data-Sections registration, which proves the MEAS patch deletes nothing +--tag bench/substitution_report.py's own option ("via --tag"), quoted in docs/EVALS.md's map-data-Sections registration (the per-repo FIND readout), not a ripwire flag diff --git a/test/editcheckdeclcheck.sh b/test/editcheckdeclcheck.sh new file mode 100644 index 000000000..32504eff7 --- /dev/null +++ b/test/editcheckdeclcheck.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash +# editcheckdeclcheck.sh — --edit-check on C/C++: a declaration and the definition it declares are ONE contract. +# +# The field report: a header declares `int scale( int x, int factor = 2 )`, the .cpp defines it, three callers pass 1, 1 +# and 2 arguments, and a trailing `int bias = 0` is added to both. Every call still compiles. The verb answered: +# R1 --edit-check=scale refused as "2 distinct contracts" (the prototype and its definition); +# R2 --edit-check=./lib.cpp:scale incompatible="3" — the defaults live on the HEADER declaration and the arity test +# read the definition's parameter list only; +# R3 --edit-check=./lib.h:scale refused again — the handle the refusal itself suggested. +# +# Arms: +# (A) R1: the bare name answers (exit 0) about the definition, no refusal. +# (B) R2: the definition's handle reports incompatible="0", flags no caller, and says where the defaults came from +# (defaults_from="decl", defined in the same document's legend). +# (C) R3: the header handle answers too, with the same contract as (B). +# (D) the field-report shape on the MCP twin (edit_check symbol=scale): incompatible="0". +# (E) CONTROL — defaults never make a wildcard: a call with MORE arguments than the parameter list is still flagged, +# and only that caller is. +# (F) E3 decoy — two real overloads, scale(int,int=2,int=3) and scale(double,double,double,double): they stay two +# definitions (defs="2"), each takes defaults only from ITS declaration (a 1-argument call is accepted by the int +# overload alone, a 5-argument call by neither). +# (G) a declaration whose parameter TYPES match no definition is not folded and lends no defaults: the 1-argument +# call against `scale( double, int )` stays flagged, and the bare name stays refused. +# (H) E2 — every handle printed after "Qualify one contract:" is accepted on a rerun, on (G) and on a two-platform +# corpus (one declaration, two .cpp definitions, which is two contracts and must stay refused). +# (J) M2 — a header that drops a default over two platform definitions: no printed handle is the declaration's, +# whose own answer has no call edges (callers="0"), and the definitions' handles flag the broken call. +# (K) M3 — `./lib.cpp` beside `./sub/lib.cpp`, and paths with a space: every printed handle names one contract. +# (L) M1 — the identity is the FULL scope chain plus the member's cv/ref qualifiers: outer::detail::f vs ::detail::f, +# outer::a::f vs ::a::f, Outer::S::f vs ::S::f and a const member never share a default; a chain spelled two ways +# (namespace block vs qualified out-of-line definition) still pairs. +# (O) a namespace opened by a macro (in-file #define, or QT_BEGIN_NAMESPACE from another header) makes the chain +# unreadable: never paired, disclosed as defaults_untied=; a qualified definition after `using namespace` likewise. +# (M) S1 — comments in a parameter list (`/* = 2 */`, trailing `//`) do not block the pairing. +# (N) S2 — a declaration with matching types and defaults that the include proof cannot reach is disclosed as +# defaults_untied= beside the flags. +# (I) an unproven declaration (the definition's file does not include the header) lends no defaults. +# (P) an attribute group in a class head (`struct alignas( 8 ) S`, `struct __attribute__(( packed )) S`, +# `class __declspec( dllexport ) S`) is part of the head: the member's chain keeps `S`, so its declaration pairs. +# (Q) a function-pointer parameter is a list this reader cannot parse: the declaration is Unproven, never Different, so +# a caller relying on its default is disclosed by defaults_untied=; two overloads that both meet that one +# declaration count it once. +# +# Operates on private temp git repos. Needs git and python3. +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first"; exit 2; } +command -v git >/dev/null 2>&1 || { echo "git required"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } + +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +echo "editcheckdeclcheck: BIN=$BIN" + +commit(){ ( cd "$1" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init ) >/dev/null 2>&1 \ + || no "could not commit $1 — every arm on it would read no-baseline"; } +# ec → the document on stdout (full legend), stderr to $TMP/err, exit code to $TMP/rc +ec(){ ( cd "$1" && "$BIN" . --no-cache --edit-check="$2" --legend=full 2>"$TMP/err"; echo $? >"$TMP/rc" ); } +rc(){ cat "$TMP/rc"; } +# the root element, and one attribute off it (empty when absent) +root(){ printf '%s' "$1" | grep -oE ']*>' | head -1; } +attr(){ printf '%s' "$1" | python3 -c 'import re,sys; m=re.search(r"(?]*incompatible=\"1\"",sys.stdin.read())))'; } +legend(){ printf '%s' "$1" | python3 -c 'import re,sys; m=re.match(r"\A(?:\s*)+",sys.stdin.read(),re.S); sys.stdout.write(m.group(0) if m else "")'; } + +# ── the field-report corpus ─────────────────────────────────────────────────────────────────────────────── +F="$TMP/field"; mkdir -p "$F" +printf 'int scale( int x, int factor = 2 );\n' >"$F/lib.h" +printf '#include "lib.h"\nint scale( int x, int factor )\n{\n return x * factor;\n}\n' >"$F/lib.cpp" +printf '#include "lib.h"\nint a() { return scale( 1 ); }\nint b() { return scale( 2 ); }\nint c() { return scale( 3, 4 ); }\n' >"$F/use.cpp" +commit "$F" +printf 'int scale( int x, int factor = 2, int bias = 0 );\n' >"$F/lib.h" +printf '#include "lib.h"\nint scale( int x, int factor, int bias )\n{\n return x * factor + bias;\n}\n' >"$F/lib.cpp" + +echo "=== (A) R1: the bare name is one contract, not two ===" +OA="$( ec "$F" scale )"; RCA="$( rc )"; RA="$( root "$OA" )" +if [ "$RCA" = 0 ] && [ -n "$RA" ]; then + ok "(A) --edit-check=scale answers (exit 0) instead of refusing a prototype and its definition as two contracts" + case "$( attr "$RA" p )" in + lib.cpp:2) ok "(A) the answer is about the definition (p=\"lib.cpp:2\")" ;; + *) no "(A) p=\"$( attr "$RA" p )\", expected the definition lib.cpp:2" ;; + esac + [ "$( attr "$RA" incompatible )" = 0 ] \ + && ok "(A) incompatible=\"0\": every call still binds through the header's defaults" \ + || no "(A) incompatible=\"$( attr "$RA" incompatible )\", expected 0 — flags: $( flagged "$OA" )" +else + no "(A) --edit-check=scale exited $RCA: $( cat "$TMP/err" )" +fi + +echo "=== (B) R2: the definition's handle reads the defaults off its declaration ===" +OB="$( ec "$F" ./lib.cpp:scale )"; RB="$( root "$OB" )" +if [ "$( rc )" = 0 ] && [ -n "$RB" ]; then + [ "$( attr "$RB" incompatible )" = 0 ] && [ -z "$( flagged "$OB" )" ] \ + && ok "(B) incompatible=\"0\" and no caller row flagged (callers=\"$( attr "$RB" callers )\")" \ + || no "(B) incompatible=\"$( attr "$RB" incompatible )\" flags=[$( flagged "$OB" )] — the defaults on lib.h admit all three calls" + [ "$( attr "$RB" callers )" = 3 ] && ok "(B) premise: callers=\"3\" (the arm is about three real call sites)" \ + || no "(B) premise broken: callers=\"$( attr "$RB" callers )\", expected 3" + [ "$( attr "$RB" status )" = contract-change ] && [ "$( attr "$RB" params_now )" = 3 ] \ + && ok "(B) the edit is still reported: status=\"contract-change\" params_was=\"$( attr "$RB" params_was )\" params_now=\"3\"" \ + || no "(B) status=\"$( attr "$RB" status )\" params_now=\"$( attr "$RB" params_now )\" — the widened list must still read as a contract change" + [ "$( attr "$RB" defaults_from )" = decl ] \ + && ok "(B) defaults_from=\"decl\" names where the arity range came from" \ + || no "(B) defaults_from=\"$( attr "$RB" defaults_from )\", expected decl" + case "$( legend "$OB" )" in + *'defaults_from='*) ok "(B) the legend defines defaults_from=" ;; + *) no "(B) defaults_from= is emitted but not defined in the document's legend" ;; + esac +else + no "(B) --edit-check=./lib.cpp:scale exited $( rc ): $( cat "$TMP/err" )" +fi + +echo "=== (C) R3: the header's handle is accepted and answers the same contract ===" +OC="$( ec "$F" ./lib.h:scale )"; RC3="$( root "$OC" )" +if [ "$( rc )" = 0 ] && [ -n "$RC3" ]; then + ok "(C) --edit-check=./lib.h:scale answers (exit 0)" + for k in p status incompatible callers defs; do + [ "$( attr "$RC3" "$k" )" = "$( attr "$RB" "$k" )" ] \ + || no "(C) $k=\"$( attr "$RC3" "$k" )\" differs from the definition handle's \"$( attr "$RB" "$k" )\"" + done + ok "(C) p/status/incompatible/callers/defs agree with --edit-check=./lib.cpp:scale" +else + no "(C) --edit-check=./lib.h:scale exited $( rc ): $( cat "$TMP/err" )" +fi + +echo "=== (D) the MCP twin answers the field-report shape the same way ===" +python3 - "$F" <<'PY' | "$BIN" --mcp >"$TMP/mcp.rpc" 2>/dev/null +import json, sys +print( json.dumps( { "jsonrpc": "2.0", "id": 1, "method": "initialize" } ) ) +print( json.dumps( { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "edit_check", "arguments": { "path": sys.argv[1], "symbol": "scale", "legend": "full" } } } ) ) +PY +OD="$( python3 -c ' +import json,sys +lines=[l for l in open(sys.argv[1]).read().splitlines() if l.strip()] +r=json.loads(lines[-1]) if lines else {} +sys.stdout.write("__ERROR__:"+str(r.get("error",{}).get("message","no response")) if "result" not in r else r["result"]["content"][0]["text"])' "$TMP/mcp.rpc" 2>/dev/null )" +RD="$( root "$OD" )" +[ -n "$RD" ] && [ "$( attr "$RD" incompatible )" = 0 ] && [ "$( attr "$RD" defaults_from )" = decl ] \ + && ok "(D) MCP edit_check symbol=scale: incompatible=\"0\" defaults_from=\"decl\"" \ + || no "(D) MCP edit_check symbol=scale: ${OD:0:300}" + +echo "=== (E) CONTROL: defaults never make a wildcard ===" +E="$TMP/over"; mkdir -p "$E"; cp "$F"/lib.h "$F"/lib.cpp "$F"/use.cpp "$E/" +printf 'int d() { return scale( 1, 2, 3, 4 ); }\n' >>"$E/use.cpp" +commit "$E" +OE="$( ec "$E" ./lib.cpp:scale )"; RE="$( root "$OE" )" +[ "$( attr "$RE" incompatible )" = 1 ] && [ "$( flagged "$OE" )" = d ] \ + && ok "(E) the 4-argument call to a 3-parameter scale is flagged, and only it (incompatible=\"1\", d)" \ + || no "(E) incompatible=\"$( attr "$RE" incompatible )\" flags=[$( flagged "$OE" )], expected 1 and [d]" + +echo "=== (F) E3 decoy: real overloads stay two definitions, each with its own declaration's defaults ===" +D="$TMP/decoy"; mkdir -p "$D" +printf 'int scale( int x, int f = 2, int g = 3 );\ndouble scale( double x, double y, double z, double w );\n' >"$D/lib.h" +printf '#include "lib.h"\nint scale( int x, int f, int g ) { return x + f + g; }\ndouble scale( double x, double y, double z, double w ) { return x + y + z + w; }\n' >"$D/lib.cpp" +printf '#include "lib.h"\nint a1() { return scale( 1 ); }\nint a3() { return scale( 1, 2, 3 ); }\ndouble a4() { return scale( 1.0, 2.0, 3.0, 4.0 ); }\nint a5() { return scale( 1, 2, 3, 4, 5 ); }\n' >"$D/use.cpp" +commit "$D" +OF="$( ec "$D" scale )"; RF="$( root "$OF" )" +if [ "$( rc )" = 0 ] && [ -n "$RF" ]; then + [ "$( attr "$RF" defs )" = 2 ] && [ "$( printf '%s' "$OF" | grep -oE ']*params="[0-9]+"' | grep -oE 'params="[0-9]+"' | tr '\n' ' ' )" = 'params="3" params="4" ' ] \ + && ok "(F) defs=\"2\": the two overloads stay two definitions (params 3 and 4), the declarations fold beside them" \ + || no "(F) defs=\"$( attr "$RF" defs )\" def rows: $( printf '%s' "$OF" | grep -oE ']*>' | tr '\n' ' ' )" + [ "$( flagged "$OF" )" = a5 ] \ + && ok "(F) only the 5-argument call is flagged; the 1-argument call binds the int overload through ITS defaults" \ + || no "(F) flags=[$( flagged "$OF" )], expected [a5] — a1 flagged means the int overload's defaults went to the double one" +else + no "(F) --edit-check=scale on two overloads exited $( rc ): $( cat "$TMP/err" )" +fi + +echo "=== (G) a declaration whose parameter TYPES match no definition lends nothing ===" +G="$TMP/mismatch"; mkdir -p "$G" +printf 'int scale( int x, int f = 2 );\n' >"$G/lib.h" +printf '#include "lib.h"\nint scale( double x, int f ) { return int( x ) * f; }\n' >"$G/lib.cpp" +printf '#include "lib.h"\nint a1() { return scale( 1 ); }\n' >"$G/use.cpp" +commit "$G" +OG="$( ec "$G" ./lib.cpp:scale )"; RG="$( root "$OG" )" +[ "$( flagged "$OG" )" = a1 ] && [ -z "$( attr "$RG" defaults_from )" ] \ + && ok "(G) scale( double, int ) takes no defaults from scale( int, int = 2 ): a1 stays flagged, no defaults_from=" \ + || no "(G) flags=[$( flagged "$OG" )] defaults_from=\"$( attr "$RG" defaults_from )\" — a different parameter-type list is a different contract" +ec "$G" scale >/dev/null +[ "$( rc )" = 1 ] && grep -q 'Qualify one contract' "$TMP/err" \ + && ok "(G) the bare name stays refused: the declaration and the definition are two contracts here" \ + || no "(G) --edit-check=scale exited $( rc ), expected the ambiguity refusal: $( cat "$TMP/err" )" + +echo "=== (H) E2: every handle the refusal prints is accepted on a rerun ===" +P="$TMP/platform"; mkdir -p "$P" +printf 'int scale( int x, int f = 2 );\n' >"$P/lib.h" +printf '#include "lib.h"\nint scale( int x, int f ) { return x * f; }\n' >"$P/posix.cpp" +printf '#include "lib.h"\nint scale( int x, int f ) { return x + f; }\n' >"$P/win.cpp" +printf '#include "lib.h"\nint a1() { return scale( 1 ); }\n' >"$P/use.cpp" +commit "$P" +# The handles a refusal lists, one per line: split on ", " (a quoted handle may hold a space), quotes removed. +handles(){ sed -n 's/.*Qualify one contract: \(.*\) — e\.g\..*/\1/p' "$TMP/err" | sed 's/ (+[^)]*)//g' | awk 'BEGIN{RS=", "} {sub(/\n$/,""); gsub(/^'"'"'|'"'"'$/,""); if (length) print}'; } +example(){ sed -n "s/.* — e\.g\. --edit-check=\(.*\)$/\1/p" "$TMP/err" | sed "s/^'//; s/'\$//"; } +roundTrip(){ # roundTrip