룰 카탈로그

이 페이지는 scripts/gen-site-rules.mjsdocs/rules/catalog.md 에서 옮겨 적은 것이다 — 그 파일은 동시에, 바이트 그대로, 에이전트가 읽는 rule-catalog 계약 문서다 — 그리고 메타 테스트(crates/engine/tests/rule_contracts/)가 아래 나열된 모든 id 가 엔진이 런타임에 실제로 싣는 것과 일치하는지 기계로 검사한다. 그래서 카탈로그가 코드와 조용히 어긋날 수 없다. 아래 DSL 팩 룰은 발견이 난 줄에, 또는 바로 윗줄에 // zzop-<rule-id>-ok 주석을 달아 인라인으로 억제할 수 있다(마커는 룰 id 에서 파생된다 — float-money-compare 룰은 // zzop-float-money-compare-ok 를 받는다). 네이티브 분석은 끄기만 가능하고, 주석으로 움직이는 예외가 둘 있다 — non-idempotent-write/unsafe-read-endpoint 는 손으로 쓴 // idempotent-ok: <reason> 를 존중하고(끝의 콜론 필수), dead-candidates/unimported-export 는 생성 파일 배너를 단 파일을 건너뛴다. 모든 룰과 네이티브 분석 id 는 실행 단위로 끌 수도 있다 — zzop.config.jsoncrules: { "<id>": "off" }, 또는 임베더용 disabledRules.

범위: 아래 모든 표는 바이너리가 기본으로 싣는 룰이고, 그것이 이 레포가 싣는 룰 전부는 아니다. examples/packs/ 에는 내보낸 팩이 있다 — 진짜이고, 테스트되고, 축을 선언하는 룰인데 일부러 기본 세트에 컴파일하지 않은 것들이라 아래에 행이 없다. 이름이 나오는 경우가 있다면 그건 번들 룰의 행이 그쪽을 가리키는 것이다. (ls examples/packs/*.json 이 명부이고, docs/rules/catalog.md § Exported packs 가 어떤 테스트가 각 팩을 내보냈는지와 그 안의 룰을 세는 커맨드를 든다.) 내보냈다는 것은 지웠다는 뜻이 아니다: 각 팩은 본문이 곧 팩 JSON인 계약 문서로 제공된다 — MCP 호스트에서는 리소스 zzop://contract/example-pack-<stem>, CLI 바이너리로는 zzop contract example-pack-<stem>(계약 인덱스가 내보낸 팩마다 한 항목씩 든다). 트리의 zzop/rules/ — 기본 저작 팩 위치 — 아래에 하나 쓰면 다음 실행이 싣는다. config 키가 필요 없다.

어떤 룰이 어떤 언어에 닿는지는 룰마다 다른 사실이고, 그 룰 자신의 file_pattern 이 정하지 팩 수준에서 정하지 않는다. 그래서 한 팩이 어떤 언어에는 빽빽하고 다른 언어에는 비어 있을 수 있다. 네이티브로 파싱하는 모든 언어는 오늘 최소 한 룰에 닿는다 — C# 도 포함이고 여러 룰에 닿는다(call-scan 해시 룰들은 C# 자신의 어휘 MD5.Create/HashAlgorithm.Create 를 말하고, 다른 룰들은 경로 후보로 .cs 를 받는다) — 그러나 분포는 매우 고르지 않고 언어별 합계는 발행하지 않는다. 그 수가 잘 정의되지 않기 때문이다: 어떤 패턴은 확장자뿐 아니라 디렉터리로도 좁혀지므로 한 레포 안의 .ts 파일 둘이 서로 다른 수에 해당한다. 대신 구체적인 경로를 물어라 — docs/rules/catalog.md 가 출하된 팩에 대고 그것을 답하는 커맨드 하나를 든다.

Rust 에서는 테스트 영역 안의 발견이 버려진다 — 자격증명 룰만 빼고. #[cfg(test)]/#[test] 로 가려진 항목 안에 떨어진 발견은 보고 전에 빼진다. 아래의 자격증명-정지 룰들은 그것을 거부하고 자기 행에 그렇게 적는다("Scans test paths too"). 커밋된 키는 컴파일러가 남기든 말든 유출이기 때문이다. 그러니 이 축은 "테스트 영역은 제외, 단 자격증명은 예외"이지 "전부 제외"가 아니다. 메커니즘과 경계: 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. What is actually read is a LEXICAL token search for where: over the function body, unlinked from this call, and it leaks both ways (measured 2026-08-18). Over-reports where the filter spells no token — a spread (updateMany({ ...scope, data })) and the shorthand (updateMany({ where, data })) each fire at critical on a scoped write, while where: buildWhere(orgId) does not, because that spelling puts the token back. The whole-argument form (updateMany(args)) does NOT fire: the trigger requires the call line itself to open the argument object (updateMany({) or to take no arguments (updateMany()), so a call handed its arguments by name — or one whose arguments open on the next line — is never judged on a where: token it had no chance to spell. A callback argument (deleteMany(u => …)) is vetoed for the same reason. Under-reports in the mirror case: any where: in the body satisfies the veto, a parameter type annotation included, so function f(where: object) { prisma.user.updateMany({ data }) } is a real whole-table write it stays silent on — and that silence reads as a clean bill.
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. The predicate is the token's absence, which is not the same as no sort being applied: an ordering built by a helper and spread in (findMany({ ...opts, skip, take })) fires while the sort sits one call away (measured 2026-08-18). 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( — a real hit extends transaction lock hold time across a network round-trip. CO-OCCURRENCE, not containment: nothing establishes the call happens WHILE the transaction is open, and a committed await db.$transaction([...]) followed by an unrelated fetch(...) fires the same way (measured 2026-08-18).
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. An element of an ARRAY transaction is exempt, because there the rule's own remedy breaks the code: await prisma.$transaction([ a.create(...), b.create(...) ]) takes un-awaited query builders, and awaiting one inside the array runs it immediately and outside the transaction. The exemption is structural — the still-unclosed opener lines above the call are read (up to 40 up) for an await/return/yield-ed $transaction([/Promise.all([/Promise.allSettled([. It stays silent in the safe direction in four measured cases, each of which keeps firing: a builder collected via arr.push(...) and spread in later ($transaction([...ops])), an opener past the cap, an opener line carrying no await (const p = prisma.$transaction([...]); await p;), and a walk that crossed a template literal, a mid-line comment or an unbalanced bracket. The CALLBACK form ($transaction(async (tx) => {...})) is deliberately NOT exempt — there an un-awaited write really is fire-and-forget.
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 neither a collision-atomic write (connectOrCreate/upsert/ON CONFLICT/ON DUPLICATE KEY) nor a handled unique violation anywhere — "handled" read narrowly, as a COMPARISON against the vendor collision code (err.code === "P2002", case "23505":, error.code == ER_DUP_ENTRY, instanceof UniqueConstraintError) and never a bare mention of the token, so a log line or a docs URL naming P2002 no longer silences a live race (the arm matched the bare token until 2026-08-23); the narrowing under-reports a membership test, a named constant and Mongo's numeric 11000, each disclosed in the message — check-then-act race, concurrent requests can create duplicate rows (a bare $transaction does not close it). The remedy the message gives is guarded before it is given: it first rules out a column pair that is not meant to be unique in the reader's domain (one an owner may legitimately hold twice), where the constraint fails the migration on existing duplicates and then rejects the second legitimate row, and points at a narrower key or a row lock instead — the same counter-indication now runs ahead of the constraint in this rule's two siblings, check-then-act-in-loop and sql/race-condition-toctou, and in sql/raw-sql-check-then-write. Where the pair is unique the remedy is the constraint plus a handled violation; swapping the insert for an upsert is offered only with the caution that update: OVERWRITES the row that already exists, which on a credential-bearing row is an account takeover rather than a fix.
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. CO-OCCURRENCE, not a link: nothing establishes the catch WRAPS the write — a try { JSON.parse(raw) } catch {} guarding a parse, with an unrelated create(...) on a later line, fires identically and there the write's failure propagates normally (measured 2026-08-18).
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 neither a connectOrCreate/upsert/ON CONFLICT/ON DUPLICATE KEY guard nor a handled unique violation (the same comparison-shaped error-code test find-then-create-no-unique describes, added here 2026-08-23 so one closed race goes quiet under both rules) 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 (SECRET_KEY/API key/password/token assignment, or a known cloud-key prefix). secret_key is its own alternative rather than a bare key: the name arms match a keyword the identifier ENDS with, so a committed Django/Flask SECRET_KEY reached neither secret rule until 2026-08-18, while adding bare key brought 46 further corpus findings of which one was a credential. 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. Four arms: 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. TypeScript and Python spell that same typed shape with : string = and : str =, and the fourth arm annotated-assignment covers both — anchored on the type spelling and requiring a quoted literal, which is what keeps apiKey: string = getFromVault() and an arrow function's ): string => "…" return type out. Go's var apiKey string = "…" separates name from type with a space rather than a colon and is NOT covered; Go's idiomatic := and untyped const both go through assignment. The language set gained Python, Go and C# on 2026-08-17: the separator is (?::=|[:=]) so Go's := is admitted while ==/!== stay rejected, and an optional quote after the name admits a quoted dict/JSON key. That admission was PARTIAL for one release — exclude_pattern is evaluated against the whole LINE rather than the matched value, so a multi-word quoted key was itself identifier-shaped and tripped the veto meant for identifier-shaped VALUES, making "api_key": "sk-live-…" silent while the single-word "password": "Pr0d!Pg#2024" fired: same layout, opposite verdict, decided by the key's word count. The veto now judges only a string in VALUE POSITION (after a : or =), which needed no new matcher capability. Every documented anchor of the six-of-seven drop set sits in value position and stays dead, and the corpus is unmoved — a re-scoping, not a weakening. On 2026-08-20 the pack's browser-facing extension set widened again, single-file components included; zzop explain security/hardcoded-secret prints the live pattern and this cell deliberately carries no copy of it. The consequence specific to THIS rule is that its arms and its vetoes now run over a .vue/.svelte file whole, template markup included, with no <script>-block split: an api-key="..." attribute is judged by the same regex as an assignment in code. 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 secret-key/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. Its reach is narrower than its line-scan sibling's, and the gap widened on 2026-08-20 when the security pack's line rules took on single-file components and the remaining TypeScript/JavaScript extensions and this rule's own pattern did not follow. The two halves of that gap are not the same defect. Over a single-file component nothing could follow: no front end projects the bound-literal channel out of one, so what this rule reads is empty there whatever the pattern says. Over the TypeScript/JavaScript extensions its sibling gained, the front end does dispatch and does produce the channel, and this rule's pattern simply does not list them — a real, currently-open gap, recorded here rather than left to read as coverage. Six producers feed the channel at all (TypeScript, Python, Java, C#, Go, Rust); run zzop explain security/high-entropy-secret for the live pattern, and read silence anywhere outside it as 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 — a jwt.secret, *.password or api-key style assignment whose value is 16+ non-space characters. The filetype set now includes .json and .jsonc, alongside .properties, .ya?ml, .toml, .ini, .conf, .cfg and .env, and that is where the reading of a zero changed: a JSON-configured service used to be a file this rule never opened, so its clean run said nothing. Two more admissions landed with it — a value may now end in a trailing comment or a JSON structural closer instead of ending the line, and the value alphabet is any run of non-space characters rather than a restricted set, so a password carrying punctuation is seen. The five things it does NOT flag, the 16-character floor it cannot see under, and the placeholder-word vocabulary that is wrong in exactly one direction are all spelled out in the finding's own message — read that rather than a second copy here. One whole file class is skipped before any line is read: a TRANSLATION CATALOGUE, meaning a file under a locales/locale/i18n directory whose next path component is a BCP-47 language tag, as a directory (locales/km/common.json) or as the file's stem (i18n/nb_NO.json). The 16-character floor is a proxy for "looks like a secret" that collapses where a writing system does not put spaces between words, so the rule read translated UI labels as credentials and did so unevenly — 87 measured across three trees, every one a label. Both halves of the path are required, so locales/config.json and a no/ directory outside such a container are still judged; lang/, translations/ and Java ResourceBundle messages_<tag>.properties were each measured at zero and are still scanned. 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, two arms: any file whose extension is itself browser-facing (.tsx, .jsx, .vue, .svelte) wherever it sits, OR any TypeScript/JavaScript extension under a directory named fe/frontend/client/web. Test and story paths are excluded from both, and so are three server-only path shapes whose spelling the FRAMEWORK fixes rather than the project: a Next.js App Router route handler (a file named route.ts/.js/.mjs/.cjs/.mts/.cts under an app/ segment), a Pages Router API route (pages/api/**, non-component extensions only), and next.config.*. That carve-out is the routing convention itself and not "a path containing api" — a directory someone named api holding real client code is still scanned, a route handler outside any api/ segment is still exempt, and a .vue/.svelte page under pages/api/ is still scanned so a Nuxt or SvelteKit tree cannot be swept in by the directory name. 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" — and that is the residual to watch, since a bundler inlines an env read from any module it pulls in. This is a paraphrase of the rule's file_pattern, never its second copy: zzop explain security/secret-env-in-fe prints the live one.
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, a Rust format! placeholder ({}, {name}), or a printf-family verb (%s/%v) in the PASSWORD slot between :// and @ means there is no credential on the line to rotate — a verb is % plus a NON-hex letter, so a percent-ENCODED password (%70ass) is not mistaken for one. Scans test paths too (a committed credential is leaked regardless).
private-key-committedcriticalline-scanA PEM private-key header (-----BEGIN [RSA/EC/DSA/OPENSSH/ENCRYPTED/PGP] PRIVATE KEY-----) committed to source — the key is compromised the moment the repo is shared; rotate it and move it to a secret store. The header alone is not enough, and this is the row's one precision fact: the line must either END at the header (the first line of a real PEM block) or continue into 20+ characters of base64-shaped key material, so a header quoted mid-sentence in prose does not fire; a line carrying a ${...}/{{...}} interpolation never fires at all, and neither does a PEM header sitting in a placeholder= form-hint attribute (that veto keys on the attribute NAME — value=/defaultValue= still fires). It is one of the two rules in this pack that read NON-SOURCE files — .json, .yaml, .env, .pem alongside the source extensions — because that is where a key actually lands. 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. Every arm keys on the vendor's own prefix AND a minimum body length, so the prefix alone in prose is not a finding. Three shapes deliberately never fire: test-mode keys (sk_test_), a ${...}/{{...}} interpolation, and a line reading the value from process.env — a token pulled out of the environment is not a committed one, and flagging it would train readers to ignore this rule. Like private-key-committed it reads NON-SOURCE files (.json, .yaml, .env, .pem) as well as source. 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.
sql-interpolated-statementwarningline-scanA SQL statement's TEXT assembled at runtime in Python or Go — + concatenation, a Python f-string / % operator / .format(), or Go's fmt.Sprintf — so the spliced value becomes part of the statement rather than a value bound to it. Same shape-not-dataflow standing as its siblings security/sql-string-concat and security/sql-format-interpolation, which is why it is warning; it exists separately from both because their messages prescribe PreparedStatement/JPA setParameter and Rust's format! family respectively, remedies that would be a claim this engine cannot support on a Python finding. The literal must BEGIN with the statement (leading whitespace aside), so a log line mentioning a keyword mid-sentence stays silent; the parameterized forms are deliberately NOT matched — %s INSIDE the string with a comma after it, and Go's $1 placeholder with the value as a further argument. Each arm is pinned to ONE quote character rather than accepting either — a correctness requirement, since SQL literals routinely contain single-quoted values (WHERE name LIKE '%term') and a body class excluding both quotes stops at the inner one, misreading what follows as a closing quote plus an operator; measured on this project's corpus, pinning removed a false positive and uncovered a false negative in one change, and its residual limit is a statement that escapes its own delimiter. UPPERCASE keywords only, the same precision gate security/sql-format-interpolation makes, so lowercase SQL is invisible here; a Python triple-quoted literal and any statement built across MORE THAN ONE LINE are outside a line-scan's reach. Migration paths are excluded through the same ${test-paths-migrations} vocabulary the sibling SQL rules 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. It is the SOURCE-FREE half of a pair: taint-flow reports eval only when it can also see a request-derived source in the same function and only where that method-scan reaches, while this rule needs no source at all and covers the whole browser/Node extension set including single-file components. A require_file pre-skip means a file with no eval(/new Function( text anywhere is never line-scanned, which is a cost saving and not a scope limit. Unlike the credential rules above it does NOT scan test paths — a test that evals a string is not a shipped sink.
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.
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. Two more scope facts, both deliberate: a require_file gate skips any file whose text does not mention child_process at all, so an exec reached through a re-exporting wrapper module is invisible here; and this is the one rule in the pack's browser-facing extension set that was NOT widened to .vue/.svelte, because a single-file component that spawns a shell is not a shape this rule was measured on. Test paths are excluded.
command-interpolated-stringwarningmethod-scanOne Python or Go function PROVEN by the parser's projected call-site channel to construct an OS process ALSO builds a string by concatenation or interpolation (the lexical half, and the trigger). The recognized constructors are exactly the platform APIs each producer pins — Python's subprocess.run / subprocess.call / subprocess.check_call / subprocess.check_output / subprocess.Popen / os.system / os.popen, and Go's exec.Command / exec.CommandContext — so a bare-name import (from subprocess import runrun(...)) or a third-party runner (sh, plumbum) projects no site and this rule is silent there. CO-OCCURRENCE within one function body, not proof that the built string is the one that gets executed, which is why it is warning and not critical — the same standing the Java sibling security/cmd-injection and the Rust sibling security/command-and-interpolation take. What a real hit MEANS differs by language: os.system and os.popen hand the whole string to a SHELL, as does subprocess.* called with shell=True, while an argv LIST and Go's exec.Command spawn the program directly with no shell — Go's shell-routed spelling is the explicit exec.Command("sh", "-c", built). This rule does not distinguish those cases, because the co-occurrence gate means it has not established WHICH string reached the process.
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). EJS's <%- include('partial') %> is excluded: the unescaped tag is the only form that composes templates, since the escaped form would print the partial's markup as text. The carve-out needs a CLOSED string-literal argument, so <%- include(userPath) %> and <%- include('dir/' + p) %> still report.
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 — IF that value becomes part of the URL, the server can be steered to attacker-chosen hosts (SSRF). CO-OCCURRENCE, not dataflow: nothing establishes the read reaches the URL, a handler reading a parameter for something else while calling a hard-coded endpoint fires identically, and validating the target does not clear it (the veto would need a dataflow this matcher never had) — use the inline marker.
open-redirectwarningmethod-scanredirect(...) called in a function that also reads req.query/req.params/req.body — IF the request value is the redirect target and unchecked, a phishing/OAuth-callback token-theft vector. CO-OCCURRENCE, not dataflow: the request read and the redirect are matched independently inside one function body, and redirect is matched lexically so a local helper of that name counts. A target that OPENS with a literal pinning the authority does NOT fire: a fragment starting / whose second character is neither / nor \ (an absolute PATH; // is protocol-relative), or one already carrying ?/#, which terminate the authority. Only literals spelled inside the call are read — a fragment held in a name one line up still fires. A validating call named safe/sanitize/allowlist + Url/Uri/Redirect/Link does clear it, but only when it produces the redirect's WHOLE value — read inside the call's own parentheses (8 lines), so a formatter's line break does not hide it, and the value may continue only into ??/|| fallbacks that are calls or quoted literals, or into a template literal whose text after the helper begins with ?/# (a query or fragment cannot move an origin). A helper concatenated or interpolated beside a raw value in either direction still fires, as does one applied on a preceding statement; a schema .parse() of the query is deliberately not read as validation. Known false veto, stated in the finding: sanitizeUrl (@braintree/sanitize-url) fits the name and strips protocols for XSS while passing an absolute https://evil.com through.
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. 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 collision argument does NOT carry to an HMAC (createHmac("sha1", key) is not broken by SHA-1 collisions), and it is not the reader's choice at all where a counterparty fixes the algorithm (an inbound webhook signature) — both disclosed in the message, which also names the language asymmetry behind the first (createHmac is in the TypeScript hash-call family; Go/C#/Rust/Python keep HMAC out of theirs). A Python construction whose SAME LINE also says usedforsecurity=False is skipped: that keyword is CPython 3.9+'s own statement that a digest is not a security primitive (bandit reads the same argument as B324), so it is evidence the source WROTE rather than an inference about intent — and it is honoured as a DECLARATION, meaning a digest that looks security-adjacent despite carrying it is skipped too. Residual, measured rather than implied: the check reads the call's OWN line, so a construction split across lines puts the keyword out of reach and still fires (3 of the 5 uses on getredash/redash @ ca79fe98 are spelled that way). 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.
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) appears in a function that ALSO performs a database write — if the request object is what reaches that write, a caller can set fields the handler never intended to expose. CO-OCCURRENCE, not dataflow: the halves are matched independently within one body and nothing links them, measured on logger.info({ ...req.body }) beside a create whose data is all constants, where the finding anchors on the LOGGER line. warning for that gap.

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 name carrying Safe/Sanitized/Escaped/Purified ANYWHERE in it (so markdownToSafeHTML(x) counts). Only that adjective half is position-free — the verb half stays start-anchored, so htmlEscape(x) still fires, and Unsafe is kept out by the token's capital S alone. 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 a 36-tree corpus the veto clears 8 of 33 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 a const location = useLocation() declaration stay silent, and since 2026-08-21 so does the UNQUALIFIED global whenever its = carries no whitespace. That notation requirement is what keeps two look-alikes out — a window.open feature string, where , location=no is lexically an assignment to the global, and a JSX prop — and it takes a real sink with them: a hand-minified or legacy no-space assignment to the bare global is a third silent shape, disclosed rather than measured away. window.location and location.href are unaffected and fire with or without the space, and an arrow parameter named location is silent as well.
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, and a sample value the author labelled as one — a URL literal that follows an object key spelled example in key position on the same line (an OpenAPI/Swagger @ApiProperty({ example: … }) sample). Only that key, only in key position, only for a literal after it on that line: a description: carrying the same URL, and http://example.com anywhere else, both still fire.
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. CO-OCCURRENCE, not one request: nothing establishes the method and the body: belong to the SAME call — a fetch(a, { method: 'get' }) line followed by a legitimate fetch(b, { method: 'post', body }) fires on the POST line with no GET carrying a body anywhere (measured 2026-08-18), so following the advice cannot clear it. 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. Reported once per enclosing function rather than once per go statement: the finding anchors on the first qualifying go line and data.triggerLines carries how many lines of that function qualified. Pre-Go 1.22 toolchains also capture the SAME loop variable across every iteration.

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-unguardedinfomethod-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. The guard vocabulary has TWO arms: those exact, case-SENSITIVE names, plus a SHAPE — an identifier carrying a cancel/abort/destroy/unmount/dispose stem, in any case, that is assigned true somewhere in the body. The second arm is what recognizes isCancelled and isDestroyed, which the first never matched (measured on getredash/redash @ ca79fe98: the second arm removed 12 of this rule's 35 findings there, every one a live guard with a cleanup, while that tree's one INERT guard kept reporting), and demanding the ASSIGNMENT is what keeps the widening from silencing an INERT guard: a flag declared and read but never set — an effect that returns no cleanup — leaves its if (!isCancelled) permanently true, so that finding is CORRECT and still fires where a plain case-insensitive widening would have killed it. Two things the second arm cannot do, stated rather than discovered: it does not prove WHERE the assignment happens, so a flag flipped on a non-cleanup path vetoes just as a cleanup does; and its stem list is hand-written, so a project whose flag is named outside it is not covered and its findings stay reported rather than silently green. Scoped to React files (useEffect/useState/from 'react'); a plain event handler (mounted by construction whenever it fires) is an accepted false positive, and the info severity IS that gap rather than a judgment about impact — the discriminator that would earn a warning is whether the setter sits inside a useEffect callback, which this matcher cannot ask: projected function bodies are anonymous line spans carrying no record of which call receives a function as its argument, and a useEffect callback is deliberately left unmerged from its call site (the one merge is for .then/.catch/.finally). Measured over a 9-tree corpus, 34 of 49 findings sat in an event handler rather than an effect. Treat the band as advisory until that gate is built. The headline symptom is also version-bound and this rule does not read your React version: the "state update on an unmounted component" console warning was removed in React 18.0.0 (the update was always a silent no-op), so below 18 the residual cost is a console line and at 18 and above it is wasted render work. "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 — 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. A .set( whose value is arithmetic written on the trigger's own line and outside every quote is DECLINED here and left to the sibling counter-get-set, whose INCR remedy is the right one there and the opposite of this rule's: NX on a counter freezes the value at the first write and turns the threshold off silently. Where a counter's arithmetic is out of that veto's reach (computed into a variable on an earlier line), the message carries the counter-indication ahead of its imperative.
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.
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.

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. Residuals are disclosed by SHAPE rather than by count: a MULTI-LINE statement puts the keyword on a line carrying no quote and a line-scan cannot see it; in Python a #-commented statement is read as live code because the engine's comment leader outside .sql and config files is //; and since 2026-08-21 the leading verb must be spelled in UNIFORM case — DELETE or delete, never Delete — so a Title-Case statement is missed even when the whole statement is a closed literal, while the FROM after it stays case-insensitive. The casing is the price of not reporting prose: a quoted two-word English phrase is lexically a SQL statement, and the menu label t('Delete from list') fired at critical severity until the verb's case was pinned.
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 as delete-no-where — TypeScript/JavaScript, Python, Java, C#, Go and Rust — and the same three residual shapes: the MULTI-LINE statement, the Python #-commented statement, and, since 2026-08-21, a leading verb that must be spelled in UNIFORM case — TRUNCATE or truncate, never Truncate — so a Title-Case statement is missed and the chart-control label t('Truncate Metric') no longer fires at critical severity. The optional TABLE after the verb stays case-insensitive.
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/, migration/, migrate/ and Alembic's alembic/versions/ — an extension one of them accepts and this one does not would be a promised disclosure nobody emits. TEST PATHS are then subtracted from that set, and the invariant survives the subtraction because the three siblings exclude test paths as well as migration paths: a destructive statement under django/tests/migrations/ was never a disclosure any of them promised. It is still the largest cut the file set takes — measured 2026-08-19 over nine upstream trees, every finding of the earlier form sat under a test path (django/tests/migrations/test_operations.py, typeorm/test/functional/.../migration/*.ts), migration FIXTURES that a suite runs rather than a history anyone deploys. 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.

네이티브 분석

전-그래프·전-레포 분석이다. 이 id 들은 DSL 룰과 같은 RuleConfig 활성화/심각도/억제 표면에 함께 붙는다. 각 id 는 그것을 소유한 크레이트의 register_native_analyses 가 등록한다 — 커널(crates/core)은 하나도 등록하지 않고 룰 어휘로부터 자유롭게 남는다. 다섯 크레이트가 나눠 등록하며 각자 주제 하나를 소유한다: rules/native/rules-graph 는 의존/데드코드 그래프 룰과 콜그래프 순수성 감사, rules/native/rules-http 는 단일 트리 HTTP/라우트 룰, rules/native/rules-cross-layercross-layer/* 다중 트리 조인 룰, rules/native/rules-schema 는 스키마 룰, crates/metrics 는 점수 계산. 어느 id 를 누가 소유하는지는 이 문단이 아니라 표가 답한다 — 명부는 그 크레이트들 각각의 register_native_analyses 목록이고 아래 표가 같은 집합을 행마다 든다. 여기에 id 목록을 다시 적지 않는 것은 의도다: 이 문장이 대체한 손으로 쓴 목록은 자기가 소개하는 표보다 id 하나가 모자란 채 낡아 있었고, 손으로 다시 세야 하는 목록은 위장한 census 다. 표가 스스로 다 말하지 못하는 모양이 둘 있다: schema-structural/schema-usage 는 그 아래로 보고되는 12개 schema/* 개별 id 에 대한 패밀리 게이트다 — 패밀리를 끄면 그 패스 전체가 꺼지고 각 schema/* id 는 자기가 이름 댄 룰만 끈다. 둘 다 존중되고 모든 발견의 메시지가 둘 다 말한다. 그리고 crates/metrics 의 id 들은 발견을 내는 룰이 아니라 점수 계산인데 같은 토글/게이팅 표면에 얹혀 있을 뿐이다. 그 metrics id 들은 심각도가 아예 없다 — 등록 방식 때문이 아니라(모든 네이티브 id 가 같은 스텁을 지난다) 심각도는 발견을 매기는 것인데 이들은 발견을 안 내기 때문이다. 그래서 "Default severity" 칸이 그들에게는 n/a 이고, 발견이 아닌 출력에 등급을 지어내지 않는다. 각 행은 대신 자기가 내는 것을 어떤 출하 표면이 싣는지를 적는다. zzop_engine::register_all_native 가 다섯을 조립한다.

cross-layer/* id 들은 다중 트리 예외다: 이들은 zzop_engine::analyze_trees 가 조인한 CrossLayerResult 위에서 돈다(여기 다른 모든 행은 트리 단위로 돈다). analyzeTrees 출력에서 crossLayer 옆의 crossLayerFindings 로 나간다. 이들 중 어느 것도 인라인 억제 마커를 존중하지 않는다 — 끄기 전용이다: config 의 rules: { "<id>": "off" }, 또는 임베더용 disabledRules.

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). Exempt and READ are two different sets, and the difference is deliberate. Exempt: any file directly inside a dot-directory (.storybook/main.mjs, .storybook/preview.jsx) plus every <name>.config[.<qualifier>].<ext> — a tool owns those and nothing imports them. READ for the paths it names — a strictly narrower set, the config-stemmed ones only — because owning a file is not the same as that file DECLARING what a build loads: .storybook/copyAssets.ts is tool-owned and its ordinary strings are not entry declarations. A config that IS read is exempt itself AND every source path it names is exempt too, which is how a bundler's input/entry list stops reading as a pile of orphans, including in a second config for the same tool (vite.config.sw.js) that only a --config flag reaches. The match is on the shape of the VALUE — a quoted literal carrying a source extension that resolves to a file this tree has — rather than on the keys each bundler declares entries under, which is what lets a sixth bundler's spelling work with no row added for it. Not reading the key cuts both ways, and both cuts are intended: a path sitting in an exclusion list is exempted exactly like an entry (coverage.exclude, eslint ignores, knip ignore — see corpus/frameworks/nest/vitest.config.coverage.mts, whose coverage.exclude spells out eight real packages/**/*.ts paths), and an entry written WITHOUT its extension (input: 'src/app', legal in rollup/vite) is not exempted at all. The extension is required because dropping it would let a config naming a source DIRECTORY (an alias target such as '@': resolve(__dirname, './src')) excuse every file under it (config_entries.rs, dead_candidates.rs). Framework path conventions are exempt by a DECLARATION, never by a directory name. The Next.js App Router / SvelteKit convention filenames (page.tsx, +server.ts, sitemap.ts, ...) are exempt on their spelling alone, because no ordinary module carries it. pages/ is an ordinary word, so a Next.js Pages Router directory (pages/**, src/pages/**), an Astro route directory (src/pages/**) and Next.js instrumentation/instrumentation-client are exempt ONLY where the tree holds that framework's own config beside them (next.config.*, astro.config.*) — the same file the build itself resolves those directories against. Each such file is turned into a route by its PATH, so zero importers is the convention working and deleting it takes a live endpoint off the air. A pages/, plugins/, middleware/ or composables/ directory with no such config above it keeps reporting in full. Conventions this list does not name — a Docusaurus doc route, a Nextra _meta file — are carried by the finding message instead, which says so before it says delete (dead_candidates/framework_roots.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). Framework-convention exports are exempt by NAME PLUS PATH, never by name alone: Next.js getServerSideProps/getStaticProps/getStaticPaths/getInitialProps/generateMetadata/generateStaticParams in any file, middleware and config inside a middleware.{ts,js} file, and config inside a pages/api/ route — each is read by the framework from the file path rather than imported, so zero importers is by design and deleting one changes runtime behavior. Generic names are deliberately NOT exempt globally, because a bare config elsewhere is a plausible real dead symbol; conventions this list does not name are covered by the finding message instead, which says so before it says delete. 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. Read severity there as a PRIORITY BAND, not a finding severity — it is computed from the structural gates below and from no rule finding at all, so a critical band beside a findings.bySeverity holding no critical is the normal case; the reply ships that sentence itself as architecture.topRecommendationMeaning, and the band's own section further down has the measurement.
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 and rules/native/rules-schema/src/structural/rules/relation.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 15 fields — the GOD_THRESHOLD line — a candidate to split into smaller, more cohesive models. That threshold is a convention rather than a measurement, and the finding's own message says so with the numbers it was chosen against: on the corpus's only Prisma schema (cal.com, 100 models), a threshold of 15 reports 27 of them, 14 reports 32 and 16 reports 23, so one field moves the count by 4-5. Recount by building the binary twice one field apart and re-running zzop analyze --rule schema/god-model over a Prisma schema. The constant and that measurement are declared together in rules/native/rules-schema/src/structural.rs, and this row is pinned against both (the rule BODY is rules/native/rules-schema/src/structural/rules.rs).
schema/missing-timestampsinfoA model that has no creation timestamp (createdAt, or any DateTime @default(now())) and/or no updatedAt. There is no field-count floor: a LOOKUP_FIELD_MAX = 3 floor meant to exempt small lookup tables was removed in 2026-08-29 after measurement showed it exempted exactly one model on the only schema anyone had measured — a credential store, not a lookup table — while moving one finding in 54. 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-fkwarningAn optional column that a DECLARED @relation names as a foreign key — the schema modelled the relation and then chose the optional side, and this asks whether that side was intended (rules/native/rules-schema/src/structural/rules/relation.rs). Two declarations exempt a column and it is never reported. No @relation anywhere in the model names it: the *Id suffix alone is a guess about a column another system may own, and schema/implicit-fk is the rule whose claim about such a column is true. onDelete: SetNull on the declaring relation: Prisma REFUSES that action unless the relation's scalar fields are optional, so the line already answers this rule's question. The finding message carries what the reverse edit costs — dropping the ? emits ALTER COLUMN ... SET NOT NULL, which Postgres validates against every row already stored, so a deploy stops mid-migration on the first NULL.
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/relation.rs). A column carrying a LITERAL @default is exempt and never reported (teamId Int @default(0), tenantId String @default("")): a pinned default is the schema's own declaration that the column holds a value of its own choosing when nobody supplies one, and a value the schema picked is not a parent key — modeling the relation there emits a FOREIGN KEY that the rows already in the table fail. A FUNCTION default (@default(uuid()), @default(autoincrement()), @default(now())) mints a fresh value per row, pins nothing, and is NOT exempt. Undeclared sentinels — a 0 or -1 written only by application code, or an id issued by a system with no table here — cannot be seen from the schema and are covered by the finding message instead, which tells the reader to read the column's existing values before modeling the relation.
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. The Prisma client delegate spelling counts as an occurrence: Prisma lowercases the first character to build its accessor, so model UserPassword is reached as prisma.userPassword and the declared name appears nowhere in correct code — measured on calcom/cal.com, where 7 of 7 findings examined were models in daily use. That acceptance is scoped to MULTI-WORD names, because the token bag is unqualified: accepting a single-word delegate would let any local variable named user or team silence its model, which is vacuous rather than merely loose. So a single-word model reached only through its delegate (prisma.booking) is still reported — a stated miss, not an oversight. 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 RELATION NAVIGATOR (a field whose declared type names another model in this schema) are excluded. The first three names occur everywhere and carry no signal. A MIN_FIELD_NAME_LEN = 3 floor used to exclude one- and two-character names too; it was removed in 2026-08-29 as a proxy for a fact this rule already measures directly (whether the name occurs), having excluded zero fields on the only schema anyone had measured. The navigator is excluded for a different reason: it is not a deletable field at all — it is the required opposite side of a @relation declared on the other model, so acting on such a finding makes prisma validate fail outright, and Prisma never requires source to name the back side (you traverse it through include), which makes a zero identifier count the EXPECTED reading for a correct schema rather than a signal. A genuinely dead scalar column in the same model still reports. 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-routewarning (info when the two sites straddle a deployment manifest, or declare different version scopes)The same (METHOD, path) HTTP route provided 2+ times across the tree. A repeat registration is skipped only when it resolves to the same handler DECLARATION as the first — the comparison key is (file, symbol), never the symbol name alone — because one handler deliberately registered on two paths that normalize to one key is the trailing-slash-tolerance idiom, not a shadow. Anything short of that still fires: a different symbol, an unknown or empty symbol on either side, and — the case the bare-name key used to swallow — a same-NAMED handler declared in a DIFFERENT file, which is two handlers and therefore a real shadow. Shadowing is stated as a condition, because the rule cannot check it: a later registration is only shadowed if it reaches the same router in the same running process, and this rule groups the whole analyzed tree into one route table — a tree is a directory, not a deployment unit. In a monorepo holding several services, or where a shared registration helper is called once per service, the sites live in separate processes: nothing is shadowed, and merging the handlers would break every service but one. A finding whose two sites sit in DIFFERENT manifest boundaries — the nearest ancestor directory holding a file that DECLARES a deployable unit — carries that fact as data.manifestBoundaries and a message sentence, and is reported at info rather than warning — still raised, still named, but out of the counts a --fail-on warning gate reads. It is never DROPPED, because a manifest declares packaging and not a process, and a shared package can register routes an app package mounts. Measured on macrozheng/mall (four @SpringBootApplication classes, four ports, zero pom dependencies between the application modules): 7 of 7 straddling findings were false, and the rule's own stated remedy cost more than the disease — splitting by module cuts 410 cross-module import edges into a 258-file shared artifact and leaves it with no in-tree consumers — so the only working answer was to turn the rule off. Gating needs evidence a manifest is not, while erasing needs a declaration a manifest is not either; the severity is the honest middle. Which filenames count is a hand-written vocabulary with ONE owner (is_deployment_manifest in rules/native/rules-http/src/duplicate_route/boundary.rs, whose own header says what it cannot see); it is not re-listed here, and it is not TypeScript-and-Go-only any more — Rust, the JVM, .NET, Python and PHP are measured on this axis too. Absence of the field still means same-boundary OR not-measured, never same, and "not measured" stays a real state: an ecosystem whose deployable unit is declared in a filename that vocabulary does not carry contributes no boundaries at all, and an unmeasured side therefore never buys the demotion. A SECOND axis rides beside that one and composes with it rather than replacing it: version scopes. The group key is the normalized path and nothing else, which is blind to every framework that versions by something the URL never carries — NestJS's VersioningType.HEADER/CUSTOM being the common one. When both sites carry a routeVersion (the version the controller declared, emitted verbatim by the native TypeScript extractor — see docs/NORMALIZED_AST.md) and the two DIFFER, the finding carries data.routeVersions plus a message sentence and is likewise reported at info rather than warning. Measured on cal.com (2026-08-21): all 15 findings were three @Controller({path, version}) pairs split over a cal-api-version header, every one of them under ONE apps/api/v2/package.json — same manifest boundary on both sides, so the boundary axis correctly said nothing and could not reach them. On this axis the rule also changes the message's OPENING sentence, which the boundary axis does not: the default lead's merge the handlers or remove the duplicate is not merely unhelpful on a versioned split but destructive — merging is inexpressible when the two controllers take different input DTOs by design, and deleting the older one breaks every client pinned to it — and a hedge appended two thousand characters later does not undo an imperative the reader already acted on. What the axis does NOT claim is that the scopes are disjoint: routeVersion is the source EXPRESSION, not a resolved set, and the message says so. Same absence rule as the boundary axis, for the same reason: an identical version, or a version on only one side, discloses nothing and keeps the warning. The message names BOTH exits and prices them: analyzing the units as separate trees[] entries is the declaration this rule honors (it moves the question to cross-layer/duplicate-route, where each providing source is known and named), and turning the rule off for the tree is the honest second answer where the split's edge loss is not worth paying (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). Registration order only orders handlers that share a ROUTER, and that condition is checked exactly as far as mounting makes it checkable: a file's routers mounted inside the analyzed tree carry their mount prefix in the key, so two Router instances mounted at different prefixes land in different groups and nothing fires (measured — the same fixture fires unmounted, goes silent once both mounts are declared, and fires again when both mount at the same prefix). The residual is the router this tree never mounts, e.g. a package exporting a Router for another repo to mount; the message states that condition rather than inferring a boundary to clear the finding.
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 — and since 2026-08-21 a run that saw NO such routes is disclosed too, which is the harder half: a tree substantially made of a language this build ships a route recognizer for, from which zero routes were extracted, is named in warnings by language and structural file count, because a promise conditioned on what was FOUND goes quiet exactly when the gap is largest. That disclosure carries TWO narrowings rather than being unconditional, and both are real gaps rather than fine print: it covers only languages OUTSIDE the call-graph set (so a .ts/.java/.py tree whose routes come from an unrecognized framework is not named this way), and it stands down entirely when a sibling warning already reported this tree's empty provide channel — which means a polyglot tree can lose the one language row this tripwire existed to produce, because a DIFFERENT language's framework was named instead (measured on gogs: roughly 300 route registrations, 0 extracted, and until then not one line about it); 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 — in EITHER the classic fluent spelling (http.authorizeRequests()...anyRequest().authenticated()) or the Spring-6 lambda DSL (http.authorizeHttpRequests(reg -> reg...), folding two such customizers on one chain) — 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, with a named reason — 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 a Spring config that is path-scoped (securityMatcher), carries WebSecurity.ignoring(), holds more than one authorization chain, or whose matchers / anyRequest terminal are not literally readable (a property-bound whitelist, an .access(mgr == null ? ... : mgr) terminal) aren't mapped — a route relying entirely on those still reports (rules/native/rules-http/src/mutating_route_no_auth.rs). A call the resolver could not place — a guard declared inside a factory is the common case, since it is not a top-level symbol and so draws no edge — is judged by the name it is WRITTEN with, against that same guard vocabulary: a matching name clears the route exactly as a resolved one does, because an edge the resolver declined to draw is not evidence that the call is absent. That direction is one-way; an unresolved name can only ever clear a finding, never produce one. Names that did NOT match ride the finding as data.unresolvedCallees and are listed in its message, so a guard your project spells outside its own declared vocabulary.authGuardPattern is visible at a glance rather than left as an unexplained gap in the graph. One shape that channel structurally CANNOT report, and the message therefore names before its remedy: the BFS starts at the EXPORTED route symbol, so a handler passed as an ARGUMENT to a wrapper (export const POST = withX(handler)) draws no call edge at all and its body is never walked — the argument was never a dropped CALL, so data.unresolvedCallees is empty rather than telling you the walk stopped at the door. Measured on the dogfood corpus: 15 of 151 firings sit on that shape, and at least 5 of those handlers call a guard matching the run's own declared vocabulary one hop inside.
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). Write verbs only, and that gate is now disclosed. A READ route the front end calls and no analyzed tree provides produces no finding from this rule or any other — the single-tree unprovided-consume needs its own tree to provide at least one HTTP route, which a pure front end never does. Both gates are deliberate (a read call to a missing route is more often a stale client, a flag, or a route served outside the analyzed set), but until 2026-08-17 an empty crossLayerFindings looked identical whether nothing was found or nothing was ELIGIBLE. A run with unprovided read routes now carries a warning naming the count, a sample, and both gates; the keys are also in crossLayer.unprovidedConsumes, and endpoint <key> answers consumed-unprovided for each.
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).

아직 없는 것: 아키텍처 룰(레이어 위반, feature-envy — 아직 어떤 크레이트도 준비돼 있지 않다), 인지/중첩 루프 복잡도 점수, 정밀한 taint-flow 데이터플로(오늘의 security/taint-flow 는 문서화된 거친 v1 공존 검사다), 인증 상태기계 분석, 추가 크로스파일 HTTP 그래프 검사(API 변화, 프론트/백엔드 스펙 드리프트), JSX/React 구조 룰 팩, env/i18n 동기화 검사. 각각 DSL 이 표현 못 하는 전-그래프 조인이거나 진짜 AST/JSX 모양을 요구한다. (Raw-Worker 라우트 추출 — 프레임워크 없는 Workers/Node 서버의 수동 url.pathname 디스패치 — 는 파서의 pathname-dispatch provide 어휘로 출하됐다.)

매처

모든 DSL 룰은 아래 표에서 고른 매처 모양을 정확히 하나 선언한다. 필드별 상세 의미는 DSL 레퍼런스에 있고, 여기는 요약이다.

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.

하나만 빼고 전부 한 파일의 SourceFile 조각만 보고 돌며 두 번째 파일의 내용을 볼 수 없다. io-scan 이 그 예외로, 파일들에 걸쳐 이미 조립된 IO 사실 위에서 전-트리로 평가한다. 룰의 인라인 억제 마커는 id 에서 파생되고(zzop-<rule id>-ok, 필드로 쓰는 것이 아니다) symbol-scan 을 뺀 모든 매처의 발견에 적용된다: 발견이 난 줄이나 바로 윗줄의 // zzop-<rule id>-ok 주석이 그것을 억제한다. (io-scan·call-scan·literal-scan 은 구조상 다언어라 // 뿐 아니라 # 주석 머리도 존중한다.) 마커에는 팩 접두사가 없다(security/hardcoded-secret// zzop-hardcoded-secret-ok). symbol-scan 발견은 억제 주석을 걸 소스 줄 개념이 없어서 인라인 마커를 안 갖는다. 매처 필드 표 전체는 레포의 dsl-reference.md 를 보라.

직접 팩 쓰기

팩은 설정된 팩 디렉터리에서 로드되는 <id>.json 파일 하나다 — 인터프리터 수준에는 1st party/3rd party 구분이 없다. 그 디렉터리를 가리키는 철자가 둘이고 서로 바꿔 쓸 수 없다: zzop.config.jsoncpacks.extraDirs, 또는 임베더 요청 객체의 packsDir. zzop/rules/ 디렉터리는 packs.extraDirs 가 이름 대지 않아도 주워진다. 아래는 작은 line-scan 룰 예시로, config/env 에서 와야 할 디버그 헤더 값이 하드코딩된 것을 잡는다:

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
      }
    }
  ]
}

두 철자 모두 디렉터리 하나 또는 배열을 받는다. 각각 독립적으로 로드된 뒤 팩 id 로 병합된다: 같은 id 가 둘 이상의 디렉터리에 나오면 목록에서 뒤쪽 디렉터리의 팩이 앞쪽을 통째로 대체한다(룰 단위 병합이 아니다). 이것이 호출자가 번들 팩 옆에 팩을 더하거나, 번들 팩을 통째로 덮어쓰는 방법이다 — 엔진을 포크하지 않고.

메시지는 원인과 처방을 쓰고 거기서 멈춘다. 억제 마커도, 룰 끄는 법도 안 적은 것을 보라: 엔진이 런타임에 두 문장을 모든 발견에 붙인다 — 마커(zzop-<id>-ok, id 에서 파생되고 그 매처 종류가 실제로 존중하는 주석 머리로 적힌다)와 rules: { "<pack>/<rule>": "off" } 끄기 안내. 둘 중 하나를 직접 쓰면 두 번 나오고, 손으로 쓴 마커 문장은 매처 종류가 바뀌는 순간 낡는다 — 엔진이 더는 존중하지 않는 주석 머리를 이름 대고 있게 되기 때문이다.

모든 탐지가 위 매처에 맞지는 않는다. 검사가 선언→사용 추적(선언됐지만 한 번도 읽히지 않는 식별자), 크로스파일 조인(다른 파일에 정의된 상수나 라우트 핸들러를 푸는 것), 콜그래프 BFS("핸들러 X 가, 또는 그것이 전이적으로 부르는 무언가가, Y 를 한다"), 또는 텍스트 공존이 아니라 진짜 AST/JSX 모양을 요구하면 네이티브 룰로 가라 — 순환/인지 복잡도와 JSX 구조 검사는 줄 단위 정규식으로 정직하게 인코딩할 방법이 없다.