/**
 * SanityDocFetcher — full adapter for the DocFetcher port.
 *
 * Fetches documentation from the Sanity Content Lake with support for:
 * - Basic slug-based fetching (GROQ → Portable Text → Markdown)
 * - Perspective-aware fetching (content release diffing)
 * - Document overlays (replace/append canonical docs by document ID)
 * - Direct URL fetching (markdown endpoints + content negotiation)
 * - Custom HTTP headers
 * - Document manifest (traceability: _id, _rev, slug, title)
 * - Release impact metadata (added/modified/removed/unchanged)
 *
 * The pipeline step (FetchDocsStep) calls ctx.docFetcher.fetch() and
 * writes the returned metadata to disk for downstream consumption.
 */
import { mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { canonicalDocRefLabel, isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../../_vendor/ailf-core/index.js";
import { baseDocumentId, buildContextDocPath, buildSanityAnchor, isContentReleasePerspective, } from "../../_vendor/ailf-shared/index.js";
import { fetchUrlContent, } from "../../pipeline/fetch-url-content.js";
import { escapeNunjucks } from "./escape-nunjucks.js";
import { createPerspectiveClient, createPublishedClient, getSanityClient, } from "../../sanity/client.js";
import { extractSymbolsForDoc, renderDocument, slugForDoc, } from "../../sanity/document-renderers.js";
import { buildPerspectivePinWarnings, } from "../../sanity/perspective-pin-guard.js";
import { computeUncoveredReleaseDocs, NON_EVALUABLE_RELEASE_DOC_TYPES, } from "../../sanity/release-coverage.js";
import { mergeSymbolIndexes, renderSymbolIndex, symbolIndexTierBreakdown, } from "../../sanity/symbol-index.js";
import { extractRelevanceIdentifiers } from "../../sanity/typedoc-declarations.js";
import { ALL_ARTICLES_QUERY, ALL_FEATURE_AREAS, ARTICLE_BY_ID_QUERY, ARTICLE_BY_SLUG_QUERY, ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, ARTICLE_SLUG_BY_PATH_QUERY, ARTICLE_SLUG_BY_SECTION_PATH_QUERY, ARTICLES_METADATA_BY_SLUGS_QUERY, DOCS_BY_IDS_QUERY, DOCS_IN_RELEASE_QUERY, DOCS_METADATA_BY_IDS_QUERY, FEATURE_AREA_QUERIES, } from "../../sanity/queries.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function estimateTokens(text) {
    return Math.ceil(text.length / 4);
}
/**
 * Read a `DocumentForRender` field that the GROQ projection set as a
 * scalar string, gracefully returning `undefined` when the projection
 * shape is empty/unexpected (defensive for partial overlay docs).
 */
function readString(doc, key) {
    const value = doc[key];
    return typeof value === "string" ? value : undefined;
}
/**
 * Bridge DocSourceConfig (domain type) to getSanityClient overrides.
 *
 * DocSourceConfig is the port's domain type; the Sanity client expects
 * its own config shape. This bridges the gap.
 */
function toSanityOverrides(source) {
    if (!source)
        return undefined;
    const overrides = {};
    if (source.dataset)
        overrides.dataset = source.dataset;
    if (source.projectId)
        overrides.projectId = source.projectId;
    return Object.keys(overrides).length > 0 ? overrides : undefined;
}
/**
 * Build the source-tagged locator (D0062) for a manifest entry.
 *
 * The single point where the resolved source coordinates (projectId,
 * dataset, perspective) combine with a document's Sanity-native `_id`/`_rev`
 * to map them onto the locator's `documentId`/`revision` — keeping the
 * reconciliation a single source of truth. The `{kind, projectId, dataset}`
 * anchor (with its `?? DEFAULT_DOCS_*` fallbacks for a direct caller that omits
 * them) comes from the shared {@link buildSanityAnchor}, the one
 * anchor-construction source of truth shared with the release locator builder
 * (D0063). `perspective` and `revision` are omitted when absent (perspective
 * absent ⇒ "published"). `documentType` is the Sanity `_type` threaded from the
 * already-fetched document (D0084) — a denormalized handle that lets a later
 * consumer build a type-aware query without a refetch; omitted when absent so
 * legacy/typeless callers stamp an unchanged locator.
 */
function buildSanityLocator(source, documentId, revision, documentType) {
    return {
        ...buildSanityAnchor(source),
        documentId,
        ...(documentType ? { documentType } : {}),
        ...(source?.perspective ? { perspective: source.perspective } : {}),
        ...(revision ? { revision } : {}),
    };
}
/**
 * Build a {@link ReleaseImpactDoc} for an in-scope document key, drawing the
 * display `slug` and `documentType` from the manifest entry (the resolved
 * in-scope doc) and, when present, the fresher metadata fetched from a release
 * client. The metadata's `_type`/`slug` win when set (they reflect the version
 * actually in the release); the manifest is the fallback (e.g. for a doc removed
 * under the perspective, where only the published-side manifest knows it).
 */
function toImpactDoc(documentId, manifestByKey, meta) {
    const entry = manifestByKey.get(documentId);
    const documentType = meta?._type ??
        (entry?.locator?.kind === "sanity-content-lake"
            ? entry.locator.documentType
            : undefined);
    const slug = meta?.slug ?? entry?.slug;
    return {
        documentId,
        ...(documentType ? { documentType } : {}),
        ...(slug ? { slug } : {}),
    };
}
/**
 * The slug-array display string for a {@link ReleaseImpactDoc}: its `slug` when
 * present, else the base `documentId`. This is what keeps a typeless
 * (slugless) release edit visible to the back-compat slug consumers — they see
 * a stable id string rather than the doc vanishing from `added`/`modified`.
 */
function impactDocLabel(doc) {
    return doc.slug ?? doc.documentId;
}
/**
 * Build the `FetchMetadata.perspectiveExpansion` envelope from the
 * collected perspective refs and their fetch-time document expansion.
 *
 * Perspective doc refs expand one-to-many only during fetch, so this is
 * the single point where the expansion (release → docs) and the
 * attribution (task → releases) are both known. Returning them together
 * lets the orchestration step attribute perspective-expanded documents back
 * to their owning tasks during release auto-scope computation.
 *
 * The expansion is keyed type-agnostically on the document key (D0084):
 * `docsByPerspective` is the authoritative surface auto-scope joins on, and
 * `slugsByPerspective` is the slug view (a slugless doc surfaces its base
 * `documentId`) retained for display / back-compat.
 *
 * Exported for unit testing.
 */
export function buildPerspectiveExpansion(perspectiveRefs, perspectiveToDocs) {
    const docsByPerspective = {};
    const slugsByPerspective = {};
    for (const [perspective, docs] of perspectiveToDocs) {
        docsByPerspective[perspective] = [...docs];
        slugsByPerspective[perspective] = docs.map(impactDocLabel);
    }
    const perspectivesByTask = {};
    for (const { perspective, taskId } of perspectiveRefs) {
        const perspectives = (perspectivesByTask[taskId] ??= []);
        if (!perspectives.includes(perspective)) {
            perspectives.push(perspective);
        }
    }
    return { docsByPerspective, perspectivesByTask, slugsByPerspective };
}
/**
 * Collapse manifest rows that share a document key, keeping the richer entry.
 *
 * A task that pins the SAME document by both an `id` ref and a `perspective`
 * ref resolves it twice — once from the published baseline ({@link
 * SanityDocFetcher} `resolveIdRefs`) and once as a release member
 * (`resolvePerspectiveDocContent`) — so two {@link DocumentManifestEntry} rows
 * arrive for one document (D0085). This is manifest hygiene, not a correctness
 * fix: every downstream consumer already collapses on the base `documentId`
 * (auto-scope's `Set`/`Map`, attribution), so the only artifact is a redundant
 * row if the raw `FetchMetadata.manifest` is read unaggregated.
 *
 * Keyed on the base `documentId` (the D0084 document key the diff and
 * attribution already join on; every producer here stamps a base `_id`, so this
 * is "one row per `_id`"). The winner comes from {@link richerManifestEntry} — a
 * pure function of entry content — so the surfaced row is independent of
 * `manifest.push` order (W-dedup-document-manifest).
 *
 * Exported for unit testing.
 */
export function dedupeManifestByDocId(manifest) {
    const byDoc = new Map();
    for (const entry of manifest) {
        const key = baseDocumentId(entry._id);
        const existing = byDoc.get(key);
        byDoc.set(key, existing ? richerManifestEntry(existing, entry) : entry);
    }
    return [...byDoc.values()];
}
/**
 * Pick the entry to surface when two manifest rows collide on a document key.
 *
 * Order-independent — a total order over entry content, so reordering the
 * `manifest.push` loops can never change which row wins. Precedence: prefer the
 * locator that names a `documentType` (D0084), then the release-version locator
 * (a `perspective` is pinned) over the published one, then the lexically-greater
 * `_rev` as a stable final tiebreak. A full tie means the same version read the
 * same way, so either entry surfaces identical data.
 *
 * Exported for unit testing.
 */
export function richerManifestEntry(a, b) {
    const rank = (e) => {
        const loc = e.locator?.kind === "sanity-content-lake" ? e.locator : undefined;
        return [loc?.documentType ? 1 : 0, loc?.perspective ? 1 : 0, e._rev];
    };
    const [aType, aPersp, aRev] = rank(a);
    const [bType, bPersp, bRev] = rank(b);
    if (aType !== bType)
        return aType > bType ? a : b;
    if (aPersp !== bPersp)
        return aPersp > bPersp ? a : b;
    if (aRev !== bRev)
        return aRev > bRev ? a : b;
    return a;
}
// ---------------------------------------------------------------------------
// SanityDocFetcher
// ---------------------------------------------------------------------------
/**
 * Whether a task rides the fetcher's doc-resolution flow. Literacy tasks
 * always qualify; agent-harness tasks qualify only when they declare
 * `context.docs`, so their canonical context is materialized to
 * `contexts/canonical/<id>.md` for sandbox provisioning. Every other mode is
 * excluded — narrowing to literacy only would silently drop agent-harness docs.
 *
 * Exported for unit testing; mirrors `shouldFetchDocsFor` in fetch-docs-step.
 */
export function isDocFetchRelevant(task) {
    if (task.mode === "literacy")
        return true;
    if (task.mode === "agent-harness") {
        return (task.context?.docs?.length ?? 0) > 0;
    }
    return false;
}
export class SanityDocFetcher {
    rootDir;
    features;
    /**
     * Per-URL cache of attachment-body fetches. A single `typesReference` doc is
     * now fetched once for the grader symbol index and again for each task that
     * renders it (relevance-scoped per D0069 §5), so without this a 658 KB
     * typedoc would be downloaded N+1 times per run. Sanity file-asset URLs are
     * content-addressed (the asset id is in the path), so a cached body never
     * goes stale. The promise is cached so concurrent callers dedupe too. A
     * failed fetch resolves to `null` (never rejects) and is also memoized, so a
     * transient failure fails fast for the whole run rather than re-downloading.
     */
    attachmentBodyCache = new Map();
    constructor(rootDir, options) {
        this.rootDir = rootDir;
        this.features = options?.features ?? null;
    }
    async fetch(tasks, source) {
        // Keep literacy tasks (they always ride the doc-resolution flow) plus
        // agent-harness tasks that declare `context.docs`. The latter need their
        // canonical context materialized to `contexts/canonical/<id>.md` so
        // `buildSandboxConfig` can provision it into the sandbox — narrowing to
        // literacy only would silently drop them (the mode's headline feature).
        const relevantTasks = tasks.filter(isDocFetchRelevant);
        return this.fetchInternal(relevantTasks, source);
    }
    async fetchInternal(tasks, source) {
        // 1. Collect all unique slugs and IDs across every task.
        // ID refs are resolved to slugs via a batch query so the rest of the
        // slug-based pipeline works unchanged.
        const allSlugs = new Set();
        // Slugs that arrived as bare `slug` refs (not id-resolved). Only these can
        // be ambiguous on a collision — an id ref already names a specific document.
        const slugRefSlugs = new Set();
        const idRefs = [];
        const pathRefs = [];
        const perspectiveRefs = [];
        for (const task of tasks) {
            for (const ref of task.context?.docs ?? []) {
                if (isSlugRef(ref)) {
                    allSlugs.add(ref.slug);
                    slugRefSlugs.add(ref.slug);
                }
                else if (isIdRef(ref)) {
                    idRefs.push({ id: ref.id, taskId: task.id });
                }
                else if (isPathRef(ref)) {
                    pathRefs.push({ path: ref.path, taskId: task.id });
                }
                else if (isPerspectiveRef(ref)) {
                    perspectiveRefs.push({
                        ...(task.area ? { area: task.area } : {}),
                        perspective: ref.perspective,
                        taskId: task.id,
                    });
                }
                else {
                    console.warn(`    [warn] Skipping unsupported canonical doc ref: ${canonicalDocRefLabel(ref)}`);
                }
            }
        }
        // Resolve ID refs. Articles get added to allSlugs (legacy slug-flow takes
        // over for fetch + render). Non-articles render directly via the renderer
        // registry and bypass the slug-keyed content map.
        const idResolution = await this.resolveIdRefs(idRefs, source);
        for (const slug of idResolution.idToSlug.values()) {
            allSlugs.add(slug);
        }
        // Resolve path refs → slugs
        const pathToSlug = await this.resolvePathRefsToSlugs(pathRefs, source);
        for (const slug of pathToSlug.values()) {
            allSlugs.add(slug);
        }
        // Resolve perspective refs → docs (one-to-many: each release expands to
        // every document versioned within it, of any type — D0084). Only the docs
        // that carry a slug join the article slug-flow for content fetch; the
        // document keys all flow through to auto-scope attribution.
        const perspectiveToDocs = await this.resolvePerspectiveRefsToDocs(perspectiveRefs, source);
        // Slug view of the expansion for the content-fetch path — restricted to
        // ARTICLE members, which alone ride the by-slug flow (`docsBySlug`,
        // `ARTICLE_BY_SLUG_*`). NON-article members have no article-slug join and
        // are rendered separately, below, through the type-agnostic
        // `DOCS_BY_IDS_QUERY` → renderer-registry path id-refs use — closing the gap
        // where a release of `openApiReference` (or any non-article) docs was
        // attributed to its tasks yet rendered NOTHING into the grading context
        // (D0085 / W-doctype-renderer-contract AC#2). All members still flow to
        // auto-scope via the document-key expansion in `perspectiveToDocs`.
        const perspectiveToSlugs = new Map();
        for (const [perspectiveId, docs] of perspectiveToDocs) {
            const slugs = docs
                .filter((d) => d.documentType === "article")
                .map((d) => d.slug)
                .filter((s) => Boolean(s));
            perspectiveToSlugs.set(perspectiveId, slugs);
            for (const slug of slugs) {
                allSlugs.add(slug);
            }
        }
        // Render the NON-article members of each expanded perspective ref through
        // the renderer registry, keyed by document id and fetched under the
        // release perspective (so they render from their release version). The
        // same content path the `id` ref takes; the by-slug flow above handles
        // article members. (D0085 / W-doctype-renderer-contract AC#2.)
        const perspectiveContent = await this.resolvePerspectiveDocContent(perspectiveToDocs, source);
        const metadata = {};
        // Task-level perspective refs expand one-to-many only at fetch time,
        // so return the expansion + per-task attribution to the step — release
        // auto-scope uses it to attribute the expanded document keys back to
        // their tasks (a non-article expansion is now attributable, D0084).
        if (perspectiveRefs.length > 0) {
            metadata.perspectiveExpansion = buildPerspectiveExpansion(perspectiveRefs, perspectiveToDocs);
        }
        // 2. Fetch document manifest (traceability metadata). Articles flow
        // through fetchManifest; non-article id-refs contribute their own
        // entries from the resolution we already did above.
        let manifest = await this.fetchManifest(allSlugs, source);
        for (const extra of idResolution.extraManifest) {
            manifest.push(extra);
        }
        // Non-article perspective members contribute their own traceability entries
        // (the article-only fetchManifest never saw them).
        for (const extra of perspectiveContent.extraManifest) {
            manifest.push(extra);
        }
        // A doc pinned by both an id ref and a perspective ref is resolved by both
        // loops above, so collapse to one row per document before the sort
        // (W-dedup-document-manifest). The downstream refetch + diff key on the
        // document id, so they still match the single surviving entry.
        manifest = dedupeManifestByDocId(manifest);
        manifest.sort((a, b) => a.slug.localeCompare(b.slug));
        this.warnOnAmbiguousSlugRefs(slugRefSlugs, manifest);
        if (manifest.length > 0) {
            metadata.manifest = manifest;
        }
        // 3. Perspective diffing — identify which docs changed in the release.
        // The diff joins on the document key (base documentId, D0084), so it is
        // fed the in-scope manifest (which carries `_id`, `slug`, and the locator's
        // `documentType` for every resolved doc of any type) rather than the
        // article-only slug set — a non-article release edit is now visible.
        let releaseImpact;
        if (source?.perspective) {
            releaseImpact = await this.identifyAffectedDocs(manifest, source);
            metadata.releaseImpact = releaseImpact;
            const affected = releaseImpact.modifiedDocs.length +
                releaseImpact.addedDocs.length +
                releaseImpact.removedDocs.length;
            console.log(`  Perspective diff: ${affected} of ${manifest.length} canonical docs affected`);
            if (releaseImpact.added.length > 0)
                console.log(`    Added:    ${releaseImpact.added.join(", ")}`);
            if (releaseImpact.modified.length > 0)
                console.log(`    Modified: ${releaseImpact.modified.join(", ")}`);
            if (releaseImpact.removed.length > 0)
                console.log(`    Removed:  ${releaseImpact.removed.join(", ")}`);
            // Visibility net: surface release edits that fall outside the evaluated
            // canonical set, so an applied fix to a doc no in-scope task references
            // is reported rather than silently unmeasured (the diff line above only
            // counts canonical docs).
            const uncovered = releaseImpact.uncovered ?? [];
            if (uncovered.length > 0) {
                console.warn(`  ⚠️  ${uncovered.length} doc(s) modified by the release are NOT covered by any in-scope task and will go unmeasured: ${uncovered.join(", ")}`);
            }
            // 3b. Refetch the release-affected id-ref docs under the perspective so a
            // non-article doc edited in the release renders from its RELEASE version
            // (the slug flow handles affected articles in fetchCanonicalDocs; this is
            // its id-keyed twin for the type-agnostic contentById path — D0084 /
            // doctype-agnostic-eval inventory #7). Mutates contentById + the manifest
            // entries in place; `metadata.manifest` shares the array reference.
            await this.refetchAffectedDocsByPerspective(idResolution.contentById, idResolution.typesReferenceById, manifest, releaseImpact, source);
        }
        // 4. Document overlay — resolve document IDs against canonical set
        let documentOverlay;
        if (source?.documentIds && source.documentIds.length > 0) {
            console.log(`  Resolving ${source.documentIds.length} document ID(s) against canonical set...`);
            documentOverlay = await this.resolveDocumentOverlay(source.documentIds, allSlugs, source);
            const summary = {
                appendedCount: documentOverlay.appendedDocs.length,
                documentIds: source.documentIds,
                replacedSlugs: [...documentOverlay.replacements.keys()],
            };
            metadata.documentOverlay = summary;
            console.log(`  Document overlay: ${documentOverlay.replacements.size} replacement(s), ${documentOverlay.appendedDocs.length} appended`);
        }
        // 5. URL content fetch — fetch direct URLs
        const urlContent = [];
        if (source?.urls && source.urls.length > 0) {
            const urlFetchMeta = [];
            console.log(`  Fetching ${source.urls.length} direct URL(s)...`);
            for (const url of source.urls) {
                const result = await fetchUrlContent(url, source.headers);
                urlFetchMeta.push({
                    contentLength: result.content?.length,
                    error: result.error,
                    method: result.method,
                    status: result.status,
                    url: result.url,
                });
                if (result.content) {
                    urlContent.push(result.content);
                    const tokens = estimateTokens(result.content);
                    console.log(`    ✅ ${url} (via ${result.method}, ~${tokens} tokens)`);
                }
                else {
                    console.warn(`    ⚠️  ${url}: ${result.error}`);
                }
            }
            const fetched = urlFetchMeta.filter((m) => m.method !== "failed");
            const failures = urlFetchMeta.filter((m) => m.method === "failed");
            metadata.urlFetch = {
                failures: failures.map((f) => ({ error: f.error, url: f.url })),
                fetchedUrls: fetched,
                totalFailed: failures.length,
                totalFetched: fetched.length,
            };
        }
        // 6. Fetch canonical docs and assemble per-task contexts
        //
        // Build a per-slug perspective map so each slug is fetched with the
        // right perspective client. Sources:
        //   a) release impact diff (added/modified slugs → source perspective)
        //   b) perspective ref expansion (expanded slugs → ref's perspective)
        // When both set a perspective for the same slug, the ref-level
        // perspective wins (it's more specific).
        const slugPerspective = new Map();
        // Source-level perspective affects release-impacted slugs
        if (source?.perspective) {
            for (const slug of [
                ...(releaseImpact?.added ?? []),
                ...(releaseImpact?.modified ?? []),
            ]) {
                slugPerspective.set(slug, source.perspective);
            }
        }
        // Perspective ref expansion — each slug gets its ref's perspective
        for (const [perspectiveId, slugs] of perspectiveToSlugs) {
            for (const slug of slugs) {
                slugPerspective.set(slug, perspectiveId);
            }
        }
        const affectedSlugs = new Set(slugPerspective.keys());
        const removedSlugs = new Set(releaseImpact?.removed ?? []);
        const docsBySlug = await this.fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, documentOverlay, source, slugPerspective);
        // 7. Assemble per-task context + symbol index. Both surfaces dispatch
        // through the renderer registry uniformly — the slug-flow, id-ref flow,
        // and overlay flow all produce `DocumentForRender` objects here, and the
        // registry decides how to render Markdown and extract symbols per
        // `_type`. Per-task assembly aggregates symbols across all references
        // attached to the task, writes:
        //   - `contexts/canonical/<task.id>.md` — existing rendered-doc
        //     artifact, consumed by Promptfoo `vars.docs`.
        //   - `contexts/canonical-symbols/<task.id>.md` — W0197 symbol-index
        //     artifact, written only when the task produced ≥1 symbol.
        //   - `contexts/canonical-symbols/manifest.json` — per-task manifest
        //     listing which tasks produced indexes (with counts + tier
        //     breakdowns). The grader-context consumer reads this manifest
        //     to decide between symbol-index and full-doc injection.
        const canonicalDir = join(this.rootDir, "contexts", "canonical");
        const symbolsDir = join(this.rootDir, "contexts", "canonical-symbols");
        mkdirSync(canonicalDir, { recursive: true });
        mkdirSync(symbolsDir, { recursive: true });
        const renderCtx = { fetchUrl: this.fetchAttachmentBody };
        const renderAndExtract = async (doc) => {
            const rendered = await renderDocument(doc, renderCtx);
            const symbols = await extractSymbolsForDoc(doc, renderCtx);
            return { markdown: rendered.content, symbols, slug: rendered.slug };
        };
        const consumeDoc = async (doc, fallbackSlug) => {
            const { markdown, symbols, slug } = await renderAndExtract(doc);
            if (!markdown)
                return null;
            return { markdown, slug: fallbackSlug ?? slug, symbols };
        };
        const symbolIndexManifest = [];
        const contexts = await Promise.all(tasks.map(async (task) => {
            // Phase 1 — resolve each ref to a slot. Most refs render+extract now
            // via the registry (slug-flow, perspective expansion, or the
            // pre-rendered non-article id-refs in contentById). typesReference
            // id-refs are deferred: their candidate markdown is relevance-scoped
            // to this task, rendered in phase 3 once the hints are known (D0069 §5).
            const slots = await Promise.all((task.context?.docs ?? []).map(async (ref) => {
                if (isPerspectiveRef(ref)) {
                    const expanded = perspectiveToSlugs.get(ref.perspective) ?? [];
                    const docs = expanded
                        .filter((slug) => !removedSlugs.has(slug))
                        .map((slug) => ({ slug, doc: docsBySlug.get(slug) }))
                        .filter((e) => e.doc !== undefined);
                    const articleRefs = await Promise.all(docs.map((e) => consumeDoc(e.doc, e.slug)));
                    // NON-article members were rendered once at resolution time
                    // through the registry; stitch them in by document id.
                    const nonArticleRefs = [];
                    for (const id of perspectiveContent.idsByPerspective.get(ref.perspective) ?? []) {
                        const entry = perspectiveContent.contentById.get(id);
                        if (entry) {
                            nonArticleRefs.push({
                                markdown: entry.content,
                                slug: entry.slug,
                                symbols: entry.symbolIndex,
                            });
                        }
                    }
                    return {
                        kind: "consumed",
                        refs: [
                            ...articleRefs.filter((c) => c !== null),
                            ...nonArticleRefs,
                        ],
                    };
                }
                if (isIdRef(ref) && idResolution.typesReferenceById.has(ref.id)) {
                    return {
                        kind: "deferred",
                        deferred: idResolution.typesReferenceById.get(ref.id),
                    };
                }
                if (isIdRef(ref) && idResolution.contentById.has(ref.id)) {
                    const entry = idResolution.contentById.get(ref.id);
                    return {
                        kind: "consumed",
                        refs: [
                            {
                                markdown: entry.content,
                                slug: entry.slug,
                                symbols: entry.symbolIndex,
                            },
                        ],
                    };
                }
                let slug;
                if (isSlugRef(ref)) {
                    slug = ref.slug;
                }
                else if (isIdRef(ref)) {
                    slug = idResolution.idToSlug.get(ref.id);
                }
                else if (isPathRef(ref)) {
                    slug = pathToSlug.get(ref.path);
                }
                if (!slug || removedSlugs.has(slug))
                    return { kind: "consumed", refs: [] };
                const doc = docsBySlug.get(slug);
                if (!doc)
                    return { kind: "consumed", refs: [] };
                const consumed = await consumeDoc(doc, slug);
                return { kind: "consumed", refs: consumed ? [consumed] : [] };
            }));
            // Overlay docs (appended) are always articles — `resolveDocumentOverlay`
            // fetches them via `fetchArticleById` — so they never carry a typedoc
            // attachment and render now with no relevance hints.
            const overlayConsumed = [];
            if (documentOverlay && documentOverlay.appendedDocs.length > 0) {
                const consumed = await Promise.all(documentOverlay.appendedDocs.map((doc) => consumeDoc(doc)));
                for (const c of consumed) {
                    if (c)
                        overlayConsumed.push(c);
                }
            }
            // Phase 2 — relevance hints (D0069 §2): the union of the symbols this
            // task's co-pinned articles/other refs legitimize and the identifier-
            // shaped tokens in its prompt + title. A deferred typesReference's own
            // symbols are intentionally excluded — including them would make every
            // export self-relevant and defeat the scoping.
            const relevanceSymbols = new Set();
            for (const slot of slots) {
                if (slot.kind !== "consumed")
                    continue;
                for (const ref of slot.refs)
                    for (const entry of ref.symbols.symbols)
                        relevanceSymbols.add(entry.symbol);
            }
            for (const ref of overlayConsumed)
                for (const entry of ref.symbols.symbols)
                    relevanceSymbols.add(entry.symbol);
            for (const token of extractRelevanceIdentifiers(task.prompt?.text ?? ""))
                relevanceSymbols.add(token);
            for (const token of extractRelevanceIdentifiers(task.title))
                relevanceSymbols.add(token);
            const relevanceHints = [...relevanceSymbols];
            // Phase 3 — render deferred typesReference docs with the hints, then
            // stitch every slot back into original ref order.
            const refResolutions = await Promise.all(slots.map(async (slot) => {
                if (slot.kind === "consumed")
                    return slot.refs;
                const rendered = await renderDocument(slot.deferred.doc, {
                    fetchUrl: this.fetchAttachmentBody,
                    relevanceSymbols: relevanceHints,
                });
                if (!rendered.content)
                    return [];
                return [
                    {
                        markdown: rendered.content,
                        slug: slot.deferred.slug,
                        symbols: slot.deferred.symbolIndex,
                    },
                ];
            }));
            const parts = [];
            const slugs = [];
            const taskIndexes = [];
            const consumed = [
                ...refResolutions.flat(),
                ...overlayConsumed,
            ];
            for (const c of consumed) {
                parts.push(c.markdown);
                slugs.push(c.slug);
                if (c.symbols.symbols.length > 0)
                    taskIndexes.push(c.symbols);
            }
            // Append URL-fetched content. URLs are raw markdown blobs from
            // external sources (no Sanity doc shape), so they don't contribute
            // to the per-task symbol index.
            if (urlContent.length > 0) {
                parts.push(...urlContent);
            }
            const combined = escapeNunjucks(parts.join("\n\n---\n\n"));
            const contextPath = join(canonicalDir, `${task.id}.md`);
            writeFileSync(contextPath, combined, "utf-8");
            // Aggregate per-task symbol index. Always emit a manifest entry —
            // entry presence is the consumer's signal that extraction ran.
            // The per-task .md file is only written when the index is
            // non-empty; a missing file under an entry-present manifest is
            // permitted (consumer treats it the same as count zero).
            const merged = mergeSymbolIndexes(taskIndexes);
            const tierBreakdown = symbolIndexTierBreakdown(merged);
            const symbolsRelativePath = `contexts/canonical-symbols/${task.id}.md`;
            if (merged.symbols.length > 0) {
                const indexMarkdown = renderSymbolIndex(merged, task.title);
                writeFileSync(join(symbolsDir, `${task.id}.md`), indexMarkdown, "utf-8");
            }
            symbolIndexManifest.push({
                taskId: task.id,
                path: symbolsRelativePath,
                symbolCount: merged.symbols.length,
                tierBreakdown,
            });
            return {
                taskId: task.id,
                content: combined,
                slugs,
                tokenCount: estimateTokens(combined),
            };
        }));
        // Persist the symbol-index manifest so the literacy compiler can read
        // it to decide between symbol-index and full-doc grader-context
        // injection per task. Sorted for stable diffs across runs.
        symbolIndexManifest.sort((a, b) => a.taskId.localeCompare(b.taskId));
        writeFileSync(join(symbolsDir, "manifest.json"), JSON.stringify({ entries: symbolIndexManifest }, null, 2), "utf-8");
        metadata.symbolIndexes = symbolIndexManifest;
        const hasMetadata = Object.keys(metadata).length > 0;
        return {
            contexts,
            ...(hasMetadata ? { metadata } : {}),
        };
    }
    // -----------------------------------------------------------------------
    // Private: Fetch document manifest
    // -----------------------------------------------------------------------
    async fetchManifest(allSlugs, source) {
        const client = source?.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        const allMetadata = await client.fetch(ARTICLES_METADATA_BY_SLUGS_QUERY, { slugs: [...allSlugs] });
        return allMetadata
            .map((m) => {
            const path = buildContextDocPath({
                sectionSlug: m.sectionSlug,
                slug: m.slug,
            });
            return {
                // ARTICLES_METADATA_BY_SLUGS_QUERY filters `_type == "article"`, so
                // the documentType is "article" without projecting `_type` (a query
                // change is out of scope for this slice — D0084 / W-doctype-locator-match-key).
                locator: buildSanityLocator(source, m._id, m._rev, "article"),
                _id: m._id,
                _rev: m._rev,
                slug: m.slug,
                ...(m.sectionSlug ? { sectionSlug: m.sectionSlug } : {}),
                ...(path ? { path } : {}),
                title: m.title,
            };
        })
            .sort((a, b) => a.slug.localeCompare(b.slug));
    }
    /**
     * Warn when a bare `slug` ref resolves to more than one published article.
     *
     * The by-slug content fetch (`ARTICLE_BY_SLUG_QUERY`, `...[0]`) can only
     * return the first match, so a colliding legacy slug ref silently injects an
     * arbitrary winner. Naming the candidate `_id`s surfaces the ambiguity so the
     * ref can be pinned to an id — the collision-safe path id refs already take.
     * Id-resolved slugs are excluded: those refs already name a specific
     * document, so their slug appearing on multiple articles is expected and not
     * a warning.
     */
    warnOnAmbiguousSlugRefs(slugRefSlugs, manifest) {
        if (slugRefSlugs.size === 0)
            return;
        const idsBySlug = new Map();
        for (const entry of manifest) {
            if (!slugRefSlugs.has(entry.slug))
                continue;
            const ids = idsBySlug.get(entry.slug) ?? [];
            ids.push(entry._id);
            idsBySlug.set(entry.slug, ids);
        }
        for (const [slug, ids] of idsBySlug) {
            if (ids.length <= 1)
                continue;
            console.warn(`    [warn] slug ref "${slug}" matches ${ids.length} published articles (${ids.join(", ")}); the by-slug fetch uses the first match — pin an id ref to disambiguate`);
        }
    }
    // -----------------------------------------------------------------------
    // Private: Resolve ID refs (type-agnostic)
    // -----------------------------------------------------------------------
    /**
     * Batch-resolve document ID refs without filtering on `_type`.
     *
     * Articles are routed back into the slug-flow (so manifest, perspective
     * diffing, and overlay handling continue to work unchanged). Non-articles
     * are rendered eagerly via the renderer registry — `typesReference` gets
     * a high-fidelity formatter; anything else falls through to the default
     * walker so authors can pin marketing pages, glossary entries, etc.
     * without an AILF code change.
     *
     * Three log shapes:
     *   - `info` — doc rendered with the default formatter (suggests a
     *     dedicated renderer would improve fidelity)
     *   - `warn` — registered renderer produced empty content (W0195 AC#3)
     *   - `warn` — id had no matching document (existed/wrong tenant/typo)
     */
    async resolveIdRefs(idRefs, source) {
        const idToSlug = new Map();
        const contentById = new Map();
        // typesReference docs are NOT rendered here. Their candidate markdown is
        // relevance-scoped per task (D0069 §5), so rendering is deferred to the
        // per-task assembly loop. The task-independent work — the grader symbol
        // index (unchanged grader path) and the raw doc for later rendering — is
        // captured now so the attachment is parsed once, not per task.
        const typesReferenceById = new Map();
        const extraManifest = [];
        if (idRefs.length === 0)
            return { idToSlug, contentById, typesReferenceById, extraManifest };
        const uniqueIds = [...new Set(idRefs.map((r) => r.id))];
        const taskByRef = new Map();
        for (const { id, taskId } of idRefs) {
            const existing = taskByRef.get(id) ?? [];
            existing.push(taskId);
            taskByRef.set(id, existing);
        }
        // Resolve id-ref content from the PUBLISHED baseline — the same "before"
        // state the slug flow fetches with `fetchArticleBySlug`. When a release
        // perspective is active the affected docs are refetched per document id
        // under the perspective by `refetchAffectedDocsByPerspective` (D0084 /
        // doctype-agnostic-eval inventory #7), the id-keyed twin of the article
        // affected-slug refetch in `fetchCanonicalDocs`. Keying that refetch on the
        // document id (not slug) is what lets a non-article doc — which never enters
        // `docsBySlug` and so cannot be refetched per-slug — render from its release
        // version rather than its published one. With no perspective active the
        // original client is used unchanged (drafts stay queryable, per the
        // DOCS_BY_IDS_QUERY contract).
        const client = source?.perspective
            ? createPublishedClient(source)
            : getSanityClient(toSanityOverrides(source));
        const docs = await client.fetch(DOCS_BY_IDS_QUERY, {
            ids: uniqueIds,
        });
        const docsById = new Map(docs.map((d) => [d._id, d]));
        // Net-new release-only docs — those introduced *by* the active release with
        // no published predecessor — exist only as `versions.<release>.<id>`, so the
        // published baseline above returns nothing for them and the ref would be
        // dropped (empty context → fetch-docs aborts). Fall back to the release
        // perspective for the ids the published baseline missed, so a pre-release
        // doc resolves and renders from its release version. Modified docs are found
        // in the published baseline and keep it — their release version is overlaid
        // later by `refetchAffectedDocsByPerspective` — so this only rescues the
        // net-new case (D0084 / doctype-agnostic-eval inventory #7 follow-up).
        if (source?.perspective) {
            const missingIds = uniqueIds.filter((id) => !docsById.has(id));
            if (missingIds.length > 0) {
                const perspectiveClient = createPerspectiveClient(source.perspective, source);
                const releaseDocs = await perspectiveClient.fetch(DOCS_BY_IDS_QUERY, { ids: missingIds });
                for (const doc of releaseDocs)
                    docsById.set(doc._id, doc);
            }
        }
        let articleCount = 0;
        let highFidelity = 0;
        let defaultFidelity = 0;
        for (const id of uniqueIds) {
            const taskIds = taskByRef.get(id) ?? [];
            const doc = docsById.get(id);
            if (!doc) {
                console.warn(`    [warn] doc "${id}" not found (referenced by task(s): ${taskIds.join(", ")})`);
                continue;
            }
            const renderCtx = { fetchUrl: this.fetchAttachmentBody };
            // Article id-refs keep the resolved slug for the slug-keyed machinery
            // (manifest, perspective diff, document overlay), but the *content* the
            // task receives is rendered from the document already fetched by _id and
            // served from `contentById`. Routing the body back through the by-slug
            // query would re-introduce the ambiguity the id ref exists to remove:
            // editorial slugs are not unique, so `ARTICLE_BY_SLUG_QUERY`'s `[0]` can
            // resolve to a different document than the one this ref named.
            if (doc._type === "article") {
                const slug = doc.slug;
                if (typeof slug === "string" && slug.length > 0) {
                    idToSlug.set(id, slug);
                    articleCount += 1;
                    const rendered = await renderDocument(doc, renderCtx);
                    if (rendered.content) {
                        const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
                        contentById.set(id, {
                            content: rendered.content,
                            slug,
                            type: doc._type,
                            symbolIndex,
                        });
                    }
                }
                else {
                    console.warn(`    [warn] article "${id}" has no slug (referenced by task(s): ${taskIds.join(", ")})`);
                }
                continue;
            }
            // typesReference: defer candidate markdown to the per-task loop so it can
            // be relevance-scoped (D0069 §5). Do the task-independent work now — the
            // grader symbol index (uncapped, unchanged) and traceability — and stash
            // the raw doc keyed by id. Always high-fidelity (a registered renderer),
            // and the metadata header means it is never empty, so the empty-content
            // and default-fidelity branches below don't apply.
            if (doc._type === "typesReference") {
                const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
                const slug = slugForDoc(doc);
                typesReferenceById.set(id, { doc, slug, symbolIndex });
                const _rev = doc._rev;
                const revision = typeof _rev === "string" && _rev ? _rev : undefined;
                const title = doc.title;
                extraManifest.push({
                    locator: buildSanityLocator(source, doc._id, revision, doc._type),
                    _id: doc._id,
                    _rev: revision ?? "",
                    slug,
                    title: typeof title === "string" ? title : `(${doc._type})`,
                });
                highFidelity += 1;
                continue;
            }
            const rendered = await renderDocument(doc, renderCtx);
            if (!rendered.content) {
                console.warn(`    [warn] doc "${id}" resolved as "${doc._type}" but produced empty context (referenced by task(s): ${taskIds.join(", ")})`);
                continue;
            }
            const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
            contentById.set(id, {
                content: rendered.content,
                slug: rendered.slug,
                type: doc._type,
                symbolIndex,
            });
            const _rev = doc._rev;
            const revision = typeof _rev === "string" && _rev ? _rev : undefined;
            const title = doc.title;
            extraManifest.push({
                locator: buildSanityLocator(source, doc._id, revision, doc._type),
                _id: doc._id,
                _rev: revision ?? "",
                slug: rendered.slug,
                title: typeof title === "string" ? title : `(${doc._type})`,
            });
            if (rendered.fidelity === "default") {
                console.log(`    [info] doc "${id}" rendered with default formatter — a dedicated renderer for "${doc._type}" would likely improve grader fidelity`);
                defaultFidelity += 1;
            }
            else {
                highFidelity += 1;
            }
        }
        if (articleCount > 0) {
            console.log(`  Resolved ${articleCount} article id ref(s) to slugs`);
        }
        if (highFidelity + defaultFidelity > 0) {
            console.log(`  Resolved ${highFidelity + defaultFidelity} non-article id ref(s) (${highFidelity} high-fidelity, ${defaultFidelity} default)`);
        }
        return { idToSlug, contentById, typesReferenceById, extraManifest };
    }
    /**
     * Fetch the raw body of a Sanity file asset URL, used by the
     * `typesReference` renderer to inline typedoc JSON. Returns `null`
     * on any HTTP/network failure rather than throwing — the renderer
     * surfaces a placeholder so the rest of the context still renders.
     *
     * Wrapped in a 30s `AbortSignal.timeout` so a slow CDN can't hang the
     * eval pipeline indefinitely. Timeouts surface as a single `null`
     * return like any other fetch failure. Results are memoized per URL (see
     * `attachmentBodyCache`).
     */
    fetchAttachmentBody = (url) => {
        const cached = this.attachmentBodyCache.get(url);
        if (cached)
            return cached;
        const pending = this.fetchAttachmentBodyUncached(url);
        this.attachmentBodyCache.set(url, pending);
        return pending;
    };
    fetchAttachmentBodyUncached = async (url) => {
        try {
            const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
            if (!response.ok) {
                console.warn(`    [warn] attachment fetch failed for ${url}: HTTP ${response.status}`);
                return null;
            }
            return await response.text();
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`    [warn] attachment fetch failed for ${url}: ${msg}`);
            return null;
        }
    };
    // -----------------------------------------------------------------------
    // Private: Resolve path refs to slugs
    // -----------------------------------------------------------------------
    /**
     * Resolve path-based canonical doc references to their article slugs.
     *
     * Handles two path formats:
     * - Simple path: "webhooks" → resolves as slug lookup
     * - Section-qualified path: "content-lake/webhooks" → matches section + slug
     *
     * @see docs/design-docs/canonical-doc-resolution.md
     */
    async resolvePathRefsToSlugs(pathRefs, source) {
        const result = new Map();
        if (pathRefs.length === 0)
            return result;
        const client = source?.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        for (const { path, taskId } of pathRefs) {
            const segments = path.split("/").filter(Boolean);
            const articleSlug = segments[segments.length - 1];
            const sectionSlug = segments.length > 1 ? segments.slice(0, -1).join("/") : undefined;
            let article;
            if (sectionSlug) {
                // Section-qualified path: disambiguate by section
                article = await client.fetch(ARTICLE_SLUG_BY_SECTION_PATH_QUERY, {
                    articleSlug,
                    sectionSlug,
                });
                if (!article) {
                    // Fallback: try without section qualifier (section slug might differ)
                    article = await client.fetch(ARTICLE_SLUG_BY_PATH_QUERY, { slug: articleSlug });
                    if (article) {
                        console.warn(`    [warn] Path "${path}" section "${sectionSlug}" didn't match (article found in section "${article.sectionSlug ?? "unknown"}")`);
                    }
                }
            }
            else {
                // Simple path: resolve by slug directly
                article = await client.fetch(ARTICLE_SLUG_BY_PATH_QUERY, { slug: articleSlug });
            }
            if (article) {
                result.set(path, article.slug);
            }
            else {
                console.warn(`    [warn] No article found for path "${path}" (referenced by task "${taskId}")`);
            }
        }
        if (result.size > 0) {
            console.log(`  Resolved ${result.size} path ref(s) to slugs`);
        }
        return result;
    }
    // -----------------------------------------------------------------------
    // Private: Resolve perspective refs to docs (one-to-many)
    // -----------------------------------------------------------------------
    /**
     * Expand perspective canonical doc refs to the **document keys** (D0084)
     * within each content release, type-agnostically.
     *
     * Each perspective ref resolves to ALL documents versioned in that release
     * — of any type — over the type-agnostic `DOCS_IN_RELEASE_QUERY`. This is
     * the one-to-many expansion described in the canonical-doc-resolution design
     * doc, generalized off the article-only `ARTICLES_IN_RELEASE_QUERY` so a
     * release of `openApiReference` (or any typeless) version docs expands to its
     * members rather than to zero. Each entry carries the base `documentId` (the
     * cross-version join key auto-scope intersects on), the `documentType` when
     * projected, and the `slug` for display (absent for typeless docs).
     *
     * The perspective value from the ref is used as both the release ID
     * for the versioned document query AND the client perspective for
     * fetching content. If a source-level perspective is also set, the
     * ref-level perspective takes precedence for its own resolution.
     *
     * Expansion is intentionally unconditional; a stale or unrelated pin is
     * surfaced through the non-fatal {@link warnOnSuspectPerspectivePin}
     * guard rather than gating the fetch.
     *
     * @see docs/design-docs/canonical-doc-resolution.md
     * @see docs/design-docs/doctype-agnostic-eval.md
     */
    async resolvePerspectiveRefsToDocs(perspectiveRefs, source) {
        const result = new Map();
        if (perspectiveRefs.length === 0)
            return result;
        // Deduplicate by perspective ID (multiple tasks may reference the same release)
        const uniquePerspectives = [
            ...new Set(perspectiveRefs.map((r) => r.perspective)),
        ];
        for (const perspectiveId of uniquePerspectives) {
            // Create a client with this specific perspective so versioned docs are visible
            const client = createPerspectiveClient(perspectiveId, source);
            const releaseDocs = await client.fetch(DOCS_IN_RELEASE_QUERY, { releaseId: perspectiveId });
            // The stale-pin relatedness guard is article+section specific; feed it
            // the docs that carry a slug, with their section when present (typeless
            // docs have no section→area mapping, so they leave the relatedness signal
            // inconclusive — quiet by design). The empty-expansion warning still
            // fires for a vanished release.
            this.warnOnSuspectPerspectivePin(perspectiveId, perspectiveRefs, releaseDocs
                .filter((d) => Boolean(d.slug))
                .map((d) => ({
                slug: d.slug,
                ...(d.sectionSlug ? { sectionSlug: d.sectionSlug } : {}),
            })));
            if (releaseDocs.length === 0) {
                result.set(perspectiveId, []);
                continue;
            }
            const docs = releaseDocs.map((d) => ({
                documentId: baseDocumentId(d._id),
                ...(d._type ? { documentType: d._type } : {}),
                ...(d.slug ? { slug: d.slug } : {}),
            }));
            result.set(perspectiveId, docs);
            console.log(`  Expanded perspective "${perspectiveId}" → ${docs.length} doc(s): ${docs.map(impactDocLabel).join(", ")}`);
        }
        return result;
    }
    /**
     * Non-fatal stale-pin guard for one perspective ref expansion.
     *
     * Warns when the pinned release expanded to no articles (release gone,
     * published, or empty) or when the expanded articles' sections are
     * unrelated to the pinning task's area per the feature-registry
     * section→area mapping (see {@link buildPerspectivePinWarnings}).
     * Warning only — expansion remains unconditional and the fetch never
     * fails because of a suspect pin.
     */
    warnOnSuspectPerspectivePin(perspectiveId, perspectiveRefs, articles) {
        const taskRefs = perspectiveRefs
            .filter((r) => r.perspective === perspectiveId)
            .map((r) => ({ taskId: r.taskId, ...(r.area ? { area: r.area } : {}) }));
        const warnings = buildPerspectivePinWarnings(perspectiveId, taskRefs, articles, this.features);
        for (const warning of warnings) {
            console.warn(`    ⚠️  ${warning}`);
        }
    }
    // -----------------------------------------------------------------------
    // Private: Render non-article perspective members through the registry
    // -----------------------------------------------------------------------
    /**
     * Render the NON-article members of each expanded perspective ref through the
     * type-agnostic `DOCS_BY_IDS_QUERY` → renderer-registry path — the same
     * content path id-refs take — so a release of `openApiReference` (or any
     * non-article) docs reaches the per-task grading context and the document
     * manifest, instead of being attributed-but-unrendered (the by-slug flow
     * returns nothing for a non-article slug). Closes
     * W-doctype-renderer-contract AC#2 / D0085.
     *
     * Article members are intentionally excluded — they ride the by-slug flow
     * (`fetchCanonicalDocs` → `ARTICLE_BY_SLUG_*`) that the manifest, release
     * diff, and document overlay all key on. Each non-article member is fetched
     * under its release perspective (so it renders from the release version, the
     * point of pinning a release) and rendered once here; the per-task assembly
     * loop stitches the pre-rendered content in by document id. Returns the
     * rendered content keyed by base `documentId`, the per-perspective member-id
     * lists, and manifest entries to merge.
     *
     * Empty/unresolved members are warned and skipped (mirrors `resolveIdRefs`).
     * Members already rendered from an earlier perspective are not refetched.
     */
    async resolvePerspectiveDocContent(perspectiveToDocs, source) {
        const contentById = new Map();
        const idsByPerspective = new Map();
        const extraManifest = [];
        if (perspectiveToDocs.size === 0) {
            return { contentById, idsByPerspective, extraManifest };
        }
        const renderCtx = { fetchUrl: this.fetchAttachmentBody };
        let rendered = 0;
        for (const [perspectiveId, docs] of perspectiveToDocs) {
            // Render the non-article members EXCEPT structural/navigation types
            // (`docsNavSection`, `redirect`, …). Those have no high-fidelity renderer,
            // so the default walker would serialize their nav-tree items as raw JSON
            // into the grading context — pure noise for the model. They stay in the
            // expansion envelope (auto-scope attribution, D0084); only their content
            // render is suppressed, mirroring the uncovered-docs warning's denylist
            // (W-doctype-perspective-structural-filter / D0085).
            const nonArticleIds = docs
                .filter((d) => d.documentType !== "article" &&
                !(d.documentType &&
                    NON_EVALUABLE_RELEASE_DOC_TYPES.has(d.documentType)))
                .map((d) => d.documentId);
            idsByPerspective.set(perspectiveId, nonArticleIds);
            // Resolve only the members not already rendered from another perspective.
            const idsToFetch = nonArticleIds.filter((id) => !contentById.has(id));
            if (idsToFetch.length === 0)
                continue;
            const client = createPerspectiveClient(perspectiveId, source);
            const fetched = await client.fetch(DOCS_BY_IDS_QUERY, { ids: idsToFetch });
            // The perspective client returns the published-form (base) `_id` for
            // versioned docs, so it matches the base `documentId` the expansion keys
            // on (mirrors the DOCS_IN_RELEASE_QUERY contract).
            const docsById = new Map(fetched.map((d) => [baseDocumentId(d._id), d]));
            for (const id of idsToFetch) {
                const doc = docsById.get(id);
                if (!doc) {
                    console.warn(`    [warn] perspective "${perspectiveId}" member "${id}" did not resolve under the release perspective`);
                    continue;
                }
                const result = await renderDocument(doc, renderCtx);
                if (!result.content) {
                    console.warn(`    [warn] perspective "${perspectiveId}" member "${id}" resolved as "${doc._type}" but produced empty context`);
                    continue;
                }
                const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
                contentById.set(id, {
                    content: result.content,
                    slug: result.slug,
                    symbolIndex,
                });
                const _rev = doc._rev;
                const revision = typeof _rev === "string" && _rev ? _rev : undefined;
                const title = doc.title;
                extraManifest.push({
                    locator: buildSanityLocator(source, doc._id, revision, doc._type),
                    _id: doc._id,
                    _rev: revision ?? "",
                    slug: result.slug,
                    title: typeof title === "string" ? title : `(${doc._type})`,
                });
                rendered += 1;
            }
        }
        if (rendered > 0) {
            console.log(`  Rendered ${rendered} non-article perspective member(s) via the renderer registry`);
        }
        return { contentById, idsByPerspective, extraManifest };
    }
    // -----------------------------------------------------------------------
    // Private: Perspective diffing
    // -----------------------------------------------------------------------
    /**
     * Diff published-vs-perspective on the **document key** (base `documentId`,
     * D0084) over the type-agnostic `DOCS_METADATA_BY_IDS_QUERY`, so a non-article
     * doc modified inside a content release is detected — the article-`slug` join
     * over `ARTICLES_METADATA_BY_SLUGS_QUERY` could not see it.
     *
     * The in-scope set is the manifest the fetcher already resolved (every doc of
     * any type, with `_id`, `slug`, and the locator's `documentType`). Both
     * clients are queried for the same base document ids; `_rev` drives the
     * modified test exactly as the slug join did. Each affected population is
     * returned both as document-key entries (`*Docs`, authoritative) and as the
     * slug `string[]` view for display / console / back-compat — a slugless doc
     * surfaces its base `documentId` in the slug view so it stays visible.
     */
    async identifyAffectedDocs(manifest, source) {
        // The in-scope document key is the base (prefix-normalized) `_id`. The
        // manifest carries `documentType` on its locator; fall back to it when the
        // metadata fetch returns no `_type` (e.g. removed-in-perspective docs).
        const inScopeIds = [...new Set(manifest.map((m) => baseDocumentId(m._id)))];
        const manifestByKey = new Map(manifest.map((m) => [baseDocumentId(m._id), m]));
        const emptyImpact = () => ({
            added: [],
            modified: [],
            removed: [],
            unchanged: inScopeIds.map((id) => manifestByKey.get(id)?.slug ?? id),
            addedDocs: [],
            modifiedDocs: [],
            removedDocs: [],
            unchangedDocs: inScopeIds.map((id) => toImpactDoc(id, manifestByKey)),
        });
        if (!source.perspective || inScopeIds.length === 0) {
            return emptyImpact();
        }
        const publishedClient = createPublishedClient(source);
        const perspectiveClient = createPerspectiveClient(source.perspective, source);
        const [publishedMeta, perspectiveMeta] = await Promise.all([
            publishedClient.fetch(DOCS_METADATA_BY_IDS_QUERY, {
                ids: inScopeIds,
            }),
            perspectiveClient.fetch(DOCS_METADATA_BY_IDS_QUERY, {
                ids: inScopeIds,
            }),
        ]);
        // Key on the base documentId so the published doc and its
        // `versions.<release>.<id>` version of itself join (a raw `_id` would not).
        const publishedMap = new Map(publishedMeta.map((d) => [baseDocumentId(d._id), d]));
        const perspectiveMap = new Map(perspectiveMeta.map((d) => [baseDocumentId(d._id), d]));
        const addedDocs = [];
        const modifiedDocs = [];
        const removedDocs = [];
        const unchangedDocs = [];
        for (const id of inScopeIds) {
            const pub = publishedMap.get(id);
            const persp = perspectiveMap.get(id);
            const doc = toImpactDoc(id, manifestByKey, pub ?? persp);
            if (!pub && persp) {
                addedDocs.push(doc);
            }
            else if (pub && !persp) {
                removedDocs.push(doc);
            }
            else if (pub && persp && pub._rev !== persp._rev) {
                modifiedDocs.push(doc);
            }
            else {
                unchangedDocs.push(doc);
            }
        }
        // Visibility net: the diff above only sees canonical docs of in-scope
        // tasks, so a release edit to any other doc is invisible to it. Compute
        // the full release footprint (every doc versioned in the release, any type)
        // and report the docs that fall outside the in-scope set, so applied fixes
        // to docs no task references are surfaced rather than silently unmeasured.
        // Only a content release has a curated footprint — a `drafts`/`raw` pin is
        // a dataset-wide view (everyone's open drafts), so the net stays absent
        // (= not computed) rather than indicting unrelated work-in-progress.
        // Best-effort: a footprint-query failure must never fail the run.
        const uncoveredDocs = isContentReleasePerspective(source.perspective)
            ? await this.identifyUncoveredReleaseDocs(source.perspective, perspectiveClient, inScopeIds)
            : undefined;
        return {
            added: addedDocs.map(impactDocLabel),
            modified: modifiedDocs.map(impactDocLabel),
            removed: removedDocs.map(impactDocLabel),
            unchanged: unchangedDocs.map(impactDocLabel),
            addedDocs,
            modifiedDocs,
            removedDocs,
            unchangedDocs,
            ...(uncoveredDocs
                ? {
                    uncovered: uncoveredDocs.map(impactDocLabel),
                    uncoveredDocs,
                }
                : {}),
        };
    }
    /**
     * Compute the docs versioned in the release that are NOT canonical docs of
     * any in-scope task, via the full type-agnostic `sanity::partOfRelease`
     * footprint (`DOCS_IN_RELEASE_QUERY`) — so a non-article release edit outside
     * the evaluated set is surfaced, not just articles.
     *
     * Returns `undefined` (not `[]`) when the footprint query fails, preserving
     * the three-valued `ReleaseImpact.uncovered` contract: absent = not computed,
     * `[]` = checked and fully covered, non-empty = unmeasured docs named.
     *
     * The caller gates on {@link isContentReleasePerspective}, so `releaseId`
     * is always a real release ID — never `drafts`/`raw`/`published`.
     */
    async identifyUncoveredReleaseDocs(releaseId, perspectiveClient, inScopeIds) {
        try {
            const releaseDocs = await perspectiveClient.fetch(DOCS_IN_RELEASE_QUERY, { releaseId });
            const footprint = releaseDocs.map((d) => ({
                documentId: baseDocumentId(d._id),
                ...(d._type ? { documentType: d._type } : {}),
                ...(d.slug ? { slug: d.slug } : {}),
            }));
            return computeUncoveredReleaseDocs(footprint, inScopeIds);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`    [warn] Could not compute the full release footprint (uncovered-edits check skipped): ${msg}`);
            return undefined;
        }
    }
    // -----------------------------------------------------------------------
    // Private: Document overlay
    // -----------------------------------------------------------------------
    async resolveDocumentOverlay(documentIds, canonicalSlugs, source) {
        const overlay = {
            appendedDocs: [],
            replacements: new Map(),
        };
        if (documentIds.length === 0)
            return overlay;
        const results = await Promise.all(documentIds.map(async (id) => {
            const doc = await this.fetchArticleById(id, source);
            return { doc, id };
        }));
        for (const { doc, id } of results) {
            if (!doc) {
                console.warn(`    [warn] No article found for document ID "${id}"`);
                continue;
            }
            const slug = readString(doc, "slug");
            if (slug && canonicalSlugs.has(slug)) {
                overlay.replacements.set(slug, doc);
                console.log(`    📄 Document ${id} → replaces canonical doc "${slug}"`);
            }
            else {
                overlay.appendedDocs.push(doc);
                const slugInfo = slug ? ` (slug: "${slug}")` : "";
                console.log(`    📄 Document ${id} → appended as additional context${slugInfo}`);
            }
        }
        return overlay;
    }
    // -----------------------------------------------------------------------
    // Private: Fetch individual articles
    // -----------------------------------------------------------------------
    async fetchArticleById(id, source) {
        const client = source.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        return client.fetch(ARTICLE_BY_ID_QUERY, { id });
    }
    async fetchArticleBySlug(slug) {
        const client = getSanityClient();
        const doc = await client.fetch(ARTICLE_BY_SLUG_QUERY, { slug });
        if (!doc) {
            console.warn(`    [warn] No article found for slug "${slug}"`);
            return null;
        }
        return doc;
    }
    async fetchArticleBySlugWithPerspective(slug, source) {
        if (!source.perspective) {
            return this.fetchArticleBySlug(slug);
        }
        const client = createPerspectiveClient(source.perspective, source);
        const doc = await client.fetch(ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, { slug });
        if (!doc) {
            console.warn(`    [warn] No article found for slug "${slug}" in perspective "${source.perspective}"`);
            return null;
        }
        return doc;
    }
    // -----------------------------------------------------------------------
    // Private: Fetch all canonical docs with perspective/overlay awareness
    // -----------------------------------------------------------------------
    async fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, overlay, source, slugPerspective) {
        const docsBySlug = new Map();
        for (const slug of allSlugs) {
            if (removedSlugs.has(slug))
                continue;
            // Check if this slug has a document overlay replacement
            if (overlay?.replacements.has(slug)) {
                console.log(`      Fetching: ${slug} (from document overlay)`);
                docsBySlug.set(slug, overlay.replacements.get(slug));
                continue;
            }
            let doc;
            if (affectedSlugs.has(slug)) {
                // Use the per-slug perspective when available; fall back to
                // source-level perspective. This ensures perspective-ref-expanded
                // slugs are fetched from their specific release even when no
                // source-level --sanity-perspective is set.
                const perspective = slugPerspective?.get(slug) ?? source?.perspective;
                if (perspective && source) {
                    const perspectiveSource = { ...source, perspective };
                    console.log(`      Fetching: ${slug} (from perspective: ${perspective})`);
                    doc = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
                }
                else {
                    // No perspective available — fetch from published
                    console.log(`      Fetching: ${slug}`);
                    doc = await this.fetchArticleBySlug(slug);
                }
            }
            else {
                console.log(`      Fetching: ${slug}`);
                doc = await this.fetchArticleBySlug(slug);
            }
            if (doc)
                docsBySlug.set(slug, doc);
        }
        return docsBySlug;
    }
    // -----------------------------------------------------------------------
    // Private: Refetch release-affected id-ref docs under the perspective
    // -----------------------------------------------------------------------
    /**
     * Refetch the release-affected id-ref docs under the release perspective and
     * overwrite their resolved content + manifest entry, so they enter the
     * rendered per-task context from their RELEASE version rather than the
     * published baseline that {@link resolveIdRefs} fetched (D0084 /
     * doctype-agnostic-eval inventory #7).
     *
     * This is the id-keyed twin of the article affected-slug refetch in
     * {@link fetchCanonicalDocs}: the slug flow refetches a changed article via
     * `ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY`, but a non-article never enters
     * `docsBySlug` (it has no slug join and rides the `contentById` path), so it
     * cannot be refetched per-slug. Keying the refetch on the document id — the
     * cross-version document key the diff already computed — lets any document
     * type render from its release version with no slug dependence.
     *
     * Affected = the `added` ∪ `modified` document keys from the release diff. The
     * refetch matches each resolved id-ref entry to an affected doc on the base
     * `documentId` (the author may pin a published id while the diff normalizes a
     * versioned one), refetches the whole affected set in one `DOCS_BY_IDS_QUERY`
     * under a perspective-stacked client, and re-renders through the same renderer
     * registry as the baseline resolution. Both the eagerly-rendered `contentById`
     * entries and the lazily-rendered `typesReferenceById` raw docs are
     * overwritten in place (a `typesReference` is rendered per task, so only its
     * stashed doc + symbol index need the release version), along with the
     * matching manifest entry's `_rev`/locator — so the manifest carries the
     * release `_rev` and the document version is recorded. Unresolved/empty docs
     * are left untouched.
     */
    async refetchAffectedDocsByPerspective(contentById, typesReferenceById, manifest, releaseImpact, source) {
        if (!source?.perspective || !releaseImpact)
            return;
        // The affected document keys (base ids) the diff found added or modified.
        const affectedKeys = new Set([...releaseImpact.addedDocs, ...releaseImpact.modifiedDocs].map((d) => d.documentId));
        if (affectedKeys.size === 0)
            return;
        // Match each already-resolved id-ref to an affected doc on the base id.
        // The maps are keyed by the pinned ref id (the published id the author
        // wrote); the diff keys on the normalized base id, so normalize both sides.
        const resolvedIds = [...contentById.keys(), ...typesReferenceById.keys()];
        const idsToRefetch = resolvedIds.filter((id) => affectedKeys.has(baseDocumentId(id)));
        if (idsToRefetch.length === 0)
            return;
        const client = createPerspectiveClient(source.perspective, source);
        const docs = await client.fetch(DOCS_BY_IDS_QUERY, {
            ids: idsToRefetch,
        });
        const docsById = new Map(docs.map((d) => [d._id, d]));
        const renderCtx = { fetchUrl: this.fetchAttachmentBody };
        let refetched = 0;
        for (const id of idsToRefetch) {
            const doc = docsById.get(id);
            if (!doc)
                continue;
            const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
            if (typesReferenceById.has(id)) {
                // typesReference renders lazily per task — overwrite its raw doc +
                // symbol index so the deferred render uses the release version.
                const existing = typesReferenceById.get(id);
                typesReferenceById.set(id, { ...existing, doc, symbolIndex });
            }
            else {
                const rendered = await renderDocument(doc, renderCtx);
                if (!rendered.content)
                    continue;
                const existing = contentById.get(id);
                contentById.set(id, {
                    content: rendered.content,
                    slug: existing.slug,
                    type: doc._type,
                    symbolIndex,
                });
            }
            // Record the release version on the manifest entry so the document
            // manifest reflects the release `_rev`/locator, not the published one.
            const _rev = doc._rev;
            const revision = typeof _rev === "string" && _rev ? _rev : undefined;
            const entry = manifest.find((m) => m._id === id);
            if (entry) {
                entry._rev = revision ?? entry._rev;
                entry.locator = buildSanityLocator(source, doc._id, revision, doc._type);
            }
            refetched += 1;
        }
        if (refetched > 0) {
            console.log(`  Refetched ${refetched} affected id-ref doc(s) under perspective "${source.perspective}"`);
        }
    }
    // -----------------------------------------------------------------------
    // Public non-port methods — opt-in CLI features
    // -----------------------------------------------------------------------
    /**
     * Generate feature-area context files (one per GROQ feature area query).
     *
     * This is NOT part of the DocFetcher port — it's an opt-in CLI feature
     * used by `ailf fetch-docs --include-feature-areas`.
     */
    async fetchFeatureAreaContexts(source) {
        const client = getSanityClient(toSanityOverrides(source), source);
        const contextsDir = join(this.rootDir, "contexts");
        mkdirSync(contextsDir, { recursive: true });
        console.log("Generating feature-area contexts...\n");
        for (const feature of ALL_FEATURE_AREAS) {
            const query = FEATURE_AREA_QUERIES[feature];
            const docs = await client.fetch(query);
            if (docs.length === 0) {
                console.warn(`  [warn] No articles found for "${feature}"`);
                continue;
            }
            const rendered = await Promise.all(docs.map((d) => renderDocument(d, { fetchUrl: this.fetchAttachmentBody })));
            const combined = rendered.map((r) => r.content).join("\n\n---\n\n");
            const outPath = join(contextsDir, `${feature}.md`);
            writeFileSync(outPath, escapeNunjucks(combined), "utf-8");
            console.log(`  ${feature}: ${docs.length} articles, ~${estimateTokens(combined)} tokens`);
        }
    }
    /**
     * Generate a full corpus context file (all articles in one markdown file).
     *
     * This is NOT part of the DocFetcher port — it's an opt-in CLI feature
     * used by `ailf fetch-docs --include-corpus`.
     */
    async fetchFullCorpus(source) {
        const client = getSanityClient(toSanityOverrides(source), source);
        const baseUrl = source?.baseUrl ?? "https://www.sanity.io/docs";
        console.log("\nGenerating full corpus...");
        const docs = await client.fetch(ALL_ARTICLES_QUERY);
        const rendered = await Promise.all(docs.map(async (d) => {
            const result = await renderDocument(d, {
                fetchUrl: this.fetchAttachmentBody,
            });
            // Corpus output prepends the article URL line so the dataset can
            // be consumed by RAG retrievers that key on canonical URL.
            const slug = readString(d, "slug") ?? result.slug;
            return `URL: ${baseUrl}/${slug}\n\n${result.content}`;
        }));
        const corpus = rendered.join("\n\n---\n\n");
        const outDir = join(this.rootDir, "contexts");
        mkdirSync(outDir, { recursive: true });
        writeFileSync(join(outDir, "full-corpus.md"), escapeNunjucks(corpus), "utf-8");
        console.log(`  full-corpus.md: ${docs.length} articles, ~${estimateTokens(corpus)} tokens`);
    }
}
