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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
72 changes: 72 additions & 0 deletions .changeset/cli-conversion-notice-one-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/cli": patch
---

refactor(cli): the ADR-0087 conversion notice has one source, and a gate that holds it (#13743)

**No output change.** The sentence `os build`, `os validate` and `os lint`
print for an ADR-0087 D2 conversion is byte-for-byte what it was. What changed
is that there is now exactly one copy of it, and a guard that keeps it that
way.

## Why a changeset at all

`@objectstack/cli` publishes `dist`, which is compiled from the edited `src`,
so this diff changes the published package even though it changes nothing an
author can observe. It is graded `patch` rather than skipped: nothing is added
to the package's public export surface (`src/utils/format.ts` is internal —
the package exports only `.` and `./console`), no CLI flag, payload key or
exit code moves, and no wording moves.

## What was duplicated

The human face of a conversion notice was written out three times, verbatim:

```
packages/cli/src/commands/compile.ts printWarning(`…`)
packages/cli/src/commands/lint.ts printWarning(`…`)
packages/cli/src/commands/validate.ts warnings.push(`…`)
```

Measured on the branch point: one distinct template literal across the three,
124 bytes each. They were held equal by convention alone. The parity guard in
`packages/cli/test/validate-build-gate-parity.test.ts` asserts that each
command **passes** an `onConversionNotice` sink to `normalizeStackInput` — it
never asserted they **say the same thing** once they have one, so a reword in
one command diverged from the other two with every gate green.

That matters more than ordinary duplication because the sentence is close to a
contract: a conversion rewrites the old shape and asks the author for nothing,
so this notice is the only warning they get before the conversion retires from
the load path and their metadata stops loading. An author who runs two of the
three commands over one tree is meant to be told the same thing in the same
words.

## What changed

`formatConversionNotice(notice)` in `src/utils/format.ts` is now the single
source of the sentence, and all three commands render through it.

It is a **formatter, not a printer**, and that is what makes one
implementation possible. The three call sites are genuinely not
interchangeable in what they DO with the string — `os build` and `os lint`
hand it to `printWarning` behind `!flags.json`, while `os validate` pushes it
into the `warnings` list that `--strict` then judges — but they were identical
in what they SAY. The whole difference lives in the disposition of the
returned string, so it costs the function no parameter.

The parity guard gains the rule it could not see: every authoring command
renders through the one formatter, and none spells the sentence out inline —
with a positive control, so it cannot go green on a CLI that says nothing at
all. `src/utils/format.conversion-notice.test.ts` pins the rendered sentence
itself.

## What is deliberately NOT unified

`ConversionNotice.message` (built in `packages/spec/src/conversions/apply.ts`)
and the `defineStack:` warning (`packages/spec/src/stack.zod.ts`) are two
further renderings of the same fields, in different registers and for
different audiences. Neither is touched here, and neither can read this
function — `@objectstack/cli` depends on `@objectstack/spec`, not the reverse.
Whether all of them should descend from one source is a separate question,
filed separately.
5 changes: 2 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
formatConversionNotice,
printAuthoringAdvisories,
printAuthoringRuleErrors,
printDocIssueErrors,
Expand DownExpand Up@@ -234,9 +235,7 @@ export default class Compile extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import {
printHeader,
printSuccess,
printWarning,
formatConversionNotice,
printError,
printInfo,
printStep,
Expand DownExpand Up@@ -576,9 +577,7 @@ export default class Lint extends Command {
if (conversionNotices.length > 0 && !flags.json) {
console.log('');
for (const n of conversionNotices) {
printWarning(
`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`,
);
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
Expand DownExpand Up@@ -406,7 +407,7 @@ export default class Validate extends Command {
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`);
warnings.push(formatConversionNotice(n));
}

// Every advisory the registry raised. All of them feed `--strict` now:
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/format.conversion-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { CONVERSION_NOTICE_CODE, type ConversionNotice } from '@objectstack/spec';
import { formatConversionNotice } from './format.js';

/**
* The VALUE pin for the ADR-0087 D2 conversion notice's human face (#13743).
*
* Its sibling in `test/validate-build-gate-parity.test.ts` is structural: it
* holds the three authoring commands to ONE formatter so no copy can drift.
* That rule says nothing about what the one source actually says — and the
* sentence is the point. A conversion rewrites the old shape and asks the
* author for nothing, so this notice is the only warning they get before the
* conversion retires from the load path and their metadata stops loading.
*
* ⭐ It is also the measurement that made the #13743 extraction safe to do at
* all. `os build`, `os validate` and `os lint` each carried a verbatim copy of
* this template; the copies were byte-identical (one distinct template literal
* across the three), so hoisting them onto one function changes no output. The
* expected string below is that literal's rendering, transcribed from the
* pre-extraction source — if the extraction had altered one byte, this fails.
*/
describe('formatConversionNotice (#13743)', () => {
const notice: ConversionNotice = {
code: CONVERSION_NOTICE_CODE,
conversionId: 'page-jsx-to-html',
surface: 'page.kind',
toMajor: 15,
retiresIn: 16,
from: 'jsx',
to: 'html',
path: 'pages[0].kind',
message: '[protocol] converted page.kind at pages[0].kind …',
};

it('renders the four fields an author needs, in the shipped wording', () => {
expect(formatConversionNotice(notice)).toBe(
"pages[0].kind: 'jsx' → 'html' (converted at load; conversion 'page-jsx-to-html', retires in protocol 16)",
);
});

/**
* The expiry is what separates this notice from an ordinary deprecation
* warning: it names the protocol major in which the source STOPS LOADING.
* Pinned separately from the whole-string assertion above so a future reword
* cannot drop it while still looking like a reword.
*/
it('always names the retiring major — the part that makes it actionable', () => {
expect(formatConversionNotice({ ...notice, retiresIn: 21 })).toContain('retires in protocol 21');
});

/**
* ⛔ NOT the notice's own `message`. `ConversionNotice.message` is a second,
* longer prose form built in `packages/spec/src/conversions/apply.ts`, and it
* is what the `--json` payloads carry under `conversions`. The text face has
* always rendered its own terser sentence from the structured fields instead.
* Pinned so the difference is a recorded fact rather than something the next
* reader discovers and "fixes" in one command only — which is exactly the
* divergence this card is about.
*/
it('is derived from the structured fields, not from notice.message', () => {
expect(formatConversionNotice({ ...notice, message: 'REPLACED' })).not.toContain('REPLACED');
});
});
42 changes: 41 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import { formatZodIssue, type ConversionNotice } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

Expand DownExpand Up@@ -1395,3 +1395,43 @@ export function printBulletList(
remedy: options.remedy,
});
}

// ─── ADR-0087 D2 conversion notices ─────────────────────────────────

/**
* The human face of one ADR-0087 D2 conversion notice — ONE implementation of
* that sentence, deliberately, for the same reason as
* {@link printTruncationNotice} above.
*
* The sentence is close to a contract. A conversion rewrites an old-shape key
* at load and asks the author for nothing, so this notice is the ONLY warning
* they get before the conversion retires and their metadata stops loading —
* and an author who runs two of the three authoring commands over one tree is
* meant to be told the same thing in the same words. It was written out three
* times, verbatim, in `compile.ts`, `validate.ts` and `lint.ts` (#13743), held
* equal by convention alone: the parity guard in
* `test/validate-build-gate-parity.test.ts` asserted that each command PASSES
* an `onConversionNotice` sink, never that they SAY the same thing once they
* have one — so a reword in one command drifted from the other two with every
* gate green.
*
* ⛔ A formatter, not a printer, and that is what makes one implementation
* possible at all. The three call sites are genuinely not interchangeable in
* what they DO with the string — `os build` and `os lint` hand it to
* {@link printWarning} behind `!flags.json`, while `os validate` pushes it
* into the `warnings` list that `--strict` then judges — but they were
* byte-identical in what they SAY (measured: one distinct template literal
* across the three). The whole difference lives in the disposition of the
* returned string, so it costs this function no parameter.
*
* ⛔ NOT the `defineStack` face, which is a fourth rendering of these same
* fields and deliberately a different sentence: `warnConversionNotice` in
* `packages/spec/src/stack.zod.ts` adds a `defineStack:` prefix and a trailing
* "update the source" instruction, because that seam warns once per process
* while an author is composing, not inside a command's report. It also cannot
* import this: `@objectstack/cli` depends on `@objectstack/spec`, not the
* reverse. Hoisting all four onto one source is a separate question.
*/
export function formatConversionNotice(notice: ConversionNotice): string {
return `${notice.path}: '${notice.from}' → '${notice.to}' (converted at load; conversion '${notice.conversionId}', retires in protocol ${notice.retiresIn})`;
}
81 changes: 80 additions & 1 deletion packages/cli/test/validate-build-gate-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,26 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {

const sourceOf = (file: string) => readFileSync(join(COMMANDS_DIR, file), 'utf8');

const UTILS_DIR = join(__dirname, '..', 'src', 'utils');

/**
* The three authoring commands, as one list. Named once so a rule below cannot
* quietly cover a subset of the class it describes — the #12297 failure the
* sink guard at the bottom of this file records.
*/
const AUTHORING_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts', 'lint.ts'];

/**
* The prose fingerprint of the ADR-0087 D2 conversion notice — the part of the
* sentence that is neither interpolation nor punctuation, so it survives a
* rename of the loop variable and does NOT survive a reword. Matching on this
* rather than the whole template is deliberate: a divergence that only reworded
* the tail would still be caught by the formatter-call assertion, and a
* whole-template match would go vacuously green the day someone reflowed a
* line.
*/
const NOTICE_PROSE = 'converted at load; conversion';

/** Every `lintFoo(`/`validateFoo(` call site in a command's source. */
function gateCallsIn(file: string): Set<string> {
const calls = sourceOf(file).match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [];
Expand DownExpand Up@@ -154,7 +174,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
* sink is dropped, which is the moment it is cheap to fix.
*/
it('all three authoring commands pass a conversion-notice sink to normalizeStackInput', () => {
for (const file of ['compile.ts', 'validate.ts', 'lint.ts']) {
for (const file of AUTHORING_COMMANDS) {
const src = sourceOf(file);
const call = src.match(/normalizeStackInput\([\s\S]{0,400}?\)\s*;/);
expect(call, `${file} must call normalizeStackInput`).not.toBeNull();
Expand All@@ -166,4 +186,63 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', ()
).toBe(true);
}
});

/**
* The same drift again, one step past the sink: not "does this command hear
* the notice" but "does it SAY THE SAME THING once it has one".
*
* ⭐ [#13743] The sink guard above is blind here by construction. It asserts
* each command PASSES an `onConversionNotice` sink; once all three had one,
* each rendered the sentence from its own verbatim copy of the template, held
* equal by convention alone. A reword in one command diverged it from the
* other two and EVERY GATE STAYED GREEN — including this file, which is the
* one place that would have been expected to notice.
*
* That sentence is close to a contract: a conversion asks the author for
* nothing at load, so the notice is the ONLY warning they get before the
* conversion retires and their metadata stops loading. An author who runs two
* of the three commands over one tree must be told the same thing in the same
* words.
*
* The rule is therefore structural rather than comparative — the three
* copies are gone, and what is asserted is that they cannot come back: every
* authoring command renders through the ONE formatter, and none of them
* spells the sentence out inline. Comparing three literals for equality would
* have locked today's three copies together while leaving a fourth free to
* appear; requiring the single source forecloses both.
*/
it('all three authoring commands render the conversion notice through ONE formatter', () => {
// Positive control FIRST: the sentence must still exist in the formatter.
// Without this, deleting `formatConversionNotice` and every inline copy
// would satisfy every "no inline copy" assertion below — a rule that is
// green precisely when the notice has been silenced.
const formatter = readFileSync(join(UTILS_DIR, 'format.ts'), 'utf8');
expect(
/export function formatConversionNotice\b/.test(formatter),
'src/utils/format.ts must export formatConversionNotice — if it moved, move this guard with it.',
).toBe(true);
expect(
formatter.includes(NOTICE_PROSE),
`src/utils/format.ts no longer carries the notice wording ("${NOTICE_PROSE}"), so the ` +
`assertions below would pass vacuously on a CLI that says nothing at all.`,
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
expect(
calls(file, 'formatConversionNotice'),
`${file} must render its ADR-0087 D2 conversion notices with formatConversionNotice() ` +
`from src/utils/format.ts. The three commands dispose of the string differently — ` +
`os build and os lint print it, os validate pushes it into the --strict warnings list ` +
`— but they must SAY the same thing, so the sentence has exactly one source.`,
).toBe(true);
expect(
sourceOf(file).includes(NOTICE_PROSE),
`${file} spells the ADR-0087 D2 conversion notice out inline instead of calling ` +
`formatConversionNotice(). That is the #13743 divergence: this sentence is the only ` +
`warning an old-shape author gets before the conversion retires and their metadata ` +
`stops loading, and a copy here drifts from the other commands silently. Edit the ` +
`wording in src/utils/format.ts, where all three read it.`,
).toBe(false);
}
});
});
Loading