Fix the team resolve properly: backfill the name BEFORE confirmation

My first attempt did not work in prod -- team stayed 0/323 after deploy.
I resolved the team name AFTER the hint-confirmation check, but the check
itself reads hit.currentTeam.name, which is undefined because
/sports/1/players returns { id, link }. With a FULL-NAME hint (what
snapshotService passes) neither branch of teamRecordMatchesHint could
match: the name branch had no name, and the abbr branch cannot resolve a
full name to an abbr. Confirmation failed, the team was nulled, and my
later backfill ran on an already-null value.

withTeamName() now backfills the name from the cached /teams list BEFORE
any comparison, and is used at all three confirmation sites plus the
return. Verified against the live API on all four cases: no hint, FULL-NAME
hint, abbr hint -> "Philadelphia Phillies"; WRONG hint -> null.

That last case matters most: a wrong hint must still REFUSE. The
confirmation exists so a namesake collision cannot tag a player to a team
he is not on, which would fabricate opponents downstream. Making the match
succeed must not make it succeed wrongly, and a test locks it.

Gates: 4,087 tests / 327 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-01 23:34:16 -04:00
parent cfda597fb5
commit 9fc17a4689
2 changed files with 62 additions and 12 deletions
+28 -12
View File
@@ -216,6 +216,21 @@ function teamNorm(s) {
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
* an abbr hint into a team id.
*/
/**
* `/sports/1/players` returns `currentTeam: { id, link }` with NO `name`, so a
* raw currentTeam cannot be matched against a FULL-NAME hint and cannot be
* reported as a team. Backfill the name from the cached `/teams` list (id is
* present on 100% of the player list) BEFORE any comparison — doing it after
* the confirmation check leaves the check failing and nulls the team anyway,
* which is exactly how `team` ended up null on 416/416 graded rows.
*/
function withTeamName(currentTeam, teams) {
if (!currentTeam || currentTeam.id == null) return null;
if (currentTeam.name) return { id: currentTeam.id, name: currentTeam.name };
const row = (teams || []).find((t) => t.id === currentTeam.id);
return { id: currentTeam.id, name: (row && row.name) || null };
}
function teamRecordMatchesHint(team, teamHint, teams) {
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
const tId = team.id;
@@ -237,7 +252,7 @@ function teamRecordMatchesHint(team, teamHint, teams) {
function disambiguateByHint(candidates, teamHint, teams) {
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
const matches = (candidates || []).filter((p) =>
p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams));
p.currentTeam && teamRecordMatchesHint(withTeamName(p.currentTeam, teams), teamHint, teams));
return matches.length === 1 ? matches[0] : null;
}
@@ -288,8 +303,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
if (exact.length === 1) {
hit = exact[0];
if (teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
const tms = await ensureTeams();
teamConfirmed = teamRecordMatchesHint(withTeamName(hit.currentTeam, tms), teamHint, tms);
}
} else if (exact.length >= 2) {
// Namesake collision — resolve ONLY with a confident hint, else refuse.
@@ -298,8 +313,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
} else {
hit = lastNameInitialFallback(people, targetKey);
if (hit && teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
const tms = await ensureTeams();
teamConfirmed = teamRecordMatchesHint(withTeamName(hit.currentTeam, tms), teamHint, tms);
}
}
@@ -313,14 +328,13 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
//
// The id is present on 100% of the list, and the /teams list (already cached
// 24h) maps id -> name, so this costs no new request.
const teamId = teamConfirmed ? (hit.currentTeam?.id ?? null) : null;
let teamName = teamConfirmed ? (hit.currentTeam?.name ?? null) : null;
if (teamName == null && teamId != null) {
try {
const row = (await ensureTeams()).find((t) => t.id === teamId);
if (row && row.name) teamName = row.name;
} catch { /* honest-absent: an unresolved team stays null, never guessed */ }
let resolvedTeam = null;
if (teamConfirmed && hit.currentTeam) {
try { resolvedTeam = withTeamName(hit.currentTeam, await ensureTeams()); }
catch { resolvedTeam = null; /* honest-absent: never guessed */ }
}
const teamId = resolvedTeam ? resolvedTeam.id : null;
const teamName = resolvedTeam ? resolvedTeam.name : null;
return {
id: hit.id,
fullName: hit.fullName ?? name,
@@ -443,5 +457,7 @@ module.exports = {
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
// 2026-08-01 — the team-name backfill the S59 invariant depends on.
withTeamName,
},
};
+34
View File
@@ -66,3 +66,37 @@ describe('the invariants input is the PLAYER team, not the props own game'
expect(players(out)).toEqual(['X']); // abstained — no player team known
});
});
describe('mlbStatsAdapter — the team the invariant depends on', () => {
const { __internals } = require('../../src/services/adapters/mlbStatsAdapter');
const { withTeamName, teamRecordMatchesHint } = __internals || {};
const TEAMS = [{ id: 143, abbr: 'PHI', name: 'Philadelphia Phillies' },
{ id: 141, abbr: 'TOR', name: 'Toronto Blue Jays' }];
it('backfills the team NAME from the id — the list returns { id, link } only', () => {
expect(withTeamName({ id: 143, link: '/api/v1/teams/143' }, TEAMS))
.toEqual({ id: 143, name: 'Philadelphia Phillies' });
});
it('returns null for an absent team rather than an empty shell', () => {
expect(withTeamName(null, TEAMS)).toBeNull();
expect(withTeamName({ link: '/x' }, TEAMS)).toBeNull();
});
it('leaves the name null when the id is unknown — never guessed', () => {
expect(withTeamName({ id: 999 }, TEAMS)).toEqual({ id: 999, name: null });
});
it('a FULL-NAME hint now matches — this is what was silently failing', () => {
// Before the backfill, name was undefined, so a full-name hint could match
// neither branch: confirmation failed and the team was nulled.
const t = withTeamName({ id: 143 }, TEAMS);
expect(teamRecordMatchesHint(t, ['Philadelphia Phillies', 'New York Mets'], TEAMS)).toBe(true);
expect(teamRecordMatchesHint(t, ['PHI'], TEAMS)).toBe(true);
});
it('a WRONG hint still REFUSES — the anti-fabrication guard is preserved', () => {
const t = withTeamName({ id: 143 }, TEAMS);
expect(teamRecordMatchesHint(t, ['Toronto Blue Jays'], TEAMS)).toBe(false);
});
});