diff --git a/.changeset/4795-check-bindable-text-keys.md b/.changeset/4795-check-bindable-text-keys.md new file mode 100644 index 0000000000..0f97234b8a --- /dev/null +++ b/.changeset/4795-check-bindable-text-keys.md @@ -0,0 +1,35 @@ +--- +'@object-ui/cli': minor +--- + +feat(cli): `objectui check` refuses a `${…}` on a text key its node never evaluates + +`SchemaRenderer` evaluates an expression on the closed text keys `title`, +`label`, `value` and `description` only where `@objectstack/spec`'s +`expressionBindableTextKeysFor(type)` lists that key for the node's type. +Anywhere else the expression is never resolved, so the user sees +`${data.total}` as literal text, or nothing at all — and no step before the +browser said so. + +`objectui check` now refuses such an expression in every file it recognises as +an ObjectUI schema, and the run exits non-zero. The refusal names the key, the +path to it (spelled as `objectui validate` spells paths), the keys that type +does evaluate, and the channels `SchemaRenderer` evaluates. Types with no row in +the carriage map are refused too: `text.value` and `action:button.label` are +never evaluated. + +- **Component nodes only**: the file's root node and every node its `children` + hold. Objects under other keys, such as a form's `fields` entries, are + definitions rather than nodes and are not judged. A root of type `page` keeps + its own `title`, which is a page key; its `children` are still judged. +- **The type is matched as written**, as the runtime matches it: `ui:card` has + no row, and is not read as `card`. +- **A type no registered component answers to** gets a warning instead of a + refusal, because a custom renderer may evaluate its own keys. +- **There is no escape spelling**: a `${…}` meant as literal text on one of + these keys, such as a code sample in a `code-editor`'s `value`, is refused + too. Write it without `${`. + +The key vocabulary and the per-type carriage map are read from +`@objectstack/spec`, now a declared dependency of `@object-ui/cli`, so a row +added upstream moves this check and the runtime together. diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index ba3b91986c..c33b1443cd 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -224,13 +224,41 @@ distinction matters: Issue at map: Unrecognized key: "latitudeFieId" ``` -A run with no parse errors ends on a tally rather than a pass: how many -recognised files validated, how many were recognised by a structural key and not -validated, and how many did not validate, followed by a pointer to -`objectui validate` for the verdict. +#### Expressions on text keys that are never evaluated + +`SchemaRenderer` evaluates a `${…}` expression on the closed text keys `title`, +`label`, `value` and `description` only where `@objectstack/spec`'s +`expressionBindableTextKeysFor(type)` lists that key for the node's type. On any +other type, the expression is never resolved: the user sees its literal text, or +nothing. `objectui check` refuses it in every recognised file: + +- **Component nodes only.** The file's root node and every node its `children` + hold, at any depth. Objects under other keys (a form's `fields`, a grid's + `columns`) are definitions, not nodes, and are not judged. A root whose `type` + is `page` keeps its own `title`: that is a page key, while its `children` are + still judged. +- **The type is matched as written.** `ui:card` is not `card`, exactly as at + render time. +- **A type no registered component answers to** is warned about, not refused: a + custom renderer may evaluate its own keys. +- **There is no escape spelling.** A `${…}` meant as literal text on one of + these keys — a code sample in a `code-editor`'s `value`, a shell `${VAR}` — is + refused too. Write the text without `${`, for example with string + concatenation in a code sample. -Only unreadable JSON makes `objectui check` exit non-zero; everything above is a -report. Use `objectui validate` when you want a failing exit code. +```text +x Unevaluated expression in dashboard.json at children → 0 → title: "statistic" never evaluates `title` (it evaluates only `label`, `value`, `description`), so the expression is never resolved: the user sees its literal text, or nothing. + Channels SchemaRenderer evaluates: `label`, `value`, `description`, `content`, `properties.title` (evaluated, then hoisted onto the node); or resolve the value in the host before the schema reaches SchemaRenderer. +``` + +A run with no parse errors and no refused expression ends on a tally rather than +a pass: how many recognised files validated, how many were recognised by a +structural key and not validated, and how many did not validate, followed by a +pointer to `objectui validate` for the verdict. + +Unreadable JSON and a refused expression make `objectui check` exit non-zero; +everything else above is a report. Use `objectui validate` when you want a +failing exit code on a document that does not validate. ### `objectui doctor` diff --git a/examples/schema-catalog/src/schemas/plugin-editor/javascript-editor.json b/examples/schema-catalog/src/schemas/plugin-editor/javascript-editor.json index 4c2566f680..0daafd825e 100644 --- a/examples/schema-catalog/src/schemas/plugin-editor/javascript-editor.json +++ b/examples/schema-catalog/src/schemas/plugin-editor/javascript-editor.json @@ -1,6 +1,6 @@ { "type": "code-editor", - "value": "function greet(name) {\n console.log(`Hello, ${name}!`);\n return `Welcome, ${name}`;\n}\n\ngreet('Developer');", + "value": "function greet(name) {\n console.log('Hello, ' + name + '!');\n return 'Welcome, ' + name;\n}\n\ngreet('Developer');", "language": "javascript", "theme": "vs-dark", "height": "300px" diff --git a/packages/cli/package.json b/packages/cli/package.json index 181a4f5de3..dab31306d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,6 +56,7 @@ "@object-ui/components": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.1.0", "@tailwindcss/postcss": "^4.3.3", "@types/glob": "^9.0.0", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts new file mode 100644 index 0000000000..1ab2789d61 --- /dev/null +++ b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts @@ -0,0 +1,347 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `objectui check` refuses a `${…}` written into a closed text key its + * component node never evaluates (objectui#4795, ruling item 2). + * + * The ruling (maintainer, 2026-08-31): reject `${…}` in `title` / `label` / + * `value` / `description` on a component node whose + * `expressionBindableTextKeysFor(type)` answer excludes that key, types with no + * row included (`text.value`, `action:button.label`). Sub-rule (i): component + * nodes only, and the page document root's own `title` is a page key. + * Sub-rule (ii): a type no registered component answers to warns instead. + * + * Expected text is derived from the command's own formatters, fed from the + * gate's real findings, rather than copied here as literals. What the + * assertions hold by hand is the SUBJECT: which key, at which path, refused or + * not, and the exit code. + * + * Fixtures live under `os.tmpdir()`, never in the repo tree: `check()` globs + * every JSON file under the directory it is handed, so a fixture committed + * inside this workspace would be scanned by the repo's own `pnpm check`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { EXPRESSION_BINDABLE_TEXT_KEYS, expressionBindableTextKeysFor } from '@objectstack/spec/ui'; + +import { check } from '../commands/check.js'; +import { formatIssuePath } from '../utils/issue-path.js'; +import { KNOWN_SCHEMA_TYPES, isKnownSchemaType } from '../utils/known-schema-types.js'; +import { + describeUnbindableTextExpression, + findUnbindableTextExpressions, + workingChannels, +} from '../utils/unbindable-text-expressions.js'; + +const EXPR = '${data.total}'; + +let cwd: string; +let lines: string[]; +let exitCodes: number[]; +let restoreLog: () => void; + +/** + * The CSI sequences chalk may add. The escape byte is built with + * `String.fromCharCode` rather than spelled into the source, so this file holds + * no raw control character. + */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +function plainLines(): string[] { + return lines.map((l) => l.replace(ANSI, '')); +} + +function refusalLines(): string[] { + return plainLines().filter((l) => l.startsWith('x Unevaluated expression in ')); +} + +function warningLines(): string[] { + return plainLines().filter((l) => l.startsWith('⚠️ Expression not judged in ')); +} + +/** + * Write one fixture and run the command over it. Every fixture carries + * `className`, a structural key, so recognition is never the variable a case + * turns on. + */ +async function checkOne(document: Record): Promise { + writeFileSync(join(cwd, 'schema.json'), JSON.stringify({ className: 'x', ...document })); + await check(cwd); +} + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'objectui-check-4795-')); + lines = []; + exitCodes = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restoreLog = () => { + console.log = original; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restoreLog(); + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('objectui check refuses an expression on a text key the node never evaluates', () => { + it.each([ + // The two no-row types the ruling names by hand. + ['text', 'value'], + ['action:button', 'label'], + // A type WITH a row, on a key its row leaves out. + ['card', 'label'], + ['statistic', 'title'], + // A namespaced spelling: the lookup is asked verbatim, and `ui:card` has no row. + ['ui:card', 'title'], + ])('refuses `%s.%s`, naming the key, the path and the working channels', async (type, key) => { + // Preconditions, asserted rather than assumed. + expect(isKnownSchemaType(type)).toBe(true); + expect(expressionBindableTextKeysFor(type)).not.toContain(key); + + await checkOne({ type, [key]: EXPR }); + + const [finding] = findUnbindableTextExpressions({ type, [key]: EXPR }); + expect(finding).toMatchObject({ severity: 'refusal', type, key, path: [key] }); + expect(refusalLines()).toEqual([ + `x Unevaluated expression in schema.json ${describeUnbindableTextExpression(finding)}`, + ]); + const refusal = refusalLines()[0]; + expect(refusal).toContain(`at ${formatIssuePath([key])}:`); + expect(refusal).toContain('`' + key + '`'); + expect(refusal).toContain(`"${type}"`); + expect(plainLines()).toContain(` ${workingChannels(finding)}`); + expect(workingChannels(finding)).toContain('`content`'); + expect(workingChannels(finding)).toContain('`properties.' + key + '`'); + // A refusal fails the run. + expect(plainLines()).toContain('Found 1 errors'); + expect(exitCodes).toEqual([1]); + }); + + it('names the keys a type with a row does evaluate', async () => { + await checkOne({ type: 'card', label: EXPR }); + const [finding] = findUnbindableTextExpressions({ type: 'card', label: EXPR }); + expect(finding.evaluatedKeys).toEqual(expressionBindableTextKeysFor('card')); + for (const own of expressionBindableTextKeysFor('card')) { + expect(refusalLines()[0]).toContain('`' + own + '`'); + expect(workingChannels(finding)).toContain('`' + own + '`'); + } + }); + + it('refuses an expression inside a surrounding string, as the evaluator interpolates it', async () => { + await checkOne({ type: 'text', value: `Total: ${EXPR}` }); + expect(refusalLines()).toHaveLength(1); + expect(exitCodes).toEqual([1]); + }); + + it('walks `children`, an array or a single node, and prints the path to the key', async () => { + const document = { + type: 'flex', + children: [ + { type: 'text', content: 'fine' }, + { type: 'aspect-ratio', children: { type: 'text', value: EXPR } }, + ], + }; + await checkOne(document); + const findings = findUnbindableTextExpressions(document); + expect(findings.map((f) => f.path)).toEqual([['children', 1, 'children', 'value']]); + expect(refusalLines()).toHaveLength(1); + expect(refusalLines()[0]).toContain( + `at ${formatIssuePath(['children', 1, 'children', 'value'])}:`, + ); + expect(exitCodes).toEqual([1]); + }); + + it('refuses every offending node in the file, not only the first', async () => { + await checkOne({ + type: 'stack', + children: [ + { type: 'text', value: EXPR }, + { type: 'action:button', label: EXPR }, + ], + }); + expect(refusalLines()).toHaveLength(2); + expect(plainLines()).toContain('Found 2 errors'); + }); +}); + +describe('objectui check passes an expression on a key the node evaluates', () => { + it.each([ + ['statistic', 'value'], + ['statistic', 'label'], + ['statistic', 'description'], + ['card', 'title'], + ['card', 'description'], + ['button', 'label'], + ])('passes `%s.%s`', async (type, key) => { + // The precondition: the carriage map DOES carry this key. + expect(expressionBindableTextKeysFor(type)).toContain(key); + await checkOne({ type, [key]: EXPR }); + expect(refusalLines()).toEqual([]); + expect(warningLines()).toEqual([]); + expect(exitCodes).toEqual([]); + }); + + it.each([ + ['plain text', 'Revenue'], + ['an unclosed opener', 'costs ${ nothing'], + ['an empty template', 'empty ${} braces'], + ])('passes %s, which the evaluator does not read as an expression', async (_label, value) => { + await checkOne({ type: 'text', value }); + expect(refusalLines()).toEqual([]); + expect(exitCodes).toEqual([]); + }); + + it('judges only the four closed keys: `content` and the `properties` bag are evaluated channels', async () => { + await checkOne({ type: 'text', content: EXPR, properties: { value: EXPR } }); + expect(refusalLines()).toEqual([]); + expect(exitCodes).toEqual([]); + }); +}); + +describe('sub-rule (i): component nodes only', () => { + it('does not judge the page document root’s own `title`', async () => { + // The control: `page` has no row, so the key WOULD be refused on a node. + expect(expressionBindableTextKeysFor('page')).not.toContain('title'); + await checkOne({ type: 'page', title: EXPR }); + expect(refusalLines()).toEqual([]); + expect(warningLines()).toEqual([]); + expect(exitCodes).toEqual([]); + }); + + it('still walks the page document root’s `children`', async () => { + await checkOne({ type: 'page', title: EXPR, children: [{ type: 'text', value: EXPR }] }); + expect(refusalLines()).toHaveLength(1); + expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['children', 0, 'value'])}:`); + }); + + it('does not refuse a form field definition’s `label`: `fields[]` entries are not component nodes', async () => { + const field = { name: 'total', type: 'text', label: EXPR }; + await checkOne({ type: 'form', fields: [field] }); + expect(refusalLines()).toEqual([]); + expect(exitCodes).toEqual([]); + + // The control: the SAME object is refused where it IS a component node, + // so the pass above is decided by position, not by the object. + lines = []; + exitCodes = []; + await checkOne({ type: 'form', fields: [field], children: [field] }); + expect(refusalLines()).toHaveLength(1); + expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['children', 0, 'label'])}:`); + expect(exitCodes).toEqual([1]); + }); +}); + +describe('sub-rule (ii): a type no registered component answers to warns, never refuses', () => { + it('warns on `stat-card`, and the run still passes', async () => { + expect(isKnownSchemaType('stat-card')).toBe(false); + const document = { type: 'grid', children: [{ type: 'stat-card', value: EXPR }] }; + await checkOne(document); + + const [finding] = findUnbindableTextExpressions(document); + expect(finding).toMatchObject({ severity: 'warning', type: 'stat-card', key: 'value' }); + expect(refusalLines()).toEqual([]); + expect(warningLines()).toEqual([ + `⚠️ Expression not judged in schema.json ${describeUnbindableTextExpression(finding)}`, + ]); + expect(exitCodes).toEqual([]); + }); + + it('refuses on a registered type in the same file as a warned one', async () => { + await checkOne({ + type: 'grid', + children: [ + { type: 'stat-card', value: EXPR }, + { type: 'text', value: EXPR }, + ], + }); + expect(warningLines()).toHaveLength(1); + expect(refusalLines()).toHaveLength(1); + expect(exitCodes).toEqual([1]); + }); +}); + +describe('only recognised ObjectUI files are judged', () => { + it('judges a file the validity arm admitted, with no structural key', async () => { + const document = { type: 'statistic', label: 'Total', value: '1', title: EXPR }; + writeFileSync(join(cwd, 'leaf.json'), JSON.stringify(document)); + await check(cwd); + expect(refusalLines()).toHaveLength(1); + }); + + it('does not judge a file no recogniser admitted', async () => { + writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'x', type: 'module', description: EXPR })); + await check(cwd); + expect(refusalLines()).toEqual([]); + expect(exitCodes).toEqual([]); + }); +}); + +/** + * The gate and `SchemaRenderer` ask the lookup the same question. + * + * Both call `expressionBindableTextKeysFor(type)`, but a lookup called with a + * different type string answers a different question: a gate that stripped + * `ui:` would grant `ui:card` the `card` row the runtime never applies, and + * pass the very literal the user then sees. The runtime's half — the type is + * passed VERBATIM — is pinned by the `@object-ui/react` suite + * `SchemaRenderer.bindableTextKeys.test.tsx` ("a namespaced spelling is not + * silently normalized"). This table pins the gate's half on every type this + * build registers, against the same verbatim lookup. + */ +describe('the gate asks the lookup what SchemaRenderer asks, on every registered type', () => { + /** The four keys the gate refuses on a component node of `type`. */ + const refusedByGate = (type: string): string[] => { + const node: Record = { type }; + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) node[key] = EXPR; + // Under a parent's `children`, so every type — `page` included — is judged + // as a component node; the page document root is sub-rule (i)'s, above. + return findUnbindableTextExpressions({ type: 'div', children: [node] }) + .filter((finding) => finding.severity === 'refusal') + .map((finding) => finding.key); + }; + + it('refuses exactly the keys the verbatim lookup excludes', () => { + const disagreements = KNOWN_SCHEMA_TYPES.flatMap((type) => { + const excluded = EXPRESSION_BINDABLE_TEXT_KEYS.filter( + (key) => !expressionBindableTextKeysFor(type).includes(key), + ); + const refused = refusedByGate(type); + return JSON.stringify(refused) === JSON.stringify(excluded) + ? [] + : [`${type}: lookup excludes [${excluded.join(', ')}], gate refused [${refused.join(', ')}]`]; + }); + expect(disagreements).toEqual([]); + }); + + it('gives a namespaced spelling no row, even where its bare name has one', () => { + // The registered types a prefix-stripping gate would get wrong. Derived, + // and asserted non-empty, so this case cannot pass by having no subject. + const namespacedWithBareRow = KNOWN_SCHEMA_TYPES.filter((type) => { + const bare = type.slice(type.lastIndexOf(':') + 1); + return type.includes(':') && expressionBindableTextKeysFor(bare).length > 0; + }); + expect(namespacedWithBareRow).toContain('ui:card'); + for (const type of namespacedWithBareRow) { + expect(refusedByGate(type), type).toEqual([...EXPRESSION_BINDABLE_TEXT_KEYS]); + } + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 46caf5acef..0073f66428 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -16,6 +16,11 @@ import { safeValidateSchema } from '@object-ui/types/zod'; import { formatIssuePath } from '../utils/issue-path.js'; import { isKnownSchemaType } from '../utils/known-schema-types.js'; import { didYouMeanClause } from '../utils/known-type-case-suggestion.js'; +import { + describeUnbindableTextExpression, + findUnbindableTextExpressions, + workingChannels, +} from '../utils/unbindable-text-expressions.js'; /** * Root keys that positively identify a file as an ObjectUI schema node. @@ -213,7 +218,9 @@ export interface CheckTally { * actually established, bucket by bucket, and sends the reader to * `objectui validate` for the verdict. ⛔ It says nothing that reads as a pass. * - * The exit code is unchanged: a run exits non-zero on unreadable JSON only. + * It is printed only when nothing was refused. A run exits non-zero on + * unreadable JSON, and on a `${…}` expression refused on a text key its node + * never evaluates (objectui#4795). */ export function closingLine(tally: CheckTally): string { return ( @@ -415,6 +422,20 @@ export async function check(cwd: string = process.cwd()) { ) ); } + // objectui#4795, ruling item 2: a `${…}` on a closed text key its + // component node never evaluates reaches the user as literal + // text. Refused on a registered type, warned on any other + // (sub-rule ii). Judged on recognised files only, from either arm. + for (const finding of findUnbindableTextExpressions(content)) { + const line = `${file} ${describeUnbindableTextExpression(finding)}`; + if (finding.severity === 'refusal') { + console.log(chalk.red(`x Unevaluated expression in ${line}`)); + console.log(chalk.dim(` ${workingChannels(finding)}`)); + errors++; + } else { + console.log(chalk.yellow(`⚠️ Expression not judged in ${line}`)); + } + } } } } diff --git a/packages/cli/src/utils/unbindable-text-expressions.ts b/packages/cli/src/utils/unbindable-text-expressions.ts new file mode 100644 index 0000000000..f076523620 --- /dev/null +++ b/packages/cli/src/utils/unbindable-text-expressions.ts @@ -0,0 +1,211 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + EXPRESSION_BINDABLE_TEXT_KEYS, + expressionBindableTextKeysFor, +} from '@objectstack/spec/ui'; + +import { formatIssuePath } from './issue-path.js'; +import { isKnownSchemaType } from './known-schema-types.js'; + +/** + * `objectui check` refuses a `${…}` expression written into a text key that + * `SchemaRenderer` never evaluates (objectui#4795, ruling item 2). + * + * ## The defect this refuses + * + * A value reaches the screen only when `SchemaRenderer` evaluates it AND the + * renderer reads it back. For the four closed text keys — `title`, `label`, + * `value`, `description` — the evaluation half is decided by one lookup: + * `expressionBindableTextKeysFor(type)` from `@objectstack/spec`, which + * `SchemaRenderer`'s evaluation memo consumes. A key the lookup does not list + * for the node's type is passed through untouched, so `${data.total}` reaches + * the user as those literal characters, or the node renders blank. + * + * The ruling (maintainer, 2026-08-31, on objectui#4795): reject a `${…}` in + * any of the four keys on a component node whose lookup answer excludes that + * key — types with no row included, since the empty set is the contract's + * answer for them. Two sub-rules: + * + * (i) Component nodes only. The page document root's own `title` is a page + * key, not a component text key. + * (ii) A type no registered component answers to gets a WARNING, not a + * refusal: a custom renderer may evaluate its own keys. + * + * ## One lookup, called the way the runtime calls it + * + * The vocabulary and the carriage map are the spec's, IMPORTED here rather + * than copied, so a row added upstream moves this gate and the runtime in the + * same install. The type string is passed VERBATIM, because that is what the + * evaluation memo in `SchemaRenderer` passes: no prefix stripping, so + * `ui:card` answers the empty set exactly as it does at render time. Each half + * is pinned against that verbatim lookup: the runtime's by the `@object-ui/react` + * suite `SchemaRenderer.bindableTextKeys.test.tsx`, this gate's over every + * registered type by `check-unbindable-text-expression-4795.test.ts`. + * + * ## What a component node is: the root, and what `children` holds + * + * `SchemaRenderer` does not recurse on its own. Each renderer decides which of + * its keys it hands back to `SchemaRenderer`, and many keys that hold objects + * with a string `type` are not nodes at all: a form's `fields[]` entries are + * field definitions rendered by the form's field widgets, a grid's `columns[]` + * entries are column definitions, and `{ "type": "multiple" }` can be a + * selection mode. A walk over every object with a string `type` would refuse + * `{ "type": "text", "label": "${…}" }` inside `fields[]` — a false refusal + * on a key this gate has no business judging. + * + * So the walk follows the protocol's ONE composition key, + * `BaseSchema.children`, from the document root: the same single spelling the + * core validator's recursive walk and the SDUI parser's child-list key follow. + * Nodes that a renderer reaches through a key of its own (`trigger`, `footer`, + * a page's `regions`, a tab's `content`) are not walked. That is a stated + * boundary, and it fails quiet in the safe direction: a node the walk does not + * reach is not refused, and nothing outside a component node is refused. + * + * ## What an expression is + * + * The evaluator's own interpolation pattern: a `${` closed by a `}` with at + * least one character between them. A lone `${`, or an empty `${}`, is not an + * expression to the evaluator, so it is not one here either. + */ + +/** The evaluator's interpolation pattern, as `ExpressionEvaluator.evaluate` matches it. */ +const EXPRESSION_PATTERN = /\$\{[^}]+\}/; + +/** The protocol's one composition key: `BaseSchema.children`. */ +const COMPOSITION_KEY = 'children'; + +/** + * The document-root type whose own text keys are page keys (sub-rule i). Its + * `children` are still component nodes and are still walked. + */ +const PAGE_DOCUMENT_ROOT_TYPE = 'page'; + +/** One `${…}` sitting on a closed text key its node's type does not evaluate. */ +export interface UnbindableTextExpression { + /** + * `refusal` when the type is a registered component; `warning` when no + * registered component answers to it (sub-rule ii). + */ + severity: 'refusal' | 'warning'; + /** From the document root to the key: `['children', 0, 'value']`. */ + path: readonly (string | number)[]; + /** The node's `type`, verbatim. */ + type: string; + /** The closed text key holding the expression. */ + key: string; + /** The keys of the four this type's node DOES evaluate: the lookup's answer. */ + evaluatedKeys: readonly string[]; +} + +type ComponentNode = Record & { type: string }; + +function isComponentNode(value: unknown): value is ComponentNode { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as { type?: unknown }).type === 'string' + ); +} + +function judge( + node: ComponentNode, + path: readonly (string | number)[], + into: UnbindableTextExpression[], +): void { + const evaluatedKeys: readonly string[] = expressionBindableTextKeysFor(node.type); + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) { + const value = node[key]; + if (typeof value !== 'string' || !EXPRESSION_PATTERN.test(value)) continue; + if (evaluatedKeys.includes(key)) continue; + into.push({ + severity: isKnownSchemaType(node.type) ? 'refusal' : 'warning', + path: [...path, key], + type: node.type, + key, + evaluatedKeys, + }); + } +} + +function visit( + node: ComponentNode, + path: readonly (string | number)[], + isDocumentRoot: boolean, + into: UnbindableTextExpression[], +): void { + if (!(isDocumentRoot && node.type === PAGE_DOCUMENT_ROOT_TYPE)) { + judge(node, path, into); + } + const children = node[COMPOSITION_KEY]; + if (Array.isArray(children)) { + children.forEach((child, index) => { + if (isComponentNode(child)) visit(child, [...path, COMPOSITION_KEY, index], false, into); + }); + } else if (isComponentNode(children)) { + visit(children, [...path, COMPOSITION_KEY], false, into); + } +} + +/** + * Every `${…}` on one of the four closed text keys of a component node whose + * type does not evaluate that key, in document order. `document` is a parsed + * file `objectui check` has recognised as an ObjectUI schema. + */ +export function findUnbindableTextExpressions(document: unknown): UnbindableTextExpression[] { + const found: UnbindableTextExpression[] = []; + if (isComponentNode(document)) visit(document, [], true, found); + return found; +} + +const inCode = (key: string): string => '`' + key + '`'; + +/** + * The line `check` prints for a finding, after the file name: the path spelled + * as `objectui validate` spells it, the type, the key, and the keys this type + * does evaluate. + */ +export function describeUnbindableTextExpression(finding: UnbindableTextExpression): string { + const at = formatIssuePath(finding.path); + if (finding.severity === 'warning') { + return ( + `at ${at}: "${finding.type}" is not a registered component, so the expression on ` + + `${inCode(finding.key)} is not refused; a custom renderer may evaluate it itself.` + ); + } + const evaluates = + finding.evaluatedKeys.length > 0 + ? `it evaluates only ${finding.evaluatedKeys.map(inCode).join(', ')}` + : `it evaluates none of ${EXPRESSION_BINDABLE_TEXT_KEYS.map(inCode).join(', ')}`; + return ( + `at ${at}: "${finding.type}" never evaluates ${inCode(finding.key)} ` + + `(${evaluates}), so the expression is never resolved: the user sees its ` + + 'literal text, or nothing.' + ); +} + +/** + * The channels a refused expression can move to: the keys this type evaluates, + * then the channels the runtime's unevaluated-expression diagnostic names — + * `content`, the `properties` bag (evaluated, then hoisted onto the node), and + * resolving the value in the host. The line says these are EVALUATED; whether + * a renderer reads a key back is the renderer's, and this gate cannot see it. + */ +export function workingChannels(finding: UnbindableTextExpression): string { + const channels = [ + ...finding.evaluatedKeys.map(inCode), + inCode('content'), + `${inCode(`properties.${finding.key}`)} (evaluated, then hoisted onto the node)`, + ]; + return ( + `Channels SchemaRenderer evaluates: ${channels.join(', ')}; ` + + 'or resolve the value in the host before the schema reaches SchemaRenderer.' + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54fd0ec5b5..a92e1dcd2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -901,6 +901,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^17.1.0 + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3