@sanity/ailf 4.2.0 → 4.3.1

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 (105) 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/pipeline-request.d.ts +4 -0
  21. package/dist/_vendor/ailf-core/schemas/pipeline-request.js +7 -0
  22. package/dist/_vendor/ailf-core/schemas/symbol-preflight-report.d.ts +51 -0
  23. package/dist/_vendor/ailf-core/schemas/symbol-preflight-report.js +57 -0
  24. package/dist/_vendor/ailf-core/types/index.d.ts +12 -0
  25. package/dist/_vendor/ailf-core/types/index.js +1 -0
  26. package/dist/_vendor/ailf-core/types/package-surface.d.ts +36 -0
  27. package/dist/_vendor/ailf-core/types/package-surface.js +13 -0
  28. package/dist/_vendor/ailf-core/types/pipeline-request.d.ts +1 -0
  29. package/dist/_vendor/ailf-core/types/preflight-scoring.d.ts +52 -0
  30. package/dist/_vendor/ailf-core/types/preflight-scoring.js +18 -0
  31. package/dist/_vendor/ailf-core/types/repo-config.d.ts +14 -0
  32. package/dist/_vendor/ailf-core/types/symbol-preflight-report.d.ts +66 -0
  33. package/dist/_vendor/ailf-core/types/symbol-preflight-report.js +25 -0
  34. package/dist/adapters/api-client/build-request.d.ts +1 -0
  35. package/dist/adapters/api-client/build-request.js +3 -0
  36. package/dist/adapters/config-sources/file-config-adapter.js +1 -0
  37. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.d.ts +4 -0
  38. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.js +159 -82
  39. package/dist/adapters/index.d.ts +1 -0
  40. package/dist/adapters/index.js +1 -0
  41. package/dist/adapters/package-surface/dts-package-surface.d.ts +46 -0
  42. package/dist/adapters/package-surface/dts-package-surface.js +173 -0
  43. package/dist/adapters/package-surface/in-memory-package-surface.d.ts +15 -0
  44. package/dist/adapters/package-surface/in-memory-package-surface.js +28 -0
  45. package/dist/adapters/package-surface/index.d.ts +9 -0
  46. package/dist/adapters/package-surface/index.js +8 -0
  47. package/dist/adapters/package-surface/parse-dts-exports.d.ts +31 -0
  48. package/dist/adapters/package-surface/parse-dts-exports.js +54 -0
  49. package/dist/adapters/task-sources/repo-schemas.d.ts +6 -0
  50. package/dist/adapters/task-sources/repo-schemas.js +15 -0
  51. package/dist/commands/pipeline-action.d.ts +2 -0
  52. package/dist/commands/pipeline-action.js +12 -0
  53. package/dist/commands/remote-pipeline.js +10 -2
  54. package/dist/commands/remote-results.d.ts +12 -1
  55. package/dist/commands/remote-results.js +25 -5
  56. package/dist/composition-root.js +9 -0
  57. package/dist/config/package-surface.ts +37 -0
  58. package/dist/config/preflight-scoring.ts +26 -0
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +1 -1
  61. package/dist/orchestration/build-app-context.js +1 -0
  62. package/dist/orchestration/pipeline-orchestrator.d.ts +19 -1
  63. package/dist/orchestration/pipeline-orchestrator.js +38 -0
  64. package/dist/orchestration/steps/calculate-scores-step.js +11 -0
  65. package/dist/orchestration/steps/generate-configs-step.js +16 -1
  66. package/dist/orchestration/steps/run-eval-step.js +27 -0
  67. package/dist/pipeline/calculate-scores.d.ts +66 -5
  68. package/dist/pipeline/calculate-scores.js +141 -27
  69. package/dist/pipeline/compiler/index.d.ts +1 -1
  70. package/dist/pipeline/compiler/index.js +1 -1
  71. package/dist/pipeline/compiler/literacy-bridge.d.ts +9 -0
  72. package/dist/pipeline/compiler/literacy-bridge.js +2 -0
  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 +146 -1
  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/map-request-to-config.js +1 -0
  85. package/dist/pipeline/preflight/compute-preflight.d.ts +67 -0
  86. package/dist/pipeline/preflight/compute-preflight.js +118 -0
  87. package/dist/pipeline/preflight/emit-symbol-preflight.d.ts +51 -0
  88. package/dist/pipeline/preflight/emit-symbol-preflight.js +102 -0
  89. package/dist/pipeline/preflight/load-package-surface.d.ts +14 -0
  90. package/dist/pipeline/preflight/load-package-surface.js +19 -0
  91. package/dist/pipeline/preflight/load-preflight-context.d.ts +13 -0
  92. package/dist/pipeline/preflight/load-preflight-context.js +25 -0
  93. package/dist/pipeline/preflight/load-preflight-scoring.d.ts +12 -0
  94. package/dist/pipeline/preflight/load-preflight-scoring.js +17 -0
  95. package/dist/pipeline/preflight/parse-imports.d.ts +62 -0
  96. package/dist/pipeline/preflight/parse-imports.js +125 -0
  97. package/dist/report-store.d.ts +8 -0
  98. package/dist/report-store.js +55 -6
  99. package/dist/sanity/document-renderers.d.ts +45 -7
  100. package/dist/sanity/document-renderers.js +99 -13
  101. package/dist/sanity/queries.d.ts +11 -11
  102. package/dist/sanity/queries.js +7 -0
  103. package/dist/sanity/symbol-index.d.ts +98 -0
  104. package/dist/sanity/symbol-index.js +615 -0
  105. package/package.json +2 -1
@@ -18,8 +18,8 @@ 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 { renderDocument, } from "../../sanity/document-renderers.js";
22
- import { toMarkdown } from "../../sanity/portable-text.js";
21
+ import { extractSymbolsForDoc, renderDocument, } from "../../sanity/document-renderers.js";
22
+ import { mergeSymbolIndexes, renderSymbolIndex, symbolIndexTierBreakdown, } from "../../sanity/symbol-index.js";
23
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";
24
24
  // ---------------------------------------------------------------------------
25
25
  // Helpers
@@ -35,11 +35,14 @@ function escapeNunjucks(text) {
35
35
  function estimateTokens(text) {
36
36
  return Math.ceil(text.length / 4);
37
37
  }
38
- function formatArticle(doc) {
39
- const sectionLabel = doc.section ? `Section: ${doc.section.title}\n` : "";
40
- const desc = doc.description ? `${doc.description}\n\n` : "";
41
- const markdown = toMarkdown(doc.content ?? []);
42
- 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;
43
46
  }
44
47
  /**
45
48
  * Bridge DocSourceConfig (domain type) to getSanityClient overrides.
@@ -154,12 +157,12 @@ export class SanityDocFetcher {
154
157
  console.log(` Resolving ${source.documentIds.length} document ID(s) against canonical set...`);
155
158
  documentOverlay = await this.resolveDocumentOverlay(source.documentIds, allSlugs, source);
156
159
  const summary = {
157
- appendedCount: documentOverlay.appendedContent.length,
160
+ appendedCount: documentOverlay.appendedDocs.length,
158
161
  documentIds: source.documentIds,
159
162
  replacedSlugs: [...documentOverlay.replacements.keys()],
160
163
  };
161
164
  metadata.documentOverlay = summary;
162
- 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`);
163
166
  }
164
167
  // 5. URL content fetch — fetch direct URLs
165
168
  const urlContent = [];
@@ -219,37 +222,62 @@ export class SanityDocFetcher {
219
222
  }
220
223
  const affectedSlugs = new Set(slugPerspective.keys());
221
224
  const removedSlugs = new Set(releaseImpact?.removed ?? []);
222
- const contentBySlug = await this.fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, documentOverlay, source, slugPerspective);
223
- // 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.
224
240
  const canonicalDir = join(this.rootDir, "contexts", "canonical");
241
+ const symbolsDir = join(this.rootDir, "contexts", "canonical-symbols");
225
242
  mkdirSync(canonicalDir, { recursive: true });
226
- const contexts = tasks.map((task) => {
227
- const parts = [];
228
- const slugs = [];
229
- for (const ref of task.context?.docs ?? []) {
230
- // 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) => {
231
263
  if (isPerspectiveRef(ref)) {
232
264
  const expanded = perspectiveToSlugs.get(ref.perspective) ?? [];
233
- for (const slug of expanded) {
234
- if (removedSlugs.has(slug))
235
- continue;
236
- const content = contentBySlug.get(slug) ?? "";
237
- if (content) {
238
- parts.push(content);
239
- slugs.push(slug);
240
- }
241
- }
242
- 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);
243
271
  }
244
- // Non-article id-refs bypass the slug-keyed content map: their
245
- // rendered Markdown was produced by `resolveIdRefs` and is cached
246
- // by document `_id`. Article id-refs continue to go through the
247
- // slug-flow below (consistent with overlay/perspective handling).
248
272
  if (isIdRef(ref) && idResolution.contentById.has(ref.id)) {
249
273
  const entry = idResolution.contentById.get(ref.id);
250
- parts.push(entry.content);
251
- slugs.push(entry.slug);
252
- continue;
274
+ return [
275
+ {
276
+ markdown: entry.content,
277
+ slug: entry.slug,
278
+ symbols: entry.symbolIndex,
279
+ },
280
+ ];
253
281
  }
254
282
  let slug;
255
283
  if (isSlugRef(ref)) {
@@ -262,33 +290,77 @@ export class SanityDocFetcher {
262
290
  slug = pathToSlug.get(ref.path);
263
291
  }
264
292
  if (!slug)
265
- continue;
293
+ return null;
266
294
  if (removedSlugs.has(slug))
267
- continue;
268
- const content = contentBySlug.get(slug) ?? "";
269
- if (content) {
270
- parts.push(content);
271
- 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);
272
309
  }
273
310
  }
274
- // Append extra documents from overlay that didn't match canonical slugs
275
- if (documentOverlay && documentOverlay.appendedContent.length > 0) {
276
- 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);
277
323
  }
278
- // 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.
279
327
  if (urlContent.length > 0) {
280
328
  parts.push(...urlContent);
281
329
  }
282
330
  const combined = escapeNunjucks(parts.join("\n\n---\n\n"));
283
331
  const contextPath = join(canonicalDir, `${task.id}.md`);
284
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
+ });
285
351
  return {
286
352
  taskId: task.id,
287
353
  content: combined,
288
354
  slugs,
289
355
  tokenCount: estimateTokens(combined),
290
356
  };
291
- });
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;
292
364
  const hasMetadata = Object.keys(metadata).length > 0;
293
365
  return {
294
366
  contexts,
@@ -370,17 +442,18 @@ export class SanityDocFetcher {
370
442
  }
371
443
  continue;
372
444
  }
373
- const rendered = await renderDocument(doc, {
374
- fetchUrl: this.fetchAttachmentBody,
375
- });
445
+ const renderCtx = { fetchUrl: this.fetchAttachmentBody };
446
+ const rendered = await renderDocument(doc, renderCtx);
376
447
  if (!rendered.content) {
377
448
  console.warn(` [warn] doc "${id}" resolved as "${doc._type}" but produced empty context (referenced by task(s): ${taskIds.join(", ")})`);
378
449
  continue;
379
450
  }
451
+ const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
380
452
  contentById.set(id, {
381
453
  content: rendered.content,
382
454
  slug: rendered.slug,
383
455
  type: doc._type,
456
+ symbolIndex,
384
457
  });
385
458
  const _rev = doc._rev;
386
459
  const title = doc.title;
@@ -411,10 +484,14 @@ export class SanityDocFetcher {
411
484
  * `typesReference` renderer to inline typedoc JSON. Returns `null`
412
485
  * on any HTTP/network failure rather than throwing — the renderer
413
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.
414
491
  */
415
492
  fetchAttachmentBody = async (url) => {
416
493
  try {
417
- const response = await fetch(url);
494
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
418
495
  if (!response.ok) {
419
496
  console.warn(` [warn] attachment fetch failed for ${url}: HTTP ${response.status}`);
420
497
  return null;
@@ -574,7 +651,7 @@ export class SanityDocFetcher {
574
651
  // -----------------------------------------------------------------------
575
652
  async resolveDocumentOverlay(documentIds, canonicalSlugs, source) {
576
653
  const overlay = {
577
- appendedContent: [],
654
+ appendedDocs: [],
578
655
  replacements: new Map(),
579
656
  };
580
657
  if (documentIds.length === 0)
@@ -588,16 +665,14 @@ export class SanityDocFetcher {
588
665
  console.warn(` [warn] No article found for document ID "${id}"`);
589
666
  continue;
590
667
  }
591
- const content = formatArticle(doc);
592
- if (!content)
593
- continue;
594
- if (doc.slug && canonicalSlugs.has(doc.slug)) {
595
- overlay.replacements.set(doc.slug, content);
596
- 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}"`);
597
672
  }
598
673
  else {
599
- overlay.appendedContent.push(content);
600
- const slugInfo = doc.slug ? ` (slug: "${doc.slug}")` : "";
674
+ overlay.appendedDocs.push(doc);
675
+ const slugInfo = slug ? ` (slug: "${slug}")` : "";
601
676
  console.log(` 📄 Document ${id} → appended as additional context${slugInfo}`);
602
677
  }
603
678
  }
@@ -617,9 +692,9 @@ export class SanityDocFetcher {
617
692
  const doc = await client.fetch(ARTICLE_BY_SLUG_QUERY, { slug });
618
693
  if (!doc) {
619
694
  console.warn(` [warn] No article found for slug "${slug}"`);
620
- return "";
695
+ return null;
621
696
  }
622
- return formatArticle(doc);
697
+ return doc;
623
698
  }
624
699
  async fetchArticleBySlugWithPerspective(slug, source) {
625
700
  if (!source.perspective) {
@@ -629,24 +704,26 @@ export class SanityDocFetcher {
629
704
  const doc = await client.fetch(ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, { slug });
630
705
  if (!doc) {
631
706
  console.warn(` [warn] No article found for slug "${slug}" in perspective "${source.perspective}"`);
632
- return "";
707
+ return null;
633
708
  }
634
- return formatArticle(doc);
709
+ return doc;
635
710
  }
636
711
  // -----------------------------------------------------------------------
637
712
  // Private: Fetch all canonical docs with perspective/overlay awareness
638
713
  // -----------------------------------------------------------------------
639
714
  async fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, overlay, source, slugPerspective) {
640
- const contentBySlug = new Map();
715
+ const docsBySlug = new Map();
641
716
  for (const slug of allSlugs) {
642
717
  if (removedSlugs.has(slug))
643
718
  continue;
644
719
  // Check if this slug has a document overlay replacement
645
720
  if (overlay?.replacements.has(slug)) {
646
721
  console.log(` Fetching: ${slug} (from document overlay)`);
647
- contentBySlug.set(slug, overlay.replacements.get(slug));
722
+ docsBySlug.set(slug, overlay.replacements.get(slug));
723
+ continue;
648
724
  }
649
- else if (affectedSlugs.has(slug)) {
725
+ let doc;
726
+ if (affectedSlugs.has(slug)) {
650
727
  // Use the per-slug perspective when available; fall back to
651
728
  // source-level perspective. This ensures perspective-ref-expanded
652
729
  // slugs are fetched from their specific release even when no
@@ -655,23 +732,22 @@ export class SanityDocFetcher {
655
732
  if (perspective && source) {
656
733
  const perspectiveSource = { ...source, perspective };
657
734
  console.log(` Fetching: ${slug} (from perspective: ${perspective})`);
658
- const content = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
659
- contentBySlug.set(slug, content);
735
+ doc = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
660
736
  }
661
737
  else {
662
738
  // No perspective available — fetch from published
663
739
  console.log(` Fetching: ${slug}`);
664
- const content = await this.fetchArticleBySlug(slug);
665
- contentBySlug.set(slug, content);
740
+ doc = await this.fetchArticleBySlug(slug);
666
741
  }
667
742
  }
668
743
  else {
669
744
  console.log(` Fetching: ${slug}`);
670
- const content = await this.fetchArticleBySlug(slug);
671
- contentBySlug.set(slug, content);
745
+ doc = await this.fetchArticleBySlug(slug);
672
746
  }
747
+ if (doc)
748
+ docsBySlug.set(slug, doc);
673
749
  }
674
- return contentBySlug;
750
+ return docsBySlug;
675
751
  }
676
752
  // -----------------------------------------------------------------------
677
753
  // Public non-port methods — opt-in CLI features
@@ -694,7 +770,8 @@ export class SanityDocFetcher {
694
770
  console.warn(` [warn] No articles found for "${feature}"`);
695
771
  continue;
696
772
  }
697
- 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");
698
775
  const outPath = join(contextsDir, `${feature}.md`);
699
776
  writeFileSync(outPath, escapeNunjucks(combined), "utf-8");
700
777
  console.log(` ${feature}: ${docs.length} articles, ~${estimateTokens(combined)} tokens`);
@@ -711,16 +788,16 @@ export class SanityDocFetcher {
711
788
  const baseUrl = source?.baseUrl ?? "https://www.sanity.io/docs";
712
789
  console.log("\nGenerating full corpus...");
713
790
  const docs = await client.fetch(ALL_ARTICLES_QUERY);
714
- const corpus = docs
715
- .map((d) => {
716
- const markdown = toMarkdown(d.content ?? []);
717
- return (`## ${d.title}\n\n` +
718
- // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string title should fall back to "General"
719
- `Section: ${d.section?.title || "General"}\n` +
720
- `URL: ${baseUrl}/${d.slug}\n\n` +
721
- markdown);
722
- })
723
- .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");
724
801
  const outDir = join(this.rootDir, "contexts");
725
802
  mkdirSync(outDir, { recursive: true });
726
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
+ }
@@ -0,0 +1,173 @@
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 { existsSync, readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { dirname, isAbsolute, join, resolve } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+ import { PackageSurfaceResolverError, } from "../../_vendor/ailf-core/index.js";
25
+ import { parseDtsExports } from "./parse-dts-exports.js";
26
+ export class DtsPackageSurface {
27
+ resolveRoot;
28
+ cache = new Map();
29
+ constructor(opts = {}) {
30
+ this.resolveRoot =
31
+ opts.resolvePackageRoot ??
32
+ makeDefaultPackageRootResolver(opts.resolveFromDir ?? process.cwd());
33
+ }
34
+ async resolveExports(pkg) {
35
+ const cached = this.cache.get(pkg);
36
+ if (cached)
37
+ return cached;
38
+ const root = this.resolveRoot(pkg);
39
+ if (!root) {
40
+ throw new PackageSurfaceResolverError("package-not-installed", pkg, `Package "${pkg}" was not resolvable from the configured lookup path.`);
41
+ }
42
+ const pkgJsonPath = join(root, "package.json");
43
+ if (!existsSync(pkgJsonPath)) {
44
+ throw new PackageSurfaceResolverError("package-not-installed", pkg, `Package "${pkg}" has no package.json at "${pkgJsonPath}".`);
45
+ }
46
+ const pkgJson = readPackageJson(pkgJsonPath, pkg);
47
+ const version = pkgJson.version ?? "0.0.0";
48
+ const typesEntry = resolveTypesEntry(pkgJson, root);
49
+ if (!typesEntry) {
50
+ throw new PackageSurfaceResolverError("types-entry-missing", pkg, `Package "${pkg}@${version}" does not declare a \`types\` entry ` +
51
+ `(checked package.json \`types\`, \`typings\`, and \`exports["."].types\`).`);
52
+ }
53
+ const symbols = readSurface(pkg, version, typesEntry);
54
+ const surface = {
55
+ pkg,
56
+ version,
57
+ symbols,
58
+ };
59
+ this.cache.set(pkg, surface);
60
+ return surface;
61
+ }
62
+ }
63
+ function readPackageJson(path, pkg) {
64
+ try {
65
+ return JSON.parse(readFileSync(path, "utf-8"));
66
+ }
67
+ catch (err) {
68
+ throw new PackageSurfaceResolverError("parse-failed", pkg, `Failed to parse "${path}": ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ }
71
+ /**
72
+ * Pick the `.d.ts` entry the resolver should parse. Order:
73
+ * 1. `package.json#types`
74
+ * 2. `package.json#typings`
75
+ * 3. `exports["."].types` (string or object form)
76
+ */
77
+ function resolveTypesEntry(pkgJson, root) {
78
+ const candidates = [];
79
+ if (typeof pkgJson.types === "string")
80
+ candidates.push(pkgJson.types);
81
+ if (typeof pkgJson.typings === "string")
82
+ candidates.push(pkgJson.typings);
83
+ const dotExport = pkgJson.exports && typeof pkgJson.exports === "object"
84
+ ? pkgJson.exports["."]
85
+ : undefined;
86
+ if (typeof dotExport === "string") {
87
+ if (dotExport.endsWith(".d.ts"))
88
+ candidates.push(dotExport);
89
+ }
90
+ else if (dotExport && typeof dotExport === "object") {
91
+ const typesField = dotExport.types;
92
+ if (typeof typesField === "string")
93
+ candidates.push(typesField);
94
+ else if (typesField && typeof typesField === "object") {
95
+ // Conditional `types` entry (rare) — pick any string leaf.
96
+ for (const v of Object.values(typesField)) {
97
+ if (typeof v === "string") {
98
+ candidates.push(v);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ for (const candidate of candidates) {
105
+ const abs = isAbsolute(candidate) ? candidate : join(root, candidate);
106
+ if (existsSync(abs))
107
+ return abs;
108
+ }
109
+ return null;
110
+ }
111
+ function readSurface(pkg, version, entryPath) {
112
+ const names = new Set();
113
+ const visited = new Set();
114
+ const parseFile = (path, hops) => {
115
+ if (visited.has(path))
116
+ return;
117
+ visited.add(path);
118
+ let src;
119
+ try {
120
+ src = readFileSync(path, "utf-8");
121
+ }
122
+ catch (err) {
123
+ throw new PackageSurfaceResolverError("parse-failed", pkg, `Failed to read "${path}" for "${pkg}@${version}": ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ const parsed = parseDtsExports(src);
126
+ for (const name of parsed.names)
127
+ names.add(name);
128
+ if (hops <= 0)
129
+ return;
130
+ const baseDir = dirname(path);
131
+ for (const spec of parsed.reExports) {
132
+ if (!spec.startsWith("."))
133
+ continue; // bare specifier — out of scope
134
+ const resolved = resolveRelativeDts(baseDir, spec);
135
+ if (resolved)
136
+ parseFile(resolved, hops - 1);
137
+ }
138
+ };
139
+ parseFile(entryPath, /* hops */ 1);
140
+ return [...names].sort().map((name) => ({ name, source: "types" }));
141
+ }
142
+ /**
143
+ * Resolve a relative re-export specifier to an existing `.d.ts` file.
144
+ * Tries `<spec>.d.ts`, `<spec>/index.d.ts`, and `<spec>` literally.
145
+ */
146
+ function resolveRelativeDts(baseDir, spec) {
147
+ const base = resolve(baseDir, spec);
148
+ const candidates = [
149
+ base.endsWith(".d.ts") ? base : null,
150
+ `${base}.d.ts`,
151
+ join(base, "index.d.ts"),
152
+ ].filter((p) => p !== null);
153
+ for (const path of candidates) {
154
+ if (existsSync(path))
155
+ return path;
156
+ }
157
+ return null;
158
+ }
159
+ function makeDefaultPackageRootResolver(fromDir) {
160
+ // `createRequire` needs a file URL or path that ends with a slash so
161
+ // it knows it's a directory, not a file.
162
+ const anchor = fromDir.endsWith("/") ? fromDir : `${fromDir}/`;
163
+ const req = createRequire(pathToFileURL(anchor));
164
+ return (pkg) => {
165
+ try {
166
+ const pkgJsonPath = req.resolve(`${pkg}/package.json`);
167
+ return dirname(pkgJsonPath);
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ };
173
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * InMemoryPackageSurface — `PackageSurfaceResolver` test double.
3
+ *
4
+ * Backed by a plain `Map<string, PackageSurface>`; calls for unknown
5
+ * packages throw the same `package-not-installed` error the
6
+ * `DtsPackageSurface` adapter throws, so test scenarios for the
7
+ * `unresolved` path need no special handling.
8
+ */
9
+ import { type PackageSurface, type PackageSurfaceResolver } from "../../_vendor/ailf-core/index.d.ts";
10
+ export declare class InMemoryPackageSurface implements PackageSurfaceResolver {
11
+ private readonly surfaces;
12
+ constructor(surfaces?: Iterable<PackageSurface>);
13
+ set(surface: PackageSurface): void;
14
+ resolveExports(pkg: string): Promise<PackageSurface>;
15
+ }