// Template-side drift check (FR-004 Plan #3, T5). The other half of the
// guarantee: Phase B types the payload at the CALL SITE (compile-time), while
// `verify` parses the opaque template TEXT and cross-checks every variable
// against the payload's declared field tree (build-time). A Mustache template
// is a runtime string the TS compiler can't see into, so this is the only way
// to catch "a renamed field silently broke a prompt".
//
// Zero core dependency by design: `verify` takes a PLAIN field tree (no
// metadata import). The CLI derives that tree from the loaded object.value and
// passes it in — keeping this engine a standalone, byte-portable module.
import Mustache from "mustache";
import type { Provider } from "./provider.js";
/** A `{{var}}` references a field the (contextual) payload does not declare. */
export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
/** A `{{> ref}}` partial does not resolve in the provider. */
export const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED";
/** A declared @requiredSlots slot is never referenced by the template (warning). */
export const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED";
/** A declared @requiredTags output tag is absent from the template text. */
export const ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING";
/**
* A plain field-tree node mirroring an `object.value` view-object's field walk.
* `fields` present → a context-pushing field (object / array-of-object); absent
* → a scalar (string/number/boolean/scalar-array).
*/
export interface PayloadField {
name: string;
fields?: PayloadField[];
}
export interface VerifyError {
code: string;
/** The offending variable path, partial ref, or slot name. */
path: string;
}
export interface VerifyOptions {
/** When given, `{{> ref}}` partials are resolved + their bodies recursed. */
provider?: Provider;
/** Slots that MUST be referenced; an unused one is reported as a warning. */
requiredSlots?: string[];
/**
* Output tags the rendered text is contracted to contain. Each must appear as
* both an opening form (`` or whitespace, so attributes are
* allowed) and a closing `` — across the body AND resolved partials.
*/
requiredTags?: string[];
}
const MAX_DEPTH = 32;
// A Mustache parse token: [type, value, start, end, subTokens?, ...].
type Token = readonly unknown[];
/**
* The context stack — innermost context last, mirroring Mustache lookup order.
* Generic over the field node so consumers (e.g. the docs annotator) can resolve
* an ENRICHED field tree (carrying owner/type metadata) through the EXACT same
* walk verify uses, guaranteeing the two surfaces agree.
*/
export type ResolveStack = readonly F[][];
function find(fields: F[], name: string): F | undefined {
return fields.find((f) => f.name === name);
}
/**
* Resolve a (possibly dotted) variable path the way Mustache does: the FIRST
* segment is looked up through the context stack (innermost → outermost); each
* remaining segment is a direct descent into the resolved field's `fields`.
* Returns the resolved field, or undefined if any segment is missing.
*
* EXPORTED so the docs annotator can share this ONE resolution (annotator ⇆
* verify must agree). Generic over the node type: an enriched tree resolves the
* same way, since only `name`/`fields` drive the walk.
*/
export function resolveTemplateVariable(
stack: ResolveStack,
path: string,
): F | undefined {
const segs = path.split(".");
let current: F | undefined;
for (let i = stack.length - 1; i >= 0; i--) {
const hit = find(stack[i]!, segs[0]!);
if (hit) {
current = hit;
break;
}
}
for (let i = 1; current && i < segs.length; i++) {
current = current.fields ? (find(current.fields, segs[i]!) as F | undefined) : undefined;
}
return current;
}
// Internal alias preserving the original call sites unchanged.
const resolve = resolveTemplateVariable;
function parse(text: string): Token[] {
return Mustache.parse(text) as unknown as Token[];
}
/**
* Parse a template into Mustache tokens (`[type, value, start, end, subTokens?]`),
* the SAME parse verify walks. Exported so the docs annotator tokenizes through
* one parser (no divergent re-tokenization). Returns a readonly token list.
*/
export function parseTemplate(text: string): readonly (readonly unknown[])[] {
return parse(text);
}
// An opening tag is `` or XML whitespace, so
// attributes are allowed (``) but a longer name is not over-matched
// (`` does not satisfy `answer`).
const TAG_OPEN_DELIMS = new Set([">", " ", "\t", "\n", "\r"]);
function hasOpenTag(text: string, tag: string): boolean {
const needle = `<${tag}`;
for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + 1)) {
const next = text[i + needle.length];
if (next !== undefined && TAG_OPEN_DELIMS.has(next)) return true;
}
return false;
}
// A closing tag is the exact literal ``. A self-closing `` has no such
// form, so it never satisfies a required tag — these wrap content a parser reads.
function hasCloseTag(text: string, tag: string): boolean {
return text.includes(`${tag}>`);
}
/**
* Walk a Mustache template's tokens against a payload field tree, returning a
* list of drift errors. Context-sensitive: a section `{{#posts}}…{{/posts}}`
* over a container field checks its body against that field's element type.
*/
export function verify(
templateText: string,
fields: PayloadField[],
opts?: VerifyOptions,
): VerifyError[] {
const errors: VerifyError[] = [];
const provider = opts?.provider;
const root = fields;
const referencedAtRoot = new Set();
// The static text the output-tag check scans: the body plus every
// provider-resolved partial body, collected during the single walk below
// (no second resolution pass).
const staticTexts: string[] = [templateText];
function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[]): void {
const atRoot = stack.length === 1 && stack[0] === root;
for (const tok of tokens) {
const type = tok[0] as string;
const value = tok[1] as string;
switch (type) {
case "name": // {{x}}
case "&": // {{&x}}
case "{": {
// {{{x}}} (spec); mustache.js emits "&" for it too
if (value === ".") break; // implicit iterator — always valid
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
if (!resolve(stack, value)) errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
break;
}
case "#": // {{#x}}…{{/x}}
case "^": {
// {{^x}}…{{/x}}
const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : [];
if (value === ".") {
walk(sub, stack, seen);
break;
}
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
const field = resolve(stack, value);
if (!field) {
// Unresolved section head is itself drift; skip the body (its
// context is unknowable, walking it would cascade false errors).
errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
break;
}
// `#` over a container pushes its element fields; `^` (and `#` over a
// scalar, used as a conditional) keep the current context.
const push = type === "#" && field.fields !== undefined;
walk(sub, push ? [...stack, field.fields!] : stack, seen);
break;
}
case ">": {
// {{> group/source}}
if (!provider) break; // can't resolve without a provider
if (seen.includes(value) || seen.length >= MAX_DEPTH) break; // cycle/depth guard
const text = provider.resolve(value);
if (text === undefined) {
errors.push({ code: ERR_PARTIAL_UNRESOLVED, path: value });
break;
}
staticTexts.push(text);
walk(parse(text), stack, [...seen, value]);
break;
}
default:
break; // text / comment / set-delimiter
}
}
}
walk(parse(templateText), [root], []);
for (const slot of opts?.requiredSlots ?? []) {
if (!referencedAtRoot.has(slot)) errors.push({ code: ERR_REQUIRED_SLOT_UNUSED, path: slot });
}
const requiredTags = opts?.requiredTags ?? [];
if (requiredTags.length > 0) {
// Scan body + resolved partials as one joined string: the open and close
// forms are located independently, so a tag may legitimately straddle the
// boundary. The "\n" separator only blocks a spurious tag spliced together
// from two fragments (a real tag is always contiguous within one body).
const haystack = staticTexts.join("\n");
for (const tag of requiredTags) {
if (!hasOpenTag(haystack, tag) || !hasCloseTag(haystack, tag)) {
errors.push({ code: ERR_OUTPUT_TAG_MISSING, path: tag });
}
}
}
return errors;
}