@funkai/cli 0.1.4 → 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.
@@ -5,20 +5,20 @@ import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
5
5
  describe(parseFrontmatter, () => {
6
6
  it("parses name from frontmatter", () => {
7
7
  const content = "---\nname: my-prompt\n---\nHello";
8
- const result = parseFrontmatter(content, "test.prompt");
8
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
9
9
  expect(result.name).toBe("my-prompt");
10
10
  });
11
11
 
12
12
  it("parses group and version", () => {
13
13
  const content = "---\nname: test\ngroup: agents/test\nversion: 2\n---\nBody";
14
- const result = parseFrontmatter(content, "test.prompt");
14
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
15
15
  expect(result.group).toBe("agents/test");
16
16
  expect(result.version).toBe("2");
17
17
  });
18
18
 
19
19
  it("parses schema with shorthand type strings", () => {
20
20
  const content = "---\nname: test\nschema:\n scope: string\n target: string\n---\n";
21
- const result = parseFrontmatter(content, "test.prompt");
21
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
22
22
  expect(result.schema).toEqual([
23
23
  { name: "scope", type: "string", required: true },
24
24
  { name: "target", type: "string", required: true },
@@ -39,7 +39,7 @@ describe(parseFrontmatter, () => {
39
39
  "---",
40
40
  "",
41
41
  ].join("\n");
42
- const result = parseFrontmatter(content, "test.prompt");
42
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
43
43
  expect(result.schema).toEqual([
44
44
  { name: "scope", type: "string", required: true, description: "The scope" },
45
45
  { name: "target", type: "string", required: false },
@@ -48,42 +48,44 @@ describe(parseFrontmatter, () => {
48
48
 
49
49
  it("returns empty schema when no schema field", () => {
50
50
  const content = "---\nname: test\n---\nBody";
51
- const result = parseFrontmatter(content, "test.prompt");
51
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
52
52
  expect(result.schema).toEqual([]);
53
53
  });
54
54
 
55
55
  it("throws on missing frontmatter", () => {
56
- expect(() => parseFrontmatter("No frontmatter", "test.prompt")).toThrow("No frontmatter");
56
+ expect(() => parseFrontmatter({ content: "No frontmatter", filePath: "test.prompt" })).toThrow(
57
+ "No frontmatter",
58
+ );
57
59
  });
58
60
 
59
61
  it("throws on missing name", () => {
60
- expect(() => parseFrontmatter("---\nversion: 1\n---\n", "test.prompt")).toThrow(
61
- 'Missing or empty "name"',
62
- );
62
+ expect(() =>
63
+ parseFrontmatter({ content: "---\nversion: 1\n---\n", filePath: "test.prompt" }),
64
+ ).toThrow('Missing or empty "name"');
63
65
  });
64
66
 
65
67
  it("throws on invalid name format", () => {
66
- expect(() => parseFrontmatter("---\nname: My Prompt\n---\n", "test.prompt")).toThrow(
67
- "Invalid prompt name",
68
- );
68
+ expect(() =>
69
+ parseFrontmatter({ content: "---\nname: My Prompt\n---\n", filePath: "test.prompt" }),
70
+ ).toThrow("Invalid prompt name");
69
71
  });
70
72
 
71
73
  it("returns undefined group when not specified", () => {
72
74
  const content = "---\nname: test\n---\nBody";
73
- const result = parseFrontmatter(content, "test.prompt");
75
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
74
76
  expect(result.group).toBeUndefined();
75
77
  });
76
78
 
77
79
  it("should throw on invalid group segment", () => {
78
80
  const content = "---\nname: test\ngroup: agents/INVALID\n---\nBody";
79
- expect(() => parseFrontmatter(content, "test.prompt")).toThrow(
81
+ expect(() => parseFrontmatter({ content, filePath: "test.prompt" })).toThrow(
80
82
  'Invalid group segment "INVALID"',
81
83
  );
82
84
  });
83
85
 
84
86
  it("should accept valid multi-segment group", () => {
85
87
  const content = "---\nname: test\ngroup: agents/specialized\n---\nBody";
86
- const result = parseFrontmatter(content, "test.prompt");
88
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
87
89
  expect(result.group).toBe("agents/specialized");
88
90
  });
89
91
  });
@@ -13,6 +13,10 @@ export interface ParsedPrompt {
13
13
  readonly sourcePath: string;
14
14
  }
15
15
 
16
+ // ---------------------------------------------------------------------------
17
+ // Private
18
+ // ---------------------------------------------------------------------------
19
+
16
20
  /**
17
21
  * Convert a kebab-case name to PascalCase.
18
22
  *
@@ -55,6 +59,22 @@ function escapeTemplateLiteral(str: string): string {
55
59
  .replaceAll("${", "\\${");
56
60
  }
57
61
 
62
+ /** @private */
63
+ function formatGroupValue(group: string | undefined): string {
64
+ if (group) {
65
+ return `'${group}' as const`;
66
+ }
67
+ return "undefined";
68
+ }
69
+
70
+ /** @private */
71
+ function parseGroupSegments(group: string | undefined): readonly string[] {
72
+ if (group) {
73
+ return group.split("/").map(toCamelCase);
74
+ }
75
+ return [];
76
+ }
77
+
58
78
  /**
59
79
  * Generate the Zod schema expression for a list of schema variables.
60
80
  *
@@ -68,13 +88,9 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
68
88
  const fields = vars
69
89
  .map((v) => {
70
90
  const base = "z.string()";
71
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
72
- const expr: string = (() => {
73
- if (v.required) {
74
- return base;
75
- }
76
- return `${base}.optional()`;
77
- })();
91
+ const expr = match(v.required)
92
+ .with(true, () => base)
93
+ .otherwise(() => `${base}.optional()`);
78
94
  return ` ${v.name}: ${expr},`;
79
95
  })
80
96
  .join("\n");
@@ -102,15 +118,9 @@ const HEADER = [
102
118
  export function generatePromptModule(prompt: ParsedPrompt): string {
103
119
  const escaped = escapeTemplateLiteral(prompt.template);
104
120
  const schemaExpr = generateSchemaExpression(prompt.schema);
105
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
106
- const groupValue: string = (() => {
107
- if (prompt.group) {
108
- return `'${prompt.group}' as const`;
109
- }
110
- return "undefined";
111
- })();
121
+ const groupValue = formatGroupValue(prompt.group);
112
122
 
113
- const lines: string[] = [
123
+ const lines: readonly string[] = [
114
124
  HEADER,
115
125
  `// Source: ${prompt.sourcePath}`,
116
126
  "",
@@ -171,13 +181,7 @@ interface TreeNode {
171
181
  function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
172
182
  return prompts.reduce<Record<string, unknown>>((root, prompt) => {
173
183
  const importName = toCamelCase(prompt.name);
174
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
175
- const segments: string[] = (() => {
176
- if (prompt.group) {
177
- return prompt.group.split("/").map(toCamelCase);
178
- }
179
- return [];
180
- })();
184
+ const segments = parseGroupSegments(prompt.group);
181
185
 
182
186
  const target = segments.reduce<Record<string, unknown>>((current, segment) => {
183
187
  const existing = current[segment];
@@ -212,22 +216,18 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
212
216
  *
213
217
  * @private
214
218
  */
215
- function serializeTree(node: TreeNode, indent: number): string[] {
219
+ function serializeTree(node: TreeNode, indent: number): readonly string[] {
216
220
  const pad = " ".repeat(indent);
217
221
 
218
- const lines: string[] = [];
219
- for (const [key, value] of Object.entries(node)) {
220
- if (typeof value === "string") {
221
- lines.push(`${pad}${key},`);
222
- } else {
223
- lines.push(`${pad}${key}: {`);
224
- for (const child of serializeTree(value, indent + 1)) {
225
- lines.push(child);
226
- }
227
- lines.push(`${pad}},`);
228
- }
229
- }
230
- return lines;
222
+ return Object.entries(node).flatMap(([key, value]) =>
223
+ match(typeof value)
224
+ .with("string", () => [`${pad}${key},`])
225
+ .otherwise(() => [
226
+ `${pad}${key}: {`,
227
+ ...serializeTree(value as TreeNode, indent + 1),
228
+ `${pad}},`,
229
+ ]),
230
+ );
231
231
  }
232
232
 
233
233
  /**
@@ -247,7 +247,7 @@ export function generateRegistry(prompts: readonly ParsedPrompt[]): string {
247
247
  const tree = buildTree(sorted);
248
248
  const treeLines = serializeTree(tree, 1);
249
249
 
250
- const lines: string[] = [
250
+ const lines: readonly string[] = [
251
251
  HEADER,
252
252
  "",
253
253
  "import { createPromptRegistry } from '@funkai/prompts'",
@@ -2,6 +2,17 @@ import { Liquid } from "liquidjs";
2
2
 
3
3
  const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
4
4
 
5
+ /** Module-level engine — no config needed for variable extraction. */
6
+ const engine = new Liquid();
7
+
8
+ /** @private */
9
+ function extractRoot(variable: unknown): string {
10
+ if (Array.isArray(variable)) {
11
+ return String(variable[0]);
12
+ }
13
+ return String(variable);
14
+ }
15
+
5
16
  /**
6
17
  * Extract top-level variable names from a Liquid template string.
7
18
  *
@@ -9,22 +20,22 @@ const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
9
20
  * and extract all referenced variable names. Only returns the root
10
21
  * variable name (e.g. `user` from `{{ user.name }}`).
11
22
  *
23
+ * @param template - The Liquid template string to parse.
24
+ * @returns Sorted, deduplicated array of top-level variable names.
12
25
  * @throws {Error} If a variable name is dangerous (e.g. `__proto__`)
26
+ * @example
27
+ * ```ts
28
+ * extractVariables("Hello {{ user.name }}, you have {{ count }} items");
29
+ * // ["count", "user"]
30
+ * ```
13
31
  */
14
- export function extractVariables(template: string): string[] {
15
- const engine = new Liquid();
32
+ export function extractVariables(template: string): readonly string[] {
16
33
  const parsed = engine.parse(template);
17
34
  const variables = engine.variablesSync(parsed);
18
35
 
19
36
  const roots = new Set(
20
37
  variables.map((variable) => {
21
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
22
- const root: string = (() => {
23
- if (Array.isArray(variable)) {
24
- return String(variable[0]);
25
- }
26
- return String(variable);
27
- })();
38
+ const root = extractRoot(variable);
28
39
 
29
40
  if (DANGEROUS_NAMES.has(root)) {
30
41
  throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
@@ -10,11 +10,25 @@ interface RenderTag {
10
10
  params: Record<string, string>;
11
11
  }
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Private
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** @private */
18
+ function parseParamsOrEmpty(raw: string, partialName: string): Record<string, string> {
19
+ if (raw.length > 0) {
20
+ return parseParams(raw, partialName);
21
+ }
22
+ return {};
23
+ }
24
+
13
25
  /**
14
26
  * Parse literal string parameters from a render tag's param string.
15
27
  *
16
28
  * Only supports literal string values (e.g. `role: 'Bot'`).
17
29
  * Throws if a parameter value is a variable reference.
30
+ *
31
+ * @private
18
32
  */
19
33
  function parseParams(raw: string, partialName: string): Record<string, string> {
20
34
  const literalMatches = [...raw.matchAll(LITERAL_PARAM_RE)];
@@ -34,23 +48,56 @@ function parseParams(raw: string, partialName: string): Record<string, string> {
34
48
  );
35
49
  }
36
50
 
51
+ /** @private */
52
+ function errorMessage(error: unknown): string {
53
+ if (error instanceof Error) {
54
+ return error.message;
55
+ }
56
+ return String(error);
57
+ }
58
+
59
+ /**
60
+ * Render a single partial tag via LiquidJS, wrapping errors with context.
61
+ *
62
+ * @private
63
+ */
64
+ function renderPartial(engine: Liquid, tag: RenderTag): string {
65
+ const liquidTag = `{% render '${tag.partialName}' ${Object.entries(tag.params)
66
+ .map(([k, v]) => `${k}: '${v}'`)
67
+ .join(", ")} %}`;
68
+ try {
69
+ return engine.parseAndRenderSync(liquidTag);
70
+ } catch (error) {
71
+ throw new Error(`Failed to render partial '${tag.partialName}': ${errorMessage(error)}`, {
72
+ cause: error,
73
+ });
74
+ }
75
+ }
76
+
37
77
  /**
38
78
  * Find all `{% render %}` tags in a template string.
79
+ *
80
+ * @private
39
81
  */
40
82
  function parseRenderTags(template: string): RenderTag[] {
41
83
  return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
42
84
  const rawParams: string = (m[2] ?? "").trim();
43
- const params: Record<string, string> = (() => {
44
- if (rawParams.length > 0) {
45
- return parseParams(rawParams, m[1]);
46
- }
47
- return {};
48
- })();
85
+ const params = parseParamsOrEmpty(rawParams, m[1]);
49
86
 
50
87
  return { fullMatch: m[0], partialName: m[1], params };
51
88
  });
52
89
  }
53
90
 
91
+ /**
92
+ * Parameters for flattening partial render tags.
93
+ */
94
+ export interface FlattenPartialsParams {
95
+ /** Template string (frontmatter already stripped). */
96
+ readonly template: string;
97
+ /** Directories to search for partial `.prompt` files. */
98
+ readonly partialsDirs: readonly string[];
99
+ }
100
+
54
101
  /**
55
102
  * Flatten `{% render %}` partial tags in a template at codegen time.
56
103
  *
@@ -61,11 +108,15 @@ function parseRenderTags(template: string): RenderTag[] {
61
108
  * All other Liquid expressions (`{{ var }}`, `{% if %}`, `{% for %}`)
62
109
  * are preserved for runtime rendering.
63
110
  *
64
- * @param template - Template string (frontmatter already stripped).
65
- * @param partialsDirs - Directories to search for partial `.prompt` files.
111
+ * @param params - Template content and partial directories.
66
112
  * @returns Flattened template with all render tags resolved.
113
+ * @example
114
+ * ```ts
115
+ * flattenPartials({ template: "{% render 'header' %}\nBody", partialsDirs: ["./partials"] });
116
+ * // "Welcome!\nBody"
117
+ * ```
67
118
  */
68
- export function flattenPartials(template: string, partialsDirs: readonly string[]): string {
119
+ export function flattenPartials({ template, partialsDirs }: FlattenPartialsParams): string {
69
120
  const tags = parseRenderTags(template);
70
121
  if (tags.length === 0) {
71
122
  return template;
@@ -78,13 +129,8 @@ export function flattenPartials(template: string, partialsDirs: readonly string[
78
129
  });
79
130
 
80
131
  const result = tags.reduce((acc, tag) => {
81
- const rendered = engine.parseAndRenderSync(
82
- `{% render '${tag.partialName}' ${Object.entries(tag.params)
83
- .map(([k, v]) => `${k}: '${v}'`)
84
- .join(", ")} %}`,
85
- );
86
-
87
- return acc.replace(tag.fullMatch, rendered);
132
+ const rendered = renderPartial(engine, tag);
133
+ return acc.replace(tag.fullMatch, () => rendered);
88
134
  }, template);
89
135
 
90
136
  return result;
@@ -1,6 +1,10 @@
1
+ import { match, P } from "ts-pattern";
1
2
  import { parse as parseYaml } from "yaml";
2
3
 
4
+ /** Regex matching YAML frontmatter fenced by `---` delimiters. */
3
5
  export const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
6
+
7
+ /** Regex validating prompt names (lowercase alphanumeric with hyphens). */
4
8
  export const NAME_RE = /^[a-z0-9-]+$/;
5
9
 
6
10
  /**
@@ -41,18 +45,32 @@ export interface ParsedFrontmatter {
41
45
  readonly schema: readonly SchemaVariable[];
42
46
  }
43
47
 
48
+ /**
49
+ * Parameters for parsing frontmatter from a `.prompt` file.
50
+ */
51
+ export interface ParseFrontmatterParams {
52
+ /** Raw file content (including frontmatter fences). */
53
+ readonly content: string;
54
+ /** File path for error messages. */
55
+ readonly filePath: string;
56
+ }
57
+
44
58
  /**
45
59
  * Parse YAML frontmatter from a `.prompt` file's raw content.
46
60
  *
47
61
  * Extracts `name`, `group`, `version`, and `schema` fields.
48
62
  * The `schema` field maps variable names to their type definitions.
49
63
  *
50
- * @param content - Raw file content (including frontmatter fences).
51
- * @param filePath - File path for error messages.
64
+ * @param params - Content and file path to parse.
52
65
  * @returns Parsed frontmatter with schema variables.
53
66
  * @throws If frontmatter is missing, malformed, or has an invalid name.
67
+ * @example
68
+ * ```ts
69
+ * const fm = parseFrontmatter({ content: "---\nname: greeting\n---\nHello!", filePath: "greeting.prompt" });
70
+ * // { name: "greeting", group: undefined, version: undefined, schema: [] }
71
+ * ```
54
72
  */
55
- export function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter {
73
+ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams): ParsedFrontmatter {
56
74
  const fmMatch = content.match(FRONTMATTER_RE);
57
75
  if (!fmMatch) {
58
76
  throw new Error(`No frontmatter found in ${filePath}`);
@@ -76,37 +94,70 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
76
94
  );
77
95
  }
78
96
 
79
- const group: string | undefined = (() => {
80
- if (typeof parsed.group === "string") {
81
- const g = parsed.group as string;
82
- const invalidSegment = g.split("/").find((segment) => !NAME_RE.test(segment));
83
- if (invalidSegment !== undefined) {
84
- throw new Error(
85
- `Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
86
- );
87
- }
88
- return g;
89
- }
90
- return undefined;
91
- })();
92
- const version: string | undefined = (() => {
93
- if (parsed.version !== null && parsed.version !== undefined) {
94
- return String(parsed.version);
95
- }
96
- return undefined;
97
- })();
97
+ const group = parseGroup(parsed.group, filePath);
98
+ const version = parseVersion(parsed.version);
98
99
 
99
100
  const schema = parseSchemaBlock(parsed.schema, filePath);
100
101
 
101
102
  return { name, group, version, schema };
102
103
  }
103
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // Private
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** @private */
110
+ function stringOrDefault(value: unknown, fallback: string): string {
111
+ if (typeof value === "string") {
112
+ return value;
113
+ }
114
+ return fallback;
115
+ }
116
+
117
+ /** @private */
118
+ function stringOrUndefined(value: unknown): string | undefined {
119
+ if (typeof value === "string") {
120
+ return value;
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * Validate and extract the `group` field from parsed frontmatter.
127
+ *
128
+ * @private
129
+ */
130
+ function parseGroup(raw: unknown, filePath: string): string | undefined {
131
+ if (typeof raw !== "string") {
132
+ return undefined;
133
+ }
134
+ const invalidSegment = raw.split("/").find((segment) => !NAME_RE.test(segment));
135
+ if (invalidSegment !== undefined) {
136
+ throw new Error(
137
+ `Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
138
+ );
139
+ }
140
+ return raw;
141
+ }
142
+
143
+ /**
144
+ * Extract the `version` field from parsed frontmatter.
145
+ *
146
+ * @private
147
+ */
148
+ function parseVersion(raw: unknown): string | undefined {
149
+ if (raw !== null && raw !== undefined) {
150
+ return String(raw);
151
+ }
152
+ return undefined;
153
+ }
154
+
104
155
  /**
105
156
  * Parse the `schema` block from frontmatter into an array of variable definitions.
106
157
  *
107
158
  * @private
108
159
  */
109
- function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
160
+ function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariable[] {
110
161
  if (raw === null || raw === undefined) {
111
162
  return [];
112
163
  }
@@ -119,34 +170,27 @@ function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
119
170
 
120
171
  const schema = raw as Record<string, unknown>;
121
172
 
122
- return Object.entries(schema).map(([varName, value]): SchemaVariable => {
123
- if (typeof value === "string") {
124
- return { name: varName, type: value, required: true };
125
- }
126
-
127
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
128
- const def = value as Record<string, unknown>;
129
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
130
- const type: string = (() => {
131
- if (typeof def.type === "string") {
132
- return def.type as string;
133
- }
134
- return "string";
135
- })();
136
- const required = def.required !== false;
137
- const description: string | undefined = (() => {
138
- if (typeof def.description === "string") {
139
- return def.description as string;
140
- }
141
- return undefined;
142
- })();
143
-
144
- return { name: varName, type, required, description };
145
- }
146
-
147
- throw new Error(
148
- `Invalid schema definition for "${varName}" in ${filePath}. ` +
149
- "Expected a type string or an object with { type, required?, description? }.",
150
- );
151
- });
173
+ return Object.entries(schema).map(
174
+ ([varName, value]): SchemaVariable =>
175
+ match(value)
176
+ .with(P.string, (v) => ({ name: varName, type: v, required: true }))
177
+ .with(
178
+ P.when(
179
+ (v): v is Record<string, unknown> =>
180
+ typeof v === "object" && v !== null && !Array.isArray(v),
181
+ ),
182
+ (def) => {
183
+ const type = stringOrDefault(def.type, "string");
184
+ const required = def.required !== false;
185
+ const description = stringOrUndefined(def.description);
186
+ return { name: varName, type, required, description };
187
+ },
188
+ )
189
+ .otherwise(() => {
190
+ throw new Error(
191
+ `Invalid schema definition for "${varName}" in ${filePath}. ` +
192
+ "Expected a type string or an object with { type, required?, description? }.",
193
+ );
194
+ }),
195
+ );
152
196
  }
@@ -36,34 +36,29 @@ export function lintPrompt(
36
36
  schemaVars: readonly SchemaVariable[],
37
37
  templateVars: readonly string[],
38
38
  ): LintResult {
39
- const diagnostics: LintDiagnostic[] = [];
40
39
  const declared = new Set(schemaVars.map((v) => v.name));
41
40
  const used = new Set(templateVars);
42
41
 
43
- for (const varName of used) {
44
- if (!declared.has(varName)) {
45
- diagnostics.push({
46
- level: "error",
47
- message:
48
- `Undefined variable "${varName}" in ${name}.prompt\n` +
49
- ` Variable "${varName}" is used in the template but not declared in frontmatter schema.\n` +
50
- " Add it to the schema section in the frontmatter.",
51
- });
52
- }
53
- }
42
+ const undeclaredErrors: readonly LintDiagnostic[] = [...used]
43
+ .filter((varName) => !declared.has(varName))
44
+ .map((varName) => ({
45
+ level: "error" as const,
46
+ message:
47
+ `Undefined variable "${varName}" in ${name}.prompt\n` +
48
+ ` Variable "${varName}" is used in the template but not declared in frontmatter schema.\n` +
49
+ " Add it to the schema section in the frontmatter.",
50
+ }));
54
51
 
55
- for (const varName of declared) {
56
- if (!used.has(varName)) {
57
- diagnostics.push({
58
- level: "warn",
59
- message:
60
- `Unused variable "${varName}" in ${name}.prompt\n` +
61
- ` Variable "${varName}" is declared in the schema but never used in the template.`,
62
- });
63
- }
64
- }
52
+ const unusedWarnings: readonly LintDiagnostic[] = [...declared]
53
+ .filter((varName) => !used.has(varName))
54
+ .map((varName) => ({
55
+ level: "warn" as const,
56
+ message:
57
+ `Unused variable "${varName}" in ${name}.prompt\n` +
58
+ ` Variable "${varName}" is declared in the schema but never used in the template.`,
59
+ }));
65
60
 
66
- return { name, filePath, diagnostics };
61
+ return { name, filePath, diagnostics: [...undeclaredErrors, ...unusedWarnings] };
67
62
  }
68
63
 
69
64
  /**