Files
civitai__civitai/scripts/mutation/listing-problems-kind.mutation.py
Zachary Lowden b34d90fd1c fix(apps): make the listing-completeness advisory KIND-AWARE (on-site copy lives in block.manifest.json) (#4370)
* fix(apps): make the listing-completeness advisory KIND-AWARE

`computeListingProblems` was kind-blind. It emitted `empty-description`,
`empty-tagline` and `empty-category` with the label "Missing <field>" for
EVERY listing. On an OFF-SITE listing that is correct — the author typed that
copy into the submit wizard and can go and fix it. On an ON-SITE listing it is
wrong, not merely terse: those scalars have NO author surface other than
`block.manifest.json`, and `approveRequest`'s (3b-sync) MANIFEST-GOVERNED COPY
RE-SYNC (`publish-request.service.ts`, scoped `kind: 'onsite'`) re-derives them
from the manifest on every subsequent-version approve. An on-site author who
found some other way to set a tagline would have it reverted at the next
approve — so `/apps/mine` was telling them to do something that cannot work.

KEEP-WITH-CORRECTED-LABELS, not suppress. The gap is real (the store page
genuinely has no tagline), so hiding it would trade wrong advice for no advice;
and suppression would silently kill the on-site branch of a released CLI that
consumes these codes. The codes and severities are therefore KIND-INVARIANT and
only the three labels move.

`kind` is a REQUIRED input, which is what makes "a caller was missed" a compile
error rather than a whole surface keeping the old advice. All three callers are
threaded, each PROJECTING the column rather than restating what its own filter
implies:
  - appListings.listMine        (app-access.service)     — both kinds, one page
  - appListings.listMySubmissions (offsite-listing.service) — on-site media
    revisions appear here too, via its `{ kind: 'onsite' }` OR-branch
  - blocks.listMyPublishRequests (blocks.router)         — projects `kind` even
    though its `where` filters on it

The kind lookup is an explicit equality branch, not a `TABLE[kind] ?? default`:
`kind` is an untrusted cast off the `app_listings.kind` column, and indexing an
object literal with an inherited key (`'constructor'`) returns something truthy,
which `??` accepts and which then yields a problem with NO label. An
unrecognised kind degrades to the original labels and never throws.

No component change: `ListingProblemsIndicator` renders whatever `label` it is
given.

Tests: 12 regression cases RED at origin/main d345b654a2 / green at HEAD, plus
labelled invariant guards pinning the wire contract. 14/14 mutants killed, each
by its own guard's assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(apps): correct the empty-category DIAGNOSIS, and close three audit gaps

Follow-up to the adversarial audit of #4370. No change to the kind-awareness
itself; the wire contract is untouched (same 8 codes, same severities, off-site
labels byte-identical).

1) empty-category named the WRONG REMEDY on an on-site listing (MEDIUM).

   The old label — and the comment justifying it — argued that an on-site listing
   can only lack a category when `AppBlock.category` is null, so the manifest must
   be the fix. That reasoning skipped a reachable, DESIGNED state: the advisory
   reads `AppListing.category`, and `setMarketplaceMeta` (moderator curation)
   writes `AppBlock.category` ONLY — it never touches the listing row.

     author omits category   -> listing minted null
     moderator curates       -> AppBlock.category set, AppListing.category null
     -> advisory fires; author "sets it in the manifest"; (3a)'s null-gate does
        NOT fire, their value is DISCARDED, and (3b-sync) writes the moderator's.

   The problem firing is right (the store card genuinely shows none); the
   diagnosis was wrong. The remedy that always clears it is an approved new
   version — the manifest key matters only when no category is set anywhere. The
   label now leads with that and marks the manifest conditional, which is why it
   is deliberately asymmetric with description/tagline (for those two the manifest
   IS the whole remedy, re-derived on every sync).

   Enumerating this also turned up a THIRD writer of the column that the audit's
   "exactly two" missed: (submit-draft) in publish-request.service mints the
   pre-approval draft straight from the manifest, because no AppBlock exists yet.
   It does not rescue the old claim — it is a create, not a rewrite of an existing
   null — but the comment now lists all three.

2) A comment claimed a guard that did not exist (LOW-MEDIUM).

   `ListingProblemKind`'s docblock said assignability with `ListingKind` was
   pinned in listing-problems.kind.test.ts. That file held no such assertion. The
   hazard IS covered — by the listMine call site, under the ROOT typecheck — so
   the comment now names the real mechanism, and the test carries a belt-and-
   braces pin that says out loud it is only checked by the deliberate pass.

3) LISTING-vs-REQUEST kind was pinned only structurally.

   The audit's mutant reading `r.kind` instead of `r.appListing.kind` SURVIVED all
   132 tests, because every fixture set the two equal. Not a live hole, but the
   stated rationale is that they might diverge, so a fixture now makes them —
   both directions.

4) The fixture guard enumerated only factory output, not inline literals. It now
   scans this file's own source, with both controls (a planted bad literal it must
   flag, and a non-zero count of literals actually examined).

Battery committed (scripts/mutation/) with its results, so the claims are
auditable from artifacts rather than reconstructible from a table. Two mutants
added: M14 (the audit's survivor) and M15 (reverting the category diagnosis).

Matrix re-measured at origin/main 4bfd4c16d: 15 red at base / green at HEAD.
Mutation: 16 defined / 16 verdicts / 16 killed, every run at the 154 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:05:17 -05:00

216 lines
9.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Mutation battery for the KIND dimension of the listing-completeness advisory.
Committed so the PR's mutation claims are AUDITABLE FROM ARTIFACTS rather than
reconstructible only from a table in the PR body. Re-run it and diff the output.
scripts/mutation/listing-problems-kind.mutation.py # run the battery
scripts/mutation/listing-problems-kind.mutation.py --list # just print the mutants
Method, and why each part is there:
* Each mutant asserts its target text occurs EXACTLY ONCE before replacing. A
`count=1` replace on an ambiguous pattern hits an occurrence you did not picture.
* Each mutant is the NARROWEST expression that can be wrong. None removes a guard
together with its enclosing condition — that kind of mutant dies for the wrong
reason and proves nothing about the guard.
* BASELINE RECONCILIATION: the baseline total is measured at the start, and every
mutant's run must produce the SAME total. A run whose total differs was truncated
or panicked, so its verdict is UNATTRIBUTED — never SURVIVED. (A sibling battery
once scored a mutant SURVIVED off a run that died a quarter of the way in.)
* DEFINED vs VERDICTS-PRODUCED are reported separately, so a mutant that silently
failed to apply cannot be counted as coverage.
* M0 is a POSITIVE CONTROL: a mutant known to be caught, kept in the batch so a run
that has stopped executing anything cannot report a clean sweep.
Every mutation is reverted from a byte copy of the original in a `finally`, so an
interrupted run leaves the tree clean.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
RUNNER = ROOT / "scripts/mutation/listing-problems-kind.suite.sh"
RUN_LOG = ROOT / ".mutation-run.log"
LP = "src/server/services/blocks/listing-problems.ts"
AA = "src/server/services/blocks/app-access.service.ts"
BR = "src/server/routers/blocks.router.ts"
OS_ = "src/server/services/blocks/offsite-listing.service.ts"
CAT_ONSITE = (
"'Missing category — resubmit to apply it; "
"set \"category\" in block.manifest.json first if your app has none'"
)
# id, file, find, replace, what defect it expresses
MUTANTS: list[tuple[str, str, str, str, str]] = [
("M0-CTRL", LP,
"'empty-tagline': 'Missing tagline',",
"'empty-tagline': 'MUTANT-CONTROL',",
"POSITIVE CONTROL: off-site tagline label is garbage. Must die loudly."),
("M1", LP,
"listing.kind === 'onsite' ? TEXT_PROBLEM.onsite : TEXT_PROBLEM.offsite",
"listing.kind === 'offsite' ? TEXT_PROBLEM.onsite : TEXT_PROBLEM.offsite",
"the kind comparison names the WRONG literal (arms swapped)."),
("M2", LP,
"listing.kind === 'onsite' ? TEXT_PROBLEM.onsite : TEXT_PROBLEM.offsite",
"listing.kind === 'onsite' ? TEXT_PROBLEM.offsite : TEXT_PROBLEM.offsite",
"the ON-SITE arm collapses to off-site == THE ORIGINAL DEFECT, exactly."),
("M3", LP,
"listing.kind === 'onsite' ? TEXT_PROBLEM.onsite : TEXT_PROBLEM.offsite",
"listing.kind === 'onsite' ? TEXT_PROBLEM.onsite : TEXT_PROBLEM.onsite",
"the OFF-SITE arm gets manifest advice (the defect pointing the other way)."),
("M4", LP,
"'empty-tagline': 'Missing tagline — set \"tagline\" in block.manifest.json and resubmit',",
"'empty-tagline': 'Missing tagline',",
"ONE on-site label regresses to the original; the others stay correct."),
("M5", LP,
"'empty-description':\n 'Missing description — set \"description\" in block.manifest.json and resubmit',",
"'empty-description':\n 'Missing description — set \"tagline\" in block.manifest.json and resubmit',",
"an on-site label names the WRONG manifest key."),
("M6", LP,
"problems.push({ code: 'empty-tagline', label: text['empty-tagline'], severity: 'advisory' });",
"problems.push({ code: 'empty-tagline', label: text['empty-description'], severity: 'advisory' });",
"the label lookup uses the WRONG key at one push site."),
("M7", LP,
" if (isEmpty(listing.category))\n problems.push({ code: 'empty-category', label: text['empty-category'], severity: 'advisory' });",
" if (isEmpty(listing.category))\n problems.push({ code: 'empty-category', label: TEXT_PROBLEM.offsite['empty-category'], severity: 'advisory' });",
"one code stays kind-BLIND while the other two are fixed (a PARTIAL fix)."),
("M8", AA,
" kind,\n iconId: r.iconId ?? null,",
" kind: 'offsite' as typeof kind,\n iconId: r.iconId ?? null,",
"CALLER 1 (listMine) hardcodes offsite instead of threading the row's kind."),
("M9", AA,
" kind,\n iconId: r.iconId ?? null,",
" kind: 'onsite' as typeof kind,\n iconId: r.iconId ?? null,",
"CALLER 1 hardcodes onsite (the mirror of M8)."),
("M10", BR,
" kind: listing.kind as ListingProblemKind,",
" kind: 'onsite' as ListingProblemKind,",
"CALLER 2 spells its where-clause's conclusion instead of reading the column."),
("M11", BR,
" status: true,\n kind: true,\n iconId: true,",
" status: true,\n iconId: true,",
"CALLER 2 stops PROJECTING kind (the silent-revert route)."),
("M12", OS_,
" kind: (r.appListing.kind ?? 'offsite') as ListingProblemKind,",
" kind: 'offsite' as ListingProblemKind,",
"CALLER 3 hardcodes offsite — wrong for the on-site media revisions it returns."),
("M13", OS_,
" kind: true,\n iconId: true,\n coverId: true,",
" iconId: true,\n coverId: true,",
"CALLER 3 stops PROJECTING the listing's kind."),
# --- added after the #4370 adversarial audit ------------------------------
("M14", OS_,
" kind: (r.appListing.kind ?? 'offsite') as ListingProblemKind,",
" kind: (r.kind ?? 'offsite') as ListingProblemKind,",
"CALLER 3 reads the REQUEST's kind instead of the LISTING's. SURVIVED the "
"pre-audit battery, because every fixture set the two equal — the audit found "
"it. Killed now by the one fixture that makes them disagree."),
("M15", LP,
" 'empty-category':\n " + CAT_ONSITE + ",",
" 'empty-category': 'Missing category — set \"category\" in block.manifest.json and resubmit',",
"the on-site category label reverts to MANIFEST-FIRST — the wrong diagnosis the "
"audit caught: inert once a moderator has curated, because (3a)'s null-gate no "
"longer fires."),
]
def run() -> tuple[int | None, int | None, list[str]]:
p = subprocess.run(["bash", str(RUNNER), str(RUN_LOG)], capture_output=True, text=True)
out = p.stdout
m = re.search(
r"Tests\s+(?:(\d+) failed \| )?(\d+) passed(?: \| (\d+) skipped)? \((\d+)\)", out
)
if not m:
return None, None, ["<<UNPARSEABLE>>: " + out.strip()[:400]]
return int(m.group(1) or 0), int(m.group(4)), [l for l in out.splitlines() if l.startswith("× ")]
def main() -> int:
if "--list" in sys.argv:
for mid, rel, _f, _r, why in MUTANTS:
print(f"{mid:9} {rel.split('/')[-1]:28} {why}")
return 0
print("measuring baseline (unmutated tree)...", flush=True)
base_failed, baseline_total, _ = run()
if base_failed is None:
print("FATAL: could not parse the baseline run; is the suite runnable?")
return 2
if base_failed:
print(f"FATAL: baseline is RED ({base_failed} failing). Fix that before mutating.")
return 2
print(f"baseline: {baseline_total} tests, 0 failing\n", flush=True)
results = []
for mid, rel, find, repl, why in MUTANTS:
path = ROOT / rel
orig = path.read_text()
n = orig.count(find)
if n != 1:
results.append({"id": mid, "file": rel, "why": why,
"verdict": "NOT-APPLIED", "occurrences": n})
print(f"{mid}: NOT-APPLIED (target occurs {n}x, expected exactly 1)", flush=True)
continue
backup = path.with_suffix(path.suffix + ".mutbak")
shutil.copyfile(path, backup)
try:
path.write_text(orig.replace(find, repl, 1))
failed, total, names = run()
if failed is None:
verdict = "UNATTRIBUTED (unparseable run)"
elif total != baseline_total:
verdict = f"UNATTRIBUTED (total {total} != baseline {baseline_total})"
elif failed > 0:
verdict = "KILLED"
else:
verdict = "SURVIVED"
results.append({"id": mid, "file": rel, "why": why, "verdict": verdict,
"failed": failed, "total": total, "killers": names})
print(f"{mid}: {verdict} failed={failed} total={total}", flush=True)
for nm in names[:6]:
print(" " + nm, flush=True)
finally:
shutil.copyfile(backup, path)
backup.unlink()
(ROOT / "scripts/mutation/listing-problems-kind.results.json").write_text(
json.dumps({"baselineTotal": baseline_total, "results": results}, indent=2) + "\n"
)
RUN_LOG.unlink(missing_ok=True)
defined = len(MUTANTS)
produced = sum(1 for r in results if r.get("verdict") in ("KILLED", "SURVIVED"))
killed = sum(1 for r in results if r.get("verdict") == "KILLED")
survived = sum(1 for r in results if r.get("verdict") == "SURVIVED")
print(f"\nDEFINED={defined} VERDICTS-PRODUCED={produced} KILLED={killed} "
f"SURVIVED={survived} OTHER={defined - produced}")
return 0 if (produced == defined and killed == defined) else 1
if __name__ == "__main__":
sys.exit(main())