Skip to content

Commit 8b48903

Browse files
os-litantclaude
andauthored
feat(spec): ship a per-release section in spec-changes.json, verified against both tarballs (#18889)
Fixes #17080 Clause-②: yes (widening) Ruled **A** (issue comment 5643392133, decision batch #119 item 2, maintainer 「同意」 2026-09-12): minors ship real, machine-readable change data, with a correctness gate. All four execution clauses land here. ## The defect, re-measured from the published tarballs `npm pack @objectstack/spec@17.3.0 @objectstack/spec@17.4.0`, then the repo's own export-surface row convention (`ENTRY: NAME (KIND)` — entry point, export name, export kind — the spelling `build-spec-changes.ts` flattens to): | | 17.3.0 | 17.4.0 | |---|---|---| | export-surface rows | 5259 | 5433 | | **true delta** | — | **225 added, 51 removed** | | `spec-changes.json` → `aggregate.added` / `.removed` | 0 / 0 | **0 / 0** | | `protocolVersion` | 17.0.0 | 17.0.0 | | `perMajor[16→17]` | converted 58, migrated 77 | converted 57, migrated 77 | | `[REMOVED]` prescriptions in `json-schema/**` | 156 | 221 | Every figure the card reports reproduced. The shipped manifest answered `0 / 0` over a release that moved 276 exports, and a consumer reading semver sees a minor. ## What lands **1 · A per-release section, shipped in the tarball.** `spec-changes.json` gains `release` — `fromVersion` → `toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived and left, each named) and `converted` / `migrated` (the ADR-0087 D2/D3 entries first registered in that release). Generated at **publish time only**, from the previously published tarball: the committed copy stays the deterministic registry projection and `check:spec-changes` is green against it, which is how the determinism concern is answered rather than traded away. **2 · The correctness gate, as acceptance.** Before anything reaches npm, the release lane packs the artifact it is about to publish and recomputes the delta from **the two tarballs**, with its own reader — `scripts/check-release-spec-changes.mjs` deliberately does not import the generator's flattening, because an instrument that shares the code it audits reports agreement with itself. A mismatch fails the release, naming the disagreeing exports and the **direction** of each disagreement (claimed-but-not-real, real-but-unclaimed, per array) plus the command that regenerates the section. A held release arrives with its own diagnosis. **3 · `os validate --json` reads the same data.** New key `specReleaseChanges` — `fromVersion`, `toVersion`, the four counts and the file it read. It is a **sibling** of `protocolVersionGap`, not a widening of it: that key means "the platform on disk is outside the range you declared", and a consumer gating CI on it must not start failing because an ordinary minor shipped exports. One key, one question. (Note `specVersionGap` is spelled `protocolVersionGap` in this tree — it was renamed by #14261, which is still an unreleased changeset; the ruling's clause 3 names the old spelling.) **4 · Published payload change.** `Clause-②: yes (widening)`, contract-review carrier, changeset carrying the migration-free declaration, and `content/docs/upgrading.mdx` gains *What the installed artifact tells you, without a second worktree*. ## Absence is not zero The section is **omitted, loudly**, when the previous tarball ships no export snapshot or no manifest — an empty `release` is indistinguishable from "this release changed nothing", which is the misreading the whole section exists to end. The gate derives the same condition from the same artifacts, accepts that absence, and **refuses** a section that is present when it could not have been computed. `specReleaseChanges` is `null` in exactly those cases. ## Verification Real artifacts, not fixtures — `npm pack` of published 17.3.0, `pnpm pack` of this tree: ``` generate --previous-package (the unpacked published 17.3.0) -> pack -> gate ✓ release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated. ``` ⚠️ **Corrected by the `domain:spec` seat** — this line read 「374 added」 when the PR opened. `origin/main` has since moved the export surface, and **386** is the number measured at the current head `1737a24b510` from a real `npm pack` of the published 17.3.0, independently confirmed by the at-tier review at the previous head. `os-dev.md:56` reserves this body to the PR-open write, so the round named the number and the seat writes it. **Ablation, on the real artifact.** One export dropped from `release.added` in the packed manifest (mutation proven on disk: sha256 `bc21927e…` → `3bbff365…`), restored under `trap … EXIT INT TERM`, hash verified equal after: ``` exit 1 ✗ the per-release section of spec-changes.json disagrees with the two tarballs (ADR-0087 D4). A wrong change file is worse than none — a consumer gates its upgrade on this data. release.added: 1 export(s) the two tarballs show as added and the section OMITS: ./ai: BUILD_PROGRESS_FRAME_TYPE (const) Regenerate with: pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts --previous-package PREV_PACKAGE_DIR [the real line spells the placeholder in angle brackets] ``` Gates and tests, exit codes captured by redirect: - `pnpm check:release-spec-changes` — 15 batteries (roster + floor + verdict handshake), exit 0. The real run needs two published tarballs, so the self-test is what a PR can run; it drives the same `verifyRelease()` the release lane calls. - `pnpm --filter @objectstack/spec check:generated` — **all 16 artifacts up to date** after `gen:api-surface` + `gen:export-origins` + `gen:api-surface-declarations` (7 new declarations on the root entry, 0 removed). ⚠️ **Corrected by the `domain:spec` seat: this read 「all 15」 when the PR opened.** The count moved because #18971 (`d8b12fca97c`) registered `check:api-surface-declarations` as a new generated artifact family. The gate now prints 「Checking 16 generated artifacts」. ⛔ The 15 was not wrong when written — the base moved under it. `os-dev.md:56` reserves this body to the PR-open write, so the round named the number and the seat writes it. - `pnpm --filter @objectstack/spec exec vitest run --project local src/migrations/migrations.test.ts` — 137 passed. - `pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/spec-release-changes.test.ts` — 6 passed. - `pnpm --filter @objectstack/spec typecheck` — exit 0. `pnpm --filter @objectstack/cli typecheck` — exit 0 (after building the runtime closure the CLI's test project reads; its first run reported only `TS2307 Cannot find module` for packages this worktree had not built). - `pnpm check:entry-guard` — exit 0 (265 files, 205 exporters inert on import). This one caught a real defect in the new gate: it exports `verifyRelease` and dispatched at the top level, so importing it would have run it. Fixed with `isEntrypoint`. - **Gate families**, derived by `scripts/pm/dispatch-gates.mjs` from the diff and reconciled with `--ran`: **153 derived, 147 run** (146 exit 0), **4 NOT MEASURED** (`check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:dual-build-cjs-loads` — each exits 3, PREREQUISITE NOT MET, wanting a full repo build), **2 unrun** (`check:pm-dispatch-gates`, whose own header forbids running it in an agent container's foreground; `check:type-check-debt`, repo-wide `tsc`, killed by this runner's timeout). `check:spec-changes` and `check:skill-examples` deserve a word each: the first is **green**, which is the determinism half of clause 1; the second exits 1 saying *"packages/client-react/dist holds no .d.ts — the package is not built"*, so it is NOT MEASURED, not a finding. None of the six touches this diff's subject, and CI builds fresh. ## Acceptance notes - **A withdrawn conversion is not reported by `release.converted`.** Between 17.3.0 and 17.4.0 `perMajor[16→17].converted` went 58 → 57: `field-required-notnull-explicit` left the published chain. The registry records that as a deliberate withdrawal (`⛔ WITHDRAWN — there is deliberately NO field-required-notnull-explicit`), so this is documented behaviour, not drift. The section reports entries a release **added**, which is the four arrays ADR-0087 D4 names; an id that disappeared is visible only by comparing two published manifests, and the code says so rather than implying otherwise. - **The tombstone trap the card names is not closed by this change, and this change does not claim to close it.** Of the 65 new `[REMOVED]` prescriptions between 17.3.0 and 17.4.0, exactly one names 17.4.0 as the retiring release. `release.removed` answers "which exports left in this release" exactly; it says nothing about which *prescriptions* were written in it, because the prescription text carries no retirement version in data. A consumer still cannot tell 「retired in X」 from 「prescription written in Y」 from the tombstones alone. - The card's *"every conversion entry carries `toMajor: 17`"* holds inside `perMajor[16→17]`; the aggregate carries `toMajor` values 11, 13, 14, 15 and 17. The substance — no resolution finer than a major — reproduced. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43f4766 commit 8b48903

20 files changed

Lines changed: 1574 additions & 62 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(spec): `spec-changes.json` ships a per-release section, verified against both tarballs (#17080)
7+
8+
Clause-②: yes (widening) — one new OPTIONAL section on a published artifact plus one new
9+
`os validate --json` key. Nothing previously present is renamed, retired or reshaped: the
10+
`aggregate` and `perMajor` records and every existing key keep their spelling and meaning.
11+
Contract-review tier.
12+
13+
`spec-changes.json` (ADR-0087 D4) is keyed to the **protocol major**, while this repo's
14+
launch-window convention ships BREAKING entries as **minors**. A consumer crossing one minor
15+
therefore reads a file whose finest question is "16 → 17" — answered long ago — with
16+
`added: 0, removed: 0`, which reads as *nothing changed*. Measured on the published tarballs:
17+
between `@objectstack/spec@17.3.0` and `17.4.0` the export surface gained **225** exports and
18+
lost **51**, and the shipped manifest reported zero of each.
19+
20+
**What ships now.** The published artifact carries a `release` section — `fromVersion` →
21+
`toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived
22+
and left, each named `"<entry>: <name> (<kind>)"`) and `converted` / `migrated` (the ADR-0087
23+
D2/D3 entries first registered in that release):
24+
25+
```bash
26+
jq '.release | {fromVersion, toVersion, added: (.added | length), removed: (.removed | length)}' \
27+
node_modules/@objectstack/spec/spec-changes.json
28+
os validate --json | jq .specReleaseChanges # the same data, via the CLI
29+
```
30+
31+
**The committed copy is unchanged and stays deterministic.** The section is a function of a
32+
previously *published* tarball, so it is generated at publish time only; `check:spec-changes`
33+
keeps the registry-only projection in the tree exactly as it was.
34+
35+
**A wrong change file is worse than none, so it is gated.** Before anything reaches npm the
36+
release lane recomputes the delta from the two tarballs — the previously published one and the
37+
one about to be published — and refuses to publish when the section disagrees, naming the
38+
disagreeing exports and the direction of each disagreement. A release whose data would mislead
39+
does not ship.
40+
41+
**Absence stays distinguishable from zero.** When the previous tarball carries no export
42+
snapshot the section is omitted rather than emitted empty, and `specReleaseChanges` is `null`
43+
in exactly that case: a consumer must never read "could not be computed" as "nothing changed",
44+
which is the defect this closes.
45+
46+
New public exports on `@objectstack/spec`: `SpecReleaseChangesSchema`,
47+
`SpecReleaseSurfaceSchema`, `composeReleaseChanges`, and the types `SpecReleaseChanges`,
48+
`SpecReleaseSurface`, `PreviousReleaseRegistries`, `ReleaseSurfaceDiff`.

‎.github/workflows/lint.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,18 @@ jobs:
943943
- name: A declared gate population reaches the tree
944944
run: pnpm check:declared-population-live
945945

946+
# ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs
947+
# two published tarballs and npm, so it lives in the release lane and
948+
# cannot run on a PR; what runs here is its `--self-test`, which is
949+
# therefore the only thing standing between an edit to that gate and the
950+
# next release trusting it. Its batteries drive the same `verifyRelease()`
951+
# the release lane calls, over synthetic tarball trees: a section matching
952+
# both artifacts, one omitting a real export, one inventing an export, a
953+
# from/to version pointing at another release, and the two absences that
954+
# are legitimate. Fixture-only, no spawns; well under a second.
955+
- name: Per-release spec-changes gate self-test
956+
run: pnpm check:release-spec-changes
957+
946958
# PM bare-root worklist self-test (#10840). The step above proves the
947959
# dispatch derivation still WORKS; this one proves the recorded triage of
948960
# the gates that derivation structurally cannot see is still true of the

‎.github/workflows/release.yml‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -913,11 +913,19 @@ jobs:
913913
- name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4)
914914
# Ordering is load-bearing: `gh release upload` needs the Release the
915915
# step above creates.
916+
#
917+
# `--prepare` regenerates the manifest against the previously published
918+
# tarball exactly as the publish lane does, so the asset this repair
919+
# uploads carries the same per-release section the npm artifact does.
920+
# It cannot repair the npm artifact itself — that tarball is immutable —
921+
# and this lane never publishes one.
916922
if: steps.audit.outputs.releases-missing == 'true'
917923
env:
918924
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
919925
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
920-
run: bash scripts/release-spec-changes.sh
926+
run: |
927+
bash scripts/release-spec-changes.sh --prepare
928+
bash scripts/release-spec-changes.sh --attach
921929
922930
# ══════════════════════════════════════════════════════════════════════════
923931
# HUMAN LANE — the ONLY job in this repository that publishes.
@@ -1222,6 +1230,35 @@ jobs:
12221230
12231231
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec. ADVISORY ONLY — the publish continues. HOTCRM_REF is pre-v17; ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate."
12241232
1233+
# ──────────────────────────────────────────────────────────────────────
1234+
# ADR-0087 D4 — the per-release section, and the gate that proves it.
1235+
#
1236+
# Both steps run BEFORE `changeset publish`, and that ordering is the
1237+
# whole design. The section has to be in the tree when the tarball is
1238+
# packed (a consumer's tooling reads `node_modules`, not a Release page),
1239+
# and a delta that disagrees with the artifacts must stop the release
1240+
# while stopping it is still free — after `changeset publish` the tarball
1241+
# is immutable and the only remaining repair is another version.
1242+
#
1243+
# `--no-git-checks` is what `changeset publish` passes to `pnpm publish`
1244+
# (its own source), so the working-tree edit `--prepare` makes does not
1245+
# block the publish. The committed copy is untouched: it stays the
1246+
# registry-only projection `check:spec-changes` gates on every PR.
1247+
# ──────────────────────────────────────────────────────────────────────
1248+
- name: Generate the per-release spec-changes section (ADR-0087 D4)
1249+
env:
1250+
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
1251+
run: bash scripts/release-spec-changes.sh --prepare
1252+
1253+
- name: Correctness gate — the delta must match both tarballs
1254+
# ⛔ A failure here fails the release, by design (#17080): a wrong change
1255+
# file is worse than none, because a consumer stops looking once it has
1256+
# one. The gate names the disagreeing exports and the direction of each
1257+
# disagreement, so a held release arrives with its own diagnosis.
1258+
env:
1259+
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
1260+
run: bash scripts/release-spec-changes.sh --verify
1261+
12251262
# ──────────────────────────────────────────────────────────────────────
12261263
# The publish itself. `pnpm run release` = build + build-console +
12271264
# scripts/release-publish.sh, which is `changeset publish` followed by ONE
@@ -1312,7 +1349,10 @@ jobs:
13121349
env:
13131350
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13141351
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
1315-
run: bash scripts/release-spec-changes.sh
1352+
# Uploads the file the two steps above generated and verified — the
1353+
# Release asset and the npm artifact are the same bytes by construction,
1354+
# not by two runs agreeing.
1355+
run: bash scripts/release-spec-changes.sh --attach
13161356

13171357
# ══════════════════════════════════════════════════════════════════════════
13181358
# Runtime image — fed by either lane. Building an image for a version that is

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,7 @@ examples/app-crm/storage/
129129
.claude/worktrees/
130130
# an agent worktree is git plumbing, never repo content: a `.git` FILE reading `gitdir:` plus a whole second checkout
131131
.worktrees/
132+
133+
# The release lane's scratch space: the previously published tarball it unpacks
134+
# and the artifact it packs to verify the ADR-0087 D4 per-release section.
135+
.release-spec-changes/

‎content/docs/upgrading.mdx‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,71 @@ one checklist per major, which is what those rows link to.
315315

316316
The [release notes overview](/docs/releases) summarizes what each major changed.
317317

318+
### What the installed artifact tells you, without a second worktree
319+
320+
The release pages are written for a human. The same delta ships **inside the
321+
package**, for your tooling: `@objectstack/spec` carries a `spec-changes.json`,
322+
and from the release that follows 17.4.0 on it carries a **`release`** section
323+
describing the release you actually installed — `from` → `to` at the package version, not at the protocol
324+
major.
325+
326+
```bash
327+
# What did the release I just installed change?
328+
jq '.release | {fromVersion, toVersion,
329+
added: (.added | length), removed: (.removed | length),
330+
converted: (.converted | length), migrated: (.migrated | length)}' \
331+
node_modules/@objectstack/spec/spec-changes.json
332+
```
333+
334+
`added` and `removed` are the public exports that arrived and left, each one
335+
named — `"./ai: AgentSchema (const)"`, the entry point followed by the export
336+
and its kind. `converted` and `migrated` are the ADR-0087 conversions and
337+
semantic migrations first registered in that release. The same file's
338+
`aggregate` and `perMajor` records are unchanged and still answer the
339+
major-boundary question.
340+
341+
The `os` CLI reads the same section, so a CI job does not have to know the file
342+
exists:
343+
344+
```bash
345+
os validate --json | jq .specReleaseChanges
346+
```
347+
348+
Three properties worth relying on:
349+
350+
- **The section describes a release, not a major.** Every entry in `added`
351+
arrived in `toVersion` and every entry in `removed` left in it. That is the
352+
one question the `perMajor` records cannot answer, and it is the question a
353+
minor upgrade asks.
354+
- **A missing section is not an empty one.** The key is absent when the delta
355+
could not be computed — a release published before this section existed, or
356+
one whose predecessor shipped no export snapshot. `os validate --json` reports
357+
`null` in exactly those cases. ⛔ Do not read an absent section as "nothing
358+
changed"; read it as "this artifact does not say".
359+
- **It is verified against the artifacts before it ships.** The release lane
360+
recomputes the delta from the previously published tarball and the one about
361+
to be published, and a release whose section disagrees with them does not
362+
publish. The numbers are as true as the two tarballs are.
363+
364+
<Callout type="warn">
365+
**What it does not tell you.** The export surface is the shape of the API, not
366+
its behaviour: a release can narrow what a value is allowed to be, or start
367+
enforcing a constraint that was declared and inert, without adding or removing a
368+
single export. A delta of `0 added, 0 removed` is a real measurement of the
369+
export surface and says nothing about the accept-sets behind it — the release
370+
checklist above is still the record for those.
371+
372+
**And it does not report withdrawals.** All four arrays report what a release
373+
**added**: `converted` and `migrated` list the ADR-0087 ids *first registered*
374+
in it. An id that **left** the published chain between the two releases is in
375+
none of them — `converted: []` means "this release registered none", never
376+
"none was withdrawn". ADR-0087 D4 names these four arrays and this section
377+
carries exactly those four; to see a withdrawal, compare the `aggregate`
378+
records of the two installed manifests (`.aggregate.converted[].conversionId`
379+
and `.aggregate.migrated[].migrationId`) — an id present in the older one and
380+
absent from the newer one was withdrawn.
381+
</Callout>
382+
318383
### The per-package changelogs are the exhaustive record
319384

320385
The release pages above are **triaged**, deliberately: a change is written up

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"check:agent-test-spelling": "node scripts/check-agent-test-spelling.mjs --self-test && node scripts/check-agent-test-spelling.mjs",
7272
"check:ratchet-remedy-authority": "node scripts/check-ratchet-remedy-authority.mjs --self-test && node scripts/check-ratchet-remedy-authority.mjs",
7373
"check:merged-result": "node scripts/check-merged-result.mjs --self-test",
74+
"check:release-spec-changes": "node scripts/check-release-spec-changes.mjs --self-test",
7475
"check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs",
7576
"check:pm-skill-id-lint": "node scripts/pm/check-skill-id-lint.mjs --self-test && node scripts/pm/check-skill-id-lint.mjs",
7677
"check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs",

‎packages/cli/src/commands/validate.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
isReportedError,
4545
} from '../utils/format.js';
4646
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
47+
import { readSpecReleaseChanges } from '../utils/spec-release-changes.js';
4748
// [#14553] The navigation-contribution group check, shared with `os compile`.
4849
// Reports; never refuses — the runtime still relocates, deliberately.
4950
import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js';
@@ -590,6 +591,14 @@ export default class Validate extends Command {
590591
// point at the migration guide.
591592
const protocolGap = checkProtocolVersionGap(config.manifest);
592593

594+
// The minor-resolution half of the same question. `protocolGap` is null
595+
// for an app on `^17` running spec 17.4.0 — compatible at the major, and
596+
// silent about a release that narrowed accept-sets under the launch-window
597+
// convention. This reads the installed artifact's own per-release delta
598+
// (ADR-0087 D4), so `--json` answers "what moved in the release I have"
599+
// without a second worktree and a hand diff of two node_modules trees.
600+
const specReleaseChanges = readSpecReleaseChanges();
601+
593602
// 4b. Structural advisories (non-blocking) — computed HERE, above the
594603
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
595604
// is computed up beside `normalized`: everything below that branch only
@@ -696,6 +705,12 @@ export default class Validate extends Command {
696705
// rename is one stroke, no alias, no dual-key window; its value
697706
// shape is unchanged.
698707
protocolVersionGap: protocolGap,
708+
// A sibling key, deliberately, rather than a widening of the one
709+
// above: `protocolVersionGap` non-null means "the platform on disk
710+
// is outside the range you declared", and a consumer gating CI on
711+
// that must not start failing because an ordinary minor shipped
712+
// exports. One key, one question.
713+
specReleaseChanges,
699714
duration: timer.elapsed(),
700715
},
701716
// `--strict` means one thing — "treat warnings as errors" — and it now
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
7+
import { afterAll, describe, expect, it } from 'vitest';
8+
9+
import { readSpecReleaseChanges } from './spec-release-changes.js';
10+
11+
const dir = mkdtempSync(join(tmpdir(), 'spec-release-changes-'));
12+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
13+
14+
let seq = 0;
15+
/** Write a `spec-changes.json` the reader will be pointed at, and return its path. */
16+
function manifest(doc: unknown): string {
17+
const path = join(dir, `spec-changes-${(seq += 1)}.json`);
18+
writeFileSync(path, typeof doc === 'string' ? doc : JSON.stringify(doc, null, 2));
19+
return path;
20+
}
21+
22+
const RELEASE = {
23+
fromVersion: '17.3.0',
24+
toVersion: '17.4.0',
25+
added: [{ surface: './ai: NewThing (const)' }, { surface: './ui: Other (type)' }],
26+
converted: [{ surface: 's', to: 't', conversionId: 'conv-new', toMajor: 17 }],
27+
migrated: [],
28+
removed: [{ surface: './integration: Gone (type)' }],
29+
};
30+
31+
describe('readSpecReleaseChanges (ADR-0087 D4 per-release section)', () => {
32+
it('reports the installed release delta at package-version resolution', () => {
33+
const result = readSpecReleaseChanges(manifest({ protocolVersion: '17.0.0', release: RELEASE }));
34+
expect(result).toMatchObject({
35+
fromVersion: '17.3.0',
36+
toVersion: '17.4.0',
37+
added: 2,
38+
removed: 1,
39+
converted: 1,
40+
migrated: 0,
41+
});
42+
});
43+
44+
it('⛔ reports NOTHING, never zeros, for a manifest with no release section', () => {
45+
// This is the pre-#17080 artifact, and the whole defect it fixes: a
46+
// consumer that reads `added: 0` from a release which moved 225 exports
47+
// concludes the upgrade is safe. Absence must stay distinguishable from a
48+
// measured empty delta.
49+
expect(readSpecReleaseChanges(manifest({ protocolVersion: '17.0.0', aggregate: {} }))).toBeNull();
50+
});
51+
52+
it('a measured EMPTY delta is reported, with zeros', () => {
53+
const empty = { ...RELEASE, added: [], converted: [], migrated: [], removed: [] };
54+
expect(readSpecReleaseChanges(manifest({ release: empty }))).toMatchObject({
55+
added: 0,
56+
removed: 0,
57+
converted: 0,
58+
migrated: 0,
59+
});
60+
});
61+
62+
it('a half-readable section is no section — never a delta with a hole in it', () => {
63+
expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, added: undefined } }))).toBeNull();
64+
expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, fromVersion: 17 } }))).toBeNull();
65+
expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, removed: 'three' } }))).toBeNull();
66+
});
67+
68+
it('an unreadable or absent file is silence, not a throw', () => {
69+
expect(readSpecReleaseChanges(join(dir, 'does-not-exist.json'))).toBeNull();
70+
expect(readSpecReleaseChanges(manifest('{ not json'))).toBeNull();
71+
expect(readSpecReleaseChanges(null)).toBeNull();
72+
});
73+
74+
it('names the file it read, so a consumer can read the entries themselves', () => {
75+
const path = manifest({ release: RELEASE });
76+
expect(readSpecReleaseChanges(path)?.source).toBe(path);
77+
});
78+
});

0 commit comments

Comments
 (0)