@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +49 -0
- package/dist/index.mjs +376 -3237
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/commands/generate.ts +1 -1
- package/src/commands/prompts/create.ts +1 -0
- package/src/commands/prompts/generate.ts +37 -28
- package/src/commands/prompts/lint.ts +26 -17
- package/src/commands/prompts/setup.ts +53 -27
- package/src/commands/validate.ts +1 -1
- package/src/lib/prompts/__tests__/flatten.test.ts +29 -27
- package/src/lib/prompts/__tests__/frontmatter.test.ts +17 -15
- package/src/lib/prompts/codegen.ts +37 -37
- package/src/lib/prompts/extract-variables.ts +20 -9
- package/src/lib/prompts/flatten.ts +62 -16
- package/src/lib/prompts/frontmatter.ts +97 -53
- package/src/lib/prompts/lint.ts +18 -23
- package/src/lib/prompts/paths.ts +24 -10
- package/src/lib/prompts/pipeline.ts +5 -4
|
@@ -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(
|
|
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(() =>
|
|
61
|
-
|
|
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(() =>
|
|
67
|
-
"
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
|
80
|
-
|
|
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(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/src/lib/prompts/lint.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
/**
|