// The registry, assembled — for callers with no server. // // lib/projects.ts is the app's façade: it memoises, it dispatches song // decisions into the TypeScript reducer, and it is full of `import type`. This // is the same thing for `umtool` on the command line, in plain ESM, sharing the // walk, the kinds and every per-kind module with the app so the two cannot // disagree about what a project is or what is wrong with it. // // WHAT IT DOES NOT DO: the song reducer. Nine of the decision kinds // (unjudged-variant, stale-recipe, loudness, …) are computed by lib/decisions.ts // against readSong(), readSpec(), the loudness cache and the accepted cover set, // all of which are TypeScript beside the app. Porting them would be a second // implementation of the thing this file exists to avoid having two of. So the // CLI reports every report-video decision and every routing one — which is what // `umtool check` is for, since the two defects that shipped in real videos were // both manifest problems — and says plainly that song decisions live in the UI. import path from "node:path"; import { REPORTS_ROOT } from "../paths.mjs"; import { PROJECT_KINDS, kindById } from "./kinds.mjs"; import { collapseFolders, foldersFor, walkProjects } from "./walk.mjs"; export { PROJECT_KINDS, REPORTS_ROOT }; export const ROUTING_DECISION_KINDS = ["shadowed-name", "unroutable-name", "ambiguous-project"]; export async function projectRefs(root = REPORTS_ROOT) { return walkProjects(root); } export async function summarise(p, root = REPORTS_ROOT) { const k = kindById(p.kind); const ctx = { ...p, root }; let body = {}; if (k?.summarise) { try { body = await k.summarise(ctx); } catch (err) { body = { flags: [`could not be read: ${err?.message ?? err}`] }; } } const flags = [...(body.flags ?? [])]; if (p.routing === "shadowed") flags.push(`/${p.id.split("/")[0]} is a tool page`); if (p.routing === "unroutable") flags.push("name will not route"); if (p.ambiguousWith) flags.push(`two kinds match: ${p.ambiguousWith.join(", ")}`); return { ...p, badge: k?.badge ?? p.kind, title: body.title ?? p.name, subtitle: body.subtitle ?? null, state: body.state ?? "draft", newestMtimeMs: body.newestMtimeMs ?? 0, facts: body.facts ?? [], flags, attrs: body.attrs ?? {}, haystack: `${body.haystack ?? ""} ${p.id} ${p.kind} ${p.template}`.toLowerCase(), }; } /** Every decision this side can compute for one project. */ export async function decisionsFor(p, root = REPORTS_ROOT) { const out = []; const k = kindById(p.kind); const ctx = { ...p, root }; if (k?.decisions) { try { const s = k.summarise ? await k.summarise(ctx) : null; out.push(...(await k.decisions(ctx, s))); } catch (err) { out.push({ kind: "unreadable", project: p.id, target: p.id, why: `could not be read: ${err?.message ?? err}`, severity: "blocking", at: 0, }); } } if (p.routing === "shadowed") { out.push({ kind: "shadowed-name", project: p.id, target: p.id.split("/")[0], why: `/browse/${p.id.split("/")[0]} is a tool page and always wins the route`, severity: "blocking", at: 0, }); } if (p.routing === "unroutable") { out.push({ kind: "unroutable-name", project: p.id, target: p.name, why: "this name cannot be a URL segment, so the project has no address of its own", severity: "info", at: 0, }); } if (p.ambiguousWith) { out.push({ kind: "ambiguous-project", project: p.id, target: p.ambiguousWith.join(" + "), why: "two kinds match this directory — it is read as the first, which is a bug", severity: "blocking", at: 0, }); } return out.map((d) => ({ ...d, project: p.id })); } /** Whether this kind's decisions are fully computable without the app. */ export const decisionsAreComplete = (kind) => !!kindById(kind)?.decisions; /** * Resolve a user-typed project argument. * * An exact id wins; otherwise a UNIQUE basename does. Two projects answering to * one name is the reason ids are paths, so it is reported rather than resolved. */ export async function resolveProject(arg, root = REPORTS_ROOT) { const refs = await projectRefs(root); const exact = refs.find((p) => p.id === arg); if (exact) return { project: exact }; // A path the user typed relative to the cwd, or absolute. const abs = path.resolve(arg); const byDir = refs.find((p) => p.dir === abs); if (byDir) return { project: byDir }; const named = refs.filter((p) => p.name === arg); if (named.length === 1) return { project: named[0] }; if (named.length > 1) return { ambiguous: named }; return {}; } export async function folders(root = REPORTS_ROOT) { return collapseFolders(foldersFor(await projectRefs(root))); }