@sanity/ailf 0.1.34 → 0.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.
@@ -63,8 +63,9 @@ export class CalculateScoresStep {
63
63
  catch {
64
64
  // Non-fatal — proceed without source metadata
65
65
  }
66
+ let belowCritical = [];
66
67
  try {
67
- calculateAndWriteScores({
68
+ const result = calculateAndWriteScores({
68
69
  allowedOrigins: ctx.config.allowedOrigins,
69
70
  mode: ctx.config.mode,
70
71
  resolvedSource,
@@ -75,25 +76,14 @@ export class CalculateScoresStep {
75
76
  searchMode: ctx.config.searchMode,
76
77
  source: ctx.config.source,
77
78
  });
79
+ belowCritical = result.belowCritical;
78
80
  }
79
81
  catch (err) {
80
- const code = err !== null && typeof err === "object" && "status" in err
81
- ? err.status
82
- : undefined;
83
- if (code !== undefined && code !== 1) {
84
- return {
85
- durationMs: Date.now() - start,
86
- error: `calculate-scores failed with exit code ${code}`,
87
- status: "failed",
88
- };
89
- }
90
- if (code === undefined) {
91
- return {
92
- durationMs: Date.now() - start,
93
- error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
94
- status: "failed",
95
- };
96
- }
82
+ return {
83
+ durationMs: Date.now() - start,
84
+ error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
85
+ status: "failed",
86
+ };
97
87
  }
98
88
  // Postcondition: score summary exists and is valid
99
89
  const summaryIssues = checkScoreSummaryValid(ctx.config.rootDir);
@@ -105,10 +95,19 @@ export class CalculateScoresStep {
105
95
  status: "failed",
106
96
  };
107
97
  }
98
+ // Propagate belowCritical into pipeline state for downstream consumers
99
+ // (e.g., orchestrator reporting, publish step metadata).
100
+ // This is informational — the pipeline continues to run subsequent steps.
101
+ if (belowCritical.length > 0) {
102
+ state.belowCritical = belowCritical;
103
+ }
104
+ const criticalSuffix = belowCritical.length > 0
105
+ ? ` (${belowCritical.length} area(s) below critical threshold: ${belowCritical.join(", ")})`
106
+ : "";
108
107
  return {
109
108
  durationMs: Date.now() - start,
110
109
  status: "success",
111
- summary: "Scores calculated and summary written",
110
+ summary: `Scores calculated and summary written${criticalSuffix}`,
112
111
  };
113
112
  }
114
113
  cacheInputs(ctx) {
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import { resolve } from "path";
15
15
  import { checkScoreSummaryValid } from "../../pipeline/checks.js";
16
16
  import { buildProvenance, } from "../../pipeline/provenance.js";
17
+ import { generateReportTitle } from "../../pipeline/report-title.js";
17
18
  import { generateReportId } from "../../report-store.js";
18
19
  import { withRetry } from "../../sinks/retry.js";
19
20
  export class PublishReportStep {
@@ -101,6 +102,7 @@ export class PublishReportStep {
101
102
  comparedAgainst: autoCompareResult.baselineReportId,
102
103
  };
103
104
  }
105
+ const title = generateReportTitle({ provenance });
104
106
  const report = {
105
107
  comparison: comparison ?? undefined,
106
108
  completedAt: now,
@@ -109,6 +111,7 @@ export class PublishReportStep {
109
111
  provenance,
110
112
  summary,
111
113
  tag: this.options.publishTag ?? ctx.config.publishTag,
114
+ title,
112
115
  };
113
116
  // Share reportId with downstream steps (CallbackStep + orchestrator job update)
114
117
  state.reportId = reportId;
@@ -99,4 +99,9 @@ export interface CalculateScoresOptions {
99
99
  /** Documentation source name */
100
100
  source?: string;
101
101
  }
102
- export declare function calculateAndWriteScores(options: CalculateScoresOptions): void;
102
+ /** Result from calculateAndWriteScores — replaces process.exit() calls. */
103
+ export interface CalculateScoresResult {
104
+ /** Feature areas that scored below the critical threshold (40). */
105
+ belowCritical: string[];
106
+ }
107
+ export declare function calculateAndWriteScores(options: CalculateScoresOptions): CalculateScoresResult;
@@ -674,15 +674,10 @@ export function calculateAndWriteScores(options) {
674
674
  const resultsIssues = checkResultsExist(ROOT, baselineResultsPath);
675
675
  const resultsErrors = resultsIssues.filter((i) => i.severity === "error");
676
676
  if (resultsErrors.length > 0) {
677
- console.error("❌ Results validation failed:");
678
- for (const e of resultsErrors) {
679
- console.error(` ERROR: ${e.message}`);
680
- if (e.path) {
681
- console.error(` at ${e.path}`);
682
- }
683
- }
684
- console.error("\nRun 'pnpm eval' first to generate results, then 'pnpm calculate-scores'.");
685
- process.exit(1);
677
+ const details = resultsErrors
678
+ .map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
679
+ .join("; ");
680
+ throw new Error(`Results validation failed: ${details}. Run 'pnpm eval' first to generate results.`);
686
681
  }
687
682
  console.log(`Reading results from: ${baselineResultsPath}`);
688
683
  if (source) {
@@ -750,10 +745,7 @@ export function calculateAndWriteScores(options) {
750
745
  writeFileSync(join(outDir, "grader-judgments.json"), JSON.stringify(judgments, null, 2));
751
746
  console.log(`Grader judgments written to results/latest/grader-judgments.json (${judgments.length} judgments)`);
752
747
  }
753
- // Exit with non-zero if any area below critical threshold
754
- if (summary.belowCritical.length > 0) {
755
- process.exit(1);
756
- }
748
+ return { belowCritical: summary.belowCritical };
757
749
  }
758
750
  function printPerModelReport(perModel) {
759
751
  console.log("-".repeat(80));
@@ -264,15 +264,10 @@ export function generateConfigs(options) {
264
264
  const modelIssues = validateModelsYaml(rootDir);
265
265
  const modelErrors = modelIssues.filter((i) => i.severity === "error");
266
266
  if (modelErrors.length > 0) {
267
- console.error("❌ config/models.yaml validation failed:");
268
- for (const e of modelErrors) {
269
- console.error(` ERROR: ${e.message}`);
270
- if (e.path) {
271
- console.error(` at ${e.path}`);
272
- }
273
- }
274
- console.error("\nFix config/models.yaml before generating configs. Run 'pnpm validate' for details.");
275
- process.exit(1);
267
+ const details = modelErrors
268
+ .map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
269
+ .join("; ");
270
+ throw new Error(`config/models.yaml validation failed: ${details}. Run 'pnpm validate' for details.`);
276
271
  }
277
272
  console.log("Loading config/models.yaml...");
278
273
  const models = loadModels(rootDir);
@@ -24,6 +24,15 @@ export interface MirrorOptions {
24
24
  /** If true, log what would be done without writing */
25
25
  dryRun?: boolean;
26
26
  }
27
+ /** Authorship info extracted from git context or GitHub Actions environment. */
28
+ export interface GitAuthor {
29
+ /** Git commit author name (e.g., "Jordan Smith") */
30
+ gitName?: string;
31
+ /** Git commit author email (e.g., "jordan@example.com") */
32
+ gitEmail?: string;
33
+ /** GitHub username (from GITHUB_ACTOR or event payload) */
34
+ githubUsername?: string;
35
+ }
27
36
  export interface GitContext {
28
37
  /** Full repo identifier (e.g., "sanity-io/visual-editing") */
29
38
  repo: string;
@@ -35,6 +44,8 @@ export interface GitContext {
35
44
  branch: string;
36
45
  /** HEAD commit SHA */
37
46
  commitSha: string;
47
+ /** Author of the current commit/trigger */
48
+ author: GitAuthor;
38
49
  }
39
50
  export interface MirrorResult {
40
51
  /** Total tasks processed */
@@ -84,3 +95,69 @@ export declare function mirrorDocId(owner: string, repo: string, taskId: string)
84
95
  * that's not mirrored.
85
96
  */
86
97
  export declare function computeTaskHash(task: TaskDefinition): string;
98
+ /** @internal Exported for testing — not part of the public API. */
99
+ export declare function buildMirrorDocument(task: TaskDefinition, opts: {
100
+ contentHash: string;
101
+ docId: string;
102
+ /** Existing author from the current mirror document (write-once preservation) */
103
+ existingAuthor?: GitAuthor;
104
+ git: GitContext;
105
+ slugToDocId: Map<string, string>;
106
+ }): {
107
+ baseline?: {
108
+ rubric?: "full" | "abbreviated" | "none" | undefined;
109
+ enabled?: boolean | undefined;
110
+ } | undefined;
111
+ _id: string;
112
+ _type: string;
113
+ ownership: string;
114
+ status: "active" | "draft" | "paused" | "archived";
115
+ assert: Record<string, unknown>[];
116
+ canonicalDocs: ({
117
+ _key: string;
118
+ reason: string;
119
+ } | {
120
+ refType: string;
121
+ path: string;
122
+ _key: string;
123
+ reason: string;
124
+ } | {
125
+ doc?: {
126
+ _ref: string;
127
+ _type: string;
128
+ } | undefined;
129
+ docId?: string | undefined;
130
+ refType: string;
131
+ _key: string;
132
+ reason: string;
133
+ } | {
134
+ refType: string;
135
+ perspective: string;
136
+ _key: string;
137
+ reason: string;
138
+ })[];
139
+ description: string;
140
+ docCoverage: boolean;
141
+ featureArea: {
142
+ _ref: string;
143
+ _type: string;
144
+ };
145
+ id: {
146
+ _type: string;
147
+ current: string;
148
+ };
149
+ origin: {
150
+ branch: string;
151
+ commitSha: string;
152
+ contentHash: string;
153
+ lastSyncedAt: string;
154
+ path: string;
155
+ repo: string;
156
+ repoName: string;
157
+ repoOwner: string;
158
+ type: string;
159
+ author: GitAuthor;
160
+ lastEditor: GitAuthor;
161
+ };
162
+ taskPrompt: string;
163
+ };
@@ -13,7 +13,8 @@
13
13
  * @see docs/exec-plans/tasks-as-content/phase-5-content-lake-mirroring.md
14
14
  */
15
15
  import { createHash } from "crypto";
16
- import { isSlugRef, } from "../_vendor/ailf-core/index.js";
16
+ import { readFileSync } from "fs";
17
+ import { isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../_vendor/ailf-core/index.js";
17
18
  // ---------------------------------------------------------------------------
18
19
  // Public API
19
20
  // ---------------------------------------------------------------------------
@@ -56,22 +57,30 @@ export async function mirrorRepoTasks(options) {
56
57
  const areas = [...new Set(tasks.map((t) => t.featureArea))];
57
58
  const createdAreas = await ensureFeatureAreas(client, areas, dryRun);
58
59
  result.areasCreated = createdAreas;
59
- // Fetch existing mirror document content hashes for change detection
60
+ // Fetch existing mirror document state for change detection + ownership check
60
61
  const mirrorIds = tasks.map((t) => mirrorDocId(git.owner, git.name, t.id));
61
- const existingHashes = await fetchExistingHashes(client, mirrorIds);
62
+ const existingDocState = await fetchExistingDocState(client, mirrorIds);
62
63
  // Mirror each task
63
64
  for (const task of tasks) {
64
65
  try {
65
66
  const docId = mirrorDocId(git.owner, git.name, task.id);
67
+ const existing = existingDocState.get(docId);
68
+ // Skip graduated tasks — ownership was changed to "studio"
69
+ if (existing?.ownership === "studio") {
70
+ console.log(` ℹ️ Skipping "${task.id}" — graduated to Studio ownership`);
71
+ result.skipped++;
72
+ continue;
73
+ }
66
74
  const contentHash = computeTaskHash(task);
67
75
  // Skip unchanged
68
- if (existingHashes.get(docId) === contentHash) {
76
+ if (existing?.hash === contentHash) {
69
77
  result.skipped++;
70
78
  continue;
71
79
  }
72
80
  const doc = buildMirrorDocument(task, {
73
81
  contentHash,
74
82
  docId,
83
+ existingAuthor: existing?.existingAuthor,
75
84
  git,
76
85
  slugToDocId,
77
86
  });
@@ -106,12 +115,15 @@ export async function detectGitContext(repoTasksPath) {
106
115
  if (ghRepo) {
107
116
  const [owner, name] = ghRepo.split("/");
108
117
  const branch = ghHeadRef || ghRef.replace("refs/heads/", "").replace("refs/tags/", "");
118
+ // Extract author from GitHub Actions environment
119
+ const author = detectGitHubActionsAuthor();
109
120
  return {
110
121
  repo: ghRepo,
111
122
  owner: owner ?? "unknown",
112
123
  name: name ?? "unknown",
113
124
  branch: branch || "unknown",
114
125
  commitSha: ghSha || "unknown",
126
+ author,
115
127
  };
116
128
  }
117
129
  // Fallback: try git CLI
@@ -134,12 +146,15 @@ export async function detectGitContext(repoTasksPath) {
134
146
  remote.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/);
135
147
  const owner = match?.[1] ?? "unknown";
136
148
  const name = match?.[2] ?? "unknown";
149
+ // Extract author from git log
150
+ const author = detectGitCliAuthor(repoTasksPath, execSync);
137
151
  return {
138
152
  repo: `${owner}/${name}`,
139
153
  owner,
140
154
  name,
141
155
  branch,
142
156
  commitSha,
157
+ author,
143
158
  };
144
159
  }
145
160
  catch {
@@ -149,10 +164,63 @@ export async function detectGitContext(repoTasksPath) {
149
164
  name: "unknown",
150
165
  branch: "unknown",
151
166
  commitSha: "unknown",
167
+ author: {},
152
168
  };
153
169
  }
154
170
  }
155
171
  // ---------------------------------------------------------------------------
172
+ // Author detection helpers
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Extract author info from GitHub Actions environment variables and
176
+ * the webhook event payload (GITHUB_EVENT_PATH).
177
+ */
178
+ function detectGitHubActionsAuthor() {
179
+ const ghActor = process.env.GITHUB_ACTOR ?? undefined;
180
+ const author = { githubUsername: ghActor };
181
+ // Try to read richer author info from the event payload
182
+ const eventPath = process.env.GITHUB_EVENT_PATH;
183
+ if (eventPath) {
184
+ try {
185
+ const event = JSON.parse(readFileSync(eventPath, "utf-8"));
186
+ // Push event: head_commit.author has name + email
187
+ if (event.head_commit?.author) {
188
+ author.gitName = event.head_commit.author.name ?? undefined;
189
+ author.gitEmail = event.head_commit.author.email ?? undefined;
190
+ }
191
+ // PR event: pull_request.user.login is the PR author
192
+ if (event.pull_request?.user?.login) {
193
+ author.githubUsername = event.pull_request.user.login;
194
+ }
195
+ }
196
+ catch {
197
+ // Event payload parsing is best-effort — fall through with GITHUB_ACTOR only
198
+ }
199
+ }
200
+ return author;
201
+ }
202
+ /**
203
+ * Extract author info from git CLI (local fallback when not in GitHub Actions).
204
+ */
205
+ function detectGitCliAuthor(cwd, execSyncFn) {
206
+ try {
207
+ const authorLine = execSyncFn('git log -1 --format="%an|%ae"', {
208
+ encoding: "utf-8",
209
+ cwd,
210
+ })
211
+ .toString()
212
+ .trim();
213
+ const [gitName, gitEmail] = authorLine.split("|");
214
+ return {
215
+ gitName: gitName || undefined,
216
+ gitEmail: gitEmail || undefined,
217
+ };
218
+ }
219
+ catch {
220
+ return {};
221
+ }
222
+ }
223
+ // ---------------------------------------------------------------------------
156
224
  // Document ID scheme
157
225
  // ---------------------------------------------------------------------------
158
226
  /**
@@ -247,42 +315,82 @@ async function ensureFeatureAreas(client, areas, dryRun) {
247
315
  await transaction.commit();
248
316
  return missing;
249
317
  }
250
- // ---------------------------------------------------------------------------
251
- // Fetch existing content hashes
252
- // ---------------------------------------------------------------------------
253
318
  /**
254
- * Fetch existing mirror documents' content hashes for change detection.
255
- * The hash is stored in origin.contentHash on the document.
319
+ * Fetch existing mirror documents' ownership and content hashes.
320
+ *
321
+ * The ownership field determines whether the mirror step is allowed to
322
+ * update the document:
323
+ * - `"repo"` or absent → safe to update (active mirror)
324
+ * - `"studio"` → skip (graduated to Studio ownership)
325
+ *
326
+ * The hash is stored in origin.contentHash for change detection.
256
327
  */
257
- async function fetchExistingHashes(client, docIds) {
328
+ async function fetchExistingDocState(client, docIds) {
258
329
  if (docIds.length === 0)
259
330
  return new Map();
260
- const query = `*[_id in $ids] { _id, "hash": origin.contentHash }`;
261
- const results = await client.fetch(query, {
262
- ids: docIds,
263
- });
331
+ const query = `*[_id in $ids] {
332
+ _id,
333
+ "hash": origin.contentHash,
334
+ ownership,
335
+ "existingAuthor": origin.author
336
+ }`;
337
+ const results = await client.fetch(query, { ids: docIds });
264
338
  const map = new Map();
265
339
  for (const r of results) {
266
- if (r.hash)
267
- map.set(r._id, r.hash);
340
+ map.set(r._id, {
341
+ hash: r.hash ?? undefined,
342
+ ownership: r.ownership ?? undefined,
343
+ existingAuthor: r.existingAuthor ?? undefined,
344
+ });
268
345
  }
269
346
  return map;
270
347
  }
271
348
  // ---------------------------------------------------------------------------
272
349
  // Build mirror document
273
350
  // ---------------------------------------------------------------------------
274
- function buildMirrorDocument(task, opts) {
275
- const { contentHash, docId, git, slugToDocId } = opts;
276
- // Build canonical docs with resolved references.
277
- // Only slug refs can be resolved to article references today.
278
- // Other ref types (path, id, perspective) are stored with reason only.
351
+ /** @internal Exported for testing — not part of the public API. */
352
+ export function buildMirrorDocument(task, opts) {
353
+ const { contentHash, docId, existingAuthor, git, slugToDocId } = opts;
354
+ // Build canonical docs with resolved references and correct refType.
355
+ // Each ref type gets the appropriate resolution fields set on the
356
+ // mirror document so Studio can display them correctly.
279
357
  const canonicalDocs = task.canonicalDocs.map((ref, i) => {
280
- const resolvedId = isSlugRef(ref) ? slugToDocId.get(ref.slug) : undefined;
281
- return {
282
- _key: `cd${i}`,
283
- ...(resolvedId ? { doc: { _ref: resolvedId, _type: "reference" } } : {}),
284
- reason: ref.reason ?? "",
285
- };
358
+ const base = { _key: `cd${i}`, reason: ref.reason ?? "" };
359
+ if (isSlugRef(ref)) {
360
+ const resolvedId = slugToDocId.get(ref.slug);
361
+ // When a slug resolves to a document, store as "id" ref with
362
+ // the resolved article reference. When unresolved, store as
363
+ // "slug" so Studio knows the resolution strategy even if the
364
+ // article doesn't exist yet.
365
+ return {
366
+ ...base,
367
+ refType: resolvedId ? "id" : "slug",
368
+ ...(resolvedId
369
+ ? { doc: { _ref: resolvedId, _type: "reference" } }
370
+ : {}),
371
+ };
372
+ }
373
+ if (isPathRef(ref)) {
374
+ return { ...base, refType: "path", path: ref.path };
375
+ }
376
+ if (isIdRef(ref)) {
377
+ return {
378
+ ...base,
379
+ refType: "id",
380
+ ...(ref.id
381
+ ? { doc: { _ref: ref.id, _type: "reference" }, docId: ref.id }
382
+ : {}),
383
+ };
384
+ }
385
+ if (isPerspectiveRef(ref)) {
386
+ return {
387
+ ...base,
388
+ refType: "perspective",
389
+ perspective: ref.perspective,
390
+ };
391
+ }
392
+ // Unknown ref type — store with reason only
393
+ return base;
286
394
  });
287
395
  // Build assertions
288
396
  const assertArray = task.assertions.map((a, i) => {
@@ -315,6 +423,8 @@ function buildMirrorDocument(task, opts) {
315
423
  return {
316
424
  _id: docId,
317
425
  _type: "ailf.task",
426
+ ownership: "repo",
427
+ status: task.status ?? "active",
318
428
  assert: assertArray,
319
429
  canonicalDocs,
320
430
  description: task.description,
@@ -325,6 +435,7 @@ function buildMirrorDocument(task, opts) {
325
435
  },
326
436
  id: { _type: "slug", current: task.id },
327
437
  origin: {
438
+ // Existing provenance fields
328
439
  branch: git.branch,
329
440
  commitSha: git.commitSha,
330
441
  contentHash,
@@ -334,6 +445,9 @@ function buildMirrorDocument(task, opts) {
334
445
  repoName: git.name,
335
446
  repoOwner: git.owner,
336
447
  type: "repo",
448
+ // Authorship: author is write-once (preserve existing), lastEditor always updates
449
+ author: existingAuthor ?? git.author,
450
+ lastEditor: git.author,
337
451
  },
338
452
  taskPrompt: task.taskPrompt,
339
453
  ...(task.baseline
@@ -0,0 +1,66 @@
1
+ /**
2
+ * pipeline/report-title.ts
3
+ *
4
+ * Pure function that generates descriptive report titles from provenance
5
+ * metadata. The title is the primary display string shown in dashboards,
6
+ * Slack digests, and Studio views — it conveys trigger context, evaluated
7
+ * areas, source/perspective, and document scope at a glance.
8
+ *
9
+ * Score is intentionally omitted from the title since it is surfaced
10
+ * heavily elsewhere in the UI. The `tag` field (on Report) is preserved
11
+ * as a secondary label; the title is the primary display string.
12
+ *
13
+ * Segments are joined with ` · ` (middle dot with spaces).
14
+ *
15
+ * @see docs/design-docs/report-store/domain-model.md
16
+ * @see packages/eval/src/pipeline/provenance.ts — builds the provenance input
17
+ */
18
+ import type { EvalMode, ReportTrigger } from "./types.js";
19
+ /** Input required to generate a human-readable report title. */
20
+ export interface ReportTitleInput {
21
+ provenance: {
22
+ /** Feature areas that were evaluated */
23
+ areas: string[];
24
+ /** Evaluation mode */
25
+ mode: EvalMode;
26
+ /** Resolved documentation source */
27
+ source: {
28
+ name: string;
29
+ perspective?: string;
30
+ };
31
+ /** Sanity document IDs targeted (when scoped to specific documents) */
32
+ targetDocuments?: string[];
33
+ /** What triggered the evaluation */
34
+ trigger: ReportTrigger;
35
+ };
36
+ /**
37
+ * Total number of known feature areas in the system.
38
+ * Used to determine whether to show "All areas" vs "N areas"
39
+ * when more than 3 areas are evaluated.
40
+ */
41
+ totalAreaCount?: number;
42
+ }
43
+ /**
44
+ * Generate a descriptive report title from provenance metadata.
45
+ *
46
+ * The title is composed of up to four segments separated by ` · `:
47
+ *
48
+ * 1. **Trigger context** — what initiated the evaluation (always present)
49
+ * 2. **Areas** — which feature areas were evaluated (omitted if empty)
50
+ * 3. **Source context** — non-default source or perspective (omitted if default)
51
+ * 4. **Target documents** — scoped document IDs (omitted if not scoped)
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * generateReportTitle({
56
+ * provenance: {
57
+ * areas: ["GROQ", "Mutations"],
58
+ * mode: "baseline",
59
+ * source: { name: "production" },
60
+ * trigger: { type: "manual" },
61
+ * },
62
+ * })
63
+ * // → "Manual eval · GROQ, Mutations"
64
+ * ```
65
+ */
66
+ export declare function generateReportTitle(input: ReportTitleInput): string;