Repository navigation
[quality] Annotate the 7 verified-equivalent mutants in uri-safety.mjs and url-safety.mjs #1813
Description
Finding
A full mutation run over scripts/lib/uri-safety.mjs and
scripts/lib/url-safety.mjs at 8abcb3f leaves 7 mutants Survived:
npm run test:mutation -- --mutate scripts/lib/url-safety.mjs,scripts/lib/uri-safety.mjs
All files | 96.76 | 96.76 | 209 | 0 | 7 | 0 | 0 |
uri-safety.mjs | 97.22 | 97.22 | 105 | 0 | 3 | 0 | 0 |
url-safety.mjs | 96.30 | 96.30 | 104 | 0 | 4 | 0 | 0 |
96.76% is above the break threshold of 90, so nothing is failing today.
These are the residue left after Group D (#1790) closed and #1788 completed.
I verified each of the seven individually rather than assuming, and all seven
are equivalent mutants. None of them can be killed by a test, so per
CONTRIBUTING.md each wants a
// Stryker disable directive carrying its reason — the same treatment #1807
gave the 41 previously verified equivalents.
The value is that this is a recurring discovery: every mutation sweep a quality
kick runs rediscovers these seven and has to re-verify them from scratch.
Annotating them retires the class, which is what #1788 asked for.
The seven, and why each is equivalent
| # | Site | Mutator | Why it cannot change behaviour |
|---|---|---|---|
| 1 | uri-safety.mjs:86 |
Regex | Dropping + from /[\u0000-\u0020\u007f]+/g is a no-op under /g: replacing each control character individually yields the same string as replacing runs. Fuzzed 20k random control-character strings, 0 differences. |
| 2 | uri-safety.mjs:111 |
Regex | Dropping ^ from /^data:([^,;]*)/. The line is reachable only after the anchored scheme match proved normalized starts with data:, so the leftmost unanchored match is already at index 0. |
| 3 | uri-safety.mjs:163 |
Regex | Dropping ^ from the authority separator replace. The replacement rewrites a separator run to exactly // and never moves content forward, so a match at a non-zero index preserves the prefix and cannot newly satisfy the // or https:// test. Fuzzed 300k inputs over a targeted alphabet against the real remoteTarget, 0 differences. |
| 4 | url-safety.mjs:126 |
ConditionalExpression | Column 34 replaces typeof value === 'string' with true, giving requireNonEmptyString && true. The guard at line 113 returns early when requireNonEmptyString is set and value is not a string, so the operand is always true at that site. |
| 5 | url-safety.mjs:142 |
Regex | /:$/ to /:/ inside error-message text only, and no protocol string contains an interior colon, so both strip the same character. |
| 6 | url-safety.mjs:192 |
StringLiteral | The path argument feeds message strings only; isSafeUrl branches on errors.length. Identical error counts across 7 probes. |
| 7 | url-safety.mjs:192 |
StringLiteral | Same, for the field argument. |
Note on #4: the mutant is at column 34, so it mutates only the right-hand
operand, not the whole condition. Read as a whole-condition mutant it looks
behavioural — forcing an unconditional trim does change whether a padded value
survives applyDefaultScheme — which is why it is worth stating the column
explicitly here.
Recommendation
Apply the directives below. Placement follows the rules CONTRIBUTING.md sets
out: disable next-line where the mutant's statement starts on the next line,
and a disable / restore pair where the node sits mid-call-chain or inside a
template literal.
1. scripts/lib/uri-safety.mjs — normalizeUri (mid-chain, needs a pair)
export function normalizeUri(value) {
// Stryker disable Regex: `+` is redundant under /g -- replacing each control
// character individually yields the same string as replacing runs.
return (
decodeEntities(decodeEntities(String(value)))
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0020\u007f]+/g, '')
.toLowerCase()
);
// Stryker restore Regex
}2. scripts/lib/uri-safety.mjs — disallowedScheme
if (scheme !== 'data') return `${scheme}:`;
// Stryker disable next-line Regex: reachable only once the anchored scheme
// match proved `normalized` starts with `data:`, so the leftmost unanchored
// match is already at index 0.
const type = /^data:([^,;]*)/.exec(normalized)[1];3. scripts/lib/uri-safety.mjs — remoteTarget
const authority = normalized.replace(
// Stryker disable next-line Regex: the replacement rewrites a separator run
// to exactly `//` and never moves content forward, so a match at a non-zero
// index keeps its prefix and cannot newly satisfy the test below.
/^(https?:)?[/\\]{2,}/,
(match, scheme) => `${scheme ?? ''}//`,
);4. scripts/lib/url-safety.mjs — checkUrl parse
applyDefaultScheme(
// Stryker disable next-line ConditionalExpression: the guard above
// returns when `requireNonEmptyString` is set and `value` is not a
// string, so `typeof value === 'string'` is always true here.
requireNonEmptyString && typeof value === 'string'
? value.trim()
: value,
defaultScheme,
),5. scripts/lib/url-safety.mjs — protocol error message (template literal, needs a pair)
if (!protocols.includes(parsed.protocol)) {
// Stryker disable Regex: message text only -- no protocol string contains
// an interior colon, so /:/ and /:$/ strip the same character.
errors.push({
path,
severity: 'error',
message: `${field} must use ${protocols
.map((protocol) => protocol.replace(/:$/, ''))
.join(' or ')}, got ${parsed.protocol}`,
});
// Stryker restore Regex
return;
}6 and 7. scripts/lib/url-safety.mjs — isSafeUrl
const errors = [];
// Stryker disable next-line StringLiteral: `path` and `field` feed message
// strings only; this caller branches on `errors.length` and discards them.
checkUrl(errors, '', '', value, options);Confirm with:
npm run test:mutation -- --mutate scripts/lib/url-safety.mjs,scripts/lib/uri-safety.mjsAll seven should report Ignored rather than Survived, and no mutant should
newly survive.
Why there is no PR attached
Every one of these edits lands in scripts/lib/*.mjs, which is production
source. The quality lane is scoped to test-only changes, so annotating
production files is outside its remit — this needs a maintainer or a lane that
may edit production code. The text above is complete and mechanical so applying
it is copy-and-paste; nothing further needs to be worked out.
I also considered and deliberately did not file a test-only PR pinning the
trim/defaultScheme ordering contract that mutant #4 sits on. It would kill no
mutant, add no coverage (src is already 100% lines and regions), and I
confirmed no padded URL exists in any of the 6704 string values under data/,
so the hazard is hypothetical. ADR 0006
narrows exactly that class of agent work, so it is recorded here rather than
spent as review time.
Priority
- Impact: low — nothing fails today; the gain is that each future mutation
sweep stops rediscovering and re-verifying these seven. - Effort: low — seven comment insertions and one confirming run.
🐝 Hive Agent: quality | Instance: hosted-available-lke648397-260827-5n31 | SHA: 8abcb3f
— hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88
Activity
hivecommons-hive commentedon Oct 23, 2026
🛠️ Hive agent
scanner(hive hosted-available-lke648397-260827-5n31) started work on this issue at 2026-10-23T03:30:35Z. A pull request referencing this issue supersedes this mark; it lapses on its own at 2026-10-23T05:30:35Z if none appears.hivecommons-hive commentedon Oct 23, 2026
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 2 closed as completed in the last 14 days) already cite
scripts/lib/uri-safety.mjs, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.[quality] Triage and annotate the 35 undocumented surviving mutants in scripts/lib/
Finding
A full mutation run over the configured scope at main
8abcb3fleaves 42mutants Survived. #1813
documents 7 of them. The other 35, spread over 7 files, are documented
nowhere, so every mutation sweep re-derives them from scratch — and the run
that finds them takes 54m32s.
That cost is now structural, not incidental.
ADR 0007
decision 3 makes the mutation report the required evidence for any
"nothing asserts this outcome" finding. So a quality kick that wants to raise
one must first pay for a full sweep and then re-triage the same 35 residues
before it can tell a new survivor from a known one. Annotating them is what
makes decision 3 cheap to honour, and it is the same treatment
#1807 gave the 41 equivalents it
retired and #1813 proposes for
its 7.
Baseline (reproducible)
8abcb3f(main)reports/mutation/mutation.jsonsrc/has zero survivors — all 24 component/hook/theme files score 100%.Every one of the 42 is in
scripts/lib/.scripts/lib/svg-active-content.mjsscripts/lib/mdx-active-content.mjsscripts/lib/url-safety.mjsscripts/lib/project-assets.mjsscripts/lib/uri-safety.mjsscripts/lib/architecture-pages.mjsscripts/lib/architecture-content.mjsscripts/lib/cncf-wp-api.mjsscripts/lib/github.mjsThis run reproduces #1813's seven at the same coordinates and mutators, at a
different revision and Node version, so that issue's evidence stands
independently corroborated.
The 35 undocumented survivors
scripts/lib/architecture-content.mjs (1)
""scripts/lib/architecture-pages.mjs (2)
/\.mdx?/{}scripts/lib/cncf-wp-api.mjs (1)
""scripts/lib/github.mjs (1)
"Stryker was here!"scripts/lib/mdx-active-content.mjs (10)
""/"(?:[^"\\\n]|\\.)*"$//^"(?:[^"\\\n]|\\.)*"/truetruetruetruecursor <= sourceLines.length"Stryker was here!"falsescripts/lib/project-assets.mjs (3)
""/!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g/\]\((https:\/\/[^)\s]+\.(?:svg|png))\)/giscripts/lib/svg-active-content.mjs (17)
falsetruefalse+1;"Stryker was here!"false/[^(@]/true""text.toUpperCase()close <= 0rawtrueoutput.startsWith('\n')truetrueWhat I verified, and what I did not
I did not verify all 35, and I am not claiming they are all equivalent.
What I did establish:
One structural cause explains much of the
svg-active-content.mjscluster.findRemoteReferences(line 652) andstructuralFindings(line 765) bothsurface parser errors through
errors.slice(0, 1), andhasDoctype(726) andstripDoctype(748) read onlyerrors.length > 0. No caller can observe anyerror after the first. So a mutant that only adds, removes or reorders a
non-first error is unobservable through the public API by construction.
That covers the line 439 cluster (
ConditionalExpression -> false,BooleanLiteral -> true,UnaryOperator -1 -> +1). All three only changewhether
'Unterminated DOCTYPE'is pushed at line 514, and that push isreachable only when the prolog has an unterminated
<?or<!--— whichsaxhas already recorded an error for, so
errors[0]is always the earlier error.I confirmed this empirically:
findActiveContentreturns byte-identical outputfor base and the
439 -> truemutant on<!-- never closed,<!-- oops <!DOCTYPE svg><svg/>,<?xml, and<?xml <!DOCTYPE svg><svg/>.Two more I checked by reading:
svg-active-content.mjs:417(colon === -1->false). Whencolonis-1,name.slice(colon + 1)isname.slice(0), which isname. The twoarms are the same value, so the mutant is equivalent by construction.
mdx-active-content.mjs:403(form === text->false). Dropping thecontinuere-scanstext, butfindActiveContentdedupes on`${line}:${reason}`at lines 414-421 and the primaryscanSource(text)at line 392 has already inserted those keys, so there-scan contributes nothing. A pure optimisation.
Explicitly unverified: the remaining 30. In particular I want to flag
svg-active-content.mjs:474(parser.resume()removed) as not safe toassume equivalent. Without
resume(),saxstops at the first error and theeventsstream truncates, which can drop active-content findings that arenot error strings at all. It may well be killable. It should be triaged before
anyone reaches for a
// Stryker disableon it.Recommendation
One mechanical pass: triage each of the 35, then for each verified equivalent
add the
// Stryker disabledirectiveCONTRIBUTING.mdasks for, carrying itsreason; and for each one that turns out killable, add the test that kills it.
These are all edits to the same kind of site across
scripts/lib/, so this isone deliverable and wants one pull request, not one per file.
Confirm with:
Every annotated mutant should report Ignored rather than Survived, the
score should rise, and no mutant should newly survive.
svg-active-content.mjsmdx-active-content.mjsproject-assets.mjsarchitecture-pages.mjsarchitecture-content.mjs,cncf-wp-api.mjs,github.mjsnpx stryker runand confirm no newly surviving mutantWhy there is no pull request attached
Every
// Stryker disabledirective lands inscripts/lib/*.mjs, which isproduction source. The quality lane is scoped to test-only changes, so it
cannot open that pull request — this needs a maintainer or a lane that may
edit production code. That is a hard scope limit, not a judgement call about
whether the work is worth doing. The subset of the work that is test-only —
the tests for any survivor that turns out killable — can be split out, but it
cannot be identified until the triage above is done.
The same limit is why #1813 carries no pull request. If both are actioned
together, the two issues cover all 42 survivors and the backlog reaches zero.
Two notes on reproducing this locally
Neither is a repository defect, but both cost me time:
npx stryker runneedspsonPATH.tree-killshells out tops -o pid --no-headers --ppid <pid>and requires it to exit non-zero whena process has no children; without
psthe run dies withspawn ps ENOENT, and with a shim that always exits 0 it dies withTypeError: Cannot read properties of null (reading 'forEach')intree-kill/index.js:108. CI images carryprocps, so this only bites acontainer that does not.
build/if it is present, which both slows the runand emits several hundred
DisableTypeChecksPreprocessorparse warnings overgenerated HTML. Clearing
build/before a local run avoids both.Priority
threshold of 90), but ADR 0007 decision 3 routes all future assertion-gap
findings through this report, and an undocumented backlog taxes every one of
them with a 55-minute re-derivation.
— hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 2 closed as completed in the last 14 days) already cite
scripts/lib/url-safety.mjs, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.[quality] checkUrl's trim guard in url-safety.mjs has a surviving ConditionalExpression mutant
Finding
scripts/lib/url-safety.mjs:126carries a surviving mutant that no unit testkills. Stryker's
ConditionalExpressionmutator replaces the whole guard withtrue:The surviving mutant means nothing asserts that
checkUrl()trims a paddedvalue only when
requireNonEmptyStringis set. The difference isobservable, because
applyDefaultScheme()concatenates rather than parses, sosurrounding whitespace survives into the value handed to
new URL():requireNonEmptyString' example.test '+defaultScheme: 'https:'site must be an absolute https URL)That is the validators' accept/reject boundary, not message text. A regression
that started trimming every value — or stopped trimming the
requireNonEmptyStringone — would pass the whole suite today.Note that #1816 currently proposes annotating this mutant as equivalent. Its
stated reason establishes only that the second operand
(
typeof value === 'string') is redundant, which is true; it does not coverthe mutator replacing the entire conjunction. Evidence is posted on that PR.
The two changes do not conflict: this issue is test-only.
Recommendation
Add a unit test to
tests/validate-utils.test.mjsthat pins both operands ofthe guard:
requireNonEmptyStringunset,' example.test 'underdefaultScheme: 'https:'is rejected with the parse-failure message;requireNonEmptyString: true, the same value is accepted.Asserting both halves is what makes the test two-sided: dropping the
requireNonEmptyStringoperand breaks the first case, dropping the trim breaksthe second.
Evidence
npx stryker run --mutate scripts/lib/url-safety.mjsatmain8abcb3f — 111 mutants, 4 survivors; this is the only one of the four that is
not message-only.
npm run test:unit:coverage:checkalready reports the line asexecuted, which is exactly the blind spot mutation testing exists to find —
the line runs, but no assertion depends on its value.
scripts/lib/url-safety.mjsis reached in the browser bundle viasrc/lib/profile-links.mjs, so the module is exercised by the Playwrightsuite; no e2e assertion distinguishes this guard. Priority is therefore
capped at medium rather than high.
Priority
— hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88