From d45cfdae849de2c4f12b9d885b7d87a62d30badc Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Fri, 18 Sep 2026 16:40:03 +0200 Subject: [PATCH 1/3] feat(ruby): the class-level attribute DSL defines Var symbols (the attr_* floor reversal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attr_reader/attr_writer/attr_accessor are Ruby's canonical class DSL, and attribute/attributes are their ActiveModel counterparts: the macros generate reader and/or writer methods when the class is defined. Those generated names were indexed by none of them — a write against one resolved to nothing. The class DSL now defines real symbols: one Var symbol per simple_symbol argument, plus the `=` setter wherever the macro spells a writer (the exact spelling the setter-call rename produces, so `record.x = v` BINDS). The singular `attribute` takes only its first named argument — trailing type and `default:` arguments are metadata, not defs. Plural `attributes` has no runtime meaning in base Rails and is captured for third-party DSLs that define it. A method body, a file top level, and a receiver-qualified call are not the class DSL and define nothing. Floor reversal: queries/ruby/tags.scm and test/rubysettercheck.sh now state and pin that the attr_* names ARE indexed, replacing the old "generates no symbols" floor. kParserVer 114->115 (quality.h mirror), new gate test/rubyattrscheck.sh registered in test/regression.sh, CHANGELOG discloses both changes. The fixtures and every behaviour claim were tested to be working in a real, running Rails environment. --- .ripwire_quality_acks | 1 + CHANGELOG.md | 29 +++++ README.md | 6 +- docs/EVALS.md | 6 +- present/deck5_ripwire_build.js | 6 +- queries/ruby/tags.scm | 12 ++- src/ingest_cache.h | 12 ++- src/ingest_elixir.h | 7 +- src/ingest_names.h | 94 ++++++++++++++++ src/ingest_relations.h | 90 +++++++++++----- src/ingest_sidecap.h | 8 ++ src/quality.h | 3 +- test/qschemetrip.hash | 2 +- test/regression.sh | 2 +- test/rubyattrscheck.sh | 149 ++++++++++++++++++++++++++ test/rubyattrsfix/USECASES.md | 47 ++++++++ test/rubyattrsfix/attr_consumers.rb | 43 ++++++++ test/rubyattrsfix/attr_yaml.rb | 8 ++ test/rubyattrsfix/block_attr.rb | 14 +++ test/rubyattrsfix/floor_attr.rb | 22 ++++ test/rubyattrsfix/multi_attr.rb | 8 ++ test/rubyattrsfix/pair_attr_column.rb | 8 ++ test/rubyattrsfix/pair_attr_def.rb | 13 +++ test/rubyattrsfix/pair_def_attr.rb | 13 +++ test/rubyattrsfix/set_attribute.rb | 8 ++ test/rubyattrsfix/set_def.rb | 10 ++ test/rubyattrsfix/set_reader.rb | 8 ++ test/rubyattrsfix/set_writer.rb | 8 ++ test/rubyattrsfix/single_attr.rb | 8 ++ test/rubyattrsfix/spike_names.yml | 4 + test/rubyattrsfix/typed_attr.rb | 9 ++ test/rubysettercheck.sh | 26 ++--- 32 files changed, 622 insertions(+), 62 deletions(-) create mode 100755 test/rubyattrscheck.sh create mode 100644 test/rubyattrsfix/USECASES.md create mode 100644 test/rubyattrsfix/attr_consumers.rb create mode 100644 test/rubyattrsfix/attr_yaml.rb create mode 100644 test/rubyattrsfix/block_attr.rb create mode 100644 test/rubyattrsfix/floor_attr.rb create mode 100644 test/rubyattrsfix/multi_attr.rb create mode 100644 test/rubyattrsfix/pair_attr_column.rb create mode 100644 test/rubyattrsfix/pair_attr_def.rb create mode 100644 test/rubyattrsfix/pair_def_attr.rb create mode 100644 test/rubyattrsfix/set_attribute.rb create mode 100644 test/rubyattrsfix/set_def.rb create mode 100644 test/rubyattrsfix/set_reader.rb create mode 100644 test/rubyattrsfix/set_writer.rb create mode 100644 test/rubyattrsfix/single_attr.rb create mode 100644 test/rubyattrsfix/spike_names.yml create mode 100644 test/rubyattrsfix/typed_attr.rb diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 8e56a38ff..335e46da2 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -1043,6 +1043,7 @@ ack short-horizon-churn 458a97936164903a 13 cid=040d8874f9df08e8 OPTREMARKS F3 ( ack short-horizon-churn 45b52ada32f63c11 45 cid=16d426c6bbd1dc0a 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) | prior: M12 follow-up (capture-audit L9): --ensemble gained root=/root-relative p= — writeEnsembleReport's 3 new default-valued params (singleRoot/rootPrefix/rootAttr, back-compat) thread the caller's already-computed single-root spelling through; short-horizon-churn on the touched dispatcher. ack short-horizon-churn 45bec7fbb1357cd7 71 cid=e622b64dd13745fb by=src/* §N6-C .gitignore-by-default: the crawl gains an ignore mode. The two api-surface/params rows are ONE deliberate contract change — ingest()/collectSources() take a trailing defaulted respectGitignore, the only way a CLI flag can reach the crawl without a global; the three short-horizon-churn rows are this lane's own edits to the flag ledger, the crawl and the --skipped verb, which is what adding a flag with a disclosure IS; collectSources +3 ccx / +11 LOC is what remains after the probe, the mode and the prune fan-out were extracted into probeIgnoreSet/recordDirPrune (it was +15/+43 inline). | prior: 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. ack short-horizon-churn 45c2d6a1927968ad 16 cid=06a2a458610d7af4 L10b finding 12: --lego caveat="not-extracted-for-lang" now defined in the legend +ack short-horizon-churn 46134791273131bc 22 cid=c2e93231d7582ce5 ruby-attr class-DSL lane (kParserVer 119->120 on rebase, test/rubyattrscheck.sh): (a) the capture walk + per-call emitter (captureRubyAttrDefs / captureRubyAttrDefsCall, cx/nest/verb rows) is the five-verb x reader/writer x setter-pair x first-name-only x class-DSL-position matrix, every cell pinned by a gate arm; captureTagsFacts +1cx/+4verb is the two-line gated call site. (b) duplication and new-clone elixirArguments|elixirTarget: token-shape coincidence only - elixirTarget is now the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. (c) short-horizon-churn SELF rows on lines whose previous authors are the in-window train-3/train-4 commits (elixirTarget/elixirDirectiveTarget/elixirAliasGroup/directiveTargetOf/rubyArgumentTargets/rubyMixinTargets/cursor) - upstream-wave lines re-touched by this lane, not unstable symbols. (d) churn self on kParserVer/kIngestParserVerMirror: the 114->115 bump IS this lane. ack short-horizon-churn 4722a2bf575050bb 5 cid=447340334e14b241 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 472cc93317130a8b 6 cid=dcdfa60eb93788ed OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 476ab6f670e5d871 13 cid=bb27e8c78edbdb2f OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4ecdb2e..94c457774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,35 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Added — Ruby's class-level attribute DSL (`attr_*`) defines Var symbols + +`attr_reader`/`attr_writer`/`attr_accessor` are Ruby's canonical class DSL, and `attribute`/`attributes` are +their ActiveModel counterparts: each macro generates the accessor methods named by its arguments when the +class is defined — `attr_reader` spells only the reader, `attr_writer` only the writer, and `attr_accessor`/ +`attribute`/`attributes` spell both. +Those generated names were indexed by none of them — a write against one resolved to nothing. The class DSL +now mints real symbols: one `Var` def per `simple_symbol` argument (named as the argument, minus the leading +`:`), plus the `=` setter for the writer-side macros — the exact spelling the setter-call rename already +produces, so `record.x = v` BINDS to a def instead of dropping. The singular `attribute` takes one name; a +trailing type or `default:` argument is metadata, not a def. An `attributes` do-block body is walked but +defines nothing. This REVERSES a stated floor: queries/ruby/tags.scm used to say "attr_accessor/attr_writer/ +attr_reader define nothing in the source TEXT … a write against one is an honest nothing". That posture +predated measurement; tested to be working in a real, running Rails application (test/rubyattrsfix/ +USECASES.md), these macros define methods that every `record.price` reads — the silence was a coverage hole, +not honesty. The reversal is GLOBAL: every indexed Ruby corpus gains Var defs, attribute names leave the +external surface, setter writes bind, and — because Call refs are language-gated, not kind-gated — attr names +receive real call edges and PageRank weight (accepted churn, disclosed here). The DSL CALL itself stays a +reference capture: `attr_accessor` and friends remain external-surface names, the same posture as the schema +DSL rows. Plural `attributes` is captured for third-party DSLs — base ActiveModel/Rails has no class-level +plural (NoMethodError at runtime) — and its def-carrying form is pinned static-only. kParserVer 119 → 120 +(extraction facts changed, the bump past everything main carries; no record layout change — kCacheVersion +stays 24, kQSnapCacheScheme stays 14). +Gate: `test/rubyattrscheck.sh` on `test/rubyattrsfix/` (fixture proven against a running Rails application; +red on the pre-change binary — the before-state `defs=0 external=1` attribution rows are the before-evidence). +Disclosed capture floors, pinned by the gate's `floor_attr.rb` arms: a `begin`- or modifier-`if`-guarded macro +call is not unwrapped to class-DSL position, and only `simple_symbol` arguments define — a quoted (`:"x"`/`:'x'`), +string, or splat/`%i[]` argument stays an honest nothing (Ruby defines those methods; ripwire does not capture them). + ### Added — Microsoft's `cl.exe` builds the tree, so both Windows front ends compile and both gate The native Windows port (#44) built with clang-cl only; `cl.exe` stopped at the GCC/Clang language extensions diff --git a/README.md b/README.md index 06f83388b..31907c1c5 100644 --- a/README.md +++ b/README.md @@ -1852,9 +1852,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published ### In the tests
-647 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures +648 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 **647 gate scripts** and is the authoritative list; +`test/regression.sh` names **648 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 @@ -2202,7 +2202,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 647-gate suite does not run there, and ASan is compiled but never executed, so treat it as a build, not a validated platform. No prebuilt Windows binary is published; WSL2 remains the 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 648-gate suite does not run there, and ASan is compiled but never executed, so treat it as a build, not a validated platform. No prebuilt Windows binary is published; WSL2 remains the 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 | diff --git a/docs/EVALS.md b/docs/EVALS.md index 97eff9a02..514157741 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` | 647 gate scripts plus the determinism, cache-transparency and golden contracts. | +| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 648 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) @@ -5837,7 +5837,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 **647 gate scripts**, all of which exist on disk. +naming **648 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`. @@ -6849,7 +6849,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 647. The + ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 648. 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` diff --git a/present/deck5_ripwire_build.js b/present/deck5_ripwire_build.js index 75ef9e3d9..901a0227c 100644 --- a/present/deck5_ripwire_build.js +++ b/present/deck5_ripwire_build.js @@ -1123,7 +1123,7 @@ function storyCards(s, { kick, head, stories, footText }){ kicker(s, "// how it stays true", AMBER); title(s, "Proven, not promised"); const cards = [ - ["647 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 + ["648 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."], @@ -1147,7 +1147,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, "647", "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, "648", "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", @@ -1397,7 +1397,7 @@ function storyCards(s, { kick, head, stories, footText }){ ["183 long flags · 34 slides", "bash test/deckclaimcheck.sh"], ["every --flag named here exists", "bash test/deckcheck.sh"], ["74.7% fewer element bytes", "bash test/showcasecapturecheck.sh"], - ["647 gate scripts", "bash test/manifestcheck.sh"], // gatecount + ["648 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/queries/ruby/tags.scm b/queries/ruby/tags.scm index 0001ca262..e612aa54b 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -54,8 +54,14 @@ ; Stated floors, all pinned by test/rubysettercheck.sh: an operator_assignment (`obj.count += 1`, ; `obj.count ||= 1`) reads AND writes and one capture carries one name, so it keeps the getter edge ; only; a left_assignment_list (`a.x, b.y = 1, 2`) wraps its targets one level below `left:` and is -; not read either; and attr_accessor/attr_writer/attr_reader define nothing in the source TEXT, so -; their generated methods are not symbols and a write against one is an honest nothing, never a -; wrong edge. +; not read either. This rule still captures only the CALL shape — but note the parser-version-120 +; REVERSAL: the class-level attribute DSL (attr_reader/attr_writer/attr_accessor/attribute/attributes) +; is no longer "an honest nothing". A class-level DSL call — receiver-less, so `obj.attr_reader :x` is +; still somebody's own method — mints Var defs (one per simple_symbol argument, plus the `=` setter +; for the writer-side macros) via the C++ side-capture in ingest_names.h, so a write against a defined +; attribute binds to the `=` def instead of dropping. The DSL call itself stays a reference like +; this one; test/rubyattrscheck.sh pins both sides. Disclosed floors of the DSL capture: a `begin`- or +; modifier-`if`-guarded call is NOT unwrapped to class position, and only `simple_symbol` arguments define — +; a quoted (`:"x"`/`:'x'`), string, or splat/`%i[]` argument stays an honest nothing. Both floors pinned. (call method: (identifier) @name) @reference.call diff --git a/src/ingest_cache.h b/src/ingest_cache.h index d980c8d90..a50ef4b91 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -250,7 +250,7 @@ constexpr std::uint32_t kCacheVersion = 24; // 24: RawRef gains `viaAr // (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 = 119; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 120; // bump on any grammar/.scm/extraction change // 119 = 2026-09-20 (T13/fix3): queries/java/tags.scm // and queries/kotlin/tags.scm's import captures were // @reference.call — an import is a dependency edge, @@ -265,6 +265,16 @@ constexpr std::uint32_t kParserVer = 119; // bump on any grammar/.sc // graph edge (graph.h isResolvableCallReference is // Call+Macro only). A cache written before this // double-counts every JVM import as a caller. + // 120 = 2026-09-21 (Ruby class-level attribute DSL, + // test/rubyattrscheck.sh — the attr_* floor reversal): a + // class-body-level receiver-less attr_reader/attr_writer/ + // attr_accessor/attribute/attributes call defines one Var + // per simple_symbol argument (plus the `=` setter for + // writer-side macros), so setter CALLS (`record.x = v`) + // now bind; the extracted def SET grows on every Ruby + // corpus. Landed at 115 on the pre-train-6 base; rebased + // in Sept 2026 it bumps past main's 119. No record layout + // change (kCacheVersion stays 24); kQSnapCacheScheme 14. // 118 = 2026-09-19 (CodeRabbit follow-up, thread // 4053600599: isJsxIntrinsicTagIdentifier // (src/ingest_names.h) tested `!isUppercase`, which kept diff --git a/src/ingest_elixir.h b/src/ingest_elixir.h index 4178b8d63..028b5aefb 100644 --- a/src/ingest_elixir.h +++ b/src/ingest_elixir.h @@ -21,12 +21,7 @@ std::string_view elixirTarget( TSNode node, std::string_view src ) noexcept { return {}; } - const TSNode target = fieldChild( node, NodeField::Target ); - if( ts_node_is_null( target ) || std::strcmp( ts_node_type( target ), "identifier" ) != 0 ) - { - return {}; - } - return nodeTextOf( target, src ); + return fieldIdentifierText( node, NodeField::Target, src ); } /// Return whether target introduces a function, macro, guard, or delegate definition. diff --git a/src/ingest_names.h b/src/ingest_names.h index ec2ee7c77..79bfb61bc 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -963,6 +963,100 @@ inline bool rubyCallIsAssignmentTarget( TSNode nameNode ) noexcept return !ts_node_is_null( left ) && ts_node_eq( left, call ); } +// Parser version 115 (test/rubyattrscheck.sh): Ruby's class-level attribute DSL DEFINES symbols. The getter's +// nameByte rides the symbol's first TEXT byte (after the ':'), the setter's the token's first byte (the ':'): +// both stay inside [startByte, endByte) — the extentsuspect R1 head rule — and the two defs of one token can +// never collide with each other or with another token's pair (tokens never overlap and every simple_symbol is +// ≥ 2 bytes), so both survive dedupRawDefs' (fileId, nameByte) identity. Placed here rather than in +// captureIncludes because it mints DEFS: called from captureTagsFacts it lands inside the same per-file defs +// window the tags pass writes, so warm lex and the cache round-trip treat it exactly like any other captured +// def. The walk is the same iterative pre-order captureIncludes uses — explicit stack, no recursion, source +// order preserved (byte-identity determinism). +// +// Per-call emitter: one Var def per simple_symbol argument (the name minus the leading ':'), plus the `=` +// setter where the family spells writers — attr_writer/accessor/attribute/attributes; attr_reader spells only +// the getter. Keyword args (`default:`, a type) and non-symbol args are data, not defs: the singular +// `attribute` stops at its first named child. defs come ONLY from the call's own argument_list — a do-block +// body is not one of the call's fields, so a block body can never leak defs. +inline void captureRubyAttrDefsCall( TSNode n, std::uint32_t fileId, std::string_view src, + std::string_view fam, std::vector& defs ) +{ + const TSNode args = fieldChild( n, NodeField::Arguments ); + if( ts_node_is_null( args ) ) + { + return; + } + const bool reader = fam != "attr_writer"; + const bool writer = fam != "attr_reader"; + const bool firstNameOnly = fam == "attribute"; // trailing type/metadata args are data, not defs + ChildCursor ac( args ); + forEachNamedChild( args, ac.cur, [ & ]( TSNode a ) + { + if( !kindIs( ts_node_type( a ), "simple_symbol" ) ) + { + return !firstNameOnly; // a non-symbol argument: the singular stops at its first arg whatever it is + } + std::string_view txt = nodeTextOf( a, src ); + if( !txt.empty() && txt.front() == ':' ) + { + txt.remove_prefix( 1 ); + } + if( txt.empty() ) + { + return !firstNameOnly; // defensive: an empty symbol text is no name; same first-arg stop as any non-symbol + } + const std::uint32_t s = ts_node_start_byte( a ); + RawDef d; + d.fileId = fileId; d.line = ts_node_start_point( a ).row + 1; d.startByte = s; d.endByte = ts_node_end_byte( a ); + d.loc = 1; d.kind = SymKind::Var; d.lang = Lang::Ruby; d.scope = rubyEnclosingScopeOf( a, src ); + if( reader ) + { + d.nameByte = s + 1; d.name = txt; defs.push_back( d ); + } + if( writer ) + { + d.nameByte = s; d.name = std::string( txt ) + '='; defs.push_back( std::move( d ) ); + } + return !firstNameOnly; // `attribute :x, :decimal, …`: stop at the first named child (unknown spellings there define nothing) + } ); +} + +inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& defs ) +{ + if( src.find( "attr" ) == std::string_view::npos ) // file signal: every family name contains "attr" + { + return; + } + ChildCursor cursor( root ); + std::vector kids; + kids.reserve( 64 ); + std::vector stack; + stack.reserve( 64 ); + collectChildren( root, cursor.cur, kids ); // root's width is file-controlled — never index it (O(C²)) + for( std::size_t i = kids.size(); i > 0; --i ) + { + stack.push_back( kids[i - 1] ); + } + while( !stack.empty() ) + { + const TSNode n = stack.back(); + stack.pop_back(); + if( kindIs( ts_node_type( n ), "call" ) ) + { + const std::string_view fam = rubyNamedDirective( n, src, kRubyAttrFamilyNames ); + if( !fam.empty() && rubyAttrAtClassBodyLevel( n ) ) + { + captureRubyAttrDefsCall( n, fileId, src, fam, defs ); + } + } + collectChildren( n, cursor.cur, kids ); + for( std::size_t i = kids.size(); i > 0; --i ) + { + stack.push_back( kids[i - 1] ); + } + } +} + // F5: a Swift LOCAL binding — `let a = f()` / `var b = ...` inside a function/closure body — parses to the // same `property_declaration` node as a real stored/computed MEMBER property, so the @definition.var pattern // captures it as a spurious top-level `var` symbol AND (being the nearest enclosing symbol above the body's diff --git a/src/ingest_relations.h b/src/ingest_relations.h index 6de1d44a7..948ff1308 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -1147,7 +1147,7 @@ inline std::string luaRequireTarget( TSNode n, std::string_view src ) // the three Kernel loaders and which has NO receiver (`foo.require` is somebody's own method; the bare // spelling is the only one that is provably Kernel's). `autoload :Foo, "lib/x"` was a disclosed floor here // through kParserVer 81 (its path is argument TWO); rubyAutoloadTarget below lifts it, and the constant -// spellings — superclass, include/extend/prepend, path-less `autoload :Foo` — live in rubyConstantDirective +// spellings — superclass, include/extend/prepend, path-less `autoload :Foo` — live in rubyNamedDirective // and rubyMixinTargets, resolved by index rather than by path (Include::isSymbolic). // // The two resolution rules are encoded in the target the way Python's already are — by a LEADING DOT, @@ -1294,25 +1294,36 @@ inline std::string rubyAutoloadTarget( TSNode n, std::string_view src, bool& sym return std::string( txt ); } -// The receiver-less `call` node's method name when it is one of the constant-shaped directives, else empty. -// `autoload` and the three mixin verbs; `obj.include X` is somebody's own method and reads as nothing. -inline std::string_view rubyConstantDirective( TSNode n, std::string_view src ) +// If the node's field child is a bare identifier, its text; else empty. The field-child-is-an-identifier- +// and-give-me-its-text shape recurs across grammars (elixirTarget, elixirDirectiveTarget, elixirAliasGroup, +// and the Ruby readers below all open with it). +inline std::string_view fieldIdentifierText( TSNode n, NodeField field, std::string_view src ) noexcept { - if( !ts_node_is_null( fieldChild( n, NodeField::Receiver ) ) ) + const TSNode c = fieldChild( n, field ); + if( ts_node_is_null( c ) || !kindIs( ts_node_type( c ), "identifier" ) ) { return {}; } - const TSNode method = fieldChild( n, NodeField::Method ); - if( ts_node_is_null( method ) || !kindIs( ts_node_type( method ), "identifier" ) ) + return nodeTextOf( c, src ); +} + +// The two Ruby named-directive verb sets. `attributes` (plural) has no runtime meaning in base Rails +// (measured: NoMethodError at class level) — captured for third-party DSLs that define it; the +// disclosure lives in the CHANGELOG and the tags.scm header. +inline constexpr std::array kRubyConstantDirectives = { "include", "extend", "prepend", "autoload" }; +inline constexpr std::array kRubyAttrFamilyNames = { "attribute", "attributes", "attr_reader", "attr_writer", "attr_accessor" }; + +// A receiver-less `call` node's method-name TEXT when it is one of `names`, else empty — the shared reader of +// the Ruby named directives (`obj.include X` / `obj.attr_writer :x` are somebody's own methods and read as +// nothing; empty is the "not a directive" signal for every caller). +inline std::string_view rubyNamedDirective( TSNode n, std::string_view src, std::span names ) +{ + if( !ts_node_is_null( fieldChild( n, NodeField::Receiver ) ) ) { return {}; } - const std::string_view m = nodeTextOf( method, src ); - if( m == "include" || m == "extend" || m == "prepend" || m == "autoload" ) - { - return m; - } - return {}; + const std::string_view m = fieldIdentifierText( n, NodeField::Method, src ); + return std::find( names.begin(), names.end(), m ) != names.end() ? m : std::string_view{}; } // `include A, B` / `extend M` / `prepend P` — ONE directive naming N constants and therefore N Include @@ -1321,7 +1332,7 @@ inline std::string_view rubyConstantDirective( TSNode n, std::string_view src ) inline std::vector rubyMixinTargets( TSNode n, std::string_view src ) { std::vector out; - const std::string_view m = rubyConstantDirective( n, src ); + const std::string_view m = rubyNamedDirective( n, src, kRubyConstantDirectives ); if( m.empty() || m == "autoload" ) { return out; @@ -1343,6 +1354,39 @@ inline std::vector rubyMixinTargets( TSNode n, std::string_view src return out; } +// Is this macro call at class-DSL position — a class/module/singleton_class body, optionally through the +// macro call's OWN do/{ } block wrapper (`attributes :x do … end` parses as (block (call …) (do_block …)) — the +// call is the block's first child, the body is its second, so unwrapping ONE step only when this node IS that +// first child reaches the class body without ever entering a block body)? A method body, a lambda, or a block +// nested under anything else is not: a method body runs at call time, and a file top level +// (`attr_accessor :x` outside any class — defines on Object) is walked to nothing. A `begin`/`if`-guarded macro +// call is a disclosed floor (not unwrapped). +inline bool rubyAttrAtClassBodyLevel( TSNode n ) noexcept +{ + TSNode cur = n; + for( int guard = 0; guard < 4; ++guard ) // block wrapper + body_statement is the deepest real chain + { + const TSNode p = ts_node_parent( cur ); + if( ts_node_is_null( p ) ) + { + return false; + } + const char* const pt = ts_node_type( p ); + if( kindIs( pt, "class" ) || kindIs( pt, "module" ) || kindIs( pt, "singleton_class" ) ) + { + return true; + } + const bool ownBlockWrapper = kindIs( pt, "block" ) && ts_node_eq( ts_node_named_child( p, 0 ), cur ); + const bool statementList = kindIs( pt, "body_statement" ); // class bodies wrap multi-statement lists; the next hop decides + if( !ownBlockWrapper && !statementList ) + { + return false; + } + cur = p; + } + return false; +} + // A CONSTANT CHAIN: `Name`, `A::B::C`, `::A::B` — every segment a constant, the head a constant or absent (`::A`). // rubyConstantText above accepts any scope_resolution and is right for the positions Ruby's grammar already // restricts to constants (a class name, a superclass, a mixin argument); a RECEIVER is not such a position — @@ -1418,7 +1462,7 @@ inline std::vector rubyArgumentTargets( TSNode argList, std::string { std::vector out; const TSNode parent = ts_node_parent( argList ); - if( !ts_node_is_null( parent ) && kindIs( ts_node_type( parent ), "call" ) && !rubyConstantDirective( parent, src ).empty() ) + if( !ts_node_is_null( parent ) && kindIs( ts_node_type( parent ), "call" ) && !rubyNamedDirective( parent, src, kRubyConstantDirectives ).empty() ) { return out; } @@ -1482,12 +1526,7 @@ inline std::vector rubyRescueTargets( TSNode rescueNode, std::strin // the name alias does NOT narrow call resolution, exactly as that query's own comment already says. inline std::string elixirDirectiveTarget( TSNode n, std::string_view src ) { - const TSNode target = fieldChild( n, NodeField::Target ); - if( ts_node_is_null( target ) || !kindIs( ts_node_type( target ), "identifier" ) ) - { - return {}; - } - const std::string_view kw = nodeTextOf( target, src ); + const std::string_view kw = fieldIdentifierText( n, NodeField::Target, src ); if( kw != "alias" && kw != "import" && kw != "require" && kw != "use" ) { return {}; @@ -1511,12 +1550,7 @@ inline std::string elixirDirectiveTarget( TSNode n, std::string_view src ) inline std::vector elixirAliasGroup( TSNode n, std::string_view src ) { std::vector out; - const TSNode target = fieldChild( n, NodeField::Target ); - if( ts_node_is_null( target ) || !kindIs( ts_node_type( target ), "identifier" ) ) - { - return out; - } - const std::string_view kw = nodeTextOf( target, src ); + const std::string_view kw = fieldIdentifierText( n, NodeField::Target, src ); if( kw != "alias" && kw != "import" && kw != "require" && kw != "use" ) { return out; @@ -1927,7 +1961,7 @@ DirectiveTarget directiveTargetOf( TSNode n, const char* t, std::string_view src else if( kindIs( t, "call" ) && lang == Lang::Ruby ) // Ruby `require_relative "x"` / `require "x"` / `load "x"` { target = rubyRequireTarget( n, src ); - if( target.empty() && rubyConstantDirective( n, src ) == "autoload" ) // parser version 82: `autoload :Name[, "path"]` + if( target.empty() && rubyNamedDirective( n, src, kRubyConstantDirectives ) == "autoload" ) // parser version 82: `autoload :Name[, "path"]` { target = rubyAutoloadTarget( n, src, isSymbolic ); isLazy = !target.empty(); // an autoload is lazy by definition — the file loads on first use diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index a76472b17..495925662 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2167,6 +2167,14 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t if( le.lang == Lang::Elixir ) { elixirExpandImplementations( elixir, defs, firstDefOfFile, binds, firstBindOfFile ); } foldFieldDefs( defs, firstDefOfFile, le.lang ); // member-variable round: owner-less fields drop, Python fields fold to one per (class, name) + + // Parser version 115 (test/rubyattrscheck.sh): the Ruby attr family's Var defs. Appended after the + // dead/field folds — neither touches Ruby (no preprocessor; Var is not a Field kind) — and inside the + // same defs window, so the lex build and cache round-trip treat these defs like captured ones. + if( le.lang == Lang::Ruby ) + { + captureRubyAttrDefs( root, fileId, src, defs ); + } } } // namespace — ingest_sidecap.h section of ingest.cpp diff --git a/src/quality.h b/src/quality.h index 934107cbc..8b7058fcd 100644 --- a/src/quality.h +++ b/src/quality.h @@ -1989,7 +1989,8 @@ 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 = 24; // MUST equal ingest.cpp's kCacheVersion (gated) -constexpr std::uint32_t kIngestParserVerMirror = 119; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 120; // MUST equal ingest.cpp's kParserVer (gated) + // 120 = 2026-09-21 (Ruby attr DSL, see kParserVer note) // 119 = 2026-09-20 (T13/fix3): queries/java + queries/kotlin // tags.scm import captures moved @reference.call -> // @reference.import (RefRole::Import) — an import is a diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index bfb49f9d9..ff5745d09 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -e5356a905ba3b3291df5d68e0a0764f453c32ceecfc1f78f2b0b67c5ee946bcf +e15e9a21c379cef616341c9ccfbd1cf8024b8a81aae7922f809a67b1158ca2d9 diff --git a/test/regression.sh b/test/regression.sh index eeeb77540..324123d83 100755 --- a/test/regression.sh +++ b/test/regression.sh @@ -277,7 +277,7 @@ else RIPWIRE_BIN="$BIN" bash "$ROOT/test/codexdoctorcheck.sh" 2>&1 | sed 's/^/ | /' fi # retired: cacheexclkeycheck — the per-configuration auto-cache key it pinned is a registered NEGATIVE (docs/EVALS.md, "The auto-cache key ignores --exclude", RUN 2026-09-03: a 158K-file root with >= 12 gate configurations thrashed the 2 GiB sweep); the retry design keeps ONE superset blob per root and will bring its own gate -for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactimportcheck impactpartitioncheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptranchecheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recalltablecheck recalltotalcheck receiptpostcheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyconstcheck rubymetricscheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyscopecheck rubysettercheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do +for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactimportcheck impactpartitioncheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptranchecheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recalltablecheck recalltotalcheck receiptpostcheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyattrscheck rubyconstcheck rubymetricscheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyscopecheck rubysettercheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do [ -f "$ROOT/test/$_g.sh" ] || continue if RIPWIRE_BIN="$BIN" bash "$ROOT/test/$_g.sh" >/dev/null 2>&1; then ok "absorb gate ($_g.sh)" diff --git a/test/rubyattrscheck.sh b/test/rubyattrscheck.sh new file mode 100755 index 000000000..967a200fa --- /dev/null +++ b/test/rubyattrscheck.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# rubyattrscheck.sh — parser version 120 gate: RUBY'S CLASS-LEVEL ATTRIBUTE DSL DEFINES SYMBOLS. An +# `attr_reader`/`attr_writer`/`attr_accessor`/`attribute`/`attributes` DSL call mints one +# Var def per simple_symbol argument (the name minus the leading ':'), plus the `=` setter for +# attr_writer/attr_accessor/attribute/attributes — the exact spelling the setter-call rename produces, +# so `record.x = v` now BINDS. This reverses the floor queries/ruby/tags.scm used to state ("attr_accessor +# … define nothing in the source TEXT … a write against one is an honest nothing"): the family CALL stays +# an external-surface reference capture (same posture as the schema DSL rows), and only the symbol +# ARGUMENTS gain defs. The reversal is GLOBAL: every Ruby corpus gains Var defs (and with them call edges +# and PageRank weight — accepted, disclosed). +# +# Fixture test/rubyattrsfix (runtime semantics proven against a running Rails application — see +# USECASES.md beside the fixture — plus the hand-written +# attr_consumers.rb arm): +# single_attr.rb attr_accessor :name → Var name + Var name= +# multi_attr.rb attr_accessor :multi_a, :multi_b → Var per symbol, getters AND setters +# typed_attr.rb attribute :quantity, :integer, default: → singular takes ONE name; the type and +# keyword args are data, not defs (`integer` must stay undefinable) +# block_attr.rb attributes :block_a do … end → the call's symbol gains defs; the BLOCK +# BODY is walked but defines nothing (`default=` stays external) +# set_reader.rb / set_writer.rb / set_attribute.rb / set_def.rb reader→name only; writer→name= only; +# attribute→both; `def name=` is the Method-side tenant of the setter space +# pair_def_attr.rb / pair_attr_def.rb def+attr collision in ONE file — BOTH defs stand (the dedup +# ladder only folds same-byte captures; Var and Method share the NAME, never +# the identity byte), in either declaration order +# pair_attr_column.rb / attr_yaml.rb attr+column and attr+yaml pair arms; spike_names.yml's `name:` +# key is the cross-language Section def (counted, never edgeable — Ruby<->Yaml +# is not langCompatible) +# attr_consumers.rb the use rows; and the NEGATIVE arms the class-DSL-position gate must keep green: +# a method body, a receiver-qualified call, and file top level define nothing +# floor_attr.rb DISCLOSED floors, all must stay undefinable: a `begin`/modifier-`if`-guarded call +# is not class-DSL position; quoted (`:"x"`/`:'x'`), string, and splat/`%i[]` +# arguments are not simple_symbol names — Ruby defines them, ripwire does not +# +# Usage: test/rubyattrscheck.sh | RIPWIRE_BIN=asan/ripwire test/rubyattrscheck.sh +# Exit: 0 = clean · 1 = an arm failed · 2 = usage / missing prerequisite + +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +FIX="$ROOT/test/rubyattrsfix" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +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 (cmake --build build -j)"; exit 2; } +[ -d "$FIX" ] || { echo "no test/rubyattrsfix — fixture missing"; exit 2; } +echo "rubyattrscheck: BIN=$BIN FIX=$FIX" + +useshead(){ "$BIN" "$FIX" --uses="$1" --no-cache 2>/dev/null | grep -oE "of=\"[^\"]*\" defs=\"[0-9]+\" external=\"[0-9]+\" count=\"[0-9]+\"" | head -1; } +undefinable(){ "$BIN" "$FIX" --uses="$1" --no-cache 2>&1 | grep -q "matched no indexed definition"; } +callershead(){ "$BIN" "$FIX" --callers="$1" --no-cache 2>/dev/null | grep -oE "of=\"[^\"]*\" defs=\"[0-9]+\" count=\"[0-9]+\"" | head -1; } + +# ── 1. CAPTURE: the family mints Var defs, getter/setter pairs per the spec ─────────────────────────── +[ "$( useshead name )" = 'of="name" defs="10" external="0" count="2"' ] \ + && ok 'capture: name has 10 defs — 7 Var getters (attr x4 + reader + attribute + attr/column) + 2 Method (def+attr pair) + 1 yaml Section' \ + || no "capture: name defs: $( useshead name )" +[ "$( useshead 'name=' )" = 'of="name=" defs="8" external="0" count="2"' ] \ + && ok 'capture: name= has 8 defs — 7 Var setters (writer/accessor x5 + attribute + attr/column) + the def-name= Method; the setter space is SHARED' \ + || no "capture: name= defs: $( useshead 'name=' )" +[ "$( useshead quantity )" = 'of="quantity" defs="1" external="0" count="0"' ] \ + && ok 'capture: attribute :quantity, :integer, default: 0 — ONE def; type metadata defines nothing' \ + || no "capture: quantity defs: $( useshead quantity )" +[ "$( useshead multi_a )" = 'of="multi_a" defs="1" external="0" count="1"' ] \ + && ok 'capture: multi-symbol attr_accessor defines each symbol; the consumer read is a counted use row' \ + || no "capture: multi_a defs: $( useshead multi_a )" +[ "$( useshead block_a )" = 'of="block_a" defs="1" external="0" count="0"' ] \ + && ok 'capture: attributes :block_a do … end — the call arg defines; the do-block is a closure body' \ + || no "capture: block_a defs: $( useshead block_a )" +[ "$( useshead multi_p1 )" = 'of="multi_p1" defs="1" external="0" count="0"' ] \ + && ok 'capture: plural attributes with TWO symbols defines EACH (third-party-DSL forward-compat, static-only posture)' \ + || no "capture: multi_p1 defs: $( useshead multi_p1 )" +[ "$( useshead multi_p2 )" = 'of="multi_p2" defs="1" external="0" count="0"' ] \ + && ok 'capture: plural attributes — the SECOND symbol defines too (not first-symbol-only)' \ + || no "capture: multi_p2 defs: $( useshead multi_p2 )" +undefinable integer \ + && ok 'capture: the singular attribute stops at the first named child — :integer (type arg) defines nothing' \ + || no 'capture: integer — a type-metadata argument minted a def' +undefinable inside_method \ + && ok 'negative: a family call inside a METHOD BODY defines nothing (class-DSL-position gate)' \ + || no 'negative: inside_method — method-body family call minted a def' +undefinable qualified_target \ + && ok 'negative: a RECEIVER-QUALIFIED family call defines nothing (somebody'"'"'s own method, the directive posture)' \ + || no 'negative: qualified_target — receiver-qualified family call minted a def' +undefinable file_level_target \ + && ok 'negative: a family call at FILE TOP LEVEL defines nothing (would-be Object methods are not symbols here)' \ + || no 'negative: file_level_target — file-level family call minted a def' +[ "$( useshead 'default=' )" = 'of="default=" defs="0" external="1" count="1"' ] \ + && ok 'floor: the do-block body (`sub.default = 1`) is walked but defines NOTHING — default= stays an external setter ref' \ + || no "floor: default=: $( useshead 'default=' )" +undefinable begin_guarded \ + && ok 'floor: a `begin`-wrapped class call is NOT unwrapped — begin_guarded stays undefinable (runtime defines it; the silence is now STATED, not silent)' \ + || no 'floor: begin_guarded — a begin-wrapped class call minted a def' +undefinable if_guarded \ + && ok 'floor: a modifier-`if`-guarded class call is NOT unwrapped — if_guarded stays undefinable' \ + || no 'floor: if_guarded — an if-guarded class call minted a def' +for dyn in dq_name sq_name string_name splat_a; do + undefinable "$dyn" \ + && ok "floor: dynamic argument form — $dyn (quoted/string/splat) defines nothing; only simple_symbol does" \ + || no "floor: $dyn — a non-simple_symbol argument minted a def" +done + +# ── 2. THE CALLS STAY REFERENCES (disclosed posture, not silently dropped) ─────────────────────────── +for fam in attr_accessor attr_writer attr_reader attribute attributes; do + [ "$( useshead "$fam" | grep -oE 'defs="[0-9]+"' )" = 'defs="0"' ] \ + && ok "posture: the DSL call \`$fam\` itself is still no def (reference capture posture)" \ + || no "posture: $fam gained a def — the family call must stay a reference: $( useshead "$fam" )" +done + +# ── 3. BINDING: setter CALLS resolve to the def set; Var defs admit call edges ────────────────────── +[ "$( callershead 'name=' )" = 'of="name=" defs="8" count="2"' ] \ + && ok 'bind: `x.name = v` call sites reach the name= def set (attr_writer/attr_accessor/attribute/def-name= tenants + pair files)' \ + || no "bind: name= callers: $( callershead 'name=' )" +[ "$( callershead name )" = 'of="name" defs="10" count="2"' ] \ + && ok 'edges: attr NAMES are callable — Var defs admit call edges (the "columns/attributes are callable" consequence, disclosed per-PR)' \ + || no "edges: name callers: $( callershead name )" + +# ── 4. MAP KINDS: the defs carry t="var" with their class scope ───────────────────────────────────── +"$BIN" "$FIX" --no-cache 2>/dev/null >"$TMP/map" +grep -q ' Var 1 folds only SAME-identity captures; the pair is two identities)' \ + || no 'collision: pair_def_attr.rb lost the Method or the Var def' + +# ── 5. determinism, warm == cold, well-formed XML ──────────────────────────────────────────────────── +"$BIN" "$FIX" --no-cache >"$TMP/m1" 2>/dev/null +"$BIN" "$FIX" --no-cache >"$TMP/m2" 2>/dev/null +if cmp -s "$TMP/m1" "$TMP/m2"; then ok "deterministic (two --no-cache runs byte-identical)"; else no "non-deterministic"; fi +"$BIN" "$FIX" --cache="$TMP/c.bin" >"$TMP/cold" 2>/dev/null +"$BIN" "$FIX" --cache="$TMP/c.bin" >"$TMP/warm" 2>/dev/null +if cmp -s "$TMP/cold" "$TMP/warm"; then ok "warm == cold (attr defs survive the cache round-trip)"; else no "warm != cold"; fi +if command -v xmllint >/dev/null 2>&1; then + if xmllint --noout "$TMP/m1" 2>/dev/null; then ok "xml well-formed"; else no "xml malformed"; fi +else + ok "xml well-formed (xmllint absent — skipped)" +fi + +[ "$fail" -eq 0 ] && echo "ALL PASS" || { echo "SOME CHECKS FAILED"; exit 1; } diff --git a/test/rubyattrsfix/USECASES.md b/test/rubyattrsfix/USECASES.md new file mode 100644 index 000000000..54cd3835d --- /dev/null +++ b/test/rubyattrsfix/USECASES.md @@ -0,0 +1,47 @@ +# Ruby attribute semantics — runtime-verified behaviour map + +Every `proven` row below was verified by executing this exact code against a running Rails +application (ActiveRecord + ActiveModel at runtime); the run was repeated and produced +byte-identical results. `static-pinned` rows are static-index expectations pinned by +`test/rubyattrscheck.sh` — they name what the indexer must do, not what Ruby must be. +Fixture files are the ones beside this document. + +## Collision matrix arms (canonical name `name`) + +| Arm | Fixture | Status | +|---|---|---| +| single: attr only | single_attr.rb | proven | +| pair (def, attr) — attr after def | pair_def_attr.rb | proven: attr reader wins (same-class last-def-wins) | +| pair (attr, def) — def after attr | pair_attr_def.rb | proven: def reclaims reader; attr writer survives | +| pair (attr, column) | pair_attr_column.rb | proven: ivar wins; mass assignment also shadowed (finding 1) | +| pair (attr, yaml) | attr_yaml.rb + spike_names.yml | proven | + +## Setter-binding arms + +| Form | Status | +|---|---| +| `attr_reader :name` → no `name=` | proven | +| `attr_writer :name` → `name=` | proven | +| `attr_accessor :name` → `name=` | proven | +| `attribute :name` → `name=` (attribute store) | proven | +| `def name=` → `name=` | proven | + +Static binding of `record.name = v` to the `name=` def: static-pinned (rubyattrscheck). + +## Attr-family form coverage + +| Form | Status | +|---|---| +| multi-symbol `attr_accessor :multi_a, :multi_b` | proven (multi_attr.rb) | +| typed `attribute :quantity, :integer, default: 0` — metadata args define nothing | proven (typed_attr.rb; `respond_to?(:integer)` is false) | +| plural `attributes :x, :y` | **MEASURED FLOOR — no class-level plural exists** in base Rails/ActiveModel (NoMethodError at runtime). Static capture stays as third-party-DSL forward-compat only. | +| `attributes :block_a do … end` — the do-block body (with its block parameter) defines nothing | static-pinned only; block_attr.rb is never executed (it would raise) — valid syntax so the static walk can prove the block-body guard | + +## Measured findings (runtime truth) + +1. **`attr_accessor` also shadows AR's attribute store.** mass assignment through the ivar + writer leaves the column NULL; writing the column requires `record[:name] = ...`. +2. **(def, attr) in one class is order-sensitive** (last definition wins) — unlike + (def, `attribute`), where the module-generated reader makes it order-insensitive. + Both orders tested. ripwire's def tower orders DEFS, not runtime method-table + precedence — disclosed, not a contradiction. diff --git a/test/rubyattrsfix/attr_consumers.rb b/test/rubyattrsfix/attr_consumers.rb new file mode 100644 index 000000000..d77746f2e --- /dev/null +++ b/test/rubyattrsfix/attr_consumers.rb @@ -0,0 +1,43 @@ +# Hand-written arm: OUTSIDE call sites for the attr family, plus the +# negative gates the class-DSL-position capture must respect. +# +# Reads/writes here are the `--uses` rows the gate asserts. The write targets on SetReader +# bind to `name=` (the name-based setter ref); attr_reader defines no `name=` def, so the +# def set for `name=` comes from the attr_writer/attr_accessor/attribute/def-name= files only. + +def read_single_attr + a = Spike::SingleAttr.new + a.name = "x" + a.name +end + +def write_writer(value) + w = Spike::SetWriter.new + w.name = value +end + +def read_reader + Spike::SetReader.new.name +end + +def read_multi + m = Spike::MultiAttr.new + m.multi_a = 1 + m.multi_a +end + +# NEGATIVE arms — the capture must NOT fire on these: +# receiver-qualified family calls are somebody's own methods (rubyNamedDirective posture) +# method bodies are not class-body level +# file top level is not class-body level +def method_body_attr_reader + attr_reader :inside_method + @inside_method +end + +def receiver_qualified + io = StringIO.new + io.attr_writer :qualified_target +end + +attr_reader :file_level_target diff --git a/test/rubyattrsfix/attr_yaml.rb b/test/rubyattrsfix/attr_yaml.rb new file mode 100644 index 000000000..6fa55caa0 --- /dev/null +++ b/test/rubyattrsfix/attr_yaml.rb @@ -0,0 +1,8 @@ +# pair arm (attr, yaml): attr only; the yaml source is spike_names.yml beside it. +module Spike + class AttrYaml < ApplicationRecord + self.table_name = "spike_plain" + + attr_accessor :name + end +end diff --git a/test/rubyattrsfix/block_attr.rb b/test/rubyattrsfix/block_attr.rb new file mode 100644 index 000000000..24fd8111a --- /dev/null +++ b/test/rubyattrsfix/block_attr.rb @@ -0,0 +1,14 @@ +# attr-form arm (STATIC-ONLY, never executed): the plural `attributes` with a +# do-block is third-party-DSL syntax — base Rails 8.1 raises NoMethodError +# (measured, see USECASES.md). Valid Ruby syntax; exists so the static capture can +# prove the do-block body is WALKED BUT DEFINES NOTHING (the block's `sub` and +# assignments must not become symbols). +module Spike + class BlockAttr + attributes :block_a do |sub| + sub.default = 1 + end + + attributes :multi_p1, :multi_p2 + end +end diff --git a/test/rubyattrsfix/floor_attr.rb b/test/rubyattrsfix/floor_attr.rb new file mode 100644 index 000000000..ec9c6e1d3 --- /dev/null +++ b/test/rubyattrsfix/floor_attr.rb @@ -0,0 +1,22 @@ +# floor arm (STATIC-ONLY, never executed): the forms the class-DSL capture DISCLOSES as floors. +# Ruby's attr_* accepts all of these and defines real methods at runtime, but the capture +# unwraps only the call's OWN do/{ } block — so a `begin`-wrapped or modifier-`if`-guarded +# call is not class-DSL position — and defines only `simple_symbol` arguments — so a quoted +# (`:"x"` / `:'x'`), string, or splat/`%i[]` argument stays an honest nothing. Every name here +# must remain undefinable; that silence is stated, pinned, and deliberate. +module Spike + class FloorGuarded + begin + attr_accessor :begin_guarded + end + + attr_writer :if_guarded if true + end + + class FloorDynamic + attr_accessor :"dq_name" + attr_reader :'sq_name' + attr_writer "string_name" + attr_reader *%i[splat_a splat_b] + end +end diff --git a/test/rubyattrsfix/multi_attr.rb b/test/rubyattrsfix/multi_attr.rb new file mode 100644 index 000000000..e4072060c --- /dev/null +++ b/test/rubyattrsfix/multi_attr.rb @@ -0,0 +1,8 @@ +# attr-form arm: multi-symbol attr_accessor (one call, N simple_symbol args). +module Spike + class MultiAttr < ApplicationRecord + self.table_name = "spike_plain" + + attr_accessor :multi_a, :multi_b + end +end diff --git a/test/rubyattrsfix/pair_attr_column.rb b/test/rubyattrsfix/pair_attr_column.rb new file mode 100644 index 000000000..f68fbfac3 --- /dev/null +++ b/test/rubyattrsfix/pair_attr_column.rb @@ -0,0 +1,8 @@ +# pair arm (attr, column): attr_accessor (class method) shadows the column reader. +module Spike + class PairAttrColumn < ApplicationRecord + self.table_name = "spike_pair_attr_columns" + + attr_accessor :name + end +end diff --git a/test/rubyattrsfix/pair_attr_def.rb b/test/rubyattrsfix/pair_attr_def.rb new file mode 100644 index 000000000..0a697ca08 --- /dev/null +++ b/test/rubyattrsfix/pair_attr_def.rb @@ -0,0 +1,13 @@ +# pair arm (attr, def): the def comes AFTER the attr_accessor and reclaims +# the reader; the attr's writer survives. +module Spike + class PairAttrDef < ApplicationRecord + self.table_name = "spike_plain" + + attr_accessor :name + + def name + "def" + end + end +end diff --git a/test/rubyattrsfix/pair_def_attr.rb b/test/rubyattrsfix/pair_def_attr.rb new file mode 100644 index 000000000..89dd43d47 --- /dev/null +++ b/test/rubyattrsfix/pair_def_attr.rb @@ -0,0 +1,13 @@ +# pair arm (def, attr): attr_accessor comes AFTER the def, same class — +# Ruby's same-class last-definition-wins makes the attr the runtime reader. +module Spike + class PairDefAttr < ApplicationRecord + self.table_name = "spike_plain" + + def name + "def" + end + + attr_accessor :name + end +end diff --git a/test/rubyattrsfix/set_attribute.rb b/test/rubyattrsfix/set_attribute.rb new file mode 100644 index 000000000..ebe433bd9 --- /dev/null +++ b/test/rubyattrsfix/set_attribute.rb @@ -0,0 +1,8 @@ +# setter arm: `attribute :name` (ActiveModel::Attributes) must produce a setter. +module Spike + class SetAttribute < ApplicationRecord + self.table_name = "spike_plain" + + attribute :name + end +end diff --git a/test/rubyattrsfix/set_def.rb b/test/rubyattrsfix/set_def.rb new file mode 100644 index 000000000..12c4f9133 --- /dev/null +++ b/test/rubyattrsfix/set_def.rb @@ -0,0 +1,10 @@ +# setter arm: explicit `def name=` must be a setter. +module Spike + class SetDef < ApplicationRecord + self.table_name = "spike_plain" + + def name=(value) + @assigned = value + end + end +end diff --git a/test/rubyattrsfix/set_reader.rb b/test/rubyattrsfix/set_reader.rb new file mode 100644 index 000000000..f0ff64d96 --- /dev/null +++ b/test/rubyattrsfix/set_reader.rb @@ -0,0 +1,8 @@ +# setter arm: attr_reader must NOT produce a setter. +module Spike + class SetReader < ApplicationRecord + self.table_name = "spike_plain" + + attr_reader :name + end +end diff --git a/test/rubyattrsfix/set_writer.rb b/test/rubyattrsfix/set_writer.rb new file mode 100644 index 000000000..92cd74c1f --- /dev/null +++ b/test/rubyattrsfix/set_writer.rb @@ -0,0 +1,8 @@ +# setter arm: attr_writer must produce a setter. +module Spike + class SetWriter < ApplicationRecord + self.table_name = "spike_plain" + + attr_writer :name + end +end diff --git a/test/rubyattrsfix/single_attr.rb b/test/rubyattrsfix/single_attr.rb new file mode 100644 index 000000000..f80fd7b14 --- /dev/null +++ b/test/rubyattrsfix/single_attr.rb @@ -0,0 +1,8 @@ +# single-source arm: `attr_accessor :name` only. +module Spike + class SingleAttr < ApplicationRecord + self.table_name = "spike_plain" + + attr_accessor :name + end +end diff --git a/test/rubyattrsfix/spike_names.yml b/test/rubyattrsfix/spike_names.yml new file mode 100644 index 000000000..40447b76e --- /dev/null +++ b/test/rubyattrsfix/spike_names.yml @@ -0,0 +1,4 @@ +# the YAML source of the `name` collision matrix. +# The `name:` key below is the data-tier Section def ripwire resolves today +# (same live-picker shape as .github/workflows/ci.yml). +name: yaml diff --git a/test/rubyattrsfix/typed_attr.rb b/test/rubyattrsfix/typed_attr.rb new file mode 100644 index 000000000..f1cd84355 --- /dev/null +++ b/test/rubyattrsfix/typed_attr.rb @@ -0,0 +1,9 @@ +# attr-form arm: `attribute` with type metadata — the metadata args are data, +# not defs; the symbol arg gets a getter+setter. +module Spike + class TypedAttr < ApplicationRecord + self.table_name = "spike_plain" + + attribute :quantity, :integer, default: 0 + end +end diff --git a/test/rubysettercheck.sh b/test/rubysettercheck.sh index 1a6f0a81e..5fd40cc10 100755 --- a/test/rubysettercheck.sh +++ b/test/rubysettercheck.sh @@ -150,19 +150,21 @@ PW="$( rowOf 'n="pairwriter" ' )" if echo "$PW" | grep -q '=` setter for the writer-side macros) — so the generated names +# are now symbols, the writes BIND, and the edges below are the graph half of the reversal. See queries/ruby/ +# tags.scm's header, CHANGELOG.md, and test/rubyattrscheck.sh; this gate keeps the per-verb spellings honest. +if grep -q ']*' "$SPLIT" | head -3 | tr '\n' ' ' )"; fi +if grep -q ']*' "$SPLIT" | head -3 | tr '\n' ' ' )"; fi +if grep -q ']*' "$SPLIT" | head -3 | tr '\n' ' ' )"; fi AU="$( rowOf 'n="attruser" ' )" -echo "$AU" | grep -q ' Date: Wed, 23 Sep 2026 20:31:44 +0200 Subject: [PATCH 2/3] test(ruby): pin the inline-visibility lift, plural readers-only, and the block floors; satisfy the review gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the attr_* floor reversal, answering joyful-ii-v-i's review of #310: - CAPTURE LIFT: an inline-visibility wrapper (private/protected/public/module_function attr_reader/etc., Ruby 3 / RuboCop inline) is class-DSL position — the visibility call's argument evaluates first, so the macro runs and the method IS defined. Unwrap ONE receiverless visibility call when the family call is its sole argument; the visibility call's own parent chain must pass the same gate (keeps method-body wrappers out). Fixture priv_attr.rb + 8 arms. - PLURAL READERS-ONLY: the plural attributes setter was a guess; AMS/jsonapi-serializer/dry-struct define readers only (measured). writer now excludes 'attributes'; 2 negative arms pin the setter silence. - FLOORS STATED + PINNED: concern included/class_methods do-body, Struct.new/Class.new/Module.new do-body, and non-modifier if-then blocks are disclosed floors (runtime-real, not unwrapped); 5 new floor arms. - DEAD BRANCH DOC: the ownBlockWrapper branch is conservatively unreachable in tree-sitter-ruby 0.23.1 (do/{ } is the call's block: FIELD); comment now says so and names the real tree shape. - PARSER-VERSION 120: stale '115' comments in ingest_names.h and ingest_sidecap.h corrected; qschemetripcheck.sh re-pin log gains the dated 119->120 entry (pin unchanged). - ACKS: range-form --quality-ack writes the tool-owned rows for the elixir pair + captureTagsFacts cost; the stale hand-added short-horizon-churn row is pruned. --quality-delta gating=0 vs merge-base AND vs origin/main (exit 0). - DOCS: tags.scm, CHANGELOG (readers-only, lift, floors, five-verb scope, non-unique p::sc::n id note), USECASES updated. rubyattrscheck.sh: 32 -> 47 arms, ALL PASS. --- .ripwire_quality_acks | 5 +++- CHANGELOG.md | 43 +++++++++++++++++---------- queries/ruby/tags.scm | 25 +++++++++++----- src/ingest_names.h | 11 +++---- src/ingest_relations.h | 37 ++++++++++++++++++----- src/ingest_sidecap.h | 2 +- test/qschemetripcheck.sh | 3 ++ test/rubyattrscheck.sh | 52 ++++++++++++++++++++++++++++++++- test/rubyattrsfix/USECASES.md | 3 +- test/rubyattrsfix/floor_attr.rb | 28 +++++++++++++++++- test/rubyattrsfix/priv_attr.rb | 13 +++++++++ 11 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 test/rubyattrsfix/priv_attr.rb diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 335e46da2..e2086cad1 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -431,6 +431,7 @@ ack complexity 5ec38fbd414fa4d4 56 cid=35883c203ba33e28 lane/tc-sliceat MCP half ack complexity 61e5df9e1e40ff70 19 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack complexity 639de1c3670999f9 69 cid=bbf7c6fe9dbbe217 wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement ack complexity 685ade09a168535e 30 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean +ack complexity 6979fa794f2a5154 382 cid=8249597fd315bfac ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack complexity 6a38d70787d09e64 28 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack complexity 6e58b0a307757079 85 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack complexity 7051b3950aaf4c14 35 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. @@ -628,6 +629,7 @@ ack duplication 96ccf33fadeecaee 18 incidental shape similarity of small string/ ack duplication 978255b9ac3b2028 37 by=src/* member-variable round (card A3), side-table rule: isMemberAccessSite is the ONE definition of the member-access predicate contextratio.h / nonlocalstate.h / graph.h share; the 37-token pairs with editCheckImplicitReceiver and eligibleForJoin are the two-conjunct-bool-predicate SHAPE, not shared logic ack duplication 979a4b8f7a8177ba 20 the three sidecar read seams keep one O_NOFOLLOW shape by design, each with its own once-per-site DISCLOSE (pathguard.h round 3) ack duplication 9a01b02dda7e280e 28 2026-09-06 stranger-audit rows 13-20: readBaseline/readAckRecords report what they skip (arity), wrapMcpJson/Opencode take the command token (arity), the pre-Q1 refusal in readBaseline, the notes date and the release workflow text — all deliberate; churn rows are this edit +ack duplication 9a4535db03a4028c 28 ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack duplication 9ab0a94a523f59c2 36 by=test/macroreparsefix lane fix/cpp-macro-member-reparse-2026-09-11, member-macro re-parse fixtures (test/macroreparsefix): sumDraft (leak_plain.cpp) and tallyCargo (leak_anon.cpp, control/semi.cpp) are deliberately ordinary C loops around the member macros, and test/macroreparsecheck.sh pins them BY NAME as the functions a derailed parse loses, so their bytes are the measured input. Their token streams meet unrelated plain loops (abicheck KindCounts::sumWhere, vendored dynamic_map.hpp node_rank::lt/le, bench geometry perimeter, the cloneidiomfix and sliceflowsensfix fixtures) with no shared identifier or contract; rewording a fixture to dodge a token match would game the number. ack duplication 9b15c6e24f597b61 57 taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff ack duplication 9b5f24f5ccf5c917 24 lane B1 cap disclosure: a 24-token clone of the three-line null-tolerant optional-sink guard, not of any logic — noteCap forwards four census arguments to a CapDisclosure, serialize.h's noteOmittedBody pushes a NodeId. Sharing them would mean a template over two unrelated sinks to save three lines, and the guard is the house shape for an optional out-parameter here. @@ -777,6 +779,7 @@ ack new-clone-of-reused-helper 7ad7c707204ff7b3 14 qsnapPut is constrained to ty ack new-clone-of-reused-helper 845088dc7642af45 4 V3 harvest 2026-08-15: two MCP gate harnesses. Every MCP gate in this suite is deliberately STANDALONE — the house rule in their own headers is 'does NOT edit regression.sh or any other existing test file', so a gate carries its own 3-4 line JSON-RPC transport wrapper (mcp_call in mcphandlecheck, mcpCall in mcpeditpresencecheck, the curl wrapper in mcpremotecheck). test/mcptoolprunecheck.sh needs BOTH transports (HTTP for the pinned-root arms A-E/G, stdio for arm F, and the pair is the point: pinning is exactly what makes the omission provable), so its http_call and stdio_call land as the 9th and 10th members of two families that already exist. Extracting a shared test/lib harness would couple every MCP gate to one file and is a suite-wide refactor, not this lane's; sharing one of the two existing spellings instead would make this gate fail whenever an unrelated gate edits its own helper. No production code involved ack new-clone-of-reused-helper 894d5d3469e376e9 3 M1: four MCP gates now share a call() helper that injects legend:"full" for the seventeen declaring verbs. The clone is DELIBERATE and this repo's gates say why in their own comments: two gates deriving the same fact through one shared helper fail together on a shared mistake, so gate helpers are copied, not factored. Each copy carries its own re-pin note explaining the posture that gate needs. ack new-clone-of-reused-helper 9497791b092d0735 4 M10 (capture-audit L9): at= anchor family added to --for/--situ/--naming-calibration/--merge-scout/--stray-content/--dmm/--handoff. forRootRelPathsLegendShort gained a 2nd bool param (default-valued, back-compat) to fold at= into the existing short root-rel comment under --for's byte ceiling; runForLens grew from splicing the stamp through the ceiling ladder's byte accounting; the coPairAttr clone pair is a coincidental 2-bool-dispatch shape collision (different domains, no real duplication); short-horizon-churn rows are every function this finding's fix touched this session. +ack new-clone-of-reused-helper 9a4535db03a4028c 11 ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack new-clone-of-reused-helper 9b15c6e24f597b61 4 taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff ack new-clone-of-reused-helper a42cd763fdab0077 42 a probe TU deliberately mirrors layout declarations (#224 structlayout_probe.cpp) ack new-clone-of-reused-helper a5943aaae0184b8f 4 readability-wave1 (naming lens): ncAnyOf is a one-expression std::find wrapper, sortNotes a one-expression std::stable_sort wrapper — 30 normalized tokens of the SAME STL-adapter idiom over unrelated types (a char-class set test vs a note sort). There is no call ncAnyOf could make to sortNotes, and rewriting the std::find as a hand loop would only hide the detector. Kept as written. @@ -1043,7 +1046,6 @@ ack short-horizon-churn 458a97936164903a 13 cid=040d8874f9df08e8 OPTREMARKS F3 ( ack short-horizon-churn 45b52ada32f63c11 45 cid=16d426c6bbd1dc0a 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) | prior: M12 follow-up (capture-audit L9): --ensemble gained root=/root-relative p= — writeEnsembleReport's 3 new default-valued params (singleRoot/rootPrefix/rootAttr, back-compat) thread the caller's already-computed single-root spelling through; short-horizon-churn on the touched dispatcher. ack short-horizon-churn 45bec7fbb1357cd7 71 cid=e622b64dd13745fb by=src/* §N6-C .gitignore-by-default: the crawl gains an ignore mode. The two api-surface/params rows are ONE deliberate contract change — ingest()/collectSources() take a trailing defaulted respectGitignore, the only way a CLI flag can reach the crawl without a global; the three short-horizon-churn rows are this lane's own edits to the flag ledger, the crawl and the --skipped verb, which is what adding a flag with a disclosure IS; collectSources +3 ccx / +11 LOC is what remains after the probe, the mode and the prune fan-out were extracted into probeIgnoreSet/recordDirPrune (it was +15/+43 inline). | prior: 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. ack short-horizon-churn 45c2d6a1927968ad 16 cid=06a2a458610d7af4 L10b finding 12: --lego caveat="not-extracted-for-lang" now defined in the legend -ack short-horizon-churn 46134791273131bc 22 cid=c2e93231d7582ce5 ruby-attr class-DSL lane (kParserVer 119->120 on rebase, test/rubyattrscheck.sh): (a) the capture walk + per-call emitter (captureRubyAttrDefs / captureRubyAttrDefsCall, cx/nest/verb rows) is the five-verb x reader/writer x setter-pair x first-name-only x class-DSL-position matrix, every cell pinned by a gate arm; captureTagsFacts +1cx/+4verb is the two-line gated call site. (b) duplication and new-clone elixirArguments|elixirTarget: token-shape coincidence only - elixirTarget is now the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. (c) short-horizon-churn SELF rows on lines whose previous authors are the in-window train-3/train-4 commits (elixirTarget/elixirDirectiveTarget/elixirAliasGroup/directiveTargetOf/rubyArgumentTargets/rubyMixinTargets/cursor) - upstream-wave lines re-touched by this lane, not unstable symbols. (d) churn self on kParserVer/kIngestParserVerMirror: the 114->115 bump IS this lane. ack short-horizon-churn 4722a2bf575050bb 5 cid=447340334e14b241 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 472cc93317130a8b 6 cid=dcdfa60eb93788ed OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 476ab6f670e5d871 13 cid=bb27e8c78edbdb2f OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -1545,6 +1547,7 @@ ack verbosity 6031be13b40a1b6f 205 cid=58496e25cbf67431 round ec5e3c3..HEAD, the ack verbosity 6302e2e27e23bcde 64 cid=7202bbc7db7cc1da 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. ack verbosity 639de1c3670999f9 185 cid=ffcb81de788c329a 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. | prior: wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) ack verbosity 685ade09a168535e 96 cid=124bb29c88050ae1 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean +ack verbosity 6979fa794f2a5154 418 cid=8249597fd315bfac ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack verbosity 6a38d70787d09e64 74 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack verbosity 6acbcaa854eada23 289 cid=e5c009ae9a0d8393 preloaded-corpus hoist: lexicalScores/lexicalScoresTiered gain one optional defaulted preloadedFileText param so a caller scoring many queries against one tree reads the corpus once instead of per call (--eval-retrieval was ~11.8M file opens/run, 48% of its CPU in the kernel). api-surface rows ARE the intended additive change; short-horizon-churn is this session's own edits to those two functions, not instability. Scores proven byte-identical on an identical tree. ack verbosity 7004309695fb79f1 265 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c457774..476fa9930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,29 +20,40 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli `attr_reader`/`attr_writer`/`attr_accessor` are Ruby's canonical class DSL, and `attribute`/`attributes` are their ActiveModel counterparts: each macro generates the accessor methods named by its arguments when the class is defined — `attr_reader` spells only the reader, `attr_writer` only the writer, and `attr_accessor`/ -`attribute`/`attributes` spell both. +`attribute` spell both. The family is EXACTLY these five verbs; ActiveSupport's neighbouring accessor macros +(`cattr_accessor`, `mattr_accessor`, `thread_mattr_accessor`, `class_attribute`, `attr_internal`) are +deliberately not in it — the scope is a decision, not an omission. Those generated names were indexed by none of them — a write against one resolved to nothing. The class DSL now mints real symbols: one `Var` def per `simple_symbol` argument (named as the argument, minus the leading -`:`), plus the `=` setter for the writer-side macros — the exact spelling the setter-call rename already -produces, so `record.x = v` BINDS to a def instead of dropping. The singular `attribute` takes one name; a -trailing type or `default:` argument is metadata, not a def. An `attributes` do-block body is walked but -defines nothing. This REVERSES a stated floor: queries/ruby/tags.scm used to say "attr_accessor/attr_writer/ -attr_reader define nothing in the source TEXT … a write against one is an honest nothing". That posture -predated measurement; tested to be working in a real, running Rails application (test/rubyattrsfix/ -USECASES.md), these macros define methods that every `record.price` reads — the silence was a coverage hole, -not honesty. The reversal is GLOBAL: every indexed Ruby corpus gains Var defs, attribute names leave the -external surface, setter writes bind, and — because Call refs are language-gated, not kind-gated — attr names -receive real call edges and PageRank weight (accepted churn, disclosed here). The DSL CALL itself stays a -reference capture: `attr_accessor` and friends remain external-surface names, the same posture as the schema -DSL rows. Plural `attributes` is captured for third-party DSLs — base ActiveModel/Rails has no class-level -plural (NoMethodError at runtime) — and its def-carrying form is pinned static-only. kParserVer 119 → 120 +`:`), plus the `=` setter for the writer-side macros (`attr_writer`/`attr_accessor`/`attribute`; the plural +`attributes` is READERS-ONLY — its third-party owners, AMS/jsonapi-serializer/dry-struct, define no setters, +measured — so no phantom setter weight) — the exact spelling the setter-call rename already produces, so +`record.x = v` BINDS to a def instead of dropping. The singular `attribute` takes one name; a trailing type +or `default:` argument is metadata, not a def. An `attributes` do-block body is walked but defines nothing. +An INLINE-VISIBILITY wrapper is also class-DSL position: `private attr_reader :x` (Ruby 3, RuboCop's +Style/AccessModifierDeclarations: inline) evaluates its argument first — the macro runs and the method IS +defined — then applies visibility, so the capture unwraps one receiverless `private`/`protected`/`public`/ +`module_function` call when the family call is its sole argument. This REVERSES a stated floor: +queries/ruby/tags.scm used to say "attr_accessor/attr_writer/attr_reader define nothing in the source TEXT +… a write against one is an honest nothing". That posture predated measurement; tested to be working in a +real, running Rails application (test/rubyattrsfix/USECASES.md), these macros define methods that every +`record.price` reads — the silence was a coverage hole, not honesty. The reversal is GLOBAL: every indexed +Ruby corpus gains Var defs, attribute names leave the external surface, setter writes bind, and — because +Call refs are language-gated, not kind-gated — attr names receive real call edges and PageRank weight +(accepted churn, disclosed here). The DSL CALL itself stays a reference capture: `attr_accessor` and friends +remain external-surface names, the same posture as the schema DSL rows. One id caveat: a same-class +`def x` + `attr_accessor :x` produces TWO defs sharing one `p::sc::n` id (the dedup ladder folds only +same-byte captures; the pair is two identities) — the id is not unique in that case. kParserVer 119 → 120 (extraction facts changed, the bump past everything main carries; no record layout change — kCacheVersion stays 24, kQSnapCacheScheme stays 14). Gate: `test/rubyattrscheck.sh` on `test/rubyattrsfix/` (fixture proven against a running Rails application; red on the pre-change binary — the before-state `defs=0 external=1` attribution rows are the before-evidence). Disclosed capture floors, pinned by the gate's `floor_attr.rb` arms: a `begin`- or modifier-`if`-guarded macro -call is not unwrapped to class-DSL position, and only `simple_symbol` arguments define — a quoted (`:"x"`/`:'x'`), -string, or splat/`%i[]` argument stays an honest nothing (Ruby defines those methods; ripwire does not capture them). +call is not unwrapped to class-DSL position; the do-block BODY of an `included`/`class_methods` +(ActiveSupport::Concern), of `Struct.new`/`Class.new`/`Module.new`, and a non-modifier `if … then … end` +block are not unwrapped either (each defines real methods at runtime); and only `simple_symbol` arguments +define — a quoted (`:"x"`/`:'x'`), string, or splat/`%i[]` argument stays an honest nothing (Ruby defines +those methods; ripwire does not capture them). Every floor is stated and pinned, never silent. ### Added — Microsoft's `cl.exe` builds the tree, so both Windows front ends compile and both gate diff --git a/queries/ruby/tags.scm b/queries/ruby/tags.scm index e612aa54b..5d13fcebe 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -55,13 +55,22 @@ ; `obj.count ||= 1`) reads AND writes and one capture carries one name, so it keeps the getter edge ; only; a left_assignment_list (`a.x, b.y = 1, 2`) wraps its targets one level below `left:` and is ; not read either. This rule still captures only the CALL shape — but note the parser-version-120 -; REVERSAL: the class-level attribute DSL (attr_reader/attr_writer/attr_accessor/attribute/attributes) -; is no longer "an honest nothing". A class-level DSL call — receiver-less, so `obj.attr_reader :x` is -; still somebody's own method — mints Var defs (one per simple_symbol argument, plus the `=` setter -; for the writer-side macros) via the C++ side-capture in ingest_names.h, so a write against a defined -; attribute binds to the `=` def instead of dropping. The DSL call itself stays a reference like -; this one; test/rubyattrscheck.sh pins both sides. Disclosed floors of the DSL capture: a `begin`- or -; modifier-`if`-guarded call is NOT unwrapped to class position, and only `simple_symbol` arguments define — -; a quoted (`:"x"`/`:'x'`), string, or splat/`%i[]` argument stays an honest nothing. Both floors pinned. +; REVERSAL: the class-level attribute DSL (attr_reader/attr_writer/attr_accessor/attribute/attributes +; — EXACTLY these five; ActiveSupport's cattr_accessor/mattr_accessor/thread_mattr_accessor/ +; class_attribute/attr_internal are deliberately not in the family) is no longer "an honest nothing". +; A class-level DSL call — receiver-less, so `obj.attr_reader :x` is still somebody's own method — +; mints Var defs (one per simple_symbol argument, plus the `=` setter for the writer-side macros +; attr_writer/attr_accessor/attribute; attr_reader and the plural `attributes` — third-party DSLs +; measured READERS-ONLY, e.g. AMS/jsonapi-serializer/dry-struct — spell no setter) via the C++ side- +; capture in ingest_names.h, so a write against a defined attribute binds to the `=` def instead +; of dropping. An INLINE-VISIBILITY wrapper (`private attr_reader :x`, Ruby 3 / RuboCop inline) is +; class-DSL position too: the macro evaluates first and the method IS defined, then visibility applies. +; The DSL call itself stays a reference like this one; test/rubyattrscheck.sh pins both sides. +; Disclosed floors of the DSL capture, each pinned by an arm: a `begin`- or modifier-`if`-guarded call +; and the do-block BODY of an `included`/`class_methods` (ActiveSupport::Concern), of +; `Struct.new`/`Class.new`/`Module.new`, or a non-modifier `if … then … end` block are NOT unwrapped to +; class position; and only `simple_symbol` arguments define — a quoted (`:"x"`/`:'x'`), string, or +; splat/`%i[]` argument stays an honest nothing. All of these define real methods at runtime; the +; silence is stated, never silent. (call method: (identifier) @name) @reference.call diff --git a/src/ingest_names.h b/src/ingest_names.h index 79bfb61bc..e171add2c 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -963,7 +963,7 @@ inline bool rubyCallIsAssignmentTarget( TSNode nameNode ) noexcept return !ts_node_is_null( left ) && ts_node_eq( left, call ); } -// Parser version 115 (test/rubyattrscheck.sh): Ruby's class-level attribute DSL DEFINES symbols. The getter's +// Parser version 120 (test/rubyattrscheck.sh): Ruby's class-level attribute DSL DEFINES symbols. The getter's // nameByte rides the symbol's first TEXT byte (after the ':'), the setter's the token's first byte (the ':'): // both stay inside [startByte, endByte) — the extentsuspect R1 head rule — and the two defs of one token can // never collide with each other or with another token's pair (tokens never overlap and every simple_symbol is @@ -974,8 +974,9 @@ inline bool rubyCallIsAssignmentTarget( TSNode nameNode ) noexcept // order preserved (byte-identity determinism). // // Per-call emitter: one Var def per simple_symbol argument (the name minus the leading ':'), plus the `=` -// setter where the family spells writers — attr_writer/accessor/attribute/attributes; attr_reader spells only -// the getter. Keyword args (`default:`, a type) and non-symbol args are data, not defs: the singular +// setter where the family spells writers — attr_writer/accessor/attribute; attr_reader (getter only) and the +// plural `attributes` (third-party DSLs measured readers-only: AMS, jsonapi-serializer, dry-struct) spell no +// setter. Keyword args (`default:`, a type) and non-symbol args are data, not defs: the singular // `attribute` stops at its first named child. defs come ONLY from the call's own argument_list — a do-block // body is not one of the call's fields, so a block body can never leak defs. inline void captureRubyAttrDefsCall( TSNode n, std::uint32_t fileId, std::string_view src, @@ -987,7 +988,7 @@ inline void captureRubyAttrDefsCall( TSNode n, std::uint32_t fileId, std::string return; } const bool reader = fam != "attr_writer"; - const bool writer = fam != "attr_reader"; + const bool writer = fam != "attr_reader" && fam != "attributes"; // plural `attributes` is readers-only (measured: AMS / jsonapi-serializer / dry-struct define no setters) const bool firstNameOnly = fam == "attribute"; // trailing type/metadata args are data, not defs ChildCursor ac( args ); forEachNamedChild( args, ac.cur, [ & ]( TSNode a ) @@ -1044,7 +1045,7 @@ inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_ if( kindIs( ts_node_type( n ), "call" ) ) { const std::string_view fam = rubyNamedDirective( n, src, kRubyAttrFamilyNames ); - if( !fam.empty() && rubyAttrAtClassBodyLevel( n ) ) + if( !fam.empty() && rubyAttrAtClassBodyLevel( n, src ) ) { captureRubyAttrDefsCall( n, fileId, src, fam, defs ); } diff --git a/src/ingest_relations.h b/src/ingest_relations.h index 948ff1308..6b0db2135 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -1312,6 +1312,7 @@ inline std::string_view fieldIdentifierText( TSNode n, NodeField field, std::str // disclosure lives in the CHANGELOG and the tags.scm header. inline constexpr std::array kRubyConstantDirectives = { "include", "extend", "prepend", "autoload" }; inline constexpr std::array kRubyAttrFamilyNames = { "attribute", "attributes", "attr_reader", "attr_writer", "attr_accessor" }; +inline constexpr std::array kRubyVisibilityNames = { "module_function", "private", "protected", "public" }; // A receiver-less `call` node's method-name TEXT when it is one of `names`, else empty — the shared reader of // the Ruby named directives (`obj.include X` / `obj.attr_writer :x` are somebody's own methods and read as @@ -1355,16 +1356,23 @@ inline std::vector rubyMixinTargets( TSNode n, std::string_view src } // Is this macro call at class-DSL position — a class/module/singleton_class body, optionally through the -// macro call's OWN do/{ } block wrapper (`attributes :x do … end` parses as (block (call …) (do_block …)) — the -// call is the block's first child, the body is its second, so unwrapping ONE step only when this node IS that -// first child reaches the class body without ever entering a block body)? A method body, a lambda, or a block +// macro call's OWN do/{ } block wrapper or an INLINE VISIBILITY wrapper? In tree-sitter-ruby 0.23.1 the call's +// do/{ } block is a FIELD (`(call … block: (do_block (body_statement …)))`; `{ }` interposes `block_body` +// between the field and its statements), so the call itself sits directly at the class body — the fixture +// passes through the plain body_statement ascent and the `ownBlockWrapper` branch below is a conservatively- +// unreachable guard against a future grammar that reintroduces a wrapping `block` node. An INLINE visibility +// wrapper — `private attr_reader :x` (Ruby 3, RuboCop's Style/AccessModifierDeclarations: inline) — parses as +// the family call being the SOLE argument of a receiverless private/protected/public/module_function call: +// the visibility call's own parent chain must ALSO pass this gate (its argument is evaluated first — the +// macro runs and the method IS defined — then visibility applies). A method body, a lambda, or a block // nested under anything else is not: a method body runs at call time, and a file top level -// (`attr_accessor :x` outside any class — defines on Object) is walked to nothing. A `begin`/`if`-guarded macro -// call is a disclosed floor (not unwrapped). -inline bool rubyAttrAtClassBodyLevel( TSNode n ) noexcept +// (`attr_accessor :x` outside any class — defines on Object) is walked to nothing. A `begin`/modifier-`if`- +// guarded macro call and the do-block bodies of `included`/`class_methods`/`Struct.new`/`Class.new`/ +// `Module.new` or a non-modifier `if … then … end` are disclosed floors (not unwrapped). +inline bool rubyAttrAtClassBodyLevel( TSNode n, std::string_view src ) noexcept { TSNode cur = n; - for( int guard = 0; guard < 4; ++guard ) // block wrapper + body_statement is the deepest real chain + for( int guard = 0; guard < 4; ++guard ) // visibility wrapper + block wrapper + body_statement is the deepest real chain { const TSNode p = ts_node_parent( cur ); if( ts_node_is_null( p ) ) @@ -1376,6 +1384,21 @@ inline bool rubyAttrAtClassBodyLevel( TSNode n ) noexcept { return true; } + if( kindIs( pt, "argument_list" ) ) + { + // Inline visibility: the sole argument of a receiverless visibility call, and the VISIBILITY + // call's own parent chain must pass the same gate (next hop(s)) — this is what keeps + // `def m; private attr_reader :x; end` out (`private` inside a method body fails the ascent). + const TSNode outer = ts_node_parent( p ); + if( !ts_node_is_null( outer ) && kindIs( ts_node_type( outer ), "call" ) + && !rubyNamedDirective( outer, src, kRubyVisibilityNames ).empty() + && ts_node_named_child_count( p ) == 1 && ts_node_eq( ts_node_named_child( p, 0 ), cur ) ) + { + cur = outer; // one more ascent; the visibility call's own parent decides + continue; + } + return false; + } const bool ownBlockWrapper = kindIs( pt, "block" ) && ts_node_eq( ts_node_named_child( p, 0 ), cur ); const bool statementList = kindIs( pt, "body_statement" ); // class bodies wrap multi-statement lists; the next hop decides if( !ownBlockWrapper && !statementList ) diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 495925662..6faed2993 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2168,7 +2168,7 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t if( le.lang == Lang::Elixir ) { elixirExpandImplementations( elixir, defs, firstDefOfFile, binds, firstBindOfFile ); } foldFieldDefs( defs, firstDefOfFile, le.lang ); // member-variable round: owner-less fields drop, Python fields fold to one per (class, name) - // Parser version 115 (test/rubyattrscheck.sh): the Ruby attr family's Var defs. Appended after the + // Parser version 120 (test/rubyattrscheck.sh): the Ruby attr family's Var defs. Appended after the // dead/field folds — neither touches Ruby (no preprocessor; Var is not a Field kind) — and inside the // same defs window, so the lex build and cache round-trip treat these defs like captured ones. if( le.lang == Lang::Ruby ) diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index ce561492b..2d1d4ca61 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,9 @@ SRC="$ROOT/src/quality.h" ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds PIN="$ROOT/test/qschemetrip.hash" # RE-PIN LOG (the pin is a bare hash, so its justification has to live here). +# 2026-09-23 (PR #310, ruby-attr DSL lane): kParserVer 119 -> 120 (indexes the Ruby attr-family's name +# changes AND the inline-visibility lift; re-pin logic below). Only the extraction-identity declaration +# moves — kQSnapCacheScheme stays 14, kCacheVersion stays 24 — so no cached Snapshot MEANING changes. # 2026-09-20, TRAIN 13 (integration/train-13 on main ae6e3e7a: lane/t13-contrib-finish a7281dea, # lane/t13-honesty-fixes 0cf97744): RE-DERIVED ON THE FINAL MERGED TREE with UPDATE_GOLDEN=1. # TWO manifest inputs move, both from the honesty lane: diff --git a/test/rubyattrscheck.sh b/test/rubyattrscheck.sh index 967a200fa..b7a14fd10 100755 --- a/test/rubyattrscheck.sh +++ b/test/rubyattrscheck.sh @@ -30,7 +30,12 @@ # a method body, a receiver-qualified call, and file top level define nothing # floor_attr.rb DISCLOSED floors, all must stay undefinable: a `begin`/modifier-`if`-guarded call # is not class-DSL position; quoted (`:"x"`/`:'x'`), string, and splat/`%i[]` -# arguments are not simple_symbol names — Ruby defines them, ripwire does not +# arguments are not simple_symbol names; an `included`/`class_methods` (Concern), +# `Struct.new`/`Class.new`/`Module.new` do-block body and a non-modifier +# `if … then … end` block are not unwrapped — Ruby defines all of these, ripwire does not +# priv_attr.rb the Ruby 3 INLINE-VISIBILITY lift: `private attr_reader :x` / `protected attr_accessor` +# / `public attr_writer` / `module_function attr_accessor` DO define (the macro runs +# before visibility applies, so the family macro still decides which side exists) # # Usage: test/rubyattrscheck.sh | RIPWIRE_BIN=asan/ripwire test/rubyattrscheck.sh # Exit: 0 = clean · 1 = an arm failed · 2 = usage / missing prerequisite @@ -75,6 +80,36 @@ callershead(){ "$BIN" "$FIX" --callers="$1" --no-cache 2>/dev/null | grep -oE "o [ "$( useshead multi_p2 )" = 'of="multi_p2" defs="1" external="0" count="0"' ] \ && ok 'capture: plural attributes — the SECOND symbol defines too (not first-symbol-only)' \ || no "capture: multi_p2 defs: $( useshead multi_p2 )" +undefinable 'multi_p1=' \ + && ok 'floor: plural attributes is READERS-ONLY — multi_p1= stays undefinable (AMS/jsonapi-serializer/dry-struct define no setters; measured)' \ + || no 'floor: multi_p1= — plural attributes minted a setter' +undefinable 'multi_p2=' \ + && ok 'floor: plural attributes readers-only — multi_p2= stays undefinable' \ + || no 'floor: multi_p2= — plural attributes minted a setter' +[ "$( useshead priv_name )" = 'of="priv_name" defs="1" external="0" count="0"' ] \ + && ok 'lift: private attr_reader :priv_name — the INLINE-VISIBILITY call IS class-DSL position; the reader defines (the macro runs before visibility applies)' \ + || no "lift: priv_name defs: $( useshead priv_name )" +undefinable 'priv_name=' \ + && ok 'lift: private attr_reader — still reader-only: no priv_name= setter (the MACRO, not the visibility, decides the pair)' \ + || no 'lift: priv_name= — attr_reader under a visibility call minted a setter' +[ "$( useshead prot_pair )" = 'of="prot_pair" defs="1" external="0" count="0"' ] \ + && ok 'lift: protected attr_accessor — the accessor pair defines (reader side)' \ + || no "lift: prot_pair defs: $( useshead prot_pair )" +[ "$( useshead 'prot_pair=' )" = 'of="prot_pair=" defs="1" external="0" count="0"' ] \ + && ok 'lift: protected attr_accessor — prot_pair= setter defines (writer side of the pair)' \ + || no "lift: prot_pair= defs: $( useshead 'prot_pair=' )" +undefinable pub_set \ + && ok 'lift: public attr_writer — writer-only: no bare pub_set reader (the macro spells one side)' \ + || no 'lift: pub_set — attr_writer under a visibility call minted a reader' +[ "$( useshead 'pub_set=' )" = 'of="pub_set=" defs="1" external="0" count="0"' ] \ + && ok 'lift: public attr_writer — pub_set= setter defines' \ + || no "lift: pub_set= defs: $( useshead 'pub_set=' )" +[ "$( useshead mod_acc )" = 'of="mod_acc" defs="1" external="0" count="0"' ] \ + && ok 'lift: module_function attr_accessor — both sides define (reader)' \ + || no "lift: mod_acc defs: $( useshead mod_acc )" +[ "$( useshead 'mod_acc=' )" = 'of="mod_acc=" defs="1" external="0" count="0"' ] \ + && ok 'lift: module_function attr_accessor — mod_acc= setter defines' \ + || no "lift: mod_acc= defs: $( useshead 'mod_acc=' )" undefinable integer \ && ok 'capture: the singular attribute stops at the first named child — :integer (type arg) defines nothing' \ || no 'capture: integer — a type-metadata argument minted a def' @@ -101,6 +136,21 @@ for dyn in dq_name sq_name string_name splat_a; do && ok "floor: dynamic argument form — $dyn (quoted/string/splat) defines nothing; only simple_symbol does" \ || no "floor: $dyn — a non-simple_symbol argument minted a def" done +undefinable concern_included \ + && ok 'floor: an `included do … end` body (ActiveSupport::Concern) defines nothing — a do_block body is not unwrapped' \ + || no 'floor: concern_included — an included-block body minted a def' +undefinable concern_class_method \ + && ok 'floor: a `class_methods do … end` body (Concern) defines nothing' \ + || no 'floor: concern_class_method — a class_methods-block body minted a def' +undefinable struct_attr \ + && ok 'floor: a `Struct.new(:s) do … end` body defines nothing' \ + || no 'floor: struct_attr — a Struct.new-block body minted a def' +undefinable classnew_attr \ + && ok 'floor: a `Class.new do … end` body defines nothing' \ + || no 'floor: classnew_attr — a Class.new-block body minted a def' +undefinable ifthen_attr \ + && ok 'floor: a non-modifier `if … then … end` block in a class body defines nothing (same floor as the pinned modifier form)' \ + || no 'floor: ifthen_attr — an if-then block minted a def' # ── 2. THE CALLS STAY REFERENCES (disclosed posture, not silently dropped) ─────────────────────────── for fam in attr_accessor attr_writer attr_reader attribute attributes; do diff --git a/test/rubyattrsfix/USECASES.md b/test/rubyattrsfix/USECASES.md index 54cd3835d..7aabeedf5 100644 --- a/test/rubyattrsfix/USECASES.md +++ b/test/rubyattrsfix/USECASES.md @@ -34,7 +34,8 @@ Static binding of `record.name = v` to the `name=` def: static-pinned (rubyattrs |---|---| | multi-symbol `attr_accessor :multi_a, :multi_b` | proven (multi_attr.rb) | | typed `attribute :quantity, :integer, default: 0` — metadata args define nothing | proven (typed_attr.rb; `respond_to?(:integer)` is false) | -| plural `attributes :x, :y` | **MEASURED FLOOR — no class-level plural exists** in base Rails/ActiveModel (NoMethodError at runtime). Static capture stays as third-party-DSL forward-compat only. | +| INLINE VISIBILITY: `private attr_reader :x` / `protected attr_accessor` / `public attr_writer` / `module_function attr_accessor` | static-pinned (priv_attr.rb) plus core-Ruby semantics: the argument evaluates FIRST (the macro runs, the method IS defined), then visibility applies — so the defs are exactly the macro's own | +| plural `attributes :x, :y` | **MEASURED FLOOR — no class-level plural exists** in base Rails/ActiveModel (NoMethodError at runtime). Static capture is READERS-ONLY by measurement: the two dominant third-party owners, `ActiveModel::Serializer` and `jsonapi-serializer`, define read accessors only, and `dry-struct`'s singular `attribute :name, Types::String` defines a reader only — no setter is minted by the capture (pinned). | | `attributes :block_a do … end` — the do-block body (with its block parameter) defines nothing | static-pinned only; block_attr.rb is never executed (it would raise) — valid syntax so the static walk can prove the block-body guard | ## Measured findings (runtime truth) diff --git a/test/rubyattrsfix/floor_attr.rb b/test/rubyattrsfix/floor_attr.rb index ec9c6e1d3..a11459237 100644 --- a/test/rubyattrsfix/floor_attr.rb +++ b/test/rubyattrsfix/floor_attr.rb @@ -2,7 +2,11 @@ # Ruby's attr_* accepts all of these and defines real methods at runtime, but the capture # unwraps only the call's OWN do/{ } block — so a `begin`-wrapped or modifier-`if`-guarded # call is not class-DSL position — and defines only `simple_symbol` arguments — so a quoted -# (`:"x"` / `:'x'`), string, or splat/`%i[]` argument stays an honest nothing. Every name here +# (`:"x"` / `:'x'`), string, or splat/`%i[]` argument stays an honest nothing. The do-block +# BODY of an ActiveSupport::Concern (`included`/`class_methods`), of `Struct.new`/`Class.new`/ +# `Module.new`, and a non-modifier `if … then … end` block are the same floor: the walk stops +# at the do_block/if node, while the runtime evaluates the block (at include time, or on the +# new class/module, or at class-definition time) and defines the accessors. Every name here # must remain undefinable; that silence is stated, pinned, and deliberate. module Spike class FloorGuarded @@ -19,4 +23,26 @@ class FloorDynamic attr_writer "string_name" attr_reader *%i[splat_a splat_b] end + + class FloorConcern + included do + attr_accessor :concern_included + end + + class_methods do + attr_writer :concern_class_method + end + + Struct.new(:s) do + attr_accessor :struct_attr + end + + Class.new do + attr_reader :classnew_attr + end + + if true then + attr_reader :ifthen_attr + end + end end diff --git a/test/rubyattrsfix/priv_attr.rb b/test/rubyattrsfix/priv_attr.rb new file mode 100644 index 000000000..b43a36a37 --- /dev/null +++ b/test/rubyattrsfix/priv_attr.rb @@ -0,0 +1,13 @@ +# attr-form arm (RUNTIME-REAL): the Ruby 3 INLINE-VISIBILITY idiom (RuboCop +# Style/AccessModifierDeclarations: inline). `private attr_reader :x` evaluates its +# argument first — the macro runs and the reader IS defined — then applies visibility, +# so these defs are exactly as real as the plain forms. The position gate unwraps one +# visibility call when the family call is its sole argument. +module Spike + class PrivAttr + private attr_reader :priv_name + protected attr_accessor :prot_pair + public attr_writer :pub_set + module_function attr_accessor :mod_acc + end +end \ No newline at end of file From eb3673b2c8329dc30257c02c8f053625d8f330ad Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Wed, 23 Sep 2026 21:28:32 +0200 Subject: [PATCH 3/3] ci: re-trigger checks on 50f7b222 (the 09-23 push did not fire the pull_request synchronize event)