Rule catalog

This page is transcribed from docs/rules/catalog.md by scripts/gen-site-rules.mjs — that file is also, byte for byte, the rule-catalog contract document an agent reads — and a meta-test (crates/engine/tests/rule_contracts/) machine-checks that every id listed below matches what the engine actually loads at runtime, so the catalog cannot silently drift out of sync with the code. Each DSL pack rule below is suppressible inline with a // zzop-<rule-id>-ok comment on, or directly above, the finding's line (the marker is derived from the rule id — rule float-money-compare takes // zzop-float-money-compare-ok); native analyses are disable-only, with two comment-driven exceptions — non-idempotent-write/unsafe-read-endpoint honor a hand-written // idempotent-ok: <reason> (trailing colon required), and dead-candidates/unimported-export skip files carrying a generated-file banner. Every rule and native analysis id can also be turned off per-run instead — in zzop.config.jsonc via rules: { "<id>": "off" }, or via disabledRules for embedders.

Scope: every table below is a rule the binary loads by default, and that is not every rule this repository ships. examples/packs/ holds the exported packs — real, tested, axis-declaring rules that are deliberately not compiled into the default set, so none of them has a row below; where one is named at all, it is a bundled rule's row pointing across at it. (ls examples/packs/*.json is the roster; docs/rules/catalog.md § Exported packs states which test moved each pack out and carries the command that counts the rules in them). Exported is not deleted: each pack is served as a contract document whose text IS the pack JSON — MCP resource zzop://contract/example-pack-<stem> on MCP hosts (zzop contract example-pack-<stem> with the CLI binary; the contract index lists one entry per exported pack). Write one under a tree's zzop/rules/ — the default authored-pack location — and the next run loads it, no config key needed.

Which languages a rule reaches is a per-rule fact, decided by that rule's own file_pattern and nothing at the pack level, so a pack can be dense for one language and empty for another. Every natively parsed language is reached by at least one rule today — C# included, and by several rules (the call-scan hash rules speak C#'s own vocabulary, MD5.Create/ HashAlgorithm.Create; others admit .cs by path candidacy) — but the distribution is very uneven and no per-language total is published, because the number is not well defined: some patterns are directory-scoped as well as extension-scoped, so two .ts files in one repo are eligible for different counts. Ask about a concrete PATH instead — docs/rules/catalog.md carries the one command that answers it against the shipped packs.

In Rust, findings inside a test region are dropped — except the credential rules. A finding landing inside a #[cfg(test)]/#[test]-gated item is subtracted before it is reported; the credential-at-rest rules below opt out and say so in their own row ("Scans test paths too"), because a committed key is leaked whether or not the compiler keeps it. So the axis is "test regions are excluded EXCEPT for credentials at rest", never "everything is excluded". Mechanism and boundaries: docs/rules/dsl-reference.md.

db

Rule idSeverityMatcherDetects
update-delete-no-wherecriticalmethod-scanupdateMany/deleteMany called with no populated where: clause anywhere in the enclosing function — a whole-table write. An empty where: {} counts as no filter (Prisma writes every row) and still fires; a populated, multi-line, or dynamic where: vetoes.
pagination-no-orderbywarningmethod-scanskip/take pagination used with no populated orderBy anywhere in the enclosing function — page boundaries can shift between requests without a stable sort. An empty orderBy: {} (no ordering applied) counts as none and still fires.
client-new-in-handlerwarningmethod-scannew PrismaClient() constructed inside a function that also looks like a request handler — exhausts the DB connection pool under load.
external-call-and-txwarningmethod-scanA network call (fetch/axios/got) in the same function as a $transaction( — extends transaction lock hold time across a network round-trip.
unawaited-writewarningline-scanA DB write (create/update/delete/upsert) on a DB-client-shaped receiver (prisma/db/tx/client/repo/...) whose promise is neither awaited, returned, nor chained — fire-and-forget; a failed write looks identical to a successful one.
unbounded-user-limitwarningline-scanA take/limit pagination size read directly from user input (req.query/req.params/req.body) with no upper-bound clamp — unbounded page size, a cheap memory/CPU exhaustion vector.
find-then-create-no-uniquewarningmethod-scanA findFirst/findOne/findUnique read followed by .create( in the same function with no connectOrCreate/upsert/ON CONFLICT anywhere — check-then-act race, concurrent requests can create duplicate rows (a bare $transaction does not close it).
float-money-compareinfoline-scanA money-named identifier (price/amount/balance/fee/cost) compared with ==/=== against a float literal — IEEE754 rounding makes strict equality on monetary values unreliable.
empty-catch-and-writewarningmethod-scanA DB write — an ORM call (create/update/delete/upsert/updateMany/deleteMany) or a raw-SQL statement head (INSERT INTO/UPDATE <table> SET/DELETE FROM, UPPERCASE-only) — in the same function as an empty catch {} — write failure is silently discarded.
multi-write-no-txwarningmethod-scanA create-family write (create/createMany/insert, or a raw-SQL INSERT INTO head) and a mutate-family write (update/delete/upsert/..., or a raw-SQL UPDATE <table> SET/DELETE FROM head, all raw-SQL heads matched UPPERCASE-only) in the same function with no $transaction(/bare transaction(/SQL BEGIN/driver batch( — a failure between the two leaves partial state (co-occurrence heuristic; independent writes suppress with the marker).
non-atomic-counter-updatewarningmethod-scanA field: value +/- 1 arithmetic object entry appears LEXICALLY AFTER a findUnique/findFirst/findOne/findById read in the NEAREST enclosing function, with no atomic increment:/decrement:/$inc/FOR UPDATE anywhere in it — the reported line is the first arithmetic entry that actually follows a read, and a read in a sibling closure does not pair. Textual order, not dataflow: nothing proves the arithmetic recomputes the field the read returned, so an unrelated take: cursor + 1 pagination bound after a read still fires. When it is the shape it names, a read-modify-write counter loses updates under concurrency.
connection-no-releasewarningmethod-scanpool.connect(/getConnection(/acquireConnection( with no .release(/.destroy(/.end(, no return conn, and no using/await using declaration in the same function — a connection leak under load (release in a callee still fires; verify before refactoring).
client-new-in-loopwarningmethod-scanA DB/connection client or pool constructor (new PrismaClient()/new Pool()/new Sequelize()/new MongoClient()/new IORedis()/new Redis(), createPool/createClient/createConnection) proven via loop spans to sit structurally inside a loop body — a fresh connection/pool per iteration exhausts the pool. The loop-proven sibling of client-new-in-handler, which fires on request-handler co-occurrence instead.
write-in-loop-no-txwarningmethod-scanA DB write — on a DB-client-shaped receiver, or a raw-SQL INSERT INTO/UPDATE <table> SET/DELETE FROM statement head (UPPERCASE-only) — proven via loop spans to sit structurally inside a loop body, with no $transaction(/bare transaction(/SQL BEGIN/driver batch( anywhere in the function — each iteration autocommits independently, so a mid-loop failure leaves a partial, non-rollback-able prefix. The loop-proven sibling of multi-write-no-tx, which needs two different write-verb families to co-occur and misses a single-verb write repeated in a loop.
check-then-act-in-loopwarningmethod-scanA .create(/.insert( call whose own line is proven via the parser's projected loop spans to sit inside a loop body appears LEXICALLY AFTER a findFirst/findUnique/findOne/exists read in the NEAREST enclosing function, with no connectOrCreate/upsert/ON CONFLICT/ON DUPLICATE KEY guard anywhere in it — a batch get-or-create race across concurrent workers/retries. Only the create is loop-proven: the read is NOT itself proven to sit in the loop, and the order is textual, not dataflow (nothing proves the create writes the row the read looked for). The loop-proven sibling of find-then-create-no-unique, which is order-gated the same way but proves nothing about either call sitting in an iterated body.
idempotency-key-regenerated-in-loopwarningmethod-scanAn idempotency key assigned a fresh random value (randomUUID()/uuidv4()/nanoid()/cuid()/...) on a line proven via loop spans to sit structurally inside a loop/retry body — every attempt carries a different key, so the server treats each retry as a brand-new request, defeating the idempotency the key exists to provide.
unawaited-transactionwarningline-scanA $transaction(/.transaction( call on a DB-client-shaped receiver (prisma/db/client) whose promise is neither awaited, returned, nor chained — runs detached, so the handler can send its response before the transaction commits or fails.
manual-tx-no-rollbackwarningmethod-scanA BEGIN/beginTransaction()/startTransaction() call and a COMMIT/.commit() call co-occur in the same function with no ROLLBACK/.rollback() anywhere in it (co-occurrence heuristic) — an error raised between BEGIN and COMMIT has nothing to release it, poisoning the pooled connection for whichever request borrows it next.
tx-and-empty-catchwarningmethod-scanAn empty catch {} co-occurs with a $transaction(/@Transactional/beginTransaction() call in the same function (co-occurrence heuristic, doesn't prove the catch wraps the transaction) — swallowing the error prevents the throw that would trigger a rollback, so a partially-failed transaction can commit as if it succeeded.
money-tx-no-isolation-levelinfomethod-scanA $transaction(/@Transactional span also matches a money/inventory-named identifier (balance/amount/payment/wallet/ledger/charge/refund/invoice/inventory/stock) and a write call (update/create/upsert/increment/decrement), with no explicit isolation level (isolationLevel/Serializable/RepeatableRead) or FOR UPDATE row lock anywhere in it — co-occurrence heuristic, a review nudge rather than a confirmed defect (doesn't prove the write touches the money field, or that the default isolation on the target engine is actually unsafe here); the database's default isolation (e.g. Postgres READ COMMITTED) can permit lost updates/write skew on a money-critical read-modify-write.
tx-and-db-call-in-loopwarningmethod-scanA call on a transaction-scoped client proven via loop spans to sit structurally inside a loop body while the enclosing function also opens $transaction(...) (co-occurrence heuristic for the wrapping, same discipline as external-call-and-tx) — the transaction, and the row/table locks under it, stays open for the whole loop, so lock hold time scales with the number of rows processed. Mirror image of write-in-loop-no-tx (which requires the transaction to be ABSENT) — mutually exclusive by construction, never co-fires. Batch the calls (updateMany/createMany/findMany) or move per-row reads out of the transaction.

reliability

Rule idSeverityMatcherDetects
async-route-no-catchwarningmethod-scanAsync Express/router handler registered with no try/catch, next(err), or .catch() — an unhandled rejection can crash the process or hang the request.
sync-fs-in-handlerwarningmethod-scanSynchronous fs/child_process call alongside request-handler context (req/res/ctx/...) — blocks Node's single event loop for every concurrent request.
map-async-no-promise-allwarningmethod-scan.map(async ...) used without Promise.all/Promise.allSettled — rejections become unhandled, ordering/completion guarantees are lost.
debug-true-committedwarningline-scanDebug flag or disabled TLS verification (debug: true, NODE_TLS_REJECT_UNAUTHORIZED=0, rejectUnauthorized: false) committed to source. The two TLS shapes are Node-specific; the debug: true shape is not, and fires on a Rust struct literal (Settings { debug: true }) as readily as on a JS object literal.
promise-all-and-writeswarningmethod-scanPromise.all(...) used alongside DB write calls (create/update/delete/upsert) — partial-failure non-atomicity, no rollback for writes that already committed.
json-parse-no-trywarningmethod-scanJSON.parse(...) called on apparent external input (req/body/params/query/...) with no surrounding try — malformed input throws instead of producing a handled 4xx.
fetch-no-timeoutwarningmethod-scanOutbound HTTP call (fetch/axios/got) on a backend-looking path with no timeout/AbortController visible in the same function — a hung upstream hangs the request indefinitely.
reqwest-no-timeoutwarningmethod-scanA reqwest client constructed inside a function (Client::new() / Client::builder() / Client::default() / ClientBuilder::new(), or a reqwest::get convenience call — in a file that names reqwest at all) with no .timeout( or .read_timeout( call in the same function body — reqwest's default client carries no request timeout. FUNCTION-LOCAL and lexical, and the veto is those method spellings only: a client built by a shared constructor elsewhere is invisible to it, a timeout set outside this function reads as absent, read_timeout clears it (it bounds the stalled read), and connect_timeout does not (it caps the handshake, not a stalled response). The TypeScript sibling is reliability/fetch-no-timeout.
body-limit-missinginfoline-scanBody parser (express.json/urlencoded/bodyParser.*) configured with no explicit limit — relies on the implicit 100kb default: too small for some endpoints, unexamined for the rest.
interval-no-clearwarningline-scansetInterval(...) with no matching clearInterval(...) anywhere in the file — a leaked timer keeps the process/page alive and re-fires forever.
stream-open-no-close-in-loopwarningmethod-scanA file/stream handle (createReadStream/createWriteStream/fs.open/fs.openSync) proven via loop spans to open structurally inside a loop body, with no .close(/.destroy(/pipeline(/finished( anywhere in the enclosing function — file descriptors accumulate under a fan-out until the process hits EMFILE.
listener-subscribe-in-loopwarningmethod-scanAn EventEmitter listener (.on(/.once(/.addListener(/.prependListener( with a string event name) proven via loop spans to subscribe structurally inside a loop body, with no .off(/.removeListener(/.removeAllListeners( anywhere in the enclosing function — listeners pile up and each event fires the handler N times. The event-listener sibling of interval-no-clear.
await-inside-promise-all-arraywarningline-scanawait used directly inside a Promise.all(...)/Promise.allSettled(...)/Promise.race(...) array literal — each awaited element resolves before the next array element is even evaluated, serializing calls that were meant to run concurrently. Syntax-proven, not a heuristic: an await textually between the array's [ and ] always serializes that element.
emitter-async-listenerwarningmethod-scanAn async callback registered as an EventEmitter listener (.on/.once/.addListener/.prependListener) — the emitter fires it and discards the returned promise, so a rejection inside the handler becomes an unhandled promise rejection, which crashes the process on Node 15+.
fs-check-then-usewarningmethod-scanA create/write call (fs.writeFile/fs.open/fs.rename/...) appears LEXICALLY AFTER a filesystem existence/permission check (fs.existsSync/fs.access/fs.stat/...) in the NEAREST enclosing function, with no exclusive-create flag (wx/O_EXCL) visible anywhere in it — CWE-367 TOCTOU: between the check and the use, the path can be swapped out from under it or another writer can win the race. The order is textual, and path identity is not checked at all — a check on one path followed by a write to a different one still fires.
fs-in-loop-serialwarningmethod-scanAn awaited fs/fs.promises call (readFile/writeFile/unlink/readdir/stat/... — deliberately excluding open/stream creation, which is stream-open-no-close-in-loop's handle-leak territory) proven via loop spans to sit structurally inside a loop body, with no Promise.all/Promise.allSettled in the enclosing function — each iteration's disk I/O waits for the previous one, serializing work the filesystem would run concurrently. Generalizes map-async-no-promise-all to ordinary for/for-of/while loop forms, scoped to fs receivers (DB-in-loop is sql/nplus1's territory, network-in-loop is perf/api-in-loop's).

security

Rule idSeverityMatcherDetects
hardcoded-secretwarningline-scanHardcoded secret-shaped literal (API key/password/token assignment, or a known cloud-key prefix). The match is not on the property name alone: identifier-shaped VALUES — letter-only words joined by - or _, any casing — are rejected as CSS classes / selectors / constant names. A digit in a segment defeats the any-casing arm (sk-a1B2c3D4 keeps firing), and separator-free words (Changemeplease) keep firing — but the UPPER_SNAKE arm is written [A-Z][A-Z0-9]*(_[A-Z0-9]+)+, so a digit-bearing screaming-snake value ("ABC_123_DEF") IS dropped: "digits keep it firing" holds for the kebab/snake any-casing arm only. The casing-agnostic form of that veto is this rule's alone: the sibling critical rule jwt-sign-literal-secret keeps the all-lowercase snake and kebab arms and drops only the any-casing one (the UPPER_SNAKE and PascalCase arms are present in BOTH rules), so a mixed-case identifier-shaped value is silent here and still fires there (apiKey: "Mantine_DatePicker_Input" reports nothing, jwt.sign(payload, "Mantine_DatePicker_Input") reports a critical). Deliberate, not drift — the widening was measured on the key: value shape only. Measured: this gate drops six of the seven findings that motivated it; the seventh (token: "adsbygoogle") has no separator to key on and deliberately still fires, since no shape test tells it apart from a weak literal password. Blind spot, measured: a passphrase-style credential (correct-horse-battery-staple) is always silenced, and a random base64url token is silenced when it draws no digit (~0.9% at 24 chars, ~0.26% at 32); base64-standard, hex and alphanumeric values and the common vendor prefixes (AKIA…, sk_live_…, ghp_…, xoxb-…) are unaffected. Both silenced classes are now high-entropy-secret’s to catch (the row below) — this rule’s own arms are unchanged, and sub-80-bit passphrases plus names outside both vocabularies stay silent in both. Three arms, not two: alongside assignment and known-prefix, a rust-str-const arm keyed on : &str = covers Rust's typed constant form (const API_KEY: &str = "…"), which assignment cannot reach because the type sits between the name and the value — a const/static of any other type, and a let with an explicit type, are not matched by it, while the untyped let api_key = "…" goes through assignment as usual. Scans test paths too (a committed credential is leaked regardless).
high-entropy-secretwarningliteral-scanA string literal bound to a secret-named binding (name ending in api-key/apikey/secret/passwd/password/token) whose VALUE measures at least 80 total Shannon bits — the entropy judgment hardcoded-secret’s line scan structurally cannot make. Reads the string-literal-with-binding-name IR channel (zzop_core::BoundStringLiteral: name + FNV-1a-64 value hash + extraction-time entropy, never the value — nothing secret-shaped is written to the analysis cache, and the channel is deliberately absent from the external envelope: an unsalted 64-bit hash of a real secret is dictionary-crackable). Threshold 80 is measured, not chosen (2026-08-03, scratchpad entropy-measure.mjs sweep): 4-word diceware p5 = 83.0 (2.9% missed), 24-char no-digit base64url floor = 85.0 (0% missed), hardest true negatives below it (PlaceholderSecretValue 75.7, an 18-char mixed-case alphanumeric token 75.1, refresh_token 41.4). Vetoes: value literally equal to its own binding name (hash equality — exact only), mock/test/fake/dummy/sample/placeholder/example in the binding NAME (boundary-anchored: name start, -/_, digit or camelCase transition — mockApiKey/test_secret vetoed, latestToken/attestationSecret keep firing); a placeholder word in the VALUE is invisible by design — a value-side veto is structurally impossible, the channel never carries the value — so a clean-named placeholder relies on the floor. Published residuals: a weak sub-floor credential (password, admin123456) never reaches this rule — that class is hardcoded-secret's line scan's to catch; 88.5% of 3-word passphrases sit under 80 bits and stay silent; an English-word identifier chain long enough to clear the floor (mantine-DatePickerInput-input, 107.4 bits) is indistinguishable from a passphrase and fires when secret-named. Covers TS/JS, Python, Java, C#, Go, Rust (the six channel producers); silence elsewhere is no-evidence, never clean. Scans test paths too (a committed fixture credential still has to be rotated).
config-file-secretwarningline-scanA high-entropy secret value committed in a config file (.properties/.yml/.toml/.ini/.conf/.cfg/.env) — a jwt.secret/*.password/api-key assignment whose value is 16+ chars. Empty values, short placeholders, and ${VAR} env references are not flagged. Scans test paths too (a committed credential is leaked regardless).
api-key-in-urlwarningline-scanA secret-shaped query parameter (api_key/access_token/token/secret) appears in a URL — leaks via proxy/browser/Referer logs.
secret-env-in-fewarningline-scanA server-only-shaped env var (SECRET/PRIVATE/SERVICE_ROLE/SERVICE_KEY) read through process.env/import.meta.env from frontend code — inlined into the shipped JS bundle, readable via devtools. Path scope: a .tsx/.jsx file anywhere, or a .ts/.js file under an fe/frontend/client/web directory. A plain .ts module elsewhere (a Next.js src/lib/api.ts) is never scanned, so zero findings outside those paths means "not checked", not "clean". The exact regex is not copied here — this path scope is a paraphrase of the rule's file_pattern, not its value.
hardcoded-passwordwarningline-scanA password-shaped literal hardcoded (direct assignment, or a JDBC getConnection(url, user, password) call) — a credential committed to source, can't be rotated without a code change. Scans test paths too (a committed credential is leaked regardless).
conn-string-credentialscriticalline-scanConnection-string URL with a password in the userinfo slot (scheme://user:pass@host — redis/postgres/mongodb/amqp/...) committed to source — repo readers own the datastore and git history preserves it; move to env/secret config AND rotate. An interpolated userinfo slot is never flagged — a ${...} template placeholder, a {{...}}/<...> hole, process.env, or a Rust format! placeholder ({}, {name}) between :// and @ means there is no credential on the line to rotate. Scans test paths too (a committed credential is leaked regardless).
private-key-committedcriticalline-scanA PEM private-key header (-----BEGIN [RSA/EC/DSA/OPENSSH/...] PRIVATE KEY-----) committed to source — the key is compromised the moment the repo is shared; rotate it and move it to a secret store. Scans test paths too.
vendor-token-committedcriticalline-scanA format-identified LIVE vendor credential (Stripe sk_live_/rk_live_, GitHub ghp_/gho_, Slack xox[bpars]-, Google AIza...) committed to source — rotate immediately; committed means leaked. Test-mode keys (sk_test_) deliberately do not fire. Scans test paths too.
jwt-sign-literal-secretcriticalline-scanjwt.sign(payload, '<string literal>') — a positional committed signing secret lets anyone forge tokens (the hardcoded-secret rule needs a key: value shape, so this positional form was uncovered); placeholder-word and interpolation shapes are vetoed. Its identifier-shaped-value veto is deliberately narrower than hardcoded-secret's — all-lowercase snake/kebab plus UPPER_SNAKE/PascalCase, not any casing — so a mixed-case value like "Mantine_DatePicker_Input" is dropped there and still fires here; no measurement has arrived for this positional form, and widening a critical rule's veto without one buys silence, not signal. Scans test paths too.
raw-query-unsafe-apicriticalline-scan$queryRawUnsafe/$executeRawUnsafe called — no parameterization, so any interpolated request-derived string is a SQL injection.
annotation-sql-concatinfoline-scanJPA @Query annotation built via string concatenation — hygiene, not injection: Java annotation element values must be constant expressions (JLS 9.7.1), so a request-derived operand cannot compile, and the concatenation only hides the full statement from review. The runtime-concatenation sibling security/sql-string-concat is the injectable shape.
sql-string-concatwarningline-scanSQL built by string concatenation — injection risk. Annotation lines are excluded: element values are compile-time constants (JLS 9.7.1), and annotation-sql-concat owns that shape at info.
sql-format-interpolationwarningline-scanA SQL statement assembled by a Rust format!/write!/writeln! macro whose template BEGINS with the statement and carries a { placeholder on either side of the statement keywords — a dynamic column list or table name fires exactly as an interpolated value does. The interpolated value becomes part of the statement TEXT. A shape, not a dataflow: nothing here proves the value is request-derived, which is why it is warning and not critical — the same standing the Java sibling security/sql-string-concat takes. A SQL literal with no placeholder is not matched, and neither is a log string that mentions SQL keywords mid-sentence (the template must begin with the statement); migration paths are excluded through the same ${test-paths-migrations} vocabulary the sql/ no-where siblings use.
taint-flowwarningmethod-scanA tainted-source access and a dangerous sink call in the same function body (coarse v1 co-occurrence, not real dataflow — see the rule's own message for the three documented precision limits). Capped at warning, not critical: co-occurrence approximates dataflow, it doesn't prove it.
eval-dynamic-codewarningline-scaneval( with a non-literal argument, or any new Function( — constructing code from strings at runtime defeats CSP and every static analyzer (taint-flow covers eval+request-source in .ts/.tsx; this rule is source-free and .js-inclusive).
cmd-injectionwarningmethod-scanA method the parser PROVED uses a process-exec API — Runtime.getRuntime().exec(...) spelled as that fixed platform chain, or a new ProcessBuilder(...) construction — and that also concatenates an identifier onto a string literal (that lexical half is the trigger and supplies the line). The exec witness used to be the bare word exec/ProcessBuilder, which any local variable or unrelated method of that name satisfied; the projected call site retires that class, at a disclosed cost — an exec through a variable receiver (rt.exec(cmd)), or a file this engine cannot parse, projects no site and the rule is silent there. Still CO-OCCURRENCE between the two halves, not proof the concatenated string is the one that gets executed. When they ARE connected and the concatenated part is request-derived, it is command injection.
command-and-interpolationwarningmethod-scanOne Rust function that the parser PROVED constructs an OS process (a Command::new(...) call in any of its std::process::/process::/bare spellings) AND builds a string with a format!/write!/writeln! macro (the lexical half, and the trigger). The structural witness retires the string/comment false-fire class the old Command::new regex had; its cost is silence where the construction cannot be parsed or resolved. -and- is literal: this is CO-OCCURRENCE within one function body, not proof the interpolated string is the one that gets executed, which is why it is warning and not critical — the same standing as the Java sibling security/cmd-injection. Pass every argument as its own .arg(value) rather than building one shell string.
shell-exec-interpolationcriticalline-scanexec/execSync whose command string carries a ${...} interpolation or +-concat — a dynamic segment inside a shell line is command injection; use execFile/spawn with an argv array (those APIs deliberately do not fire). Scoped twice: the lexical shape above, AND a parser-projected process-exec call site on the same line, whose producer resolves the callee against this file's own child_process bindings — so a RegExp's pattern.exec(...), an unrelated helper named exec, or the same spelling inside a string or comment is structurally not a site. The gate's cost is silence on a degraded file or an unresolvable binding, where the pure regex used to fire.
xxe-no-guardcriticalmethod-scanDocumentBuilderFactory/SAXParserFactory.newInstance() with no XXE guard (disallow-doctype-decl/FEATURE_SECURE_PROCESSING) — default XML parsing resolves external entities (file read/SSRF/billion laughs).
unsafe-deserializationwarningmethod-scanA new ObjectInputStream construction and a readObject( call co-occur in the same Java method — two independent patterns (the readObject( one is the trigger), not a single dotted ObjectInputStream.readObject() chain — that dotted shape is never matched as such, and no order is required, so the two can sit on unrelated objects. Native Java deserialization of an attacker-controlled byte stream can trigger remote code execution via gadget chains.
template-unescaped-outputwarningline-scanTemplate-engine unescaped-output syntax (EJS <%- %>, Handlebars {{{ }}}, Mustache {{& }}) — server-rendered stored/reflected XSS if any interpolated value is user-influenced; use the escaped form or a vetted sanitizer. Scoped to .ejs/.hbs/.mustache/.njk template extensions (Pug != is deliberately uncovered — indistinguishable from the inequality operator).
html-response-from-requestwarningmethod-scanA res.send/write/end of HTML-shaped content in the same function as req.query/params/body/headers with no sanitizer — reflected XSS (co-occurrence heuristic; the res.send-HTML sink security/taint-flow does not list).
dangerous-html-concatwarningline-scanAn HTML tag string literal concatenated with a variable ("<div>" + userVar) in a response-context file — an injection sink if the variable is user-influenced; use an auto-escaping template engine or sanitizer.
path-traversalwarningmethod-scanA filesystem call (fs/fsp.*/readFile/writeFile/createReadStream), a req.params/req.query/req.body read, and a path.join(...) call all appear in the same function — three independent patterns triggered on the filesystem call, i.e. a co-occurrence heuristic, not proof the request-derived value actually flows into the joined path. When it does, an unvalidated .. segment escapes the intended directory.
java-path-traversalwarningmethod-scannew File(...) constructed in a method that also reads request.getParameter(...) — two independent patterns triggered on the new File(...), i.e. a co-occurrence heuristic, not proof the parameter reaches the path: the two can match unrelated statements and nothing checks order or dataflow, so a new File(...) on a constant path in a method that reads an unrelated parameter fires just the same. Deliberately weaker evidence than its TypeScript sibling path-traversal above, which needs a third co-occurring path.join(...). When they ARE connected, an unvalidated .. segment escapes the intended directory. Id keeps its java- prefix (documented exception): dropping it would collide with this pack's JS path-traversal rule above.
ssrf-user-urlwarningmethod-scanAn outbound HTTP call (fetch/axios/got) made in a function that also reads req.query/req.params/req.body — a request-derived value can steer the server to attacker-chosen hosts (SSRF).
open-redirectwarningmethod-scanredirect(...) called in a function that also reads req.query/req.params/req.body — unchecked request-derived redirect target, a phishing/OAuth-callback token-theft vector.
sendfile-from-requestwarningline-scansendFile(/download( handed a req.params/query/body value directly — path traversal via a file-serving API (path-traversal covers the fs+path.join shape; this covers the serving APIs). A path.basename(...)-wrapped arg does not fire.
weak-password-hashwarningcall-scanA digest the PARSER witnessed being constructed with a broken algorithm (MD5/MD2/SHA-1) on a line that also names a credential (password/passwd/pwd). Reads the projected call-site channel with its algorithm field, so one rule covers TypeScript/JavaScript (crypto.createHash), Python (hashlib), Java (MessageDigest.getInstance), C# (MD5.Create/HashAlgorithm.Create), Go (crypto/md5 and siblings) and Rust (the md5/sha1/sha2 crates). Narrower and more honest than the text arms it replaced: the bare word md5/sha1 no longer counts, so a variable, a parameter, an error message or a project's own helper of that name is not matched, and neither is an algorithm named only in a string literal or a comment. Narrower in a way that costs recall too, and it is disclosed rather than discovered: a digest whose ALGORITHM the source does not spell at the site (createHash(algoVar)) carries none — never-guess — so nothing is reported there, and the same holds for an unparseable file, a hashing package no producer claims, and a credential word on a different line. The credential half stays lexical CO-OCCURRENCE, which is why this is warning and not critical. The bcrypt cost-factor arm moved to security/bcrypt-cost-too-low in the same change.
bcrypt-cost-too-lowwarningline-scanbcrypt invoked with a SINGLE-DIGIT cost factor, in either argument position — after the value being hashed (bcrypt.hash(pw, 8), bcrypt.hashSync(pw, 4)) or first, as the salt-first family spells it (bcrypt.genSalt(4, cb), bcrypt.genSaltSync(4), and the nested bcrypt.hashSync(pw, bcrypt.genSaltSync(4)) bcryptjs's README documents) — each increment doubles the work, so a single-digit cost is orders of magnitude below the current recommendation. Use at least 10; the unit is bcrypt-specific, not a scrypt/argon2 parameter. Split out of security/weak-password-hash on 2026-08-03 when that rule became structural: a bcrypt cost is a numeric ARGUMENT to an adaptive hash that is itself the recommended answer, so there is no digest construction to witness and the check stays lexical — with the limits that implies: a cost passed through a constant, or configured elsewhere, is invisible, and only a literal digit is read.
weak-token-randomwarningline-scanMath.random() used on the same line as a token/otp/nonce/session-id/secret-shaped identifier — a predictable, non-cryptographic PRNG for a security-sensitive value.
weak-randomwarningline-scannew Random() used on the same line as a token/session/otp/nonce-shaped identifier — a predictable, non-cryptographic PRNG for a security-sensitive value.
weak-cryptowarningcall-scanA digest the PARSER witnessed being constructed with a broken algorithm (MD5/MD2/SHA-1) — the general-case sibling of security/weak-password-hash: no credential word required, so tokens, cache keys, signatures and content addressing are covered, and the two rules CO-FIRE on a credential line by design (they say different things about it). Made the same call-scan migration weak-password-hash made first (2026-08-09): reads the projected hash-call channel's algorithm field, so one rule covers TypeScript/JavaScript, Python, Java, C#, Go and Rust in each platform's own spelling, a mention is no longer a construction (no firing on variables, prose or comments — including the commons-codec DigestUtils.md5* helpers, whose algorithm lives in a method name this channel does not witness; zero uses measured in corpus and cases at migration time), and a construction whose algorithm the source does not spell carries none — never-guess, silence is absence of evidence. The cipher arms split out to security/weak-cipher in the same change.
weak-cipherwarningline-scanA Cipher.getInstance(...) transformation naming a broken cipher or mode — DES/RC4/RC2, or ECB mode (equal plaintext blocks encrypt to equal ciphertext blocks). Java/JSP sources only: the JCA factory is the one cipher-construction spelling this engine judges today; other languages' cipher constructions are unmeasured, not endorsed. Split out of security/weak-crypto on 2026-08-09 when that rule became the cross-language call-scan hash rule (the languages it covers are enumerated in its own row above — no count here to drift) — one rule, one concept, one suppress marker, so a pre-split zzop-weak-crypto-ok left on a cipher line no longer suppresses it.
timing-unsafe-compareinfoline-scanA secret/token/signature/hmac/api-key-shaped identifier compared with ===/!== — short-circuiting equality leaks a timing side-channel.
trust-all-tlscriticalline-scanTLS certificate/hostname verification disabled (trust-all X509TrustManager, ALLOW_ALL_HOSTNAME_VERIFIER, or an always-true hostname-verifier lambda) — accepts any certificate for any host, opening a MITM path.
jwt-no-expirywarningmethod-scanjwt.sign(...) called in a function where expiresIn never appears — a token with no expiry, valid forever if it leaks.
jwt-none-algorithmcriticalline-scanalgorithm(s): 'none' in a JWT-adjacent file — alg=none turns signature verification off entirely; no legitimate production use.
jwt-verify-bypasswarningline-scanignoreExpiration: true or verify: false in a JWT-library-adjacent file — token validation partially disabled.
cors-wildcardwarningline-scanCORS origin set to * — defeats the same-origin protection CORS exists to provide. Beyond the literal header/origin property spellings, Rust idiom arms match tower-http allow_origin with Any, actix allow_any_origin, and the typed ACCESS_CONTROL_ALLOW_ORIGIN constant beside a * literal; a wildcard reached through a variable or a framework not named here is not seen.
cors-credentials-wildcardwarningline-scancredentials: true in a file that also configures origin: '*' — the pairing is FILE-level, not object-level, so the two keys may belong to two unrelated config objects and still fire. Browsers REJECT a wildcard Access-Control-Allow-Origin combined with credentials, so this exact shape is broken/non-functional rather than exploitable as written; the real risk is the usual "fix" — echoing the request's Origin back with credentials: true still set (that shape is cors-reflected-origin-credentials).
cors-reflected-origin-credentialswarningline-scancredentials: true together with origin: true (reflect-any-origin) or origin: req.headers... on one line — any site can make credentialed requests (cors-wildcard/cors-credentials-wildcard cover the literal '*'; this covers reflection, which those matchers miss). Multi-line option objects are a documented miss.
csp-weak-or-disabledwarningline-scanContent-Security-Policy turned off or wide-open (contentSecurityPolicy: false, unsafe-inline, default-src *) — removes the browser's last-line XSS mitigation; keep a restrictive policy.
insecure-cookiewarningmethod-scanA cookie is set (res.cookie/setCookie) with no httpOnly: true anywhere in the same function body — an explicit httpOnly: false still fires. cookies.set is deliberately out of scope: on SvelteKit it defaults to httpOnly, so the absent option is correct code there.
error-leak-to-clientwarningline-scanA raw error object sent directly to the client (res.status(5xx).send/json(err), Hono c.json(err)) — stack traces/paths/SQL fragments help an attacker map internals.
stacktrace-to-responsewarningmethod-scanA stack-trace call (printStackTrace()/getStackTrace()) and an HTTP-response token (getWriter()/ResponseEntity/@ResponseBody/HttpServletResponse) both appear in the same method — a co-occurrence heuristic, not proof the trace is written to the response (a printStackTrace() going to stderr in a method that merely mentions HttpServletResponse fires too). When the trace does reach the client it exposes internal class names and file paths. .getMessage() is deliberately NOT matched — a curated message string is not a stack trace.
localstorage-jwtwarningline-scanA token/JWT-shaped value written to localStorage — readable by any script on the page, so one XSS bug anywhere on the origin exfiltrates it.
mass-assignmentwarningmethod-scanreq.body (or a spread of it) passed directly into a database write in the same function — lets a caller set fields the handler never intended to expose.

browser

Rule idSeverityMatcherDetects
no-document-writewarningline-scandocument.write/writeln — breaks HTML parsing post-load, blocked under many CSP/PWA setups.
postmessage-wildcardwarningline-scanpostMessage(..., '*') — a wildcard targetOrigin broadcasts the payload to whatever origin currently holds the window (opener/embedder swaps included); pass the intended origin literal.
unsafe-html-sinkwarningline-scanA non-literal value assigned to innerHTML/outerHTML, passed to insertAdjacentHTML, or set as dangerouslySetInnerHTML's __html — the standalone XSS sink check (security/taint-flow fires on these sinks only with a request-derived source in the same .ts/.tsx function; this rule needs no source — component props/state are the common carrier — and covers .js/.jsx). Plain string-literal assignments stay silent. A finding is dropped only when a sanitizer-shaped call is the WHOLE VALUE — escape*/sanitize*/validate*/purify* (optionally method-qualified, so DOMPurify.sanitize counts) or a *Safe/*Sanitized/*Escaped/*Purified wrapper. Whole-value is enforced: the veto consumes the call's argument list and requires the value to end there, so a concat (escapeHtml(t) + rawHtml), a ternary, a method chain, and a template literal that interpolates an escaped fragment into an href all still fire. JSON.stringify is deliberately NOT a sanitizer here — JSON escapes only ", \ and control characters, so a value containing </script> breaks out. Three residuals: sanitizer-NAMED is not sanitizer-PROVEN (a do-nothing sanitizeFoo silences it, and a safe builder under any other name is not recognized); a sanitizer applied anywhere but as the value itself is invisible — an earlier line, a hook return, or a component prop the caller sanitized all still fire (the prop case counts as a residual only for a component private to the tree; on one its package EXPORTS the caller set is open and a string prop type promises nothing, so firing there is correct); and a call broken across lines or nesting parens more than one level deep is not recognized as whole-value, which errs toward MORE findings. On the measured corpus the veto cleared 1 of 7 findings; the residuals are line-scan limits, not bugs.
javascript-urlwarningline-scanA literal javascript: scheme URL in an href/src attribute, DOM property assignment, or setAttribute — executes arbitrary script on click/load; validate the scheme against an http(s) allowlist. Catches the literal form only (a dynamic href is a separate, harder class).
location-assign-dynamicwarningline-scanA non-literal assigned to location/location.href or passed to location.assign/replace — client-side open-redirect / DOM-XSS navigation sink (security/open-redirect covers the server res.redirect(req.*) side). Literal/absolute-path targets and const location = useLocation() stay silent.
jquery-html-sinkwarningline-scanA non-literal passed to jQuery .html()/.append()/.prepend()/... in a jQuery file — the same HTML-injection surface as innerHTML; use .text() for plain text or sanitize.
vue-v-htmlwarningline-scanVue's v-html directive renders raw HTML — XSS if the bound value is user-influenced (the Vue analog of dangerouslySetInnerHTML); prefer {{ }} interpolation or sanitize.
markdown-and-html-sink-unsanitizedwarningmethod-scanA markdown renderer (marked/markdown-it/remark/...) whose output reaches an HTML sink in the same function with no sanitizer — markdown renderers emit raw HTML by default; run output through DOMPurify/sanitize-html. (.vue SFCs are not span-projected today, so same-file <script>/<template> co-occurrence does not fire — .ts/.tsx/.js/.jsx does.)

egress

Rule idSeverityMatcherDetects
http-url-literalwarningline-scanA plain-http:// URL string LITERAL in source — not proven to be a request target, and no https-page context is observed, so mixed content/MITM is the risk named rather than a fact established; excludes localhost/private-IP/XML-namespace lookalikes.
get-and-bodywarningmethod-scanA GET request carrying a body (method: 'get' alongside a body: property in the same function) — servers/proxies may silently drop the body on a GET. Skipped WHOLE-FILE when the first 8 lines carry a machine-generated banner comment (@generated, auto-generated/autogenerated, automatically generated, code generated by, this file is generated/this file was generated, openapi-generator): the GET-with-body is the generator's output, so the fix advice (use POST, or move the data to query params) cannot be followed. That banner list is compiled into the rule and is NOT vocabulary.generatedFileMarkers — a house generator whose banner differs — or that stamps none at all — still fires and wants an exclude entry.
ws-no-authinfomethod-scanWebSocket opened/upgraded (new WebSocket(...)/.upgrade(...)) with no auth material (token/auth/session/cookie/jwt) visible in the same function — unauthenticated realtime channel.

go

Rule idSeverityMatcherDetects
goroutine-in-loopwarningmethod-scanA go func(){...}()/go someCall(...) statement proven via loop spans to start structurally inside a for loop body — fans out one goroutine per iteration with no bound, exhausting memory/file descriptors on a large range/count. Pre-Go 1.22 toolchains also capture the SAME loop variable across every iteration. Bound concurrency with a worker pool/semaphore, or (pre-1.22) copy the loop variable into a new local before passing it into the goroutine.

http

Rule idSeverityMatcherDetects
protected-path-no-auth-evidencewarningio-scanFramework-neutral: any assembled http provide under a protected path segment (/admin/, /internal/) with no witnessed auth evidence — the auth-guarded attribute is absent (no native guard recognized — middleware, decorator/annotation, or a handler wrapped in a guard call like requireAdmin(handler) — and no Mode B adapter injection). Deliberately NO same-line keyword carve-out: on a security rule a lexical token over-clearing a real unguarded route (e.g. Java's value="/admin/..." argument, Django's views.AdminView) is worse than relying on the attribute/marker escape hatches. Marker works in // and # comment languages, and needs a readable source line: a whole-tree analyzeEnvelope run (Mode A) carries no filesystem root, so there the marker AND the mistyped-marker disclosure are both inert — clear such a route by injecting auth-guarded (a Mode A projection's own attributes field does this too), or disable the rule in config. An adapterOverlays overlay (Mode B) lies on a natively-parsed tree whose file is read, so the marker works normally there. Language scope (narrowed 2026-08-02): scans only TS/JS, Java, and Python — the languages with an auth-guarded producer. Go and C# have route recognizers but no auth-evidence producer, so a correctly guarded Go/C# route would ALWAYS have fired; each language re-enters scope when it gains a producer.
dev-path-no-guard-hintwarningio-scanFramework-neutral: any assembled http provide under a dev/debug/internal/__test__/playground path segment (the test arm is the DUNDER spelling only — a plain /test/ segment is not matched) whose registration line carries no guard-hint keyword (dev/debug/internal/env/guard/isProduction/isLocal/NODE_ENV) as a later, unquoted call argument — preserves the env-gate=WHERE-axis carve-out while a quoted path string (value="/debug") can no longer self-clear. Known residual: an unquoted later argument whose identifier contains a keyword (Django's views.DebugView) still clears — exposure hygiene tolerates that direction; protected-path-no-auth-evidence deliberately does not. Marker works in // and # comment languages. Three channels read the registration line — the guard-hint carve-out, the marker, and the mistyped-marker disclosure — and all three are inert under a whole-tree analyzeEnvelope run (Mode A, no filesystem root): there a matching route fires even with a guard-hint argument present, no marker suppresses it, and a typo is not called out (disable the rule in config instead). An adapterOverlays overlay (Mode B) lies on a natively-parsed tree whose file is read, so all three behave normally. Language scope: unlike its sibling above, this rule keeps scanning EVERY parsed language — Go and C# included — because its clear channel is the guard-hint vocabulary on the registration line itself, not an injected attribute, so a correctly env-gated Go/C# route can already clear itself and no per-language evidence producer is required.

perf

Rule idSeverityMatcherDetects
api-in-loopwarningmethod-scanNetwork call made inside a loop or array-iteration callback — the HTTP analogue of N+1.

react

Rule idSeverityMatcherDetects
setstate-after-async-unguardedwarningmethod-scanA setX(...) state setter appears LEXICALLY AFTER an async boundary (await, or a .then(/.catch(/.finally( continuation) in the same function body, with no unmount/abort guard (AbortController/AbortSignal/isMounted/mountedRef/signal:/cancelled/didCancel) anywhere in it — a textual-order claim, not dataflow. Scoped to React files (useEffect/useState/from 'react'); a plain event handler (mounted by construction whenever it fires) is an accepted false positive. "Same function" is the NEAREST enclosing function for the BOUNDARY↔SETTER PAIRING ONLY (after_in_same_function) — the guard veto is NOT narrowed with it: absent is still evaluated over the whole declaration span, so an AbortController in an outer useEffect callback silences a setter in a nested load arrow. Under the pairing rule a boundary in a sibling closure no longer pairs with the setter, while a .then(/.catch(/.finally( continuation callback is scoped together with the call that schedules it, so a real continuation still fires. The flip side is an accepted under-report — a setter inside a nested .map/setTimeout/event-handler closure whose only boundary sits outside that closure is now silent. The narrowing needs parser-projected function spans, and it degrades per LINE, not only per file: any line sitting inside no projected span (parse failure, size cap, an external-parser envelope that omits them — but also an ordinary line of a file that HAS spans, since a file with spans still has lines outside all of them) keeps the old whole-declaration pairing scope and its sibling-closure false positives. Lexical, not execution, order: a setter in an else branch counts, and so does one following a conditional boundary (if (stale) { await refresh(); } then setX(...)) even on the path where nothing was awaited. The reported line is the first setter that actually follows the boundary, not the first setter in the span. When the setter genuinely follows an in-flight await in code that can unmount mid-request (an effect's async loader), the resolve races teardown — the classic "state update on an unmounted component" warning, a stale-data flash, or a wasted render after remount.

redis

Rule idSeverityMatcherDetects
flushall-in-codecriticalline-scanflushall/flushdb call or quoted command literal reachable from application code — wipes every key in the database/instance; one bug or exposed admin endpoint away from total data loss. The quoted command form counts only in COMMAND POSITION (first element after a ( or [), and the line is dropped when it looks like a string denylist (new Set(/new Map(, or three consecutive quoted words) — a config that merely NAMES the command is data, not a call. That denylist gate drops the WHOLE LINE before the call/literal arms are tried, so it silences a direct .flushall(/.flushdb( call on a line that also carries the denylist shape (new Set(await client.keys(...))) — the "data, not a call" reading does not exempt the call form. Residual: a two-element command array is indistinguishable from a two-element denylist. Second residual, a false negative: the gate reads shape, not reachability, so a command set that IS dispatched later (new Set(["FLUSHALL"]) whose member is handed to sendCommand on another line) is silenced along with the inert config tables it was written for — exposure is small, since the dominant real spelling is the direct call.
keys-command-in-codewarningline-scanKEYS scan (.keys('pattern') with a string argument, or a quoted 'KEYS' command) — O(N) walk that blocks the single-threaded server; use the SCAN cursor family or an index set. Same command-position + string-denylist gate as flushall-in-code above (this rule escaped the field measurement only because its literal is case-sensitive KEYS and the measured denylist spelled it lowercase), including that gate's false negative: a KEYS set that really is dispatched later looks exactly like an inert config table and is silenced.
client-no-error-listenerwarningline-scanredis/ioredis client created in a file with no .on('error', ...) anywhere in it — node-redis emits error on an EventEmitter, so an unhandled listener crashes the process on the first connection blip.
lock-get-then-setwarningmethod-scanA .set( call appears LEXICALLY AFTER a .get(/.exists( read in the NEAREST enclosing function, alongside a lock-ish identifier and with no atomic acquire (setnx/.eval(/redlock/WATCH/MULTI) anywhere in it — the reported line is the first .set( that actually follows a read, and a read in a sibling closure does not pair. Textual order, not dataflow: nothing proves the set writes the key the read checked, or that both run on the same control-flow path. The check-whether-the-lock-is-free-then-set-it shape is a classic TOCTOU race where two concurrent callers can both pass the check before either writes. Use atomic SET key value NX (with a TTL), a WATCH/MULTI transaction, an .eval( Lua script, or a redlock library instead.
lock-no-ttlwarningline-scanA lock acquired with SET key value NX/setnx and no EX/PX/EXAT/PXAT expiry on that same call — if the holder crashes before releasing it, the key lives forever and wedges every future caller waiting on that lock. Always pair NX with a TTL.
counter-get-setwarningmethod-scanA .set( call whose value looks like arithmetic (n++/n +- 1/Number(...)/parseInt(...)) appears LEXICALLY AFTER a .get( read in the NEAREST enclosing function, with no atomic INCR/INCRBY/DECR/DECRBY/HINCRBY/.eval(/MULTI anywhere in it — the reported line is the first arithmetic set that actually follows a read, and a read in a sibling closure does not pair. Textual order, not dataflow: nothing proves the set writes the key the read returned, or that both run on the same control-flow path. When it genuinely is read-modify-write, it is not atomic and silently loses one of two concurrent increments under load. Use Redis's atomic INCR/INCRBY/DECR/DECRBY/HINCRBY instead.

sql

Rule idSeverityMatcherDetects
nplus1warningmethod-scanawait on a store/ORM call inside a loop or array-iteration callback — N+1 query pattern. House-convention path scope: only .ts files under a domains/<name>/routes/ or api/ path segment are scanned, at any depth (src/api/orders.ts counts) — the same shape anywhere else in the tree is silently not checked. The exact regex is not copied here — this path scope is a paraphrase of the rule's file_pattern, not its value.
count-in-loopwarningmethod-scanstore.count()/prisma.<model>.count() called inside a loop or array-iteration callback.
race-condition-toctouwarningmethod-scanA create/upsert/insert write appears LEXICALLY AFTER an awaited findOne/findById/findUnique read in the NEAREST enclosing function — the reported line is the write, specifically the first one that actually follows a read. The order is TEXTUAL, not dataflow: it does not prove the read feeds a branch that reaches the write, nor that the two touch the same row (a read of one table followed by an insert into another still fires). Three vetoes, all evaluated over the whole function: any try block at all, a FOR UPDATE/serializable lock, or ON CONFLICT/ON DUPLICATE KEY — the try veto is the broad one, so a handler that wraps anything in try is silent here. Path scope: only .ts/.tsx files under api/, routes/ or controllers/ (each at any depth, tree root included — aligned 2026-08-03; the routes//controllers/ arms previously required a directory above them), or named *handler/*controller, are scanned — the same shape anywhere else in the tree is silently not checked. The exact regex is not copied here — this path scope is a paraphrase of the rule's file_pattern, not its value.
raw-sql-check-then-writewarningmethod-scanA raw-SQL write statement head (INSERT INTO, or UPDATE <table> SET) appears LEXICALLY AFTER a raw-SQL SELECT statement head in the NEAREST enclosing function — the raw-SQL arm of the check-then-act family, for backends whose vocabulary and file layout the ORM arm (race-condition-toctou) structurally cannot reach. The order is TEXTUAL, not dataflow, and same-table matching is NOT proven (a SELECT on one table followed by an INSERT into another still fires). Statement heads are matched UPPERCASE-only — the precision gate that separates SQL from English prose, at the cost of not seeing lowercase SQL. Two vetoes, both of them things that actually serialize the check: a FOR UPDATE/serializable lock, or an atomic conflict clause (ON CONFLICT/ON DUPLICATE KEY/INSERT OR IGNORE/INSERT OR REPLACE). Deliberately NO try veto and NO transaction veto — a transaction does not close this race at READ COMMITTED.
delete-no-wherecriticalline-scanA closed SQL string literal DELETE FROM <table> with no WHERE in application code — a whole-table delete. Fires only when the entire statement is one closed literal with no interpolation/concat, so the missing WHERE is statically provable; migration paths — including Alembic's alembic/versions/ layout — are sql/destructive-migration's turf at disclosure severity instead. The pattern carries no host-language syntax at all (a quote, the keyword, the table, the closing quote), so one rule covers TypeScript/JavaScript, Python, Java, C#, Go and Rust in each language's own spelling, C# verbatim and Go raw strings included. Two disclosed residuals: a MULTI-LINE statement puts the keyword on a line carrying no quote and a line-scan cannot see it, and in Python a #-commented statement is read as live code because the engine's comment leader outside .sql and config files is //.
update-no-wherecriticalline-scanA closed SQL string literal UPDATE <table> SET ... with no WHERE in application code — a whole-table update; same closed-literal discipline and migration-path handoff as delete-no-where. A placeholder anywhere between the SET and the closing quote is never flagged — a ${...} template placeholder or a Rust format! placeholder ({}, {name}) means the statement isn't complete here, and the interpolating Rust shape is security/sql-format-interpolation's turf instead. A printf %s is the opposite call and deliberately so: it is read as a BOUND VALUE, the role ? already plays, so a parameterized whole-table UPDATE still fires; the disclosed cost is a %s splicing a whole clause. Same language set and multi-line residual as delete-no-where.
truncate-in-app-codecriticalline-scanA quote-anchored TRUNCATE [TABLE] <table> reachable from application code (migration paths excluded, and covered by sql/destructive-migration at disclosure severity) — a full-table wipe one call away, the SQL analog of redis/flushall-in-code. Same language set and multi-line residual as delete-no-where: TypeScript/JavaScript, Python, Java, C#, Go and Rust.
destructive-migrationinfoline-scanDROP TABLE/DROP COLUMN/TRUNCATE — plus closed-literal whole-table DELETE FROM/UPDATE ... SET — inside a migration path. Info/non-gating by design: migrations are usually deliberate; the value is review-time attention on NEW migrations, with a two-phase (deprecate, then drop) recommendation. A file containing a DROP TABLE IF EXISTS x; followed by a CREATE TABLE with nothing between them but whitespace and -- comments is an idempotent bootstrap preamble and is skipped WHOLE-FILE. ONE such adjacency anywhere in the file is enough, so the usual 0001_initial.sql layout that lists every DROP first and every CREATE after is covered too (matched via its last DROP). IF EXISTS is required — a plain DROP TABLE x directly above a CREATE TABLE (a rename) still fires. A line-scan matcher has no backreferences, so the DROP and the CREATE cannot be correlated by table name; the accepted cost is that a genuinely destructive statement sharing a file with a bootstrap preamble is missed. The FILE SET is an invariant rather than a list: the three critical siblings exclude migration paths and name this rule as the disclosure, so it admits every extension they admit plus .sql, under migrations/, migrate/ and Alembic's alembic/versions/ — an extension one of them accepts and this one does not would be a promised disclosure nobody emits. Spelling scope: the matcher reads destructive SQL as TEXT, so the ORM-METHOD spelling — Alembic op.drop_table, EF Core migrationBuilder.DropTable, TypeORM queryRunner.dropTable, Django migrations.DeleteModel — is deliberately not read. A widened DROP[_\s]*(TABLE|COLUMN) arm would reach the first three and only those three (each spells the verb DROP and differs from DROP TABLE in the separator alone); Django's migrations.DeleteModel carries neither DROP nor TABLE/COLUMN and would need a token of its own, so for that one spelling this is a reach gap rather than a judgment. For the three the widening does reach, it is not there because in real migration histories most such calls sit in the tool-generated inverse half (downgrade, Down), which drops exactly what the same file's forward half created, and a line-scan has no enclosing-function fact to tell the undo from the change.

Native analyses

Whole-graph/whole-repo analyses. Their ids join the same RuleConfig enable/severity/suppression surface as DSL rules. Each id is registered by its owning crate's own register_native_analyses — the kernel (crates/core) itself registers none, staying rule-vocabulary-free. Five crates register between them, each owning one subject: rules/native/rules-graph the dependency/dead-code graph rules plus the call-graph purity audit, rules/native/rules-http the single-tree HTTP/route rules, rules/native/rules-cross-layer the cross-layer/* multi-tree join rules, rules/native/rules-schema the schema rules, and crates/metrics the score computations. Which ids each owns is the table's answer, not this paragraph's — the roster is the register_native_analyses list in each of those crates, and the table below is the same set row by row. No id list is repeated here on purpose: the hand-written one this sentence replaced had drifted one id short of the table it introduces, and a list that must be re-counted by hand is a census in disguise. Two shapes the table does not spell out on its own: schema-structural/schema-usage are FAMILY gates over the 12 schema/* per-issue ids they report under — disabling a family switches its whole pass off while each schema/* id disables exactly the rule it names, both are honored, and every finding's message states both; and the crates/metrics ids are score computations, not findings-producing rules, that merely ride the same toggle/gating surface. Those metrics ids carry no severity at all — not because of how they register (every native id goes through the same stub), but because severity grades a finding and these emit none. The "Default severity" column therefore reads n/a for them rather than inventing a level for output that is never a finding. Each row says instead whether any shipped surface carries what it produces. zzop_engine::register_all_native composes the five.

The cross-layer/* ids are the multi-tree exception: they run over zzop_engine::analyze_trees's joined CrossLayerResult (every other row here runs per-tree), exposed as crossLayerFindings alongside crossLayer in analyzeTrees's output. None of them honor an inline suppression marker — they are disable-only: rules: { "<id>": "off" } in config, or disabledRules for embedders.

IdDefault severityDetects
circularwarningImport cycles in the dependency graph (Tarjan SCC, graph.rs).
unreachableinfoClosed "dead islands" — files imported in-repo (fan-in > 0) yet unreachable from any entrypoint (unreachable.rs).
dead-candidatesinfoFile-level dead-code candidates: fan-in == 0 and not an entry-point pattern (tests/Storybook/dev-tool config/.d.ts, and files carrying an author-declared @generated/auto-generated banner, excluded) (dead_candidates.rs).
unimported-exportinfoSymbol-level export/import reconciliation — exported symbols no other file imports, split into an unused reason (never imported anywhere; delete it) and an in-file-only reason (referenced in its own file; drop the export keyword). The id says unimported-export, not dead-exports (its former spelling): the in-file-only half reports a symbol that is very much alive. Dev-tool config files, and files carrying an author-declared @generated/auto-generated banner, are excluded (dead_exports.rs, the Rust module that keeps the old name). Per-tree: when a monorepo is analyzed as multiple separate trees, this rule reasons within each tree — a symbol imported only from another tree via a deep path (no barrel export *) can read as never-imported in its own tree and be reported dead. Barrel re-exports (export *) keep such public API alive. Recommendation: rely on barrels for cross-tree public API, or analyze cross-tree-shared packages as their own tree whose entry (index) exposes them. TypeScript only: a type/interface export whose name appears in the PUBLIC SIGNATURE of some exported declaration in the same file — a parameter or return type annotation, a member of an exported interface, or the right-hand side of an exported type alias — is exempt and never reported, because un-exporting it would stop consumers from naming that type. Function bodies are never inspected, so a type used only as an internal useState<T> generic inside a hook with no annotated return type, and a type that only annotates an UNEXPORTED declaration's field, are still reported. Other languages produce no such set — but that is moot, because the whole rule is TypeScript-only: it runs solely over TypeScript-dispatched files (ts/tsx/js/jsx/mjs/cjs/mts/cts) and bails outright on a tree with none, so a Python, Go, Java, C# or Rust repo gets zero unimported-export findings because nothing was examined, not because nothing is dead. A local rename (export { X as Y } with no from-clause) publishes the declaration under its PUBLIC name, so importers of Y keep it alive; a rename nobody imports is still reported.
cache-lane-file-readwarningIncremental-cache self-audit, and the one native rule that judges nothing until you configure it. Name the function that produces one CACHED per-file unit in vocabulary.cacheLaneAnchorPattern (matched against a symbol's name); zzop then BFSes the whole-repo call graph from every matching symbol and reports any callee it can reach whose name is in vocabulary.fileReadCallees (defaulted to the stdlib read spellings — read_to_string, read_dir, readFileSync, ...). The defect it names is a silent one: a filesystem read reachable from a memoized unit is an input the cache KEY almost certainly does not cover, so once the file it reads changes, every warm entry keeps serving the old answer and nothing errors. The finding names the anchor, the reached symbol, the callee and the hop count, and its message offers the fix that is usually right — keep the read and put what it read INTO the key — before the one that is merely simpler (hoist the read to the caller). Both vocabularies are declared, and an undeclared anchor makes no judgment at all (there is no built-in and there cannot be one: which of your functions carries a closure promise is knowable only to you), so this rule is silent on a default config rather than guessing an anchor set. Reachability is what needs the call graph, so the rule only sees as far as CALL_GRAPH_COVERED_EXTENSIONS resolves — a read behind an unresolved cross-crate specifier is under-reported, never invented. Conversely a name is not a proof: read_to_string is also a Read trait method, so narrow the sink list if your tree reads in-memory buffers through it. Unlike every other rule here it does NOT need HTTP routes to exist — the engine's call-graph pass runs for it on a route-free library or compiler tree, which is exactly the population it is for (cache_lane_file_read.rs).
seamsn/aStrangler-seam scoring — folders that are self-contained (few boundary-crossing import edges), i.e. good first-extraction candidates (seams.rs). Rust embedding lane only — not carried on the CLI/MCP JSON reply. The shaped reply's architecture object is built from health/recommendations/critical alone (crates/summary/src/analyze/mod.rs), so seams reaches no zzop analyze or analyze_repo output in any form — only the raw zzop-facade JSON of a direct engine embedding carries it.
criticalityn/aTransitive blast-radius scoring — surfaces stable-but-critical files a churn-weighted risk score underweights (criticality.rs). Blast radius is the FACT (count of transitive dependents, computed over the whole graph); the reported ORDER is that fact size-weighted: blastRadius * ln(loc + 2), with blastRadius as the tie-break, because two hubs of equal blast are not equal danger — a 5-line re-export barrel is cheap to fix and a 400-line core is the bomb. Reaches the CLI/MCP reply only as architecture.criticalTop: the top 3 file PATHS off this ranking, without the contributing metrics — and re-sorting the raw critical array by blastRadius alone is a different order, so it does not reproduce them.
scoresn/aThe structural health scores, 0–100 each (scores/compute.rs; recount with awk '/pub const SCORE_MEANINGS/,/^\];/' crates/metrics/src/scores/meanings.rs and count the quoted keys — this cell said 17 for a release after v0.30.0 removed two). Rust embedding lane only — not carried on the CLI/MCP JSON reply. The Scores struct feeds health/recommendations, and only those two derivatives reach a reply (see their rows); the per-metric numbers themselves are reachable only through the raw zzop-facade JSON of a direct engine embedding.
healthn/aComposite structural-health index rolling the per-metric scores up into one number (health.rs). Reaches the CLI/MCP reply as architecture.pain (the scalar only — the contributors breakdown stays in the raw facade output).
recommendationsn/aROI-ranked improvement recommendations derived from FileNodes, coupling, and circular deps (recommendations.rs). Reaches the CLI/MCP reply as architecture.topRecommendation{id, severity, topItem} off recommendations[0] only; the rest of the list and every entry's full items stay in the raw facade output.
schema-structuralwarningFAMILY GATE for the 9 Prisma structural rules keyed off a model's own declaration — schema/god-model, schema/missing-timestamps, schema/redundant-index, schema/float-money, schema/stale-updated-at, schema/temporal-as-string, schema/fk-no-index, schema/nullable-fk, schema/implicit-fk, each with its own row below (rules/native/rules-schema/src/structural.rs, bodies in rules/native/rules-schema/src/structural/rules.rs). Disabling this id switches the whole pass off, including the parse it needs; disabling one schema/* id drops only that rule. This id never appears in a finding's ruleId — findings carry the per-issue id.
schema/god-modelwarningA model declaring at least GOD_THRESHOLD fields — a candidate to split into smaller, more cohesive models (rules/native/rules-schema/src/structural/rules.rs).
schema/missing-timestampsinfoA model with more than LOOKUP_FIELD_MAX fields that has no creation timestamp (createdAt, or any DateTime @default(now())) and/or no updatedAt. Small lookup tables are exempt by the field-count floor; an updatedAt-only miss is worded as a suggestion, since an append-only model legitimately has none (rules/native/rules-schema/src/structural/rules.rs).
schema/redundant-indexinfoA single-column @@index on a column already covered by @id/@unique — the index adds maintenance cost and no lookup path (rules/native/rules-schema/src/structural/rules.rs).
schema/float-moneywarningA monetary-looking field (name matching this rule's money vocabulary) typed Float/Double — lossy for currency; use Decimal (rules/native/rules-schema/src/structural/rules.rs).
schema/stale-updated-atwarningA field named like an update timestamp that carries no @updatedAt — Prisma will not auto-refresh it on writes, so its value silently goes stale (rules/native/rules-schema/src/structural/rules.rs).
schema/temporal-as-stringwarningA date/time-named field typed String — comparisons and ordering become lexicographic; use DateTime (rules/native/rules-schema/src/structural/rules.rs).
schema/fk-no-indexwarning (info when the column is a non-leading member of a composite index)A foreign-key-shaped field with no @@index/@@unique covering it as a leading column — queries filtering on it alone scan the table. The info arm is the partial case: the column IS in a composite, but only queries that also constrain the leading column(s) are served by it (rules/native/rules-schema/src/structural/rules.rs).
schema/nullable-fkwarningA foreign-key-shaped field declared optional — flags the optional relation for confirmation, not as a defect (rules/native/rules-schema/src/structural/rules.rs).
schema/implicit-fkinfoA foreign-key-shaped field with no @relation — the relation exists in the application's head but not in the schema, so Prisma enforces nothing (rules/native/rules-schema/src/structural/rules.rs).
schema-usagewarningFAMILY GATE for the 3 usage-aware cross-checks layered on the structural rules — schema/unreferenced-model-name, schema/unreferenced-field-name, schema/model-churn, each with its own row below (rules/native/rules-schema/src/usage.rs). Whole-tree pass, so it is recomputed every run rather than served from the per-file findings cache. Disabling this id switches the whole pass off; disabling one schema/* id drops only that rule. This id never appears in a finding's ruleId — findings carry the per-issue id.
schema/unreferenced-model-nameinfoA model whose NAME never occurs as an identifier token in this tree's source, and for which no bound-model attribute was injected on its symbol. Evidence sightline: that verdict is decided from identifier tokens scanned from this tree's .ts/.tsx files only (.d.ts excluded) — no Python, Go, Java, C# or Rust source is searched for the name, so a schema consumed from those languages reads as dead. A tree holding the schema with no such file at all (a schema-only tree, or a Prisma schema with a non-TypeScript client) supplies NO evidence, and then EVERY model reports here regardless of use — measured 2026-07-25: a directory containing one schema.prisma with two models produced two findings. Confirm the tree actually contains the consuming code before acting, or inject a bound-model attribute. The id says unreferenced-model-name, not dead-model (its former spelling), because the name is all this check ever looks at (rules/native/rules-schema/src/usage.rs).
schema/unreferenced-field-nameinfoA field of an otherwise-referenced model whose NAME never occurs as an identifier token in this tree's source. Fields named id/createdAt/updatedAt, and any name shorter than 3 characters, are excluded — they occur everywhere and carry no signal. A model that already reports schema/unreferenced-model-name short-circuits before its fields, so the two never pile up on one model. Same evidence sightline as the row above, and the same reason for the name (rules/native/rules-schema/src/usage.rs).
schema/model-churnwarning (critical past the higher threshold)A model carrying a high injected migration-churn count (model-churn attribute on the model's symbol) — accumulated schema changes suggesting an unstable design. Native analysis injects nothing here, so this fires only for a Mode-B producer that knows the project's migration layout; without one it is silent by construction, never a clean bill of health (rules/native/rules-schema/src/usage.rs).
unsafe-read-endpointwarningA GET/HEAD ("safe") endpoint whose handler reaches a database/store write via call-graph BFS — violates the safe-method contract. A pure counter bump (incr/incrby/decr/decrby) is deliberately NOT such a write here: that vocabulary belongs to non-idempotent-write, and this rule reproduces the write vocabulary it always had (create/update/delete/upsert/insert/save/remove/...), so a GET whose only reached write is a view-counter increment reports nothing. The hand-written // idempotent-ok: marker goes on the handler's body-start line or up to 3 lines above it, and REQUIRES its trailing colon: a bare // idempotent-ok does not suppress, and is now named in the finding's message instead of being silently ignored — as is any other marker-shaped comment in that window that this scanner does not honor. Language sightline: this check needs store-write evidence that only the TypeScript parser produces (ts/tsx/js/jsx/mjs/cjs/mts/cts) — SourceSymbol::write_sites, which every other parser leaves empty — so a Python, Go, Rust, C# or Java handler has no write site the BFS could reach and this rule can never fire on it. Zero findings outside those extensions means NOT ANALYZED, never "no unsafe read". Note this is NARROWER than mutating-route-no-auth below, and structurally so rather than by a list that has to be maintained here: that rule needs only a call graph, and its own row names the extension set the call graph covers; this one needs TypeScript-only write sites ON TOP of that. So a repo in any call-graph-covered language other than TypeScript/JavaScript can show that rule's findings while this one stayed dark on the very same routes.
non-idempotent-writewarningA write endpoint (PUT/DELETE always; POST/PATCH for accumulation only) that reaches a non-idempotent create, atomic-accumulate, or counter-bump operation via call-graph BFS — a retry duplicates or doubles the effect. Same // idempotent-ok: marker semantics and near-miss disclosure as unsafe-read-endpoint above, and the same language sightline: this check needs store-write evidence that only the TypeScript parser produces (ts/tsx/js/jsx/mjs/cjs/mts/cts), so zero findings in a Python/Go/Rust/C#/Java repo means NOT ANALYZED, never "no non-idempotent write".
duplicate-routewarningThe same (METHOD, path) HTTP route provided 2+ times across the tree. A repeat registration resolving to the SAME handler symbol as the first is skipped, not flagged — one handler deliberately registered on two paths that normalize to one key is the trailing-slash-tolerance idiom, not a shadow; a DIFFERENT symbol (or an unknown symbol on either side) is still flagged (rules/native/rules-http/src/duplicate_route.rs).
soft-delete-bypasswarningA findMany/findFirst/findUnique/count call site on a model with a deletedAt/deleted_at marker field whose argument span never mentions that field — a soft-deleted row can leak back into a "live" read (rules/native/rules-schema/src/join.rs). Language sightline: query call sites are extracted by the TypeScript parser only, so all three of these JOIN rules only ever see a Prisma client called from TypeScript/JavaScript — the same schema driven by prisma-client-py, prisma-client-go, or any other non-TypeScript client contributes no call site, and all three then report nothing. Zero findings there means NOT ANALYZED, never "no such call site".
orderby-unindexedwarningA single-field literal orderBy: { field: 'asc' } naming a field with no @id/@unique/leading-@@index coverage on the target model — an unindexed sort that gets slower as the table grows (rules/native/rules-schema/src/join.rs). Same language sightline as soft-delete-bypass above — query call sites are extracted by the TypeScript parser only.
enum-string-driftwarningA literal-object field: 'Literal' at a query call site whose field resolves to exactly one declared schema enum, where 'Literal' is not one of that enum's members — a string that drifted out of sync with the enum (rules/native/rules-schema/src/join.rs). Same language sightline as soft-delete-bypass above — query call sites are extracted by the TypeScript parser only.
route-shadowingwarningWithin one file, a param-segment route (/x/{}) registered earlier than a same-shape literal-segment route of the same method makes the later literal route unreachable in a first-match router. Framework-scoped by file extension: fires only on the first-match ecosystems zzop extracts (TypeScript/JavaScript — Express/Koa/Hono/NestJS — and Python FastAPI, whose Starlette router matches in registration order); specificity-match frameworks (Java Spring AntPathMatcher, C# ASP.NET, Go gin/net/http, Rust axum) pick the literal regardless of order and are exempt, so their routes never fire. A .ts/.js app whose specific router is itself specificity-match (e.g. Fastify's radix find-my-way) is the residual — the extension can't distinguish it from Express, so the message keeps its "may be a false positive if your router is most-specific-match" caveat (rules/native/rules-http/src/route_shadowing.rs).
mutating-route-no-authinfoA POST/PUT/PATCH/DELETE route, in a file the call-graph BFS has edges for, whose handler's BFS never reaches a callee named like an auth guard — with a per-language REACH bound the finding message spells out: cross-file and literal for JS/TS, but .java and a Python module-attribute receiver resolve a specifier to ITSELF, so the walk stops ONE HOP out and a guard reached as handler -> helper in another file -> guard is not found. A finding on those languages means "no guard within one hop", never "no guard anywhere". Three classes of route never reach the BFS at all and are exempt before it runs, each a "do not guess" rather than a verdict: a route in a file OUTSIDE CALL_GRAPH_COVERED_EXTENSIONS (ts/tsx/js/jsx/mjs/cjs/mts/cts/java/py/pyi/rs) — so a Go or C# mutating route is never checked, because a symbol graph restricted to those ecosystems is provably empty and "never reaches a guard" would be guaranteed, not evidence; a run that saw such routes says so out loud in its own warnings, naming the language, the count, an example path and this rule id, so the silence is disclosed rather than read as an all-clear; a route registered in a test-classified file; and the auth-ACQUISITION surface itself — the endpoint that hands out the credential cannot be required to present it — as a standalone tier (/auth//login//logout//signin//signup) plus a conditional tier (/register//token//refresh//password//otp) exempt only when the path ALSO sits under an auth-family segment. Guard vocabulary: auth/guard/verify/session/token/permission/acl/owner/admin/role (non-exhaustive; the full vocabulary is DEFAULT_AUTH_GUARD_PATTERN in rules/native/rules-http/src/mutating_route_no_auth.rs). The BFS can't see route-level middleware, but common Express guard registrations (app/router.use(guard), a route-level guard argument, a handler WRAPPED in a guard call like requireAdmin(handler), well-known callees like passport.authenticate) are now recognized natively and exempt a route through the same auth-guarded attribute (on the route's ioKey or a pathScope prefix) as the generic entity-attribute channel — registration order isn't modeled, so a recognized guard covers its scope regardless of where it sits relative to the route. Decorator/annotation auth that runs before the handler is likewise recognized natively and exempts the route by its own registration line — NestJS @UseGuards, Spring method security (@PreAuthorize/@PostAuthorize/@Secured/@RolesAllowed), class- or method-level, and FastAPI Depends(...) (in a route decorator's dependencies=[...], a parameter default, an inline Annotated[..., Depends(...)] parameter, or a bare parameter annotation naming a tree-resolved X = Annotated[..., Depends(<guard>)] alias — the shared deps.py idiom; the injected callable's name must read as an authorization check in every shape, and a dependency factory whose anonymous switch is off (required=False) is refused; an alias that is not bound in the route's own file, that two modules declare with disagreeing verdicts, or whose declaration this run never saw is never judged, so the route keeps firing) — as is a Django REST Framework view's permission_classes (evidence lives in views.py while the route anchor lives in urls.py, so it is joined to the route by view NAME, with AllowAny deliberately NOT counted as evidence and a same-named-class disagreement dropped rather than guessed), and a NestJS module's route-scoped auth middleware (consumer.apply(AuthMiddleware).forRoutes({path, method})), matched to the route by an exact, global-prefix-anchored path/method comparison. A Spring global SecurityFilterChain is modeled too: a fully-parsed secure-by-default chain (http.authorizeRequests()...anyRequest().authenticated()) exempts every route that escapes its .permitAll() matchers, scoped to the config's own source module (a strict parse-all-or-nothing that bails — keeping findings — on any path-scoped/unrecognized/WebSecurity.ignoring() form, since a wrong exemption would hide a real gap). A framework outside these native vocabularies, or a project's own custom middleware naming, still needs a producer/adapter to inject the auth-guarded attribute; native vocab, native middleware/decorator/config recognition, and injected evidence all compose. Residual: a NestJS global guard (useGlobalGuards/APP_GUARD) and Spring's lambda-DSL / path-scoped config aren't mapped — a route relying entirely on those still reports (rules/native/rules-http/src/mutating_route_no_auth.rs).
unprovided-consumeinfoAn HTTP IoConsume whose key matches no IoProvide anywhere in the analysis, gated to trees that provide at least one HTTP route themselves — a typo'd path, a renamed/removed backend route, or a route this analysis failed to parse. Unmatched consumes are split by first-path-segment overlap with the tree's own provided key space: overlapping ones stay individual; "foreign" ones (no overlap) fold into ONE aggregate finding once 3+ accumulate, enumerating every folded key (below 3, foreign consumes also stay individual). The vetoes below are applied FIRST, so vetoing one sibling can drop a group from 3 to 2 and replace one aggregate with several individual findings — a veto only ever removes KEYS, but the finding COUNT can rise and an individual finding can appear at a line the aggregate never anchored to. Any key containing :// is vetoed as third-party egress, matching the linker's own gate that buckets such a key into crossLayer.externalConsumes — an unmatched absolute-URL consume is expected there, not drift; this replaces the former localhost-only skip. An absolute URL whose host is one this analysis declares in hosts is the exception: it is re-keyed to its internal path before the veto and matched against this source's routes, the same transform the multi-tree linker runs before its own gate — so single-tree and multi-tree agree on declared-host calls too. Such a finding reports the internal path and keeps the absolute spelling in data.rawKey (data.rawKeys on an aggregate). A key whose path is ALL {} placeholders (GET /{}, the head-drop artifact of an unresolved ${BASE} interpolation) is vetoed as well: it names no route, so its failure to match is an extraction gap rather than a missing contract — the multi-tree join sends the same key to crossLayer.unresolvedConsumes (counted by cross-layer/unresolved-consume-ratio), and both surfaces decide it with the one shared zzop_core::key_carries_route_identity predicate. A root GET / is unaffected: zero segments, but a fully known path. Static-asset path suffixes are vetoed too (images, fonts, styles, scripts, .map, .txt), and .json/.xml only when the path carries no /api-ish segment — so a Rails-style .json API route outside /api/, or a real route ending in a vetoed extension, is silently not flagged (rules/native/rules-http/src/unprovided_consume.rs).
cross-layer/unconsumed-endpointinfoA crossLayer.unconsumedProvides http entry — an endpoint no tree in this analyzeTrees run calls. Caveats consumers outside the analysis (another repo, a mobile client, an unresolved dynamic URL) may still exist. A provide identified as a tRPC mount route (a literal trpc path segment, e.g. /api/trpc/{}) is excluded when ITS OWN source tree produced 1+ trpc-kind cross-layer edge — per tree, not run-global: a route in a tree with zero tRPC edges of its own is never excluded because some other tree in the run has them — the mount route IS the transport those edges flow through, so "unconsumed" would be tone noise; the exclusion is disclosed via a warnings entry on the owning tree, never silent. Two further exclusions: (a) a write-verb ROUTE already reported by cross-layer/unconsumed-mutation-endpoint is not repeated here — matched per route, so a co-located read verb at the same file:line (a verb-agnostic registration like gin's router.Any) still reports; disable that rule and its write routes appear here instead, never dropped; (b) a fixed list of externally-fetched paths is never reported — /, /health, /healthz, /healthcheck, /livez, /readyz, /robots.txt, /sitemap.xml, /sitemap_index.xml, /rss.xml, /feed.xml, /atom.xml, /favicon.ico, and anything under /.well-known/ (whole-path, case-insensitive, trailing slash trimmed — the list is exact, NOT a *.xml wildcard), since a monitor, browser, crawler or feed reader calling them from outside the analysis is the normal case, so "no in-tree caller" can never evidence deadness. Volume fold: at most 25 endpoints per SOURCE are listed individually; beyond that the source's tail collapses into ONE further finding of the same rule id stating how many routes it stands for, because on a backend-only or framework-package repo every route is unconsumed by construction (measured: 503 and 479 on two such trees). The fold is per source, so a small tree analyzed beside a large one keeps all of its own findings, and nothing is dropped — crossLayer.unconsumedProvides stays uncapped and the fold finding says so (rules/native/rules-cross-layer/src/cross_layer/unconsumed_endpoint.rs).
cross-layer/method-mismatchwarningA crossLayer.unprovidedConsumes http consume whose path exactly matches a provide somewhere in the analysis, but the method differs (e.g. FE calls POST /api/users, only GET /api/users is provided) (rules/native/rules-cross-layer/src/cross_layer/method_mismatch.rs).
cross-layer/version-skewwarningA crossLayer.unprovidedConsumes http consume whose key differs from a provide only in one version-shaped path segment (/v1/ vs /v2/) (rules/native/rules-cross-layer/src/cross_layer/version_skew.rs).
cross-layer/path-near-missinfoA crossLayer.unprovidedConsumes http consume whose key matches a provide once {} parameter positions are allowed to differ, but is otherwise segment-identical — strict elsewhere (a plural/typo literal difference does not count). Gated on the consume side: a consume whose path is ALL {} placeholders (a head-drop artifact, e.g. GET /{}) carries no literal evidence and never fires here; an all-{} provide is ungated and stays a legitimate suggestion target (rules/native/rules-cross-layer/src/cross_layer/path_near_miss.rs).
cross-layer/route-near-missinfoA crossLayer.unprovidedConsumes http consume whose key differs from a same-method provide by EXACTLY ONE structural dimension — case (letter casing) or prefix (an all-literal 1-2 segment leading base path added/removed, e.g. /api) — disjoint from path-near-miss's same-count parameter-generalization case; names the exact dimension so the fix is actionable. Same consume-side all-{}-placeholder gate as path-near-miss (rules/native/rules-cross-layer/src/cross_layer/route_near_miss.rs).
cross-layer/prefix-driftinfoAn aggregate over route-near-miss: when 3+ http consumes from one tree all near-miss providers in another tree by the SAME missing/extra base path prefix (e.g. every FE call omits the /api a NestJS setGlobalPrefix adds), reports ONE finding naming the single likely base-path/gateway/baseURL cause and enumerating every folded route, instead of N near-identical per-route findings. The subsumed per-route route-near-miss findings are replaced (not silently dropped — the aggregate lists them). Derived from route-near-miss, so it only fires when that rule is enabled (rules/native/rules-cross-layer/src/cross_layer/prefix_drift.rs).
cross-layer/db-table-name-in-multiple-sourceswarningThe same db-table key CONSUMED (not provided) by 2+ distinct source trees — evidence of a naming collision or a genuinely shared database; message says to verify which. Emits ONE COPY PER PARTICIPATING SOURCE, each anchored in that source's own tree (since 2026-07-29): a single representative made WHICH tree could silence the finding an accident of sorting, because exclude applies to the anchor — that tree excluding its own paths deleted the half of the finding that was about the OTHER trees. Every copy still lists the full source set. (rules/native/rules-cross-layer/src/cross_layer/shared_db_table.rs).
cross-layer/duplicate-routewarningThe same http (method, path) key PROVIDED by 2+ DISTINCT source trees — the cross-tree counterpart to duplicate-route above. Emits ONE COPY PER PROVIDING SOURCE, each anchored in that source's own tree (since 2026-07-29), for the reason db-table-name-in-multiple-sources above records: exclude applies to the anchor, so a single representative made WHICH tree could silence an N-tree fact an accident of sorting. Every copy still names the full source set (rules/native/rules-cross-layer/src/cross_layer/duplicate_route.rs).
cross-layer/external-shadow-internalwarningA crossLayer.externalConsumes consume (absolute URL) whose normalized method+path matches a route an analyzed tree provides — the caller hardcodes one environment's host instead of the relative/proxied path (rules/native/rules-cross-layer/src/cross_layer/external_shadow_internal.rs).
cross-layer/external-secret-in-urlwarningA crossLayer.externalConsumes consume whose URL query string carries a secret-named parameter (token/key/apikey/secret/...) — credentials in URLs leak through logs, referrers, and history, whether the value is a literal or interpolated (rules/native/rules-cross-layer/src/cross_layer/external_secret_in_url.rs).
cross-layer/external-host-in-multiple-sourceswarningThe same external host called directly from 2+ distinct source trees — a duplicated third-party integration; centralize behind one client or a backend proxy. Emits ONE COPY PER CALLING SOURCE, each anchored in that source's own tree (since 2026-07-29) — same anchor reasoning as the two rules above (rules/native/rules-cross-layer/src/cross_layer/external_duplicated_integration.rs).
cross-layer/external-host-fanoutinfoThe same external host called directly from 3+ distinct files — vendor calls scattered across the codebase instead of centralized in one client module. File identity is (source, file), not the bare tree-relative file: two trees carrying the same relative path (src/api.ts in a frontend and in a backend) used to fold into one file and veto the finding, so an unchanged repo can newly report this. Findings now carry an exampleSites payload — up to 5 {source, file, line} objects, the first of which is the finding's own anchor. Counts are a lower bound (only consume keys the join extracted at the call site are counted), hence "at least N distinct files". Deliberately still ONE finding, unlike the three sibling N-source rules above: this rule counts FILES, so splitting per source divides that count across trees and drops each share below the threshold — it would delete the finding rather than re-anchor it (rules/native/rules-cross-layer/src/cross_layer/external_host_fanout.rs).
cross-layer/external-base-url-driftinfoThe same external path consumed against 2+ different hosts (port included) — base-URL/config drift for what looks like one logical service (rules/native/rules-cross-layer/src/cross_layer/external_base_url_drift.rs).
cross-layer/external-version-inconsistentinfoOne external host consumed through both version-shaped (/v1/...) and versionless paths — inconsistent API version pinning against the same vendor (rules/native/rules-cross-layer/src/cross_layer/external_version_inconsistent.rs).
cross-layer/external-ip-literalwarningA crossLayer.externalConsumes consume whose host is a raw IP literal (loopback excluded — committed localhost URLs are the DSL localhost-url-literal-committed rule's turf) — environment-specific addressing committed into code (rules/native/rules-cross-layer/src/cross_layer/external_ip_literal.rs).
cross-layer/ambiguous-consumewarningA consume whose key is provided by 2+ distinct trees (crossLayer.ambiguousConsumes) — which provider actually serves the call depends on deploy-time routing the analysis cannot see (rules/native/rules-cross-layer/src/cross_layer/ambiguous_consume.rs).
cross-layer/all-consumes-unjoinedinfoONE finding per tree whose internal http calls ALL failed to join, when the run does have routes to join against. Fires at 3+ such calls (MIN_UNJOINED_CONSUMES, the same floor cross-layer/prefix-drift uses — both answer "pattern or coincidence?" over the same population). The population is crossLayer.unprovidedConsumes + crossLayer.ambiguousConsumes for that tree; unresolvedConsumes is deliberately excluded (extractor blindness, already cross-layer/unresolved-consume-ratio's subject) and so is externalConsumes (third-party egress is supposed not to join). It REPLACES the per-call cross-layer/ambiguous-consume and cross-layer/unprovided-mutation-call findings from that tree, whose "no provider anywhere" verdicts cannot be trusted while the join is dark — no information is lost, the finding states the count, the per-bucket split and a key sample, and disabling it hands the per-call findings back. Aggregates are never folded into it: when cross-layer/prefix-drift fires it names the actual prefix and is the strictly better message. The cause is almost always a base path this engine refuses to guess, on either side of the join — a baseURL assigned from a cross-file constant, or a router mounted under a computed prefix. Both sides now have a declarative repair the message names: trees[].topology.clientBase for the calling side (since 2026-07-29 — this finding is why it was built) and mountedAt/mounts/hosts for the serving side (rules/native/rules-cross-layer/src/cross_layer/all_consumes_unjoined.rs).
cross-layer/unconsumed-mutation-endpointwarning (info when the run has a blind source, or when THIS route is a named near-miss target — a caller was found, just off by a drifted path)A crossLayer.unconsumedProvides http entry with a write method (POST/PUT/PATCH/DELETE) — an unconsumed mutation endpoint is standing attack surface, not just dead code. It reports write routes in cross-layer/unconsumed-endpoint's place: that rule stands down on exactly the ROUTES this one actually reported — keyed off this rule's real output, (source, file, line, interface key) per emitted finding, never the file:line anchor alone, so a co-located read verb from a verb-agnostic registration (gin router.Any) is never swept up — and not a second copy of this rule's predicate; it covers those routes itself when this rule is disabled — so a write route yields exactly one finding, never two, and disabling either rule never drops it. Severity is conditional: when this run has 1+ source whose http consumes are majority-unresolved (the same blindness cross-layer/unresolved-consume-ratio self-reports), a confident "unconsumed" verdict is not warranted, so the finding fires at info instead and the message names the blind source(s) plus the quantified unresolved-consume count — the finding still fires either way (never suppressed), only the severity and framing change. The converse is NOT a completeness claim, and the message says so on the warning branch too: that blindness check only asks whether a source's http consumes are majority-unresolved, so its not firing means blindness was not witnessed — a caller in a source with a minority of unresolved consumes, in a call shape or language this extraction does not model, or outside the run entirely stays invisible to it. Same tRPC mount-route exclusion as that rule (see its catalog entry) applies here too; its externally-fetched-path list does NOT, since every requester justifying that list (monitor, browser, crawler, feed reader) issues a read (rules/native/rules-cross-layer/src/cross_layer/unconsumed_mutation_endpoint.rs).
cross-layer/unprovided-mutation-callwarning (downgraded to info when the run has a provide-blind source)A crossLayer.unprovidedConsumes http consume with a write method — a state-changing call whose target no analyzed tree provides; intentionally co-fires with the unprovided-diagnosis rules above. Severity is conditional: when this run has 1+ source that imports a server framework yet extracted almost no http routes tree-wide (the same near-zero condition the engine's framework-silence self-report fires on), a confident "no matching provide anywhere" verdict is not warranted, so the finding fires at info instead and the message names the blind source(s) — the finding still fires either way (never suppressed), only the severity and framing change. The converse is NOT a completeness claim, and the message says so on the warning branch too: that blindness check only asks whether a source imports a server framework yet extracted almost no http routes tree-wide, so its not firing means blindness was not witnessed — a provider in a source with no recognized framework import, or registered in a route shape this extraction does not model, stays invisible to it. The provide-side mirror of cross-layer/unconsumed-mutation-endpoint's consume-blind downgrade above (rules/native/rules-cross-layer/src/cross_layer/unprovided_mutation_call.rs).
cross-layer/route-shadowingwarningA {}-parameter route pattern provided by one tree that would shadow a same-method, same-shape literal route provided by a DIFFERENT tree if both are served behind one first-match gateway — the cross-tree counterpart to route-shadowing above (rules/native/rules-cross-layer/src/cross_layer/cross_tree_route_shadowing.rs).
cross-layer/unresolved-consume-ratioinfoA tree whose http consumes are majority-unresolved (dynamic URLs, generated SDK clients, wrapper functions) — self-reports that the cross-layer join is mostly blind for that tree instead of staying silent. Two thresholds, both exact: "majority" is unresolved * 2 >= total, i.e. at or above 50% (a 50/50 tree fires), integer math only so the output is byte-stable across platforms; and the tree must carry at least 5 http consumes in total (MIN_TOTAL_CONSUMES) — below that a ratio claim is small-sample noise. That same floor is the one cross-layer/untraced-client-import-no-visible-consume fires BELOW, so the two blind-spot self-reports partition the space and never co-fire on one tree. The numerator counts both forms of crossLayer.unresolvedConsumes: a consume whose key never resolved, AND one the linker demoted there because its key carries no route identity (GET /{}) — so closing that fabrication raises this ratio rather than hiding the blindness (rules/native/rules-cross-layer/src/cross_layer/unresolved_consume_ratio.rs).
cross-layer/untraced-client-import-no-visible-consumeinfoA tree importing an SDK-shaped package (@scope/sdk, *-sdk, openapi*, *api-client*) from 3+ files, OR an opaque HTTP client library (superagent, got, node-fetch, oazapfts, ...) the egress extractor cannot trace at all, from 1+ files, while having fewer visible http consumes than unresolved-consume-ratio's floor — consumption flows through a client the egress extractor cannot see; the not-even-visible half of the blind-spot partition. oazapfts joined the opaque-client list once its native recognition retired in favor of a Mode B adapter; the worked example that demonstrated it was one of six framework-flavor adapters removed on 2026-07-28, and examples/adapters/README.md states why enumerating framework flavors is a race this engine declined to run. The id says untraced-client-import, not sdk-import, because "SDK" names only the first class — the second is ordinary HTTP client libraries, none of which is an SDK; what both share is that the egress extractor cannot trace calls through them. Renamed from cross-layer/sdk-import-no-visible-consume; see VERSIONING.md (rules/native/rules-cross-layer/src/cross_layer/sdk_import_no_visible_consume.rs).
cross-layer/unconsumed-procedureinfoA tRPC procedure (kind trpc, key "VERB dotted.path", composed at assembly from cross-file router fragments) that no analyzed tree calls — TypeScript's compiler catches calls to nonexistent procedures but not unused definitions. Caveats server-side createCaller/SSR consumers this analysis cannot see (rules/native/rules-cross-layer/src/cross_layer/unconsumed_procedure.rs).
cross-layer/body-field-driftwarningA matched http edge whose FE-witnessed request-body literal (body-shape-v1) disagrees with the BE handler's resolved @Body() DTO: a required field the DTO declares but the FE literal never sets (only when the FE literal is otherwise exhaustive at that level), an undeclared key the FE sends (only when the DTO's own field list is complete), or a missing @Body('subKey') wrapper key entirely. Anchored at the consume, citing the DTO's file:line; caveats that this is a witnessed-literals-only comparison — interceptors/transforms can add or strip fields (rules/native/rules-cross-layer/src/cross_layer/body_field_drift.rs).
cross-layer/sensitive-response-fieldwarning (critical when consumed)A route handler's DECLARED response shape contains a field whose NAME is sensitive-shaped. The fact is response-shape-v1: the handler's return-type annotation (async findOne(): Promise<UserDto>, Promise<X> unwrapped syntactically; a plain identifier type also counts) resolved at assemble time against the same tree-wide class/interface shape merge the request-body path uses — declarations only, no return-statement or flow reading, ever. The vocabulary has three axes, each chosen for the false positive its neighbor would produce: SUBSTRING tokens (password/passwd/secret/apikey/privatekey/credential/passphrase/salt — long enough that no benign name embeds them), EXACT tokens (token/jwt/hash/pwd/ssn/otptoken ⊂ tokenCount and hash ⊂ contentHash are why these never match as substrings), and the SUFFIX token token (accessToken/refreshToken end in it, tokenizer does not); names are lowercased and _/--stripped first. KNOWN BOUNDARY of these built-in values — measured 2026-08-04 over 513 response fields in zzop's own corpus, then deliberately left alone: the substring axis cannot tell a credential apart from credential METADATA, so passwordChangedAt, hasPassword and credentialsRequired all fire; and the token suffix cannot tell a secret apart from an opaque pagination CURSOR, so nextPageToken/continuationToken fire too. In the other direction sessionId, authorization and cookie do NOT fire — none is a substring token, none is an exact token, none ends in token. These were not "fixed" because every candidate fix trades one error class for another (session as a substring immediately opens sessionCount), and because a project that disagrees can already say so: declare vocabulary.sensitiveResponseFieldSubstrings / ...ExactNames / ...Suffixes and your list REPLACES the built-in one whole. When you do, spell each entry the way the matcher compares it — lowercased with _/- removed, so sessionid rather than sessionId or session_id — because these three keys are normalized before comparison, and an entry that can therefore never match is reported to you as a config warning rather than silently accepted. The names in this paragraph are pinned against the matcher in both directions (the_built_in_vocabularys_measured_boundary_is_pinned_in_both_directions), so a change that makes the heuristic smarter turns that test red and forces this text to move with it. Provide-side only, so it fires with zero cross-layer edges; an http edge landing on the route escalates it to critical with the consumer count — no edge means "no consumer WITNESSED", never "unexposed", and the message says so. A handler that declares NO return type produces no fact and no finding — it is disclosed on the owning tree's warnings ("N route handlers declare no return type — declare one to turn this analysis on"), so zero findings on an annotation-free tree is distinguishable from a clean one. A SECOND per-tree disclosure covers the routes declaring cannot help: http routes from provider shapes with no response capture at all (Express/Hono router mounts, file-convention routes, every non-TypeScript framework) or whose annotation the capture cannot read (array/union/primitive/non-Promise generic) are counted against the tree's http total ("N of M http routes carry no response-shape evidence"), with the Mode B overlay response channel named as the way in — so an all-Express tree's zero is also distinguishable from a clean one. DELIBERATE SUBSTRATE BOUNDARY, stated in every message: the DSL security pack's secret rules (hardcoded-secret, jwt-sign-literal-secret, vendor-token-committed) match literal secret VALUES written in source; this rule reads declared response field NAMES — a different defect (stored data leaking through an API contract, not a committed credential), so the two can legitimately co-fire on one file and neither subsumes the other. Evidence bounds carried in the message: NAME evidence only (an auth route returning token can be by design — verify), and DECLARATION evidence only (runtime serialization like class-transformer @Exclude decorators, toJSON methods, or interceptors is not read). Language sightline: IoProvide::response has one built-in producer today — parser-typescript's Nest controller-decorator return-type capture (RESPONSE_WITNESS_EXTENSIONS) — while a Mode B adapter overlay can supply it for routes in any language; that fact is this rule's only trigger — zero findings on routes outside those extensions, or registered in any non-decorator shape (Hono/Express/file-convention routes), with no overlay supplying the fact, means the response side was NOT ANALYZED, never "no sensitive response field" (rules/native/rules-cross-layer/src/cross_layer/sensitive_response_field.rs).
cross-layer/retrying-write-no-idempotencycriticalA frontend WRITE call that runs under an automatic retry (IoConsume::retry_configured — the parser-typescript egress-retry-v1 recognizer: an axios-retry-wired file, or a pRetry(...)/backOff(...) wrapper enclosing the call) resolves to a real provider route via the cross-layer join, AND that provider route carries no witnessed idempotency guard — the two-sided check that justifies critical. If the retry fires (timeout, dropped response, 5xx) the non-idempotent request is replayed, so a provider that is not idempotent applies the write twice (double charge, duplicate order). Anchored at the consume (where the retry is configured), citing the provider file:line. The veto is a truthy idempotency-guarded attribute on the provider route (an exact IoKey or covering PathScope), the same open-vocab entity-attribute channel as mutating-route-no-auth's auth-guarded: set NATIVELY by parser-typescript's inline-handler recognizer when a handler reads the Idempotency-Key header (Express/Hono, TS only), or INJECTED via a Mode B overlay's attributes for every other provider language/framework — a paste-ready stub for that injection ships in the finding's data.injectionStub. Attribute absence means "no guard witnessed", not "proven unguarded". Like every cross-layer/* analysis this rule honors NO inline suppression marker: for a handler known-idempotent by inspection the escape hatch is to inject the idempotency-guarded attribute (the paste-ready data.injectionStub), or a path-scoped exclude in config (rules/native/rules-cross-layer/src/cross_layer/retrying_write_no_idempotency.rs). Language sightline: an automatic retry is witnessed by exactly one built-in producer today — the parser-typescript egress recognizer — though an adapter envelope can supply the tag from any language. The native witness is an axios-retry-wired file, or a pRetry(...)/backOff(...) wrapper — no Python (tenacity, urllib3 Retry), Java (Spring Retry, Resilience4j), Go, Rust or C# retry policy is natively recognized, and within TypeScript the hono-client, tRPC and fetch-wrapper consume paths leave the tag unset too. Since that tag is this rule's only trigger, zero findings means the retry side was NOT ANALYZED, never "no replayed write".
cross-layer/unknown-verb-routeinfoA route whose path is statically served but whose HTTP method(s) could not be determined — an all-verb pages/api handler, a pathname-dispatch block, a Go HandleFunc registration that pins no method literal, or a Django URLconf entry (url()/re_path()/path()), which is verb-unknown BY CONSTRUCTION since a URLconf binds a path to a view class and the method(s) are decided inside that class. The honest-disclosure replacement for the retired [GET, POST] verb fabrication (1b): the path is confirmed served, only the verb is a static unknown, so no exact verb-level cross-layer check (unconsumed/near-miss/method-mismatch) can run against the route without inventing a method. Not an error — inject the method(s) through a Normalized AST adapter (Mode B overlay) to enable exact verb-level checks (rules/native/rules-cross-layer/src/cross_layer/unknown_verb_route.rs).

Not yet implemented: architecture rules (layer-violations, feature-envy — no crate is scaffolded for them yet), cognitive/nested-loop complexity scoring, precise taint-flow dataflow (today's security/taint-flow is a documented coarse v1 co-occurrence check), an auth-state-machine analysis, additional cross-file HTTP graph checks (API churn, frontend/backend spec drift), a JSX/React structural rule pack, and env/i18n sync checks — each needs either a whole-graph join the DSL can't express or real AST/JSX shape. (Raw-Worker route extraction — manual url.pathname dispatch in framework-less Workers/Node servers — shipped as the parser's pathname-dispatch provide vocabulary.)

Matchers

Every DSL rule declares exactly one matcher shape, drawn from the table below. Full field-by-field semantics are in the DSL reference; this is the short version.

MatcherOperates overUse it for
line-scanPer-line regex over a file's raw text.Lexical patterns — a keyword, a call, a literal shape on one line.
method-scanMulti-pattern co-occurrence within one symbol's body span (innermost span wins on overlap; files without spans are skipped)."These patterns appear together in one function" — e.g. a network call and a $transaction( in the same method.
symbol-scanA file's declared symbols (functions/classes/consts/types/interfaces).Naming-convention or banned-export rules line-scan can't express reliably.
io-scanA file's IoFacts — the cross-layer IO (HTTP routes, DB tables, ...) the parser projects alongside symbols.Boundary-convention rules, e.g. "every HTTP endpoint must be versioned under /api/v[0-9]+/".
call-scanA file's projected call_sites — one fact per witnessed use of an API family (console-write, env-read, process-exec), each carrying the callee exactly as the source wrote it.The structural counterpart to a lexical call match: a mention inside a string or a comment is not a site, one rule covers every language whose parser projects the channel, and in_loop asks whether the call runs once per iteration.
literal-scanA file's projected string_literals — the binding name, the value's hash, and the value's entropy, computed at extraction. Never the value itself.The two judgments a regex structurally cannot make: comparing a value against its own binding name, and thresholding entropy — without ever writing a candidate secret into the analysis cache.

All but one operate on a single file's SourceFile slice in isolation and cannot see a second file's content; io-scan is the exception, evaluating whole-tree over IO facts already composed across files. A rule's inline suppress marker is DERIVED from its id — zzop-<rule id>-ok, never authored as a field — and applies to the findings of every matcher except symbol-scan: a // zzop-<rule id>-ok comment on the finding's own line, or the single line directly above it, suppresses it. (io-scan, call-scan and literal-scan are multi-language by construction, so they honor a # comment leader as well as //.) The marker carries no pack prefix (security/hardcoded-secret// zzop-hardcoded-secret-ok). symbol-scan findings have no source-line concept to anchor a suppress comment against, so they carry no inline marker. See dsl-reference.md in the repo for the full matcher field tables.

Write your own pack

A pack is one <id>.json file loaded from a configured packs directory — no first-party/third-party distinction exists at the interpreter level. Two spellings name that directory and they are not interchangeable: packs.extraDirs in a zzop.config.jsonc, or packsDir on an embedder's request object. A zzop/rules/ directory is also picked up without packs.extraDirs naming it. Here's a small line-scan rule, flagging a hardcoded debug header value that should come from config/env instead:

debug-headers.json
{
  "id": "debug-headers",
  "framework": "any",
  "schema_version": 1,
  "rules": [
    {
      "id": "hardcoded-debug-token",
      "severity": "warning",
      "message": "X-Debug-Token header set to a string literal — this bypasses per-environment config and risks shipping a real token. Read it from env/config instead.",
      "matcher": {
        "type": "line-scan",
        "file_pattern": "(?i)\\.(ts|tsx)$",
        "require_file": "X-Debug-Token",
        "skip_comment_lines": true,
        "line_pattern": "[\"']X-Debug-Token[\"']\\s*:\\s*[\"'][^\"'`]+[\"']",
        "snippet_max": 160
      }
    }
  ]
}

Both spellings accept either one directory or an array of directories. Each is loaded independently and then merged by pack id: if the same id shows up in more than one directory, the pack from the later directory in the list replaces the earlier one whole (not a per-rule merge) — this is how a caller adds packs alongside the bundled ones, or overrides a bundled pack outright, without forking the engine.

The message writes the cause and the fix, and stops there. Notice it names no suppress marker and no way to disable the rule: the engine appends both sentences to every finding at runtime — the marker (zzop-<id>-ok, derived from the id, spelled with the comment leaders that matcher kind actually honours) and the rules: { "<pack>/<rule>": "off" } disable hint. Writing either one yourself renders it twice, and a hand-written marker sentence goes stale the moment the matcher kind changes, because it names leaders the engine no longer honours.

Not every detection fits the matchers above. Reach for a native rule instead when the check needs declaration→use tracking (an identifier declared but never read), a cross-file join (resolving a constant or route handler defined in another file), call-graph BFS ("handler X, or something it calls transitively, does Y"), or real AST/JSX shape rather than text co-occurrence — cyclomatic/cognitive complexity and JSX-structural checks have no honest regex-over-lines encoding.