Skip to content

Commit 8118be4

Browse files
refactor(metadata-protocol): add the protocol host split machinery and move one pilot member through it (#25467 E1) (#29488)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9UmbUGPtw5rdRcd3UpYXs5N)_ Part of #25467 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:为 `protocol.ts` 的类体拆分搭好“宿主”机制:新增 `protocol-host.ts`(被搬出的方法体经它访问类成员)和工具 `scripts/host-split.mjs`(只允许三种封闭改写,并逐个 token 校验搬出的方法体与原方法体一致);只搬了一个试点方法 `tenantAuthoredWriteRefusal`,没有成段搬移。 - **为什么改**:#25467 裁决 E 的第二步 E1:先把搬移规则、校验工具、两个按路径 pin 和读缝普查做成能穿过宿主的形态,E2 才能成段搬移而不让守卫失明。 - **风险与回滚**:低。没有行为改动;试点方法签名和文档不变,对外 `index.d.ts`(ESM 与 CJS)与 main 构建出的逐字节相同。回滚即整体 revert。 - **先看**:`packages/metadata-protocol/src/protocol-host.ts`;`packages/metadata-protocol/scripts/host-split.mjs` 文件头(封闭改写集);`protocol.ts` 里类的 `static {}` 块。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run scripts/host-split.test.mjs src/protocol.read-verb-canonical-fold.test.ts src/protocol.lookup-artifact-item-call-sites.test.ts`,预期 3 个文件全绿。 - **需要你做的**:无需审批级别操作(约 1,530 行,低于 3,000 行线)。`getMetaTypes()` 显式返回类型已由席位裁定移出 E1(保持推断,`.d.ts` 逐字节不变),另立小步处理。 ## What changes Step **E1** of ruling E (6537939895, item E1), claim 6578609040. The host split machinery, one pilot member, no section moves. **The host.** `src/protocol-host.ts` declares `ProtocolHost`: every class member a moved body reads, each typed AS that member (`ObjectStackProtocolImplementation['x']`, or `(typeof ObjectStackProtocolImplementation)['x']` for a static). The class builds the host in a `static {}` block, the one place that can read its private members; a static block adds no member, so the published `.d.ts` does not move. A method is forwarded as a closure called at call time (a spy or test double on the instance is still the one a moved body reaches), a field as a getter (plus a setter when written), a private static as a closure to the static. `protocolHost(this)` builds one host per instance and caches it in a WeakMap. **The closed rewrite set and its check.** `packages/metadata-protocol/scripts/host-split.mjs` (a tool, not a gate) has three commands: `survey`, `move MEMBER MODULE` and `check BASE_REF MEMBER MODULE`. Inside a moved body exactly three rewrites are allowed: 1. `this.x` becomes `host.x` (an instance member); 2. a private static `ObjectStackProtocolImplementation.fn(…)` becomes `host.fn(…)`; 3. `this.constructor` becomes `host`. `check` proves AST identity modulo exactly that set by walking every token (operators and keywords included), and also proves the body text, comments included, equals the base body rewritten and re-indented; it checks the stub, the method's unchanged signature, the function's parameters and return type, and that the `Pick` names exactly the members read. Everything outside the set is a refusal: a bare `this` or `this[…]`, `super`, the method's `arguments`, an identifier already named `host`, a private static read as a value or a private static field, `this.constructor` not followed by a static, an undeclared member, generators and destructured parameters. `scripts/host-split.test.mjs` (35 tests, collected by the package's vitest run) pins each rewrite kind, each refusal, and fifteen negative controls on the moved function plus three on the stub: a changed operator (`??` to `||`, `===` to `!==`), `const` to `let`, a changed literal, a wrong rewrite target, an unrewritten member or static, an extra statement, a changed comment, a `Pick` naming one member too many or too few, a renamed host parameter, a retyped parameter, a changed return type, reordered stub arguments, a stub handing over `this`, and an edited docblock. Writing these tests found one tool bug, fixed here: `arguments.length` escaped the `arguments` refusal. **The pilot.** `tenantAuthoredWriteRefusal` (5 rewrite sites, all kind 1) moved to `src/protocol-tenant-authored-write.ts`. `node scripts/host-split.mjs check 838a802 tenantAuthoredWriteRefusal protocol-tenant-authored-write.ts` reads `ok: true, textIdentical: true`. Ablation, with `folded.packageId === stamped` changed to `!==` in the module, then restored: ``` PROBLEM protocol-tenant-authored-write.ts:65: ExclamationEqualsEqualsToken where the base has EqualsEqualsEqualsToken (`===`) PROBLEM protocol-tenant-authored-write.ts: tenantAuthoredWriteRefusal's body text differs from the base body rewritten and re-indented, first at body line 34 ``` **The pins and the census read through the host, for every module.** These are the forced test edits: - `src/protocol.read-verb-canonical-fold.test.ts`: `delegateBody` follows a stub (`fn(protocolHost(this), …)` or the per-member series' `fn.call(host, …)`) to the moved function in the module protocol.ts imports it from. A new assertion requires that the pilot is read through the split. Before the edit the pin was red on the pilot (`expected [ 'tenantAuthoredWriteRefusal' ] to deeply equal []`). Ablation, removing the fold from the pilot module's body (`const folded = request;`), then restored: ``` AssertionError: A `/meta` verb takes a required `type` without folding it at the request boundary. … expected [ 'tenantAuthoredWriteRefusal' ] to deeply equal [] ``` - `src/protocol.lookup-artifact-item-call-sites.test.ts`: the site scan covers protocol.ts plus every `protocol-*.ts` module (comments blanked through the shared `scripts/js-comment-mask.mjs`), and a new test pins that the module list includes the host's and the pilot's. Ablation, planting an unrecorded `lookupArtifactItem(` call in the pilot module, then restored: ``` AssertionError: a site with no recorded disposition: classify it … expected [ Array(1) ] to deeply equal [] AssertionError: expected 22 to be 21 ``` - Both pins import `maskComments` from `scripts/js-comment-mask.mjs` (check:comment-mask-adoption refused a private stripper), so `turbo.json`'s `@objectstack/metadata-protocol#test` inputs and `scripts/cross-package-test-inputs.mjs` declare that file (check:cross-package-test-inputs). Two config lines outside the claim's file surface, forced by those gates. - `scripts/check-durability-degradation-log-level.mjs` (`HOST_SPLIT`): HOST CALL now also accepts `host.x(…)` where `host` is the function's first parameter, typed by a host interface declared in the module or imported by name from a split module, with members typed AS the class's instance or static members. STUB accepts `fn(protocolHost(this), …)`. There are 10 new self-test cases (flags, negative controls, BOUND pairs); the self-test reads 75 cases. Ablation, renaming `hostParam` and `hostOf`, then restored: 4 failures (the 3 E1 flags plus the deeper BOUND), each `expected violation=true seams=1, got violation=false count=0 seams=0`. The census reads 100 seams against main's 98. The two new lines are `protocol-packageless-overlay-migration.ts`'s `migratePackagelessOverlays()` guarding `storedPairRow()`, a helper that already took its module's own host as a first parameter named `host`. Both read "no invented answer", so no verdict or ledger row moves. This is recorded in the docblock, as #28058 recorded its 93 to 95. ## The published `.d.ts` Built from main and from this head, twice: before the merge of main (main 838a802, sha256 `ef70d5a4…004a8` on both sides) and after it (main 7f277a8, sha256 `de90eb4e…ccf88` on both sides, normaliser 2 `83d37f67…7e2c0` on both). Each time `index.d.ts` is byte-identical to main's, and ESM equals CJS. A trial merge of the later main 6af3d29 (which edits protocol.ts again), built and then discarded, also reads byte-identical (`0e8c1a2c…ef526` on both sides). The declaration-emit comment leak into `getMetaTypes()`'s inferred return type struck this diff once: a `[#28001]` docblock printed inside `examples?`. It was cleared the way E0 cleared it, by the length of a comment line above the leaking offset (one word in the pilot's import comment); a 1 to 8 character probe read raw-equal at 5 or more. ## Deviation the seat ruled: `getMetaTypes()` explicit return type dropped from E1 E0 carried this item into E1 with the condition "normaliser 2 hashes equal". The inferred return type prints as 793 lines of `.d.ts`, and an explicit annotation prints as written, so the two meet only if that 793-line literal is pasted inline. Measured: that gives a byte-identical `.d.ts` and green tsc, but 793 duplicated lines whose drift is silent in the widening direction. Seat 5 ruled option C: the return type stays inferred, so `index.d.ts` stays byte-identical (the E-series invariant), and every E2 cut reads the raw `.d.ts` diff of `getMetaTypes()`. A named composed type with a type-equality pin against main's `.d.ts` (option B) becomes its own small step after E1 lands, filed by the seat. The `turbo.json` and `scripts/cross-package-test-inputs.mjs` lines above are accepted as forced. ## Serial Re-read just before opening against main 6af3d29 (34 commits past this branch's one merge of main at 7f277a8; `git merge-tree` with main is clean). Open PRs whose own diff touches `packages/metadata-protocol/src/protocol.ts`: #28731 only, and `git merge-tree` with this head is clean. No open PR touches the other files here. ## Gates On 710b8fe: `node scripts/pm/dispatch-gates.mjs --commands` derived 82 commands, and each records `exit 0`. `--ran` reports 82 derived, 82 run, 0 NOT-MEASURED. The first round, on cf94e93's parent, caught the two items above: check:comment-mask-adoption and check:cross-package-test-inputs. check:dual-build-cjs-loads exited 3 there (unbuilt packages); they were built and it now exits 0. The package's typecheck and test, through os-verify-lock, read Test Files 305 passed, 4 skipped; Tests 22191 passed, 26 skipped. The 11 direct dependents' typecheck exits 0. Lint (`eslint packages/metadata-protocol` plus the three root scripts, `--no-inline-config`) exits 0. CI is not read. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk)_ <!-- signed: domain:server#5 · os-steve · session_01ADHkmR4MjTzeUv5x6tNkbk --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e119e53 commit 8118be4

11 files changed

Lines changed: 1452 additions & 81 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
refactor(metadata-protocol): one `ObjectStackProtocolImplementation` member body moves out of `protocol.ts` through a host object, the shape the rest of the class follows
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
No behaviour change. `tenantAuthoredWriteRefusal` keeps its docblock and signature on the class; its body now lives in `protocol-tenant-authored-write.ts`, reached through `protocol-host.ts`, a per-instance object that forwards to the class's own members (built in the class's static block, so the class gains no member). The moved body differs from the method body only by `this.` reading `host.`. The published `index.d.ts` (ESM and CJS) is byte-identical to the one `main` builds before this change. Nothing to migrate.

‎packages/metadata-protocol/scripts/host-split.mjs‎

Lines changed: 653 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 E1] Pins `host-split.mjs`: the closed rewrite set, the check that a
5+
* moved body is the member's own body under exactly that set, and the
6+
* refusals that keep everything outside the set from moving.
7+
*
8+
* Every positive pin here has a negative control beside it: a moved body
9+
* changed by one thing the set does not allow must fail the check, by name.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
import {
14+
HOST_OF,
15+
checkMove,
16+
classOf,
17+
insertHostEntries,
18+
memberTable,
19+
parse,
20+
planMove,
21+
survey,
22+
} from './host-split.mjs';
23+
24+
const C = 'ObjectStackProtocolImplementation';
25+
26+
// One class that exercises all three rewrites, a comment, a template literal
27+
// whose second line starts at column 0, an operator and a declaration keyword.
28+
const BASE = `class Base { protected hook(): void {} }
29+
export class ${C} extends Base {
30+
private cache = new Map<string, number>();
31+
private count = 0;
32+
private static helper(x: number): number { return x + 1; }
33+
private static readonly LIMIT = 3;
34+
static shared(x: number): number { return x * 2; }
35+
private read(k: string) { return this.cache.get(k); }
36+
37+
/** The contract of \`target\`. */
38+
async target(k: string, n: number): Promise<number> {
39+
// a comment the move carries
40+
const v = this.read(k) ?? 0;
41+
this.count += n;
42+
const msg = \`line one
43+
line two \${k}\`;
44+
const same = v === n;
45+
const later = [1, 2].map((x) => this.read(String(x)));
46+
const nested = function (this: unknown) { return this; };
47+
return ${C}.helper(v) + this.constructor.shared(n) + msg.length + (same ? 1 : 0) + later.length + (nested ? 0 : 1);
48+
}
49+
50+
bareThis() { return [this]; }
51+
elementThis(k: string) { return this[k]; }
52+
usesSuper() { return super.hook(); }
53+
usesArguments(a: number) { return arguments.length + a; }
54+
namedHost() { const host = 1; return host; }
55+
staticField() { return ${C}.LIMIT; }
56+
staticAsValue() { const f = ${C}.helper; return f(1); }
57+
bareConstructor() { return this.constructor; }
58+
undeclared() { return this.nope; }
59+
*generator() { yield this.count; }
60+
destructured({ a }: { a: number }) { return a + this.count; }
61+
hostAsPropertyName() { return { host: this.count }.host; }
62+
stubbed(k: string) { const self = { count: this.count }; return stubbedBody.call(self, k); }
63+
}
64+
`;
65+
66+
const HOST_SOURCE = `export interface ProtocolHost {
67+
}
68+
`;
69+
const PROTOCOL_FACTORY = `
70+
installProtocolHost((protocol) => ({
71+
}));
72+
`;
73+
74+
/** Perform the move the tool performs, in memory: the stubbed protocol source and the module source. */
75+
function move(member = 'target') {
76+
const plan = planMove(BASE, member, 'protocol-target.ts');
77+
expect(plan.refusals).toEqual([]);
78+
const head = BASE.slice(0, plan.stub.start) + plan.stub.text + BASE.slice(plan.stub.end);
79+
const moduleSource = `import type { ProtocolHost } from './protocol-host.js';\n\n${plan.fnText}`;
80+
return { plan, head, moduleSource };
81+
}
82+
83+
const check = (headSource, moduleSource) => checkMove({
84+
baseSource: BASE,
85+
headSource,
86+
moduleSource,
87+
moduleFile: 'protocol-target.ts',
88+
member: 'target',
89+
});
90+
91+
describe('host-split: the move', () => {
92+
it('rewrites exactly the three kinds and names exactly the members the body reads', () => {
93+
const { plan } = move();
94+
const kinds = plan.sites.map((s) => `${s.kind}:${s.member}`);
95+
expect(kinds).toEqual([
96+
'member:read',
97+
'member:count',
98+
'member:read',
99+
'privateStatic:helper',
100+
'constructor:shared',
101+
]);
102+
expect(plan.names).toEqual(['count', 'helper', 'read', 'shared']);
103+
// `this` inside a nested `function` is not the method's: it does not move.
104+
expect(plan.fnText).toContain('function (this: unknown) { return this; }');
105+
// The column-0 line inside the template literal keeps its bytes.
106+
expect(plan.fnText).toContain('\nline two ${k}`;');
107+
expect(plan.fnText).toContain(' // a comment the move carries\n');
108+
expect(plan.fnText).toContain(
109+
"export async function target(host: Pick<ProtocolHost, 'count' | 'helper' | 'read' | 'shared'>, k: string, n: number): Promise<number> {",
110+
);
111+
expect(plan.stub.text).toContain(`return target(${HOST_OF}(this), k, n);`);
112+
});
113+
114+
it('builds host entries by kind: a method closure, a field accessor pair when written, a static closure', () => {
115+
const { plan } = move();
116+
const e = Object.fromEntries([...plan.hostEntries].map(([k, v]) => [k, v]));
117+
expect(e.read.iface).toBe(` readonly read: ${C}['read'];`);
118+
expect(e.read.factory).toBe(' read: (...a) => protocol.read(...a),');
119+
expect(e.count.iface).toBe(` count: ${C}['count'];`);
120+
expect(e.count.factory).toBe(' get count() { return protocol.count; },\n set count(value) { protocol.count = value; },');
121+
expect(e.helper.iface).toBe(` readonly helper: (typeof ${C})['helper'];`);
122+
expect(e.helper.factory).toBe(` helper: (...a) => ${C}.helper(...a),`);
123+
expect(e.shared.iface).toBe(` readonly shared: (typeof ${C})['shared'];`);
124+
125+
const { host, protocol } = insertHostEntries(HOST_SOURCE, PROTOCOL_FACTORY, plan.hostEntries);
126+
expect(host.split('\n').filter((l) => l.startsWith(' ')).map((l) => l.trim().split(':')[0])).toEqual([
127+
'count', 'readonly helper', 'readonly read', 'readonly shared',
128+
]);
129+
expect(protocol).toContain(' get count() { return protocol.count; },\n set count(value) { protocol.count = value; },\n helper:');
130+
// Inserting again adds nothing.
131+
const again = insertHostEntries(host, protocol, plan.hostEntries);
132+
expect(again).toEqual({ host, protocol });
133+
});
134+
135+
it.each([
136+
['bareThis', 'a bare `this`'],
137+
['elementThis', '`this[…]`'],
138+
['usesSuper', '`super`'],
139+
['usesArguments', '`arguments` of the method'],
140+
['namedHost', 'already named `host`'],
141+
['staticField', 'private static `LIMIT` (field) read'],
142+
['staticAsValue', 'private static `helper` (method) read as a value'],
143+
['bareConstructor', '`this.constructor` not followed by `.<static>`'],
144+
['undeclared', '`this.nope` names no declared instance member'],
145+
['generator', 'a generator method'],
146+
['destructured', 'a destructured parameter'],
147+
])('refuses %s: %s', (member, reason) => {
148+
const plan = planMove(BASE, member, 'protocol-target.ts');
149+
expect(plan.refusals.map((r) => r.reason).join(' | ')).toContain(reason);
150+
expect(plan.fnText).toBeUndefined();
151+
});
152+
153+
it('does not refuse `host` as a property name', () => {
154+
expect(planMove(BASE, 'hostAsPropertyName', 'protocol-target.ts').refusals).toEqual([]);
155+
});
156+
157+
it('survey: tells stubs from movable and refused members', () => {
158+
const { rows, clashes } = survey(BASE);
159+
const by = Object.fromEntries(rows.map((r) => [r.name, r]));
160+
expect(clashes).toEqual([]);
161+
expect(by.stubbed.stub).toBe('call');
162+
expect(by.target.refusals).toEqual([]);
163+
expect(by.bareThis.refusals).toHaveLength(1);
164+
const { head } = move();
165+
expect(Object.fromEntries(survey(head).rows.map((r) => [r.name, r.stub])).target).toBe('host');
166+
});
167+
168+
it('reports a name declared both as an instance and a static member', () => {
169+
const table = memberTable(classOf(parse('x.ts', `class ${C} { x() {} static x() {} }`)));
170+
expect(table.clashes).toEqual(['x']);
171+
});
172+
});
173+
174+
describe('host-split: the check', () => {
175+
it('passes the move it made: AST identity modulo the set, each kind counted, text identical', () => {
176+
const { head, moduleSource } = move();
177+
const r = check(head, moduleSource);
178+
expect(r.problems).toEqual([]);
179+
expect(r.ok).toBe(true);
180+
expect(r.textIdentical).toBe(true);
181+
expect(r.counts).toEqual({ member: 3, privateStatic: 1, constructor: 1 });
182+
expect(r.hostMembers).toEqual(['count', 'helper', 'read', 'shared']);
183+
});
184+
185+
// Negative controls: one change outside the set each, and the check names it.
186+
it.each([
187+
['an operator', (m) => m.replace('?? 0', '|| 0'), 'where the base has'],
188+
['an equality operator', (m) => m.replace('v === n', 'v !== n'), 'where the base has'],
189+
['a declaration keyword', (m) => m.replace('const v =', 'let v ='), 'where the base has'],
190+
['a literal', (m) => m.replace('[1, 2]', '[1, 3]'), 'where the base has'],
191+
['a member rewritten to another member', (m) => m.replace('host.read(k)', 'host.cache(k)'), '`this.read` must read `host.read`'],
192+
['a member left unrewritten', (m) => m.replace('host.read(k)', 'this.read(k)'), '`this.read` must read `host.read`'],
193+
['a private static left on the class', (m) => m.replace('host.helper(v)', `${C}.helper(v)`), `\`${C}.helper(…)\` must read \`host.helper(…)\``],
194+
['`this.constructor.<static>` rewritten wrong', (m) => m.replace('host.shared(n)', 'host.helper(n)'), '`this.constructor.shared` must read `host.shared`'],
195+
['an extra statement', (m) => m.replace(" return host.helper", " void 0;\n return host.helper"), 'children where the base has'],
196+
['a comment changed (AST-equal, text not)', (m) => m.replace('a comment the move carries', 'a comment the move changed'), "body text differs"],
197+
['the Pick naming a member the body does not read', (m) => m.replace("'count' | ", "'cache' | 'count' | "), 'host type must be'],
198+
['the Pick missing a member the body reads', (m) => m.replace(" | 'shared'", ''), 'host type must be'],
199+
['the host parameter renamed', (m) => m.replace('target(host:', 'target(h:'), 'first parameter is not `host`'],
200+
['a parameter retyped', (m) => m.replace('n: number)', 'n: unknown)'), "parameters after the host are not the method's own"],
201+
['the return type changed', (m) => m.replace('): Promise<number> {', '): Promise<unknown> {'), 'return type'],
202+
])('fails %s', (_label, mutate, expected) => {
203+
const { head, moduleSource } = move();
204+
const mutated = mutate(moduleSource);
205+
expect(mutated).not.toBe(moduleSource);
206+
const r = check(head, mutated);
207+
expect(r.ok).toBe(false);
208+
expect(r.problems.join('\n')).toContain(expected);
209+
});
210+
211+
it.each([
212+
['the stub forwarding its parameters out of order', (h) => h.replace('target(protocolHost(this), k, n)', 'target(protocolHost(this), n, k)'), 'is not the stub'],
213+
['the stub handing over anything but the host', (h) => h.replace('target(protocolHost(this), k, n)', 'target(this, k, n)'), 'is not the stub'],
214+
["the method's docblock edited", (h) => h.replace('The contract of `target`.', 'The contract.'), 'docblock, modifiers or signature changed'],
215+
])('fails %s', (_label, mutate, expected) => {
216+
const { head, moduleSource } = move();
217+
const mutated = mutate(head);
218+
expect(mutated).not.toBe(head);
219+
const r = check(mutated, moduleSource);
220+
expect(r.ok).toBe(false);
221+
expect(r.problems.join('\n')).toContain(expected);
222+
});
223+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 E1] The host: what a body moved out of `ObjectStackProtocolImplementation`
5+
* reaches the class through.
6+
*
7+
* Ruling E (6537939895) moves the class's members out of `protocol.ts` a whole
8+
* section at a time (E2). A moved body is an exported function whose first
9+
* parameter is `host`, and it differs from the method body it was by exactly one
10+
* closed set of rewrites, checked by `scripts/host-split.mjs`:
11+
*
12+
* - `this.<member>` → `host.<member>`;
13+
* - a private static `ObjectStackProtocolImplementation.<fn>(…)` → `host.<fn>(…)`;
14+
* - `this.constructor` → `host`.
15+
*
16+
* The method stays on the class with its docblock and signature, so the published
17+
* `.d.ts` keeps it, and its body becomes `return <fn>(protocolHost(this), …)`.
18+
*
19+
* ## The host is one object per protocol instance
20+
*
21+
* {@link ProtocolHost} lists every class member some moved body reads, each typed
22+
* AS that member (`ObjectStackProtocolImplementation['x']`, or
23+
* `(typeof ObjectStackProtocolImplementation)['x']` for a static), so a change to
24+
* a member's signature on the class is a change here, and naming a member the
25+
* class does not have is a compile error. A moved function takes the `Pick` of
26+
* exactly the members its body reads.
27+
*
28+
* The object is built by the class itself, in its static block in `protocol.ts`:
29+
* only code inside the class body can read its private members, and a static
30+
* block adds no member to the class, so the published `.d.ts` does not move.
31+
* There:
32+
*
33+
* - a METHOD is a closure that calls it on the instance at call time
34+
* (`(...a) => protocol.x(...a)`), never a bound or copied function, so a
35+
* method replaced on the instance or its prototype afterwards (a test double,
36+
* a spy) is still the one a moved body reaches;
37+
* - a FIELD is a getter that reads the instance's field when it is read, and a
38+
* setter too when a moved body assigns it, so a moved body sees and makes the
39+
* same writes the method body did;
40+
* - a private STATIC method is a closure to the static.
41+
*
42+
* The census of read seams (`scripts/check-durability-degradation-log-level.mjs`,
43+
* `HOST_SPLIT`) and the two by-path pins (`protocol.read-verb-canonical-fold`,
44+
* `protocol.lookup-artifact-item-call-sites`) read a moved body through this
45+
* shape: a `host.x(…)` call resolves to the class method `x`, and a stub
46+
* `return fn(protocolHost(this), …)` to `fn`'s body.
47+
*/
48+
49+
import type { ObjectStackProtocolImplementation } from './protocol.js';
50+
51+
/**
52+
* Every `ObjectStackProtocolImplementation` member a moved body reads, sorted by
53+
* name. `scripts/host-split.mjs move` adds the entries a move needs, here and in
54+
* the class's static block.
55+
*/
56+
export interface ProtocolHost {
57+
readonly overrideWriteVerdict: ObjectStackProtocolImplementation['overrideWriteVerdict'];
58+
readonly packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner'];
59+
readonly packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal'];
60+
readonly storedFlowBindingAgrees: ObjectStackProtocolImplementation['storedFlowBindingAgrees'];
61+
readonly uninstalledNamedBaseRefusal: ObjectStackProtocolImplementation['uninstalledNamedBaseRefusal'];
62+
}
63+
64+
const hosts = new WeakMap<ObjectStackProtocolImplementation, ProtocolHost>();
65+
let build: ((protocol: ObjectStackProtocolImplementation) => ProtocolHost) | undefined;
66+
67+
/**
68+
* Called once, from `ObjectStackProtocolImplementation`'s static block: the one
69+
* place that can read the class's private members to build a host.
70+
*/
71+
export function installProtocolHost(factory: (protocol: ObjectStackProtocolImplementation) => ProtocolHost): void {
72+
build = factory;
73+
}
74+
75+
/** The host of one protocol instance, built on first use and kept for the instance's life. */
76+
export function protocolHost(protocol: ObjectStackProtocolImplementation): ProtocolHost {
77+
let host = hosts.get(protocol);
78+
if (host === undefined) {
79+
if (build === undefined) {
80+
throw new Error('protocolHost: ObjectStackProtocolImplementation has not installed its host factory');
81+
}
82+
host = build(protocol);
83+
hosts.set(protocol, host);
84+
}
85+
return host;
86+
}

0 commit comments

Comments
 (0)