Bring the matrix layer under fault testing (63% → 84%) - #1064
Merged
Conversation
Three strands, all the same idea: make the size of what is NOT measured visible,
then shrink it.
1. THE PHASE LAYERS ARE IN THE GATE. EntraID/Omada/midPoint Phases moved from
`exclusions` to `mutate` (109 -> 112 files, 23 -> 20 exclusions). Their
exclusion reasons had gone stale in the way this whole effort is about: they
still asserted 39.6% / 57.8% / 55.9% when the files now measure 64.3% / 65.8%
/ 63.0%. A written reason that has quietly stopped being true is worse than no
reason, because it reads as considered. Deleting them removes that.
2. THE JS BACKLOG EXISTS NOW. .ci/js-mutation-scope-baseline.json lists the 402
eligible .js/.jsx files with no mutation evidence, and
app/api/src/mutationScope.guard.test.js enforces that every eligible file is
mutation-tested, excluded with a written reason, or on that list -- which may
only SHRINK. This is the PowerShell scope guard's lesson applied before the
same thing happens here: 4 of 409 files were covered, and nothing said so.
Two properties learned the hard way over there: the guard asserts it found
>300 files BEFORE concluding anything, because an empty walk satisfies "every
file is decided" vacuously -- which is precisely how the PowerShell version
reported full coverage of a decision nobody had made. And it fails on stale or
already-covered entries, so the backlog cannot drift into overstating the gap.
3. EFFECTIVE ACCESS BROUGHT IN, 79.3% -> ~93% across the scope.
policies.js 83.6% -> 96.4%: every survivor sat in the logic picking the
DECISIVE ace, which drives the Direct/Indirect badge. They survived because
every fixture listed the expected winner LAST, so "keep whichever ace we just
looked at" gave the same answer as "pick the best one" -- the reduce could
have ignored its accumulator entirely and passed. Putting the winner first is
the only ordering that separates them. That also hid a real distinction: an
explicit group grant and an inherited one both badge Indirect, so badge-only
assertions could not see rank at all.
engine.js 69.4% -> 91.6%:
- `explicit: r.holder === principalId` had no fixture where a GROUP held the
grant, so hard-coding it true survived. That flag is the Direct badge: a
user would be told they hold access directly while the group it actually
came from vanishes from the answer.
- both truncation caps were untested. depth >= maxDepth and
depthByNode.size >= maxNodes decide when an access answer comes back
INCOMPLETE, and each is one off-by-one from silently returning less access
than the principal has. The fixture chain is 2 deep, so maxDepth 1 and a
node budget of 2 land exactly where >= and > disagree.
- a diamond graph re-admitted its shared node; on a cyclic graph the walk
would not terminate.
- the per-resolve telemetry line hard-coded cacheHit, so nothing showed
whether the cache did anything at all.
lru.js is new, extracted from engine.js at 94.7% (100% of killable). Inside
the engine it was reachable only through resolve() with a database and enough
distinct keys to force an eviction, so NOTHING exercised recency, eviction or
capacity. Same move as the PSMutant orchestrator: code that cannot be measured
where it sits gets lifted somewhere it can.
Remaining survivors checked and equivalent: map.delete() on an absent key is a
no-op, and `r < rb -> r <= rb` sits inside `if (r !== rb)`.
TWO GATES CAUGHT ME, both correctly:
- the inline db-mock ratchet, because my new test added a 61st inline factory.
Switched to the shared manual mock -- and then the ratchet still failed,
because my COMMENT explaining the rule quoted the offending call and the
check matches on file text. Reworded to describe the pattern instead.
- three unrelated tests timed out at 5s during the full run. They take 11-22s
under the CPU load of a concurrent mutation run and pass in isolation (97/97);
not caused by this change.
Suites: effectiveAccess 45 -> 76 tests, scope guard 5, all passing.
TWO THREADS. PowerShell is working toward the gate's break of 85 without
excluding anything; the JS side is widening what is measured at all.
POWERSHELL: 350 -> 378 killed of 543 (target 405 against a 476 denominator).
Declarations 48 -> 115, of which 67 are on the phase files:
- 55 display-class: progress calls, refresh cadence, percentages, and counters
whose only consumer is a log line.
- 12 error-picks, and these are PROVABLE rather than judgement. `Select-Object
-Last 1 -> -Last 2` on a phase's error message can only differ if two errors
share a prefix; counting $script:phaseErrors.Add per prefix shows every phase
has exactly one -- EXCEPT SignInLogs, which has two. SignInLogs' mutant is
therefore left undeclared and killable. The reason records that check, and
notes that a second Add would make the declaration false and fail the build.
THE AUDIT CAUGHT TWO OF MY OWN DECLARATIONS. I generated candidates by pattern
and `$x = 0` swept in OmadaCrawler.Phases.ps1:980 and MidpointCrawler.Phases.ps1:83
-- both are the SYSTEM ID records get attributed to, not display counters.
Starting either at 1 attributes records to the wrong system. Removed from the
declarations, and on the test list instead. This is exactly the caveat the config
states: a wrong declaration of an observable constant cannot be caught
automatically, so reading each one is the only defence.
Tests, all aimed at cases the existing fixtures could not see:
- Omada system registration: the old fixture had ONE row satisfying both halves
of `systemType -eq 'Omada' -and $S.tenantId`, so it read identically to -or.
Three rows that disagree prove a SQL Server system cannot land in the Omada map.
- enabled / syncEnabled on the Entra tenant, the Omada system and the midPoint
resource. Registered $false, a freshly connected system is silently inert.
midPoint's connected resources are deliberately enabled but NOT syncEnabled --
midPoint is what gets crawled, not them.
- Omada config defaults, including maxRetries = 0 surviving: the guard is
`$null -ne`, not truthiness, precisely so "do not retry" is not replaced by 5.
- the governance tally, which MY OWN earlier test could not discriminate: one
no-scope and one no-match are symmetric, so swapping the counters reads the
same. Now two against one, plus the sample budget (three skips, two samples).
- a tenant-wide OAuth2 consent carrying a principal id, and a per-user consent
with none. As -or the first is ingested as though a user had personally
authorised it -- the distinction that phase exists to make.
- sign-in activity for exactly ONE user, and identity derivation skipped when
the filter names no attribute (a real configuration; the wizard writes the key
before the value).
- midPoint shadow-kind filter and system-id resolution.
Also identified as equivalent rather than chased: `$seenKeys[$key] = $true` (only
ContainsKey is ever read), and the role/service `-not $oid -or -not $disp` guards
where $disp falls back to $oid, so both readings agree on every input.
JS: account linking measured for the first time (4 files, 83-99% line coverage).
classifier.js 68.2% -> 76.5%
engine.helpers.js 85.2% -> 93.2%
defaultRules.js 87.5%
engine.js 59.5% (38 of its mutants have no coverage at all)
Chosen on the same principle as effectiveAccess: high coverage over code where
BOTH failure directions are silent. Link too eagerly and one person inherits
another's access in every view; link too shyly and their true combined access is
never visible to a reviewer. Neither throws.
What that exposed, at 97% line coverage: emailLocalPart had no test for absent
input though it is called on crawler output where email is routinely null;
`.toLowerCase().trim()` is two steps and neither was pinned, so " Alice@x.com "
could stop matching "alice" and one person arrives as two identities; the
no-@ branch, where slice(0,-1) silently drops the last character of every
domain-less value; and rule PRIORITY ordering, which decides which rule wins --
the new fixture supplies rules out of order so an unsorted compile classifies the
account as Service rather than Admin.
JS backlog 402 -> 398; the scope guard keeps it shrink-only and stays green.
Suites: phase layers 248 tests, accountlinking 72, all passing.
The authoritative run over the three phase files scores 86.1% (397 killed / 461), clearing the gate's break threshold without excluding a single file -- which was the constraint: reach 85 by writing tests, not by narrowing scope. New tests close gaps where both failure directions were silent: a tenant, an Omada system and a midPoint resource are each now proven registered as enabled and syncable (a freshly connected system that registers otherwise is inert and never crawled again); a tenant-wide consent can no longer be recorded as an individual user's grant; identity correlation no longer runs against a filter naming no attribute. Three fixtures were rebuilt after they turned out to be SYMMETRIC -- counting two complementary groups with equal counts makes the groups interchangeable, so the mutant that swaps them survives a passing test. They now assert WHICH ids landed in each group, not how many. The 79 new equivalence declarations on the phase files are checked, not merely recorded: PSMutant fails the build if a declared mutant is ever killed or stops existing, and this run exited 0, so none of them is stale.
Mutation testing existed on the JS side but ran nowhere -- four Stryker configs, all `break: null`, invoked by hand. Nothing re-ran them, so a test that stopped discriminating would have gone unnoticed indefinitely, and the coverage page's Mutation column read "-" for API and UI. js-mutation.yml mirrors ps-mutation.yml: weekly Monday 05:00 UTC and on demand, never on a pull request (a run this heavy can never be a required check -- GitHub leaves one that never reports permanently pending, so on PRs it would only LOOK like a gate). One job per scope, fail-fast off, so a regression in one names itself in the check list without costing the other three their measurement. Each config now carries an enforced floor a few points under its measured score, verified green by running all four: auth 100.00 >= 98, effective access 94.65 >= 92, account linking 81.94 >= 80, UI 93.79 >= 91. That run also settled which score `break` compares against -- the TOTAL column (81.94), not "covered" (85.43) -- so the merged report's arithmetic counts NoCoverage as missed, exactly as the gate does. The publish job merges each package's per-scope reports into the shape generate-coverage-doc.py already reads and commits the refreshed page. Which reports it demands is derived from the configs' own jsonReporter.fileName, so a fifth scope requires its report with no edit; a missing one is a hard error rather than a quietly smaller merge, which would publish a score measured over less code than the page's scope note claims. It publishes even when a scope is below its floor: freezing the page at the last good number would leave it asserting something false for as long as the regression lasted. Fixes a real bug this surfaced: the page read ONE mutation-scope declaration and applied it to every suite, so wiring JS in would have reported PowerShell's 112 files as the scope of a JS score. Scope is now resolved per suite, with a regression test that fails against the old behaviour. Also: the coverage-docs tooling had tests that CI never ran (they are only exercised by post-merge workflows), so a PR could break the page and nothing would say so until it had landed; they now run in the ci-scripts job. The gate-wiring assertion that mutation stays off pull requests is derived over *-mutation.yml rather than naming ps-mutation.yml, with a check that the glob still matches both -- a rename would otherwise pass over an empty list. Documentation corrected where this made it wrong: the recorded PowerShell floor (80 -> 85), "nothing in this repo is declared yet" (there are 127), and a status section describing the phase layers as unmeasured after they had been measured.
bump-version.yml merges every changes/*.md into CHANGES.md and then DELETES the fragments. A stack sharing one cumulative file therefore republishes the lower PRs' bullets every time a higher one merges: when #1062 lands, changes/phases-into-gate.md is consumed, and this branch would re-add it carrying those same bullets plus its own. Each branch now owns a fragment named after itself, holding only the bullets it added -- which is what "uniquely named fragment file" in the workflow header means, and what makes its merge-conflict-free claim true for a stack rather than only for parallel branches.
The matrix layer decides which access is shown once inheritance, context rollups and attribute cuts are applied, and inheritedAccess.js decides whether a grant reads as held directly or through a group -- the thing a reviewer acts on. It was picked as the next scope because it matched the profile of the two worst surprises so far (effectiveAccess/engine.js 93% line -> 69% mutation, accountlinking/classifier.js 97% -> 68%). It is worse than both. 1,127 mutants over 8 files: 63.44% detected against a suite line coverage of 94.3%, a 31-point gap and the widest recorded here. file lines branch funcs mutation scopeHistory.js 84.9% 62.0% 85.7% 51.66% filterSql.js 87.8% 73.8% 91.7% 56.52% rollupBuilders.js 100.0% 100.0% 100.0% 61.67% inheritedAccess.js 97.6% 66.1% 92.3% 65.14% contextRollup.js 100.0% 95.5% 100.0% 68.80% attributeCut.js 100.0% 88.0% 100.0% 70.93% resourceContexts.js 100.0% 100.0% 100.0% 85.71% attrExpr.js 100.0% 100.0% 100.0% 93.48% rollupBuilders.js is the sharpest case in the repo: perfect coverage on every axis, and 38% of injected faults go unnoticed. The 103 no-coverage mutants are NOT an artifact of this config's narrow test include, which was the obvious suspicion and would have made the number a lie. Checked rather than assumed: running the FULL API suite with coverage limited to src/matrix reproduces the per-file figures exactly -- same 94.26% lines, same 76.24% branch, same uncovered lines. The include loses nothing, because the route tests that also drive these modules are in it deliberately (an excluded killer surfaces as a false survivor, which is worse than measuring less). Mutators stay fully enabled rather than inheriting the auth config's StringLiteral/ObjectLiteral exclusions. These are SQL builders and the unit mocks are SQL-blind, so the exclusion looked justified -- but the tests here assert on the emitted SQL directly, so a mutated literal dies. Carry the caveat with the number though: pinning SQL text proves it is unchanged, not that the query returns the right rows. That stays the contract tests' job. Floor set at 61, just under the measurement, and ratchets up from there.
inheritedAccess.js 65.14% -> 77.64%; the matrix scope 63.44% -> 67.02%, so the floor ratchets 61 -> 65. Each group below was verified by re-applying Stryker's exact reported replacement and confirming the tests fail -- not by assuming a new test covers what it looks like it covers. propagationScope (17 mutants, none previously reached). `reaches()` decides which ancestor actually grants a person's access: per migration 038 an assignment counts at the focus node only when its scope includes `self`, and on an ancestor only when it includes `descendants`. No test varied the scope, so the whole rule could have been inverted silently -- the "why does this person have access" panel naming a resource that grants nothing, or omitting the one that does. The main fixture is five rows that are each the only example of their case, asserted by WHICH ids come back; a count could not tell the right two from a different two. Group principals (9 mutants). A group is how access is delivered, not someone who has it, and three separate sites drop group principals from holder rows and counts. Every existing fixture was a `User`, so all three filters passed everything through and deleting them changed no result -- the textbook fixture that never reaches the branch. Now each mixes a group in with users, plus a principal that no longer exists, which is what makes the `!u` half of the guard load-bearing. The effective-access cache. Its eviction branch needed 256 distinct scopes to reach, so nothing ran it. Rather than build a fixture for that, the hand-rolled Map+eviction is replaced by createLru from src/effectiveAccess -- the identical cache, already extracted and tested for exactly this reason. Reuse over a second implementation, and the untestable branch stops existing. What remains is now covered: one scope is one entry however its node ids are ordered, a repeat is served rather than recomputed, and -- the one that matters -- a bumped sync version stops pre-crawl access being served, so a revoked grant cannot keep appearing in the matrix until the process restarts.
Matrix scope 67.02% -> 72.91%, floor ratchets 65 -> 70.
rollupBuilders.js 61.67% -> 96.67%; filterSql.js 56.52% -> 76.09%.
Every kill verified by re-applying Stryker's exact reported span.
rollupBuilders (21 of 23). All 23 survivors were WHERE-clause assembly, and they
survived for one reason: the existing "omits the IN-clause when unscoped" tests
assert `not.toMatch(/IN \(SELECT/)`, which passes just as happily on the broken
`p."principalId" IN null` that dropping the guard actually produces. Asserting
the ABSENCE of a string said nothing about what was there. These assert the whole
span between two fixed anchors instead, so a lost WHERE keyword, a lost AND
silently merging two conditions, an unasked-for condition, or the group-account
exclusion vanishing all change the result.
The 2 that remain are provably equivalent, not unexamined: both are the ' AND '
separator in builders whose bodies contain exactly one `where.push`, so the list
can never hold two conditions and [x].join(sep) === x. Left visible rather than
silenced with a disable comment, which would also hide the day a second condition
makes them killable. Checking that claim is what caught the opposite case --
buildGroupTotalsSql looked identical but has TWO pushes (it also excludes group
accounts over Principals), so its separator IS reachable and now has a test.
filterSql (42 of 44 in the targeted clusters, then both remaining). Three things
were entirely unmeasured:
* Cross-entity context translation. A context filter names members of one kind
while the query filters another, so it is rewritten -- principals expand down
to identities, identities roll up to principals, systems match on systemId.
Only the direct same-kind case was ever tested. A wrong rewrite still returns
rows, they are simply the wrong people. Two of the mappings are exact mirror
images differing only in which column is selected and which is matched, so
each test asserts the direction rather than "IdentityMembers is involved",
which is true of either.
* Include vs exclude routing, for context conditions. Sending one down the wrong
branch shows exactly the population the user asked to hide. The attribute
tests could not see it -- the include assertion still matches when the clause
is wrapped, because the wrapper goes around it.
* The guard in collectContextIds. Nothing malformed was ever passed in.
Two mutants needed inputs differing from a valid one in exactly ONE property to
prove each check load-bearing, and both are shapes real JSON produces: a condition
mis-tagged as an attribute while still carrying its context id, and an id wrapped
in the single-element array a multi-select emits. The unusable-pairing table
likewise had to cover every pairing rather than a sample -- Resource+Identity and
Principal+System exist precisely because without them the `entity` half of two
guards could be deleted with nothing failing.
scopeHistory.js 51.66% -> 94.31%; matrix scope 72.91% -> 80.93%; floor 70 -> 78.
90 of its 102 undetected mutants killed, each verified by re-applying Stryker's
exact reported span.
This file re-expresses the matrix filters against reconstructed jsonb snapshots
so the trend charts can answer "who had this access in March". Only the direct
same-kind context case was ever exercised, which left unmeasured: both
cross-entity translations, the entire exclude path, the value normalisation, the
extendedAttributes path, and every guard.
A wrong answer here is uniquely hard to notice. The live matrix can be checked
against the system it mirrors; a historical figure has nothing to compare against,
so it is believed by default.
The sharpest gap was the query skeleton. Two of the four CTEs are built by one
helper parameterised with the table to reconstruct, passed as a bare string --
`asofSurrogateCte('asof_principals', 'Principals', 'Principals')`. Nothing
asserted which table each rebuilt, so swapping the pair would reconstruct
principals from the Resources audit trail and produce SQL that still parses, still
runs, and still returns rows. The test now pins each CTE to both places its table
name appears: the `_history."tableName"` it replays and the live table it unions.
Also closed: values are normalised before binding (empty ones dropped, the rest
stringified, capped at 200) -- the fixture mixes three keepers of three different
types against three ways of being empty, so letting one empty form through or
dropping a falsy-but-real 0 changes the bound parameters; identifiers reaching an
ext.-prefixed or plain column are validated before interpolation, which is what
keeps a crafted field name out of the query text; and a missing subject or
resource block is treated as no conditions rather than throwing.
One correction along the way: the first version of the CTE assertion described a
`WITH latest AS (SELECT DISTINCT ON …)` shape the file does not have. It failed,
which is the point of writing the assertion against the real output rather than
against what the code was assumed to emit.
Same reason as the parent branch: bump-version.yml merges every changes/*.md into CHANGES.md and then deletes them, so a stack sharing one cumulative fragment republishes the lower PRs' bullets each time a higher one merges. This branch's four bullets move to a file named after it; the shared fragment goes back to carrying only the PowerShell work it belongs to.
TaekeK
force-pushed
the
feature/matrix-mutation-scope
branch
from
August 19, 2026 10:17
68514d5 to
d762e0a
Compare
TaekeK
added a commit
that referenced
this pull request
Aug 19, 2026
#1062 merged, so main now carries the phase-layer work plus the version bump. Two resolutions were needed. The Stryker configs for account linking and effective access conflicted because both PRs touch them: #1062 created them measurement-only, this PR turns them into enforced gates. Took this branch's version -- break 80 and 92 with the floor rationale -- after checking field by field that main's copy carries nothing else this one lacks. Only thresholds.break and _comment differ, and both differences are the point of this PR. Dropped changes/phases-into-gate.md. bump-version.yml consumed it when #1062 merged: its eight bullets are already in CHANGES.md and the fragment was deleted. Git wanted to restore it here, which would have republished those same eight bullets on the next bump -- the exact duplication the per-PR fragment split was meant to prevent, arriving through the merge rather than the branch. This PR keeps only changes/js-mutation-ci.md, its own three bullets. The same will happen to #1064 and #1065 as each is retargeted; the deletion propagates down the stack from here.
#1063 landed as a squash commit, so this branch's ancestry no longer matches main's and four files needed deciding. Taken from this branch, because they are what the PR does: .ci/js-mutation-scope-baseline.json backlog 398 -> 390, the eight matrix files leaving it for the new scope .github/workflows/js-mutation.yml the matrix scope entry, and the header's file count app/api/package.json test:mutation:matrix Taken from main: tools/mutation/test_stryker_to_mutation_json.py. Its version opens the report inside a `with` block rather than leaking the handle through json.load(open(...)) -- a review improvement made on #1063 that this branch, cut before the squash, would otherwise have reverted. Dropped changes/phases-into-gate.md and changes/js-mutation-ci.md: bump-version consumed both when #1062 and #1063 merged, and their bullets are already in CHANGES.md. Restoring them here would republish eleven bullets on the next bump. This PR keeps only changes/matrix-mutation-scope.md. Checked before resolving that neither the workflow nor package.json carried any other edit on main, so nothing from review is lost by preferring this side.
TaekeK
added a commit
that referenced
this pull request
Aug 20, 2026
#1064 landed as a squash commit -- the third in this stack -- so five files needed deciding. Taken from this branch, because they are what the PR does: .ci/js-mutation-scope-baseline.json backlog 390 -> 386, the four UI hooks leaving it for the new scopes .github/workflows/js-mutation.yml the hooks and listhooks scope entries, and the timeout note app/api/stryker.matrix.config.json break 78 -> 81 with StringLiteral and ObjectLiteral excluded, which is this PR's policy change applied to the scope #1064 introduced app/ui/package.json test:mutation:hooks / :listhooks Taken from main: tools/mutation/test_stryker_to_mutation_json.py, for the same reason as the previous merge -- its `with open(...)` block came from review on #1063 and this branch, cut before that squash, still carries the version that leaks the handle. Dropped the three earlier fragments: bump-version consumed each as its PR merged, so their fifteen bullets are already in CHANGES.md and restoring them would publish the lot a second time. This PR keeps only changes/ui-mutation-scope.md. Verified after resolving that all seven scopes in the workflow have both a Stryker config and the vitest config it names, and that every floor survived.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The matrix layer decides which access is shown once inheritance, context rollups and attribute cuts are applied, and
inheritedAccess.jsdecides whether a grant reads as held directly or through a group — the thing a reviewer acts on.Picked as the next scope because it matched the profile of the two worst surprises so far (
effectiveAccess/engine.js93% line → 69% mutation,accountlinking/classifier.js97% → 68%). It was worse than both: 1,127 mutants over 8 files at 63.44% against a suite line coverage of 94.3% — a 31-point gap, the widest recorded here.rollupBuilders.jswas the sharpest case in the repo: 100% of lines, branches and functions, and 38% of injected faults unnoticed.Where it ended up
Floor ratchets 61 → 65 → 70 → 78 → 81 as the work landed. Every kill was verified by re-applying Stryker's exact reported span and confirming the tests fail — not by assuming a new test covers what it looks like it covers.
What was actually broken
propagationScope(17 mutants, none previously reached). Per migration 038, an assignment counts at the focus node only when its scope includesself, and on an ancestor only when it includesdescendants. No test varied the scope, so the whole rule could have been inverted silently — the "why does this person have access" panel naming a resource that grants nothing, or omitting the one that does.User, so all three filters passed everything through and deleting them changed no result.createLrufromsrc/effectiveAccess— the identical cache, already extracted and tested for exactly this reason. What remains is now covered, including the one that matters: a bumped sync version stops pre-crawl access being served.Resourcesaudit trail and produce SQL that still parses, still runs, and still returns rows.The "omits the IN-clause when unscoped" tests were the instructive failure: they assert
not.toMatch(/IN \(SELECT/), which passes just as happily on the brokenp."principalId" IN nullthat dropping the guard actually produces. Asserting the absence of a string said nothing about what was there.Two remaining survivors are provably equivalent — the
' AND 'separator in builders whose bodies contain exactly onewhere.push, so[x].join(sep) === x. Left visible rather than silenced, which would also hide the day a second condition makes them killable.3 of 4. Targets
feature/js-mutation-ci.🤖 Generated with Claude Code