From fe15f12f5cded2633ee6eee6401e5d9b5d699971 Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Tue, 13 Oct 2026 00:00:30 +0200 Subject: [PATCH 1/2] refactor(ingest): move Ruby's Rails/DSL capture lane into src/ingest_ruby.h (#339 extraction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure relocation, byte-identical output. The rendered db/schema.rb column capture, the Jbuilder view typing, the FactoryBot walk and the class-side helper-method / singleton-accessor / mixin DSL walks move VERBATIM out of ingest_names.h and ingest_binds.h into one per-language section in the ingest_elixir.h slot. The single def-walker that dispatches the schema lane stays in the shared names section and reaches the minting through its existing forward declaration. Shared feature families stay where they are: captureRubyDefs / captureRubyAttrDefsCall (definitions, ingest_names.h), captureRubyBareCalls + the typed-receiver cluster (receiver classification, ingest_binds.h), captureRubyMixinBases (base relations, ingest_relations.h — slice 4a retires it in the follow-up PR). No extraction change: kParserVer and kQSnapCacheScheme stay (byte-identical, argvdiffcheck 678 vectors). Comment locators retagged (graph.h, model.h, ingest_cache.h, tags.scm); the five FE-B ack rows whose line numbers shifted are relocated; ingest_ruby.h registered in optremarks.py COLD_FILES and fieldidcheck's SITE_FILES. --- .ripwire_quality_acks | 12 +- docs/ARCHITECTURE.md | 3 +- queries/ruby/tags.scm | 2 +- scripts/optremarks.py | 4 + src/graph.h | 2 +- src/ingest.cpp | 1 + src/ingest_binds.h | 543 -------------------------- src/ingest_cache.h | 2 +- src/ingest_names.h | 311 +-------------- src/ingest_ruby.h | 874 ++++++++++++++++++++++++++++++++++++++++++ src/model.h | 14 +- test/fieldidcheck.sh | 2 +- 12 files changed, 900 insertions(+), 870 deletions(-) create mode 100644 src/ingest_ruby.h diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 8cd08cd07..5bd8199e7 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -438,7 +438,7 @@ ack complexity 52c2e3e32753dd28 161 cid=32fe671bf177bea8 Review of #214, round 2 ack complexity 5361e2bced6f1988 23 cid=b34695b5d2051f1d capture-audit 2026-09-04 wave-1 close: symbols two lanes each grew past the other's acked magnitude — runDoctor (L10 legend + blobs_floor=, L9 built_from=), writeEnsembleReport (L9 root=, L10 conditional unavailable=), runAffected/runVerify (L9 root-relative block, L4 gauge), writeTestGateReport (L4 gauge splice, L9 row-gated root=; the XML/JSON twins' duplication is the lockstep mcpclidiffcheck asserts, as L9 acked; graphGaugeAttrXml/Json are the same lockstep), writePanelReport (L10 conditional attrs, L4 counts_floor), printUsage/validateConfig (L1 H10 hoist + L5/L9/L10 help text). Re-acked at the merged magnitude; prior reasons kept | prior: M12 follow-up (capture-audit L9): --ensemble gained root=/root-relative p= — writeEnsembleReport's 3 new default-valued params (singleRoot/rootPrefix/rootAttr, back-compat) thread the caller's already-computed single-root spelling through; short-horizon-churn on the touched dispatcher. ack complexity 53a1214d5505fa41 433 cid=e155c3fb2ceeb9f1 lane V1 N4 (bd8d666): the no-baseline half of the M8 --since policy decided beside the shape/range validation, from SinceScope::baselineSha | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement ack complexity 53b3f823688054b4 83 cid=137a04e79db61a58 by=src/* §N6-C .gitignore-by-default: the crawl gains an ignore mode. The two api-surface/params rows are ONE deliberate contract change — ingest()/collectSources() take a trailing defaulted respectGitignore, the only way a CLI flag can reach the crawl without a global; the three short-horizon-churn rows are this lane's own edits to the flag ledger, the crawl and the --skipped verb, which is what adding a flag with a disclosure IS; collectSources +3 ccx / +11 LOC is what remains after the probe, the mode and the prune fan-out were extracted into probeIgnoreSet/recordDirPrune (it was +15/+43 inline). -ack complexity 569699e57b2a3d87 16 cid=6c5914500a133567 now=16 was=13 p=src/ingest_binds.h:2370 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity 569699e57b2a3d87 16 cid=6c5914500a133567 now=16 was=13 p=src/ingest_binds.h:1827 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 572dbeee3b452cbc 83 cid=4e4f42aee136bfe3 by=src/* A6 (survey card A6, agent-lsp): tested/untested partition on --impact/--callers/--callees rows, reusing the isTestSymbol-seeded lens computeQMetrics/--safe-delete already run (graph.h::testSymbolForwardReach/countTestedIn, shared, not duplicated per-verb). api-surface (2, contract-change): emitColumnarSymbolRows/printJsonSymbolRows gain one optional testReach pointer param (default nullptr, byte-identical on every pre-existing caller) so the columnar/json dialects can carry the same tested= column the XML dialect carries. complexity (1, runCallHierarchy 80->83): the partition's counting loop itself was factored out to graph.h::countTestedIn (shared with --impact, verified this ack run no longer lists emitColumnarSymbolRows/runImpact, which carried the identical loop before extraction); the residual 3 points are the hop_tested=/hop_untested= attribute wiring on an already-large pre-existing dispatcher (its own header comment already names it 'this file's largest dispatcher') - splitting it into per-dialect emitters the way --impact already is would be a correct follow-up but is a materially larger, separate diff. duplication (2, testSymbolForwardReach vs situ.h::testSeedForwardReach / vs csharpInFileTestScope, 38/28 tokens): the shared seed-collect-then-forwardReach shape now lives in ONE template (graph.h::seedForwardReachIf) that both isTestSymbol- and isTestPath-seeded callers delegate to in one line each - the residual similarity is between two intentionally-DIFFERENT predicates the L8 comment on computeQMetrics explains why cannot be merged (a Rust in-file cfg-test mod has no isTestPath file at all), and csharpInFileTestScope is an unrelated short predicate the token-shape coincidentally now resembles post-extraction. short-horizon-churn (8) and verbosity (2): the partition touches --impact's three dialect emitters (XML/columnar/JSON), --callers/--callees' single dispatcher, the shared row helpers, and the MCP impact twin (mcpclidiffcheck parity) in one change - one feature landing across its natural surface, not incidental edits. Full assigned gate list green (reachcheck/callerscheck/impactimportcheck/testedreachcheck/testgatecheck/testgatepagecheck/testgaterefusecheck/graphlegendbudgetcheck/floormarkcheck/legendcoveragecheck/manifestcheck/mcpclidiffcheck), determinism + xmllint clean. ack complexity 574641dcc1bdf0ec 110 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack complexity 59f050855874875f 28 cid=79ec141006215e25 by=src/* P9 the folded edit receipt: runEditVerb gains the postCheck opt-out parameter (defaulted true, so every existing call site is source-compatible) and the line-range + post-check splice; editplan's ensureStage and receipt each gain the root parameter they need to spell one identity (M12's root-relative rule, applied to the sibling it missed) and to run the per-op post-check. The +15 LOC in runEditVerb is the copy-out-before-the-index-rebuild discipline the fold requires — every reference into ing dangles once getIndex re-ingests, and that is stated in the code. | prior: M12 (lane L9): the minor half of the same change — runEditVerb/fetchBody each gain ONE single-root ternary plus the comment naming why the display path and the disk path may now differ, and testmap.h's ctor is ambient churn from the sibling edits in the same file. @@ -496,9 +496,9 @@ ack complexity c5726c667bc4ffa4 77 r2 pkg-dir mention lift: applyMentionBoost cc ack complexity c79b36c44111aca3 30 cid=c0aaf33a6026048d now=30 was=0 p=src/ingest_binds.h:189 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity c94040903501f1fe 26 cid=c0513ed9cc8bd60f now=26 was=11 p=src/ingest_binds.h:267 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity ca97a4b6bf07887b 279 cid=cfe6534d0f63b7c4 now=279 was=271 p=src/serialize.h:2626 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. -ack complexity cab2f93db3992faf 131 cid=34e51af7ebbf56db now=131 was=0 p=src/ingest_binds.h:4183 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity cab2f93db3992faf 131 cid=34e51af7ebbf56db now=131 was=0 p=src/ingest_binds.h:3640 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity cb7342964b38db9c 33 cid=3af54969638510bc by=src/* member-variable round (card A3): usesText grows the Owner.field member-selector branch (resolveFieldSelector + bare-name refusal listing spellings), the registered contract of this round | prior: R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). -ack complexity ce8ba13980fd838f 19 cid=16497434baf13239 now=19 was=0 p=src/ingest_binds.h:4104 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity ce8ba13980fd838f 19 cid=16497434baf13239 now=19 was=0 p=src/ingest_binds.h:3561 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity d03215f48bec3886 17 cid=d622ffa6d33014f4 E1 (terminality round A, lane E): +1 branch: the redaction-marker refusal beside the NUL one (one ladder, one vocabulary) ack complexity d295ac7080c14dd5 18 E1 answer grader + questions task source + claude-runner control isolation (F3). churn=self on run_one/main/build_prompt/_claude_metrics/_harness_metrics/_execute/prepare_codex_environment/prepare_opencode_environment is this one change's own edit window on bench/agentloop/run_agentloop.py, which the opencode round touched days ago: the three preparers now share ephemeral_run_home()+link_credential() and the three command builders share build_harness_command(), so every one of those symbols is edited by the same commit that introduces the claude preparer. run_one's complexity regression was FIXED rather than acked (25->under the bar, by extracting build_harness_command/question_timeout/prepare_environment) and the two dead-code rows were fixed by restoring explicit dispatch — a dict of callables had hidden the codex/opencode preparers from the resolver. The remaining duplication row (prepare_claude_environment | prepare_codex_environment, 101 tokens, down from 282) is DELIBERATE and not further factored: the residue is 'ephemeral home named by one env var + credentials symlinked + set the var', and collapsing it would need a nine-parameter helper that opencode still could not use (it derives every path from xdg-basedir at module load, so five dirs plus HOME must move). Each preparer is asserted independently by its own canary gate — agentloopclaudecheck/agentloopopencodecheck/agentloopcodexcheck — and keeping the three recipes separately readable is the point of those gates. grade_answers.py's new-symbol rows are the six protocol grader types plus the closed accept-rule clause grammar; apply_clauses is a flat per-clause-kind dispatch with no nesting, and transcript_answer_text's error-masking row is the deliberate 'schema drift degrades to nulls, never raises' contract the retained transcript makes safe. Gates: agentloopgradercheck (new, born red on origin/main at exit 2) and agentloopclaudecheck (new, born red at exit 1) both green; codex/opencode canaries and analyze.py --self-test unchanged and green. ack complexity d42c85b67bd0956f 52 cid=346b0c28d573003f L10b finding 8: --situ distinguishes clean-tree from changed-but-symbol-free wording (small branch added) | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement @@ -921,7 +921,7 @@ ack params ed4b3f43f7f19909 9 T3 disclosure-gap fix 2026-08-22: verbosity/churn ack params eea83c3db0f03d69 19 cid=012e11d249150604 by=src/* A2 (dropped_positive, 2026-09-03): packSignatures gained one trailing droppedPositiveOut out-param and the post-ladder accounting inside the rank-adaptive branch — same shared-arithmetic reason as its JSON sibling. ack params f5c01847f9a77e62 6 cid=68a5c5ed63c1ce7b timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. ack params f6dedd851e4b2a25 10 cid=8b170f73ca092237 or-arise rung 2 (--slice-flow/--slice-depth): the ten gating rows are this lane's own flag-addition footprint and nothing foreign — printUsage/validateConfig grow the two new help entries and three refusal arms every modifier flag must add; sliceBundleText/sliceWalk grow the flow legend+rows and the all-occurrence output param (contract kept source-compatible via defaulted flowSpec); runSlice grows the seed-VAR refusal and flow wiring; per-symbol growth is the cost of the registered rung-2 contract in EVALS -ack params f843cd4aaded1a41 7 cid=23a2ed80b7310ad0 now=7 was=0 p=src/ingest_binds.h:4141 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack params f843cd4aaded1a41 7 cid=23a2ed80b7310ad0 now=7 was=0 p=src/ingest_binds.h:3598 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack short-horizon-churn 0024ff89023ae1f8 5 S2c classifier-gap round: churn=self on meter_init/meter_classify_bash/meter_classify_other is this round's own edit window on hooks/ripwire-nudge.sh, which the S2b sweep round, the fixture-isolation round and this one all landed in on the same day — the horizon is counting one wave of meter work, not an unstable symbol. No behaviour is defended by the ack: every classifier verdict is pinned line-by-line by test/hookcheck.sh section (12b) arms C4-C11, each verified RED against this file at origin/main before the fix existed, and the arm contract by A1-A4. The verbosity row on meter_classify_bash (59 to 67 LOC, minor, unacked) is the resolution order becoming explicit — head, walk, scan, shell-misc — and is left visible on purpose ack short-horizon-churn 00e484ac1f953c3d 7 E1 answer grader + questions task source + claude-runner control isolation (F3). churn=self on run_one/main/build_prompt/_claude_metrics/_harness_metrics/_execute/prepare_codex_environment/prepare_opencode_environment is this one change's own edit window on bench/agentloop/run_agentloop.py, which the opencode round touched days ago: the three preparers now share ephemeral_run_home()+link_credential() and the three command builders share build_harness_command(), so every one of those symbols is edited by the same commit that introduces the claude preparer. run_one's complexity regression was FIXED rather than acked (25->under the bar, by extracting build_harness_command/question_timeout/prepare_environment) and the two dead-code rows were fixed by restoring explicit dispatch — a dict of callables had hidden the codex/opencode preparers from the resolver. The remaining duplication row (prepare_claude_environment | prepare_codex_environment, 101 tokens, down from 282) is DELIBERATE and not further factored: the residue is 'ephemeral home named by one env var + credentials symlinked + set the var', and collapsing it would need a nine-parameter helper that opencode still could not use (it derives every path from xdg-basedir at module load, so five dirs plus HOME must move). Each preparer is asserted independently by its own canary gate — agentloopclaudecheck/agentloopopencodecheck/agentloopcodexcheck — and keeping the three recipes separately readable is the point of those gates. grade_answers.py's new-symbol rows are the six protocol grader types plus the closed accept-rule clause grammar; apply_clauses is a flat per-clause-kind dispatch with no nesting, and transcript_answer_text's error-masking row is the deliberate 'schema drift degrades to nulls, never raises' contract the retained transcript makes safe. Gates: agentloopgradercheck (new, born red on origin/main at exit 2) and agentloopclaudecheck (new, born red at exit 1) both green; codex/opencode canaries and analyze.py --self-test unchanged and green. ack short-horizon-churn 0130d0ed5ed1f4cd 2 cid=ced9892f114d85a0 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. @@ -1630,7 +1630,7 @@ ack verbosity 6acbcaa854eada23 289 cid=e5c009ae9a0d8393 preloaded-corpus hoist: ack verbosity 6e58b0a307757079 227 cid=cd68c661a9c12758 now=227 was=222 p=src/serialize.h:8704 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 7004309695fb79f1 265 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. ack verbosity 7051b3950aaf4c14 82 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. -ack verbosity 71459175b4b3d120 122 cid=31f157ab1a674d7b now=122 was=121 p=src/ingest_binds.h:4410 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack verbosity 71459175b4b3d120 122 cid=31f157ab1a674d7b now=122 was=121 p=src/ingest_binds.h:3867 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 719e8441be665024 959 cid=e508c8076e15f8e3 lane win-build-green (review F1/F2/F4): ci.yml's jobs key grows because the windows job stopped asserting exit codes and started asserting answers — a --version SHAPE check (semver + emit= + built_from=), fixture-map floors that separate a real crawl from the 951-byte empty one a Windows path bug produces, ctest --no-tests=error, and the cl leg's own expectation (configure must succeed, build must fail, no D8xxx flag regression hiding behind the seam, every erroring file ours, first error one of the extension codes) built with -k 0 so its log is the inventory. Every added line is an assertion or the reason for one; shrinking this key means deleting the checks the review asked for. Same shape as the release.yml jobs acks above. ack verbosity 73ac37ff2580b0b0 86 cid=b07db8431c1b7c18 R1 identity round (harvest Wave-2 R1, 2026-08-24). Five gating rows, ONE change: finding identity now survives a rename or a move. (1) runQualityDelta complexity 201->205 and verbosity 419->462: the +43 lines are overwhelmingly the LEGEND this repo requires — the identity mechanism is a claim about what an ack means, so the report has to say what it rested on (both mechanisms, both truncation caps, the two per-route counters), and legendcoveragecheck arm (A) requires every new attribute be named there. The code added to this dispatcher is three lines: the ledger is now taken from the already-healed basis instead of re-read (re-reading it here would undo the healing), one countAckRescues call, and one call to quality::identityDisclosure — which is itself an EXTRACTION made in this lane precisely because --quality-delta flagged the inline version. The residual +4 complexity is the cid ternary at the ack-write site. Decomposing runQualityDelta is its own round, recorded as such by the W2-E ack above and unchanged by this one. (2) resolveDeltaBasis verbosity 73->86 + churn=self: the two healIdentity calls (ref-pair and working-tree arms) and the comment explaining why the healing must precede computeDelta — heal the baseline after the delta is taken and the delta has already been computed against the stale identity. churn=self on it and on mergescout::injectFileLevelFallback is this one edit window, not a history signal. (3) injectFileLevelFallback: its hand-rolled fopen/fseek/fread/fclose block was retired onto docparse::detail::readWholeFile, the canonical whole-file read, after this lane extracted quality.h's copy and the reuse-decline kind reported the pair as a 320-token clone. Re-read before the swap: short read or ftell failure clears the buffer and the file contributes nothing, byte-identical to what it replaced. Gate: test/identitycheck.sh, 19 arms, 9 RED on the pre-lane binary. ack verbosity 763ff87870373241 84 cid=0f3e66f03f7be1f1 round ec5e3c3..HEAD, the MCP door across L6 (batch two-grammar), L8 P9 (post_check on the edit verbs), V2 F3/F7/F8/F9 (receipt keys, batch attribution, top-level array reader, legend present-but-empty) and L7 P1 (legend on 16 verbs): dispatchMcpLine/runBatchSub/runCliEdit are the one dispatch each of those lanes had to pass through | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement @@ -1683,7 +1683,7 @@ ack verbosity c79b36c44111aca3 75 cid=c0aaf33a6026048d now=75 was=0 p=src/ingest ack verbosity c840f9a6ec2e6c6b 73 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack verbosity c8c7bb0104aa16b8 76 cid=d54042b64ff7b78e H11 (lane ca-L2): writeBaseline gains a DEFAULTED absorbedGating parameter so a dirty pin's absorbed count reaches the sidecar; --edit-check reports incompatible=0 (both existing callers still bind) | prior: W1-S2 churn-keying fix (pathQualifiedKey): bodyHashesBySym's pathQualified-param drop is the deliberate contract change (one keying, no mode); pathQualifiedKey is canonicalId/voteKey-SHAPED but a distinct key domain — canonicalId's bare-name degrade IS the bug this fixes, voteKey is a rename-vote pair with a different separator and value type; churn=self is this fix's own edit trail; gate: qualitysignalcheck.sh §1d ack verbosity ca97a4b6bf07887b 653 cid=b2c9107cb2d8ac03 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement -ack verbosity cab2f93db3992faf 224 cid=34e51af7ebbf56db now=224 was=0 p=src/ingest_binds.h:4183 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack verbosity cab2f93db3992faf 224 cid=34e51af7ebbf56db now=224 was=0 p=src/ingest_binds.h:3640 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity cb7342964b38db9c 112 cid=3af54969638510bc by=src/* member-variable round (card A3): usesText grows the Owner.field member-selector branch (resolveFieldSelector + bare-name refusal listing spellings), the registered contract of this round | prior: lane/r10-cheap-buckets (r10 GitNexus fix round, LB-A + LB-G). SIX gating rows, ONE lane, read one by one before acking. THREE api-surface contract-changes, all deliberate parameter additions that ARE the feature: (a) mcpverbs usesText 2->3 params, taking McpPageArgs exactly as impactText already did, because the MCP uses verb gained the same default site cap as the CLI and an MCP-only agent that reads capped=1 needs a hatch it can reach (mcpclidiffcheck LENS 1 pins the two surfaces' root-attribute sets equal, so capping one and not the other is a divergence, not a saving); both dispatch sites AND kMcpVerbFields updated in the SAME commit, verified by mcpclidiffcheck/mcpverbscheck/usescheck green. (b)+(c) serialize packSignatures 17->18 and packSignaturesJson 11->12, both taking a trailing hasRelevanceFloor bool, default false so every non---for caller is byte-identical (verified: default map, pack-task, expand, exemplar, recall, hotspots, callers, grep, impact all unchanged). The flag cannot be replaced by passing a smaller topN, because those emitters read topN==0 as ALL, so a query nothing scores on would emit the whole corpus. THREE verbosity rows are the new code itself, already cut twice in this lane: duplicated rule bodies hoisted into relevanceFloorCut/pathTierIndexOver/compareTierThenPath, then the restated rationale moved to those helpers' headers - together taking gating from 13 to 6. What remains is runForLens +10, runCallHierarchy +11 and usesText +14 lines of genuinely new behaviour (the floor cut and its note plumbing; the tier index, the page window and the conditional legend clause). Splitting runForLens is a real refactor of its own - it was 658 lines before this lane touched it - and does not belong in an output-composition fix round. ack verbosity d42c85b67bd0956f 214 cid=44e97a2de5235603 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut ack verbosity d44595768a7cf3af 106 cid=ad934f87aaaed8df preloaded-corpus hoist: lexicalScores/lexicalScoresTiered gain one optional defaulted preloadedFileText param so a caller scoring many queries against one tree reads the corpus once instead of per call (--eval-retrieval was ~11.8M file opens/run, 48% of its CPU in the kernel). api-surface rows ARE the intended additive change; short-horizon-churn is this session's own edits to those two functions, not instability. Scores proven byte-identical on an identical tree. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cbe173dde..a238ba71b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,8 @@ live in `src/ingest_*.h` sections compiled into that one TU — the same section crawl + parse setup (`ingest_crawl.h`), the raw-facts model + incremental-cache codec (`ingest_cache.h`), structural metrics (`ingest_metrics.h`), cross-symbol relation capture (`ingest_relations.h`), the markdown section tier (`ingest_docs.h`), name resolution + capture -policy (`ingest_names.h`), local-binding capture (`ingest_binds.h`), parse infrastructure + the +policy (`ingest_names.h`), local-binding capture (`ingest_binds.h`), Ruby's Rails/DSL capture lane +(`ingest_ruby.h`), parse infrastructure + the fused side-capture passes (`ingest_sidecap.h`), and the `--match`/`--lint` query tail (`ingest_astquery.h`). The 2026-08-30 follow-on decomposed `ingest()`'s own body into four phase sections in call order: the lazy tags.scm prewarm (`ingest_prewarm.h`), the parallel parse pool diff --git a/queries/ruby/tags.scm b/queries/ruby/tags.scm index e2f6687e7..d44ff08bf 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -82,7 +82,7 @@ ; The Rails schema capture (test/rubyschemacheck.sh): a file whose tree holds a ; `create_table "x", … do |t| … end` call is a rendered db/schema.rb (content-gated — path plays no ; part), and each `t. "name"` / `t. :name` in the table block mints ONE data-kind def -; (SymKind::Section at Lang::Ruby, see model.h) via the C++ side-capture in ingest_names.h, span = the +; (SymKind::Section at Lang::Ruby, see model.h) via the C++ side-capture in ingest_ruby.h, span = the ; name token — Rails-generated attribute uses (`product.price`) resolve to the column as a def. ; DEFINITIONS ONLY by maintainer decision: the defs answer --uses/--grep/--whereis, but buildGraph's ; byName skips Section-Ruby, so columns admit NO call edges, only the restart share every def gets (untyped-receiver diff --git a/scripts/optremarks.py b/scripts/optremarks.py index 899856063..24094a1df 100755 --- a/scripts/optremarks.py +++ b/scripts/optremarks.py @@ -114,6 +114,10 @@ "Elixir-specific capture helpers: per node, but for one grammar with a small corpus share. Same argument as ingest_jsimports.h above." ), ( "src/ingest_clojure.h", "Clojure-specific capture helpers: per query capture, but only for Clojure files. Same corpus-share argument as ingest_elixir.h above." ), + ( "src/ingest_ruby.h", + "Ruby's Rails/DSL capture lane (schema columns, Jbuilder typing, the factory and class-side DSL walks): per node, but behind " + "the contains-\"attr\" / contains-\"create_table\" per-file early-outs and one grammar family's corpus share. Same argument as " + "ingest_jsimports.h and ingest_elixir.h above; the general per-node walk shapes it was carved from stay covered by ingest_binds.h." ), # ── per-DIRECTIVE, where every hot ingest section above is per-node or per-file ──────────────────── ( "src/ingest_importcap.h", "the shared @import.path capture vocabulary (issue #358), carrying the C family for now. It is called from ONE place in ingest_sidecap.h and runs once per captured " diff --git a/src/graph.h b/src/graph.h index 285e20d26..658a09606 100644 --- a/src/graph.h +++ b/src/graph.h @@ -4884,7 +4884,7 @@ inline void rubyInstanceForwarders( const IngestResult& ing, HashMap& refs ) -{ - std::size_t first = refs.size(); - while( first > 0 && refs[ first - 1 ].fileId == fileId ) - { - --first; - } - const auto read = []( const RawRef& r ) { return r.role == RefRole::Call && r.recv == RecvKind::None && r.name == kJbuilderLocal; }; - refs.erase( std::remove_if( refs.begin() + std::ptrdiff_t( first ), refs.end(), read ), refs.end() ); - for( std::size_t i = first; i < refs.size(); ++i ) - { - if( refs[ i ].role == RefRole::Call && refs[ i ].recv == RecvKind::NamedVar && refs[ i ].recvVar == kJbuilderLocal ) - { - rubyTypeReceiver( refs[ i ], kJbuilderTemplate, kJbuilderLocal, false ); - } - } -} - -// Does `call` sit at class-body position — its nearest enclosing def-or-class a class or module, any blocks between? -inline bool rubyAtClassBody( TSNode call ) noexcept -{ - for( TSNode p = ts_node_parent( call ); !ts_node_is_null( p ); p = ts_node_parent( p ) ) - { - const char* t = ts_node_type( p ); - if( kindIs( t, "class" ) || kindIs( t, "module" ) ) - { - return true; - } - if( kindIs( t, "method" ) || kindIs( t, "singleton_method" ) || kindIs( t, "singleton_class" ) ) - { - return false; - } - } - return false; -} - -// One binding per symbol argument in `args` (`helper_method :a, :b`, `attr_accessor :c`): `like` with the symbol's own start -// byte and its name (the colon dropped). -inline void rubyNoteSymbolArguments( TSNode args, const RawBind& like, std::string_view src, std::vector& binds ) -{ - if( ts_node_is_null( args ) ) - { - return; - } - ChildCursor cursor( args ); // O(children) (src/infra/tschildren.h) - forEachNamedChild( args, cursor.cur, [ & ]( TSNode a ) - { - if( kindIs( ts_node_type( a ), "simple_symbol" ) ) - { - RawBind b = like; - b.startByte = ts_node_start_byte( a ); - b.var = std::string( pattern::nodeText( a, src ).substr( 1 ) ); - binds.push_back( std::move( b ) ); - } - return true; - } ); -} - -// Each symbol of the `helper_method` call `call`, a binding against the class or module around it. -inline void rubyNoteHelperMethods( TSNode call, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - const std::string owner = rubyEnclosingScopeOf( call, src ); - if( !owner.empty() ) - { - rubyNoteSymbolArguments( fieldChild( call, NodeField::Arguments ), - RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubyHelperMethod, .typeName = owner }, src, binds ); - } -} - -// Every node of a Ruby file's tree, handed to `take` in turn; a node `take` returns true for is taken, and its subtree is -// not walked. Iterative, so a hostile nesting depth costs heap, not stack. -template -inline void rubyWalkUntaken( TSNode root, Take&& take ) -{ - std::vector stack { root }; - std::vector kids; - ChildCursor cursor( root ); - while( !stack.empty() ) - { - const TSNode n = stack.back(); - stack.pop_back(); - if( !take( n ) ) - { - collectChildren( n, cursor.cur, kids ); - stack.insert( stack.end(), kids.begin(), kids.end() ); - } - } -} - -// One file's `helper_method` declarations, into `binds` — a file that never spells the word is not walked (rubyWalkUntaken). -inline void captureRubyHelperMethods( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - if( src.find( "helper_method" ) == std::string_view::npos ) - { - return; - } - rubyWalkUntaken( root, [ & ]( TSNode n ) - { - const bool declares = kindIs( ts_node_type( n ), "call" ) && ts_node_is_null( fieldChild( n, NodeField::Receiver ) ) - && fieldChildTextOfKind( n, NodeField::Method, "identifier", src ) == "helper_method"; - if( declares && rubyAtClassBody( n ) ) - { - rubyNoteHelperMethods( n, fileId, src, binds ); - return true; - } - return false; - } ); -} - -// Parser version 137 (test/rubyclassrecvcheck.sh): is the Ruby def `defNode` a SINGLETON method — `def self.m`, or a def -// inside `class << self` — which a call on a class object answers and no instance's lookup reaches? The nearest class, -// module or def above a plain `def` decides; a block between them (`class_methods do`, `included do`) is walked through. -// rubyDefSide below tells whose class object; captureRubyClassMixins reads a directive in `class << self` with this. -inline bool rubyIsSingletonDef( TSNode defNode ) noexcept -{ - if( kindIs( ts_node_type( defNode ), "singleton_method" ) ) - { - const TSNode object = fieldChild( defNode, NodeField::Object ); - return !ts_node_is_null( object ) && kindIs( ts_node_type( object ), "self" ); - } - for( TSNode n = ts_node_parent( defNode ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) - { - const char* t = ts_node_type( n ); - if( kindIs( t, "singleton_class" ) ) - { - const TSNode value = fieldChild( n, NodeField::Value ); - return !ts_node_is_null( value ) && kindIs( ts_node_type( value ), "self" ); - } - if( kindIs( t, "class" ) || kindIs( t, "module" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) - { - return false; - } - } - return false; -} - -// What a Ruby def is to a class object (parser versions 137 and 145; test/rubyclassrecvcheck.sh). -enum class RubyDefSide : std::uint8_t -{ - Instance, // an instance method: no call on a class object reaches it - Singleton, // a singleton method of the class or module that holds it — `def self.m`, a def or an accessor in `class << self` - Includer, // a class method of each class INCLUDING its module, defined on the includer itself: a singleton def inside a - // concern's `included do`, which runs on the includer - ClassMethods, // a class method of each includer from a module extended onto it: a def inside a concern's `class_methods - // do`, which ActiveSupport::Concern makes the concern's ClassMethods -}; - -// The two blocks of an ActiveSupport::Concern whose defs can be its includer's class methods (RubyDefSide::Includer, -// ClassMethods). -inline constexpr std::array kRubyIncluderBlocks = { "included", "class_methods" }; - -// The concern hook (kRubyIncluderBlocks) whose block `n` is, written at class-body level, or empty. -inline std::string_view rubyIncluderHook( TSNode n, std::string_view src ) noexcept -{ - const TSNode owner = kindIs( ts_node_type( n ), "do_block" ) || kindIs( ts_node_type( n ), "block" ) ? ts_node_parent( n ) : TSNode {}; - if( ts_node_is_null( owner ) || !kindIs( ts_node_type( owner ), "call" ) ) - { - return {}; - } - const std::string_view hook = rubyNamedDirective( owner, src, kRubyIncluderBlocks ); - return !hook.empty() && rubyAttrAtClassBodyLevel( owner, src ) ? hook : std::string_view {}; -} - -// What a def written in a concern's `hook` block is: in `included do`, a singleton def is the includer's own and a plain -// one an instance method; in `class_methods do`, a plain def is the includer's class method. -inline RubyDefSide rubyHookSide( std::string_view hook, bool singleton ) noexcept -{ - if( hook == "included" ) - { - return singleton ? RubyDefSide::Includer : RubyDefSide::Instance; - } - return singleton ? RubyDefSide::Instance : RubyDefSide::ClassMethods; -} - -// What the Ruby def — or `class << self` accessor — at `defNode` is to a class object. A `def self.m`, or a `class << self` -// above it, makes it a singleton method, and the nearest class, module or def above decides whose — unless a concern's -// `included do` (for a singleton def) or `class_methods do` (for a plain one) at class-body level comes first, which makes -// it the includer's: defined on the includer itself, or in the module the concern extends onto it. Any other block between -// is walked through. -inline RubyDefSide rubyDefSide( TSNode defNode, std::string_view src ) noexcept -{ - const bool selfDef = kindIs( ts_node_type( defNode ), "singleton_method" ); - if( selfDef && fieldChildTextOfKind( defNode, NodeField::Object, "self", src ).empty() ) - { - return RubyDefSide::Instance; // `def obj.m` is another object's - } - bool singleton = selfDef; - for( TSNode n = ts_node_parent( defNode ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) - { - const char* t = ts_node_type( n ); - if( kindIs( t, "singleton_class" ) ) - { - if( fieldChildTextOfKind( n, NodeField::Value, "self", src ).empty() ) - { - return RubyDefSide::Instance; // `class << Other` opens another object's singleton - } - singleton = true; - continue; - } - if( kindIs( t, "class" ) || kindIs( t, "module" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) - { - break; - } - if( const std::string_view hook = rubyIncluderHook( n, src ); !hook.empty() ) - { - return rubyHookSide( hook, singleton ); - } - } - return singleton ? RubyDefSide::Singleton : RubyDefSide::Instance; -} - -// The importedName a Ruby class-object binding carries for a def on `side` (model.h LocalBindKind::RubySingletonDef). -inline std::string rubySideMark( RubyDefSide side ) -{ - return std::string( side == RubyDefSide::Includer ? kRubyIncluderMark : side == RubyDefSide::ClassMethods ? kRubyClassMethodsMark : std::string_view {} ); -} - -// A LocalBindKind::RubySingletonDef binding for the def at `defNode` named `name`, when a class object answers it: a -// singleton method (parser version 137), or a class method of each includer (parser version 145). -inline void rubyNoteClassSideDef( TSNode defNode, std::uint32_t fileId, std::string_view name, std::string_view src, std::vector& binds ) -{ - const RubyDefSide side = rubyDefSide( defNode, src ); - if( side != RubyDefSide::Instance ) - { - binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( defNode ), .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, - .var = std::string( name ), .importedName = rubySideMark( side ) } ); - } -} - -// The bindings for what the `class << self` call `n` declares on the class object's `side` — an accessor, or a -// delegation-DSL call (captureRubySingletonAccessors below). An accessor's binding sits at its symbol, -// where captureRubyDefs starts its def (no owner), and carries the side's mark (one a concern's `included do` -// declares is the includer's); a delegated name has no def, and is read against the class that owns the singleton. -inline void rubyNoteSingletonDecl( TSNode n, RubyDefSide side, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - const std::string_view delegation = rubyNamedDirective( n, src, kRubyDelegationCalls ); - if( delegation.empty() ) - { - rubyNoteSymbolArguments( fieldChild( n, NodeField::Arguments ), - RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .importedName = rubySideMark( side ) }, - src, binds ); - return; - } - const std::string owner = rubyEnclosingScopeOf( n, src ); - if( delegation == "delegate_missing_to" ) - { - binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( n ), .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .typeName = owner } ); - return; - } - rubyNoteSymbolArguments( fieldChild( n, NodeField::Arguments ), RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .typeName = owner }, - src, binds ); -} - -// What a `class << self` declares beside its defs is the class object's too (parser version 137): each accessor -// (`attr_accessor :config`, one RubySingletonDef binding per name, where captureRubyDefs starts its def) and each name -// the delegation DSL defines (model.h kRubyDelegationCalls — `delegate :reset, to: :instance`: a binding per name against -// the owning class; `delegate_missing_to` one with no name, as it answers every name). A file that never writes `<<` is not -// walked; the walk is iterative, so a hostile nesting depth costs heap, not stack. -inline void captureRubySingletonAccessors( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - if( src.find( "<<" ) == std::string_view::npos ) - { - return; - } - rubyWalkUntaken( root, [ & ]( TSNode n ) - { - const bool call = kindIs( ts_node_type( n ), "call" ); - const bool declares = call && ( !rubyNamedDirective( n, src, kRubyAttrFamilyNames ).empty() || !rubyNamedDirective( n, src, kRubyDelegationCalls ).empty() ); - const RubyDefSide side = declares && rubyAttrAtClassBodyLevel( n, src ) ? rubyDefSide( n, src ) : RubyDefSide::Instance; - if( side == RubyDefSide::Instance ) - { - return false; - } - rubyNoteSingletonDecl( n, side, fileId, src, binds ); - return true; - } ); -} - -// Parser version 145 (test/rubyclassrecvcheck.sh): each mixin the CLASS OBJECT's lookup reaches rather than its -// instances' — the constants of an `extend M`, and of an `include`/`prepend` inside `class << self` — as a -// LocalBindKind::RubyClassMixin binding at the constant's own start byte, where captureRubyMixinBases puts its inherit -// reference, read where that reads the directive (class-body level, or a concern's `included do`). One a concern's -// `included do` writes extends the includer, not the concern: importedName kRubyIncluderMark. A class body's `extend self` -// names no constant: its binding sits at the directive, var "self". A file that never writes `extend` or `<<` is not -// walked, and the walk enters no def, whose body holds no directive. -// Is the mixin directive `n` an `extend self` — its one argument `self`? -inline bool rubyExtendsSelf( TSNode n ) noexcept -{ - const TSNode args = fieldChild( n, NodeField::Arguments ); - return !ts_node_is_null( args ) && ts_node_named_child_count( args ) == 1 && kindIs( ts_node_type( ts_node_named_child( args, 0 ) ), "self" ); -} - -// The RubyClassMixin bindings of the mixin directive `n` (an `extend` when `extend`), read where captureRubyMixinBases reads -// it: one per constant a class object's lookup reaches, and a module body's `extend self`. -inline void rubyNoteClassMixins( TSNode n, bool extend, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - const bool inIncluded = rubyInConcernIncludedBlock( n, src ); - if( !inIncluded && !rubyAttrAtClassBodyLevel( n, src ) ) - { - return; - } - const bool singleton = rubyIsSingletonDef( n ); // the directive sits in `class << self` - if( extend && !inIncluded && !singleton && rubyExtendsSelf( n ) ) - { - binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( n ), .lang = Lang::Ruby, .kind = LocalBindKind::RubyClassMixin, .var = "self" } ); - } - if( extend == singleton ) - { - return; // an include joins the instances' lookup, an extend inside `class << self` the singleton's own - } - const bool includer = inIncluded || ( singleton && rubyDefSide( n, src ) == RubyDefSide::Includer ); - for( const TSNode c : rubyMixinTargets( n, src ) ) - { - binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( c ), .lang = Lang::Ruby, .kind = LocalBindKind::RubyClassMixin, - .importedName = includer ? std::string( kRubyIncluderMark ) : std::string {} } ); - } -} - -inline void captureRubyClassMixins( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - if( src.find( "extend" ) == std::string_view::npos && src.find( "<<" ) == std::string_view::npos ) - { - return; - } - rubyWalkUntaken( root, [ & ]( TSNode n ) - { - const char* t = ts_node_type( n ); - if( kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) - { - return true; // a def body holds no directive - } - const std::string_view verb = kindIs( t, "call" ) ? rubyNamedDirective( n, src, kRubyConstantDirectives ) : std::string_view {}; - if( verb.empty() || verb == "autoload" ) - { - return false; - } - rubyNoteClassMixins( n, verb == "extend", fileId, src, binds ); - return true; - } ); -} - -// ─── FactoryBot's factory definitions (parser version 132, test/rubytypedrecvcheck.sh) ───────────────────────────────── -// `factory :user do … end` inside a `FactoryBot.define` (or `FactoryGirl.define`) block defines what `create( :user )` -// builds, and its class is FactoryBot's own rule: the `class:` option as written — a constant, or a string naming one (its -// final segment) — else the enclosing factory's (a nested factory builds its parent's class), else the `parent:` factory's -// when the file defined it above, else the name camelised (`:user_profile` → UserProfile). One RubyFactory binding per name -// and per `aliases:` entry; graph.h rubyFactoryClasses joins them over the tree, where two classes for one name type nothing. -// A `class:` of any other spelling (a symbol, an expression) records no factory: its class is not known. - -// `user_profile` → UserProfile, `admin/user` → User: the final segment of the constant FactoryBot camelises the name into -inline std::string rubyFactoryDefaultClass( std::string_view name ) -{ - const std::size_t slash = name.rfind( '/' ); - std::string out; - bool upper = true; - for( const char c : name.substr( slash == std::string_view::npos ? 0 : slash + 1 ) ) - { - if( c == '_' ) - { - upper = true; - continue; - } - out.push_back( upper && c >= 'a' && c <= 'z' ? char( c - 'a' + 'A' ) : c ); - upper = false; - } - return out; -} - -// The value of the `key:` option among a call's arguments (`class: "User"`, `parent: :user`), or null. -inline TSNode rubyCallOption( TSNode call, std::string_view key, std::string_view src ) -{ - const TSNode args = fieldChild( call, NodeField::Arguments ); - TSNode value {}; - if( !ts_node_is_null( args ) ) - { - ChildCursor cursor( args ); - forEachNamedChild( args, cursor.cur, [ & ]( TSNode c ) - { - const TSNode k = kindIs( ts_node_type( c ), "pair" ) ? fieldChild( c, NodeField::Key ) : TSNode {}; - const bool named = !ts_node_is_null( k ) && kindIs( ts_node_type( k ), "hash_key_symbol" ) && pattern::nodeText( k, src ) == key; - value = named ? fieldChild( c, NodeField::Value ) : value; - return !named; - } ); - } - return value; -} - -// The class a `class:` option names: a constant's final segment, or a plain string's (`"Admin::User"` → User); else empty. -inline std::string_view rubyFactoryClassOption( TSNode value, std::string_view src ) -{ - if( ts_node_is_null( value ) ) - { - return {}; - } - if( isRubyConstantNode( value ) ) - { - return rubyFinalConstant( value, src ); - } - if( !kindIs( ts_node_type( value ), "string" ) || ts_node_named_child_count( value ) != 1 - || !kindIs( ts_node_type( ts_node_named_child( value, 0 ) ), "string_content" ) ) - { - return {}; - } - const std::string_view text = pattern::nodeText( ts_node_named_child( value, 0 ), src ); - const std::size_t cut = text.rfind( "::" ); - return cut == std::string_view::npos ? text : text.substr( cut + 2 ); -} - -class RubyFactoryWalk -{ -public: - RubyFactoryWalk( std::uint32_t fileId, std::string_view src, std::vector& binds ) : fileId_( fileId ), src_( src ), binds_( binds ) {} - void run( TSNode root ); - -private: - struct Item - { - TSNode node; - bool inDefine; // inside a `FactoryBot.define` block - std::uint32_t parent; // index into classes_ of the enclosing factory's class; kNone outside one - }; - static constexpr std::uint32_t kNone = 0xFFFFFFFFu; - void define( TSNode call, const Item& item ); - std::string classOf( TSNode call, std::string_view name, const Item& item ) const; - void emit( TSNode call, std::string_view name, const std::string& cls ); - - std::uint32_t fileId_; - std::string_view src_; - std::vector& binds_; - std::vector stack_; - std::vector kids_; - std::vector classes_; - HashMap byName_; // a factory defined above in this file → its class -}; - -inline void RubyFactoryWalk::run( TSNode root ) -{ - ChildCursor cursor( root ); - stack_.push_back( Item { root, false, kNone } ); - while( !stack_.empty() ) - { - Item item = stack_.back(); - stack_.pop_back(); - const TSNode n = item.node; - if( kindIs( ts_node_type( n ), "call" ) ) - { - const TSNode recv = fieldChild( n, NodeField::Receiver ); - const std::string_view on = !ts_node_is_null( recv ) && isRubyConstantNode( recv ) ? rubyFinalConstant( recv, src_ ) : std::string_view {}; - const std::string_view m = fieldChildTextOfKind( n, NodeField::Method, "identifier", src_ ); - item.inDefine = item.inDefine || ( ( on == "FactoryBot" || on == "FactoryGirl" ) && m == "define" ); - if( item.inDefine && ts_node_is_null( recv ) && m == "factory" ) - { - define( n, item ); - continue; - } - } - collectChildren( n, cursor.cur, kids_ ); - for( std::size_t i = kids_.size(); i > 0; --i ) - { - stack_.push_back( Item { kids_[ i - 1 ], item.inDefine, item.parent } ); - } - } -} - -// one `factory :name, …` call: its class (the section note above), a binding per name and alias, then its block's -// factories, which build this class unless they say otherwise -inline void RubyFactoryWalk::define( TSNode call, const Item& item ) -{ - const std::string_view name = rubyFirstSymbolArgument( call, src_ ); - std::string cls = name.empty() ? std::string {} : classOf( call, name, item ); - if( cls.empty() ) - { - return; // no symbol name, or a class this file does not say: no factory is recorded, and none nested in it - } - const std::uint32_t index = std::uint32_t( classes_.size() ); - classes_.push_back( std::move( cls ) ); - byName_.try_emplace( name, index ); - emit( call, name, classes_[ index ] ); - if( const TSNode aliases = rubyCallOption( call, "aliases", src_ ); !ts_node_is_null( aliases ) && kindIs( ts_node_type( aliases ), "array" ) ) - { - ChildCursor cursor( aliases ); - forEachNamedChild( aliases, cursor.cur, [ & ]( TSNode a ) - { - const std::string_view alias = kindIs( ts_node_type( a ), "simple_symbol" ) ? pattern::nodeText( a, src_ ).substr( 1 ) : std::string_view {}; - if( !alias.empty() ) - { - emit( call, alias, classes_[ index ] ); - } - return true; - } ); - } - if( const TSNode block = rubyCallBlock( call ); !ts_node_is_null( block ) ) - { - stack_.push_back( Item { block, true, index } ); - } -} - -// the class `factory :name, …` builds, by FactoryBot's rule (the section note above); empty when this file does not say -inline std::string RubyFactoryWalk::classOf( TSNode call, std::string_view name, const Item& item ) const -{ - if( const TSNode opt = rubyCallOption( call, "class", src_ ); !ts_node_is_null( opt ) ) - { - return std::string( rubyFactoryClassOption( opt, src_ ) ); - } - if( const TSNode parent = rubyCallOption( call, "parent", src_ ); !ts_node_is_null( parent ) ) - { - const bool symbol = kindIs( ts_node_type( parent ), "simple_symbol" ); - const auto named = symbol ? byName_.find( pattern::nodeText( parent, src_ ).substr( 1 ) ) : byName_.end(); - return named == byName_.end() ? std::string {} : classes_[ named->second ]; - } - return item.parent != kNone ? classes_[ item.parent ] : rubyFactoryDefaultClass( name ); -} - -inline void RubyFactoryWalk::emit( TSNode call, std::string_view name, const std::string& cls ) -{ - RawBind b; - b.fileId = fileId_; - b.startByte = ts_node_start_byte( call ); - b.lang = Lang::Ruby; - b.kind = LocalBindKind::RubyFactory; - b.var = std::string( name ); - b.typeName = cls; - binds_.push_back( std::move( b ) ); -} - -// One file's FactoryBot factories, into `binds` — a file with no `FactoryBot.define` / `FactoryGirl.define` is not walked. -inline void captureRubyFactories( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) -{ - if( src.find( "FactoryBot.define" ) != std::string_view::npos || src.find( "FactoryGirl.define" ) != std::string_view::npos ) - { - RubyFactoryWalk( fileId, src, binds ).run( root ); - } -} - // `described_class::Worker` — a constant path whose head is RSpec's described_class — spelled through the path the // described class is written as (`Log::Worker`, parser version 137); empty for any other head, and where the file // redefines described_class or a local of that name reaches the head. diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 1c50420a9..2b7366370 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -488,7 +488,7 @@ constexpr std::uint32_t kParserVer = 171; // bump on any grammar/.sc // build, a let or local holding one) carries its type in the // call RawRef's recvVar (FE-B on #373 moved it to memberCtor/memberVia), and each // FactoryBot `factory` mints a RawBind of the APPENDED kind - // LocalBindKind::RubyFactory (ingest_binds.h). Same layout: + // LocalBindKind::RubyFactory (ingest_ruby.h). Same layout: // kCacheVersion unchanged (27). A Ruby cache written at 131 // holds untyped receivers and no factories and must re-parse. // 131 = 2026-10-02 (test/rubyreachcheck.sh): a Ruby MIXIN is an diff --git a/src/ingest_names.h b/src/ingest_names.h index cef8eb1d0..d507a7561 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -1168,7 +1168,8 @@ inline void captureRubyAttrDefsCall( TSNode n, std::uint32_t fileId, std::string // the maintainer's quality-delta flagged: two byte-identical walkers). The per-file signals (contains // "attr" / contains "create_table") decide which dispatch fires before the walk starts, exactly like // each lane's former early-out. Everything else in each lane is unchanged. -// The schema table helper is defined with its lane below (this walker lives with the attr lane). +// The schema lane's helpers live in ingest_ruby.h (included later in this TU); this walker keeps the attr lane +// and reaches the schema minting through the declaration below. inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_view src, std::vector& defs ); inline void captureRubyDefs( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& defs ) { @@ -1216,314 +1217,6 @@ inline void captureRubyDefs( TSNode root, std::uint32_t fileId, std::string_view } -// ─── the Rails schema capture (test/rubyschemacheck.sh): RUBY db/schema.rb COLUMNS ARE Section DEFS ─── -// A schema file is recognized BY CONTENT, never by path: any Ruby file whose tree holds a -// `create_table "x", … do |t| … end` call — the call's `block:` field is a do_block whose `parameters:` -// field is a block_parameters carrying ONE bare identifier — is a rendered-schema document (the block -// is the create_table call's OWN do_block, found by named-child kind because the shared NodeField set -// has no Block enumerator). Path plays no part: a rendered dump sits at file level or under -// `ActiveRecord::Schema[].define … do`. WHAT A WALK OF THE FILE IS NOT: a MIGRATION wraps its -// create_table inside a class/module body, and rubySchemaTableCall's Gate 0 refuses every -// class/module-nested table — so the container DOES matter exactly there (the migration shape -// contributes ZERO defs, pinned by the three migration fixtures). The file signal below is a -// contains-"create_table" reject like the attribute-family capture's contains-"attr" reject; the -// semantic gate is the AST shape. -// -// Each `t. "name"` / `t. :name` receiver-CALL in the table's do-block body mints ONE -// SymKind::Section def at Lang::Ruby — the same data-kind slot as a YAML key / doc heading (model.h; -// note the wording change there: a Section is no longer "isolated in the graph" once these are -// Lang::Ruby and admit call edges) — with span = the name token and name = the literal (both quote -// spellings via stringLiteralText, or a simple_symbol's text minus ':'). The DSL CALLS keep their -// external-surface reference posture exactly like the attr family: no def named `string`/`datetime` -// appears. `t.timestamps` (a receiver call with NO arguments field) mints `created_at` AND `updated_at` -// — the DSL names them literally, symmetric with the id rule; the two defs anchor at the method -// token's start/end bytes, since no literal name tokens exist in the source. -// -// Non-column DSL — t.index / t.references / t.belongs_to / t.polymorphic — names no column and defines -// nothing (stated floor: `t.references :owner` would otherwise mint a phantom "owner" column). A column -// call whose receiver is not a bare identifier (`registry.string`, a receiver-less `string`) is not a -// column; a string named by a leading-comment-skipped FIRST argument is (the attribute-DSL comment posture). -// -// The id rule — the four grounded spellings, all pinned by test/rubyschemacheck.sh: -// – no column named `id`, no `id:` pair, no primary_key pair → an implicit `id` def anchored at -// the TABLE-NAME string's content start (no `id` token exists; the anchor is per-table unique, so -// the multi-def floor — `id` on N tables → --uses=id defs="N" — holds naturally) -// – `id: false` (the pair VALUE is the anonymous `false` token) → no id def (a key-less table) -// – `id: :uuid` (pair value a simple_symbol) → `id` def AT THE PAIR KEY (the key's -// text minus its trailing ':'; the uuid TYPE is not modelled — stated floor) -// – `primary_key: "x"` (pair value a string) → a def named the string's content -// instead of the implicit `id` -// Duplicate column NAMES across tables stay SEPARATE defs (distinct nameByte identities — the dedup -// ladder only folds same-byte captures). -inline constexpr std::array kRubySchemaNonColumns = { "index", "references", "belongs_to", "polymorphic", "check_constraint", "exclusion_constraint", "unique_constraint" }; - -// The FIRST non-comment named child of an argument_list — the attribute-DSL comment posture ("attribute( # note -// then :x"): a comment is an n-ary extra, never an argument. Null when the list is all comments/empty. -inline TSNode rubyFirstNonCommentArg( TSNode args ) noexcept -{ - ChildCursor cursor( args ); - TSNode first{}; - bool got = false; - forEachNamedChild( args, cursor.cur, [ & ]( TSNode c ) - { - if( kindIs( ts_node_type( c ), "comment" ) ) - { - return true; // skip the comment train, keep looking - } - first = c; - got = true; - return false; // stop at the first real argument - } ); - return got ? first : TSNode{}; -} - -// A pair node's KEY text: a hash_key_symbol carries its trailing ':' (the whole `force:` token) — strip -// it so the id/primary_key comparisons see the bare word. A key without the colon (already bare) passes -// through unchanged. -inline std::string_view rubyPairKeyText( TSNode pair, std::string_view src ) noexcept -{ - const TSNode key = fieldChild( pair, NodeField::Key ); - std::string_view t = nodeTextOf( key, src ); - if( !t.empty() && t.back() == ':' ) - { - t.remove_suffix( 1 ); - } - return t; -} - -// One `t. ` receiver-call inside a table block: a Section def per the rules above. The -// RECEIVER must BE the block parameter the create_table call bound (`t` in a rendered schema's do |t|); -// any other bare identifier (`helper.string "x"`) is somebody's own helper call and names no column. -// The `sawId` out-param records an explicit `id` column so the implicit rule can see it. -inline void rubySchemaColumnCall( TSNode c, std::uint32_t fileId, std::string_view src, std::string_view receiverName, std::vector& defs, bool& sawId ) -{ - if( !kindIs( ts_node_type( c ), "call" ) ) - { - return; - } - const TSNode recv = fieldChild( c, NodeField::Receiver ); - if( ts_node_is_null( recv ) || !kindIs( ts_node_type( recv ), "identifier" ) || nodeTextOf( recv, src ) != receiverName ) - { - return; // receiver-less, receiver-qualified, or a DIFFERENT bare name: not a column on the block parameter - } - const std::string_view m = fieldIdentifierText( c, NodeField::Method, src ); - if( m.empty() ) - { - return; - } - const std::uint32_t line = ts_node_start_point( c ).row + 1; - if( m == "timestamps" && ts_node_is_null( fieldChild( c, NodeField::Arguments ) ) ) - { - const TSNode meth = fieldChild( c, NodeField::Method ); - const std::uint32_t s = ts_node_start_byte( meth ), e = ts_node_end_byte( meth ); - RawDef d; - d.fileId = fileId; d.line = line; d.startByte = s; d.endByte = e; d.loc = 1; - d.kind = SymKind::Section; d.lang = Lang::Ruby; - d.nameByte = s; d.name = "created_at"; defs.push_back( d ); // the two anchors are the - d.nameByte = e; d.name = "updated_at"; defs.push_back( d ); // method token's edges — no literal tokens exist - return; - } - if( std::find( kRubySchemaNonColumns.begin(), kRubySchemaNonColumns.end(), m ) != kRubySchemaNonColumns.end() ) - { - return; // t.index / references / belongs_to / polymorphic name no column (stated floor) - } - const TSNode args = fieldChild( c, NodeField::Arguments ); - if( ts_node_is_null( args ) ) - { - return; - } - const TSNode nameNode = rubyFirstNonCommentArg( args ); - if( ts_node_is_null( nameNode ) ) - { - return; - } - const std::string lit = stringLiteralText( nameNode, src ); // "" when not a string node - std::string_view txt = lit; - if( txt.empty() && kindIs( ts_node_type( nameNode ), "simple_symbol" ) ) - { - txt = nodeTextOf( nameNode, src ); - if( txt.size() > 1 && txt.front() == ':' ) - { - txt.remove_prefix( 1 ); - } - } - if( txt.empty() ) - { - return; // `""` / `: ` defensive: an empty name is no column - } - const std::uint32_t s = ts_node_start_byte( nameNode ), e = ts_node_end_byte( nameNode ); - RawDef d; - d.fileId = fileId; d.line = line; d.startByte = s; d.endByte = e; d.loc = 1; - d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; // content start — past the quote or ':' - d.name = std::string( txt ); - if( d.name == "id" ) - { - sawId = true; - } - defs.push_back( d ); -} - -// One create_table call: the schema-file gate plus the whole table's defs (name, options, columns, id). -inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_view src, std::vector& defs ) -{ - std::string_view receiverName; // the block parameter's name (`t`); empty until Gate 2 passes - // Gate 0: a rendered schema's tables live at file level or under `ActiveRecord::Schema[].define … do` - // — NEVER inside a class/module body. A MIGRATION's `create_table "users" … do |t|` (class-wrapped, - // often string-named) is the same AST shape, and without this gate it would mint the SAME columns as - // the schema that migration created — double-counting every added column on every corpus with both - // files indexed. `add_column`/`remove_column` are not read anywhere (a column added then dropped never - // registers; pinned by the migration fixtures), but the create_table of a real migration must not - // double its schema either. Excluding at least one `class`/`module` ancestor keeps the capture - // content-gated (still no path heuristic): a migration file next to the schema contributes ZERO defs. - // Checked FIRST — the cheapest gate — before the argument/block walks. - { - for( TSNode p = ts_node_parent( n ); !ts_node_is_null( p ); p = ts_node_parent( p ) ) - { - const std::string_view k( ts_node_type( p ) ); - if( k == "class" || k == "module" ) // a locals distilled walk — no cross-helper dependency - { - return; - } - } - } - const TSNode args = fieldChild( n, NodeField::Arguments ); - if( ts_node_is_null( args ) ) - { - return; - } - // Gate 1: a STRING table name as the first argument (a symbol-named table is a migration spelling, - // not a rendered-schema shape — stated floor; no name node → no schema surface either). - const TSNode tableName = rubyFirstNonCommentArg( args ); - if( ts_node_is_null( tableName ) || !kindIs( ts_node_type( tableName ), "string" ) ) - { - return; - } - // Gate 2: the call's OWN do_block, and its parameters: field carries ONE bare identifier (the `|t|`). - TSNode block{}; - { - ChildCursor cursor( n ); - forEachNamedChild( n, cursor.cur, [ & ]( TSNode c ) - { - if( kindIs( ts_node_type( c ), "do_block" ) ) - { - block = c; - return false; - } - return true; - } ); - } - if( ts_node_is_null( block ) ) - { - return; - } - const TSNode params = fieldChild( block, NodeField::Parameters ); - if( ts_node_is_null( params ) ) - { - return; // `create_table "x" do … end` with no handle — not the schema shape - } - { - ChildCursor cursor( params ); - TSNode only{}; - std::size_t named = 0; - forEachNamedChild( params, cursor.cur, [ & ]( TSNode c ) { only = c; ++named; return true; } ); - if( named != 1 || !kindIs( ts_node_type( only ), "identifier" ) ) - { - return; // multi-parameter or non-identifier handle — not a rendered schema's do |t| - } - receiverName = nodeTextOf( only, src ); // the `|t|` spelling — column calls must use it - } - // Options: the id/primary_key pairs among the call's arguments. - bool idFalse = false, primaryKey = false; - TSNode idKey{}, pkValue{}; - { - ChildCursor cursor( args ); - forEachNamedChild( args, cursor.cur, [ & ]( TSNode a ) - { - if( !kindIs( ts_node_type( a ), "pair" ) ) - { - return true; - } - const std::string_view k = rubyPairKeyText( a, src ); - const TSNode v = fieldChild( a, NodeField::Value ); - if( k == "id" ) - { - if( kindIs( ts_node_type( v ), "false" ) ) - { - idFalse = true; - } - else if( kindIs( ts_node_type( v ), "simple_symbol" ) ) - { - idKey = fieldChild( a, NodeField::Key ); - } - } - else if( k == "primary_key" ) - { - primaryKey = true; - pkValue = v; - } - return true; - } ); - } - // The columns: the do_block's body: field (its body_statement) — walk its named children directly - // (a fresh cursor; the outer walks cannot be reused mid-flight, tschildren.h). - bool sawIdColumn = false; - const TSNode body = fieldChild( block, NodeField::Body ); - if( !ts_node_is_null( body ) ) - { - ChildCursor cursor( body ); - forEachNamedChild( body, cursor.cur, [ & ]( TSNode c ) - { - rubySchemaColumnCall( c, fileId, src, receiverName, defs, sawIdColumn ); - return true; - } ); - } - // The id rule, in the grounded order: an explicit id column wins; then the pairs decide. - if( sawIdColumn || idFalse ) - { - return; - } - if( primaryKey && !ts_node_is_null( pkValue ) && kindIs( ts_node_type( pkValue ), "string" ) ) - { - const std::string name = stringLiteralText( pkValue, src ); - if( name.empty() ) - { - return; - } - const std::uint32_t s = ts_node_start_byte( pkValue ), e = ts_node_end_byte( pkValue ); - RawDef d; - d.fileId = fileId; d.line = ts_node_start_point( pkValue ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; - d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; d.name = name; - defs.push_back( d ); - return; - } - if( !ts_node_is_null( idKey ) ) - { - const std::uint32_t s = ts_node_start_byte( idKey ), e = ts_node_end_byte( idKey ); - RawDef d; - d.fileId = fileId; d.line = ts_node_start_point( idKey ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; - d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s; d.name = "id"; - defs.push_back( d ); - return; - } - // A primary_key pair with a NON-string value (composite `["a","b"]`, hand-written `:symbol`): the - // key is NOT an `id` and the rendered columns are the block's own explicit `t.` lines (already - // minted above) — suppress the implicit id entirely (the composite case is pinned by the fixture). - if( primaryKey ) - { - return; - } - // Implicit `id` — anchored at the table-name string's content start (a stable per-table byte). - { - const std::uint32_t s = ts_node_start_byte( tableName ), e = ts_node_end_byte( tableName ); - RawDef d; - d.fileId = fileId; d.line = ts_node_start_point( tableName ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; - d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; d.name = "id"; - defs.push_back( d ); - } -} - - - // F5: a Swift LOCAL binding — `let a = f()` / `var b = ...` inside a function/closure body — parses to the // same `property_declaration` node as a real stored/computed MEMBER property, so the @definition.var pattern // captures it as a spurious top-level `var` symbol AND (being the nearest enclosing symbol above the body's diff --git a/src/ingest_ruby.h b/src/ingest_ruby.h new file mode 100644 index 000000000..9dc6c2f02 --- /dev/null +++ b/src/ingest_ruby.h @@ -0,0 +1,874 @@ +#pragma once +#if !defined( RIPWIRE_INGEST_TU ) +#error "ingest_ruby.h is a SECTION of src/ingest.cpp's translation unit - include it only from ingest.cpp (see the ingest-family split note there)" +#endif + +// ingest_ruby.h - Ruby's Rails/DSL capture lane, carved VERBATIM out of ingest_names.h and +// ingest_binds.h (2026-10-12, the extraction @mpapis offered on #339): the rendered db/schema.rb +// column capture (test/rubyschemacheck.sh; the one walker that dispatches it stays in the shared +// names section and reaches the minting through the existing forward declaration), the Jbuilder +// view typing, the class-side helper-method / singleton-accessor / mixin DSL walks and the +// FactoryBot walk (test/rubyrakejbuildercheck.sh, rubytypedrecvcheck.sh, rubyreachcheck.sh, +// rubyrspectargetcheck.sh). This is a per-language section in the ingest_elixir.h slot: it reopens +// `namespace rw` and the unnamed namespace inside it - one TU, one unnamed namespace, internal +// linkage unchanged, zero new API surface. The shared feature families (definitions, base +// relations, receiver classification, the bare-call walk) stay in the data-driven sections; what +// lives here is the Rails-DSL posture that genuinely differs. +namespace rw +{ +namespace +{ + +// ─── the Rails schema capture (test/rubyschemacheck.sh): RUBY db/schema.rb COLUMNS ARE Section DEFS ─── +// A schema file is recognized BY CONTENT, never by path: any Ruby file whose tree holds a +// `create_table "x", … do |t| … end` call — the call's `block:` field is a do_block whose `parameters:` +// field is a block_parameters carrying ONE bare identifier — is a rendered-schema document (the block +// is the create_table call's OWN do_block, found by named-child kind because the shared NodeField set +// has no Block enumerator). Path plays no part: a rendered dump sits at file level or under +// `ActiveRecord::Schema[].define … do`. WHAT A WALK OF THE FILE IS NOT: a MIGRATION wraps its +// create_table inside a class/module body, and rubySchemaTableCall's Gate 0 refuses every +// class/module-nested table — so the container DOES matter exactly there (the migration shape +// contributes ZERO defs, pinned by the three migration fixtures). The per-file signal (the walker's +// contains-"create_table" early-out in ingest_names.h) is a reject like the attribute-family capture's +// contains-"attr" reject; the semantic gate is the AST shape. +// +// Each `t. "name"` / `t. :name` receiver-CALL in the table's do-block body mints ONE +// SymKind::Section def at Lang::Ruby — the same data-kind slot as a YAML key / doc heading (model.h; +// note the wording change there: a Section is no longer "isolated in the graph" once these are +// Lang::Ruby and admit call edges) — with span = the name token and name = the literal (both quote +// spellings via stringLiteralText, or a simple_symbol's text minus ':'). The DSL CALLS keep their +// external-surface reference posture exactly like the attr family: no def named `string`/`datetime` +// appears. `t.timestamps` (a receiver call with NO arguments field) mints `created_at` AND `updated_at` +// — the DSL names them literally, symmetric with the id rule; the two defs anchor at the method +// token's start/end bytes, since no literal name tokens exist in the source. +// +// Non-column DSL — t.index / t.references / t.belongs_to / t.polymorphic — names no column and defines +// nothing (stated floor: `t.references :owner` would otherwise mint a phantom "owner" column). A column +// call whose receiver is not a bare identifier (`registry.string`, a receiver-less `string`) is not a +// column; a string named by a leading-comment-skipped FIRST argument is (the attribute-DSL comment posture). +// +// The id rule — the four grounded spellings, all pinned by test/rubyschemacheck.sh: +// – no column named `id`, no `id:` pair, no primary_key pair → an implicit `id` def anchored at +// the TABLE-NAME string's content start (no `id` token exists; the anchor is per-table unique, so +// the multi-def floor — `id` on N tables → --uses=id defs="N" — holds naturally) +// – `id: false` (the pair VALUE is the anonymous `false` token) → no id def (a key-less table) +// – `id: :uuid` (pair value a simple_symbol) → `id` def AT THE PAIR KEY (the key's +// text minus its trailing ':'; the uuid TYPE is not modelled — stated floor) +// – `primary_key: "x"` (pair value a string) → a def named the string's content +// instead of the implicit `id` +// Duplicate column NAMES across tables stay SEPARATE defs (distinct nameByte identities — the dedup +// ladder only folds same-byte captures). +inline constexpr std::array kRubySchemaNonColumns = { "index", "references", "belongs_to", "polymorphic", "check_constraint", "exclusion_constraint", "unique_constraint" }; + +// The FIRST non-comment named child of an argument_list — the attribute-DSL comment posture ("attribute( # note +// then :x"): a comment is an n-ary extra, never an argument. Null when the list is all comments/empty. +inline TSNode rubyFirstNonCommentArg( TSNode args ) noexcept +{ + ChildCursor cursor( args ); + TSNode first{}; + bool got = false; + forEachNamedChild( args, cursor.cur, [ & ]( TSNode c ) + { + if( kindIs( ts_node_type( c ), "comment" ) ) + { + return true; // skip the comment train, keep looking + } + first = c; + got = true; + return false; // stop at the first real argument + } ); + return got ? first : TSNode{}; +} + +// A pair node's KEY text: a hash_key_symbol carries its trailing ':' (the whole `force:` token) — strip +// it so the id/primary_key comparisons see the bare word. A key without the colon (already bare) passes +// through unchanged. +inline std::string_view rubyPairKeyText( TSNode pair, std::string_view src ) noexcept +{ + const TSNode key = fieldChild( pair, NodeField::Key ); + std::string_view t = nodeTextOf( key, src ); + if( !t.empty() && t.back() == ':' ) + { + t.remove_suffix( 1 ); + } + return t; +} + +// One `t. ` receiver-call inside a table block: a Section def per the rules above. The +// RECEIVER must BE the block parameter the create_table call bound (`t` in a rendered schema's do |t|); +// any other bare identifier (`helper.string "x"`) is somebody's own helper call and names no column. +// The `sawId` out-param records an explicit `id` column so the implicit rule can see it. +inline void rubySchemaColumnCall( TSNode c, std::uint32_t fileId, std::string_view src, std::string_view receiverName, std::vector& defs, bool& sawId ) +{ + if( !kindIs( ts_node_type( c ), "call" ) ) + { + return; + } + const TSNode recv = fieldChild( c, NodeField::Receiver ); + if( ts_node_is_null( recv ) || !kindIs( ts_node_type( recv ), "identifier" ) || nodeTextOf( recv, src ) != receiverName ) + { + return; // receiver-less, receiver-qualified, or a DIFFERENT bare name: not a column on the block parameter + } + const std::string_view m = fieldIdentifierText( c, NodeField::Method, src ); + if( m.empty() ) + { + return; + } + const std::uint32_t line = ts_node_start_point( c ).row + 1; + if( m == "timestamps" && ts_node_is_null( fieldChild( c, NodeField::Arguments ) ) ) + { + const TSNode meth = fieldChild( c, NodeField::Method ); + const std::uint32_t s = ts_node_start_byte( meth ), e = ts_node_end_byte( meth ); + RawDef d; + d.fileId = fileId; d.line = line; d.startByte = s; d.endByte = e; d.loc = 1; + d.kind = SymKind::Section; d.lang = Lang::Ruby; + d.nameByte = s; d.name = "created_at"; defs.push_back( d ); // the two anchors are the + d.nameByte = e; d.name = "updated_at"; defs.push_back( d ); // method token's edges — no literal tokens exist + return; + } + if( std::find( kRubySchemaNonColumns.begin(), kRubySchemaNonColumns.end(), m ) != kRubySchemaNonColumns.end() ) + { + return; // t.index / references / belongs_to / polymorphic name no column (stated floor) + } + const TSNode args = fieldChild( c, NodeField::Arguments ); + if( ts_node_is_null( args ) ) + { + return; + } + const TSNode nameNode = rubyFirstNonCommentArg( args ); + if( ts_node_is_null( nameNode ) ) + { + return; + } + const std::string lit = stringLiteralText( nameNode, src ); // "" when not a string node + std::string_view txt = lit; + if( txt.empty() && kindIs( ts_node_type( nameNode ), "simple_symbol" ) ) + { + txt = nodeTextOf( nameNode, src ); + if( txt.size() > 1 && txt.front() == ':' ) + { + txt.remove_prefix( 1 ); + } + } + if( txt.empty() ) + { + return; // `""` / `: ` defensive: an empty name is no column + } + const std::uint32_t s = ts_node_start_byte( nameNode ), e = ts_node_end_byte( nameNode ); + RawDef d; + d.fileId = fileId; d.line = line; d.startByte = s; d.endByte = e; d.loc = 1; + d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; // content start — past the quote or ':' + d.name = std::string( txt ); + if( d.name == "id" ) + { + sawId = true; + } + defs.push_back( d ); +} + +// One create_table call: the schema-file gate plus the whole table's defs (name, options, columns, id). +inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_view src, std::vector& defs ) +{ + std::string_view receiverName; // the block parameter's name (`t`); empty until Gate 2 passes + // Gate 0: a rendered schema's tables live at file level or under `ActiveRecord::Schema[].define … do` + // — NEVER inside a class/module body. A MIGRATION's `create_table "users" … do |t|` (class-wrapped, + // often string-named) is the same AST shape, and without this gate it would mint the SAME columns as + // the schema that migration created — double-counting every added column on every corpus with both + // files indexed. `add_column`/`remove_column` are not read anywhere (a column added then dropped never + // registers; pinned by the migration fixtures), but the create_table of a real migration must not + // double its schema either. Excluding at least one `class`/`module` ancestor keeps the capture + // content-gated (still no path heuristic): a migration file next to the schema contributes ZERO defs. + // Checked FIRST — the cheapest gate — before the argument/block walks. + { + for( TSNode p = ts_node_parent( n ); !ts_node_is_null( p ); p = ts_node_parent( p ) ) + { + const std::string_view k( ts_node_type( p ) ); + if( k == "class" || k == "module" ) // a locals distilled walk — no cross-helper dependency + { + return; + } + } + } + const TSNode args = fieldChild( n, NodeField::Arguments ); + if( ts_node_is_null( args ) ) + { + return; + } + // Gate 1: a STRING table name as the first argument (a symbol-named table is a migration spelling, + // not a rendered-schema shape — stated floor; no name node → no schema surface either). + const TSNode tableName = rubyFirstNonCommentArg( args ); + if( ts_node_is_null( tableName ) || !kindIs( ts_node_type( tableName ), "string" ) ) + { + return; + } + // Gate 2: the call's OWN do_block, and its parameters: field carries ONE bare identifier (the `|t|`). + TSNode block{}; + { + ChildCursor cursor( n ); + forEachNamedChild( n, cursor.cur, [ & ]( TSNode c ) + { + if( kindIs( ts_node_type( c ), "do_block" ) ) + { + block = c; + return false; + } + return true; + } ); + } + if( ts_node_is_null( block ) ) + { + return; + } + const TSNode params = fieldChild( block, NodeField::Parameters ); + if( ts_node_is_null( params ) ) + { + return; // `create_table "x" do … end` with no handle — not the schema shape + } + { + ChildCursor cursor( params ); + TSNode only{}; + std::size_t named = 0; + forEachNamedChild( params, cursor.cur, [ & ]( TSNode c ) { only = c; ++named; return true; } ); + if( named != 1 || !kindIs( ts_node_type( only ), "identifier" ) ) + { + return; // multi-parameter or non-identifier handle — not a rendered schema's do |t| + } + receiverName = nodeTextOf( only, src ); // the `|t|` spelling — column calls must use it + } + // Options: the id/primary_key pairs among the call's arguments. + bool idFalse = false, primaryKey = false; + TSNode idKey{}, pkValue{}; + { + ChildCursor cursor( args ); + forEachNamedChild( args, cursor.cur, [ & ]( TSNode a ) + { + if( !kindIs( ts_node_type( a ), "pair" ) ) + { + return true; + } + const std::string_view k = rubyPairKeyText( a, src ); + const TSNode v = fieldChild( a, NodeField::Value ); + if( k == "id" ) + { + if( kindIs( ts_node_type( v ), "false" ) ) + { + idFalse = true; + } + else if( kindIs( ts_node_type( v ), "simple_symbol" ) ) + { + idKey = fieldChild( a, NodeField::Key ); + } + } + else if( k == "primary_key" ) + { + primaryKey = true; + pkValue = v; + } + return true; + } ); + } + // The columns: the do_block's body: field (its body_statement) — walk its named children directly + // (a fresh cursor; the outer walks cannot be reused mid-flight, tschildren.h). + bool sawIdColumn = false; + const TSNode body = fieldChild( block, NodeField::Body ); + if( !ts_node_is_null( body ) ) + { + ChildCursor cursor( body ); + forEachNamedChild( body, cursor.cur, [ & ]( TSNode c ) + { + rubySchemaColumnCall( c, fileId, src, receiverName, defs, sawIdColumn ); + return true; + } ); + } + // The id rule, in the grounded order: an explicit id column wins; then the pairs decide. + if( sawIdColumn || idFalse ) + { + return; + } + if( primaryKey && !ts_node_is_null( pkValue ) && kindIs( ts_node_type( pkValue ), "string" ) ) + { + const std::string name = stringLiteralText( pkValue, src ); + if( name.empty() ) + { + return; + } + const std::uint32_t s = ts_node_start_byte( pkValue ), e = ts_node_end_byte( pkValue ); + RawDef d; + d.fileId = fileId; d.line = ts_node_start_point( pkValue ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; + d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; d.name = name; + defs.push_back( d ); + return; + } + if( !ts_node_is_null( idKey ) ) + { + const std::uint32_t s = ts_node_start_byte( idKey ), e = ts_node_end_byte( idKey ); + RawDef d; + d.fileId = fileId; d.line = ts_node_start_point( idKey ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; + d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s; d.name = "id"; + defs.push_back( d ); + return; + } + // A primary_key pair with a NON-string value (composite `["a","b"]`, hand-written `:symbol`): the + // key is NOT an `id` and the rendered columns are the block's own explicit `t.` lines (already + // minted above) — suppress the implicit id entirely (the composite case is pinned by the fixture). + if( primaryKey ) + { + return; + } + // Implicit `id` — anchored at the table-name string's content start (a stable per-table byte). + { + const std::uint32_t s = ts_node_start_byte( tableName ), e = ts_node_end_byte( tableName ); + RawDef d; + d.fileId = fileId; d.line = ts_node_start_point( tableName ).row + 1; d.startByte = s; d.endByte = e; d.loc = 1; + d.kind = SymKind::Section; d.lang = Lang::Ruby; d.nameByte = s + 1; d.name = "id"; + defs.push_back( d ); + } +} + + +// ─── A Jbuilder template's `json`, and `helper_method` (test/rubyrakejbuildercheck.sh, parser version 135) ───────────── +// graph.h RubyTopSelf reads a template's self outside any class as the view. The template handler binds `json` there to the +// view's JbuilderTemplate (model.h kJbuilderTemplate), whose method_missing makes every name a key: a read of it is no +// call, and a call on it is typed that class. A `helper_method :a, :b` at class-body position names methods of the class +// its views call, recorded as LocalBindKind::RubyHelperMethod bindings against the declaring class or module (a concern's +// `included do` block declares for the concern, whose def it is). + +// Over a template's references — the trailing run carrying this fileId: the bare-word `json` reads dropped, and each call +// on `json` typed JbuilderTemplate. +inline void typeRubyJbuilderLocal( std::uint32_t fileId, std::vector& refs ) +{ + std::size_t first = refs.size(); + while( first > 0 && refs[ first - 1 ].fileId == fileId ) + { + --first; + } + const auto read = []( const RawRef& r ) { return r.role == RefRole::Call && r.recv == RecvKind::None && r.name == kJbuilderLocal; }; + refs.erase( std::remove_if( refs.begin() + std::ptrdiff_t( first ), refs.end(), read ), refs.end() ); + for( std::size_t i = first; i < refs.size(); ++i ) + { + if( refs[ i ].role == RefRole::Call && refs[ i ].recv == RecvKind::NamedVar && refs[ i ].recvVar == kJbuilderLocal ) + { + rubyTypeReceiver( refs[ i ], kJbuilderTemplate, kJbuilderLocal, false ); + } + } +} + +// Does `call` sit at class-body position — its nearest enclosing def-or-class a class or module, any blocks between? +inline bool rubyAtClassBody( TSNode call ) noexcept +{ + for( TSNode p = ts_node_parent( call ); !ts_node_is_null( p ); p = ts_node_parent( p ) ) + { + const char* t = ts_node_type( p ); + if( kindIs( t, "class" ) || kindIs( t, "module" ) ) + { + return true; + } + if( kindIs( t, "method" ) || kindIs( t, "singleton_method" ) || kindIs( t, "singleton_class" ) ) + { + return false; + } + } + return false; +} + +// One binding per symbol argument in `args` (`helper_method :a, :b`, `attr_accessor :c`): `like` with the symbol's own start +// byte and its name (the colon dropped). +inline void rubyNoteSymbolArguments( TSNode args, const RawBind& like, std::string_view src, std::vector& binds ) +{ + if( ts_node_is_null( args ) ) + { + return; + } + ChildCursor cursor( args ); // O(children) (src/infra/tschildren.h) + forEachNamedChild( args, cursor.cur, [ & ]( TSNode a ) + { + if( kindIs( ts_node_type( a ), "simple_symbol" ) ) + { + RawBind b = like; + b.startByte = ts_node_start_byte( a ); + b.var = std::string( pattern::nodeText( a, src ).substr( 1 ) ); + binds.push_back( std::move( b ) ); + } + return true; + } ); +} + +// Each symbol of the `helper_method` call `call`, a binding against the class or module around it. +inline void rubyNoteHelperMethods( TSNode call, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + const std::string owner = rubyEnclosingScopeOf( call, src ); + if( !owner.empty() ) + { + rubyNoteSymbolArguments( fieldChild( call, NodeField::Arguments ), + RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubyHelperMethod, .typeName = owner }, src, binds ); + } +} + +// Every node of a Ruby file's tree, handed to `take` in turn; a node `take` returns true for is taken, and its subtree is +// not walked. Iterative, so a hostile nesting depth costs heap, not stack. +template +inline void rubyWalkUntaken( TSNode root, Take&& take ) +{ + std::vector stack { root }; + std::vector kids; + ChildCursor cursor( root ); + while( !stack.empty() ) + { + const TSNode n = stack.back(); + stack.pop_back(); + if( !take( n ) ) + { + collectChildren( n, cursor.cur, kids ); + stack.insert( stack.end(), kids.begin(), kids.end() ); + } + } +} + +// One file's `helper_method` declarations, into `binds` — a file that never spells the word is not walked (rubyWalkUntaken). +inline void captureRubyHelperMethods( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + if( src.find( "helper_method" ) == std::string_view::npos ) + { + return; + } + rubyWalkUntaken( root, [ & ]( TSNode n ) + { + const bool declares = kindIs( ts_node_type( n ), "call" ) && ts_node_is_null( fieldChild( n, NodeField::Receiver ) ) + && fieldChildTextOfKind( n, NodeField::Method, "identifier", src ) == "helper_method"; + if( declares && rubyAtClassBody( n ) ) + { + rubyNoteHelperMethods( n, fileId, src, binds ); + return true; + } + return false; + } ); +} + +// Parser version 137 (test/rubyclassrecvcheck.sh): is the Ruby def `defNode` a SINGLETON method — `def self.m`, or a def +// inside `class << self` — which a call on a class object answers and no instance's lookup reaches? The nearest class, +// module or def above a plain `def` decides; a block between them (`class_methods do`, `included do`) is walked through. +// rubyDefSide below tells whose class object; captureRubyClassMixins reads a directive in `class << self` with this. +inline bool rubyIsSingletonDef( TSNode defNode ) noexcept +{ + if( kindIs( ts_node_type( defNode ), "singleton_method" ) ) + { + const TSNode object = fieldChild( defNode, NodeField::Object ); + return !ts_node_is_null( object ) && kindIs( ts_node_type( object ), "self" ); + } + for( TSNode n = ts_node_parent( defNode ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) + { + const char* t = ts_node_type( n ); + if( kindIs( t, "singleton_class" ) ) + { + const TSNode value = fieldChild( n, NodeField::Value ); + return !ts_node_is_null( value ) && kindIs( ts_node_type( value ), "self" ); + } + if( kindIs( t, "class" ) || kindIs( t, "module" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) + { + return false; + } + } + return false; +} + +// What a Ruby def is to a class object (parser versions 137 and 145; test/rubyclassrecvcheck.sh). +enum class RubyDefSide : std::uint8_t +{ + Instance, // an instance method: no call on a class object reaches it + Singleton, // a singleton method of the class or module that holds it — `def self.m`, a def or an accessor in `class << self` + Includer, // a class method of each class INCLUDING its module, defined on the includer itself: a singleton def inside a + // concern's `included do`, which runs on the includer + ClassMethods, // a class method of each includer from a module extended onto it: a def inside a concern's `class_methods + // do`, which ActiveSupport::Concern makes the concern's ClassMethods +}; + +// The two blocks of an ActiveSupport::Concern whose defs can be its includer's class methods (RubyDefSide::Includer, +// ClassMethods). +inline constexpr std::array kRubyIncluderBlocks = { "included", "class_methods" }; + +// The concern hook (kRubyIncluderBlocks) whose block `n` is, written at class-body level, or empty. +inline std::string_view rubyIncluderHook( TSNode n, std::string_view src ) noexcept +{ + const TSNode owner = kindIs( ts_node_type( n ), "do_block" ) || kindIs( ts_node_type( n ), "block" ) ? ts_node_parent( n ) : TSNode {}; + if( ts_node_is_null( owner ) || !kindIs( ts_node_type( owner ), "call" ) ) + { + return {}; + } + const std::string_view hook = rubyNamedDirective( owner, src, kRubyIncluderBlocks ); + return !hook.empty() && rubyAttrAtClassBodyLevel( owner, src ) ? hook : std::string_view {}; +} + +// What a def written in a concern's `hook` block is: in `included do`, a singleton def is the includer's own and a plain +// one an instance method; in `class_methods do`, a plain def is the includer's class method. +inline RubyDefSide rubyHookSide( std::string_view hook, bool singleton ) noexcept +{ + if( hook == "included" ) + { + return singleton ? RubyDefSide::Includer : RubyDefSide::Instance; + } + return singleton ? RubyDefSide::Instance : RubyDefSide::ClassMethods; +} + +// What the Ruby def — or `class << self` accessor — at `defNode` is to a class object. A `def self.m`, or a `class << self` +// above it, makes it a singleton method, and the nearest class, module or def above decides whose — unless a concern's +// `included do` (for a singleton def) or `class_methods do` (for a plain one) at class-body level comes first, which makes +// it the includer's: defined on the includer itself, or in the module the concern extends onto it. Any other block between +// is walked through. +inline RubyDefSide rubyDefSide( TSNode defNode, std::string_view src ) noexcept +{ + const bool selfDef = kindIs( ts_node_type( defNode ), "singleton_method" ); + if( selfDef && fieldChildTextOfKind( defNode, NodeField::Object, "self", src ).empty() ) + { + return RubyDefSide::Instance; // `def obj.m` is another object's + } + bool singleton = selfDef; + for( TSNode n = ts_node_parent( defNode ); !ts_node_is_null( n ); n = ts_node_parent( n ) ) + { + const char* t = ts_node_type( n ); + if( kindIs( t, "singleton_class" ) ) + { + if( fieldChildTextOfKind( n, NodeField::Value, "self", src ).empty() ) + { + return RubyDefSide::Instance; // `class << Other` opens another object's singleton + } + singleton = true; + continue; + } + if( kindIs( t, "class" ) || kindIs( t, "module" ) || kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) + { + break; + } + if( const std::string_view hook = rubyIncluderHook( n, src ); !hook.empty() ) + { + return rubyHookSide( hook, singleton ); + } + } + return singleton ? RubyDefSide::Singleton : RubyDefSide::Instance; +} + +// The importedName a Ruby class-object binding carries for a def on `side` (model.h LocalBindKind::RubySingletonDef). +inline std::string rubySideMark( RubyDefSide side ) +{ + return std::string( side == RubyDefSide::Includer ? kRubyIncluderMark : side == RubyDefSide::ClassMethods ? kRubyClassMethodsMark : std::string_view {} ); +} + +// A LocalBindKind::RubySingletonDef binding for the def at `defNode` named `name`, when a class object answers it: a +// singleton method (parser version 137), or a class method of each includer (parser version 145). +inline void rubyNoteClassSideDef( TSNode defNode, std::uint32_t fileId, std::string_view name, std::string_view src, std::vector& binds ) +{ + const RubyDefSide side = rubyDefSide( defNode, src ); + if( side != RubyDefSide::Instance ) + { + binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( defNode ), .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, + .var = std::string( name ), .importedName = rubySideMark( side ) } ); + } +} + +// The bindings for what the `class << self` call `n` declares on the class object's `side` — an accessor, or a +// delegation-DSL call (captureRubySingletonAccessors below). An accessor's binding sits at its symbol, +// where captureRubyDefs starts its def (no owner), and carries the side's mark (one a concern's `included do` +// declares is the includer's); a delegated name has no def, and is read against the class that owns the singleton. +inline void rubyNoteSingletonDecl( TSNode n, RubyDefSide side, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + const std::string_view delegation = rubyNamedDirective( n, src, kRubyDelegationCalls ); + if( delegation.empty() ) + { + rubyNoteSymbolArguments( fieldChild( n, NodeField::Arguments ), + RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .importedName = rubySideMark( side ) }, + src, binds ); + return; + } + const std::string owner = rubyEnclosingScopeOf( n, src ); + if( delegation == "delegate_missing_to" ) + { + binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( n ), .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .typeName = owner } ); + return; + } + rubyNoteSymbolArguments( fieldChild( n, NodeField::Arguments ), RawBind { .fileId = fileId, .lang = Lang::Ruby, .kind = LocalBindKind::RubySingletonDef, .typeName = owner }, + src, binds ); +} + +// What a `class << self` declares beside its defs is the class object's too (parser version 137): each accessor +// (`attr_accessor :config`, one RubySingletonDef binding per name, where captureRubyDefs starts its def) and each name +// the delegation DSL defines (model.h kRubyDelegationCalls — `delegate :reset, to: :instance`: a binding per name against +// the owning class; `delegate_missing_to` one with no name, as it answers every name). A file that never writes `<<` is not +// walked; the walk is iterative, so a hostile nesting depth costs heap, not stack. +inline void captureRubySingletonAccessors( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + if( src.find( "<<" ) == std::string_view::npos ) + { + return; + } + rubyWalkUntaken( root, [ & ]( TSNode n ) + { + const bool call = kindIs( ts_node_type( n ), "call" ); + const bool declares = call && ( !rubyNamedDirective( n, src, kRubyAttrFamilyNames ).empty() || !rubyNamedDirective( n, src, kRubyDelegationCalls ).empty() ); + const RubyDefSide side = declares && rubyAttrAtClassBodyLevel( n, src ) ? rubyDefSide( n, src ) : RubyDefSide::Instance; + if( side == RubyDefSide::Instance ) + { + return false; + } + rubyNoteSingletonDecl( n, side, fileId, src, binds ); + return true; + } ); +} + +// Parser version 145 (test/rubyclassrecvcheck.sh): each mixin the CLASS OBJECT's lookup reaches rather than its +// instances' — the constants of an `extend M`, and of an `include`/`prepend` inside `class << self` — as a +// LocalBindKind::RubyClassMixin binding at the constant's own start byte, where captureRubyMixinBases puts its inherit +// reference, read where that reads the directive (class-body level, or a concern's `included do`). One a concern's +// `included do` writes extends the includer, not the concern: importedName kRubyIncluderMark. A class body's `extend self` +// names no constant: its binding sits at the directive, var "self". A file that never writes `extend` or `<<` is not +// walked, and the walk enters no def, whose body holds no directive. +// Is the mixin directive `n` an `extend self` — its one argument `self`? +inline bool rubyExtendsSelf( TSNode n ) noexcept +{ + const TSNode args = fieldChild( n, NodeField::Arguments ); + return !ts_node_is_null( args ) && ts_node_named_child_count( args ) == 1 && kindIs( ts_node_type( ts_node_named_child( args, 0 ) ), "self" ); +} + +// The RubyClassMixin bindings of the mixin directive `n` (an `extend` when `extend`), read where captureRubyMixinBases reads +// it: one per constant a class object's lookup reaches, and a module body's `extend self`. +inline void rubyNoteClassMixins( TSNode n, bool extend, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + const bool inIncluded = rubyInConcernIncludedBlock( n, src ); + if( !inIncluded && !rubyAttrAtClassBodyLevel( n, src ) ) + { + return; + } + const bool singleton = rubyIsSingletonDef( n ); // the directive sits in `class << self` + if( extend && !inIncluded && !singleton && rubyExtendsSelf( n ) ) + { + binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( n ), .lang = Lang::Ruby, .kind = LocalBindKind::RubyClassMixin, .var = "self" } ); + } + if( extend == singleton ) + { + return; // an include joins the instances' lookup, an extend inside `class << self` the singleton's own + } + const bool includer = inIncluded || ( singleton && rubyDefSide( n, src ) == RubyDefSide::Includer ); + for( const TSNode c : rubyMixinTargets( n, src ) ) + { + binds.push_back( RawBind { .fileId = fileId, .startByte = ts_node_start_byte( c ), .lang = Lang::Ruby, .kind = LocalBindKind::RubyClassMixin, + .importedName = includer ? std::string( kRubyIncluderMark ) : std::string {} } ); + } +} + +inline void captureRubyClassMixins( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + if( src.find( "extend" ) == std::string_view::npos && src.find( "<<" ) == std::string_view::npos ) + { + return; + } + rubyWalkUntaken( root, [ & ]( TSNode n ) + { + const char* t = ts_node_type( n ); + if( kindIs( t, "method" ) || kindIs( t, "singleton_method" ) ) + { + return true; // a def body holds no directive + } + const std::string_view verb = kindIs( t, "call" ) ? rubyNamedDirective( n, src, kRubyConstantDirectives ) : std::string_view {}; + if( verb.empty() || verb == "autoload" ) + { + return false; + } + rubyNoteClassMixins( n, verb == "extend", fileId, src, binds ); + return true; + } ); +} + +// ─── FactoryBot's factory definitions (parser version 132, test/rubytypedrecvcheck.sh) ───────────────────────────────── +// `factory :user do … end` inside a `FactoryBot.define` (or `FactoryGirl.define`) block defines what `create( :user )` +// builds, and its class is FactoryBot's own rule: the `class:` option as written — a constant, or a string naming one (its +// final segment) — else the enclosing factory's (a nested factory builds its parent's class), else the `parent:` factory's +// when the file defined it above, else the name camelised (`:user_profile` → UserProfile). One RubyFactory binding per name +// and per `aliases:` entry; graph.h rubyFactoryClasses joins them over the tree, where two classes for one name type nothing. +// A `class:` of any other spelling (a symbol, an expression) records no factory: its class is not known. + +// `user_profile` → UserProfile, `admin/user` → User: the final segment of the constant FactoryBot camelises the name into +inline std::string rubyFactoryDefaultClass( std::string_view name ) +{ + const std::size_t slash = name.rfind( '/' ); + std::string out; + bool upper = true; + for( const char c : name.substr( slash == std::string_view::npos ? 0 : slash + 1 ) ) + { + if( c == '_' ) + { + upper = true; + continue; + } + out.push_back( upper && c >= 'a' && c <= 'z' ? char( c - 'a' + 'A' ) : c ); + upper = false; + } + return out; +} + +// The value of the `key:` option among a call's arguments (`class: "User"`, `parent: :user`), or null. +inline TSNode rubyCallOption( TSNode call, std::string_view key, std::string_view src ) +{ + const TSNode args = fieldChild( call, NodeField::Arguments ); + TSNode value {}; + if( !ts_node_is_null( args ) ) + { + ChildCursor cursor( args ); + forEachNamedChild( args, cursor.cur, [ & ]( TSNode c ) + { + const TSNode k = kindIs( ts_node_type( c ), "pair" ) ? fieldChild( c, NodeField::Key ) : TSNode {}; + const bool named = !ts_node_is_null( k ) && kindIs( ts_node_type( k ), "hash_key_symbol" ) && pattern::nodeText( k, src ) == key; + value = named ? fieldChild( c, NodeField::Value ) : value; + return !named; + } ); + } + return value; +} + +// The class a `class:` option names: a constant's final segment, or a plain string's (`"Admin::User"` → User); else empty. +inline std::string_view rubyFactoryClassOption( TSNode value, std::string_view src ) +{ + if( ts_node_is_null( value ) ) + { + return {}; + } + if( isRubyConstantNode( value ) ) + { + return rubyFinalConstant( value, src ); + } + if( !kindIs( ts_node_type( value ), "string" ) || ts_node_named_child_count( value ) != 1 + || !kindIs( ts_node_type( ts_node_named_child( value, 0 ) ), "string_content" ) ) + { + return {}; + } + const std::string_view text = pattern::nodeText( ts_node_named_child( value, 0 ), src ); + const std::size_t cut = text.rfind( "::" ); + return cut == std::string_view::npos ? text : text.substr( cut + 2 ); +} + +class RubyFactoryWalk +{ +public: + RubyFactoryWalk( std::uint32_t fileId, std::string_view src, std::vector& binds ) : fileId_( fileId ), src_( src ), binds_( binds ) {} + void run( TSNode root ); + +private: + struct Item + { + TSNode node; + bool inDefine; // inside a `FactoryBot.define` block + std::uint32_t parent; // index into classes_ of the enclosing factory's class; kNone outside one + }; + static constexpr std::uint32_t kNone = 0xFFFFFFFFu; + void define( TSNode call, const Item& item ); + std::string classOf( TSNode call, std::string_view name, const Item& item ) const; + void emit( TSNode call, std::string_view name, const std::string& cls ); + + std::uint32_t fileId_; + std::string_view src_; + std::vector& binds_; + std::vector stack_; + std::vector kids_; + std::vector classes_; + HashMap byName_; // a factory defined above in this file → its class +}; + +inline void RubyFactoryWalk::run( TSNode root ) +{ + ChildCursor cursor( root ); + stack_.push_back( Item { root, false, kNone } ); + while( !stack_.empty() ) + { + Item item = stack_.back(); + stack_.pop_back(); + const TSNode n = item.node; + if( kindIs( ts_node_type( n ), "call" ) ) + { + const TSNode recv = fieldChild( n, NodeField::Receiver ); + const std::string_view on = !ts_node_is_null( recv ) && isRubyConstantNode( recv ) ? rubyFinalConstant( recv, src_ ) : std::string_view {}; + const std::string_view m = fieldChildTextOfKind( n, NodeField::Method, "identifier", src_ ); + item.inDefine = item.inDefine || ( ( on == "FactoryBot" || on == "FactoryGirl" ) && m == "define" ); + if( item.inDefine && ts_node_is_null( recv ) && m == "factory" ) + { + define( n, item ); + continue; + } + } + collectChildren( n, cursor.cur, kids_ ); + for( std::size_t i = kids_.size(); i > 0; --i ) + { + stack_.push_back( Item { kids_[ i - 1 ], item.inDefine, item.parent } ); + } + } +} + +// one `factory :name, …` call: its class (the section note above), a binding per name and alias, then its block's +// factories, which build this class unless they say otherwise +inline void RubyFactoryWalk::define( TSNode call, const Item& item ) +{ + const std::string_view name = rubyFirstSymbolArgument( call, src_ ); + std::string cls = name.empty() ? std::string {} : classOf( call, name, item ); + if( cls.empty() ) + { + return; // no symbol name, or a class this file does not say: no factory is recorded, and none nested in it + } + const std::uint32_t index = std::uint32_t( classes_.size() ); + classes_.push_back( std::move( cls ) ); + byName_.try_emplace( name, index ); + emit( call, name, classes_[ index ] ); + if( const TSNode aliases = rubyCallOption( call, "aliases", src_ ); !ts_node_is_null( aliases ) && kindIs( ts_node_type( aliases ), "array" ) ) + { + ChildCursor cursor( aliases ); + forEachNamedChild( aliases, cursor.cur, [ & ]( TSNode a ) + { + const std::string_view alias = kindIs( ts_node_type( a ), "simple_symbol" ) ? pattern::nodeText( a, src_ ).substr( 1 ) : std::string_view {}; + if( !alias.empty() ) + { + emit( call, alias, classes_[ index ] ); + } + return true; + } ); + } + if( const TSNode block = rubyCallBlock( call ); !ts_node_is_null( block ) ) + { + stack_.push_back( Item { block, true, index } ); + } +} + +// the class `factory :name, …` builds, by FactoryBot's rule (the section note above); empty when this file does not say +inline std::string RubyFactoryWalk::classOf( TSNode call, std::string_view name, const Item& item ) const +{ + if( const TSNode opt = rubyCallOption( call, "class", src_ ); !ts_node_is_null( opt ) ) + { + return std::string( rubyFactoryClassOption( opt, src_ ) ); + } + if( const TSNode parent = rubyCallOption( call, "parent", src_ ); !ts_node_is_null( parent ) ) + { + const bool symbol = kindIs( ts_node_type( parent ), "simple_symbol" ); + const auto named = symbol ? byName_.find( pattern::nodeText( parent, src_ ).substr( 1 ) ) : byName_.end(); + return named == byName_.end() ? std::string {} : classes_[ named->second ]; + } + return item.parent != kNone ? classes_[ item.parent ] : rubyFactoryDefaultClass( name ); +} + +inline void RubyFactoryWalk::emit( TSNode call, std::string_view name, const std::string& cls ) +{ + RawBind b; + b.fileId = fileId_; + b.startByte = ts_node_start_byte( call ); + b.lang = Lang::Ruby; + b.kind = LocalBindKind::RubyFactory; + b.var = std::string( name ); + b.typeName = cls; + binds_.push_back( std::move( b ) ); +} + +// One file's FactoryBot factories, into `binds` — a file with no `FactoryBot.define` / `FactoryGirl.define` is not walked. +inline void captureRubyFactories( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& binds ) +{ + if( src.find( "FactoryBot.define" ) != std::string_view::npos || src.find( "FactoryGirl.define" ) != std::string_view::npos ) + { + RubyFactoryWalk( fileId, src, binds ).run( root ); + } +} + +} // namespace - ingest_ruby.h section of ingest.cpp + +} // namespace rw diff --git a/src/model.h b/src/model.h index cf0ddbb57..c1846b7e8 100644 --- a/src/model.h +++ b/src/model.h @@ -902,7 +902,7 @@ inline const RspecTarget* rspecTargetOf( std::string_view cls, std::string_view // Ruby's delegation DSL: a class that writes one defines methods the tree indexes nowhere in it (ActiveSupport's `delegate` // and `delegate_missing_to`, Forwardable's `def_delegator(s)`), so it may answer a name no lookup over the tree finds (graph.h -// RubySelfReach); one a `class << self` writes defines the class object's (ingest_binds.h captureRubySingletonAccessors). +// RubySelfReach); one a `class << self` writes defines the class object's (ingest_ruby.h captureRubySingletonAccessors). inline constexpr std::string_view kRubyDelegationCalls[] = { "def_delegator", "def_delegators", "delegate", "delegate_missing_to" }; // A Jbuilder template's `json` (test/rubyrakejbuildercheck.sh, parser version 135): the template handler binds it to the @@ -1059,28 +1059,28 @@ enum class LocalBindKind : std::uint8_t // var "." is a dot import. fromSymbol kNoNode, spans {0,0}. Read only by graph.h FalseEdgeRules; // every other binding consumer filters by kind or skips file-scope records. APPENDED (cache u8). RubyFactory, // parser version 132: a FactoryBot `factory :name` definition — var=the factory name (or one of its - // `aliases:`), typeName=the final segment of the class it builds (ingest_binds.h captureRubyFactories). + // `aliases:`), typeName=the final segment of the class it builds (ingest_ruby.h captureRubyFactories). // Read by graph.h rubyFactoryClasses ONLY; every other reader skips it by kind or finds no // variable of that name. APPENDED for the same cache reason as VarDecl. RubyHelperMethod, // parser version 135: a `helper_method :name` declaration at class-body position — var=the method, - // typeName=the final segment of the class or module that declares it (ingest_binds.h - // RubyBareCallWalk::noteDeclaredCall). Read by graph.h RubyTopSelf ONLY. APPENDED, as above. + // typeName=the final segment of the class or module that declares it (ingest_ruby.h + // rubyNoteHelperMethods). Read by graph.h RubyTopSelf ONLY. APPENDED, as above. RubySingletonDef, // parser version 137: a Ruby SINGLETON method — `def self.m`, a def or an accessor inside `class << self` // — at startByte, the def's own start (Symbol::sigStartByte); var=the method, typeName empty - // (ingest_binds.h rubyNoteClassSideDef, captureRubySingletonAccessors). A name the delegation DSL + // (ingest_ruby.h rubyNoteClassSideDef, captureRubySingletonAccessors). A name the delegation DSL // defines in a `class << self` has no def: typeName=the owning class, var=the name (empty for // `delegate_missing_to`, every name). Read by graph.h's class-object lookup ONLY // (rubyClassObjectDefs). APPENDED, as above. Parser version 145: a def that is a class method of // each class INCLUDING its module rather than one of the module carries importedName // kRubyIncluderMark — a singleton def inside a concern's `included do … end`, which defines it on // the includer itself — or kRubyClassMethodsMark — a def inside its `class_methods do … end`, which - // ActiveSupport::Concern extends onto the includer (ingest_binds.h rubyDefSide). + // ActiveSupport::Concern extends onto the includer (ingest_ruby.h rubyDefSide). RubyClassMixin, // parser version 145: a mixin the CLASS OBJECT's lookup reaches rather than its instances' — the constant // of an `extend M`, or of an `include`/`prepend` inside `class << self` — at startByte, the // constant's own start, where ingest_relations.h captureRubyMixinBases puts its inherit reference; // var and typeName empty; importedName=kRubyIncluderMark when the directive sits in a concern's // `included do … end`, which extends the includer, not the concern. A module body's `extend self` - // has no constant: var="self", at the directive's own start byte (ingest_binds.h + // has no constant: var="self", at the directive's own start byte (ingest_ruby.h // captureRubyClassMixins). Read by graph.h's class-object lookup ONLY (rubyFqnAncestry). APPENDED, // as above. // FE-B (test/receiverevidencecheck.sh): the receiver-evidence facts graph.h ReceiverEvidence reads, and NOTHING else. diff --git a/test/fieldidcheck.sh b/test/fieldidcheck.sh index 9413eb66a..46c48a20d 100755 --- a/test/fieldidcheck.sh +++ b/test/fieldidcheck.sh @@ -64,7 +64,7 @@ no(){ printf ' FAIL %s\n' "$*"; fail=1; } HDR="$ROOT/src/infra/fieldid.h" CRAWL="$ROOT/src/ingest_crawl.h" # The files the conversion touched. Sections of src/ingest.cpp's TU, plus the two standalone consumers. -SITE_FILES="src/ingest_metrics.h src/ingest_binds.h src/ingest_sidecap.h src/ingest_relations.h src/ingest_names.h src/ingest_jsimports.h src/ingest_elixir.h src/preprocdead.h src/slice.h" +SITE_FILES="src/ingest_metrics.h src/ingest_binds.h src/ingest_sidecap.h src/ingest_relations.h src/ingest_names.h src/ingest_ruby.h src/ingest_jsimports.h src/ingest_elixir.h src/preprocdead.h src/slice.h" [ -f "$HDR" ] || { echo "missing $HDR — nothing to check"; exit 2; } [ -f "$CRAWL" ] || { echo "missing $CRAWL — the grammar table is what this gate enumerates over"; exit 2; } From 88cc3383e1bbb3c70273ab37dc0b35cd571a2446 Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Wed, 14 Oct 2026 17:39:51 +0200 Subject: [PATCH 2/2] fix(ingest): `//` on the ingest_ruby.h include; retarget two Jbuilder comments The trailing note after the include used `#`, which is not a C++ comment marker: it leaves extra tokens on the preprocessing directive, and clang (-Wextra-tokens), GCC and MSVC (C4067) each warn on it. This was the build's only warning. The two comments that still named ingest_binds.h as the home of the Jbuilder typing now point at ingest_ruby.h, which this branch moved it to: the language table's `.jbuilder` note (ingest_crawl.h) and the kJbuilderLocal block (model.h). Review points 1 and 2, both from the #396 review. --- src/ingest.cpp | 2 +- src/ingest_crawl.h | 2 +- src/model.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ingest.cpp b/src/ingest.cpp index b05022379..cad9f5bac 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -196,7 +196,7 @@ extern "C" #include "ingest_binds.h" #include "ingest_elixir.h" #include "ingest_clojure.h" -#include "ingest_ruby.h" # Ruby Rails/DSL capture lane (schema columns, Jbuilder typing, factories, class-side DSL walks) - carved verbatim 2026-10-12 +#include "ingest_ruby.h" // Ruby Rails/DSL capture lane (schema columns, Jbuilder typing, factories, class-side DSL walks) - carved verbatim 2026-10-12 #include "ingest_importcap.h" #include "ingest_valuerefs.h" #include "ingest_sidecap.h" diff --git a/src/ingest_crawl.h b/src/ingest_crawl.h index 7ccdca0e3..39f226a42 100644 --- a/src/ingest_crawl.h +++ b/src/ingest_crawl.h @@ -191,7 +191,7 @@ constexpr std::array kLangTable = {{ { ".rb", Lang::Ruby, &tree_sitter_ruby, "ruby" }, // Ruby — class/module/def + method calls + require // A Rake task file and a Jbuilder view are plain Ruby (test/rubyrakejbuildercheck.sh, parser version 135): what a // task or a JSON view calls is a caller like any other. Outside any class their self is known — Rake's `main`, the - // view — and graph.h RubyTopSelf reads it; a `.jbuilder` file's `json` is the template's (ingest_binds.h). + // view — and graph.h RubyTopSelf reads it; a `.jbuilder` file's `json` is the template's (ingest_ruby.h). { ".rake", Lang::Ruby, &tree_sitter_ruby, "ruby" }, { kJbuilderExt, Lang::Ruby, &tree_sitter_ruby, "ruby" }, { ".json", Lang::Json, &tree_sitter_json, "json" }, // JSON — top-level + 2nd-level object keys as t="sec"; DATA, no call edges diff --git a/src/model.h b/src/model.h index c1846b7e8..970e1897c 100644 --- a/src/model.h +++ b/src/model.h @@ -906,7 +906,7 @@ inline const RspecTarget* rspecTargetOf( std::string_view cls, std::string_view inline constexpr std::string_view kRubyDelegationCalls[] = { "def_delegator", "def_delegators", "delegate", "delegate_missing_to" }; // A Jbuilder template's `json` (test/rubyrakejbuildercheck.sh, parser version 135): the template handler binds it to the -// view's JbuilderTemplate, a BasicObject whose method_missing makes every call on it a key. ingest_binds.h types the local +// view's JbuilderTemplate, a BasicObject whose method_missing makes every call on it a key. ingest_ruby.h types the local // in a `.jbuilder` file; graph.h RubyTypedReceivers refuses a call on it unless the tree opens the class. inline constexpr std::string_view kJbuilderTemplate = "JbuilderTemplate"; inline constexpr std::string_view kJbuilderLocal = "json";