mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
b34d90fd1c
* 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>
216 lines
9.7 KiB
Python
Executable File
216 lines
9.7 KiB
Python
Executable File
#!/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())
|