/** * Directory loader + guide catalog / disambiguation rendering — split out of * parse-api-guide.ts (pure code motion). The dependency is one-directional: * this module imports the parser; the parser never imports this module. * * `isStaleSchema` deliberately stays in parse-api-guide.ts — it is the * parse-time schema gate's predicate, used inside `parseApiGuide()` itself. */ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Guide } from "./guide-loader.js"; import { slug } from "./path-template.js"; import { type ApiGuide, type LoadedApiGuides, type NotifyFn, type ParseError, } from "./api-guide-types.js"; import { parseApiGuide } from "./parse-api-guide.js"; // ═══════════════════════════════════════════════════════════════════ // projectToGuide — strips recipe fields, keeps presentation + kind // ═══════════════════════════════════════════════════════════════════ export function projectToGuide(guide: ApiGuide): Guide { const projection: Guide = { content: guide.content, updated: guide.updated, category: guide.category, source: guide.source, icon: guide.icon, shortName: guide.shortName, kind: guide.kind, ...(guide.domains ? { domains: guide.domains } : {}), }; return projection; } // ═══════════════════════════════════════════════════════════════════ // Directory loader — one malformed guide doesn't block the store. // // Loads from per-guide subdirectories: //guide.md. // The folder name must equal slug(shortName) — a divergent folder routes to // malformed (enforced), so the active set holds at most one guide per // shortName. Flat top-level .md files are not loaded. // ═══════════════════════════════════════════════════════════════════ /** * Push a malformed guide and warn about it. One helper owns both the catalog * entry and the load-time warn, so every malformed guide — parse * failure, illegal shortName, divergent folder — surfaces the same signal * (and its actionable `fix` when present) instead of being silently * quarantined. Fires once per cached scan. */ function pushMalformed( result: LoadedApiGuides, file: string, filename: string, error: ParseError, warn: (msg: string) => void, ): void { result.malformed.push({ file, filename, error }); warn( `⚠ Malformed guide '${filename}': ${error.field} — expected ${error.expected}; found ${error.found}.` + (error.fix ? ` ${error.fix}` : ""), ); } export function loadApiGuidesFromDir( dir: string, notify?: NotifyFn, ): LoadedApiGuides { const result: LoadedApiGuides = { guides: {}, malformed: [] }; if (!existsSync(dir)) return result; let entries: string[]; try { entries = readdirSync(dir); } catch { return result; } // Every load-time diagnostic routes through one channel: ctx.ui.notify // when the caller has a UI context (renders via the Text component — // wraps long lines, honors newlines), else console.warn. One fallback // here, not one repeated per call site. const warn = (msg: string) => { if (notify) notify(msg, "warning"); else console.warn(msg); }; // One-time schema-gate banner: sets the "pass these to your agent" frame before the first // stale-schema malformed warning. Fired only when the parse failure is a // schemaVersion gate refusal, so ordinary malformed guides don't drag it // in. The per-guide failure already names the file + migration doc in its // fix; the banner only sets the frame once per scan. let schemaBannerEmitted = false; const emitSchemaGateBanner = () => { if (schemaBannerEmitted) return; schemaBannerEmitted = true; warn( `\n⚠ One or more guides were authored against an older guide schema and failed to load. ` + `Pass the warnings below to the agent to fix them ` + `(re-stamp schemaVersion + apply the breaking changes in the migration doc), then /reload.\n`, ); }; for (const entry of entries) { const entryPath = join(dir, entry); try { if (!statSync(entryPath).isDirectory()) continue; } catch { continue; } const guidePath = join(entryPath, "guide.md"); try { if (!statSync(guidePath).isFile()) continue; } catch { continue; } const name = entry; // folder name = slug(shortName) in steady state let raw: string; try { raw = readFileSync(guidePath, "utf-8"); } catch { continue; } const parsed = parseApiGuide(raw, { file: guidePath, filename: name, }); if (parsed.ok) { const guide = parsed.guide; // Illegal-shortName + divergence checks (permanent). One try/catch // owns the slug() call: a throw (empty or all-symbol shortName) is // routed to malformed, never escaped. let slugged: string; try { slugged = slug(guide.shortName); } catch { pushMalformed( result, guidePath, name, { field: "shortName", expected: "a shortName that slugs to a non-empty safe directory name", found: `"${guide.shortName}"`, fix: "Set a valid shortName (lowercase letters, digits, and '-') in the guide's frontmatter.", }, warn, ); continue; } // Divergence check (permanent) — ENFORCED, not advisory: the folder // name must equal slug(shortName); the coupling IS the identity. A // divergent guide routes to malformed (never loads), so the active // set structurally holds at most one guide per shortName. if (entry !== slugged) { pushMalformed( result, guidePath, name, { field: "shortName", expected: `a folder named slug(shortName) ('${slugged}')`, found: `folder '${entry}'`, fix: `Rename the folder to '${slugged}': mv ${entryPath} ${join(dir, slugged)}`, }, warn, ); continue; } result.guides[entry] = guide; } else { if (parsed.error.field === "schemaVersion") emitSchemaGateBanner(); pushMalformed(result, guidePath, name, parsed.error, warn); } } return result; } // ════════════════════════════════════════════════════════════════════ // Disambiguation helpers — shared by the api-guide menu and the // api-fetch ambiguous-operation error. One rendering of the per-guide // listing so the two surfaces stay visually consistent. // ════════════════════════════════════════════════════════════════════ /** * Truncated op-name summary for disambiguation surfaces: * "N ops: a, b, c, d, e, +K more" (first 5 names, then the remaining count). * A 50-op guide must not dump 50 names into context — the menu exists to * help pick a guide cheaply; the full op list is one api-guide call away. */ function formatOpSummary(ops: { name: string }[]): string { const names = ops.map((o) => o.name); const count = names.length; const head = names.slice(0, 5); const remaining = count - head.length; const tail = remaining > 0 ? `, +${remaining} more` : ""; return `${count} ops: ${head.join(", ")}${tail}`; } /** * Per-guide listing lines for a disambiguation surface (the api-guide * multi-guide menu and the api-fetch ambiguous-op error). Each entry: * `icon shortName` [+ ` — description` when present] then the truncated * op-name list. When `description:` is absent the line falls back to * shortName + op names only (the status-quo shape), so un-backfilled * guides render consistently with those that have a one-line summary. */ export function formatGuideListings(entries: { guide: ApiGuide }[]): string { const lines: string[] = []; for (const { guide } of entries) { lines.push( ` ${guide.icon} ${guide.shortName}` + (guide.description ? ` — ${guide.description}` : ""), ); lines.push(` ${formatOpSummary(guide.operations)}`); } return lines.join("\n"); } /** * Resolve a guide by shortName across a domain's matches (exact, * case-insensitive). Shared by api-guide, api-learn's fetch-recipe, and * /api verify — three call sites, one resolution rule (a drifted copy would * ship a known second bug). Returns a structured outcome; each caller renders * its own message (tool result vs command notify). */ export function selectGuideByShortName( matches: { guide: ApiGuide; dirName: string }[], selector: string, ): | { ok: true; guide: ApiGuide; dirName: string } | { ok: false; reason: "no_match"; valid: string[] } | { ok: false; reason: "ambiguous"; directories: string[] } { const lc = selector.toLowerCase(); const sel = matches.filter((m) => m.guide.shortName.toLowerCase() === lc); if (sel.length === 0) { return { ok: false, reason: "no_match", valid: matches.map((m) => m.guide.shortName), }; } if (sel.length > 1) { return { ok: false, reason: "ambiguous", directories: sel.map((s) => s.dirName), }; } return { ok: true, guide: sel[0]!.guide, dirName: sel[0]!.dirName }; } /** * Render the no-match / ambiguous error text for a failed * `selectGuideByShortName` result. `callToAction` is the trailing * "how to see the menu" sentence — it differs per surface (tool vs * command), so callers pass their own. */ export function shortNameErrorText( sel: Extract, { ok: false }>, domain: string, selector: string, callToAction: string, ): string { if (sel.reason === "no_match") { return ( `No guide named '${selector}' for '${domain}'. ` + `Available guides: ${sel.valid.join(", ")}. ` + callToAction ); } return ( `Ambiguous guide '${selector}' for '${domain}' — ` + `${sel.directories.length} guides share shortName '${selector}' ` + `(directories: ${sel.directories.join(", ")}). Rename one guide's shortName to ` + `disambiguate. ` + callToAction ); } // ════════════════════════════════════════════════════════════════════ // Catalog rendering — healthy + ⚠ malformed together // // Base catalog is collapsed by `organization:`: one line per org with its // guide count and domain set. Guides without `organization:` fall back to // the per-guide line, so the un-backfilled corpus renders unchanged (no // forced migration). Op counts live on the per-domain disambiguation menu // (api-guide {domain}), where they're useful for picking a guide — not // here, where they'd bloat context for orgs with many guides. // ════════════════════════════════════════════════════════════════════ export function formatApiGuideCatalog(loaded: LoadedApiGuides): string { const lines: string[] = ["API guides:"]; // Org-grouped rows preserve first-appearance order; guides without // organization fall back to the per-guide line (no forced migration). const orgRows: { org: string; guides: ApiGuide[] }[] = []; const orgIndex = new Map(); const orgless: { name: string; guide: ApiGuide }[] = []; for (const [name, guide] of Object.entries(loaded.guides)) { if (guide.organization) { const idx = orgIndex.get(guide.organization); if (idx === undefined) { orgIndex.set(guide.organization, orgRows.length); orgRows.push({ org: guide.organization, guides: [guide] }); } else { orgRows[idx]!.guides.push(guide); } } else { orgless.push({ name, guide }); } } for (const row of orgRows) { const domains = new Set(); for (const g of row.guides) { for (const d of g.domains ?? []) domains.add(d); } const domList = domains.size > 0 ? [...domains].join(", ") : "—"; const n = row.guides.length; lines.push(` 🏛️ ${row.org} — ${n} guide${n > 1 ? "s" : ""} (${domList})`); } for (const { name, guide } of orgless) { const domains = guide.domains && guide.domains.length > 0 ? guide.domains.join(", ") : name; lines.push( ` ${guide.icon} ${guide.shortName} — ${domains} (verified ${guide.verified}, ${guide.operations.length} ops)`, ); } for (const mal of loaded.malformed) { lines.push( ` ⚠ malformed — ${mal.filename}: ${mal.error.field} — expected ${mal.error.expected}; found ${mal.error.found}`, ); // Actionable advice on its own line, matching api-fetch/api-learn's // `Fix:` convention — multi-line fixes (e.g. the frontmatter template) // render naturally instead of being inlined into the summary line. if (mal.error.fix) lines.push(` fix: ${mal.error.fix}`); } if (Object.keys(loaded.guides).length === 0 && loaded.malformed.length === 0) { lines.push(" (no guides — call api-learn({domain, dir}) to author one)"); } lines.push(""); lines.push( 'Call api-guide({domain: ""}) for detail. Multiple guides for a domain show a disambiguation menu.', ); return lines.join("\n"); }