Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .changeset/6823-deprecation-guidance-agreement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
---

Test-only (objectui#6823): the console deprecation notice and the machine-readable
`deprecated.replacement` a deprecated type declares are now asserted to offer the same
set of replacement types, one case per renderer (`div`, `span`).

Since objectui#6674 a deprecated type's migration guidance is stated twice — the notice
string literal in the renderer, and the `deprecated.replacement` on its registration,
transcribed from the notice by hand. Nothing held them together, so a reword of either
left the other stale, and the stale copy is the one an automated gate reads and repeats
to authors. There is no `tsc` here to notice one string literal drifting from another.

`packages/components/src/__tests__/deprecation-guidance-agreement.test.tsx` extracts the
double-quoted type names from each side — the notice's guidance BULLETS, and the
declaration's replacement line — and asserts set equality. Nothing about the guidance
text is restated in the test; a third copy of the sentence is the defect one layer up,
and per triage it is the signal to stop rather than the way to write this.

Shape ruled by triage (2026-08-29): assert the agreement, do not converge the two texts.
The notice wording is pinned byte-for-byte by four existing tests, and those pins are
themselves objectui#4000's ruling — converging would trade one ruled invariant for
another. No pin is touched here, and no published behaviour changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* 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.
*/

/**
* A deprecated type's migration guidance is stated TWICE, and this file is the
* only thing holding the two statements together (objectui#6823).
*
* 1. the human-facing console notice — `DIV_DEPRECATION_NOTICE` /
* `SPAN_DEPRECATION_NOTICE`, string literals inside the renderers, and
* 2. the machine-readable `deprecated.replacement` on the same file's
* registration, which objectui#6674 added so a gate can tell an author what
* to write instead.
*
* Both say the same thing today, in different words, because the second was
* transcribed from the first by hand. Nothing asserted they still agree, so a
* reword of either left the other stale — and the stale one is the copy an
* automated gate reads and repeats to authors, at a scale no console notice
* reaches. This is the shape objectui#4580 ruled about and objectui#6067 /
* #5671 / #5893 executed three times since; the unit here is a sentence rather
* than a type, and unlike those it fails SILENTLY, because there is no `tsc` to
* notice a string literal drifting away from another string literal.
*
* ## Why this ASSERTS the agreement instead of deriving one text from the other
*
* Deriving the notice's guidance line from the declaration is the direction
* that class of ruling usually takes, and triage weighed it here and ruled
* against it (2026-08-29, on objectui#6823). Those precedents were about TYPES,
* enforced by `tsc`, with no competing ruling on their wording. This sentence
* is pinned BYTE-FOR-BYTE by four existing tests —
* `div-deprecation-provenance`, `div-deprecation-warn-once`,
* `span-deprecation-provenance`, `span-deprecation-warn-once` — and those pins
* are themselves a deliberate maintainer ruling: objectui#4000 records that the
* guidance is "byte-for-byte what it was." Converging the two statements means
* MOVING those pins, i.e. trading one ruled invariant for another, which is a
* judged change of its own and not a rider on a drift-prevention card.
*
* The assertion below buys the whole drift protection without touching
* objectui#4000 at all.
*
* ## What is compared, and why it is EXTRACTED rather than restated
*
* Restating either text here would be a THIRD copy of the sentence — the very
* defect, one layer up, and the point at which this file would have to stop and
* escalate instead. So nothing below spells out any guidance text. Both
* statements are reduced, mechanically, to the thing they are both about: the
* set of component type names offered as the replacement, read out of each text
* as its double-quoted runs. That convention is one both texts already follow —
* a replacement type is named in double quotes on both sides — and it is the
* half that has to agree: prose framing may differ (the notice explains WHEN to
* reach for each, the declaration is one line for a gate to repeat), the
* alternatives on offer may not.
*
* Set equality, not containment, and that is deliberate: containment one way
* catches a name added to the declaration but not the notice, and misses the
* reverse. Drift has no preferred direction, so both directions are asserted at
* once.
*
* ## Scope — the notice's SCOPE sentence is deliberately not read here
*
* The `surfaces` / `isHtmlTierNode` half is already pinned to itself by
* objectui#6674, in the two provenance tests. Extraction below is confined to
* the notice's guidance BULLETS, so this file cannot start failing because that
* sentence was reworded — the bullets are what `deprecated.replacement`
* restates, and their layout is itself part of what objectui#4000's four pins
* hold in place.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

/**
* The component type names a piece of guidance offers: its double-quoted runs.
*
* Deliberately not a list of names kept here. A hard-coded list would be the
* third copy this file exists to avoid, and would go stale in exactly the way
* the two statements it compares can.
*/
function offeredTypeNames(guidance: string): Set<string> {
return new Set([...guidance.matchAll(/"([^"]+)"/g)].map((match) => match[1]));
}

/**
* The notice's migration guidance: its bullet lines, and only those.
*
* Everything else in the notice — the header naming the type, the SCOPE
* sentence objectui#6674 already pins elsewhere, the docs link — is out of
* scope for this comparison, and reading past the bullets is how this file
* would acquire a coupling to text that is not its business.
*/
function guidanceBullets(notice: string): string {
return notice
.split('\n')
.filter((line) => line.trimStart().startsWith('- '))
.join('\n');
}

/**
* Render one JSON-authored node of `type` and return the deprecation notices it
* emitted.
*
* The notice literal is module-private, as it should be — exporting it to test
* it would widen the package's surface for a test's convenience. It is read the
* way every reader reads it: off the console. The warn-once guard
* (objectui#3965) is a module-level Set per renderer module, so each type may be
* rendered in exactly one case of this file; the count assertion in each case is
* what would catch that rule being broken later.
*/
function deprecationNoticesFor(type: string): string[] {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const Component = ComponentRegistry.get(type);
if (!Component) throw new Error(`Component "${type}" is not registered`);
render(<Component schema={{ type }} />);
const deprecation = new RegExp(`The "${type}" component is deprecated`);
return warn.mock.calls
.map((args: unknown[]) => String(args[0]))
.filter((message) => deprecation.test(message));
}

/**
* One case's body, shared so both renderers are held to the identical check and
* a third deprecated type joins them in one line rather than by transcription —
* which is the failure mode this whole file is about.
*/
function expectGuidanceAndDeclarationAgree(type: string): void {
const notices = deprecationNoticesFor(type);
// Control FIRST: an agreement between two texts proves nothing if one of them
// was never produced. Zero notices (a renderer that stopped warning, a type
// that stopped resolving) would otherwise read as a pass on empty sets.
expect(notices).toHaveLength(1);

const bullets = guidanceBullets(notices[0]);
// Control: the notice really carries bullet guidance. A reflow that dissolves
// the bullets is a reword of the guidance, and it has to come back through
// here rather than silently emptying the comparison.
expect(bullets).not.toBe('');

const declared = ComponentRegistry.deprecationFor(type, 'json')?.replacement;
// Control: the declaration objectui#6674 added is still there and still says
// something. Without this, deleting `replacement` would turn this case green.
expect(declared).toBeTruthy();

const fromDeclaration = offeredTypeNames(declared as string);
// Control: the comparison is not vacuous. Guidance rewritten without quoted
// names on the declaration side would otherwise satisfy an empty-vs-empty
// equality while saying nothing at all.
expect(fromDeclaration.size).toBeGreaterThan(0);

const fromNotice = offeredTypeNames(bullets);

// The whole point: the alternatives the human is offered and the alternatives
// a gate would repeat are the same set. Reword either side's list — add,
// drop or rename one — and this goes red.
expect([...fromNotice].sort()).toEqual([...fromDeclaration].sort());
}

describe('deprecation guidance — the console notice and `deprecated.replacement` agree (#6823)', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('div: the notice offers exactly the alternatives the declaration names', () => {
expectGuidanceAndDeclarationAgree('div');
});

it('span: the notice offers exactly the alternatives the declaration names', () => {
expectGuidanceAndDeclarationAgree('span');
});
});
Loading