blume 1.1.4 → 1.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 (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1286 -63
  4. package/dist/cli/index.js.map +32 -21
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +1 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +19 -8
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +25 -2
  26. package/src/astro/templates.ts +114 -22
  27. package/src/cli/commands/eval.ts +291 -0
  28. package/src/cli/commands/init.ts +9 -4
  29. package/src/cli/commands/mcp-stdio.ts +36 -0
  30. package/src/cli/index.ts +4 -0
  31. package/src/cli/required-secrets.ts +1 -1
  32. package/src/components/content/AccordionItem.astro +2 -2
  33. package/src/components/content/TreeFolder.astro +1 -2
  34. package/src/components/islands/AskAI.astro +9 -2
  35. package/src/components/islands/ask-ai.tsx +4 -2
  36. package/src/components/islands/hooks.ts +10 -4
  37. package/src/components/layout/NavTree.astro +37 -19
  38. package/src/components/layout/ReferenceLayout.astro +4 -0
  39. package/src/components/layout/RootLayout.astro +1 -1
  40. package/src/components/openapi/SchemaProperty.astro +3 -3
  41. package/src/core/config-input.ts +18 -0
  42. package/src/core/config.ts +4 -0
  43. package/src/core/data.ts +1 -0
  44. package/src/core/graph.ts +1 -0
  45. package/src/core/navigation.ts +9 -2
  46. package/src/core/schema.ts +51 -4
  47. package/src/core/server-features.ts +1 -1
  48. package/src/core/types.ts +5 -3
  49. package/src/eval/agents.ts +340 -0
  50. package/src/eval/findings.ts +103 -0
  51. package/src/eval/prompts.ts +78 -0
  52. package/src/eval/report.ts +214 -0
  53. package/src/eval/run.ts +290 -0
  54. package/src/eval/schema.ts +124 -0
  55. package/src/openapi/references.ts +23 -2
  56. package/src/openapi/render-mdx.ts +27 -4
  57. package/src/openapi/scalar.ts +1 -0
  58. package/src/openapi/source.ts +11 -4
  59. package/src/registry/eject.ts +23 -1
  60. package/src/search/build.ts +4 -3
@@ -0,0 +1,214 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+
4
+ import { join, relative } from "pathe";
5
+
6
+ import { AGENTS } from "../audit/agent.ts";
7
+ import { countBySeverity } from "../core/diagnostics.ts";
8
+ import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
9
+
10
+ const ESC = String.fromCodePoint(27);
11
+ const COLORS = {
12
+ bold: `${ESC}[1m`,
13
+ cyan: `${ESC}[36m`,
14
+ dim: `${ESC}[2m`,
15
+ green: `${ESC}[32m`,
16
+ red: `${ESC}[31m`,
17
+ reset: `${ESC}[0m`,
18
+ yellow: `${ESC}[33m`,
19
+ };
20
+
21
+ const GLYPH: Record<QuestionStatus, string> = {
22
+ error: "!",
23
+ fail: "✖",
24
+ pass: "✔",
25
+ skip: "⊘",
26
+ };
27
+
28
+ const STATUS_COLOR: Record<QuestionStatus, string> = {
29
+ error: COLORS.yellow,
30
+ fail: COLORS.red,
31
+ pass: COLORS.green,
32
+ skip: COLORS.dim,
33
+ };
34
+
35
+ /** Longest id gets the room; everything shorter aligns to it. */
36
+ const ID_PAD = 28;
37
+
38
+ const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
39
+
40
+ const money = (cost: number | undefined): string =>
41
+ cost === undefined ? "" : `$${cost.toFixed(2)}`;
42
+
43
+ const duration = (ms: number): string => {
44
+ if (ms < 60_000) {
45
+ return seconds(ms);
46
+ }
47
+ const minutes = Math.floor(ms / 60_000);
48
+ const rest = Math.round((ms % 60_000) / 1000);
49
+ return `${minutes}m ${rest}s`;
50
+ };
51
+
52
+ /** One question's progress/report line: glyph, id, status, score, time, cost. */
53
+ export const questionLine = (result: QuestionResult): string => {
54
+ const color = STATUS_COLOR[result.status];
55
+ const glyph = `${color}${GLYPH[result.status]}${COLORS.reset}`;
56
+ const id = result.id.padEnd(ID_PAD);
57
+ if (result.status === "skip") {
58
+ return ` ${glyph} ${id} ${COLORS.dim}skipped${COLORS.reset}`;
59
+ }
60
+ const score = result.score === undefined ? "" : result.score.toFixed(2);
61
+ const cells = [
62
+ `${color}${result.status}${COLORS.reset}`,
63
+ score,
64
+ `${COLORS.dim}${seconds(result.durationMs)}${COLORS.reset}`,
65
+ `${COLORS.dim}${money(result.costUsd)}${COLORS.reset}`,
66
+ ]
67
+ .filter((cell) => cell !== "")
68
+ .join(" ");
69
+ return ` ${glyph} ${id} ${cells}`;
70
+ };
71
+
72
+ /** Indented context under a question's line: missing facts, error detail. */
73
+ export const questionDetails = (
74
+ result: QuestionResult,
75
+ verbose: boolean
76
+ ): string[] => {
77
+ const lines: string[] = [];
78
+ if (result.status === "fail") {
79
+ for (const fact of result.missing) {
80
+ lines.push(` ${COLORS.dim}missing: ${fact}${COLORS.reset}`);
81
+ }
82
+ }
83
+ if (result.status === "error" && result.detail) {
84
+ lines.push(` ${COLORS.dim}${result.detail}${COLORS.reset}`);
85
+ }
86
+ if (verbose && result.answer && result.status !== "pass") {
87
+ lines.push(
88
+ ...result.answer
89
+ .split("\n")
90
+ .map((line) => ` ${COLORS.dim}> ${line}${COLORS.reset}`)
91
+ );
92
+ }
93
+ return lines;
94
+ };
95
+
96
+ /** The one-line totals: `9 passed · 2 failed · 1 skipped · 1m 42s · $0.71`. */
97
+ export const summaryLine = (result: EvalResult): string => {
98
+ const { counts } = result;
99
+ const parts = [
100
+ `${counts.pass} passed`,
101
+ counts.fail > 0 ? `${counts.fail} failed` : "",
102
+ counts.error > 0 ? `${counts.error} errored` : "",
103
+ counts.skip > 0 ? `${counts.skip} skipped` : "",
104
+ duration(result.durationMs),
105
+ money(result.costUsd),
106
+ ].filter((part) => part !== "");
107
+ return parts.join(" · ");
108
+ };
109
+
110
+ /** The header line the command prints before the first question runs. */
111
+ export const headerLine = (total: number, agent: EvalResult["agent"]): string =>
112
+ `${COLORS.bold}blume eval${COLORS.reset} ${total} question(s) · ${AGENTS[agent].name}`;
113
+
114
+ /** The dim announce line while a question's agents run. */
115
+ export const startLine = (id: string, index: number, total: number): string =>
116
+ ` ${COLORS.dim}▸ ${id} (${index + 1}/${total})${COLORS.reset}`;
117
+
118
+ /** `fix:` pointers for failed questions, naming the file that resolves each. */
119
+ export const fixLines = (result: EvalResult, root: string): string[] =>
120
+ result.diagnostics
121
+ .filter((diagnostic) => diagnostic.code !== "BLUME_EVAL_ROUTE_UNKNOWN")
122
+ .map((finding) => {
123
+ const site = finding.file
124
+ ? `${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
125
+ : "";
126
+ return ` ${COLORS.cyan}fix:${COLORS.reset} ${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
127
+ });
128
+
129
+ /** Dim warnings for route hints that no longer match a page. */
130
+ export const warningLines = (result: EvalResult, root: string): string[] =>
131
+ result.diagnostics
132
+ .filter((diagnostic) => diagnostic.code === "BLUME_EVAL_ROUTE_UNKNOWN")
133
+ .map((finding) => {
134
+ const site = finding.file
135
+ ? ` ${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
136
+ : "";
137
+ return ` ${COLORS.yellow}⚠${COLORS.reset}${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
138
+ });
139
+
140
+ /** The human report, written to stderr by the command. */
141
+ export const formatEvalReport = (
142
+ result: EvalResult,
143
+ root: string,
144
+ options: { verbose?: boolean } = {}
145
+ ): string => {
146
+ const lines: string[] = [headerLine(result.results.length, result.agent), ""];
147
+
148
+ for (const question of result.results) {
149
+ lines.push(
150
+ questionLine(question),
151
+ ...questionDetails(question, Boolean(options.verbose))
152
+ );
153
+ }
154
+ lines.push("");
155
+
156
+ // The finding tells the author which file fixes which failure.
157
+ const failures = fixLines(result, root);
158
+ lines.push(...failures);
159
+ if (failures.length > 0) {
160
+ lines.push("");
161
+ }
162
+
163
+ lines.push(` ${summaryLine(result)}`, "");
164
+ return lines.join("\n");
165
+ };
166
+
167
+ /**
168
+ * The machine-readable report. The `diagnostics` + `summary` shape matches
169
+ * `blume validate --json` and `blume audit --json` exactly — anything parsing
170
+ * those keeps working — with the eval run's own results alongside.
171
+ */
172
+ export const evalReportJson = (
173
+ result: EvalResult,
174
+ root: string,
175
+ threshold: number
176
+ ): string => {
177
+ const diagnostics = result.diagnostics.map((diagnostic) =>
178
+ diagnostic.file
179
+ ? { ...diagnostic, file: relative(root, diagnostic.file) }
180
+ : diagnostic
181
+ );
182
+ return `${JSON.stringify(
183
+ {
184
+ diagnostics,
185
+ eval: {
186
+ agent: result.agent,
187
+ costUsd: result.costUsd,
188
+ counts: result.counts,
189
+ durationMs: result.durationMs,
190
+ results: result.results,
191
+ threshold,
192
+ },
193
+ summary: countBySeverity(result.diagnostics),
194
+ },
195
+ null,
196
+ 2
197
+ )}\n`;
198
+ };
199
+
200
+ /**
201
+ * Write the full JSON report where a `--fix` agent can read it — a file
202
+ * rather than inline prompt text, because a long run's answers would exceed
203
+ * the platform's argv limit.
204
+ */
205
+ export const writeEvalReport = async (
206
+ result: EvalResult,
207
+ root: string,
208
+ threshold: number
209
+ ): Promise<string> => {
210
+ const dir = await mkdtemp(join(tmpdir(), "blume-eval-"));
211
+ const path = join(dir, "report.json");
212
+ await writeFile(path, evalReportJson(result, root, threshold));
213
+ return path;
214
+ };
@@ -0,0 +1,290 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+
4
+ import { join } from "pathe";
5
+
6
+ import { buildMcpData } from "../ai/mcp/data.ts";
7
+ import { AGENTS } from "../audit/agent.ts";
8
+ import type { AgentKind } from "../audit/agent.ts";
9
+ import type { BlumeProject } from "../core/project-graph.ts";
10
+ import type { Diagnostic } from "../core/types.ts";
11
+ import {
12
+ agentArgs,
13
+ parseVerdict,
14
+ readAgentOutput,
15
+ runAgentHeadless,
16
+ writeMcpConfig,
17
+ } from "./agents.ts";
18
+ import type { HeadlessRunner } from "./agents.ts";
19
+ import { questionFinding, routeFindings } from "./findings.ts";
20
+ import { judgePrompt, readerPrompt } from "./prompts.ts";
21
+ import type { EvalQuestion, EvalsFile } from "./schema.ts";
22
+
23
+ /** Reader runs search and read several pages; the judge grades one answer. */
24
+ const DEFAULT_READER_TIMEOUT_MS = 180_000;
25
+ const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
26
+
27
+ export type QuestionStatus = "error" | "fail" | "pass" | "skip";
28
+
29
+ /** One question's outcome, carrying everything the reports print. */
30
+ export interface QuestionResult {
31
+ answer?: string;
32
+ costUsd?: number;
33
+ /** Why the run errored (timeout, agent failure, unparseable verdict). */
34
+ detail?: string;
35
+ durationMs: number;
36
+ expected: string[];
37
+ id: string;
38
+ missing: string[];
39
+ notes?: string;
40
+ question: string;
41
+ routes: string[];
42
+ score?: number;
43
+ status: QuestionStatus;
44
+ }
45
+
46
+ export interface EvalResult {
47
+ agent: AgentKind;
48
+ /** Total spend, when the agent CLI reports it (claude does, codex doesn't). */
49
+ costUsd?: number;
50
+ counts: Record<QuestionStatus, number>;
51
+ diagnostics: Diagnostic[];
52
+ durationMs: number;
53
+ results: QuestionResult[];
54
+ }
55
+
56
+ export type EvalProgress =
57
+ | {
58
+ kind: "question-end";
59
+ index: number;
60
+ result: QuestionResult;
61
+ total: number;
62
+ }
63
+ | { kind: "question-start"; id: string; index: number; total: number };
64
+
65
+ export interface EvalRunOptions {
66
+ agent: AgentKind;
67
+ evals: EvalsFile;
68
+ /** Where the evals file lives, for findings with no usable route hint. */
69
+ evalsPath: string;
70
+ judgeTimeoutMs?: number;
71
+ onProgress?: (event: EvalProgress) => void;
72
+ project: BlumeProject;
73
+ /** The evals file's raw text, for line-anchoring findings. */
74
+ rawEvals: string;
75
+ readerTimeoutMs?: number;
76
+ /** The spawn function — injectable so tests never launch a real agent. */
77
+ run?: HeadlessRunner;
78
+ }
79
+
80
+ interface QuestionContext {
81
+ bin: string;
82
+ dir: string;
83
+ judgeTimeoutMs: number;
84
+ kind: AgentKind;
85
+ mcp: Awaited<ReturnType<typeof writeMcpConfig>>;
86
+ readerTimeoutMs: number;
87
+ run: HeadlessRunner;
88
+ }
89
+
90
+ const errored = (
91
+ question: EvalQuestion,
92
+ detail: string,
93
+ durationMs: number
94
+ ): QuestionResult => ({
95
+ detail,
96
+ durationMs,
97
+ expected: question.expected,
98
+ id: question.id,
99
+ missing: [],
100
+ question: question.question,
101
+ routes: question.routes,
102
+ status: "error",
103
+ });
104
+
105
+ /** Run one question: a fresh empty cwd, the reader, then the judge. */
106
+ const runQuestion = async (
107
+ question: EvalQuestion,
108
+ index: number,
109
+ context: QuestionContext
110
+ ): Promise<QuestionResult> => {
111
+ const started = performance.now();
112
+ const elapsed = () => Math.round(performance.now() - started);
113
+
114
+ // An empty working directory per invocation is the fresh-eyes guardrail:
115
+ // even if a tool restriction slips, there is nothing here to read.
116
+ const workDir = join(context.dir, `work-${index}`);
117
+ await mkdir(workDir, { recursive: true });
118
+
119
+ const answerPath = join(workDir, "answer.txt");
120
+ const reader = await context.run(
121
+ context.bin,
122
+ agentArgs(context.kind, { lastMessagePath: answerPath, mcp: context.mcp }),
123
+ {
124
+ cwd: workDir,
125
+ prompt: readerPrompt(question),
126
+ timeoutMs: context.readerTimeoutMs,
127
+ }
128
+ );
129
+ const answer = await readAgentOutput(context.kind, reader, answerPath);
130
+ if (answer.isError) {
131
+ return {
132
+ ...errored(question, `reader ${answer.detail ?? "failed"}`, elapsed()),
133
+ costUsd: answer.costUsd,
134
+ };
135
+ }
136
+
137
+ const verdictPath = join(workDir, "verdict.txt");
138
+ const judge = await context.run(
139
+ context.bin,
140
+ agentArgs(context.kind, { lastMessagePath: verdictPath }),
141
+ {
142
+ cwd: workDir,
143
+ prompt: judgePrompt(question, answer.text),
144
+ timeoutMs: context.judgeTimeoutMs,
145
+ }
146
+ );
147
+ const graded = await readAgentOutput(context.kind, judge, verdictPath);
148
+ const costUsd =
149
+ answer.costUsd === undefined && graded.costUsd === undefined
150
+ ? undefined
151
+ : (answer.costUsd ?? 0) + (graded.costUsd ?? 0);
152
+ if (graded.isError) {
153
+ return {
154
+ ...errored(question, `judge ${graded.detail ?? "failed"}`, elapsed()),
155
+ answer: answer.text,
156
+ costUsd,
157
+ };
158
+ }
159
+
160
+ const verdict = parseVerdict(graded.text);
161
+ if (!verdict) {
162
+ return {
163
+ ...errored(question, "judge returned no parseable verdict", elapsed()),
164
+ answer: answer.text,
165
+ costUsd,
166
+ };
167
+ }
168
+
169
+ return {
170
+ answer: answer.text,
171
+ costUsd,
172
+ durationMs: elapsed(),
173
+ expected: question.expected,
174
+ id: question.id,
175
+ missing: verdict.missing,
176
+ notes: verdict.notes || undefined,
177
+ question: question.question,
178
+ routes: question.routes,
179
+ score: verdict.score,
180
+ status: verdict.pass ? "pass" : "fail",
181
+ };
182
+ };
183
+
184
+ /**
185
+ * Run every question through the reader/judge pair, sequentially: each
186
+ * question is already two agent sessions, and a serial run keeps progress
187
+ * output ordered and cost attribution obvious.
188
+ */
189
+ export const runEval = async (options: EvalRunOptions): Promise<EvalResult> => {
190
+ const started = performance.now();
191
+ const kind = options.agent;
192
+ const run = options.run ?? runAgentHeadless;
193
+ const anchor = { path: options.evalsPath, raw: options.rawEvals };
194
+
195
+ const dir = await mkdtemp(join(tmpdir(), "blume-eval-"));
196
+ const snapshotPath = join(dir, "mcp-data.json");
197
+ await writeFile(
198
+ snapshotPath,
199
+ JSON.stringify(await buildMcpData(options.project))
200
+ );
201
+ const mcp = await writeMcpConfig(dir, snapshotPath);
202
+
203
+ const context: QuestionContext = {
204
+ bin: AGENTS[kind].bin,
205
+ dir,
206
+ judgeTimeoutMs: options.judgeTimeoutMs ?? DEFAULT_JUDGE_TIMEOUT_MS,
207
+ kind,
208
+ mcp,
209
+ readerTimeoutMs: options.readerTimeoutMs ?? DEFAULT_READER_TIMEOUT_MS,
210
+ run,
211
+ };
212
+
213
+ const diagnostics: Diagnostic[] = [];
214
+ const results: QuestionResult[] = [];
215
+ const { questions } = options.evals;
216
+
217
+ for (const [index, question] of questions.entries()) {
218
+ diagnostics.push(...routeFindings(question, options.project, anchor));
219
+
220
+ if (question.skip) {
221
+ results.push({
222
+ durationMs: 0,
223
+ expected: question.expected,
224
+ id: question.id,
225
+ missing: [],
226
+ question: question.question,
227
+ routes: question.routes,
228
+ status: "skip",
229
+ });
230
+ continue;
231
+ }
232
+
233
+ options.onProgress?.({
234
+ id: question.id,
235
+ index,
236
+ kind: "question-start",
237
+ total: questions.length,
238
+ });
239
+ // Sequential by design: each question is already two agent sessions, and
240
+ // a serial run keeps progress output ordered and cost attribution obvious.
241
+ // oxlint-disable-next-line no-await-in-loop
242
+ const result = await runQuestion(question, index, context);
243
+ results.push(result);
244
+ if (result.status === "fail" || result.status === "error") {
245
+ diagnostics.push(
246
+ questionFinding(
247
+ question,
248
+ {
249
+ detail: result.detail,
250
+ missing: result.missing,
251
+ status: result.status,
252
+ },
253
+ options.project,
254
+ anchor
255
+ )
256
+ );
257
+ }
258
+ options.onProgress?.({
259
+ index,
260
+ kind: "question-end",
261
+ result,
262
+ total: questions.length,
263
+ });
264
+ }
265
+
266
+ const counts: Record<QuestionStatus, number> = {
267
+ error: 0,
268
+ fail: 0,
269
+ pass: 0,
270
+ skip: 0,
271
+ };
272
+ for (const result of results) {
273
+ counts[result.status] += 1;
274
+ }
275
+ const costs = results.flatMap((result) =>
276
+ result.costUsd === undefined ? [] : [result.costUsd]
277
+ );
278
+
279
+ return {
280
+ agent: kind,
281
+ costUsd:
282
+ costs.length > 0
283
+ ? costs.reduce((total, cost) => total + cost, 0)
284
+ : undefined,
285
+ counts,
286
+ diagnostics,
287
+ durationMs: Math.round(performance.now() - started),
288
+ results,
289
+ };
290
+ };
@@ -0,0 +1,124 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { load } from "js-yaml";
4
+ import { z } from "zod";
5
+
6
+ /** Question ids are kebab-case slugs so they read well in reports and CI logs. */
7
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
8
+
9
+ const questionSchema = z.strictObject({
10
+ expected: z
11
+ .array(z.string().min(1))
12
+ .min(1, "expected must list at least one fact"),
13
+ id: z
14
+ .string()
15
+ .regex(ID_PATTERN, "id must be a kebab-case slug (a-z, 0-9, dashes)"),
16
+ question: z.string().min(1),
17
+ routes: z
18
+ .union([z.string(), z.array(z.string())])
19
+ .default([])
20
+ .transform((value) => (typeof value === "string" ? [value] : value)),
21
+ severity: z.enum(["error", "warning"]).default("error"),
22
+ skip: z.boolean().default(false),
23
+ });
24
+
25
+ /** One author-written eval: a question plus the facts a passing answer states. */
26
+ export type EvalQuestion = z.infer<typeof questionSchema>;
27
+
28
+ const fullSchema = z.strictObject({
29
+ questions: z.array(questionSchema).min(1),
30
+ version: z.literal(1).default(1),
31
+ });
32
+
33
+ /**
34
+ * The evals file schema. A bare top-level list of questions is accepted as
35
+ * shorthand — `loadEvalsFile` wraps it before validating, rather than a
36
+ * `z.union`, so schema errors name the offending field instead of collapsing
37
+ * into an opaque "invalid union" issue.
38
+ */
39
+ export const evalsFileSchema = fullSchema.superRefine((value, context) => {
40
+ const seen = new Set<string>();
41
+ for (const question of value.questions) {
42
+ if (seen.has(question.id)) {
43
+ context.addIssue({
44
+ code: z.ZodIssueCode.custom,
45
+ message: `duplicate question id "${question.id}"`,
46
+ path: ["questions"],
47
+ });
48
+ }
49
+ seen.add(question.id);
50
+ }
51
+ });
52
+
53
+ export type EvalsFile = z.infer<typeof evalsFileSchema>;
54
+
55
+ /** A problem loading or validating the evals file, with the path it names. */
56
+ export class EvalsFileError extends Error {
57
+ readonly path: string;
58
+
59
+ constructor(path: string, message: string) {
60
+ super(message);
61
+ this.name = "EvalsFileError";
62
+ this.path = path;
63
+ }
64
+ }
65
+
66
+ const describeIssues = (error: z.ZodError): string =>
67
+ error.issues
68
+ .map((issue) => {
69
+ const at = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
70
+ return `${issue.message}${at}`;
71
+ })
72
+ .join("; ");
73
+
74
+ /**
75
+ * Read and validate an evals file. Returns the parsed questions plus the raw
76
+ * text, kept so findings can anchor to the line a question is defined on.
77
+ */
78
+ export const loadEvalsFile = async (
79
+ path: string
80
+ ): Promise<{ evals: EvalsFile; raw: string }> => {
81
+ let raw: string;
82
+ try {
83
+ raw = await readFile(path, "utf-8");
84
+ } catch {
85
+ throw new EvalsFileError(
86
+ path,
87
+ `No evals file found at ${path}. Run \`blume eval init\` to draft one.`
88
+ );
89
+ }
90
+
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = load(raw);
94
+ } catch (error) {
95
+ const detail = error instanceof Error ? error.message : String(error);
96
+ throw new EvalsFileError(path, `Invalid YAML in ${path}: ${detail}`);
97
+ }
98
+
99
+ // The bare-list shorthand: a top-level sequence of questions.
100
+ const candidate = Array.isArray(parsed) ? { questions: parsed } : parsed;
101
+ const result = evalsFileSchema.safeParse(candidate);
102
+ if (!result.success) {
103
+ throw new EvalsFileError(
104
+ path,
105
+ `Invalid evals file at ${path}: ${describeIssues(result.error)}`
106
+ );
107
+ }
108
+ return { evals: result.data, raw };
109
+ };
110
+
111
+ /**
112
+ * The 1-based line where a question's `id:` entry appears in the raw evals
113
+ * file, so a finding with no route hint can still point somewhere editable.
114
+ */
115
+ export const locateQuestion = (raw: string, id: string): number | undefined => {
116
+ const pattern = new RegExp(`^\\s*-?\\s*id:\\s*["']?${id}["']?\\s*$`, "u");
117
+ const lines = raw.split("\n");
118
+ for (const [index, line] of lines.entries()) {
119
+ if (pattern.test(line)) {
120
+ return index + 1;
121
+ }
122
+ }
123
+ return undefined;
124
+ };
@@ -38,6 +38,12 @@ export interface ReferenceSource {
38
38
  */
39
39
  basePath: string;
40
40
  label: string;
41
+ /** Whether generated pages are included in llms.txt/llms-full.txt. */
42
+ includeInLlms: boolean;
43
+ /** Whether generated pages are included in site search. */
44
+ includeInSearch: boolean;
45
+ /** Whether generated pages emit noindex metadata and stay out of the sitemap. */
46
+ noindex: boolean;
41
47
  /** Local path or `http(s)` URL, verbatim from config. */
42
48
  spec: string;
43
49
  /** Per-block Scalar theme name override, if any (Scalar renderer only). */
@@ -78,10 +84,22 @@ type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
78
84
  /** A spec is a single source (`spec` shorthand prepended to any `sources`). */
79
85
  const sourcesOf = (
80
86
  block: Block
81
- ): { label?: string; route?: string; spec: string }[] => {
87
+ ): {
88
+ includeInLlms: boolean;
89
+ includeInSearch: boolean;
90
+ label?: string;
91
+ noindex: boolean;
92
+ route?: string;
93
+ spec: string;
94
+ }[] => {
82
95
  const sources = [...block.sources];
83
96
  if (block.spec) {
84
- sources.unshift({ spec: block.spec });
97
+ sources.unshift({
98
+ includeInLlms: true,
99
+ includeInSearch: true,
100
+ noindex: false,
101
+ spec: block.spec,
102
+ });
85
103
  }
86
104
  return sources;
87
105
  };
@@ -118,8 +136,11 @@ const referencesFor = (
118
136
  return {
119
137
  basePath,
120
138
  display,
139
+ includeInLlms: source.includeInLlms,
140
+ includeInSearch: source.includeInSearch,
121
141
  kind,
122
142
  label,
143
+ noindex: source.noindex,
123
144
  renderer,
124
145
  route,
125
146
  scalar: block.scalar,