fix(portals): absent is not empty, and every start claims the generation

Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 02:13:40 +02:00
1 parent 481d368019
commit 7c1b433d47
12 files changed
+147 -14

No files matched your search

@@ -93,6 +93,18 @@ describe('title-sources.operations', () => {
expect(scanQuery.params).toContain('Ça');
});
it('pairs raw tokens by meaning, not by position', async () => {
// Normalization drops whole words, so "FR: Ça" becomes just "ca".
// Pairing positionally would hand "ca" the raw token "FR:" and
// send it down the ASCII branch, where it cannot match "Ça".
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'FR: Ça' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('instr');
expect(scanQuery.params).toContain('Ça');
});
it('keeps the word boundary for ASCII tokens', async () => {
// The looser substring test must not leak into the ASCII path,
// where it would let "it" match "Titanic".
@@ -160,10 +160,20 @@ function scanCandidateQuery(
// multiword titles like "I Am" — matching on "i" alone would return most
// of the catalog and hand it all to the TypeScript pass to throw away.
const tokens = base.split(' ').filter(Boolean);
const rawTokens = rawTitle.split(/\s+/).filter(Boolean);
// Paired by NORMALIZED form, not by position: normalization drops whole
// words ("FR: Ça" -> "ca"), so a positional pairing hands "ca" the raw
// token "FR:" and picks the wrong branch for it.
const rawByBase = new Map<string, string>();
for (const raw of rawTitle.split(/\s+/).filter(Boolean)) {
const normalized = normalizeTitleKeys(raw).base;
if (normalized && !rawByBase.has(normalized)) {
rawByBase.set(normalized, raw);
}
}
const wordMatches = sql.join(
tokens.map((token, index) =>
tokenPredicate(token, rawTokens[index] ?? token)
tokens.map((token) =>
tokenPredicate(token, rawByBase.get(token) ?? token)
),
sql` AND `
);