From 09ebd07012e94da2072062f84fe309e39b9eb63f Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Fri, 25 Sep 2026 12:32:06 +0200 Subject: [PATCH 1/4] feat(ruby): db/schema.rb columns mint Section defs (Rails schema columns) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rails schema is recognized BY CONTENT, never by path: a Ruby file whose tree holds a `create_table "x", … do |t| … end` call (the call's do_block carries one bare block parameter) is a rendered schema. Each `t. "name"` / `t. :name` in a table block mints ONE SymKind::Section def at Lang::Ruby — the data-kind slot a doc heading or YAML key already occupies, now with call edges: Rails-generated attribute uses (`product.price`) bind to the column and reach the call graph + PageRank on every Rails corpus (accepted, disclosed in CHANGELOG + tags.scm). model.h's Section wording changes with it ("isolated in the graph" was true only while every Section was a markdown heading/YAML key; the Ruby ones admit edges). The id rule, the four real-world spellings (pinned by the gate): - no id column/spelling -> implicit `id` def at the table-name string (no `id` token exists; anchor is per-table unique, so the multi-def floor id on N tables -> defs="N" holds) - `id: false` -> no id def - `id: :uuid` (simple_symbol value) -> `id` def at the pair key (the uuid is a TYPE of the id column, never a column of its own — Rails docs; type not modelled) - `primary_key: "x"` -> def named the string instead `t.timestamps` mints created_at AND updated_at (anchored at the method token's edges). The DSL CALLS keep their reference posture (string/datetime/create_table stay external). MIGRATIONS are excluded by a class/module-nest gate: a rendered schema's tables live at file level or under Schema[].define — never inside a class body — so a MIGRATION's class-wrapped create_table (string- or symbol-named) mints nothing. That is what makes the content gating honest: the capture is content-addressable everywhere, but the rendered schema stays the ONE source of column names — indexing a migration beside its schema would double every def and its PageRank weight. add_column/remove_column/ change_column are argument data and read nowhere, so a column added then dropped never registers. All four interference points are pinned by the three migration fixtures. Disclosed floors, each pinned by an arm: t.index/references/belongs_to/polymorphic name no column; a symbol-named table is a migration spelling, not a schema surface; string fragments (`where("price > ?")`) stay opaque; duplicate columns across tables stay SEPARATE defs. The picker splits honestly: `rec.name`/rich receivers are graph_ambiguous, `self.name` in the defining class pins its own def. Gate: test/rubyschemacheck.sh, 32 arms, ALL PASS on build/ and asan/; registered in test/regression.sh same-commit (per repo rule). Fixture test/rubyschemafix: schema text from a real, running Rails 8.1 app's `bin/rails db:schema:dump` (verified db:schema:load-able), original domain tables scrubbed to spike_* names; two spellings no dump emits are restored by hand and marked in-file (id: :uuid, which that dump actually failed on, and a literal t.timestamps); three migration fixtures pin the interference floors. Before-evidence recorded: on the pre-change binary every column read defs="0" external="1"; --uses=name was defs="9" and now reads defs="19" external="0" with binding rows. kParserVer is 123 on this branch (carried 122 pre-rebase; train-20's .astro work took 122, so this capture sits at 123 — renumber again at merge if main has moved on), with the quality.h mirror bumped in the same diff. No record layout change (kCacheVersion 25 / kQSnapCacheScheme 14 stay). Tags.scm, CHANGELOG and the fixture's USECASES.md carry the disclosures; gatecount re-pinned (649 -> 650 on this base) and qschemetrip re-pinned in the same commit. Verification on this head (rebased onto train-20 b343b988 / 0.6.4): determinism byte-identical + xmllint clean; ASan+LSan repo sweep clean (g1fresh + cachefuzz ALL PASS, both binaries built from one tree); quality-delta vs origin/main regressions="0" gating="0"; local battery ALL PASS (rubyschemacheck 32 ×2 binaries, rubyattrscheck 47, rubysettercheck, manifest, loopconservation, gatecount 650, qschemetrip, versioncheck, qackorigin, qackconcurrency, ackonly); full suite alone in leap-build: gates=665 pass=655 skip=4 fail=6 — four environment/upstream (crossdirinclude + legendcoverage: documented -j6 timeouts on this laptop, each proven passing standalone ~253s/~187s; htmlrendercheck: known HOME artifact; regexcheck: the train-19-oracle arm needs rg, absent from the container) and TWO self-inflicted on the first rebase draft (the regression.sh absorb list was merged with a `;;` spelling error: manifestcheck "regression.sh does not parse" + loopconservation's zoomcheck-vanish) — fixed, both pass standalone on this committed head. This message states the verified numbers. AI-assistance note: developed with AI assistance (DeepSeek V4 Flash and Qwen 3.8 Flash models) under the author's direct supervision; code, fixture provenance and every claim in this commit were reviewed and verified by the author before the commit. --- .ripwire_quality_acks | 4 +- CHANGELOG.md | 80 ++++++ README.md | 4 +- docs/EVALS.md | 6 +- present/deck5_ripwire_build.js | 6 +- queries/ruby/tags.scm | 13 + src/ingest_cache.h | 20 +- src/ingest_names.h | 328 ++++++++++++++++++++++ src/ingest_sidecap.h | 1 + src/model.h | 4 +- src/quality.h | 5 +- test/qschemetrip.hash | 2 +- test/regression.sh | 2 +- test/rubyschemacheck.sh | 156 ++++++++++ test/rubyschemafix/USECASES.md | 55 ++++ test/rubyschemafix/all_four.rb | 14 + test/rubyschemafix/column_consumers.rb | 16 ++ test/rubyschemafix/column_yaml.rb | 6 + test/rubyschemafix/config/spike_names.yml | 5 + test/rubyschemafix/consumer_ambiguous.rb | 11 + test/rubyschemafix/consumer_locality.rb | 12 + test/rubyschemafix/floor_case.rb | 12 + test/rubyschemafix/id_default.rb | 6 + test/rubyschemafix/id_false.rb | 8 + test/rubyschemafix/id_renamed.rb | 6 + test/rubyschemafix/id_uuid.rb | 6 + test/rubyschemafix/migration_columns.rb | 13 + test/rubyschemafix/migration_string.rb | 14 + test/rubyschemafix/migration_symbol.rb | 11 + test/rubyschemafix/pair_def_column.rb | 18 ++ test/rubyschemafix/quote_double.rb | 6 + test/rubyschemafix/quote_single.rb | 6 + test/rubyschemafix/quote_symbol.rb | 6 + test/rubyschemafix/schema.rb | 83 ++++++ test/rubyschemafix/single_column.rb | 6 + test/rubyschemafix/timestamps.rb | 6 + test/rubyschemafix/triple_plain.rb | 14 + test/rubyschemafix/triple_reversed.rb | 13 + test/rubyschemafix/triple_super.rb | 15 + 39 files changed, 984 insertions(+), 15 deletions(-) create mode 100755 test/rubyschemacheck.sh create mode 100644 test/rubyschemafix/USECASES.md create mode 100644 test/rubyschemafix/all_four.rb create mode 100644 test/rubyschemafix/column_consumers.rb create mode 100644 test/rubyschemafix/column_yaml.rb create mode 100644 test/rubyschemafix/config/spike_names.yml create mode 100644 test/rubyschemafix/consumer_ambiguous.rb create mode 100644 test/rubyschemafix/consumer_locality.rb create mode 100644 test/rubyschemafix/floor_case.rb create mode 100644 test/rubyschemafix/id_default.rb create mode 100644 test/rubyschemafix/id_false.rb create mode 100644 test/rubyschemafix/id_renamed.rb create mode 100644 test/rubyschemafix/id_uuid.rb create mode 100644 test/rubyschemafix/migration_columns.rb create mode 100644 test/rubyschemafix/migration_string.rb create mode 100644 test/rubyschemafix/migration_symbol.rb create mode 100644 test/rubyschemafix/pair_def_column.rb create mode 100644 test/rubyschemafix/quote_double.rb create mode 100644 test/rubyschemafix/quote_single.rb create mode 100644 test/rubyschemafix/quote_symbol.rb create mode 100644 test/rubyschemafix/schema.rb create mode 100644 test/rubyschemafix/single_column.rb create mode 100644 test/rubyschemafix/timestamps.rb create mode 100644 test/rubyschemafix/triple_plain.rb create mode 100644 test/rubyschemafix/triple_reversed.rb create mode 100644 test/rubyschemafix/triple_super.rb diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 347d9f931..e41d3ae6a 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -433,7 +433,7 @@ ack complexity 5ec38fbd414fa4d4 56 cid=35883c203ba33e28 lane/tc-sliceat MCP half ack complexity 61e5df9e1e40ff70 19 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack complexity 639de1c3670999f9 69 cid=bbf7c6fe9dbbe217 wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement ack complexity 685ade09a168535e 30 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack complexity 6979fa794f2a5154 382 cid=8249597fd315bfac ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack complexity 6979fa794f2a5154 386 cid=820a0c985a597ef1 now=386 was=385 p=src/ingest_sidecap.h:1565 ruby schema-column capture (kParserVer 121->122): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack complexity 6a38d70787d09e64 28 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack complexity 6e58b0a307757079 85 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack complexity 7051b3950aaf4c14 35 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. @@ -1556,7 +1556,7 @@ ack verbosity 6031be13b40a1b6f 205 cid=58496e25cbf67431 round ec5e3c3..HEAD, the ack verbosity 6302e2e27e23bcde 64 cid=7202bbc7db7cc1da C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack verbosity 639de1c3670999f9 185 cid=ffcb81de788c329a C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) ack verbosity 685ade09a168535e 96 cid=124bb29c88050ae1 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack verbosity 6979fa794f2a5154 418 cid=8249597fd315bfac ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack verbosity 6979fa794f2a5154 420 cid=820a0c985a597ef1 now=420 was=416 p=src/ingest_sidecap.h:1565 ruby schema-column capture (kParserVer 121->122): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack verbosity 6a38d70787d09e64 74 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack verbosity 6acbcaa854eada23 289 cid=e5c009ae9a0d8393 preloaded-corpus hoist: lexicalScores/lexicalScoresTiered gain one optional defaulted preloadedFileText param so a caller scoring many queries against one tree reads the corpus once instead of per call (--eval-retrieval was ~11.8M file opens/run, 48% of its CPU in the kernel). api-surface rows ARE the intended additive change; short-horizon-churn is this session's own edits to those two functions, not instability. Scores proven byte-identical on an identical tree. ack verbosity 7004309695fb79f1 265 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa9e58d6..25897b27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,48 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli --- +## [Unreleased] + +### Added — a Ruby `db/schema.rb` renders its columns as Section definitions + +A Rails schema is recognized BY CONTENT, never by path: a Ruby file whose tree holds a +`create_table "x", … do |t| … end` call — the call's do-block carries one block parameter — is a +rendered schema, wherever it lives. Each `t. "name"` / `t. :name` in a table block mints +ONE `SymKind::Section` def at `Lang::Ruby` — the data-kind slot a doc heading or YAML key already +occupies (model.h's wording changes with it: a Section is no longer "isolated in the graph"; the Ruby +ones admit call edges) — span = the name token, both quote spellings and the `:symbol` form. +Rails-generated attribute uses (`product.price`) then BIND to the column and leave the external +surface; because Call refs are language-gated, not kind-gated, columns receive real call edges and +PageRank weight on every Rails corpus (accepted churn, disclosed here). The id rule covers the four +grounded spellings: no id column/spelling → an implicit `id` def (the anchor is the table-name string +— no `id` token exists there); `id: false` → no id def; `id: :uuid` → an `id` def at the pair key +(the uuid is a TYPE of the id column, never a column of its own; the type is not modelled); +`primary_key: "x"` → a def named the string instead. `t.timestamps` 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 edges. The DSL CALLS keep their reference posture exactly like the attr family: +`string`/`datetime`/`create_table` stay external-surface names. Duplicate columns across tables stay +SEPARATE defs (same name, distinct identity bytes): `id` on N tables is `defs="N"`, and the map row +merges them with `overloads`, never hiding the multiplicity. MIGRATIONS never interfere: a +class-wrapped create_table — the migration shape, string- or symbol-named — is refused by a +class/module-nest gate (a rendered schema's tables live at file level or under `Schema[].define`, never +inside a class body), so the rendered `db/schema.rb` stays the ONE source of column names and indexing +the migration beside its schema cannot double the defs (or their PageRank weight); `add_column` / +`remove_column` / `change_column` are argument data and read nowhere — a column added then dropped +never registers (pinned by the three migration fixtures). kParserVer 122 → 123 (extraction facts +changed, renumbered on rebase because train-20's `.astro` work took 122; no record layout change — +kCacheVersion and kQSnapCacheScheme stay). +Gate: `test/rubyschemacheck.sh` on `test/rubyschemafix/` — schema text from a real, running Rails +8.1 app's `bin/rails db:schema:dump` (verified `db:schema:load`-able), original domain tables scrubbed +to `spike_*` names; two spellings no dump emits are restored by hand and marked in-file (`id: :uuid`, +which that dump actually failed on, and a literal `t.timestamps`). Stated capture floors, each pinned +by a gate arm: `t.index` / `t.references` / `t.belongs_to` / `t.polymorphic` name no column; a +symbol-named table (`create_table :users`) is a migration spelling, not a schema surface; `where("price +> ?")` string fragments stay opaque; the picker splits honestly (`rec.name` on a plain local and a rich +`X.new.name` receiver are `graph_ambiguous`, `self.name` inside the defining class pins its own def). +The before-evidence: on the pre-change binary every column was `defs=0 external=1` — the fixture's +`--uses=name` read `defs="9"` (what attrs/defs/yaml already made) with the schema columns invisible; it +now reads `defs="19" external="0"` with `count` rows binding. + ## [0.6.4] — 2026-09-25 ### Added — Astro (`.astro`) frontmatter is indexed on the TypeScript grammar (#320, #67) @@ -769,6 +811,44 @@ block are not unwrapped either (each defines real methods at runtime); and only define — a quoted (`:"x"`/`:'x'`), string, or splat/`%i[]` argument stays an honest nothing (Ruby defines those methods; ripwire does not capture them). Every floor is stated and pinned, never silent. +### Added — a Ruby `db/schema.rb` renders its columns as Section definitions + +A Rails schema is recognized BY CONTENT, never by path: a Ruby file whose tree holds a +`create_table "x", … do |t| … end` call — the call's do-block carries one block parameter — is a rendered +schema, wherever it lives. Each `t. "name"` / `t. :name` in a table block mints ONE +`SymKind::Section` def at `Lang::Ruby` — the data-kind slot a doc heading or YAML key already occupies +(model.h's wording changes with it: a Section is no longer "isolated in the graph"; the Ruby ones admit +call edges) — span = the name token, both quote spellings and the `:symbol` form. Rails-generated +attribute uses (`product.price`) then D-BIND to the column and leave the external surface; because Call +refs are language-gated, not kind-gated, columns receive real call edges and PageRank weight on every +Rails corpus (accepted churn, disclosed here). The id rule covers the four grounded spellings: no id +column/spelling → an implicit `id` def (the anchor is the table-name string — no `id` token exists +there); `id: false` → no id def; `id: :uuid` → an `id` def at the pair key (the uuid TYPE is not +modelled); `primary_key: "x"` → a def named the string instead. `t.timestamps` 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 edges. The DSL CALLS keep their reference posture exactly like the attr family: +`string`/`datetime`/`create_table` stay external-surface names. Duplicate columns across tables stay +SEPARATE defs (same name, distinct identity bytes): `id` on N tables is `defs="N"`, and the map row +merges them with `overloads`, never hiding the multiplicity. MIGRATIONS never interfere: a +class-wrapped create_table — the migration shape, string- or symbol-named — is refused by a +class/module-nest gate (a rendered schema's tables live at file level or under `Schema[].define`, never +inside a class body), so the rendered `db/schema.rb` stays the ONE source of column names and indexing +the migration beside its schema cannot double the defs (or their PageRank weight); `add_column` / +`remove_column` / `change_column` are argument data and read nowhere — a column added then dropped +never registers (pinned by the three migration fixtures). kParserVer 121 → 122 (extraction facts +changed; no record layout change — kCacheVersion and kQSnapCacheScheme stay). +Gate: `test/rubyschemacheck.sh` on `test/rubyschemafix/` — schema text byte-faithful to a real, running +Rails 8.1 app's dump (verified `db:schema:load`-able), scrubbed to `spike_*` tables; two spellings no +dump ever emits are restored by hand and marked in-file (`id: :uuid`, which that dump actually failed +on, and a literal `t.timestamps`). Stated capture floors, each pinned by a gate arm: `t.index` / +`t.references` / `t.belongs_to` / `t.polymorphic` name no column; a symbol-named table +(`create_table :users`) is a migration spelling, not a schema surface; `where("price > ?")` string +fragments stay opaque; the picker splits honestly (`rec.name` on a plain local and a rich `X.new.name` +receiver are `graph_ambiguous`, `self.name` inside the defining class pins its own def). The before- +evidence: on the pre-change binary every column was `defs=0 external=1` — the fixture's `--uses=name` +read `defs="9"` (what attrs/defs/yaml already made) with the schema columns invisible; it now reads +`defs="19" external="0"` with `count` rows binding. + ## [0.6.2] — 2026-09-21 ### Added — Microsoft's `cl.exe` builds the tree, so both Windows front ends compile and both gate diff --git a/README.md b/README.md index 054c60c6e..0d2ae7a3a 100644 --- a/README.md +++ b/README.md @@ -1948,9 +1948,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published ### In the tests
-649 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures +650 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures -`test/regression.sh` names **649 gate scripts** and is the authoritative list; +`test/regression.sh` names **650 gate scripts** and is the authoritative list; `python3 test/pargates.py . ./build/ripwire -j 6` runs the same set in parallel. On top of them sit the contracts that do not fit a unit test: two runs byte-identical, warm output identical to cold, output that pipes clean through `xmllint --noout`, a sanitizer build with `-fno-sanitize-recover=all`, and a diff --git a/docs/EVALS.md b/docs/EVALS.md index 93c2f83e0..b8e883901 100644 --- a/docs/EVALS.md +++ b/docs/EVALS.md @@ -21,7 +21,7 @@ section, and it is not an afterthought. | **Co-change / known-item evals** | `--eval`, `--eval-retrieval` (see `bench/ANSWERQUALITY.md`) | Whether the tool surfaces the other files a real historical commit touched; and known-item retrieval across four rankers. | | **Ensemble calibration harness** | `bench/ensemblecal/` | Whether `--ensemble`'s four evidence families are actually orthogonal, how often each fires, how stable each is across commits — and the preset ladder derived from that (§9). | | **Differential argv harness** | `test/argvdiffcheck.sh` | That a refactor changed *nothing observable*: two binaries, every argv vector, stdout + stderr + exit code byte-identical. | -| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 649 gate scripts plus the determinism, cache-transparency and golden contracts. | +| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 650 gate scripts plus the determinism, cache-transparency and golden contracts. | | **`--quality-delta`** | `src/quality.h` | Ten measured code-quality failure modes, reported only where a change made them worse. | ### The labeling protocol (why the held-out eval is allowed to disagree with the ranker) @@ -5837,7 +5837,7 @@ copy here would be exactly the dialect divergence that gate exists to catch. Com tags, wrap, stable-order defaults), seven individually invoked standalone gates (`g1freshcheck`, `skillscan`, `htmlexport`, `compresscheck`, `handoffcheck`, `releaseinstallcheck`, `taskroutecheck`), and a single loop -naming **649 gate scripts**, all of which exist on disk. +naming **650 gate scripts**, all of which exist on disk. `python3 test/pargates.py . ./build/ripwire -j 6` runs the same scripts in parallel so a full verification fits in one sitting. It does not modify `regression.sh`. @@ -6849,7 +6849,7 @@ Listed because the reason is more useful than the silence. shipped**. See `bench/locbench/anchorhop_calib.json`. The mention anchor's reproducible numbers are the ablations in §4. - **A single round gate-count.** Two in-tree numbers disagree (`test/pargates.py`'s docstring says - ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 649. The + ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 650. The loop is the authority; the stale docstrings are a known drift. Since 2026-09-10 the number is not written by hand anywhere: `docs/gatecount_build.py` derives it from the loop and rewrites every published site, `test/gatecountcheck.sh` fails if any of them drifts, and `test/manifestcheck.sh` diff --git a/present/deck5_ripwire_build.js b/present/deck5_ripwire_build.js index 187b2ea97..d32ba5cc7 100644 --- a/present/deck5_ripwire_build.js +++ b/present/deck5_ripwire_build.js @@ -1123,7 +1123,7 @@ function storyCards(s, { kick, head, stories, footText }){ kicker(s, "// how it stays true", AMBER); title(s, "Proven, not promised"); const cards = [ - ["649 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount + ["650 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount ["byte-identical, always", "two runs over the same tree produce the same bytes; warm equals cold. Enforced in CI, twice — Release AND a plain flavour, because NDEBUG once blinded a whole class of checks"], ["differential refactoring", "a refactor must prove it changed nothing observable: two binaries, hundreds of argv vectors, stdout + stderr + exit codes byte-identical"], ["held-out labels, authored blind", "eval labels were written by reading source before the ranker ever ran on them — so the eval is allowed to say the ranker is wrong. It has."], @@ -1147,7 +1147,7 @@ function storyCards(s, { kick, head, stories, footText }){ title(s, "Claims you can trust, because we publish what failed", { size: 32 }); card(s, MX, 1.72, 3.86, 1.72); - stat(s, "649", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount + stat(s, "650", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount MX+0.15, 1.86, 3.56, CYAN, { bsize: 42, bh: 0.66, lsize: 9.5 }); card(s, 4.68, 1.72, 3.86, 1.72, CARD2); stat(s, "8", "registered NEGATIVES — changes built, gated green, measured against a band written before the code, and reverted rather than tuned", @@ -1397,7 +1397,7 @@ function storyCards(s, { kick, head, stories, footText }){ ["183 long flags · 34 slides", "bash test/deckclaimcheck.sh"], ["every --flag named here exists", "bash test/deckcheck.sh"], ["74.7% fewer element bytes", "bash test/showcasecapturecheck.sh"], - ["649 gate scripts", "bash test/manifestcheck.sh"], // gatecount + ["650 gate scripts", "bash test/manifestcheck.sh"], // gatecount ["49 repos · 71 papers · 237 surveyed","bash test/readmedriftcheck.sh"], ["the ten moments, any row", "ripwire . --callers=SYM | wc -c"], ["the head-to-head table", "bench/headtohead/r4-2026-08-06/"], diff --git a/queries/ruby/tags.scm b/queries/ruby/tags.scm index 87e8027d9..050dcc71b 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -77,3 +77,16 @@ ; raises in a class and in a module) and defines nothing. (call method: (identifier) @name) @reference.call +; Parser version 122 (test/rubyschemacheck.sh), the Rails SCHEMA capture: 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 +; name token — Rails-generated attribute uses (`product.price`) then bind to the column and enter the +; call graph. The four id spellings and `t.timestamps` also name columns (implicit `id` / +; `id: false` / `id: :uuid` / `primary_key: "x"` / created_at+updated_at). The DSL CALLS stay +; references like this one (`string`, `datetime`, `create_table` remain external). Stated floors: +; `t.index`/`references`/`belongs_to`/`polymorphic` name no column; duplicate columns across tables +; stay SEPARATE defs (`id` on N tables -> defs="N"); `where("price > ?")` fragments stay opaque. A +; MIGRATION never contributes: a class-wrapped create_table (string- or symbol-named) is refused by +; the class/module-nest gate — the schema is the ONE source of column names; add_column/remove_column/ +; change_column are argument data and read nowhere (a column added then dropped never registers). diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 08d55f59e..e87a97a41 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -279,7 +279,25 @@ constexpr std::uint32_t kCacheVersion = 25; // 25: #150 AND #157 (trai // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 122; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 123; // bump on any grammar/.scm/extraction change + // 123 = 2026-09-25 (Rails schema-column capture, + // test/rubyschemacheck.sh, unreleased; carried 122 on the + // branch and renumbered 123 on rebase, because train-20's + // .astro work (below) took 122): a Ruby file whose + // tree holds a `create_table "x", … do |t| … end` call is a + // rendered db/schema.rb BY CONTENT, and every + // `t. "name"` / `t. :name` in the table block + // mints a SymKind::Section def (the data-kind slot, same + // as a doc heading / YAML key) at Lang::Ruby — so + // `record.price` call refs bind to the column and reach + // the call graph (a Rails corpus gains call edges + + // PageRank weight on every column, row). Also: the + // four-spelling id rule (implicit / id: false / id: :uuid / + // primary_key: "x") and `t.timestamps` -> created_at + + // updated_at. MIGRATIONS contribute nothing (class/module- + // nest gate). No record layout change: kCacheVersion stays, + // kQSnapCacheScheme stays. A 121/122 cache written before + // this round differs only in the schema defs it lacks. // 122 = 2026-09-25 (#320/#67, test/astrocheck.sh): .astro joins // kLangTable on the TypeScript grammar, parsed through ONE // included range over its `---` frontmatter; blank lines diff --git a/src/ingest_names.h b/src/ingest_names.h index 43ee9c23b..f86a8d0c2 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -1199,6 +1199,334 @@ inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_ } } + +// ─── parser version 122 (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 schema dumped inside `ActiveRecord::Schema[8.1] +// .define … do`, a migration's class-level table, or a plain top-level one all look identical to this +// walk — the container does not matter. 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" }; + +// 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, std::string_view /*src*/ ) 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 +// `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::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" ) ) + { + return; // receiver-less or receiver-qualified: 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, src ); + 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 ) +{ + // 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, src ); + 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| + } + } + // 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, 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; + } + // 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 ); + } +} + +// The schema walk: ONE pass over the whole tree (the same explicit-stack shape as captureRubyAttrDefs — +// root's width is file-controlled, never index it, O(C²)). Every create_table-with-handle call in the +// file mints its table's defs; anything that is not such a call contributes nothing. +inline void captureRubySchemaDefs( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& defs ) +{ + if( src.find( "create_table" ) == std::string_view::npos ) // file signal: the gate name must appear + { + return; + } + ChildCursor cursor( root ); + std::vector kids; + kids.reserve( 64 ); + std::vector stack; + stack.reserve( 64 ); + collectChildren( root, cursor.cur, kids ); // root's width is file-controlled — never index it (O(C²)) + for( std::size_t i = kids.size(); i > 0; --i ) + { + stack.push_back( kids[i - 1] ); + } + while( !stack.empty() ) + { + const TSNode n = stack.back(); + stack.pop_back(); + if( kindIs( ts_node_type( n ), "call" ) && fieldIdentifierText( n, NodeField::Method, src ) == "create_table" ) + { + rubySchemaTableCall( n, fileId, src, defs ); + } + collectChildren( n, cursor.cur, kids ); + for( std::size_t i = kids.size(); i > 0; --i ) + { + stack.push_back( kids[i - 1] ); + } + } +} + // F5: a Swift LOCAL binding — `let a = f()` / `var b = ...` inside a function/closure body — parses to the // same `property_declaration` node as a real stored/computed MEMBER property, so the @definition.var pattern // captures it as a spurious top-level `var` symbol AND (being the nearest enclosing symbol above the body's diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index e5275babf..8bfc93204 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2321,6 +2321,7 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t if( le.lang == Lang::Ruby ) { captureRubyAttrDefs( root, fileId, src, defs ); + captureRubySchemaDefs( root, fileId, src, defs ); } } diff --git a/src/model.h b/src/model.h index 46ddfb50a..b6909530d 100644 --- a/src/model.h +++ b/src/model.h @@ -82,7 +82,9 @@ inline const char* symTag( SymKind k ) noexcept case SymKind::Struct: return "struct"; case SymKind::Interface: return "iface"; case SymKind::Var: return "var"; - case SymKind::Section: return "sec"; // markdown heading (doc structure; isolated in the graph) + case SymKind::Section: return "sec"; // data-kind defs: doc headings, JSON/YAML keys, and Ruby db/schema.rb + // columns (parser 122) — the Ruby ones admit call edges, so a Section + // is "isolated" only when its language is not the caller's (best-effort) case SymKind::Macro: return "macro"; // #define (disclosed-degraded: replacement text, not a parsed body) case SymKind::Field: return "field"; // member variable (id=path::Owner::field; use-sites via --uses=Owner.field) case SymKind::Other: return "other"; diff --git a/src/quality.h b/src/quality.h index fd7b6bd53..2c315f500 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2102,7 +2102,10 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 25; // MUST equal ingest.cpp's kCacheVersion (gated); 25 = #157 + #150 -constexpr std::uint32_t kIngestParserVerMirror = 122; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 123; // MUST equal ingest.cpp's kParserVer (gated) + // 123 = 2026-09-25 (Rails schema-column capture, see kParserVer note; + // carried 122 on the branch, renumbered on rebase after train-20's .astro + // work took 122; kIngestCacheVersionMirror stays 25) // 122 = 2026-09-25 (#320/#67, Astro frontmatter, see kParserVer note; // kIngestCacheVersionMirror stays 25) // 121 = 2026-09-24 (#310, Ruby attr DSL, see kParserVer note) diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index 6ed85a87b..c9a87e8c2 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -98afcd66a22ccaaa58bb671b517c58484ba382eb0f6755dd7f526c2123ebfc64 +3250f2dc0c1ca4798555e6535d22ff45d76b043fe79ab02dd6dd38fdad4fd249 diff --git a/test/regression.sh b/test/regression.sh index 82cecd82f..9880306b2 100755 --- a/test/regression.sh +++ b/test/regression.sh @@ -277,7 +277,7 @@ else RIPWIRE_BIN="$BIN" bash "$ROOT/test/codexdoctorcheck.sh" 2>&1 | sed 's/^/ | /' fi # retired: cacheexclkeycheck — the per-configuration auto-cache key it pinned is a registered NEGATIVE (docs/EVALS.md, "The auto-cache key ignores --exclude", RUN 2026-09-03: a 158K-file root with >= 12 gate configurations thrashed the 2 GiB sweep); the retry design keeps ONE superset blob per root and will bring its own gate -for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactimportcheck impactpartitioncheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptranchecheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recalltablecheck recalltotalcheck receiptpostcheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyattrscheck rubyconstcheck rubymetricscheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyscopecheck rubysettercheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do +for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactimportcheck impactpartitioncheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptranchecheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recalltablecheck recalltotalcheck receiptpostcheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyattrscheck rubyconstcheck rubymetricscheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyschemacheck rubyscopecheck rubysettercheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do [ -f "$ROOT/test/$_g.sh" ] || continue if RIPWIRE_BIN="$BIN" bash "$ROOT/test/$_g.sh" >/dev/null 2>&1; then ok "absorb gate ($_g.sh)" diff --git a/test/rubyschemacheck.sh b/test/rubyschemacheck.sh new file mode 100755 index 000000000..ebdfa87c9 --- /dev/null +++ b/test/rubyschemacheck.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# rubyschemacheck.sh — parser version 122 gate: RUBY db/schema.rb COLUMNS ARE Section DEFS. The Rails +# schema capture: a Ruby file whose tree holds a `create_table "x", … do |t| … end` call is a rendered +# schema BY CONTENT (no path heuristic), and each `t. "name"` / `t. :name` receiver-call in +# the table block mints ONE SymKind::Section def at Lang::Ruby — the same data-kind slot as a doc +# heading / YAML key (model.h) — with span = the name token. Rails-generated attribute uses +# (`product.price`, pinned here as explicit-receiver model calls) then bind to the column def set and +# reach the call graph and PageRank: the "admission consequence" the PR body discloses. The DSL CALLS +# keep their reference posture exactly like the attr family (--uses=string/define/timestamps stay +# defs=0 external=1). The four id spellings and t.timestamps name columns too, per the rules below. +# +# Fixture test/rubyschemafix — schema text from a real, running Rails 8.1 app's `bin/rails +# db:schema:dump` (verified db:schema:load-able), with the original domain tables scrubbed to spike_* +# names; two spellings no dump ever emits are restored BY HAND and marked in-file: `id: :uuid` (the +# dump itself notes "Could not dump … Unknown type 'uuid'") and a literal `t.timestamps` (rendered +# schemas expand it to t.datetime pairs) — runtime-semantics and shape notes in USECASES.md beside the +# fixture. +# name the 9 `t.string "name"` columns (→ defs=19 with the 9 attr/def defs + the yaml key) +# id the multi-def floor: EVERY table carries its key — 14 (12 implicit + id: :uuid + …) +# event_id `primary_key: "event_id"` renames the key off `id` +# ref `id: false` table's only column; NO id def there +# created_at/updated_at both the rendered t.datetime pair AND the literal t.timestamps call +# quote_symbol t.string :name + t.references :owner (floor) + t.index ["name"] (floor) +# migration_string/migration_symbol/migration_columns the migration INTERFERENCE floors: a +# class-wrapped create_table (string- OR symbol-named) mints NOTHING — the schema is the only +# source of column names, indexing a migration beside its schema would double every def and its +# PageRank weight — and add_column/remove_column are argument data, never defs (a column added +# then dropped never registers) +# floor_case where("name = ?") — a string fragment stays opaque, never a def or a use +# all_four / pair_def_column / triple_* the def+attr+column(+yaml) collision matrix; the picker +# styles: `self.name` inside PairDefColumn pins its own def (locality), `rec.name` on a local and +# `SingleColumn.new.name` on a rich receiver split over the whole def set (graph_ambiguous) +# config/spike_names.yml the cross-language name key (counted, never edgeable) +# +# Usage: test/rubyschemacheck.sh | RIPWIRE_BIN=asan/ripwire test/rubyschemacheck.sh +# Exit: 0 = clean · 1 = an arm failed · 2 = usage / missing prerequisite + +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +FIX="$ROOT/test/rubyschemafix" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } +[ -d "$FIX" ] || { echo "no test/rubyschemafix — fixture missing"; exit 2; } +echo "rubyschemacheck: BIN=$BIN FIX=$FIX" + +useshead(){ "$BIN" "$FIX" --uses="$1" --no-cache 2>/dev/null | grep -oE "of=\"[^\"]*\" defs=\"[0-9]+\" external=\"[0-9]+\" count=\"[0-9]+\"" | head -1; } +undefinable(){ "$BIN" "$FIX" --uses="$1" --no-cache 2>&1 | grep -q "matched no indexed definition"; } +callershead(){ "$BIN" "$FIX" --callers="$1" --no-cache 2>/dev/null | grep -oE "of=\"[^\"]*\" defs=\"[0-9]+\" count=\"[0-9]+\"" | head -1; } + +# ── 1. CAPTURE: columns are Section defs; the id rule; the timestamps pair ────────────────────────── +[ "$( useshead name )" = 'of="name" defs="19" external="0" count="3"' ] \ + && ok 'capture: name — 9 columns + 9 attr/def defs + 1 yaml key = 19; only the 3 consumers use it (the where() fragment is opaque)' \ + || no "capture: name: $( useshead name )" +[ "$( useshead id )" = 'of="id" defs="14" external="0" count="1"' ] \ + && ok 'capture: id — the multi-def floor: 12 implicit + the id: :uuid pair + the id-pair… 14 keys on 14 tables (only id_false and the renamed table have none)' \ + || no "capture: id: $( useshead id )" +[ "$( useshead event_id )" = 'of="event_id" defs="1" external="0" count="0"' ] \ + && ok 'capture: `primary_key: "event_id"` mints the renamed key def, and NO implicit id beside it' \ + || no "capture: event_id: $( useshead event_id )" +[ "$( useshead ref )" = 'of="ref" defs="1" external="0" count="0"' ] \ + && ok 'capture: the `id: false` table'"'"'s own column mints; the table has NO id def' \ + || no "capture: ref: $( useshead ref )" +[ "$( useshead created_at )" = 'of="created_at" defs="2" external="0" count="1"' ] \ + && ok 'capture: created_at — the rendered t.datetime column AND the literal t.timestamps call each mint one; the consumer call reaches them' \ + || no "capture: created_at: $( useshead created_at )" +[ "$( useshead updated_at )" = 'of="updated_at" defs="2" external="0" count="0"' ] \ + && ok 'capture: updated_at — both spellings def; no consumer calls it (floor proof the count is per-row, not padded)' \ + || no "capture: updated_at: $( useshead updated_at )" + +# ── 2. FLOORS: the DSL calls stay references; non-column DSL names no column ─────────────────────── +undefinable owner \ + && ok 'floor: t.references :owner names no column — owner stays undefinable (references is the reference, its arg is data)' \ + || no 'floor: owner — t.references minted a def' +[ "$( useshead index )" = 'of="index" defs="0" external="1" count="1"' ] \ + && ok 'floor: t.index ["name"] — the CALL is an external reference (DSL posture), the array arg defines nothing' \ + || no "floor: index: $( useshead index )" +undefinable email \ + && ok 'migration: a CLASS-wrapped string-named create_table ("spike_migrated") mints nothing — the same columns the migration created are already minted from the rendered schema; indexing both would DOUBLE the defs+PageRank' \ + || no 'migration: email — a class-wrapped (string-named) migration table minted a def' +undefinable widget_name \ + && ok 'migration: a symbol-named migration table (create_table :spike_widgets) mints nothing — both the class-nest and the string-name gates refuse' \ + || no 'migration: widget_name — a symbol-named migration table minted a def' +undefinable bla \ + && ok 'migration: add_column "bla" is argument data — the DSL call is a reference, its args are not defs (only the schema is the source of names)' \ + || no 'migration: bla — add_column minted a def' +undefinable gonna_die \ + && ok 'migration: a column added then REMOVED never registers (remove_column is read nowhere)' \ + || no 'migration: gonna_die — a removed column minted a def' +for dsl in string define datetime timestamps create_table; do + [ "$( useshead "$dsl" | grep -oE 'defs="[0-9]+" external="[0-9]+"' )" = 'defs="0" external="1"' ] \ + && ok "posture: the DSL call \`$dsl\` itself is still no def (reference capture posture)" \ + || no "posture: $dsl gained a def — the DSL call must stay a reference: $( useshead "$dsl" )" +done + +# ── 3. BINDING: model call sites reach the column def set (the admission consequence) ─────────────── +[ "$( callershead name )" = 'of="name" defs="19" count="3"' ] \ + && ok 'bind: the 3 name call sites reach the 19-def set — columns admit call edges (the Lang::Ruby admission)' \ + || no "bind: name callers: $( callershead name )" +[ "$( callershead id )" = 'of="id" defs="14" count="1"' ] \ + && ok 'bind: `IdDefault.new.id` sits on the 14-table implicit-id def set' \ + || no "bind: id callers: $( callershead id )" +[ "$( callershead created_at )" = 'of="created_at" defs="2" count="1"' ] \ + && ok 'bind: `Timestamps.new.created_at` reaches BOTH created_at defs (rendered column + literal call)' \ + || no "bind: created_at callers: $( callershead created_at )" + +# ── 4. AMBIGUITY + LOCALITY: the resolver splits honestly, pins on evidence ───────────────────────── +"$BIN" "$FIX" --uses=name --no-cache 2>/dev/null >"$TMP/uses_name" +grep -q 'of="name" defs="19" external="0" count="3"' "$TMP/uses_name" \ + && grep -q 'graph_ambiguous="4"' "$TMP/uses_name" \ + && ok 'ambiguity: the name graph_ambiguous gauge counts exactly the 4 splitting sites (rich/new/rec receivers + id + created_at), never a silent pin' \ + || no 'ambiguity: name gauge/rows wrong' +grep -q 'p="consumer_ambiguous.rb:8"' "$TMP/uses_name" \ + && ok 'ambiguous arm: rec.name on a plain local SPLITS over the whole def set (its row is in the gauge)' \ + || no 'ambiguous arm: consumer_ambiguous.rb:8 row missing' +grep -q 'p="pair_def_column.rb:15"' "$TMP/uses_name" \ + && ok 'locality arm: self.name inside PairDefColumn pins the in-class def — its row exists and is NOT in the ambiguous gauge' \ + || no 'locality arm: pair_def_column.rb:15 row missing' +grep -q 'p="column_consumers.rb:6"' "$TMP/uses_name" \ + && ok 'binding arm: SingleColumn.new.name is a real use row (explicit model receiver)' \ + || no 'binding arm: column_consumers.rb:6 row missing' + +# ── 5. MAP KINDS: the defs carry t="sec" with the DSL row family merged by name ───────────────────── +"$BIN" "$FIX" --no-cache 2>/dev/null >"$TMP/map" +grep -q '"$TMP/m1" 2>/dev/null +"$BIN" "$FIX" --no-cache >"$TMP/m2" 2>/dev/null +if cmp -s "$TMP/m1" "$TMP/m2"; then ok "deterministic (two --no-cache runs byte-identical)"; else no "non-deterministic"; fi +"$BIN" "$FIX" --cache="$TMP/c.bin" >"$TMP/cold" 2>/dev/null +"$BIN" "$FIX" --cache="$TMP/c.bin" >"$TMP/warm" 2>/dev/null +if cmp -s "$TMP/cold" "$TMP/warm"; then ok "warm == cold (schema defs survive the cache round-trip)"; else no "warm != cold"; fi +if command -v xmllint >/dev/null 2>&1; then + if xmllint --noout "$TMP/m1" 2>/dev/null; then ok "xml well-formed"; else no "xml malformed"; fi +else + ok "xml well-formed (xmllint absent — skipped)" +fi + +[ "$fail" -eq 0 ] && echo "ALL PASS" || { echo "SOME CHECKS FAILED"; exit 1; } \ No newline at end of file diff --git a/test/rubyschemafix/USECASES.md b/test/rubyschemafix/USECASES.md new file mode 100644 index 000000000..f686490e9 --- /dev/null +++ b/test/rubyschemafix/USECASES.md @@ -0,0 +1,55 @@ +# rubyschemafix — fixture fact sheet (the Rails schema-column capture) + +What this fixture is, and which claims in it are runtime-proven vs stated floors. + +## Provenance + +`schema.rb` is the rendered schema of a real, running Rails 8.1 application: its `bin/rails +db:schema:dump` output, with the original domain tables and all identifying markers stripped and the +remaining tables renamed to `spike_*`. The dump was verified to load back into that application +(`bin/rails db:schema:load` succeeded), so the shape vocabulary here — `force: :cascade`, `id: false`, +`primary_key:`, the option-pair spellings, the string/symbol column spellings, `null: false` — comes +from a working Rails install, not from a hand-invented guess. + +Two spellings no real dump ever emits are restored BY HAND in `schema.rb` and marked in-file: + +- `spike_id_uuids` with `id: :uuid` — the source app's dump FAILED for this table ("Could not dump + table … Unknown type 'uuid' for column 'id'": the dump host lacked the uuid extension). The table's + shape is the standard Rails rendering for a uuid key; the `id: :uuid` pair is the spelling ripwire's + id rule reads (the pair KEY names `id`, the uuid TYPE is not modelled — stated floor). +- `spike_timestamps_literal` with a bare `t.timestamps` — rendered schemas expand it into the two + `t.datetime` pairs (present above it as `spike_timestamps`); the DSL CALL itself exists only in + migrations. The column names it mints (`created_at`, `updated_at`) are part of Active Record's + documented timestamps contract. + +The model files mirror the source application's runtime shapes (`self.table_name`, `attribute`, +`def name`; a quoted `where("name = ?")` in `floor_case.rb` is the runtime pattern of a raw SQL +fragment). They are indexed as text; nothing here executes at gate time. + +## What the gate pins (all measured on the binary, per the repo's honesty rule) + +| Query | Pinned answer | Why | +|---|---|---| +| `--uses=name` | defs=19 external=0 count=3 | 9 columns + 9 attr/def defs + 1 yaml key; only the 3 consumers use it — the `where("name = ?")` fragment stays opaque | +| `--uses=id` | defs=14 external=0 count=1 | the multi-def floor: EVERY table carries its key (12 implicit, `id: :uuid`, and the explicit `id: false`-less ones); only `id: false` + the renamed table have none | +| `--uses=created_at` | defs=2 | the rendered `t.datetime "created_at"` column AND the literal `t.timestamps` call | +| `--uses=event_id` | defs=1 | `primary_key: "event_id"` renames the key off `id` | +| `--callers=created_at` | count=1 | `Timestamps.new.created_at` reaches BOTH defs — the Lang::Ruby admission consequence | +| `graph_ambiguous` | 4 on `name` | the rich/new/rec receiver sites split; `self.name` in PairDefColumn pins (0) | + +## Stated floors (silence is stated, never silent) + +- `t.references :owner` / `t.belongs_to` / `t.polymorphic` / `t.index` name no column (the fixture + spells `references` + `index` in `spike_quote_symbol`; `owner` stays undefinable). +- A symbol-named table (`create_table :users`) is a migration spelling, not a rendered-schema shape — + no name-string node ⇒ no schema surface. +- Duplicate column names across tables stay SEPARATE defs (`id` on N tables → defs=N); the map row + merges them by name with `overloads`, never hiding the multiplicity. +- MIGRATIONS never interfere (the three migration fixtures, all pinned undefinable): a class-wrapped + `create_table "x"` / `create_table :x` — the migration shape — is refused by the class/module-nest + gate, so the schema is the ONE source of column names and indexing a migration beside its schema + cannot double any def; `add_column` / `remove_column` / `change_column` are argument data and read + nowhere — a column added then dropped (`gonna_die`) never registers. +- `where("price > ?")` string fragments are opaque to any static extractor. +- The DSL calls (`string`, `datetime`, `create_table`, …) stay external-surface references — no def + named `string` appears; only the NAME args gain defs (same posture as the attr family). \ No newline at end of file diff --git a/test/rubyschemafix/all_four.rb b/test/rubyschemafix/all_four.rb new file mode 100644 index 000000000..423054b34 --- /dev/null +++ b/test/rubyschemafix/all_four.rb @@ -0,0 +1,14 @@ +# all-four arm: def + attribute + column in ONE model, and the yaml source in +# config/spike_names.yml. This is the canonical collision; runtime dispatch winner +# is the def, the static picker order (def > attr > column > yaml) is pinned in C. +module Spike + class AllFour < ApplicationRecord + self.table_name = "spike_all_fours" + + attribute :name + + def name + "def" + end + end +end diff --git a/test/rubyschemafix/column_consumers.rb b/test/rubyschemafix/column_consumers.rb new file mode 100644 index 000000000..e90901908 --- /dev/null +++ b/test/rubyschemafix/column_consumers.rb @@ -0,0 +1,16 @@ +# consumer arm: EXPLICIT model receivers — `Spike::SingleColumn.new.name` reaches the +# column def set by name (the sameRoot+langCompatible admission the plan calls out); +# `Spike::IdDefault.new.id` sits on the multi-table implicit id floor (14 id defs). +module Spike + def self.single_name + SingleColumn.new.name + end + + def self.default_id + IdDefault.new.id + end + + def self.literal_stamp + Timestamps.new.created_at + end +end diff --git a/test/rubyschemafix/column_yaml.rb b/test/rubyschemafix/column_yaml.rb new file mode 100644 index 000000000..f794405e8 --- /dev/null +++ b/test/rubyschemafix/column_yaml.rb @@ -0,0 +1,6 @@ +# pair arm (column, yaml): column only; the yaml source lives in config/spike_names.yml. +module Spike + class ColumnYaml < ApplicationRecord + self.table_name = "spike_column_yamls" + end +end diff --git a/test/rubyschemafix/config/spike_names.yml b/test/rubyschemafix/config/spike_names.yml new file mode 100644 index 000000000..9e2e762a9 --- /dev/null +++ b/test/rubyschemafix/config/spike_names.yml @@ -0,0 +1,5 @@ +# spike_names.yml — the cross-language Section def for `name`. +# The all-four collision (def + attribute + column + yaml) in all_four.rb; the YAML +# key is counted, never edgeable (Ruby<->Yaml is not langCompatible). +name: + display: "Spike" \ No newline at end of file diff --git a/test/rubyschemafix/consumer_ambiguous.rb b/test/rubyschemafix/consumer_ambiguous.rb new file mode 100644 index 000000000..ffe21c986 --- /dev/null +++ b/test/rubyschemafix/consumer_ambiguous.rb @@ -0,0 +1,11 @@ +# consumer arm, spam: a call site with NO locality evidence — `rec.name` where rec is +# an ordinary local. Every in-repo def named `name` is a candidate (the 9 columns, the +# attr Vars, the method defs, the yaml key); the resolver SPLITS and says so (amb=K), +# never silently picks. The call edges land on the def set, not on nothing. +module Spike + class ConsumerAmbiguous < ApplicationRecord + def render_label( rec ) + rec.name + end + end +end \ No newline at end of file diff --git a/test/rubyschemafix/consumer_locality.rb b/test/rubyschemafix/consumer_locality.rb new file mode 100644 index 000000000..ab7aed9f7 --- /dev/null +++ b/test/rubyschemafix/consumer_locality.rb @@ -0,0 +1,12 @@ +# consumer arm, locality-resolved: the call site sits inside PairDefColumn, whose own +# `def name` is in the same file — the resolver's locality tier pins THAT def, so the +# row is amb=0, not split over the columns and yaml key. +module Spike + class PairDefColumnConsumer < ApplicationRecord + self.table_name = "spike_pair_def_columns" + + def current_name + name + end + end +end \ No newline at end of file diff --git a/test/rubyschemafix/floor_case.rb b/test/rubyschemafix/floor_case.rb new file mode 100644 index 000000000..1e8dc422f --- /dev/null +++ b/test/rubyschemafix/floor_case.rb @@ -0,0 +1,12 @@ +# floor arm: a raw SQL string fragment. The `name` inside it is opaque to any +# static extractor — this is a DISCLOSED floor (no def, no use), proven only that +# the query runs. +module Spike + class FloorCase < ApplicationRecord + self.table_name = "spike_single_columns" + + def self.find_named( value ) + where( "name = ?", value ).to_a + end + end +end diff --git a/test/rubyschemafix/id_default.rb b/test/rubyschemafix/id_default.rb new file mode 100644 index 000000000..55f9e7ad3 --- /dev/null +++ b/test/rubyschemafix/id_default.rb @@ -0,0 +1,6 @@ +# id arm: implicit default id. +module Spike + class IdDefault < ApplicationRecord + self.table_name = "spike_id_defaults" + end +end diff --git a/test/rubyschemafix/id_false.rb b/test/rubyschemafix/id_false.rb new file mode 100644 index 000000000..732241389 --- /dev/null +++ b/test/rubyschemafix/id_false.rb @@ -0,0 +1,8 @@ +# id arm: create_table carried `id: false` — there is NO id column; the model +# declares its own primary key. +module Spike + class IdFalse < ApplicationRecord + self.table_name = "spike_id_false" + self.primary_key = "ref" + end +end diff --git a/test/rubyschemafix/id_renamed.rb b/test/rubyschemafix/id_renamed.rb new file mode 100644 index 000000000..08c02c84c --- /dev/null +++ b/test/rubyschemafix/id_renamed.rb @@ -0,0 +1,6 @@ +# id arm: `t.primary_key "event_id"` renames the key off `id`. +module Spike + class IdRenamed < ApplicationRecord + self.table_name = "spike_id_renamed" + end +end diff --git a/test/rubyschemafix/id_uuid.rb b/test/rubyschemafix/id_uuid.rb new file mode 100644 index 000000000..d0d21eaaa --- /dev/null +++ b/test/rubyschemafix/id_uuid.rb @@ -0,0 +1,6 @@ +# id arm: `id: :uuid` — the key is present, still named `id`, uuid type. +module Spike + class IdUuid < ApplicationRecord + self.table_name = "spike_id_uuids" + end +end diff --git a/test/rubyschemafix/migration_columns.rb b/test/rubyschemafix/migration_columns.rb new file mode 100644 index 000000000..c70a1fdbd --- /dev/null +++ b/test/rubyschemafix/migration_columns.rb @@ -0,0 +1,13 @@ +# migration arm, column lifecycle: an OLD migration adds "bla", a LATER one removes it — +# only the rendered schema is the source of column names. `add_column` / `remove_column` +# are not read anywhere (the DSL call is a reference, its arguments are data), so a column +# added then dropped never registers, and a column that STILL exists is registered by the +# schema, not by the migration that added it. `bla` and `gonna_die` stay undefinable. +class MoreColumns < ActiveRecord::Migration[8.1] + def change + add_column :spike_single_columns, "bla", :string + change_column :spike_single_columns, :ref, :text + add_column :spike_single_columns, "gonna_die", :string + remove_column :spike_single_columns, "gonna_die" + end +end \ No newline at end of file diff --git a/test/rubyschemafix/migration_string.rb b/test/rubyschemafix/migration_string.rb new file mode 100644 index 000000000..2176b45a6 --- /dev/null +++ b/test/rubyschemafix/migration_string.rb @@ -0,0 +1,14 @@ +# migration arm, STRING-named table: a real migration wraps its create_table in a class body. +# The schema capture must NOT mint from it — the same columns the migration created are already +# minted from the rendered db/schema.rb, and indexing both would DOUBLE the defs (and the +# PageRank weight) on every corpus that carries migrations. The suppression is the +# class/module-nest gate: a rendered schema's tables live at file level or under +# ActiveRecord::Schema[].define, never inside a class. `email` here stays undefinable; the +# table's implicit id is NOT added to the id multi-def floor (the gate pins both). +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table "spike_migrated" do |t| + t.string "email" + end + end +end \ No newline at end of file diff --git a/test/rubyschemafix/migration_symbol.rb b/test/rubyschemafix/migration_symbol.rb new file mode 100644 index 000000000..5345d1766 --- /dev/null +++ b/test/rubyschemafix/migration_symbol.rb @@ -0,0 +1,11 @@ +# migration arm, SYMBOL-named table: the other common spelling. Already floored by the +# string-name gate (a symbol-named create_table is a migration spelling, not a rendered +# schema), kept as a fixture so the floor is pinned from BOTH sides — the class-nest gate +# and the name-string gate both refuse, and the widget columns stay undefinable. +class CreateWidgets < ActiveRecord::Migration[8.1] + def change + create_table :spike_widgets do |t| + t.string "widget_name" + end + end +end \ No newline at end of file diff --git a/test/rubyschemafix/pair_def_column.rb b/test/rubyschemafix/pair_def_column.rb new file mode 100644 index 000000000..bc7fd9547 --- /dev/null +++ b/test/rubyschemafix/pair_def_column.rb @@ -0,0 +1,18 @@ +# pair arm (def, column): the class def shadows the generated attribute reader. +module Spike + class PairDefColumn < ApplicationRecord + self.table_name = "spike_pair_def_columns" + + def name + "def" + end + + # locality-resolved arm: a `self.name` read at class level, in the class whose own + # `def name` sits in this same file — the receiver pins the enclosing class, so + # the resolver picks THAT def (amb=0), never splitting over the schema column or + # the yaml key. + def self.lookup + self.name + end + end +end diff --git a/test/rubyschemafix/quote_double.rb b/test/rubyschemafix/quote_double.rb new file mode 100644 index 000000000..824666252 --- /dev/null +++ b/test/rubyschemafix/quote_double.rb @@ -0,0 +1,6 @@ +# quote-spelling arm: column declared t.string "name" (double quotes). +module Spike + class QuoteDouble < ApplicationRecord + self.table_name = "spike_quote_double" + end +end diff --git a/test/rubyschemafix/quote_single.rb b/test/rubyschemafix/quote_single.rb new file mode 100644 index 000000000..2b7cf31f4 --- /dev/null +++ b/test/rubyschemafix/quote_single.rb @@ -0,0 +1,6 @@ +# quote-spelling arm: column declared `t.string 'name'` (single quotes). +module Spike + class QuoteSingle < ApplicationRecord + self.table_name = "spike_quote_single" + end +end diff --git a/test/rubyschemafix/quote_symbol.rb b/test/rubyschemafix/quote_symbol.rb new file mode 100644 index 000000000..22cdf94ff --- /dev/null +++ b/test/rubyschemafix/quote_symbol.rb @@ -0,0 +1,6 @@ +# quote-spelling arm: column declared t.string :name (symbol). +module Spike + class QuoteSymbol < ApplicationRecord + self.table_name = "spike_quote_symbol" + end +end diff --git a/test/rubyschemafix/schema.rb b/test/rubyschemafix/schema.rb new file mode 100644 index 000000000..c6ae07a30 --- /dev/null +++ b/test/rubyschemafix/schema.rb @@ -0,0 +1,83 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2000_01_01_000001) do + create_table "spike_all_fours", force: :cascade do |t| + t.string "name" + end + + create_table "spike_column_yamls", force: :cascade do |t| + t.string "name" + end + + create_table "spike_id_defaults", force: :cascade do |t| + end + + create_table "spike_id_false", id: false, force: :cascade do |t| + t.string "ref", null: false + end + + create_table "spike_id_renamed", primary_key: "event_id", force: :cascade do |t| + end + +# Could not dump table "spike_id_uuids" because of following StandardError +# Unknown type 'uuid' for column 'id' +# Restored by hand: the standard Rails rendering for a uuid table — `id: :uuid` (the value is a +# simple_symbol), the spelling the capture's id rule reads. + create_table "spike_id_uuids", id: :uuid, force: :cascade do |t| + end + + create_table "spike_pair_attr_columns", force: :cascade do |t| + t.string "name" + end + + create_table "spike_pair_def_columns", force: :cascade do |t| + t.string "name" + end + + create_table "spike_plain", force: :cascade do |t| + end + + create_table "spike_quote_double", force: :cascade do |t| + t.string "name" + end + + create_table "spike_quote_single", force: :cascade do |t| + t.string "name" + end + +create_table "spike_quote_symbol", force: :cascade do |t| + t.string :name + t.references :owner + t.index ["name"], name: "idx_spike" + end + + create_table "spike_single_columns", force: :cascade do |t| + t.string "name" + end + + create_table "spike_timestamps", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + +# Migration spelling, added by hand: the rendered dump expands `t.timestamps` into the two datetime +# columns above; the DSL CALL itself (the rule under test) names both columns literally. + create_table "spike_timestamps_literal", force: :cascade do |t| + t.timestamps + end + + create_table "spike_triples", force: :cascade do |t| + t.string "name" + end + +end diff --git a/test/rubyschemafix/single_column.rb b/test/rubyschemafix/single_column.rb new file mode 100644 index 000000000..1c89b79d4 --- /dev/null +++ b/test/rubyschemafix/single_column.rb @@ -0,0 +1,6 @@ +# single-source arm: column `name` only (no def, no attr). +module Spike + class SingleColumn < ApplicationRecord + self.table_name = "spike_single_columns" + end +end diff --git a/test/rubyschemafix/timestamps.rb b/test/rubyschemafix/timestamps.rb new file mode 100644 index 000000000..59fd081ba --- /dev/null +++ b/test/rubyschemafix/timestamps.rb @@ -0,0 +1,6 @@ +# timestamps arm: `t.timestamps` yields created_at / updated_at columns. +module Spike + class Timestamps < ApplicationRecord + self.table_name = "spike_timestamps" + end +end diff --git a/test/rubyschemafix/triple_plain.rb b/test/rubyschemafix/triple_plain.rb new file mode 100644 index 000000000..d920b8458 --- /dev/null +++ b/test/rubyschemafix/triple_plain.rb @@ -0,0 +1,14 @@ +# triple arm, spelling (a): plain `def name` alongside `attribute :name` +# and a real `name` column. Ruby lookup is by ancestry — the class-level def +# wins over the module-generated attribute reader regardless of declaration order. +module Spike + class TriplePlain < ApplicationRecord + self.table_name = "spike_triples" + + attribute :name + + def name + "def" + end + end +end diff --git a/test/rubyschemafix/triple_reversed.rb b/test/rubyschemafix/triple_reversed.rb new file mode 100644 index 000000000..c9a2881ec --- /dev/null +++ b/test/rubyschemafix/triple_reversed.rb @@ -0,0 +1,13 @@ +# triple arm, spelling (a) reversed: `def name` declared BEFORE `attribute :name`. +# Same winner as triple_plain — proof that lookup is by ancestry, not declaration order. +module Spike + class TripleReversed < ApplicationRecord + self.table_name = "spike_triples" + + def name + "def" + end + + attribute :name + end +end diff --git a/test/rubyschemafix/triple_super.rb b/test/rubyschemafix/triple_super.rb new file mode 100644 index 000000000..8f58e4612 --- /dev/null +++ b/test/rubyschemafix/triple_super.rb @@ -0,0 +1,15 @@ +# triple arm, spelling (b): `def name; super; end` + `attribute :name` + +# column. Bare `super` reaches the generated attribute reader, which reads the +# column-backed @attributes store. There is no separate "column method" — the +# column IS the storage. Expected chain: def -> super -> generated attr reader -> column. +module Spike + class TripleSuper < ApplicationRecord + self.table_name = "spike_triples" + + attribute :name + + def name + super + end + end +end From 24b34abcbeb31fea0ff2eccb8fcf54f960de898c Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Sat, 26 Sep 2026 19:33:53 +0200 Subject: [PATCH 2/4] fix(ruby): CodeRabbit review round on the schema capture (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, addressed: 1. CHANGELOG: the rebase auto-merge had left a STALE DUPLICATE of the schema entry inside [0.6.3] carrying the pre-rebase text ("D-BIND", "byte-faithful", "kParserVer 121 → 122") — an unreleased feature was claimed in a released section with the wrong parser history. Deleted; the [Unreleased] copy (the truthful 122 → 123 wording) is the only one left. 2. Receiver binding: a column call now must be ON the block parameter the create_table call bound (`t` in a rendered do |t|) — previously ANY bare identifier receiver was accepted, so `helper.string "x"` minted a phantom column. rubySchemaColumnCall takes the parameter's name from the table walk and refuses a different receiver. New negative fixture + gate arm (`helper.string "unbound_column"` stays undefinable). 3. Gate map-kind arms are now scoped INSIDE the schema.rb file block (the yaml Section key can no longer satisfy them) and assert the overloads counts (name overloads="9"). 4. Locality fixture: consumer_locality.rb claimed to demonstrate the locality pin from a DIFFERENT class than the one defining `name`. The genuine arm — `self.name` inside PairDefColumn (its own file) — was already pinned at pair_def_column.rb:15 by the gate; the standalone file added nothing (its bare `name` call contributes no row) and is deleted. The bare-name-with-no- evidence floor stays covered by the count arms. Gate test/rubyschemacheck.sh: 34 arms, ALL PASS on build/ and asan/; qschemetrip re-pinned; kParserVer stays 123 (unreleased; the receiver floor is folded into its note — a 122/123 cache written before this round differs only there). Verification: deterministic byte-identical runs + xmllint clean; the full local battery green (rubyschemacheck 34 x2 binaries, rubyattrscheck, rubysettercheck, manifest, loopconservation, gatecount, qschemetrip, versioncheck, g1fresh, cachefuzz, qackorigin, qackconcurrency, ackonly); ASan + LSan sweep clean; quality-delta vs origin/main regressions="0" gating="0". AI-assistance note: developed with AI assistance (DeepSeek V4 Flash and Qwen 3.8 Flash models) under the author's direct supervision; every change here was reviewed and verified by the author. --- CHANGELOG.md | 38 ------------------------- src/ingest_cache.h | 11 +++++-- src/ingest_names.h | 14 +++++---- test/rubyschemacheck.sh | 28 ++++++++++++++---- test/rubyschemafix/USECASES.md | 4 ++- test/rubyschemafix/consumer_locality.rb | 12 -------- test/rubyschemafix/schema.rb | 1 + 7 files changed, 43 insertions(+), 65 deletions(-) delete mode 100644 test/rubyschemafix/consumer_locality.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 25897b27d..be7037916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -811,44 +811,6 @@ block are not unwrapped either (each defines real methods at runtime); and only define — a quoted (`:"x"`/`:'x'`), string, or splat/`%i[]` argument stays an honest nothing (Ruby defines those methods; ripwire does not capture them). Every floor is stated and pinned, never silent. -### Added — a Ruby `db/schema.rb` renders its columns as Section definitions - -A Rails schema is recognized BY CONTENT, never by path: a Ruby file whose tree holds a -`create_table "x", … do |t| … end` call — the call's do-block carries one block parameter — is a rendered -schema, wherever it lives. Each `t. "name"` / `t. :name` in a table block mints ONE -`SymKind::Section` def at `Lang::Ruby` — the data-kind slot a doc heading or YAML key already occupies -(model.h's wording changes with it: a Section is no longer "isolated in the graph"; the Ruby ones admit -call edges) — span = the name token, both quote spellings and the `:symbol` form. Rails-generated -attribute uses (`product.price`) then D-BIND to the column and leave the external surface; because Call -refs are language-gated, not kind-gated, columns receive real call edges and PageRank weight on every -Rails corpus (accepted churn, disclosed here). The id rule covers the four grounded spellings: no id -column/spelling → an implicit `id` def (the anchor is the table-name string — no `id` token exists -there); `id: false` → no id def; `id: :uuid` → an `id` def at the pair key (the uuid TYPE is not -modelled); `primary_key: "x"` → a def named the string instead. `t.timestamps` 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 edges. The DSL CALLS keep their reference posture exactly like the attr family: -`string`/`datetime`/`create_table` stay external-surface names. Duplicate columns across tables stay -SEPARATE defs (same name, distinct identity bytes): `id` on N tables is `defs="N"`, and the map row -merges them with `overloads`, never hiding the multiplicity. MIGRATIONS never interfere: a -class-wrapped create_table — the migration shape, string- or symbol-named — is refused by a -class/module-nest gate (a rendered schema's tables live at file level or under `Schema[].define`, never -inside a class body), so the rendered `db/schema.rb` stays the ONE source of column names and indexing -the migration beside its schema cannot double the defs (or their PageRank weight); `add_column` / -`remove_column` / `change_column` are argument data and read nowhere — a column added then dropped -never registers (pinned by the three migration fixtures). kParserVer 121 → 122 (extraction facts -changed; no record layout change — kCacheVersion and kQSnapCacheScheme stay). -Gate: `test/rubyschemacheck.sh` on `test/rubyschemafix/` — schema text byte-faithful to a real, running -Rails 8.1 app's dump (verified `db:schema:load`-able), scrubbed to `spike_*` tables; two spellings no -dump ever emits are restored by hand and marked in-file (`id: :uuid`, which that dump actually failed -on, and a literal `t.timestamps`). Stated capture floors, each pinned by a gate arm: `t.index` / -`t.references` / `t.belongs_to` / `t.polymorphic` name no column; a symbol-named table -(`create_table :users`) is a migration spelling, not a schema surface; `where("price > ?")` string -fragments stay opaque; the picker splits honestly (`rec.name` on a plain local and a rich `X.new.name` -receiver are `graph_ambiguous`, `self.name` inside the defining class pins its own def). The before- -evidence: on the pre-change binary every column was `defs=0 external=1` — the fixture's `--uses=name` -read `defs="9"` (what attrs/defs/yaml already made) with the schema columns invisible; it now reads -`defs="19" external="0"` with `count` rows binding. - ## [0.6.2] — 2026-09-21 ### Added — Microsoft's `cl.exe` builds the tree, so both Windows front ends compile and both gate diff --git a/src/ingest_cache.h b/src/ingest_cache.h index e87a97a41..0dea6f4d8 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -295,9 +295,14 @@ constexpr std::uint32_t kParserVer = 123; // bump on any grammar/.sc // four-spelling id rule (implicit / id: false / id: :uuid / // primary_key: "x") and `t.timestamps` -> created_at + // updated_at. MIGRATIONS contribute nothing (class/module- - // nest gate). No record layout change: kCacheVersion stays, - // kQSnapCacheScheme stays. A 121/122 cache written before - // this round differs only in the schema defs it lacks. + // nest gate). Review round at 123 (unreleased): a column + // call's receiver must BE the block parameter — a + // different bare-identifier receiver (`helper.string "x"`) + // names nothing; a 122/123 cache written before that + // round differs only there. No record layout change: + // kCacheVersion stays, kQSnapCacheScheme stays. A 121/122 + // cache written before this round differs only in the + // schema defs it lacks. // 122 = 2026-09-25 (#320/#67, test/astrocheck.sh): .astro joins // kLangTable on the TypeScript grammar, parsed through ONE // included range over its `---` frontmatter; blank lines diff --git a/src/ingest_names.h b/src/ingest_names.h index f86a8d0c2..cbd694741 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -1273,17 +1273,19 @@ inline std::string_view rubyPairKeyText( TSNode pair, std::string_view src ) noe } // One `t. ` receiver-call inside a table block: a Section def per the rules above. 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::vector& defs, bool& sawId ) +// 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" ) ) + if( ts_node_is_null( recv ) || !kindIs( ts_node_type( recv ), "identifier" ) || nodeTextOf( recv, src ) != receiverName ) { - return; // receiver-less or receiver-qualified: not a column on the block parameter + 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() ) @@ -1345,6 +1347,7 @@ inline void rubySchemaColumnCall( TSNode c, std::uint32_t fileId, std::string_vi // 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 @@ -1408,6 +1411,7 @@ inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_vie { 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; @@ -1450,7 +1454,7 @@ inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_vie ChildCursor cursor( body ); forEachNamedChild( body, cursor.cur, [ & ]( TSNode c ) { - rubySchemaColumnCall( c, fileId, src, defs, sawIdColumn ); + rubySchemaColumnCall( c, fileId, src, receiverName, defs, sawIdColumn ); return true; } ); } diff --git a/test/rubyschemacheck.sh b/test/rubyschemacheck.sh index ebdfa87c9..4606b6f0b 100755 --- a/test/rubyschemacheck.sh +++ b/test/rubyschemacheck.sh @@ -20,7 +20,9 @@ # event_id `primary_key: "event_id"` renames the key off `id` # ref `id: false` table's only column; NO id def there # created_at/updated_at both the rendered t.datetime pair AND the literal t.timestamps call -# quote_symbol t.string :name + t.references :owner (floor) + t.index ["name"] (floor) +# quote_symbol t.string :name + t.references :owner (floor) + t.index ["name"] (floor) + +# helper.string "unbound_column" — a column call on a DIFFERENT bare identifier +# names nothing (the receiver must be the block parameter, `t`) # migration_string/migration_symbol/migration_columns the migration INTERFERENCE floors: a # class-wrapped create_table (string- OR symbol-named) mints NOTHING — the schema is the only # source of column names, indexing a migration beside its schema would double every def and its @@ -92,6 +94,9 @@ undefinable bla \ undefinable gonna_die \ && ok 'migration: a column added then REMOVED never registers (remove_column is read nowhere)' \ || no 'migration: gonna_die — a removed column minted a def' +undefinable unbound_column \ + && ok 'receiver: a column call on a DIFFERENT bare identifier (`helper.string "unbound_column"`) names no column — the receiver must BE the block parameter (`t`) the create_table call bound' \ + || no 'receiver: unbound_column — a non-t receiver minted a def' for dsl in string define datetime timestamps create_table; do [ "$( useshead "$dsl" | grep -oE 'defs="[0-9]+" external="[0-9]+"' )" = 'defs="0" external="1"' ] \ && ok "posture: the DSL call \`$dsl\` itself is still no def (reference capture posture)" \ @@ -127,16 +132,27 @@ grep -q 'p="column_consumers.rb:6"' "$TMP/uses_name" \ # ── 5. MAP KINDS: the defs carry t="sec" with the DSL row family merged by name ───────────────────── "$BIN" "$FIX" --no-cache 2>/dev/null >"$TMP/map" -grep -q '"$TMP/schema_block" <<'PYEOF' +import re, sys +xml = open(sys.argv[1], encoding='utf-8').read() +m = re.search(r']*>(.*?)', xml, re.S) +sys.stdout.write(m.group(1) if m else "") +PYEOF +grep -q ' ?")` string fragments are opaque to any static extractor. - The DSL calls (`string`, `datetime`, `create_table`, …) stay external-surface references — no def - named `string` appears; only the NAME args gain defs (same posture as the attr family). \ No newline at end of file + named `string` appears; only the NAME args gain defs (same posture as the attr family). +- A column call's receiver must BE the block parameter: `helper.string "unbound_column"` (a different + bare identifier) names no column — the gate accepts only `t.` (the `|t|` handle) forms. \ No newline at end of file diff --git a/test/rubyschemafix/consumer_locality.rb b/test/rubyschemafix/consumer_locality.rb deleted file mode 100644 index ab7aed9f7..000000000 --- a/test/rubyschemafix/consumer_locality.rb +++ /dev/null @@ -1,12 +0,0 @@ -# consumer arm, locality-resolved: the call site sits inside PairDefColumn, whose own -# `def name` is in the same file — the resolver's locality tier pins THAT def, so the -# row is amb=0, not split over the columns and yaml key. -module Spike - class PairDefColumnConsumer < ApplicationRecord - self.table_name = "spike_pair_def_columns" - - def current_name - name - end - end -end \ No newline at end of file diff --git a/test/rubyschemafix/schema.rb b/test/rubyschemafix/schema.rb index c6ae07a30..fbbd12348 100644 --- a/test/rubyschemafix/schema.rb +++ b/test/rubyschemafix/schema.rb @@ -59,6 +59,7 @@ t.string :name t.references :owner t.index ["name"], name: "idx_spike" + helper.string "unbound_column" end create_table "spike_single_columns", force: :cascade do |t| From 37f20f31955ec18ecf554780b9f635467cba665d Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Sun, 27 Sep 2026 01:05:55 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(ruby):=20maintainer=20round=20on=20the?= =?UTF-8?q?=20schema=20capture=20=E2=80=94=20definitions=20only=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to joyful-ii-V-I's review. The design change: schema columns are now DEFINITIONS ONLY. The "admission consequence" (columns reaching the call graph + PageRank) is retracted after the maintainer measured that untyped receivers bind unrelated calls to columns (`response.code` against a `code` column) and that a 200-table app's map was taken over by columns (196/200 rows; declines 292->1324). Columns still answer --uses/--grep/--whereis and appear in the map as defs. Required, all addressed: 1. Definitions-only: buildGraph/1d's byName fill skips SymKind::Section && Lang::Ruby (the same table feeds call binding AND common-name damping, so both consequences disappear at one seam, mirroring contextratio.h's isMeasurableKind). Gate §3 is now defs-only arms: callers=id -> defs=14 count=0, callers=created_at -> defs=2 count=0, a false-binding arm (a column-ONLY name, ref, gains no caller), and callers=name -> defs=19 count=3 (the METHOD/attr defs' edges only). model.h's sec comment is back to "no call edges"; CHANGELOG/tags.scm reworded. The ambiguity gauge drops 4 -> 2 (the id/created_at column sites left the gauge), pinned. 2. Shared walker instead of acking the duplication: captureRubyAttrDefs and captureRubySchemaDefs are now ONE pre-order walk (captureRubyDefs) dispatching both lanes per call node, with the per-file signals preserved (quality-delta duplication row must be gone — verified on the range, below). 3. Constraint floors: check_constraint / exclusion_constraint / unique_constraint added to kRubySchemaNonColumns — `t.check_constraint "price > 0", name: ...` no longer mints n="price > 0" (real dumps were measured); pinned by a fixture + undefinable arm. 4. kParserVer lands at 126 (123 is reserved for #325, 124 in flight, 125 queued for #338; the merge commit sets the final number). Mirror 126 in the same diff. 5. Version-stale "122" comments made number-free: tags.scm, the ingest_names.h lane header, model.h, rubyschemacheck.sh header, both .ripwire_quality_acks reasons. Optional, all addressed: composite/symbol primary_key now mints NO implicit id (an array-valued key has no id; the rendered columns are the block's own t. lines) pinned by a composite-key fixture + arms, with the id defs=14 arm as the regression guard; spike_quote_single now spells t.string 'name' so the single-quote path is pinned; the lane header no longer claims "the container does not matter" (Gate 0 refuses class/module-nested tables); CHANGELOG "before" corrected to defs=10 (9 attr/def defs + the yaml key, per the maintainer's measurement). The lpin locality row is moot under definitions-only — answered on the review thread. Nice-to-have, all addressed: rubyFirstNonCommentArg's unused src parameter dropped; CHANGELOG floor lines for references/belongs_to (no _id), create_join_table, and the Rails 8 queue_/cache_/cable_schema.rb content-match. Gate test/rubyschemacheck.sh: 39 arms, ALL PASS on build/ and asan/. Verification: determinism byte-identical + xmllint; full battery green (rubyattrscheck 47, rubysettercheck, manifest, loopconservation, gatecount, qschemetrip re-pinned, versioncheck, g1fresh, cachefuzz, qackorigin, qackconcurrency, ackonly); ASan + LSan sweep clean; quality-delta on the maintainer's range b343b988..HEAD: the captureRubyAttrDefs|captureRubySchemaDefs duplication row is GONE (shared walker) and gating=0 (verified after this commit). AI-assistance note: developed with AI assistance (DeepSeek V4 Flash and Qwen 3.8 Flash models) under the author's direct supervision; every change here was reviewed and verified by the author. --- .ripwire_quality_acks | 4 +- CHANGELOG.md | 61 +++++++++-------- queries/ruby/tags.scm | 22 ++++--- src/graph.h | 9 +++ src/ingest_cache.h | 55 +++++++++------- src/ingest_names.h | 91 ++++++++++++-------------- src/ingest_sidecap.h | 3 +- src/model.h | 4 +- src/quality.h | 7 +- test/qschemetrip.hash | 2 +- test/rubyschemacheck.sh | 59 +++++++++++------ test/rubyschemafix/USECASES.md | 4 +- test/rubyschemafix/column_consumers.rb | 7 +- test/rubyschemafix/migration_string.rb | 4 +- test/rubyschemafix/schema.rb | 8 ++- 15 files changed, 192 insertions(+), 148 deletions(-) diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index e41d3ae6a..9a1b497b8 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -433,7 +433,7 @@ ack complexity 5ec38fbd414fa4d4 56 cid=35883c203ba33e28 lane/tc-sliceat MCP half ack complexity 61e5df9e1e40ff70 19 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack complexity 639de1c3670999f9 69 cid=bbf7c6fe9dbbe217 wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement ack complexity 685ade09a168535e 30 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack complexity 6979fa794f2a5154 386 cid=820a0c985a597ef1 now=386 was=385 p=src/ingest_sidecap.h:1565 ruby schema-column capture (kParserVer 121->122): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack complexity 6979fa794f2a5154 386 cid=820a0c985a597ef1 now=386 was=385 p=src/ingest_sidecap.h:1565 ruby schema-column capture (the Rails schema capture round): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack complexity 6a38d70787d09e64 28 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack complexity 6e58b0a307757079 85 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack complexity 7051b3950aaf4c14 35 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. @@ -1556,7 +1556,7 @@ ack verbosity 6031be13b40a1b6f 205 cid=58496e25cbf67431 round ec5e3c3..HEAD, the ack verbosity 6302e2e27e23bcde 64 cid=7202bbc7db7cc1da C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack verbosity 639de1c3670999f9 185 cid=ffcb81de788c329a C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) ack verbosity 685ade09a168535e 96 cid=124bb29c88050ae1 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack verbosity 6979fa794f2a5154 420 cid=820a0c985a597ef1 now=420 was=416 p=src/ingest_sidecap.h:1565 ruby schema-column capture (kParserVer 121->122): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack verbosity 6979fa794f2a5154 420 cid=820a0c985a597ef1 now=420 was=416 p=src/ingest_sidecap.h:1565 ruby schema-column capture (the Rails schema capture round): captureTagsFacts +1cx/+4verb is the one-line gated call site of the schema walk (captureRubySchemaDefs, defined in ingest_names.h beside the attribute-DSL lane); the capture's own helpers are under every bar. Phantom rows on untouched files come from a stale baseline index (reported separately). | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. ack verbosity 6a38d70787d09e64 74 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack verbosity 6acbcaa854eada23 289 cid=e5c009ae9a0d8393 preloaded-corpus hoist: lexicalScores/lexicalScoresTiered gain one optional defaulted preloadedFileText param so a caller scoring many queries against one tree reads the corpus once instead of per call (--eval-retrieval was ~11.8M file opens/run, 48% of its CPU in the kernel). api-surface rows ARE the intended additive change; short-horizon-churn is this session's own edits to those two functions, not instability. Scores proven byte-identical on an identical tree. ack verbosity 7004309695fb79f1 265 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. diff --git a/CHANGELOG.md b/CHANGELOG.md index be7037916..8b1e2fb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,39 +21,48 @@ A Rails schema is recognized BY CONTENT, never by path: a Ruby file whose tree h `create_table "x", … do |t| … end` call — the call's do-block carries one block parameter — is a rendered schema, wherever it lives. Each `t. "name"` / `t. :name` in a table block mints ONE `SymKind::Section` def at `Lang::Ruby` — the data-kind slot a doc heading or YAML key already -occupies (model.h's wording changes with it: a Section is no longer "isolated in the graph"; the Ruby -ones admit call edges) — span = the name token, both quote spellings and the `:symbol` form. -Rails-generated attribute uses (`product.price`) then BIND to the column and leave the external -surface; because Call refs are language-gated, not kind-gated, columns receive real call edges and -PageRank weight on every Rails corpus (accepted churn, disclosed here). The id rule covers the four -grounded spellings: no id column/spelling → an implicit `id` def (the anchor is the table-name string -— no `id` token exists there); `id: false` → no id def; `id: :uuid` → an `id` def at the pair key -(the uuid is a TYPE of the id column, never a column of its own; the type is not modelled); -`primary_key: "x"` → a def named the string instead. `t.timestamps` 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 edges. The DSL CALLS keep their reference posture exactly like the attr family: -`string`/`datetime`/`create_table` stay external-surface names. Duplicate columns across tables stay -SEPARATE defs (same name, distinct identity bytes): `id` on N tables is `defs="N"`, and the map row -merges them with `overloads`, never hiding the multiplicity. MIGRATIONS never interfere: a +occupies (model.h: a Section's isolation is a language property; the Ruby ones are still no call +edges) — span = the name token, both quote spellings and the `:symbol` form. Rails-generated attribute +uses (`product.price`) then resolve to the column as a definition: it appears in `--uses`, `--grep`, +`--whereis` and the map. THE COLUMNS ARE DEFINITIONS ONLY, by maintainer decision: buildGraph's +byName skips Section-Ruby, so a column admits NO call edge and no PageRank weight — an untyped +`response.code` must not become a table's caller, and 200 `name` columns must not damp a model's real +`def name` (both measured at review; edges may return behind an evidence rule, e.g. a receiver proven +to be the owning model). The id rule covers the grounded spellings: no id column/spelling → an +implicit `id` def (the anchor is the table-name string — no `id` token exists there); `id: false` → +no id def; `id: :uuid` → an `id` def at the pair key (the uuid is a TYPE of the id column, never a +column of its own; the type is not modelled); `primary_key: "x"` → a def named the string instead. +An ARRAY or symbol `primary_key` (a composite key, or a hand-written migration) mints NO implicit id — +the rendered composite columns are the block's own explicit `t.` lines. `t.timestamps` 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 edges. The DSL CALLS keep their reference posture exactly like the +attr family: `string`/`datetime`/`create_table` stay external-surface names. Duplicate columns across +tables stay SEPARATE defs (same name, distinct identity bytes): `id` on N tables is `defs="N"`, and +the map row merges them with `overloads`, never hiding the multiplicity. MIGRATIONS never interfere: a class-wrapped create_table — the migration shape, string- or symbol-named — is refused by a class/module-nest gate (a rendered schema's tables live at file level or under `Schema[].define`, never inside a class body), so the rendered `db/schema.rb` stays the ONE source of column names and indexing -the migration beside its schema cannot double the defs (or their PageRank weight); `add_column` / -`remove_column` / `change_column` are argument data and read nowhere — a column added then dropped -never registers (pinned by the three migration fixtures). kParserVer 122 → 123 (extraction facts -changed, renumbered on rebase because train-20's `.astro` work took 122; no record layout change — -kCacheVersion and kQSnapCacheScheme stay). +the migration beside its schema cannot double any def; `add_column` / `remove_column` / +`change_column` are argument data and read nowhere — a column added then dropped never registers +(pinned by the three migration fixtures). Rails 8's `db/queue_schema.rb`, `db/cache_schema.rb` and +`db/cable_schema.rb` are matched by the same content gate. kParserVer lands at 126 (extraction facts +changed; carried 122 pre-rebase and renumbered for the train-20 `.astro` collision, then to 126 at +review — 123 is reserved for #325, 124 in flight, 125 queued for #338; the merge commit sets the final +number; no record layout change — kCacheVersion and kQSnapCacheScheme stay). Gate: `test/rubyschemacheck.sh` on `test/rubyschemafix/` — schema text from a real, running Rails 8.1 app's `bin/rails db:schema:dump` (verified `db:schema:load`-able), original domain tables scrubbed to `spike_*` names; two spellings no dump emits are restored by hand and marked in-file (`id: :uuid`, which that dump actually failed on, and a literal `t.timestamps`). Stated capture floors, each pinned -by a gate arm: `t.index` / `t.references` / `t.belongs_to` / `t.polymorphic` name no column; a -symbol-named table (`create_table :users`) is a migration spelling, not a schema surface; `where("price -> ?")` string fragments stay opaque; the picker splits honestly (`rec.name` on a plain local and a rich -`X.new.name` receiver are `graph_ambiguous`, `self.name` inside the defining class pins its own def). -The before-evidence: on the pre-change binary every column was `defs=0 external=1` — the fixture's -`--uses=name` read `defs="9"` (what attrs/defs/yaml already made) with the schema columns invisible; it -now reads `defs="19" external="0"` with `count` rows binding. +by a gate arm: `t.index` / `t.references` / `t.belongs_to` / `t.polymorphic` / `t.check_constraint` / +`t.exclusion_constraint` / `t.unique_constraint` name no column (a `t.references`/`t.belongs_to` mints +no `_id`, and a `create_join_table` mints nothing — raws dumps render these as explicit columns); +a column call must be ON the block parameter (`helper.string "x"` names nothing); a symbol-named table +(`create_table :users`) is a migration spelling, not a schema surface; `where("price > ?")` string +fragments stay opaque; the picker splits honestly (`rec.name` on a plain local and a rich `X.new.name` +receiver are `graph_ambiguous`, `self.name` inside the defining class pins its own def). The +before-evidence: on the pre-change binary every column was `defs=0 external=1` — the fixture's +`--uses=name` read `defs="10"` (9 attr/def defs plus the YAML key) with the schema columns invisible; it +now reads `defs="19" external="0"` and the column uses resolve to definitions only. ## [0.6.4] — 2026-09-25 diff --git a/queries/ruby/tags.scm b/queries/ruby/tags.scm index 050dcc71b..7f24a27a3 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -77,16 +77,20 @@ ; raises in a class and in a module) and defines nothing. (call method: (identifier) @name) @reference.call -; Parser version 122 (test/rubyschemacheck.sh), the Rails SCHEMA capture: a file whose tree holds a +; 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 -; name token — Rails-generated attribute uses (`product.price`) then bind to the column and enter the -; call graph. The four id spellings and `t.timestamps` also name columns (implicit `id` / -; `id: false` / `id: :uuid` / `primary_key: "x"` / created_at+updated_at). The DSL CALLS stay -; references like this one (`string`, `datetime`, `create_table` remain external). Stated floors: -; `t.index`/`references`/`belongs_to`/`polymorphic` name no column; duplicate columns across tables -; stay SEPARATE defs (`id` on N tables -> defs="N"); `where("price > ?")` fragments stay opaque. A -; MIGRATION never contributes: a class-wrapped create_table (string- or symbol-named) is refused by -; the class/module-nest gate — the schema is the ONE source of column names; add_column/remove_column/ +; 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 and no PageRank weight (untyped-receiver +; binding and map takeover were measured; edges may return behind an evidence rule). The four id +; spellings and `t.timestamps` also name columns (implicit `id` / `id: false` / `id: :uuid` / +; `primary_key: "x"` / created_at+updated_at); an ARRAY or symbol primary_key (composite key) mints no +; implicit id. The DSL CALLS stay references like this one (`string`, `datetime`, `create_table` remain +; external). Stated floors: `t.index`/`references`/`belongs_to`/`polymorphic`/`check_constraint`/ +; `exclusion_constraint`/`unique_constraint` name no column; duplicate columns across tables stay +; SEPARATE defs (`id` on N tables -> defs="N"); `where("price > ?")` fragments stay opaque. A MIGRATION +; never contributes: a class-wrapped create_table (string- or symbol-named) is refused by the +; class/module-nest gate — the schema is the ONE source of column names; add_column/remove_column/ ; change_column are argument data and read nowhere (a column added then dropped never registers). diff --git a/src/graph.h b/src/graph.h index a6fe00bb0..d6cb76128 100644 --- a/src/graph.h +++ b/src/graph.h @@ -2238,6 +2238,15 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null PROFILE_SCOPE_DESCRIBE( "buildGraph/1d: byName (name -> def ids)" ); for( const Symbol& s : ing.symbols ) { + if( s.kind == SymKind::Section && s.lang == Lang::Ruby ) + { + continue; // definitions-only (#339): a Ruby schema column answers --uses/--grep/--whereis as + // a def, but admits NO call edge — the byName table feeds both call binding and the + // common-name damping, so an untyped `response.code` must not become a column's + // caller, and 200 `name` columns must not damp a model's real `def name`. + // Mirrors contextratio.h's isMeasurableKind treatment of Sections. Edges may come + // later behind an evidence rule (receiver proven to be the owning model). + } byName[ s.name ].push_back( s.id ); } } diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 0dea6f4d8..2e0b7a20f 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -279,30 +279,37 @@ constexpr std::uint32_t kCacheVersion = 25; // 25: #150 AND #157 (trai // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 123; // bump on any grammar/.scm/extraction change - // 123 = 2026-09-25 (Rails schema-column capture, - // test/rubyschemacheck.sh, unreleased; carried 122 on the - // branch and renumbered 123 on rebase, because train-20's - // .astro work (below) took 122): a Ruby file whose - // tree holds a `create_table "x", … do |t| … end` call is a - // rendered db/schema.rb BY CONTENT, and every - // `t. "name"` / `t. :name` in the table block - // mints a SymKind::Section def (the data-kind slot, same - // as a doc heading / YAML key) at Lang::Ruby — so - // `record.price` call refs bind to the column and reach - // the call graph (a Rails corpus gains call edges + - // PageRank weight on every column, row). Also: the - // four-spelling id rule (implicit / id: false / id: :uuid / - // primary_key: "x") and `t.timestamps` -> created_at + - // updated_at. MIGRATIONS contribute nothing (class/module- - // nest gate). Review round at 123 (unreleased): a column - // call's receiver must BE the block parameter — a - // different bare-identifier receiver (`helper.string "x"`) - // names nothing; a 122/123 cache written before that - // round differs only there. No record layout change: - // kCacheVersion stays, kQSnapCacheScheme stays. A 121/122 - // cache written before this round differs only in the - // schema defs it lacks. +constexpr std::uint32_t kParserVer = 126; // bump on any grammar/.scm/extraction change + // 126 = the Rails schema-column capture + // (test/rubyschemacheck.sh, unreleased; reviewed round at + // 123/126 — carried 122 pre-rebase, renumbered once for the + // train-20 .astro collision and again to 126 at the + // maintainer's request: 123 is reserved for #325, 124 in + // flight, 125 queued for #338; the MERGE COMMIT sets the + // final number): a Ruby file whose tree holds a + // `create_table "x", … do |t| … end` call is a rendered + // db/schema.rb BY CONTENT, and every `t. "name"` / + // `t. :name` in the table block mints a + // SymKind::Section def (the data-kind slot, same as a doc + // heading / YAML key) at Lang::Ruby — DEFINITIONS ONLY: + // the defs answer --uses/--grep/--whereis, but + // buildGraph's byName skips Section-Ruby, so columns + // admit NO call edges and no PageRank weight (the + // "admission consequence" planned earlier was retracted + // after the maintainer measured unrelated-call binding + // and map takeover; edges may return behind an evidence + // rule). Also: the four-spelling id rule (implicit / + // id: false / id: :uuid / primary_key: "x"; an array or + // symbol primary_key suppresses the implicit id — a + // composite key has no id), `t.timestamps` -> created_at + // + updated_at, constraint DSL floors (check/ + // exclusion/unique_constraint name no column), the + // review-round receiver floor (a column call must be on + // the block parameter), and MIGRATIONS contribute + // nothing (class/module-nest gate). No record layout + // change: kCacheVersion stays, kQSnapCacheScheme stays. + // A 122/123/124/125 cache differs only in the schema + // defs and Section-Ruby edges it lacks. // 122 = 2026-09-25 (#320/#67, test/astrocheck.sh): .astro joins // kLangTable on the TypeScript grammar, parsed through ONE // included range over its `---` frontmatter; blank lines diff --git a/src/ingest_names.h b/src/ingest_names.h index cbd694741..dad39bf6a 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -1163,9 +1163,18 @@ inline void captureRubyAttrDefsCall( TSNode n, std::uint32_t fileId, std::string } ); } -inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& defs ) -{ - if( src.find( "attr" ) == std::string_view::npos ) // file signal: every family name contains "attr" +// ONE pre-order walk of the whole tree dispatching BOTH Ruby def-side lanes — the class-level +// attribute DSL and the rendered-schema column capture — from the same explicit stack (the duplication +// 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). +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 ) +{ + const bool wantAttr = src.find( "attr" ) != std::string_view::npos; // every family name contains "attr" + const bool wantSchema = src.find( "create_table" ) != std::string_view::npos; // the schema gate name must appear + if( !wantAttr && !wantSchema ) { return; } @@ -1185,10 +1194,17 @@ inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_ stack.pop_back(); if( kindIs( ts_node_type( n ), "call" ) ) { - const std::string_view fam = rubyNamedDirective( n, src, kRubyAttrFamilyNames ); - if( !fam.empty() && rubyAttrAtClassBodyLevel( n, src ) ) + if( wantAttr ) + { + const std::string_view fam = rubyNamedDirective( n, src, kRubyAttrFamilyNames ); + if( !fam.empty() && rubyAttrAtClassBodyLevel( n, src ) ) + { + captureRubyAttrDefsCall( n, fileId, src, fam, defs ); + } + } + if( wantSchema && fieldIdentifierText( n, NodeField::Method, src ) == "create_table" ) { - captureRubyAttrDefsCall( n, fileId, src, fam, defs ); + rubySchemaTableCall( n, fileId, src, defs ); } } collectChildren( n, cursor.cur, kids ); @@ -1200,15 +1216,18 @@ inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_ } -// ─── parser version 122 (test/rubyschemacheck.sh): RUBY db/schema.rb COLUMNS ARE Section DEFS ─── +// ─── 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 schema dumped inside `ActiveRecord::Schema[8.1] -// .define … do`, a migration's class-level table, or a plain top-level one all look identical to this -// walk — the container does not matter. 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. +// 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; @@ -1236,11 +1255,11 @@ inline void captureRubyAttrDefs( TSNode root, std::uint32_t fileId, std::string_ // 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" }; +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, std::string_view /*src*/ ) noexcept +inline TSNode rubyFirstNonCommentArg( TSNode args ) noexcept { ChildCursor cursor( args ); TSNode first{}; @@ -1313,7 +1332,7 @@ inline void rubySchemaColumnCall( TSNode c, std::uint32_t fileId, std::string_vi { return; } - const TSNode nameNode = rubyFirstNonCommentArg( args, src ); + const TSNode nameNode = rubyFirstNonCommentArg( args ); if( ts_node_is_null( nameNode ) ) { return; @@ -1374,7 +1393,7 @@ inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_vie } // 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, src ); + const TSNode tableName = rubyFirstNonCommentArg( args ); if( ts_node_is_null( tableName ) || !kindIs( ts_node_type( tableName ), "string" ) ) { return; @@ -1486,6 +1505,13 @@ inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_vie 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 ); @@ -1496,40 +1522,7 @@ inline void rubySchemaTableCall( TSNode n, std::uint32_t fileId, std::string_vie } } -// The schema walk: ONE pass over the whole tree (the same explicit-stack shape as captureRubyAttrDefs — -// root's width is file-controlled, never index it, O(C²)). Every create_table-with-handle call in the -// file mints its table's defs; anything that is not such a call contributes nothing. -inline void captureRubySchemaDefs( TSNode root, std::uint32_t fileId, std::string_view src, std::vector& defs ) -{ - if( src.find( "create_table" ) == std::string_view::npos ) // file signal: the gate name must appear - { - return; - } - ChildCursor cursor( root ); - std::vector kids; - kids.reserve( 64 ); - std::vector stack; - stack.reserve( 64 ); - collectChildren( root, cursor.cur, kids ); // root's width is file-controlled — never index it (O(C²)) - for( std::size_t i = kids.size(); i > 0; --i ) - { - stack.push_back( kids[i - 1] ); - } - while( !stack.empty() ) - { - const TSNode n = stack.back(); - stack.pop_back(); - if( kindIs( ts_node_type( n ), "call" ) && fieldIdentifierText( n, NodeField::Method, src ) == "create_table" ) - { - rubySchemaTableCall( n, fileId, src, defs ); - } - collectChildren( n, cursor.cur, kids ); - for( std::size_t i = kids.size(); i > 0; --i ) - { - stack.push_back( kids[i - 1] ); - } - } -} + // F5: a Swift LOCAL binding — `let a = f()` / `var b = ...` inside a function/closure body — parses to the // same `property_declaration` node as a real stored/computed MEMBER property, so the @definition.var pattern diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 8bfc93204..d6cd97722 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2320,8 +2320,7 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t // same defs window, so the lex build and cache round-trip treat these defs like captured ones. if( le.lang == Lang::Ruby ) { - captureRubyAttrDefs( root, fileId, src, defs ); - captureRubySchemaDefs( root, fileId, src, defs ); + captureRubyDefs( root, fileId, src, defs ); } } diff --git a/src/model.h b/src/model.h index b6909530d..7ae8c1a46 100644 --- a/src/model.h +++ b/src/model.h @@ -83,8 +83,8 @@ inline const char* symTag( SymKind k ) noexcept case SymKind::Interface: return "iface"; case SymKind::Var: return "var"; case SymKind::Section: return "sec"; // data-kind defs: doc headings, JSON/YAML keys, and Ruby db/schema.rb - // columns (parser 122) — the Ruby ones admit call edges, so a Section - // is "isolated" only when its language is not the caller's (best-effort) + // columns — the Ruby ones are definitions only, NO call edges + // (graph.h buildGraph's byName skips them; see isMeasurableKind) case SymKind::Macro: return "macro"; // #define (disclosed-degraded: replacement text, not a parsed body) case SymKind::Field: return "field"; // member variable (id=path::Owner::field; use-sites via --uses=Owner.field) case SymKind::Other: return "other"; diff --git a/src/quality.h b/src/quality.h index 2c315f500..49c377971 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2102,10 +2102,9 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 25; // MUST equal ingest.cpp's kCacheVersion (gated); 25 = #157 + #150 -constexpr std::uint32_t kIngestParserVerMirror = 123; // MUST equal ingest.cpp's kParserVer (gated) - // 123 = 2026-09-25 (Rails schema-column capture, see kParserVer note; - // carried 122 on the branch, renumbered on rebase after train-20's .astro - // work took 122; kIngestCacheVersionMirror stays 25) +constexpr std::uint32_t kIngestParserVerMirror = 126; // MUST equal ingest.cpp's kParserVer (gated) + // 126 = the Rails schema-column capture (see kParserVer note; + // definitions only — no Section-Ruby call edges; kIngestCacheVersionMirror stays 25) // 122 = 2026-09-25 (#320/#67, Astro frontmatter, see kParserVer note; // kIngestCacheVersionMirror stays 25) // 121 = 2026-09-24 (#310, Ruby attr DSL, see kParserVer note) diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index c9a87e8c2..3eb6cd3cd 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -3250f2dc0c1ca4798555e6535d22ff45d76b043fe79ab02dd6dd38fdad4fd249 +4d698fff67c26f99118e2f03e02d558a93ceee11c4462cb9fe1981652bd21a1d diff --git a/test/rubyschemacheck.sh b/test/rubyschemacheck.sh index 4606b6f0b..4967ef9b7 100755 --- a/test/rubyschemacheck.sh +++ b/test/rubyschemacheck.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# rubyschemacheck.sh — parser version 122 gate: RUBY db/schema.rb COLUMNS ARE Section DEFS. The Rails -# schema capture: a Ruby file whose tree holds a `create_table "x", … do |t| … end` call is a rendered -# schema BY CONTENT (no path heuristic), and each `t. "name"` / `t. :name` receiver-call in +# rubyschemacheck.sh — the Rails schema capture gate: RUBY db/schema.rb COLUMNS ARE Section DEFS. A +# Ruby file whose tree holds a `create_table "x", … do |t| … end` call is a rendered schema BY CONTENT +# (no path heuristic), and each `t. "name"` / `t. :name` receiver-call in # the table block mints ONE SymKind::Section def at Lang::Ruby — the same data-kind slot as a doc # heading / YAML key (model.h) — with span = the name token. Rails-generated attribute uses -# (`product.price`, pinned here as explicit-receiver model calls) then bind to the column def set and -# reach the call graph and PageRank: the "admission consequence" the PR body discloses. The DSL CALLS -# keep their reference posture exactly like the attr family (--uses=string/define/timestamps stay -# defs=0 external=1). The four id spellings and t.timestamps name columns too, per the rules below. +# (`product.price`, pinned here as explicit-receiver model calls) resolve to the column as a def — +# DEFINITIONS ONLY by maintainer decision: the defs answer --uses/--grep/--whereis but admit NO call +# edges and no PageRank weight (graph.h buildGraph's byName skips Section-Ruby; §3 pins that). The DSL +# CALLS keep their reference posture exactly like the attr family (--uses=string/define/timestamps +# stay defs=0 external=1). The four id spellings and t.timestamps name columns too, per the rules +# below; a composite/symbol primary_key mints no implicit id. # # Fixture test/rubyschemafix — schema text from a real, running Rails 8.1 app's `bin/rails # db:schema:dump` (verified db:schema:load-able), with the original domain tables scrubbed to spike_* @@ -26,7 +28,7 @@ # migration_string/migration_symbol/migration_columns the migration INTERFERENCE floors: a # class-wrapped create_table (string- OR symbol-named) mints NOTHING — the schema is the only # source of column names, indexing a migration beside its schema would double every def and its -# PageRank weight — and add_column/remove_column are argument data, never defs (a column added +# — and add_column/remove_column are argument data, never defs (a column added # then dropped never registers) # floor_case where("name = ?") — a string fragment stays opaque, never a def or a use # all_four / pair_def_column / triple_* the def+attr+column(+yaml) collision matrix; the picker @@ -74,6 +76,15 @@ callershead(){ "$BIN" "$FIX" --callers="$1" --no-cache 2>/dev/null | grep -oE "o [ "$( useshead updated_at )" = 'of="updated_at" defs="2" external="0" count="0"' ] \ && ok 'capture: updated_at — both spellings def; no consumer calls it (floor proof the count is per-row, not padded)' \ || no "capture: updated_at: $( useshead updated_at )" +[ "$( useshead origin )" = 'of="origin" defs="1" external="0" count="0"' ] \ + && ok 'capture: a COMPOSITE primary_key (["origin","destination"]) mints the rendered columns — and NO implicit id (the id defs=14 arm above is the regression guard)' \ + || no "capture: origin: $( useshead origin )" +[ "$( useshead destination )" = 'of="destination" defs="1" external="0" count="0"' ] \ + && ok 'capture: the composite key'"'"'s second column mints too' \ + || no "capture: destination: $( useshead destination )" +undefinable 'price > 0' \ + && ok "floor: t.check_constraint \"price > 0\", name: … — a constraint EXPRESSION names no column (would otherwise mint n=\"price > 0\")" \ + || no 'floor: price > 0 — a check_constraint expression minted a def' # ── 2. FLOORS: the DSL calls stay references; non-column DSL names no column ─────────────────────── undefinable owner \ @@ -103,22 +114,25 @@ for dsl in string define datetime timestamps create_table; do || no "posture: $dsl gained a def — the DSL call must stay a reference: $( useshead "$dsl" )" done -# ── 3. BINDING: model call sites reach the column def set (the admission consequence) ─────────────── +# ── 3. DEFINITIONS ONLY: columns admit NO call edges (maintainer decision, graph.h buildGraph byName) ── +[ "$( callershead id )" = 'of="id" defs="14" count="0"' ] \ + && ok 'defs-only: `IdDefault.new.id` matches the 14 defs but has NO caller row — columns take no edges' \ + || no "defs-only: id callers: $( callershead id )" +[ "$( callershead created_at )" = 'of="created_at" defs="2" count="0"' ] \ + && ok 'defs-only: `Timestamps.new.created_at` matches both defs but carries no call edge' \ + || no "defs-only: created_at callers: $( callershead created_at )" +[ "$( callershead ref )" = 'of="ref" defs="1" count="0"' ] \ + && ok "defs-only false-binding: a COLUMN-ONLY name (ref) gains no caller from an unrelated 'obj.ref' style site — pre-change it did bind (untyped receivers)" \ + || no "defs-only: ref callers: $( callershead ref )" [ "$( callershead name )" = 'of="name" defs="19" count="3"' ] \ - && ok 'bind: the 3 name call sites reach the 19-def set — columns admit call edges (the Lang::Ruby admission)' \ - || no "bind: name callers: $( callershead name )" -[ "$( callershead id )" = 'of="id" defs="14" count="1"' ] \ - && ok 'bind: `IdDefault.new.id` sits on the 14-table implicit-id def set' \ - || no "bind: id callers: $( callershead id )" -[ "$( callershead created_at )" = 'of="created_at" defs="2" count="1"' ] \ - && ok 'bind: `Timestamps.new.created_at` reaches BOTH created_at defs (rendered column + literal call)' \ - || no "bind: created_at callers: $( callershead created_at )" + && ok 'defs-only: the 3 name caller rows come from the METHOD/attr defs only — the 9 columns contribute zero edges to the 19-def set' \ + || no "defs-only: name callers: $( callershead name )" # ── 4. AMBIGUITY + LOCALITY: the resolver splits honestly, pins on evidence ───────────────────────── "$BIN" "$FIX" --uses=name --no-cache 2>/dev/null >"$TMP/uses_name" grep -q 'of="name" defs="19" external="0" count="3"' "$TMP/uses_name" \ - && grep -q 'graph_ambiguous="4"' "$TMP/uses_name" \ - && ok 'ambiguity: the name graph_ambiguous gauge counts exactly the 4 splitting sites (rich/new/rec receivers + id + created_at), never a silent pin' \ + && grep -q 'graph_ambiguous="2"' "$TMP/uses_name" \ + && ok 'ambiguity: the name gauge counts ONLY the 2 sites that still split over real (method/attr) defs — the id/created_at column sites left the gauge once columns stopped taking edges' \ || no 'ambiguity: name gauge/rows wrong' grep -q 'p="consumer_ambiguous.rb:8"' "$TMP/uses_name" \ && ok 'ambiguous arm: rec.name on a plain local SPLITS over the whole def set (its row is in the gauge)' \ @@ -126,9 +140,9 @@ grep -q 'p="consumer_ambiguous.rb:8"' "$TMP/uses_name" \ grep -q 'p="pair_def_column.rb:15"' "$TMP/uses_name" \ && ok 'locality arm: self.name inside PairDefColumn pins the in-class def — its row exists and is NOT in the ambiguous gauge' \ || no 'locality arm: pair_def_column.rb:15 row missing' -grep -q 'p="column_consumers.rb:6"' "$TMP/uses_name" \ +grep -q 'p="column_consumers.rb:7"' "$TMP/uses_name" \ && ok 'binding arm: SingleColumn.new.name is a real use row (explicit model receiver)' \ - || no 'binding arm: column_consumers.rb:6 row missing' + || no 'binding arm: column_consumers.rb:7 row missing' # ── 5. MAP KINDS: the defs carry t="sec" with the DSL row family merged by name ───────────────────── "$BIN" "$FIX" --no-cache 2>/dev/null >"$TMP/map" @@ -152,6 +166,9 @@ grep -q ' 0", name: "check_positive" + end + + create_table "spike_composite_keys", primary_key: ["origin", "destination"], force: :cascade do |t| + t.string "origin" + t.string "destination" end create_table "spike_single_columns", force: :cascade do |t| From 221afc08562d5c9b0c00d0a499f71bf6b6a9f751 Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Sun, 27 Sep 2026 11:39:08 +0200 Subject: [PATCH 4/4] =?UTF-8?q?ci:=20empty=20=E2=80=94=20re-probe=20the=20?= =?UTF-8?q?macOS=20Release=20trace-guard=20(B1)=20outcome=20on=20an=20unch?= =?UTF-8?q?anged=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit