Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/20697-semantic-migration-conversion-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
---

feat(spec): a semantic migration names the D2 conversions whose applied edits it judges

Clause-②: yes (widening)

`SemanticMigration`, the ADR-0087 D3 entry type exported by `@objectstack/spec`,
gains one optional member, `conversionIds?: readonly string[]`. It lists the ids
of the D2 conversions whose applied edits the entry judges. Each id is the same
`conversionId` that the conversion's `MigrationApplication` rows carry, so a
printer of an `applyMetaMigrations` result can show the entry beside those edits
for review. The chain copies the field onto the entry's `MigrationTodo`, so
`objectstack migrate meta --json` shows it on that todo. Nothing is removed or
renamed, and every entry is still reported as a todo of its hop.

One link ships: `flow-decision-edge-branching-first-match` judges the
`mode: 'inclusive'` edits that `flow-decision-mode-inclusive-explicit` writes.
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,7 @@ export const entry: SemanticMigration = {
+ '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the '
+ 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. '
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
// The edits this entry judges: every `mode: 'inclusive'` that conversion
// writes is one decision to keep, delete or narrow, per the criteria above.
conversionIds: ['flow-decision-mode-inclusive-explicit'],
};
50 changes: 50 additions & 0 deletions packages/spec/src/migrations/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,56 @@ describe('migration chain (ADR-0087 D3)', () => {
}
});

// `SemanticMigration.conversionIds` is the join between an applied edit and
// the entry that judges it, keyed on `MigrationApplication.conversionId`.
// An id the chain never replays at or before the entry's hop can never meet
// an applied edit, so the link would silently pair nothing: a typo, a
// conversion of a later major, or one whose step fell below the floor.
it('every `conversionIds` link on a semantic entry names a registered conversion that its own step or an earlier one replays', () => {
const dangling: string[] = [];
let links = 0;
for (const major of MIGRATION_MAJORS) {
const replayed = new Set(
MIGRATION_MAJORS.filter((m) => m <= major).flatMap((m) => MIGRATIONS_BY_MAJOR[m]!.conversionIds),
);
for (const s of MIGRATIONS_BY_MAJOR[major]!.semantic) {
for (const id of s.conversionIds ?? []) {
links++;
if (!CONVERSION_IDS.has(id)) {
dangling.push(`protocol ${major}: ${s.id} → ${id} (no registered conversion has this id)`);
} else if (!replayed.has(id)) {
dangling.push(`protocol ${major}: ${s.id} → ${id} (registered, but no step at or below ${major} replays it)`);
}
}
}
}
expect(
dangling,
`semantic entry link(s) that can pair with no applied edit: ${dangling.join(', ')}. `
+ 'Remedy: correct the id in the entry file under `entries/semantic/` to the D2 conversion whose '
+ 'applied edits the entry judges, one graduated into the entry\'s own step or an earlier one, '
+ 'then `gen:migration-registry`; or drop the id if the entry judges no edit of that conversion.',
).toEqual([]);
// Anti-vacuity: at least one link exists, so the loop above read something.
expect(links).toBeGreaterThan(0);
});

it('the decision-mode pair joins end to end: the chain carries the link onto the TODO, and it names the applied edits', () => {
const JUDGE = 'flow-decision-edge-branching-first-match';
const CONVERSION = 'flow-decision-mode-inclusive-explicit';
const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION)!;
expect(conversion.toMajor).toBe(18);

const result = applyMetaMigrations(conversion.fixture.before, 17, 18);
const todo = result.todos.find((t) => t.id === JUDGE);
expect(todo?.conversionIds).toEqual([CONVERSION]);

// The join a printer makes: the applied edits whose `conversionId` the TODO names.
const judged = result.applied.filter((a) => todo!.conversionIds!.includes(a.conversionId));
expect(judged.length).toBeGreaterThan(0);
expect(new Set(judged.map((a) => a.conversionId))).toEqual(new Set([CONVERSION]));
});

it('a graduated conversion belongs to the step for its own major', () => {
for (const [majorStr, step] of Object.entries(MIGRATIONS_BY_MAJOR)) {
const major = Number(majorStr);
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11450,6 +11450,9 @@ const step18: MigrationStep = {
+ '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the '
+ 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. '
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
// The edits this entry judges: every `mode: 'inclusive'` that conversion
// writes is one decision to keep, delete or narrow, per the criteria above.
conversionIds: ['flow-decision-mode-inclusive-explicit'],
},
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
// span already, and a nested backtick would close it.
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ export interface SemanticMigration {
reason: string;
/** How the consumer proves the hand-migration correct (their own verify loop). */
acceptanceCriteria: string;
/**
* Ids of the D2 conversions whose APPLIED edits this entry judges: the
* mechanical rewrites the chain replay makes, for which this entry states the
* judgment the consumer still owes (keep the written value, delete it, or
* narrow the source instead). Each id names a conversion that this entry's
* step or an earlier one replays (`MigrationStep.conversionIds`), and
* `migrations.test.ts` refuses one that does not.
*
* The join key is the same name on both sides: an id here is the
* `conversionId` of every {@link MigrationApplication} that conversion
* produces, and the chain copies this field onto the entry's
* {@link MigrationTodo} like every other field. So a printer of a chain
* result can show the entry beside each applied edit it judges, for review.
* The link moves nothing out of the chain result: the entry is reported as a
* TODO of its hop whether or not any edit it names was applied.
*
* Omit it when the entry judges no mechanical edit. Add an id only after
* reading the entry and confirming that it judges that conversion's output;
* ⛔ never derive one from the entry's prose naming the id, since prose also
* names incidental analogues.
*/
conversionIds?: readonly string[];
}

/**
Expand Down
Loading