Skip to content

Bring the matrix layer under fault testing (63% → 84%) - #1064

Merged
TaekeK merged 11 commits into
mainfrom
feature/matrix-mutation-scope
Aug 20, 2026
Merged

Bring the matrix layer under fault testing (63% → 84%)#1064
TaekeK merged 11 commits into
mainfrom
feature/matrix-mutation-scope

Conversation

@TaekeK

@TaekeK TaekeK commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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.

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 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.js was the sharpest case in the repo: 100% of lines, branches and functions, and 38% of injected faults unnoticed.

Where it ended up

file lines branch mutation (before → after)
rollupBuilders.js 100.0% 100.0% 61.67 → 100.00
scopeHistory.js 84.9% 62.0% 51.66 → 96.10
inheritedAccess.js 97.6% 66.1% 65.14 → 80.51
filterSql.js 87.8% 73.8% 56.52 → 76.32
scope 94.3% 76.2% 63.44 → 83.79

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 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.
  • Group principals (9 mutants). Three 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 effective-access cache. Its eviction branch needed 256 distinct scopes to reach, so nothing ran it. Replaced the hand-rolled Map+eviction with createLru from src/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.
  • Cross-entity context translation. Two mappings are exact mirror images differing only in which column is selected and which is matched, so each test asserts the direction — "IdentityMembers is involved" is true of either.
  • Include/exclude routing. The existing tests couldn't see it: the include assertion still matches when the clause is wrapped, because the wrapper goes around it.
  • The as-of query skeleton. Two CTEs are built by one helper parameterised with the table to reconstruct, passed as a bare string. 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 "omits the IN-clause when unscoped" tests were the instructive failure: they 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.

Two remaining survivors are provably equivalent — the ' AND ' separator in builders whose bodies contain exactly one where.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

TaekeK added 4 commits August 18, 2026 09:37
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.
TaekeK added 6 commits August 19, 2026 12:14
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
TaekeK force-pushed the feature/matrix-mutation-scope branch from 68514d5 to d762e0a Compare August 19, 2026 10:17
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.
Base automatically changed from feature/js-mutation-ci to main August 20, 2026 07:03
#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
TaekeK merged commit 65d349a into main Aug 20, 2026
34 checks passed
@TaekeK
TaekeK deleted the feature/matrix-mutation-scope branch August 20, 2026 10:18
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant