From 5f5bbd8d148352707d20c68e37aa9b6076c6a189 Mon Sep 17 00:00:00 2001 From: like Date: Fri, 16 Oct 2026 18:10:21 +0800 Subject: [PATCH] feat: diagnose unresolved template state references --- apps/web/content/docs/api/core.mdx | 21 +++++++ packages/core/README.md | 13 +++++ packages/core/src/spec-validator.test.ts | 62 ++++++++++++++++++++ packages/core/src/spec-validator.ts | 72 +++++++++++++++++++++++- skills/core/SKILL.md | 13 +++++ 5 files changed, 180 insertions(+), 1 deletion(-) diff --git a/apps/web/content/docs/api/core.mdx b/apps/web/content/docs/api/core.mdx index e45068df..eeed39a2 100644 --- a/apps/web/content/docs/api/core.mdx +++ b/apps/web/content/docs/api/core.mdx @@ -697,6 +697,27 @@ const patches = diffToPatches(oldSpec, newSpec); // [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }] ``` + +## Spec Validation + +```typescript +import { validateSpec } from "@json-render/core"; + +const result = validateSpec(spec, { checkTemplateStateRefs: true }); +``` + +For hosts that require seeded or explicitly writable template state, opt in to +`validateSpec(spec, { checkTemplateStateRefs: true })`. Absolute interpolations +such as `${/ui/name}` must resolve in initial `state` or be covered by a declared +`$bindState` or literal action `statePath`. Otherwise validation returns +`unresolved_template_state_ref` with the offending expression's JSON Pointer in +`issue.path`, including references in inactive conditional branches and action +parameters. This can detect accidental JavaScript expressions inside `$template`; +it never evaluates or rewrites them. Literal state keys containing operator +characters remain valid when present. Bare repeat-item references are not checked. +The option defaults to false, preserving unresolved references' empty-string +rendering. Validation cannot detect content a model omitted from its response. + ## evaluateVisibility Evaluates a visibility condition against the state model. diff --git a/packages/core/README.md b/packages/core/README.md index 977fb331..10fe17c6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -611,6 +611,19 @@ const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec); `validateSpec` checks structure beyond the catalog schema: missing or dangling `children` and named `slots` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`). + +For hosts that require seeded or explicitly writable template state, opt in to +`validateSpec(spec, { checkTemplateStateRefs: true })`. Absolute interpolations +such as `${/ui/name}` must resolve in initial `state` or be covered by a declared +`$bindState` or literal action `statePath`. Otherwise validation returns +`unresolved_template_state_ref` with the offending expression's JSON Pointer in +`issue.path`, including references in inactive conditional branches and action +parameters. This can detect accidental JavaScript expressions inside `$template`; +it never evaluates or rewrites them. Literal state keys containing operator +characters remain valid when present. Bare repeat-item references are not checked. +The option defaults to false, preserving unresolved references' empty-string +rendering. Validation cannot detect content a model omitted from its response. + `autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` or named `slots` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted: ```typescript diff --git a/packages/core/src/spec-validator.test.ts b/packages/core/src/spec-validator.test.ts index 71853bfe..0ff800c0 100644 --- a/packages/core/src/spec-validator.test.ts +++ b/packages/core/src/spec-validator.test.ts @@ -766,3 +766,65 @@ describe("autoFixSpec", () => { expect(fixes).toHaveLength(0); }); }); + +describe("optional template state references", () => { + it("reports an unresolved conditional expression with its exact path", () => { + const spec: Spec = { + root: "selection", + state: { ui: { plan: "basic" } }, + elements: { + selection: { + type: "Text", + props: { + text: { + $cond: { $state: "/ui/plan" }, + $then: { + $template: + 'Selected: ${/ui/plan === "basic" ? "Basic" : "Pro"}', + }, + $else: "None", + }, + }, + }, + }, + }; + expect(validateSpec(spec).valid).toBe(true); + expect(validateSpec(spec, { checkTemplateStateRefs: true })).toMatchObject({ + valid: false, + issues: [ + { + code: "unresolved_template_state_ref", + path: "/elements/selection/props/text/$then/$template", + }, + ], + }); + }); + + it("preserves literal state keys, future writes, bindings and bare item names", () => { + const spec: Spec = { + root: "text", + state: { ui: { 'plan === "basic" ? "Basic" : "Pro"': "literal" } }, + elements: { + text: { + type: "Text", + props: { + text: { + $template: + '${/ui/plan === "basic" ? "Basic" : "Pro"} ${/ui/later} ${/form/name} ${item}', + }, + value: { $bindState: "/form/name" }, + }, + on: { + press: { + action: "setState", + params: { statePath: "/ui/later", value: "later" }, + }, + }, + }, + }, + }; + expect(validateSpec(spec, { checkTemplateStateRefs: true }).valid).toBe( + true, + ); + }); +}); diff --git a/packages/core/src/spec-validator.ts b/packages/core/src/spec-validator.ts index b9fa53ee..5828d26d 100644 --- a/packages/core/src/spec-validator.ts +++ b/packages/core/src/spec-validator.ts @@ -25,12 +25,15 @@ export interface SpecIssue { message: string; /** The element key where the issue was found (if applicable) */ elementKey?: string; + /** JSON Pointer to the offending value, when available. */ + path?: string; /** Machine-readable issue code for programmatic handling */ code: | "missing_root" | "root_not_found" | "missing_child" | "invalid_visible" + | "unresolved_template_state_ref" | "repeat_without_children" | "repeat_item_outside_scope" | "repeat_state_mismatch" @@ -61,6 +64,13 @@ export interface ValidateSpecOptions { * Defaults to false since orphans are harmless (just unused). */ checkOrphans?: boolean; + /** + * Check absolute $template references against initial state and statically + * declared binding/action write paths. Defaults to false: unresolved template + * references normally render as empty strings. Bare repeat-item names are + * not checked. + */ + checkTemplateStateRefs?: boolean; } /** @@ -85,7 +95,7 @@ export function validateSpec( spec: Spec, options: ValidateSpecOptions = {}, ): SpecValidationIssues { - const { checkOrphans = false } = options; + const { checkOrphans = false, checkTemplateStateRefs = false } = options; const issues: SpecIssue[] = []; // 1. Check root @@ -307,6 +317,66 @@ export function validateSpec( } } + if (checkTemplateStateRefs) { + const writablePaths = new Set(); + const collectWrites = (value: unknown): void => { + if (!value || typeof value !== "object") return; + const record = value as Record; + if (typeof record.$bindState === "string") + writablePaths.add(record.$bindState); + if ( + typeof record.action === "string" && + record.params && + typeof record.params === "object" + ) { + const statePath = (record.params as Record).statePath; + if (typeof statePath === "string") writablePaths.add(statePath); + } + for (const child of Object.values(record)) collectWrites(child); + }; + for (const element of Object.values(spec.elements)) collectWrites(element); + const escapePointer = (key: string) => + key.replaceAll("~", "~0").replaceAll("/", "~1"); + const checkTemplates = ( + value: unknown, + path: string, + elementKey: string, + ): void => { + if (!value || typeof value !== "object") return; + const record = value as Record; + if (typeof record.$template === "string") { + for (const match of record.$template.matchAll(/\$\{([^}]+)\}/g)) { + const reference = match[1]!; + if (!reference.startsWith("/")) continue; + if (getByPath(spec.state ?? {}, reference) !== undefined) continue; + if ( + [...writablePaths].some( + (target) => + reference === target || reference.startsWith(`${target}/`), + ) + ) + continue; + issues.push({ + severity: "error", + code: "unresolved_template_state_ref", + elementKey, + path: `${path}/$template`, + message: `Template reference ${JSON.stringify(reference)} has no initial state or declared write path. $template interpolates state paths, not JavaScript expressions. Initialize the referenced field; use $cond for a conditional value.`, + }); + } + } + for (const [key, child] of Object.entries(record)) { + checkTemplates(child, `${path}/${escapePointer(key)}`, elementKey); + } + }; + for (const [key, element] of Object.entries(spec.elements)) { + const base = `/elements/${escapePointer(key)}`; + checkTemplates(element.props, `${base}/props`, key); + checkTemplates(element.on, `${base}/on`, key); + checkTemplates(element.watch, `${base}/watch`, key); + } + } + // 4. Orphaned elements (optional) if (checkOrphans) { const reachable = new Set(); diff --git a/skills/core/SKILL.md b/skills/core/SKILL.md index 8838e3ae..f2d571c3 100644 --- a/skills/core/SKILL.md +++ b/skills/core/SKILL.md @@ -209,6 +209,19 @@ const { spec: fixed, fixDetails } = autoFixSpec(spec); const attempt = autoFixSpec(spec, { lossy: retriesExhausted }); ``` + +For hosts that require seeded or explicitly writable template state, opt in to +`validateSpec(spec, { checkTemplateStateRefs: true })`. Absolute interpolations +such as `${/ui/name}` must resolve in initial `state` or be covered by a declared +`$bindState` or literal action `statePath`. Otherwise validation returns +`unresolved_template_state_ref` with the offending expression's JSON Pointer in +`issue.path`, including references in inactive conditional branches and action +parameters. This can detect accidental JavaScript expressions inside `$template`; +it never evaluates or rewrites them. Literal state keys containing operator +characters remain valid when present. Bare repeat-item references are not checked. +The option defaults to false, preserving unresolved references' empty-string +rendering. Validation cannot detect content a model omitted from its response. + ## Visibility Conditions Control element visibility with state-based conditions. `VisibilityContext` is `{ stateModel: StateModel }`.