@sanity/ailf 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/config/package-surface.ts +37 -0
  2. package/config/preflight-scoring.ts +26 -0
  3. package/dist/_vendor/ailf-core/artifact-registry.d.ts +1 -1
  4. package/dist/_vendor/ailf-core/artifact-registry.js +47 -0
  5. package/dist/_vendor/ailf-core/config-helpers.d.ts +35 -0
  6. package/dist/_vendor/ailf-core/config-helpers.js +67 -0
  7. package/dist/_vendor/ailf-core/index.d.ts +1 -1
  8. package/dist/_vendor/ailf-core/index.js +1 -1
  9. package/dist/_vendor/ailf-core/ports/context.d.ts +18 -0
  10. package/dist/_vendor/ailf-core/ports/doc-fetcher.d.ts +30 -0
  11. package/dist/_vendor/ailf-core/ports/index.d.ts +3 -1
  12. package/dist/_vendor/ailf-core/ports/index.js +1 -0
  13. package/dist/_vendor/ailf-core/ports/mode-handler.d.ts +23 -0
  14. package/dist/_vendor/ailf-core/ports/package-surface-resolver.d.ts +71 -0
  15. package/dist/_vendor/ailf-core/ports/package-surface-resolver.js +36 -0
  16. package/dist/_vendor/ailf-core/schemas/eval-config.d.ts +6 -0
  17. package/dist/_vendor/ailf-core/schemas/eval-config.js +14 -0
  18. package/dist/_vendor/ailf-core/schemas/index.d.ts +1 -0
  19. package/dist/_vendor/ailf-core/schemas/index.js +1 -0
  20. package/dist/_vendor/ailf-core/schemas/symbol-preflight-report.d.ts +51 -0
  21. package/dist/_vendor/ailf-core/schemas/symbol-preflight-report.js +57 -0
  22. package/dist/_vendor/ailf-core/types/generalized-task.d.ts +20 -3
  23. package/dist/_vendor/ailf-core/types/index.d.ts +13 -1
  24. package/dist/_vendor/ailf-core/types/index.js +1 -0
  25. package/dist/_vendor/ailf-core/types/package-surface.d.ts +36 -0
  26. package/dist/_vendor/ailf-core/types/package-surface.js +13 -0
  27. package/dist/_vendor/ailf-core/types/preflight-scoring.d.ts +52 -0
  28. package/dist/_vendor/ailf-core/types/preflight-scoring.js +18 -0
  29. package/dist/_vendor/ailf-core/types/repo-config.d.ts +14 -0
  30. package/dist/_vendor/ailf-core/types/symbol-preflight-report.d.ts +66 -0
  31. package/dist/_vendor/ailf-core/types/symbol-preflight-report.js +25 -0
  32. package/dist/adapters/config-sources/file-config-adapter.js +1 -0
  33. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.d.ts +25 -5
  34. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.js +276 -95
  35. package/dist/adapters/index.d.ts +1 -0
  36. package/dist/adapters/index.js +1 -0
  37. package/dist/adapters/package-surface/dts-package-surface.d.ts +46 -0
  38. package/dist/adapters/package-surface/dts-package-surface.js +173 -0
  39. package/dist/adapters/package-surface/in-memory-package-surface.d.ts +15 -0
  40. package/dist/adapters/package-surface/in-memory-package-surface.js +28 -0
  41. package/dist/adapters/package-surface/index.d.ts +9 -0
  42. package/dist/adapters/package-surface/index.js +8 -0
  43. package/dist/adapters/package-surface/parse-dts-exports.d.ts +31 -0
  44. package/dist/adapters/package-surface/parse-dts-exports.js +54 -0
  45. package/dist/adapters/task-sources/repo-schemas.d.ts +22 -0
  46. package/dist/adapters/task-sources/repo-schemas.js +93 -1
  47. package/dist/adapters/task-sources/repo-task-source.js +11 -2
  48. package/dist/commands/pipeline-action.d.ts +2 -0
  49. package/dist/commands/pipeline-action.js +12 -0
  50. package/dist/commands/remote-pipeline.js +9 -2
  51. package/dist/commands/remote-results.d.ts +12 -1
  52. package/dist/commands/remote-results.js +25 -5
  53. package/dist/commands/validate-tasks.js +8 -2
  54. package/dist/composition-root.js +9 -0
  55. package/dist/config/package-surface.ts +37 -0
  56. package/dist/config/preflight-scoring.ts +26 -0
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +1 -1
  59. package/dist/orchestration/build-app-context.js +1 -0
  60. package/dist/orchestration/pipeline-orchestrator.d.ts +19 -1
  61. package/dist/orchestration/pipeline-orchestrator.js +38 -0
  62. package/dist/orchestration/steps/calculate-scores-step.js +11 -0
  63. package/dist/orchestration/steps/generate-configs-step.js +16 -1
  64. package/dist/orchestration/steps/run-eval-step.js +27 -0
  65. package/dist/pipeline/calculate-scores.d.ts +66 -5
  66. package/dist/pipeline/calculate-scores.js +141 -27
  67. package/dist/pipeline/compiler/index.d.ts +1 -1
  68. package/dist/pipeline/compiler/index.js +1 -1
  69. package/dist/pipeline/compiler/literacy-bridge.d.ts +9 -0
  70. package/dist/pipeline/compiler/literacy-bridge.js +2 -0
  71. package/dist/pipeline/compiler/mode-handlers/__fixtures__/agent-harness-example-tasks.js +0 -12
  72. package/dist/pipeline/compiler/mode-handlers/__fixtures__/knowledge-probe-example-tasks.js +0 -12
  73. package/dist/pipeline/compiler/mode-handlers/literacy/assertions.d.ts +1 -1
  74. package/dist/pipeline/compiler/mode-handlers/literacy/assertions.js +31 -4
  75. package/dist/pipeline/compiler/mode-handlers/literacy/compiler.js +190 -6
  76. package/dist/pipeline/compiler/mode-handlers/literacy/index.js +2 -0
  77. package/dist/pipeline/compiler/mode-handlers/literacy/types.d.ts +17 -2
  78. package/dist/pipeline/compiler/rubric-resolution.d.ts +17 -1
  79. package/dist/pipeline/compiler/rubric-resolution.js +78 -2
  80. package/dist/pipeline/compiler/scoring-bridge.d.ts +49 -2
  81. package/dist/pipeline/compiler/scoring-bridge.js +104 -10
  82. package/dist/pipeline/eval-fingerprint.d.ts +9 -0
  83. package/dist/pipeline/eval-fingerprint.js +7 -1
  84. package/dist/pipeline/preflight/compute-preflight.d.ts +67 -0
  85. package/dist/pipeline/preflight/compute-preflight.js +118 -0
  86. package/dist/pipeline/preflight/emit-symbol-preflight.d.ts +51 -0
  87. package/dist/pipeline/preflight/emit-symbol-preflight.js +102 -0
  88. package/dist/pipeline/preflight/load-package-surface.d.ts +14 -0
  89. package/dist/pipeline/preflight/load-package-surface.js +19 -0
  90. package/dist/pipeline/preflight/load-preflight-context.d.ts +13 -0
  91. package/dist/pipeline/preflight/load-preflight-context.js +25 -0
  92. package/dist/pipeline/preflight/load-preflight-scoring.d.ts +12 -0
  93. package/dist/pipeline/preflight/load-preflight-scoring.js +17 -0
  94. package/dist/pipeline/preflight/parse-imports.d.ts +62 -0
  95. package/dist/pipeline/preflight/parse-imports.js +125 -0
  96. package/dist/report-store.d.ts +8 -0
  97. package/dist/report-store.js +55 -6
  98. package/dist/sanity/document-renderers.d.ts +106 -0
  99. package/dist/sanity/document-renderers.js +307 -0
  100. package/dist/sanity/queries.d.ts +32 -11
  101. package/dist/sanity/queries.js +78 -0
  102. package/dist/sanity/symbol-index.d.ts +98 -0
  103. package/dist/sanity/symbol-index.js +615 -0
  104. package/dist/tasks/knowledge-probe/define-type-api.task.ts +2 -6
  105. package/dist/tasks/knowledge-probe/groq-projections.task.ts +0 -5
  106. package/dist/tasks/literacy/content-lake.task.ts +4 -10
  107. package/dist/tasks/literacy/frameworks.task.ts +2 -8
  108. package/dist/tasks/literacy/functions.task.ts +1 -4
  109. package/dist/tasks/literacy/groq.task.ts +3 -12
  110. package/dist/tasks/literacy/image-handling.task.ts +1 -4
  111. package/dist/tasks/literacy/nextjs-live.task.ts +1 -4
  112. package/dist/tasks/literacy/portable-text.task.ts +2 -8
  113. package/dist/tasks/literacy/studio-setup.task.ts +2 -8
  114. package/dist/tasks/literacy/visual-editing.task.ts +2 -8
  115. package/package.json +2 -1
  116. package/tasks/knowledge-probe/define-type-api.task.ts +2 -6
  117. package/tasks/knowledge-probe/groq-projections.task.ts +0 -5
  118. package/tasks/literacy/content-lake.task.ts +4 -10
  119. package/tasks/literacy/frameworks.task.ts +2 -8
  120. package/tasks/literacy/functions.task.ts +1 -4
  121. package/tasks/literacy/groq.task.ts +3 -12
  122. package/tasks/literacy/image-handling.task.ts +1 -4
  123. package/tasks/literacy/nextjs-live.task.ts +1 -4
  124. package/tasks/literacy/portable-text.task.ts +2 -8
  125. package/tasks/literacy/studio-setup.task.ts +2 -8
  126. package/tasks/literacy/visual-editing.task.ts +2 -8
@@ -18,8 +18,9 @@ import { join } from "path";
18
18
  import { canonicalDocRefLabel, isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../../_vendor/ailf-core/index.js";
19
19
  import { fetchUrlContent, } from "../../pipeline/fetch-url-content.js";
20
20
  import { createPerspectiveClient, createPublishedClient, getSanityClient, } from "../../sanity/client.js";
21
- import { toMarkdown } from "../../sanity/portable-text.js";
22
- 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_IN_RELEASE_QUERY, ARTICLES_METADATA_BY_SLUGS_QUERY, FEATURE_AREA_QUERIES, } from "../../sanity/queries.js";
21
+ import { extractSymbolsForDoc, renderDocument, } from "../../sanity/document-renderers.js";
22
+ import { mergeSymbolIndexes, renderSymbolIndex, symbolIndexTierBreakdown, } from "../../sanity/symbol-index.js";
23
+ 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_IN_RELEASE_QUERY, ARTICLES_METADATA_BY_SLUGS_QUERY, DOCS_BY_IDS_QUERY, FEATURE_AREA_QUERIES, } from "../../sanity/queries.js";
23
24
  // ---------------------------------------------------------------------------
24
25
  // Helpers
25
26
  // ---------------------------------------------------------------------------
@@ -34,11 +35,14 @@ function escapeNunjucks(text) {
34
35
  function estimateTokens(text) {
35
36
  return Math.ceil(text.length / 4);
36
37
  }
37
- function formatArticle(doc) {
38
- const sectionLabel = doc.section ? `Section: ${doc.section.title}\n` : "";
39
- const desc = doc.description ? `${doc.description}\n\n` : "";
40
- const markdown = toMarkdown(doc.content ?? []);
41
- return `## ${doc.title}\n\n${sectionLabel}${desc}${markdown}`;
38
+ /**
39
+ * Read a `DocumentForRender` field that the GROQ projection set as a
40
+ * scalar string, gracefully returning `undefined` when the projection
41
+ * shape is empty/unexpected (defensive for partial overlay docs).
42
+ */
43
+ function readString(doc, key) {
44
+ const value = doc[key];
45
+ return typeof value === "string" ? value : undefined;
42
46
  }
43
47
  /**
44
48
  * Bridge DocSourceConfig (domain type) to getSanityClient overrides.
@@ -99,9 +103,11 @@ export class SanityDocFetcher {
99
103
  }
100
104
  }
101
105
  }
102
- // Resolve ID refs slugs via batch query
103
- const idToSlug = await this.resolveIdRefsToSlugs(idRefs, source);
104
- for (const slug of idToSlug.values()) {
106
+ // Resolve ID refs. Articles get added to allSlugs (legacy slug-flow takes
107
+ // over for fetch + render). Non-articles render directly via the renderer
108
+ // registry and bypass the slug-keyed content map.
109
+ const idResolution = await this.resolveIdRefs(idRefs, source);
110
+ for (const slug of idResolution.idToSlug.values()) {
105
111
  allSlugs.add(slug);
106
112
  }
107
113
  // Resolve path refs → slugs
@@ -118,8 +124,14 @@ export class SanityDocFetcher {
118
124
  }
119
125
  }
120
126
  const metadata = {};
121
- // 2. Fetch document manifest (traceability metadata)
127
+ // 2. Fetch document manifest (traceability metadata). Articles flow
128
+ // through fetchManifest; non-article id-refs contribute their own
129
+ // entries from the resolution we already did above.
122
130
  const manifest = await this.fetchManifest(allSlugs, source);
131
+ for (const extra of idResolution.extraManifest) {
132
+ manifest.push(extra);
133
+ }
134
+ manifest.sort((a, b) => a.slug.localeCompare(b.slug));
123
135
  if (manifest.length > 0) {
124
136
  metadata.manifest = manifest;
125
137
  }
@@ -145,12 +157,12 @@ export class SanityDocFetcher {
145
157
  console.log(` Resolving ${source.documentIds.length} document ID(s) against canonical set...`);
146
158
  documentOverlay = await this.resolveDocumentOverlay(source.documentIds, allSlugs, source);
147
159
  const summary = {
148
- appendedCount: documentOverlay.appendedContent.length,
160
+ appendedCount: documentOverlay.appendedDocs.length,
149
161
  documentIds: source.documentIds,
150
162
  replacedSlugs: [...documentOverlay.replacements.keys()],
151
163
  };
152
164
  metadata.documentOverlay = summary;
153
- console.log(` Document overlay: ${documentOverlay.replacements.size} replacement(s), ${documentOverlay.appendedContent.length} appended`);
165
+ console.log(` Document overlay: ${documentOverlay.replacements.size} replacement(s), ${documentOverlay.appendedDocs.length} appended`);
154
166
  }
155
167
  // 5. URL content fetch — fetch direct URLs
156
168
  const urlContent = [];
@@ -210,66 +222,145 @@ export class SanityDocFetcher {
210
222
  }
211
223
  const affectedSlugs = new Set(slugPerspective.keys());
212
224
  const removedSlugs = new Set(releaseImpact?.removed ?? []);
213
- const contentBySlug = await this.fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, documentOverlay, source, slugPerspective);
214
- // 7. Assemble per-task context, write files, build DocContext[]
225
+ const docsBySlug = await this.fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, documentOverlay, source, slugPerspective);
226
+ // 7. Assemble per-task context + symbol index. Both surfaces dispatch
227
+ // through the renderer registry uniformly — the slug-flow, id-ref flow,
228
+ // and overlay flow all produce `DocumentForRender` objects here, and the
229
+ // registry decides how to render Markdown and extract symbols per
230
+ // `_type`. Per-task assembly aggregates symbols across all references
231
+ // attached to the task, writes:
232
+ // - `contexts/canonical/<task.id>.md` — existing rendered-doc
233
+ // artifact, consumed by Promptfoo `vars.docs`.
234
+ // - `contexts/canonical-symbols/<task.id>.md` — W0197 symbol-index
235
+ // artifact, written only when the task produced ≥1 symbol.
236
+ // - `contexts/canonical-symbols/manifest.json` — per-task manifest
237
+ // listing which tasks produced indexes (with counts + tier
238
+ // breakdowns). The grader-context consumer reads this manifest
239
+ // to decide between symbol-index and full-doc injection.
215
240
  const canonicalDir = join(this.rootDir, "contexts", "canonical");
241
+ const symbolsDir = join(this.rootDir, "contexts", "canonical-symbols");
216
242
  mkdirSync(canonicalDir, { recursive: true });
217
- const contexts = tasks.map((task) => {
218
- const parts = [];
219
- const slugs = [];
220
- for (const ref of task.context?.docs ?? []) {
221
- // Perspective refs expand to multiple slugs
243
+ mkdirSync(symbolsDir, { recursive: true });
244
+ const renderCtx = { fetchUrl: this.fetchAttachmentBody };
245
+ const renderAndExtract = async (doc) => {
246
+ const rendered = await renderDocument(doc, renderCtx);
247
+ const symbols = await extractSymbolsForDoc(doc, renderCtx);
248
+ return { markdown: rendered.content, symbols, slug: rendered.slug };
249
+ };
250
+ const consumeDoc = async (doc, fallbackSlug) => {
251
+ const { markdown, symbols, slug } = await renderAndExtract(doc);
252
+ if (!markdown)
253
+ return null;
254
+ return { markdown, slug: fallbackSlug ?? slug, symbols };
255
+ };
256
+ const symbolIndexManifest = [];
257
+ const contexts = await Promise.all(tasks.map(async (task) => {
258
+ // Resolve each ref to a "consumed" record (or null). Refs that
259
+ // hit the slug-flow / overlay path render+extract via the
260
+ // registry; non-article id-refs were already rendered+extracted
261
+ // in resolveIdRefs and live in contentById.
262
+ const refResolutions = await Promise.all((task.context?.docs ?? []).map(async (ref) => {
222
263
  if (isPerspectiveRef(ref)) {
223
264
  const expanded = perspectiveToSlugs.get(ref.perspective) ?? [];
224
- for (const slug of expanded) {
225
- if (removedSlugs.has(slug))
226
- continue;
227
- const content = contentBySlug.get(slug) ?? "";
228
- if (content) {
229
- parts.push(content);
230
- slugs.push(slug);
231
- }
232
- }
233
- continue;
265
+ const docs = expanded
266
+ .filter((slug) => !removedSlugs.has(slug))
267
+ .map((slug) => ({ slug, doc: docsBySlug.get(slug) }))
268
+ .filter((e) => e.doc !== undefined);
269
+ const consumed = await Promise.all(docs.map((e) => consumeDoc(e.doc, e.slug)));
270
+ return consumed.filter((c) => c !== null);
271
+ }
272
+ if (isIdRef(ref) && idResolution.contentById.has(ref.id)) {
273
+ const entry = idResolution.contentById.get(ref.id);
274
+ return [
275
+ {
276
+ markdown: entry.content,
277
+ slug: entry.slug,
278
+ symbols: entry.symbolIndex,
279
+ },
280
+ ];
234
281
  }
235
282
  let slug;
236
283
  if (isSlugRef(ref)) {
237
284
  slug = ref.slug;
238
285
  }
239
286
  else if (isIdRef(ref)) {
240
- slug = idToSlug.get(ref.id);
287
+ slug = idResolution.idToSlug.get(ref.id);
241
288
  }
242
289
  else if (isPathRef(ref)) {
243
290
  slug = pathToSlug.get(ref.path);
244
291
  }
245
292
  if (!slug)
246
- continue;
293
+ return null;
247
294
  if (removedSlugs.has(slug))
248
- continue;
249
- const content = contentBySlug.get(slug) ?? "";
250
- if (content) {
251
- parts.push(content);
252
- slugs.push(slug);
295
+ return null;
296
+ const doc = docsBySlug.get(slug);
297
+ if (!doc)
298
+ return null;
299
+ const consumed = await consumeDoc(doc, slug);
300
+ return consumed ? [consumed] : null;
301
+ }));
302
+ // Overlay docs (appended) flow through the same registry dispatch.
303
+ const overlayConsumed = [];
304
+ if (documentOverlay && documentOverlay.appendedDocs.length > 0) {
305
+ const consumed = await Promise.all(documentOverlay.appendedDocs.map((doc) => consumeDoc(doc)));
306
+ for (const c of consumed) {
307
+ if (c)
308
+ overlayConsumed.push(c);
253
309
  }
254
310
  }
255
- // Append extra documents from overlay that didn't match canonical slugs
256
- if (documentOverlay && documentOverlay.appendedContent.length > 0) {
257
- parts.push(...documentOverlay.appendedContent);
311
+ const parts = [];
312
+ const slugs = [];
313
+ const taskIndexes = [];
314
+ const consumed = [
315
+ ...refResolutions.flat().filter((c) => c !== null),
316
+ ...overlayConsumed,
317
+ ];
318
+ for (const c of consumed) {
319
+ parts.push(c.markdown);
320
+ slugs.push(c.slug);
321
+ if (c.symbols.symbols.length > 0)
322
+ taskIndexes.push(c.symbols);
258
323
  }
259
- // Append URL-fetched content
324
+ // Append URL-fetched content. URLs are raw markdown blobs from
325
+ // external sources (no Sanity doc shape), so they don't contribute
326
+ // to the per-task symbol index.
260
327
  if (urlContent.length > 0) {
261
328
  parts.push(...urlContent);
262
329
  }
263
330
  const combined = escapeNunjucks(parts.join("\n\n---\n\n"));
264
331
  const contextPath = join(canonicalDir, `${task.id}.md`);
265
332
  writeFileSync(contextPath, combined, "utf-8");
333
+ // Aggregate per-task symbol index. Always emit a manifest entry —
334
+ // entry presence is the consumer's signal that extraction ran.
335
+ // The per-task .md file is only written when the index is
336
+ // non-empty; a missing file under an entry-present manifest is
337
+ // permitted (consumer treats it the same as count zero).
338
+ const merged = mergeSymbolIndexes(taskIndexes);
339
+ const tierBreakdown = symbolIndexTierBreakdown(merged);
340
+ const symbolsRelativePath = `contexts/canonical-symbols/${task.id}.md`;
341
+ if (merged.symbols.length > 0) {
342
+ const indexMarkdown = renderSymbolIndex(merged, task.title);
343
+ writeFileSync(join(symbolsDir, `${task.id}.md`), indexMarkdown, "utf-8");
344
+ }
345
+ symbolIndexManifest.push({
346
+ taskId: task.id,
347
+ path: symbolsRelativePath,
348
+ symbolCount: merged.symbols.length,
349
+ tierBreakdown,
350
+ });
266
351
  return {
267
352
  taskId: task.id,
268
353
  content: combined,
269
354
  slugs,
270
355
  tokenCount: estimateTokens(combined),
271
356
  };
272
- });
357
+ }));
358
+ // Persist the symbol-index manifest so the literacy compiler can read
359
+ // it to decide between symbol-index and full-doc grader-context
360
+ // injection per task. Sorted for stable diffs across runs.
361
+ symbolIndexManifest.sort((a, b) => a.taskId.localeCompare(b.taskId));
362
+ writeFileSync(join(symbolsDir, "manifest.json"), JSON.stringify({ entries: symbolIndexManifest }, null, 2), "utf-8");
363
+ metadata.symbolIndexes = symbolIndexManifest;
273
364
  const hasMetadata = Object.keys(metadata).length > 0;
274
365
  return {
275
366
  contexts,
@@ -289,40 +380,130 @@ export class SanityDocFetcher {
289
380
  .sort((a, b) => a.slug.localeCompare(b.slug));
290
381
  }
291
382
  // -----------------------------------------------------------------------
292
- // Private: Resolve ID refs to slugs
383
+ // Private: Resolve ID refs (type-agnostic)
293
384
  // -----------------------------------------------------------------------
294
385
  /**
295
- * Batch-resolve document ID refs to their article slugs.
386
+ * Batch-resolve document ID refs without filtering on `_type`.
387
+ *
388
+ * Articles are routed back into the slug-flow (so manifest, perspective
389
+ * diffing, and overlay handling continue to work unchanged). Non-articles
390
+ * are rendered eagerly via the renderer registry — `typesReference` gets
391
+ * a high-fidelity formatter; anything else falls through to the default
392
+ * walker so authors can pin marketing pages, glossary entries, etc.
393
+ * without an AILF code change.
296
394
  *
297
- * This bridges IdDocRef entries into the slug-based fetch pipeline.
298
- * Articles are queried by _id and their slugs are returned for use
299
- * in the existing slug-based content map.
395
+ * Three log shapes:
396
+ * - `info` doc rendered with the default formatter (suggests a
397
+ * dedicated renderer would improve fidelity)
398
+ * - `warn` — registered renderer produced empty content (W0195 AC#3)
399
+ * - `warn` — id had no matching document (existed/wrong tenant/typo)
300
400
  */
301
- async resolveIdRefsToSlugs(idRefs, source) {
302
- const result = new Map();
401
+ async resolveIdRefs(idRefs, source) {
402
+ const idToSlug = new Map();
403
+ const contentById = new Map();
404
+ const extraManifest = [];
303
405
  if (idRefs.length === 0)
304
- return result;
406
+ return { idToSlug, contentById, extraManifest };
305
407
  const uniqueIds = [...new Set(idRefs.map((r) => r.id))];
408
+ const taskByRef = new Map();
409
+ for (const { id, taskId } of idRefs) {
410
+ const existing = taskByRef.get(id) ?? [];
411
+ existing.push(taskId);
412
+ taskByRef.set(id, existing);
413
+ }
306
414
  const client = source?.perspective
307
415
  ? createPerspectiveClient(source.perspective, source)
308
416
  : getSanityClient(toSanityOverrides(source));
309
- // Batch query: fetch slug for each document ID
310
- const articles = await client.fetch(`*[_type == "article" && _id in $ids] { _id, "slug": slug.current }`, { ids: uniqueIds });
311
- const idToSlugMap = new Map(articles.map((a) => [a._id, a.slug]));
312
- for (const { id, taskId } of idRefs) {
313
- const slug = idToSlugMap.get(id);
314
- if (slug) {
315
- result.set(id, slug);
417
+ const docs = await client.fetch(DOCS_BY_IDS_QUERY, {
418
+ ids: uniqueIds,
419
+ });
420
+ const docsById = new Map(docs.map((d) => [d._id, d]));
421
+ let articleCount = 0;
422
+ let highFidelity = 0;
423
+ let defaultFidelity = 0;
424
+ for (const id of uniqueIds) {
425
+ const taskIds = taskByRef.get(id) ?? [];
426
+ const doc = docsById.get(id);
427
+ if (!doc) {
428
+ console.warn(` [warn] doc "${id}" not found (referenced by task(s): ${taskIds.join(", ")})`);
429
+ continue;
430
+ }
431
+ // Article id-refs flow back into the slug-keyed pipeline so manifest,
432
+ // perspective diff, and document overlay continue to work. The slug
433
+ // projection in DOCS_BY_IDS_QUERY mirrors ARTICLE_PROJECTION.
434
+ if (doc._type === "article") {
435
+ const slug = doc.slug;
436
+ if (typeof slug === "string" && slug.length > 0) {
437
+ idToSlug.set(id, slug);
438
+ articleCount += 1;
439
+ }
440
+ else {
441
+ console.warn(` [warn] article "${id}" has no slug (referenced by task(s): ${taskIds.join(", ")})`);
442
+ }
443
+ continue;
444
+ }
445
+ const renderCtx = { fetchUrl: this.fetchAttachmentBody };
446
+ const rendered = await renderDocument(doc, renderCtx);
447
+ if (!rendered.content) {
448
+ console.warn(` [warn] doc "${id}" resolved as "${doc._type}" but produced empty context (referenced by task(s): ${taskIds.join(", ")})`);
449
+ continue;
450
+ }
451
+ const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
452
+ contentById.set(id, {
453
+ content: rendered.content,
454
+ slug: rendered.slug,
455
+ type: doc._type,
456
+ symbolIndex,
457
+ });
458
+ const _rev = doc._rev;
459
+ const title = doc.title;
460
+ extraManifest.push({
461
+ _id: doc._id,
462
+ _rev: typeof _rev === "string" ? _rev : "",
463
+ slug: rendered.slug,
464
+ title: typeof title === "string" ? title : `(${doc._type})`,
465
+ });
466
+ if (rendered.fidelity === "default") {
467
+ console.log(` [info] doc "${id}" rendered with default formatter — a dedicated renderer for "${doc._type}" would likely improve grader fidelity`);
468
+ defaultFidelity += 1;
316
469
  }
317
470
  else {
318
- console.warn(` [warn] No article found for document ID "${id}" (referenced by task "${taskId}")`);
471
+ highFidelity += 1;
319
472
  }
320
473
  }
321
- if (result.size > 0) {
322
- console.log(` Resolved ${result.size} document ID ref(s) to slugs`);
474
+ if (articleCount > 0) {
475
+ console.log(` Resolved ${articleCount} article id ref(s) to slugs`);
323
476
  }
324
- return result;
477
+ if (highFidelity + defaultFidelity > 0) {
478
+ console.log(` Resolved ${highFidelity + defaultFidelity} non-article id ref(s) (${highFidelity} high-fidelity, ${defaultFidelity} default)`);
479
+ }
480
+ return { idToSlug, contentById, extraManifest };
325
481
  }
482
+ /**
483
+ * Fetch the raw body of a Sanity file asset URL, used by the
484
+ * `typesReference` renderer to inline typedoc JSON. Returns `null`
485
+ * on any HTTP/network failure rather than throwing — the renderer
486
+ * surfaces a placeholder so the rest of the context still renders.
487
+ *
488
+ * Wrapped in a 30s `AbortSignal.timeout` so a slow CDN can't hang the
489
+ * eval pipeline indefinitely. Timeouts surface as a single `null`
490
+ * return like any other fetch failure.
491
+ */
492
+ fetchAttachmentBody = async (url) => {
493
+ try {
494
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
495
+ if (!response.ok) {
496
+ console.warn(` [warn] attachment fetch failed for ${url}: HTTP ${response.status}`);
497
+ return null;
498
+ }
499
+ return await response.text();
500
+ }
501
+ catch (err) {
502
+ const msg = err instanceof Error ? err.message : String(err);
503
+ console.warn(` [warn] attachment fetch failed for ${url}: ${msg}`);
504
+ return null;
505
+ }
506
+ };
326
507
  // -----------------------------------------------------------------------
327
508
  // Private: Resolve path refs to slugs
328
509
  // -----------------------------------------------------------------------
@@ -470,7 +651,7 @@ export class SanityDocFetcher {
470
651
  // -----------------------------------------------------------------------
471
652
  async resolveDocumentOverlay(documentIds, canonicalSlugs, source) {
472
653
  const overlay = {
473
- appendedContent: [],
654
+ appendedDocs: [],
474
655
  replacements: new Map(),
475
656
  };
476
657
  if (documentIds.length === 0)
@@ -484,16 +665,14 @@ export class SanityDocFetcher {
484
665
  console.warn(` [warn] No article found for document ID "${id}"`);
485
666
  continue;
486
667
  }
487
- const content = formatArticle(doc);
488
- if (!content)
489
- continue;
490
- if (doc.slug && canonicalSlugs.has(doc.slug)) {
491
- overlay.replacements.set(doc.slug, content);
492
- console.log(` 📄 Document ${id} → replaces canonical doc "${doc.slug}"`);
668
+ const slug = readString(doc, "slug");
669
+ if (slug && canonicalSlugs.has(slug)) {
670
+ overlay.replacements.set(slug, doc);
671
+ console.log(` 📄 Document ${id} → replaces canonical doc "${slug}"`);
493
672
  }
494
673
  else {
495
- overlay.appendedContent.push(content);
496
- const slugInfo = doc.slug ? ` (slug: "${doc.slug}")` : "";
674
+ overlay.appendedDocs.push(doc);
675
+ const slugInfo = slug ? ` (slug: "${slug}")` : "";
497
676
  console.log(` 📄 Document ${id} → appended as additional context${slugInfo}`);
498
677
  }
499
678
  }
@@ -513,9 +692,9 @@ export class SanityDocFetcher {
513
692
  const doc = await client.fetch(ARTICLE_BY_SLUG_QUERY, { slug });
514
693
  if (!doc) {
515
694
  console.warn(` [warn] No article found for slug "${slug}"`);
516
- return "";
695
+ return null;
517
696
  }
518
- return formatArticle(doc);
697
+ return doc;
519
698
  }
520
699
  async fetchArticleBySlugWithPerspective(slug, source) {
521
700
  if (!source.perspective) {
@@ -525,24 +704,26 @@ export class SanityDocFetcher {
525
704
  const doc = await client.fetch(ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, { slug });
526
705
  if (!doc) {
527
706
  console.warn(` [warn] No article found for slug "${slug}" in perspective "${source.perspective}"`);
528
- return "";
707
+ return null;
529
708
  }
530
- return formatArticle(doc);
709
+ return doc;
531
710
  }
532
711
  // -----------------------------------------------------------------------
533
712
  // Private: Fetch all canonical docs with perspective/overlay awareness
534
713
  // -----------------------------------------------------------------------
535
714
  async fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, overlay, source, slugPerspective) {
536
- const contentBySlug = new Map();
715
+ const docsBySlug = new Map();
537
716
  for (const slug of allSlugs) {
538
717
  if (removedSlugs.has(slug))
539
718
  continue;
540
719
  // Check if this slug has a document overlay replacement
541
720
  if (overlay?.replacements.has(slug)) {
542
721
  console.log(` Fetching: ${slug} (from document overlay)`);
543
- contentBySlug.set(slug, overlay.replacements.get(slug));
722
+ docsBySlug.set(slug, overlay.replacements.get(slug));
723
+ continue;
544
724
  }
545
- else if (affectedSlugs.has(slug)) {
725
+ let doc;
726
+ if (affectedSlugs.has(slug)) {
546
727
  // Use the per-slug perspective when available; fall back to
547
728
  // source-level perspective. This ensures perspective-ref-expanded
548
729
  // slugs are fetched from their specific release even when no
@@ -551,23 +732,22 @@ export class SanityDocFetcher {
551
732
  if (perspective && source) {
552
733
  const perspectiveSource = { ...source, perspective };
553
734
  console.log(` Fetching: ${slug} (from perspective: ${perspective})`);
554
- const content = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
555
- contentBySlug.set(slug, content);
735
+ doc = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
556
736
  }
557
737
  else {
558
738
  // No perspective available — fetch from published
559
739
  console.log(` Fetching: ${slug}`);
560
- const content = await this.fetchArticleBySlug(slug);
561
- contentBySlug.set(slug, content);
740
+ doc = await this.fetchArticleBySlug(slug);
562
741
  }
563
742
  }
564
743
  else {
565
744
  console.log(` Fetching: ${slug}`);
566
- const content = await this.fetchArticleBySlug(slug);
567
- contentBySlug.set(slug, content);
745
+ doc = await this.fetchArticleBySlug(slug);
568
746
  }
747
+ if (doc)
748
+ docsBySlug.set(slug, doc);
569
749
  }
570
- return contentBySlug;
750
+ return docsBySlug;
571
751
  }
572
752
  // -----------------------------------------------------------------------
573
753
  // Public non-port methods — opt-in CLI features
@@ -590,7 +770,8 @@ export class SanityDocFetcher {
590
770
  console.warn(` [warn] No articles found for "${feature}"`);
591
771
  continue;
592
772
  }
593
- const combined = docs.map(formatArticle).join("\n\n---\n\n");
773
+ const rendered = await Promise.all(docs.map((d) => renderDocument(d, { fetchUrl: this.fetchAttachmentBody })));
774
+ const combined = rendered.map((r) => r.content).join("\n\n---\n\n");
594
775
  const outPath = join(contextsDir, `${feature}.md`);
595
776
  writeFileSync(outPath, escapeNunjucks(combined), "utf-8");
596
777
  console.log(` ${feature}: ${docs.length} articles, ~${estimateTokens(combined)} tokens`);
@@ -607,16 +788,16 @@ export class SanityDocFetcher {
607
788
  const baseUrl = source?.baseUrl ?? "https://www.sanity.io/docs";
608
789
  console.log("\nGenerating full corpus...");
609
790
  const docs = await client.fetch(ALL_ARTICLES_QUERY);
610
- const corpus = docs
611
- .map((d) => {
612
- const markdown = toMarkdown(d.content ?? []);
613
- return (`## ${d.title}\n\n` +
614
- // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string title should fall back to "General"
615
- `Section: ${d.section?.title || "General"}\n` +
616
- `URL: ${baseUrl}/${d.slug}\n\n` +
617
- markdown);
618
- })
619
- .join("\n\n---\n\n");
791
+ const rendered = await Promise.all(docs.map(async (d) => {
792
+ const result = await renderDocument(d, {
793
+ fetchUrl: this.fetchAttachmentBody,
794
+ });
795
+ // Corpus output prepends the article URL line so the dataset can
796
+ // be consumed by RAG retrievers that key on canonical URL.
797
+ const slug = readString(d, "slug") ?? result.slug;
798
+ return `URL: ${baseUrl}/${slug}\n\n${result.content}`;
799
+ }));
800
+ const corpus = rendered.join("\n\n---\n\n");
620
801
  const outDir = join(this.rootDir, "contexts");
621
802
  mkdirSync(outDir, { recursive: true });
622
803
  writeFileSync(join(outDir, "full-corpus.md"), escapeNunjucks(corpus), "utf-8");
@@ -9,3 +9,4 @@ export { SanityDocFetcher } from "./doc-fetchers/index.js";
9
9
  export { PromptfooEvalAdapter } from "./eval-runners/index.js";
10
10
  export { ConsoleLogger, type ConsoleLoggerOptions, JsonLogger, QuietLogger, } from "./loggers/index.js";
11
11
  export { CliConfigAdapter, FileConfigAdapter } from "./config-sources/index.js";
12
+ export { DtsPackageSurface, InMemoryPackageSurface, type DtsPackageSurfaceOptions, type PackageRootResolver, parseDtsExports, type ParsedDtsExports, } from "./package-surface/index.js";
@@ -9,3 +9,4 @@ export { SanityDocFetcher } from "./doc-fetchers/index.js";
9
9
  export { PromptfooEvalAdapter } from "./eval-runners/index.js";
10
10
  export { ConsoleLogger, JsonLogger, QuietLogger, } from "./loggers/index.js";
11
11
  export { CliConfigAdapter, FileConfigAdapter } from "./config-sources/index.js";
12
+ export { DtsPackageSurface, InMemoryPackageSurface, parseDtsExports, } from "./package-surface/index.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * DtsPackageSurface — `PackageSurfaceResolver` adapter that reads installed
3
+ * package `.d.ts` files from `node_modules`.
4
+ *
5
+ * Resolution flow:
6
+ * 1. Find the package's root directory via the configured resolver
7
+ * (default uses `createRequire` against the working directory).
8
+ * 2. Read its `package.json` to capture the resolved `version` and the
9
+ * `.d.ts` entry path (`types`, `typings`, or `exports["."].types`).
10
+ * 3. Parse the entry `.d.ts` for top-level export declarations.
11
+ * 4. Follow ONE hop of `export * from "./relative"` re-exports — direct
12
+ * siblings only, no transitive walking.
13
+ * 5. Cache the result by package name for the resolver's lifetime.
14
+ *
15
+ * Throws `PackageSurfaceResolverError` with a typed `reason` when the
16
+ * package isn't installed or its types entry can't be resolved. Callers
17
+ * (the W0198 preflight) catch the error and convert it into per-binding
18
+ * `unresolved` findings rather than `missing` deductions.
19
+ */
20
+ import { type PackageSurface, type PackageSurfaceResolver } from "../../_vendor/ailf-core/index.d.ts";
21
+ /**
22
+ * Resolves a package's installed root directory (the directory whose
23
+ * `package.json` describes it). Returns `null` when the package isn't
24
+ * installed in the configured lookup path.
25
+ */
26
+ export type PackageRootResolver = (pkg: string) => string | null;
27
+ export interface DtsPackageSurfaceOptions {
28
+ /**
29
+ * How to find a package's root directory. Defaults to a `createRequire`
30
+ * walk from `resolveFromDir` (or the current working directory).
31
+ * Tests pass a fixture-aware resolver so they don't depend on real
32
+ * `node_modules` contents.
33
+ */
34
+ resolvePackageRoot?: PackageRootResolver;
35
+ /**
36
+ * Directory whose `node_modules` chain is searched by the default
37
+ * resolver. Ignored when `resolvePackageRoot` is supplied.
38
+ */
39
+ resolveFromDir?: string;
40
+ }
41
+ export declare class DtsPackageSurface implements PackageSurfaceResolver {
42
+ private readonly resolveRoot;
43
+ private readonly cache;
44
+ constructor(opts?: DtsPackageSurfaceOptions);
45
+ resolveExports(pkg: string): Promise<PackageSurface>;
46
+ }