Skip to content

[quality] Annotate the 7 verified-equivalent mutants in uri-safety.mjs and url-safety.mjs #1813

Closed

Description

@hivecommons-hive

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.mjs

All 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

  1. added
    testingApproved by a Hive merger/owner for auto-merge on green CI
    agent/qualityApproved by a Hive merger/owner for auto-merge on green CI
    stage/010-actionableTriaged; eligible for the hive to pick up
    stage/020-claimedClaimed by the scanner or a relay contributor
    and removed
    stage/010-actionableTriaged; eligible for the hive to pick up
    on Oct 23, 2026
  2. hivecommons-hive commented on Oct 23, 2026

    @hivecommons-hive
    ContributorAuthor

    🛠️ 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.

  3. hivecommons-hive commented on Oct 23, 2026

    @hivecommons-hive
    ContributorAuthor

    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 8abcb3f leaves 42
    mutants 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)

    npx stryker run     # TZ=UTC, config scope: src/**/*.{js,mjs} + scripts/lib/**/*.mjs
    
    Revision 8abcb3f (main)
    Node v26.12.0
    Mutants instrumented 4482 (57 files)
    Mutants tested 4211
    Mutation score 98.99% (break threshold 90)
    Survived 42
    Timeout 9
    Duration 54m32s
    Report reports/mutation/mutation.json

    src/ has zero survivors — all 24 component/hook/theme files score 100%.
    Every one of the 42 is in scripts/lib/.

    File Survived Documented by
    scripts/lib/svg-active-content.mjs 17 —
    scripts/lib/mdx-active-content.mjs 10 —
    scripts/lib/url-safety.mjs 4 #1813
    scripts/lib/project-assets.mjs 3 —
    scripts/lib/uri-safety.mjs 3 #1813
    scripts/lib/architecture-pages.mjs 2 —
    scripts/lib/architecture-content.mjs 1 —
    scripts/lib/cncf-wp-api.mjs 1 —
    scripts/lib/github.mjs 1 —

    This 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)

    Mutator Location Replacement
    StringLiteral 230:44-230:50 ""

    scripts/lib/architecture-pages.mjs (2)

    Mutator Location Replacement
    Regex 44:55-44:64 /\.mdx?/
    BlockStatement 78:11-80:4 {}

    scripts/lib/cncf-wp-api.mjs (1)

    Mutator Location Replacement
    StringLiteral 32:74-32:77 ""

    scripts/lib/github.mjs (1)

    Mutator Location Replacement
    StringLiteral 61:50-61:52 "Stryker was here!"

    scripts/lib/mdx-active-content.mjs (10)

    Mutator Location Replacement
    StringLiteral 108:3-108:13 ""
    Regex 164:35-164:58 /"(?:[^"\\\n]|\\.)*"$/
    Regex 164:35-164:58 /^"(?:[^"\\\n]|\\.)*"/
    ConditionalExpression 282:11-282:36 true
    ConditionalExpression 298:13-298:30 true
    ConditionalExpression 304:18-304:42 true
    ConditionalExpression 344:12-344:39 true
    EqualityOperator 344:12-344:39 cursor <= sourceLines.length
    StringLiteral 391:35-391:37 "Stryker was here!"
    ConditionalExpression 403:9-403:22 false

    scripts/lib/project-assets.mjs (3)

    Mutator Location Replacement
    StringLiteral 88:22-88:25 ""
    Regex 121:5-121:43 /!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g
    Regex 126:5-126:48 /\]\((https:\/\/[^)\s]+\.(?:svg|png))\)/gi

    scripts/lib/svg-active-content.mjs (17)

    Mutator Location Replacement
    ConditionalExpression 417:11-417:23 false
    BooleanLiteral 439:28-439:33 true
    ConditionalExpression 439:9-439:19 false
    UnaryOperator 439:17-439:19 +1
    CallExpression 474:5-474:21 ;
    StringLiteral 534:33-534:35 "Stryker was here!"
    ConditionalExpression 572:7-572:24 false
    Regex 572:8-572:14 /[^(@]/
    ConditionalExpression 599:9-599:38 true
    StringLiteral 599:32-599:38 ""
    MethodExpression 599:9-599:27 text.toUpperCase()
    EqualityOperator 605:11-605:20 close <= 0
    MethodExpression 607:31-607:41 raw
    ConditionalExpression 620:20-620:52 true
    MethodExpression 1018:55-1018:76 output.startsWith('\n')
    ConditionalExpression 1026:11-1026:33 true
    ConditionalExpression 1065:16-1065:35 true

    What 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.mjs cluster.
    findRemoteReferences (line 652) and structuralFindings (line 765) both
    surface parser errors through errors.slice(0, 1), and hasDoctype (726) and
    stripDoctype (748) read only errors.length > 0. No caller can observe any
    error 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 change
    whether 'Unterminated DOCTYPE' is pushed at line 514, and that push is
    reachable only when the prolog has an unterminated <? or <!-- — which sax
    has already recorded an error for, so errors[0] is always the earlier error.
    I confirmed this empirically: findActiveContent returns byte-identical output
    for base and the 439 -> true mutant 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). When colon is
      -1, name.slice(colon + 1) is name.slice(0), which is name. The two
      arms are the same value, so the mutant is equivalent by construction.
    • mdx-active-content.mjs:403 (form === text -> false). Dropping the
      continue re-scans text, but findActiveContent dedupes on
      `${line}:${reason}` at lines 414-421 and the primary
      scanSource(text) at line 392 has already inserted those keys, so the
      re-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 to
    assume equivalent. Without resume(), sax stops at the first error and the
    events stream truncates, which can drop active-content findings that are
    not error strings at all. It may well be killable. It should be triaged before
    anyone reaches for a // Stryker disable on it.

    Recommendation

    One mechanical pass: triage each of the 35, then for each verified equivalent
    add the // Stryker disable directive CONTRIBUTING.md asks for, carrying its
    reason; 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 is
    one deliverable and wants one pull request, not one per file.

    Confirm with:

    npx stryker run

    Every annotated mutant should report Ignored rather than Survived, the
    score should rise, and no mutant should newly survive.

    • Triage the 17 in svg-active-content.mjs
    • Triage the 10 in mdx-active-content.mjs
    • Triage the 3 in project-assets.mjs
    • Triage the 2 in architecture-pages.mjs
    • Triage the 1 each in architecture-content.mjs, cncf-wp-api.mjs, github.mjs
    • Re-run npx stryker run and confirm no newly surviving mutant

    Why there is no pull request attached

    Every // Stryker disable directive lands in scripts/lib/*.mjs, which is
    production 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:

    1. npx stryker run needs ps on PATH. tree-kill shells out to
      ps -o pid --no-headers --ppid <pid> and requires it to exit non-zero when
      a process has no children; without ps the run dies with
      spawn ps ENOENT, and with a shim that always exits 0 it dies with
      TypeError: Cannot read properties of null (reading 'forEach') in
      tree-kill/index.js:108. CI images carry procps, so this only bites a
      container that does not.
    2. Stryker's sandbox copies build/ if it is present, which both slows the run
      and emits several hundred DisableTypeChecksPreprocessor parse warnings over
      generated HTML. Clearing build/ before a local run avoids both.

    Priority

    • Impact: medium — nothing fails today (98.99% is well above the break
      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.
    • Effort: medium — 35 triages, most of them short, plus one confirming run.

    — 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:126 carries a surviving mutant that no unit test
    kills. Stryker's ConditionalExpression mutator replaces the whole guard with
    true:

    -        requireNonEmptyString && typeof value === 'string'
    +        true
               ? value.trim()
               : value,

    The surviving mutant means nothing asserts that checkUrl() trims a padded
    value only when requireNonEmptyString is set. The difference is
    observable, because applyDefaultScheme() concatenates rather than parses, so
    surrounding whitespace survives into the value handed to new URL():

    input requireNonEmptyString unmutated mutated
    ' example.test ' + defaultScheme: 'https:' unset rejected (site must be an absolute https URL) accepted

    That is the validators' accept/reject boundary, not message text. A regression
    that started trimming every value — or stopped trimming the
    requireNonEmptyString one — 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 cover
    the 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.mjs that pins both operands of
    the guard:

    • with requireNonEmptyString unset, ' example.test ' under
      defaultScheme: 'https:' is rejected with the parse-failure message;
    • with requireNonEmptyString: true, the same value is accepted.

    Asserting both halves is what makes the test two-sided: dropping the
    requireNonEmptyString operand breaks the first case, dropping the trim breaks
    the second.

    Evidence

    • Unit: npx stryker run --mutate scripts/lib/url-safety.mjs at main
      8abcb3f — 111 mutants, 4 survivors; this is the only one of the four that is
      not message-only.
    • Unit coverage: npm run test:unit:coverage:check already reports the line as
      executed, which is exactly the blind spot mutation testing exists to find —
      the line runs, but no assertion depends on its value.
    • End-to-end: scripts/lib/url-safety.mjs is reached in the browser bundle via
      src/lib/profile-links.mjs, so the module is exercised by the Playwright
      suite; no e2e assertion distinguishes this guard. Priority is therefore
      capped at medium rather than high.

    Priority

    • Impact: medium
    • Effort: low

    — hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/qualityApproved by a Hive merger/owner for auto-merge on green CIhive/claimed-by-scannertestingApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions