refactor: extract testable resolve-core + cover album routing; check.sh checks .cjs (closes task/resolver-orchestration-testable)

Source: task/resolver-orchestration-testable (plan/steward-linker-quality) — new album orchestration in resolver.js had zero node:test coverage.

- Extract the pure album/artist orchestration into service/resolve-core.js
  (resolveAll/resolveOne/pickDirect/cacheKey) taking injected search/classify
  fns; resolver.js becomes a thin server wiring the real Spotify search +
  ollama classify. No behavior change.
- Add service/resolver.test.js: self-titled artist+album tie-break, LLM-album
  -> Google fallback, kind-aware cache separation, plus gate/drop/error paths.
- scripts/check.sh: node --check now also matches *.cjs (extract.test.cjs).
- AGENTS.md Testing: note resolve-core coverage + the extract.test.cjs extractor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-23 00:29:59 +00:00
co-authored by Claude Opus 4.8
parent 2b4babf955
commit 8496e55baf
5 changed files with 265 additions and 79 deletions
+20 -75
View File
@@ -15,8 +15,9 @@
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { norm, pickArtist, pickAlbum, linkResult } from "./lib.js";
import { pickArtist, pickAlbum } from "./lib.js";
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
import { resolveAll as resolveAllCore } from "./resolve-core.js";
// --- minimal .env loader (no dependency) ------------------------------------
try {
@@ -34,10 +35,10 @@ const USE_SPOTIFY = Boolean(SPOTIFY_ID && SPOTIFY_SECRET);
const OLLAMA_URL = (process.env.OLLAMA_URL || "").replace(/\/$/, ""); // presence enables the LLM gate
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL;
const USE_LLM = Boolean(OLLAMA_URL);
const MAX_CANDIDATES = 100;
// Cache only the network half (Spotify direct-url lookup); the final link object is rebuilt
// per request so a name's result never depends on a stale gate decision. norm(name) -> url|null
// Cache only the network half (the Spotify direct-url lookup); resolve-core rebuilds the final
// link object per request so a name's result never depends on a stale gate decision. The key
// includes the kind hint (see resolve-core's cacheKey).
const spCache = new Map();
// --- Spotify (optional): direct-artist-link lookup --------------------------
@@ -58,36 +59,21 @@ async function spotifyToken() {
return spToken;
}
// The direct Spotify url for an exact name match across artist AND album, or null (no creds /
// no exact hit). One search call covers both types; `kindHint` ("artist" | "album", from the
// LLM gate) decides which exact hit wins when BOTH match the same string (e.g. a self-titled
// album) — default prefers the artist, matching the artist-first MVP behaviour. Returns
// { url, kind } so the caller can shape an artist vs album link.
async function spotifyDirect(name, kindHint) {
if (!USE_SPOTIFY) return null;
// The injected `search` for resolve-core: one type=artist,album Spotify search per candidate,
// returning the exact-name-match hits as { artist, album } where each is the external Spotify url
// (string) or null. No creds -> both null (nothing links). resolve-core's pickDirect applies the
// kindHint tie-break and shapes the link; the cache (spCache) is owned by resolve-core too.
async function search(name) {
if (!USE_SPOTIFY) return { artist: null, album: null };
const token = await spotifyToken();
const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`;
const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) throw new Error(`spotify search http ${r.status}`);
const j = await r.json();
const artist = pickArtist(name, j.artists?.items);
const album = pickAlbum(name, j.albums?.items);
const order = kindHint === "album" ? [["album", album], ["artist", artist]] : [["artist", artist], ["album", album]];
for (const [kind, hit] of order) {
const u = hit?.external_urls?.spotify;
if (u) return { url: u, kind };
}
return null;
}
async function spotifyDirectCached(name, kindHint) {
// Cache key includes the kind hint: the same string can resolve to an artist OR an album
// depending on which the gate confirmed, so a hint-blind cache would cross-contaminate.
const key = `${kindHint || "artist"}:${norm(name)}`;
if (spCache.has(key)) return spCache.get(key);
const hit = await spotifyDirect(name, kindHint);
spCache.set(key, hit);
return hit;
return {
artist: pickArtist(name, j.artists?.items)?.external_urls?.spotify ?? null,
album: pickAlbum(name, j.albums?.items)?.external_urls?.spotify ?? null,
};
}
// --- LLM gate (optional): which candidates are artists/albums? --------------
@@ -109,52 +95,11 @@ async function ollamaClassify(candidates) {
}
// --- resolve --------------------------------------------------------------
// allowFallback: when the gate has already confirmed this name is an artist/album, a missing
// Spotify direct link still yields a Google->Bandcamp fallback link (instead of null).
// kindHint ("artist" | "album", from the LLM gate or undefined) biases the Spotify lookup and
// the fallback link's kind; the actual exact Spotify hit's kind wins when there is one.
async function resolveOne(name, allowFallback, kindHint) {
if (!norm(name)) return null;
try {
const sp = await spotifyDirectCached(name, kindHint);
if (sp) return linkResult(name, sp.url, sp.kind); // direct Spotify page (artist or album)
return allowFallback ? linkResult(name, null, kindHint) : null; // google fallback, or unconfirmed
} catch (e) {
console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time
return null;
}
}
async function resolveAll(candidates) {
const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES);
// Gate: which names may link? null => no LLM gate, the Spotify exact-match (artist OR album)
// is the gate (only names actually on Spotify link). A Map(name -> "artist"|"album") => the
// LLM's verdict; names not in it are dropped, names in it may use the Google fallback when
// not on Spotify, and the mapped kind routes the Spotify lookup to the right type.
let confirmed = null;
if (USE_LLM && uniq.length) {
try {
confirmed = await ollamaClassify(uniq);
} catch (e) {
console.error(`llm classify: ${e.message}`); // LLM down -> fall back to the Spotify gate
confirmed = null;
}
}
const out = {};
await Promise.all(
uniq.map(async (name) => {
if (confirmed && !confirmed.has(norm(name))) {
out[name] = null; // LLM said this is not an artist/album
return;
}
const kindHint = confirmed ? confirmed.get(norm(name)) : undefined;
out[name] = await resolveOne(name, confirmed != null, kindHint);
}),
);
return out;
}
// Run the pure orchestration (resolve-core) with the live clients injected: the Spotify `search`,
// the ollama `classify` gate (null when no LLM is configured -> the Spotify exact-match is the
// gate), and the shared spCache.
const resolveAll = (candidates) =>
resolveAllCore(candidates, { search, classify: USE_LLM ? ollamaClassify : null, cache: spCache });
// --- HTTP -------------------------------------------------------------------
function readBody(req) {