Skip to content

Commit a7581b3

Browse files
test(spec): name each isomorphic pin for its module and schema, sorted (#19932)
Fixes #19665 Clause-②: no ## What this changes `packages/spec/src/type-alias-convention.pin.test.ts` keyed its isomorphic pins on a dense, hand-kept counter (`Iso0` … `Iso881`). Two branches off one base each took "the next free number" for a different schema, at insertion points far apart, and git merged them cleanly into two declarations of one name: `Iso871` once already, then `Iso877` / `Iso878` on #19600, where only `check:test-typecheck` caught it. Each pin is now named for the pair that is already unique to it, its module path and its schema export name: - The name is `Iso_`, then the module path below `packages/spec/src` without `.zod.ts`, with each kebab segment camelCased and the segments joined by `_` (so `ai/knowledge-document.zod.ts` becomes `ai_knowledgeDocument`), then `__`, then the schema export name. For example: `Iso_shared_epoch__EpochMs`. - The block is sorted by that name in code-unit order (the order `LC_ALL=C sort` gives), with one heading per module. Each note about a module's pins sits under that module's heading and names the pins it covers. The schema alone now decides both a new pin's name and where it goes. There are four commits: 1. `ece9f71d2c`: the mechanical half. It is the transform below, run on `fdeeea0cc9`, byte for byte. That includes the runtime companion's pin counter, whose regex now matches the name family (`Iso` followed by word characters) instead of `Iso` followed by digits. 2. `fc8eda2d62`: prose only. - The naming rule and the retired counter are written at the head of the list. - The two cohort blocks (phase 2 and the #4593 backfill) are filed there too. - Notes that pointed at a pin by its position or its numeral now name the pin. - "Positional and stay vacant" is removed. - The count history gets a `789 -> 789` entry. 3. `ea78da8908`: a merge of `main` at `2c1011b01b`, to re-measure this head on current `main`. `main` has not touched the pin file since the merge base: its blob is `5620656d04` on both `fdeeea0cc9` and `2c1011b01b`. So the merge leaves the file exactly as `fc8eda2d62` had it, and the re-key was not re-run. 4. `4a724cfbf3`: `packages/spec/src/shared/duration.test.ts:11` cited the `EpochMs` pin as `Iso868`. It now cites `Iso_shared_epoch__EpochMs`. This commit changes comment text only. The diff against `main` is two files: the pin file, and that one comment line in `duration.test.ts`. No assertion is added and none is weakened. The exemption set is unchanged, and nothing is regenerated from a corpus measurement. `scripts/check-spec-parsed-alias.mjs` is untouched, because its reader never reads the numeral. ## Acceptance: the assertion set is identical, member for member Both sides are read with the gate's own `readIsomorphicPins`: before is `fdeeea0cc9` and after is `fc8eda2d62`. | reading | before | after | |---|---|---| | `readIsomorphicPins` entries | 789 | 789 | | pin declarations | 789 | 789 | | set difference, either direction | | **0** | | pin bodies changed (the text right of ` = `, keyed on `path.ts::Schema`) | | **0** | | names off the rule / duplicate names | | 0 / 0 | | pin names in sorted order | no | yes | Controls, to show the instrument can fire: - Deleting one pin line gives a set difference of 1. - Swapping one pin's `z.infer` operand to another schema keeps the set at 789 and reads 1 body changed. The module series is untouched: there are 174 `import type * as M…` lines before and after, and no import line is in the diff. ## The collision is gone: merge probe with a firing control The probe ran `git merge-tree --write-tree` in a throwaway `git clone --bare --shared` with no merge driver registered. The clone was removed afterwards. In each case, two branches off one base each add one pin for a different schema: | scheme and base | the two insertions | merge-tree | duplicate pin names | |---|---|---|---| | OLD, `fdeeea0cc9`: both take `Iso882`, one in `api/analytics`, one in `ui/view` | 1259 lines apart | exit 0, clean, 0 markers | **`Iso882`** (the #19600 shape) | | NEW, `fc8eda2d62`: `Iso_api_analytics__ProbeAlphaSchema` and `Iso_ui_view__ProbeBetaSchema`, each at its sorted place | 1174 lines apart | exit 0, clean, 0 markers | none, and the merged names are still sorted | | NEW, same module, with an existing pin between the two names | 6 lines apart | exit 0, clean | none | | NEW, same module, with the two names sort-adjacent | same line | exit 1, CONFLICT | none | The last row is the case that remains. Two new names with no existing pin between them insert at the same place, and git stops with a conflict; the resolution is to keep both lines. That failure is loud, and it cannot merge into a duplicate. The module series (`M…`) is **left untouched, and it is not a measured collision**: no one has yet seen two PRs that each import a new module. I ran one simulation of its mechanism only. Two branches that each append `import type * as M189` after `M188` conflict at the tail of the import block (merge-tree exit 1). Keeping both lines when resolving would give tsc a duplicate `M189`. So in this simulation its failure mode is a conflict, not a silent clean merge. ## Landing order: this PR lands first, then #19809 re-runs the re-key on its own pin On the maintainer's instruction, this PR lands before #19809. #19809 also edits this file: it re-pins `KanbanConfigSchema` as `Iso882` and raises the count to 790. After this PR has landed, #19809 does this on its own branch: 1. Merge `main`. The pin file conflicts. 2. Take #19809's own copy of the file, still on the old names, as `F`: run `git show H:packages/spec/src/type-alias-convention.pin.test.ts > F`, where `H` is #19809's head before the merge. Then run the transform below on `F`. It renames and sorts every pin, including the new one, which becomes `Iso_ui_view__KanbanConfigSchema`. - This step is exact while #19809's pin set is `main`'s set plus its own pin. At `ff13eece89` it is: 790 pins, which are `main`'s 789 plus `ui/view.zod.ts::KanbanConfigSchema`. 3. Re-apply this PR's prose half as a three-way file merge: `git merge-file -p F BASE1 HEAD1 > out`. `BASE1` is this file at `ece9f71d2c`. `HEAD1` is this file at `fc8eda2d62`, which is the same bytes `main` holds once this PR lands. 4. By hand: - In the one conflicting hunk, at the tail of the count history, keep both entries: this PR's `789 -> 789` entry first, then #19809's entry restated as `789 -> 790`, with its `toHaveLength(790)`. In that entry, name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`. - In #19809's `ui/view` note, name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`, and drop "Iso829 stays vacant". 5. Check acceptance: compare `readIsomorphicPins` on `F` before the transform with the result. The sets must be equal, the bodies unchanged, the names on the rule, and the block sorted. I dry-ran steps 2 and 3 against #19809's head `ff13eece89`: 790 = 790, set difference 0 both ways, 0 bodies changed, 0 names off the rule, sorted, and `Iso_ui_view__KanbanConfigSchema` declared once. The prose merge conflicts at that one hunk only. The transform is `node rekey.mjs FILE`. It rewrites the file in place and is idempotent: running it on `fc8eda2d62` changes nothing. It is written without a less-than character so this body keeps it intact. Running exactly this text on `fdeeea0cc9` reproduces `ece9f71d2c` byte for byte. ```js // rekey.mjs FILE: re-key and sort the isomorphic pin block of // packages/spec/src/type-alias-convention.pin.test.ts, in place. Idempotent. // Refuses (exit 1) on any line it cannot place. Spelled without a less-than // character so it survives a GitHub body intact: LT stands for one. import { readFileSync, writeFileSync } from 'node:fs'; const LT = String.fromCharCode(60); const file = process.argv[2]; const L = readFileSync(file, 'utf8').split('\n'); const die = (m) => { console.error(`rekey: ${m}`); process.exit(1); }; // Module alias -> module path, read the way the gate reads it. const mods = new Map(); for (const l of L) { const m = l.match(/^import type \* as (M\d+) from '\.\/(.+?)\.js';$/); if (m) mods.set(m[1], m[2]); } // Stable name: path below packages/spec/src minus `.zod`, kebab segments // camelCased, joined by `_`; then `__`; then the schema export name. const keyOf = (path) => { if (!path.endsWith('.zod')) die(`module ${path} is not a .zod module`); return path.slice(0, -'.zod'.length).split('/').map((s) => { if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(s)) die(`segment "${s}" of ${path} is not lowercase kebab`); return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); }).join('_'); }; // Region: after the rule closing the "N isomorphic aliases" banner, up to the // rule opening "Representative spot-checks". const bannerAt = L.findIndex((l) => /^\/\/ \d+ isomorphic aliases:/.test(l)); if (bannerAt === -1) die('count banner not found'); const start = L.findIndex((l, i) => i > bannerAt && l.startsWith('// ----')) + 1; const spotAt = L.findIndex((l) => l === '// Representative spot-checks on the phase-2 FLIP.'); if (start === 0 || spotAt === -1 || !L[spotAt - 1].startsWith('// ----')) die('region bounds not found'); const end = spotAt - 1; const HEADING = /^\/\/ (?:\[#\d+\] )?([a-z0-9-]+(?:\/[a-z0-9-]+)*)\.zod\.ts(?:$|[ ,;:(—-])/; const PIN = new RegExp(`^export type (Iso\\w+) = (Assert${LT}Eq${LT} z\\.input${LT} typeof (M\\d+)\\.(\\w+) >, z\\.infer${LT} typeof \\3\\.\\4 > >>;)$`); const preamble = []; const sections = new Map(); // key -> { heading, notes[], pins: Map(name -> line) } const names = new Set(); let cur = null; for (let i = start; i !== end; i++) { const l = L[i]; if (l === '') continue; if (l.startsWith('// ----')) { // an inner banner moves, verbatim, to the preamble const endRule = L.findIndex((x, j) => j > i && x.startsWith('// ----')); if (endRule === -1 || endRule >= end) die(`unclosed banner at line ${i + 1}`); if (preamble.length) preamble.push('//'); preamble.push(...L.slice(i + 1, endRule)); i = endRule; continue; } const h = l.match(HEADING); if (h) { const key = keyOf(`${h[1]}.zod`); if (!sections.has(key)) sections.set(key, { heading: l, notes: [], pins: new Map() }); else if (l !== `// ${h[1]}.zod.ts`) die(`second heading for ${h[1]} carries text (line ${i + 1})`); cur = sections.get(key); continue; } const p = l.match(PIN); if (p) { const path = mods.get(p[3]); if (!path) die(`line ${i + 1}: ${p[3]} imports no module`); const key = keyOf(path); if (!cur || cur !== sections.get(key)) { console.error(`rekey: line ${i + 1} pins ${path}.ts outside its section; moved there`); if (!sections.has(key)) sections.set(key, { heading: `// ${path}.ts`, notes: [], pins: new Map() }); } const name = `Iso_${key}__${p[4]}`; if (names.has(name)) die(`duplicate pin ${name} (line ${i + 1})`); names.add(name); sections.get(key).pins.set(name, `export type ${name} = ${p[2]}`); continue; } if (l.startsWith('//')) { if (!cur) preamble.push(l); else cur.notes.push(l); continue; } die(`line ${i + 1} is neither a heading, a pin, a comment nor blank: ${l.slice(0, 80)}`); } // Code-unit order; every name is ASCII, so byte order is the same order. const byCodeUnit = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)); const out = ['']; if (preamble.length) out.push(...preamble, ''); for (const key of [...sections.keys()].sort((a, b) => byCodeUnit(`Iso_${a}__`, `Iso_${b}__`))) { const s = sections.get(key); out.push(s.heading, ...s.notes, ...[...s.pins.keys()].sort(byCodeUnit).map((n) => s.pins.get(n)), ''); } const next = [...L.slice(0, start), ...out, ...L.slice(end)]; // The runtime companion counts pins by declaration: the name family, not a numeral. const text = next.join('\n').replace( `self.match(/^export type Iso\\d+ = Assert${LT}/gm)`, `self.match(/^export type Iso\\w+ = Assert${LT}/gm)`, ); writeFileSync(file, text); console.log(`rekey: ${names.size} pins in ${sections.size} module sections`); ``` ## Verification Round 2, on `4a724cfbf3`, after the merge of `main` at `2c1011b01b`: - The pin file is byte-identical to `fc8eda2d62`'s (blob `8ae4405bd5`). `readIsomorphicPins` on `main`'s file and on this head's file reads 789 = 789, with a set difference of 0 both ways. Control: dropping one pin line reads 788, difference 1. - `pnpm --filter @objectstack/spec check:test-typecheck`: OK (53 files / 255 errors / 142 signatures held). - `node scripts/check-spec-parsed-alias.mjs`: "1459 bare z.input aliases, 789 pinned isomorphic, 670 paired with an XParsed. OK". Its `--self-test`: 18 assertions passed. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. - `vitest run --project local` on `src/type-alias-convention.pin.test.ts` and `src/shared/duration.test.ts`: 2 files, 14 tests passed. The whole spec `local` project: 530 files, 15617 passed, 1 todo. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 77 commands, and all 77 were run. The `--ran` reconciliation reads 77 run, 0 NOT MEASURED, 0 unrun. Four gates first exited 3 for unbuilt prerequisites (`check:doc-formula-expressions`, `check:lean-entry-closure`, `check:dual-build-cjs-loads`, `check:type-check-debt`), and each exited 0 after its build. Round 1, on `fc8eda2d62`. The pin file's bytes have not changed since, so these still describe it: - Non-vacuity, from the committed state: `node scripts/ablation-replace.mjs` renamed `Iso_ui_view__TreeConfigSchema` to the existing name `Iso_ui_view__RowHeightSchema`, and `check:test-typecheck` turned red ("2 type error(s) in a file the ledger does not cover"). The file was then restored: blob == HEAD and `git diff HEAD` is empty. - The set identity against `fdeeea0cc9` and the merge probe are in the sections above. ## Changeset None. The file is a test. `@objectstack/spec`'s `files` ships `src/**/*.zod.ts` and `dist` but never `*.test.ts`, so nothing published changes. `check-empty-changeset.mjs` refuses a new empty-frontmatter changeset, so the repo's disposition for this diff is the `skip-changeset` label. The seat applied that label after the PR opened. The one red `Check Changeset` run on `fc8eda2d62` came from the `opened` event, before the label; on `4a724cfbf3` that check is `skipped`. ## Acceptance notes - On the base, two `api/errors.zod.ts` pins (`FieldErrorCode` and `FieldErrorSchema`) sat under the `api/error-code-ledger.zod.ts` heading. The sorted layout files them under their own module. - `packages/spec/src/shared/duration.test.ts:11` cited this file's `Iso868` in the present tense. That pin is `EpochMs`, now `Iso_shared_epoch__EpochMs`, and `4a724cfbf3` updates the citation. The other `Iso`-plus-digits hits under `packages/spec/src`, outside this file, are `ui/component.zod.ts` lines 2052, 2523 and 2929 and `ui/i18n.zod.ts` line 198. They name `Iso818`, `Iso819`, `Iso839` and `Iso759`, pins that were deleted before this PR, so they are history and stay as they are. - A check that the block stays sorted, and that each name matches its derivation, would be a new assertion, so none is added. The candidate, for the maintainer: in the runtime companion, assert that the `Iso` names are in code-unit order and that each one equals the rule applied to its `Mn` path and schema. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0142415 commit a7581b3

2 files changed

Lines changed: 970 additions & 990 deletions

File tree

‎packages/spec/src/shared/duration.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* of schema and a page of reasoning — the reasoning is what these hold.
99
*
1010
* The ISOMORPHISM family copies the `EpochMs` pattern (ADR-0122,
11-
* `type-alias-convention.pin.test.ts` `Iso868`): author state and parsed state
11+
* `type-alias-convention.pin.test.ts` `Iso_shared_epoch__EpochMs`): author state and parsed state
1212
* coincide, so no `*Parsed` synonym exists, and the assertion is the type-level
1313
* `Eq<z.input, z.infer>` rather than a runtime claim about a value. The pin
1414
* file carries the same assertion as the gate's exemption registry; this one

0 commit comments

Comments
 (0)