feat(export): native merged/federated IFC export at parity with the JS exporter (#2951) - #2970
feat(export): native merged/federated IFC export at parity with the JS exporter (#2951)#2970BIMvoice wants to merge 10 commits into
Conversation
export_merged_with_stats (rust/export/src/merged.rs) only ID-offsets STEP entity instance names (#123) across federated models; GlobalId is a separate 22-char attribute the offset never touches. Two federated models sharing an element (same file merged twice, a shared grid, a linked type) emitted that element's GlobalId twice into one file -- a spec violation independent of the exporter's other parity gaps tracked in #2951. A model after the first now has each IfcRoot entity's GlobalId checked against everything already emitted; a collision is re-stamped with a fresh deterministic id (mirroring merged-exporter.ts's mintUniqueGuid) rather than written through unchanged. Ported the same 22-char-alphabet GlobalId check, the non-rooted-type denylist, and the deterministic-id hash from packages/parser/src/deterministic-global-id.ts, cross-checked byte-for-byte against the JS implementation for several seeds.
…porter (#2951) Adds MergedOptions.included -- a per-model (index-aligned) VisibilityFilter {roots, excluded}, mirroring the role StepOptions.included already plays for single-model export and computeIncludedEntityIds's role in merged-exporter.ts. An absent field/entry keeps a model in full; an explicit empty filter keeps nothing from it, pinned by dedicated tests so the two can't collapse into each other. The closure never walks into an excluded id (so a hidden product can't re-enter through a relationship naming it), and a kept IFCREL* entity that still names an excluded id is dropped to a fixpoint rather than emitted with a dangling #ref -- there is no per-SET/LIST-attribute narrowing on the Rust side yet, so dropping the whole relationship is the conservative, correctness-first choice for this increment. Also fixes a latent bug this surfaced: excluding model 0's own IfcProject used to leave every later model's own project dropped anyway (redirected to a canonical project that was never written), producing a file with no IfcProject at all. New module rust/export/src/merged_visibility.rs keeps merged.rs under its 400-line module-size ratchet budget (396/400).
…Root, not a scan+denylist leading_guid() in rust/export/src/merged.rs scanned for the first quoted token anywhere on a STEP entity line, then excluded a fixed denylist of non-IfcRoot types whose first attribute is itself a string. Both halves were unsound: a non-rooted entity whose first attribute is not a string (e.g. IFCMATERIALLAYER, whose Name is its 4th attribute) could still expose a later quoted string to the scan no matter how complete the denylist was, and the denylist itself was missing several non-rooted types (IFCMATERIALLAYER, IFCMATERIALLAYERSET, and others). When that coincidentally 22-character, GlobalId-charset string collided with a real GlobalId already emitted, the GlobalId-collision reconciliation this branch added silently rewrote it -- corrupting ordinary model data. GlobalId identification is now positional (the quoted token must be the entity's true first attribute, only whitespace allowed before it) and type-checked against rust/core's generated IfcRoot subtype table via IfcType::is_subtype_of, rather than a hand-maintained denylist that can drift out of sync with the schema. The existing shared-GlobalId reconciliation test used IFCGRIDAXIS's AxisTag as a stand-in "GlobalId" -- IfcGridAxis is not an IfcRoot subtype, so that test only exercised reconciliation because of this same bug. Rebuilt it with genuinely rooted entities (IFCDOOR/IFCWALL/ IFCSPACE), and added regression coverage for the corruption itself: a non-rooted entity's coincidentally GlobalId-shaped string must survive the merge byte-for-byte, whether it's the entity's first attribute or a later one, and even for a malformed line where a genuinely rooted type's first attribute isn't a string at all.
…r's GUID check leading_guid's IfcType::is_subtype_of(IfcRoot) check is resolved against rust-core's generated schema, which is derived from IFC4X3 only. A rooted type that exists in IFC2X3 and/or IFC4 but was dropped or renamed in IFC4X3 (IFCPROXY, IFCDOORSTYLE, the IFC4 *STANDARDCASE variants, and 51 others) resolves to IfcType::Unknown and was silently treated as non-rooted, so its GlobalId was never reconciled across a merge -- the same defect the schema-derived check was meant to fix, just on older files. Reproduced by execution (RED) with a shared IFCDOORSTYLE GlobalId across two IFC2X3 models duplicating in the merged STEP text. Adds a small supplemental table of the 54 confirmed IFC2X3/IFC4-only rooted types (derived by diffing @ifc-lite/data's per-schema entity tables against the IFC4X3-only generated schema), consulted only when IfcType::from_str resolves to Unknown -- anything genuinely unrecognised stays non-rooted, so the corruption the prior fix closed stays closed.
…e merged exporter's visibility filter merged_visibility.rs's fixpoint withheld a kept relationship's whole line the instant it named ANY excluded id, regardless of where that id sat. On real IFC this is more than conservative: an exporter that lists every element of a storey in one IFCRELCONTAINEDINSPATIALSTRUCTURE loses that storey's containment for every other, still-visible element just because one sibling was hidden. narrow_relationship_line mirrors filterHiddenRefsFromRelationshipLine (reference-collector.ts): a SET/LIST attribute is narrowed to its surviving members, and the whole line is withheld only when an excluded id sits in a single-valued slot or was a SET/LIST's only member -- including the one schema-optional exception (IfcRelConnectsStructuralMember.ConditionCoordinateSystem) JS carries. Reuses step_text::split_top_level_args rather than adding a second attribute-group parser. Also corrects merged_visibility.rs's docstring, which claimed the old whole-drop approach "can only under-connect, never dangle" -- true only for the IFCREL* shape it inspects, not for a non-IFCREL* entity (e.g. IFCSTYLEDITEM.Item) still referencing an excluded id. That gap is inherited from the JS reference, which documents it openly, not introduced here.
# Conflicts: # rust/export/src/merged_tests.rs
…ng changesets Both describe rust/export/src/merged.rs's visibility-filter feature; the narrowing changeset directly amended a claim made in the visibility-filter changeset (whole-relationship-drop -> per-attribute narrowing), so keeping both as separate entries would contradict itself within the same release.
The two forked #2951 chains each grew merged.rs independently (guid-misidentification/schema-coverage added the IfcRoot type check and the 54-entry legacy rooted-type table; visibility-filter/relationship- narrowing added MergedOptions.included and narrowing). Combined, the file crossed the 400-line module-size ratchet (432 lines) with no allowlist row. Split along the seam merged.rs already documented in its own comments: GlobalId identification and re-stamping (leading_guid, is_legacy_rooted_type, replace_leading_guid, deterministic_global_id, mint_unique_guid) is a self-contained concern independent of the emission loop that calls it (offsetting, visibility filtering, relationship narrowing) -- the same kind of seam merged_visibility.rs already split out for the filtering side. merged.rs: 245 lines. merged_guid.rs: 198 lines. Ratchet passes with no allowlist changes and no digest change.
…e line Adds narrowing_and_guid_restamping_both_apply_to_the_same_relationship_line, exercising the one interaction neither forked #2951 chain could test on its own: an IFCREL* line (itself an IfcRoot subtype, so it carries its own GlobalId) that is BOTH narrowed (relationship-narrowing chain, a SET member excluded by visibility filtering) AND re-stamped (base/misidentification/ schema-coverage chain, its GlobalId collides with an earlier model's). Asserts the exact emitted line: narrowed SET, re-stamped GlobalId (shape- checked, not just non-empty), and everything else byte-identical to what narrowing+offsetting alone would produce -- confirming re-stamping only ever touches the leading quoted attribute. Mutation-tested locally (not committed): disabling narrowing alone left the excluded SET member in the output while re-stamping still worked; disabling re-stamping alone left the collided GlobalId unchanged while narrowing still worked. Both mutations made this test fail as expected; file restored via `cp` + `diff` to byte-identical afterward.
|
Warning Review limit reached
Next review available in: 46 minutes Limit details: You’ve used all 2 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Viewer benchmark✅ No threshold regressions detected. 01_Snowdon_Towers_Sample_Structural(1).ifcBaseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.
AC20-FZK-Haus.ifcBaseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.
Refresh the baseline from a CI run: dispatch the Benchmark workflow with |
|
#2951 is assigned to @Blogbotana, who has had #2952 open on it since 2026-08-20 17:20. This PR opened today at 08:01 and says Two implementations of the same feature, one of them from the person the issue was assigned to. Flagging rather than judging the code — I have not reviewed either. For whoever untangles it, the state as of now:
#2952's single failure is The house convention is to check for an existing contributor PR before starting an assigned issue, precisely so this does not happen. Since it has, the call on which lands is a maintainer's, not mine and not either author's — but whichever way it goes, the work in the other PR deserves an explicit disposition rather than being closed silently, and @Blogbotana deserves to hear it from a person. If #2970 is the one that lands, the fair outcome is credit to @Blogbotana on the issue and a note on #2952 saying why. If #2952 is, this one supersedes cleanly since it is newer. |
|
Holding this one. #2951 was filed by @Blogbotana, assigned to @Blogbotana, and implemented in #2952 fifteen hours before this PR opened. The maintainer decision is that the external contributor's PR takes precedence, so #2952 is the one that lands for #2951. That is a coordination failure on our side rather than anything wrong with the work here, and this PR is not wasted: four things in it exist nowhere in #2952 and are being carried over. Detail is in #2952 (comment), in short:
Worth being explicit about what does not carry over, so nobody ports it by reflex: #2952 has the unify branch of GUID reconciliation, unit compatibility and the federation fallback, spatial unification, infra dedup, Next step depends on @Blogbotana: either they fold these in, or #2952 lands and the port goes on top as a follow-up. Leaving this open until that is settled rather than closing it, since the visibility work is the thing we would otherwise have to write from scratch. |
|
The CodeRabbit tick on this PR is a false pass. The only CLI run: completed, exit 0, 11 files reviewed, 2 findings (1 major, 1 minor). Real: a parse miss returns
|
…3040) * docs(agents): respect assignments, and claim work before starting it Three PRs today duplicated work that was already claimed. The one that matters: issue #2951 was filed by an external contributor, assigned to them, and implemented in #2952 — and #2970 arrived fifteen hours later implementing the same thing. They objected, correctly. The cost is not the wasted effort. It is that someone who did everything right watched the project duplicate their work. The rule has three parts, and the third is the one that was missing: check assignees, check for an open PR referencing the issue, and assign yourself BEFORE writing code rather than when opening the PR. An assignment made at PR time claims nothing, because the window it needed to cover has already closed. Check again just before opening, since a claim can appear while you work. Also states who keeps the work when a duplicate happens: the person who was assigned, not whoever is further along. And that a duplicate is enumerated before it is closed, so what it uniquely holds is not lost. * docs(agents): helping is welcome, taking over is not The first version said "if someone else is assigned, it is theirs, do not start", which forbids the cases that are actually fine and gives no way to tell them apart from the case that is not. Two things make it help rather than a takeover: They accepted an offer. Comment saying what you would do and wait for a yes. Silence is not a yes. An assignee who is mid-development and reads "we have already built this in parallel" is being told, not asked, which is exactly what happened on #2670. It has genuinely gone quiet: no commits and no word for about a week, and even then comment first, wait a couple of days, and reassign explicitly rather than working in the shadows. Also lists what needs no permission at all, since the first version could be read as discouraging it: reviewing their PR, diagnosing a failing check and posting the cause, answering a question, reporting a defect in shipped code. And what is not help however good the code: a parallel implementation announced afterwards, an unraised branch duplicating their work, pushing to their branch, a competing PR. If you already built something before noticing, say so, hand it over, and let them decide. That is recoverable. Landing it is not. Applies to us as much as to any bot.
|
Status note so this does not sit here unexplained. This and #2952 are competing implementations of #2951, both rewriting I am not closing this yet, deliberately. #2952 has seven unresolved Major findings on its head, so closing this now would drop working code with nothing merged in its place. The piece here that #2952 does not have in the same shape is the visibility filter ( Plan: once #2952 lands, this gets closed and the visibility work rebased on top of it as its own PR, rather than the two continuing to diverge. If you want to start that rebase before #2952 merges, that is fine, just expect churn.
|
…not green
`check-coderabbit-review.mjs` answers one question per PR: did CodeRabbit
read the diff at the head commit. A sweep across all 36 open PRs found
the failure modes it was built for, plus four it does not cover — each of
which renders as green, or as "nothing failing", in any report that
counts only failing and pending checks:
- `headRepositoryOwner.login` is not ours. Every other column is then a
report about a branch we cannot push to.
- `mergeStateStatus == DIRTY` WITH runs present: green checks over a
merge commit that can no longer be formed. #2970 and #2971 are in
exactly that state, and both show `fail=0, pending=0`.
- ZERO workflow runs at the head commit. A PR that was already DIRTY at
push time never gets a run, so `statusCheckRollup` comes back EMPTY —
and an empty rollup counts up to the same `fail=0, pending=0` as a
fully passing one. This is the vacuous-pass shape, so the run count
is a first-class signal here rather than something derived.
- The newest run on the BRANCH is against a superseded commit, while
the rollup still shows that older commit's green.
The sweep that found these was a throwaway script, which makes it a habit
someone has to remember. It lands here as a second entry point,
`scripts/check-pr-green.mjs`, over a new pure module
`scripts/lib/pr-green-sweep.mjs`. `coderabbit-review-state.mjs` is reused
unchanged — the review verdict is one column of the table, not a
reimplementation.
The disqualifier order is worst-first and each earlier reason invalidates
the later ones AS EVIDENCE: a fork's counts are not ours to read, a DIRTY
base means the green ran on a merge that cannot be formed, zero runs
means the counts are empty rather than passing, a stale run means they
describe a commit that is no longer the head. `not ours` takes precedence
as a disqualifier but ranks LOWEST for sorting, because it is the one
verdict that is not a task; both halves of that deliberate disagreement
are pinned by tests.
It must not pass vacuously, so three cases are hard failures with their
own `kind` and their own message rather than a shorter, cleaner report:
zero PRs returned (what a wrong `--author`, a wrong repo and a truncated
response all look like), an unreachable API — including mid-sweep, where
a swallowed per-PR failure would drop a row from a report that still
looks complete — and a malformed response, which covers a non-JSON body,
an empty body, a runs payload with no numeric `total_count`, and a
GraphQL payload with no `pullRequest` node. A missing `total_count` is
reported as malformed, never defaulted to 0: zero runs is a real verdict
about the PR, and defaulting would manufacture it out of a broken sweep.
24 tests in the style of the existing classifier's. Verified by mutation:
dropping the zero-runs disqualifier fails 2, returning `[]` for an empty
PR list fails 1, swallowing a transport failure fails 2, tolerating a
runs payload with no `total_count` fails 1, disabling stale-run detection
fails 3, and removing the not-ours precedence fails 3.
Wiring: the tests run in CI as a named step in `node-tests` (and were
already covered by that job's `scripts/lib/*.test.mjs` catch-all). The
entry point itself is marked `@unwired-by-design` per the convention
#3071 establishes — every verdict it produces is transient GitHub state
(queued runs, review latency, a base that is DIRTY only until the next
rebase, rate limiting), so a required check built on it would fail for
reasons unrelated to the diff under test.
|
Nudging this one, because it is the only PR on the board with no movement and it is now rotting rather than waiting. State: I said this morning I would not close it until #2952 lands, so the visibility work would not be lost. That reasoning was right and the plan attached to it is not working: #2952 still has 6 unresolved Major findings on its head, including the wrapping arithmetic on EXPRESS ids. It is not landing this week, and every day this waits the conflict widens. The specific thing at risk
What I would suggest, and it is your callExtract the visibility filter as its own PR against current main, decoupled from the #2951 allowlist-vs-denylist race entirely. It does not depend on which merged-export implementation wins: whichever one lands, a visibility filter sits on top of it. That turns a rotting 11-file PR into a small one that can be reviewed and merged on its own merits, and it stops the work being hostage to an external contributor's review cycle. Then close this, and the If you have already solved rootedness properly in this PR, saying so on #2952 is worth more than either the rebase or the extraction. That is the finding blocking the external PR, and you may have the answer sitting in Happy to do the extraction myself if you would rather not — say the word. Not touching it otherwise, it is yours. |
|
Nudge with the specific state, since this one's checks column is misleading in a way that is easy to miss. This PR is stale on two independent axes at once. Two separate things, and each alone would be misread:
A check for either one alone gets this wrong in a different direction — "it has runs, so it is fine" misses the first, and "the lane changed, so rebase" misses that the base is gone too. Only you can clear it, because it is conflicting:
Nothing here is about the change itself. |
* feat(scripts): tell a real CodeRabbit review from a green tick
A CodeRabbit pass does not mean the diff was reviewed. Three states render
as the same check: reviewed and found nothing, reviewed only the newest
commit, or never ran because the org is rate limited. Under Fair Usage the
third is green on every open PR at once, so 'all our PRs show CodeRabbit
passing' is a symptom rather than reassurance.
Two signals are needed and neither works alone. The rate-limit sentinel
alone gives false positives: a PR can carry it verbatim and still have
genuine inline findings posted minutes later, because the summary comment
is not rewritten when a later pass succeeds. So the sentinel is conclusive
only when the inline thread count is also zero.
Note gh pr view --json comments does not return inline review threads at
all, so the count comes from reviewGraphQL threads; without that every PR
reads as having no findings.
Classification is a pure function with unit tests over synthetic bodies.
Not wired into CI, and should not be: the answer depends on transient
GitHub state, so a required check on it would fail for reasons unrelated
to the diff. Empty target list exits non-zero rather than reporting a
vacuous all-clear.
* fix(scripts): stop the review detector failing in both directions at once
The classifier returned NO-REVIEW whenever the CodeRabbit comment list was
empty, before the inline thread count was consulted. CodeRabbit posts inline
findings while its summary comment is absent, deleted, or rewritten in place,
so three live threads read as "posted no comment at all". Only the absence of
both signals is absence of review.
And no timestamp reached the classifier at all: threads were fetched for their
author, comments for their body, and the input was {bodies, inlineThreadCount}.
A review from two pushes ago therefore certified commits the bot never saw. The
GraphQL query now also returns each thread's first-comment createdAt, each
CodeRabbit review's submittedAt, and the head commit's pushedDate (falling back
to committedDate), and evidence older than its head commit reports
PRE-PUSH-REVIEW rather than REVIEWED.
The two mistakes are not symmetric. A false "not reviewed" is loud and
self-correcting; a false "reviewed" is silent and terminal, because nobody
looks again and the tool has certified the thing it exists to catch. So the
ambiguous cases now resolve to reviewed: false -- a comment with none of the
markers (INCONCLUSIVE), and a timestamp that is missing or unparseable
(UNDATED). The parse returns null rather than NaN on purpose: every comparison
against NaN is false in both directions, so a bare `<` would have reached the
reassuring branch by accident.
Tests go 10 -> 19, covering both directions, both composed, a genuinely
reviewed-at-head PR still reading REVIEWED, and each ambiguous case. The file
was never run by CI; it now has a step.
* docs(scripts): declare the CodeRabbit review check unwired-by-design, machine-readably
The header already explained in prose why this must not gate CI -- its
verdict depends on transient GitHub state -- but prose is not something
#3071's wiring checker can read. That checker accepts exactly one
declaration, an `@unwired-by-design <reason>` line, and reports every other
unreferenced scripts/check-*.mjs as an accidental omission. Say it in the
form the gate reads, so the deliberate exception stays visible and the
genuine omissions stand out.
Comment only. Its classifier tests still run:
node --test scripts/lib/coderabbit-review-state.test.mjs -> 19 pass, 0 fail
* feat(scripts): sweep every open PR for the four green ticks that are not green
`check-coderabbit-review.mjs` answers one question per PR: did CodeRabbit
read the diff at the head commit. A sweep across all 36 open PRs found
the failure modes it was built for, plus four it does not cover — each of
which renders as green, or as "nothing failing", in any report that
counts only failing and pending checks:
- `headRepositoryOwner.login` is not ours. Every other column is then a
report about a branch we cannot push to.
- `mergeStateStatus == DIRTY` WITH runs present: green checks over a
merge commit that can no longer be formed. #2970 and #2971 are in
exactly that state, and both show `fail=0, pending=0`.
- ZERO workflow runs at the head commit. A PR that was already DIRTY at
push time never gets a run, so `statusCheckRollup` comes back EMPTY —
and an empty rollup counts up to the same `fail=0, pending=0` as a
fully passing one. This is the vacuous-pass shape, so the run count
is a first-class signal here rather than something derived.
- The newest run on the BRANCH is against a superseded commit, while
the rollup still shows that older commit's green.
The sweep that found these was a throwaway script, which makes it a habit
someone has to remember. It lands here as a second entry point,
`scripts/check-pr-green.mjs`, over a new pure module
`scripts/lib/pr-green-sweep.mjs`. `coderabbit-review-state.mjs` is reused
unchanged — the review verdict is one column of the table, not a
reimplementation.
The disqualifier order is worst-first and each earlier reason invalidates
the later ones AS EVIDENCE: a fork's counts are not ours to read, a DIRTY
base means the green ran on a merge that cannot be formed, zero runs
means the counts are empty rather than passing, a stale run means they
describe a commit that is no longer the head. `not ours` takes precedence
as a disqualifier but ranks LOWEST for sorting, because it is the one
verdict that is not a task; both halves of that deliberate disagreement
are pinned by tests.
It must not pass vacuously, so three cases are hard failures with their
own `kind` and their own message rather than a shorter, cleaner report:
zero PRs returned (what a wrong `--author`, a wrong repo and a truncated
response all look like), an unreachable API — including mid-sweep, where
a swallowed per-PR failure would drop a row from a report that still
looks complete — and a malformed response, which covers a non-JSON body,
an empty body, a runs payload with no numeric `total_count`, and a
GraphQL payload with no `pullRequest` node. A missing `total_count` is
reported as malformed, never defaulted to 0: zero runs is a real verdict
about the PR, and defaulting would manufacture it out of a broken sweep.
24 tests in the style of the existing classifier's. Verified by mutation:
dropping the zero-runs disqualifier fails 2, returning `[]` for an empty
PR list fails 1, swallowing a transport failure fails 2, tolerating a
runs payload with no `total_count` fails 1, disabling stale-run detection
fails 3, and removing the not-ours precedence fails 3.
Wiring: the tests run in CI as a named step in `node-tests` (and were
already covered by that job's `scripts/lib/*.test.mjs` catch-all). The
entry point itself is marked `@unwired-by-design` per the convention
#3071 establishes — every verdict it produces is transient GitHub state
(queued runs, review latency, a base that is DIRTY only until the next
rebase, rate limiting), so a required check built on it would fail for
reasons unrelated to the diff under test.
---------
Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
Closes #2951 — native (Rust) merged/federated IFC export at parity with the JS merged exporter.
Verified
cargo test -p ifc-lite-export --lib— 256 passed / 0 failedcargo test -p ifc-lite-processing --test module_size_ratchet— 5/5, includingallowlist_digest_is_pinnedcheck-changesets.mjs— passcheck-api-surfacedoes not applyThe decomposition stays under the 400-line rule without an allowlist entry:
merged_guid.rs198 lines,merged_visibility.rs298.One defect fixed on the way, worth naming
leading_guidscanned for the first quoted token anywhere in the line, so any entity whose first string attribute was not a GlobalId could be misread. It now reads the GlobalId positionally and gates onIfcType::is_subtype_of(IfcRoot)(rust/core/src/generated/schema.rs:6354), so a non-rooted entity is never treated as carrying one.That mattered more than it looks: a test elsewhere had been using
IFCGRIDAXIS'sAxisTag— a non-rooted attribute — as a stand-in GlobalId, and passed because of the bug. Code and fixture shared one wrong assumption about what a GlobalId is, which is the class of defect mutation testing cannot catch, since the mutation is judged by the same blind test.🤖 Generated with Claude Code