@sanity/ailf 4.1.0 → 4.2.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 (38) hide show
  1. package/dist/_vendor/ailf-core/types/generalized-task.d.ts +20 -3
  2. package/dist/_vendor/ailf-core/types/index.d.ts +1 -1
  3. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.d.ts +21 -5
  4. package/dist/adapters/doc-fetchers/sanity-doc-fetcher.js +129 -25
  5. package/dist/adapters/task-sources/repo-schemas.d.ts +16 -0
  6. package/dist/adapters/task-sources/repo-schemas.js +78 -1
  7. package/dist/adapters/task-sources/repo-task-source.js +11 -2
  8. package/dist/commands/validate-tasks.js +8 -2
  9. package/dist/pipeline/compiler/mode-handlers/__fixtures__/agent-harness-example-tasks.js +0 -12
  10. package/dist/pipeline/compiler/mode-handlers/__fixtures__/knowledge-probe-example-tasks.js +0 -12
  11. package/dist/pipeline/compiler/mode-handlers/literacy/compiler.js +44 -5
  12. package/dist/sanity/document-renderers.d.ts +68 -0
  13. package/dist/sanity/document-renderers.js +221 -0
  14. package/dist/sanity/queries.d.ts +21 -0
  15. package/dist/sanity/queries.js +71 -0
  16. package/dist/tasks/knowledge-probe/define-type-api.task.ts +2 -6
  17. package/dist/tasks/knowledge-probe/groq-projections.task.ts +0 -5
  18. package/dist/tasks/literacy/content-lake.task.ts +4 -10
  19. package/dist/tasks/literacy/frameworks.task.ts +2 -8
  20. package/dist/tasks/literacy/functions.task.ts +1 -4
  21. package/dist/tasks/literacy/groq.task.ts +3 -12
  22. package/dist/tasks/literacy/image-handling.task.ts +1 -4
  23. package/dist/tasks/literacy/nextjs-live.task.ts +1 -4
  24. package/dist/tasks/literacy/portable-text.task.ts +2 -8
  25. package/dist/tasks/literacy/studio-setup.task.ts +2 -8
  26. package/dist/tasks/literacy/visual-editing.task.ts +2 -8
  27. package/package.json +1 -1
  28. package/tasks/knowledge-probe/define-type-api.task.ts +2 -6
  29. package/tasks/knowledge-probe/groq-projections.task.ts +0 -5
  30. package/tasks/literacy/content-lake.task.ts +4 -10
  31. package/tasks/literacy/frameworks.task.ts +2 -8
  32. package/tasks/literacy/functions.task.ts +1 -4
  33. package/tasks/literacy/groq.task.ts +3 -12
  34. package/tasks/literacy/image-handling.task.ts +1 -4
  35. package/tasks/literacy/nextjs-live.task.ts +1 -4
  36. package/tasks/literacy/portable-text.task.ts +2 -8
  37. package/tasks/literacy/studio-setup.task.ts +2 -8
  38. package/tasks/literacy/visual-editing.task.ts +2 -8
@@ -97,6 +97,19 @@ export interface TaskOptions {
97
97
  /** Arbitrary Promptfoo overrides (escape hatch) */
98
98
  promptfooOverrides?: Record<string, unknown>;
99
99
  }
100
+ /**
101
+ * Variable keys reserved by the AILF compilers. Populated automatically
102
+ * from canonical task fields; forbidden in `prompt.vars` to prevent silent
103
+ * override of canonical values.
104
+ */
105
+ export type ReservedPromptVarKey = "task" | "docs" | "__featureArea";
106
+ /**
107
+ * Variables for prompt-template interpolation. Freeform extras are
108
+ * allowed; reserved keys (see {@link ReservedPromptVarKey}) are forbidden.
109
+ */
110
+ export type PromptVars = Record<string, unknown> & {
111
+ [K in ReservedPromptVarKey]?: never;
112
+ };
100
113
  /** Fields shared by all task modes */
101
114
  export interface TaskCommonFields {
102
115
  /** Unique task identifier */
@@ -121,7 +134,11 @@ export interface TaskCommonFields {
121
134
  providers?: TaskProviderConfig[];
122
135
  /** Task-level execution options */
123
136
  options?: TaskOptions;
124
- /** Prompt configuration */
137
+ /**
138
+ * Prompt configuration. Reserved `vars` keys (see
139
+ * {@link ReservedPromptVarKey}) are forbidden — use `prompt.text` for
140
+ * the prompt body and `context.docs` for documentation references.
141
+ */
125
142
  prompt?: {
126
143
  /** Named prompt template */
127
144
  template?: string;
@@ -129,8 +146,8 @@ export interface TaskCommonFields {
129
146
  text?: string;
130
147
  /** System message override */
131
148
  systemMessage?: string;
132
- /** Variables for template interpolation */
133
- vars?: Record<string, unknown>;
149
+ /** Variables for template interpolation (reserved keys forbidden) */
150
+ vars?: PromptVars;
134
151
  };
135
152
  /** Arbitrary metadata */
136
153
  metadata?: Record<string, unknown>;
@@ -28,7 +28,7 @@ export type { AilfEvalWorkflow, AilfEvalWorkflowJob, AilfEvalWorkflowStep, RepoA
28
28
  export type { PipelineRequest, PipelineRequestCallback, PipelineRequestCallerExecutor, PipelineRequestCallerGit, PipelineRequestCallerOwner, PipelineRequestDebug, PipelineRequestTaskSource, } from "./pipeline-request.js";
29
29
  export type { ArtifactId, AssociationAxis, AssociationValues, Brand, EntryKey, Err, FixtureId, IdValidationError, NewReportId, Ok, ProviderId, PromptId, Result, ResultId, RubricId, RunFingerprint, RunId, SuiteId, TaskId, TaskSlug, TraceId, } from "./branded-ids.js";
30
30
  export { err, fixtureId, generateRunId, ok, providerId, resultId, runId, suiteId, taskId, traceId, } from "./branded-ids.js";
31
- export type { AgentHarnessTaskDefinition, ContentLakeAuthorableMode, ContentLakeAuthorableTask, CustomTaskDefinition, GeneralizedAssertionDefinition, GeneralizedDocRef, GeneralizedTaskDefinition, GeneralizedTemplatedAssertion, GeneralizedValueAssertion, IdDocRef, KnowledgeProbeTaskDefinition, LiteracyTaskDefinition, MCPServerTaskDefinition, PathDocRef, PerspectiveDocRef, RubricRef, SlugDocRef, TaskCommonFields, TaskDifficulty, TaskOptions, TaskProviderConfig, TaskStatus, } from "./generalized-task.js";
31
+ export type { AgentHarnessTaskDefinition, ContentLakeAuthorableMode, ContentLakeAuthorableTask, CustomTaskDefinition, GeneralizedAssertionDefinition, GeneralizedDocRef, GeneralizedTaskDefinition, GeneralizedTemplatedAssertion, GeneralizedValueAssertion, IdDocRef, KnowledgeProbeTaskDefinition, LiteracyTaskDefinition, MCPServerTaskDefinition, PromptVars, PathDocRef, PerspectiveDocRef, ReservedPromptVarKey, RubricRef, SlugDocRef, TaskCommonFields, TaskDifficulty, TaskOptions, TaskProviderConfig, TaskStatus, } from "./generalized-task.js";
32
32
  type DocumentRef = _DocumentRef;
33
33
  /** Aggregated retrieval metrics for a feature area */
34
34
  export interface AreaRetrievalMetrics {
@@ -21,13 +21,29 @@ export declare class SanityDocFetcher implements DocFetcher {
21
21
  private fetchInternal;
22
22
  private fetchManifest;
23
23
  /**
24
- * Batch-resolve document ID refs to their article slugs.
24
+ * Batch-resolve document ID refs without filtering on `_type`.
25
25
  *
26
- * This bridges IdDocRef entries into the slug-based fetch pipeline.
27
- * Articles are queried by _id and their slugs are returned for use
28
- * in the existing slug-based content map.
26
+ * Articles are routed back into the slug-flow (so manifest, perspective
27
+ * diffing, and overlay handling continue to work unchanged). Non-articles
28
+ * are rendered eagerly via the renderer registry `typesReference` gets
29
+ * a high-fidelity formatter; anything else falls through to the default
30
+ * walker so authors can pin marketing pages, glossary entries, etc.
31
+ * without an AILF code change.
32
+ *
33
+ * Three log shapes:
34
+ * - `info` — doc rendered with the default formatter (suggests a
35
+ * dedicated renderer would improve fidelity)
36
+ * - `warn` — registered renderer produced empty content (W0195 AC#3)
37
+ * - `warn` — id had no matching document (existed/wrong tenant/typo)
38
+ */
39
+ private resolveIdRefs;
40
+ /**
41
+ * Fetch the raw body of a Sanity file asset URL, used by the
42
+ * `typesReference` renderer to inline typedoc JSON. Returns `null`
43
+ * on any HTTP/network failure rather than throwing — the renderer
44
+ * surfaces a placeholder so the rest of the context still renders.
29
45
  */
30
- private resolveIdRefsToSlugs;
46
+ private fetchAttachmentBody;
31
47
  /**
32
48
  * Resolve path-based canonical doc references to their article slugs.
33
49
  *
@@ -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 { renderDocument, } from "../../sanity/document-renderers.js";
21
22
  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";
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
  // ---------------------------------------------------------------------------
@@ -99,9 +100,11 @@ export class SanityDocFetcher {
99
100
  }
100
101
  }
101
102
  }
102
- // Resolve ID refs slugs via batch query
103
- const idToSlug = await this.resolveIdRefsToSlugs(idRefs, source);
104
- for (const slug of idToSlug.values()) {
103
+ // Resolve ID refs. Articles get added to allSlugs (legacy slug-flow takes
104
+ // over for fetch + render). Non-articles render directly via the renderer
105
+ // registry and bypass the slug-keyed content map.
106
+ const idResolution = await this.resolveIdRefs(idRefs, source);
107
+ for (const slug of idResolution.idToSlug.values()) {
105
108
  allSlugs.add(slug);
106
109
  }
107
110
  // Resolve path refs → slugs
@@ -118,8 +121,14 @@ export class SanityDocFetcher {
118
121
  }
119
122
  }
120
123
  const metadata = {};
121
- // 2. Fetch document manifest (traceability metadata)
124
+ // 2. Fetch document manifest (traceability metadata). Articles flow
125
+ // through fetchManifest; non-article id-refs contribute their own
126
+ // entries from the resolution we already did above.
122
127
  const manifest = await this.fetchManifest(allSlugs, source);
128
+ for (const extra of idResolution.extraManifest) {
129
+ manifest.push(extra);
130
+ }
131
+ manifest.sort((a, b) => a.slug.localeCompare(b.slug));
123
132
  if (manifest.length > 0) {
124
133
  metadata.manifest = manifest;
125
134
  }
@@ -232,12 +241,22 @@ export class SanityDocFetcher {
232
241
  }
233
242
  continue;
234
243
  }
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
+ if (isIdRef(ref) && idResolution.contentById.has(ref.id)) {
249
+ const entry = idResolution.contentById.get(ref.id);
250
+ parts.push(entry.content);
251
+ slugs.push(entry.slug);
252
+ continue;
253
+ }
235
254
  let slug;
236
255
  if (isSlugRef(ref)) {
237
256
  slug = ref.slug;
238
257
  }
239
258
  else if (isIdRef(ref)) {
240
- slug = idToSlug.get(ref.id);
259
+ slug = idResolution.idToSlug.get(ref.id);
241
260
  }
242
261
  else if (isPathRef(ref)) {
243
262
  slug = pathToSlug.get(ref.path);
@@ -289,40 +308,125 @@ export class SanityDocFetcher {
289
308
  .sort((a, b) => a.slug.localeCompare(b.slug));
290
309
  }
291
310
  // -----------------------------------------------------------------------
292
- // Private: Resolve ID refs to slugs
311
+ // Private: Resolve ID refs (type-agnostic)
293
312
  // -----------------------------------------------------------------------
294
313
  /**
295
- * Batch-resolve document ID refs to their article slugs.
314
+ * Batch-resolve document ID refs without filtering on `_type`.
315
+ *
316
+ * Articles are routed back into the slug-flow (so manifest, perspective
317
+ * diffing, and overlay handling continue to work unchanged). Non-articles
318
+ * are rendered eagerly via the renderer registry — `typesReference` gets
319
+ * a high-fidelity formatter; anything else falls through to the default
320
+ * walker so authors can pin marketing pages, glossary entries, etc.
321
+ * without an AILF code change.
296
322
  *
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.
323
+ * Three log shapes:
324
+ * - `info` doc rendered with the default formatter (suggests a
325
+ * dedicated renderer would improve fidelity)
326
+ * - `warn` — registered renderer produced empty content (W0195 AC#3)
327
+ * - `warn` — id had no matching document (existed/wrong tenant/typo)
300
328
  */
301
- async resolveIdRefsToSlugs(idRefs, source) {
302
- const result = new Map();
329
+ async resolveIdRefs(idRefs, source) {
330
+ const idToSlug = new Map();
331
+ const contentById = new Map();
332
+ const extraManifest = [];
303
333
  if (idRefs.length === 0)
304
- return result;
334
+ return { idToSlug, contentById, extraManifest };
305
335
  const uniqueIds = [...new Set(idRefs.map((r) => r.id))];
336
+ const taskByRef = new Map();
337
+ for (const { id, taskId } of idRefs) {
338
+ const existing = taskByRef.get(id) ?? [];
339
+ existing.push(taskId);
340
+ taskByRef.set(id, existing);
341
+ }
306
342
  const client = source?.perspective
307
343
  ? createPerspectiveClient(source.perspective, source)
308
344
  : 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);
345
+ const docs = await client.fetch(DOCS_BY_IDS_QUERY, {
346
+ ids: uniqueIds,
347
+ });
348
+ const docsById = new Map(docs.map((d) => [d._id, d]));
349
+ let articleCount = 0;
350
+ let highFidelity = 0;
351
+ let defaultFidelity = 0;
352
+ for (const id of uniqueIds) {
353
+ const taskIds = taskByRef.get(id) ?? [];
354
+ const doc = docsById.get(id);
355
+ if (!doc) {
356
+ console.warn(` [warn] doc "${id}" not found (referenced by task(s): ${taskIds.join(", ")})`);
357
+ continue;
358
+ }
359
+ // Article id-refs flow back into the slug-keyed pipeline so manifest,
360
+ // perspective diff, and document overlay continue to work. The slug
361
+ // projection in DOCS_BY_IDS_QUERY mirrors ARTICLE_PROJECTION.
362
+ if (doc._type === "article") {
363
+ const slug = doc.slug;
364
+ if (typeof slug === "string" && slug.length > 0) {
365
+ idToSlug.set(id, slug);
366
+ articleCount += 1;
367
+ }
368
+ else {
369
+ console.warn(` [warn] article "${id}" has no slug (referenced by task(s): ${taskIds.join(", ")})`);
370
+ }
371
+ continue;
372
+ }
373
+ const rendered = await renderDocument(doc, {
374
+ fetchUrl: this.fetchAttachmentBody,
375
+ });
376
+ if (!rendered.content) {
377
+ console.warn(` [warn] doc "${id}" resolved as "${doc._type}" but produced empty context (referenced by task(s): ${taskIds.join(", ")})`);
378
+ continue;
379
+ }
380
+ contentById.set(id, {
381
+ content: rendered.content,
382
+ slug: rendered.slug,
383
+ type: doc._type,
384
+ });
385
+ const _rev = doc._rev;
386
+ const title = doc.title;
387
+ extraManifest.push({
388
+ _id: doc._id,
389
+ _rev: typeof _rev === "string" ? _rev : "",
390
+ slug: rendered.slug,
391
+ title: typeof title === "string" ? title : `(${doc._type})`,
392
+ });
393
+ if (rendered.fidelity === "default") {
394
+ console.log(` [info] doc "${id}" rendered with default formatter — a dedicated renderer for "${doc._type}" would likely improve grader fidelity`);
395
+ defaultFidelity += 1;
316
396
  }
317
397
  else {
318
- console.warn(` [warn] No article found for document ID "${id}" (referenced by task "${taskId}")`);
398
+ highFidelity += 1;
319
399
  }
320
400
  }
321
- if (result.size > 0) {
322
- console.log(` Resolved ${result.size} document ID ref(s) to slugs`);
401
+ if (articleCount > 0) {
402
+ console.log(` Resolved ${articleCount} article id ref(s) to slugs`);
323
403
  }
324
- return result;
404
+ if (highFidelity + defaultFidelity > 0) {
405
+ console.log(` Resolved ${highFidelity + defaultFidelity} non-article id ref(s) (${highFidelity} high-fidelity, ${defaultFidelity} default)`);
406
+ }
407
+ return { idToSlug, contentById, extraManifest };
325
408
  }
409
+ /**
410
+ * Fetch the raw body of a Sanity file asset URL, used by the
411
+ * `typesReference` renderer to inline typedoc JSON. Returns `null`
412
+ * on any HTTP/network failure rather than throwing — the renderer
413
+ * surfaces a placeholder so the rest of the context still renders.
414
+ */
415
+ fetchAttachmentBody = async (url) => {
416
+ try {
417
+ const response = await fetch(url);
418
+ if (!response.ok) {
419
+ console.warn(` [warn] attachment fetch failed for ${url}: HTTP ${response.status}`);
420
+ return null;
421
+ }
422
+ return await response.text();
423
+ }
424
+ catch (err) {
425
+ const msg = err instanceof Error ? err.message : String(err);
426
+ console.warn(` [warn] attachment fetch failed for ${url}: ${msg}`);
427
+ return null;
428
+ }
429
+ };
326
430
  // -----------------------------------------------------------------------
327
431
  // Private: Resolve path refs to slugs
328
432
  // -----------------------------------------------------------------------
@@ -1425,6 +1425,22 @@ export declare function parseCanonicalTaskFile(raw: unknown, filename: string):
1425
1425
  * GeneralizedTaskDefinition shape.
1426
1426
  */
1427
1427
  export declare function detectLegacyFieldNames(raw: unknown, filename: string): string[];
1428
+ interface MigrationResult {
1429
+ migrated: unknown;
1430
+ warnings: string[];
1431
+ }
1432
+ /**
1433
+ * Pre-process legacy `prompt.vars.{task,docs,__featureArea}` into the
1434
+ * canonical shape. Backwards-compatible: legacy-shape tasks continue to
1435
+ * load, but a deprecation warning is emitted per affected task.
1436
+ *
1437
+ * Legacy: prompt: { vars: { task: "...", docs: "file://..." } }
1438
+ * Canonical: prompt: { text: "..." }
1439
+ *
1440
+ * Applies to every task regardless of mode. Per-task dedup: at most one
1441
+ * warning per task per call, listing every reserved key that was present.
1442
+ */
1443
+ export declare function migratePromptShape(raw: unknown, filename: string): MigrationResult;
1428
1444
  /**
1429
1445
  * Zod schema for .ailf/config.yaml — controls documentation source,
1430
1446
  * report destination, and trigger behavior for evaluations from an
@@ -141,11 +141,30 @@ const AssertionSchema = z.union([
141
141
  // ---------------------------------------------------------------------------
142
142
  // Shared field schemas — building blocks reused across mode variants
143
143
  // ---------------------------------------------------------------------------
144
+ /**
145
+ * Variable keys reserved by the AILF compilers — populated automatically
146
+ * from canonical task fields (`prompt.text`, `context.docs`, `area`).
147
+ * Mirrors `ReservedPromptVarKey` in `@sanity/ailf-core`; the `satisfies`
148
+ * clause makes drift a build error.
149
+ */
150
+ const RESERVED_PROMPT_VAR_KEYS = [
151
+ "task",
152
+ "docs",
153
+ "__featureArea",
154
+ ];
144
155
  const TaskPromptSchema = z.object({
145
156
  template: z.string().optional(),
146
157
  text: z.string().optional(),
147
158
  systemMessage: z.string().optional(),
148
- vars: z.record(z.string(), z.unknown()).optional(),
159
+ vars: z
160
+ .record(z.string(), z.unknown())
161
+ .refine((vars) => !RESERVED_PROMPT_VAR_KEYS.some((key) => key in vars), {
162
+ message: `prompt.vars contains a reserved key. Reserved keys: ` +
163
+ RESERVED_PROMPT_VAR_KEYS.join(", ") +
164
+ `. Use prompt.text for the prompt body and context.docs for ` +
165
+ `documentation references.`,
166
+ })
167
+ .optional(),
149
168
  });
150
169
  const RubricRefSchema = z.union([
151
170
  z.object({ ref: z.string().min(1) }),
@@ -416,6 +435,64 @@ export function detectLegacyFieldNames(raw, filename) {
416
435
  }
417
436
  return warnings;
418
437
  }
438
+ /**
439
+ * Pre-process legacy `prompt.vars.{task,docs,__featureArea}` into the
440
+ * canonical shape. Backwards-compatible: legacy-shape tasks continue to
441
+ * load, but a deprecation warning is emitted per affected task.
442
+ *
443
+ * Legacy: prompt: { vars: { task: "...", docs: "file://..." } }
444
+ * Canonical: prompt: { text: "..." }
445
+ *
446
+ * Applies to every task regardless of mode. Per-task dedup: at most one
447
+ * warning per task per call, listing every reserved key that was present.
448
+ */
449
+ export function migratePromptShape(raw, filename) {
450
+ if (!Array.isArray(raw))
451
+ return { migrated: raw, warnings: [] };
452
+ const warnings = [];
453
+ const migrated = raw.map((entry, i) => {
454
+ if (typeof entry !== "object" || entry === null)
455
+ return entry;
456
+ const obj = entry;
457
+ const prompt = obj.prompt;
458
+ if (typeof prompt !== "object" || prompt === null)
459
+ return entry;
460
+ const promptObj = prompt;
461
+ const vars = promptObj.vars;
462
+ if (typeof vars !== "object" || vars === null)
463
+ return entry;
464
+ const varsObj = vars;
465
+ // Detect which reserved keys are present
466
+ const presentReserved = RESERVED_PROMPT_VAR_KEYS.filter((key) => key in varsObj);
467
+ if (presentReserved.length === 0)
468
+ return entry;
469
+ const taskId = typeof obj.id === "string" ? obj.id : `task[${i}]`;
470
+ // Build migrated prompt + vars
471
+ const newPrompt = { ...promptObj };
472
+ const newVars = { ...varsObj };
473
+ for (const key of presentReserved) {
474
+ if (key === "task" && newPrompt.text === undefined) {
475
+ // Move the prompt body to prompt.text only if the canonical slot
476
+ // is unset; an explicit prompt.text always wins.
477
+ newPrompt.text = newVars.task;
478
+ }
479
+ delete newVars[key];
480
+ }
481
+ // Drop empty vars to keep the migrated shape minimal
482
+ if (Object.keys(newVars).length === 0) {
483
+ delete newPrompt.vars;
484
+ }
485
+ else {
486
+ newPrompt.vars = newVars;
487
+ }
488
+ warnings.push(`[${filename}] ${taskId}: deprecated prompt.vars keys ` +
489
+ `(${presentReserved.join(", ")}) — migrated to canonical shape ` +
490
+ `(prompt.text + context.docs). Update the task source to silence ` +
491
+ `this warning.`);
492
+ return { ...obj, prompt: newPrompt };
493
+ });
494
+ return { migrated, warnings };
495
+ }
419
496
  // ---------------------------------------------------------------------------
420
497
  // Config schemas — specific to the eval pipeline
421
498
  // ---------------------------------------------------------------------------
@@ -22,7 +22,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
22
22
  import { resolve } from "path";
23
23
  import { load } from "js-yaml";
24
24
  import { CANONICAL_EVAL_MODES } from "../../_vendor/ailf-shared/index.js";
25
- import { detectLegacyFieldNames, parseCanonicalTaskFile, } from "./repo-schemas.js";
25
+ import { detectLegacyFieldNames, migratePromptShape, parseCanonicalTaskFile, } from "./repo-schemas.js";
26
26
  import { discoverTsTaskFiles, loadTsTaskFile } from "./task-file-loader.js";
27
27
  /** Set of canonical mode names for O(1) lookup */
28
28
  const KNOWN_MODES = new Set(CANONICAL_EVAL_MODES);
@@ -69,10 +69,19 @@ export class RepoTaskSource {
69
69
  legacyWarnings.join("\n") +
70
70
  "\n\nSee contributing-tasks.md for the canonical task format.");
71
71
  }
72
+ // W0193: pre-migrate legacy prompt.vars.{task,docs,__featureArea}
73
+ // to the canonical prompt.text + context.docs shape. Mode-agnostic —
74
+ // every mode's TaskPromptSchema rejects reserved keys, so the shim
75
+ // unblocks legacy tasks regardless of mode. Per-task deprecation
76
+ // warning fires on stderr.
77
+ const { migrated, warnings: deprecationWarnings } = migratePromptShape(parsed, file);
78
+ for (const warning of deprecationWarnings) {
79
+ console.warn(warning);
80
+ }
72
81
  // Validate through canonical Zod schema
73
82
  let validated;
74
83
  try {
75
- validated = parseCanonicalTaskFile(parsed, file);
84
+ validated = parseCanonicalTaskFile(migrated, file);
76
85
  }
77
86
  catch (err) {
78
87
  const msg = err instanceof Error ? err.message : String(err);
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
17
17
  import { resolve, relative, basename } from "path";
18
18
  import { Command } from "commander";
19
19
  import { load } from "js-yaml";
20
- import { detectLegacyFieldNames, parseCanonicalTaskFile, } from "../adapters/task-sources/repo-schemas.js";
20
+ import { detectLegacyFieldNames, migratePromptShape, parseCanonicalTaskFile, } from "../adapters/task-sources/repo-schemas.js";
21
21
  import { validateCanonicalTasks, formatRepoValidationResult, } from "../adapters/task-sources/repo-validation.js";
22
22
  import { discoverTsTaskFiles, loadTsTaskFile, } from "../adapters/task-sources/task-file-loader.js";
23
23
  export function createValidateTasksCommand() {
@@ -133,8 +133,14 @@ function validateTaskArray(entries, file, accumulator) {
133
133
  console.error();
134
134
  return false;
135
135
  }
136
+ // W0193: pre-migrate legacy prompt.vars.{task,docs,__featureArea} shape
137
+ // and surface deprecation warnings (non-fatal — the file still validates).
138
+ const { migrated, warnings: deprecationWarnings } = migratePromptShape(entries, file);
139
+ for (const warning of deprecationWarnings) {
140
+ console.warn(` ${warning}`);
141
+ }
136
142
  try {
137
- const tasks = parseCanonicalTaskFile(entries, file);
143
+ const tasks = parseCanonicalTaskFile(migrated, file);
138
144
  console.log(` ${file}: ${tasks.length} task${tasks.length === 1 ? "" : "s"} valid`);
139
145
  accumulator.push(...tasks);
140
146
  return true;
@@ -30,10 +30,6 @@ export const scaffoldProjectTask = {
30
30
  "2. Configure sanity.config.ts with project ID 'test-project' and dataset 'production'\n" +
31
31
  "3. Create a 'post' schema type with title, slug, body, and author fields\n" +
32
32
  "4. Ensure the project builds without errors",
33
- vars: {
34
- task: "Scaffold a Sanity Studio project with a post schema type. " +
35
- "The project should build cleanly.",
36
- },
37
33
  },
38
34
  assertions: [
39
35
  { type: "file-exists", value: "sanity.config.ts" },
@@ -70,10 +66,6 @@ export const modifyCodeTask = {
70
66
  text: "In the existing Sanity Studio project, add a custom document action " +
71
67
  "that logs a message before publishing. Follow the Sanity docs for " +
72
68
  "custom document actions.",
73
- vars: {
74
- task: "Add a custom document action that wraps the default publish action " +
75
- "and logs 'Publishing document: <title>' before executing.",
76
- },
77
69
  },
78
70
  assertions: [
79
71
  { type: "file-exists", value: "actions/logPublishAction.ts" },
@@ -127,10 +119,6 @@ export const multiFileRefactorTask = {
127
119
  "3. Query method calls (fetch → client.fetch with new signature)\n" +
128
120
  "4. Mutation helpers (create/patch/delete API changes)\n" +
129
121
  "Ensure the project compiles after migration.",
130
- vars: {
131
- task: "Migrate the codebase from @sanity/client v5 to v6, " +
132
- "updating all files. Project must compile cleanly after migration.",
133
- },
134
122
  },
135
123
  assertions: [
136
124
  {
@@ -38,10 +38,6 @@ export const groqProjectionTask = {
38
38
  "5. Array slicing with `[0..5]` and `[0...5]`\n" +
39
39
  "6. Conditional projections using `select()`\n\n" +
40
40
  "Provide working code examples for each.",
41
- vars: {
42
- task: "Explain GROQ projection syntax with working code examples " +
43
- "covering projections, spread, dereference, slicing, and select().",
44
- },
45
41
  },
46
42
  assertions: [
47
43
  { type: "contains", value: "->" },
@@ -89,10 +85,6 @@ export const defineTypeApiTask = {
89
85
  "3. Why were these typed helpers introduced? What did they replace?\n" +
90
86
  "4. Show a complete example of a document schema with various field types\n" +
91
87
  "5. How do you add validation rules using the typed API?",
92
- vars: {
93
- task: "Explain Sanity's defineType/defineField schema API with examples, " +
94
- "motivation, and validation rules.",
95
- },
96
88
  },
97
89
  assertions: [
98
90
  { type: "contains", value: "defineType" },
@@ -142,10 +134,6 @@ export const ecosystemComparisonTask = {
142
134
  "4. Developer experience and customization\n" +
143
135
  "5. Pricing models\n" +
144
136
  "6. When would you choose one over the other?",
145
- vars: {
146
- task: "Compare Sanity and Contentful across architecture, content modeling, " +
147
- "querying, DX, pricing, and use case fit.",
148
- },
149
137
  },
150
138
  assertions: [
151
139
  { type: "contains-any", value: ["GROQ", "groq"] },
@@ -10,6 +10,18 @@ import { LiteracyVariant, } from "../../../normalize-mode.js";
10
10
  import { buildBaselineAssertions, resolveAssertions } from "./assertions.js";
11
11
  import { LITERACY_PROMPT_TEMPLATES } from "./prompts.js";
12
12
  import { validateLiteracyTask } from "./validation.js";
13
+ /**
14
+ * Variable keys reserved by the AILF compilers. Authoring these via
15
+ * `prompt.vars` is rejected by `PromptVars` at compile time and by
16
+ * `TaskPromptSchema` at parse time; this constant exists to defend
17
+ * the literacy compiler at runtime against legacy-shape `*.task.ts`
18
+ * files that bypass both gates.
19
+ */
20
+ const RESERVED_PROMPT_VAR_KEYS = [
21
+ "task",
22
+ "docs",
23
+ "__featureArea",
24
+ ];
13
25
  /**
14
26
  * Compile a literacy task into Promptfoo configuration.
15
27
  */
@@ -58,20 +70,47 @@ function buildPrompts(evalMode) {
58
70
  // ---------------------------------------------------------------------------
59
71
  function buildTestCases(task, evalMode, options, warnings) {
60
72
  const tests = [];
61
- const promptText = task.prompt?.text ?? task.prompt?.template ?? "";
73
+ // W0193: type-erased read of prompt.vars so we can defensively detect
74
+ // reserved keys on legacy-shape `*.task.ts` files (the type narrow makes
75
+ // `task.prompt.vars.task` `never`, but TS task files bypass both the
76
+ // type and the parse-time schema). YAML/inline-task paths have already
77
+ // been migrated by `migratePromptShape` upstream.
78
+ const rawVars = (task.prompt?.vars ?? {});
79
+ const legacyTaskBody = typeof rawVars.task === "string" ? rawVars.task : undefined;
80
+ const promptText = task.prompt?.text ?? legacyTaskBody ?? task.prompt?.template ?? "";
62
81
  const contextDocs = task.context?.docs ?? [];
63
82
  const taskArea = task.area ?? "";
64
83
  const taskTitle = task.title;
65
- const promptVars = task.prompt?.vars ?? {};
84
+ // Strip reserved keys from the vars spread so they cannot override the
85
+ // canonical assignments below. `safePromptVars` carries only freeform
86
+ // template extras.
87
+ const safePromptVars = {};
88
+ const presentReserved = [];
89
+ for (const [key, value] of Object.entries(rawVars)) {
90
+ if (RESERVED_PROMPT_VAR_KEYS.includes(key)) {
91
+ presentReserved.push(key);
92
+ continue;
93
+ }
94
+ safePromptVars[key] = value;
95
+ }
96
+ // Single deduplicated deprecation warning per task — even when several
97
+ // reserved keys are present.
98
+ if (presentReserved.length > 0) {
99
+ warnings.push(`Literacy task "${task.id}": deprecated prompt.vars keys ` +
100
+ `(${presentReserved.join(", ")}) — use prompt.text for the prompt ` +
101
+ `body and context.docs for documentation references. The compiler ` +
102
+ `migrated them in-memory, but the task source should be updated.`);
103
+ }
66
104
  const hasDocs = contextDocs.length > 0;
67
105
  const docsVar = hasDocs ? `file://contexts/canonical/${task.id}.md` : "";
68
106
  const assertions = resolveAssertions(task, options, warnings);
69
- // Gold entry — canonical docs injected
107
+ // Gold entry — canonical docs injected. Spread freeform extras first so
108
+ // canonical keys (task / docs / __featureArea) cannot be overridden.
70
109
  const goldVars = {
110
+ ...safePromptVars,
71
111
  task: promptText,
72
112
  docs: docsVar,
73
113
  __featureArea: taskArea,
74
- ...promptVars,
75
114
  };
76
115
  tests.push({
77
116
  description: `${taskTitle} (gold)`,
@@ -89,10 +128,10 @@ function buildTestCases(task, evalMode, options, warnings) {
89
128
  tests.push({
90
129
  description: `${taskTitle} (baseline)`,
91
130
  vars: {
131
+ ...safePromptVars,
92
132
  task: promptText,
93
133
  docs: "",
94
134
  __featureArea: taskArea,
95
- ...promptVars,
96
135
  },
97
136
  prompts: ["without-docs"],
98
137
  ...(baselineAssertions.length > 0