mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
* fix(quality): resolve net-new lint errors and allowlist #9343 assert rewrite Two `no-explicit-any` errors landed with #9407 and #9320 after the suppressions inventory was generated. Project policy is to fix new violations rather than freeze them, so both are typed instead: - #9407: `executor as unknown as Record<string, unknown>` - #9320: `(k: { name?: string })` Also allowlists the net-assert reduction in web-tools-translation-2820 (39->35). #9343 inverted the contract — bare JSON must no longer be promoted to tool_calls without an explicit <tool> envelope — so the tests were rewritten to assert non-promotion, which costs fewer asserts than validating a promoted object. More restrictive, not weaker. * fix(quality): raise integration ceiling to 40min and unpin codex-cli version in test The integration gate's 20min ceiling killed a healthy run: measured 22m08s hermetic on an idle 16-core box (935 tests across 112 files, strictly serial at --test-concurrency=1 because ~16 of them bind a port or share a DB). The "~3-10min" estimate in the code was stale by ~3x. 40min keeps the ceiling's real purpose — turning a genuine hang into a visible failure — without failing a long-but-healthy suite. Also fixes a base-red in chat-pipeline:564c204efebumped DEFAULT_CODEX_CLIENT_VERSION to 0.146.0 but the User-Agent assertion still pinned 0.144.1. The line two above already read the constant via getCodexClientVersion(); this one duplicated the literal. Deriving it from the same source stops the next bump from breaking the test again. * fix(ratelimit): re-arm Bottleneck reservoir heartbeat after updateSettings Bottleneck 2.19.5 (frozen upstream dependency, no release since 2019) has a bug in LocalDatastore#_startHeartbeat() (node_modules/bottleneck/lib/ LocalDatastore.js:29,56): the guard `if (this.heartbeat == null && ...)` only (re)creates the periodic reservoir-refresh interval the first time it runs. Every later call -- including the one updateSettings() itself triggers internally -- falls into the else branch and does clearInterval(this.heartbeat) WITHOUT resetting the reference back to null. Because the stale reference sticks around, every future _startHeartbeat() call keeps taking the same dead else branch: the periodic reservoir refresh is gone forever after the first manual updateSettings() call on a limiter. Every limiter created by this file starts with a live heartbeat (buildLimiterDefaults() always sets reservoirRefreshInterval/ reservoirRefreshAmount), so the very first updateFromHeaders() / updateFromResponseBody() / applyRequestQueueSettings() call against a limiter permanently kills its refresh. In production this wedges the request queue once the reservoir hits 0: an auto-enrolled apikey connection accumulates its default 60 requests, the reservoir zeroes, the queue freezes for ~120s, the watchdog fires a synthetic 502 (RATE_LIMIT_QUEUE_WEDGED), the connection cools down and gets excluded from weighted combo pools -- turning a configured 70/30 split into ~50/50. Add applyLimiterSettings(), a module-local wrapper around limiter.updateSettings() that nulls the stale heartbeat reference and re-invokes _startHeartbeat() afterward so it takes the "start a fresh interval" branch again. Route all 5 updateSettings() call sites through it (updateAllLimiterSettings, both updateFromHeaders() branches, loadPersistedLimits(), and updateFromResponseBody()). updateAllLimiterSettings is now async and awaited by its two callers (initializeRateLimits, applyRequestQueueSettings); the sync call sites use the existing trackAsyncOperation() fire-and-forget tracking pattern. tests/integration/combo-matrix/weighted.test.ts is the E2E proof: the "weighted: 70/30" case now passes with zero WEDGED/RATE_LIMIT_QUEUE/502 log lines across 200 sequential requests (previously the wedge/recovery cycle inflated its runtime and skewed the distribution toward ~50/50). Refs #8213 * fix(tests): remove stray TDD probes committed by accident inf4e93f339dThree TDD repro/probe test files landed on the release tip viaf4e93f339d(docs: add management authentication terminology guide, files from a worktree. Each file is a pre-fix TDD probe that belongs to a *different*, still-in-flight fix branch/PR and duplicates a file path that PR already owns and will properly update on merge: - tests/unit/authz/probe-9033-repro.test.ts: probe for #9033 (IP blacklist direct-connection bypass). 3/4 asserts fail against this tree (D1, D2, Bonus — all assert the not-yet-implemented target behavior); D3 passes (pre-existing behavior). Owned by PR #9385 (open, unmerged), which modifies this exact path. - tests/unit/repro-8522.test.ts: probe for #8522 (absolute file-size baseline reds innocent PRs on inherited drift). First test fails against this tree's evaluateFileSizes (still absolute-only); second (sanity: real growth still flags) passes. #8522 is actually CLOSED upstream — PR #9355 merged the real fix into release/v3.8.50 today (2026-08-05T15:53Z) modifying this exact path — but this branch's merge-base with release/v3.8.50 (6b0e11e378) predates that merge, so the fix has not synced into this tree yet. - tests/unit/repro-8956.test.ts: probe for #8956 (resolveProjectRoot stops at synthetic Next.js standalone package.json). First test fails against this tree; second (sanity: named package.json still resolves) passes. Owned by PR #9354 (open, unmerged), which modifies this exact path. Each deleted file's real implementation + passing version already exists in its owning PR and will land normally through that PR's own merge — deleting the premature copy here does not lose any coverage. No config/quality/test-masking-allowlist.json entry was added: the _deletedWithReplacement schema only supports `replacement` (a test file that must already exist in this tree's HEAD — none does, the real versions live in the unmerged sibling PRs above) or `sourceRemoved` (production files that must be absent from HEAD — they are not, none of the three issues are implemented in this tree). Neither shape fits an "owned by an in-flight sibling PR" deletion, so the CI test-masking gate will flag these 3 deletions for mandatory human review on this branch's next PR diff against release/v3.8.50 — flagged for the owner rather than inventing a new allowlist shape. Refs #9033, #8522, #8956, #7786 * fix(tests): align 8189-classifier-compat with #9276 always-mode semantics tests/unit/8189-classifier-compat-auto-narrow.test.ts was a test-sibling forgotten when #9276 (commit6b531fbacd) removed the unconditional `if (mode === "always") return true` branch from shouldDefaultAllowClassifier(). tests/unit/claude-classifier-compat.test.ts was updated in that same commit; this file was not. Old contract: 'always' mode short-circuited every Claude-format request unconditionally (operator opt-in was treated as sufficient on its own). New contract: 'always' now requires the same SECURITY_MONITOR_MARKER system-prompt text as 'auto' — the marker-optional behavior let a normal chat request through /v1/messages be silently swallowed by an operator's 'always' opt-in. The single 'always' test (1 assert, no-marker body expecting true) is replaced by two tests mirroring the depth already used for 'auto' mode in the same file: no-marker/false and marker-present/true. Net effect is +1 assert, not a reduction — the new pair verifies both directions of the narrowed contract instead of only the now-incorrect unconditional case. Before: 3/4 pass (the 'always' test failed: expected true, got false). After: 5/5 pass. Refs #9276 * fix(tests): align deepseek-web-tools-execute with #9343 tool envelope contract tests/unit/deepseek-web-tools-execute-2820.test.ts (executor level) was a test-sibling forgotten when #9343 (commitd969555417) hardened tool-call parsing: bare JSON with no explicit <tool>/<tool_call> envelope is never promoted to tool_calls anymore (previously it was, whenever a tools[] set was requested — a security gap allowing prose/code-fenced JSON echoed back by the model, or a copy-attack, to trigger real tool execution). Three siblings were updated in the same commit: web-tools-translation.test.ts and web-tools-translation-2820.test.ts (parseToolCallsFromText, the shared translator), and deepseek-web-tools-variants.test.ts (parseDeepSeekToolCalls, deepseek-specific parser) — all inverted their bare-JSON assertions to `toolCalls === null` + `content === text` (preserved verbatim, not stripped). This file calls the executor's execute() (full HTTP round trip through buildToolAwareResult), so it was not touched by that diff and kept asserting the old contract (finish_reason: "tool_calls", content: null). Verified against source (open-sse/executors/deepseek-web.ts buildToolAwareResult): when parseDeepSeekToolCalls returns toolCalls=null, hasCalls is false, so finish_reason is "stop", message.tool_calls is never set, and message.content is the parser's returned content — which for text with no <tool>/<tool_call> tag at all is the original string, unchanged (parseToolCallsFromText's early-return branch). The test now asserts exactly that shape, at the same executor level as the rest of the file's tool_calls that make sense at that level as the rest of the file's tool_calls Refs #9343 * fix(tests): align visionBridge tests with #8430 contract (partial — see note) Two test-siblings were forgotten when #8430 (commit7e55abbc41) hardened Vision Bridge's vision-model selection: getBestVisionModel() now validates that a candidate has a usable active connection (hasUsableCredentialsForModel, DB-backed) before returning it, instead of unconditionally returning the fixedModel or a hardcoded "openai/gpt-4o-mini" default. Three siblings were updated in the same commit (visionBridgeRouter.test.ts, the new repro-8430.test.ts, vision-bridge-preserve-on-failure-4012.test.ts); these two were not. tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts (8 failures, all "No vision-capable provider connected"): callVisionModel()'s `routerConfig` param only merges into getBestVisionModel's CONFIG argument, never its `deps` argument, so there is no way to inject a credentials stub through this function's public signature (unlike the guardrail class and getBestVisionModel itself, which do accept an injectable `hasUsableCredentials`). These tests exercise callVisionModel's own request/response handling, not credential routing (already covered elsewhere), so the fix seeds one real usable `provider_connections` row per provider the file exercises (openai, anthropic) via createProviderConnection in a test.before() hook, with resetDbInstance() in test.after() per the DB-handle-cleanup convention. All 8 now pass. tests/unit/guardrails/visionBridge.test.ts (7 failures): 1 of the 7 (VB-S03) is a genuine forgotten-contract case, fixed here — same semantic flip already applied to vision-bridge-preserve-on-failure-4012.test.ts: in the combo describe path, when EVERY describe call fails, the raw image is now replaced with an "(unavailable)" stub instead of preserved, because that path is only reached for confirmed non-vision targets. Assertions inverted to match (imagePart undefined, unavailable-stub present), same assert count, no weakening. *** THE OTHER 6 (VB-S12, VB-S12b, VB-S01, VB-S13, VB-S07, VB-S10) ARE DELIBERATELY LEFT FAILING. *** These are NOT a #8430 contract change — root- caused to what looks like a separate, unintentional regression: the ONE call to getBestVisionModel() in visionBridge.ts's whole-request-reroute path (line 244, `getBestVisionModel({ fixedModel: configuredModel })`) does not pass a `deps` second argument, so it always uses the real DB-backed hasUsableCredentialsForModel instead of this.deps.hasUsableCredentials — even though the two adjacent checks in the very same function (`checkCreds(model)` at line 226, `checkCreds(bestModel)` at line 246) DO honor the injectable override. In this suite's empty-but-readable isolated test DB, that real check deterministically returns `false` (not the indeterminate `null` the file's own createGuardrail() comment says these tests rely on: "Fail-open (null) so classic VB-S01/S07/S10 reroute tests keep working without a live credential DB"), so getBestVisionModel silently returns null, the reroute branch's `if (bestModel && ...)` guard never fires, and every test that expects a reroute observes a silent no-op instead. Evidence this is a source gap, not a test that needs updating: - The file's own pre-existing comment names VB-S01/S07/S10 as tests the `null` fail-open default is SUPPOSED to keep green. - VB-CRED-01/02 (the file's only two tests that actually inject a non-default hasUsableCredentials mock) both pass today, but neither one's assertions distinguish "mock honored" from "mock ignored, real check also says no" — they don't prove the threading works, they just don't happen to notice it's missing. - visionBridgeRouter.test.ts, repro-8430.test.ts, and vision-bridge-preserve-on-failure-4012.test.ts (22 tests, all green) all either call getBestVisionModel directly with explicit deps, or mock callVisionModel wholesale (bypassing getBestVisionModel entirely) — none of them exercises this exact call site through the guardrail's own deps. Per instructions, this was intentionally NOT "fixed" by weakening these 6 tests' assertions (that would mask the gap) or by seeding fake DB credentials to route around it (that would hide a real production DI inconsistency behind a test-only workaround) or by touching src/lib/guardrails/visionBridge.ts (a production behavior change outside a test-alignment task's scope, and Hard Rule #18 requires its own TDD/validation cycle). Flagging for the owner: the likely one-line fix is threading `{ hasUsableCredentials: this.deps.hasUsableCredentials }` as getBestVisionModel's second argument at visionBridge.ts:244, mirroring the two adjacent call sites in the same function. Before: 15 failures (7 + 8). After: 9 pass added (1 + 8), 6 still fail (unchanged, by design). Refs #8430 * feat(quality): add strayFromCommit deletion allowlist form to test-masking gate The deletion allowlist supported two shapes: replacement (test rewritten elsewhere) and sourceRemoved (feature deleted). Neither fits a third legitimate case surfaced today: test files that entered the repo BY ACCIDENT — commitf4e93f339d(#7786 docs) swept another session's worktree artifacts into the release, including TDD probes owned by open fix PRs (probe-9033-repro -> PR #9385, repro-8956 -> PR #9354, repro-8522 -> PR #9355). Those probes fail by design until their owning PR merges, so every unit run on the release tip broke on them. The new strayFromCommit form is verified, not trusted: the gate asks git which commit actually ADDED the file (git log --diff-filter=A) and only exempts the deletion when it matches the declared hash; a non-empty reason naming the owning PR/issue is mandatory. Also allowlists the deepseek-web-tools-execute assert reduction (23->21) fromed661f2126— same #9343 contract-inversion class as the existing web-tools-translation entry. Gate unit tests: 55/55 pass. Full gate vs main: OK. * fix(guardrails): pass credential deps to getBestVisionModel at reroute call site The individual-model reroute path in VisionBridgeGuardrail.preCall() calls getBestVisionModel({ fixedModel: configuredModel }) without its second `deps` argument, so the router always falls back to the real DB-backed hasUsableCredentialsForModel instead of an injected `deps.hasUsableCredentials` override. The two adjacent credential checks in the same function (the original-model check and the best-model check, both via the local `checkCreds` binding) already thread deps correctly — only this middle call, added in #8430, was left out. Pass the same resolved `checkCreds` used by those two adjacent checks as `getBestVisionModel`'s deps argument so all three credential checks in this reroute path stay consistent. Fixes 6 tests in tests/unit/guardrails/visionBridge.test.ts that depended on the injected hasUsableCredentials mock being honored on this path: VB-S12, VB-S12b, VB-S01, VB-S13, VB-S07, VB-S10. Refs #8430 * fix(quality): raise unit ceiling to 100min and align 2 more forgotten sibling tests Unit ceiling 45->100min: a hermetic-env measurement on the loaded devbox (load 7-26) was still inside invocation 1 of 3 at 76min when killed; contention factor 2-3x measured, no idle measurement exists. The pre-flight's real condition is exactly that contended one (unit runs in Promise.all with integration+vitest), and there 45min provably killed a healthy suite and fabricated a false base-red. The 45min value came from v3.8.43 as an estimate never validated by measurement. TODO in-code: re-tighten after an idle run on the .113 box. Also aligns the 6th and 7th occurrences of the same systemic pattern (behavior change merged updating only part of the sibling tests): - issue-7859-gemini-web-redirect-valid: #9407 refined ServiceLogin redirects to mean expired session; the #7859 regression coverage is preserved via a non-ServiceLogin public redirect variant. - provider-validation-specialty claude-web 429: #9406 inverted the contract (rate-limited session is unhealthy); the dedicated repro file owns the full contract, this sibling now matches it. Also carries the file-size rebaseline for #9323's base.ts growth (1578->1623, WAF retry + burst guard) and the eslintWarnings baseline tightened 5000->0 (real measured value with the TS7 suppressions in place — 5000 left the ratchet inert). Refs #9407, #9406, #9323 * fix(tests): restore the 3 TDD probes now owned by merged fixes and drop their stray allowlist entries The base advanced while this PR was open: the real fixes for the three issues behind the stray probes all merged into release/v3.8.50 — #9385 (issue 9033), #9355 (issue 8522) and #9354 (issue 8956). - probe-9033-repro / repro-8522: the base rewrote both probes into the regression tests of their merged fixes, so the delete side of the rebase conflict was dropped and the base versions kept. - repro-8956: #9354 only realigned one fixture line in auto-update.test.ts (package.json marker now needs a name field) and added no test for the new skip-synthetic behavior — the probe is the ONLY regression coverage of that merged fix (2/2 green on the base), so deleting it would remove real coverage. Restored. With no test-file deletions left in the PR diff, the three strayFromCommit allowlist entries are stale and removed. The strayFromCommit form support in check-test-masking.mjs stays (covered by its own fixtures). * fix(quality): rebaseline file-size for PR #9529 own growth The base sits exactly at the old frozen values, so the base-relative mode (#8522) does not cover this growth — it is this PR's own: - open-sse/services/rateLimitManager.ts 1060->1105: the applyLimiterSettings() helper that re-arms the reservoir heartbeat after updateSettings (Bottleneck 2.19.5 fix, TDD in ratelimit-reservoir-refresh.test.ts). - tests/integration/chat-pipeline.test.ts 1592->1598: codex User-Agent derived from getCodexClientVersion() instead of a pinned literal. - tests/unit/provider-validation-specialty.test.ts 2980->2985: new claude-web 429 -> valid:false coverage (#9406). * fix(docs): sync provider count to 291 in README and CLAUDE The live catalog counts 291 providers but README.md/CLAUDE.md still said 290, so the STRICT 'Docs Gates (fast-path)' check reds EVERY open PR against release/v3.8.50 (verified on #9537/#9539 as well — inherited base-red, not introduced by this PR). Updated all provider-count mentions including the section anchor. * fix(tests): align launch-codex 6312 guard with the async #9454 spawn contract #9454 made resolveCodexSpawn async (PATH-probes a native codex.exe before the .cmd shim) and updated its own tests, but left this older sibling calling the function synchronously — destructuring the Promise yields undefined and reds Unit fast-path (1/4) for EVERY open PR against the release (verified on #9537/#9539; inherited base-red). Realigned to the async contract with an injected probe; keeps the original #6312 fallback guard plus the only non-Windows codex coverage (now also asserting the probe never runs off Windows). * fix(translator): move state-mutating reasoning summary helper out of the pure leaf #9500 added buildResponsesReasoningSummaryDelta(state, ...) to pureHelpers.ts, but the function reads AND mutates stream state (reasoningSummaryIndex map) — violating the leaf contract declared in the file header ('no host imports, no stream state') and guarded by response-openai-responses-purehelpers-split.test.ts, which reds Unit fast-path (4/4) for every open PR (inherited base-red, verified on #9537/#9539). Moved verbatim to the host next to the other stream-state helpers (markResponsesReasoningDeltaEmitted); the host was its only consumer. Behavior unchanged: repro-9500-reasoning-separator 3/3 green, leaf/host architecture tests green. * fix(quality): rebaseline openai-responses.ts for the leaf-state relocation The #9500 helper moved from pureHelpers.ts into the host (previous commit) grows the host file 1174->1204 while the leaf shrinks by the same amount — net-zero LOC across the pair, but the per-file frozen ratchet only sees the growing side. * fix(tests): let the 9442 cert-mode test see past the harness trust-store guard tests/_setup/isolateDataDir.ts sets OMNIROUTE_SKIP_SYSTEM_TRUST=1 globally, which makes installCert() return before issuing any command — so the #9442 install-gap test captured nothing and could NEVER pass under npm run test:unit (it only passed invoked directly, harness-less; inherited base-red on Unit fast-path 3/4, verified on #9537/#9539). Clear the flag for this file only (restored in test.after): safe because every spawned command is a logging stub on PATH and OMNIROUTE_NO_SUDO=1 strips sudo, so nothing touches the real trust store. 6/6 under the CI harness including system-trust-test-guard. --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
705 lines
30 KiB
JavaScript
705 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/check/check-test-masking.mjs
|
|
// Gate anti test-masking (a preocupação nº1 do CLAUDE.md: "subagente não pode
|
|
// enfraquecer/remover asserts pra ficar verde"). Para cada arquivo de teste MODIFICADO
|
|
// num PR, compara a contagem de asserts base vs HEAD: sinaliza REMOÇÃO LÍQUIDA de asserts
|
|
// e NOVAS tautologias `assert.ok(true)`. Heurístico mas alto-sinal. Espelha o plumbing
|
|
// de check-pr-test-policy.mjs (diff base...HEAD); no-op fora de contexto de PR.
|
|
//
|
|
// v2 (6A.10): acrescenta 3 novos subchecks:
|
|
// 1. Arquivos de teste DELETADOS: --diff-filter=MDR com detecção de rename.
|
|
// 2. Aumento líquido de .skip/.todo/.only/{skip:true}: esconde asserts sem remover.
|
|
// 3. Tautologias extras: expect(true).toBe(true), assert.equal(1,1), assert.ok(true).
|
|
import fs from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const TEST_RE = /\.(test|spec)\.(ts|tsx)$/;
|
|
// Production TypeScript sources (excludes test files, handled separately via TEST_RE).
|
|
const PROD_SRC_RE = /\.(ts|tsx|mts|cts)$/;
|
|
|
|
/** Conta chamadas de assert.*( / assert( / expect( . */
|
|
export function countAssertions(src) {
|
|
const a = (src.match(/\bassert\b\s*[.(]/g) || []).length;
|
|
const e = (src.match(/\bexpect\s*\(/g) || []).length;
|
|
return a + e;
|
|
}
|
|
|
|
/** Conta tautologias assert.ok(true). */
|
|
export function countTautologies(src) {
|
|
return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
|
|
}
|
|
|
|
/**
|
|
* (6A.10 subcheck 2) Conta marcadores de skip/todo/only que silenciam testes:
|
|
* - .skip(, .todo(, .only( — em qualquer runner (node:test, jest, vitest)
|
|
* - { skip: true } — opção de objeto node:test
|
|
*/
|
|
export function countSkips(src) {
|
|
const modifiers = (src.match(/\.\s*(?:skip|todo|only)\s*\(/g) || []).length;
|
|
const skipOpt = (src.match(/\{\s*skip\s*:\s*true\s*\}/g) || []).length;
|
|
return modifiers + skipOpt;
|
|
}
|
|
|
|
/**
|
|
* (6A.10 subcheck 3) Conta tautologias que mantêm os asserts no texto mas nunca
|
|
* verificam nada real:
|
|
* - expect(true).toBe(true)
|
|
* - assert.equal(1, 1) / assert.strictEqual(1, 1)
|
|
* - assert.ok(true) (já coberto por countTautologies; incluído aqui para completude)
|
|
*/
|
|
export function countExtendedTautologies(src) {
|
|
let count = 0;
|
|
// expect(true).toBe(true)
|
|
count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length;
|
|
// assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity
|
|
count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length;
|
|
// assert.ok(true)
|
|
count += (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* (#6404) Narrower sibling of countExtendedTautologies(), deliberately EXCLUDING
|
|
* `assert.ok(true)`: that pattern is intentionally left to the lenient, diff-only,
|
|
* new-occurrences-only subcheck 3 above, because ~15 pre-existing, verified-legitimate
|
|
* uses already exist repo-wide (documented fallbacks like "expected to throw" /
|
|
* "DB not available, expected" in try/catch branches) — an absolute, always-on scan
|
|
* against all of them would be a mass false-positive, not a real signal.
|
|
*
|
|
* `expect(true).toBe(true)` / `assert.equal(1, 1)` / `assert.strictEqual(1, 1)` have
|
|
* no such legitimate use anywhere in this codebase (verified zero pre-existing hits
|
|
* after fixing #6404's playground-api-tab.test.tsx) — a genuinely bare, no-argument
|
|
* tautology is never a deliberate pattern here, so it is safe to fail on ANY hit,
|
|
* with or without a PR diff to compare against. See scanBareTautologies() below.
|
|
*/
|
|
export function countBareTautologies(src) {
|
|
let count = 0;
|
|
// expect(true).toBe(true)
|
|
count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length;
|
|
// assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity
|
|
count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length;
|
|
return count;
|
|
}
|
|
|
|
// ─── (6348) Subcheck 4: inline-reimplemented prod conditions (REPORT-ONLY) ───
|
|
// A test that copies a conditional expression out of production code (instead of
|
|
// importing and exercising the symbol that owns it) is the wrong-shape-contract-test
|
|
// class (#6216): the assertion re-encodes the branch locally, so it stays green even
|
|
// when the real prod condition drifts. This subcheck is a HEURISTIC, textual gate
|
|
// mirroring the count* helpers above — it never parses an AST. It is REPORT-ONLY for
|
|
// now (warns, does not fail the gate).
|
|
|
|
/** Collapse all runs of whitespace to a single space and trim. */
|
|
function normalizeWhitespace(s) {
|
|
return (s || "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/**
|
|
* Count "significant" tokens in a normalized condition. Single-char identifiers
|
|
* (`x`, `i`) and single-digit numeric literals (`0`, `1`) are treated as noise and
|
|
* NOT counted; operators and multi-char identifiers/numbers ARE. This is what makes
|
|
* `x > 0` trivial (1 significant token) while `status >= 500` is meaningful (3):
|
|
* - `status >= 500` → status, >=, 500 → 3
|
|
* - `x === LIMIT && y` → ===, LIMIT, && → 3
|
|
* - `x > 0` → > → 1
|
|
*/
|
|
export function countSignificantTokens(cond) {
|
|
const tokens =
|
|
(cond || "").match(/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g) ||
|
|
[];
|
|
let count = 0;
|
|
for (const tk of tokens) {
|
|
if (/^[A-Za-z_$]/.test(tk)) {
|
|
if (tk.length >= 2) count++; // multi-char identifier
|
|
} else if (/^\d/.test(tk)) {
|
|
if (tk.length >= 2) count++; // multi-digit number
|
|
} else {
|
|
count++; // operator
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** A condition is "meaningful" when it carries ≥3 significant tokens. */
|
|
function isSignificantCondition(cond) {
|
|
return countSignificantTokens(cond) >= 3;
|
|
}
|
|
|
|
/**
|
|
* Extract meaningful (≥3-token) conditional expressions from a production source,
|
|
* paired with the nearest enclosing declared symbol that "owns" them. Covers
|
|
* `if (...)` (via paren balancing) and comparison-bearing ternaries (`a === b ? … : …`).
|
|
* Returns [{ condition (whitespace-normalized), owner }].
|
|
*/
|
|
export function extractProdConditions(src) {
|
|
const results = [];
|
|
if (!src) return results;
|
|
|
|
// Declarations (function / const / let / var) with their positions, so each
|
|
// condition can be attributed to the symbol whose body it lives in.
|
|
const decls = [];
|
|
const declRe =
|
|
/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
|
|
let dm;
|
|
while ((dm = declRe.exec(src))) {
|
|
decls.push({ index: dm.index, name: dm[1] || dm[2] });
|
|
}
|
|
const ownerAt = (idx) => {
|
|
let owner = "";
|
|
for (const d of decls) {
|
|
if (d.index <= idx) owner = d.name;
|
|
else break;
|
|
}
|
|
return owner;
|
|
};
|
|
|
|
const seen = new Set();
|
|
const pushCond = (raw, owner) => {
|
|
const norm = normalizeWhitespace(raw);
|
|
if (!norm || seen.has(norm) || !isSignificantCondition(norm)) return;
|
|
seen.add(norm);
|
|
results.push({ condition: norm, owner });
|
|
};
|
|
|
|
// if (...) — balance parentheses to capture the full condition.
|
|
const ifRe = /\bif\s*\(/g;
|
|
let m;
|
|
while ((m = ifRe.exec(src))) {
|
|
let depth = 1;
|
|
let i = m.index + m[0].length;
|
|
for (; i < src.length && depth > 0; i++) {
|
|
const ch = src[i];
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth--;
|
|
}
|
|
pushCond(src.slice(m.index + m[0].length, i - 1), ownerAt(m.index));
|
|
}
|
|
|
|
// Comparison-bearing ternaries: `<lhs> <cmp> <rhs> ? … : …` (best-effort, low-noise).
|
|
const ternRe = /([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
|
|
let t;
|
|
while ((t = ternRe.exec(src))) {
|
|
pushCond(t[1], ownerAt(t.index));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Collect the identifiers/module specifiers a test file imports, so we can tell
|
|
* whether it exercises a prod symbol through the real import (clean) or merely
|
|
* re-implements one of its conditions locally (masked). Returns a Set of names:
|
|
* imported bindings, module paths, and module basenames.
|
|
*/
|
|
export function extractImports(src) {
|
|
const names = new Set();
|
|
if (!src) return names;
|
|
const addModule = (mod) => {
|
|
names.add(mod);
|
|
const base = mod
|
|
.split("/")
|
|
.pop()
|
|
.replace(/\.\w+$/, "");
|
|
if (base) names.add(base);
|
|
};
|
|
let m;
|
|
const importRe = /import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
while ((m = importRe.exec(src))) {
|
|
addModule(m[2]);
|
|
for (const id of m[1].match(/[A-Za-z_$][\w$]*/g) || []) {
|
|
if (id !== "as" && id !== "type") names.add(id);
|
|
}
|
|
}
|
|
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((m = dynRe.exec(src))) addModule(m[1]);
|
|
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((m = reqRe.exec(src))) addModule(m[1]);
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* PURE core of subcheck 4. Given the sources of the prod files changed in a PR,
|
|
* one test file's source, and the set of names that test imports: return the prod
|
|
* conditions the test re-implements textually WITHOUT importing the symbol that owns
|
|
* them. Whitespace is squashed on both sides so spacing differences never mask a hit.
|
|
* Returns [{ condition, owner }].
|
|
*/
|
|
export function findReimplementedConditions(prodSources, testSource, testImports) {
|
|
const flags = [];
|
|
if (!testSource) return flags;
|
|
const imports = testImports instanceof Set ? testImports : new Set(testImports || []);
|
|
const squash = (s) => (s || "").replace(/\s+/g, "");
|
|
const testSq = squash(testSource);
|
|
const seen = new Set();
|
|
for (const prod of prodSources || []) {
|
|
for (const { condition, owner } of extractProdConditions(prod)) {
|
|
if (owner && imports.has(owner)) continue; // exercised through the real import
|
|
if (seen.has(condition)) continue;
|
|
if (testSq.includes(squash(condition))) {
|
|
seen.add(condition);
|
|
flags.push({ condition, owner: owner || null });
|
|
}
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* (6A.10 subcheck 1) Sinaliza arquivos de teste DELETADOS ou renomeados-e-não-
|
|
* substituídos. Recebe lista de paths de arquivos de teste que foram deletados
|
|
* (filtro D do git diff --diff-filter=MDR).
|
|
*
|
|
* `deletionAllowlist` (`_deletedWithReplacement` no test-masking-allowlist.json)
|
|
* isenta uma deleção de três formas, cada uma com sua própria verificação:
|
|
* 1. `replacement` (path string) — o substituto declarado existe no HEAD e é
|
|
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem
|
|
* rename detectável" (conteúdo novo demais para o -M do git).
|
|
* 2. `sourceRemoved` (array de paths) — feature removida por completo: TODOS
|
|
* os arquivos de produção listados precisam estar ausentes no HEAD (sem
|
|
* substituto porque não há mais código a testar). Usar apenas quando a
|
|
* remoção do código-fonte está confirmada na mesma commit/PR.
|
|
* 3. `strayFromCommit` (hash) + `reason` (não-vazio) — o arquivo entrou no
|
|
* repositório POR ACIDENTE no commit declarado (ex.: um commit de docs
|
|
* que varreu artefatos de worktree de outra sessão, caso f4e93f339d) e a
|
|
* deleção devolve o arquivo ao seu fluxo dono (um PR/issue aberto). O
|
|
* gate verifica via git que o commit declarado é exatamente o que ADICIONOU
|
|
* o arquivo; o `reason` deve nomear o PR/issue dono para a revisão humana.
|
|
* Qualquer entrada cuja condição declarada não se verifique continua flagada.
|
|
*/
|
|
export function evaluateDeletedFiles(
|
|
deletedPaths,
|
|
deletionAllowlist = {},
|
|
fileExists = fs.existsSync,
|
|
addedByCommit = lookupAddedByCommit
|
|
) {
|
|
const flags = [];
|
|
for (const f of deletedPaths) {
|
|
if (!TEST_RE.test(f)) continue;
|
|
const entry = deletionAllowlist[f];
|
|
if (entry && typeof entry.replacement === "string") {
|
|
if (TEST_RE.test(entry.replacement) && fileExists(entry.replacement)) continue;
|
|
flags.push(
|
|
`${f}: deleção allowlistada mas o substituto declarado (${entry.replacement}) não existe ou não é arquivo de teste`
|
|
);
|
|
continue;
|
|
}
|
|
if (entry && Array.isArray(entry.sourceRemoved) && entry.sourceRemoved.length > 0) {
|
|
const stillPresent = entry.sourceRemoved.filter((p) => fileExists(p));
|
|
if (stillPresent.length === 0) continue;
|
|
flags.push(
|
|
`${f}: deleção allowlistada como feature removida mas ${stillPresent.join(", ")} ainda existe(m) no HEAD`
|
|
);
|
|
continue;
|
|
}
|
|
if (entry && typeof entry.strayFromCommit === "string" && entry.strayFromCommit.trim()) {
|
|
if (typeof entry.reason !== "string" || !entry.reason.trim()) {
|
|
flags.push(
|
|
`${f}: deleção allowlistada como stray mas sem \`reason\` — nomeie o PR/issue dono do arquivo`
|
|
);
|
|
continue;
|
|
}
|
|
const actual = addedByCommit(f);
|
|
const declared = entry.strayFromCommit.trim();
|
|
if (actual && (actual === declared || actual.startsWith(declared))) continue;
|
|
flags.push(
|
|
`${f}: deleção allowlistada como stray de ${declared} mas o commit que adicionou o arquivo é ${actual ?? "desconhecido"}`
|
|
);
|
|
continue;
|
|
}
|
|
flags.push(
|
|
`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`
|
|
);
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* (subcheck 1, forma 3) Hash COMPLETO do commit que adicionou `path` (o add
|
|
* mais recente — cobre o caso deletado-e-readicionado). `null` quando o git
|
|
* não conhece o path.
|
|
*/
|
|
function lookupAddedByCommit(path) {
|
|
try {
|
|
const out = execFileSync("git", ["log", "--diff-filter=A", "--format=%H", "--", path], {
|
|
encoding: "utf8",
|
|
});
|
|
const hashes = out
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
return hashes.length ? hashes[0] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse `git diff --name-status -M --diff-filter=DR` output, separating TRUE
|
|
* test-file deletions ("D\tpath") from RENAMES ("R<score>\told\tnew").
|
|
*
|
|
* A rename whose destination is still a test file is a *relocation* (the test
|
|
* was substituted at a new path, not removed) — per this file's subcheck-1
|
|
* contract it must NOT be treated as a deletion; the assert-reduction check
|
|
* still runs across the rename to catch gutting-via-rename. A rename that lands
|
|
* OUTSIDE test scope (test → non-test) removes the test and is treated as a
|
|
* deletion. Returns test-file paths only.
|
|
*/
|
|
export function partitionDeletedRenamed(nameStatusOutput) {
|
|
const deletedTests = [];
|
|
const renames = [];
|
|
for (const line of (nameStatusOutput || "").split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const parts = line.split("\t").map((s) => s.trim());
|
|
const status = parts[0] || "";
|
|
if (status.startsWith("D")) {
|
|
if (TEST_RE.test(parts[1] || "")) deletedTests.push(parts[1]);
|
|
} else if (status.startsWith("R")) {
|
|
const from = parts[1] || "";
|
|
const to = parts[2] || "";
|
|
if (TEST_RE.test(from)) renames.push({ from, to });
|
|
}
|
|
}
|
|
return { deletedTests, renames };
|
|
}
|
|
|
|
/**
|
|
* Avalia por-arquivo: flag em remoção líquida de asserts, nova tautologia,
|
|
* aumento líquido de skips, ou nova tautologia extendida.
|
|
*
|
|
* Cada entrada de perFile deve ter:
|
|
* { file, baseAsserts, headAsserts, baseTaut, headTaut,
|
|
* baseSkips, headSkips, baseExtTaut, headExtTaut }
|
|
*
|
|
* Os campos de skip e extTaut são opcionais (default 0) para compatibilidade
|
|
* com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut.
|
|
*/
|
|
/**
|
|
* (#6634) `check-test-masking.test.ts` legitimately embeds tautology-pattern string
|
|
* literals (`assert.ok(true)`, `expect(true).toBe(true)`, `assert.equal(1,1)`) as
|
|
* FIXTURES to exercise `countBareTautologies()`/`scanBareTautologies()` (#6404). The
|
|
* diff-based tautology counters (`countTautologies()`/`countExtendedTautologies()`)
|
|
* are dumb regex scans of raw source text with no awareness that a literal sits
|
|
* inside a fixture string rather than real assertion code, so any new fixture line
|
|
* self-trips a HARD "new tautology" flag on the gate's own regression-test file.
|
|
* Mirrors the exclusion `scanBareTautologies()` already applies for the same reason.
|
|
*
|
|
* The exclusion covers the whole `check-test-masking*` gate self-test family — not
|
|
* just `check-test-masking.test.ts` but sibling regression files such as
|
|
* `check-test-masking-selfref-6634.test.ts`, which likewise embed tautology-pattern
|
|
* literals as fixtures/documentation to prove this gate's own behavior.
|
|
*/
|
|
const SELF_TEST_FIXTURE_RE = /(^|\/)check-test-masking(-[\w-]+)?\.test\.tsx?$/;
|
|
function isSelfTestFixtureFile(file) {
|
|
return SELF_TEST_FIXTURE_RE.test(file);
|
|
}
|
|
|
|
export function evaluateMasking(perFile, assertReductionAllowlist = new Set()) {
|
|
const flags = [];
|
|
for (const f of perFile) {
|
|
const baseSkips = f.baseSkips ?? 0;
|
|
const headSkips = f.headSkips ?? 0;
|
|
const baseExtTaut = f.baseExtTaut ?? 0;
|
|
const headExtTaut = f.headExtTaut ?? 0;
|
|
const isSelfTestFixture = isSelfTestFixtureFile(f.file);
|
|
|
|
// The net-assert-REDUCTION signal can be allowlisted per file when the reduction is a
|
|
// verified-legitimate refactor/field-removal (config/quality/test-masking-allowlist.json).
|
|
// The tautology / skip / deletion signals below are NEVER allowlisted.
|
|
if (f.headAsserts < f.baseAsserts && !assertReductionAllowlist.has(f.file))
|
|
flags.push(
|
|
`${f.file}: asserts ${f.baseAsserts} → ${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)`
|
|
);
|
|
if (!isSelfTestFixture && f.headTaut > f.baseTaut)
|
|
flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`);
|
|
if (headSkips > baseSkips)
|
|
flags.push(
|
|
`${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)`
|
|
);
|
|
if (!isSelfTestFixture && headExtTaut > baseExtTaut)
|
|
flags.push(
|
|
`${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))`
|
|
);
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* (#6404) Absolute floor scan for bare tautologies (`expect(true).toBe(true)`,
|
|
* `assert.equal(1, 1)` / `assert.strictEqual(1, 1)`), independent of PR diffing.
|
|
*
|
|
* The subcheck-3 diff logic above (`evaluateMasking`'s `headExtTaut > baseExtTaut`)
|
|
* only fires for a tautology INTRODUCED within the current PR's own diff, and
|
|
* `resolveBase()` returns `null` outside CI (no `GITHUB_BASE_SHA`/`GITHUB_BASE_REF`),
|
|
* so a local `npm run check:test-masking` run silently no-ops — "sem base ref —
|
|
* pulando" — regardless of what the tests actually contain. That is exactly how
|
|
* #6404's `expect(true).toBe(true)` in `playground-api-tab.test.tsx` slipped through
|
|
* for a full release cycle after merging once (the diff-only gate has nothing to
|
|
* compare a pre-existing, already-merged tautology against, and local runs never
|
|
* scan repo content at all). This scans every tracked test file's current content,
|
|
* in or out of PR context, so a stray tautology can never hide once merged.
|
|
*
|
|
* Uses `countBareTautologies()` (not `countExtendedTautologies()`) — deliberately
|
|
* excludes `assert.ok(true)`, which has ~15 verified-legitimate pre-existing uses
|
|
* repo-wide and stays governed by the lenient, new-occurrence-only diff subcheck.
|
|
*
|
|
* `check-test-masking.test.ts` is excluded — its fixtures legitimately embed the
|
|
* literal pattern as string literals to exercise the count* helpers themselves.
|
|
*/
|
|
export function scanBareTautologies(testFiles, readFile) {
|
|
const read = readFile || ((f) => fs.readFileSync(f, "utf8"));
|
|
const flags = [];
|
|
for (const file of testFiles || []) {
|
|
if (isSelfTestFixtureFile(file)) continue;
|
|
let src;
|
|
try {
|
|
src = read(file);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const count = countBareTautologies(src);
|
|
if (count > 0) {
|
|
flags.push(
|
|
`${file}: ${count} tautologia(s) pura(s) (expect(true).toBe(true) / assert.equal(1,1)) — ` +
|
|
"substitua por um assert real do comportamento observável"
|
|
);
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
function git(args) {
|
|
try {
|
|
return execFileSync("git", args, { encoding: "utf8" });
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** All git-tracked test files (`.test.ts(x)`/`.spec.ts(x)`), repo-wide — used by the
|
|
* absolute floor scan so it also covers files untouched by the current diff/PR. */
|
|
function listTrackedTestFiles() {
|
|
return git(["ls-files"])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => TEST_RE.test(f));
|
|
}
|
|
|
|
function resolveBase() {
|
|
if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA;
|
|
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether the per-file diff subchecks should be skipped for being too large to be a
|
|
* reviewable unit. Exported so the threshold behavior is testable without a repo: the
|
|
* boundary is what matters, and an off-by-one here either blocks a release or silently
|
|
* disables the check on a big-but-legitimate PR.
|
|
*
|
|
* `max <= 0` disables the skip entirely (always analyze) — a deliberate escape hatch.
|
|
*/
|
|
export function shouldSkipDiffSubchecks(changedCount, max) {
|
|
const n = Number(changedCount);
|
|
const cap = Number(max);
|
|
if (!Number.isFinite(n) || n < 0) return false;
|
|
if (!Number.isFinite(cap) || cap <= 0) return false;
|
|
return n > cap;
|
|
}
|
|
|
|
function main() {
|
|
// (#6404) Absolute floor scan — runs unconditionally, PR or not, so a tautology
|
|
// that is already merged into the base (and thus invisible to the diff-only
|
|
// subchecks below) or a local pre-push run (which has no PR base to diff
|
|
// against) still gets caught. See scanBareTautologies() doc comment.
|
|
let bareTautAllowlist = new Set();
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8"));
|
|
bareTautAllowlist = new Set(raw._bareTautologyAllowlist || []);
|
|
} catch {
|
|
// no allowlist file — treat as empty
|
|
}
|
|
const trackedTestFiles = listTrackedTestFiles().filter((f) => !bareTautAllowlist.has(f));
|
|
const absoluteTautFlags = scanBareTautologies(trackedTestFiles);
|
|
if (absoluteTautFlags.length) {
|
|
console.error(
|
|
`[test-masking] ${absoluteTautFlags.length} tautologia(s) pura(s) encontradas ` +
|
|
`(scan absoluto — roda com ou sem contexto de PR):\n` +
|
|
absoluteTautFlags.map((f) => " ✗ " + f).join("\n") +
|
|
`\n → substitua por um assert real do comportamento observável.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const base = resolveBase();
|
|
if (!base) {
|
|
console.log(
|
|
"[test-masking] sem base ref (não é PR) — pulando checks de diff (scan absoluto de tautologias OK)."
|
|
);
|
|
return;
|
|
}
|
|
|
|
// (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter.
|
|
// Renames test→test são RELOCAÇÕES (substituição) e passam pela verificação de
|
|
// redução de asserts abaixo (gutting-via-rename ainda flaga); só deleções reais
|
|
// e renames test→não-teste contam como remoção de teste.
|
|
const { deletedTests, renames } = partitionDeletedRenamed(
|
|
git(["diff", "--name-status", "-M", "--diff-filter=DR", `${base}...HEAD`])
|
|
);
|
|
|
|
const relocatedOutOfTest = [];
|
|
const renamePerFile = [];
|
|
for (const { from, to } of renames) {
|
|
if (!TEST_RE.test(to)) {
|
|
// test → non-test: the test was removed from coverage.
|
|
relocatedOutOfTest.push(from);
|
|
continue;
|
|
}
|
|
// test → test: compare the original (base) against the relocated (head) file so
|
|
// a clean relocation passes but a rename that drops asserts/adds tautologies fires.
|
|
const baseSrc = git(["show", `${base}:${from}`]);
|
|
const headSrc = fs.existsSync(to) ? fs.readFileSync(to, "utf8") : "";
|
|
renamePerFile.push({
|
|
file: to,
|
|
baseAsserts: countAssertions(baseSrc),
|
|
headAsserts: countAssertions(headSrc),
|
|
baseTaut: countTautologies(baseSrc),
|
|
headTaut: countTautologies(headSrc),
|
|
baseSkips: countSkips(baseSrc),
|
|
headSkips: countSkips(headSrc),
|
|
baseExtTaut: countExtendedTautologies(baseSrc),
|
|
headExtTaut: countExtendedTautologies(headSrc),
|
|
});
|
|
}
|
|
|
|
// Arquivos de teste modificados (subcheck original + skips + extTaut)
|
|
const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
|
|
|
|
// (gap 6) A release PR is not a reviewable unit, and this is where that stops being free.
|
|
// Releases squash-merge into `main`, so a release PR's merge-base is the PREVIOUS cycle's
|
|
// fork point and the diff spans the whole cycle. In the v3.8.49 run that was ~1277 changed
|
|
// test files, each costing a `git show base:file` process plus a full regex pass — the check
|
|
// ran twice without finishing, >30 min pegged on one core, and the release waited on it.
|
|
//
|
|
// Every one of those files was already gated by this same check on its own PR during the
|
|
// cycle. Re-analyzing the aggregate buys nothing and blocks the release, so above the
|
|
// threshold the per-file diff subchecks are skipped — LOUDLY, naming the count, because a
|
|
// silent skip is how a gate becomes indistinguishable from a passing one (that is gap 12,
|
|
// and it cost two production bugs this cycle).
|
|
//
|
|
// The floor is untouched: scanBareTautologies() above already ran unconditionally over all
|
|
// tracked test files (3977 files, ~1 s), so nothing here lowers absolute coverage.
|
|
const maxChangedTests = Number(process.env.TEST_MASKING_MAX_CHANGED_TESTS || 300);
|
|
if (shouldSkipDiffSubchecks(changed.length + renamePerFile.length, maxChangedTests)) {
|
|
console.log(
|
|
`[test-masking] ${changed.length} teste(s) modificado(s) + ${renamePerFile.length} ` +
|
|
`renomeado(s) excede o teto de ${maxChangedTests} — pulando os subchecks de diff.\n` +
|
|
` Um diff desse tamanho é um PR de release (base = main, merge-base = fork do ciclo ` +
|
|
`anterior por causa do squash), não uma unidade revisável.\n` +
|
|
` Cada um desses arquivos já passou por este mesmo gate no PR de origem.\n` +
|
|
` O scan absoluto de tautologias rodou sobre TODOS os testes rastreados e está OK.\n` +
|
|
` Para forçar a análise completa: TEST_MASKING_MAX_CHANGED_TESTS=999999`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const perFile = [...renamePerFile];
|
|
for (const file of changed) {
|
|
const baseSrc = git(["show", `${base}:${file}`]);
|
|
const headSrc = fs.readFileSync(file, "utf8");
|
|
perFile.push({
|
|
file,
|
|
baseAsserts: countAssertions(baseSrc),
|
|
headAsserts: countAssertions(headSrc),
|
|
baseTaut: countTautologies(baseSrc),
|
|
headTaut: countTautologies(headSrc),
|
|
baseSkips: countSkips(baseSrc),
|
|
headSkips: countSkips(headSrc),
|
|
baseExtTaut: countExtendedTautologies(baseSrc),
|
|
headExtTaut: countExtendedTautologies(headSrc),
|
|
});
|
|
}
|
|
|
|
// Per-file allowlist for verified-legitimate net-assert reductions (refactor/field-removal).
|
|
// Only exempts the reduction signal; tautology/skip/deletion signals still fire.
|
|
let assertReductionAllowlist = new Set();
|
|
let deletionAllowlist = {};
|
|
let reimplementedAllowlist = new Set();
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8"));
|
|
assertReductionAllowlist = new Set(Object.keys(raw).filter((k) => !k.startsWith("_")));
|
|
deletionAllowlist = raw._deletedWithReplacement || {};
|
|
reimplementedAllowlist = new Set(raw._reimplementedConditions || []);
|
|
} catch {
|
|
// no allowlist file — treat as empty
|
|
}
|
|
|
|
// (6348 subcheck 4, REPORT-ONLY) Tests that inline-reimplement a prod condition
|
|
// instead of importing the symbol that owns it. Prod files changed in this PR
|
|
// (added/copied/modified TS sources) are the reference corpus; each changed test
|
|
// file is scanned against them. Warns only — it never fails the gate for now.
|
|
const prodChanged = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => PROD_SRC_RE.test(f) && !TEST_RE.test(f) && fs.existsSync(f));
|
|
const prodSources = prodChanged.map((f) => {
|
|
try {
|
|
return fs.readFileSync(f, "utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
});
|
|
const changedTests = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
|
|
const reimplementedFlags = [];
|
|
if (prodSources.length) {
|
|
for (const tf of changedTests) {
|
|
if (reimplementedAllowlist.has(tf)) continue;
|
|
const src = fs.readFileSync(tf, "utf8");
|
|
for (const hit of findReimplementedConditions(prodSources, src, extractImports(src))) {
|
|
reimplementedFlags.push(
|
|
`${tf}: re-implementa a condição \`${hit.condition}\`` +
|
|
(hit.owner ? ` (dona: ${hit.owner})` : "") +
|
|
" — asserte através do import real em vez de copiar a condição"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (reimplementedFlags.length) {
|
|
console.warn(
|
|
`[test-masking] (report-only) ${reimplementedFlags.length} teste(s) re-implementam ` +
|
|
`condição de produção em vez de importar o símbolo dono (classe #6216):\n` +
|
|
reimplementedFlags.map((f) => " ⚠ " + f).join("\n") +
|
|
`\n → importe o símbolo/função dono e asserte através dele (evita contrato duplicado ` +
|
|
`que diverge silenciosamente). Report-only por enquanto — não falha o gate.`
|
|
);
|
|
}
|
|
|
|
const deletedFlags = evaluateDeletedFiles(
|
|
[...deletedTests, ...relocatedOutOfTest],
|
|
deletionAllowlist
|
|
);
|
|
const maskingFlags = evaluateMasking(perFile, assertReductionAllowlist);
|
|
const allFlags = [...deletedFlags, ...maskingFlags];
|
|
|
|
if (allFlags.length) {
|
|
console.error(
|
|
`[test-masking] ${allFlags.length} sinal(is) de enfraquecimento de teste:\n` +
|
|
allFlags.map((f) => " ✗ " + f).join("\n") +
|
|
`\n → se a redução é legítima (refator/consolidação), explique no PR; senão, restaure os asserts.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
`[test-masking] OK — ${changed.length} modificado(s), ${renames.length} renomeado(s) (relocação), ` +
|
|
`${deletedTests.length} deletado(s) — sem enfraquecimento`
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|