Sign in

Source SAST

One row in rules/cwe-22-path-traversal.toml names three different analysis engines:

[[sinks]]
name      = "open"
detectors = ["source-c", "source-python", "binary"]
args      = [{ idx = 0, kind = "Path" }]
notes     = "POSIX open() / Python builtin open() — first arg is the path."

It is the only row in the catalog that names all three. source-c merges it into a tree-sitter walker's sink table, source-python into a rustpython AST walker's. The binary engine — the SSA taint engine over lifted machine code — already carries an identical hardcoded open, so it discards the catalog copy on the name collision: three surfaces behave the same, two are driven by this row.

RowsEngines namedWhich rules
1,190at least oneevery row carrying a detectors line
1,084exactly onesingle-surface rules
106more than onethe genuinely shared part of the catalog
96source-c + binaryC-level primitives — libc string, format, file and privilege calls, SSL_CTX_set_verify, and the SQLite, libpq and MySQL query entry points
9Kotlin + JavaJVM-family pairs
1source-c + source-python + binarythe open row above

Naming an engine is not the same as driving it: of those 96 rows, 25 are the definition the binary engine actually reads, and the rest are shadowed, gated out or unmodelled.

Both surfaces emit the same finding shape — CWE id, rule id, severity, confidence, location — into the same store, and both project into the same SARIF 2.1.0 run. The binary engine answers what is wrong with this code? by lifting an executable; the source walkers answer it by parsing the code the executable came from. system(user_input) fires the same CWE-78 topic whether an analyst uploaded the C file or the compiled ELF.

Ten language walkers run in production over uploaded source and over files pulled out of firmware images, propagating taint from request objects and process inputs to catalog sinks, alongside two secret scanners and a dependency inventory.

QuestionSection
What does a run actually emit?Worked example: Python command injection
Which languages are covered, and by what parser?Production language walkers · Walkers outside production dispatch
Where do the rules come from?The shared catalog
When does a sanitiser really clear a flow?Taint propagation and sanitiser scope
How are credentials and dependencies found?Secret detection: two subsystems · The firmware filename gate · Secret output modes · Source SCA
What gets skipped, and where do findings land?File dispatch and size limits · Storage and SARIF export
How good is it, and what does it miss?The labelled corpus · Gap-corpus scores · Limits

Worked example: Python command injection

The Python walker returns 30 findings on a 130-line Flask fixture and none on the lines marked safe. training/vulnerabilities/cwe-78-python-os-system-injection/main.py carries semgrep-style markers — # ruleid: on a line that must fire, # ok: on a line that must not:

 7  @app.route("/route_param/<route_param>")
 8  def route_param(route_param):
10      # ruleid: os-system-injection
11      return os.system(route_param)

13  @app.route("/route_param_ok/<route_param>")
14  def route_param_ok(route_param):
16      # ok: os-system-injection
17      return os.system("ls -la")

37  @app.route("/get_param_inline", methods=["GET"])
38  def get_param_inline():
39      # ruleid: os-system-injection
40      os.system(flask.request.args.get("param"))

120         md5 = hashlib.md5(url+app.config['MD5_SALT']).hexdigest()
122         # ruleid: os-system-injection
123         r = os.system('wget %s -O "%s"'%(url, fpath))

Abridged, one line per finding:

cwe  rule_id                                  severity  confidence  location                            symbol
78   command-injection.os-system-non-literal  Critical  Medium      main.py:11:12   route_param         os.system
78   command-injection.os-system              Critical  High        main.py:11:12   route_param         web route handler parameter
78   command-injection.os-system-non-literal  Critical  Medium      main.py:40:5    get_param_inline    os.system
78   command-injection.os-system              Critical  High        main.py:40:5    get_param_inline    http-query
78   command-injection.os-system-non-literal  Critical  Medium      main.py:75:5    post_param          os.system
327  weak-crypto.broken-primitive             Medium    Medium      main.py:120:15  index               weak crypto
327  weak-crypto.hashlib-md5                  Medium    Medium      main.py:120:15  index               hashlib.md5
78   command-injection.os-system-non-literal  Critical  Medium      main.py:123:13  index               os.system
78   command-injection.os-system              Critical  High        main.py:123:13  index               http-form

Line 17 produces nothing.

Two tiers, but not on every line. 17 lines carry a ruleid: marker; 11 get both tiers and 6 get only Tier-A. command-injection.os-system-non-literal is Tier-A: the argument to os.system is not a string literal, true regardless of where the value came from — Medium confidence, naming the sink. command-injection.os-system is the taint verdict — High confidence, naming the source the flow started from (web route handler parameter, http-query, http-form).

The six Tier-A-only lines are a source-recognition gap, not a design choice. All six are the flask.request.form['param'] subscript idiom (lines 75, 82, 89, 96, 103, 110). The source table recognises flask.request.form.get('url', None) — line 118's value is tracked all the way to the sink on line 123 — but not the subscript form, so those flows are invisible to taint and only the non-literal shape survives. Route-handler parameters are auto-tainted, as line 11's High-confidence finding shows; Tier-A earns its place here by covering an unrecognised source idiom, and elsewhere by covering ordinary function parameters, which no rule auto-taints.

Location is path:line:col plus the enclosing scope name. That is what distinguishes a source finding from a binary one downstream: a binary finding puts a virtual address where this puts a line. The detector field does not — 118 of the 166 places a binary finding is built emit static-rule exactly as every source finding does, and only the other 48 emit heuristic or symbolic-exec.

A promoted case pins a floor, not a ceiling. The two findings on line 120 are correct CWE-327 hits inside a fixture written for CWE-78, and the case records all four rule ids, including the two weak-crypto ones it was never written to test — but a fifth rule appearing tomorrow would go unnoticed.

Production language walkers

Ten languages are dispatched by file extension in production, and nine of them propagate taint at binding level: a tainted name, not a tainted expression tree.

LanguageParserTaintSanitisersScope-typedCatalog rowsHardcoded sinks + calls
Pythonrustpython ASTbinding-level20yes32119 + 0
PHPtree-sitterbinding-level31yes2233 + 18
JavaScript / TypeScriptoxcbinding-level19yes2010 + 15
Ctree-sitterbinding-level2no1230 + 0
Rubytree-sitterbinding-level16no10773 + 25
Javatree-sitterbinding-level21no10039 + 7
Gotree-sitterbinding-level13no7448 + 25
C#tree-sitterbinding-level23no854 + 6
Kotlintree-sitterbinding-level24no935 + 23
Swifttree-sitternone220 + 0

The Catalog rows column counts shared-catalog entries carrying that walker's detector token, merged on top of that walker's own hardcoded table and deduplicated by name. It is not a coverage figure — read it against the last column, which is what the walker knows without the catalog. That last column counts hardcoded sink and dangerous-call tables only; shape rules held elsewhere, such as Swift's two config checks, are outside it.

TypeScript folds into the JavaScript walker: js, mjs, cjs, jsx, ts, tsx, mts, cts all route to the oxc parser, which picks TS flavour from the filename.

C has two sanitisers, realpath and canonicalize_file_name; nothing else in C clears a tainted value, so a hand-rolled path check leaves the flow tainted all the way to the sink. C is also the only walker whose behaviour is entirely catalog-driven — both its hardcoded tables are empty.

Kotlin and C# invert the ratio — 58 and 60 hardcoded entries against 9 and 8 catalog rows, so an edit to a shared topic file barely moves either walker. C# still carries 42 labelled corpus cases, because almost all of its detection lives in the hardcoded table underneath.

Swift has no taint propagation by design: it runs dangerous-call and shape rules. Its 22 catalog rows are 11 insecure-RNG, 6 log-injection and 5 SQLite entries; a hardcoded pair adds a secret-named UserDefaults key and JavaScriptCanOpenWindowsAutomatically = true. The SQLite check fires on any query argument it cannot resolve to a string literal, so a local bound to "SELECT …" stays quiet while an interpolated one fires. The six log-injection rules — NSLog, os_log, os_log_info, os_log_debug, os_log_error, os_log_fault — apply that same test to argument 0, and a non-literal format argument to NSLog is ordinary Swift, so CWE-117 is the family most likely to dominate the output on a real codebase.

Walkers outside production dispatch

Nine further walkers are absent from the production extension table and reachable only from the evaluation harness; a tenth crate, Rust, is wired to neither. The harness knows all nine, but training/gaps/ holds fixtures for only two.

WalkerMeasured byShape
Terraformgap harness only — 260 fixtures, no promoted proofs, no corpus gate26 distinct rule ids over HCL
Generic (any text file)gap harness only — 77 fixtures, no promoted proofs, no corpus gateruns the source secret scanner over the file
Scalain-crate corpus test over 22 promoted cases; zero gap fixturesline-based, with a per-method taint-lite context rather than a parse tree
Apex, Dockerfile, OCaml, Clojure, HTML, Bashin-crate corpus test over 12 / 7 / 7 / 5 / 4 / 3 promoted cases; zero gap fixtures
Rustnothingcrate exists with zero consumers, wired to neither production dispatch nor the harness

The shared catalog

Every rule ships inside the compiled binary: crates/rules embeds 40 cwe-*.toml topic files at build time, so there is no runtime filesystem read, and a missing catalog directory fails the build rather than degrading to a smaller built-in list at run time.

ContentsCountNotes
Topic files40cwe-*.toml, embedded at build time
Distinct CWE ids42
Dangerous-call entries640
Sink entries543
Lifecycle state machines719 transitions, 23 emit rules

The seven machines are C FILE* streams, POSIX file descriptors, descriptors opened on /dev/random or /dev/urandom, pthread mutexes, heap pointers, Go channels and Go mutexes. Only the heap-pointer machine routes to the binary engine; the other six are source-only.

Routing a sink to both the C walker and the binary taint engine is one field on the row: detectors = ["source-c", "binary"].

Merge order is hardcoded-wins: each consumer merges the rows carrying its own token on top of its hardcoded table, and a name collision resolves to the hardcoded entry, because the curated tables carry per-symbol severity tiers a topic-wide severity cannot express.

Gates are honoured or declined by each consuming engine. A non_literal_arg = 0 gate means "fire only when argument 0 is not a literal constant". The C walker sees the argument expression and can enforce it; the binary side's import scan cannot, so it drops those rows instead of firing them unguarded:

if row.dc.non_literal_arg.is_some() {
    // Source-level literal-arg gate doesn't translate to the
    // binary import-scan layer; skip rather than emit unguarded.
    continue;
}

14 dangerous-call rows declare binary and are dropped by that gate:

Dropped familyCallsWhy the import scan cannot judge it
CWE-285 POSIX privilege10 — setuid, seteuid, setreuid, setresuid, their group twins, initgroups, setgroupssetuid(0) and setuid(NOBODY_UID) are ordinary startup code; only a caller-derived UID is the bug
CWE-120 bounded copy4 — strncpy, strncat, stpncpy, wcpncpythe bug is a size argument the scan never sees

SSL_CTX_set_verify, which carries no gate, fires on both surfaces.

Enterprise overrides append topics on top of the embedded floor and never replace the built-in catalog. The CLI entry point parses and validates the override directory before dispatching any command, and a configured-but-unreadable override aborts startup rather than running a partial ruleset. Ordering is enforced there, not by the catalog: the active catalog caches on first read and a second install call is silently discarded, so a consumer that reads before that hook runs gets the embedded floor with no error.

Which shared rows actually drive the binary engine

A row's detectors line is an intent, not a guarantee.

Fate on the binary sideRowsEffect
Shadowed by an identical hardcoded entry55detection unchanged, but editing the catalog row changes nothing on the binary surface
Dropped by the non_literal_arg gate14source-only in practice
Kind not modelled by the binary sink table1PQexecParams — no CWE-89 sink kind on the binary side
Lifecycle machine1heap-pointer state machine
Catalog-driven — the row is the definition25an edit reaches the binary engine

Across the whole catalog the same shape holds: 64 sink rows declare binary, and 13 survive dedup and kind-mapping to become entries the binary engine did not already have.

The binary engine models SQL injection but refuses a catalog SQL row. Its sink table defines a SqlInjection kind and hardcodes sqlite3_exec, sqlite3_prepare, sqlite3_prepare_v2, PQexec, mysql_query and mysql_real_query, yet the catalog importer maps the sql-string kind to nothing. Adding a SQL sink to rules/cwe-89-sql-injection.toml with detectors = ["binary"] is a silent no-op even though the capability is there — a wiring omission, not a precision tradeoff. The C walker is worse off: the same seven SQL rows declare source-c, its kind map drops sql-string too, and its hardcoded sink table is empty, so it cannot emit a CWE-89 finding at all and the catalog's SQLite, libpq and MySQL entries are inert on the source side.

Taint propagation and sanitiser scope

Taint is binding-level and per file. Sources — argv, environment, stdin, and framework request objects recognised by name and idiom — mark bindings tainted; assignment propagates; reassignment to a clean value kills it; a sink entry keyed on callee name and argument index fires when a tainted binding reaches it.

A sanitiser that clears every sink kind is a false-negative machine. os.system("ping " + re.escape(user)) is still command injection; a regex escape neutralises nothing a shell cares about. Python, JavaScript and PHP therefore type their sanitisers by scope — Universal, Html, Url, Shell, Sql, Path, Regex — and the walker asks whether that scope clears this sink kind. Five Flask handlers identical but for the wrapper around the tainted value, through the Python walker:

# five files, each:  @app.route("/p/<user>")
#                    def f(user): <the one line below>

os.system("ping " + user)                -> os-system-non-literal, os-system
os.system("ping " + re.escape(user))     -> os-system-non-literal, os-system
os.system("ping " + html.escape(user))   -> os-system-non-literal, os-system
os.system("ping " + shlex.quote(user))   -> os-system-non-literal
os.system("ping " + str(int(user)))      -> os-system-non-literal

re.escape has scope Regex and clears nothing; html.escape has scope Html, which does not cover a shell argument; shlex.quote and the integer cast kill the taint verdict. The Tier-A non-literal rule survives all five, because the argument is still not a literal.

Six walkers do not have this, and in all six any sanitiser clears any sink:

WalkerSanitiser recordScope-typed
Python, JavaScript, PHPname plus scope — Universal, Html, Url, Shell, Sql, Path, Regexyes
Cname, a return flag, and a list of out-arguments the call also cleansno
Go, Java, Ruby, C#, Kotlinbare nameno

Runtime.exec on a value passed through Encode.forHtml is a false negative in the Java walker today.

Where scope is uncertain it stays Universal: narrowing a sanitiser can only create new false positives, so a scope is tightened only when the escaper is unambiguously context-specific.

The binary side's SSA lattice is the deeper instrument — dominator analysis, alias-aware seeding, interprocedural summaries (Findings). The source side sees names, argument expressions, and framework idioms the binary lost at compile time.

Secret detection: two subsystems

"High-entropy string inside a compiled binary" and "AWS key in a config file" have different false-positive profiles, so they are different scanners.

Binary-magic secretsSource-side secrets
Scanscompiled bytestext
Anchored detectors20 magic-anchored signatures, one per secret kind28 typed high-precision rules
KindsPEM private/public/certificate blocks, OpenSSH and PuTTY private keys, X.509 DER, PKCS#8/#12/#7, raw RSA and EC DER keys, PGP private keys, OpenPGP RSA session keys, JWTs, AWS AKIA/ASIA keys, GitHub tokens, Slack tokens, GCP service accounts, crypt(3) and LDAP-SHA password hashesaws-access-key-id, github-pat, slack-bot-token, google-api-key, openai-api-key, stripe-live-secret and the rest
Unanchored passes3, with no single magic to anchor on, so they slide over the whole region: expanded AES round-key schedules, expanded SM4 schedules, a fingerprint table of known-leaked firmware signing keysa vendored gitleaks pattern set, a Shannon-entropy scanner, an obfuscation pass that hex- and base64-decodes candidates and rescans the plaintext
Structural parsersa real DER parse or key-structure check behind every signature, never a regex alonePEM bodies, /etc/shadow rows, htpasswd files, default-credential files, network-service configs
Cap256 findings per binarynone

The firmware signing-key table is transcribed from Binarly's FwHunt supply-chain rules: leaked OEM unlock keys, MSI Boot Guard and firmware-capsule keys, the Intel Alder Lake leak.

Three source-side structural parsers cover credentials the prefix and entropy rules miss.

ParserFires onEmits
Password hashesits own basename list, separate from the credential-file gate below — shadow, shadow.default, shadow-, gshadow, gshadow-, master.passwd, spwd.db, smbpasswd, smbpasswd-, vsftpd.user_listseverity gated on the crypt scheme
Network-service configsits own basename list — wpa_supplicant*.conf, hostapd*.conf, wg*.conf / wireguard*.conf, snmpd.conf, *.ovpnCWE-798 for WPA PSKs and SNMP community strings, CWE-321 for WireGuard and inline OpenVPN key material
Vendor default credentialsthe credential-file gate below or a network-config name, then one of 15 shipped defaults (admin, password, 1234, root, toor, changeme, the empty string and the rest) or username-equals-password for an admin-ish usernameCWE-1392

The shadow parser is not a regex. It reads user:$id$rounds$salt$hash and gates severity on the scheme, because MD5-crypt cracks in minutes where SHA-512-crypt takes days.

The default-credential parser is a closed list, not a dictionary. It catches the admin:admin row a token scanner reads as low-entropy noise, and never fires on a hashed value.

Redaction shape. A binary-magic finding carries first 8 characters, ellipsis, last 4, 64 bytes maximum; PEM reduces to its header line, DER to a 16-byte hex prefix. The redaction protects the report, not the artifact — the verbatim bytes stay recoverable from the binary itself at the recorded offset and length.

The firmware filename gate

Over files pulled out of a firmware image the text scanner is filename-gated, because a generic password\s*= content grep returns about 0.1% true positives on real firmware extractions — jQuery's password:!0 selector, form handlers, sample configs. Restricting content scans to files whose name already says credential lifts precision to roughly 70%. 26 of the 28 typed patterns run on every file regardless, because AKIA followed by 16 base32 characters is not ambiguous; only basic-auth-in-url and cloudflare-global-api-key sit behind the gate, being the two whose shape is not self-identifying.

The gate is a closed list, matched exactly — there is no globbing, so id_rsa.bak and id_rsa_backup, the two shapes a careless backup actually produces, do not open it.

Opens the gateValues
17 basenameshtpasswd, passwd, credentials, credentials.json, .env, .npmrc, .netrc, .pypirc, config.json, secrets.yml, secrets.yaml, id_rsa, id_dsa, id_ecdsa, id_ed25519, authorized_keys, known_hosts
13 extensionspem, key, crt, cer, der, p12, pfx, p7b, keystore, jks, htpasswd, kdbx, env
Not on itshadow — it runs on its own list, feeding the shadow parser rather than the pattern table

The gate cuts both ways: the gitleaks set, the entropy scanner and the obfuscation pass run only behind it, so a token sitting in an extracted /www/js/app.js is found only if it matches one of those 26 prefixes or carries PEM armour, which is checked on every file because -----BEGIN is its own out-of-band signal. The scan also sees only the first 4 MiB of each extracted file — the head the unpack pipeline already holds for the artifact classifier and the banner scan — so a credential deeper in than that is invisible whatever its shape. A recognised manifest or package-database path is the one exception, read to 128 MiB so a multi-megabyte rpmdb survives intact, and that whole head goes to the secret scanner too.

Two further losses are silent:

Secret output modes

Output differs by mode, and neither mode puts the matched bytes in the report.

Firmware modeSource mode (generic walker)
Filename gatecontent scans restricted to credential-named filesnone
Typed patterns26 of 28 on every file; 2 behind the gateall, over every line of every file
gitleaks setbehind the gateover every line of every file
Output fieldsredacted preview plus a BLAKE3 fingerprint of the raw matchrule id, CWE id, line number
Matched bytes in reportnevernever

The BLAKE3 fingerprint collapses one Apache testkey.pem rehosted under eight paths on a NAS rootfs into one finding and seven sightings instead of eight independent rows.

Dropping the filename gate is paid for by a per-match filter. A hit must:

So AKIAIOSFODNN7EXAMPLE in a README stays quiet, and so does a fixed-length pattern that matched only the prefix of a longer ==-padded dummy. FAKE, SAMPLE and TEST are deliberately absent from that list: they occur as substrings inside genuine high-entropy tokens, so excluding them would trade false positives for false negatives.

Source SCA

Dependency manifests and lockfiles are parsed for 11 ecosystems into a plain inventory — ecosystem, name, version, origin — with no transitive resolution and no network access, because a lockfile already enumerates the resolved tree. CVE attribution happens downstream, and the ecosystem determines how.

EcosystemsCVE join
npm, PyPI, Packagist, RubyGems, Go modules, Cargo, Maven, Debian dpkg, Alpine apkmap onto an OSV ecosystem name directly
opkg, RPMmatched by package name against a curated native table — there is no OSV OpenWRT feed, and the distro-release qualifier the OSV rpm feeds require is not recoverable from the rpmdb alone

That native-table join is the same one described in CVEs & SBOM, which covers the firmware-tree side of it.

Bundled JavaScript is found by a separate scanner that recognises 15 banner patterns — jQuery, Bootstrap, Angular, lodash, moment and others — in web assets. It reads the licence banner, not the filename: /*! jQuery v1.10.2 | (c) 2005, 2013 jQuery Foundation yields jQuery 1.10.2, which is how a vendored copy no lockfile mentions still reaches the CVE join. A hit requires the canonical name and an adjacent dotted version, so prose like "see jQuery 1.x for details" does not match — and a bundle whose build stripped the licence header is invisible.

File dispatch and size limits

One entry point routes a file by extension through a single language table, skips non-UTF-8 content, and never errors: an unparseable file yields zero findings, not a failed analysis.

A file over 8 MB is skipped whole and logged, not truncated. The cap exists because firmware carries huge generated artefacts under source extensions (minified bundles, machine-emitted C#, data-table Python) that are zero-signal for taint analysis and cost a full parse tree in memory, which is an adversarial-upload denial-of-service vector. The consequence is a real blind spot: an over-cap .js bundle yields no taint findings at all, whatever is in it.

Where content is truncated upstream, tree-sitter and oxc error-recover, so the loss is recall on the tail rather than a fabricated finding — a half-parsed trailing function is absent from the tree rather than misread.

Storage and SARIF export

Source findings render through the same report path as binary findings into the same findings store, and the pipeline worker runs the walk off the async thread pool because a taint walk is CPU-bound.

The access-control scope row is written first: a failure there aborts the job rather than storing a report with no scope record, since the read path fails closed on a missing record and the alternative is a silently invisible child file.

SARIF export projects a source finding as an artifact URI plus a line region, a binary finding as an address, in the same run. The source path is attacker-influenced — it comes from an uploaded archive — so the exporter strips traversal and absolute components, and falls back to a synthetic artifact when nothing safe remains, rather than letting a hostile path escape into the emitted URI.

The labelled corpus

training/vulnerabilities/ holds 362 promoted cases across 18 languages, each recording the rule ids the walker must emit — and 120 of them are ungated, sitting in the corpus with nothing re-running them.

LanguagePromoted casesRe-run by
Python68integration test
C#42in-crate test
Java34nothing
PHP34nothing
JavaScript32nothing
Go27integration test
Scala22in-crate test
Ruby20nothing
C15integration test
TypeScript13in-crate test
Kotlin13in-crate test
Apex12in-crate test
OCaml7in-crate test
Dockerfile7in-crate test
Clojure5in-crate test
Swift4in-crate test
HTML4in-crate test
Bash3in-crate test

JavaScript is the surprise: the crate does have a corpus test, but it filters on -typescript- in the case directory name, so it re-runs the 13 TypeScript cases and skips all 32 JavaScript ones.

A gate asserts a floor: every recorded rule must appear, and extra rules are allowed through, because one sample can legitimately trip several detectors — so a rule that starts over-firing on a promoted case is not caught here. The gates also carry a patched-twin path — a case marked class = "patched" must fire nothing at all — but no case is so marked, so that path never runs.

One of the 362 records an empty rule set: a CWE-611 Express XXE case whose two vulnerable sites carry upstream semgrep's own todoruleid: markers, because the rule is not written in a way that can find them. It is a fixture with no positive assertion to make rather than a walker miss.

Gap-corpus scores

241 of the 966 scored gap fixtures pass. training/gaps/ holds 1,001 unpromoted fixtures with ruleid:/ok: markers, of which 966 have a walker to score them; running every walker against every one of those:

LanguageFixturesPassMisses a ruleid: lineFires on an ok: lineBoth
Terraform2602423600
Python235691361119
JavaScript14132881110
Java106335779
Ruby79204748
Generic (secrets)77284423
Go683514136
Solidity35not scored

Terraform is the extreme: 26 HCL rule ids against 260 fixtures, so 236 of them miss at least one line they are required to flag, and none over-fire. Go is the opposite shape — the highest pass rate at 35 of 68, and the highest over-fire rate, 13 fixtures where a finding lands on a line marked safe. Solidity is the remaining 35: fixtures with no walker at all, so the harness does not even load them.

The harness matches by span, not by line, and ignores rule id. A marker owns the source from its own line to the next marker or end of file, so a ruleid: marker is satisfied by any finding anywhere in that stretch — one on the right line for the wrong reason counts as a pass, and so does one several lines further down. fn:, fp:, todook: and todoruleid: are neutral: they bound a span without voting either way, which is how an upstream fixture's known-unmatchable sites stay out of the score. Promotion into the labelled corpus is what re-records the rule ids. The recurring blocker in this loop follows from the same blindness: a correct finding from rule A lands inside rule B's ok: span and marks B failed. The fix is a hand-authored case that isolates B by removing A's trigger, never a weakened A.

Limits

The catalog is the seam that keeps the two engines honest with each other, and it is narrower than the row count suggests: 106 rows name more than one engine, 25 of the 96 cross-surface C rows drive the binary engine, and on the other 1,084 the two surfaces are only agreeing on a schema.