Skip to content

S96 — make a music scan tell the truth about itself#568

Merged
detain merged 22 commits into
masterfrom
s96-music-scan-observability
Jul 26, 2026
Merged

S96 — make a music scan tell the truth about itself#568
detain merged 22 commits into
masterfrom
s96-music-scan-observability

Conversation

@detain

@detain detain commented Jul 26, 2026

Copy link
Copy Markdown
Owner

S96 — make a music scan tell the truth about itself

Observability for the music scan path: severity, log volume, the stale worker header, lost-track
accounting, and the durable-progress fields a caller can actually trust. Builds on S95's two-phase
deferred write (project_music_scan_hang_fixed context:
a scan completed end-to-end for the first time on 2026-07-25, 61135/61135).

33 files, +5518/−312. Production changes are confined to src/Media/{Library,Music}; the rest is tests.

Highlights

  • MusicLibraryScanner (+745) — truthful per-run accounting: $handled remainder, charging lost
    tracks rather than silently dropping them, per-album (not per-track) log volume, and one
    temp-dir logger instead of two.
  • ScanResult (+73) / ScanJobRepository (+141) — durable progress fields and the backfill's
    IS NULL guard pinned on the arm real MySQL actually returns.
  • MusicLibraryType / MusicLibraryService / MediaScanner / the library managers — severity and
    durationMs corrections, including a durationMs pin against mixed-unit timing.

Verification

gate result
PHPUnit (full, fresh container) Tests: 7534, Assertions: 57601, Skipped: 7exit 0
PHPStan (phpstan.neon.dist) [OK] No errors — exit 0
PHPCS (PSR-12, src/) 0 errors; only the 2 pre-existing LibraryMetadataMatcher.php warnings
php -l clean
Coverage on new src/ lines 273/280 = 97.50% (100% excluding 7 @covers-discarded lines)

Tests: 7534 = master ffc41739's 7480 + 54 added. merge-tree --write-tree against master is clean
(0 conflicts).

Review history

Ten review→fix rounds (r1–r10); every finding fixed, including low and informational. Two lessons
recorded from the sequence rather than left implicit:

  • The affectedOn meta-pin's evasion list is deliberately non-exhaustive, and says so. Its job is to
    make accidental drift and plausible refactors loud, not to be tamper-proof. Rounds 7–9 each found a
    new way to defeat a token-level check, which is unbounded; the block now states the invariant it
    enforces and lists known evasions by class rather than by spelling.
  • Assertion 5 is the trait backstop. Assertions 4/6/7 are scoped to the class's own line range;
    assertion 5 sums over the methods the class declares, and a flattened trait method's range resolves
    into the trait — which is why it catches trait placement independently of assertion 3. getMethods()
    reporting a trait-flattened method as declared by the using class was verified empirically on 8.3.6.

⚠️ For whoever reads this file next: assertion line numbers shifted in the final commit —
A3 :2865:2873, A4 :2876:2888, A5 :2888:2900. A4's new number equals A5's old one, so
cite the assertion by name, not just by line.

🤖 Generated with Claude Code

detain and others added 22 commits July 25, 2026 10:18
A music scan could not be diagnosed from outside the process, which is why the
empty Music library survived four wrong diagnoses. Every fact in the final
production investigation had to be reverse-engineered from
`music_artists.created_at` timestamps.

(a) `MusicLibraryScanner::createLogger()` mkdir'd a `sys_get_temp_dir()`
    directory per construction and logged into it, so every line landed inside
    the systemd unit's `PrivateTmp` — unreadable without nsenter, destroyed on
    restart — and one directory leaked per instantiation (66 on prod, 6,346 on
    the dev box). It also accepted ONLY a `StructuredLogger` and discarded any
    other PSR-3 logger, while `MediaServicesProvider` passed none at all, so the
    "fallback" was the only live path. The logger property is now PSR-3, the
    fallback is the shared MEDIA channel (`.logs/app.log`), and the container
    names the `logger.media` parameter.

(b) `items_added` was never written for a scan/rescan job: the sink wrote only
    `items_found`/`items_updated`/`current_path`, and `markCompleted()`'s
    `$finalCounts` parameter had no caller. The scanner now passes its live
    `added`/`updated`/`failed` snapshot as the progress sink's 4th argument
    (folded into the UPDATE the sink already throttled — no extra statement),
    `LibraryManager::scanLibrary()` returns a `ScanResult`, `MediaScanner::scan()`
    returns its added count, and the worker stamps the final values. A rescan
    also stops discarding its prune count. `items_updated` deliberately keeps
    meaning "processed files" — it is the UI's progress numerator.

(c) `reapStaleJobs()` fails EVERY `running` row. Documented rather than
    age-guarded: this is the only call site and it runs once at boot, so an age
    guard would leave a young orphan `running` forever (the very hang it exists
    to fix), and a periodic reaper would kill legitimate multi-hour scans (prod
    took 4 h 09 m to its first durable write). `config/process.php`'s claim that
    running both spawn paths at once is "SAFE" was true of `claimNext()` and
    false of the reaper; it is corrected, and a config test now fails if
    `library-scan` count leaves 1.

(d) NOT de-duplicated. `countAudioFiles()` is only free to drop if the progress
    denominator or S95's bounded memory is given up; see the worklog.

(e) A `music_artists`/`music_albums` row with a NULL `media_item_id` (one failed
    `createMediaItem()`, which swallows its Throwable and returns '') stayed NULL
    forever — the natural-key branch returned the stored NULL and short-circuited
    before the adoption lookup, measured still NULL after two clean rescans, and
    `NULL UNIQUE` means nothing fails loudly. It is now backfilled, adopting a
    committed orphan when one exists, which is also the only route by which that
    residue case was ever reclaimable.

(f) `ScanResult` gains `failed`, and `library_scan_jobs` gains `items_failed`
    (migration 095). A partially-failed scan reported clean success everywhere:
    S95's `finally` made the counts self-consistent, removing the last DB-visible
    trace. Policy skips (the unknown-artist rule) are NOT counted as failures —
    they are tallied in the scan-completion log line instead, once per scan.

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

The outer catch in flushAlbum() must charge only the files the track loop never
reached; the existing log-write-failure fixture is the only shape where some
files are already accounted for when it fires, so the discriminating assertions
belong there. Also pins that the S96(e) backfill UPDATE carries
`AND media_item_id IS NULL` — the column is UNIQUE, so clobbering a concurrent
writer's id would point two rows at one media_item and lose an album later.

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

has() cannot catch a missing constructorParameter — the entry exists either way —
so this asserts the PHP-DI definition itself. PHP-DI skips defaulted optional
constructor params, which is exactly what sent every music-scan log line into the
unit's PrivateTmp.

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

countAudioFiles() costs 0.0069 ms/file (median of 5 runs, warm 10,000-file tree,
PHP 8.3.6) = ~0.4 s for the 61,135-file production path, against a scan that took
4 h 09 m to its first durable write. Every way to remove the second walk costs
more than it saves: materialising the file list undoes S95's bounded memory
(1,463 B/entry, ~89 MB there), dropping the denominator removes the admin
progress percentage, and caching a previous count is stale and useless on a
first scan.

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

Fix round r1 for S96: all 11 review findings (1 HIGH, 2 MED, 4 LOW, 4 INFO).

HIGH-1 — scripts/run-library-scan-worker.php still told the operator that running
it alongside the start.php-embedded worker "is safe", the same false claim the
step corrected in three other places, in the one document read immediately
before doing the unsafe thing. (c)'s entire mitigation is documentation, so this
defeated it. Corrected, the reaper named as the reason, and pinned by a new
ManagedWorkersConfigTest guard over BOTH spawner documents.

MED-2 — severity inversion: losing a whole album logged at `warning` while
losing one file logged at `error`, and config/logger.php routes only
error-and-above into .logs/error.log, so the quietest signal got a clean file
and the loudest three were buried. Level now tracks data loss: the artist/album
upsert failures, the per-path summary and LibraryManager's per-library summary
all log at `error`; policy-only unknown-artist skips stay `warning`. The test
doubles now record the level (they discarded it), so the ladder is pinned.

MED-3 — the (a) fix made per-entity debug lines the dominant content of
app.log. Dropped the per-TRACK "Upserted track" line: ~89% of the volume
(measured 61,135 of 68,247 lines/scan), and its only question — "is the scan
writing?" — is now answered authoritatively by items_added. Per-artist/album
lines stay: bounded by album count, and the album is S95's flush unit. Prod
projection 68,247 lines/11.25 MiB -> 7,112 lines/1.08 MiB per scan. The suite
also stopped writing into the working tree's .logs/ (MusicLibraryScannerTest
injects a NullLogger; the production fallback is pinned by its own identity
test): 27,094 lines/4.33 MiB -> 39 lines/6.6 KiB per run.

LOW-4 — a rescan job row carried two definitions of items_added over its
lifetime and could move backwards. markCompleted() now writes the three
cumulative tallies through GREATEST(), so the row is a high-water mark; the
progress denominator/numerator stay absolute. Real-MySQL test, since a mock
cannot evaluate GREATEST.
LOW-5 — MusicLibraryService::scanDirectory()'s docblock still documented the
3-argument sink the 4th argument crosses.
LOW-6 — AudiobookLibraryManager::rescanLibrary() counted per-file failures and
discarded the number, reporting a false clean `failed: 0`.
LOW-7 — documented why a job that dies mid-scan stamps no final counters, and
what closing it for the non-music scanners would take.
INFO-8 — migration 095's semicolon caveat described a runner limitation that no
longer exists.
INFO-9 — ScanResult::toArray()'s consumer list claimed the Arr sync response,
which serialises a different result object.
INFO-10 — `failed` reached the JSON but nothing rendered it; `library:scan` had
the whole ScanResult in hand and discarded it, so it now prints the counters.
The phlix-ui ScanJob interface stays a follow-up (S110 is live in that repo).
INFO-11 — was the orchestrator's stray worklog, already removed; tree is clean.

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

Without this the MED-3 fix is a comment: re-adding a per-track debug line
restores ~89% of the app.log volume, and deleting the per-album lines would
leave a successful scan with no write trace at any level. Both halves asserted.

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

Fix round r2 for S96: all actionable review r2 findings (1 HIGH, 1 MED, 4 LOW,
2 INFO; F8/F10/F11 are record-only and filed as steps).

F1 HIGH — a lost track was charged to nothing and the scan closed at INFO.
upsertTrack() returned 'skipped' both for a LOST file (createMediaItem()
swallowed its own Throwable and returned '', or the INSERT reported writing
nothing) and for a BENIGN unchanged one, so flushAlbum() charged neither. A scan
that lost five files was byte-identical to a rescan of an unchanged library:
`scanned=5 added=0 updated=0 failed=0`, summary INFO, items_failed 0, and on the
"wrote nothing" shape no log line at any level — which is precisely what
migration 095 exists to make impossible. The return is now split 'skipped' vs
'failed'; a 'failed' track costs exactly one file and logs its PATH at error
(createMediaItem()'s own line carries no path). Re-measured, all six of the
reviewer's scenarios: S2 0→1 charged, S3 0→5 charged with 5 error lines, S4/S5
unchanged, S1/S6 still clean.

Resolved the self-contradiction this exposed. Measured contract on real MySQL
8.0.46: an INSERT returns lastInsertId() as a string, returns NULL when it wrote
0 rows, and THROWS PDOException on any real error. So flushAlbum()'s "the DB
layer throws on error" was right, and the three `=== false` branches were wrong
twice over — unreachable for this client, and blind to the one falsy value it
does return. All four sites now go through statementWroteNothing(), which also
documents why `if (!$result)` would be a bug: a successful media_items INSERT
returns the string '0' (UUID PK, no AUTO_INCREMENT).

F2 MED — MusicLibraryManager::createDefaultLogger() was a SECOND, unfixed copy
of the S96(a) defect: mkdir(sys_get_temp_dir() . '/phlix_music_' . uniqid()) per
construction, matching the step's own acceptance-criterion glob, so "no new
/tmp/phlix_music_* dirs" was met only for the copy S96(a) looked at. Measured
+11 dirs per suite run before, +0 after. Both halves fixed: the shared MEDIA
logger, and ?LoggerInterface so MusicLibraryType::getLibraryManager() stops
discarding any other PSR-3 logger. getScanner()'s narrowing stays and says why
(MediaScanner's ctor). Pre-existing leaked dirs left in place deliberately.

F3 LOW — the r1 fix for the toArray() consumer list was also wrong: library:scan
reads PROPERTIES and never calls toArray(). Third version names the single real
consumer and the grep that establishes it.
F4 LOW — MED-3's rationale cited items_added/current_path, which the two
sink-less entry points do not have; scoped to the paths where it holds and stated
what the other two leave behind (one album ≈ 8.6 files).
F5 LOW — items_removed dropped from MONOTONIC_FINAL_COLUMNS: no path ever gives
it a prior value, so its clamp could not fire. items_added stays, with the
arithmetic showing the final delta EQUALS newRows and the live value is a lower
bound on it, so GREATEST is a retraction guard and not a max of rival metrics.
NULL cannot bite (all five columns int unsigned NOT NULL DEFAULT 0, verified).
F6 LOW — microtime(true) → hrtime(true) for every elapsed interval feeding
ScanResult::durationMs (audiobook, music, book, photo, base manager).
F7 INFO — no caller of library:scan exists (no unit, cron, docker entrypoint,
supervisord or CI job), so a lossy scan now exits 2 and warns on STDERR.
F9 INFO — the branch's 2 new phpcs errors in tests/ are gone: the snake_case
names renamed, and the bespoke PSR-3 double replaced by an interface mock. Every
touched test file is back to its master error count.

Test delta against master ffc4173 (7480): 7520, i.e. +40, reconciling to 40
added test* methods.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hrtime() switch had no coverage at all: mutating one of the two timing lines
back to microtime() left the whole audiobook suite green. A clock going backwards
cannot be tested without moving the system clock, but the realistic regression
(editing one line and not the other, mixing nanoseconds with seconds) can — the
mutant now fails with durationMs = -88274223122786272.

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

Review r2 F9 named 2 new errors; a per-file sweep against ffc4173 found 4, and
characterised them slightly wrong (only 2 of the 4 are "Method name"):

- ManagedWorkersConfigTest       11 -> 9  two snake_case names renamed
- MediaServicesProviderTest      10 -> 9  one snake_case name renamed (not in the
                                          finding; same class of defect)
- MusicScanMediaItemBackfillIntegrationTest 1 -> 0  BackfillTaggedScanner moved to
                                          its own file, following the existing
                                          precedent for helper classes under tests/
- MusicLibraryScannerTest         8 -> 7  the bespoke RecordingPsrLogger double
                                          dropped for a LoggerInterface mock (done
                                          in the previous commit)

Every file the branch touches is now at its master error count, and both files the
branch adds are at 0. The 7 remaining in MusicLibraryScannerTest and the 9 each in
the two config/provider tests are pre-existing and belong to S128.

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

Review r3 measured two fixes that could not fail, plus five doc/contract defects.

MED-1 — the HIGH fix's `null` arm was pinned by nothing. `statementWroteNothing()`
is strict, but every test that made an INSERT report "wrote nothing" went through
`MusicSchemaConnection::returnFalseFor()`, which returns literal `false`, while
`runInsert()` returned `int 1`. Deleting `|| $result === null` left
scanner+library+command+integration at `OK (1113 tests, 10122 assertions)` —
byte-identical to baseline. The doubles still modelled the OLD, unreachable
`=== false` contract the fix had replaced. So the double now models the measured
one: `returnNullFor()` for "wrote nothing", and `runInsert()` returns the insert id
as a STRING — `'0'` for `media_items` (CHAR(36) UUID PK, no AUTO_INCREMENT), which
is FALSY, so the docblock's "do not simplify to `!$result`" warning is now
falsifiable off a real-MySQL box too. Both falsy arms and the `'0'` success are
asserted individually via reflection, and the production consequence is driven
end-to-end on all four INSERTs the scanner branches on.

MED-2 — F6 changed the `hrtime()` pair in five managers and guarded ONE. Mutating
the end timestamp back to `microtime(true)` in `BookLibraryManager`,
`MusicLibraryManager`, `LibraryManager` and `PhotoLibraryManager` left the FULL
suite with zero `durationMs` failures, and `MusicLibraryManagerTest`'s
`assertIsInt($result->durationMs)` could not fail at all (`public int` + `(int)`
cast). All four now pin BOTH bounds — a one-sided `assertLessThan()` passes for the
negative number the mixed-unit bug actually produces.

Also:
- `backfillMusicMediaItemId()` used `$affected === false`, so a `null` fell through
  to `$referenced = true` and logged "Backfilled a NULL media_item_id" at `info`
  for a row still NULL — the inverse of the r2 HIGH, in the same file. Now on
  `statementWroteNothing()`, pinned by a `returnNullFor()` scenario.
- `EXIT_FILES_LOST` 2 -> 3: 2 is `Symfony\...\Command::INVALID`, the framework's
  "invalid usage", i.e. exactly the meaning this code exists to be distinguished
  from. The test now asserts the value AND the non-collision. No consumer existed.
- `LibraryScanWorker`'s "these three counters through GREATEST()" was made false by
  this branch's own F5 (`items_removed` left MONOTONIC_FINAL_COLUMNS); corrected.
- `ScanJobRepository::COUNTER_COLUMNS`, the single authority for `items_added`:
  "new TRACK rows" -> "new track `media_items` rows", with the one shape where
  those differ spelled out.
- `MusicLibraryType`'s "~10 copies … recorded as a follow-up" pointed at a step
  that did not exist; it is 23 classes and the step is S132.

Gates on dev PHP 8.3.6 (prod is 8.5; CI pins 8.3 — no gate here measures 8.5):
phpunit `Tests: 7523` = 7480 (master ffc4173) + 43 added test methods, 0 failures,
own throwaway mysql:8.0.46 on port 34433 (--network host, 3306 never bound);
phpstan L9 `[OK] No errors`; phpcs PSR12 src/ 0 errors + only the 2 pre-existing
`LibraryMetadataMatcher` warnings; every touched test file at its base phpcs count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… five (S96 r3 INFO-6)

`statementWroteNothing()`'s docblock said the pre-fix file had "three call sites"
branching on `=== false`. Measured at master `ffc41739` there were FIVE, in FOUR
methods: `upsertArtist()` (:1083), `upsertAlbum()` (:1206), `upsertTrack()` twice
(:1324, :1341) and `createMediaItem()` (:1712) — and all five are on the helper now,
so the sweep is complete and only the count was wrong. Review r3 finding 6 raised
the same miscount in the Fix r2 worklog prose ("all four"); this is the copy a
reader actually meets, and it is this file's third doc-accuracy finding, so the
number is now the measured one with the sites named.

Comment-only. Gates on dev PHP 8.3.6: php -l clean, phpcs PSR12 0 issues on the
file, phpstan L9 [OK] No errors, MusicLibraryScannerTest OK (49 tests, 419
assertions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review r4 (MED, single finding): the r3 round's own new test armed the
scanner double with `null` for the backfill UPDATE and claimed in a comment
that this is "the way the real client does" it. That comment was false, and
the state it modelled cannot occur.

Measured again here against real MySQL 8.0.46 through PhlixMySQLConnection,
using the scanner's verbatim statement
`UPDATE music_artists SET media_item_id = ? WHERE id = ? AND media_item_id IS NULL`:

  guard-excluded row  -> int 0      re-run of the same statement -> int 0
  healable row        -> int 1      matched but value unchanged  -> int 0
  unknown column      -> THROWS     null                         -> never

`Connection::query()` returns `rowCount()` whenever the leading keyword is
`update` (vendor/workerman/mysql/src/Connection.php:1859-1860, after trim()
at :1835), so `null` is unreachable for this statement. Consequence: the
only production-reachable arm of the guard at MusicLibraryScanner.php:1644,
`is_int($affected) && $affected < 1`, was pinned by nothing — dropping it
left the FULL suite byte-identical to baseline, assertion count included.

The production predicate is CORRECT and unchanged. What changes:

* MusicSchemaConnection::returnAffectedRowsFor($needle, $affected) — the
  measured UPDATE/DELETE/REPLACE arm, returning before the table handlers so
  the in-memory row is not mutated either. It REFUSES an INSERT/SELECT/SHOW
  needle, so the same defect cannot be written in the opposite direction.
* testABackfillUpdateThatWroteNothingIsNotReportedAsHealed() now has two
  scenarios: (A) the measured `int 0`, which is what production takes, and
  (B) the `null` arm kept and relabelled honestly as defence-in-depth
  against a bare createMock(Connection::class) and the keyword-miss branch
  at Connection.php:1866 — not as real-client behaviour.
* testTheSchemaDoubleModelsTheClientsPerKeywordReturnDomain() asserts the
  per-keyword mapping (select/show -> list, insert -> string|null,
  update/delete/replace -> int) directly, because the return domain belongs
  to the STATEMENT KEYWORD and not to the client. That over-generalisation
  is what produced this finding.
* The guard site and statementWroteNothing()'s docblock now record which
  arm is reachable, which test pins each one, and that five of the helper's
  six call sites are inserts while the sixth is this UPDATE.

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

Review r5 returned 3 LOW + 4 INFO on S96, all of them claims that overstated
what was verified. This closes them.

LOW-1 — the double contradicted two rows of the four-row table fix r4 cited it
as pinning: `SHOW` returned `int 1` (no `show` arm, fell through to
`runUpdate()`) where the client returns an array, and `TRUNCATE`/`SET` returned
`int 1` where the client returns `null`. `MusicSchemaConnection` now derives the
keyword exactly as the driver does (`keywordOf()` mirrors
`trim()`/`explode(" ")`/`strtolower()`) and dispatches on all four rows,
`default` included. The fidelity test asserts every row it claims to pin —
select/show/insert/update/delete/replace/anything-else — which also closes the
`DELETE`-regresses-to-null and `SHOW` holes r5 drove through the meta-pin.

LOW-2 — the arm-time refusal read only the first `" \t\n"`-delimited token while
the needle is matched with `str_contains()`, so r5 measured four bypasses, one
of which armed an INSERT with `int 0`. The refusal now matches the way the
dispatcher matches: the needle must not mention insert/select/show and must
mention update/delete/replace. Its honest residue (a keyword inside an
identifier, `INTO metadata_updates`) is closed one layer down by
`keywordFaithful()`, a dispatch-time check that sees the statement rather than
the needle — so the unproducible shape cannot be returned however it was armed.
`query()` now has exactly one `return`, funnelling every arm through that check,
and the single-return structure is itself pinned, which closes r5's
new-arm-outside-the-guarded-helper defeat.

LOW-3 — comment-only, and the src half of this commit is comment-only
(token-stream verified): "`null` is unreachable for an UPDATE" was false.
`Connection.php:1854` splits on spaces only, so a reformatted UPDATE
(`"UPDATE\nmusic_artists …"`, a tab, a leading block comment) returns `null` on
real MySQL. Both guard halves are load-bearing and both comments now say why.

INFO — the unexercised `?: ''` fallback is gone with the rewrite; both refusal
messages are now distinguishable and pinned; the audit's "13 arms" is corrected
to 19 (13 return-value + 6 fault arms) where `faultOnNth()` is declared.

Production predicate at the backfill guard unchanged.

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

The scenario labels and the assertion message inside
testABackfillUpdateThatWroteNothingIsNotReportedAsHealed() still carried the
claim review r5 disproved — "the ONLY half a real UPDATE can trip" and "`null`
is unreachable for this statement". `Connection.php:1854` splits with
`explode(" ", …)`, so the real client returns `null` for an UPDATE whose keyword
is followed by a newline, a tab, or a leading block comment (measured on real
MySQL 8.0.46 at r5). Both arms are load-bearing; the messages a future round
will read now say so, and the stale `MusicLibraryScanner.php:1644` citation is
replaced by the method name.

Assertions and behaviour unchanged — message text only.

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

Review r6 LOW-1: the arm-time refusal `returnAffectedRowsFor()` grew in r4 and
r5 was refusing legitimate needles — `'SET a.total_tracks = (SELECT COUNT(*)'`
(lifted verbatim from `refreshAlbumTrackTotal()`'s production UPDATE, whose real
return is an `int`), `'UPDATE settings SET selected'` and `'DELETE FROM
tv_shows'` — because a needle is a substring and `str_contains($needle,
'select')` fires on a subquery, a column name and a table name. Worse, its
message told the reader to use `returnNullFor()` instead, i.e. to model `null`
for an UPDATE: the exact r3/r4 defect the whole thread exists to prevent.

Removed rather than patched a fourth time. `keywordFaithful()` classifies the
STATEMENT, which is what the driver does, and for this arm the check is total:
the method can only arm an `int`, and an `int` is faithful for
update/delete/replace and nothing else. Every needle r4 and r5 measured is now
re-pinned at dispatch (10 rows, distinguishable per-keyword reasons), plus the
six needles that must arm AND take effect, plus an inert leading-whitespace
needle.

Review r6 PRIORITY 2: pin the eighth defeat shape. Dropping `keywordOf()`'s
inner `trim()` left the file green while the double answered `null` for
`"UPDATE\t music_artists …"`, which the real client reports as an `int`.
Asserted, together with the two other rows r6 found documented-but-unasserted
(tab-with-no-space, leading block comment).

Review r6 LOW-2: `keywordOf()`'s table now marks each row asserted or
unreachable, says which part of the derivation each asserted row pins, and
records that the outer `trim()` cannot be reached because `query()` `ltrim()`s
first.

Review r6 LOW-3: `keywordFaithful()`'s `default => 'null'` arm is now evaluated
by three real statements, so `default => 'int'` is RED.

`src/` stays COMMENT-ONLY (4899 tokens, md5 e430dd0553304c3c62d56d495d60d00d,
identical at 1ed09c7, 372a162 and here): two comments narrowed r5's "followed
by a single space", which r6 measured as not the rule.

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

Prose wrap only — no new claim. Re-verified COMMENT-ONLY by token stream: 4899
tokens, md5 e430dd0553304c3c62d56d495d60d00d, identical to 1ed09c7, 372a162
and 77150dd, and `git diff HEAD -- src/` has 0 non-comment changed lines.

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

Review r7 (1 LOW + 1 INFO, neither blocking):

- LOW-1: the funnel's "totality" rested on an UNSTATED, UNENFORCED precondition —
  resolveQueryReturn() being the SOLE reader of $this->affectedOn. r7 measured a real
  loss: a second, non-funnelling public reader hands `int 0` back for a SELECT and every
  layer stays GREEN. Closed MECHANICALLY, not in prose: the existing meta-pin
  testEveryQueryReturnFunnelsThroughTheKeywordFidelityCheck() now also asserts
  `->affectedOn` is referenced EXACTLY twice (the write in returnAffectedRowsFor(), the
  one read in resolveQueryReturn()), via a token-based countPropertyReads() helper scoped
  to the class's own line range. A sentence would have to be re-read to work; a count
  fails when someone adds the third reference. r7's exact 9A shape goes from
  `OK (53 tests, 453 assertions)` to `Tests: 53, Assertions: 453, Failures: 1`.
  Both of r7's spellings are caught — including 'INTO metadata_updates', which the
  DELETED arm-time layer never covered — so the new guard is strictly stronger than the
  layer fix r6 removed. The KNOWN-LIMIT block gains the missing limit and the new guard's
  own measured residue (a dynamic `$this->{'affectedOn'}` access evades the count: GREEN).

- INFO-1: src signpost at MusicLibraryScanner.php restored the qualifier its own target
  docblock states — the fidelity test asserts every whitespace layout the keywordOf()
  table names AS REACHABLE; row 6 (the driver's outer trim()) is asserted by nothing and
  the table says so. COMMENT-ONLY: 4899 tokens / md5 e430dd0553304c3c62d56d495d60d00d,
  unchanged at 1ed09c7 / 372a162 / 77150dd / 3bfb2b0 / this tree.

Coverage (S96's first measurement, PCOV 1.0.11 / PHP 8.3.6). New-code coverage on the
branch's changed src files was 87.86% (246/280 added executable lines). Six tests added
for genuinely reachable, AC-load-bearing gaps, each mutation-proven:

- LibraryManager::scanLibrary() routing for photo/book/audiobook + all three
  scan*Library() helpers were at count=0 — 22 added lines, so `return 0;` in any of them
  was a GREEN mutation and a fully successful scan of those three types would have gone
  on reporting items_added = 0, i.e. AC (b) unpinned in three library types. The scanner
  LABEL is asserted too ('image', not the 'photo' ENUM member).
- the music arm of that same routing block.
- MediaScanner::scan() `return 0` for a missing path (feeds items_added).
- MusicLibraryScanner: the album backfill's adoption closure (the artist twin was
  covered, the album one was not); backfillMusicMediaItemId()'s failed-mint block
  ($mayAdopt = true; return null) — AC (e); and upsertTrack()'s repair branch returning
  'failed' vs 'updated' — AC (f), r2's HIGH finding, both arms distinguishable.

Two 0.00% files are tooling artifacts, NOT missing tests, and are left alone (S141 is out
of scope): ScanResult.php measures 0/13 with @Covers and 13/13 with it stripped;
MusicLibraryType.php 0/11 -> 7/11. Both are executed and asserted by existing tests.

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

Review r8 defeated the `->affectedOn` reference count three ways, each handing an
`int 0` back for a `SELECT` — r7's own 9A defeat — while the count still read
exactly 2, and none of the three was named in the KNOWN-LIMIT block. It also
measured a fourth bypass (a public wrapper on `resolveQueryReturn()`) that skips
`keywordFaithful()` with zero new `affectedOn` references, and showed that one
residue the block DID name (a rename) is not a residue at all: it fails loud.

Closed rather than documented:

- widen `private` -> `public` and read the store from a test body: closed by
  asserting the property stays private, which also closes the subclass route
  independently of `final`.
- relocate the single read into a delegate method: closed by counting per-METHOD
  as well as per-class, so "the ONE read in resolveQueryReturn()" is now a pinned
  fact rather than a claim a token counter cannot see.
- `get_object_vars($this)['affectedOn']`: closed by counting the NAME in every
  spelling (`->affectedOn`, `$affectedOn`, `'affectedOn'`), which also closes
  r7's two disclosed residues — the dynamic access and an in-class reflection
  read.
- r8 finding 2, the public wrapper on `resolveQueryReturn()`: closed by asserting
  the method stays private AND that its name occurs exactly twice in the class
  (declaration + the one call from `query()`).

`countPropertyReads()` becomes `countNameTokens()`: spelling-agnostic, and
optionally scoped to one method's line range. Comments still cannot move the
number — a whole comment is one token that matches none of the three spellings.

The KNOWN-LIMIT block now records the measured residue instead of a guessed one:
a test that defines its own double class, a name assembled at runtime
(`'affected' . 'On'`), a `ReflectionProperty` read from OUTSIDE the class,
duplicating `resolveQueryReturn()`'s body instead of calling it, and
`nullOn`/`falseOn`. The false "rename" claim is named only to un-name it.

No production file touched. No new test methods: `Tests: 7534` unchanged,
MusicLibraryScannerTest 55 tests / 468 -> 473 assertions (+5).

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

Mutation MUT-H measured the rename against the NEW pin: because the isPrivate()
assertion runs first, a renamed property never reaches a count and the test
ERRORS with `ReflectionException: Property …MusicSchemaConnection::$affectedOn
does not exist` — it does not fail `"0 is identical to 3"` as the block claimed.
Still RED, but a block written to stop prose over-claiming must not over-claim
its own failure mode. Docblock only; `OK (55 tests, 473 assertions)` unchanged.

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

Review r9 measured two GREEN defeats of the affectedOn meta-pin that the
KNOWN-LIMIT block said could not happen, both exploiting the same root
cause: countNameTokens() is scoped to the class's LINE RANGE, and a trait
is inside the class semantically while sitting outside that range.

- MUT-TRAIT: a `use`d trait holding a public non-funnelling reader handed
  an int 0 back for a SELECT with all seven assertions green.
- MUT-SPARE: the "total must be 3" pin attributed only 2 of its 3
  occurrences, so moving the declaration into a trait freed a slot for an
  in-class reader at the same total.

Closed at the root rather than documented:
- assertion 3 pins `getTraitNames() === []`;
- assertion 5 pins the sum over every method the class DECLARES at 2, so
  all three occurrences are attributed to a location and no slot is free.
  A flattened trait method reports as declared by the using class while
  its line range resolves to the trait, so this catches MUT-TRAIT too.
- countNameTokens() now also matches T_ENCAPSED_AND_WHITESPACE, closing
  the nowdoc/heredoc-spelled name (r9 finding 4).

The KNOWN-LIMIT block is restructured to stop generating findings by
making a completeness claim it cannot keep: it now states the invariant
the nine assertions enforce, then states explicitly that the evasion list
is NOT exhaustive and why, then lists the known evasion CLASSES rather
than spellings, each with a measured GREEN behind it. The false entry
r9 found ("duplicating resolveQueryReturn()'s body", RED in its literal
form) is recorded as RED, not as a residue.

No production file is touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured, not assumed. A trait fires assertion 3 FIRST; a token probe
shows assertion 5 sees both trait mutants independently (method sum
2 -> 3) while assertion 4 does NOT (the class-range total stays 3), so
"closed by assertions 3 and 5" was true but imprecise about ordering.

The no-trait assertion's own message said the trait-held reader kept
"all seven other assertions" green — that was true of the SEVEN-assertion
version review r9 attacked, not of this nine-assertion one, where the
method-sum assertion also sees it. Reworded to say so.

Message text and docblock only: the token count is unchanged at 16046
and no assertion, expected value or helper is touched.

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

Review r10's three findings are all stated claims that its own measurements
falsify. Text only — no assertion, helper body, charlist or token-type list
is touched, so there is NO behaviour change: the file still runs
OK (55 tests, 475 assertions), and MUT-TRAIT / MUT-PROMO stay RED at the
same assertions with their messages intact.

1. LOW-MED — the file asserted both A and not-A about scoping, and the
   false version sat in assertion 3's own FAILURE MESSAGE, i.e. the text an
   engineer reads at the moment a trait is added. "Every count below is
   scoped to the class's own line range" is false of assertion 5: it sums
   over the methods the class DECLARES, and a flattened trait method's
   range resolves into the TRAIT's file and lines, which is precisely why
   it catches trait placement independently of assertion 3 (r10 measured
   A5_methodSum 2 -> 3 under MUT-TRAIT/MUT-SPARE while A4 stayed 3). All
   three sites now state the split — 4/6/7 class-range, 5 per declared
   method — consistent with what countNameTokensInMethods()'s docblock and
   the "WHAT IS CLOSED" r9 bullet already said correctly.

2. LOW — countNameTokens() OVER-counts and its docblock described
   something narrower than what it does. It counts four token types, and
   the trim() charlist includes whitespace, which is LOAD-BEARING (a nowdoc
   token's text is "affectedOn\n"), so a padded literal and an
   interpolation fragment count too; summing per-method also double-counts
   two declarations sharing one physical line. Both are disclosed, with
   r10's measurements and the reason the direction is safe: an over-count
   can only produce a spurious RED, it can never hide an evasion, since a
   reader that is present cannot subtract from a count. The charlist is
   deliberately left alone — narrowing it would re-open the nowdoc evasion.

3. LOW, cosmetic — an out-of-class private read is a RUNTIME
   Error: Cannot access private property, not a compile failure; php -l
   exits 0 and the script prints before dying.

The KNOWN-LIMIT block is untouched: it still declares itself
non-exhaustive and still lists evasions by class, not by spelling. No
evasion was added to it and no completeness claim was restored.

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

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 high · 15 medium · 84 minor

Alerts:
⚠ 100 issues (≤ 0 issues of at least minor severity)

Results:
100 new issues

Category Results
Compatibility 1 high
UnusedCode 1 medium
BestPractice 3 medium
Documentation 17 minor
ErrorProne 4 medium
CodeStyle 66 minor
Complexity 7 medium
Comprehensibility 1 minor

View in Codacy

🟢 Metrics 122 complexity · 0 duplication

Metric Results
Complexity 122
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.46377% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.30%. Comparing base (ffc4173) to head (fb6559b).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/Media/Library/ScanResult.php 14.28% 6 Missing ⚠️
src/Media/Music/MusicLibraryType.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #568      +/-   ##
============================================
+ Coverage     64.95%   65.30%   +0.34%     
- Complexity    20931    20957      +26     
============================================
  Files           656      656              
  Lines         65856    66023     +167     
============================================
+ Hits          42775    43114     +339     
+ Misses        23081    22909     -172     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@detain
detain merged commit 762c2c9 into master Jul 26, 2026
16 of 17 checks passed
@detain
detain deleted the s96-music-scan-observability branch July 26, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants