Skip to content

Commit cdeabec

Browse files
feat(spec,cli): a structured relevance question takes a semantic notice off os migrate meta's default list (counted; --all lists it) (#22115)
Fixes #22072 Clause-②: yes > **Patch round 1** (head `5104a5d74`) answers the contract review FAIL `6044604409` with the seat's REWORK `6044626135`: (1) four entries whose surface also names a code door lose their question, so the batch is 25; (2) disposition B: `todos` stays whole and `absentTodos` names the proven-absent subset; (3) a declared `tiers` answers `unknown`, like `plugins` / `devPlugins`; (4) `--all` is listed in `content/docs/upgrading.mdx`. This body was updated by the seat from the dev's report `6045718354`. > **Merge round** (head `1d6efd229`): the contract review `6045970836` PASSed `5104a5d74`. Since then, the branch has merged `origin/main` `db4c45b8c` in merge commit `e74764dbb` (parents `5104a5d74`, `db4c45b8c`). That brings in main's `os migrate meta --write` and eight new step-18 entries. > - **Conflicts:** two, resolved by stacking both intents. In `meta.ts`, the `--all` and `--write` flags are now both declared. In `upgrading.mdx`, the flags table carries the `--all` row and main's `--out` and `--write` rows. > - **Catalogue:** 391 entries (77 + 314). > - **Changeset:** commit `1d6efd229` makes the `--step` sentence precise, as the review asked. > **Merge round 2** (head `16cb55b8d`): `1d6efd229` was red on CI once merged with main. `Type Check · workspace` failed in `@objectstack/cli` `check:test-typecheck`, on #22121's new `test/migrate-meta-out-snapshot.test.ts`. > - **The merge:** `origin/main` `54ace18c6` is merged in merge commit `5fc3a5a2a` (parents `1d6efd229`, `54ace18c6`). It brings #22121, which makes `os migrate meta --out` write its snapshot on a run with nothing to migrate. The merge was a clean auto-merge, with no conflict and no hand edit. > - **The fix:** commit `16cb55b8d` adds `all: false` to the `MigrationReport` literal in that test. > - The measured error was `TS2741: Property 'all' is missing … but required in type 'MigrationReport'` at line 274. That is this PR's required `--all` field on the CLI-internal report type, not `absentTodos`. > - No assertion changed. `os migrate meta` now has the one exit that #20620's triage (`5888087153`) allows a notice: a structured proof, derived from the stack, that the notice's surface is absent. An entry that carries a question leaves the default list only when the chain answers `absent` for this stack. The run counts those entries on one line, and `--all` lists them in full. ⛔ Nothing matches the prose of `surface`. ## The question: `SemanticMigration.relevantWhen` (`packages/spec/src/migrations/types.ts`) - It is an optional field beside `conversionIds`, following the precedent #20697 set. Its type is a closed union, `SemanticRelevance`. This release has one member, `StackDeclaresRelevance`: `{ kind: 'stack-declares', keys: [...] }`. It asks one question: does the stack declare anything under one of these top-level keys? - `keys` is a non-empty tuple of `SemanticRelevanceKey`, which is `StackDefinitionKey` minus the five carrier keys (`manifest`, `packages`, `plugins`, `devPlugins` and `tiers`). A typo does not compile. A key later retired from the stack makes the entry that names it fail to compile. - ⛔ There is no callback form (H4). A callback could do arbitrary work, and it could not be enumerated or pinned. A new kind of question is a new union member, evaluated in `chain.ts`. ## Evaluation (`chain.ts`) The answer has three values, and only `absent` moves an entry (H2, built in): | what the question reads | answer | | --- | --- | | the key is missing, or holds `[]` or `{}` | absent | | a non-empty array or map (the authored map form included) | present | | any other value: a function, a promise, a scalar, `null`, or a getter that throws | unknown | | a stack that is not a plain object, or no stack at all | unknown | | any entry in `plugins` / `devPlugins` / `tiers` (unless the stack visibly declares the key itself) | unknown | | an assembled `packages[].manifest` body | read like the top level; an unreadable body is unknown | - The question is asked of the stack the chain was handed and of every hop's checkpoint. Present in any of them counts as present, and absent needs all of them, so a conversion that renames or removes a key cannot make the key read as absent on either side. - An entry whose `conversionIds` names a conversion that applied an edit in the same run is always listed. The applied edit proves the surface is there. - `MigrationChainResult` and `MigrationHopResult` gain a required member, `absentTodos`. `todos` keeps every semantic entry of every hop crossed, exactly as before; `absentTodos` names the proven-absent subset of it (the same objects, in chain order). ## The output (`packages/cli/src/commands/migrate/meta.ts`; the cross-lane half, declared on the `domain:cli` seat post) - ③ lists `todos` minus `absentTodos`, and its header counts that difference. A new group ④ follows it. It is two lines. The first counts the entries proven absent and names `--all`. The second says the proof covers the stack this run loaded, and not metadata a deployment stores (Studio, the metadata API). - `--all` prints each of those entries in full, as the same block ③ prints, followed by `absent: nothing is declared under` and the keys. - `--json` always carries `absentTodos`, and `hops[].absentTodos` with `--step`. `--step` adds a `not listed` count to a hop's line when that count is not zero. - A run whose only notices are proven absent still counts them in ④ and writes `--out`. - **With #22121's `--out` on both exits**, the early return is guarded by the original condition `applied.length === 0 && todos.length === 0`. - Because `todos` is whole, a run whose only notices are proven absent never takes that branch. It goes down the main path: ④, then the one `writeStackSnapshot`, then `--write`. - Measured in-process on the merged code, with and without `--all`. Every arm writes the snapshot exactly once and names it on exactly one line: | arm | exit taken | ④ printed | | --- | --- | --- | | the real hop-18 run (314 todos, 20 absent) | main path | yes | | only-absent (`todos` = `absentTodos` = the 20 questioned) | main path | yes | | nothing listed and nothing absent | early return | no | | an empty range | early return | no | - **With main's `--write`**, its own group (⑤ in the report docstring) prints after ④ and before the data-migration advice. - `--write` reads only the chain's `applied`, `stack` and the normalized input. It never reads `todos` or `absentTodos`, and neither of them changes what it writes. - Measured on a probe project with one dashboard `refreshInterval` and no cubes (`--from 17`): - The dry run and `--write --json` report identical `todos` (314) and `absentTodos` (17). - The written sites equal `applied` (1 of 1). - The idempotent re-run applies 0 and still names the same 17. - `--write --all` lists ④ in full, then prints ⑤ (`Wrote 1 of 1 …`). - A run with no applied edit prints `--write`'s "no mechanical change" line in either branch. hotcrm's default report now ends like this: ```text 302 manual change(s) require your judgment: … 12 more manual change(s) not listed: their surfaces are absent from this stack (run with --all to list them). Absent is proven over the stack this run loaded; metadata a deployment stores (Studio, the metadata API) is not read here. ``` ## The first batch: 25 of the 391 entries carry a question | keys the question names | protocol 17 | protocol 18 | entries | | --- | ---: | ---: | --- | | `analyticsCubes` | 0 | 8 | `cube-join-sql-and-relationship-retired`, `cube-member-inner-name-retired`, `cube-member-sql-expression-retired`, `cube-metric-expression-types-retired`, `cube-metric-filters-retired`, `cube-refresh-key-retired`, `analytics-cube-public-default-visible-enforced`, `analytics-cube-single-granularity-default-enforced` | | `dashboards` | 1 | 2 | `dashboard-widget-compareto-offset`, `dashboard-refresh-interval-unit-in-key`, `dashboard-header-modal-target-page-only` | | `dashboards` / `reports` / `pages` | 0 | 1 | `chart-config-aria-retired` | | `permissions` | 0 | 1 | `permission-restore-purge-bits-retired` | | `sharingRules` | 1 | 0 | `sharing-rule-recipient-reconcile` | | `apis` | 1 | 1 | `declarative-apis-endpoints-live`, `api-endpoint-cache-ttl-unit-in-key` | | `jobs` | 1 | 1 | `job-retry-policy-constraints-tightened`, `job-timeout-unit-in-key` | | `agents` | 0 | 2 | `agent-memory-store-retired-and-limits-required`, `agent-structured-output-refused-members-retired` | | `datasets` | 0 | 2 | `dataset-measure-aggregate-field-type-refused`, `dataset-measure-selecting-aggregate-field-type-refused` | | `mappings` | 0 | 1 | `mapping-lookup-params-retired` | | `hooks` | 0 | 1 | `hook-timeout-unit-in-key` | | `tools` | 1 | 0 | `tool-requires-confirmation-retired` | | **total** | **5 of 77** | **20 of 314** | **25 of 391** | `semantic-relevance.test.ts` pins this table, so every addition is a reviewed edit. I read each entry against three rules: 1. **Only the named keys.** The surface lives only under the named top-level key(s). I checked this against the schema: in a stack, the item schema is referenced only by those collections. `ReportSchema` is also carried inline in an `object-metric` drill-down, so the chart-config entry names `pages` too. 2. **No runtime door.** The surface names no runtime request body, no client, engine or driver API, no kernel or plugin config, and no platform-shipped item. 3. **No stored row.** The acceptance criteria send the author to no stored `sys_metadata` row and no runtime door. Some families are left out on purpose: - **Rule 3:** flows (their entries send the author to stored flow rows), the joined-report entries, dashboard widget arity, stage order and chart structure, RLS `tags`, `check` on select/delete, the cross-class comparison, and `cel-predicate-list-comparand-refused`. - **Rule 2:** the analytics row wildcard and the dataset member expression (both are judged on inline datasets in query bodies too); and `cel-predicate-one-value-comparand-refused`, `cel-predicate-variable-root-comparand-refused`, `rls-predicate-array-comparand-refused` and `rls-predicate-stored-list-ordering-refused`: their surface also names a code door, a direct `matchesFilterCondition` / compiler caller (patch round 1). - **Rule 1:** - connectors: a connector document also reaches the runtime through `engine.registerConnector` from code; - datasources: driver configs are built in code; - the hook-body stored-metadata target: it applies to stored hook rows. - **Main's eight new step-18 entries** (merge round) carry no question: - `flow-edge-unresolved-or-repeated-refused` falls under rule 3: its acceptance criteria send the author to stored flow rows in `sys_metadata`. - The seven `sys_flow_dispatch` / `sys_job` / `sys_job_queue` / `sys_job_run` / `sys_migration_journal` / `sys_migration` / `sys_presence` `organization_id` retirements fall under rule 2: each surface is a platform-shipped table, not a stack key. ## Measured: listed count before and after "Before" is `todos`, which is whole again (disposition B). "After" is `todos` minus `absentTodos`, which is what ③ lists. I read both from `--json` of this branch's CLI at the merge head `e74764dbb`. `absentTodos` was a subset of `todos` on every row. | stack | `--from` | before | after | left the list | | --- | ---: | ---: | ---: | ---: | | hotcrm `c967803` | 17 | 314 | 302 | 12 | | hotcrm `c967803` | 16 | 391 | 376 | 15 | | `examples/app-crm` | 17 | 314 | 301 | 13 | | `examples/app-todo` | 17 | 314 | 300 | 14 | | `examples/app-multi-package` (assembled `packages[]`) | 17 | 314 | 294 | 20 | | `examples/app-showcase` (lists runtime plugins) | 17 | 314 | 314 | 0 (H2: a listed plugin makes every answer unknown) | | a minimal stack with one object | 17 | 314 | 294 | 20 | | a minimal stack with one object | 16 | 391 | 366 | 25 | - **hotcrm** is a read-only clone at `c967803`, the commit the card measured, loaded with this branch's CLI and spec. - At the card's time it reproduced the baseline exactly: 304 manual notices and 12 applied edits. - The base is 314 now because `main` has added ten step-18 entries since. The 12 entries that leave the list, and the 12 applied edits, are unchanged. - hotcrm's `--step` hop line reads `12 mechanical, 302 manual, 12 not listed (surface absent)`. - **The ceiling:** most of the remaining notices govern surfaces that are not stack keys at all (kernel and plugin configs, client, engine and driver APIs, exported types). No `stack-declares` question can reach them, and each needs a different kind of question. ## For the contract review - **`todos` membership — disposition B (patch round 1).** `applyMetaMigrations().todos`, `MigrationHopResult.todos` and `--json`'s `todos` are unchanged: every semantic entry, as their TSDoc promised. `absentTodos` is a new required member of `MigrationChainResult` and `MigrationHopResult` naming the proven-absent subset. `migrations.test.ts`'s original equality pin is restored, with a subset pin beside it. `Clause-②: yes`, `minor`, no BREAKING line. - **What the proof covers.** The proof is about the definition this run loads. Rows a deployment stores (Studio, the metadata API, AI-authored rows) are a separate subject. `os migrate meta --stored` reports D2 conversion TODOs per row and never reports the D3 catalogue. The printed scope line states this boundary. - **Internal exports: two functions and one type.** `chain.ts` exports the functions `semanticRelevanceVerdict` and `semanticTodoAbsent` and the type `SemanticRelevanceVerdict` for its own tests only. None of them is re-exported from `@objectstack/spec/migrations`; the `api-surface/migrations.json` shard gains only the three published types. ## Verification (head `16cb55b8d`; the merge `5fc3a5a2a` with `54ace18c6`) - **build:** the turbo build of the CLI closure, the CLI, the showcase closure and `client-react` gave 61 tasks, all successful. - **The `--out` / report pins against the merged code:** `test/migrate-meta-out-snapshot.test.ts` (#22121, whole file), `src/commands/migrate/meta.report-order.test.ts` (ORDER, SET, PAIR, ABSENT, including 'a run whose only notices are proven absent still counts them and still writes --out') and `test/migrate-meta-write.test.ts` gave `Test Files 3 passed (3)`, `Tests 52 passed (52)`. - **spec, the 58 test files that read the registry or the chain:** `Test Files 58 passed (58)`, `Tests 2331 passed (2331)`. That covers the ENUMERATION (25: 5 + 20), SHAPE, EVALUATION and CHAIN pins, and `migrations.test.ts`'s equality and subset pins. - **typecheck:** - `pnpm --filter @objectstack/spec typecheck` exit 0. - `pnpm --filter @objectstack/cli typecheck` exit 0, `check:test-typecheck` included: `OK — … 3 file(s) / 28 error(s) / 6 pinned signature(s) held in test-typecheck-debt.json`. - `tsc -p tsconfig.test.json` gave 29 errors before the fix (the 28 the ledger holds, plus the new TS2741) and 28 after. The ledger is not touched. - **cli unit tier:** `Test Files 263 passed (263)`, `Tests 3874 passed (3874)`. - **cli integration tier:** `test/migrate-meta-engine-guidance.test.ts` (it passes `--all`) and `test/migrate-meta-default-range.test.ts` gave `Test Files 2 passed (2)`, `Tests 10 passed | 1 skipped (11)`. - **Generated artifacts:** `check:migration-registry` reports `391 semantic`; #22121 adds no entry. `registry.ts` carries 25 `relevantWhen` lines. - **Gates:** `dispatch-gates --commands` derived 117 families at `16cb55b8d`. 116 exited 0. 1 is NOT MEASURED: `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET. - `check:vendor-version-stamps` first exited 1 on ENOENT for a temp file the concurrently running CLI unit tier created under `packages/cli/tmp/`. Re-run alone, it exited 0. - `--ran` accounted for all 117. - **ESLint, narrowed to the 35 changed TS files** against the merge base `54ace18c6`: 35 files, 0 errors, 0 warnings. There is no type-aware linting, so no untouched file's verdict can move. ## Mechanism hypotheses, measured - **H1 held.** `SemanticMigration` was at `types.ts:38` and `conversionIds` at `:73`. The chain mapped every `step.semantic` entry into `todos` (`chain.ts:102`). Since the order and pair work, ③ prints from `meta.ts` around `:439`. - **H2 held and is built in** (see the evaluation table). `app-showcase` measures it: 314 before and 314 after. - **H3: hotcrm was reachable read-only** (it is a public repository), so its before/after is measured rather than NOT MEASURED. - **H4:** the closed form covers the first batch, so no callback form exists. ## Acceptance notes - **A pre-existing defect.** `os migrate meta --from 18 --to 18 --out FILE` covers a range with no step: its human report writes no snapshot and says nothing about it, while `--json` writes the file. Unchanged here (an empty range has no `absentTodos`). Filed by the seat as #22116. - **Upgrade skill.** `skills/objectstack-upgrade/SKILL.md` (Tier H) loops over `--json`'s `todos`; under disposition B its reading is unchanged, so no governed edit is owed. - **Merges.** The branch carries three merges of `origin/main`: - `de8ccbcfa` (with `a543e244f`, clean); - `e74764dbb` (with `db4c45b8c`, two conflicts resolved by stacking both intents, see the merge-round note at the top); - `5fc3a5a2a` (with `54ace18c6`, clean, see merge round 2). - Every line the branch added to `meta.ts` and `upgrading.mdx` survives the merge, and so does every line main added, except two that were changed by hand. Both are comments. - The comment on main's `--write` group is relabelled from ④ to ⑤, because this PR's ④ is the absent group. - The report docstring's ④ item now ends with `;`, and a ⑤ item follows it for `--write`. - None of main's ten new step-18 entries carries a question, so the enumeration pin is unaffected. - The measured before/after table was read at `e74764dbb`. The catalogue (391) and every `relevantWhen` are unchanged since, so it still holds. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54ace18 commit cdeabec

39 files changed

Lines changed: 942 additions & 64 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
`os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full.
7+
8+
Clause-②: yes
9+
10+
- **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`.
11+
- **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint.
12+
- **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before.
13+
- **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent).
14+
- **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`:
15+
- a value the question cannot read (a function, a promise, a scalar, a getter that throws);
16+
- a stack that is not a plain object;
17+
- any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show.
18+
19+
An entry that judges a conversion which applied an edit in the same run is never named either.
20+
- **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before.
21+
- **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores.
22+
- `--all` prints each of those entries in full, with the keys it was proven absent under.
23+
- `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`.
24+
- `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero.
25+
- A run whose only notices are proven absent still writes `--out`.

‎content/docs/upgrading.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ Useful flags:
215215
| Flag | What it does |
216216
| :--- | :--- |
217217
| `--step` | Report each major's hop separately, so a failure bisects to the exact major |
218+
| `--all` | Also list, in full, the manual changes whose surface the command proved absent from your stack — by default they are only counted. `--json` always reports them in `todos` and names them in `absentTodos` |
218219
| `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot |
219220
| `--write` | Write the mechanical changes into your source files, where each can be traced to one literal; list the rest with the reason (see below) |
220221
| `--to 17` | Stop at an intermediate major instead of this runtime's |

‎packages/cli/src/commands/migrate/meta.report-order.test.ts‎

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
/**
44
* `os migrate meta` — the human report leads with what blocks the stack.
55
*
6-
* The chain hands the printer every semantic entry of every hop it crosses,
7-
* whatever the stack holds, so the semantic group is the whole catalogue of
8-
* each major crossed — hundreds of notices. The report therefore prints three
9-
* groups in the order an upgrader acts on them, each under one header line that
10-
* counts it:
6+
* The chain hands the printer every semantic entry of every hop it crosses —
7+
* hundreds of notices — and proves only the entries carrying a structured
8+
* relevance question (`relevantWhen`) irrelevant to the stack. The report
9+
* therefore prints its groups in the order an upgrader acts on them, each
10+
* under one header line that counts it:
1111
*
1212
* ① the verdict, and every schema refusal left after the chain;
1313
* ② the applied mechanical edits;
14-
* ③ the semantic notices.
14+
* ③ the semantic notices the stack may owe (`todos` minus `absentTodos`);
15+
* ④ the notices proven absent from the stack (`absentTodos`, a subset of
16+
* `todos`) — counted on one line by default, listed in full under `--all`
17+
* (the ABSENT pins below).
1518
*
1619
* ## Two kinds of pin, kept apart on purpose
1720
*
@@ -45,6 +48,9 @@
4548
* EARLIER step replays, and none is authored yet.
4649
*/
4750

51+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
52+
import { tmpdir } from 'node:os';
53+
import { dirname, join } from 'node:path';
4854
import { stripVTControlCharacters } from 'node:util';
4955
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5056
import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec';
@@ -126,6 +132,7 @@ function run(
126132
fromMajor: number,
127133
toMajor: number,
128134
amend: (result: MigrationChainResult) => MigrationChainResult = (r) => r,
135+
options: { all?: boolean; out?: string } = {},
129136
): Run {
130137
const normalized = normalizeStackInput(stack, { convert: false });
131138
const result = amend(applyMetaMigrations(normalized, fromMajor, toMajor));
@@ -137,6 +144,8 @@ function run(
137144
refusals: parsed.success ? [] : parsed.error.issues,
138145
dataMigrations: [],
139146
step: false,
147+
all: options.all ?? false,
148+
...(options.out ? { out: options.out } : {}),
140149
elapsed: '1ms',
141150
};
142151
printMigrationReport(report);
@@ -159,15 +168,42 @@ function appliedLines(result: MigrationChainResult): string[] {
159168
return result.applied.map((a) => ` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`);
160169
}
161170

162-
/** The lines a semantic notice prints — every field, split the way a terminal splits it. */
171+
/** The lines one semantic notice prints — every field, split the way a terminal splits it. */
172+
function blockLines(t: MigrationTodo): string[] {
173+
return [
174+
` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`,
175+
` why: ${t.reason}`,
176+
` verify: ${t.acceptanceCriteria}`,
177+
].join('\n').split('\n');
178+
}
179+
180+
/**
181+
* The notices ③ lists: every entry of `todos` the chain did not name in
182+
* `absentTodos` — written from the chain's data by key, not by the printer's
183+
* own filter.
184+
*/
185+
function listedOf(result: MigrationChainResult): MigrationTodo[] {
186+
const absent = new Set(result.absentTodos.map((t) => `${t.toMajor}:${t.id}`));
187+
return result.todos.filter((t) => !absent.has(`${t.toMajor}:${t.id}`));
188+
}
189+
190+
/** The lines ③ prints — every listed notice, in chain order. */
163191
function noticeLines(result: MigrationChainResult): string[] {
164-
return result.todos.flatMap((t) =>
165-
[
166-
` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`,
167-
` why: ${t.reason}`,
168-
` verify: ${t.acceptanceCriteria}`,
169-
].join('\n').split('\n'),
170-
);
192+
return listedOf(result).flatMap(blockLines);
193+
}
194+
195+
/** ④ without `--all`: the line counting the proven-absent notices, and the line scoping the proof. */
196+
const ABSENT_COUNT_RE = /^ {2}(\d+) more manual change\(s\) not listed: their surfaces are absent from this stack \(run with --all to list them\)\.$/;
197+
const ABSENT_SCOPE_RE = /^ {4}Absent is proven over the stack this run loaded; /;
198+
/** ④ under `--all`: the header counting the group. */
199+
const ABSENT_HEADER_RE = /^ {2}(\d+) manual change\(s\) whose surfaces are absent from this stack \(listed by --all\):$/;
200+
201+
/** The lines ④ prints under `--all` — each absent notice in full, then the keys it was proven absent under. */
202+
function absentListLines(result: MigrationChainResult): string[] {
203+
return result.absentTodos.flatMap((t) => [
204+
...blockLines(t),
205+
` absent: nothing is declared under ${(t.relevantWhen?.keys ?? []).map((k) => `\`${k}\``).join(' / ')}`,
206+
]);
171207
}
172208

173209
/** The lines the refusal group prints — one `formatZodIssue` render per refusal. */
@@ -233,7 +269,7 @@ describe('each group opens with one header line that counts it', () => {
233269
` Applied ${result.applied.length} mechanical change(s):`,
234270
]);
235271
expect(lines.filter((l) => SEMANTIC_HEADER_RE.test(l))).toEqual([
236-
` ${result.todos.length} manual change(s) require your judgment:`,
272+
` ${listedOf(result).length} manual change(s) require your judgment:`,
237273
]);
238274
});
239275
});
@@ -250,8 +286,8 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () =
250286
const expected = noticeLines(result);
251287
// Anti-vacuity: more than one hop's catalogue, and at least one notice
252288
// whose prose spans several terminal lines.
253-
expect(new Set(result.todos.map((t) => t.toMajor)).size).toBeGreaterThan(1);
254-
expect(expected.length).toBeGreaterThan(result.todos.length * 3);
289+
expect(new Set(listedOf(result).map((t) => t.toMajor)).size).toBeGreaterThan(1);
290+
expect(expected.length).toBeGreaterThan(listedOf(result).length * 3);
255291
const header = indexOf(lines, SEMANTIC_HEADER_RE);
256292
const printedNotices = lines.slice(header + 1, header + 1 + expected.length);
257293
expect(printedNotices).toEqual(expected);
@@ -273,6 +309,7 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () =
273309
const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
274310
const accounted = [
275311
...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)),
312+
...lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l)),
276313
...refusalLines(report),
277314
...appliedLines(result),
278315
...noticeLines(result),
@@ -414,11 +451,11 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m
414451
it('keeps every semantic entry in ③ — the judge included — with the chain\'s count and bytes', () => {
415452
const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
416453
const header = indexOf(lines, SEMANTIC_HEADER_RE);
417-
expect(lines[header]).toBe(` ${result.todos.length} manual change(s) require your judgment:`);
454+
expect(lines[header]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`);
418455
const expected = noticeLines(result);
419456
expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected);
420457
const entries = lines.slice(header + 1).filter((l) => /^ {4}⚠ \[protocol \d+\] /.test(l));
421-
expect(entries).toHaveLength(result.todos.length);
458+
expect(entries).toHaveLength(listedOf(result).length);
422459
const judge = todoOf(result, DECISION_JUDGE);
423460
expect(entries).toContain(` ⚠ [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`);
424461
});
@@ -435,12 +472,13 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m
435472
}
436473
runs.set(a.conversionId, (runs.get(a.conversionId) ?? 0) + 1);
437474
}
438-
const reviews = result.todos.flatMap((t) =>
475+
const reviews = listedOf(result).flatMap((t) =>
439476
(t.conversionIds ?? []).filter((id) => runs.has(id)).map((id) => reviewLine(t, runs.get(id)!)),
440477
);
441478
expect(reviews.length, 'anti-vacuity: the stack exercises a link').toBeGreaterThan(0);
442479
const accounted = [
443480
...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)),
481+
...lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l)),
444482
...refusalLines(report),
445483
...appliedLines(result),
446484
...reviews,
@@ -493,3 +531,96 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m
493531
expect(reviewsUnder(lines, last)).toEqual([reviewLine(judge, length)]);
494532
});
495533
});
534+
535+
/**
536+
* ④ — the notices the chain PROVED irrelevant to the stack (`absentTodos`: an
537+
* entry's structured `relevantWhen` question answered `absent` over the stack).
538+
* ADR-0087 D3 lets such an entry leave ③, and only such an entry, so the pins
539+
* hold both halves: by default ④ is one line that counts them and names
540+
* `--all`, and under `--all` every one of them is printed in full — nothing the
541+
* chain reported becomes unreachable from the terminal.
542+
*/
543+
describe('the notices proven absent leave ③ for one counting line, and --all lists them (ABSENT)', () => {
544+
it('counts them on one line naming --all, after ③, and lists none of them by default', () => {
545+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
546+
// Anti-vacuity: the stack declares no analytics cube, so the chain proved
547+
// some entries absent — and still left others listed.
548+
expect(result.absentTodos.length).toBeGreaterThan(0);
549+
expect(result.todos.length).toBeGreaterThan(0);
550+
551+
const counts = lines.filter((l) => ABSENT_COUNT_RE.test(l));
552+
expect(counts).toHaveLength(1);
553+
expect(Number(ABSENT_COUNT_RE.exec(counts[0]!)![1])).toBe(result.absentTodos.length);
554+
expect(lines.filter((l) => ABSENT_SCOPE_RE.test(l))).toHaveLength(1);
555+
expect(indexOf(lines, ABSENT_COUNT_RE)).toBeGreaterThan(indexOf(lines, SEMANTIC_HEADER_RE));
556+
557+
const headlines = new Set(listedOf(result).map((t) => blockLines(t)[0]));
558+
for (const t of result.absentTodos) {
559+
const headline = blockLines(t)[0]!;
560+
if (headlines.has(headline)) continue; // a listed entry sharing the headline prints it legitimately
561+
expect(lines, `${t.id} is counted, not listed`).not.toContain(headline);
562+
}
563+
});
564+
565+
it('lists every one of them under --all, in full and in chain order, after ③', () => {
566+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true });
567+
const header = indexOf(lines, ABSENT_HEADER_RE);
568+
expect(header).toBeGreaterThan(indexOf(lines, SEMANTIC_HEADER_RE));
569+
expect(Number(ABSENT_HEADER_RE.exec(lines[header]!)![1])).toBe(result.absentTodos.length);
570+
const expected = absentListLines(result);
571+
expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected);
572+
// The counting line belongs to the default only.
573+
expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l))).toEqual([]);
574+
// ③ is unchanged by --all: the same header and the same notices.
575+
const semantic = indexOf(lines, SEMANTIC_HEADER_RE);
576+
expect(lines[semantic]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`);
577+
expect(lines.slice(semantic + 1, semantic + 1 + noticeLines(result).length)).toEqual(noticeLines(result));
578+
});
579+
580+
it('③ and ④ together print every semantic entry of every hop crossed, each exactly once', () => {
581+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true });
582+
const crossed = result.hops.flatMap((h) => MIGRATIONS_BY_MAJOR[h.toMajor]!.semantic.map((s) => `${h.toMajor}:${s.id}`));
583+
// The chain's `todos` is still the whole catalogue; ④ is a subset of it.
584+
expect(result.todos.map((t) => `${t.toMajor}:${t.id}`)).toEqual(crossed);
585+
for (const t of result.absentTodos) expect(result.todos).toContain(t);
586+
const printed = [...listedOf(result), ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`);
587+
expect(printed.slice().sort()).toEqual(crossed.slice().sort());
588+
expect(new Set(printed).size).toBe(printed.length);
589+
// …and the terminal shows each headline as often as the chain has entries carrying it.
590+
const headlineCount = (h: string) => lines.filter((l) => l === h).length;
591+
for (const t of result.todos) {
592+
const h = blockLines(t)[0]!;
593+
expect(headlineCount(h), t.id).toBe(result.todos.filter((u) => blockLines(u)[0] === h).length);
594+
}
595+
// Only an entry carrying a structured question can be in ④.
596+
for (const t of result.absentTodos) expect(t.relevantWhen, `${t.id} carries relevantWhen`).toBeDefined();
597+
});
598+
599+
it('prints no ④ at all when the chain proved nothing absent', () => {
600+
const { lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, (r) => ({ ...r, absentTodos: [] }));
601+
expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l) || ABSENT_HEADER_RE.test(l))).toEqual([]);
602+
});
603+
604+
it('a run whose only notices are proven absent still counts them and still writes --out', () => {
605+
const out = join(mkdtempSync(join(tmpdir(), 'os-migrate-meta-absent-only-')), 'migrated.stack.json');
606+
try {
607+
const { result, lines } = run(
608+
CANONICAL_STACK,
609+
MIGRATION_SUPPORT_FLOOR,
610+
TERMINUS,
611+
(r) => {
612+
const questioned = r.todos.filter((t) => t.relevantWhen);
613+
return { ...r, todos: questioned, absentTodos: questioned };
614+
},
615+
{ out },
616+
);
617+
expect(result.applied).toEqual([]);
618+
expect(result.absentTodos.length).toBeGreaterThan(0);
619+
expect(lines.filter((l) => ABSENT_COUNT_RE.test(l))).toHaveLength(1);
620+
expect(lines.some((l) => l.includes('Nothing to migrate'))).toBe(false);
621+
expect(existsSync(out), 'the snapshot --out asked for is written').toBe(true);
622+
} finally {
623+
rmSync(dirname(out), { recursive: true, force: true });
624+
}
625+
});
626+
});

0 commit comments

Comments
 (0)