Skip to content

Fix the cross-frontend defects the JS rebuild exposed, and two gaps the CVE campaign found - #46

Merged
SYM01 merged 17 commits into
mainfrom
claude/rolldown-vs-esbuild-qcao2g
Aug 22, 2026
Merged

Fix the cross-frontend defects the JS rebuild exposed, and two gaps the CVE campaign found#46
SYM01 merged 17 commits into
mainfrom
claude/rolldown-vs-esbuild-qcao2g

Conversation

@SYM01

@SYM01 SYM01 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Asking whether the JavaScript rebuild generalized turned up six live defects in the other frontends. All were reproduced against the working tree before being written down. Two further fixes came from the real-world CVE campaign, which now scores 18/21 (85.7%) on scorable ground truth, up from 15/21.

The frontend defects share one root cause: the JS frontend was the only one whose "I could not model this" path was guarded. CLAUDE.md states the convention — an unmodelled node yields a fallback marker and converter tests fail if one appears — and it was enforced for one frontend out of seven. Skipped() was asserted only for JS. Everywhere else, a construct the lowering did not understand cost findings with no error, no marker, and no failing test.

Ordering matters: the shared guard is what makes every other fix verifiable, so each known fallback is removed before the guard that would trip on it.

What changed

Defect Fix
Ruby keyword arguments erased — User.where(name: params[:q]) became one ruby.unsupported and the argument disappeared lower a hash literal pairwise; the container stays untainted, since ActiveRecord's hash form is parameterized by construction
Fallback intrinsics guarded for JS only testsupport.RequireNoFallbackIntrinsic, called whole-tree from js/python/ruby/rust with Skipped() pinned alongside
A def inside control flow was claimed by nobody — the collectors entered only def/class bodies while the lowering descends into if/while/for/with/try/rescue/blocks and skips defs, trusting the collector both collectors recurse through every node that opens no scope, so they cannot drift from the lowering again
Ruby class << self appeared in no switch at all, so a singleton body was dropped whole sclass handled; a def inside one is named and shaped like def self.m — class-qualified canonical name, receiver in parameter slot 0
Rust replaced an unmodelled rvalue with an empty string constant, i.e. clean data: taint died at it and the result read as a legitimately safe value rust.unsupported intrinsic, with the corpus guarded crate by crate so cargo resolves each crate's deps
rustc's spans for macro-expanded code point into its own sysroot — three steps of the shipped sql_injection taint path were pinned at /rustc//library/alloc/src/macros.rs, which does not exist on the scanning machine a span outside the tree being scanned is rejected in favour of the last one accepted from user code — the macro's call site
Ruby columns were zero-based for the life of the frontend, while Python adds one, JS computes off − start + 1 and Go's tokeniser is 1-based col + 1, checked against the actual text at the reported position
The position golden covered JavaScript only, which is how a whole-language column error survived one golden per language, keyed by sample; a language whose toolchain is absent contributes nothing and the rest still assert
requireJava checked that java was on PATH while the JDK 24 floor is enforced inside ConvertFile, so an old JDK passed the guard and then failed every test both the converter tests and the corpus ask the frontend the same question it asks itself
A spec-bearing format! placeholder ({:>10}) failed the whole template decode, leaving an EMPTY template — which reads downstream as "this format string contains no host" the decode stops at the unmodelled token and keeps what it read; the literal prefix came from literal-run tokens, which are compile-time text

That last one is the sharpest: adding a width specifier to provably safe code produced a high-confidence CWE-918 false positive that the same code with a plain {} does not. test/rust/ssrf_safe_format_spec is a new sample that fails on the pre-fix tree.

Two gaps the CVE campaign found

TypeScript parameter decorators did not parse, which loses the WHOLE file — NestJS and Angular are built out of them. It cost nocodb 58 of 1469 files and 190 of 5271 in another revision, all controllers, strategies and filters; n8n the same shape. Decorators on classes, methods and properties parse fine, so only the parameter form is affected.

esbuild-jsast now exposes the flag, and its Options doc carries the finding that shaped the fix: setting it lowers every experimental decorator in the file, not only the parameter ones — Class.Decorators comes back empty and they reappear as __decorateClass/__decorateParam calls. Set always, that would trade the as-written tree of every decorated file for the few that cannot parse otherwise. So it is a ladder rung, reached only after the dialect ladder fails, which confines the lowered shape to files that would contribute nothing at all.

The pair of tests is the point. One pins that a parameter decorator parses; the other pins that a class/method/property-decorated file is not lowered — if that ever starts reporting __decorate*, every decorated file has begun paying for the few that need it.

Measured: nocodb PARTIAL(1411/1469)ok and PARTIAL(5081/5271)ok, n8n three revisions to within 3 files, all with findings unchanged. Six repos reached full JS coverage.

A call through a function-holding package variable had no callee name. A package re-exporting another's function as a variable — var Params = macaron.Params, how grafana's pkg/web surfaces macaron — makes every call through it indirect: SSA loads the global and calls the loaded value, so there is no static callee and convertCall left the name empty. An unnamed callee matches no source, sink or propagator glob, so the call was invisible to every rule.

collectFuncAliases maps such a variable to its function and the call is named after it — semantically, like every other Go callee, so _go-common.yaml also names macaron's own spelling of the accessor. The guard is what keeps it sound: only a variable with exactly one store program-wide is mapped, and only when that store's value is a function. Counting every store rather than only the function-valued ones is load-bearing — a var later reassigned to a mock or to nil has no single answer at the call site and must stay unresolved. The sample's third case is that control.

This flipped grafana CVE-2021-43798 from class-only to a hit (go-path-traversal at pkg/api/plugins.go:303), the flow that had been traced by hand and set aside as a separate engine gap. Path-traversal is now 7/8.

Verification

Every fix has a gate that was confirmed to fail without it — by reverting the change and watching the test go red, not by trusting green.

Findings are byte-identical across the Python, Ruby and Rust corpora (98 / 33 / 12, per-sample-dir scans matching how test/corpus drives scan.Scan). The JS golden rows are unchanged; the one extra crossfile_interproc row appears only because the golden now scans per sample dir, as the corpus does.

make gate                       # fmt, vet, lint (0 issues), build, test
go test ./... -count=1          # green
go test ./test/corpus/ -count=1 # green
make gate-llvm                  # green

Java subtests skip locally (JDK 21) and run in CI.

Three verification hazards found on the way

Generating the goldens turned up two defects in TestRegenerateManifests of the same shape as the ones being fixed — a helper whose output cannot tell "nothing found" from "did not run". Both are fixed here:

  • a scan whose frontend failed reports zero findings, so regenerating from it emptied the manifest of every language the machine lacks a toolchain for. Coverage tells them apart, and a golden write skips such a sample too.
  • max/line/sink in a manifest are hand-written — nothing in scan output implies them — and regenerating deleted them. They are now carried over for every rule that still fires.

The third is new, and it bit rather than being theoretical: regenerating still discards hand-written prose, and running -run RegenerateManifests to update one position golden rewrote 227 expected.yaml files, stripping their rationale comments. TestRegeneratePositionGoldens is the correct target and updates only the goldens — the two are deliberately separate. Reverted, not committed. Left alone as a separate change, but the split is easy to miss and costs the reviewer nothing to know.

Explicitly not done

  • Prism instead of Ripper. The right long-term payload upgrade (byte offsets on every node, error recovery), but a rewrite of rbdump.rb and its consumer; the Ruby fixes here do not need it.
  • A rustc_public helper binary. The principled fix for the MIR text boundary, but it needs nightly and a rebuild per user toolchain.
  • Widening the byte archaeology in decodeFmtTemplate. The failure is made local rather than total instead.
  • Top-level await. esbuild's Transform API refuses to downlevel it at every target and format; only bundling could. Zero occurrences observed, and it is listed in the capability table as a measured residual.
  • Making worker()'s field list structural. It is a hand-maintained list of the whole-program results each lowering goroutine needs, and a field left out is silently empty rather than nil-panicking — which is how the alias map landed inert the first time. Documented on the function; folding the shared fields into one copied struct would prevent recurrence but touches many call sites.

Determinism, and two gaps the campaign found (later commits)

The nondeterminism recorded above as an unrelated observation turned out to be two engine defects, both now fixed — so the count-based verification hazard it described is gone.

analysis: make a scan's findings deterministic

The same binary on the same tree reported 127/117/117 findings on gogs. With the IR, the scope and the rule all held fixed, one rule swung [5 12 12 14 15 13 15 5] across eight runs. Two independent causes, both map-iteration order reaching a first-seen-wins decision:

  • buildMethodImpls, buildCallers and buildGlobalReaders built their name lists by ranging over a map, so the worklist visited functions in a different order each run. Every summary channel is first-seen-wins, so that order decides which origin a callee's summary keeps. Now sorted at construction.
  • stringParamOrigin picked one parameter with break on the first map match. Several parameters share an origin whenever a call site passes the same tainted value twice, and which index won was random — sometimes a string parameter (summarize the wrapper), sometimes the receiver (drop it). It now returns every string match: the engine cannot tell which parameter carried the value to the sink, so summarizing one silently drops a caller that taints another.

Localized by tracing every worklist enqueue with its originating channel and diffing runs, not by inspection. After sorting the first cause, traces matched for 46,241 lines and the next divergence was an addition, which led to the break.

A full CLI scan of gogs is now byte-identical across five runs at 134 findings. +6 findings versus the union of three pre-fix runs, 0 lost; the added ones are real (a route-derived ref reaching git-module's BranchCommitID/TagCommitID). Tests pin the sortedness of all three indexes, with a guard so that assertion cannot go vacuous.

js: source a React component's props and context

react-xss had fired zero times across all 61 campaign applications. The sink was right; the sources were three react-router hooks and the browser globals, so no value a component was handed could reach it.

  • The dominant idiom function C({html}) lost the prop entirely — the pattern binds no identifier, so the parameter took an _arg0 name and html read as an unbound global. A capital-initial function's destructured first parameter now binds each property to js:props.<key>, reusing the trick COV-11 uses for destructured request handlers. The predicate is React's own dispatch rule: JSX compiles a lowercase tag to a host-element string. Deciding by "first parameter is destructured" would have been a regression, not just noise — it rebinds a destructured {req} handler to a local and loses a real request source.
  • react-xss-props, a new rule at severity: low. The this.props/this.context forms already lowered to matchable callees and needed only globs. Split out rather than folded into react-xss for the reason ruby-xss-cell-option is split: a prop is not request input by construction. It carries no extend: — inheriting the browser-source fragment is what cost ruby-xss-cell-option six corpus false positives.

Also drops ghost's subdir: ghost from the campaign's projects.yaml. Ghost is a monorepo and that scoped the scan to its core package, excluding apps/portal — the tree this CVE's own ground-truth files live in. The entry could not score no matter what the engine found; the rule change alone was not enough.

ghost CVE-2026-24778: class-only → HIT. 5 findings on the vulnerable tree, all in ground-truth files, landing on signup-page.js:560 — the exact vulnerable line.

ruby: carry taint through keyword arguments

Ruby erased every hash: a keyword list lowered its pairs for side effects and returned "", and a **splat was not lowered at all. Python has carried keyword taint through builtin.kwarg since it was written; Ruby never opted in. ruby-xss-cell-option's own comment already named this as why it could describe decidim CVE-2024-41673 without detecting it.

The frontend now appends one builtin.kwarg marker per pair after every positional argument. No engine or gIR change — builtin.kwarg is already an intrinsic propagator and already unwrapped into Arg.Name.

The hash keeps its inert placeholder in the positional slot, and that is load-bearing: a rule pins its injection point by logical index, and User.where(name: params[:q]) puts its hash at exactly the index ruby:*.where#1 names. Let a tainted value occupy the slot and every parameterized ActiveRecord query becomes a finding. On the callee side paramNames grows a matching inert @kwargs slot so markers land on the keyword parameters rather than one slot early.

Ruby sinks were almost all unpinned, and an unpinned sink treats every argument as an injection point — so keyword taint turned six ordinary Rails idioms into findings. Each got the pin it should always have had: redirect_to/redirect #0 (a flash notice: is not the destination), send_file/send_data #0 (filename: is a Content-Disposition value), and the SSRF sinks pin their URL, where the not hostFixed() guard actively made it worse by reading a tainted keyword as a controllable host. render gets a per-sink when: naming html/inline/plain/text/body.

That last one is also a detection gained: render html: params[:q] is what ruby-xss's sink comment always described and could never reach.

decidim CVE-2024-41673: class-only → HIT. On the decidim tree the only rule that moves is ruby-xss-cell-option, +6 (22 → 28), all severity: low so none gate, all the same cell-option-through-a-translation shape as the CVE. No other rule changes on a large Rails app.

cleanup: dedupe the kwarg marker, derive the component predicate

A quality pass over the two commits above. builtin.kwarg's two-channel contract moved to ssabuild.SetKwargMarker so the pairing — whose failure mode is silent taint loss — is stated once instead of in both frontends. The JS components map went away: unlike handlers, component-ness is a pure function of a name pendingFunc already holds. Ruby lost two copies of the same parameter walk and three spellings of the same unwrap — that third was not cosmetic, assocKeyName read a symbol key one level too shallow, so render :html => params[:q] produced a nameless marker the new guard could not see. Extracted _js-html-sanitizers.yaml, since a sanitizer known to one raw-HTML rule but not another is a false positive on the same file in the same scan. The pin-vs-keyword rationale had been stated in five places and the Ruby receiver-offset convention in three; each rulepack now points at the sample that pins it, and the convention is documented once in docs/writing-rules.md.

Not taken, all three genuine and all larger than a cleanup: binding a kwarg marker to a callee parameter by name in handleCall (which would retire the @kwargs placeholder and close the known misalignment when a call omits an optional positional); setting CallCommon.MethodName in the Ruby frontend so its sink indices match the documented receiver-excluded convention (it also feeds CHA, so it is a behavior change); and extending the sink spec to name a keyword injection point directly, which is a Rule-model change and needs sign-off.

Campaign result

Re-run on a fresh live fetch of 61 OSV.dev advisories, all 61 scanned:

RECALL (as run):   23/49 (46.9%)   +10 class-only signal
RECALL (in-model): 23/39 (59.0%)   after excluding 10 out-of-model
RECALL (scorable): 18/21 (85.7%)   also excluding 18 unscorable ground truth

Scorable recall 16/21 → 18/21, both flips from the work above. One further change, verified against a pre-change binary rather than assumed: mlflow CVE-2024-27133 moved miss → class-only because react-xss-props fires on mlflow's own React UI (EditableNote.tsx, a prop reaching dangerouslySetInnerHTML) — the new rule working in a third application, just not in that CVE's ground-truth file.

The single remaining scorable miss is directus CVE-2025-55746, and the two class-onlys are nocodb and mlflow. All three take their untrusted value as an ordinary function parameter rather than a component prop or a framework accessor, which is a different gap from either fixed here.

Corpus holds at precision 1.000 / recall 1.000, FP=0 FN=0 over 329 samples. make gate, go test ./... and make gate-llvm green.

claude added 7 commits August 14, 2026 09:48
…acks

Two steps of the cross-frontend audit.

ruby lowerExpr had no case for `bare_assoc_hash`/`hash`, so a keyword argument
became one ruby.unsupported intrinsic and the argument vanished. Nor for
`@label`, the keyword key itself -- a symbol literal like the other scalars.
test/ruby/rails_query_sqli emitted that intrinsic on every run.

The hash is lowered like `array`: each key and value lowered so a source or sink
inside still fires, container left untainted. Untainted is the point rather than
a shortcut -- ActiveRecord's `where(name: params[:q])` is parameterized by
construction, and a hash that carried its values' taint would make every such
call a false positive. That sample now passes for its documented reason (the #1
sink pinning) instead of because the argument was erased. Ruby findings are
byte-identical, 33 before and after.

requireNoFallbackIntrinsic moves from the JS test file into internal/testsupport,
parameterized by intrinsic name, and python and ruby gain the whole-tree test JS
already had -- fallback count plus a pinned Skipped(). Both halves are otherwise
invisible: frontend.Batch errors only when ZERO files convert, so a frontend can
drop all but one file and still report Converted, and an unmodelled construct
costs findings with nothing failing.

That guard is what makes the rest of the audit's fixes verifiable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The collectors and the lowering split the statement tree between them:
lowerBody/lowerStmt descend into the control-flow compounds and skip a def,
trusting the collector to claim it. Both collectors entered only def/class
bodies, so a def under `if`/`try`/`with`/`rescue`/a block was claimed by
NEITHER -- it left no error and no fallback intrinsic, it simply was not
analyzed. `if TYPE_CHECKING:`, feature flags and `except ImportError:` shims
all take that shape.

Both now recurse through every node that opens no scope rather than
enumerating compounds, which is what keeps them in step with the lowering.

Ruby additionally gains `class << self`: its Ripper tag appeared in no switch
at all, so a singleton body was dropped whole. A def inside one is a class
method, so it is named and shaped like `def self.m` -- class-qualified
canonical name, receiver in parameter slot 0.

No finding changes across either corpus; both repros now produce findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
…ings

Two ways the MIR lowering was quietly wrong about provenance.

An rvalue form the parser did not recognize became an empty string constant --
clean data. Taint died at it and the result read as a legitimately safe value,
with no error and nothing failing; every other frontend emits a marker instead.
It now emits rust.unsupported, and the corpus is guarded for it crate by crate
the way the corpus itself is driven, so cargo resolves each crate's deps.

Separately, rustc's spans for macro-expanded code point into its own sysroot:
anything through format! lands in /rustc/<hash>/library/alloc/src/macros.rs,
which does not exist on the scanning machine. Three steps of the shipped
sql_injection sample's taint path were pinned there. A span outside the tree
being scanned is now rejected in favour of the last one accepted from user
code -- the macro's call site.

No finding changes across the Rust corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
Ripper counts columns from 0, and the frontend passed that straight through
while Python adds one, JS computes off-start+1 and Go's tokeniser is 1-based.
Every Ruby column shipped one to the left of what it named.

Nothing could have caught it: expected.yaml asserts counts and an occasional
line, and the position golden covered JavaScript only. It is now per language
and keyed by sample, so a language whose toolchain is absent contributes
nothing and the rest still assert. Rendering piggybacks on the corpus scans, so
the oracle costs no extra work; the JS rows are unchanged.

Two regeneration hazards found while generating the goldens, both of the same
shape as the defects being fixed -- a helper whose output cannot tell "nothing
found" from "did not run":

  - a scan whose frontend failed reports zero findings, so regenerating from it
    EMPTIED the manifest of every language the machine lacks a toolchain for.
    Coverage tells them apart, and a golden write skips such a sample too.
  - max/line/sink in a manifest are hand-written; regenerating from scan output
    deleted them. They are now carried over for every rule that still fires.

Java's test guard also asked the wrong question: it checked that `java` was on
PATH while ConvertFile enforces a JDK 24 floor, so an old JDK passed the guard
and then failed every test. Both the converter tests and the corpus now ask the
frontend the same question it asks itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
rustc encodes a spec-bearing or explicit-index argument (`{:>10}`, `{0}`) with a
control byte the MIR template decoder does not model, and it failed the whole
decode on one -- leaving an EMPTY template. Downstream that is not "unknown", it
is the positive claim that the format string contains no host, so hostFixed()
could not prove the host constant and the finding fired.

The effect: adding a width specifier to provably safe code turned it into a
high-confidence CWE-918. Same code with a plain `{}` produces nothing.

The decode now stops at such a token instead of failing. What it read up to
there came from literal-run tokens -- compile-time text that cannot carry taint
-- so the constant prefix stands, and the remainder is rendered as one argument
insertion, which the skeleton reads as dynamic. A tainted host still fires:
`format!("https://{:>10}/v1/", p)` yields no constant authority.

No byte archaeology was widened; the failure is local rather than total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The gate is per language and asserts only what a run actually scanned, so the
Java rows could not be produced on a machine below the JDK 24 floor -- and CI,
which has one, then failed on the missing file. Generated against Temurin 25.

Java columns are 0 throughout: the frontend reads bytecode, whose LineNumber
table carries no column. That is now pinned rather than merely true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

🛡️ Quality Gate — 7d049e5..4cecfe5

Result: ✅ PASS

1 · Lines changed (excluding tests)

Net +930 / −155 across 27 product-source file(s).

Area +
cmd/ 9 1
converters/ 667 95
internal/ 77 25
rulepacks/ 177 34

Counts cmd converters internal pkg proto rulepacks; excludes *_test.go, testdata/, test/, generated *.pb.go.

2 · Corpus signal/noise (TP / FP / FN)

Metric Base Head Δ
TP 236 241 5
FP 0 0 0
FN 0 0 0
Precision 1.000 1.000 +0.000
Recall 1.000 1.000 +0.000
F1 1.000 1.000 +0.000

⚠️ Sample count differs (base N=321, head N=329) — the PR added/removed corpus samples, or a toolchain differs between checkouts. The raw TP/FN deltas partly reflect that, so read precision/recall (rates) rather than the counts.

3 · Rule changes

  • Added: react-xss-props, ruby-xss-cell-option
  • Modified: react-xss, ruby-open-redirect, ruby-path-traversal, ruby-ssrf, ruby-xss, svelte-xss, vue-xss

4 · Performance · gated (benchstat, count=10)

Engine hot paths and per-language full-pipeline scans, all compared by
benchstat so the base→head difference is statistically reliable rather than
wall-clock noise. A language whose toolchain is absent is skipped.

goos: linux
goarch: amd64
pkg: github.com/bytevet/godzilla/internal/analysis
cpu: AMD EPYC 9V74 80-Core Processor                
                               │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                               │                  sec/op                   │       sec/op        vs base               │
Engine_RuleScaling/rules=1-4                                   1.578m ± 2%          1.601m ± 1%       ~ (p=0.043 n=10)
Engine_RuleScaling/rules=10-4                                  11.37m ± 3%          11.27m ± 3%       ~ (p=0.579 n=10)
Engine_RuleScaling/rules=50-4                                  36.80m ± 2%          36.99m ± 5%       ~ (p=0.796 n=10)
Engine_RuleScaling/rules=200-4                                 114.5m ± 5%          113.3m ± 4%       ~ (p=0.631 n=10)
Engine_InertRules/inert=0-4                                    61.32µ ± 0%          61.64µ ± 0%       ~ (p=0.019 n=10)
Engine_InertRules/inert=14-4                                   62.03µ ± 0%          62.15µ ± 2%       ~ (p=0.061 n=10)
Engine_InertRules/inert=100-4                                  64.47µ ± 0%          65.28µ ± 1%  +1.27% (p=0.000 n=10)
geomean                                                        1.517m               1.522m       +0.30%

                               │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                               │                   B/op                    │       B/op        vs base                 │
Engine_RuleScaling/rules=1-4                                  1.393Mi ± 0%       1.393Mi ± 0%       ~ (p=0.807 n=10)
Engine_RuleScaling/rules=10-4                                 11.11Mi ± 0%       11.11Mi ± 0%       ~ (p=0.123 n=10)
Engine_RuleScaling/rules=50-4                                 54.28Mi ± 0%       54.28Mi ± 0%       ~ (p=0.165 n=10)
Engine_RuleScaling/rules=200-4                                216.2Mi ± 0%       216.2Mi ± 0%       ~ (p=0.739 n=10)
Engine_InertRules/inert=0-4                                   41.21Ki ± 0%       41.21Ki ± 0%       ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=14-4                                  41.57Ki ± 0%       41.57Ki ± 0%       ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=100-4                                 43.95Ki ± 0%       43.95Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean                                                       1.438Mi            1.438Mi       +0.00%
¹ all samples are equal

                               │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                               │                 allocs/op                 │    allocs/op      vs base                 │
Engine_RuleScaling/rules=1-4                                   3.651k ± 0%        3.651k ± 0%       ~ (p=1.000 n=10) ¹
Engine_RuleScaling/rules=10-4                                  22.41k ± 0%        22.42k ± 0%       ~ (p=0.071 n=10)
Engine_RuleScaling/rules=50-4                                  105.7k ± 0%        105.7k ± 0%       ~ (p=0.137 n=10)
Engine_RuleScaling/rules=200-4                                 417.9k ± 0%        417.9k ± 0%       ~ (p=0.957 n=10)
Engine_InertRules/inert=0-4                                     57.00 ± 0%         57.00 ± 0%       ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=14-4                                    59.00 ± 0%         59.00 ± 0%       ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=100-4                                   59.00 ± 0%         59.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                                        2.558k             2.558k       +0.00%
¹ all samples are equal

pkg: github.com/bytevet/godzilla/internal/rules
            │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
            │                  sec/op                   │         sec/op           vs base          │
MatchGlob-4                                 152.1n ± 1%               152.3n ± 2%  ~ (p=0.781 n=10)

            │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
            │                   B/op                    │         B/op           vs base            │
MatchGlob-4                                  0.000 ± 0%              0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

            │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
            │                 allocs/op                 │       allocs/op        vs base            │
MatchGlob-4                                  0.000 ± 0%              0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

pkg: github.com/bytevet/godzilla/internal/scan
                  │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                  │                  sec/op                   │       sec/op        vs base               │
Scan_Python-4                                     31.60m ± 0%          31.41m ± 0%  -0.58% (p=0.001 n=10)
Scan_JS-4                                         653.5µ ± 2%          657.4µ ± 2%       ~ (p=0.631 n=10)
Scan_Rust-4                                       45.49m ± 0%          44.89m ± 0%  -1.33% (p=0.000 n=10)
Scan_Java-4                                       497.6m ± 2%          491.3m ± 3%       ~ (p=0.315 n=10)
Scan_Ruby-4                                       77.48m ± 1%          77.29m ± 0%       ~ (p=0.436 n=10)
Scan_GoWithDeps-4                                  2.739 ± 3%           2.769 ± 2%       ~ (p=0.684 n=10)
Scan_GoSimple-4                                   135.2m ± 1%          135.0m ± 1%       ~ (p=0.393 n=10)
geomean                                           75.05m               74.85m       -0.27%

                  │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                  │                   B/op                    │       B/op         vs base                │
Scan_Python-4                                    527.2Ki ± 3%        447.9Ki ± 2%  -15.04% (p=0.000 n=10)
Scan_JS-4                                        384.4Ki ± 1%        383.4Ki ± 0%        ~ (p=0.247 n=10)
Scan_Rust-4                                      664.7Ki ± 2%        539.9Ki ± 3%  -18.78% (p=0.000 n=10)
Scan_Java-4                                     1361.5Ki ± 2%        459.3Ki ± 5%  -66.27% (p=0.000 n=10)
Scan_Ruby-4                                      609.6Ki ± 4%        418.1Ki ± 3%  -31.41% (p=0.000 n=10)
Scan_GoWithDeps-4                                1.441Gi ± 0%        1.442Gi ± 0%        ~ (p=0.075 n=10)
Scan_GoSimple-4                                  8.570Mi ± 0%        8.582Mi ± 0%   +0.14% (p=0.000 n=10)
geomean                                          2.772Mi             2.132Mi       -23.07%

                  │ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
                  │                 allocs/op                 │     allocs/op      vs base                │
Scan_Python-4                                     3.328k ± 4%         2.381k ± 0%  -28.46% (p=0.000 n=10)
Scan_JS-4                                         1.936k ± 0%         1.919k ± 0%   -0.85% (p=0.000 n=10)
Scan_Rust-4                                       3.994k ± 5%         2.625k ± 0%  -34.28% (p=0.000 n=10)
Scan_Java-4                                      12.143k ± 0%         2.000k ± 1%  -83.53% (p=0.000 n=10)
Scan_Ruby-4                                       4.260k ± 2%         2.010k ± 0%  -52.80% (p=0.000 n=10)
Scan_GoWithDeps-4                                 18.86M ± 0%         18.86M ± 0%   +0.01% (p=0.000 n=10)
Scan_GoSimple-4                                   63.78k ± 0%         63.89k ± 0%   +0.17% (p=0.000 n=10)
geomean                                           20.65k              12.86k       -37.73%

Gate blocks on a regression that is significant at alpha=0.01 (benchstat marks anything weaker as ~) on: Engine_RuleScaling,Engine_InertRules,MatchGlob,Scan_GoWithDeps,Scan_GoSimple,Scan_Python,Scan_JS,Scan_Rust,Scan_Java,Scan_Ruby — time sec/op > 10%, memory B/op/allocs/op > 10%. The strict alpha keeps subprocess/GC run-to-run noise on the heavier scans from tripping the gate.


Both revisions were built and benchmarked back-to-back on this runner; numbers are only comparable within a single run.

claude added 2 commits August 15, 2026 02:24
…fault

A decorator on a PARAMETER does not parse without TypeScript's legacy decorators,
and that loses the WHOLE file -- NestJS and Angular are built out of them. It cost
nocodb 58 of 1469 files, all controllers, strategies and filters; n8n the same
shape. Decorators on classes, methods and properties parse without the flag, so
only the parameter form is affected.

esbuild-jsast now exposes the flag (its Options doc carries the finding that
motivated the rung): setting it LOWERS every experimental decorator in the file,
not only the parameter ones -- Class.Decorators comes back empty and the
decorators reappear as __decorateClass/__decorateParam calls after the class. Set
always, that would trade the as-written tree of every decorated file for the few
that cannot parse otherwise. Reached only after the dialect ladder has failed, the
lowered shape is confined to files that would contribute nothing at all.

The pair of tests is the point. One pins that a parameter decorator now parses;
the other pins that a class/method/property-decorated file is NOT lowered, which
is what keeps the rung confined -- if that one starts reporting __decorate* calls,
every decorated file has begun paying for the few that need it. Both read the
lowered IR, since the __decorate* calls are how the flag is observable from
outside.

nocodb: PARTIAL(1411/1469) -> ok, 1 finding before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A package re-exporting another's function as a variable -- `var Params =
macaron.Params`, how grafana's pkg/web surfaces macaron -- turns every call
through it into an INDIRECT call: SSA loads the global and calls the loaded value,
so there is no static callee and convertCall left the name EMPTY. An unnamed
callee matches no source, sink or propagator glob, so the call was invisible to
every rule. grafana CVE-2021-43798 reaches os.Open through exactly that alias, and
a minimal repro of the shape confirmed it: the direct call fires, the aliased one
does not.

collectFuncAliases maps such a variable to its function, and convertCall names the
call after it. Semantic, like every other Go callee -- the FQN of the function it
resolves to, not the variable the source wrote -- so _go-common.yaml now also
names macaron's own spelling of the accessor; both are needed, since a project may
call it either way.

The guard is what keeps this sound: only a variable with EXACTLY ONE store
program-wide is mapped, and only when that store's value is a function. Counting
every store rather than only the function-valued ones is the load-bearing part -- a
var later reassigned to a mock or to nil has no single answer at the call site, and
must stay unresolved rather than resolve to whichever value was seen first. The
sample's third case is that control, and max: 2 fails if it ever starts resolving.

Two notes for whoever touches this next. worker() is a hand-maintained list of the
whole-program results each lowering goroutine needs, and a field left out of it is
silently EMPTY rather than nil-panicking -- the analysis just stops having an
effect, which is how this landed inert the first time. And gogs, a real macaron
consumer, turns out to scan NONDETERMINISTICALLY: the same binary on the same tree
reported 127, 117 and 125 findings across three runs, so the effect here was
measured by set-comparing repeated JSON runs rather than by counts. Zero stable
gains, zero stable losses -- the point being no false positives, since the repro
and sample carry the recall claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
@SYM01 SYM01 changed the title Fix the cross-frontend defects the JS rebuild exposed Fix the cross-frontend defects the JS rebuild exposed, and two gaps the CVE campaign found Aug 17, 2026
claude added 8 commits August 17, 2026 10:43
The sink half of the cells model already landed -- a cells template is compiled by
Erbse, which has no SafeBuffer, so `<%= %>` there is a raw sink. This is the
source half: `options[...]` inside a cell, the arguments its CALLER passed.
Decidim CVE-2024-41673 is a request value handed in that way and interpolated
unescaped.

The frontend emits it the way params already works -- a synthetic source CALL from
lowerAref -- but scoped two ways, because unlike params a cell option is NOT
untrusted by construction. It is emitted only under a cells directory, where
`options` has exactly one meaning, and named `ruby:@cell.options` so it can never
collide with a real method of that name. isCellsPath is now the single predicate
behind both halves of the model, so the sink and the source cannot come to
disagree about what a cell is.

The rule ships at severity: low, which is the whole design. A cell is invoked from
a controller or another view, so its argument may be request-derived or entirely
internal and the frontend cannot tell which. Low keeps these advisory under the
default `-fail-on medium` gate while still reporting them -- the trade
py-insecure-config already makes -- and a cross-function flow lands at Medium
confidence, inside the LLM reviewer's range. Raising it is a gate change, not a
tuning knob.

Measured, and it corrected the estimate that had blocked this. The fear was a
flood: decidim interpolates in 325 cell templates, and an earlier count of 675
interpolation SITES was read as if it predicted findings. Scanning the whole of
decidim at the vulnerable commit yields exactly ONE cell-option finding -- a site
is not a flow, and most cells never route an option into raw output.

The first draft did flood, for an unrelated reason worth recording: it carried
`extend: $_ruby-sources.yaml`, whose ordinary request sources made it a duplicate
of ruby-xss on every `raw(params[:x])` -- six corpus false positives, in files that
are not cells at all. The corpus precision floor caught it. The rule now declares
its one source and nothing else, and the sample's negatives pin the scoping:
html_escape neutralizes, a model read is not sourced, and `options` in app/helpers
must stay inert.

This does NOT flip the CVE. Its flow crosses from the cell class into the cell's
own template, and a template's bare `<%= label %>` is a separate module with
nothing linking it to the class -- same file fires, split across .rb and .erb does
not. That is cross-module cell/template linkage, a third gap, neither source nor
sink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A cell's class and its template are separate files, so the flow that matters
crosses a module boundary. A template's `<%= label %>` is a bare name in its OWN
module with no def of that name, so it lowered to an inert free identifier and the
two halves of a cell never met -- source in the class, unescaped sink in the
template, and nothing in between.

Resolved the way the JS frontend already resolves a cross-module default import:
the template emits a MARKER callee and a PostProgram pass rewrites it once every
file is lowered, since the class may not be parsed yet. The pairing is the cells
convention -- templates live in a directory named after the cell and the class
sits BESIDE that directory with a `_cell` suffix, so `app/cells/foo/show` is
rendered by `app/cells/foo_cell`. A name the class does not define (a Rails
helper, something from an included module) and an AMBIGUOUS name in a file holding
several cell classes both strip back to the plain bare name, so they can still
match a rule glob instead of dangling.

Also carries `t`/`translate`, since a translation contains what it interpolates
and the flow runs through one.

Scope, measured rather than assumed: this does NOT flip decidim CVE-2024-41673,
and the reason is worth recording because it is not a gap. The CVE interpolates
via `t("key", current: index)`, and a hash argument is lowered pairwise with the
CONTAINER deliberately left untainted -- tainting it would fire on every
`where(name: params[:q])`, ActiveRecord's hash form being parameterized by
construction. `t(x)` flows and `t("k", name: x)` does not, verified as a pair.
Interpolating a translation and parameterizing a query are the same shape to the
frontend; separating them needs per-key taint, not another propagator, and
flipping that tradeoff belongs with the ActiveRecord measurement it was made on.

The sample's negatives are the scoping: an identical `<%= title %>` under
app/views must stay inert, since an ActionView template escapes and has no paired
cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
The quality gate failed this PR on Scan_Java B/op +34.79% and allocs/op
+43.08%, against a Ruby-only commit that cannot touch the Java frontend. The
benchmark is bimodal on a SINGLE build, no base/head involved -- ten runs of
the unchanged tree:

  b.N=1  ~3.3-3.6 MB/op  ~33k allocs/op   (2 runs)
  b.N=2  ~1.86-1.91 MB/op ~17.4k allocs/op (8 runs)

Per-op cost halves as b.N doubles, which is a fixed once-per-process cost
being amortized: the Java frontend compiles its dump helper and caches it, and
each subprocess frontend memoizes its toolchain probe. A scan taking ~0.7s per
op never gets past b.N=1 or 2, so which value it lands on is decided by
wall-clock timing rather than by the code -- and benchstat compares two
revisions that happened to land differently. The gate's own ±35% / ±42%
variance on those samples is the artifact showing through.

Scanning once outside the loop moves that cost where it belongs. Java now
measures 462-512 KB/op (±5%) and 1,986-2,019 allocs/op (±1%), so ~15k of the
17k "allocations per scan" were process setup counted once and divided.

Absolute numbers drop for every per-language benchmark, which is a measurement
change and not a speedup -- steady-state scan cost is what they now report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The rust position golden wanted rewriting on every regeneration, differently
depending on whether cargo had run. Chasing that turned up the actual defect: a
cargo build runs rustc IN the crate directory, so its MIR spans are
crate-relative, and span() stored them verbatim.

A finding reported at "src/lib.rs:17" names no particular file -- every crate in a
workspace has one. srclines cannot open it for a snippet, SARIF cannot annotate
it, and underRoot resolved it with filepath.Abs, i.e. against the PROCESS cwd,
so whether a span counted as inside the scanned tree depended on where the scan
was launched from. Resolving against the crate directory the module was lowered
with fixes all of it, and makes the built path agree with the source-lowered one,
which reports absolute paths.

That agreement is what the golden needed: the corpus now passes both with and
without GODZILLA_ALLOW_BUILD, where before it could only match the machine it was
generated on. Two rows that had been pinned as `src=-|sink=-` -- no position at
all -- now carry real ones. The three remaining `-` rows are dependency spans
outside the crate, which the sysroot filter rejects by design and identically
either way.

Found alongside it, in the same area: main.go called buildpolicy.SetAllowed
unconditionally, so the -allow-build flag's FALSE default unset
GODZILLA_ALLOW_BUILD -- the environment variable that flag's own help text offers
as the alternative could never take effect through the CLI. Only an explicitly
passed flag decides the policy now.

The regression tests pin both halves, since they pull in opposite directions: a
crate-relative span must be joined onto the crate dir, and an absolute sysroot
span (everything expanded from format!) must still be rejected rather than joined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A scan of the same tree returned a different finding count on each run --
127/117/117 on gogs, and 5..21 for go-command-injection alone with the IR,
the scope and the rule all held fixed. Two independent causes, both map
iteration order reaching a first-seen-wins decision:

  - buildMethodImpls, buildCallers and buildGlobalReaders each build their
    name lists by ranging over a map, so the worklist visited functions in
    a per-run order. Every summary channel is first-seen-wins, so that
    order decides which origin a callee's summary keeps. Sort them.

  - stringParamOrigin picked ONE parameter out of `seeds` with a `break` on
    first match. Several parameters share an origin whenever a call site
    passed the same tainted value twice, and which one won was random --
    sometimes a string parameter (summarize the wrapper), sometimes the
    receiver (drop it). Return every string match instead: the engine
    cannot tell which parameter carried the value to the sink, so
    summarizing one silently drops a caller that taints another.

Net effect on gogs: 134 findings, byte-identical across runs, +6 over the
union of three pre-fix runs and none lost. The added ones are real: a route
param reaching git-module's BranchCommitID/TagCommitID, whose wrapper
previously had only one of its parameters summarized. Corpus signal/noise
is unchanged at precision 1.000 / recall 1.000 over 325 samples.

Tests pin the sortedness of all three indexes (with a guard against the
assertion going vacuous) and the every-match contract of
stringParamOrigins, because losing either shows up as a flaky number
rather than a failure.
react-xss has fired ZERO times across all 61 applications in the CVE-recall
campaign. Its sink is right -- dangerouslySetInnerHTML, via the synthetic
js:__godzilla_react_html -- but its only sources were three react-router hooks
and the shared browser globals, so no value a component was HANDED could ever
reach it. Ghost CVE-2026-24778 is the shape it was missing:
`this.context.site.portal_signup_terms_html` rendered as raw HTML.

Two halves:

  - A capital-initial function's destructured first parameter now binds each
    property to a `js:props.<key>` read, reusing the trick COV-11 uses for a
    destructured request handler. `function C({html})` is the dominant React
    idiom and it lost the prop entirely: the pattern binds no identifier, so the
    parameter took an `_arg0` name and `html` read as an unbound global. The
    predicate is React's own dispatch rule -- JSX compiles a lowercase tag to a
    host-element string, so only a capital-initial function can be a component.
    Deciding by "first parameter is destructured" instead would have downgraded
    a destructured `{req}` handler from a request source to a plain local.

  - A new rule, react-xss-props, at severity: low. The `this.props`/`this.context`
    forms already lowered to matchable callees and needed only the globs. It is
    split out rather than added to react-xss for the reason ruby-xss-cell-option
    is split: a prop is not request input by construction, so seeding it inside
    the gating rule would put every component that renders its own argument into
    the default -fail-on medium gate. It carries no `extend:` -- pulling in the
    browser-source fragment is what cost ruby-xss-cell-option six corpus false
    positives.

Also drops ghost's `subdir: ghost` from the campaign's projects.yaml. Ghost is a
monorepo and that scoped the scan to its core package, excluding apps/portal --
the tree this CVE's own ground-truth files live in. The entry could not score no
matter what the engine found.

Measured on Ghost at the vulnerable ref: 5 findings, all 5 in ground-truth files,
landing on signup-page.js:560 -- the exact vulnerable line -- plus offer-page and
the three accent_color injections the same commit fixed. The CVE goes
class-only -> HIT. Corpus holds at precision 1.000 / recall 1.000, TP 239 -> 240
over 327 samples.
Ruby erased every hash: a keyword list lowered its pairs for side effects and
returned the constant "", so `t(key, name: tainted)` dropped the value and a
`**splat` was not lowered at all -- assocPairs kept only assoc_new. Python has
carried keyword taint through builtin.kwarg since it was written; Ruby simply
never opted in. decidim CVE-2024-41673 is the shape, and ruby-xss-cell-option's
own comment already named this as the reason it could describe the CVE without
detecting it.

The frontend now appends one builtin.kwarg marker per pair AFTER every positional
argument, and lowers a `**` splat as a plain trailing value. No engine or gIR
change: builtin.kwarg is already an intrinsic propagator and already unwrapped
into Arg.Name for rule guards.

The hash keeps its inert placeholder in the positional slot, and that is
load-bearing rather than tidy. A rule pins its injection point by logical index,
and `User.where(name: params[:q])` puts its hash at exactly the index
`ruby:*.where#1` names -- let a tainted value occupy the slot and every
parameterized ActiveRecord query becomes an injection finding. Appending is the
same choice the Python frontend makes for its splat markers. On the callee side,
paramNames grows a matching inert `@kwargs` slot so the appended markers land on
the keyword parameters rather than one slot early; the alignment is by order and
only holds when the call passes every declared positional, which the comment says
plainly.

Ruby sinks were almost all unpinned, and an unpinned sink treats every argument
as an injection point -- so keyword taint turned six ordinary Rails idioms into
findings. Each gets the pin it should always have had: redirect_to/redirect #0
(a flash `notice:` is not the destination), send_file/send_data #0 (`filename:`
is a Content-Disposition value, not a path), and the SSRF sinks pin their URL,
where the `not hostFixed()` guard actively made it worse by reading a tainted
keyword as a controllable host. `render` gets a per-sink `when:` naming
html/inline/plain/text/body, which separates real HTML output from `locals:`
(auto-escaped) and `json:`.

That last one is also a detection gained: `render html: params[:q]` is what
ruby-xss's sink comment always described and could never reach.

Measured: decidim CVE-2024-41673 goes class-only -> HIT. On the decidim tree the
only rule that moves is ruby-xss-cell-option, +6 (22 -> 28), all severity: low so
none gate, and all the same cell-option-through-a-translation shape as the CVE.
No other rule changes on a large Rails app. Corpus holds at precision 1.000 /
recall 1.000, FP=0 FN=0 over 329 samples.
Quality pass over the previous two commits. No intended behavior change, with
one exception noted below.

  - builtin.kwarg's two-channel contract (Operands feeds the taint propagator,
    Call.Args feeds unwrapKwarg) was written out in both the Python and Ruby
    frontends. Moved to ssabuild.SetKwargMarker so the pairing -- whose failure
    mode is silent taint loss -- is stated once.

  - The JS collector carried a `components` map mirroring `handlers`. The two are
    not the same kind of fact: `handlers` records something only the route
    registration elsewhere in the AST knows, while component-ness is a pure
    function of the function's own name, which pendingFunc already holds. Derived
    at the use site instead, dropping the map and four plumbing sites. The
    predicate moves next to the rest of the React model in lower.go and uses the
    byte-range form the repo already uses for first-character checks.

  - Ruby: `pairParamNames` replaces two copies of the same [ident, default] walk;
    `symbolText`/`labelName` replace three spellings of the same unwrap. That
    third one was not cosmetic -- assocKeyName read a symbol key one level too
    shallow, so `render :html => params[:q]` produced a nameless marker the new
    kwargs guard could not see. The hashrocket spelling now fires.

  - Ruby: a literal keyword key was lowered only to discard the result, which
    allocates a constant nothing reads; only a computed key needs it now.
    appendArgList appends into a caller-supplied slice, so lowerDotCall builds
    its argument list in one exact-sized allocation instead of three.

  - A `**splat` inside a hash in VALUE position is now lowered like one in
    argument position; the two paths had silently diverged.

  - Extracted _js-html-sanitizers.yaml. react-xss-props had inlined the same five
    globs _js-sfc-xss.yaml carries, and a sanitizer known to one raw-HTML rule but
    not another is a false positive on the same file in the same scan.

  - Comments: the pin-vs-keyword rationale was stated in five places and the Ruby
    receiver-offset convention in three. Each rulepack now points at the sample
    that pins it, and the convention -- including why a pin cannot name a keyword
    -- is documented once in docs/writing-rules.md.

Corpus unchanged at precision 1.000 / recall 1.000, FP=0 FN=0 over 329 samples.

Not taken, all three genuine and all larger than a cleanup: binding a kwarg
marker to a callee parameter BY NAME in the engine's handleCall (which would
retire the @kwargs placeholder and close the known misalignment when a call omits
an optional positional); setting CallCommon.MethodName in the Ruby frontend so
its sink indices match the documented receiver-excluded convention (it also feeds
CHA, so it is a behavior change); and extending the sink spec to name a keyword
injection point directly, which is a Rule-model change and needs sign-off.
@SYM01
SYM01 merged commit 3ebbd83 into main Aug 22, 2026
8 checks passed
@SYM01
SYM01 deleted the claude/rolldown-vs-esbuild-qcao2g branch August 22, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants