@funkai/cli 0.1.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 +16 -0
- package/.turbo/turbo-test$colon$coverage.log +36 -0
- package/.turbo/turbo-test.log +26 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/funkai.mjs +2 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/commands/create.ts.html +208 -0
- package/coverage/lcov-report/commands/generate.ts.html +388 -0
- package/coverage/lcov-report/commands/index.html +161 -0
- package/coverage/lcov-report/commands/lint.ts.html +331 -0
- package/coverage/lcov-report/commands/setup.ts.html +493 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +131 -0
- package/coverage/lcov-report/lib/codegen.ts.html +805 -0
- package/coverage/lcov-report/lib/extract-variables.ts.html +181 -0
- package/coverage/lcov-report/lib/flatten.ts.html +385 -0
- package/coverage/lcov-report/lib/frontmatter.ts.html +487 -0
- package/coverage/lcov-report/lib/index.html +191 -0
- package/coverage/lcov-report/lib/lint.ts.html +307 -0
- package/coverage/lcov-report/lib/paths.ts.html +487 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +749 -0
- package/dist/index.mjs +19297 -0
- package/dist/index.mjs.map +1 -0
- package/kidd.config.ts +7 -0
- package/package.json +54 -0
- package/src/commands/agents/validate.ts +9 -0
- package/src/commands/generate.ts +78 -0
- package/src/commands/prompts/create.ts +41 -0
- package/src/commands/prompts/generate.ts +71 -0
- package/src/commands/prompts/lint.ts +57 -0
- package/src/commands/prompts/setup.ts +149 -0
- package/src/commands/setup.ts +12 -0
- package/src/commands/validate.ts +47 -0
- package/src/index.ts +7 -0
- package/src/lib/prompts/__tests__/extract-variables.test.ts +47 -0
- package/src/lib/prompts/__tests__/flatten.test.ts +230 -0
- package/src/lib/prompts/__tests__/frontmatter.test.ts +89 -0
- package/src/lib/prompts/__tests__/lint.test.ts +80 -0
- package/src/lib/prompts/codegen.ts +240 -0
- package/src/lib/prompts/extract-variables.ts +32 -0
- package/src/lib/prompts/flatten.ts +91 -0
- package/src/lib/prompts/frontmatter.ts +143 -0
- package/src/lib/prompts/lint.ts +74 -0
- package/src/lib/prompts/paths.ts +118 -0
- package/src/lib/prompts/pipeline.ts +114 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { flattenPartials } from "@/lib/prompts/flatten.js";
|
|
6
|
+
|
|
7
|
+
const PARTIALS_DIR = resolve(import.meta.dirname, "../../../../../prompts/src/prompts");
|
|
8
|
+
|
|
9
|
+
describe("flattenPartials", () => {
|
|
10
|
+
describe("param parsing", () => {
|
|
11
|
+
it("resolves a single literal param", () => {
|
|
12
|
+
const template = "{% render 'identity', role: 'Bot' %}";
|
|
13
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
14
|
+
|
|
15
|
+
expect(result).toContain("<identity>");
|
|
16
|
+
expect(result).toContain("You are Bot, .");
|
|
17
|
+
expect(result).not.toContain("{% render");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("resolves multiple literal params", () => {
|
|
21
|
+
const template = "{% render 'identity', role: 'TestBot', desc: 'a test agent' %}";
|
|
22
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
23
|
+
|
|
24
|
+
expect(result).toContain("You are TestBot, a test agent.");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts an empty string as a valid literal param value", () => {
|
|
28
|
+
const template = "{% render 'identity', role: '', desc: 'helper' %}";
|
|
29
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
30
|
+
|
|
31
|
+
expect(result).toContain("You are , helper.");
|
|
32
|
+
expect(result).not.toContain("{% render");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("throws when the first param uses a variable reference", () => {
|
|
36
|
+
const template = "{% render 'identity', role: agentRole, desc: 'helper' %}";
|
|
37
|
+
|
|
38
|
+
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
|
|
39
|
+
'parameter "role" uses a variable reference',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws when a non-first param uses a variable reference", () => {
|
|
44
|
+
const template = "{% render 'identity', role: 'Bot', desc: myDesc %}";
|
|
45
|
+
|
|
46
|
+
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
|
|
47
|
+
'parameter "desc" uses a variable reference',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws when all params are variable references", () => {
|
|
52
|
+
const template = "{% render 'identity', role: agentRole, desc: agentDesc %}";
|
|
53
|
+
|
|
54
|
+
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow("uses a variable reference");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles extra whitespace around colons in params", () => {
|
|
58
|
+
const template = "{% render 'identity', role : 'Bot', desc : 'helper' %}";
|
|
59
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
60
|
+
|
|
61
|
+
expect(result).toContain("You are Bot, helper.");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles param values containing spaces", () => {
|
|
65
|
+
const template = "{% render 'identity', role: 'Test Bot', desc: 'a helpful assistant' %}";
|
|
66
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
67
|
+
|
|
68
|
+
expect(result).toContain("You are Test Bot, a helpful assistant.");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("render tag parsing", () => {
|
|
73
|
+
it("returns template unchanged when no render tags exist", () => {
|
|
74
|
+
const template = "<identity>\nYou are a bot.\n</identity>";
|
|
75
|
+
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("parses a render tag with no params", () => {
|
|
79
|
+
const template = "{% render 'identity' %}\n\nDone.";
|
|
80
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
81
|
+
|
|
82
|
+
expect(result).toContain("<identity>");
|
|
83
|
+
expect(result).toContain("</identity>");
|
|
84
|
+
expect(result).not.toContain("{% render");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("parses left-only whitespace trim {%-", () => {
|
|
88
|
+
const template = "{%- render 'identity', role: 'Bot', desc: 'helper' %}\nDone.";
|
|
89
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
90
|
+
|
|
91
|
+
expect(result).toContain("You are Bot, helper.");
|
|
92
|
+
expect(result).not.toContain("{%-");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("parses right-only whitespace trim -%}", () => {
|
|
96
|
+
const template = "{% render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
|
|
97
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
98
|
+
|
|
99
|
+
expect(result).toContain("You are Bot, helper.");
|
|
100
|
+
expect(result).not.toContain("-%}");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("parses both-side whitespace trim {%- -%}", () => {
|
|
104
|
+
const template = "{%- render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
|
|
105
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
106
|
+
|
|
107
|
+
expect(result).toContain("You are Bot, helper.");
|
|
108
|
+
expect(result).not.toContain("{%");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("handles extra whitespace between {% and render keyword", () => {
|
|
112
|
+
const template = "{% render 'identity', role: 'Bot', desc: 'helper' %}";
|
|
113
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
114
|
+
|
|
115
|
+
expect(result).toContain("You are Bot, helper.");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not match render tags with double quotes", () => {
|
|
119
|
+
const template = '{% render "identity" %}';
|
|
120
|
+
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not match malformed render tags without closing %}", () => {
|
|
124
|
+
const template = "{% render 'identity'";
|
|
125
|
+
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("integration with real partials", () => {
|
|
130
|
+
it("flattens identity partial with literal params", () => {
|
|
131
|
+
const template =
|
|
132
|
+
"{% render 'identity', role: 'TestBot', desc: 'a test agent' %}\n\nFollow instructions.";
|
|
133
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
134
|
+
|
|
135
|
+
expect(result).toContain("<identity>");
|
|
136
|
+
expect(result).toContain("You are TestBot, a test agent.");
|
|
137
|
+
expect(result).toContain("</identity>");
|
|
138
|
+
expect(result).toContain("Follow instructions.");
|
|
139
|
+
expect(result).not.toContain("{% render");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("flattens constraints partial with no bindings", () => {
|
|
143
|
+
const template = "{% render 'constraints' %}";
|
|
144
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
145
|
+
|
|
146
|
+
expect(result).toContain("<constraints>");
|
|
147
|
+
expect(result).toContain("</constraints>");
|
|
148
|
+
expect(result).not.toContain("## In Scope");
|
|
149
|
+
expect(result).not.toContain("## Out of Scope");
|
|
150
|
+
expect(result).not.toContain("## Rules");
|
|
151
|
+
expect(result).not.toContain("{% render");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("flattens tools partial with no bindings (else branch)", () => {
|
|
155
|
+
const template = "{% render 'tools' %}";
|
|
156
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
157
|
+
|
|
158
|
+
expect(result).toContain("<tools>");
|
|
159
|
+
expect(result).toContain("</tools>");
|
|
160
|
+
expect(result).toContain("No tools are configured for this agent.");
|
|
161
|
+
expect(result).not.toContain("{% render");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("flattens multiple render tags in one template", () => {
|
|
165
|
+
const template = [
|
|
166
|
+
"{% render 'identity', role: 'Bot', desc: 'helper' %}",
|
|
167
|
+
"",
|
|
168
|
+
"{% render 'identity', role: 'Agent', desc: 'analyzer' %}",
|
|
169
|
+
].join("\n");
|
|
170
|
+
|
|
171
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
172
|
+
|
|
173
|
+
expect(result).toContain("You are Bot, helper.");
|
|
174
|
+
expect(result).toContain("You are Agent, analyzer.");
|
|
175
|
+
expect(result).not.toContain("{% render");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("preserves surrounding markdown content", () => {
|
|
179
|
+
const template =
|
|
180
|
+
"# System Prompt\n\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n\n## Instructions\n\nDo the thing.";
|
|
181
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
182
|
+
|
|
183
|
+
expect(result).toMatch(/^# System Prompt/);
|
|
184
|
+
expect(result).toContain("You are Bot, helper.");
|
|
185
|
+
expect(result).toContain("## Instructions\n\nDo the thing.");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("throws when partial file does not exist", () => {
|
|
189
|
+
const template = "{% render 'nonexistent' %}";
|
|
190
|
+
|
|
191
|
+
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("searches multiple partialsDirs in order", () => {
|
|
195
|
+
const emptyDir = resolve(import.meta.dirname);
|
|
196
|
+
const template = "{% render 'identity', role: 'Bot', desc: 'test' %}";
|
|
197
|
+
const result = flattenPartials(template, [emptyDir, PARTIALS_DIR]);
|
|
198
|
+
|
|
199
|
+
expect(result).toContain("You are Bot, test.");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("template preservation", () => {
|
|
204
|
+
it("preserves {{ var }} and {% if %} expressions", () => {
|
|
205
|
+
const template = "Hello {{ name }}.\n{% if context %}{{ context }}{% endif %}";
|
|
206
|
+
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("flattens render tag while preserving surrounding Liquid blocks", () => {
|
|
210
|
+
const template =
|
|
211
|
+
"{% if show_identity %}\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n{% endif %}\n\n{{ instructions }}";
|
|
212
|
+
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
213
|
+
|
|
214
|
+
expect(result).toContain("{% if show_identity %}");
|
|
215
|
+
expect(result).toContain("{% endif %}");
|
|
216
|
+
expect(result).toContain("{{ instructions }}");
|
|
217
|
+
expect(result).toContain("You are Bot, helper.");
|
|
218
|
+
expect(result).not.toContain("{% render");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns empty string unchanged", () => {
|
|
222
|
+
expect(flattenPartials("", [PARTIALS_DIR])).toBe("");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns whitespace-only template unchanged", () => {
|
|
226
|
+
const ws = " \n\n ";
|
|
227
|
+
expect(flattenPartials(ws, [PARTIALS_DIR])).toBe(ws);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
|
|
4
|
+
|
|
5
|
+
describe("parseFrontmatter", () => {
|
|
6
|
+
it("parses name from frontmatter", () => {
|
|
7
|
+
const content = "---\nname: my-prompt\n---\nHello";
|
|
8
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
9
|
+
expect(result.name).toBe("my-prompt");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("parses group and version", () => {
|
|
13
|
+
const content = "---\nname: test\ngroup: agents/test\nversion: 2\n---\nBody";
|
|
14
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
15
|
+
expect(result.group).toBe("agents/test");
|
|
16
|
+
expect(result.version).toBe("2");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("parses schema with shorthand type strings", () => {
|
|
20
|
+
const content = "---\nname: test\nschema:\n scope: string\n target: string\n---\n";
|
|
21
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
22
|
+
expect(result.schema).toEqual([
|
|
23
|
+
{ name: "scope", type: "string", required: true },
|
|
24
|
+
{ name: "target", type: "string", required: true },
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("parses schema with full object definitions", () => {
|
|
29
|
+
const content = [
|
|
30
|
+
"---",
|
|
31
|
+
"name: test",
|
|
32
|
+
"schema:",
|
|
33
|
+
" scope:",
|
|
34
|
+
" type: string",
|
|
35
|
+
" description: The scope",
|
|
36
|
+
" target:",
|
|
37
|
+
" type: string",
|
|
38
|
+
" required: false",
|
|
39
|
+
"---",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n");
|
|
42
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
43
|
+
expect(result.schema).toEqual([
|
|
44
|
+
{ name: "scope", type: "string", required: true, description: "The scope" },
|
|
45
|
+
{ name: "target", type: "string", required: false },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns empty schema when no schema field", () => {
|
|
50
|
+
const content = "---\nname: test\n---\nBody";
|
|
51
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
52
|
+
expect(result.schema).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws on missing frontmatter", () => {
|
|
56
|
+
expect(() => parseFrontmatter("No frontmatter", "test.prompt")).toThrow("No frontmatter");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws on missing name", () => {
|
|
60
|
+
expect(() => parseFrontmatter("---\nversion: 1\n---\n", "test.prompt")).toThrow(
|
|
61
|
+
'Missing or empty "name"',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("throws on invalid name format", () => {
|
|
66
|
+
expect(() => parseFrontmatter("---\nname: My Prompt\n---\n", "test.prompt")).toThrow(
|
|
67
|
+
"Invalid prompt name",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns undefined group when not specified", () => {
|
|
72
|
+
const content = "---\nname: test\n---\nBody";
|
|
73
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
74
|
+
expect(result.group).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should throw on invalid group segment", () => {
|
|
78
|
+
const content = "---\nname: test\ngroup: agents/INVALID\n---\nBody";
|
|
79
|
+
expect(() => parseFrontmatter(content, "test.prompt")).toThrow(
|
|
80
|
+
'Invalid group segment "INVALID"',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should accept valid multi-segment group", () => {
|
|
85
|
+
const content = "---\nname: test\ngroup: agents/specialized\n---\nBody";
|
|
86
|
+
const result = parseFrontmatter(content, "test.prompt");
|
|
87
|
+
expect(result.group).toBe("agents/specialized");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
|
|
4
|
+
|
|
5
|
+
describe("lintPrompt", () => {
|
|
6
|
+
it("returns no diagnostics when vars match schema", () => {
|
|
7
|
+
const result = lintPrompt(
|
|
8
|
+
"test",
|
|
9
|
+
"test.prompt",
|
|
10
|
+
[{ name: "scope", type: "string", required: true }],
|
|
11
|
+
["scope"],
|
|
12
|
+
);
|
|
13
|
+
expect(result.diagnostics).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("errors on undefined template variable", () => {
|
|
17
|
+
const result = lintPrompt("test", "test.prompt", [], ["scope"]);
|
|
18
|
+
expect(result.diagnostics).toHaveLength(1);
|
|
19
|
+
expect(result.diagnostics[0].level).toBe("error");
|
|
20
|
+
expect(result.diagnostics[0].message).toContain('Undefined variable "scope"');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("warns on unused schema variable", () => {
|
|
24
|
+
const result = lintPrompt(
|
|
25
|
+
"test",
|
|
26
|
+
"test.prompt",
|
|
27
|
+
[{ name: "scope", type: "string", required: true }],
|
|
28
|
+
[],
|
|
29
|
+
);
|
|
30
|
+
expect(result.diagnostics).toHaveLength(1);
|
|
31
|
+
expect(result.diagnostics[0].level).toBe("warn");
|
|
32
|
+
expect(result.diagnostics[0].message).toContain('Unused variable "scope"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reports both errors and warnings", () => {
|
|
36
|
+
const result = lintPrompt(
|
|
37
|
+
"test",
|
|
38
|
+
"test.prompt",
|
|
39
|
+
[{ name: "declared", type: "string", required: true }],
|
|
40
|
+
["undeclared"],
|
|
41
|
+
);
|
|
42
|
+
expect(result.diagnostics).toHaveLength(2);
|
|
43
|
+
const levels = result.diagnostics.map((d) => d.level).toSorted();
|
|
44
|
+
expect(levels).toEqual(["error", "warn"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns no diagnostics for prompts with no schema and no vars", () => {
|
|
48
|
+
const result = lintPrompt("test", "test.prompt", [], []);
|
|
49
|
+
expect(result.diagnostics).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("hasLintErrors", () => {
|
|
54
|
+
it("returns false when no errors", () => {
|
|
55
|
+
const results = [{ name: "test", filePath: "test.prompt", diagnostics: [] }];
|
|
56
|
+
expect(hasLintErrors(results)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns true when errors exist", () => {
|
|
60
|
+
const results = [
|
|
61
|
+
{
|
|
62
|
+
name: "test",
|
|
63
|
+
filePath: "test.prompt",
|
|
64
|
+
diagnostics: [{ level: "error" as const, message: "oops" }],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
expect(hasLintErrors(results)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns false when only warnings", () => {
|
|
71
|
+
const results = [
|
|
72
|
+
{
|
|
73
|
+
name: "test",
|
|
74
|
+
filePath: "test.prompt",
|
|
75
|
+
diagnostics: [{ level: "warn" as const, message: "hmm" }],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
expect(hasLintErrors(results)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { match } from "ts-pattern";
|
|
2
|
+
|
|
3
|
+
import type { SchemaVariable } from "./frontmatter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fully parsed prompt ready for code generation.
|
|
7
|
+
*/
|
|
8
|
+
export interface ParsedPrompt {
|
|
9
|
+
name: string;
|
|
10
|
+
group?: string;
|
|
11
|
+
schema: SchemaVariable[];
|
|
12
|
+
template: string;
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a kebab-case name to PascalCase.
|
|
18
|
+
*
|
|
19
|
+
* @param name - Kebab-case string to convert.
|
|
20
|
+
* @returns PascalCase version of the name.
|
|
21
|
+
*
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
function toPascalCase(name: string): string {
|
|
25
|
+
return name
|
|
26
|
+
.split("-")
|
|
27
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
28
|
+
.join("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a kebab-case name to camelCase.
|
|
33
|
+
*
|
|
34
|
+
* @param name - Kebab-case string to convert.
|
|
35
|
+
* @returns camelCase version of the name.
|
|
36
|
+
*
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
function toCamelCase(name: string): string {
|
|
40
|
+
const pascal = toPascalCase(name);
|
|
41
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Escape a template string for embedding inside a JS template literal.
|
|
46
|
+
*
|
|
47
|
+
* Backticks, `${`, and backslashes must be escaped.
|
|
48
|
+
*
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
function escapeTemplateLiteral(str: string): string {
|
|
52
|
+
return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate the Zod schema expression for a list of schema variables.
|
|
57
|
+
*
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
function generateSchemaExpression(vars: SchemaVariable[]): string {
|
|
61
|
+
if (vars.length === 0) {
|
|
62
|
+
return "z.object({})";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields = vars
|
|
66
|
+
.map((v) => {
|
|
67
|
+
const base = "z.string()";
|
|
68
|
+
const expr = match(v.required)
|
|
69
|
+
.with(true, () => base)
|
|
70
|
+
.otherwise(() => `${base}.optional()`);
|
|
71
|
+
return ` ${v.name}: ${expr},`;
|
|
72
|
+
})
|
|
73
|
+
.join("\n");
|
|
74
|
+
|
|
75
|
+
return `z.object({\n${fields}\n})`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const HEADER = [
|
|
79
|
+
"/*",
|
|
80
|
+
"|==========================================================================",
|
|
81
|
+
"| AUTO-GENERATED — DO NOT EDIT",
|
|
82
|
+
"|==========================================================================",
|
|
83
|
+
"|",
|
|
84
|
+
"| Run `funkai prompts generate` to regenerate.",
|
|
85
|
+
"|",
|
|
86
|
+
"*/",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Generate a per-prompt TypeScript module with a default export.
|
|
91
|
+
*
|
|
92
|
+
* The module contains the Zod schema, inlined template, and
|
|
93
|
+
* `render` / `validate` functions.
|
|
94
|
+
*/
|
|
95
|
+
export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
96
|
+
const escaped = escapeTemplateLiteral(prompt.template);
|
|
97
|
+
const schemaExpr = generateSchemaExpression(prompt.schema);
|
|
98
|
+
const groupValue = match(prompt.group != null)
|
|
99
|
+
.with(true, () => `'${prompt.group}' as const`)
|
|
100
|
+
.otherwise(() => "undefined");
|
|
101
|
+
|
|
102
|
+
const lines: string[] = [
|
|
103
|
+
HEADER,
|
|
104
|
+
`// Source: ${prompt.sourcePath}`,
|
|
105
|
+
"",
|
|
106
|
+
"import { z } from 'zod'",
|
|
107
|
+
"import { engine } from '@funkai/prompts'",
|
|
108
|
+
"",
|
|
109
|
+
`const schema = ${schemaExpr}`,
|
|
110
|
+
"",
|
|
111
|
+
"type Variables = z.infer<typeof schema>",
|
|
112
|
+
"",
|
|
113
|
+
`const template = \`${escaped}\``,
|
|
114
|
+
"",
|
|
115
|
+
"export default {",
|
|
116
|
+
` name: '${prompt.name}' as const,`,
|
|
117
|
+
` group: ${groupValue},`,
|
|
118
|
+
" schema,",
|
|
119
|
+
...match(prompt.schema.length)
|
|
120
|
+
.with(0, () => [
|
|
121
|
+
" render(variables?: undefined): string {",
|
|
122
|
+
" return engine.parseAndRenderSync(template, {})",
|
|
123
|
+
" },",
|
|
124
|
+
" validate(variables?: undefined): Variables {",
|
|
125
|
+
" return schema.parse(variables ?? {})",
|
|
126
|
+
" },",
|
|
127
|
+
])
|
|
128
|
+
.otherwise(() => [
|
|
129
|
+
" render(variables: Variables): string {",
|
|
130
|
+
" return engine.parseAndRenderSync(template, schema.parse(variables))",
|
|
131
|
+
" },",
|
|
132
|
+
" validate(variables: unknown): Variables {",
|
|
133
|
+
" return schema.parse(variables)",
|
|
134
|
+
" },",
|
|
135
|
+
]),
|
|
136
|
+
"}",
|
|
137
|
+
"",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A tree node used during registry code generation.
|
|
145
|
+
* Leaves hold the camelCase import name; branches hold nested nodes.
|
|
146
|
+
*/
|
|
147
|
+
type TreeNode = {
|
|
148
|
+
readonly [key: string]: string | TreeNode;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build a nested tree from sorted prompts, grouped by their `group` field.
|
|
153
|
+
*
|
|
154
|
+
* @param prompts - Sorted parsed prompts.
|
|
155
|
+
* @returns A tree where leaves are import names and branches are group namespaces.
|
|
156
|
+
* @throws If a prompt name collides with a group namespace at the same level.
|
|
157
|
+
*
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
161
|
+
return prompts.reduce<Record<string, unknown>>((root, prompt) => {
|
|
162
|
+
const importName = toCamelCase(prompt.name);
|
|
163
|
+
const segments = prompt.group ? prompt.group.split("/").map(toCamelCase) : [];
|
|
164
|
+
|
|
165
|
+
const target = segments.reduce<Record<string, unknown>>((current, segment) => {
|
|
166
|
+
const existing = current[segment];
|
|
167
|
+
if (typeof existing === "string") {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Collision: prompt "${existing}" and group namespace "${segment}" ` +
|
|
170
|
+
"share the same key at the same level.",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (existing == null) {
|
|
174
|
+
current[segment] = {};
|
|
175
|
+
}
|
|
176
|
+
return current[segment] as Record<string, unknown>;
|
|
177
|
+
}, root);
|
|
178
|
+
|
|
179
|
+
if (typeof target[importName] === "object" && target[importName] !== null) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Collision: prompt "${importName}" conflicts with existing group namespace ` +
|
|
182
|
+
`"${importName}" at the same level.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
target[importName] = importName;
|
|
187
|
+
return root;
|
|
188
|
+
}, {}) as TreeNode;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Serialize a tree node into indented object literal lines.
|
|
193
|
+
*
|
|
194
|
+
* @param node - The tree node to serialize.
|
|
195
|
+
* @param indent - Current indentation level.
|
|
196
|
+
* @returns Array of source lines forming the object literal body.
|
|
197
|
+
*
|
|
198
|
+
* @private
|
|
199
|
+
*/
|
|
200
|
+
function serializeTree(node: TreeNode, indent: number): string[] {
|
|
201
|
+
const pad = " ".repeat(indent);
|
|
202
|
+
|
|
203
|
+
return Object.entries(node).flatMap(([key, value]) =>
|
|
204
|
+
typeof value === "string"
|
|
205
|
+
? [`${pad}${key},`]
|
|
206
|
+
: [`${pad}${key}: {`, ...serializeTree(value, indent + 1), `${pad}},`],
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate the registry `index.ts` that wires all prompt modules
|
|
212
|
+
* together via `createPromptRegistry()`.
|
|
213
|
+
*
|
|
214
|
+
* Prompts are organized into a nested object structure based on their
|
|
215
|
+
* `group` field, with each `/`-separated segment becoming a nesting level.
|
|
216
|
+
*/
|
|
217
|
+
export function generateRegistry(prompts: ParsedPrompt[]): string {
|
|
218
|
+
const sorted = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
219
|
+
|
|
220
|
+
const imports = sorted
|
|
221
|
+
.map((p) => `import ${toCamelCase(p.name)} from './${p.name}.js'`)
|
|
222
|
+
.join("\n");
|
|
223
|
+
|
|
224
|
+
const tree = buildTree(sorted);
|
|
225
|
+
const treeLines = serializeTree(tree, 1);
|
|
226
|
+
|
|
227
|
+
const lines: string[] = [
|
|
228
|
+
HEADER,
|
|
229
|
+
"",
|
|
230
|
+
"import { createPromptRegistry } from '@funkai/prompts'",
|
|
231
|
+
imports,
|
|
232
|
+
"",
|
|
233
|
+
"export const prompts = createPromptRegistry({",
|
|
234
|
+
...treeLines,
|
|
235
|
+
"})",
|
|
236
|
+
"",
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
return lines.join("\n");
|
|
240
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Liquid } from "liquidjs";
|
|
2
|
+
|
|
3
|
+
const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract top-level variable names from a Liquid template string.
|
|
7
|
+
*
|
|
8
|
+
* Uses LiquidJS's built-in `variablesSync` to parse the template AST
|
|
9
|
+
* and extract all referenced variable names. Only returns the root
|
|
10
|
+
* variable name (e.g. `user` from `{{ user.name }}`).
|
|
11
|
+
*
|
|
12
|
+
* @throws {Error} If a variable name is dangerous (e.g. `__proto__`)
|
|
13
|
+
*/
|
|
14
|
+
export function extractVariables(template: string): string[] {
|
|
15
|
+
const engine = new Liquid();
|
|
16
|
+
const parsed = engine.parse(template);
|
|
17
|
+
const variables = engine.variablesSync(parsed);
|
|
18
|
+
|
|
19
|
+
const roots = new Set(
|
|
20
|
+
variables.map((variable) => {
|
|
21
|
+
const root = Array.isArray(variable) ? String(variable[0]) : String(variable);
|
|
22
|
+
|
|
23
|
+
if (DANGEROUS_NAMES.has(root)) {
|
|
24
|
+
throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return root;
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return [...roots].toSorted();
|
|
32
|
+
}
|