Uh oh!
There was an error while loading. Please reload this page.
refactor(cli): render the ADR-0087 conversion notice from one source, and gate it there - #15096
Conversation
The human face of an ADR-0087 D2 conversion notice was written out three times, verbatim, in os build / os validate / os lint. The three template literals were byte-identical (measured: one distinct literal across the three) and held equal by convention alone — the parity guard asserted each command PASSES an onConversionNotice sink, never that they SAY the same thing once they have one, so a reword in one command diverged silently with every gate green. Hoist the sentence into formatConversionNotice() in src/utils/format.ts and render all three through it. It is a formatter, not a printer, which is what makes one implementation possible: the three call sites genuinely differ in DISPOSITION (build/lint print behind !flags.json, validate pushes into the --strict warnings list) but not in what they say, so the difference costs the function no parameter. Output is byte-identical. Extend packages/cli/test/validate-build-gate-parity.test.ts with the rule the old guard 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 pass vacuously on a CLI that says nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…nsolidation Part of #13743. `@objectstack/cli` publishes `dist`, compiled from the edited `src`, so this diff changes the published package even though it changes nothing an author can observe — `patch`, not `skip-changeset`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…arity Brings in PR #15062 (`os i18n check --help`), which lands in packages/cli while this branch is open — the dispatch named it as the one to merge and re-verify against. Also #15065 (rest), #15063/#15067 (docs) and #15055 (spec). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
📓 Docs Drift CheckThis PR changes 1 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 48aac740974ea994794664b7dce221ed4dfb15b1 && git checkout 48aac740974ea994794664b7dce221ed4dfb15b1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab47816914fd39709f4e9645370729ce1f4f9826 9fc66fafafab958e3ae03b0f43bf8989c9b72699 && git checkout -B drift-repro ab47816914fd39709f4e9645370729ce1f4f9826 && git merge --no-ff 9fc66fafafab958e3ae03b0f43bf8989c9b72699
node scripts/docs-audit/affected-docs.mjs --json ab47816914fd39709f4e9645370729ce1f4f9826
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13743
The ADR-0087 D2 conversion notice now has one source in
packages/cli, and the parityguard holds it there. Output is byte-for-byte unchanged.
Which of triage's three options, and why
Option 1 — extract a shared formatter — carrying the smallest useful half of option 2.
Triage required this to be stated rather than defaulted, and the deciding input was a
measurement rather than a preference.
⭐ Option 3's supporting argument is falsified. Its case was that
validate.tsfoldsits copy into a
warningslist--strictjudges while the other two print directly, so"the three call sites are not fully interchangeable, and forcing an extraction may need a
parameter for the difference." The first clause is true and I verified it; the second does
not follow. The asymmetry is entirely in the disposition of the string, never in the
string:
os build(compile.ts)printWarning(...)behind!flags.jsonos lint(lint.ts)printWarning(...)behind!flags.jsonos validate(validate.ts)warnings.push(...), which--strictthen judgesMeasured at the branch point
d17f352b1b: extracting the template literal from each of thethree lines and hashing it gives one distinct value, 124 bytes, sha256 prefix
7197b905060762c3. A pureformatConversionNotice(notice)therefore serves all three andcosts no parameter for the difference — the extraction option's stated price does not
materialise.
Why not option 2 alone. A comparator locks today's three copies together and detects a
reword only after someone has written it; one source makes the divergence impossible to
write. It is also the disposition this file already reached for the same problem:
printTruncationNoticein the same module carries the note "ONE implementation of thatsentence, deliberately … nine copies of the wording would be nine chances for them to drift
apart." House precedent, not a new idea.
What option 2 still buys, and is kept. Extraction does not stop a copy coming back.
The rule added to
validate-build-gate-parity.test.tsis therefore structural rather thancomparative: every authoring command renders through the one formatter, and none spells the
sentence out inline.
Re-derived the count — and the probe returned more than three
The release comment required a bound with a control that could have returned a fourth. It
did. A whole-repo
git grep(no pathspec) for the prose fragmentconverted at load—tuned to the sentence, not to the three known sites — finds a rendering the card does not
mention, in
packages/spec/src/stack.zod.ts:and reading that seam turns up a fifth prose form on
ConversionNotice.message(
packages/spec/src/conversions/apply.ts), which is what the--jsonpayloads publish.⇒ The card's "three" is correct as a count of the three authoring commands, and wrong as
a bound on the wording. Four renderings, three distinct sentences. The two in
packages/specare fenced read-only on this card and cannot import the CLI's formatteranyway (
@objectstack/clidepends on@objectstack/spec, not the reverse), so they arefiled separately as #15095 and pinned here as a recorded fact rather than left for the next
reader to discover and "fix" in one command only.
The change
packages/cli/src/utils/format.ts—formatConversionNotice(notice), a formatter anddeliberately not a printer.
compile.ts/lint.ts/validate.ts— render through it; the three inline literalsare gone (
grep -cfor the prose in each command source: 0, 0, 0).packages/cli/test/validate-build-gate-parity.test.ts— the rule the old guard could notsee, with a positive control that fails if the formatter stops carrying the sentence.
packages/cli/src/utils/format.conversion-notice.test.ts— the value pin on the renderedsentence, plus the recorded form-1 / form-3 split.
.changeset/cli-conversion-notice-one-source.md—patch. Measured from this package'sown
filesfield:@objectstack/clipublishesdist, compiled from the editedsrc, sothe diff changes the published package even though it changes nothing an author can
observe.
skip-changesetwould have been wrong.Red-first — four arms, each naming a different assertion
Every mutation was committed-first, proved on disk by blob hash, and restored by blob-hash
equality against the HEAD blob under
trap ... EXIT INT TERMon absolute paths. The guardis a source-text scanner, so no build or
diststep participates in any arm.lint.ts's copy, dropping the shared formattervitest exit 1—lint.ts must render its ADR-0087 D2 conversion notices with formatConversionNotice(); 1 failed / 8 passedvitest exit 1—lint.ts spells the ADR-0087 D2 conversion notice out inline; 1 failed / 8 passedformat.tsvitest exit 1— control fires, and the pin reportsconverted while loadingwhereconverted at loadwas expected; 2 failed / 10 passedvitest exit 0— 2 files, 12 tests passedBlob evidence, arm by arm:
lint.tsHEAD blob43ace89d…; mutated to82ef231d…(A) anda25e93f7…(B); restored to43ace89d…withgit diff HEADempty each time.format.tsHEAD blob
53d51fe1…; mutated to80839a7e…(C); restored to53d51fe1…. Finalgit status --porcelain -- packages/cliwas empty.Arm A is precisely the check the dispatch asked for — reword one copy and show the guard
redden — and arms B and C exist because A alone would not have told you which assertion
fired.
Verification
At final HEAD
9fc66fafon a clean tree, after mergingorigin/main(which brought inPR #15062, the armed sibling the dispatch warned about, plus four others):
pnpm --filter @objectstack/cli typecheck— exit 0. Both halves:tsc --noEmitoversrc(which is where the new value pin lives, so it is type-checked), andcheck:test-typecheckovertsconfig.test.json, whose verdict line readsOK — @objectstack/cli's test layer compiles … 3 file(s) / 28 error(s) / 6 pinned signature(s) held— the pre-existing ledger, unmoved.conversion-notice e2e suites (
lint-conversion-notices,validate-json-failure-conversions,build-json-failure-conversions— these drive thereal CLI over a live conversion via
tsx, so they are the behavioural proof the outputdid not move), and
i18n-check-help-surface-parityfrom the merged sibling PR.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat that HEAD: 42 families (33 by path, 6 by change kind, 5 declared whole-tree).
40 green. Two are NOT MEASURED and say so themselves:
check-test-completeness.mjsgrades a saved
turbo run testlog this box cannot produce ("the local reading for thisgate is NOT MEASURED"), and
check-half-states.mjsneeds repo-scoped GitHub REST, whichthis container is refused (
GET /repos/...returns 403 with no rate-limit headers whileGET /rate_limitreports 15000 left). Five others first exited 3 on an unbuilt closureand went green after a full
pnpm build:check:dual-build-cjs-loads,check:i18n,check:i18n-coverage,check:type-check-debt(which had also hit a local 300s cap), andthe two named above stayed 3 for the reasons given.
Declared narrowing. The repo-wide
pnpm lintsweep was not run locally; CI owns it andruns it once regardless. Everything else in the derived union was run.
What this deliberately does not do
ConversionNotice.messageanddefineStack'swarnConversionNoticeare untouched. Whetherall of the renderings should descend from one source is a real question with a real cost on
either side, and it is #15095, not this card.
🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code
Generated by Claude Code