Repository navigation
Commit a7581b3
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
0 commit comments