Skip to content

Commit d79eecf

Browse files
refactor(spec): codemod split of conversions/registry.ts into per-entry modules (#29987) (#29993)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBKGNRxbkYw4a3n8552mQyGC)_ Fixes #29987 Ruling-ref: 6583399215 Clause-②: no (move-only) Metadata-protocol: none Skills-docs: follow-up filed as #29994 — .claude/skills/spec-property-retirement/SKILL.md (it still says a new conversion is written in `conversions/registry.ts`; after the split it is a module under `conversions/entries/` plus one import line in `registry.ts`) ADR-0087 / D3: no migration entry moves. `migrations/` is untouched, and `check-adr-0087-registration --base origin/main` exits 0. ## 维护者速读 - 改了什么:codemod `scripts/codemod-split-conversions-registry.mjs` 把 `packages/spec/src/conversions/registry.ts`(16,019 行)拆成 112 个条目模块(`entries/<toMajor>/<id>.ts`)加一个 `entries/shared.ts`;`registry.ts` 剩 354 行,只留导入和四张表。另有 4 个按路径读这个文件的读者同步放宽,build-schemas 的三处 D2 补救提示也改成了拆分后的写法。 - 为什么改:#29465 大文件拆分计划(裁决 6583399215)的一部分,已在今天的窗口里于最新 `main`(`c2d353d2`)上重新生成。这张表是冲突最多的文件之一,按条目拆开后,每条 conversion 单独成文件,评审和合并都更容易。 - 风险与回滚:只搬不改。159 条语句、727,159 字节逐字节相同;`ALL_CONVERSIONS` 和每个 `CONVERSIONS_BY_MAJOR[n]` 的 id、顺序和函数源码在 base 与 head 完全一致;每次 head 构建的 134 个声明文件都与一次同状态的 base 构建逐字节相同(`main` 自己的 d.ts 构建在两种状态间交替,见 Tests)。回滚就是 revert 这个 PR。 - 先看:`packages/spec/src/conversions/registry.ts`(新的宿主文件)、`entries/shared.ts`,以及下面的读者普查表。 - 两分钟验证:`pnpm install --frozen-lockfile`,然后 `node scripts/codemod-split-conversions-registry.mjs --verify --base origin/main`(字节一致)和 `--arrays --base origin/main`(表相等)。 - 需要你做的:不用做什么。你已批准在今天的窗口落地,CI 在 `dca57060` 上全绿,由 spec 席位送入合并队列。 ## What changes - **New:** `scripts/codemod-split-conversions-registry.mjs`. It sits beside the pilot's `codemod-split-sql-driver.mjs` and follows its conventions (ts-morph in memory, a plan mode, `--write`, `--verify`, `--self-test`). - **Generated:** `packages/spec/src/conversions/entries/11/` (3), `15/` (2), `17/` (12) and `18/` (95) hold one module per conversion entry, named by its `id`. `entries/shared.ts` holds the 11 helpers that two or more entries read. The 31 helpers that only one entry reads move into that entry's module, following pilot ruling 6591753301. Every module is a leaf: it imports `../../types.js`, `../../walk.js`, `../shared.js` and the spec modules `registry.ts` already imported (for example `ui/assembled-views.zod.js`), and never another entry. - **Host:** `registry.ts` keeps its module docblock, `import type { MetadataConversion }`, one import line per entry in base definition order, and `OrderedConversion`, `inApplicationOrder`, `MAJOR_18_CONVERSIONS`, `CONVERSIONS_BY_MAJOR` and `ALL_CONVERSIONS` byte for byte, including the `@__PURE__` markers. The public import path `conversions/registry` and its two exports are unchanged. - **Readers widened** (ruling 6597555122): - `packages/spec/scripts/conversions-major18-merge.test.ts` now reads the import lines in `registry.ts` and each `entries/18/` module. - `scripts/check-adr-0087-registration.mjs` reads the conversions ledger as `registry.ts` plus `entries/**`, and gains a CR1-CR3 battery. - `scripts/docs-audit/affected-docs.mjs` treats `registry.ts` plus `entries/**` as the conversion table. - `scripts/adr-anchors/`: the ADR-0120 anchor follows `declared-index-unique-scope` into its module, and the host keeps ADR-0087. - The `scripts/regen-artifacts.mjs` row note was updated. ## Reader census (all 111 files on base `c2d353d2` that name `conversions/registry`) | class | files | disposition | |---|---|---| | import | 51 | unaffected: both exports stay in `registry.ts` (3 tests `vi.mock` it, same exports) | | prose only | 47 | unaffected (CHANGELOGs, docblocks, ADR text) | | code reads the path or text | 13 | one row each below | | code reader | disposition | |---|---| | `packages/spec/scripts/conversions-major18-merge.test.ts` | **widened**. The base version fails 3 of its cases on the split tree; the widened one passes 12/12 | | `scripts/check-adr-0087-registration.mjs` | **widened**; self-test 710 assertions pass, `--base origin/main` exit 0 | | `scripts/docs-audit/affected-docs.mjs` | **widened**. The pre-edit version fails 2 counterfactual cases on the split tree; the edited one passes 605/605 | | `scripts/adr-anchors/packages__spec__src__conversions__registry.ts.json` | **split**: ADR-0120 moved with its entry, so a new anchor was added for `entries/18/declared-index-unique-scope.ts`. `check:adr-anchors` exit 0 | | `scripts/regen-artifacts.mjs` | row note updated (prose of a NOT_DRIVER_MANAGED row) | | `packages/spec/scripts/build-schemas.ts` | **reworded** (`c5fc1136`): its three D2 remedies now name a module under `src/conversions/entries/` plus its import and array line in `src/conversions/registry.ts` | | `packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts` | **pin added** (`c5fc1136`): the remedy names `src/conversions/entries/` with the toMajor directory. It fails on the old remedy text | | `packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts` | **pin added** (`c5fc1136`): the remedy names `src/conversions/entries/`. It fails on the old remedy text. The two check-mode tests pass 29/29 | | `packages/spec/src/root-entry-migrations-split.pin.test.ts` | unchanged: `registry.ts` is still in the root graph; passes | | `packages/spec/export-origins/root.json` | unchanged: the export origin is still `registry.ts`; `check:export-origins` exit 0 | | `packages/spec/liveness/action.json` | unchanged. Its note cites `registry.ts:5578`, which was already stale on `main` before this PR | | `scripts/check-spec-docblock-symbol-anchors.mjs` | unchanged: the anchored symbols stay in the host; passes | | `scripts/check-future-spec-major.mjs` | unchanged: the path is only a self-test fixture label | To reproduce the census: `git grep -l 'conversions/registry' c2d353d -- scripts packages/spec`. The one file new since the Phase 0 base `5cdaddbc` is `packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts`, and it is an import. ## Tests Phase 1 readings: head `dca57060` (a plain merge of `main` `c2d353d2` into `c5fc1136`, no rebase), base `c2d353d2`. `main` did not touch `packages/spec/src/conversions/` between `5cdaddbc` and `c2d353d2`. Regenerating from `main`'s `registry.ts` with `--write` leaves `git diff HEAD` empty, so the merge is the regenerate. - **Byte-identity:** `--verify --base origin/main` (`c2d353d2`) exits 0. It compares 159 statements (727,159 bytes). All 112 of 112 entries and all 42 helpers moved (11 into `entries/shared.ts`), and 5 statements stayed. It writes 113 modules and checks 584 import bindings. Undoing the split rebuilds the base `registry.ts` byte for byte. - **Tables:** `--arrays --base origin/main` exits 0. Exports match (`ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`). `ALL_CONVERSIONS` holds 112 ids, sha256 `6dfde60307ccd336d6265e84bb095632acbbdbcd08483d73f5e83070c9728ae7`, in the same order. `CONVERSIONS_BY_MAJOR` is `{11: 3, 15: 2, 17: 12, 18: 95}` with the same membership and order. 112 of 112 entry digests are equal, where a digest is JSON with the function source included. - **Determinism:** two `--write` runs from `main`'s unsplit `registry.ts` (input sha256 `3f5386899b7024a1e429d6a7b93cd41ce567f1171bfb5ee27974d1ffb2ec6c01`) both give output tree sha256 `960cdabf7a6532e0f0ffaa035cbe200a85b0eff2a1ddea58b0df515b4147d74b`, the same as Phase 0, and leave `git diff HEAD` empty. - **Declaration files** (ruling 6348891160 item 1, read with 6441032442 B's second normalisation, S1 applied): - **The tree was quiet.** The four builds ran back to back in one locked script, in the order head-1, base-1, head-2, base-2. Nothing else ran in either worktree or on the box, and the tests and gates started only after the builds finished. - **The builds:** `pnpm --filter @objectstack/spec build` produced base-1, head-1, base-2 and head-2. Each build's 134 declaration files fall into one of two states, with set sha256 `cb955a20e1de3c63…` or `cdac5d7b0c33761e…`. base-1 and head-2 are in the first state; head-1 and base-2 are in the second. - **The pairing:** base-1 ≡ head-2 and base-2 ≡ head-1. Each pair is 134/134 raw byte-identical. `dtsnorm.mjs` passes on both pairs, and its control (a renamed declaration in `action.zod-CQhac7BH.d.mts`) is caught, with the normalised hash and the surface digest both DIFFER. The codemod's `--dts base-1 head-2` reads PASS, with its control (a dropped `readonly`) caught. - **Why the cross pair reads FAIL (9):** `--dts base-2 head-2` reads FAIL (9). The 9 are the 8 files `api`, `contracts`, `marketplace` and `system` `index.d.ts` / `index.d.mts`, plus the renamed `automation-api.zod` chunk pair. base-1 against base-2 differs in exactly the same way: 10 files and 34 statements, with string-literal members in another order inside `z.ZodEnum` record types and object shapes, and the declared-surface digest EQUAL on every file. This is `main`'s own `.d.ts` build being nondeterministic from run to run, not the split. Every quiet head build equals a quiet base build byte for byte, so no reorder belongs to head. - **A correction to Phase 0's acceptance note 3:** the irregular head builds reported there were this same nondeterminism. They were not caused by the concurrent gates. - The readings are in the project files under `issue-29987/phase1/`. - `check:api-surface` and `check:dual-source-exports` exit 0. - **Self-test:** `--self-test` runs 40 cases in 6 batteries, and all pass. - **Spec suite at the base's counts:** base `c2d353d2` and head `dca57060` both give `test` 811/811 files and 24,376 tests plus 1 todo, and `test:repo` 90/90 files and 1,539 tests. - **Built-entry checks:** `pure-schema-construction.test.ts`, including the `./shared` consumer bundle, is in the local suite above and passes. - **Type-check:** `pnpm --filter @objectstack/spec typecheck` exits 0 at head with `NODE_OPTIONS=--max-old-space-size=6144`. Without the flag, the `check:test-typecheck` step runs out of heap at about 2 GB on this box, at base and at head alike. ## Acceptance notes 1. **Size.** 35,227 changed lines (+19,405 / -15,822 in 124 files against `c2d353d2`) is over the 3,000-line threshold. The maintainer approved landing it in today's window. 2. **`skip-changeset`:** the `.d.ts` meets ruling 6348891160 item 1 in its strongest form, raw identity against a base build of the same state. Following 6441032442 item 4 and the pilot PR #29598, the PR takes no changeset. 3. **Phase 0's irregular head builds.** Phase 0 put them down to concurrent gate runs. That was wrong: Phase 1 shows `main`'s spec `.d.ts` build alternating between two states on a quiet tree (see Tests). A `.d.ts` reading for any spec split therefore has to pair builds of the same state, and a single base-against-head pair can read FAIL with no change at all. 4. **Follow-ups, noted and not filed:** - The `MAJOR_18_CONVERSIONS` docblock in the host still says to define a conversion directly above the entry that follows it. It has to stay byte-identical in a move-only PR, so the merge test now gives the post-split spelling: import it directly above that entry's import. - The skills line above names the second follow-up (#29994, which also covers this docblock). ## Verification - Regenerate (in the Phase 1 window, on the latest `main`, never a rebase): `git checkout origin/main -- packages/spec/src/conversions/registry.ts`, then `node scripts/codemod-split-conversions-registry.mjs --write`. `--write` deletes stale codemod-marked modules and refuses any file under `entries/` that it did not write. If the regeneration moves different code, re-check the reader table above. - Proofs: `node scripts/codemod-split-conversions-registry.mjs --verify --base origin/main`, then `--arrays --base origin/main`. - `.d.ts`: build `@objectstack/spec` at least twice at base and twice at head on a quiet tree, pair builds of the same state, then run `node scripts/codemod-split-conversions-registry.mjs --dts BASE_DIST HEAD_DIST`. The report reading under 6441032442 B is `dtsnorm.mjs`, kept in the project files at `issue-29987/dtsnorm.mjs`, sha256 `e0b4b0c3d6a29e99dea99b2a65b683b0649f3e7c6d4fa671b994d590d48219b4`. Run it as `node dtsnorm.mjs --typescript node_modules/typescript --dirs BASE_DIST HEAD_DIST --control`, and expect exit 0 and `"pass": true`. - Gates at head `dca57060`: `node scripts/pm/dispatch-gates.mjs --commands` derives 96, and all 96 exit 0, including `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:doc-formula-expressions`, which are measurable now. `--ran` exits 0: 96 run, 0 NOT-MEASURED, 0 unrun. - CI at `dca57060`: 54 check runs succeeded and 10 were skipped; none failed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzg1QFj9ukYzafVoAqg Body refreshed by spec seat 3 (Phase 1 on c2d353d / dca5706; contract review PASS 6628915927): 2026-11-04T11:09Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 614e831 commit d79eecf

124 files changed

Lines changed: 19405 additions & 15822 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)'
280280
// …and the two other routes a reader might actually need are named.
281281
expect(output).toContain('RENAMED_DEFS');
282282
expect(output).toContain('src/conversions/registry.ts');
283+
expect(output).toContain('src/conversions/entries/<toMajor>/');
283284
// Anchored on the merge base, which is the half a hand-edit cannot reach.
284285
expect(output).toContain(`merge base ${head.slice(0, 12)}`);
285286
},

‎packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its
246246
expect(remedy).not.toContain('copy these lines');
247247
// The D2 conversion is still asked for — the table did not replace it.
248248
expect(remedy).toContain('src/conversions/registry.ts');
249+
expect(remedy).toContain('src/conversions/entries/');
249250
},
250251
);
251252

‎packages/spec/scripts/build-schemas.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,8 +1228,10 @@ if (surfaceDoc) {
12281228
` so the edit is lost. Nothing is inferred here: a leaf name matched against unrelated\n` +
12291229
` conversion surfaces is what let tombstones register themselves by coincidence\n` +
12301230
` (#4659).\n\n` +
1231-
` 2. Add a D2 conversion in src/conversions/registry.ts naming the surface (and a D3\n` +
1232-
` chain step referencing it) so \`os migrate meta\` rewrites their source.`,
1231+
` 2. Add a D2 conversion naming the surface, as one module under\n` +
1232+
` src/conversions/entries/<toMajor>/ plus its import and array line in\n` +
1233+
` src/conversions/registry.ts (and a D3 chain step referencing it) so\n` +
1234+
` \`os migrate meta\` rewrites their source.`,
12331235
);
12341236
process.exit(1);
12351237
}
@@ -2798,8 +2800,10 @@ function checkManifestRemovals(git: GitRun, baseRev: string | null): void {
27982800
`\n — then run \`${MIGRATION_REGISTRY_COMMAND}\`. ⛔ Never type an\n` +
27992801
` entry into the generated table by hand: the registry is generated whole and\n` +
28002802
` never committed, so the edit is lost.\n\n` +
2801-
` 2. Add a D2 conversion in src/conversions/registry.ts naming the surface (and a D3\n` +
2802-
` chain step referencing it) plus a \`major\` changeset, so the removal reaches\n` +
2803+
` 2. Add a D2 conversion naming the surface, as one module under\n` +
2804+
` src/conversions/entries/<toMajor>/ plus its import and array line in\n` +
2805+
` src/conversions/registry.ts (and a D3 chain step referencing it), plus a\n` +
2806+
` \`major\` changeset, so the removal reaches\n` +
28032807
` spec-changes.json, the upgrade guide and \`os migrate meta\` — the table is the\n` +
28042808
` proof it was declared, the conversion is the prescription a consumer follows.\n\n` +
28052809
` If the schema was not meant to disappear at all, this is not the fix: an added\n` +
@@ -3009,7 +3013,8 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null;
30093013
` (#5898) — as one entry file under ${entryDirOf('retired-key')}, named for\n` +
30103014
` that major per ${MIGRATION_ENTRIES_README}, then\n` +
30113015
` \`${MIGRATION_REGISTRY_COMMAND}\`; the table is generated, ⛔ never typed. The\n` +
3012-
` D2 conversion (src/conversions/registry.ts) naming the surface stays\n` +
3016+
` D2 conversion naming the surface (a module under src/conversions/entries/,\n` +
3017+
` imported and listed in src/conversions/registry.ts) stays\n` +
30133018
` required — it is the prescription consumers follow — but it is no longer\n` +
30143019
` what dates the clock; or\n` +
30153020
` 2. its def is not reachable from the metadata-type roots — this gate computes\n` +

‎packages/spec/scripts/conversions-major18-merge.test.ts‎

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@
2121
// end-appended list, the tail-appended definitions and the old array's tail are
2222
// the lit controls — each MUST conflict, or a clean result would be equally
2323
// explained by a harness that cannot see a conflict at all.
24+
//
25+
// [#29987 codemod] Since the codemod split (`scripts/codemod-split-conversions-registry.mjs`)
26+
// each conversion is DEFINED in a module of its own, `src/conversions/entries/18/<id>.ts`,
27+
// which a retirement adds as a new file and which therefore never conflicts. What a
28+
// retirement still edits in `registry.ts` is two lines: the import of its conversion and
29+
// its entry in the list. The imports stand in the order the definitions stood, so the
30+
// placement rule reads them where it read the definitions: an import is a definition's
31+
// place in this file, and the module it names is read for the definition itself.
2432

2533
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2634
import { spawnSync } from 'node:child_process';
@@ -46,10 +54,14 @@ const CLOSE = '];\n';
4654
const ENTRY = / \{ conversion: ([A-Za-z_$][\w$]*), order: (\d+(?:\.\d+)?) \},\n/y;
4755
/** A blank line — which is what a comment between entries is once `maskComments` has blanked it; it belongs to the entry below it. */
4856
const BLANK = /[ \t]*\n/y;
49-
/** A conversion definition and its id, which every definition in the file spells on the next line. */
50-
const DEFINITION = /^(?:export )?const ([A-Za-z_$][\w$]*): MetadataConversion = \{\n id: '([^'\n]+)',$/gm;
51-
/** Where a conversion that sorts last is defined: directly above this declaration's doc comment. */
52-
const TAIL_ANCHOR = '\ninterface OrderedConversion {\n';
57+
/**
58+
* A conversion's place in the file: its import, from the module named for its id
59+
* (`./entries/<toMajor>/<id>.js`). The module itself is read for the definition
60+
* ({@link definitionInModule}).
61+
*/
62+
const DEFINITION = /^import \{ ([A-Za-z_$][\w$]*) \} from '\.\/entries\/18\/([^'\n]+)\.js';$/gm;
63+
/** The module a conversion is defined in, from its id. */
64+
const MODULE_PATH = (id: string): string => path.resolve(HERE, `../src/conversions/entries/18/${id}.ts`);
5365
/** How `CONVERSIONS_BY_MAJOR` reads the entries (the annotation: see that table's docblock). */
5466
const WIRING = ' 18: /* @__PURE__ */ inApplicationOrder(MAJOR_18_CONVERSIONS),\n';
5567

@@ -128,11 +140,26 @@ function entriesOf(source: string): { entries: Entry[]; end: number } {
128140
return { entries, end: at };
129141
}
130142

131-
/** Every conversion definition in the file, in file order — a commented-out one is not a definition. */
143+
/** Every conversion's import in the file, in file order — a commented-out one is not an import. */
132144
function definitionsOf(source: string): { ident: string; id: string; at: number }[] {
133145
return [...maskOnce(source).matchAll(DEFINITION)].map((m) => ({ ident: m[1]!, id: m[2]!, at: m.index! }));
134146
}
135147

148+
/** Where a conversion that sorts last is imported: directly after every other conversion's import. */
149+
function definitionsTail(source: string): number {
150+
const defs = definitionsOf(source);
151+
expect(defs.length, 'the conversions\' imports').toBeGreaterThan(0);
152+
const last = defs.at(-1)!;
153+
return source.indexOf('\n', last.at) + 1;
154+
}
155+
156+
/** The definition in a conversion's module: `const IDENT: MetadataConversion = {` with its id on the next line, exported by name. */
157+
function definitionInModule(ident: string, id: string): boolean {
158+
const text = maskComments(readFileSync(MODULE_PATH(id), 'utf8'));
159+
return text.includes(`\nconst ${ident}: MetadataConversion = {\n id: '${id}',\n toMajor: 18,\n`)
160+
&& text.endsWith(`\nexport { ${ident} };\n`);
161+
}
162+
136163
/** Rule 1, as findings: the entries are sorted strictly by identifier. */
137164
function sortFindings(source: string): string[] {
138165
const idents = entriesOf(source).entries.map((e) => e.ident);
@@ -147,13 +174,13 @@ function placementFindings(source: string): string[] {
147174
return entries.flatMap((e, i) => {
148175
if (PLACED_BEFORE_THE_RULE.has(e.ident)) return [];
149176
const at = index.get(e.ident);
150-
if (at === undefined) return [`${e.ident}: no \`const ${e.ident}: MetadataConversion\` definition in the file`];
177+
if (at === undefined) return [`${e.ident}: no import of \`${e.ident}\` from its module in the file`];
151178
const want = entries[i + 1]?.ident;
152179
const next = defs[at + 1]?.ident;
153180
if (next === want) return [];
154181
return [
155-
`${e.ident} is defined above ${next ?? 'nothing (it is the last conversion defined)'}; define it directly above `
156-
+ (want ? `${want}'s definition, the entry that follows it` : 'OrderedConversion, after every other conversion'),
182+
`${e.ident} is imported above ${next ?? 'nothing (it is the last conversion imported)'}; import it directly above `
183+
+ (want ? `${want}'s import, the entry that follows it` : 'nothing, after every other conversion\'s import'),
157184
];
158185
});
159186
}
@@ -165,17 +192,20 @@ function docStart(source: string, offset: number): number {
165192
}
166193

167194
const kebab = (ident: string): string => `synthetic-${ident.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
168-
const definition = (ident: string): string => `/** A synthetic major-18 retirement. */\nconst ${ident}: MetadataConversion = {\n`
195+
/** A retirement's line in `registry.ts`: the import of its conversion (the module is a new file, so it never conflicts). */
196+
const definition = (ident: string): string => `import { ${ident} } from './entries/18/${kebab(ident)}.js';\n`;
197+
/** The pre-split shape, for the old-shape control only: the definition itself, in the one file. */
198+
const legacyDefinition = (ident: string): string => `/** A synthetic major-18 retirement. */\nconst ${ident}: MetadataConversion = {\n`
169199
+ ` id: '${kebab(ident)}',\n toMajor: 18,\n surface: 'synthetic.key',\n summary: 'synthetic',\n`
170200
+ ' apply: (stack) => stack,\n fixture: { before: {}, after: {}, expectedNotices: 0 },\n};\n\n';
171201
const entry = (ident: string, order: number): string => ` { conversion: ${ident}, order: ${order} },\n`;
172202

173-
/** Offset of the definition the doc comment asks for: above the next entry's, or above `OrderedConversion`. */
203+
/** Offset of the import the doc comment asks for: above the next entry's, or after every other conversion's. */
174204
function definitionSite(source: string, ident: string): number {
175205
const next = entriesOf(source).entries.find((e) => e.ident > ident);
176-
if (!next) return docStart(source, source.indexOf(TAIL_ANCHOR) + 1);
206+
if (!next) return definitionsTail(source);
177207
const def = definitionsOf(source).find((d) => d.ident === next.ident);
178-
expect(def, `${next.ident}'s definition`).toBeDefined();
208+
expect(def, `${next.ident}'s import`).toBeDefined();
179209
return docStart(source, def!.at);
180210
}
181211

@@ -198,9 +228,9 @@ function retireAtListEnd(source: string, ident: string, order: number): string {
198228
return insert(withDefinition, entriesOf(withDefinition).end, entry(ident, order));
199229
}
200230

201-
/** The definition half done the old way: defined after every other conversion (the entry still sorted). */
231+
/** The definition half done the old way: imported after every other conversion (the entry still sorted). */
202232
function retireAtDefinitionsEnd(source: string, ident: string, order: number): string {
203-
const tail = docStart(source, source.indexOf(TAIL_ANCHOR) + 1);
233+
const tail = definitionsTail(source);
204234
return sortedEntry(insert(source, tail, definition(ident)), ident, order);
205235
}
206236

@@ -302,9 +332,10 @@ describe('major 18 in the conversions registry — the shape the merge needs (#2
302332
).toEqual([]);
303333
});
304334

305-
it('each entry is a major-18 conversion defined in this file, and the list is all of them', () => {
335+
it('each entry is a major-18 conversion imported from the module that defines it, and the list is all of them', () => {
306336
const ids = new Map(definitionsOf(SOURCE).map((d) => [d.ident, d.id]));
307337
expect(IDENTS.filter((i) => !ids.has(i))).toEqual([]);
338+
expect(IDENTS.filter((i) => !definitionInModule(i, ids.get(i)!)), 'each module defines its conversion under its id').toEqual([]);
308339
expect(new Set(IDENTS.map((i) => ids.get(i)))).toEqual(new Set(APPLIED));
309340
expect(APPLIED).toHaveLength(IDENTS.length);
310341
expect(CONVERSIONS_BY_MAJOR[18]!.every((c) => c.toMajor === 18)).toBe(true);
@@ -321,7 +352,7 @@ describe('major 18 in the conversions registry — the shape the merge needs (#2
321352
expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toEqual(order);
322353
});
323354

324-
it('a conversion added after the rule is defined directly above the entry that follows it', () => {
355+
it('a conversion added after the rule is imported directly above the entry that follows it', () => {
325356
expect(PLACED_BEFORE_THE_RULE.size, '⛔ the exemption is closed: place a new conversion by the rule').toBe(46);
326357
expect(placementFindings(SOURCE)).toEqual([]);
327358
});
@@ -376,9 +407,9 @@ describe('major 18 in the conversions registry — two retirements, no merge dri
376407
expect(placementFindings(retireAtDefinitionsEnd(SOURCE, A, NEXT_ORDER))).toEqual([
377408
...(PLACED_BEFORE_THE_RULE.has(neighbour)
378409
? []
379-
: [`${neighbour} is defined above ${IDENTS[k]}; define it directly above ${A}'s definition, the entry that follows it`]),
380-
`${A} is defined above nothing (it is the last conversion defined); define it directly above ${IDENTS[k]}'s `
381-
+ 'definition, the entry that follows it',
410+
: [`${neighbour} is imported above ${IDENTS[k]}; import it directly above ${A}'s import, the entry that follows it`]),
411+
`${A} is imported above nothing (it is the last conversion imported); import it directly above ${IDENTS[k]}'s `
412+
+ 'import, the entry that follows it',
382413
]);
383414
});
384415

@@ -408,7 +439,7 @@ describe('major 18 in the conversions registry — two retirements, no merge dri
408439
expect(m.conflicted).toEqual([REL]);
409440
});
410441

411-
it('the same two conversions defined after every other conversion conflict — the definitions have a tail too', () => {
442+
it('the same two conversions imported after every other conversion conflict — the imports have a tail too', () => {
412443
const m = repo.merge('definitions-end-a', 'definitions-end-b');
413444
expect(m.status).toBe(1);
414445
expect(m.conflicted).toEqual([REL]);
@@ -419,7 +450,7 @@ describe('major 18 in the conversions registry — two retirements, no merge dri
419450
+ " id: 'view-list-tabs-removed',\n};\n\n"
420451
+ 'export const CONVERSIONS_BY_MAJOR = {\n 18: [\n viewListTabsRemoved,\n ],\n};\n';
421452
const append = (ident: string) => old
422-
.replace('export const CONVERSIONS_BY_MAJOR', `${definition(ident)}export const CONVERSIONS_BY_MAJOR`)
453+
.replace('export const CONVERSIONS_BY_MAJOR', `${legacyDefinition(ident)}export const CONVERSIONS_BY_MAJOR`)
423454
.replace(' viewListTabsRemoved,\n', ` viewListTabsRemoved,\n ${ident},\n`);
424455
const legacy = new FileRepo(old);
425456
try {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#29987 codemod] The `flow-node-crud-filter-alias` conversion (`toMajor:
4+
// 11`), moved out of `conversions/registry.ts` whole by
5+
// `scripts/codemod-split-conversions-registry.mjs` (ruling 6583399215:
6+
// per-entry files).
7+
//
8+
// A MOVE, not a redesign: each declaration below is its `registry.ts` text
9+
// exactly, with the comments above it, in the order it stood there. A LEAF: it
10+
// imports neither `registry.ts` nor another conversion's module. `registry.ts`
11+
// imports `flowNodeFilterAlias` and lists it where it stood.
12+
13+
import type { MetadataConversion } from '../../types.js';
14+
import { mapFlowNodes, renameConfigKey } from '../../walk.js';
15+
16+
/**
17+
* CRUD flow-node `config.filters` → `config.filter` (protocol 11.0).
18+
*
19+
* This entry demonstrates ADR-0087's **PD #12 retirement path** (issue #2645):
20+
* the `get_record` / `update_record` / `delete_record` executors historically
21+
* tolerated the `filters` alias via a consumer-side
22+
* `readAliasedConfig(cfg, …, 'filter', ['filters'], …)` fallback. That scattered
23+
* dialect tolerance is promoted here into one declared, expiring conversion and
24+
* the executor fallback is deleted: the load path now hands the executor the
25+
* canonical `filter` key, so the executor reads `cfg.filter` directly.
26+
*
27+
* **Retired from the load path at 18** — its window (protocol 11) ran long
28+
* ago, and the entry stayed live only because nothing marked it (#23487). The
29+
* authoring funnel no longer rewrites `filters`, so an author meets the CRUD
30+
* node's strict config contract, whose refusal names `filter` and this release
31+
* (`CRUD_ALIAS_GUIDANCE`), at every door that judges a flow: `os validate` /
32+
* `os build` / `os lint`. Stored flows keep healing: the automation engine's
33+
* flow rehydration and stored-row rehydration (`applyConversionsToStoredItem`)
34+
* replay the full chain, this entry included, and so does the
35+
* artifact-ingestion door inside its window. `os migrate meta` does not reach
36+
* it: protocol 11 is below `MIGRATION_SUPPORT_FLOOR`.
37+
*/
38+
const flowNodeFilterAlias: MetadataConversion = {
39+
id: 'flow-node-crud-filter-alias',
40+
toMajor: 11,
41+
retiredFromLoadPath: true,
42+
retiredAfter: '17.7.0',
43+
surface: 'flow.node.config.filter',
44+
summary: "CRUD flow-node config key 'filters' → 'filter'",
45+
apply(stack, emit) {
46+
const crudTypes = new Set(['get_record', 'update_record', 'delete_record']);
47+
return mapFlowNodes(stack, (node, path) => {
48+
if (typeof node.type !== 'string' || !crudTypes.has(node.type)) return node;
49+
const renamed = renameConfigKey(node, 'filters', 'filter');
50+
if (!renamed) return node;
51+
emit({ from: 'filters', to: 'filter', path: `${path}.config.filter` });
52+
return renamed;
53+
});
54+
},
55+
fixture: {
56+
before: {
57+
flows: [
58+
{
59+
name: 'purge_flow',
60+
nodes: [
61+
{ id: 'n1', type: 'start' },
62+
{
63+
id: 'n2',
64+
type: 'delete_record',
65+
config: { objectName: 'lead', filters: { status: 'stale' } },
66+
},
67+
],
68+
},
69+
],
70+
},
71+
after: {
72+
flows: [
73+
{
74+
name: 'purge_flow',
75+
nodes: [
76+
{ id: 'n1', type: 'start' },
77+
{
78+
id: 'n2',
79+
type: 'delete_record',
80+
config: { objectName: 'lead', filter: { status: 'stale' } },
81+
},
82+
],
83+
},
84+
],
85+
},
86+
expectedNotices: 1,
87+
},
88+
};
89+
90+
export { flowNodeFilterAlias };

0 commit comments

Comments
 (0)