|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #27015 — the in-tree anchor is not older than the newest aged-out deletion. |
| 4 | +// |
| 5 | +// A build that cannot reach origin/main (cloud's EE image stage, an air-gapped |
| 6 | +// or tagless clone) runs the #4650 deletion gate against the committed |
| 7 | +// `authorable-surface.base.json` (#5235). Every `[RETIRED]` line the anchor |
| 8 | +// still records and the live surface has since dropped must then be dated by |
| 9 | +// proof 1 of that gate. Its registry half (RETIRED_KEYS_BY_MAJOR) is in the |
| 10 | +// tree; its tag half (`@objectstack/spec@<major - 1>.0.0`, #25767) is not, and |
| 11 | +// such a build can neither read nor fetch it. So a tombstone deletion that only |
| 12 | +// the tag dates is one the offline build refuses. |
| 13 | +// |
| 14 | +// That is what happened: #26599 (dbf7e04adf) aged the ≤16.x kits out and |
| 15 | +// deleted 38 tag-dated lines, while the anchor stayed at 85c6d76ec42a, before |
| 16 | +// that commit. Every online build passed; `@objectstack/spec#build` failed in |
| 17 | +// every offline one, and nothing in this repository noticed. |
| 18 | +// |
| 19 | +// This pin reads only committed files — no git, no tag, no build — which is |
| 20 | +// exactly what the offline build has, and asks the one question that build |
| 21 | +// cannot answer: is there a deleted tombstone that only the tag could date? |
| 22 | +// It deliberately leaves out deletions the gate proves from the build itself |
| 23 | +// (a def no longer on the surface, proof 3; a never-tombstoned line, proof 4), |
| 24 | +// and it is stricter than the gate on one shape: a tombstone in a def that is |
| 25 | +// still on the surface but no longer reachable from the metadata roots (proof |
| 26 | +// 2), which only the generator's Zod graph can tell. That errs toward a |
| 27 | +// re-anchor, never toward a broken offline build. |
| 28 | + |
| 29 | +import { describe, expect, it } from 'vitest'; |
| 30 | +import fs from 'node:fs'; |
| 31 | +import path from 'node:path'; |
| 32 | +import { fileURLToPath } from 'node:url'; |
| 33 | +import { RETIRED_KEYS_BY_MAJOR } from '../src/migrations/registry'; |
| 34 | +import { carryAuthorableKey } from './lib/renamed-defs'; |
| 35 | +import { |
| 36 | + AUTHORABLE_SURFACE_DIR_NAME, |
| 37 | + readShards, |
| 38 | + type AuthorableSurfaceShard, |
| 39 | +} from './lib/sharded-artifacts'; |
| 40 | + |
| 41 | +const SPEC_DIR = fileURLToPath(new URL('..', import.meta.url)); |
| 42 | +const RETIRED_MARK = ' [RETIRED]'; |
| 43 | +/** build-schemas.ts `TOMBSTONE_AGE_MAJORS` (#25767). */ |
| 44 | +const TOMBSTONE_AGE_MAJORS = 1; |
| 45 | +const CURRENT_MAJOR = Number.parseInt( |
| 46 | + JSON.parse(fs.readFileSync(path.join(SPEC_DIR, 'package.json'), 'utf-8')).version, |
| 47 | + 10, |
| 48 | +); |
| 49 | + |
| 50 | +const anchor = JSON.parse( |
| 51 | + fs.readFileSync(path.join(SPEC_DIR, 'authorable-surface.base.json'), 'utf-8'), |
| 52 | +) as { baseRev: string; keys: string[] }; |
| 53 | + |
| 54 | +const liveKeys = new Set( |
| 55 | + readShards<AuthorableSurfaceShard>(path.join(SPEC_DIR, AUTHORABLE_SURFACE_DIR_NAME)) |
| 56 | + .flatMap((s) => s.doc.keys) |
| 57 | + .map((e) => e.replace(RETIRED_MARK, '')), |
| 58 | +); |
| 59 | +const liveDefs = new Set([...liveKeys].map((k) => k.slice(0, k.indexOf(':')))); |
| 60 | + |
| 61 | +/** The earliest major that registers each key, carried through def renames (build-schemas.ts `registeredRetiredKeys`). */ |
| 62 | +const registeredAt = new Map<string, number>(); |
| 63 | +for (const [major, keys] of Object.entries(RETIRED_KEYS_BY_MAJOR)) { |
| 64 | + for (const key of keys) { |
| 65 | + const carried = carryAuthorableKey(key); |
| 66 | + const prev = registeredAt.get(carried); |
| 67 | + if (prev === undefined || Number(major) < prev) registeredAt.set(carried, Number(major)); |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +describe('authorable-surface.base.json is not older than the newest aged-out deletion (#27015)', () => { |
| 72 | + it('reads a real anchor and a real surface', () => { |
| 73 | + expect(anchor.baseRev).toMatch(/^[0-9a-f]{40}$/); |
| 74 | + expect(anchor.keys.length).toBeGreaterThan(0); |
| 75 | + expect(liveKeys.size).toBeGreaterThan(0); |
| 76 | + }); |
| 77 | + |
| 78 | + it('records no deleted tombstone that only the dating release tag can date', () => { |
| 79 | + const tagDatedOnly = anchor.keys |
| 80 | + .filter((e) => e.endsWith(RETIRED_MARK)) |
| 81 | + .map((e) => carryAuthorableKey(e.replace(RETIRED_MARK, ''))) |
| 82 | + .filter((key) => !liveKeys.has(key) && liveDefs.has(key.slice(0, key.indexOf(':')))) |
| 83 | + .filter((key) => { |
| 84 | + const major = registeredAt.get(key); |
| 85 | + return major === undefined || CURRENT_MAJOR - major < TOMBSTONE_AGE_MAJORS; |
| 86 | + }); |
| 87 | + |
| 88 | + expect( |
| 89 | + tagDatedOnly, |
| 90 | + `The anchor (baseRev ${anchor.baseRev.slice(0, 12)}) still records ${tagDatedOnly.length} [RETIRED] ` + |
| 91 | + `line(s) that the live surface has dropped and that RETIRED_KEYS_BY_MAJOR does not date. Online, ` + |
| 92 | + `the release tag @objectstack/spec@${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}.0.0 dates them; a build ` + |
| 93 | + `with no route to origin/main has no tag, so its spec build fails the #4650 deletion gate on these ` + |
| 94 | + `lines (#27015). Once the deletion is on origin/main, re-anchor in a commit of its own from a branch ` + |
| 95 | + `cut there: \`pnpm --filter @objectstack/spec gen:authorable-surface-base\`. In the PR that deletes ` + |
| 96 | + `the lines, the anchor cannot move yet; date each key there instead with an exact entry under ` + |
| 97 | + `src/migrations/entries/retired-keys/ at its true major.`, |
| 98 | + ).toEqual([]); |
| 99 | + }); |
| 100 | +}); |
0 commit comments