Issue #39 ports the sixteen scored propagation templates to Ruby.
docs/applicability-matrix.md fixes the
classifications before any fixture is written, and this contract records the
adaptation each cell actually uses. The template_id values are stable
benchmark identities: a template is never renamed, split, merged, or silently
dropped because Ruby spells its Java construct differently.
The classic Ruby core denominator was 16 templates / 32 core assertions. With the challenge row rolled out it is 29 templates / 58 core assertions — the sixteen classic templates plus the thirteen preregistered challenge templates, all of which the challenge-tier preregistration classifies as directly applicable to Ruby. The 16-template v0.3.0 core and this expanded core are different populations and are never compared number-to-number.
Ruby is a tranche-3, analyzer-coverage-gated language. The gate and its resolution are recorded in the analyzer-coverage gate below, before any result is quoted.
| Stratum | Template ID | Matrix cell | Ruby construct |
|---|---|---|---|
| Local | dfb-template-direct-propagation |
direct | The source result is passed straight to the sink. The negative calls the source separately and sinks a literal. |
| Local | dfb-template-local-overwrite-kill |
direct | A local is either reassigned to itself or overwritten with a literal before the sink. Ruby locals are mutable by default, so no var-style adaptation is needed. |
| Local | dfb-template-local-multi-step-chain |
direct | The source is copied through three local names before the sink. |
| Local | dfb-template-arithmetic-expression-propagation |
direct | The source participates in (value * 3) + 7. The negative computes the same expression and sinks the literal. |
| Calls/returns | dfb-template-call-context-separation |
direct | One relay method is called with a tainted and a clean argument; only the selected call may reach the sink. |
| Calls/returns | dfb-template-argument-position-separation |
direct | choose_first(first, second) returns its first parameter; moving the source between positions changes the expected flow. |
| Calls/returns | dfb-template-return-relay-one-hop |
direct | The tainted value crosses one method return. |
| Calls/returns | dfb-template-return-relay-two-hop |
direct | The tainted value crosses two nested method returns. |
| Heap/separation | dfb-template-object-separation |
direct | Two Holder instances share one attr_accessor name; only the tainted instance may reach the sink. |
| Heap/separation | dfb-template-same-object-field-separation |
direct | One Holder exposes separate tainted and clean accessors; reading one must not inherit the other's flow. |
| Heap/separation | dfb-template-alias-propagation-separation |
direct | aliased = original binds a second name to the same object; a separately constructed Holder stays distinct. |
| Heap/separation | dfb-template-array-element-separation |
direct | A two-element Array with constant indices 0 and 1. Ruby arrays are integer-indexed sequences, so this is direct, not the key-based adaptation PHP needs. |
| Control transfer | dfb-template-infeasible-branch |
direct | if false guards the tainted assignment; the positive uses if true. |
| Control transfer | dfb-template-branch-join |
direct | The source survives a one-armed if; overwriting it in both arms produces the negative. |
| Control transfer | dfb-template-loop-carried-kill |
direct | A counted while loop either carries the source through its own update or overwrites it each iteration. |
| Control transfer | dfb-template-exception-catch |
adapted (surface form only) | raise/rescue with a StandardError subclass carrying an attr_accessor attribute. |
The matrix classifies this cell as language-adapted, and the adaptation is a
surface-form rename only. Ruby's raise/rescue with a StandardError
subclass is the same construct as Java's throw/catch with a checked
exception class: a typed heap object carrying a value crosses a non-local
control transfer and is read out of the handler-bound variable. Nothing about
the flow question changes — only the keywords and the root class do. The
fixture is:
class FlowError < StandardError
attr_accessor :value
endwith flow.value = dfb_source (positive) or flow.value = "clean" (negative)
before raise flow, and dfb_sink(caught.value) inside
rescue FlowError => caught. StandardError — not Exception — is the
idiomatic Ruby base for an application error and the class a bare rescue
would catch; the fixture still names the class explicitly so the handler is an
exact match rather than a catch-all.
This is a surface adaptation, not a semantic substitution like Go's
panic/recover. If a pinned analyzer cannot prove the transfer, that is
capability evidence — inconclusive — and never a redesign of the case into a
construct the tool happens to handle.
All four heap templates use ordinary Ruby objects with attr_accessor
declarations and post-construction assignment:
class Holder
attr_accessor :value
def initialize
@value = "clean"
end
endattr_accessor is the idiomatic Ruby spelling of a public field: it defines a
value reader and a value= writer over the @value instance variable, so
holder.value = x is a method call and holder.value is another. That is a
real property of Ruby's object model, and it is what the heap fixtures ask the
analyzers about — a tool that models only direct instance-variable access and
not the generated accessor pair will find nothing, and that outcome is
capability evidence rather than a benchmark defect. No global registry, no
OpenStruct, no method_missing, and no metaprogramming appears in any
fixture.
Alias propagation relies on Ruby assignment binding a second name to the same
object; the alias variable is named aliased because alias is a Ruby
keyword. Object identity, not equal contents, is the distinction under test.
dfb-template-array-element-separation uses a fixed two-element array literal
with constant indices, which the matrix classifies as direct. Ruby's Array is
an integer-indexed sequence, so index 0 and index 1 are the direct
equivalent of the Java array elements. No slicing, growth, Hash, or
enumerable method appears; those would add unrelated semantics.
The loop pair uses a counted while over an explicit integer counter rather
than 3.times do |i| ... end. A block would introduce a closure and change the
question from "does the loop carry or kill the value" to "does the analyzer
model block capture", which is a different template. while is a direct
spelling of the Java for loop, so the cell stays direct.
Ruby locals are mutable by default, so dfb-template-local-overwrite-kill and
dfb-template-loop-carried-kill need none of the var/let mut adaptation
Kotlin, Scala, and Rust require.
Ruby's receiverless zero-argument calls are spelled without parentheses
(value = dfb_source), which is both idiomatic and the spelling the frozen
direct-flow breadth fixture already uses. Every sink call takes one
positional argument and is spelled with parentheses (dfb_sink(value)). That
split matters to anchor reconciliation and is recorded in
anchor evidence below.
The challenge-tier preregistration fixed thirteen further templates — semantic intent, positive and negative shapes, negative mechanisms, capability kinds, feature tags, and per-language applicability — before any Ruby challenge fixture existed. The preregistration classifies all thirteen as directly applicable to Ruby, so the expanded Ruby core is 29 templates and 58 assertions.
Every challenge case carries score_tier: "core",
model_profile: "benchmark-controlled", and fixture provenance revision
m3-challenge-ruby. Every fixture is standard library only — public_send,
instance_variable_set/instance_variable_get, Hash, Array, lambdas,
method(:name), Class.new, and plain classes with attr_accessor. Nothing
is required, and no gem appears anywhere.
| Stratum | Template ID | Ruby shape and distinction |
|---|---|---|
| A — dispatch/reflection | dfb-template-chal-reflective-invocation |
target.public_send(name, dfb_source) with name bound to a local string constant. The negative binds name to the sibling drop, which sinks a clean constant. |
| A | dfb-template-chal-computed-property |
holder.instance_variable_set(key, dfb_source) then holder.instance_variable_get(key) with the same local key variable. The negative writes under "@alpha" and reads under the distinct constant "@beta". |
| A | dfb-template-chal-dispatch-table |
A Hash of two lambdas; table[key].call(dfb_source). The negative selects the argument-dropping entry. |
| B — higher-order | dfb-template-chal-closure-capture |
A lambda closes over make_reporter's tainted local and is returned and called after that scope has exited. The negative captures a clean local instead. |
| B | dfb-template-chal-function-field |
method(:leak) is stored in holder.fn and a separate dispatch(holder, value) reads the accessor and calls it. The negative dispatches through a second holder whose field holds method(:drop). |
| B | dfb-template-chal-callback-registration |
A Registry instance holds an array of callables; fire(value) iterates with each and invokes them, unaware of what was registered. The negative registers a callback that ignores its parameter. |
| B | dfb-template-chal-anonymous-implementation |
Two Class.new do ... end anonymous classes each define handle; one is instantiated inline and invoked through a local reference. The negative invokes the argument-dropping one. Neither captures anything. |
| C — containers/paths | dfb-template-chal-map-iteration |
The tainted value is stored under one Hash key and retrieved by `records.each do |
| C | dfb-template-chal-nested-access-path |
A depth-3 accessor chain, outer.middle.inner.value, written and read at the identical path. The negative reads the sibling outer.middle.inner.other. |
| C | dfb-template-chal-element-object |
A two-element Array of instances; the tainted value sits in items[0].value. The negative reads items[1].value. |
| D — context/depth | dfb-template-chal-deep-relay-chain |
Six top-level relays, relay1 … relay6, no branching and no state. The negative feeds the identical chain the clean value. |
| D | dfb-template-chal-recursive-carry |
carry(value, depth) recurses to a constant depth of 5 and returns the carried value at the base case. The negative's base case returns a clean constant instead. |
| D | dfb-template-chal-context-pair-depth2 |
outer_tainted and outer_clean both reach the same wrapper and the same helper; run sinks one of the two results. The negative sinks the clean context's result. |
Ruby is one of the languages the preregistration classifies direct in every
cell, so there is no semantic substitution to record. What is recorded here,
before the fixtures were authored, is which of Ruby's several spellings of each
construct the fixtures use, because Ruby usually offers more than one and the
choice changes what the analyzers are asked:
public_send, notsend. Both resolve a method from a run-time string;public_sendrefuses private methods, which is the closer analogue ofMethod#invokeon a public method and ofgetattron a public attribute. The preregistration's Ruby paragraph namespublic_send, and the fixture uses it.instance_variable_set/instance_variable_get, not aHash. The preregistration's Ruby cell names the instance-variable pair, which preserves the member-access flavor of the template that C++ and Rust have to give up. The keys are"@alpha"and"@beta"because that is the instance-variable spelling those methods take.method(:name)for the code-valued field and the registry. Templates 5 and 6 need a first-class callable that is not an anonymous function, so that they stay distinct from templates 4 and 7.method(:leak)returns a stdlibMethodobject that answerscall, which is Ruby's ordinary way to pass a named method as a value.Class.new do ... endfor template 7. Ruby has genuinely anonymous classes, so the cell needs no adaptation. Both anonymous classes in the fixture are capture-less, which is what keeps template 7 distinct from template 4's capture question.Hash#eachfor template 8. The value is never retrieved with[]; the block parameters are the only path from the container to the sink.dfb-template-chal-context-pair-depth2sinks in the caller, per Amendment A1. Both outer contexts stay live in one fixture, the sharedhelperreturns its parameter, the sharedwrapperreturnshelper's result, andrunsinks one of the two outer results. This is the k = 2 extension of the classicdfb-template-call-context-separationfixture, which spells the k = 1 case the same way.
The classic Ruby rules above tell fixture authors to keep blocks,
metaprogramming, and method_missing out of the sixteen-template core, where
they would substitute for a template that asks a simpler question. That
instruction scopes the classic core only. The challenge tier exists to ask
about dispatch, capture, containers, and depth, and its fixtures use the
constructs deliberately — still with no method_missing, no OpenStruct, no
eval, and no global registry.
Every challenge fixture is Syntax OK under the same system Ruby the classic
fixtures use (/usr/bin/ruby -c, ruby 2.6.10p210). No adapter compiles or
executes them.
Fixtures are single .rb files with no require, no gem, no module nesting,
and no external dependency. They use the benchmark-controlled dfb_source and
dfb_sink endpoint names in Ruby's snake_case — the same spelling the
cross-language contract already uses — with DFB-SOURCE: and DFB-SINK:
marker comments on the endpoint declaration lines. Every fixture parses under
the system Ruby (/usr/bin/ruby, ruby 2.6.10p210, ruby -c). No adapter
compiles or executes them.
The Ruby core population is the 58 taint/core cases under
cases/taint/ruby/. Thirty of them were authored for the classic kernel with
fixture_provenance.revision m2-ruby-kernel, and twenty-six for the
challenge tier with revision m3-challenge-ruby. The direct-propagation pair
(dfb-taint-ruby-direct-positive and dfb-taint-ruby-direct-negative)
predates it: it is the Ruby member of the 13-language direct-flow breadth
slice, and it is frozen byte-for-byte in the published manifest
(reports/freeze.json). Its case.json therefore keeps
fixture_provenance.revision m1a-direct-core, keeps the breadth policy
reference adapters/bifrost/policies/core-direct.rqlp, and carries no CodeQL
model reference.
Editing those two files would invalidate published evidence, so the runners accommodate them instead, exactly as the Kotlin, C#, Go, and Rust kernels do:
- the Bifrost Ruby selector accepts either
core-ruby-kernel.rqlpor the breadthcore-direct.rqlppolicy for a Ruby case, and evaluates each case through the policy it declares; - the CodeQL Ruby selector defaults a Ruby case with no
codeqlmodel reference to this kernel's query, and rejects any Ruby case that names a different query.
The same case is a member of two populations, but its results are never pooled:
the breadth result lives in reports/bifrost-smoke.json and the kernel results
in the dedicated Ruby reports below.
docs/applicability-matrix.md gates this tranche explicitly: Bifrost is
inconclusive for both Ruby direct assertions in the frozen breadth smoke,
so the Ruby tranche either waits for Bifrost's Ruby indexing to be fixed, or
proceeds CodeQL-first with the Bifrost outcomes retained as inconclusive
capability evidence.
The decision recorded here is: proceed CodeQL-first. Concretely:
- CodeQL is the primary decisive analyzer for the Ruby denominator. The
pinned CLI 2.26.3 ships a production
rubyextractor and the registry resolvescodeql/ruby-all@6.0.3, so every assertion in the denominator can be decided by a real, reproducible toolchain. - Bifrost is run anyway on the full population through
core-ruby-kernel.rqlp, and whatever it produces is retained verbatim as capability evidence. Bifrost's Ruby indexing was not changed as part of this tranche; that work belongs to Bifrost, not to the benchmark. - No Bifrost outcome is converted into a negative. An
inconclusiveBifrost result is capability coverage. It never counts asnot-reached, never enters the negative half of a scorecard, and never suppresses the CodeQL result for the same assertion. - Joern is run as a second decisive analyzer through
rubysrc2cpg, which the pinned distribution ships. Its population, report, and evidence root are separate from CodeQL's and from Bifrost's.
The gate is about coverage, not about polarity: it changes which analyzer the Ruby denominator is decided by, and it changes nothing about the fixtures, the template identities, or the expected polarities.
The Bifrost Ruby slice uses the language-qualified policy
adapters/bifrost/policies/core-ruby-kernel.rqlp, whose source and sink
selectors are (language ruby (call :callee (name "dfb_source"))) and
(language ruby (call :callee (name "dfb_sink"))), with argument index 0 as
the dangerous operand. Run it from the repository root:
cargo run -- run-bifrost-ruby-kernel --bifrost /path/to/bifrostThe command selects exactly the 58 Ruby core assertions, materializes one
isolated workspace per case outside the repository, writes the normalized
report to reports/bifrost-ruby-kernel.json, and retains the verbatim per-case
Bifrost JSON under reports/raw/bifrost-ruby-kernel/. A report with incomplete
runs is normalized as inconclusive, never as a negative.
Ruby has its own CodeQL pack at adapters/codeql/ruby/, with its own
qlpack.yml, its own codeql-pack.lock.yml, and queries/RubyKernel.ql. It
does not share the Java, Python, or any other language pack.
| Item | Value |
|---|---|
| CLI | 2.26.3 (pinned) |
| Extractor | ruby, buildless (--build-mode=none) |
| Library pack | codeql/ruby-all@6.0.3 |
| Pack | adapters/codeql/ruby |
| Query | adapters/codeql/ruby/queries/RubyKernel.ql |
The query is the same minimal, language-neutral contract every other kernel
query implements: sources are calls whose method name is dfb_source, sinks
are the first positional argument of calls whose method name is dfb_sink, and
the body is one TaintTracking::Global configuration. There is no per-case,
per-template, or per-polarity branching. Ruby's parenless call surface is
irrelevant to the query — a receiverless dfb_source is the same MethodCall
node as dfb_source().
Reproduce with:
codeql pack install adapters/codeql/ruby
cargo run -- run-codeql-ruby-kernel --codeql /path/to/codeqlcodeql pack install resolved codeql/ruby-all@6.0.3 from the registry
directly, so this pack needs none of the --codeql-packs source-workspace
fallback the JavaScript kernel documents.
The runner creates one cold database per case from a workspace holding only
that case's fixture file, runs the kernel query, reconciles the SARIF against
the case's own anchors, retains the complete SARIF under
reports/raw/codeql-ruby-kernel/, and writes the dedicated normalized report to
reports/codeql-ruby-kernel.json.
The Ruby Joern kernel uses the shared script
adapters/joern/queries/kernel.sc with the RUBYSRC frontend
(rubysrc2cpg), exactly as the Java, JavaScript, and Python kernels use their
own frontends. Nothing in the script is language-specific; the two
benchmark-controlled endpoint identifiers are read out of each fixture's
DFB-SOURCE: and DFB-SINK: marker lines and passed in as parameters.
cargo run -- run-joern-ruby-kernel --joern <joern-cli>/joernThe runner selects the 58 Ruby core assertions, builds one cold CPG per case in
a per-case scratch root, retains the evidence document under
reports/raw/joern-ruby-kernel/, and writes
reports/joern-ruby-kernel.json. A frontend or engine failure is retained as
runner-error and can never become a negative.
The Ruby Semgrep slice uses the committed rule
adapters/semgrep/rules/ruby.yaml. It is the one rule file that differs in
substance from the other ten: a Ruby call's parameter list is optional and every
Ruby fixture spells the source call parenless (value = dfb_source), so its
pattern-sources is a pattern-either over both spellings, while the sink keeps
the single parenthesised form every language uses.
cargo run -- run-semgrep-ruby-kernel --semgrep /path/to/semgrepThe runner selects all 58 Ruby core assertions, scores the 14 that fall inside
the bounded CE profile, and normalizes the other 44 unsupported without
invoking Semgrep — the decision is taken from the case's own
feature_tags and expected_analysis_capability, and for a challenge template
from the preregistered CHALLENGE_SEMGREP_PARTITION, before the tool runs. The
partition is not adjustable by a result.
Analyzer findings are evidence, not ground truth by themselves. The runners
reconcile finding locations against the case's DFB-SINK: anchor: the marker
identifies the sink function's declaration, and a finding is accepted when it
lies in the same fixture file on a line that calls that function. The finding
need not be on the marker's own line.
Ruby needs its own dialect in the shared reconciler for two surface reasons, both derived from the fixtures themselves rather than assumed:
- The parameter list is optional.
def dfb_source # DFB-SOURCE: ...declares a method just asdef dfb_sink(value) # DFB-SINK: ...does, so the declared name is read after thedefkeyword rather than before a parameter list. Every other dialect reconciled here can rely on the parameter list; Ruby cannot. This matters most for Joern, which resolves the source endpoint name from the source marker line — a parameter-list rule would have failed to resolve it and the case would have been reported as inconclusive rather than analyzed. - Comments open with
#, and members are reached through.and::. Ruby shares the#comment opener with Python and the::path separator with Rust, but no existing dialect combines both, soAnchorDialect::RubypairsCommentSyntax::Hashwith the['.', ':']member prefixes.
A parenless call is deliberately not treated as a sink callsite: every benchmark sink takes one positional argument and every fixture spells that call with parentheses, while the parenless calls that do appear are all source calls, whose names are resolved from declaration lines rather than by scanning for callsites.
A successful, anchor-backed finding is reached; a successful analysis with no
matching finding is not-reached. Missing, ambiguous, or unmappable location
evidence is inconclusive, an explicitly unsupported capability is
unsupported, and a database, query, SARIF, process, or runner failure is
runner-error.
None of inconclusive, unsupported, or runner-error may be normalized to
not-reached, and none may be counted as a semantic negative. This is exactly
what keeps the Bifrost coverage gate honest.
Four adapters cover Ruby, and all four ran over the whole expanded 58-assertion population in this wave. Nothing about Ruby is deferred.
| Adapter | Expanded-population run | Report | Freeze-bound? |
|---|---|---|---|
CodeQL CLI 2.26.3 (ruby) |
Ran — whole 58-assertion population | reports/codeql-ruby-kernel.json |
No |
Joern 4.0.610 (rubysrc2cpg) |
Ran — whole 58-assertion population | reports/joern-ruby-kernel.json |
No |
| Bifrost v0.10.5 | Ran — whole 58-assertion population | reports/bifrost-ruby-kernel.json |
No |
| Semgrep CE 1.174.0 | Ran — whole 58-assertion population | reports/semgrep-ruby-kernel.json |
No |
This is the one difference between the Ruby wave and the earlier ones. The
v0.3.0 freeze manifest (reports/freeze.json) digest-binds nineteen reports —
the Bifrost smoke report, eight Bifrost kernel reports, and the ten CodeQL
kernel reports of the ten languages frozen in v0.3.0. Ruby's kernel landed
after that freeze, so no Ruby report is in the manifest's report list,
and each of the four could be replaced whole without touching published
evidence. validate-freeze passes on the resulting tree, which is the check
that proves it rather than the claim that asserts it.
Every Ruby report is a whole-population replacement, never an append: each
of the four was re-run over all 58 assertions and now carries fixture revision
sha256:020d0d8f79360af6e74064a692e2d65ffa31cd97f9971f9dad8bec065d862043, the
expanded corpus. Ruby's challenge cases are excluded from the Bifrost smoke
selection, which stays pinned at its frozen 118 cases.
All four snapshots cover the same 58 Ruby core assertions and are four separate populations. They are never merged into one Ruby number, and the 58-assertion expanded core is never compared number-to-number with the 32-assertion v0.3.0 core.
| Analyzer | reached |
not-reached |
unsupported |
inconclusive |
runner-error |
Polarity match |
|---|---|---|---|---|---|---|
| CodeQL 2.26.3 | 22 | 36 | 0 | 0 | 0 | 49/58 (49 of 58 decisive) |
| Joern 4.0.610 | 23 | 35 | 0 | 0 | 0 | 40/58 (40 of 58 decisive) |
| Semgrep CE 1.174.0 | 9 | 5 | 44 | 0 | 0 | 12/14 scored |
| Bifrost v0.10.5 | 0 | 0 | 0 | 58 | 0 | 0/58 (0 decisive) |
Per-stratum, for the two whole-population decisive analyzers:
| Stratum | Assertions | CodeQL | Joern |
|---|---|---|---|
| Classic (16 templates) | 32 | 29/32 | 26/32 |
| A — dispatch and reflection | 6 | 3/6 | 3/6 |
| B — higher-order flow | 8 | 5/8 | 4/8 |
| C — containers and deep access paths | 6 | 6/6 | 3/6 |
| D — context and depth stress | 6 | 6/6 | 4/6 |
| Expanded core | 58 | 49/58 | 40/58 |
Both classic columns reproduce the previous 16-template runs case for case — the same 29/32 for CodeQL and the same 26/32 for Joern, with the same mismatch sets — so the expansion disturbed no existing evidence.
CodeQL CLI 2.26.3, build codeql-cli:7d097a43199effe04ecd9c6bd3ad9bb02a45b3d7,
with codeql/ruby-all@6.0.3 from the committed lock. Configuration hash
0292361f24c7b18fa59543de15e5709270a5d717f0e7fa3e61de7a9436fb59f7 — unchanged,
because neither the query nor the pack moved; only the population grew.
All 58 assertions executed cleanly: 22 reached, 36 not-reached, and zero
inconclusive, unsupported, or runner-error outcomes. Every one of the 58
raw outputs is a SARIF document under reports/raw/codeql-ruby-kernel/; there
are zero retained error files. The population ran in 468 s wall clock, 5.7 s to
14.5 s per case.
49 of 58 outcomes match the expected polarity. The three classic mismatches are unchanged:
dfb-taint-ruby-alias-propagation-positive: false negative — writing throughoriginal.valueand reading throughaliased.valueis not carried across the alias.dfb-taint-ruby-exception-catch-positive: false negative — the value stored on theFlowErrorattribute is not carried throughraise/rescuetocaught.value.dfb-taint-ruby-loop-carried-negative: false positive — thewhilebody overwrites the local on every iteration, and the kill is not proven.
The six challenge mismatches, verbatim:
dfb-taint-ruby-reflective-invocation-positive: false negative.dfb-taint-ruby-computed-property-positive: false negative.dfb-taint-ruby-dispatch-table-positive: false negative.dfb-taint-ruby-function-field-positive: false negative.dfb-taint-ruby-callback-registration-positive: false negative.dfb-taint-ruby-anonymous-implementation-positive: false negative.
Read as the preregistration requires:
- Stratum A is approximation character, not skill, and CodeQL's character
here is uniformly under-approximating. All six stratum-A results are
not-reached: the three positives are false negatives and the three negatives are correct for the same reason the positives are wrong. A callee named bypublic_send, a member located byinstance_variable_set/instance_variable_get, and a lambda fetched from aHashare all declined. 3/6 here is not "half right"; it is one consistent design position scored twice, and it does not rank CodeQL against anything. - Stratum B separates the four difficulties, which is why it was split into
four. Environment capture (
closure-capture) is the one cell CodeQL decides on both halves — the lambda closing overmake_reporter's local is followed out of the creating scope. Code stored in a field (function-field), inversion of control (callback-registration), and the anonymousClass.newimplementation are all missed on the positive. Collapsing these four into one template would have reported "half" and hidden which half. - Stratum C is fully decided, including the depth-3 accessor chain and the
element-plus-field pair, and including
Hash#eachiteration. Read against the classicsame-object-field-separationandarray-element-separationresults, this says Ruby field sensitivity here is not k-limited at depth 1 or 2, and that the map is modeled through its iteration protocol and not only through[]. - Stratum D is fully decided, six-hop relay included. The preregistration's
depth calibration was aimed at Joern's verified
maxCallDepth = 4; it says nothing about CodeQL's bound, and CodeQL's Ruby analysis resolves the chain, the depth-5 recursion, and the k = 2 context pair.
Joern 4.0.610, build identity joern-cli:4.0.610, frontend rubysrc2cpg.
Configuration hash
ab10e81860305e492a930e2c2691873b23be25e97e5b354ca785058e09a20025 — the shared
adapters/joern/queries/kernel.sc was not modified, so the hash is unchanged.
All 58 assertions executed: 23 reached, 35 not-reached, and zero
inconclusive, unsupported, or runner-error outcomes. 40 of 58 match
the expected polarity. The six classic mismatches are unchanged from the
16-template run:
dfb-taint-ruby-alias-propagation-positive: false negative.dfb-taint-ruby-exception-catch-positive: false negative.dfb-taint-ruby-argument-position-negative: false positive.dfb-taint-ruby-call-context-negative: false positive.dfb-taint-ruby-infeasible-branch-negative: false positive.dfb-taint-ruby-loop-carried-negative: false positive.
The twelve challenge mismatches, verbatim:
dfb-taint-ruby-reflective-invocation-positive: false negative.dfb-taint-ruby-computed-property-negative: false positive.dfb-taint-ruby-dispatch-table-positive: false negative.dfb-taint-ruby-closure-capture-positive: false negative.dfb-taint-ruby-function-field-positive: false negative.dfb-taint-ruby-callback-registration-positive: false negative.dfb-taint-ruby-anonymous-implementation-positive: false negative.dfb-taint-ruby-map-iteration-positive: false negative.dfb-taint-ruby-nested-access-path-positive: false negative.dfb-taint-ruby-element-object-positive: false negative.dfb-taint-ruby-context-pair-depth2-positive: false negative.dfb-taint-ruby-recursive-carry-negative: false positive.
Read as the preregistration requires:
- Stratum A shows both propensities in one engine.
rubysrc2cpgdeclines thepublic_sendcallee and theHash-selected lambda — under-approximating, missing both positives and declining both negatives for the same reason. Oncomputed-propertyit does the opposite: it carries the value through theinstance_variable_set/instance_variable_getpair and joins the two provably distinct constant keys, so the positive is right and the negative is a false positive. That is approximation character, not a ranking. - Stratum B is 4/8 and every miss is a positive. All eight results are
not-reached: no callee reached through a lambda, aMethodobject in an accessor, an array of registered callables, or an anonymousClass.newinstance is resolved, so the four negatives are correct for the same reason the four positives are wrong. Joern's Ruby stratum B is a clean under-approximation, and unlike CodeQL it does not carry closure capture either. - Stratum C is 3/6, and all six results are
not-reached. Iteration over aHash, a depth-3 accessor chain, and a field inside an array element are all declined on the positive. Read against the classic heap stratum — whererubysrc2cpgdecidessame-object-field-separationandarray-element-separationcorrectly — this says the depth-1 field sensitivity the classic core establishes does not extend to depth 3, to container iteration, or to element-scoped fields. - Stratum D contradicts the preregistration's depth prediction for this
language, and is recorded as such.
docs/challenge-tier.mdpredicts that a six-hop relay, calibrated past Joern's verifiedmaxCallDepth = 4default, will fall off. The adapter did not raise that bound, and on Ruby the relay positive is neverthelessreachedwhile its negative isnot-reached— a correctly discriminated pair at depth 6. The recursion positive isreachedtoo, but so is itsoverwrite-killnegative: the recursive summary is widened to "everything in, everything out", which is exactly the widening template 12's negative exists to make visible. The k = 2 context pair isnot-reachedon both halves, so its negative is a true negative arrived at by not seeing that far. The prediction is left in the preregistration unamended: it was made about the engine's default bound and one language's frontend result does not retroactively change what was predicted.
Joern and CodeQL agree on the two classic false negatives — alias propagation
and exception catch — and on the loop-carried false positive; Joern
additionally over-approximates argument-position separation, call-context
separation, and the infeasible branch. On the challenge tier they agree on five
of the six CodeQL misses (reflective-invocation, dispatch-table,
function-field, callback-registration, anonymous-implementation), and
diverge on computed-property — CodeQL declines both halves, Joern resolves
both halves — and on closure-capture, stratum C, and stratum D, which CodeQL
decides and Joern does not.
This concerns the classic stratum only; no challenge fixture existed under
either earlier pin. On the 16-template core the 4.0.610 numbers replaced a
4.0.432 run that also scored 26/32 with an also-18/14 outcome
distribution — but with a different mismatch set. Re-pinning to 4.0.610
moved four Ruby cases, two in each direction:
dfb-taint-ruby-array-element-negative: false positive → correctnot-reached.dfb-taint-ruby-same-object-field-negative: false positive → correctnot-reached.dfb-taint-ruby-argument-position-negative: correctnot-reached→ false positive.dfb-taint-ruby-call-context-negative: correctnot-reached→ false positive.
Under 4.0.432 Ruby's mismatch set was identical to JavaScript's; it no longer
is. rubysrc2cpg gained array-element and same-object-field separation and lost
argument-position and call-context separation. Nothing in this benchmark changed
between the two runs but the pinned Joern version, and the raw evidence for all
four cases shows both endpoints observed with the flow count moving in the
direction the outcome reports — so this is analyzer drift, retained as a result.
Joern ships rubysrc2cpg and its console reports importCode.ruby as
available, but the generic importCode(language = "RUBYSRC") dispatcher the
shared kernel script used has no Ruby entry: it raises
io.joern.console.ConsoleException: No CPG generator exists for language: RUBYSRC for every case, in every spelling of the identifier, and the first
Ruby run produced 32 runner-error results because of it. The named
importCode.ruby frontend reaches the same generator in the same console, so
adapters/joern/queries/kernel.sc now dispatches Ruby through it and leaves
every other language on the generic path unchanged.
That is an adapter fix, not a fixture or query concession: no Ruby fixture was
altered, the query is the same sinks.reachableByFlows(sources) every other
Joern kernel runs, and the endpoint identifiers are still read off the
fixtures' own marker lines. Because the shared script's bytes changed, the
other Joern kernels were re-run on the new script so that no retained report
cites a configuration hash its script no longer has.
The workaround was re-probed against 4.0.610 rather than assumed to be
obsolete: the generic dispatcher still raises the same exception for RUBYSRC
on that version, so the named-dispatch branch is kept.
Semgrep CE 1.174.0, build identity semgrep-oss:1.174.0. Configuration hash
865d0bd2989f9ddd0b90f2d6675584e86706b109a033d4a1ac00bd21a617b100 — the
committed rule set was not touched for this tier.
All 58 assertions were selected and balance-checked; 14 were scored and 44
were unsupported by declared capability, decided from the case metadata
before Semgrep was invoked. Zero inconclusive and zero runner-error
outcomes; 44 retained capability-decision documents, 14 retained finding
documents, 14 retained resolved rule files, and zero error documents under
reports/raw/semgrep-ruby-kernel/.
| Partition | Assertions | Outcome |
|---|---|---|
Scored (intraprocedural partition, all classic) |
14 | 9 reached, 5 not-reached; 12/14 polarity match |
unsupported — rest of the classic core |
18 | capability coverage |
unsupported — challenge strata A, B, C, D |
26 | capability coverage |
Every one of the 26 challenge assertions took the preregistered
unsupported partition, which is what docs/challenge-tier.md said would
happen and is correct behavior for a bounded engine rather than a gap. None of
the thirteen challenge templates carries the intraprocedural feature tag, so
none enters the scored subset, and the scored subset stays at 14 with the
same two mismatches as before — false positives on
infeasible-branch-negative and loop-carried-negative, the path sensitivity
the pinned CLI documents as Pro-only. The expansion moved the unsupported
remainder from 18 to 44 and moved nothing else.
Bifrost v0.10.5, build identity
728ac69ab93224151c6c951b23d2f5bc681d8558. Configuration hash
5e57410aee16c7c6f17e9f7645982f035d2561db697deb40e4f57668c34c8cad.
All 58 results are inconclusive. None is decisive, and none is a
negative. This is the gate's predicted outcome, now measured over the whole
29-template population rather than only the 16-template core or the two breadth
assertions: the frozen breadth smoke already recorded both Ruby direct
assertions as inconclusive under this build, and neither expansion changes
the picture.
The retained per-case evidence under reports/raw/bifrost-ruby-kernel/ splits
into four groups, counted from the retained documents on this tree:
- 28
partial_discovery— 20 classic (the ten local, call/return, and control-transfer pairs) and 8 challenge (computed-property,context-pair-depth2,deep-relay-chain,recursive-carry). Each retains a diagnostic of the form "procedure value-flow snapshot for<procedure>is unknown". - 14
capability_incomplete, "unsupported (assignments)" — the four classic heap/separation pairs, the classic exception-catch pair, and the challengenested-access-pathandelement-objectpairs. - 14
capability_incomplete, "taint semantic binding is unavailable: no analysis root contains both a selected source and sink" — the seven challenge pairs whose sink call sits inside a lambda, a block, aMethodobject, or an anonymous class body:reflective-invocation,dispatch-table,closure-capture,function-field,callback-registration,anonymous-implementation, andmap-iteration. This diagnostic is new to the challenge tier and is retained as it was emitted. - 2
capability_incomplete, "unsupported (local_flow)" — the classic loop-carried pair.
Bifrost's Ruby indexing was deliberately not modified by this wave, any more
than it was by the original tranche. Under
the gate these 58 results are retained verbatim
as capability coverage; not one of them is counted as not-reached, and the
Ruby denominator is decided by CodeQL instead.
Ruby results are their own population. They are never pooled with the Java,
JavaScript, TypeScript, Python, Kotlin, C#, Go, C, C++, or Rust kernels, and
never pooled with the 13-language direct-flow breadth slice. The four Ruby
analyzer populations — CodeQL, Joern, Semgrep CE, and Bifrost — are four
separate result sets over one case population and are never merged into a
single Ruby number. Nor are the 32-assertion v0.3.0 Ruby core and this
58-assertion expanded core: they are two populations of the same name, and a
sentence that subtracts one from the other is a misreading. The Java
calibration cases (dfb-template-one-hop-relay and
dfb-template-modeled-external-summary) have no Ruby member and do not change
this denominator.