Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web/content/docs/api/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/spec-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
72 changes: 71 additions & 1 deletion packages/core/src/spec-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -307,6 +317,66 @@ export function validateSpec(
}
}

if (checkTemplateStateRefs) {
const writablePaths = new Set<string>();
const collectWrites = (value: unknown): void => {
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string>();
Expand Down
13 changes: 13 additions & 0 deletions skills/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`.
Expand Down