From 7484e93de4023c4a339aab0a345f17da8d872336 Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Fri, 25 Sep 2026 06:54:16 +0300 Subject: [PATCH 1/9] test(ruby): a red gate for RSpec's described_class as a constant receiver, with its floors pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `described_class.m( 1 )` inside `RSpec.describe Calc do … end` is `Calc.m( 1 )`, but its receiver is a bare (identifier) no binding names, so it declines: the spec reaches neither Calc::m nor any same-named method. A spec's calls are the test→code edges tested=, --seams and --test-gate read, and described_class is how RSpec spells the class under test — 902 uses in one measured Rails app's spec/, 4,216 in another. The rule the gate asks for is RSpec's own (rspec-core 3.13, Metadata::ExampleGroupHash#described_class): a group's described class is its first description argument unless that is nil or a String, otherwise the parent group's. So the innermost group with a constant argument wins and a string-described group passes its parent's through. Every method name is defined on two classes, so an unpinned call names both or neither and a pinned one names exactly one; each case has its own name, because a spec's calls share one owner. Claims: RSpec.describe Calc; a string-described describe and context inside it; a nested describe Tally (the innermost constant); a bare `describe Outer::Engine` named by its final segment; and a mutation that turns the group into Tally and requires the pin to follow. Floors, green and stated: a chained described_class.new.m (#267's one-hop bound), a group with no constant (a string, a symbol), a describe on another receiver (Docs.describe), and `subject` — not modelled this round. Against upstream main (5a65f4ad): 10 pass, 6 fail — every claim fails (both classes report no caller, the decline), every control and floor passes. test/regression.sh lists the gate; the generated gate count moves 647 -> 648. --- README.md | 4 +- docs/EVALS.md | 6 +- present/deck5_ripwire_build.js | 6 +- test/regression.sh | 2 +- test/rubydescribedclasscheck.sh | 270 ++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 9 deletions(-) create mode 100755 test/rubydescribedclasscheck.sh diff --git a/README.md b/README.md index bb3b7e45d..369dcdd73 100644 --- a/README.md +++ b/README.md @@ -1977,9 +1977,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published ### In the tests
-650 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures +651 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 **650 gate scripts** and is the authoritative list; +`test/regression.sh` names **651 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 diff --git a/docs/EVALS.md b/docs/EVALS.md index a1ef168dd..26938021f 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` | 650 gate scripts plus the determinism, cache-transparency and golden contracts. | +| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 651 gate scripts plus the determinism, cache-transparency and golden contracts. | | **`--quality-delta`** | `src/quality.h` | Ten measured code-quality failure modes, reported only where a change made them worse. | ### The labeling protocol (why the held-out eval is allowed to disagree with the ranker) @@ -5866,7 +5866,7 @@ copy here would be exactly the dialect divergence that gate exists to catch. Com tags, wrap, stable-order defaults), seven individually invoked standalone gates (`g1freshcheck`, `skillscan`, `htmlexport`, `compresscheck`, `handoffcheck`, `releaseinstallcheck`, `taskroutecheck`), and a single loop -naming **650 gate scripts**, all of which exist on disk. +naming **651 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`. @@ -6885,7 +6885,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 650. The + ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 651. 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 d32ba5cc7..cbe043f1f 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 = [ - ["650 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 + ["651 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, "650", "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, "651", "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"], - ["650 gate scripts", "bash test/manifestcheck.sh"], // gatecount + ["651 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/test/regression.sh b/test/regression.sh index 93f4edea3..92fe71b3a 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 astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck builtinbindcheck 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 +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 astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck builtinbindcheck 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 rubydescribedclasscheck 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/rubydescribedclasscheck.sh b/test/rubydescribedclasscheck.sh new file mode 100755 index 000000000..638d53c39 --- /dev/null +++ b/test/rubydescribedclasscheck.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# rubydescribedclasscheck.sh — gate for RSpec's `described_class` as a CONSTANT receiver: inside +# `RSpec.describe Calc do … end`, `described_class.m1( 1 )` is `Calc.m1( 1 )`, so it pins to Calc::m1 through the +# same Rule 2c arm a written `Calc.m1( 1 )` takes (#267), instead of declining. +# +# Why it matters: a spec's calls are the test→code edges tested=, --seams and --test-gate read. `described_class` +# is how RSpec spells the class under test (902 uses in one measured Rails app's spec/, 4216 in another), and it is +# a bare (identifier) receiver no binding names, so every such call declined and the spec reached nothing. +# +# The rule is RSpec's own (rspec-core 3.13, Metadata::ExampleGroupHash#described_class): a group's described class +# is its FIRST description argument unless that is nil or a String; otherwise it is the parent group's. So the +# INNERMOST enclosing example group whose first argument is a constant wins, and a string-described group passes +# its parent's through. An example group is a call of describe/context (and their x/f and feature spellings) +# with no receiver or the receiver `RSpec`, that carries a block. +# +# Stated floors, pinned below so each stays a decision: +# (a) a CHAINED receiver (`described_class.new.m_chain`) is untouched — the same one-hop bound as #267's +# `Calc.new.scale`: the receiver is a call, not a constant. +# (b) a group with no constant (`RSpec.describe "no class"`, `RSpec.describe :sym`) names no class, so +# `described_class` there is left exactly as it was. +# (c) a `describe` call on any other receiver (`Docs.describe Calc do`) is not an RSpec example group. +# (d) `subject` — the implicit `described_class.new` — is NOT modeled in this round: an explicit `subject { … }` +# can be anything, and telling the two apart is its own round. +# +# Usage: test/rubydescribedclasscheck.sh | RIPWIRE_BIN=asan/ripwire test/rubydescribedclasscheck.sh +# Exits non-zero on any failure. Does NOT edit test/regression.sh. Self-contained via mktemp. +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +fail=0 +ok(){ echo " PASS $1" || { fail=1; echo " FAIL could not write the PASS line for: $1"; }; return 0; } +no(){ echo " FAIL $1"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } + +DIR="$( mktemp -d )"; trap 'rm -rf "$DIR"' EXIT +FIX="$DIR/fix"; mkdir -p "$FIX/lib" "$FIX/spec" + +# Every method name is defined on TWO classes, so an unpinned call is a split or a decline and a pinned one names +# exactly one class. Each case below uses its own name: a spec's calls all land in one owner. +cat > "$FIX/lib/calc.rb" <<'RUBY' +class Calc + def self.m_top( a ) + a + end + + def self.m_str( a ) + a + end + + def self.m_ctx( a ) + a + end + + def self.m_inner( a ) + a + end + + def self.m_none( a ) + a + end + + def self.m_sym( a ) + a + end + + def self.m_docs( a ) + a + end + + def m_chain + 1 + end +end + +class Tally + def self.m_top( a ) + a + end + + def self.m_str( a ) + a + end + + def self.m_ctx( a ) + a + end + + def self.m_inner( a ) + a + end + + def self.m_none( a ) + a + end + + def self.m_sym( a ) + a + end + + def self.m_docs( a ) + a + end + + def m_chain + 2 + end +end + +module Outer + class Engine + def self.e_run + 1 + end + end +end + +class Other + def self.e_run + 2 + end +end + +module Docs + def self.describe( what ) + yield + end +end +RUBY + +cat > "$FIX/spec/calc_spec.rb" <<'RUBY' +RSpec.describe Calc do + it "pins the top-level group's class" do + described_class.m_top( 1 ) + end + + describe "a string group" do + it "passes its parent's class through" do + described_class.m_str( 1 ) + end + end + + context "a context" do + it "passes its parent's class through too" do + described_class.m_ctx( 1 ) + end + end + + describe Tally do + it "takes the innermost constant" do + described_class.m_inner( 1 ) + end + end + + it "leaves a chained receiver alone" do + described_class.new.m_chain + end +end +RUBY + +cat > "$FIX/spec/engine_spec.rb" <<'RUBY' +describe Outer::Engine do + it { described_class.e_run } +end +RUBY + +cat > "$FIX/spec/noclass_spec.rb" <<'RUBY' +RSpec.describe "no class" do + it { described_class.m_none( 1 ) } +end + +RSpec.describe :sym do + it { described_class.m_sym( 1 ) } +end +RUBY + +cat > "$FIX/spec/docs_spec.rb" <<'RUBY' +Docs.describe Calc do + described_class.m_docs( 1 ) +end +RUBY + +MAP="$DIR/map.xml" +"$BIN" "$FIX" --no-cache >"$MAP" 2>"$DIR/map.err" +if [ $? -eq 0 ]; then ok "default map exits 0"; else no "default map exited non-zero: $( cat "$DIR/map.err" )"; fi +[ -s "$DIR/map.err" ] && no "unexpected stderr: $( head -3 "$DIR/map.err" )" || ok "clean stderr" +command -v xmllint >/dev/null 2>&1 && { if xmllint --noout "$MAP"; then ok "xmllint --noout"; else no "xmllint failed"; fi; } + +# calls ROOT SYM SPEC — 0 when --callers=SYM lists SPEC's as a caller, 1 when it does not, and a FAIL (2) +# when the run itself failed: an absence arm must never read a crashed run as "not a caller". +calls(){ + if ! "$BIN" "$1" --no-cache --callers="$2" >"$DIR/c.out" 2>"$DIR/c.err" + then + no "--callers=$2 exited non-zero: $( head -3 "$DIR/c.err" )" + return 2 + fi + sed 's/>\n"$DIR/b.xml" 2>/dev/null +cmp -s "$MAP" "$DIR/b.xml" && ok "byte-identical across two --no-cache runs" \ + || no "output differs across runs" +if ! "$BIN" "$FIX" --cache="$DIR/c.bin" >"$DIR/cold.xml" 2>"$DIR/cold.err" +then + no "the cold cache run exited non-zero: $( head -3 "$DIR/cold.err" )" +fi +if ! "$BIN" "$FIX" --cache="$DIR/c.bin" >"$DIR/warm.xml" 2>"$DIR/warm.err" +then + no "the warm cache run exited non-zero: $( head -3 "$DIR/warm.err" )" +fi +cmp -s "$DIR/cold.xml" "$DIR/warm.xml" && ok "warm run == cold run" \ + || no "the warm cache disagrees with the cold run" + +echo "=== mutation: describe Tally instead of Calc → the pin must follow the group's constant ===" +MUT="$DIR/mut"; mkdir -p "$MUT/lib" "$MUT/spec"; cp "$FIX/lib/calc.rb" "$MUT/lib/calc.rb" +sed 's/^RSpec.describe Calc do$/RSpec.describe Tally do/' "$FIX/spec/calc_spec.rb" >"$MUT/spec/calc_spec.rb" +grep -q '^RSpec.describe Tally do$' "$MUT/spec/calc_spec.rb" || no "mutation did not apply" +calls "$MUT" "Tally::m_top" calc_spec.rb; mt=$? +calls "$MUT" "Calc::m_top" calc_spec.rb; mc=$? +if [ "$mt" -ne 2 ] && [ "$mc" -ne 2 ] +then + [ "$mt" -eq 0 ] && [ "$mc" -eq 1 ] && ok "mutation: described_class.m_top follows the group to Tally::m_top" \ + || no "mutation: with RSpec.describe Tally, Tally caller=$( [ "$mt" -eq 0 ] && echo yes || echo no ), Calc caller=$( [ "$mc" -eq 0 ] && echo yes || echo no )" +fi + +echo +[ "$fail" -eq 0 ] && echo "ALL PASS" || { echo "SOME CHECKS FAILED"; exit 1; } From 0178f8d0ef7b755f366fbae39cf44eea0846af24 Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Fri, 25 Sep 2026 07:09:38 +0300 Subject: [PATCH 2/9] feat(ruby): RSpec's described_class is the constant its example group names, so a spec's calls pin to the class under test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `described_class.call( … )` inside `RSpec.describe FeeService do … end` is `FeeService.call( … )`, but its receiver is a bare (identifier) that no binding names, so every such call declined: the spec reached nothing, and tested= / --seams / --test-gate read the class under test as unreached by the spec written for it. ingest_binds.h::rspecDescribedClass reads it the way RSpec does (rspec-core 3.13, Metadata::ExampleGroupHash#described_class): a group's described class is its first description argument unless that is nil or a String, otherwise the parent group's. The walk climbs the calls whose BLOCK holds the site (the child it came from is a do_block/block) and the innermost example group with a constant first argument answers — describe/context and their feature/example_group and x-/f- spellings, bare or on `RSpec`. classifyRubyReceiver then returns exactly what a written `Calc` receiver returns, NamedVar "Calc", and #267's Rule 2c arm pins the call; nothing downstream changes. A string or absent argument passes outward; a symbol or variable argument, a shared group (whose body runs in whichever group includes it) and no enclosing group leave the site exactly as it was. rubyFinalConstant / isRubyConstantNode are the constant arm's own lines, factored so both arms read one copy. Measured against 5a65f4ad (upstream main), --no-cache: Rails app A edges 30,741 -> 30,844 (+103; spec/ call edges 6,434 -> 6,537) Rails app B edges 19,922 -> 20,249 (+327; spec/ call edges 3,594 -> 3,919) activerecord, activesupport, actionpack lib and this repo's src/: default map byte-identical (no specs). Sampled on app B: three service/value classes' class methods each gain their spec as a caller (0 -> 1), and no other same-named method does. One sample stays unreached for a reason outside this round: a class method defined inside `class << self`, which extraction does not file under its class at all. test/rubydescribedclasscheck.sh 16/0 (10/6 before), also under ASan with rubyrecvnarrowcheck; ASan clean on app B, whose map is deterministic across two runs and xmllint-clean. Green: the eight other Ruby gates, clsrecv, narrow, narrowlang, chainguard, resolve, resolverhonesty, decline, testscope, testgate, testedreach, version, qextractionkey, cachehash, nodekind, gateexit, manifest, selfcheck, gatecount, qualifiedresolve, externalveto. kParserVer 120 -> 121: recv/recvVar change VALUE for these call sites, same record layout, kCacheVersion stays 25 — a Ruby cache written at 120 holds the old receiver. quality.h's mirror moves with it; test/qschemetrip.hash re-pinned with a RE-PIN LOG entry. --- src/ingest_binds.h | 107 ++++++++++++++++++++++++++++++++++++--- src/ingest_cache.h | 12 ++++- src/quality.h | 4 +- test/qschemetrip.hash | 2 +- test/qschemetripcheck.sh | 4 ++ 5 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/ingest_binds.h b/src/ingest_binds.h index f8e69ec17..98d0407d4 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -87,6 +87,97 @@ struct RecvShape // through to the honest ladder. `Outer::run( 1 )` never arrives as a scope_resolution at all — // tree-sitter-ruby parses it as an ordinary (call) with a (constant) receiver, exactly like // `Outer.run( 1 )`, so both spellings narrow through the (constant) arm. test/rubyrecvnarrowcheck.sh. +// The FINAL constant segment a (constant) / (scope_resolution) names — `Calc` → `Calc`, `Outer::Engine` → `Engine` — +// or empty when the node is a (scope_resolution) whose `name:` is not a (constant). Callers have checked the kind. +inline std::string_view rubyFinalConstant( TSNode node, std::string_view src ) +{ + const TSNode leaf = kindIs( ts_node_type( node ), "constant" ) ? node : fieldChild( node, NodeField::Name ); + if( ts_node_is_null( leaf ) || !kindIs( ts_node_type( leaf ), "constant" ) ) + { + return {}; + } + return pattern::nodeText( leaf, src ); +} + +inline bool isRubyConstantNode( TSNode node ) noexcept +{ + const char* t = ts_node_type( node ); + return kindIs( t, "constant" ) || kindIs( t, "scope_resolution" ); +} + +// An RSpec EXAMPLE GROUP call: describe/context (and the feature/example_group and x-/f- spellings), called bare or +// on `RSpec`. 1 = a group, 2 = a SHARED group (shared_examples/_for, shared_context), 0 = neither. A shared group's +// body runs inside whichever group INCLUDES it, so its described_class is not its lexical parent's. +inline int rspecGroupKind( TSNode call, std::string_view src ) +{ + const TSNode recv = fieldChild( call, NodeField::Receiver ); + if( !ts_node_is_null( recv ) && !( kindIs( ts_node_type( recv ), "constant" ) && pattern::nodeText( recv, src ) == "RSpec" ) ) + { + return 0; + } + const TSNode method = fieldChild( call, NodeField::Method ); + if( ts_node_is_null( method ) ) + { + return 0; + } + static constexpr std::string_view kGroups[] = { "describe", "context", "feature", "example_group", + "xdescribe", "fdescribe", "xcontext", "fcontext" }; + static constexpr std::string_view kShared[] = { "shared_examples", "shared_examples_for", "shared_context" }; + const std::string_view m = pattern::nodeText( method, src ); + if( std::ranges::find( kGroups, m ) != std::end( kGroups ) ) + { + return 1; + } + return std::ranges::find( kShared, m ) != std::end( kShared ) ? 2 : 0; +} + +// One example group's FIRST description argument, as RSpec reads it: nullopt when it is nil or a String (the parent +// group's described class stands), otherwise the answer — a constant's final segment, or empty for anything else +// (`describe :sym`, a variable), which names no class this tool can resolve. +inline std::optional rspecGroupArgument( TSNode call, std::string_view src ) +{ + const TSNode args = fieldChild( call, NodeField::Arguments ); + const TSNode first = ts_node_is_null( args ) ? args : ts_node_named_child( args, 0 ); + if( ts_node_is_null( first ) || kindIs( ts_node_type( first ), "string" ) ) + { + return std::nullopt; + } + return isRubyConstantNode( first ) ? rubyFinalConstant( first, src ) : std::string_view {}; +} + +// RSpec's `described_class` — a bare (identifier) receiver no binding names — read as the constant it IS. RSpec's +// own rule (rspec-core 3.13, Metadata::ExampleGroupHash#described_class): a group's described class is its FIRST +// description argument unless that is nil or a String; otherwise it is the parent group's. So the walk climbs the +// enclosing calls whose BLOCK holds the site (the child it came from is a do_block/block), and the innermost example +// group with a constant first argument answers. A string or absent argument passes outward; any other argument +// (`describe :sym`, a variable) and a shared group stop the walk with no answer, and the site is left exactly as it +// was. test/rubydescribedclasscheck.sh. +inline std::string_view rspecDescribedClass( TSNode node, std::string_view src ) +{ + TSNode prev = node; + for( TSNode n = ts_node_parent( node ); !ts_node_is_null( n ); prev = n, n = ts_node_parent( n ) ) + { + const char* pt = ts_node_type( prev ); + if( !kindIs( ts_node_type( n ), "call" ) || !( kindIs( pt, "do_block" ) || kindIs( pt, "block" ) ) ) + { + continue; // not a call, or the site is in its receiver/arguments rather than its block + } + const int group = rspecGroupKind( n, src ); + if( group == 2 ) + { + return {}; // a shared group: its body runs in whichever group includes it + } + if( group == 1 ) + { + if( const std::optional arg = rspecGroupArgument( n, src ) ) + { + return *arg; + } + } + } + return {}; +} + // nullopt when the node is neither (classifyReceiver's shared arms decide it); otherwise the answer, which is empty for a // (scope_resolution) whose `name:` is not a (constant). inline std::optional classifyRubyReceiver( TSNode node, std::string_view src ) @@ -96,16 +187,20 @@ inline std::optional classifyRubyReceiver( TSNode node, std::string_v { return RecvShape { RecvKind::ThisObj, {}, {} }; // Ruby `self` — its own node kind, not an identifier } - if( !kindIs( rt, "constant" ) && !kindIs( rt, "scope_resolution" ) ) + if( kindIs( rt, "identifier" ) && pattern::nodeText( node, src ) == "described_class" ) { - return std::nullopt; + const std::string_view cls = rspecDescribedClass( node, src ); + if( cls.empty() ) + { + return std::nullopt; // no constant-described group encloses it: the identifier arm answers as before + } + return RecvShape { RecvKind::NamedVar, std::string( cls ), {} }; // the group's constant — Rule 2c fuel } - const TSNode leaf = kindIs( rt, "constant" ) ? node : fieldChild( node, NodeField::Name ); - if( ts_node_is_null( leaf ) || !kindIs( ts_node_type( leaf ), "constant" ) ) + if( !isRubyConstantNode( node ) ) { - return RecvShape {}; + return std::nullopt; } - const std::string_view v = pattern::nodeText( leaf, src ); + const std::string_view v = rubyFinalConstant( node, src ); if( v.empty() ) { return RecvShape {}; diff --git a/src/ingest_cache.h b/src/ingest_cache.h index c5f828963..5b04bbb6b 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -279,7 +279,17 @@ constexpr std::uint32_t kCacheVersion = 25; // 25: #150 AND #157 (trai // (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 = 124; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 125; // bump on any grammar/.scm/extraction change + // 125 = 2026-09-27 (#338, test/rubydescribedclasscheck.sh): RSpec's + // `described_class` receiver is classified as the constant + // the innermost constant-described example group names + // (ingest_binds.h::rspecDescribedClass), so RawRef::recv / + // recvVar change VALUE for those call sites — NamedVar + // "Calc" where they were NamedVar "described_class". Carried + // as 121 on the PR; renumbered 125 after #310 (121), #320 + // (122) and #220 (124), with 123 reserved for #325. No record + // layout change: kCacheVersion stays 25 (NOT 24); a Ruby cache + // written at 124 holds the old receiver and must re-parse. // 124 = 2026-09-26 (#220 part 2, test/depsprecisecheck.sh // P2-O): a TS/JS RE-EXPORT (`export … from './y'`) is an // Include like an import (ingest_relations.h diff --git a/src/quality.h b/src/quality.h index 096862e99..9c77b6522 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2113,7 +2113,9 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 25; // MUST equal ingest.cpp's kCacheVersion (gated); 25 = #157 + #150 -constexpr std::uint32_t kIngestParserVerMirror = 124; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 125; // MUST equal ingest.cpp's kParserVer (gated) + // 125 = 2026-09-27 (#338, RSpec described_class, see kParserVer note; + // kIngestCacheVersionMirror stays 25) // 122 = 2026-09-25 (#320/#67, Astro frontmatter, see kParserVer note; // kIngestCacheVersionMirror stays 25) // 121 = 2026-09-24 (#310, Ruby attr DSL, see kParserVer note) diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index f75fced32..7f446db02 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -c451a79f1cdb996e4263c79f5e30fe0486e0917efdeecd08c1cb375d1040c2cf +dc868b59c7eb9612588c98e631ef03799592213292ac9000a680b7b3ff5981ab diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index b28bf0a78..c2f3d78e7 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,10 @@ 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-27, feat/ruby-described-class (#338) rebased onto upstream/main 3fcd515f: RE-DERIVED with UPDATE_GOLDEN=1 — +# kParserVer 124 -> 125 (RSpec's described_class receiver takes the group's constant; recv/recvVar change value, +# same layout; carried as 121 on the PR, 123 stays reserved for #325). kCacheVersion stays 25, kQSnapCacheScheme +# is untouched, so no cached Snapshot MEANING changes. # 2026-09-27, train 21 (#220 part 2 + builtin-bind merged): RE-DERIVED on the merged tree with UPDATE_GOLDEN=1 — the # hash is c451a79f1c…40c2cf, unchanged from #220's entry below: builtin-bind's kQSnapCacheScheme 14 -> 15 and its # declinedCallMayReach exemption move no hashed declaration, and #220's kParserVer 124 is already in the pin. From 5efb0718a7a008c87b26b2117cceb279b8696012 Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sat, 26 Sep 2026 17:48:15 +0300 Subject: [PATCH 3/9] test(ruby): describe nil passes the parent's described_class through, and a file that redefines described_class declines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RSpec reads a nil first argument like a String: the parent group's described class stands. The gate's claim said so, but no arm tried it, and the site declined. And a spec that redefines described_class — let( :described_class ) { … }, def described_class, or a local described_class = … — means what it assigns, which this rule does not read; the site pinned to the group's constant instead. Floor (e) states the answer: that file declines. Against a563c323: 15 pass, 3 fail — the nil claim and both redefinition arms. --- test/rubydescribedclasscheck.sh | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/test/rubydescribedclasscheck.sh b/test/rubydescribedclasscheck.sh index 638d53c39..015edca97 100755 --- a/test/rubydescribedclasscheck.sh +++ b/test/rubydescribedclasscheck.sh @@ -21,6 +21,9 @@ # (c) a `describe` call on any other receiver (`Docs.describe Calc do`) is not an RSpec example group. # (d) `subject` — the implicit `described_class.new` — is NOT modeled in this round: an explicit `subject { … }` # can be anything, and telling the two apart is its own round. +# (e) a file that REDEFINES `described_class` — `let( :described_class ) { … }`, `def described_class`, or a local +# `described_class = …` — declines every `described_class` in that file: the redefinition names what it means, +# and this rule does not read it, so it answers nothing rather than the group's constant. # # Usage: test/rubydescribedclasscheck.sh | RIPWIRE_BIN=asan/ripwire test/rubydescribedclasscheck.sh # Exits non-zero on any failure. Does NOT edit test/regression.sh. Self-contained via mktemp. @@ -69,6 +72,18 @@ class Calc a end + def self.m_nil( a ) + a + end + + def self.m_let( a ) + a + end + + def self.m_asgn( a ) + a + end + def m_chain 1 end @@ -103,6 +118,18 @@ class Tally a end + def self.m_nil( a ) + a + end + + def self.m_let( a ) + a + end + + def self.m_asgn( a ) + a + end + def m_chain 2 end @@ -147,6 +174,12 @@ RSpec.describe Calc do end end + describe nil do + it "passes its parent's class through, as a string does" do + described_class.m_nil( 1 ) + end + end + describe Tally do it "takes the innermost constant" do described_class.m_inner( 1 ) @@ -181,6 +214,22 @@ Docs.describe Calc do end RUBY +cat > "$FIX/spec/let_spec.rb" <<'RUBY' +RSpec.describe Calc do + let( :described_class ) { Tally } + it { described_class.m_let( 1 ) } +end +RUBY + +cat > "$FIX/spec/asgn_spec.rb" <<'RUBY' +RSpec.describe Calc do + it "calls through a local of the same name" do + described_class = Tally + described_class.m_asgn( 1 ) + end +end +RUBY + MAP="$DIR/map.xml" "$BIN" "$FIX" --no-cache >"$MAP" 2>"$DIR/map.err" if [ $? -eq 0 ]; then ok "default map exits 0"; else no "default map exited non-zero: $( cat "$DIR/map.err" )"; fi @@ -232,12 +281,15 @@ pins calc_spec.rb m_str Calc Tally "a string-described group passes its pa pins calc_spec.rb m_ctx Calc Tally "so does a string-described context" pins calc_spec.rb m_inner Tally Calc "a nested describe Tally is the innermost constant — RSpec's own rule" pins engine_spec.rb e_run Engine Other "a bare describe, and a scope_resolution constant named by its final segment" +pins calc_spec.rb m_nil Calc Tally "describe nil passes its parent's class through — RSpec reads nil like a String" -echo "=== floors (a) chained, (b) no constant, (c) not RSpec — each left exactly as it was ===" +echo "=== floors (a) chained, (b) no constant, (c) not RSpec, (e) redefined — each left exactly as it was ===" untouched calc_spec.rb m_chain "described_class.new.m_chain — the receiver is a call, the #267 one-hop bound (floor (a), stated)" untouched noclass_spec.rb m_none "RSpec.describe \"no class\" names no class (floor (b), stated)" untouched noclass_spec.rb m_sym "RSpec.describe :sym names no class (floor (b), stated)" untouched docs_spec.rb m_docs "Docs.describe Calc is not an RSpec example group (floor (c), stated)" +untouched let_spec.rb m_let "let( :described_class ) redefines it: the file declines (floor (e), stated)" +untouched asgn_spec.rb m_asgn "a local described_class = Tally redefines it: the file declines (floor (e), stated)" echo "=== determinism and warm == cold ===" "$BIN" "$FIX" --no-cache >"$DIR/b.xml" 2>/dev/null From ddd567d62afac851bb1e03cb895beaeb8bed485d Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sat, 26 Sep 2026 17:51:44 +0300 Subject: [PATCH 4/9] fix(ruby): describe nil passes the parent's described_class through, and a file that redefines described_class declines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rspecGroupArgument now reads a nil first argument the way RSpec does — like a String, so the parent group's described class stands — as its comment already said. rubyRedefinesDescribedClass declines every site in a file that redefines the name (a :described_class symbol such as let( :described_class ), def described_class, or an assignment to it): what the redefinition means is not something this rule reads (floor (e)). test/rubydescribedclasscheck.sh 18/0 (15/3 before). Measured: the redefinition floor matches 2 files in app B and none in app A, and neither file holds a described_class.m call site, so the published edge counts stand. --- src/ingest_binds.h | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 98d0407d4..046963892 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -138,13 +138,39 @@ inline std::optional rspecGroupArgument( TSNode call, std::str { const TSNode args = fieldChild( call, NodeField::Arguments ); const TSNode first = ts_node_is_null( args ) ? args : ts_node_named_child( args, 0 ); - if( ts_node_is_null( first ) || kindIs( ts_node_type( first ), "string" ) ) + if( ts_node_is_null( first ) || kindIs( ts_node_type( first ), "string" ) || kindIs( ts_node_type( first ), "nil" ) ) { return std::nullopt; } return isRubyConstantNode( first ) ? rubyFinalConstant( first, src ) : std::string_view {}; } +// True when the file REDEFINES `described_class` — `let( :described_class )`, `def described_class`, or a local +// `described_class = …` — so the name means what that assigns, which the rule below does not read. Such a file's +// sites decline (floor (e)). Conservative on purpose: any `:described_class`, or an `=` after the name, counts. +inline bool rubyRedefinesDescribedClass( std::string_view src ) noexcept +{ + static constexpr std::string_view kName = "described_class"; + for( std::size_t at = src.find( kName ); at != std::string_view::npos; at = src.find( kName, at + kName.size() ) ) + { + const std::string_view before = src.substr( 0, at ); + if( before.ends_with( ':' ) || before.ends_with( "def " ) ) + { + return true; + } + std::size_t k = at + kName.size(); + while( k < src.size() && src[ k ] == ' ' ) + { + ++k; + } + if( k + 1 < src.size() && src[ k ] == '=' && src[ k + 1 ] != '=' && src[ k + 1 ] != '~' && src[ k + 1 ] != '>' ) + { + return true; + } + } + return false; +} + // RSpec's `described_class` — a bare (identifier) receiver no binding names — read as the constant it IS. RSpec's // own rule (rspec-core 3.13, Metadata::ExampleGroupHash#described_class): a group's described class is its FIRST // description argument unless that is nil or a String; otherwise it is the parent group's. So the walk climbs the @@ -189,10 +215,10 @@ inline std::optional classifyRubyReceiver( TSNode node, std::string_v } if( kindIs( rt, "identifier" ) && pattern::nodeText( node, src ) == "described_class" ) { - const std::string_view cls = rspecDescribedClass( node, src ); + const std::string_view cls = rubyRedefinesDescribedClass( src ) ? std::string_view {} : rspecDescribedClass( node, src ); if( cls.empty() ) { - return std::nullopt; // no constant-described group encloses it: the identifier arm answers as before + return std::nullopt; // redefined, or no constant-described group encloses it: the identifier arm answers as before } return RecvShape { RecvKind::NamedVar, std::string( cls ), {} }; // the group's constant — Rule 2c fuel } From fb97fc18f9d0ecb476ff42006010cfb4ee79ad41 Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sun, 27 Sep 2026 17:15:07 +0300 Subject: [PATCH 5/9] test(ruby): floor (e) reads a local described_class by Ruby's own scoping, floor (f) is stated, and the shared-group stop is pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #338. Floor (e) declined a let/def/`=` redefinition file-wide, but three more local bindings pinned to the group's class: a block parameter (|described_class|), a method parameter, and a multiple-assignment target; `||=` did too. And the text scan had no word boundary, so my_described_class = x or :described_class_name declined every site in the file. The new arms state floor (e) as Ruby reads it: a METHOD of that name declines the file, a LOCAL declines the sites after its binding in its own scope and the blocks nested in it — never a sibling example, never across a def. Floor (f) is new and stated, pinned both ways: `describe Cask::Tab` reads as Tab, so a method both Tabs define splits, and one only ::Tab defines pins there unmarked. A shared_examples / shared_context body is pinned untouched, so the shared-group stop can no longer be removed with the gate green. Against ddd567d6: 22 pass, 8 fail — the four local shapes (still pinned) and the four scoping and word-boundary arms (still declined). The shared-group and floor (f) arms pass there by design. --- test/rubydescribedclasscheck.sh | 221 +++++++++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 4 deletions(-) diff --git a/test/rubydescribedclasscheck.sh b/test/rubydescribedclasscheck.sh index 015edca97..fc3d84459 100755 --- a/test/rubydescribedclasscheck.sh +++ b/test/rubydescribedclasscheck.sh @@ -21,9 +21,20 @@ # (c) a `describe` call on any other receiver (`Docs.describe Calc do`) is not an RSpec example group. # (d) `subject` — the implicit `described_class.new` — is NOT modeled in this round: an explicit `subject { … }` # can be anything, and telling the two apart is its own round. -# (e) a file that REDEFINES `described_class` — `let( :described_class ) { … }`, `def described_class`, or a local -# `described_class = …` — declines every `described_class` in that file: the redefinition names what it means, -# and this rule does not read it, so it answers nothing rather than the group's constant. +# (e) a REDEFINED `described_class` declines: the redefinition names what it means, and this rule does not read +# it, so it answers nothing rather than the group's constant. A METHOD of that name — any `:described_class` +# symbol (`let( :described_class ) { … }`) or `def described_class` — declines every site in the file, since a +# method reaches the whole group. A LOCAL of that name — an assignment (`=`, `||=`), a multiple-assignment +# target, a block or method parameter — declines the sites Ruby reads as that local: after the binding in its +# own scope and in the blocks nested inside it, never in a sibling block, and never across a `def`. The name +# is matched as a whole word: `my_described_class = x` and `:described_class_name` redefine nothing. +# (f) a QUALIFIED describe is named by its FINAL segment: `RSpec.describe Cask::Tab` reads as `Tab`, the same +# final-segment rule #267 gives a written `Cask::Tab.m`. So where another `Tab` defines the method too, the +# call splits between the two; where only the other `Tab` defines it (the described class inherits it), the +# call pins to that one, unmarked. Carrying the full path to the resolver is its own round. +# +# A SHARED group (`shared_examples`, `shared_examples_for`, `shared_context`) stops the walk with no answer: its body +# runs inside whichever group includes it, so its lexical parent's described class is not its own. # # Usage: test/rubydescribedclasscheck.sh | RIPWIRE_BIN=asan/ripwire test/rubydescribedclasscheck.sh # Exits non-zero on any failure. Does NOT edit test/regression.sh. Self-contained via mktemp. @@ -84,6 +95,46 @@ class Calc a end + def self.r_bparam( a ) + a + end + + def self.r_mparam( a ) + a + end + + def self.r_masgn( a ) + a + end + + def self.r_opasgn( a ) + a + end + + def self.m_sib( a ) + a + end + + def self.m_pre( a ) + a + end + + def self.m_wb1( a ) + a + end + + def self.m_wb2( a ) + a + end + + def self.m_shared( a ) + a + end + + def self.m_sctx( a ) + a + end + def m_chain 1 end @@ -130,6 +181,46 @@ class Tally a end + def self.r_bparam( a ) + a + end + + def self.r_mparam( a ) + a + end + + def self.r_masgn( a ) + a + end + + def self.r_opasgn( a ) + a + end + + def self.m_sib( a ) + a + end + + def self.m_pre( a ) + a + end + + def self.m_wb1( a ) + a + end + + def self.m_wb2( a ) + a + end + + def self.m_shared( a ) + a + end + + def self.m_sctx( a ) + a + end + def m_chain 2 end @@ -156,6 +247,29 @@ module Docs end RUBY +# floor (f): two classes whose FINAL segment matches, each in its own file so FILE:SYM names one definition +cat > "$FIX/lib/cask_tab.rb" <<'RUBY' +module Cask + class Tab + def self.t_both( a ) + a + end + end +end +RUBY + +cat > "$FIX/lib/tab.rb" <<'RUBY' +class Tab + def self.t_both( a ) + a + end + + def self.t_lone( a ) + a + end +end +RUBY + cat > "$FIX/spec/calc_spec.rb" <<'RUBY' RSpec.describe Calc do it "pins the top-level group's class" do @@ -230,6 +344,76 @@ RSpec.describe Calc do end RUBY +cat > "$FIX/spec/local_spec.rb" <<'RUBY' +RSpec.describe Calc do + [Tally].each do |described_class| + it { described_class.r_bparam( 1 ) } + end + + def helper( described_class ) + described_class.r_mparam( 1 ) + end + + it { described_class, other = Tally, 1 + described_class.r_masgn( 1 ) } + + it do + described_class ||= Tally + described_class.r_opasgn( 1 ) + end +end +RUBY + +cat > "$FIX/spec/scope_spec.rb" <<'RUBY' +RSpec.describe Calc do + it "binds a local after one call" do + described_class.m_pre( 1 ) + described_class = Tally + end + + it "is a sibling of the example that binds it" do + described_class.m_sib( 1 ) + end +end +RUBY + +cat > "$FIX/spec/wordb1_spec.rb" <<'RUBY' +RSpec.describe Calc do + it do + my_described_class = Tally + described_class.m_wb1( 1 ) + end +end +RUBY + +cat > "$FIX/spec/wordb2_spec.rb" <<'RUBY' +RSpec.describe Calc do + it do + respond_to( :described_class_name ) + described_class.m_wb2( 1 ) + end +end +RUBY + +cat > "$FIX/spec/shared_spec.rb" <<'RUBY' +RSpec.describe Calc do + shared_examples "a shared group" do + it { described_class.m_shared( 1 ) } + end + + shared_context "a shared context" do + it { described_class.m_sctx( 1 ) } + end +end +RUBY + +cat > "$FIX/spec/qual_spec.rb" <<'RUBY' +RSpec.describe Cask::Tab do + it { described_class.t_both( 1 ) } + it { described_class.t_lone( 1 ) } +end +RUBY + MAP="$DIR/map.xml" "$BIN" "$FIX" --no-cache >"$MAP" 2>"$DIR/map.err" if [ $? -eq 0 ]; then ok "default map exits 0"; else no "default map exited non-zero: $( cat "$DIR/map.err" )"; fi @@ -289,7 +473,36 @@ untouched noclass_spec.rb m_none "RSpec.describe \"no class\" names no class (f untouched noclass_spec.rb m_sym "RSpec.describe :sym names no class (floor (b), stated)" untouched docs_spec.rb m_docs "Docs.describe Calc is not an RSpec example group (floor (c), stated)" untouched let_spec.rb m_let "let( :described_class ) redefines it: the file declines (floor (e), stated)" -untouched asgn_spec.rb m_asgn "a local described_class = Tally redefines it: the file declines (floor (e), stated)" +untouched asgn_spec.rb m_asgn "a local described_class = Tally redefines it: the site after it declines (floor (e), stated)" +untouched local_spec.rb r_bparam "a block parameter |described_class| redefines it inside that block (floor (e), stated)" +untouched local_spec.rb r_mparam "a method parameter def helper( described_class ) redefines it inside that def (floor (e), stated)" +untouched local_spec.rb r_masgn "a multiple-assignment target described_class, other = … redefines it (floor (e), stated)" +untouched local_spec.rb r_opasgn "described_class ||= Tally binds a local too (floor (e), stated)" + +echo "=== floor (e) is Ruby's local scoping, and matches the name as a whole word ===" +pins scope_spec.rb m_pre Calc Tally "a site BEFORE the local's assignment still reads RSpec's method" +pins scope_spec.rb m_sib Calc Tally "a local bound in a sibling example does not reach this one" +pins wordb1_spec.rb m_wb1 Calc Tally "my_described_class = Tally is another name — nothing is redefined" +pins wordb2_spec.rb m_wb2 Calc Tally ":described_class_name is another symbol — nothing is redefined" + +echo "=== a shared group stops the walk: its body runs in whichever group includes it ===" +untouched shared_spec.rb m_shared "shared_examples inside RSpec.describe Calc is not described by Calc" +untouched shared_spec.rb m_sctx "nor is shared_context" + +echo "=== floor (f): a qualified describe is named by its final segment (stated, pinned both ways) ===" +calls "$FIX" lib/cask_tab.rb:t_both qual_spec.rb; qa=$? +calls "$FIX" lib/tab.rb:t_both qual_spec.rb; qb=$? +if [ "$qa" -ne 2 ] && [ "$qb" -ne 2 ] +then + [ "$qa" -eq 0 ] && [ "$qb" -eq 0 ] && ok "qual_spec.rb: describe Cask::Tab reads as Tab — described_class.t_both splits over Cask::Tab and ::Tab (floor (f), stated)" \ + || no "qual_spec.rb: described_class.t_both — Cask::Tab caller=$( [ "$qa" -eq 0 ] && echo yes || echo no ), ::Tab caller=$( [ "$qb" -eq 0 ] && echo yes || echo no ) — floor (f) says both" +fi +calls "$FIX" lib/tab.rb:t_lone qual_spec.rb; ql=$? +if [ "$ql" -ne 2 ] +then + [ "$ql" -eq 0 ] && ok "qual_spec.rb: described_class.t_lone pins to ::Tab, the only Tab defining it — the unmarked pin floor (f) states" \ + || no "qual_spec.rb: described_class.t_lone no longer reaches ::Tab::t_lone — floor (f) moved; restate it" +fi echo "=== determinism and warm == cold ===" "$BIN" "$FIX" --no-cache >"$DIR/b.xml" 2>/dev/null From 46494e489adaa22aab16501495284c8cdb062fce Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sun, 27 Sep 2026 17:22:41 +0300 Subject: [PATCH 6/9] refactor(ruby): rubyFinalConstant reads its name: child through fieldChildTextOfKind, the shape fieldIdentifierText already has Review round on #338, item 2. Since this branch's base, main gained fieldIdentifierText (#310): fetch a field child, check its kind, return its text. rubyFinalConstant repeated that shape with `constant` in place of `identifier`, and --quality-delta=upstream/main..HEAD gated 2 on the pair (duplication plus new-clone-of-reused-helper). The shape now lives once, as fieldChildTextOfKind( n, field, "kind", src ) in ingest_relations.h. The kind stays a string literal, so kindIs keeps its compile-time length. fieldIdentifierText keeps its three-parameter signature and calls it with "identifier", so none of its four call sites moves and no api-surface row appears. rubyFinalConstant handles a bare (constant) itself and asks for the `name:` child of kind "constant". No behavior change. --- src/ingest_binds.h | 7 +++---- src/ingest_relations.h | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 046963892..3b8dabcb9 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -91,12 +91,11 @@ struct RecvShape // or empty when the node is a (scope_resolution) whose `name:` is not a (constant). Callers have checked the kind. inline std::string_view rubyFinalConstant( TSNode node, std::string_view src ) { - const TSNode leaf = kindIs( ts_node_type( node ), "constant" ) ? node : fieldChild( node, NodeField::Name ); - if( ts_node_is_null( leaf ) || !kindIs( ts_node_type( leaf ), "constant" ) ) + if( kindIs( ts_node_type( node ), "constant" ) ) { - return {}; + return pattern::nodeText( node, src ); } - return pattern::nodeText( leaf, src ); + return fieldChildTextOfKind( node, NodeField::Name, "constant", src ); } inline bool isRubyConstantNode( TSNode node ) noexcept diff --git a/src/ingest_relations.h b/src/ingest_relations.h index ccfa1cab6..a8f62f62d 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -1295,19 +1295,28 @@ inline std::string rubyAutoloadTarget( TSNode n, std::string_view src, bool& sym return std::string( txt ); } -// 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 the node's field child is of node kind `kind`, its text; else empty. The field-child-of-one-kind-and-give- +// me-its-text shape recurs across grammars: fieldIdentifierText below is its `identifier` case, and +// ingest_binds.h's rubyFinalConstant its `constant` case. The kind stays a literal, so kindIs keeps its +// compile-time length. +template< std::size_t N > +inline std::string_view fieldChildTextOfKind( TSNode n, NodeField field, const char ( &kind )[N], std::string_view src ) noexcept { const TSNode c = fieldChild( n, field ); - if( ts_node_is_null( c ) || !kindIs( ts_node_type( c ), "identifier" ) ) + if( ts_node_is_null( c ) || !kindIs( ts_node_type( c ), kind ) ) { return {}; } return nodeTextOf( c, src ); } +// If the node's field child is a bare identifier, its text; else empty (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 +{ + return fieldChildTextOfKind( n, field, "identifier", 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. From ed6bd61da5ab17894bfb00cd683610f9ef06ac69 Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sun, 27 Sep 2026 17:23:17 +0300 Subject: [PATCH 7/9] fix(ruby): a local described_class declines where Ruby reads it as that local, and a redefining method is matched as a whole word Review round on #338, item 3 (floor (e)). Two checks replace rubyRedefinesDescribedClass's one text scan: - rubyDefinesDescribedClassMethod: a METHOD named described_class (any `:described_class` symbol, `def described_class`, `def self.described_class`) still declines the whole file, since a method reaches every example in its group. The name must now end at a word boundary: `:described_class_name` is another symbol. - rubyDescribedClassIsLocal: a LOCAL is read from the tree by Ruby's own scoping. From the site, the walk climbs; at each enclosing node it searches the children that end before the site (earlier statements, a block's or a def's parameters) without entering a nested block, lambda, def, class or module; and it stops after the first def, class or module. An enclosing assignment to the name counts too, because in `described_class = described_class.m` the right side already reads the local. Binding forms: `=`, `||=`, a multiple-assignment target, and block, method, lambda, optional, keyword, splat and rescue parameters. The walk follows the Python global/nonlocal scan's explicit stack, so a hostile nesting depth costs heap, not stack. So the three shapes the review found (a block parameter, a method parameter, a multiple assignment) decline, and so does `||=`. A site before the local's assignment and a sibling example's site pin again, as Ruby reads them. `my_described_class = x` redefines nothing. rubydescribedclasscheck: 31/0 (22/8 on the red commit). One arm is new here: `described_class = described_class.r_self( 1 )`. The file rule declined it, so the red run needed no arm, but the new walk missed it until the enclosing-assignment check. A binary with that check removed fails exactly that arm; one with the shared-group stop disabled fails exactly the two shared-group arms. --- src/ingest_binds.h | 105 ++++++++++++++++++++++++++++---- test/rubydescribedclasscheck.sh | 11 ++++ 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 3b8dabcb9..c343a3b3e 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -144,27 +144,109 @@ inline std::optional rspecGroupArgument( TSNode call, std::str return isRubyConstantNode( first ) ? rubyFinalConstant( first, src ) : std::string_view {}; } -// True when the file REDEFINES `described_class` — `let( :described_class )`, `def described_class`, or a local -// `described_class = …` — so the name means what that assigns, which the rule below does not read. Such a file's -// sites decline (floor (e)). Conservative on purpose: any `:described_class`, or an `=` after the name, counts. -inline bool rubyRedefinesDescribedClass( std::string_view src ) noexcept +// True when the file defines a METHOD named described_class — any `:described_class` symbol (`let( :described_class )`, +// `subject( :described_class )`, `define_method( :described_class )`) or `def described_class` / `def self.described_class`. +// A method reaches every example in its group, so the file's sites decline (floor (e)). Conservative on purpose, but a +// whole word: `:described_class_name` and `my_described_class` are other names. +inline bool rubyDefinesDescribedClassMethod( std::string_view src ) noexcept { static constexpr std::string_view kName = "described_class"; for( std::size_t at = src.find( kName ); at != std::string_view::npos; at = src.find( kName, at + kName.size() ) ) { + const std::size_t end = at + kName.size(); + if( end < src.size() && ( namesplit::isIdentChar( src[ end ] ) || src[ end ] == '?' || src[ end ] == '!' ) ) + { + continue; // `described_class_name`, `described_class?` — another name + } const std::string_view before = src.substr( 0, at ); - if( before.ends_with( ':' ) || before.ends_with( "def " ) ) + if( before.ends_with( ':' ) || before.ends_with( "def " ) || before.ends_with( "def self." ) ) + { + return true; + } + } + return false; +} + +// True when `n` itself BINDS the local described_class: a parameter list or parameter naming it, or an assignment +// (`=`, `||=`, a multiple assignment's target list) whose target it is. +inline bool rubyNodeBindsDescribedClass( TSNode n, std::string_view src ) +{ + const auto named = [ & ]( TSNode c ) { return !ts_node_is_null( c ) && kindIs( ts_node_type( c ), "identifier" ) && pattern::nodeText( c, src ) == "described_class"; }; + const char* t = ts_node_type( n ); + if( kindIs( t, "assignment" ) || kindIs( t, "operator_assignment" ) ) + { + return named( fieldChild( n, NodeField::Left ) ); + } + if( kindIs( t, "optional_parameter" ) || kindIs( t, "keyword_parameter" ) ) + { + return named( fieldChild( n, NodeField::Name ) ); + } + if( !kindIs( t, "block_parameters" ) && !kindIs( t, "method_parameters" ) && !kindIs( t, "lambda_parameters" ) && !kindIs( t, "left_assignment_list" ) + && !kindIs( t, "destructured_left_assignment" ) && !kindIs( t, "rest_assignment" ) && !kindIs( t, "destructured_parameter" ) + && !kindIs( t, "splat_parameter" ) && !kindIs( t, "hash_splat_parameter" ) && !kindIs( t, "block_parameter" ) && !kindIs( t, "exception_variable" ) ) + { + return false; + } + const std::uint32_t cc = ts_node_named_child_count( n ); + for( std::uint32_t i = 0; i < cc; ++i ) + { + if( named( ts_node_named_child( n, i ) ) ) { return true; } - std::size_t k = at + kName.size(); - while( k < src.size() && src[ k ] == ' ' ) + } + return false; +} + +// A node whose locals never leak out (a block, a lambda), or that sees no outer local at all (a def, a class, a module). +inline bool isRubyLocalScope( const char* t ) noexcept +{ + return kindIs( t, "block" ) || kindIs( t, "do_block" ) || kindIs( t, "lambda" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) + || kindIs( t, "class" ) || kindIs( t, "singleton_class" ) || kindIs( t, "module" ); +} + +// True when the (identifier) `site` reads a LOCAL named described_class, not RSpec's method — Ruby's own rule: a local +// is visible after its binding, in its own scope and in the blocks nested inside it. So the walk climbs from the site; +// at each enclosing node it searches the children that END before the site (earlier statements, a block's or a def's +// parameters) without entering a nested block, def, class or module; and it stops after the first def, class or +// module, which sees no outer local. Floor (e). +inline bool rubyDescribedClassIsLocal( TSNode site, std::string_view src ) +{ + const std::uint32_t at = ts_node_start_byte( site ); + std::vector stack; + for( TSNode n = ts_node_parent( site ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) + { + if( rubyNodeBindsDescribedClass( n, src ) ) + { + return true; // `described_class = described_class.m` — the right side already reads the local + } + const std::uint32_t cc = ts_node_named_child_count( n ); + for( std::uint32_t i = 0; i < cc && ts_node_end_byte( ts_node_named_child( n, i ) ) <= at; ++i ) { - ++k; + stack.push_back( ts_node_named_child( n, i ) ); } - if( k + 1 < src.size() && src[ k ] == '=' && src[ k + 1 ] != '=' && src[ k + 1 ] != '~' && src[ k + 1 ] != '>' ) + while( !stack.empty() ) { - return true; + const TSNode cur = stack.back(); + stack.pop_back(); + if( rubyNodeBindsDescribedClass( cur, src ) ) + { + return true; + } + if( isRubyLocalScope( ts_node_type( cur ) ) ) + { + continue; // a nested scope's locals never reach the site + } + const std::uint32_t kids = ts_node_named_child_count( cur ); + for( std::uint32_t i = 0; i < kids; ++i ) + { + stack.push_back( ts_node_named_child( cur, i ) ); + } + } + const char* nt = ts_node_type( n ); + if( isRubyLocalScope( nt ) && !kindIs( nt, "block" ) && !kindIs( nt, "do_block" ) && !kindIs( nt, "lambda" ) ) + { + break; // a def, class or module: no outer local reaches in } } return false; @@ -214,7 +296,8 @@ inline std::optional classifyRubyReceiver( TSNode node, std::string_v } if( kindIs( rt, "identifier" ) && pattern::nodeText( node, src ) == "described_class" ) { - const std::string_view cls = rubyRedefinesDescribedClass( src ) ? std::string_view {} : rspecDescribedClass( node, src ); + const bool redefined = rubyDefinesDescribedClassMethod( src ) || rubyDescribedClassIsLocal( node, src ); + const std::string_view cls = redefined ? std::string_view {} : rspecDescribedClass( node, src ); if( cls.empty() ) { return std::nullopt; // redefined, or no constant-described group encloses it: the identifier arm answers as before diff --git a/test/rubydescribedclasscheck.sh b/test/rubydescribedclasscheck.sh index fc3d84459..fb70190d7 100755 --- a/test/rubydescribedclasscheck.sh +++ b/test/rubydescribedclasscheck.sh @@ -135,6 +135,10 @@ class Calc a end + def self.r_self( a ) + a + end + def m_chain 1 end @@ -221,6 +225,10 @@ class Tally a end + def self.r_self( a ) + a + end + def m_chain 2 end @@ -361,6 +369,8 @@ RSpec.describe Calc do described_class ||= Tally described_class.r_opasgn( 1 ) end + + it { described_class = described_class.r_self( 1 ) } end RUBY @@ -478,6 +488,7 @@ untouched local_spec.rb r_bparam "a block parameter |described_class| redefine untouched local_spec.rb r_mparam "a method parameter def helper( described_class ) redefines it inside that def (floor (e), stated)" untouched local_spec.rb r_masgn "a multiple-assignment target described_class, other = … redefines it (floor (e), stated)" untouched local_spec.rb r_opasgn "described_class ||= Tally binds a local too (floor (e), stated)" +untouched local_spec.rb r_self "described_class = described_class.r_self — the right side already reads the local (floor (e), stated)" echo "=== floor (e) is Ruby's local scoping, and matches the name as a whole word ===" pins scope_spec.rb m_pre Calc Tally "a site BEFORE the local's assignment still reads RSpec's method" From 35daa0af27be5f2b2c7bd49dfada76859bc2f54b Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sun, 27 Sep 2026 17:30:26 +0300 Subject: [PATCH 8/9] refactor(ruby): Ruby's local-scoping kinds are one table, and fieldChildTextOfKind is one conditional return --quality-delta=upstream/main..ed6bd61d gated 4, all shape clones of existing helpers: fieldChildTextOfKind's if/return/return read of a field child against slice.h::sliceIsField, and isRubyLocalScope's kindIs chain against slice.h::sliceIsJsPatternKind and ingest_metrics.h::cc_isParamList. rubyDescribedClassIsLocal also sat at complexity 20 against a bar of 15. The kinds a local-scoping walk needs are now data: kRubyLocalKinds maps each to its RubyLocalRole (Closure, Wall, BindsLeft, BindsName, BindsChildren), and rubyLocalRole looks one up. That also removes the negated eleven-kind chain in rubyNodeBindsDescribedClass. The walk's inner search over a node's earlier children is its own function, rubyEarlierChildBindsDescribedClass, which reuses the caller's stack. No behavior change: rubydescribedclasscheck 31/0. --- src/ingest_binds.h | 120 ++++++++++++++++++++++++----------------- src/ingest_relations.h | 6 +-- 2 files changed, 73 insertions(+), 53 deletions(-) diff --git a/src/ingest_binds.h b/src/ingest_binds.h index c343a3b3e..ad7c987a5 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -167,23 +167,45 @@ inline bool rubyDefinesDescribedClassMethod( std::string_view src ) noexcept return false; } -// True when `n` itself BINDS the local described_class: a parameter list or parameter naming it, or an assignment -// (`=`, `||=`, a multiple assignment's target list) whose target it is. -inline bool rubyNodeBindsDescribedClass( TSNode n, std::string_view src ) +// How a Ruby node kind takes part in LOCAL scoping, for rubyDescribedClassIsLocal. A Closure (a block, a lambda) keeps +// its locals in but sees the enclosing ones; a Wall (a def, a class, a module) sees no outer local; the Binds* kinds +// bind a local — the `left:` child, the `name:` child (not a parameter's default value), or any child identifier. +enum class RubyLocalRole : std::uint8_t { Closure, Wall, BindsLeft, BindsName, BindsChildren }; +struct RubyLocalKind +{ + std::string_view kind; + RubyLocalRole role; +}; +inline constexpr RubyLocalKind kRubyLocalKinds[] = { + { "block", RubyLocalRole::Closure }, { "do_block", RubyLocalRole::Closure }, + { "lambda", RubyLocalRole::Closure }, { "method", RubyLocalRole::Wall }, + { "singleton_method", RubyLocalRole::Wall }, { "class", RubyLocalRole::Wall }, + { "singleton_class", RubyLocalRole::Wall }, { "module", RubyLocalRole::Wall }, + { "assignment", RubyLocalRole::BindsLeft }, { "operator_assignment", RubyLocalRole::BindsLeft }, + { "optional_parameter", RubyLocalRole::BindsName }, { "keyword_parameter", RubyLocalRole::BindsName }, + { "block_parameters", RubyLocalRole::BindsChildren }, { "method_parameters", RubyLocalRole::BindsChildren }, + { "lambda_parameters", RubyLocalRole::BindsChildren }, { "left_assignment_list", RubyLocalRole::BindsChildren }, + { "destructured_left_assignment", RubyLocalRole::BindsChildren }, { "rest_assignment", RubyLocalRole::BindsChildren }, + { "destructured_parameter", RubyLocalRole::BindsChildren }, { "splat_parameter", RubyLocalRole::BindsChildren }, + { "hash_splat_parameter", RubyLocalRole::BindsChildren }, { "block_parameter", RubyLocalRole::BindsChildren }, + { "exception_variable", RubyLocalRole::BindsChildren }, +}; + +inline std::optional rubyLocalRole( const char* t ) noexcept +{ + const auto it = std::ranges::find( kRubyLocalKinds, std::string_view( t ), &RubyLocalKind::kind ); + return it == std::end( kRubyLocalKinds ) ? std::nullopt : std::optional( it->role ); +} + +// True when `n`, whose kind plays `role`, itself BINDS the local described_class. +inline bool rubyNodeBindsDescribedClass( TSNode n, std::optional role, std::string_view src ) { const auto named = [ & ]( TSNode c ) { return !ts_node_is_null( c ) && kindIs( ts_node_type( c ), "identifier" ) && pattern::nodeText( c, src ) == "described_class"; }; - const char* t = ts_node_type( n ); - if( kindIs( t, "assignment" ) || kindIs( t, "operator_assignment" ) ) - { - return named( fieldChild( n, NodeField::Left ) ); - } - if( kindIs( t, "optional_parameter" ) || kindIs( t, "keyword_parameter" ) ) + if( role == RubyLocalRole::BindsLeft || role == RubyLocalRole::BindsName ) { - return named( fieldChild( n, NodeField::Name ) ); + return named( fieldChild( n, role == RubyLocalRole::BindsLeft ? NodeField::Left : NodeField::Name ) ); } - if( !kindIs( t, "block_parameters" ) && !kindIs( t, "method_parameters" ) && !kindIs( t, "lambda_parameters" ) && !kindIs( t, "left_assignment_list" ) - && !kindIs( t, "destructured_left_assignment" ) && !kindIs( t, "rest_assignment" ) && !kindIs( t, "destructured_parameter" ) - && !kindIs( t, "splat_parameter" ) && !kindIs( t, "hash_splat_parameter" ) && !kindIs( t, "block_parameter" ) && !kindIs( t, "exception_variable" ) ) + if( role != RubyLocalRole::BindsChildren ) { return false; } @@ -198,53 +220,55 @@ inline bool rubyNodeBindsDescribedClass( TSNode n, std::string_view src ) return false; } -// A node whose locals never leak out (a block, a lambda), or that sees no outer local at all (a def, a class, a module). -inline bool isRubyLocalScope( const char* t ) noexcept +// True when a child of `n` that ENDS before byte `at` — an earlier statement, a block's or a def's parameters — binds +// described_class, searched without entering a nested block, lambda, def, class or module, whose locals never reach +// past it. `stack` is the caller's scratch, so a hostile nesting depth costs heap, not stack. +inline bool rubyEarlierChildBindsDescribedClass( TSNode n, std::uint32_t at, std::string_view src, std::vector& stack ) { - return kindIs( t, "block" ) || kindIs( t, "do_block" ) || kindIs( t, "lambda" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) - || kindIs( t, "class" ) || kindIs( t, "singleton_class" ) || kindIs( t, "module" ); + stack.clear(); + const std::uint32_t cc = ts_node_named_child_count( n ); + for( std::uint32_t i = 0; i < cc && ts_node_end_byte( ts_node_named_child( n, i ) ) <= at; ++i ) + { + stack.push_back( ts_node_named_child( n, i ) ); + } + while( !stack.empty() ) + { + const TSNode cur = stack.back(); + stack.pop_back(); + const std::optional role = rubyLocalRole( ts_node_type( cur ) ); + if( rubyNodeBindsDescribedClass( cur, role, src ) ) + { + return true; + } + if( role == RubyLocalRole::Closure || role == RubyLocalRole::Wall ) + { + continue; // a nested scope's locals never reach the site + } + const std::uint32_t kids = ts_node_named_child_count( cur ); + for( std::uint32_t i = 0; i < kids; ++i ) + { + stack.push_back( ts_node_named_child( cur, i ) ); + } + } + return false; } // True when the (identifier) `site` reads a LOCAL named described_class, not RSpec's method — Ruby's own rule: a local -// is visible after its binding, in its own scope and in the blocks nested inside it. So the walk climbs from the site; -// at each enclosing node it searches the children that END before the site (earlier statements, a block's or a def's -// parameters) without entering a nested block, def, class or module; and it stops after the first def, class or -// module, which sees no outer local. Floor (e). +// is visible after its binding, in its own scope and in the blocks nested inside it. So the walk climbs from the site, +// searching each enclosing node's earlier children, and stops after the first def, class or module. An enclosing +// assignment counts too: in `described_class = described_class.m` the right side already reads the local. Floor (e). inline bool rubyDescribedClassIsLocal( TSNode site, std::string_view src ) { const std::uint32_t at = ts_node_start_byte( site ); std::vector stack; for( TSNode n = ts_node_parent( site ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) { - if( rubyNodeBindsDescribedClass( n, src ) ) - { - return true; // `described_class = described_class.m` — the right side already reads the local - } - const std::uint32_t cc = ts_node_named_child_count( n ); - for( std::uint32_t i = 0; i < cc && ts_node_end_byte( ts_node_named_child( n, i ) ) <= at; ++i ) + const std::optional role = rubyLocalRole( ts_node_type( n ) ); + if( rubyNodeBindsDescribedClass( n, role, src ) || rubyEarlierChildBindsDescribedClass( n, at, src, stack ) ) { - stack.push_back( ts_node_named_child( n, i ) ); - } - while( !stack.empty() ) - { - const TSNode cur = stack.back(); - stack.pop_back(); - if( rubyNodeBindsDescribedClass( cur, src ) ) - { - return true; - } - if( isRubyLocalScope( ts_node_type( cur ) ) ) - { - continue; // a nested scope's locals never reach the site - } - const std::uint32_t kids = ts_node_named_child_count( cur ); - for( std::uint32_t i = 0; i < kids; ++i ) - { - stack.push_back( ts_node_named_child( cur, i ) ); - } + return true; } - const char* nt = ts_node_type( n ); - if( isRubyLocalScope( nt ) && !kindIs( nt, "block" ) && !kindIs( nt, "do_block" ) && !kindIs( nt, "lambda" ) ) + if( role == RubyLocalRole::Wall ) { break; // a def, class or module: no outer local reaches in } diff --git a/src/ingest_relations.h b/src/ingest_relations.h index a8f62f62d..9c9e665ac 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -1303,11 +1303,7 @@ template< std::size_t N > inline std::string_view fieldChildTextOfKind( TSNode n, NodeField field, const char ( &kind )[N], std::string_view src ) noexcept { const TSNode c = fieldChild( n, field ); - if( ts_node_is_null( c ) || !kindIs( ts_node_type( c ), kind ) ) - { - return {}; - } - return nodeTextOf( c, src ); + return !ts_node_is_null( c ) && kindIs( ts_node_type( c ), kind ) ? nodeTextOf( c, src ) : std::string_view {}; } // If the node's field child is a bare identifier, its text; else empty (elixirTarget, elixirDirectiveTarget, From 24fa0cb7a974b646584138d749916c9faa22cace Mon Sep 17 00:00:00 2001 From: Andriy Tyurnikov Date: Sun, 27 Sep 2026 17:34:23 +0300 Subject: [PATCH 9/9] docs(changelog): RSpec's described_class under [Unreleased], with floors (a)-(f) and the re-measured edge totals Review round on #338, item 5. Measured with --no-cache --report on main 3fcd515f against 35daa0af: Rails app A 26,649 -> 26,755 edges, app B 20,807 -> 21,149; activerecord, activesupport and actionpack 8.1.3 lib/ and this repo's src/ give byte-identical default maps. kParserVer -> 125, kCacheVersion stays 25. --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be41a723..0ce6bd1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,49 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli --- +## [Unreleased] + + +### Added — RSpec's `described_class` is the class its example group names, so a spec's calls pin to the class under test (#338) + +`described_class` is how RSpec spells the class under test. Inside `RSpec.describe Calc do … end`, +`described_class.m( 1 )` is `Calc.m( 1 )`. Its receiver is a bare identifier that no binding names, though, so every +such call declined. A spec reached nothing through it, and `tested=`, `--seams` and `--test-gate` read the class under +test as unreached by the spec written for it. + +The call now pins the way a written `Calc.m( 1 )` does (#267's constant-receiver arm). The rule is RSpec's own +(rspec-core 3.13, `Metadata::ExampleGroupHash#described_class`): a group's described class is its first description +argument, unless that is `nil` or a String, in which case it is the parent group's. So the innermost enclosing example +group with a constant first argument answers. An example group is `describe` / `context` (and `feature`, +`example_group` and the `x`- / `f`- spellings), called bare or on `RSpec`, with a block. A shared group +(`shared_examples`, `shared_examples_for`, `shared_context`) stops the walk with no answer, because its body runs in +whichever group includes it. + +Stated floors, each pinned by `test/rubydescribedclasscheck.sh`: +- **(a)** A chained receiver (`described_class.new.m`) is untouched: #267's one-hop bound. +- **(b)** A group with no constant (`describe "text"` at the top, `describe :sym`) names no class. +- **(c)** A `describe` on any other receiver (`Docs.describe Calc do`) is not an RSpec example group. +- **(d)** `subject`, the implicit `described_class.new`, is not modelled. +- **(e)** A redefined `described_class` declines. A **method** of that name (any `:described_class` symbol, as in + `let( :described_class )`, or `def described_class`) declines every site in the file. A **local** of that name (an + assignment, `||=`, a multiple-assignment target, or a block or method parameter) declines the sites Ruby reads as + that local: after the binding in its own scope and in the blocks nested inside it, never in a sibling block, and + never across a `def`. The name is matched as a whole word, so `my_described_class = x` redefines nothing. +- **(f)** A qualified describe is named by its final segment: `RSpec.describe Cask::Tab` reads as `Tab`. Where another + `Tab` defines the method too, the call splits between them. Where only the other `Tab` defines it, the call pins + there, unmarked. A written `Cask::Tab.m` behaves the same way. + +Measured with `--no-cache`, `--report` edge totals, `main` (3fcd515f) against this change: + +| Corpus | Edges | +| --- | --- | +| Rails app A | 26,649 → 26,755 (+106) | +| Rails app B | 20,807 → 21,149 (+342) | +| activerecord, activesupport, actionpack 8.1.3 `lib/`; this repo's `src/` | default map byte-identical | + +`kParserVer` → 125 (carried as 121 on the PR): `RawRef::recv` / `recvVar` change value for these call sites. +The record layout is unchanged, so `kCacheVersion` stays 25. + ## [0.6.5] — 2026-09-27