@vertesia/studio-utils 1.5.0 → 1.5.1
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/lib/conditions/index.d.ts +1 -0
- package/lib/conditions/index.d.ts.map +1 -1
- package/lib/conditions/index.js +1 -0
- package/lib/conditions/index.js.map +1 -1
- package/lib/conditions/principal-context.d.ts +55 -0
- package/lib/conditions/principal-context.d.ts.map +1 -0
- package/lib/conditions/principal-context.js +81 -0
- package/lib/conditions/principal-context.js.map +1 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -1
- package/lib/index.js.map +1 -1
- package/lib/prompts/extract-vars.d.ts +44 -12
- package/lib/prompts/extract-vars.d.ts.map +1 -1
- package/lib/prompts/extract-vars.js +165 -75
- package/lib/prompts/extract-vars.js.map +1 -1
- package/lib/prompts/render.d.ts +7 -9
- package/lib/prompts/render.d.ts.map +1 -1
- package/lib/prompts/render.js +12 -15
- package/lib/prompts/render.js.map +1 -1
- package/lib/prompts/validate.d.ts +8 -2
- package/lib/prompts/validate.d.ts.map +1 -1
- package/lib/prompts/validate.js +87 -50
- package/lib/prompts/validate.js.map +1 -1
- package/lib/roles/system.d.ts.map +1 -1
- package/lib/roles/system.js +15 -0
- package/lib/roles/system.js.map +1 -1
- package/lib/vertesia-studio-utils.js +2 -1
- package/lib/vertesia-studio-utils.js.map +1 -1
- package/package.json +6 -5
- package/src/conditions/index.ts +6 -0
- package/src/conditions/principal-context.ts +122 -0
- package/src/index.ts +20 -1
- package/src/prompts/extract-vars.ts +212 -68
- package/src/prompts/render.test.ts +13 -2
- package/src/prompts/render.ts +24 -15
- package/src/prompts/validate.test.ts +115 -0
- package/src/prompts/validate.ts +113 -56
- package/src/roles/index.test.ts +9 -9
- package/src/roles/system.ts +16 -0
|
@@ -1,110 +1,254 @@
|
|
|
1
|
+
import { HANDLEBARS_CONTEXT_HELPERS, isHandlebarsHelper, isTemplateSystemVariable } from '@vertesia/jst';
|
|
1
2
|
import Handlebars from 'handlebars';
|
|
2
3
|
|
|
4
|
+
/** A read of a root-level template variable. */
|
|
5
|
+
export interface HandlebarsVariableReference {
|
|
6
|
+
/** Root identifier: `customer` for `{{customer.name}}`, `{{../customer}}` or `{{@root.customer}}`. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** The path as written, e.g. `customer.name`. */
|
|
9
|
+
expression: string;
|
|
10
|
+
/** True when the path reads a property of the variable (`customer.name`). */
|
|
11
|
+
hasPath: boolean;
|
|
12
|
+
/** Source of the tag that contains the read, e.g. `{{#if customer}}`. */
|
|
13
|
+
tag: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A helper name used where Handlebars will not call it. */
|
|
17
|
+
export interface HandlebarsHelperMisuse {
|
|
18
|
+
helper: string;
|
|
19
|
+
/**
|
|
20
|
+
* - `used_as_value`: passed as an argument (`{{#if stringify}}`), where Handlebars reads the data.
|
|
21
|
+
* - `missing_arguments`: called bare (`{{stringify}}`) with none of the arguments it needs.
|
|
22
|
+
*/
|
|
23
|
+
problem: 'used_as_value' | 'missing_arguments';
|
|
24
|
+
tag: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HandlebarsTemplateAnalysis {
|
|
28
|
+
references: HandlebarsVariableReference[];
|
|
29
|
+
helperMisuses: HandlebarsHelperMisuse[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PathNode {
|
|
33
|
+
type: 'PathExpression';
|
|
34
|
+
original: string;
|
|
35
|
+
parts: string[];
|
|
36
|
+
depth: number;
|
|
37
|
+
data: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SourceLocation {
|
|
41
|
+
start: { line: number; column: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Node = { type: string; loc?: SourceLocation } & Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
/** Context a template section renders with: the input root, or an item whose shape is unknown. */
|
|
47
|
+
type ContextKind = 'root' | 'item';
|
|
48
|
+
|
|
49
|
+
function isPath(node: unknown): node is PathNode {
|
|
50
|
+
return !!node && typeof node === 'object' && (node as Node).type === 'PathExpression';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `this.x`, `./x` and `this` read the context explicitly — Handlebars never treats them as helpers. */
|
|
54
|
+
function isScoped(path: PathNode): boolean {
|
|
55
|
+
return /^\.|this\b/.test(path.original);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A single bare identifier — the only form Handlebars may resolve to a helper. */
|
|
59
|
+
function isSimpleId(path: PathNode): boolean {
|
|
60
|
+
return path.parts.length === 1 && !isScoped(path) && !path.data && path.depth === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lineOffsets(template: string): number[] {
|
|
64
|
+
const offsets = [0];
|
|
65
|
+
for (let i = 0; i < template.length; i++) {
|
|
66
|
+
if (template[i] === '\n') offsets.push(i + 1);
|
|
67
|
+
}
|
|
68
|
+
return offsets;
|
|
69
|
+
}
|
|
70
|
+
|
|
3
71
|
/**
|
|
4
|
-
*
|
|
72
|
+
* Analyze the variables and helpers a Handlebars template uses, resolving names the way the
|
|
73
|
+
* prompt renderer does (helpers and system variables come from `@vertesia/jst`).
|
|
5
74
|
*
|
|
6
|
-
*
|
|
7
|
-
* - `{{
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - `
|
|
11
|
-
* - `{{@index}}
|
|
12
|
-
*
|
|
13
|
-
* Helper names in mustaches and block heads (`{{customHelper x}}`, `{{#if x}}`) are NOT added to
|
|
14
|
-
* the set — only the arguments and inner template references are. This matches the typical use
|
|
15
|
-
* case for prompt validation: detect which schema properties the template actually reads.
|
|
75
|
+
* Scoping rules:
|
|
76
|
+
* - Inside `{{#each}}` / `{{#with}}` (and `{{#section}}` over data), bare names read the current item,
|
|
77
|
+
* whose shape the input schema does not describe — they are not reported. `../x` and `@root.x`
|
|
78
|
+
* read the enclosing or root context and are reported.
|
|
79
|
+
* - `as |x|` block params are local bindings, not variables.
|
|
80
|
+
* - `{{@index}}` and other `@`-data (except `@root.x`) are runtime values, not variables.
|
|
16
81
|
*
|
|
17
|
-
* Returns
|
|
18
|
-
* (e.g. `executeHandlebars`) to surface parse errors separately.
|
|
82
|
+
* Returns null when the template does not parse; the render check reports the syntax error.
|
|
19
83
|
*/
|
|
20
|
-
export function
|
|
84
|
+
export function analyzeHandlebarsTemplate(template: string): HandlebarsTemplateAnalysis | null {
|
|
21
85
|
let ast: hbs.AST.Program;
|
|
22
86
|
try {
|
|
23
87
|
ast = Handlebars.parse(template);
|
|
24
88
|
} catch {
|
|
25
|
-
return
|
|
89
|
+
return null;
|
|
26
90
|
}
|
|
27
91
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
92
|
+
const offsets = lineOffsets(template);
|
|
93
|
+
const tagAt = (loc: SourceLocation | undefined): string => {
|
|
94
|
+
if (!loc) return '';
|
|
95
|
+
const start = (offsets[loc.start.line - 1] ?? 0) + loc.start.column;
|
|
96
|
+
const end = template.indexOf('}}', start);
|
|
97
|
+
if (end < 0) return template.slice(start);
|
|
98
|
+
return template.slice(start, template[end + 2] === '}' ? end + 3 : end + 2);
|
|
99
|
+
};
|
|
30
100
|
|
|
31
|
-
const
|
|
101
|
+
const references: HandlebarsVariableReference[] = [];
|
|
102
|
+
const helperMisuses: HandlebarsHelperMisuse[] = [];
|
|
103
|
+
const contexts: ContextKind[] = ['root'];
|
|
104
|
+
const blockParams: Array<Set<string>> = [];
|
|
105
|
+
let tag = '';
|
|
32
106
|
|
|
33
|
-
const
|
|
34
|
-
if (!node || typeof node !== 'object') return;
|
|
35
|
-
const n = node as { type: string } & Record<string, unknown>;
|
|
107
|
+
const isBlockParam = (name: string): boolean => blockParams.some((scope) => scope.has(name));
|
|
36
108
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
109
|
+
/** Record a data read of `path`, if it reads the root input. */
|
|
110
|
+
const readData = (path: PathNode): void => {
|
|
111
|
+
if (path.data) {
|
|
112
|
+
// `@root.x` reads the input root; `@index`, `@key`, ... are runtime values.
|
|
113
|
+
if (path.parts[0] === 'root' && path.parts.length > 1) {
|
|
114
|
+
references.push({
|
|
115
|
+
name: path.parts[1],
|
|
116
|
+
expression: path.original,
|
|
117
|
+
hasPath: path.parts.length > 2,
|
|
118
|
+
tag,
|
|
119
|
+
});
|
|
42
120
|
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (path.parts.length === 0) return; // `this` / `.`
|
|
124
|
+
const root = path.parts[0];
|
|
125
|
+
if (path.depth === 0 && !isScoped(path) && isBlockParam(root)) return;
|
|
126
|
+
// Handlebars stops at the root context when `../` goes past it.
|
|
127
|
+
const context = contexts[Math.max(0, contexts.length - 1 - path.depth)];
|
|
128
|
+
if (context !== 'root') return;
|
|
129
|
+
references.push({ name: root, expression: path.original, hasPath: path.parts.length > 1, tag });
|
|
130
|
+
};
|
|
43
131
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
132
|
+
/** A path in argument position (params, hash values): Handlebars reads data, never calls a helper. */
|
|
133
|
+
const visitArgument = (node: unknown): void => {
|
|
134
|
+
if (isPath(node)) {
|
|
135
|
+
const name = node.parts[0];
|
|
136
|
+
// System variables are injected as data too, so they resolve as arguments.
|
|
137
|
+
if (
|
|
138
|
+
isSimpleId(node) &&
|
|
139
|
+
!isBlockParam(name) &&
|
|
140
|
+
isHandlebarsHelper(name) &&
|
|
141
|
+
!isTemplateSystemVariable(name)
|
|
142
|
+
) {
|
|
143
|
+
helperMisuses.push({ helper: name, problem: 'used_as_value', tag });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
readData(node);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
visit(node);
|
|
150
|
+
};
|
|
50
151
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
152
|
+
const visitCall = (n: Node): void => {
|
|
153
|
+
const params = (n.params as unknown[] | undefined) ?? [];
|
|
154
|
+
for (const p of params) visitArgument(p);
|
|
155
|
+
const pairs = (n.hash as { pairs?: Array<{ value: unknown }> } | undefined)?.pairs ?? [];
|
|
156
|
+
for (const pair of pairs) visitArgument(pair.value);
|
|
157
|
+
};
|
|
56
158
|
|
|
57
|
-
|
|
58
|
-
|
|
159
|
+
const hasArguments = (n: Node): boolean =>
|
|
160
|
+
((n.params as unknown[] | undefined)?.length ?? 0) > 0 ||
|
|
161
|
+
((n.hash as { pairs?: unknown[] } | undefined)?.pairs?.length ?? 0) > 0;
|
|
59
162
|
|
|
60
|
-
|
|
163
|
+
const visit = (node: unknown): void => {
|
|
164
|
+
if (!node || typeof node !== 'object') return;
|
|
165
|
+
const n = node as Node;
|
|
166
|
+
|
|
167
|
+
switch (n.type) {
|
|
168
|
+
case 'Program': {
|
|
169
|
+
for (const stmt of (n.body as unknown[] | undefined) ?? []) visit(stmt);
|
|
61
170
|
break;
|
|
62
171
|
}
|
|
63
172
|
|
|
64
173
|
case 'MustacheStatement': {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
174
|
+
tag = tagAt(n.loc);
|
|
175
|
+
const path = n.path;
|
|
176
|
+
if (hasArguments(n)) {
|
|
177
|
+
// `{{helper arg}}`: the head is a helper name (a missing one fails the render check).
|
|
178
|
+
visitCall(n);
|
|
179
|
+
} else if (isPath(path)) {
|
|
180
|
+
// A bare `{{name}}` calls a helper of that name when there is one, else reads data.
|
|
181
|
+
// System variables are helpers returning their injected value, meant to be used bare.
|
|
182
|
+
const name = path.parts[0];
|
|
183
|
+
if (isSimpleId(path) && !isBlockParam(name) && isHandlebarsHelper(name)) {
|
|
184
|
+
if (!isTemplateSystemVariable(name)) {
|
|
185
|
+
helperMisuses.push({ helper: name, problem: 'missing_arguments', tag });
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
readData(path);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
visit(path);
|
|
72
192
|
}
|
|
73
|
-
if (params) for (const p of params) visit(p);
|
|
74
|
-
visit(hash);
|
|
75
193
|
break;
|
|
76
194
|
}
|
|
77
195
|
|
|
78
|
-
case '
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
196
|
+
case 'BlockStatement': {
|
|
197
|
+
tag = tagAt(n.loc);
|
|
198
|
+
const path = n.path;
|
|
199
|
+
const helper = isPath(path) && isSimpleId(path) ? path.parts[0] : undefined;
|
|
200
|
+
let itemContext: boolean;
|
|
201
|
+
if (hasArguments(n) || (helper && isHandlebarsHelper(helper))) {
|
|
202
|
+
visitCall(n);
|
|
203
|
+
itemContext = !!helper && HANDLEBARS_CONTEXT_HELPERS.includes(helper);
|
|
204
|
+
} else {
|
|
205
|
+
// `{{#section}}…{{/section}}` over data: iterates or enters `section`.
|
|
206
|
+
if (isPath(path)) readData(path);
|
|
207
|
+
itemContext = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const program = n.program as { blockParams?: string[] } | undefined;
|
|
211
|
+
const inverse = n.inverse as { blockParams?: string[] } | undefined;
|
|
212
|
+
blockParams.push(new Set(program?.blockParams ?? []));
|
|
213
|
+
contexts.push(itemContext ? 'item' : contexts[contexts.length - 1]);
|
|
214
|
+
visit(n.program);
|
|
215
|
+
contexts.pop();
|
|
216
|
+
blockParams.pop();
|
|
85
217
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const root = parts[0];
|
|
91
|
-
if (isLocal(root)) break;
|
|
92
|
-
variables.add(root);
|
|
218
|
+
// `{{else}}` renders with the enclosing context.
|
|
219
|
+
blockParams.push(new Set(inverse?.blockParams ?? []));
|
|
220
|
+
visit(n.inverse);
|
|
221
|
+
blockParams.pop();
|
|
93
222
|
break;
|
|
94
223
|
}
|
|
95
224
|
|
|
96
|
-
case '
|
|
97
|
-
|
|
98
|
-
|
|
225
|
+
case 'SubExpression': {
|
|
226
|
+
// `(helper arg)`: the head is a call, like a bare `{{helper}}`.
|
|
227
|
+
visitCall(n);
|
|
99
228
|
break;
|
|
100
229
|
}
|
|
101
230
|
|
|
102
|
-
// ContentStatement, CommentStatement,
|
|
231
|
+
// ContentStatement, CommentStatement, literals, partials — no variables.
|
|
103
232
|
default:
|
|
104
233
|
break;
|
|
105
234
|
}
|
|
106
235
|
};
|
|
107
236
|
|
|
108
237
|
visit(ast);
|
|
109
|
-
return
|
|
238
|
+
return { references, helperMisuses };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extract the set of root-level input variable names a Handlebars template reads.
|
|
243
|
+
*
|
|
244
|
+
* - `{{foo}}` → `foo`; `{{obj.bar.baz}}` → `obj`
|
|
245
|
+
* - `{{#each items as |item|}}{{item.name}}{{/each}}` → `items` (`item` is a block param)
|
|
246
|
+
* - `{{lookup obj key}}` → `obj`, `key` (helper name skipped)
|
|
247
|
+
* - `{{@index}}`, `{{this}}`, and bare names inside `#each`/`#with` → ignored
|
|
248
|
+
*
|
|
249
|
+
* Returns an empty set on parse failure.
|
|
250
|
+
*/
|
|
251
|
+
export function extractHandlebarsVariables(template: string): Set<string> {
|
|
252
|
+
const analysis = analyzeHandlebarsTemplate(template);
|
|
253
|
+
return new Set(analysis?.references.map((r) => r.name) ?? []);
|
|
110
254
|
}
|
|
@@ -25,6 +25,16 @@ describe('renderTemplate', () => {
|
|
|
25
25
|
renderTemplate('return `Hello ${name}`', TemplateType.jst, { properties: { name: {} } }, { name: 'Ada' }),
|
|
26
26
|
).toEqual('Hello Ada');
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
it('supplies system variables to both template languages', () => {
|
|
30
|
+
const system = { model: 'test-model', now: new Date('2026-01-02T03:04:05.000Z') };
|
|
31
|
+
expect(renderTemplate('{{#if _now}}{{_now}}{{/if}} {{_model}}', TemplateType.handlebars, {}, {}, system)).toBe(
|
|
32
|
+
'2026-01-02T03:04:05.000Z test-model',
|
|
33
|
+
);
|
|
34
|
+
expect(renderTemplate('return `${_now} ${_model}`', TemplateType.jst, {}, {}, system)).toBe(
|
|
35
|
+
'2026-01-02T03:04:05.000Z test-model',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
28
38
|
});
|
|
29
39
|
|
|
30
40
|
describe('renderSegments', () => {
|
|
@@ -97,13 +107,14 @@ function createPromptTemplate(overrides: Partial<PromptTemplate>): PromptTemplat
|
|
|
97
107
|
role: PromptRole.user,
|
|
98
108
|
status: PromptStatus.draft,
|
|
99
109
|
version: 1,
|
|
110
|
+
edit_revision: 1,
|
|
100
111
|
content: '',
|
|
101
112
|
content_type: TemplateType.jst,
|
|
102
113
|
project: 'project-1',
|
|
103
114
|
created_by: 'user-1',
|
|
104
115
|
updated_by: 'user-1',
|
|
105
|
-
created_at:
|
|
106
|
-
updated_at:
|
|
116
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
117
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
107
118
|
...overrides,
|
|
108
119
|
};
|
|
109
120
|
}
|
package/src/prompts/render.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { JSONObject, JSONSchema, PromptSegment } from '@llumiverse/common';
|
|
2
2
|
import { type PromptSegmentDef, PromptSegmentDefType, type PromptTemplate, TemplateType } from '@vertesia/common';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CompositeError,
|
|
5
|
+
renderHandlebarsTemplate,
|
|
6
|
+
renderJsTemplate,
|
|
7
|
+
type TemplateSystemContext,
|
|
8
|
+
withTemplateSystemVariables,
|
|
9
|
+
} from '@vertesia/jst';
|
|
4
10
|
|
|
5
11
|
export interface SegmentPreview {
|
|
6
12
|
error?: Error;
|
|
@@ -12,29 +18,32 @@ export interface SegmentPreview {
|
|
|
12
18
|
/**
|
|
13
19
|
* Render a prompt template with the given input data.
|
|
14
20
|
*
|
|
15
|
-
* Handlebars templates use {{variable}} interpolation against `data`.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* studio-server executor
|
|
19
|
-
*
|
|
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.
|
|
21
|
+
* Handlebars templates use {{variable}} interpolation against `data`. JST (JavaScript template)
|
|
22
|
+
* bodies evaluate against `data` with the schema's top-level property names exposed as globals.
|
|
23
|
+
* Both see the system variables (`TEMPLATE_SYSTEM_VARIABLES` in `@vertesia/jst`), added here the
|
|
24
|
+
* same way the studio-server executor adds them, so a preview renders what an execution would.
|
|
25
|
+
* `_model` is only set when the caller passes `system.model` or the data already carries it.
|
|
23
26
|
*
|
|
24
27
|
* For `TemplateType.text`, the content is returned verbatim — it is static text, not a
|
|
25
28
|
* template — matching the studio-server executor (see `apps/studio-server/src/executor/
|
|
26
29
|
* rendering/template.ts`). Routing it through the JST evaluator would compile the prose as
|
|
27
30
|
* JavaScript and throw on any plain sentence (e.g. "You are a helpful assistant.").
|
|
28
31
|
*/
|
|
29
|
-
export function renderTemplate(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
export function renderTemplate(
|
|
33
|
+
code: string,
|
|
34
|
+
contentType: TemplateType,
|
|
35
|
+
schema: JSONSchema,
|
|
36
|
+
data: JSONObject,
|
|
37
|
+
system?: TemplateSystemContext,
|
|
38
|
+
): string {
|
|
33
39
|
if (contentType === TemplateType.text) {
|
|
34
40
|
return code;
|
|
35
41
|
}
|
|
36
|
-
const
|
|
37
|
-
|
|
42
|
+
const input = withTemplateSystemVariables(data, system);
|
|
43
|
+
if (contentType === TemplateType.handlebars) {
|
|
44
|
+
return renderHandlebarsTemplate(code, input);
|
|
45
|
+
}
|
|
46
|
+
return renderJsTemplate(code, schema.properties ? Object.keys(schema.properties) : [], input);
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/**
|
|
@@ -284,3 +284,118 @@ describe('validatePrompt — jst', () => {
|
|
|
284
284
|
expect(findIssue(r.issues, 'jst_unsafe_construct')).toBeDefined();
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
describe('system variables', () => {
|
|
289
|
+
const errors = (
|
|
290
|
+
content: string,
|
|
291
|
+
contentType = TemplateType.handlebars,
|
|
292
|
+
properties: Record<string, JSONSchema> = {},
|
|
293
|
+
) =>
|
|
294
|
+
validatePrompt({ content, contentType, inputSchema: schema(properties) }).issues.filter(
|
|
295
|
+
(i) => i.severity === 'error',
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Every position a variable can appear in. `{{#if _now}}` is the reported bug: it failed
|
|
299
|
+
// because `_now` was only a helper, which Handlebars never calls in argument position.
|
|
300
|
+
it.each([
|
|
301
|
+
'{{_now}}',
|
|
302
|
+
'{{{_now}}}',
|
|
303
|
+
'{{#if _now}}{{_now}}{{/if}}',
|
|
304
|
+
'{{#unless _model}}none{{/unless}}',
|
|
305
|
+
'{{stringify _now}}',
|
|
306
|
+
'{{stringify (_now)}}',
|
|
307
|
+
'{{stringify (_model)}}',
|
|
308
|
+
'{{this._now}}',
|
|
309
|
+
'{{@root._model}}',
|
|
310
|
+
])('accepts %s without a declaration', (content) => {
|
|
311
|
+
expect(errors(content)).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('accepts system variables inside #each and #with', () => {
|
|
315
|
+
const content = '{{#each items}}{{../_now}} {{@root._model}}{{/each}}{{#with obj}}{{_now}}{{/with}}';
|
|
316
|
+
expect(errors(content, TemplateType.handlebars, { items: { type: 'array' }, obj: { type: 'object' } })).toEqual(
|
|
317
|
+
[],
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it.each(['return _now;', 'return `${_model}`;'])('accepts JST %s without a declaration', (content) => {
|
|
322
|
+
expect(errors(content, TemplateType.jst)).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it.each(['{{_now.foo}}', '{{#if _model.name}}x{{/if}}'])('rejects property access on a string: %s', (content) => {
|
|
326
|
+
const result = validatePrompt({ content, contentType: TemplateType.handlebars, inputSchema: schema({}) });
|
|
327
|
+
expect(findIssue(result.issues, 'system_variable_property_access')).toBeDefined();
|
|
328
|
+
expect(findIssue(result.issues, 'undeclared_template_variable')).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('warns when the schema declares a system variable', () => {
|
|
332
|
+
const result = validatePrompt({
|
|
333
|
+
content: '{{_now}}',
|
|
334
|
+
contentType: TemplateType.handlebars,
|
|
335
|
+
inputSchema: schema({ _now: { type: 'string' } }),
|
|
336
|
+
});
|
|
337
|
+
expect(result.error_count).toBe(0);
|
|
338
|
+
expect(findIssue(result.issues, 'reserved_variable_declared', '_now')).toBeDefined();
|
|
339
|
+
expect(findIssue(result.issues, 'unused_schema_variable')).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('still rejects ordinary undeclared variables, and lists the system variables in the message', () => {
|
|
343
|
+
const [issue] = errors('{{#if customer}}x{{/if}}');
|
|
344
|
+
expect(issue.type).toBe('undeclared_template_variable');
|
|
345
|
+
expect(issue.variable).toBe('customer');
|
|
346
|
+
expect(issue.message).toContain('in {{#if customer}}');
|
|
347
|
+
expect(issue.message).toContain('_now');
|
|
348
|
+
expect(issue.message).toContain('_model');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('handlebars scopes and helpers', () => {
|
|
353
|
+
it.each([
|
|
354
|
+
['{{#with obj}}{{k}}{{/with}}', { obj: { type: 'object' } }],
|
|
355
|
+
['{{#each items}}{{name}}{{/each}}', { items: { type: 'array' } }],
|
|
356
|
+
['{{#items}}{{name}}{{/items}}', { items: { type: 'array' } }],
|
|
357
|
+
['{{#each items as |it|}}{{it.name}}{{/each}}', { items: { type: 'array' } }],
|
|
358
|
+
] as const)('does not report item fields inside %s', (content, properties) => {
|
|
359
|
+
const result = validatePrompt({
|
|
360
|
+
content,
|
|
361
|
+
contentType: TemplateType.handlebars,
|
|
362
|
+
inputSchema: schema(properties),
|
|
363
|
+
});
|
|
364
|
+
expect(result.error_count).toBe(0);
|
|
365
|
+
expect(result.warning_count).toBe(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it.each([
|
|
369
|
+
['{{#each items}}{{../customer}}{{/each}}', 'customer'],
|
|
370
|
+
['{{#with obj}}{{@root.customer}}{{/with}}', 'customer'],
|
|
371
|
+
['{{#each items}}x{{else}}{{customer}}{{/each}}', 'customer'],
|
|
372
|
+
])('reports root reads from inside a block: %s', (content, variable) => {
|
|
373
|
+
const result = validatePrompt({
|
|
374
|
+
content,
|
|
375
|
+
contentType: TemplateType.handlebars,
|
|
376
|
+
inputSchema: schema({ items: { type: 'array' }, obj: { type: 'object' } }),
|
|
377
|
+
});
|
|
378
|
+
expect(findIssue(result.issues, 'undeclared_template_variable', variable)).toBeDefined();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('counts @root reads as usage', () => {
|
|
382
|
+
const result = validatePrompt({
|
|
383
|
+
content: '{{#with obj}}{{@root.customer}}{{/with}}',
|
|
384
|
+
contentType: TemplateType.handlebars,
|
|
385
|
+
inputSchema: schema({ obj: { type: 'object' }, customer: { type: 'string' } }),
|
|
386
|
+
});
|
|
387
|
+
expect(result.issues).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('rejects a helper passed as an argument', () => {
|
|
391
|
+
const result = validatePrompt({ content: '{{#if stringify}}x{{/if}}', contentType: TemplateType.handlebars });
|
|
392
|
+
expect(findIssue(result.issues, 'helper_used_as_value', 'stringify')).toBeDefined();
|
|
393
|
+
expect(findIssue(result.issues, 'undeclared_template_variable')).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rejects a helper called without arguments', () => {
|
|
397
|
+
const result = validatePrompt({ content: '{{stringify}}', contentType: TemplateType.handlebars });
|
|
398
|
+
expect(findIssue(result.issues, 'helper_missing_arguments', 'stringify')).toBeDefined();
|
|
399
|
+
expect(findIssue(result.issues, 'undeclared_template_variable')).toBeUndefined();
|
|
400
|
+
});
|
|
401
|
+
});
|