@vertesia/studio-utils 1.3.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/LICENSE +201 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -0
- package/lib/prompts/extract-vars.d.ts +19 -0
- package/lib/prompts/extract-vars.d.ts.map +1 -0
- package/lib/prompts/extract-vars.js +111 -0
- package/lib/prompts/extract-vars.js.map +1 -0
- package/lib/prompts/mock-data.d.ts +17 -0
- package/lib/prompts/mock-data.d.ts.map +1 -0
- package/lib/prompts/mock-data.js +52 -0
- package/lib/prompts/mock-data.js.map +1 -0
- package/lib/prompts/render.d.ts +52 -0
- package/lib/prompts/render.d.ts.map +1 -0
- package/lib/prompts/render.js +166 -0
- package/lib/prompts/render.js.map +1 -0
- package/lib/prompts/validate.d.ts +46 -0
- package/lib/prompts/validate.d.ts.map +1 -0
- package/lib/prompts/validate.js +167 -0
- package/lib/prompts/validate.js.map +1 -0
- package/lib/roles/classes.d.ts +59 -0
- package/lib/roles/classes.d.ts.map +1 -0
- package/lib/roles/classes.js +60 -0
- package/lib/roles/classes.js.map +1 -0
- package/lib/roles/content.d.ts +13 -0
- package/lib/roles/content.d.ts.map +1 -0
- package/lib/roles/content.js +39 -0
- package/lib/roles/content.js.map +1 -0
- package/lib/roles/index.d.ts +37 -0
- package/lib/roles/index.d.ts.map +1 -0
- package/lib/roles/index.js +87 -0
- package/lib/roles/index.js.map +1 -0
- package/lib/roles/system.d.ts +3 -0
- package/lib/roles/system.d.ts.map +1 -0
- package/lib/roles/system.js +187 -0
- package/lib/roles/system.js.map +1 -0
- package/lib/vertesia-studio-utils.js +2 -0
- package/lib/vertesia-studio-utils.js.map +1 -0
- package/package.json +50 -0
- package/src/index.ts +20 -0
- package/src/prompts/extract-vars.ts +110 -0
- package/src/prompts/mock-data.ts +63 -0
- package/src/prompts/render.test.ts +109 -0
- package/src/prompts/render.ts +192 -0
- package/src/prompts/validate.test.ts +274 -0
- package/src/prompts/validate.ts +216 -0
- package/src/roles/classes.ts +78 -0
- package/src/roles/content.ts +46 -0
- package/src/roles/index.test.ts +206 -0
- package/src/roles/index.ts +96 -0
- package/src/roles/system.ts +204 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { JSONSchema } from '@vertesia/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate mock data that satisfies a JSON Schema.
|
|
5
|
+
*
|
|
6
|
+
* - string → `%property_name%` (visible placeholder, useful for previews and validation render tests)
|
|
7
|
+
* - number/integer → random value 0–100
|
|
8
|
+
* - boolean → true
|
|
9
|
+
* - array → 1–2 items recursively generated from the items schema
|
|
10
|
+
* - object → recursively populated from properties
|
|
11
|
+
* - null → null
|
|
12
|
+
* - $ref or unknown → `%property_name%`
|
|
13
|
+
*
|
|
14
|
+
* @param schema JSON schema to generate data for
|
|
15
|
+
* @param propertyName Name used for string placeholders (default `value`)
|
|
16
|
+
*/
|
|
17
|
+
export function generateMockData(schema: JSONSchema, propertyName: string = 'value'): unknown {
|
|
18
|
+
if ('$ref' in schema) {
|
|
19
|
+
return `%${propertyName}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
23
|
+
const primaryType = types[0];
|
|
24
|
+
|
|
25
|
+
switch (primaryType) {
|
|
26
|
+
case 'string':
|
|
27
|
+
return `%${propertyName}%`;
|
|
28
|
+
|
|
29
|
+
case 'number':
|
|
30
|
+
case 'integer':
|
|
31
|
+
return Math.floor(Math.random() * 101);
|
|
32
|
+
|
|
33
|
+
case 'boolean':
|
|
34
|
+
return true;
|
|
35
|
+
|
|
36
|
+
case 'array': {
|
|
37
|
+
const itemSchema = schema.items as JSONSchema;
|
|
38
|
+
if (itemSchema) {
|
|
39
|
+
const itemCount = Math.floor(Math.random() * 2) + 1;
|
|
40
|
+
return Array.from({ length: itemCount }, (_, index) =>
|
|
41
|
+
generateMockData(itemSchema, `${propertyName}_${index}`),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'object': {
|
|
48
|
+
const result: Record<string, unknown> = {};
|
|
49
|
+
if (schema.properties) {
|
|
50
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
51
|
+
result[propName] = generateMockData(propSchema, propName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case 'null':
|
|
58
|
+
return null;
|
|
59
|
+
|
|
60
|
+
default:
|
|
61
|
+
return `%${propertyName}%`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { PromptRole } from '@llumiverse/common';
|
|
2
|
+
import {
|
|
3
|
+
type PromptSegmentDef,
|
|
4
|
+
PromptSegmentDefType,
|
|
5
|
+
PromptStatus,
|
|
6
|
+
type PromptTemplate,
|
|
7
|
+
TemplateType,
|
|
8
|
+
} from '@vertesia/common';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { renderSegments, renderTemplate } from './render.js';
|
|
12
|
+
|
|
13
|
+
describe('renderTemplate', () => {
|
|
14
|
+
it('returns text content verbatim instead of evaluating it as JST', () => {
|
|
15
|
+
const content = 'You are a test assistant for skill validation. Be concise.';
|
|
16
|
+
expect(renderTemplate(content, TemplateType.text, {}, {})).toEqual(content);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders handlebars templates', () => {
|
|
20
|
+
expect(renderTemplate('Hello {{name}}', TemplateType.handlebars, {}, { name: 'Ada' })).toEqual('Hello Ada');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders JST templates', () => {
|
|
24
|
+
expect(
|
|
25
|
+
renderTemplate('return `Hello ${name}`', TemplateType.jst, { properties: { name: {} } }, { name: 'Ada' }),
|
|
26
|
+
).toEqual('Hello Ada');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('renderSegments', () => {
|
|
31
|
+
it('renders a text system segment followed by a handlebars user segment', () => {
|
|
32
|
+
const segments: PromptSegmentDef<PromptTemplate>[] = [
|
|
33
|
+
{
|
|
34
|
+
type: PromptSegmentDefType.template,
|
|
35
|
+
template: createPromptTemplate({
|
|
36
|
+
id: 'sys-1',
|
|
37
|
+
role: PromptRole.system,
|
|
38
|
+
content: 'You are a test assistant for skill validation. Be concise.',
|
|
39
|
+
content_type: TemplateType.text,
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: PromptSegmentDefType.template,
|
|
44
|
+
template: createPromptTemplate({
|
|
45
|
+
id: 'user-1',
|
|
46
|
+
role: PromptRole.user,
|
|
47
|
+
content: 'Answer this question: {{question}}',
|
|
48
|
+
content_type: TemplateType.handlebars,
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
expect(renderSegments(segments, { question: 'What is 2+2?' })).toEqual([
|
|
54
|
+
{
|
|
55
|
+
title: '@system',
|
|
56
|
+
content: 'You are a test assistant for skill validation. Be concise.',
|
|
57
|
+
segmentId: 'sys-1',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: '@user',
|
|
61
|
+
content: 'Answer this question: What is 2+2?',
|
|
62
|
+
segmentId: 'user-1',
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('isolates a failing segment so the remaining segments still render', () => {
|
|
68
|
+
const segments: PromptSegmentDef<PromptTemplate>[] = [
|
|
69
|
+
{
|
|
70
|
+
type: PromptSegmentDefType.template,
|
|
71
|
+
template: createPromptTemplate({
|
|
72
|
+
id: 'bad-1',
|
|
73
|
+
content: 'return missingGlobal',
|
|
74
|
+
content_type: TemplateType.jst,
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
type: PromptSegmentDefType.template,
|
|
79
|
+
template: createPromptTemplate({
|
|
80
|
+
id: 'good-1',
|
|
81
|
+
content: 'Hello {{name}}',
|
|
82
|
+
content_type: TemplateType.handlebars,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const result = renderSegments(segments, { name: 'Ada' });
|
|
88
|
+
expect(result[0].error).toBeInstanceOf(Error);
|
|
89
|
+
expect(result[1]).toEqual({ title: '@user', content: 'Hello Ada', segmentId: 'good-1' });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function createPromptTemplate(overrides: Partial<PromptTemplate>): PromptTemplate {
|
|
94
|
+
return {
|
|
95
|
+
id: 'prompt-1',
|
|
96
|
+
name: 'Greeting',
|
|
97
|
+
role: PromptRole.user,
|
|
98
|
+
status: PromptStatus.draft,
|
|
99
|
+
version: 1,
|
|
100
|
+
content: '',
|
|
101
|
+
content_type: TemplateType.jst,
|
|
102
|
+
project: 'project-1',
|
|
103
|
+
created_by: 'user-1',
|
|
104
|
+
updated_by: 'user-1',
|
|
105
|
+
created_at: new Date('2026-01-01T00:00:00.000Z'),
|
|
106
|
+
updated_at: new Date('2026-01-01T00:00:00.000Z'),
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { JSONObject, JSONSchema, PromptSegment } from '@llumiverse/common';
|
|
2
|
+
import { type PromptSegmentDef, PromptSegmentDefType, type PromptTemplate, TemplateType } from '@vertesia/common';
|
|
3
|
+
import { CompositeError, renderHandlebarsTemplate, renderJsTemplate } from '@vertesia/jst';
|
|
4
|
+
|
|
5
|
+
export interface SegmentPreview {
|
|
6
|
+
error?: Error;
|
|
7
|
+
title: string;
|
|
8
|
+
content: string;
|
|
9
|
+
segmentId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a prompt template with the given input data.
|
|
14
|
+
*
|
|
15
|
+
* Handlebars templates use {{variable}} interpolation against `data`.
|
|
16
|
+
* JST (JavaScript template) bodies evaluate against `data` with the schema's top-level
|
|
17
|
+
* property names exposed as globals, plus `_model` — the active model id, which the
|
|
18
|
+
* studio-server executor injects into the input as `{ ..._model: run.modelId }` when
|
|
19
|
+
* executing an interaction (see `apps/studio-server/src/executor/ExecutionRequest.ts`
|
|
20
|
+
* and `apps/studio-server/src/executor/rendering/template.ts`). Listing it here keeps
|
|
21
|
+
* the Playground preview and `validatePrompt` in sync with runtime resolution — a JST
|
|
22
|
+
* template referencing `_model` validates fine here and renders fine in production.
|
|
23
|
+
*
|
|
24
|
+
* For `TemplateType.text`, the content is returned verbatim — it is static text, not a
|
|
25
|
+
* template — matching the studio-server executor (see `apps/studio-server/src/executor/
|
|
26
|
+
* rendering/template.ts`). Routing it through the JST evaluator would compile the prose as
|
|
27
|
+
* JavaScript and throw on any plain sentence (e.g. "You are a helpful assistant.").
|
|
28
|
+
*/
|
|
29
|
+
export function renderTemplate(code: string, contentType: TemplateType, schema: JSONSchema, data: JSONObject): string {
|
|
30
|
+
if (contentType === TemplateType.handlebars) {
|
|
31
|
+
return renderHandlebarsTemplate(code, data);
|
|
32
|
+
}
|
|
33
|
+
if (contentType === TemplateType.text) {
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
const globals = [...(schema.properties ? Object.keys(schema.properties) : []), '_model'];
|
|
37
|
+
return renderJsTemplate(code, globals, data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Execute a JST (JavaScript Template) with given data.
|
|
42
|
+
* Returns a discriminated union to surface errors without throwing.
|
|
43
|
+
*/
|
|
44
|
+
export function executeJST(
|
|
45
|
+
jstContent: string,
|
|
46
|
+
schema: JSONSchema,
|
|
47
|
+
data: JSONObject,
|
|
48
|
+
): { success: true; content: string } | { success: false; error: string } {
|
|
49
|
+
try {
|
|
50
|
+
const result = renderTemplate(jstContent, TemplateType.jst, schema, data);
|
|
51
|
+
return { success: true, content: result };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Execute a Handlebars template with given data.
|
|
59
|
+
* Returns a discriminated union to surface errors without throwing.
|
|
60
|
+
*/
|
|
61
|
+
export function executeHandlebars(
|
|
62
|
+
handlebarsContent: string,
|
|
63
|
+
schema: JSONSchema,
|
|
64
|
+
data: JSONObject,
|
|
65
|
+
): { success: true; content: string } | { success: false; error: string } {
|
|
66
|
+
try {
|
|
67
|
+
const result = renderTemplate(handlebarsContent, TemplateType.handlebars, schema, data);
|
|
68
|
+
return { success: true, content: result };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderChatSegment(segment: PromptSegmentDef<PromptTemplate>, data: JSONObject) {
|
|
75
|
+
const chatKey = getChatKey(segment.configuration);
|
|
76
|
+
const chat = data[chatKey];
|
|
77
|
+
let content: string;
|
|
78
|
+
if (Array.isArray(chat)) {
|
|
79
|
+
content = JSON.stringify(chat, undefined, 2);
|
|
80
|
+
} else {
|
|
81
|
+
content = `No "${chatKey}" property found on input data`;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
title: 'Chat history',
|
|
85
|
+
content,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderTemplateSegment(segment: PromptSegmentDef<PromptTemplate>, data: JSONObject) {
|
|
90
|
+
if (!segment.template) {
|
|
91
|
+
return { title: '(unknown segment)', content: '', error: new Error('Prompt segment is missing its template') };
|
|
92
|
+
}
|
|
93
|
+
const schema = segment.template.inputSchema || {};
|
|
94
|
+
const title = `@${segment.template.role}`;
|
|
95
|
+
try {
|
|
96
|
+
const content = renderTemplate(segment.template.content, segment.template.content_type, schema, data);
|
|
97
|
+
return { title, content, segmentId: segment.template.id };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Isolate the failure to this segment so the remaining segments still render.
|
|
100
|
+
return {
|
|
101
|
+
title,
|
|
102
|
+
content: error instanceof Error ? error.message : String(error),
|
|
103
|
+
segmentId: segment.template.id,
|
|
104
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderSegments(segments: PromptSegmentDef<PromptTemplate>[], data: JSONObject): SegmentPreview[] {
|
|
110
|
+
const out: SegmentPreview[] = [];
|
|
111
|
+
for (const segment of segments) {
|
|
112
|
+
if (segment.type === PromptSegmentDefType.chat) {
|
|
113
|
+
out.push(renderChatSegment(segment, data));
|
|
114
|
+
} else if (segment.type === PromptSegmentDefType.template) {
|
|
115
|
+
out.push(renderTemplateSegment(segment, data));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function renderSegmentsOrErrors(
|
|
122
|
+
segments: PromptSegmentDef<PromptTemplate>[],
|
|
123
|
+
textOrObject: string | JSONObject,
|
|
124
|
+
): SegmentPreview[] {
|
|
125
|
+
try {
|
|
126
|
+
return renderSegments(
|
|
127
|
+
segments,
|
|
128
|
+
typeof textOrObject === 'string' ? JSON.parse(textOrObject.trim()) : textOrObject || {},
|
|
129
|
+
);
|
|
130
|
+
} catch (error: unknown) {
|
|
131
|
+
if (error instanceof CompositeError) {
|
|
132
|
+
return error.errors.map((err) => ({
|
|
133
|
+
error: err as Error,
|
|
134
|
+
title: 'Rendering Error',
|
|
135
|
+
content: err.message,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
141
|
+
title: 'Rendering Error',
|
|
142
|
+
content: error instanceof Error ? error.message : String(error),
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderPrompt(segments: PromptSegmentDef<PromptTemplate>[], payload: JSONObject): PromptSegment[] {
|
|
149
|
+
const out: PromptSegment[] = [];
|
|
150
|
+
for (const segment of segments) {
|
|
151
|
+
if (segment.template) {
|
|
152
|
+
const schema = segment.template.inputSchema || {};
|
|
153
|
+
const content = renderTemplate(segment.template.content, segment.template.content_type, schema, payload);
|
|
154
|
+
out.push({ role: segment.template.role, content });
|
|
155
|
+
} else if (segment.type === PromptSegmentDefType.chat) {
|
|
156
|
+
const chatKey = getChatKey(segment.configuration);
|
|
157
|
+
const messages = payload[chatKey];
|
|
158
|
+
if (!isPromptSegmentArray(messages)) {
|
|
159
|
+
throw new Error('Chat prompt segment must have a messages array');
|
|
160
|
+
}
|
|
161
|
+
for (const msg of messages) {
|
|
162
|
+
if (!msg.role) {
|
|
163
|
+
throw new Error('Chat prompt segment must have a role');
|
|
164
|
+
}
|
|
165
|
+
out.push({ role: msg.role, content: msg.content });
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
throw new Error(`Unknown prompt segment type: ${segment.type}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getChatKey(configuration: unknown): string {
|
|
175
|
+
if (
|
|
176
|
+
typeof configuration === 'object' &&
|
|
177
|
+
configuration !== null &&
|
|
178
|
+
'chatKey' in configuration &&
|
|
179
|
+
typeof (configuration as { chatKey: unknown }).chatKey === 'string'
|
|
180
|
+
) {
|
|
181
|
+
return (configuration as { chatKey: string }).chatKey;
|
|
182
|
+
}
|
|
183
|
+
return 'chat';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isPromptSegmentArray(value: unknown): value is PromptSegment[] {
|
|
187
|
+
return Array.isArray(value) && value.every(isPromptSegment);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isPromptSegment(value: unknown): value is PromptSegment {
|
|
191
|
+
return typeof value === 'object' && value !== null && 'role' in value && 'content' in value;
|
|
192
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { JSONSchema } from '@vertesia/common';
|
|
2
|
+
import { TemplateType } from '@vertesia/common';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { type PromptValidationIssue, validatePrompt } from './validate.js';
|
|
5
|
+
|
|
6
|
+
function findIssue(
|
|
7
|
+
issues: PromptValidationIssue[],
|
|
8
|
+
type: PromptValidationIssue['type'],
|
|
9
|
+
variable?: string,
|
|
10
|
+
): PromptValidationIssue | undefined {
|
|
11
|
+
return issues.find((i) => i.type === type && (variable === undefined || i.variable === variable));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const schema = (properties: Record<string, JSONSchema>, required?: string[]): JSONSchema => ({
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties,
|
|
17
|
+
...(required ? { required } : {}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('validatePrompt — text (no validation)', () => {
|
|
21
|
+
it('returns no issues for plain text content regardless of schema', () => {
|
|
22
|
+
const r = validatePrompt({
|
|
23
|
+
content: 'Hello, this is just text. {{not a template tag}}',
|
|
24
|
+
contentType: TemplateType.text,
|
|
25
|
+
inputSchema: schema({ unused: { type: 'string' } }),
|
|
26
|
+
});
|
|
27
|
+
expect(r.error_count).toBe(0);
|
|
28
|
+
expect(r.warning_count).toBe(0);
|
|
29
|
+
expect(r.issues).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('validatePrompt — handlebars', () => {
|
|
34
|
+
describe('undeclared_template_variable (error)', () => {
|
|
35
|
+
it('flags a top-level variable that is not in the schema', () => {
|
|
36
|
+
const r = validatePrompt({
|
|
37
|
+
content: 'Summarize this document: {{document}}',
|
|
38
|
+
contentType: TemplateType.handlebars,
|
|
39
|
+
inputSchema: schema({}),
|
|
40
|
+
});
|
|
41
|
+
const issue = findIssue(r.issues, 'undeclared_template_variable', 'document');
|
|
42
|
+
expect(issue).toBeDefined();
|
|
43
|
+
expect(issue?.severity).toBe('error');
|
|
44
|
+
expect(r.error_count).toBeGreaterThanOrEqual(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('flags multiple undeclared variables independently', () => {
|
|
48
|
+
const r = validatePrompt({
|
|
49
|
+
content: 'Hi {{name}}, your role is {{role}} and dept is {{dept}}',
|
|
50
|
+
contentType: TemplateType.handlebars,
|
|
51
|
+
inputSchema: schema({ name: { type: 'string' } }),
|
|
52
|
+
});
|
|
53
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'role')).toBeDefined();
|
|
54
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'dept')).toBeDefined();
|
|
55
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'name')).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('flags the root variable when the template uses dotted access', () => {
|
|
59
|
+
// Handlebars `{{user.name}}` references the top-level `user` — the schema must declare `user`.
|
|
60
|
+
const r = validatePrompt({
|
|
61
|
+
content: 'User: {{user.name}}',
|
|
62
|
+
contentType: TemplateType.handlebars,
|
|
63
|
+
inputSchema: schema({}),
|
|
64
|
+
});
|
|
65
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'user')).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('treats no schema as schema with no properties', () => {
|
|
69
|
+
const r = validatePrompt({
|
|
70
|
+
content: 'Hello {{name}}',
|
|
71
|
+
contentType: TemplateType.handlebars,
|
|
72
|
+
});
|
|
73
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'name')).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('unused_schema_variable (warning)', () => {
|
|
78
|
+
it('flags a schema property that the template never references', () => {
|
|
79
|
+
const r = validatePrompt({
|
|
80
|
+
content: 'Hello {{name}}',
|
|
81
|
+
contentType: TemplateType.handlebars,
|
|
82
|
+
inputSchema: schema({
|
|
83
|
+
name: { type: 'string' },
|
|
84
|
+
extra: { type: 'string' },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const issue = findIssue(r.issues, 'unused_schema_variable', 'extra');
|
|
88
|
+
expect(issue).toBeDefined();
|
|
89
|
+
expect(issue?.severity).toBe('warning');
|
|
90
|
+
expect(r.warning_count).toBeGreaterThanOrEqual(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not block validation — only errors do', () => {
|
|
94
|
+
const r = validatePrompt({
|
|
95
|
+
content: 'Hello {{name}}',
|
|
96
|
+
contentType: TemplateType.handlebars,
|
|
97
|
+
inputSchema: schema({
|
|
98
|
+
name: { type: 'string' },
|
|
99
|
+
unused: { type: 'string' },
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
expect(r.error_count).toBe(0);
|
|
103
|
+
expect(r.warning_count).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('handlebars_render_error (error)', () => {
|
|
108
|
+
it('flags a syntax error in the template', () => {
|
|
109
|
+
const r = validatePrompt({
|
|
110
|
+
// Unclosed mustache → handlebars parse error
|
|
111
|
+
content: 'Hello {{name',
|
|
112
|
+
contentType: TemplateType.handlebars,
|
|
113
|
+
inputSchema: schema({ name: { type: 'string' } }),
|
|
114
|
+
});
|
|
115
|
+
expect(findIssue(r.issues, 'handlebars_render_error')).toBeDefined();
|
|
116
|
+
expect(r.error_count).toBeGreaterThanOrEqual(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('happy path', () => {
|
|
121
|
+
it('returns zero issues when every template variable is declared and every declared variable is used', () => {
|
|
122
|
+
const r = validatePrompt({
|
|
123
|
+
content: 'Hello {{name}}, please summarize: {{document}}',
|
|
124
|
+
contentType: TemplateType.handlebars,
|
|
125
|
+
inputSchema: schema(
|
|
126
|
+
{
|
|
127
|
+
name: { type: 'string' },
|
|
128
|
+
document: { type: 'string' },
|
|
129
|
+
},
|
|
130
|
+
['name', 'document'],
|
|
131
|
+
),
|
|
132
|
+
});
|
|
133
|
+
expect(r.error_count).toBe(0);
|
|
134
|
+
expect(r.warning_count).toBe(0);
|
|
135
|
+
expect(r.issues).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('accepts handlebars block helpers (#if/#each) on declared variables', () => {
|
|
139
|
+
const r = validatePrompt({
|
|
140
|
+
content: '{{#if items}}Items:{{#each items}}- {{this}}\n{{/each}}{{/if}}',
|
|
141
|
+
contentType: TemplateType.handlebars,
|
|
142
|
+
inputSchema: schema({ items: { type: 'array', items: { type: 'string' } } }),
|
|
143
|
+
});
|
|
144
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'items')).toBeUndefined();
|
|
145
|
+
expect(r.error_count).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('combined errors + warnings', () => {
|
|
150
|
+
it('reports both kinds in one pass and counts them correctly', () => {
|
|
151
|
+
const r = validatePrompt({
|
|
152
|
+
content: 'Hi {{name}}, document: {{document}}',
|
|
153
|
+
contentType: TemplateType.handlebars,
|
|
154
|
+
inputSchema: schema({
|
|
155
|
+
name: { type: 'string' },
|
|
156
|
+
extra: { type: 'string' }, // unused → warning
|
|
157
|
+
// document missing → error
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'document')).toBeDefined();
|
|
161
|
+
expect(findIssue(r.issues, 'unused_schema_variable', 'extra')).toBeDefined();
|
|
162
|
+
expect(r.error_count).toBe(1);
|
|
163
|
+
expect(r.warning_count).toBe(1);
|
|
164
|
+
expect(r.issues.length).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('ordering and independence of error sources', () => {
|
|
169
|
+
// Handlebars is non-strict — `{{undeclared}}` renders as empty string, NOT an exception.
|
|
170
|
+
// So an undeclared variable does not cause a render error; only one error is reported.
|
|
171
|
+
it('reports undeclared_template_variable only (render does not echo it in non-strict Handlebars)', () => {
|
|
172
|
+
const r = validatePrompt({
|
|
173
|
+
content: 'Hello {{undefined_var}}',
|
|
174
|
+
contentType: TemplateType.handlebars,
|
|
175
|
+
inputSchema: schema({}),
|
|
176
|
+
});
|
|
177
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'undefined_var')).toBeDefined();
|
|
178
|
+
expect(findIssue(r.issues, 'handlebars_render_error')).toBeUndefined();
|
|
179
|
+
expect(r.error_count).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// When the template has both an undeclared variable AND a real parse error,
|
|
183
|
+
// we must surface both — and the variable error must appear first in the list
|
|
184
|
+
// so the LLM/user sees the precise reason before the generic render error.
|
|
185
|
+
it('reports BOTH undeclared_template_variable and handlebars_render_error, vars first', () => {
|
|
186
|
+
const r = validatePrompt({
|
|
187
|
+
// {{name has no closing brace → parse error. extractHandlebarsVariables fails
|
|
188
|
+
// silently and returns an empty set, so no undeclared error is raised by
|
|
189
|
+
// step 1 here — but the rendering step catches the parse failure.
|
|
190
|
+
content: 'Hello {{undefined_var}} and {{name',
|
|
191
|
+
contentType: TemplateType.handlebars,
|
|
192
|
+
inputSchema: schema({}),
|
|
193
|
+
});
|
|
194
|
+
// The renderer reports the parse problem; the var-extractor couldn't even parse the
|
|
195
|
+
// template, so it returned no usedVars and we cannot detect the undeclared var here.
|
|
196
|
+
// The contract is "both kinds are independent and both reported when applicable".
|
|
197
|
+
expect(findIssue(r.issues, 'handlebars_render_error')).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('validatePrompt — jst', () => {
|
|
203
|
+
it('happy path: identifier referenced and declared', () => {
|
|
204
|
+
const r = validatePrompt({
|
|
205
|
+
content: 'return `hello ${name}`;',
|
|
206
|
+
contentType: TemplateType.jst,
|
|
207
|
+
inputSchema: schema({ name: { type: 'string' } }, ['name']),
|
|
208
|
+
});
|
|
209
|
+
expect(r.error_count).toBe(0);
|
|
210
|
+
expect(r.warning_count).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('flags undeclared identifier as error', () => {
|
|
214
|
+
const r = validatePrompt({
|
|
215
|
+
content: 'return `hello ${name}`;',
|
|
216
|
+
contentType: TemplateType.jst,
|
|
217
|
+
inputSchema: schema({}),
|
|
218
|
+
});
|
|
219
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'name')).toBeDefined();
|
|
220
|
+
expect(r.error_count).toBeGreaterThanOrEqual(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('flags declared-but-unused as warning', () => {
|
|
224
|
+
const r = validatePrompt({
|
|
225
|
+
content: 'return `hello ${name}`;',
|
|
226
|
+
contentType: TemplateType.jst,
|
|
227
|
+
inputSchema: schema({
|
|
228
|
+
name: { type: 'string' },
|
|
229
|
+
unused: { type: 'string' },
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
const issue = findIssue(r.issues, 'unused_schema_variable', 'unused');
|
|
233
|
+
expect(issue).toBeDefined();
|
|
234
|
+
expect(issue?.severity).toBe('warning');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('does not flag locally-bound variables as undeclared', () => {
|
|
238
|
+
const r = validatePrompt({
|
|
239
|
+
content: 'const greeting = `hello ${name}`; return greeting;',
|
|
240
|
+
contentType: TemplateType.jst,
|
|
241
|
+
inputSchema: schema({ name: { type: 'string' } }, ['name']),
|
|
242
|
+
});
|
|
243
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'greeting')).toBeUndefined();
|
|
244
|
+
expect(r.error_count).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('does not flag jst auto-globals (_, Array, Set)', () => {
|
|
248
|
+
const r = validatePrompt({
|
|
249
|
+
content: 'return _.stringify({ count: Array.isArray(items) ? items.length : 0 });',
|
|
250
|
+
contentType: TemplateType.jst,
|
|
251
|
+
inputSchema: schema({ items: { type: 'array', items: { type: 'string' } } }, ['items']),
|
|
252
|
+
});
|
|
253
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', '_')).toBeUndefined();
|
|
254
|
+
expect(findIssue(r.issues, 'undeclared_template_variable', 'Array')).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('flags unsafe constructs (with) as jst_unsafe_construct', () => {
|
|
258
|
+
const r = validatePrompt({
|
|
259
|
+
content: 'with (foo) { return bar; }',
|
|
260
|
+
contentType: TemplateType.jst,
|
|
261
|
+
inputSchema: schema({ foo: { type: 'object' }, bar: { type: 'string' } }, ['foo', 'bar']),
|
|
262
|
+
});
|
|
263
|
+
expect(findIssue(r.issues, 'jst_unsafe_construct')).toBeDefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('flags for-loops as jst_unsafe_construct', () => {
|
|
267
|
+
const r = validatePrompt({
|
|
268
|
+
content: 'for (let i = 0; i < items.length; i++) { return items[i]; } return null;',
|
|
269
|
+
contentType: TemplateType.jst,
|
|
270
|
+
inputSchema: schema({ items: { type: 'array', items: { type: 'string' } } }, ['items']),
|
|
271
|
+
});
|
|
272
|
+
expect(findIssue(r.issues, 'jst_unsafe_construct')).toBeDefined();
|
|
273
|
+
});
|
|
274
|
+
});
|