@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.
Files changed (40) hide show
  1. package/lib/conditions/index.d.ts +1 -0
  2. package/lib/conditions/index.d.ts.map +1 -1
  3. package/lib/conditions/index.js +1 -0
  4. package/lib/conditions/index.js.map +1 -1
  5. package/lib/conditions/principal-context.d.ts +55 -0
  6. package/lib/conditions/principal-context.d.ts.map +1 -0
  7. package/lib/conditions/principal-context.js +81 -0
  8. package/lib/conditions/principal-context.js.map +1 -0
  9. package/lib/index.d.ts +2 -1
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +4 -1
  12. package/lib/index.js.map +1 -1
  13. package/lib/prompts/extract-vars.d.ts +44 -12
  14. package/lib/prompts/extract-vars.d.ts.map +1 -1
  15. package/lib/prompts/extract-vars.js +165 -75
  16. package/lib/prompts/extract-vars.js.map +1 -1
  17. package/lib/prompts/render.d.ts +7 -9
  18. package/lib/prompts/render.d.ts.map +1 -1
  19. package/lib/prompts/render.js +12 -15
  20. package/lib/prompts/render.js.map +1 -1
  21. package/lib/prompts/validate.d.ts +8 -2
  22. package/lib/prompts/validate.d.ts.map +1 -1
  23. package/lib/prompts/validate.js +87 -50
  24. package/lib/prompts/validate.js.map +1 -1
  25. package/lib/roles/system.d.ts.map +1 -1
  26. package/lib/roles/system.js +15 -0
  27. package/lib/roles/system.js.map +1 -1
  28. package/lib/vertesia-studio-utils.js +2 -1
  29. package/lib/vertesia-studio-utils.js.map +1 -1
  30. package/package.json +6 -5
  31. package/src/conditions/index.ts +6 -0
  32. package/src/conditions/principal-context.ts +122 -0
  33. package/src/index.ts +20 -1
  34. package/src/prompts/extract-vars.ts +212 -68
  35. package/src/prompts/render.test.ts +13 -2
  36. package/src/prompts/render.ts +24 -15
  37. package/src/prompts/validate.test.ts +115 -0
  38. package/src/prompts/validate.ts +113 -56
  39. package/src/roles/index.test.ts +9 -9
  40. 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
- * Extract the set of root-level variable names referenced by a Handlebars template.
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
- * - `{{foo}}` → `foo`
7
- * - `{{obj.bar.baz}}` → `obj` (only the root identifier; nested access doesn't add `bar` or `baz`)
8
- * - `{{#if cond}}{{name}}{{/if}}` → `cond`, `name`
9
- * - `{{#each items as |item|}}{{item.name}}{{/each}}` → `items` (NOT `item` — bound by `as |item|`)
10
- * - `{{lookup obj key}}` → `obj`, `key` (helper name skipped)
11
- * - `{{@index}}`, `{{this}}` → ignored (built-in data vars / self)
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 an empty set on parse failure — callers should also run a syntactic render check
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 extractHandlebarsVariables(template: string): Set<string> {
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 new Set();
89
+ return null;
26
90
  }
27
91
 
28
- const variables = new Set<string>();
29
- const localScopes: Array<Set<string>> = [];
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 isLocal = (name: string): boolean => localScopes.some((scope) => scope.has(name));
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 visit = (node: unknown): void => {
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
- switch (n.type) {
38
- case 'Program': {
39
- const body = n.body as unknown[] | undefined;
40
- if (body) for (const stmt of body) visit(stmt);
41
- break;
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
- case 'BlockStatement': {
45
- // n.path is the helper name (`#if`, `#each`, `#customBlock`) — never a variable.
46
- // Visit params + hash to capture variables passed to the block helper.
47
- const params = n.params as unknown[] | undefined;
48
- if (params) for (const p of params) visit(p);
49
- visit(n.hash);
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
- // Track `as |x y|` block params — these are local bindings, not variables.
52
- const program = n.program as { blockParams?: string[] } | undefined;
53
- const inverse = n.inverse as { blockParams?: string[] } | undefined;
54
- const scope = new Set<string>([...(program?.blockParams ?? []), ...(inverse?.blockParams ?? [])]);
55
- localScopes.push(scope);
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
- visit(n.program);
58
- visit(n.inverse);
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
- localScopes.pop();
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
- // Bare mustache (no params, no hash) → it's a variable reference.
66
- // With params/hash, it's a helper call — skip the path (helper name), visit args.
67
- const params = n.params as unknown[] | undefined;
68
- const hash = n.hash as { pairs?: unknown[] } | undefined;
69
- const isHelperCall = (params?.length ?? 0) > 0 || (hash?.pairs?.length ?? 0) > 0;
70
- if (!isHelperCall) {
71
- visit(n.path);
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 'SubExpression': {
79
- // (helper arg1 arg2) — helper name in path is skipped, args carry variables.
80
- const params = n.params as unknown[] | undefined;
81
- if (params) for (const p of params) visit(p);
82
- visit(n.hash);
83
- break;
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
- case 'PathExpression': {
87
- if (n.data) break; // `@-data` references (@index, @key, etc.)
88
- const parts = n.parts as string[] | undefined;
89
- if (!parts || parts.length === 0) break; // `this` / `.`
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 'Hash': {
97
- const pairs = n.pairs as Array<{ value: unknown }> | undefined;
98
- if (pairs) for (const pair of pairs) visit(pair.value);
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, *Literal, PartialStatement — no variables to extract.
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 variables;
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: new Date('2026-01-01T00:00:00.000Z'),
106
- updated_at: new Date('2026-01-01T00:00:00.000Z'),
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
  }
@@ -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 { CompositeError, renderHandlebarsTemplate, renderJsTemplate } from '@vertesia/jst';
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
- * 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.
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(code: string, contentType: TemplateType, schema: JSONSchema, data: JSONObject): string {
30
- if (contentType === TemplateType.handlebars) {
31
- return renderHandlebarsTemplate(code, data);
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 globals = [...(schema.properties ? Object.keys(schema.properties) : []), '_model'];
37
- return renderJsTemplate(code, globals, data);
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
+ });