Skip to content

Commit 7118fcf

Browse files
committed
feat(spec): a semantic migration names the D2 conversions whose applied edits it judges
SemanticMigration gains an optional conversionIds list, the same ids MigrationApplication.conversionId carries, so a printer of a chain result can show an entry beside the applied edits it judges. flow-decision-edge-branching-first-match links flow-decision-mode-inclusive-explicit, and migrations.test.ts refuses a link that no step at or below the entry's own replays. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5757463 commit 7118fcf

4 files changed

Lines changed: 78 additions & 0 deletions

File tree

‎packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,7 @@ export const entry: SemanticMigration = {
8484
+ '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the '
8585
+ 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. '
8686
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
87+
// The edits this entry judges: every `mode: 'inclusive'` that conversion
88+
// writes is one decision to keep, delete or narrow, per the criteria above.
89+
conversionIds: ['flow-decision-mode-inclusive-explicit'],
8790
};

‎packages/spec/src/migrations/migrations.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,56 @@ describe('migration chain (ADR-0087 D3)', () => {
7272
}
7373
});
7474

75+
// `SemanticMigration.conversionIds` is the join between an applied edit and
76+
// the entry that judges it, keyed on `MigrationApplication.conversionId`.
77+
// An id the chain never replays at or before the entry's hop can never meet
78+
// an applied edit, so the link would silently pair nothing: a typo, a
79+
// conversion of a later major, or one whose step fell below the floor.
80+
it('every `conversionIds` link on a semantic entry names a registered conversion that its own step or an earlier one replays', () => {
81+
const dangling: string[] = [];
82+
let links = 0;
83+
for (const major of MIGRATION_MAJORS) {
84+
const replayed = new Set(
85+
MIGRATION_MAJORS.filter((m) => m <= major).flatMap((m) => MIGRATIONS_BY_MAJOR[m]!.conversionIds),
86+
);
87+
for (const s of MIGRATIONS_BY_MAJOR[major]!.semantic) {
88+
for (const id of s.conversionIds ?? []) {
89+
links++;
90+
if (!CONVERSION_IDS.has(id)) {
91+
dangling.push(`protocol ${major}: ${s.id} → ${id} (no registered conversion has this id)`);
92+
} else if (!replayed.has(id)) {
93+
dangling.push(`protocol ${major}: ${s.id} → ${id} (registered, but no step at or below ${major} replays it)`);
94+
}
95+
}
96+
}
97+
}
98+
expect(
99+
dangling,
100+
`semantic entry link(s) that can pair with no applied edit: ${dangling.join(', ')}. `
101+
+ 'Remedy: correct the id in the entry file under `entries/semantic/` to the D2 conversion whose '
102+
+ 'applied edits the entry judges, one graduated into the entry\'s own step or an earlier one, '
103+
+ 'then `gen:migration-registry`; or drop the id if the entry judges no edit of that conversion.',
104+
).toEqual([]);
105+
// Anti-vacuity: at least one link exists, so the loop above read something.
106+
expect(links).toBeGreaterThan(0);
107+
});
108+
109+
it('the decision-mode pair joins end to end: the chain carries the link onto the TODO, and it names the applied edits', () => {
110+
const JUDGE = 'flow-decision-edge-branching-first-match';
111+
const CONVERSION = 'flow-decision-mode-inclusive-explicit';
112+
const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION)!;
113+
expect(conversion.toMajor).toBe(18);
114+
115+
const result = applyMetaMigrations(conversion.fixture.before, 17, 18);
116+
const todo = result.todos.find((t) => t.id === JUDGE);
117+
expect(todo?.conversionIds).toEqual([CONVERSION]);
118+
119+
// The join a printer makes: the applied edits whose `conversionId` the TODO names.
120+
const judged = result.applied.filter((a) => todo!.conversionIds!.includes(a.conversionId));
121+
expect(judged.length).toBeGreaterThan(0);
122+
expect(new Set(judged.map((a) => a.conversionId))).toEqual(new Set([CONVERSION]));
123+
});
124+
75125
it('a graduated conversion belongs to the step for its own major', () => {
76126
for (const [majorStr, step] of Object.entries(MIGRATIONS_BY_MAJOR)) {
77127
const major = Number(majorStr);

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11450,6 +11450,9 @@ const step18: MigrationStep = {
1145011450
+ '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the '
1145111451
+ 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. '
1145211452
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
11453+
// The edits this entry judges: every `mode: 'inclusive'` that conversion
11454+
// writes is one decision to keep, delete or narrow, per the criteria above.
11455+
conversionIds: ['flow-decision-mode-inclusive-explicit'],
1145311456
},
1145411457
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
1145511458
// span already, and a nested backtick would close it.

‎packages/spec/src/migrations/types.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,28 @@ export interface SemanticMigration {
4949
reason: string;
5050
/** How the consumer proves the hand-migration correct (their own verify loop). */
5151
acceptanceCriteria: string;
52+
/**
53+
* Ids of the D2 conversions whose APPLIED edits this entry judges: the
54+
* mechanical rewrites the chain replay makes, for which this entry states the
55+
* judgment the consumer still owes (keep the written value, delete it, or
56+
* narrow the source instead). Each id names a conversion that this entry's
57+
* step or an earlier one replays (`MigrationStep.conversionIds`), and
58+
* `migrations.test.ts` refuses one that does not.
59+
*
60+
* The join key is the same name on both sides: an id here is the
61+
* `conversionId` of every {@link MigrationApplication} that conversion
62+
* produces, and the chain copies this field onto the entry's
63+
* {@link MigrationTodo} like every other field. So a printer of a chain
64+
* result can show the entry beside each applied edit it judges, for review.
65+
* The link moves nothing out of the chain result: the entry is reported as a
66+
* TODO of its hop whether or not any edit it names was applied.
67+
*
68+
* Omit it when the entry judges no mechanical edit. Add an id only after
69+
* reading the entry and confirming that it judges that conversion's output;
70+
* ⛔ never derive one from the entry's prose naming the id, since prose also
71+
* names incidental analogues.
72+
*/
73+
conversionIds?: readonly string[];
5274
}
5375

5476
/**

0 commit comments

Comments
 (0)