diff --git a/.changeset/7849-unevaluated-diagnostic-lists-properties.md b/.changeset/7849-unevaluated-diagnostic-lists-properties.md new file mode 100644 index 0000000000..c2e0864b36 --- /dev/null +++ b/.changeset/7849-unevaluated-diagnostic-lists-properties.md @@ -0,0 +1,25 @@ +--- +"@object-ui/react": patch +--- + +The unevaluated-expression diagnostic now lists `properties` among the channels +that evaluate and read back, matching the sibling `props`-bag diagnostic that +tells authors to write their keys there. + +Both messages are dev-build diagnostics in `SchemaRenderer`, and one node can +trip both. They disagreed: `propsBagDiagnostic` said *"`props` is NOT hoisted +onto the node — only `properties.*` is … Write them under `properties` +instead"*, while `unevaluatedExpression` enumerated *"channels that do evaluate +and read back today"* as `content` or host-side resolution — omitting +`properties`. An author who hit both was told to use a channel the other message +said did not work. + +The enumeration was the wrong half, established by measurement rather than by +reading the `COMPAT` label on the hoist. `properties` has no retirement on +record: `@objectstack/spec@17.2.0` calls the vocabulary carried there *"ALIVE — +this is not dead surface to retire under ADR-0049"*, keeps +`PageComponentSchema.properties` as the open carrier on purpose, gates it at the +authoring door, and tombstones no part of it via `retiredKey()`. In this repo +`props`, not `properties`, is the spelling annotated as the legacy alias. + +Diagnostic text only — no evaluation, hoist or schema behaviour changed. diff --git a/packages/react/src/__tests__/diagnosticChannelConsistency.test.ts b/packages/react/src/__tests__/diagnosticChannelConsistency.test.ts new file mode 100644 index 0000000000..0250764943 --- /dev/null +++ b/packages/react/src/__tests__/diagnosticChannelConsistency.test.ts @@ -0,0 +1,125 @@ +/** + * 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#7849 — the two dev diagnostics that talk to an author about WHERE to + * write an expression must not contradict each other. + * + * ## The defect this pins + * + * Both messages can fire on the SAME node. `propsBagDiagnostic` told the author + * to move the key under `properties`; `unevaluatedExpression` enumerated "the + * channels that do evaluate and read back today" and left `properties` OUT of + * that list, naming only `content` and host-side resolution. So the runtime + * answered one question two ways, and the half that was wrong was the + * enumeration: `properties` demonstrably evaluates, hoists and reads back. + * + * ## Why this is a DERIVED assertion and not two hard-coded strings + * + * Pinning both sentences literally would pin today's wording, and the next + * rewrite of either message would be free to reintroduce the contradiction as + * long as it also updated its own pin. So the channel the `props` diagnostic + * RECOMMENDS is parsed back out of the message it actually emits, and the + * enumeration is parsed out of the other; the assertion is that the first + * appears in the second. Reword either message and this test still asks the + * question that matters — it only goes red when they disagree. + * + * Both extractions are asserted to have MATCHED before they are compared. A + * regex that silently misses is the failure mode this shape would otherwise + * introduce: `null` vs `null` would compare equal and the pin would pass + * without reading a single word either developer wrote. + */ + +import { describe, it, expect } from 'vitest'; +import { + formatDroppedPropsBagMessage, + DROPPED_PROPS_BAG_PREFIX, +} from '../utils/propsBagDiagnostic'; +import { + collectUnevaluatedExpressions, + formatUnevaluatedExpressionMessage, + UNEVALUATED_EXPRESSION_PREFIX, +} from '../utils/unevaluatedExpression'; + +/** + * One node, authored the way that trips BOTH diagnostics: an expression written + * into the `props` envelope on a `schema`-reading renderer. + */ +const NODE_TYPE = 'badge'; +const NODE_ID = 'status_badge'; +const AUTHORED_KEY = 'label'; +const AUTHORED_VALUE = '${data.status}'; + +/** The message `propsBagDiagnostic` really emits for that node. */ +const propsBagMessage = (): string => + formatDroppedPropsBagMessage(NODE_TYPE, NODE_ID, [AUTHORED_KEY]); + +/** The message `unevaluatedExpression` really emits for that node. */ +const unevaluatedMessage = (): string => { + const findings = collectUnevaluatedExpressions( + undefined, + undefined, + { [AUTHORED_KEY]: AUTHORED_VALUE }, + ); + expect(findings.length).toBeGreaterThan(0); + return formatUnevaluatedExpressionMessage(NODE_TYPE, NODE_ID, findings); +}; + +/** + * The channel the `props` diagnostic tells the author to write under, read out + * of its own sentence ("Write it/them under `X` instead"). + */ +const recommendedChannel = (message: string): string | null => { + const m = /Write (?:it|them) under `([^`]+)` instead/.exec(message); + return m ? m[1] : null; +}; + +/** + * The enumeration sentence of the unevaluated-expression diagnostic — from + * "Channels that do evaluate and read back today:" to the end of the message. + */ +const channelEnumeration = (message: string): string | null => { + const m = /Channels that do evaluate and read back today:([\s\S]+)$/.exec(message); + return m ? m[1] : null; +}; + +describe('objectui#7849 — the two authoring diagnostics agree on the channels', () => { + it('both messages are the ones the author actually reads', () => { + expect(propsBagMessage()).toContain(DROPPED_PROPS_BAG_PREFIX); + expect(unevaluatedMessage()).toContain(UNEVALUATED_EXPRESSION_PREFIX); + }); + + it('the props diagnostic still recommends a channel, in a sentence this test can read', () => { + expect(recommendedChannel(propsBagMessage())).not.toBeNull(); + }); + + it('the unevaluated diagnostic still enumerates the working channels', () => { + expect(channelEnumeration(unevaluatedMessage())).not.toBeNull(); + }); + + it('the channel the props diagnostic RECOMMENDS is listed by the unevaluated diagnostic as one that works', () => { + const recommended = recommendedChannel(propsBagMessage()); + const enumeration = channelEnumeration(unevaluatedMessage()); + + // Guarded above, and re-guarded here: a missed match must not compare equal. + expect(recommended).not.toBeNull(); + expect(enumeration).not.toBeNull(); + + expect( + enumeration as string, + 'objectui#7849: `propsBagDiagnostic` tells the author to write the key under ' + + '`' + recommended + '`, but `unevaluatedExpression`\'s list of channels that ' + + 'evaluate and read back does not mention it. One node can trip both messages; ' + + 'they must not answer the same question two ways.', + ).toContain(recommended as string); + }); + + it('`content` — the channel that was already listed — stays listed', () => { + expect(channelEnumeration(unevaluatedMessage()) as string).toContain('content'); + }); +}); diff --git a/packages/react/src/utils/unevaluatedExpression.ts b/packages/react/src/utils/unevaluatedExpression.ts index 0c98a84a57..4a476fe1ef 100644 --- a/packages/react/src/utils/unevaluatedExpression.ts +++ b/packages/react/src/utils/unevaluatedExpression.ts @@ -173,6 +173,35 @@ function locate(finding: UnevaluatedExpressionFinding): string { /** * Build the message. Separate from the emit so a test can assert the words a * developer is going to read, not merely that something was logged. + * + * ## Why `properties.*` is in the channel list (objectui#7849) + * + * It was missing, and the omission CONTRADICTED the sibling diagnostic in + * `propsBagDiagnostic.ts`, which tells an author who wrote the same key one + * envelope out: *"`props` is NOT hoisted onto the node — only `properties.*` + * is … Write them under `properties` instead."* An author whose node trips + * both messages was told to use `properties` by one and, by the other, that + * the only channels that work are `content` or host-side resolution. + * + * The enumeration was the wrong half. `properties` is not a channel on its way + * out and there is no retirement on record for it: `@objectstack/spec@17.2.0` + * calls the vocabulary carried there *"ALIVE — this is not dead surface to + * retire under ADR-0049"*, keeps `PageComponentSchema.properties` as the open + * carrier ON PURPOSE, gates it at the authoring door + * (`validate-component-props.ts`), and tombstones no part of it via + * `retiredKey()`. In THIS repo `props` — not `properties` — is the spelling + * annotated as the legacy alias (`SchemaRenderer.tsx`: "the legacy `props` + * bag, minus every key the canonical `properties` bag also …"). + * + * Measured on this branch's base through the real renderers, not inferred: + * `badge` with `properties.label = '${data.status}'` renders `completed` and + * applies the variant classes; `card` with a conditional + * `properties.className` renders `class="… border-red-500"`; the same keys + * under `props` render an EMPTY badge body and no `border-red-500`. + * + * The words are borrowed from the sibling message on purpose — one hoist, one + * vocabulary, so the two diagnostics cannot drift apart again. Pinned by + * `__tests__/diagnosticChannelConsistency.test.ts`. */ export function formatUnevaluatedExpressionMessage( type: unknown, @@ -195,8 +224,10 @@ export function formatUnevaluatedExpressionMessage( 'to the DOM, with its source text intact. Either SchemaRenderer does not\n' + 'evaluate this key, or the expression threw and the evaluator returned the\n' + 'source unchanged.\n' + - 'Channels that do evaluate and read back today: `content`, or resolve the\n' + - 'value in the host before handing the schema to SchemaRenderer.' + 'Channels that do evaluate and read back today: `content`; `properties.*`,\n' + + 'which is evaluated and then HOISTED onto the node, so a renderer declared\n' + + 'as `({ schema })` reads it back as `schema.`; or resolve the value in\n' + + 'the host before handing the schema to SchemaRenderer.' ); }