From 3f06cdd421b4edd6457148e4e612a3276b633110 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:12:49 +0000 Subject: [PATCH 1/4] feat(scripts): codemod-split-protocol --members ports the .call(host) receiver and the statics forwarder, and moves nineteen protocol.ts bodies The members pass now moves, unedited, the members E1's rewrite refuses: an instance body run on the class's host as `this` (`.call(host)`), a private static run on a forwarder of the class's statics, a body that reads nothing of the class (plain), and a private static table moved to its section's leaf. The undo proof covers every form byte for byte, and the self-test gains three batteries for them. Nineteen facade-gate exceptions retire; triggerAutomation stays as an accepted exception with its reason rewritten. Three source-reading pins follow the moved bodies. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxm997Tr9c3JFQTKGj --- .changeset/30147-protocol-receiver-forms.md | 11 + .../areas/platform-core.json | 2 +- .../src/protocol-class-head.ts | 24 +- .../src/protocol-discovery-and-bases.ts | 82 +- .../metadata-protocol/src/protocol-host.ts | 2 + .../src/protocol-registry-hydration.ts | 139 ++- .../src/protocol-write-gates-tables.ts | 71 ++ .../src/protocol-write-gates.ts | 332 ++++++- ...l.bracketed-refusal-opener-absence.test.ts | 8 +- .../protocol.register-item-call-sites.test.ts | 3 +- packages/metadata-protocol/src/protocol.ts | 626 ++----------- ...iew-expansion-reconcile-call-sites.test.ts | 12 +- scripts/codemod-split-protocol.mjs | 874 ++++++++++++++++-- scripts/facade-delegation-registry.json | 116 +-- 14 files changed, 1572 insertions(+), 730 deletions(-) create mode 100644 .changeset/30147-protocol-receiver-forms.md create mode 100644 packages/metadata-protocol/src/protocol-write-gates-tables.ts diff --git a/.changeset/30147-protocol-receiver-forms.md b/.changeset/30147-protocol-receiver-forms.md new file mode 100644 index 00000000000..2e5db530602 --- /dev/null +++ b/.changeset/30147-protocol-receiver-forms.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): nineteen more `protocol.ts` members hand their bodies to their facets + +Clause-②: no +Metadata-protocol: none + +- Nineteen members of `ObjectStackProtocolImplementation` that the facade gate still let keep a body (the overlay and runtime-create write gates, the code-only refusals, the registry hydration's view-expansion reconcile and four static type tables) now keep their signatures on the class and delegate in one line to their section's facet, which holds the body, moved unchanged. Four static tables moved to a new leaf module, `protocol-write-gates-tables.ts`. No behaviour changes and nothing to do on upgrade. +- The published `index.d.ts` and `index.d.cts` carry the same public surface; only docblocks of private members change, where a moved body took its docblock with it. diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 981f6507ed6..713ec26217e 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -2068,7 +2068,7 @@ "examples/app-showcase/src/data/extensions/account.extension.ts#AccountExtension (AccountExtension — the shipped fixture, priority 210)", "packages/objectql/src/registry.ts#mergeObjectDefinitions (mergeObjectDefinitions — additive fields fold, the spread) + vicinity via engine: packages/objectql/src/engine.ts#registerApp (the objectExtensions loop registering 'extend' contributors — re-pointed #18104: objectExtensions is read off the manifest here, a member access; the loop this item means runs inside registerApp, which this file declares)", "packages/spec/src/data/object.zod.ts#ObjectExtensionSchema (ObjectExtensionSchema — what an extension may carry, the #4001 silently-dropped history, 'Fields to add/override', priority wins-on-conflict; defineObjectExtension)", - "packages/metadata-protocol/src/protocol.ts#refusal (the NOT_OVERRIDABLE refusal: a packaged item is sealed by its managed package, ADR-0131 D6)", + "packages/metadata-protocol/src/protocol-write-gates.ts#refusal (the NOT_OVERRIDABLE refusal: a packaged item is sealed by its managed package, ADR-0131 D6)", "packages/plugins/plugin-auth/src/managed-extension-fields.ts + managed-extension-fields.test.ts (the build-time collision gate — better-auth-managed objects ONLY; adopted by ADR-0126 §3 as Regime E prior art, NOT a generic gate)", "docs/adr/0126-packaged-metadata-customization-model.md §2-§3 (Regime E: additive contributions at package grain, base never edited, install-grain not per-org)", "content/docs/capabilities/integrations.mdx + content/docs/build-without-code.mdx (the E1 docs claim this item grounds)", diff --git a/packages/metadata-protocol/src/protocol-class-head.ts b/packages/metadata-protocol/src/protocol-class-head.ts index 4c20eb94fcc..a03cc14e491 100644 --- a/packages/metadata-protocol/src/protocol-class-head.ts +++ b/packages/metadata-protocol/src/protocol-class-head.ts @@ -7,7 +7,7 @@ * `resolveCascadeDeleteObjects`, `registerPublishMaterializer`, * `registerUninstallCleanup`, `runUninstallCleanups`, * `registerMutationProjector`, `registerAuthoringGate`, - * `registerCredentialChannel`, `restoreWriteFor`, + * `registerCredentialChannel`, `restoreWriteFor`, `objectExtensionItemRefusal`, * `registerStoredObjectExtension`, `resyncStoredObjectExtensions`, * `onMetadataMutation`, `emitMetadataMutation`, `attachMetadataMutationPubSub`, * `detachMetadataMutationPubSub`, `applyRemoteMetadataMutation`, @@ -60,6 +60,7 @@ import type { MetadataMutationProjector, } from './protocol-mutation-events.js'; import type { MetadataAuthoringGate, MetadataCredentialChannel } from './protocol-authoring-channel.js'; +import { objectExtensionItemRefusal } from './protocol-object-extension-item.js'; import type { ProtocolHost } from './protocol-host.js'; export class ProtocolClassHeadFacet { @@ -201,6 +202,27 @@ export class ProtocolClassHeadFacet { return { intent, deriveRestoredBody }; } + /** + * [#28574, ADR-0146 D10] The save, publish and restore doors' gate for an + * `object_extension` body written into `binding` (the RESOLVED package of + * the row): {@link objectExtensionItemRefusal}, asked with this engine's + * registry and the package's dependency closure + * ({@link resolveWritePackageScope}). Any other type answers `undefined`. + */ + static objectExtensionItemRefusal(host: Pick, type: string, + item: unknown, + name: string, + binding: string | null | undefined,): Error | undefined { + if ((PLURAL_TO_SINGULAR[type] ?? type) !== OBJECT_EXTENSION_TYPE) return undefined; + return objectExtensionItemRefusal(OBJECT_EXTENSION_TYPE, { + item, + name, + binding, + registry: host.engine?.registry as ObjectExtensionRegistry | undefined, + scope: host.resolveWritePackageScope(binding), + }); + } + /** * [#28574, ADR-0146 D10] Register a stored `object_extension` row as its * package's `'extend'` contributor of the object it names — the WHOLE diff --git a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts index 8157271c775..30b4c4f3b69 100644 --- a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts +++ b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts @@ -3,7 +3,8 @@ /** * [#29985 codemod] The "Discovery, types, diagnostics, packaged bases, * credential carry-forward" facet of `ObjectStackProtocolImplementation`: the - * bodies of `getProjectId`, `getMetaTypes`, `getPackagedObjectBase`, + * bodies of `getProjectId`, `getMetaTypes`, + * `rethrowUnlessMetadataStoreUnprovisioned`, `getPackagedObjectBase`, * `getPackagedDashboardBase`, `getPackagedViewBase`, `getPackagedReportBase`, * `packagedArtifactBase`, `objectRegistrationRefusal`, * `restoreTranslatedObjectEchoes`, `restoreInjectedColumnEchoes`, @@ -55,6 +56,8 @@ import { toJsonSchemaSafe, } from './protocol-meta-type-schema.js'; import type { SaveMetaItemRequest } from './protocol-door-requests.js'; +import { isMissingTableError } from '@objectstack/metadata/errors'; +import { metadataReadFailureError } from './protocol-client-failures.js'; import type { ProtocolHost } from './protocol-host.js'; import { servedOverrideBaseOf } from './served-override.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; @@ -233,6 +236,83 @@ export class ProtocolDiscoveryAndBasesFacet { return listing; } + /** + * [#5532] Decide what a failed READ against `sys_metadata` means, and + * rethrow unless it is the ONE benign reason. + * + * ## The defect + * + * Every overlay read in `getMetaItems`/`getMetaItem` used to `catch {}` into + * its own empty value — `items` left as-is, `item` left `undefined`, the + * draft lookup falling through to the active read. That made a metadata + * store the protocol cannot reach **indistinguishable** from an environment + * where the item was never customised, and the emptiness then travelled + * the whole read chain unremarked: + * + * - `getMetaItemCached` turned it into `not found` — an outage answered + * as "that item does not exist", which is the opposite disposition + * (retry the backend vs. create the item / fix the link); + * - the `state='draft'` read turned it into `NO_DRAFT` / 404 — an outage + * answered as "there is no pending edit", which a publish flow reads as + * "nothing to publish"; + * - `getMetaItems` turned it into `items: []` — an outage answered as + * "this environment declares none of these", the exact shape #5108 fixed + * one layer down in `DatabaseLoader` and #5089 in `listForIndex`. + * + * ADR-0110 D3 is the rule: a miss and an outage are different facts with + * opposite meanings, and a consumer must never read one as the other. + * + * ## The one benign reason + * + * `sys_metadata` has not been provisioned yet. There are then genuinely no + * overlay rows, so falling through to the registry / MetadataService IS the + * truth, and a first boot must not explode. Classification is by error TYPE + * through {@link isMissingTableError} — the same predicate `DatabaseLoader` + * (#5108) and this package's own `SysMetadataRepository` (#4867) ask, so a + * driver quirk is taught to the platform once. Conservative in the same + * direction: an unrecognised error is NOT benign, because a false "benign" + * silently mis-answers "does this exist?" while a false "real" costs one + * 503 the caller can retry. + * + * ## [#12536] Not benign is not the same as "the store is down" + * + * A non-benign failure is classified once more before it is wrapped, by + * {@link metadataReadFailureError}: a metadata app's hook may have REFUSED + * this read and marked its refusal with `userMessage` (commit 79c46da90), and that is + * an application refusal, not a dependency outage. Handing it to the 503 + * below destroyed the mark at the producer — the two failures left this + * guard as the same envelope with the same sentence. The classification + * splits them; neither category's wording changes. + * + * @throws {@link markedApplicationRefusalError} — [#12536] FIRST, when the + * failure carried a producer's `userMessage` mark (commit 79c46da90): the + * author's text verbatim, in its own refusal category, with the + * cause still carried for the operator and nothing else quoted. + * @throws {@link metadataStoreUnavailableError} — a 503 carrying the driver + * error as `cause`, for every UNMARKED non-benign failure. Not the + * driver error itself: unwrapped, it has + * no status, so the REST boundary would have to guess from the + * message text — and `mapDataError` guesses `no such table` into + * `404 OBJECT_NOT_FOUND`, i.e. straight back into a miss. + * @returns normally ONLY for the benign case, licensing the caller to treat + * the overlay as absent. + */ + static rethrowUnlessMetadataStoreUnprovisioned(error: unknown, readObject: string): void { + // [commit 4cda78c9b] `readObject` is REQUIRED, deliberately. This helper serves + // callers that read four different tables (`sys_metadata`, + // `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`), + // so a default would silently answer about the wrong one for three of + // them — measured, not hypothetical: the first draft of this repair + // hardcoded `sys_metadata` and turned `diffMetaItem`'s genuinely + // unprovisioned `sys_metadata_history` into a loud failure. A required + // parameter makes the compiler ask the question at every new call site. + if (isMissingTableError(error, readObject)) return; + // [#12536] CLASSIFY, do not assume. A read can fail because the store + // is unreachable OR because a metadata app's hook refused it in its + // own words — see {@link metadataReadFailureError}. + throw metadataReadFailureError(error); + } + /** The body of `ObjectStackProtocolImplementation#getPackagedObjectBase`; the method's docblock there is its contract. */ static getPackagedObjectBase(host: Pick, name: string): unknown { if (typeof name !== 'string' || name === '') return undefined; diff --git a/packages/metadata-protocol/src/protocol-host.ts b/packages/metadata-protocol/src/protocol-host.ts index 6734a29ae7f..8355ced2bc6 100644 --- a/packages/metadata-protocol/src/protocol-host.ts +++ b/packages/metadata-protocol/src/protocol-host.ts @@ -137,6 +137,7 @@ export interface ProtocolHost { readonly isHostCodeDatasource: ObjectStackProtocolImplementation['isHostCodeDatasource']; readonly isNestedArtifactField: ObjectStackProtocolImplementation['isNestedArtifactField']; readonly isOverlayAllowed: (typeof ObjectStackProtocolImplementation)['isOverlayAllowed']; + readonly isPackagedBaseRefusal: (typeof ObjectStackProtocolImplementation)['isPackagedBaseRefusal']; readonly isRuntimeCreateAllowed: (typeof ObjectStackProtocolImplementation)['isRuntimeCreateAllowed']; readonly isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; readonly isStoredEntryOfDeclinedName: ObjectStackProtocolImplementation['isStoredEntryOfDeclinedName']; @@ -163,6 +164,7 @@ export interface ProtocolHost { readonly organizationScopedWriteRefusal: (typeof ObjectStackProtocolImplementation)['organizationScopedWriteRefusal']; readonly originGatedRemovalRefusal: ObjectStackProtocolImplementation['originGatedRemovalRefusal']; readonly overlaidShippedContainerViewNames: ObjectStackProtocolImplementation['overlaidShippedContainerViewNames']; + readonly overlayBaseShippers: ObjectStackProtocolImplementation['overlayBaseShippers']; readonly overlayLockLayerAt: ObjectStackProtocolImplementation['overlayLockLayerAt']; readonly overlayPackageMismatchError: (typeof ObjectStackProtocolImplementation)['overlayPackageMismatchError']; readonly overlayRepos: ObjectStackProtocolImplementation['overlayRepos']; diff --git a/packages/metadata-protocol/src/protocol-registry-hydration.ts b/packages/metadata-protocol/src/protocol-registry-hydration.ts index 01c1e26f1a1..922d07517f9 100644 --- a/packages/metadata-protocol/src/protocol-registry-hydration.ts +++ b/packages/metadata-protocol/src/protocol-registry-hydration.ts @@ -3,10 +3,11 @@ /** * [#29985 codemod] The "Registry hydration, view expansion, write-through, * object storage" facet of `ObjectStackProtocolImplementation`: the bodies of - * `applyObjectRegistryMutation`, `hydrateOverlayIntoRegistry`, - * `expandRuntimeViewContainer`, `shippedViewContainerOf`, - * `runtimeViewContainerPackage`, `isShippedByAnotherPackage`, - * `hydrateExpandedViewItems`, `anotherPackageShips`, + * `applyObjectRegistryMutation`, `overlayPackageMismatchError`, + * `hydrateOverlayIntoRegistry`, `expandRuntimeViewContainer`, + * `shippedViewContainerOf`, `runtimeViewContainerPackage`, + * `isShippedByAnotherPackage`, `hydrateExpandedViewItems`, + * `reconcileViewExpansions`, `anotherPackageShips`, * `applyRegistryWriteThrough`, `restoreArtifactRegistryView`, * `ensureObjectStorage`, `dropObjectStorage`, `unboundOwnerPackageRefusal`, * `dropStorageRemainingRowsRefusal` moved out of `protocol.ts` whole by @@ -49,9 +50,12 @@ import { } from './protocol-package-bindings.js'; import type { HydrationOptions, ShippingPackages, UnboundOwnerRefusalState } from './protocol-package-bindings.js'; import { bundleMemberDiscriminator } from './protocol-bundle-members.js'; +import type { ViewContainerWrite } from './protocol-package-bindings.js'; import type { ProtocolHost } from './protocol-host.js'; import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; import { clientFacingFailureText } from './protocol-client-failures.js'; +// [#29985 codemod] `ObjectStackProtocolImplementation` here is `ObjectStackProtocolImplementationStatics` (imported AS the class): forwarders to its statics (#30147). +import { ObjectStackProtocolImplementationStatics as ObjectStackProtocolImplementation } from './protocol.js'; export class ProtocolRegistryHydrationFacet { /** @@ -166,6 +170,42 @@ export class ProtocolRegistryHydrationFacet { } } + /** + * [ADR-0029 D9.9] The mis-bound overlay refusal. + * + * The overlay-uniqueness index keys on + * `(type, name, organization_id, COALESCE(package_id, ''))` (ADR-0005 + * amendment, #6825), so `sys_metadata` can legitimately hold two active + * rows for one `(type, name)` bound to two packages. For every other type + * that is fine — two packages really can ship `page/home`. For `object` it + * is not representable: `computeFQN` is identity, so the registry holds + * exactly one entry per object name and could never serve two. Refusing + * the mis-bound row is what keeps the two stores in agreement instead of + * letting `sys_metadata` describe a shape the registry cannot hold. + */ + static overlayPackageMismatchError(name: string, rowPackageId: string, ownerPackageId: string, + /** [#23514] The state the refusal answers, for a `'sys_metadata'` sentinel owner only. */ + unboundOwnerState: UnboundOwnerRefusalState = {},): Error { + // [#23514] An owner keyed by the sentinel is owned by no package, so + // this prescription's "bind it to the owner" has nothing to name. + if (ownerPackageId === 'sys_metadata') { + return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId, unboundOwnerState); + } + const err: any = new Error( + `Cannot layer object '${name}': the overlay is bound to package ` + + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` + + `An object has exactly one registry entry, so it can carry exactly one overlay layer — bind the ` + + `customization to '${ownerPackageId}', or have '${rowPackageId}' extend the object instead. ` + + `See docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md.`, + ); + err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; + err.status = 422; + err.packageId = rowPackageId; + err.ownerPackageId = ownerPackageId; + err.docs = 'docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md'; + return err; + } + /** * Register ONE active overlay body into the engine's SchemaRegistry. * @@ -821,6 +861,97 @@ export class ProtocolRegistryHydrationFacet { }, options.organizationId); } + /** + * [#23198] THE reconcile of a view container's expansions: after any write + * that changes a container's stored row (a publish, a republish, a reset, a + * delete), the registry holds exactly the expansion set the stored row now + * implies. Both sides call it: the registration + * ({@link hydrateExpandedViewItems}, for every hydration of a row) and the + * delete's heal ({@link restoreArtifactRegistryView}, whose set is + * empty). No other code registers an expansion, reads the record, or + * writes it (`protocol.view-expansion-reconcile-call-sites.test.ts`). + * + * In order: + * + * 1. Register each expanded view, unless another package ships its name + * ([#21980, #22057], {@link anotherPackageShips}). Such a name is never + * recorded, so no heal ever touches it. + * 2. Replace the record of the rows the write addresses with the names + * registered in step 1. The old names come out of the record before any + * heal runs, so a write that lands meanwhile reconciles against the new + * set. A rebind (an orphan adoption, {@link reassignOrphanedMetadata}) + * addresses the row under both bindings: the record of the binding it + * left is removed, and its names count as the row's old names. + * 3. Heal each old name the new set lacks through the delete's per-name + * walk ({@link restoreArtifactRegistryView}): a packaged view of that + * name is un-shadowed, a MetadataService baseline is re-registered, and + * a name no layer serves is retired. + * + * Every gate the walk carries applies here too. An organization-scoped + * write registers nothing and heals nothing (#6602 / #6780: the bare + * entries belong to the environment-wide row). Only a kernel whose registry + * takes its stored rows ({@link registryTakesStoredRows}) re-registers a + * baseline. A walk that stops on a degraded baseline read + * retires nothing, and that name stays registered, unrecorded, until a + * restart: there is no retry ([#23149]'s residual, now reached from a + * republish as well as a reset). + * + * Views only, judged on the canonical type: no other type expands, and a + * row of another type stored under a container's name must not heal the + * container's expansions. + */ + static async reconcileViewExpansions(this: Pick, type: string, + write: ViewContainerWrite, + /** The write's own scope: `null` for an env-wide row. [#6780] */ + organizationId: string | null,): Promise { + if (canonicalMetaType(type) !== 'view') return; + if (organizationId !== null && organizationId !== undefined) return; + const registry: any = (this.engine as any)?.registry; + if (!registry || typeof registry !== 'object') return; + + // 1. The set the stored row implies now. A non-empty expansion comes + // only from the hydrator, past its `registerItem` capability probe. + const registered = new Set(); + let shipping: ShippingPackages | undefined; + for (const item of 'expansion' in write ? write.expansion : []) { + shipping ??= this.shippingPackagesOf(type); + if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue; + registry.registerItem(type, item, 'name' as any); + registered.add(String(item.name)); + } + + // 2. The record: the addressed rows' old names out, the new set in. + const record = ObjectStackProtocolImplementation.registeredViewExpansions; + let containers = record.get(registry); + if (containers === undefined) record.set(registry, (containers = new Map())); + const rows = containers.get(write.container) ?? new Map>(); + const previous = new Set(); + if ('removed' in write) { + for (const names of rows.values()) for (const name of names) previous.add(name); + rows.clear(); + } else { + const row = write.binding ?? ''; + for (const name of rows.get(row) ?? []) previous.add(name); + // A rebind: what the row registered under the binding it left is + // its own too, and that record row is gone with the binding. + if (write.reboundFrom !== undefined) { + const left = write.reboundFrom.binding ?? ''; + for (const name of rows.get(left) ?? []) previous.add(name); + rows.delete(left); + } + if (registered.size > 0) rows.set(row, registered); + else rows.delete(row); + } + if (rows.size > 0) containers.set(write.container, rows); + else containers.delete(write.container); + + // 3. Heal what the rows registered before and do not now. + for (const name of previous) { + if (registered.has(name)) continue; + await this.restoreArtifactRegistryView(type, name, organizationId); + } + } + /** * [#21980, #22057] True when a code package other than `own` ships an * artifact named `name`: some artifact in {@link shippedArtifactsOf}, the diff --git a/packages/metadata-protocol/src/protocol-write-gates-tables.ts b/packages/metadata-protocol/src/protocol-write-gates-tables.ts new file mode 100644 index 00000000000..84c534b8c96 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-write-gates-tables.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29985 codemod] The static tables of the "Write gates: overlay, code-only, + * organization, sealed and packaged-base refusals, locks, version tokens, + * conflict audit" section of `ObjectStackProtocolImplementation`: the + * initializers of `OVERLAY_ALLOWED_TYPES`, `STATIC_REGISTRY_TYPES`, + * `RUNTIME_CREATE_ALLOWED_TYPES`, `OVERLAY_CAPABLE_TYPES` moved out of + * `protocol.ts` whole by `scripts/codemod-split-protocol.mjs --members` + * (#30147). + * + * A MOVE, not a redesign. Each initializer below is the one the class field of + * the same name carried, byte for byte; the field keeps its docblock, + * modifiers and type, and reads its table from here. An initializer runs + * while its class is evaluated, so this module imports neither `protocol.ts` + * nor any module that does: only what the initializers read, from where + * `protocol.ts` imports it. Nothing re-exports this module, so the published + * surface does not move. + */ + +import { SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; + +export class ProtocolWriteGatesTables { + /** The initializer of `ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES`; the field's declaration there is its contract. */ + static readonly OVERLAY_ALLOWED_TYPES: ReadonlySet = (() => { + const out = new Set(); + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + if (!entry.allowEnvironmentOverlay) continue; + out.add(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) out.add(plural); + } + return out; + })(); + + /** The initializer of `ObjectStackProtocolImplementation.STATIC_REGISTRY_TYPES`; the field's declaration there is its contract. */ + static readonly STATIC_REGISTRY_TYPES: ReadonlySet = (() => { + const out = new Set(); + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + out.add(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) out.add(plural); + } + return out; + })(); + + /** The initializer of `ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES`; the field's declaration there is its contract. */ + static readonly RUNTIME_CREATE_ALLOWED_TYPES: ReadonlySet = (() => { + const out = new Set(); + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + if (!entry.allowRuntimeCreate) continue; + out.add(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) out.add(plural); + } + return out; + })(); + + /** The initializer of `ObjectStackProtocolImplementation.OVERLAY_CAPABLE_TYPES`; the field's declaration there is its contract. */ + static readonly OVERLAY_CAPABLE_TYPES: ReadonlySet = (() => { + const out = new Set(); + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + if (!entry.supportsOverlay) continue; + out.add(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) out.add(plural); + } + return out; + })(); +} diff --git a/packages/metadata-protocol/src/protocol-write-gates.ts b/packages/metadata-protocol/src/protocol-write-gates.ts index 74deb120080..490db0c278b 100644 --- a/packages/metadata-protocol/src/protocol-write-gates.ts +++ b/packages/metadata-protocol/src/protocol-write-gates.ts @@ -3,9 +3,13 @@ /** * [#29985 codemod] The "Write gates: overlay, code-only, organization, sealed * and packaged-base refusals, locks, version tokens, conflict audit" facet of - * `ObjectStackProtocolImplementation`: the bodies of `isArtifactBacked`, - * `isSealedManagedItem`, `overlayBaseShippers`, `isNestedArtifactField`, + * `ObjectStackProtocolImplementation`: the bodies of `mergesOverlayAtRead`, + * `registryAllowsOverlay`, `isOverlayAllowed`, `isRuntimeCreateAllowed`, + * `codeOnlySourceHint`, `codeOnlyCreateError`, `codeOnlyOverrideError`, + * `refuseOrganizationScopedWrite`, `isArtifactBacked`, `isSealedManagedItem`, + * `overlayBaseShippers`, `isNestedArtifactField`, `packagedBaseRefusal`, * `packagedArtifactOwner`, `storedFlowBindingAgrees`, + * `packagelessOverlayWriteRefusal`, `namedBaseOverlayWriteRefusal`, * `refusePackagedBaseRemoval`, `shipsBundleName`, `baseServesBundleMember`, * `pairOfAddedMember`, `findPairAddedMemberRow`, `registryMemberServedAt`, * `recordOptimisticConflictAudit`, `envWritableTypes`, @@ -35,16 +39,220 @@ import { managedItemSealedSentence } from './packaged-base-regime.js'; import type { StoredOverlayRow } from './item-lock.js'; import { itemDiscriminator } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; -import { customizationPackageId } from '@objectstack/spec/kernel'; +import { DEFAULT_METADATA_TYPE_REGISTRY, customizationPackageId } from '@objectstack/spec/kernel'; import { baseAddressServesMember, readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { canonicalMetaType, canonicalizeMetaRequestType } from './protocol-meta-type-schema.js'; import { boundPackageOf } from './protocol-package-bindings.js'; import type { ShippingPackages } from './protocol-package-bindings.js'; import { memberOption } from './protocol-bundle-members.js'; import type { MetadataAuditEntry } from './protocol-audit-entry.js'; +import { packagedBaseRegimeRow, packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { packagelessCloseSubject } from './protocol-packageless-overlay-migration.js'; +import type { PackagelessWriteSubject } from './protocol-packageless-overlay-migration.js'; +import { + namedBaseCloseSubject, + namedBaseOverlayWriteRefusal, + packagelessOverlayWriteRefusal, +} from './protocol-packaged-base-refusal.js'; +import type { + NamedBaseWriteOperation, + NamedBaseWriteSubject, + PackagelessWriteOperation, +} from './protocol-packaged-base-refusal.js'; import type { ProtocolHost } from './protocol-host.js'; +// [#29985 codemod] `ObjectStackProtocolImplementation` here is `ObjectStackProtocolImplementationStatics` (imported AS the class): forwarders to its statics (#30147). +import { ObjectStackProtocolImplementationStatics as ObjectStackProtocolImplementation } from './protocol.js'; +import type { ObjectStackProtocolImplementationStaticsHost } from './protocol.js'; export class ProtocolWriteGatesFacet { + /** + * [#6960] Does this type's registry entry carry the removal carve-out + * (`supportsOverlay`)? The name predates ADR-0131 D6's seal and no read + * consults it: a stored overlay row of a managed item is served or + * withheld by {@link declinesStoredRow}, which keys on + * `allowEnvironmentOverlay`. Normalizes plural→singular, like every other + * predicate here. + * + * Read ONLY by `deleteMetaItem`'s two-tier authorization (and mirrored by + * `SysMetadataRepository`'s delete gate); it never widens a create or an + * update. See the call site for the ruling that licenses the asymmetry. + */ + static mergesOverlayAtRead(this: Pick, type: string): boolean { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + return this.OVERLAY_CAPABLE_TYPES.has(singular) + || this.OVERLAY_CAPABLE_TYPES.has(type); + } + + /** + * Does the type's REGISTRY entry open an overlay channel (`allowEnvironmentOverlay`)? + * The registry arm of {@link isOverlayAllowed}, without the hatch. + * Normalizes plural→singular before consulting the allow-list. + * + * [ADR-0131 D6] Read alone by {@link isSealedManagedItem}: whether an item a + * managed package ships may be overlaid is the registry's answer, and the + * `OS_METADATA_WRITABLE` hatch has no say in it. + */ + static registryAllowsOverlay(this: Pick, type: string): boolean { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + return this.OVERLAY_ALLOWED_TYPES.has(singular) + || this.OVERLAY_ALLOWED_TYPES.has(type); + } + + /** + * The TYPE-level write channel: the registry's overlay opt-in, or the + * `OS_METADATA_WRITABLE` hatch naming the type. Normalize plural→singular + * before consulting the allow-list. + * + * [ADR-0131 D6] A type-level answer, so ⛔ never the answer for an item a + * managed package ships — that one is {@link isSealedManagedItem}'s, and the + * hatch does not reach it. What the hatch still opens through this predicate + * is the type's writes of items no managed package ships. + */ + static isOverlayAllowed(this: Pick, type: string): boolean { + if (this.registryAllowsOverlay(type)) return true; + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const env = this.envWritableTypes(); + return env.has(singular) || env.has(type); + } + + /** + * Does this type permit creating brand-new (artifact-free) items? + * + * ## [#8421] What the second arm means now, and why it did NOT narrow + * + * The fall-through below used to be the write-side twin of a read-side + * rule: "no static registry entry ⇒ plugin-registered ⇒ writable", mirrored + * verbatim by `getMetaTypes()`'s synthesised `allowRuntimeCreate: true`. + * The maintainer ruling of 2026-08-15 retired that rule's premise — + * verbatim, untranslated: + * + * 暂时不考虑让插件申明新的元数据类型 + * + * ⚠️ `暂时` is load-bearing: a CURRENT posture, deliberately not a + * permanent architectural closure (the full record, and what a future + * author reintroducing plugin-declared kinds must revisit, is in + * {@link getMetaTypes}'s synthesis comment — read that one first). + * + * The narrowing that ruling bought is enforced ONE layer up, at + * {@link refuseUnmintableMetaType}, which runs before this predicate on + * every `saveMetaItem` and refuses an out-of-contract type outright. This + * arm is therefore no longer an ADVERTISEMENT and must not be narrowed to + * match one: by the time it is consulted for such a type, the caller is in + * one of the residue paths the refusal deliberately exempts or does not + * guard at all — + * + * - `deleteMetaItem` / {@link historyMetaItem} / `rollbackMetaItem` / + * `promoteDraftForPublish`: rows minted under an unrecognised type + * BEFORE the refusal shipped are real, and nothing rewrites them on + * upgrade. Returning `false` here would route them off the repository + * path and strand them — turning the accumulation this card was filed + * about into an accumulation nobody can clear, which is the exact + * reasoning that kept `deleteMetaItem` open in the first place; + * - `saveMetaItem` behind the mint door's two exemptions (the compound + * arity, and a namespace the store says already exists). + * + * So the doors agree where agreement is a claim about creating a NEW + * namespace — the listing and the mint door read one predicate for that — + * and this arm keeps the clearance path open underneath. Narrowing it would + * not close anything; it would only make residue unclearable. + */ + static isRuntimeCreateAllowed(this: Pick, type: string): boolean { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (this.RUNTIME_CREATE_ALLOWED_TYPES.has(singular) + || this.RUNTIME_CREATE_ALLOWED_TYPES.has(type)) { + return true; + } + // No static registry entry ⇒ no declared flags to consult. See the doc + // above: this is the residue/clearance arm, NOT the read door's twin. + if (!this.STATIC_REGISTRY_TYPES.has(singular) + && !this.STATIC_REGISTRY_TYPES.has(type)) { + return true; + } + return false; + } + + /** + * The prescription half of a code-only refusal (#5086): where the author + * is supposed to declare this item instead. Read from the type's own + * registry entry (`filePatterns`), so a newly-flagged type carries an + * accurate hint the day it is flagged — nothing here to keep in sync, + * except the types {@link CODE_ONLY_TYPE_REMEDY} names explicitly. + */ + static codeOnlySourceHint(type: string): string { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const named = ObjectStackProtocolImplementation.CODE_ONLY_TYPE_REMEDY[singular]; + if (named) return named; + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === singular); + const pattern = entry?.filePatterns?.[0]; + return pattern ? ` Declare it in source (${pattern}) and redeploy.` : ''; + } + + /** + * #5086 — a brand-new item of a type the registry declares code-only + * (`allowRuntimeCreate: false` AND `allowEnvironmentOverlay: false`). + * + * Names the type, the flags that produced the verdict, the prescription + * and the escape hatch — the same shape the retired-key refusals in this + * wave carry, because a refusal an author cannot act on just moves the + * confusion one layer down. `NOT_CREATABLE` is the catalogued code + * (`packages/spec/src/api/error-code-ledger.zod.ts`). + */ + static codeOnlyCreateError(type: string): Error { + const err = new Error( + `Metadata type '${type}' is code-only: the metadata-type registry declares ` + + `allowRuntimeCreate=false and allowEnvironmentOverlay=false, so it cannot be created through the ` + + `runtime metadata API (PUT /api/v1/meta/${type}/:name) on any kernel.` + + ObjectStackProtocolImplementation.codeOnlySourceHint(type) + // [ADR-0131 D6] A Regime C type's refusal names its sanctioned paths and + // never the hatch, as its regime sentence does (`packaged-base-regime.ts`): + // the hatch would re-open the very source the regime closes. It still + // opens the type for an operator — the residue repair the delete door + // keeps — it is only not prescribed. Under the REST door's 500-character + // bound either way (`sharing_rule`: 473). + + (packagedBaseRegimeRow(type)?.regime === 'C' + ? '' + : ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` + + `See docs/adr/0005-metadata-customization-overlay.md.`) + ); + (err as any).code = 'NOT_CREATABLE'; + (err as any).status = 403; + return err; + } + + /** + * #5086 — the artifact-backed half of the same refusal: the name IS + * shipped by a code package, so the honest verdict is "you may not + * overlay it" rather than "you may not create it". + * + * [ADR-0131 D6] The package is a managed one and its item is sealed, so the + * sentence names the managed package and says the hatch does not open it — + * the prescription this sentence used to end with ("an operator may set + * OS_METADATA_WRITABLE") would send the operator to a door that no longer + * opens. The source remedy stays: it is where a code-only item is declared. + */ + static codeOnlyOverrideError(type: string, name: string): Error { + // [ADR-0131 D6, ADR-0126 §2] A Regime C type's declared item reads its + // regime sentence — the one the package door and the repository speak for + // the same item — so the refusal names the clone and the switch. + const err = new Error( + (packagedBaseRegimeRow(type)?.regime === 'C' ? packagedBaseRegimeSentence(type, name, 'save') : undefined) ?? + `Metadata item '${type}/${name}' is provided by a managed package and its type is ` + + `code-only (allowRuntimeCreate=false, allowEnvironmentOverlay=false), so it is sealed against ` + + `runtime edits on any kernel, and OS_METADATA_WRITABLE does not open a managed item.` + + ObjectStackProtocolImplementation.codeOnlySourceHint(type) + + ` See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.` + ); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + return err; + } + + /** Throw {@link organizationScopedWriteRefusal} for an organization-scoped write request. */ + static refuseOrganizationScopedWrite(this: Pick, subject: string, request: unknown): void { + const refusal = this.organizationScopedWriteRefusal(subject, this.requestedOrganization(request)); + if (refusal) throw refusal; + } + /** * Does an artifact (npm-package-loaded) item exist at `(type, name)`? * @@ -218,6 +426,27 @@ export class ProtocolWriteGatesFacet { return Object.prototype.hasOwnProperty.call(fields, name.slice(sep + 1)); } + /** The body of `ObjectStackProtocolImplementation#packagedBaseRefusal`; the method's docblock there is its contract. */ + static packagedBaseRefusal(host: Pick, request: { type: string; name: string; operation: 'save' | 'delete'; packageId?: string | null }): Error | null { + // [#9009] Folded HERE, at the producer of the verdict, so a caller that + // arrives with a plural spelling cannot address around the lock. + const folded = canonicalizeMetaRequestType(request); + try { + if (folded.operation === 'delete') host.refusePackagedBaseRemoval(folded); + else { + host.refusePackagedBaseOverride({ + type: folded.type, + name: folded.name, + ...(folded.packageId ? { packageId: folded.packageId } : {}), + }); + } + } catch (err) { + if (host.isPackagedBaseRefusal(err)) return err; + throw err; + } + return null; + } + /** The body of `ObjectStackProtocolImplementation#packagedArtifactOwner`; the method's docblock there is its contract. */ static packagedArtifactOwner(host: Pick, request: { type: string; name: string }): string | undefined { const folded = canonicalizeMetaRequestType(request); @@ -256,6 +485,103 @@ export class ProtocolWriteGatesFacet { } } + /** + * [ADR-0146 D11, #28576 ruling 2] The package-less write onto a packaged + * item, closed where `os migrate packageless-overlays` would move it: the + * refusal of a save, a publish, a delete or a rollback that names no + * package, of an item of one of the five overlay types that ONE read-only + * code package ships ({@link overlayBaseShippers}), whose row the + * ceremony's census classifies `movable` and whose move can succeed + * (`packagelessCloseSubject` in `protocol-packageless-overlay-migration.ts`, + * the census's own classification and the ceremony's own steps). + * + * `null` — the write stays open, as on `main` — when it names a package + * (`?package=.custom` stays open, as does every other package's own + * door; [#28905] the write naming the read-only base itself is closed by + * {@link namedBaseOverlayWriteRefusal}, not here); when no read-only + * package ships the name (environment metadata); + * and for every row the ceremony lists and does not move: a name several + * packages ship (`multi_shipped`), a base with no pair id (`no_pair`), a + * stored row whose `overrides` names another package + * (`foreign_declaration`) or whose pair already holds the item + * (`shadowed`), and a move that would fail (the ensure door would refuse + * the pair, the base records no version, or the override write gate + * refuses the declared body — an `email_template` locale the base does not + * ship). Each such row keeps a write door after the upgrade: its own. + * + * The refusal names the base's paired `.custom` and the ceremony; a row + * stored before the close stays served until the ceremony moves it, and + * its package-less write is refused by the same sentence. Registry reads + * first; the one store read (the item's rows) only when the registry has + * not left the write open. + * + * Asked by {@link saveMetaItem} and {@link publishMetaItem} (authoring + * writes only: the server-stated rewrites name the row they rewrite), + * {@link deleteMetaItem}, and {@link rollbackMetaItem} (whose address is the + * restored row's own binding: package-less when that row is). The + * sentence is `packagelessOverlayWriteRefusal` in + * `protocol-packaged-base-refusal.ts`. + * + * Not the read envelope's `editable` / `deletable` ({@link packagedBaseRefusal} + * keeps its answer): those describe the item, which the pair's write still + * changes. + */ + static async packagelessOverlayWriteRefusal(this: Pick, request: PackagelessWriteSubject, + operation: PackagelessWriteOperation,): Promise { + const host: ThisParameterType = { + engine: this.engine, + findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), + rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), + overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), + overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), + }; + const closed = await packagelessCloseSubject.call(host, request); + return closed ? packagelessOverlayWriteRefusal(request, closed, operation) : null; + } + + /** + * [#28905, ADR-0146 D1, D11] The write NAMING the read-only base itself + * (`?package=`) onto an item of one of the five overlay types that + * base ships: the refusal of a save (draft or active), a publish (both + * {@link publishMetaItem} and {@link publishPackageDrafts}, through + * {@link promoteDraftForPublish}) or a rollback that would land a row bound + * to the base. D1 puts a read-only package's customizations in its paired + * `.custom`; such a row is served at the base's address with no + * declaration, outside the pair, until [#29392] `os migrate + * packageless-overlays` moves it into the pair. Refused with + * the fix the package-less close names ({@link packagelessOverlayWriteRefusal}, + * one spelling): write it into the pair, declaring the override. + * + * Closed on the package-less close's reading of "packaged" — a READ-ONLY + * code package among {@link overlayBaseShippers} — and only where that fix + * can be taken (a pair id, the ensure door, a base version, the override + * write gate admitting the declared body): `namedBaseCloseSubject` in + * `protocol-packaged-base-refusal.ts`. `null` — the write stays open, as on + * `main` — for a writable package, a package that does not ship the name, + * a body carrying `overrides` (the D2 gate's), any other type, and a fix + * that could not be taken. + * + * ⛔ Not a delete: removing a row bound to the base writes nothing, and it + * is one way to clear such a row stored before the close; the ceremony is + * the other, moving it into the pair with its declaration. A row stored + * before it stays served until one of those, or the pair's declared + * override, which is served over it; the sentence names all three. + * + * Authoring writes only: the server-stated rewrites (`source` set, the + * `package-duplicate` write face) name the row they rewrite. Registry + * reads only. + */ + static namedBaseOverlayWriteRefusal(this: Pick, request: NamedBaseWriteSubject, + operation: NamedBaseWriteOperation,): Error | null { + const host: ThisParameterType = { + engine: this.engine, + overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), + overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), + }; + const closed = namedBaseCloseSubject.call(host, request); + return closed ? namedBaseOverlayWriteRefusal(request, closed, operation) : null; + } + /** * `deleteMetaItem`'s refusal of a removal of an item a code package ships, * on a type with no per-org overlay channel — EXCEPT a type whose loader diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts index b64eec506ed..01272e9b875 100644 --- a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -70,7 +70,13 @@ const HERE = dirname(fileURLToPath(import.meta.url)); * site before this pin noticed. */ const PRODUCERS: Readonly> = { - 'protocol.ts': 5, + // [#30147] 3: three of its throw sites moved to the two modules below. + 'protocol.ts': 3, + // [#30147] The write gates' and the registry hydration's refusals, moved out + // of `protocol.ts` by the codemod's members pass into modules it had split + // out before; the floor is each module's `new Error(` count after that move. + 'protocol-write-gates.ts': 4, + 'protocol-registry-hydration.ts': 4, // The bulk data writes, moved out of `protocol.ts` whole: their throw sites // are still this family's, so the scan follows them. The floor is the // module's own `new Error(` count when it was split out. diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts index d4b8f33f126..711a1624898 100644 --- a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -137,7 +137,8 @@ const SITES: ReadonlyArray protocol.isHostCodeDatasource(...a), isNestedArtifactField: (...a) => protocol.isNestedArtifactField(...a), isOverlayAllowed: (...a) => ObjectStackProtocolImplementation.isOverlayAllowed(...a), + isPackagedBaseRefusal: (err: unknown): err is Error => ObjectStackProtocolImplementation.isPackagedBaseRefusal(err), isRuntimeCreateAllowed: (...a) => ObjectStackProtocolImplementation.isRuntimeCreateAllowed(...a), isSealedManagedItem: (...a) => protocol.isSealedManagedItem(...a), isStoredEntryOfDeclinedName: (...a) => protocol.isStoredEntryOfDeclinedName(...a), @@ -1271,6 +1288,7 @@ export class ObjectStackProtocolImplementation implements organizationScopedWriteRefusal: (...a) => ObjectStackProtocolImplementation.organizationScopedWriteRefusal(...a), originGatedRemovalRefusal: (...a) => protocol.originGatedRemovalRefusal(...a), overlaidShippedContainerViewNames: (...a) => protocol.overlaidShippedContainerViewNames(...a), + overlayBaseShippers: (...a) => protocol.overlayBaseShippers(...a), overlayLockLayerAt: (...a) => protocol.overlayLockLayerAt(...a), overlayPackageMismatchError: (...a) => ObjectStackProtocolImplementation.overlayPackageMismatchError(...a), get overlayRepos() { return protocol.overlayRepos; }, @@ -1998,27 +2016,15 @@ export class ObjectStackProtocolImplementation implements return resolveWritePackageScope.call(host, packageId); } - /** - * [#28574, ADR-0146 D10] The save, publish and restore doors' gate for an - * `object_extension` body written into `binding` (the RESOLVED package of - * the row): {@link objectExtensionItemRefusal}, asked with this engine's - * registry and the package's dependency closure - * ({@link resolveWritePackageScope}). Any other type answers `undefined`. - */ + /** Docblock and body: {@link ProtocolClassHeadFacet.objectExtensionItemRefusal} in `protocol-class-head.ts` [#29985 codemod]. */ private objectExtensionItemRefusal( type: string, item: unknown, name: string, binding: string | null | undefined, ): Error | undefined { - if ((PLURAL_TO_SINGULAR[type] ?? type) !== OBJECT_EXTENSION_TYPE) return undefined; - return objectExtensionItemRefusal(OBJECT_EXTENSION_TYPE, { - item, - name, - binding, - registry: this.engine?.registry as ObjectExtensionRegistry | undefined, - scope: this.resolveWritePackageScope(binding), - }); + // [#29985 codemod] The body moved to `protocol-class-head.ts`; it reaches this class through `protocol-host.ts`. + return ProtocolClassHeadFacet.objectExtensionItemRefusal(protocolHost(this), type, item, name, binding); } /** Docblock and body: {@link ProtocolClassHeadFacet.registerStoredObjectExtension} in `protocol-class-head.ts` [#29985 codemod]. */ @@ -3078,81 +3084,10 @@ export class ObjectStackProtocolImplementation implements return getMetaDiagnostics.call(host, request); } - /** - * [#5532] Decide what a failed READ against `sys_metadata` means, and - * rethrow unless it is the ONE benign reason. - * - * ## The defect - * - * Every overlay read in `getMetaItems`/`getMetaItem` used to `catch {}` into - * its own empty value — `items` left as-is, `item` left `undefined`, the - * draft lookup falling through to the active read. That made a metadata - * store the protocol cannot reach **indistinguishable** from an environment - * where the item was never customised, and the emptiness then travelled - * the whole read chain unremarked: - * - * - `getMetaItemCached` turned it into `not found` — an outage answered - * as "that item does not exist", which is the opposite disposition - * (retry the backend vs. create the item / fix the link); - * - the `state='draft'` read turned it into `NO_DRAFT` / 404 — an outage - * answered as "there is no pending edit", which a publish flow reads as - * "nothing to publish"; - * - `getMetaItems` turned it into `items: []` — an outage answered as - * "this environment declares none of these", the exact shape #5108 fixed - * one layer down in `DatabaseLoader` and #5089 in `listForIndex`. - * - * ADR-0110 D3 is the rule: a miss and an outage are different facts with - * opposite meanings, and a consumer must never read one as the other. - * - * ## The one benign reason - * - * `sys_metadata` has not been provisioned yet. There are then genuinely no - * overlay rows, so falling through to the registry / MetadataService IS the - * truth, and a first boot must not explode. Classification is by error TYPE - * through {@link isMissingTableError} — the same predicate `DatabaseLoader` - * (#5108) and this package's own `SysMetadataRepository` (#4867) ask, so a - * driver quirk is taught to the platform once. Conservative in the same - * direction: an unrecognised error is NOT benign, because a false "benign" - * silently mis-answers "does this exist?" while a false "real" costs one - * 503 the caller can retry. - * - * ## [#12536] Not benign is not the same as "the store is down" - * - * A non-benign failure is classified once more before it is wrapped, by - * {@link metadataReadFailureError}: a metadata app's hook may have REFUSED - * this read and marked its refusal with `userMessage` (commit 79c46da90), and that is - * an application refusal, not a dependency outage. Handing it to the 503 - * below destroyed the mark at the producer — the two failures left this - * guard as the same envelope with the same sentence. The classification - * splits them; neither category's wording changes. - * - * @throws {@link markedApplicationRefusalError} — [#12536] FIRST, when the - * failure carried a producer's `userMessage` mark (commit 79c46da90): the - * author's text verbatim, in its own refusal category, with the - * cause still carried for the operator and nothing else quoted. - * @throws {@link metadataStoreUnavailableError} — a 503 carrying the driver - * error as `cause`, for every UNMARKED non-benign failure. Not the - * driver error itself: unwrapped, it has - * no status, so the REST boundary would have to guess from the - * message text — and `mapDataError` guesses `no such table` into - * `404 OBJECT_NOT_FOUND`, i.e. straight back into a miss. - * @returns normally ONLY for the benign case, licensing the caller to treat - * the overlay as absent. - */ + /** Docblock and body: {@link ProtocolDiscoveryAndBasesFacet.rethrowUnlessMetadataStoreUnprovisioned} in `protocol-discovery-and-bases.ts` [#29985 codemod]. */ private rethrowUnlessMetadataStoreUnprovisioned(error: unknown, readObject: string): void { - // [commit 4cda78c9b] `readObject` is REQUIRED, deliberately. This helper serves - // callers that read four different tables (`sys_metadata`, - // `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`), - // so a default would silently answer about the wrong one for three of - // them — measured, not hypothetical: the first draft of this repair - // hardcoded `sys_metadata` and turned `diffMetaItem`'s genuinely - // unprovisioned `sys_metadata_history` into a loud failure. A required - // parameter makes the compiler ask the question at every new call site. - if (isMissingTableError(error, readObject)) return; - // [#12536] CLASSIFY, do not assume. A read can fail because the store - // is unreachable OR because a metadata app's hook refused it in its - // own words — see {@link metadataReadFailureError}. - throw metadataReadFailureError(error); + // [#29985 codemod] The body moved to `protocol-discovery-and-bases.ts`; it reads nothing of this class. + return ProtocolDiscoveryAndBasesFacet.rethrowUnlessMetadataStoreUnprovisioned(error, readObject); } /** @@ -6414,16 +6349,7 @@ export class ObjectStackProtocolImplementation implements * §"Whitelist enforcement" for the rationale and the per-type rollout * checklist. */ - private static readonly OVERLAY_ALLOWED_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - if (!entry.allowEnvironmentOverlay) continue; - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; - })(); + private static readonly OVERLAY_ALLOWED_TYPES: ReadonlySet = ProtocolWriteGatesTables.OVERLAY_ALLOWED_TYPES; /** Docblock and body: {@link ProtocolWriteGatesFacet.envWritableTypes} in `protocol-write-gates.ts` [#29985 codemod]. */ private static envWritableTypes(): ReadonlySet { @@ -6459,26 +6385,9 @@ export class ObjectStackProtocolImplementation implements * from THIS set is therefore not on its own an answer to "may it be * created"; see {@link isRuntimeCreateAllowed} for what absence still buys. */ - private static readonly STATIC_REGISTRY_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; - })(); - - private static readonly RUNTIME_CREATE_ALLOWED_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - if (!entry.allowRuntimeCreate) continue; - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; - })(); + private static readonly STATIC_REGISTRY_TYPES: ReadonlySet = ProtocolWriteGatesTables.STATIC_REGISTRY_TYPES; + + private static readonly RUNTIME_CREATE_ALLOWED_TYPES: ReadonlySet = ProtocolWriteGatesTables.RUNTIME_CREATE_ALLOWED_TYPES; /** * [#6960] Metadata types whose stored overlay row of an item a managed @@ -6507,121 +6416,30 @@ export class ObjectStackProtocolImplementation implements * identically to the singular — the same normalization every sibling set * in this class does. */ - private static readonly OVERLAY_CAPABLE_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - if (!entry.supportsOverlay) continue; - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; - })(); + private static readonly OVERLAY_CAPABLE_TYPES: ReadonlySet = ProtocolWriteGatesTables.OVERLAY_CAPABLE_TYPES; - /** - * [#6960] Does this type's registry entry carry the removal carve-out - * (`supportsOverlay`)? The name predates ADR-0131 D6's seal and no read - * consults it: a stored overlay row of a managed item is served or - * withheld by {@link declinesStoredRow}, which keys on - * `allowEnvironmentOverlay`. Normalizes plural→singular, like every other - * predicate here. - * - * Read ONLY by `deleteMetaItem`'s two-tier authorization (and mirrored by - * `SysMetadataRepository`'s delete gate); it never widens a create or an - * update. See the call site for the ruling that licenses the asymmetry. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.mergesOverlayAtRead} in `protocol-write-gates.ts` [#29985 codemod]. */ private static mergesOverlayAtRead(type: string): boolean { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - return this.OVERLAY_CAPABLE_TYPES.has(singular) - || this.OVERLAY_CAPABLE_TYPES.has(type); + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on `ObjectStackProtocolImplementationStatics` as `this`. + return ProtocolWriteGatesFacet.mergesOverlayAtRead.call(ObjectStackProtocolImplementationStatics, type); } - /** - * Does the type's REGISTRY entry open an overlay channel (`allowEnvironmentOverlay`)? - * The registry arm of {@link isOverlayAllowed}, without the hatch. - * Normalizes plural→singular before consulting the allow-list. - * - * [ADR-0131 D6] Read alone by {@link isSealedManagedItem}: whether an item a - * managed package ships may be overlaid is the registry's answer, and the - * `OS_METADATA_WRITABLE` hatch has no say in it. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.registryAllowsOverlay} in `protocol-write-gates.ts` [#29985 codemod]. */ private static registryAllowsOverlay(type: string): boolean { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - return this.OVERLAY_ALLOWED_TYPES.has(singular) - || this.OVERLAY_ALLOWED_TYPES.has(type); + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on `ObjectStackProtocolImplementationStatics` as `this`. + return ProtocolWriteGatesFacet.registryAllowsOverlay.call(ObjectStackProtocolImplementationStatics, type); } - /** - * The TYPE-level write channel: the registry's overlay opt-in, or the - * `OS_METADATA_WRITABLE` hatch naming the type. Normalize plural→singular - * before consulting the allow-list. - * - * [ADR-0131 D6] A type-level answer, so ⛔ never the answer for an item a - * managed package ships — that one is {@link isSealedManagedItem}'s, and the - * hatch does not reach it. What the hatch still opens through this predicate - * is the type's writes of items no managed package ships. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.isOverlayAllowed} in `protocol-write-gates.ts` [#29985 codemod]. */ private static isOverlayAllowed(type: string): boolean { - if (this.registryAllowsOverlay(type)) return true; - const singular = PLURAL_TO_SINGULAR[type] ?? type; - const env = this.envWritableTypes(); - return env.has(singular) || env.has(type); + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on `ObjectStackProtocolImplementationStatics` as `this`. + return ProtocolWriteGatesFacet.isOverlayAllowed.call(ObjectStackProtocolImplementationStatics, type); } - /** - * Does this type permit creating brand-new (artifact-free) items? - * - * ## [#8421] What the second arm means now, and why it did NOT narrow - * - * The fall-through below used to be the write-side twin of a read-side - * rule: "no static registry entry ⇒ plugin-registered ⇒ writable", mirrored - * verbatim by `getMetaTypes()`'s synthesised `allowRuntimeCreate: true`. - * The maintainer ruling of 2026-08-15 retired that rule's premise — - * verbatim, untranslated: - * - * 暂时不考虑让插件申明新的元数据类型 - * - * ⚠️ `暂时` is load-bearing: a CURRENT posture, deliberately not a - * permanent architectural closure (the full record, and what a future - * author reintroducing plugin-declared kinds must revisit, is in - * {@link getMetaTypes}'s synthesis comment — read that one first). - * - * The narrowing that ruling bought is enforced ONE layer up, at - * {@link refuseUnmintableMetaType}, which runs before this predicate on - * every `saveMetaItem` and refuses an out-of-contract type outright. This - * arm is therefore no longer an ADVERTISEMENT and must not be narrowed to - * match one: by the time it is consulted for such a type, the caller is in - * one of the residue paths the refusal deliberately exempts or does not - * guard at all — - * - * - `deleteMetaItem` / {@link historyMetaItem} / `rollbackMetaItem` / - * `promoteDraftForPublish`: rows minted under an unrecognised type - * BEFORE the refusal shipped are real, and nothing rewrites them on - * upgrade. Returning `false` here would route them off the repository - * path and strand them — turning the accumulation this card was filed - * about into an accumulation nobody can clear, which is the exact - * reasoning that kept `deleteMetaItem` open in the first place; - * - `saveMetaItem` behind the mint door's two exemptions (the compound - * arity, and a namespace the store says already exists). - * - * So the doors agree where agreement is a claim about creating a NEW - * namespace — the listing and the mint door read one predicate for that — - * and this arm keeps the clearance path open underneath. Narrowing it would - * not close anything; it would only make residue unclearable. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.isRuntimeCreateAllowed} in `protocol-write-gates.ts` [#29985 codemod]. */ private static isRuntimeCreateAllowed(type: string): boolean { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (this.RUNTIME_CREATE_ALLOWED_TYPES.has(singular) - || this.RUNTIME_CREATE_ALLOWED_TYPES.has(type)) { - return true; - } - // No static registry entry ⇒ no declared flags to consult. See the doc - // above: this is the residue/clearance arm, NOT the read door's twin. - if (!this.STATIC_REGISTRY_TYPES.has(singular) - && !this.STATIC_REGISTRY_TYPES.has(type)) { - return true; - } - return false; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on `ObjectStackProtocolImplementationStatics` as `this`. + return ProtocolWriteGatesFacet.isRuntimeCreateAllowed.call(ObjectStackProtocolImplementationStatics, type); } /** @@ -6662,80 +6480,22 @@ export class ObjectStackProtocolImplementation implements + '(POST /api/v1/security/_activation/sharing_rule/:name, body {enabled: false}).', }; - /** - * The prescription half of a code-only refusal (#5086): where the author - * is supposed to declare this item instead. Read from the type's own - * registry entry (`filePatterns`), so a newly-flagged type carries an - * accurate hint the day it is flagged — nothing here to keep in sync, - * except the types {@link CODE_ONLY_TYPE_REMEDY} names explicitly. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.codeOnlySourceHint} in `protocol-write-gates.ts` [#29985 codemod]. */ private static codeOnlySourceHint(type: string): string { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - const named = ObjectStackProtocolImplementation.CODE_ONLY_TYPE_REMEDY[singular]; - if (named) return named; - const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === singular); - const pattern = entry?.filePatterns?.[0]; - return pattern ? ` Declare it in source (${pattern}) and redeploy.` : ''; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it reads this class's statics through `ObjectStackProtocolImplementationStatics`. + return ProtocolWriteGatesFacet.codeOnlySourceHint(type); } - /** - * #5086 — a brand-new item of a type the registry declares code-only - * (`allowRuntimeCreate: false` AND `allowEnvironmentOverlay: false`). - * - * Names the type, the flags that produced the verdict, the prescription - * and the escape hatch — the same shape the retired-key refusals in this - * wave carry, because a refusal an author cannot act on just moves the - * confusion one layer down. `NOT_CREATABLE` is the catalogued code - * (`packages/spec/src/api/error-code-ledger.zod.ts`). - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.codeOnlyCreateError} in `protocol-write-gates.ts` [#29985 codemod]. */ private static codeOnlyCreateError(type: string): Error { - const err = new Error( - `Metadata type '${type}' is code-only: the metadata-type registry declares ` - + `allowRuntimeCreate=false and allowEnvironmentOverlay=false, so it cannot be created through the ` - + `runtime metadata API (PUT /api/v1/meta/${type}/:name) on any kernel.` - + ObjectStackProtocolImplementation.codeOnlySourceHint(type) - // [ADR-0131 D6] A Regime C type's refusal names its sanctioned paths and - // never the hatch, as its regime sentence does (`packaged-base-regime.ts`): - // the hatch would re-open the very source the regime closes. It still - // opens the type for an operator — the residue repair the delete door - // keeps — it is only not prescribed. Under the REST door's 500-character - // bound either way (`sharing_rule`: 473). - + (packagedBaseRegimeRow(type)?.regime === 'C' - ? '' - : ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` - + `See docs/adr/0005-metadata-customization-overlay.md.`) - ); - (err as any).code = 'NOT_CREATABLE'; - (err as any).status = 403; - return err; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it reads this class's statics through `ObjectStackProtocolImplementationStatics`. + return ProtocolWriteGatesFacet.codeOnlyCreateError(type); } - /** - * #5086 — the artifact-backed half of the same refusal: the name IS - * shipped by a code package, so the honest verdict is "you may not - * overlay it" rather than "you may not create it". - * - * [ADR-0131 D6] The package is a managed one and its item is sealed, so the - * sentence names the managed package and says the hatch does not open it — - * the prescription this sentence used to end with ("an operator may set - * OS_METADATA_WRITABLE") would send the operator to a door that no longer - * opens. The source remedy stays: it is where a code-only item is declared. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.codeOnlyOverrideError} in `protocol-write-gates.ts` [#29985 codemod]. */ private static codeOnlyOverrideError(type: string, name: string): Error { - // [ADR-0131 D6, ADR-0126 §2] A Regime C type's declared item reads its - // regime sentence — the one the package door and the repository speak for - // the same item — so the refusal names the clone and the switch. - const err = new Error( - (packagedBaseRegimeRow(type)?.regime === 'C' ? packagedBaseRegimeSentence(type, name, 'save') : undefined) ?? - `Metadata item '${type}/${name}' is provided by a managed package and its type is ` - + `code-only (allowRuntimeCreate=false, allowEnvironmentOverlay=false), so it is sealed against ` - + `runtime edits on any kernel, and OS_METADATA_WRITABLE does not open a managed item.` - + ObjectStackProtocolImplementation.codeOnlySourceHint(type) - + ` See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.` - ); - (err as any).code = 'NOT_OVERRIDABLE'; - (err as any).status = 403; - return err; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it reads this class's statics through `ObjectStackProtocolImplementationStatics`. + return ProtocolWriteGatesFacet.codeOnlyOverrideError(type, name); } /** Docblock and body: {@link ProtocolWriteGatesFacet.organizationScopedWriteRefusal} in `protocol-write-gates.ts` [#29985 codemod]. */ @@ -6754,10 +6514,10 @@ export class ObjectStackProtocolImplementation implements return ProtocolWriteGatesFacet.requestedOrganization(request); } - /** Throw {@link organizationScopedWriteRefusal} for an organization-scoped write request. */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.refuseOrganizationScopedWrite} in `protocol-write-gates.ts` [#29985 codemod]. */ private static refuseOrganizationScopedWrite(subject: string, request: unknown): void { - const refusal = this.organizationScopedWriteRefusal(subject, this.requestedOrganization(request)); - if (refusal) throw refusal; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on `ObjectStackProtocolImplementationStatics` as `this`. + return ProtocolWriteGatesFacet.refuseOrganizationScopedWrite.call(ObjectStackProtocolImplementationStatics, subject, request); } /** Docblock and body: {@link ProtocolWriteGatesFacet.isArtifactBacked} in `protocol-write-gates.ts` [#29985 codemod]. */ @@ -6947,23 +6707,8 @@ export class ObjectStackProtocolImplementation implements * refuse this write or removal on the locked-base ground. */ packagedBaseRefusal(request: { type: string; name: string; operation: 'save' | 'delete'; packageId?: string | null }): Error | null { - // [#9009] Folded HERE, at the producer of the verdict, so a caller that - // arrives with a plural spelling cannot address around the lock. - const folded = canonicalizeMetaRequestType(request); - try { - if (folded.operation === 'delete') this.refusePackagedBaseRemoval(folded); - else { - this.refusePackagedBaseOverride({ - type: folded.type, - name: folded.name, - ...(folded.packageId ? { packageId: folded.packageId } : {}), - }); - } - } catch (err) { - if (ObjectStackProtocolImplementation.isPackagedBaseRefusal(err)) return err; - throw err; - } - return null; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it reaches this class through `protocol-host.ts`. + return ProtocolWriteGatesFacet.packagedBaseRefusal(protocolHost(this), request); } /** @@ -7467,105 +7212,22 @@ export class ObjectStackProtocolImplementation implements return refusePackagedBaseOverride.call(host, request); } - /** - * [ADR-0146 D11, #28576 ruling 2] The package-less write onto a packaged - * item, closed where `os migrate packageless-overlays` would move it: the - * refusal of a save, a publish, a delete or a rollback that names no - * package, of an item of one of the five overlay types that ONE read-only - * code package ships ({@link overlayBaseShippers}), whose row the - * ceremony's census classifies `movable` and whose move can succeed - * (`packagelessCloseSubject` in `protocol-packageless-overlay-migration.ts`, - * the census's own classification and the ceremony's own steps). - * - * `null` — the write stays open, as on `main` — when it names a package - * (`?package=.custom` stays open, as does every other package's own - * door; [#28905] the write naming the read-only base itself is closed by - * {@link namedBaseOverlayWriteRefusal}, not here); when no read-only - * package ships the name (environment metadata); - * and for every row the ceremony lists and does not move: a name several - * packages ship (`multi_shipped`), a base with no pair id (`no_pair`), a - * stored row whose `overrides` names another package - * (`foreign_declaration`) or whose pair already holds the item - * (`shadowed`), and a move that would fail (the ensure door would refuse - * the pair, the base records no version, or the override write gate - * refuses the declared body — an `email_template` locale the base does not - * ship). Each such row keeps a write door after the upgrade: its own. - * - * The refusal names the base's paired `.custom` and the ceremony; a row - * stored before the close stays served until the ceremony moves it, and - * its package-less write is refused by the same sentence. Registry reads - * first; the one store read (the item's rows) only when the registry has - * not left the write open. - * - * Asked by {@link saveMetaItem} and {@link publishMetaItem} (authoring - * writes only: the server-stated rewrites name the row they rewrite), - * {@link deleteMetaItem}, and {@link rollbackMetaItem} (whose address is the - * restored row's own binding: package-less when that row is). The - * sentence is `packagelessOverlayWriteRefusal` in - * `protocol-packaged-base-refusal.ts`. - * - * Not the read envelope's `editable` / `deletable` ({@link packagedBaseRefusal} - * keeps its answer): those describe the item, which the pair's write still - * changes. - */ + /** Docblock and body: {@link ProtocolWriteGatesFacet.packagelessOverlayWriteRefusal} in `protocol-write-gates.ts` [#29985 codemod]. */ private async packagelessOverlayWriteRefusal( request: PackagelessWriteSubject, operation: PackagelessWriteOperation, ): Promise { - const host: ThisParameterType = { - engine: this.engine, - findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), - rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), - overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), - overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), - }; - const closed = await packagelessCloseSubject.call(host, request); - return closed ? packagelessOverlayWriteRefusal(request, closed, operation) : null; - } - - /** - * [#28905, ADR-0146 D1, D11] The write NAMING the read-only base itself - * (`?package=`) onto an item of one of the five overlay types that - * base ships: the refusal of a save (draft or active), a publish (both - * {@link publishMetaItem} and {@link publishPackageDrafts}, through - * {@link promoteDraftForPublish}) or a rollback that would land a row bound - * to the base. D1 puts a read-only package's customizations in its paired - * `.custom`; such a row is served at the base's address with no - * declaration, outside the pair, until [#29392] `os migrate - * packageless-overlays` moves it into the pair. Refused with - * the fix the package-less close names ({@link packagelessOverlayWriteRefusal}, - * one spelling): write it into the pair, declaring the override. - * - * Closed on the package-less close's reading of "packaged" — a READ-ONLY - * code package among {@link overlayBaseShippers} — and only where that fix - * can be taken (a pair id, the ensure door, a base version, the override - * write gate admitting the declared body): `namedBaseCloseSubject` in - * `protocol-packaged-base-refusal.ts`. `null` — the write stays open, as on - * `main` — for a writable package, a package that does not ship the name, - * a body carrying `overrides` (the D2 gate's), any other type, and a fix - * that could not be taken. - * - * ⛔ Not a delete: removing a row bound to the base writes nothing, and it - * is one way to clear such a row stored before the close; the ceremony is - * the other, moving it into the pair with its declaration. A row stored - * before it stays served until one of those, or the pair's declared - * override, which is served over it; the sentence names all three. - * - * Authoring writes only: the server-stated rewrites (`source` set, the - * `package-duplicate` write face) name the row they rewrite. Registry - * reads only. - */ + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on this class's host (`protocol-host.ts`) as `this`. + return ProtocolWriteGatesFacet.packagelessOverlayWriteRefusal.call(protocolHost(this), request, operation); + } + + /** Docblock and body: {@link ProtocolWriteGatesFacet.namedBaseOverlayWriteRefusal} in `protocol-write-gates.ts` [#29985 codemod]. */ private namedBaseOverlayWriteRefusal( request: NamedBaseWriteSubject, operation: NamedBaseWriteOperation, ): Error | null { - const host: ThisParameterType = { - engine: this.engine, - overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), - overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), - }; - const closed = namedBaseCloseSubject.call(host, request); - return closed ? namedBaseOverlayWriteRefusal(request, closed, operation) : null; + // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it runs on this class's host (`protocol-host.ts`) as `this`. + return ProtocolWriteGatesFacet.namedBaseOverlayWriteRefusal.call(protocolHost(this), request, operation); } /** @@ -8699,42 +8361,14 @@ export class ObjectStackProtocolImplementation implements return classifyObjectContribution.call(host, name, rowPackageId); } - /** - * [ADR-0029 D9.9] The mis-bound overlay refusal. - * - * The overlay-uniqueness index keys on - * `(type, name, organization_id, COALESCE(package_id, ''))` (ADR-0005 - * amendment, #6825), so `sys_metadata` can legitimately hold two active - * rows for one `(type, name)` bound to two packages. For every other type - * that is fine — two packages really can ship `page/home`. For `object` it - * is not representable: `computeFQN` is identity, so the registry holds - * exactly one entry per object name and could never serve two. Refusing - * the mis-bound row is what keeps the two stores in agreement instead of - * letting `sys_metadata` describe a shape the registry cannot hold. - */ + /** Docblock and body: {@link ProtocolRegistryHydrationFacet.overlayPackageMismatchError} in `protocol-registry-hydration.ts` [#29985 codemod]. */ private static overlayPackageMismatchError( name: string, rowPackageId: string, ownerPackageId: string, /** [#23514] The state the refusal answers, for a `'sys_metadata'` sentinel owner only. */ unboundOwnerState: UnboundOwnerRefusalState = {}, ): Error { - // [#23514] An owner keyed by the sentinel is owned by no package, so - // this prescription's "bind it to the owner" has nothing to name. - if (ownerPackageId === 'sys_metadata') { - return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId, unboundOwnerState); - } - const err: any = new Error( - `Cannot layer object '${name}': the overlay is bound to package ` - + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` - + `An object has exactly one registry entry, so it can carry exactly one overlay layer — bind the ` - + `customization to '${ownerPackageId}', or have '${rowPackageId}' extend the object instead. ` - + `See docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md.`, - ); - err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; - err.status = 422; - err.packageId = rowPackageId; - err.ownerPackageId = ownerPackageId; - err.docs = 'docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md'; - return err; + // [#29985 codemod] The body moved to `protocol-registry-hydration.ts`; it reads this class's statics through `ObjectStackProtocolImplementationStatics`. + return ProtocolRegistryHydrationFacet.overlayPackageMismatchError(name, rowPackageId, ownerPackageId, unboundOwnerState); } /** Docblock and body: {@link ProtocolRegistryHydrationFacet.unboundOwnerPackageRefusal} in `protocol-registry-hydration.ts` [#29985 codemod]. */ @@ -8896,97 +8530,15 @@ export class ObjectStackProtocolImplementation implements return ProtocolRegistryHydrationFacet.hydrateExpandedViewItems(protocolHost(this), type, data, options); } - /** - * [#23198] THE reconcile of a view container's expansions: after any write - * that changes a container's stored row (a publish, a republish, a reset, a - * delete), the registry holds exactly the expansion set the stored row now - * implies. Both sides call it: the registration - * ({@link hydrateExpandedViewItems}, for every hydration of a row) and the - * delete's heal ({@link restoreArtifactRegistryView}, whose set is - * empty). No other code registers an expansion, reads the record, or - * writes it (`protocol.view-expansion-reconcile-call-sites.test.ts`). - * - * In order: - * - * 1. Register each expanded view, unless another package ships its name - * ([#21980, #22057], {@link anotherPackageShips}). Such a name is never - * recorded, so no heal ever touches it. - * 2. Replace the record of the rows the write addresses with the names - * registered in step 1. The old names come out of the record before any - * heal runs, so a write that lands meanwhile reconciles against the new - * set. A rebind (an orphan adoption, {@link reassignOrphanedMetadata}) - * addresses the row under both bindings: the record of the binding it - * left is removed, and its names count as the row's old names. - * 3. Heal each old name the new set lacks through the delete's per-name - * walk ({@link restoreArtifactRegistryView}): a packaged view of that - * name is un-shadowed, a MetadataService baseline is re-registered, and - * a name no layer serves is retired. - * - * Every gate the walk carries applies here too. An organization-scoped - * write registers nothing and heals nothing (#6602 / #6780: the bare - * entries belong to the environment-wide row). Only a kernel whose registry - * takes its stored rows ({@link registryTakesStoredRows}) re-registers a - * baseline. A walk that stops on a degraded baseline read - * retires nothing, and that name stays registered, unrecorded, until a - * restart: there is no retry ([#23149]'s residual, now reached from a - * republish as well as a reset). - * - * Views only, judged on the canonical type: no other type expands, and a - * row of another type stored under a container's name must not heal the - * container's expansions. - */ + /** Docblock and body: {@link ProtocolRegistryHydrationFacet.reconcileViewExpansions} in `protocol-registry-hydration.ts` [#29985 codemod]. */ private async reconcileViewExpansions( type: string, write: ViewContainerWrite, /** The write's own scope: `null` for an env-wide row. [#6780] */ organizationId: string | null, ): Promise { - if (canonicalMetaType(type) !== 'view') return; - if (organizationId !== null && organizationId !== undefined) return; - const registry: any = (this.engine as any)?.registry; - if (!registry || typeof registry !== 'object') return; - - // 1. The set the stored row implies now. A non-empty expansion comes - // only from the hydrator, past its `registerItem` capability probe. - const registered = new Set(); - let shipping: ShippingPackages | undefined; - for (const item of 'expansion' in write ? write.expansion : []) { - shipping ??= this.shippingPackagesOf(type); - if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue; - registry.registerItem(type, item, 'name' as any); - registered.add(String(item.name)); - } - - // 2. The record: the addressed rows' old names out, the new set in. - const record = ObjectStackProtocolImplementation.registeredViewExpansions; - let containers = record.get(registry); - if (containers === undefined) record.set(registry, (containers = new Map())); - const rows = containers.get(write.container) ?? new Map>(); - const previous = new Set(); - if ('removed' in write) { - for (const names of rows.values()) for (const name of names) previous.add(name); - rows.clear(); - } else { - const row = write.binding ?? ''; - for (const name of rows.get(row) ?? []) previous.add(name); - // A rebind: what the row registered under the binding it left is - // its own too, and that record row is gone with the binding. - if (write.reboundFrom !== undefined) { - const left = write.reboundFrom.binding ?? ''; - for (const name of rows.get(left) ?? []) previous.add(name); - rows.delete(left); - } - if (registered.size > 0) rows.set(row, registered); - else rows.delete(row); - } - if (rows.size > 0) containers.set(write.container, rows); - else containers.delete(write.container); - - // 3. Heal what the rows registered before and do not now. - for (const name of previous) { - if (registered.has(name)) continue; - await this.restoreArtifactRegistryView(type, name, organizationId); - } + // [#29985 codemod] The body moved to `protocol-registry-hydration.ts`; it runs on this class's host (`protocol-host.ts`) as `this`. + return ProtocolRegistryHydrationFacet.reconcileViewExpansions.call(protocolHost(this), type, write, organizationId); } /** @@ -12938,6 +12490,24 @@ export class ObjectStackProtocolImplementation implements }; return packageInstallUndo.call(host, manifest); } + + // [#29985 codemod] Fills `ObjectStackProtocolImplementationStatics` (declared above the class) for the facet modules. + static { + Object.defineProperties(ObjectStackProtocolImplementationStatics, { + CODE_ONLY_TYPE_REMEDY: { get: () => ObjectStackProtocolImplementation.CODE_ONLY_TYPE_REMEDY }, + OVERLAY_ALLOWED_TYPES: { get: () => ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES }, + OVERLAY_CAPABLE_TYPES: { get: () => ObjectStackProtocolImplementation.OVERLAY_CAPABLE_TYPES }, + RUNTIME_CREATE_ALLOWED_TYPES: { get: () => ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES }, + STATIC_REGISTRY_TYPES: { get: () => ObjectStackProtocolImplementation.STATIC_REGISTRY_TYPES }, + codeOnlySourceHint: { value: (...args: Parameters) => ObjectStackProtocolImplementation.codeOnlySourceHint(...args) }, + envWritableTypes: { value: (...args: Parameters) => ObjectStackProtocolImplementation.envWritableTypes(...args) }, + organizationScopedWriteRefusal: { value: (...args: Parameters) => ObjectStackProtocolImplementation.organizationScopedWriteRefusal(...args) }, + registeredViewExpansions: { get: () => ObjectStackProtocolImplementation.registeredViewExpansions }, + registryAllowsOverlay: { value: (...args: Parameters) => ObjectStackProtocolImplementation.registryAllowsOverlay(...args) }, + requestedOrganization: { value: (...args: Parameters) => ObjectStackProtocolImplementation.requestedOrganization(...args) }, + unboundOwnerPackageRefusal: { value: (...args: Parameters) => ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(...args) }, + }); + } } /** diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts index c233f1658c3..a553d8980c2 100644 --- a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -299,9 +299,19 @@ describe('[#23198] the write-through and the heal both end in the one reconcile' describe('[#23198] nothing reaches the record or the heal verbs around the reconcile', () => { it('only the reconcile reads or writes the record', () => { const DECLARATION = ' private static readonly registeredViewExpansions ='; + // [#30147] The reconcile's moved body reads the record through the statics + // forwarder, `protocol.ts`'s read-only getter for it (an interface entry and + // one `get`, no `set`): the forwarder carries the reconcile's read, it is no + // reader of its own. + const FORWARDER = [ + " readonly registeredViewExpansions: (typeof ObjectStackProtocolImplementation)['registeredViewExpansions'];", + ' registeredViewExpansions: { get: () => ObjectStackProtocolImplementation.registeredViewExpansions },', + ]; const uses = codeMatches(/\bregisteredViewExpansions\b/) - .filter(({ scanned, offset }) => !scanned.lines[lineOf(scanned, offset)].startsWith(DECLARATION)); + .filter(({ scanned, offset }) => !scanned.lines[lineOf(scanned, offset)].startsWith(DECLARATION)) + .filter(({ scanned, offset }) => !(scanned.file === 'protocol.ts' && FORWARDER.includes(scanned.lines[lineOf(scanned, offset)]))); expect(uses.map((u) => u.member)).toEqual(['reconcileViewExpansions']); + expect(FORWARDER.every((line) => SOURCE.split('\n').includes(line)), 'the forwarder reads the record, read-only').toBe(true); expect(SOURCE.split(DECLARATION).length - 1, 'one declaration').toBe(1); }); diff --git a/scripts/codemod-split-protocol.mjs b/scripts/codemod-split-protocol.mjs index d68581be8f7..f69dac791eb 100644 --- a/scripts/codemod-split-protocol.mjs +++ b/scripts/codemod-split-protocol.mjs @@ -589,7 +589,7 @@ function hostInterfaceEntries(hostText) { * decide, per instance method, where its body goes. Pure: the same texts give * the same plan. */ -function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES } = {}) { +function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, facade = false } = {}) { const { ts, hs } = T; const { sf, checker } = program(ts, HOST_PATH, text); const cls = hs.classOf(sf, CLASS_NAME); @@ -664,7 +664,7 @@ function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER if (sites.sites.some((s) => s.kind === 'constructor')) add('constructor-rewrite'); const names = readNames(ts, ctx, m, new Set(sites.sites.filter((x) => x.kind === 'privateStatic').map((x) => x.start))); for (const r of names.reasons) add(r); - if (scope.bindings.has(name) || freeInHost.has(name)) add('name-taken'); + if (!facade && (scope.bindings.has(name) || freeInHost.has(name))) add('name-taken'); Object.assign(record, { imports: names.imports, moduleRefs: names.moduleRefs, free: names.free }); if (reasons.length > 0) continue; @@ -677,7 +677,11 @@ function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER for (const [member, entry] of moved.hostEntries) { const have = existingEntries.get(member); if (have !== undefined && have !== entry.iface) add(`host-entry-conflict: ${member}`); - if (have === undefined && predicateMembers.has(member)) add(`host-entry-predicate: ${member}`); + if (have === undefined && predicateMembers.has(member)) { + // The facade form writes the predicate's entry typed as the predicate (#30147). + if (facade) moved.hostEntries.set(member, predicateEntry(ts, sf, cls, table, member)); + else add(`host-entry-predicate: ${member}`); + } } if (moved.sites.length === 0) add('no-host-read'); const movedBody = functionBodyOf(ts, moved.fnText); @@ -730,7 +734,7 @@ function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER if (Object.prototype.hasOwnProperty.call(pins, name)) add(`path-reader: ${name}`); const names = readNames(ts, ctx, m, new Set()); for (const r of names.reasons) add(r); - if (scope.bindings.has(name) || freeInHost.has(name)) add('name-taken'); + if (!facade && (scope.bindings.has(name) || freeInHost.has(name))) add('name-taken'); Object.assign(record, { imports: names.imports, moduleRefs: names.moduleRefs, free: names.free }); if (reasons.length > 0) continue; const baseBody = text.slice(m.body.getStart(sf), m.body.getEnd()); @@ -751,7 +755,9 @@ function plan(T, text, hostText, { anchors = SECTION_ANCHORS, pins = PATH_READER // reads and one writes) make every body that needs the writable one stay // only when protocol-host.ts already carries the readonly one — handled per // record above; between movers, the writable entry is the one written. - for (let changed = true; changed;) { + // The facade form (`facade`, the members pass) binds no moved name at module + // scope (a member is `.`), so neither name rule applies to it. + for (let changed = !facade; changed;) { changed = false; const byModule = groupMovers(records); // protocol.ts imports every mover by its name: two movers of one name (an @@ -1309,7 +1315,7 @@ function verifyFacade(T, baseText, headText, baseHostText, headHostText, moduleT * compiler's `noUnusedLocals` counts them; other JSDoc text and `//` comments * do not. */ -function usedImportNames(ts, text) { +function usedImportNames(ts, text, { docs = true } = {}) { const { sf, checker } = program(ts, HOST_PATH, text); const used = new Set(); const isImportDecl = (d) => d.getSourceFile() === sf && (ts.isImportSpecifier(d) || ts.isImportClause(d) || ts.isNamespaceImport(d)); @@ -1331,7 +1337,7 @@ function usedImportNames(ts, text) { }; const visit = (node) => { if (ts.isImportDeclaration(node)) return; - for (const doc of node.jsDoc ?? []) visitDoc(doc); + if (docs) for (const doc of node.jsDoc ?? []) visitDoc(doc); if (ts.isExportDeclaration(node) && node.moduleSpecifier) return; if (ts.isIdentifier(node) && !inJsDoc(ts, node)) { const parent = node.parent; @@ -1655,10 +1661,13 @@ function gitShow(ref, path) { function generatedModulesOnDisk() { const out = new Map(); - for (const [, , file] of SECTION_ANCHORS) { - if (!file) continue; - const p = join(SRC_DIR, file); - if (existsSync(p)) out.set(file, readFileSync(p, 'utf8')); + for (const [, , section] of SECTION_ANCHORS) { + if (!section) continue; + // A section's tables leaf (#30147) is read with it, so the members pass sees it. + for (const file of [section, tablesModuleOf(section)]) { + const p = join(SRC_DIR, file); + if (existsSync(p)) out.set(file, readFileSync(p, 'utf8')); + } } return out; } @@ -1672,12 +1681,14 @@ function generatedModulesOnDisk() { // was split (a return type since spelled, #30054) is moved by THIS pass // instead, on the split file: `--members a,b` names the methods, and each goes // exactly as the first pass would have taken it (`plan`'s stay rules, which -// read the split file as they read an unsplit one; any reason refuses, and the -// pass names what it moves, never decides it). The stub, the host entries, -// the facet member and the docblock rule are the first pass's (`planMove`, -// `insertHostEntries`, `liftFunction`, docblock ownership), and the member goes -// into the facet of its section, in class order. Only instance methods move: -// a private static's facet stub is not a hand-series stub `plan` recognises. +// read the split file as they read an unsplit one; the pass names what it +// moves, never decides it). The stub, the host entries, the facet member and +// the docblock rule are the first pass's (`planMove`, `insertHostEntries`, +// `liftFunction`, docblock ownership), and the member goes into the facet of +// its section, in class order. A method a stay rule holds moves only by one of +// the receiver forms (#30147, below), and only for the reasons those forms +// answer (`RECEIVER_SOLVES`); any other reason refuses. A private static moves +// by those forms, and a private static readonly table to its section's leaf. // // What it grows, and the hand edits it accepts (the rule #30572 sets for the // `engine.ts` pass's `--members`): the receiving module's header gains the @@ -1903,8 +1914,7 @@ function addModuleImports(ts, file, text, wanted, order = () => 0) { * every other byte stays. The inverse of `addModuleImports` on a module whose * own imports are all read. Pure. */ -function pruneModuleImports(ts, file, text) { - const used = usedImportNames(ts, text); +function pruneModuleImports(ts, file, text, used = usedImportNames(ts, text)) { const { decls } = importDeclsOf(ts, file, text); const edits = []; for (const d of decls) { @@ -1936,62 +1946,515 @@ function classPositions(ts, cls) { return pos; } +// ── The receiver forms (#30147) ───────────────────────────────────────────── +// +// E1 rewrites a body (`this.` → `host.`), so it refuses what +// that rewrite cannot carry: a local already named `host`, a private static +// FIELD read through the class name, a body that reads no member (its `host` +// parameter would go unread), and every private static that reads the class +// itself. The two other facade codemods move those bodies UNEDITED, and this +// pass ports their two mechanisms (#30147), for the members E1 refuses only: +// +// - The `.call(host)` receiver. The body moves byte for byte to a facet +// static method whose `this` parameter is the `Pick` of what it reads +// (`this: Pick`), and the stub runs it on the class's host: +// `return .x.call(protocolHost(this), …)`. A member a body reads goes +// through the host as under E1 (a method as a forwarding closure, so it is +// called, never read as a value; a field as a getter, and a setter when the +// body assigns it); nothing in the body is rewritten. +// - The statics forwarder. `ObjectStackProtocolImplementationStatics`, +// declared ahead of the class and filled by a static block at its end, holds +// a forwarder to each static the moved bodies read (a method called on the +// class at call time, a field read when it is read). A facet module imports +// it AS `ObjectStackProtocolImplementation`, so a body that names the class +// reads `ObjectStackProtocolImplementation.x` unedited; a private static that +// reads `this` (the class) runs on it: `.x.call(, …)`. +// +// A body that reads nothing of the class (no `this`, no class name) moves as a +// plain facet member with no host at all, its stub `return .x(…)`, the +// form the first pass gives a private static of that shape. A static table, a +// private static readonly field whose initializer reads only imports, moves its +// initializer to a leaf (`
-tables.ts`): an initializer runs while the +// class is evaluated, so its module must not import `protocol.ts` or a module +// that does (the facet module would, through the forwarder), and the field +// reads its table back by name. A predicate a moved body reaches through the +// host gets an entry typed as the predicate (`(err: unknown): err is Error => +// …`), so the narrowing survives the hop. +// +// The proof is the undo, as for E1, and the texts are equal by construction: +// every stub, facet member, forwarder text, host entry and leaf is exactly what +// this pass writes for the base member, and undoing them (each body and owned +// docblock back, the forwarder and the leaf gone, the facet modules' imports +// pruned) rebuilds the base tree byte for byte. A body moves unedited, so +// `host-split.mjs`'s `checkMove` (identity modulo E1's rewrites) has nothing to +// judge in these forms; the byte equality is that check's strict case. +// +// What a forwarder changes is named here, because it is the one difference a +// body could tell apart: a static reached through it runs with the class as +// `this` (a moved static of a subclass's call would see the class, not the +// subclass); the class's statics are all private, so no subclass reaches one. + +const RECEIVER_CARD = '#30147'; +const STATICS_NAME = `${CLASS_NAME}Statics`; +const STATICS_HOST_NAME = `${CLASS_NAME}StaticsHost`; + +/** The leaf a section's static tables move to, and its holder class: `protocol-write-gates.ts` -> `protocol-write-gates-tables.ts`, `ProtocolWriteGatesTables`. */ +const tablesModuleOf = (moduleFile) => moduleFile.replace(/\.ts$/, '-tables.ts'); +const tablesClassOf = (moduleFile) => facetNameOf(moduleFile).replace(/Facet$/, 'Tables'); + +/** The note a facet module carries above its import of the forwarder AS the class. */ +const ROUTE_NOTE = `// ${MARK} \`${CLASS_NAME}\` here is \`${STATICS_NAME}\` (imported AS the class): forwarders to its statics (${RECEIVER_CARD}).`; + +/** The one-line docblock on a moved table's initializer: the field keeps its own. */ +const tableOneLiner = (name) => `/** The initializer of \`${CLASS_NAME}.${name}\`; the field's declaration there is its contract. */`; + +/** + * A host entry typed as the type predicate it forwards (`x is T`), for a + * member `insertHostEntries` would forward through an untyped closure, which + * drops the predicate: the interface line is the one E1 writes, the factory + * line spells the member's own parameters and predicate. + */ +function predicateEntry(ts, sf, cls, table, member) { + const isStatic = table.statics.has(member); + const m = cls.members.find((x) => ts.isMethodDeclaration(x) && x.body && x.name && ts.isIdentifier(x.name) && x.name.text === member + && hasModifier(ts, x, ts.SyntaxKind.StaticKeyword) === isStatic); + if (!m || !m.type || m.parameters.some((p) => !ts.isIdentifier(p.name) || p.dotDotDotToken || p.initializer || p.getText(sf).includes('\n'))) { + refuse(`${member}: a predicate host entry needs one-line identifier parameters and a spelled predicate`); + } + const params = m.parameters.map((p) => p.getText(sf)).join(', '); + const args = m.parameters.map((p) => p.name.text).join(', '); + return { + iface: isStatic ? ` readonly ${member}: (typeof ${CLASS_NAME})['${member}'];` : ` readonly ${member}: ${CLASS_NAME}['${member}'];`, + factory: ` ${member}: (${params}): ${m.type.getText(sf)} => ${isStatic ? CLASS_NAME : 'protocol'}.${member}(${args}),`, + }; +} + +/** + * What a body reads of the class, for the receiver forms: `self` maps each + * member read through `this` (an instance member for an instance method, a + * static for a static) to `{ read, write, call }`; `statics` maps each static + * read through the class name (and, for a static, through `this`) to `method` + * or `getter`, its forwarder's kind. `refusals` names what neither form + * carries: a bare or indexed `this`, `this.constructor`, `super`, the method's + * `arguments`, a method read as a value, a static written or a static field + * called (the forwarder is read-only and is another receiver), a member the + * class does not declare, and the class named other than as `.`. + */ +function receiverReads(ts, sf, m, table, isStatic) { + const self = new Map(); + const statics = new Map(); + const refusals = []; + const no = (why) => { if (!refusals.includes(why)) refusals.push(why); }; + const isCallee = (n) => ts.isCallExpression(n.parent) && n.parent.expression === n; + const written = (n) => { + const p = n.parent; + if (ts.isBinaryExpression(p) && p.left === n) return p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(p.operator)) return true; + return ts.isDeleteExpression(p); + }; + const routeStatic = (access, how) => { + const name = access.name.text; + const st = table.statics.get(name); + if (!st) { no(`${how}.${name} names no declared static`); return; } + if (written(access)) { no(`it writes the static ${name} (the forwarder is read-only)`); return; } + if (st.kind === 'method' && !isCallee(access)) { no(`it reads the static method ${name} as a value (a forwarder is another function)`); return; } + if (st.kind !== 'method' && isCallee(access)) { no(`it calls the static ${st.kind} ${name} (the receiver would change)`); return; } + statics.set(name, st.kind === 'method' ? 'method' : 'getter'); + }; + const visit = (node) => { + if (node.kind === ts.SyntaxKind.ThisKeyword && T_isMethodThis(ts, node, m)) { + const p = node.parent; + if (!ts.isPropertyAccessExpression(p) || p.expression !== node || !ts.isIdentifier(p.name)) { no('a bare or indexed `this`'); return; } + const name = p.name.text; + if (name === 'constructor') { no('`this.constructor`'); return; } + if (isStatic) { + routeStatic(p, 'this'); + if (statics.has(name)) { + const u = self.get(name) ?? { read: false, write: false, call: false }; + u[isCallee(p) ? 'call' : 'read'] = true; + self.set(name, u); + } + } else { + const member = table.instance.get(name); + if (!member) { no(`\`this.${name}\` names no declared instance member`); return; } + if (table.statics.has(name)) { no(`\`${name}\` is both an instance and a static member`); return; } + if (member.kind === 'method' && !isCallee(p)) { no(`instance method \`${name}\` read as a value`); return; } + const u = self.get(name) ?? { read: false, write: false, call: false }; + u[written(p) ? 'write' : isCallee(p) ? 'call' : 'read'] = true; + self.set(name, u); + } + ts.forEachChild(p, (c) => { if (c !== node) visit(c); }); + return; + } + if (node.kind === ts.SyntaxKind.SuperKeyword) { no('`super`'); return; } + if (ts.isIdentifier(node) && !inJsDoc(ts, node) && !isPropertyNameIdentifier(ts, node)) { + if (node.text === 'arguments') { + let p = node.parent; + while (p && p !== m && (ts.isArrowFunction(p) || !ts.isFunctionLike(p))) p = p.parent; + if (p === m) no('the method\'s `arguments`'); + } + if (node.text === CLASS_NAME) { + const p = node.parent; + if (ts.isPropertyAccessExpression(p) && p.expression === node && ts.isIdentifier(p.name)) routeStatic(p, CLASS_NAME); + else no(`it names ${CLASS_NAME} other than as \`${CLASS_NAME}.\``); + } + } + ts.forEachChild(node, visit); + }; + for (const region of [...(m.typeParameters ?? []), ...m.parameters, m.type].filter(Boolean)) { + const scan = (n) => { + if (n.kind === ts.SyntaxKind.ThisKeyword || (ts.isIdentifier(n) && n.text === CLASS_NAME && !inJsDoc(ts, n))) no('its signature reads the class'); + ts.forEachChild(n, scan); + }; + scan(region); + } + if (m.body) ts.forEachChild(m.body, visit); + return { self, statics, refusals }; +} + +/** host-split's `isMethodThis`, on this script's TypeScript instance: `this` reached from `m` through arrows only. */ +function T_isMethodThis(ts, node, m) { + for (let p = node.parent; p; p = p.parent) { + if (p === m) return true; + if (ts.isArrowFunction(p)) continue; + if (ts.isFunctionLike(p) || ts.isClassLike(p)) return false; + } + return false; +} + +/** The plan's stay reasons a receiver form answers: what E1's rewrite, or a host parameter, could not carry. */ +const RECEIVER_SOLVES = Object.freeze(['closed-set', 'no-host-read', 'static-reads-class', 'indent-irreversible']); + +/** The `{ start, end }` of a member's parameter list text, as the first pass spells it (from the first parameter to the list's end, trimmed). */ +const paramsTextOf = (sf, m) => (m.parameters.length ? sf.text.slice(m.parameters[0].getStart(sf), m.parameters.end).trim() : ''); +const argsOf = (m) => m.parameters.map((p) => (p.dotDotDotToken ? '...' : '') + p.name.getText()).join(', '); +const pickTypeOf = (type, names) => `Pick<${type}, ${names.map((n) => `'${n}'`).join(' | ')}>`; + +/** + * The receiver-form move of one base member (an instance method E1 refuses, or + * a private static that reads the class), or the reasons it cannot move. + * Returns `{ form, name, module, stub, member, ownedDoc, hostEntries, routed, + * imports, moduleRefs, picks, body }`: `form` is `receiver` (an instance body + * run on the host), `statics` (a static body run on the forwarder), or `plain` + * (no receiver); `member` is the facet member's text from its line start; + * `routed` the statics the body reads through the forwarder. Pure. + */ +function receiverMove(T, planned, r) { + const { ts } = T; + const { sf, cls, ctx } = planned; + const m = r.node; + const isStatic = r.isStatic === true; + const held = r.reasons.filter((x) => !RECEIVER_SOLVES.includes(x.split(':')[0])); + if (held.length > 0) return { refusals: held }; + const reads = receiverReads(ts, sf, m, ctx.table, isStatic); + if (reads.refusals.length > 0) return { refusals: reads.refusals.map((x) => `receiver: ${x}`) }; + if (!r.module) return { refusals: ['section-kept'] }; + const facet = facetNameOf(r.module); + const names = [...reads.self.keys()].sort(); + const form = names.length === 0 ? 'plain' : isStatic ? 'statics' : 'receiver'; + const routed = new Map(reads.statics); + const isPrivate = isPrivateMember(ts, m); + const span = isPrivate ? movableDocSpan(ts, sf, m) : null; + const ownedDoc = span ? span.text : null; + const body = sf.text.slice(m.body.getStart(sf), m.body.getEnd()); + const thisParam = form === 'receiver' ? `this: ${pickTypeOf('ProtocolHost', names)}` + : form === 'statics' ? `this: ${pickTypeOf(STATICS_HOST_NAME, names)}` : null; + const params = [thisParam, paramsTextOf(sf, m)].filter(Boolean).join(', '); + const typeParams = m.typeParameters ? `<${sf.text.slice(m.typeParameters.pos, m.typeParameters.end).trim()}>` : ''; + const async = hasModifier(ts, m, ts.SyntaxKind.AsyncKeyword) ? 'async ' : ''; + const doc = ownedDoc ?? (isStatic ? staticOneLiner(r.name) : instanceOneLiner(r.name)); + const member = ` ${doc}\n static ${async}${r.name}${typeParams}(${params})${m.type ? `: ${m.type.getText(sf)}` : ''} ${body}`; + const args = argsOf(m); + const lead = ` // ${MARK} The body moved to \`${r.module}\``; + const stub = '{\n' + (form === 'receiver' + ? `${lead}; it runs on this class's host (\`${HOST_MODULE_BASENAME}\`) as \`this\`.\n return ${facet}.${r.name}.call(protocolHost(this)${args ? `, ${args}` : ''});\n` + : form === 'statics' + ? `${lead}; it runs on \`${STATICS_NAME}\` as \`this\`.\n return ${facet}.${r.name}.call(${STATICS_NAME}${args ? `, ${args}` : ''});\n` + : routed.size > 0 + ? `${lead}; it reads this class's statics through \`${STATICS_NAME}\`.\n return ${facet}.${r.name}(${args});\n` + : `${lead}; it reads nothing of this class.\n return ${facet}.${r.name}(${args});\n`) + ' }'; + const hostEntries = form === 'receiver' ? T.hs.hostEntriesFor({ instance: reads.self, statics: new Set() }, ctx.table, CLASS_NAME) : new Map(); + const predicates = new Set(cls.members.filter((x) => ts.isMethodDeclaration(x) && x.type && ts.isTypePredicateNode(x.type) && !x.type.assertsModifier && x.name && ts.isIdentifier(x.name)).map((x) => x.name.text)); + for (const k of [...hostEntries.keys()]) if (predicates.has(k)) hostEntries.set(k, predicateEntry(ts, sf, cls, ctx.table, k)); + return { + refusals: [], form, name: r.name, module: r.module, isStatic, stub, member, ownedDoc, hostEntries, routed, picks: names, body, + imports: r.imports ?? new Map(), moduleRefs: new Map([...(r.moduleRefs ?? new Map())].filter(([n]) => n !== CLASS_NAME)), + }; +} + +/** The class member `name` the members pass may move: one instance method with a body, one static method with a body, or one static field. */ +function memberNamed(ts, cls, name) { + const found = cls.members.filter((m) => m.name && ts.isIdentifier(m.name) && m.name.text === name + && ((ts.isMethodDeclaration(m) && m.body) || (ts.isPropertyDeclaration(m) && hasModifier(ts, m, ts.SyntaxKind.StaticKeyword)))); + if (found.length !== 1) refuse(`--members names ${name}, which ${CLASS_NAME} declares as ${found.length === 0 ? 'no method with a body and no static field' : 'more than one member'}`); + return found[0]; +} + +/** + * The table move of one private static readonly field: its initializer to the + * section's leaf. Returns `{ name, module, leaf, field (the head field text), + * member (the leaf member text), imports, init }`, or `{ refusals }`. The + * initializer may read the imports of `protocol.ts` and globals only: never + * `this`, the class, a top-level name of `protocol.ts` or a relative import + * (the leaf imports nothing that could import `protocol.ts`). + */ +function tableMove(T, planned, f, sectionOf) { + const { ts } = T; + const { sf, ctx } = planned; + const name = f.name.text; + const refusals = []; + if (!hasModifier(ts, f, ts.SyntaxKind.PrivateKeyword) || !hasModifier(ts, f, ts.SyntaxKind.ReadonlyKeyword) || !f.initializer) refusals.push('table: only a private static readonly field with an initializer moves'); + const section = sectionOf(f); + if (!section.module) refusals.push('section-kept'); + if (refusals.length > 0) return { refusals }; + const scan = (n) => { + if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword) refusals.push('table: its initializer reads `this` or `super`'); + ts.forEachChild(n, scan); + }; + scan(f.initializer); + const names = readNames(ts, ctx, { typeParameters: undefined, parameters: [], type: f.type, body: f.initializer }); + for (const r of names.reasons) refusals.push(r); + for (const [n] of names.moduleRefs) refusals.push(`table: its initializer reads ${n}, a top-level name of ${HOST_BASENAME} (the leaf never imports it)`); + for (const [local] of names.imports) { + const b = ctx.scope.bindings.get(local); + if (b && b.kind === 'import' && b.specifier.startsWith('.')) refusals.push(`table: its initializer reads ${local}, a relative import (the leaf imports packages only)`); + } + if (refusals.length > 0) return { refusals: [...new Set(refusals)] }; + const init = sf.text.slice(f.initializer.getStart(sf), f.initializer.getEnd()); + const head = sf.text.slice(f.getStart(sf), f.initializer.getStart(sf)); + const tail = sf.text.slice(f.initializer.getEnd(), f.getEnd()); + const holder = tablesClassOf(section.module); + return { + refusals: [], name, module: section.module, title: section.title, leaf: tablesModuleOf(section.module), init, imports: names.imports, + field: `${head}${holder}.${name}${tail}`, + member: ` ${tableOneLiner(name)}\n static readonly ${name}${f.type ? `: ${f.type.getText(sf)}` : ''} = ${init};`, + start: f.initializer.getStart(sf), end: f.initializer.getEnd(), + }; +} + +/** The union of name -> `{ value }` maps, a name read as a value in any one read as a value. */ +function mergeUses(maps) { + const out = new Map(); + for (const m of maps) for (const [n, u] of m) out.set(n, { value: Boolean(out.get(n)?.value) || u.value }); + return new Map([...out].sort(byName)); +} + +/** The leaf a section's tables move to: the header, the initializers' imports, and one holder class. Pure. */ +function tablesModuleText(T, scope, moves) { + const { title, leaf } = moves[0]; + const holder = tablesClassOf(moves[0].module); + const names = moves.map((t, i) => `\`${t.name}\`${i === moves.length - 1 ? '' : ','}`); + const out = [ + '// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.', + '', + '/**', + ...wrapComment([MARK, 'The', 'static', 'tables', 'of', 'the', ...`"${title}"`.split(' '), 'section', 'of', `\`${CLASS_NAME}\`:`, + 'the', 'initializers', 'of', ...names, 'moved', 'out', 'of', '`protocol.ts`', 'whole', 'by', '`scripts/codemod-split-protocol.mjs', '--members`', `(${RECEIVER_CARD}).`]), + ' *', + ' * A MOVE, not a redesign. Each initializer below is the one the class field of', + ' * the same name carried, byte for byte; the field keeps its docblock,', + ' * modifiers and type, and reads its table from here. An initializer runs', + ' * while its class is evaluated, so this module imports neither `protocol.ts`', + ' * nor any module that does: only what the initializers read, from where', + ' * `protocol.ts` imports it. Nothing re-exports this module, so the published', + ' * surface does not move.', + ' */', + '', + ...importLines(scope, [{ isStatic: true, imports: mergeUses(moves.map((t) => t.imports)), moduleRefs: new Map() }]), + '', + `export class ${holder} {`, + moves.map((t) => t.member).join('\n\n'), + '}', + '', + ]; + return out.join('\n'); +} + +/** The forwarder `protocol.ts` carries for `kinds` (static -> `method` | `getter`): the text ahead of the class, and the static block at its end. Pure. */ +function staticsTexts(kinds) { + const members = [...kinds.keys()].sort(); + const ahead = [ + `// ${MARK} The statics of \`${CLASS_NAME}\` its facet modules read (${RECEIVER_CARD}), private ones`, + '// included, typed as the class declares them (an indexed access on the class). The static', + `// block at the end of the class fills \`${STATICS_NAME}\` with forwarders: a method is`, + '// called on the class at call time, a field is read when the body reads it. A facet module', + `// imports it AS \`${CLASS_NAME}\`, so a moved body reads \`${CLASS_NAME}.x\``, + '// unedited, and a moved static that reads `this` runs on it. `index.ts` re-exports neither, so', + '// neither reaches the package.', + `export interface ${STATICS_HOST_NAME} {`, + ...members.map((n) => ` readonly ${n}: (typeof ${CLASS_NAME})['${n}'];`), + '}', + `export const ${STATICS_NAME} = {} as ${STATICS_HOST_NAME};`, + ].join('\n'); + const block = [ + ` // ${MARK} Fills \`${STATICS_NAME}\` (declared above the class) for the facet modules.`, + ' static {', + ` Object.defineProperties(${STATICS_NAME}, {`, + ...members.map((n) => (kinds.get(n) === 'method' + ? ` ${n}: { value: (...args: Parameters) => ${CLASS_NAME}.${n}(...args) },` + : ` ${n}: { get: () => ${CLASS_NAME}.${n} },`)), + ' });', + ' }', + ].join('\n'); + return { ahead, block }; +} + +/** + * Where the forwarder stands in a `protocol.ts`: the statics it carries (static + * -> kind, read off its static block) and the spans of its two texts, or null + * when it carries none. Refuses half of one. + */ +function staticsOf(ts, sf) { + const text = sf.text; + const cls = sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME); + const iface = sf.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === STATICS_HOST_NAME); + const blockOpen = ` // ${MARK} Fills \`${STATICS_NAME}\``; + const at = cls ? text.indexOf(blockOpen, cls.getStart(sf)) : -1; + if (!iface && at < 0) return null; + if (!iface || at < 0 || !cls) refuse(`${HOST_BASENAME} carries half of the ${STATICS_NAME} forwarder`); + const kinds = new Map(); + const blockEnd = text.indexOf('\n });\n }', at); + if (blockEnd < 0) refuse(`${HOST_BASENAME}: the ${STATICS_NAME} static block does not close as written`); + for (const l of text.slice(at, blockEnd).split('\n')) { + const hit = /^ {12}([A-Za-z_$][\w$]*): \{ (value|get)/.exec(l); + if (hit) kinds.set(hit[1], hit[2] === 'value' ? 'method' : 'getter'); + } + const aheadStart = text.indexOf(`// ${MARK} The statics of \`${CLASS_NAME}\``); + const constDecl = `export const ${STATICS_NAME} = {} as ${STATICS_HOST_NAME};`; + const aheadEnd = text.indexOf(constDecl, aheadStart) + constDecl.length; + if (aheadStart < 0 || aheadEnd < constDecl.length) refuse(`${HOST_BASENAME}: the ${STATICS_NAME} declarations are not as written`); + // The block was written as `\n\n` after the class's last member. + return { kinds, ahead: { start: aheadStart, end: aheadEnd }, block: { start: at - 2, end: blockEnd + '\n });\n }'.length } }; +} + +/** Where the forwarder's two texts go in a `protocol.ts` that carries none: ahead of the class's leading comments, and after its last member. */ +function staticsSlots(ts, sf, cls) { + const ranges = ts.getLeadingCommentRanges(sf.text, cls.getFullStart()) ?? []; + const ahead = ranges.length > 0 ? lineStartOf(sf.text, ranges[0].pos) : lineStartOf(sf.text, cls.getStart(sf)); + return { ahead, block: cls.members.at(-1).getEnd() }; +} + +/** `protocol.ts`'s import of a section's tables leaf, written after the facet's own import. */ +const tablesImportText = (moduleFile) => `\n// ${MARK} The static tables of the facet above: the class's fields read them (${RECEIVER_CARD}).\n` + + `import { ${tablesClassOf(moduleFile)} } from './${tablesModuleOf(moduleFile).replace(/\.ts$/, '.js')}';`; + /** * Plan and apply the moves `names` asks for on a split `protocol.ts`. * Returns `{ host, hostModule, modules, records, pruned }` (`modules`: the - * receiving modules' new texts). Refuses (exit 2) on a name the class does not - * declare as one instance method with a body, one already moved, one any stay - * rule holds, and every shape the section above says this pass does not grow. - * Pure. + * receiving modules' new texts, a tables leaf included). Refuses (exit 2) on a + * name the class does not declare as one method with a body or one static + * field, one already moved, one a stay rule holds that no receiver form + * carries (#30147), and every shape the section above says this pass does not + * grow. Pure. */ function generateMembers(T, text, hostText, moduleTexts, names, { anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES } = {}) { const { ts } = T; globalTs = ts; - if (!names || names.length === 0) refuse(`--members needs a comma-separated list of ${CLASS_NAME} method names`); - if (new Set(names).size !== names.length) refuse(`--members names a method twice: ${names.join(', ')}`); + if (!names || names.length === 0) refuse(`--members needs a comma-separated list of ${CLASS_NAME} member names`); + if (new Set(names).size !== names.length) refuse(`--members names a member twice: ${names.join(', ')}`); if (!text.includes(MARK)) refuse(`${HOST_BASENAME} carries no ${MARK} stub: run the first pass on an unsplit file`); - const planned = plan(T, text, hostText, { anchors, pins, needles }); + const planned = plan(T, text, hostText, { anchors, pins, needles, facade: true }); const { sf, cls, ctx } = planned; + const sectionOf = sectionTable(ts, sf, cls, anchors); + const existingEntries = hostInterfaceEntries(hostText); const records = []; + const tables = []; for (const name of names) { - const found = cls.members.filter((m) => ts.isMethodDeclaration(m) && m.body && m.name && ts.isIdentifier(m.name) && m.name.text === name && !hasModifier(ts, m, ts.SyntaxKind.StaticKeyword)); - if (found.length !== 1) refuse(`--members names ${name}, which ${CLASS_NAME} declares as ${found.length === 0 ? 'no instance method with a body' : 'more than one method'}`); - const r = planned.records.find((x) => x.node === found[0]); - if (r.reasons.some((x) => x.startsWith('already-moved'))) refuse(`${name} is already moved: its body is a stub`); - if (r.reasons.length > 0) refuse(`${name} stays on the class, as the first pass would keep it: ${r.reasons.map((x) => `${x} (${STAY_REASONS[x.split(':')[0]] ?? x})`).join('; ')}`); + const node = memberNamed(ts, cls, name); + if (ts.isPropertyDeclaration(node)) { + const t = tableMove(T, planned, node, sectionOf); + if (t.refusals.length > 0) refuse(`${name} stays on the class: ${t.refusals.join('; ')}`); + tables.push({ ...t, node }); + continue; + } + const r = planned.records.find((x) => x.node === node); + if (!r) refuse(`--members names ${name}, which the plan does not read as a method with a body`); + if (r.reasons.some((x) => x.startsWith('already-moved')) || node.body.getText(sf).includes(MARK)) refuse(`${name} is already moved: its body is a stub`); + if (!r.isStatic && r.reasons.length === 0) { records.push(r); r.form = 'e1'; continue; } + const mv = receiverMove(T, planned, r); + if (mv.refusals.length > 0) { + refuse(`${name} stays on the class, as the first pass would keep it: ${r.reasons.map((x) => `${x} (${STAY_REASONS[x.split(':')[0]] ?? x})`).join('; ')}; ` + + `and no receiver form (${RECEIVER_CARD}) carries it: ${mv.refusals.join('; ')}`); + } + Object.assign(r, { form: mv.form, mv }); records.push(r); } records.sort((a, b) => a.node.getStart(sf) - b.node.getStart(sf)); + tables.sort((a, b) => a.node.getStart(sf) - b.node.getStart(sf)); const pos = classPositions(ts, cls); - // protocol.ts: each body becomes its lifted stub, a private docblock its link. + // protocol.ts: each body becomes its stub, a private docblock its link. const edits = []; const entries = new Map(); const modules = new Map(); + const routed = new Map(); for (const r of records) { const facet = facetNameOf(r.module); const b = ctx.scope.bindings.get(facet); if (!b || b.kind !== 'import' || b.specifier !== `./${r.module.replace(/\.ts$/, '.js')}`) refuse(`${r.module}: ${HOST_BASENAME} does not import its facet ${facet} (this pass never starts a facet)`); - for (const [n] of r.moduleRefs) { + const refs = r.form === 'e1' ? r.moduleRefs : r.mv.moduleRefs; + for (const [n] of refs) { if (n !== CLASS_NAME && !ctx.scope.exported.has(n)) refuse(`${r.name} reads ${n}, a top-level name of ${HOST_BASENAME} it does not export: this pass does not widen the generated export list`); } - edits.push({ start: r.stub.start, end: r.stub.end, text: facetStubBody(r.name, r.module, r.node) }); + edits.push({ start: r.stub?.start ?? r.node.body.getStart(sf), end: r.stub?.end ?? r.node.body.getEnd(), text: r.form === 'e1' ? facetStubBody(r.name, r.module, r.node) : r.mv.stub }); r.ownedDoc = null; - if (isPrivateMember(ts, r.node)) { + if (r.form !== 'e1') r.ownedDoc = r.mv.ownedDoc; + else if (isPrivateMember(ts, r.node)) r.ownedDoc = movableDocSpan(ts, sf, r.node)?.text ?? null; + if (r.ownedDoc) { const span = movableDocSpan(ts, sf, r.node); - if (span) { r.ownedDoc = span.text; edits.push({ start: span.start, end: span.end, text: docStubOf(facet, r.name, r.module) }); } + edits.push({ start: span.start, end: span.end, text: docStubOf(facet, r.name, r.module) }); } - for (const [member, entry] of r.hostEntries) { + for (const [member, entry] of (r.form === 'e1' ? r.hostEntries : r.mv.hostEntries)) { + const have = existingEntries.get(member); + if (have !== undefined && have !== entry.iface) refuse(`${r.name} needs the host entry ${entry.iface.trim()}, and ${HOST_MODULE_BASENAME} carries ${have.trim()}`); const prev = entries.get(member); if (!prev || (entry.factory.includes('\n') && !prev.factory.includes('\n'))) entries.set(member, entry); } + if (r.form !== 'e1') { + for (const [n, kind] of r.mv.routed) { + if (routed.has(n) && routed.get(n) !== kind) refuse(`${n}: two moved bodies read it as different kinds`); + routed.set(n, kind); + } + } modules.set(r.module, [...(modules.get(r.module) ?? []), r]); } + + // The forwarder: what the class already forwards, and what the moved bodies read. + const have = staticsOf(ts, sf); + const kinds = new Map(have?.kinds ?? []); + for (const [n, kind] of routed) { + if (kinds.has(n) && kinds.get(n) !== kind) refuse(`${STATICS_NAME} forwards ${n} as a ${kinds.get(n)}, and a moved body reads it as a ${kind}`); + kinds.set(n, kind); + } + if (kinds.size > (have?.kinds.size ?? 0)) { + const t = staticsTexts(new Map([...kinds].sort(byName))); + if (have) { + edits.push({ start: have.ahead.start, end: have.ahead.end, text: t.ahead }); + edits.push({ start: have.block.start, end: have.block.end, text: `\n\n${t.block}` }); + } else { + const slots = staticsSlots(ts, sf, cls); + edits.push({ start: slots.ahead, end: slots.ahead, text: `${t.ahead}\n\n` }); + edits.push({ start: slots.block, end: slots.block, text: `\n\n${t.block}` }); + } + } + + // The tables: each initializer to its section's leaf, the leaf imported once after its facet. + const leaves = new Map(); + for (const t of tables) leaves.set(t.leaf, [...(leaves.get(t.leaf) ?? []), t]); + const moduleOut = new Map(); + for (const [leaf, moves] of leaves) { + if (moduleTexts.has(leaf)) refuse(`${leaf} exists: this pass writes a section's tables leaf whole, once`); + for (const t of moves) edits.push({ start: t.start, end: t.end, text: `${tablesClassOf(t.module)}.${t.name}` }); + const facetImport = sf.statements.find((s) => ts.isImportDeclaration(s) && s.moduleSpecifier.text === `./${moves[0].module.replace(/\.ts$/, '.js')}`); + if (!facetImport) refuse(`${moves[0].module}: ${HOST_BASENAME} does not import its facet (this pass never starts a facet)`); + edits.push({ start: facetImport.getEnd(), end: facetImport.getEnd(), text: tablesImportText(moves[0].module) }); + moduleOut.set(leaf, tablesModuleText(T, ctx.scope, moves)); + } + const inserted = T.hs.insertHostEntries(hostText, applyEdits(text, edits), new Map([...entries].sort(byName))); const pruned = pruneImports(ts, inserted.protocol, usedImportNames(ts, inserted.protocol)); // The receiving modules: the members in class order, the header's names, the imports. - const moduleOut = new Map(); for (const [file, movers] of modules) { const mod = moduleTexts.get(file); if (mod == null || !mod.includes(MARK)) refuse(`${file} is not on disk as a facet module the first pass wrote: this pass adds to a written facet, never starts one`); @@ -2003,7 +2466,7 @@ function generateMembers(T, text, hostText, moduleTexts, names, { anchors = SECT for (const r of movers) { const at = r.node.getStart(sf); const next = facet.members.find((m) => m.name && pos.has(m.name) && pos.get(m.name) > at); - const t = facetMemberText(ts, r.fnText, r.ownedDoc); + const t = r.form === 'e1' ? facetMemberText(ts, r.fnText, r.ownedDoc) : r.mv.member; if (next) modEdits.push({ start: next.start, end: next.start, text: `${t}\n\n` }); else modEdits.push({ start: facet.members.at(-1).end, end: facet.members.at(-1).end, text: `\n\n${t}` }); } @@ -2019,18 +2482,33 @@ function generateMembers(T, text, hostText, moduleTexts, names, { anchors = SECT wanted.set(local, { ...w, value: Boolean(prev && prev.value) || w.value }); }; for (const r of movers) { - for (const [local, use] of r.imports) { + const imports = r.form === 'e1' ? r.imports : r.mv.imports; + for (const [local, use] of imports) { const b = ctx.scope.bindings.get(local); if (!b || b.kind !== 'import') refuse(`import binding ${local} not found`); want(local, { specifier: b.specifier, form: b.form, imported: b.imported, value: use.value && !b.typeOnly }); } - for (const [n, use] of r.moduleRefs) want(n, { specifier: './protocol.js', form: 'named', imported: n, value: use.value }); - want('ProtocolHost', { specifier: './protocol-host.js', form: 'named', imported: 'ProtocolHost', value: false }); + for (const [n, use] of (r.form === 'e1' ? r.moduleRefs : r.mv.moduleRefs)) want(n, { specifier: './protocol.js', form: 'named', imported: n, value: use.value }); + if (r.form === 'e1' || r.form === 'receiver') want('ProtocolHost', { specifier: './protocol-host.js', form: 'named', imported: 'ProtocolHost', value: false }); + if (r.form !== 'e1' && r.mv.routed.size > 0) want(CLASS_NAME, { specifier: './protocol.js', form: 'named', imported: STATICS_NAME, value: true }); + if (r.form === 'statics') want(STATICS_HOST_NAME, { specifier: './protocol.js', form: 'named', imported: STATICS_HOST_NAME, value: false }); } const order = (s) => (ctx.scope.importOrder.has(s) ? ctx.scope.importOrder.get(s) : Number.MAX_SAFE_INTEGER); - moduleOut.set(file, addModuleImports(ts, file, applyEdits(mod, modEdits), wanted, order)); + moduleOut.set(file, withRouteNote(ts, file, addModuleImports(ts, file, applyEdits(mod, modEdits), wanted, order))); } - return { host: pruned.text, hostModule: inserted.host, modules: moduleOut, records, pruned: pruned.removed }; + return { host: pruned.text, hostModule: inserted.host, modules: moduleOut, records: [...records, ...tables], pruned: pruned.removed }; +} + +/** A facet module that imports the forwarder AS the class carries `ROUTE_NOTE` on the line above that import; one that does not, carries none. */ +function withRouteNote(ts, file, text) { + const routedImport = importDeclsOf(ts, file, text).decls.find((d) => d.named.some((e) => e.local === CLASS_NAME && e.imported === STATICS_NAME)); + const has = text.includes(`${ROUTE_NOTE}\n`); + if (routedImport && !has) { + const at = lineStartOf(text, routedImport.start); + return `${text.slice(0, at)}${ROUTE_NOTE}\n${text.slice(at)}`; + } + if (!routedImport && has) return text.replace(`${ROUTE_NOTE}\n`, ''); + return text; } /** @@ -2040,7 +2518,7 @@ function generateMembers(T, text, hostText, moduleTexts, names, { anchors = SECT * more. Returns the text, or null (after `fail`) when the head is not a module * this pass can read. */ -function undoMembersModule(ts, file, text, names, title, fail) { +function undoMembersModule(ts, file, text, names, title, fail, baseBound = null) { let out = text; for (const name of names) { const facet = facetOf(ts, file, out); @@ -2060,14 +2538,44 @@ function undoMembersModule(ts, file, text, names, title, fail) { if (!header) { fail(`${file}: its header's first paragraph is not the one the first pass writes`); return null; } for (const n of names) if (!header.names.includes(n)) fail(`${file}: its header does not list ${n}`); out = out.slice(0, header.start) + wrapComment(headerWords(title, header.names.filter((n) => !names.includes(n)))).join('\n') + out.slice(header.end); - return pruneModuleImports(ts, file, out); + // A binding only a docblock's link reads is dropped too unless the base binds + // it (#30147): a link elsewhere in the module to a name a moved body imports + // would otherwise keep that import alive. Which names the base binds is the + // one fact this undo reads off the base, as the host entries' undo does. + const code = usedImportNames(ts, out, { docs: false }); + const all = usedImportNames(ts, out); + const used = baseBound ? new Set([...all].filter((n) => code.has(n) || baseBound.has(n))) : all; + return withRouteNote(ts, file, pruneModuleImports(ts, file, out, used)); +} + +/** The local names a module's import declarations bind. */ +function importBoundNames(ts, file, text) { + const out = new Set(); + for (const d of importDeclsOf(ts, file, text).decls) for (const n of [d.defaultName, d.nsName, ...d.named.map((e) => e.local)]) if (n) out.add(n); + return out; +} + +/** A host block's keyed groups, each the text of its lines (a setter is its getter's continuation). */ +function hostGroups(text, openMarker, closeMarker, keyOf, isContinuation) { + const open = text.indexOf(openMarker); + const close = text.indexOf(closeMarker, open); + const out = new Map(); + if (open < 0 || close < 0) return out; + let key; + for (const l of text.slice(open + openMarker.length, close).split('\n')) { + const k = keyOf(l); + if (k !== undefined) { key = k; out.set(k, l); } else if (key !== undefined && isContinuation(l)) out.set(key, `${out.get(key)}\n${l}`); + else key = undefined; + } + return out; } /** * Prove this pass's moves against the pre-move tree, from the texts alone (it * shares no state with `generateMembers`). `base` / `head`: `{ text, host, - * modules }` (`protocol.ts`, `protocol-host.ts`, the section modules by file). - * Returns `{ failures, moved, checks, pruned, linesBefore, linesAfter }`. + * modules }` (`protocol.ts`, `protocol-host.ts`, the section modules and the + * tables leaves by file). Returns `{ failures, moved, checks, pruned, + * linesBefore, linesAfter, forms }`. */ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES } = {}) { const { ts, hs } = T; @@ -2076,15 +2584,23 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R const fail = (msg) => failures.push(msg); const moved = []; const checks = []; + const tables = []; const baseSf = parseSourceFile(HOST_PATH, base.text); const headSf = parseSourceFile(HOST_PATH, head.text); const baseCls = hs.classOf(baseSf, CLASS_NAME); const headCls = hs.classOf(headSf, CLASS_NAME); - const done = (pruned = []) => ({ failures, moved: moved.map((mv) => mv.name), checks, pruned, linesBefore: lineCount(base.text), linesAfter: lineCount(head.text) }); + const forms = () => { + const n = new Map(); + for (const mv of moved) n.set(mv.form ?? '?', (n.get(mv.form ?? '?') ?? 0) + 1); + if (tables.length) n.set('table', tables.length); + return Object.fromEntries(n); + }; + const done = (pruned = []) => ({ failures, moved: [...moved.map((mv) => mv.name), ...tables.map((t) => t.name)], checks, pruned, linesBefore: lineCount(base.text), linesAfter: lineCount(head.text), forms: forms() }); if (!baseCls || !headCls) { fail(`no class ${CLASS_NAME} at base or head`); return done(); } const marker = new RegExp(`^\\{\\s*// ${MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(protocol-[a-z0-9-]+\\.ts)\``); const key = (m) => `${hasModifier(ts, m, ts.SyntaxKind.StaticKeyword) ? 'static ' : ''}${m.name && ts.isIdentifier(m.name) ? m.name.text : ts.SyntaxKind[m.kind]}`; const methodsOf = (cls) => new Map(cls.members.filter((m) => ts.isMethodDeclaration(m) && m.body).map((m) => [key(m), m])); + const fieldsOf = (cls) => new Map(cls.members.filter((m) => ts.isPropertyDeclaration(m) && hasModifier(ts, m, ts.SyntaxKind.StaticKeyword) && m.name && ts.isIdentifier(m.name)).map((m) => [m.name.text, m])); const baseMethods = methodsOf(baseCls); const headMethods = methodsOf(headCls); if ([...baseMethods.keys()].join() !== [...headMethods.keys()].join()) fail(`${CLASS_NAME} does not declare the same methods in the same order at base and head`); @@ -2095,21 +2611,38 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R const bHit = marker.exec(bm.body.getText(baseSf)); if (bHit && !hHit) fail(`${k}: a stub at base is a body at head`); if (!hHit || bHit) continue; - if (k.startsWith('static ')) { fail(`${k}: a static's body moved (this pass moves instance methods only)`); continue; } - moved.push({ name: hm.name.text, hm, bm, file: hHit[1] }); - } - if (moved.length === 0) fail('nothing moved: the head carries no stub its base did not'); + moved.push({ name: hm.name.text, isStatic: k.startsWith('static '), hm, bm, file: hHit[1] }); + } + const basePlan = plan(T, base.text, base.host, { anchors, pins, needles, facade: true }); + const sectionOf = sectionTable(ts, baseSf, basePlan.cls, anchors); + const baseFields = fieldsOf(basePlan.cls); + const headFields = fieldsOf(headCls); + if ([...baseFields.keys()].join() !== [...headFields.keys()].join()) fail(`${CLASS_NAME} does not declare the same static fields in the same order at base and head`); + for (const [name, hf] of headFields) { + const bf = baseFields.get(name); + if (!bf || hf.getText(headSf) === bf.getText(basePlan.sf)) continue; + const t = tableMove(T, basePlan, bf, sectionOf); + if (t.refusals.length > 0) { fail(`${name}: its field changed, and it is no table this pass moves (${t.refusals.join('; ')})`); continue; } + if (hf.getText(headSf) !== t.field) fail(`${name}: its field is not the base field reading its table from ${t.leaf}`); + tables.push({ ...t, hf }); + } + if (moved.length === 0 && tables.length === 0) fail('nothing moved: the head carries no stub or table its base did not'); - const basePlan = plan(T, base.text, base.host, { anchors, pins, needles }); const headPrivateStatics = new Set([...hs.memberTable(headCls).statics].filter(([, s]) => s.private).map(([n]) => n)); + const expectedEntries = new Map(); + const routed = new Map(); for (const mv of moved) { const { name, hm, bm, file } = mv; - const r = basePlan.records.find((x) => x.node.name?.text === name && !x.isStatic); + const r = basePlan.records.find((x) => x.node.name?.text === name && Boolean(x.isStatic) === mv.isStatic); if (!r) { fail(`${name}: no record of it at base`); continue; } - if (r.reasons.length > 0) fail(`${name}: a stay rule holds it at base (${r.reasons.join('; ')})`); if (r.module !== file) { fail(`${name}: its stub names ${file}, and its section's module is ${r.module ?? 'none'}`); continue; } mv.title = r.section; - if (hm.body.getText(headSf) !== facetStubBody(name, file, bm)) fail(`${name}: the stub is not the first pass's lifted stub`); + const e1 = !mv.isStatic && r.reasons.length === 0; + const rm = e1 ? null : receiverMove(T, basePlan, r); + if (rm && rm.refusals.length > 0) { fail(`${name}: a stay rule holds it at base, and no receiver form carries it (${[...r.reasons, ...rm.refusals].join('; ')})`); continue; } + mv.form = e1 ? 'e1' : rm.form; + const wantStub = e1 ? facetStubBody(name, file, bm) : rm.stub; + if (hm.body.getText(headSf) !== wantStub) fail(`${name}: the stub is not the one this pass writes for its form (${mv.form})`); const facet = facetNameOf(file); const span = isPrivateMember(ts, bm) ? movableDocSpan(ts, baseSf, bm) : null; mv.ownedDoc = span ? span.text : null; @@ -2124,8 +2657,22 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R if (!fm) { fail(`${name}: ${file} at head holds no facet member ${name}`); continue; } mv.fm = fm; mv.modText = modText; - if (r.reasons.length === 0 && modText.slice(fm.start, fm.end) !== facetMemberText(ts, r.fnText, mv.ownedDoc)) { - fail(`${name}: the facet member is not its base method's text as the first pass lifts it (docblock, parameters, return type, body)`); + const wantMember = e1 ? facetMemberText(ts, r.fnText, mv.ownedDoc) : rm.member; + if (modText.slice(fm.start, fm.end) !== wantMember) { + fail(`${name}: the facet member is not its base method's text as this pass writes it (${mv.form}: docblock, parameters, return type, body)`); + } + for (const [k, e] of (e1 ? r.hostEntries : rm.hostEntries)) if (!expectedEntries.has(k) || e.factory.includes('\n')) expectedEntries.set(k, e); + if (!e1) { + for (const [n, kind] of rm.routed) routed.set(n, kind); + // The body moved unedited: its undo is the facet member's body, byte for byte. + mv.restored = modText.slice(fm.node.body.getStart(fo.sf), fm.node.body.getEnd()); + if (mv.restored !== base.text.slice(bm.body.getStart(baseSf), bm.body.getEnd())) fail(`${name}: its moved body is not the base body, byte for byte`); + const thisParam = fm.node.parameters[0]; + const pk = thisParam && ts.isIdentifier(thisParam.name) && thisParam.name.text === 'this' + ? /^this: Pick<(\w+), (.+)>$/s.exec(modText.slice(thisParam.getStart(fo.sf), thisParam.getEnd())) : null; + mv.picks = pk && pk[1] === 'ProtocolHost' ? pk[2].split('|').map((q) => q.trim().slice(1, -1)) : []; + checks.push({ name, ok: true, form: mv.form, counts: null }); + continue; } const fnText = unliftMethod(ts, modText.slice(fm.node.getStart(fo.sf), fm.end)); const fnBody = fnText === null ? null : functionBodyOf(ts, fnText); @@ -2143,27 +2690,37 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R ]) : head.text; const c = hs.checkMove({ baseSource: base.text, headSource: headA, moduleSource: `${fnText}\n`, moduleFile: file, member: name, className: CLASS_NAME }); - checks.push({ name, ok: c.ok, counts: c.counts }); + checks.push({ name, ok: c.ok, form: 'e1', counts: c.counts }); for (const p of c.problems) fail(`host-split check ${name}: ${p}`); } - // The modules: each receiving one undoes to its base; every other is its base. + // The modules: each receiving one undoes to its base; a tables leaf is its generator's text; every other is its base. const touched = new Map(); for (const mv of moved) if (mv.fm) touched.set(mv.file, [...(touched.get(mv.file) ?? []), mv]); + const leaves = new Map(); + for (const t of tables) leaves.set(t.leaf, [...(leaves.get(t.leaf) ?? []), t]); for (const file of new Set([...base.modules.keys(), ...head.modules.keys()])) { const b = base.modules.get(file) ?? null; const h = head.modules.get(file) ?? null; + if (leaves.has(file)) { + if (b !== null) fail(`${file}: a tables leaf at base (this pass writes one whole, once)`); + if (h !== tablesModuleText(T, basePlan.ctx.scope, leaves.get(file))) fail(`${file}: not the leaf this pass writes for the tables it holds`); + continue; + } if (!touched.has(file)) { if (b !== h) fail(`${file}: changed, and no member this pass moved is in it`); continue; } if (b === null || !b.includes(MARK)) { fail(`${file}: not a facet module at base (this pass never starts a facet)`); continue; } const mvs = touched.get(file); - const u = undoMembersModule(ts, file, h, mvs.map((mv) => mv.name), mvs[0].title, fail); + if (h !== null && withRouteNote(ts, file, h) !== h) fail(`${file}: its import of ${STATICS_NAME} AS the class does not carry the note above it, as this pass writes it (or the note stands alone)`); + const u = undoMembersModule(ts, file, h, mvs.map((mv) => mv.name), mvs[0].title, fail, importBoundNames(ts, file, b)); if (u !== null && u !== b) fail(`undoing the moves out of ${file} does not rebuild its base (first difference at line ${firstDiff(u, b)})`); } - // protocol.ts: each body and owned docblock back; the factory loses the entries the base did not carry. + // protocol.ts: each body and owned docblock back, each table's initializer back + // (read off its leaf at head), the forwarder and the leaves' imports gone; the + // factory loses the entries the base did not carry. const edits = []; for (const mv of moved) { if (!mv.fm || mv.restored === undefined) continue; @@ -2173,12 +2730,48 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R if (span) edits.push({ start: span.start, end: span.end, text: mv.ownedDoc }); } } + for (const [leaf, ts_] of leaves) { + const leafText = head.modules.get(leaf) ?? ''; + const leafSf = parseSourceFile(join(SRC_DIR, leaf), leafText); + const holder = leafSf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === tablesClassOf(ts_[0].module)); + for (const t of ts_) { + const lm = holder?.members.find((m) => ts.isPropertyDeclaration(m) && m.name?.getText(leafSf) === t.name && m.initializer); + if (!lm) { fail(`${leaf}: holds no table ${t.name}`); continue; } + edits.push({ start: t.hf.initializer.getStart(headSf), end: t.hf.initializer.getEnd(), text: leafText.slice(lm.initializer.getStart(leafSf), lm.initializer.getEnd()) }); + } + const imp = tablesImportText(ts_[0].module); + const at = head.text.indexOf(imp); + if (at < 0 || head.text.indexOf(imp, at + 1) >= 0) fail(`${HOST_BASENAME} does not import ${leaf} once, as this pass writes it`); + else edits.push({ start: at, end: at + imp.length, text: '' }); + } + const baseStatics = staticsOf(ts, baseSf); + const headStatics = staticsOf(ts, headSf); + const wantKinds = new Map([...(baseStatics?.kinds ?? []), ...routed].sort(byName)); + if (wantKinds.size > 0) { + if (!headStatics) fail(`${HOST_BASENAME} carries no ${STATICS_NAME} forwarder, and the moved bodies read ${[...routed.keys()].join(', ')} through it`); + else { + const t = staticsTexts(wantKinds); + if (head.text.slice(headStatics.ahead.start, headStatics.ahead.end) !== t.ahead || head.text.slice(headStatics.block.start, headStatics.block.end) !== `\n\n${t.block}`) { + fail(`${STATICS_NAME}: not the forwarder this pass writes for the base's statics and the ones the moved bodies read`); + } + if (baseStatics) { + const bt = staticsTexts(baseStatics.kinds); + edits.push({ start: headStatics.ahead.start, end: headStatics.ahead.end, text: bt.ahead }); + edits.push({ start: headStatics.block.start, end: headStatics.block.end, text: `\n\n${bt.block}` }); + } else { + if (head.text.slice(headStatics.ahead.end, headStatics.ahead.end + 2) !== '\n\n') fail(`${STATICS_NAME}: its declarations are not followed by one blank line`); + edits.push({ start: headStatics.ahead.start, end: headStatics.ahead.end + 2, text: '' }); + edits.push({ start: headStatics.block.start, end: headStatics.block.end, text: '' }); + } + } + } else if (headStatics) fail(`${HOST_BASENAME} carries a ${STATICS_NAME} forwarder no moved body reads`); let rebuilt = applyEdits(head.text, edits); const baseFactory = hostBlockKeys(base.text, FACTORY_OPEN, FACTORY_CLOSE, factoryKey); const baseIface = hostBlockKeys(base.host, IFACE_OPEN, IFACE_CLOSE, ifaceKey); if (!baseFactory || !baseIface) fail('the base carries no host factory or no ProtocolHost interface'); else { - const f = dropHostGroups(rebuilt, FACTORY_OPEN, FACTORY_CLOSE, factoryKey, baseFactory.keys, (l) => /^ {12}set /.test(l)); + const setter = (l) => /^ {12}set /.test(l); + const f = dropHostGroups(rebuilt, FACTORY_OPEN, FACTORY_CLOSE, factoryKey, baseFactory.keys, setter); if (f.missing) fail(`${HOST_BASENAME} at head carries no host factory`); rebuilt = f.text; const i = dropHostGroups(head.host, IFACE_OPEN, IFACE_CLOSE, ifaceKey, baseIface.keys, () => false); @@ -2186,10 +2779,16 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R if (i.text !== base.host) fail(`undoing the moves does not rebuild the base ${HOST_MODULE_BASENAME} (first difference at line ${firstDiff(i.text, base.host)})`); if (JSON.stringify([...f.dropped].sort()) !== JSON.stringify([...i.dropped].sort())) fail(`the factory and the interface gained different entries: factory ${f.dropped.join(', ')}; interface ${i.dropped.join(', ')}`); const picked = new Set(moved.flatMap((mv) => mv.picks ?? [])); - for (const k of i.dropped) if (!picked.has(k)) fail(`${HOST_MODULE_BASENAME} gained ${k}, which no moved body's host type names`); + const headFactory = hostGroups(head.text, FACTORY_OPEN, FACTORY_CLOSE, factoryKey, setter); + const headIface = hostGroups(head.host, IFACE_OPEN, IFACE_CLOSE, ifaceKey, () => false); + for (const k of i.dropped) { + if (!picked.has(k)) fail(`${HOST_MODULE_BASENAME} gained ${k}, which no moved body's host type names`); + const e = expectedEntries.get(k); + if (e && (headIface.get(k) !== e.iface || headFactory.get(k) !== e.factory)) fail(`the host entry ${k} is not the one this pass writes for it`); + } } const prune = pruneImports(ts, base.text, usedImportNames(ts, head.text)); - const movedText = `${moved.map((mv) => mv.restored ?? '').join('\n')}\n${moved.map((mv) => mv.ownedDoc ?? '').join('\n')}`; + const movedText = `${moved.map((mv) => mv.restored ?? '').join('\n')}\n${moved.map((mv) => mv.ownedDoc ?? '').join('\n')}\n${tables.map((t) => t.init).join('\n')}`; for (const n of prune.removed) { if (!new RegExp(`\\b${n.replace(/\$/g, '\\$')}\\b`).test(movedText)) fail(`${HOST_BASENAME} lost the import binding ${n}, which no moved body reads`); } @@ -2203,19 +2802,23 @@ function verifyMembers(T, base, head, { anchors = SECTION_ANCHORS, pins = PATH_R function printVerifyMembers(v) { for (const f of v.failures) console.error(` ✗ ${f}`); - console.log(`undo proof (${MEMBERS_CARD}): ${v.moved.length} moved method(s) (${v.moved.join(', ') || 'none'}); ` + - `host-split check: ${v.checks.filter((c) => c.ok).length}/${v.checks.length} pass; ${HOST_BASENAME} ${v.linesBefore} -> ${v.linesAfter} lines; ` + + const forms = Object.entries(v.forms ?? {}).map(([f, n]) => `${f} ${n}`).join(', ') || 'none'; + const e1 = v.checks.filter((c) => c.form === 'e1'); + console.log(`undo proof (${MEMBERS_CARD}, ${RECEIVER_CARD}): ${v.moved.length} moved member(s) (${v.moved.join(', ') || 'none'}); forms: ${forms}; ` + + `host-split check: ${e1.filter((c) => c.ok).length}/${e1.length} E1 move(s) pass (the receiver forms move bodies unedited, proved byte-equal); ${HOST_BASENAME} ${v.linesBefore} -> ${v.linesAfter} lines; ` + `undoing the moves ${v.failures.length > 0 ? 'does NOT' : 'does'} rebuild the base ${HOST_BASENAME}, ${HOST_MODULE_BASENAME} and section modules byte-for-byte ` + `once the ${v.pruned.length} import binding(s) only moved bodies read are pruned from the base (${v.pruned.join(', ') || 'none'})`); } function generatedModulesAt(ref) { const out = new Map(); - for (const [, , file] of SECTION_ANCHORS) { - if (!file) continue; - try { - out.set(file, execFileSync('git', ['show', `${ref}:${SRC_REL}/${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })); - } catch { /* absent at the ref */ } + for (const [, , section] of SECTION_ANCHORS) { + if (!section) continue; + for (const file of [section, tablesModuleOf(section)]) { + try { + out.set(file, execFileSync('git', ['show', `${ref}:${SRC_REL}/${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })); + } catch { /* absent at the ref */ } + } } return out; } @@ -2237,7 +2840,10 @@ async function mainMembers(T, argv) { const base = { text: readFileSync(HOST_PATH, 'utf8'), host: readFileSync(HOST_MODULE_PATH, 'utf8'), modules: generatedModulesOnDisk() }; const out = generateMembers(T, base.text, base.host, base.modules, names); console.log(`# codemod-split-protocol --members (${MEMBERS_CARD}): named methods onto the landed facade`); - for (const r of out.records) console.log(`- ${r.name} -> ${facetNameOf(r.module)} (${r.module}): ${r.bodyLines} body lines${r.ownedDoc ? ', its docblock with it' : ''}`); + for (const r of out.records) { + if (r.leaf) console.log(`- ${r.name} (a static table) -> ${tablesClassOf(r.module)} (${r.leaf}): its initializer`); + else console.log(`- ${r.name} -> ${facetNameOf(r.module)} (${r.module}), ${r.form}: ${r.bodyLines} body lines${r.ownedDoc ? ', its docblock with it' : ''}`); + } const head = { text: out.host, host: out.hostModule, modules: new Map([...base.modules, ...out.modules]) }; console.log(`output tree sha256: ${treeHash(new Map([[HOST_BASENAME, out.host], [HOST_MODULE_BASENAME, out.hostModule], ...out.modules]))}`); const v = verifyMembers(T, base, head); @@ -2354,6 +2960,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'members pass': 8, 'members proof': 12, 'members refusals': 10, + 'receiver forms': 10, + 'receiver proof': 10, + 'receiver refusals': 7, }); const SELF_TEST_BATTERY_FLOOR = 5; @@ -2737,6 +3346,123 @@ ${pad} refuses('a module that declares a name the body imports refuses', () => gen(['laterReader'], { ...base, modules: new Map([['protocol-first.ts', handFacet.replace('const HAND_LOCAL = 1;', 'const HAND_LOCAL = 1;\nconst laterOnly = (n: number): number => n;')]]) })); } + + // The receiver forms (#30147): members E1 refuses move unedited. The landed + // facade gains, by hand, a method with a local named `host` (E1's rewrite + // set refuses it: `receiver`), a private static that reads the class through + // `this` (`statics`), and a static table; the first pass's `readsNoMember` + // (`plain`), `readsClass` (`plain`, reading a static through the forwarder) + // and `readsPredicate` (E1 through a predicate entry) move with them. + { + const rbase = { ...base, text: base.text + .replace(' private keptOne(): number {', ` private static readonly TABLE: ReadonlySet = new Set(['a', 'b']); + + private hostLocal(n: number): number { +${pad} + const host = n; + return host + this.engine.v + (this.flag ? 1 : 0); + } + + private static readsThis(n: number): number { +${pad} + return this.secret(n) + ObjectStackProtocolImplementation.TABLE.size; + } + + private bareStatic(): unknown { +${pad} + return this; + } + + private keptOne(): number {`) }; + const RNAMES = ['hostLocal', 'readsThis', 'readsNoMember', 'readsClass', 'readsPredicate', 'TABLE']; + const rgen = (names, b = rbase) => generateMembers(T, b.text, b.host, b.modules, names, opts); + let rout = null; + try { rout = rgen(RNAMES); } catch (e) { rout = { error: String(e.message ?? e) }; } + const rhead = rout.error ? rbase : { text: rout.host, host: rout.hostModule, modules: new Map([...rbase.modules, ...rout.modules]) }; + const rvm = (h) => verifyMembers(T, rbase, h, opts); + const rv = rout.error ? { failures: [rout.error], moved: [], checks: [], forms: new Map() } : rvm(rhead); + const rfacet = rhead.modules.get('protocol-first.ts') ?? ''; + const leaf = rhead.modules.get('protocol-first-tables.ts') ?? ''; + const formOf = (n) => { const r = (rout.records ?? []).find((x) => x.name === n); return r ? (r.form ?? (r.leaf ? 'table' : undefined)) : undefined; }; + + open('receiver forms'); + { + check('the receiver forms prove against the landed facade by their undo', rv.failures.length === 0 && rv.moved.length === RNAMES.length, + [...rv.failures, `moved ${rv.moved.join()}`].join(' | ')); + check('each member gets its form: receiver, statics, plain, E1 through a predicate entry, and a table', + formOf('hostLocal') === 'receiver' && formOf('readsThis') === 'statics' && formOf('readsNoMember') === 'plain' + && formOf('readsClass') === 'plain' && formOf('readsPredicate') === 'e1' && formOf('TABLE') === 'table' + && JSON.stringify(rv.forms) === JSON.stringify({ plain: 2, e1: 1, receiver: 1, statics: 1, table: 1 }), + `${RNAMES.map((n) => `${n}=${formOf(n)}`).join(' ')} | proof ${JSON.stringify(rv.forms)}`); + check('a receiver stub runs the unedited body on the host; the facet member takes the Pick of what it reads as `this`', + rhead.text.includes('return ProtocolFirstFacet.hostLocal.call(protocolHost(this), n);') + && rfacet.includes("static hostLocal(this: Pick, n: number): number {") + && rfacet.includes(' const host = n;\n return host + this.engine.v + (this.flag ? 1 : 0);'), rfacet); + check('a statics stub runs the unedited body on the forwarder', + rhead.text.includes(`return ProtocolFirstFacet.readsThis.call(${STATICS_NAME}, n);`) + && rfacet.includes(`static readsThis(this: Pick<${STATICS_HOST_NAME}, 'secret'>, n: number): number {`) + && rfacet.includes(' return this.secret(n) + ObjectStackProtocolImplementation.TABLE.size;'), rfacet); + check('a plain stub calls its facet member with no host', + rhead.text.includes('return ProtocolFirstFacet.readsNoMember(n);') && rhead.text.includes('it reads nothing of this class.') + && rhead.text.includes(`it reads this class's statics through \`${STATICS_NAME}\`.`), rhead.text); + check('the forwarder carries each routed static once, by kind, ahead of the class and in its closing static block', + rhead.text.includes(`export interface ${STATICS_HOST_NAME} {\n readonly TABLE: (typeof ${CLASS_NAME})['TABLE'];\n readonly secret: (typeof ${CLASS_NAME})['secret'];\n}`) + && rhead.text.includes(` TABLE: { get: () => ${CLASS_NAME}.TABLE },\n secret: { value: (...args: Parameters) => ${CLASS_NAME}.secret(...args) },`) + && rhead.text.indexOf(`export const ${STATICS_NAME}`) < rhead.text.indexOf(`export class ${CLASS_NAME}`), rhead.text.slice(0, 1600)); + check('the facet module imports the forwarder AS the class, under its note', + rfacet.includes(`${ROUTE_NOTE}\nimport { ${STATICS_NAME} as ${CLASS_NAME} } from './protocol.js';`) + || new RegExp(`${ROUTE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\nimport \\{[^}]*\\b${STATICS_NAME} as ${CLASS_NAME}\\b`).test(rfacet), + rfacet.split('\n').filter((l) => l.startsWith('import') || l.startsWith('//')).join(' | ')); + check('a table\'s initializer moves to a leaf that imports no relative module; the field reads it back', + rhead.text.includes(' private static readonly TABLE: ReadonlySet = ProtocolFirstTables.TABLE;') + && leaf.includes(" static readonly TABLE: ReadonlySet = new Set(['a', 'b']);") && !/from '\.\.?\//.test(leaf), leaf); + check('a predicate reached through the host keeps its predicate in the factory entry', + rhead.text.includes(' isThing: (x: unknown): x is string => protocol.isThing(x),'), rhead.text.split('\n').filter((l) => l.includes('isThing')).join(' | ')); + let again = null; + try { again = rgen([...RNAMES].reverse()); } catch { again = null; } + check('two runs (names in any order) write byte-identical trees', again !== null && !rout.error + && treeHash(new Map([['h', again.host], ['p', again.hostModule], ...again.modules])) === treeHash(new Map([['h', rout.host], ['p', rout.hostModule], ...rout.modules]))); + } + + open('receiver proof'); + { + const same = (a, b) => a.text === b.text && a.host === b.host && a.modules.size === b.modules.size + && [...a.modules].every(([f, t]) => b.modules.get(f) === t); + const fails = (label, h) => { + const v = rvm(h); + check(label, !rout.error && !same(h, rhead) && v.failures.length > 0, same(h, rhead) ? 'the mutation did not land' : 'proved'); + }; + const withR = (patch) => ({ ...rhead, ...patch }); + const withRFacet = (text) => withR({ modules: new Map([...rhead.modules, ['protocol-first.ts', text]]) }); + fails('one edited character in a receiver body fails', withRFacet(rfacet.replace('const host = n;', 'const host = -n;'))); + fails('a receiver stub that calls without the host fails', withR({ text: rhead.text.replace('ProtocolFirstFacet.hostLocal.call(protocolHost(this), n)', 'ProtocolFirstFacet.hostLocal.call(this, n)') })); + fails('a statics stub run on the class instead of the forwarder fails', withR({ text: rhead.text.replace(`ProtocolFirstFacet.readsThis.call(${STATICS_NAME}, n)`, `ProtocolFirstFacet.readsThis.call(${CLASS_NAME}, n)`) })); + fails('a widened `this` Pick fails', withRFacet(rfacet.replace("this: Pick", "this: Pick"))); + fails('a forwarder entry no moved body reads fails', withR({ text: rhead.text.replace(` TABLE: { get: () => ${CLASS_NAME}.TABLE },`, ` TABLE: { get: () => ${CLASS_NAME}.TABLE },\n guarded: { value: (...args: Parameters) => ${CLASS_NAME}.guarded(...args) },`) })); + fails('a forwarder method entry turned into a getter fails', withR({ text: rhead.text.replace(` secret: { value: (...args: Parameters) => ${CLASS_NAME}.secret(...args) },`, ` secret: { get: () => ${CLASS_NAME}.secret },`) })); + fails('one edited character in a moved table fails', withR({ modules: new Map([...rhead.modules, ['protocol-first-tables.ts', leaf.replace("new Set(['a', 'b'])", "new Set(['a', 'c'])")]]) })); + fails('a leaf that imports protocol.ts fails', withR({ modules: new Map([...rhead.modules, ['protocol-first-tables.ts', leaf.replace('\nexport class ProtocolFirstTables {', "\nimport { ObjectStackProtocolImplementation } from './protocol.js';\n\nexport class ProtocolFirstTables {")]]) })); + fails('a predicate entry that drops its predicate fails', withR({ text: rhead.text.replace(' isThing: (x: unknown): x is string => protocol.isThing(x),', ' isThing: (...a) => protocol.isThing(...a),') })); + fails('a facet import of the forwarder without its note fails', withRFacet(rfacet.replace(`${ROUTE_NOTE}\n`, ''))); + } + + open('receiver refusals'); + { + const refuses = (label, fn) => check(label, refused(fn)); + refuses('a bare `this` refuses (the receiver would hand back the host)', () => rgen(['bareStatic'])); + refuses('`this.constructor` refuses (constructor-rewrite is not a receiver reason)', () => rgen(['viaConstructor'])); + refuses('a static written through the class refuses (the forwarder is read-only)', + () => rgen(['readsThis'], { ...rbase, text: rbase.text.replace('return this.secret(n) + ObjectStackProtocolImplementation.TABLE.size;', 'ObjectStackProtocolImplementation.TABLE = new Set();\n return this.secret(n);') })); + refuses('a static method read as a value refuses (a forwarder is another function)', + () => rgen(['readsThis'], { ...rbase, text: rbase.text.replace('return this.secret(n) + ObjectStackProtocolImplementation.TABLE.size;', 'const f = ObjectStackProtocolImplementation.secret;\n return f(n);') })); + refuses('a table whose initializer reads a relative import refuses (the leaf imports packages only)', + () => rgen(['TABLE'], { ...rbase, text: rbase.text.replace("new Set(['a', 'b'])", 'new Set([String(already)])') })); + refuses('a receiver read of a field the host carries writable refuses (the entry would differ)', + () => rgen(['hostLocal'], { ...rbase, text: rbase.text.replace('(this.flag ? 1 : 0)', 'this.count') })); + refuses('a table whose initializer reads a top-level name of protocol.ts refuses', + () => rgen(['TABLE'], { ...rbase, text: rbase.text.replace("new Set(['a', 'b'])", 'new Set([String(LOCAL)])') })); + } + } } open('determinism'); diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 50f114e6742..fcd55ac62d4 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -39,101 +39,11 @@ "card": "#30020", "reason": "state-owning: closed-set, a bare `this` (not `this.`) hands the instance itself on (#29985 report)" }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "reconcileViewExpansions", - "card": "#30147", - "reason": "codemod limit: closed-set, reads the private static field `registeredViewExpansions` (only a called private static method is in the closed set) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "packagelessOverlayWriteRefusal", - "card": "#30147", - "reason": "codemod limit: closed-set, a local named `host` would shadow the host parameter; name-taken (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "namedBaseOverlayWriteRefusal", - "card": "#30147", - "reason": "codemod limit: closed-set, a local named `host` would shadow the host parameter; name-taken (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static mergesOverlayAtRead", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that reads `this` (the class) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static registryAllowsOverlay", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that reads `this` (the class) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static isOverlayAllowed", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that reads `this` (the class) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static isRuntimeCreateAllowed", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that reads `this` (the class) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static codeOnlySourceHint", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that names the class (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static codeOnlyCreateError", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that names the class (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static codeOnlyOverrideError", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that names the class (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static refuseOrganizationScopedWrite", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that reads `this` (the class) (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static overlayPackageMismatchError", - "card": "#30147", - "reason": "codemod limit: static-reads-class, a private static that names the class (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "rethrowUnlessMetadataStoreUnprovisioned", - "card": "#30147", - "reason": "codemod limit: no-host-read, the body reads no member, so the moved function would leave its host parameter unread (#29985 report)" - }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "triggerAutomation", "card": "#30147", - "reason": "codemod limit: no-host-read, the body reads no member, so the moved function would leave its host parameter unread (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "objectExtensionItemRefusal", - "card": "#30147", - "reason": "codemod limit: name-taken, its name is already a module-scope binding of protocol.ts (#29985 report)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "packagedBaseRefusal", - "card": "#30147", - "reason": "codemod limit: host-entry-predicate, it reaches the type predicate isPackagedBaseRefusal through a host entry not yet written (#29985 report)" + "reason": "accepted exception (#30147 phase 3): its body is one `throw` (no shorter than a stub), and its section (Batch and *Many data) has no facet module; the codemod's members pass adds to a written facet and never starts one, so a facet for one statement would be the only reason to start it" }, { "file": "packages/metadata-protocol/src/protocol.ts", @@ -141,30 +51,6 @@ "card": "#30020", "reason": "state-owning: gate reading, a hand-series host slot computes (optional-call service lookup); the hand-series host rule is #30020's" }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static OVERLAY_ALLOWED_TYPES", - "card": "#30147", - "reason": "codemod limit: gate reading, a static table built in an initializer body at class load (a pure function of imports; the initializer can move to a module)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static STATIC_REGISTRY_TYPES", - "card": "#30147", - "reason": "codemod limit: gate reading, a static table built in an initializer body at class load (a pure function of imports; the initializer can move to a module)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static RUNTIME_CREATE_ALLOWED_TYPES", - "card": "#30147", - "reason": "codemod limit: gate reading, a static table built in an initializer body at class load (a pure function of imports; the initializer can move to a module)" - }, - { - "file": "packages/metadata-protocol/src/protocol.ts", - "member": "static OVERLAY_CAPABLE_TYPES", - "card": "#30147", - "reason": "codemod limit: gate reading, a static table built in an initializer body at class load (a pure function of imports; the initializer can move to a module)" - }, { "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", From ae519e3e996af7fc08c601dc420447e05ba4ba09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:42:33 +0000 Subject: [PATCH 2/4] fix(scripts): check-durability-log-level follows the members pass's receiver forms A guard or read wrapper whose body moved by the receiver forms is reached through a stub that runs its facet member on the host (`.call(protocolHost(this))`), on the statics forwarder, or with no receiver (an instance method's plain form). The resolver follows those stubs to the facet member, and a facet member's `this.x()` to the class member through the imported host or forwarder interface, so the three read seams that delegate to the moved `rethrowUnlessMetadataStoreUnprovisioned` are judged on its discriminated branch again. The checker's output on the tree is byte-identical to the base. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxm997Tr9c3JFQTKGj --- ...check-durability-degradation-log-level.mjs | 157 +++++++++++++++++- 1 file changed, 148 insertions(+), 9 deletions(-) diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 8b5248ed67b..afbb6f19294 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -3265,11 +3265,14 @@ function createHostSplit(load) { for (const stmt of e.sf.statements) { if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; const m = HOST_SPLIT.specifier.exec(stmt.moduleSpecifier.text); + // [#30147] A statics-form body's `this` is typed by the forwarder's + // interface, which `protocol.ts` itself declares and exports. + const fromHost = stmt.moduleSpecifier.text === `./${HOST_SPLIT.host.replace(/\.ts$/, '.js')}`; const named = stmt.importClause?.namedBindings; - if (!m || !named || !ts.isNamedImports(named)) continue; + if ((!m && !fromHost) || !named || !ts.isNamedImports(named)) continue; const el = named.elements.find((x) => x.name.text === hostType); if (!el) continue; - const from = entry(`${HOST_SPLIT.dir}/${m[1]}.ts`); + const from = entry(fromHost ? hostRel : `${HOST_SPLIT.dir}/${m[1]}.ts`); const exported = (el.propertyName ?? el.name).text; return from?.sf.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === exported); } @@ -3305,8 +3308,10 @@ function createHostSplit(load) { } const hostType = type.typeName.text; // The per-member series' host interface is the module's own; ruling E's - // may be imported from the module that declares it (`protocol-host.ts`). - const iface = viaHostParam + // may be imported from the module that declares it (`protocol-host.ts`), + // and so may a #30147 receiver-form body's (`this: Pick`, + // or the statics forwarder's interface, which `protocol.ts` declares). + const iface = viaHostParam || bodies.relPath !== hostRel && hostType !== undefined && !bodies.sourceFile?.statements.some((s) => ts.isInterfaceDeclaration(s) && s.name.text === hostType) ? hostInterface(bodies.relPath, hostType) : bodies.sourceFile?.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === hostType); const member = iface?.members.find( @@ -3314,7 +3319,7 @@ function createHostSplit(load) { ); if (!member) return undefined; const body = typedAsHostMember(member.type, name) ? hostMethods()?.get(name) - : viaHostParam && typedAsHostStatic(member.type, name) ? hostMethods({ static: true })?.get(name) + : typedAsHostStatic(member.type, name) ? hostMethods({ static: true })?.get(name) : undefined; return body ? { body, bodies: entry(hostRel).bodies } : undefined; }; @@ -3342,14 +3347,31 @@ function createHostSplit(load) { const hostOfThis = (arg) => !!arg && ts.isCallExpression(arg) && ts.isIdentifier(arg.expression) && arg.expression.text === HOST_SPLIT.hostOf && arg.arguments.length === 1 && arg.arguments[0].kind === ts.SyntaxKind.ThisKeyword; + // [#30147] A plain-form stub hands its facet member the method's own + // parameters and nothing else (no host: the body reads nothing of the class). + const ownParams = () => call.arguments.every((a) => { + const id = ts.isSpreadElement(a) ? a.expression : a; + return ts.isIdentifier(id) && method.parameters.some((p) => ts.isIdentifier(p.name) && p.name.text === id.text); + }); if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && ts.isIdentifier(callee.name) && callee.name.text !== 'call' && HOST_SPLIT.facet.test(callee.expression.text)) { // [#29985] `Facet.fn(protocolHost(this), …)` — the facade form of // ruling 6624345766: a facet class's static method takes the host - // first; a private static's stub, `Facet.fn(…)`, passes none. - if (isStatic ? hostOfThis(first) : !hostOfThis(first)) return undefined; + // first; a private static's stub, `Facet.fn(…)`, passes none, and + // so does an instance method's plain-form stub (#30147). + if (isStatic ? hostOfThis(first) : !hostOfThis(first) && !ownParams()) return undefined; facet = callee.expression.text; fnName = callee.name.text; + } else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name) && callee.name.text === 'call' && + ts.isPropertyAccessExpression(callee.expression) && ts.isIdentifier(callee.expression.expression) && + ts.isIdentifier(callee.expression.name) && HOST_SPLIT.facet.test(callee.expression.expression.text)) { + // [#30147] The receiver forms: `Facet.fn.call(protocolHost(this), …)` + // for an instance method, `Facet.fn.call(Statics, …)` for a + // private static; the facet member runs on that receiver as `this`. + const onStatics = !!first && ts.isIdentifier(first) && first.text === `${HOST_SPLIT.hostClass}Statics`; + if (isStatic ? !onStatics : !hostOfThis(first)) return undefined; + facet = callee.expression.expression.text; + fnName = callee.expression.name.text; } else if (ts.isPropertyAccessExpression(callee)) { // `fn.call(host, …)` — the per-member series. if (!ts.isIdentifier(callee.name) || callee.name.text !== 'call' || !ts.isIdentifier(callee.expression)) return undefined; @@ -3813,6 +3835,10 @@ function establishesBenign(expr, ctx) { const hop = ctx.functionBodies.split.hostCall(call, ctx.functionBodies); if (hop) ({ body, bodies: guardBodies } = hop); } + // [#30147] A guard whose body moved to its facet (the members pass's receiver + // and plain forms) is answered from the facet member its stub delegates to. + const movedGuard = body && guardBodies.split ? guardBodies.split.stubTarget(body, guardBodies) : undefined; + if (movedGuard) ({ body, bodies: guardBodies } = movedGuard); if (!body || !ts.isBlock(body)) return false; const exits = []; const consulted = new Set(); @@ -5318,6 +5344,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'flags: #29985 — a protocol.ts try around a `Facet.fn(protocolHost(this), …)` stub follows the facet\'s static method, and the stub costs no hop': 1, 'flags: #29985 — a try around a private static whose stub calls `Facet.fn(…)` follows the facet\'s static method': 1, 'passes: #29985 — negative control: a stub calling a static of a class the facet pattern does not name is not followed': 1, + 'passes: #30147 — a moved body\'s `host.guard(error)` whose guard moved by the plain form answers on the facet\'s discriminated branch': 1, + 'flags: #30147 — negative control: a plain-form stub whose facet guard asks no error type still invents the answer': 1, + 'flags: #30147 — a protocol.ts try around a `Facet.fn.call(protocolHost(this), …)` stub follows the facet member and its `this.x()` host call': 1, + 'passes: #30147 — negative control: a `.call` stub whose receiver is not `protocolHost(this)` is not followed': 1, + 'flags: #30147 — a try around a private static whose stub runs on the statics forwarder follows the facet member and its `this.x()` static call': 1, 'ledger (read seams): a stale entry PASSES and is reported; an unledgered seam still FAILS (#26444)': 1, 'ledger (read seams): a ledgered file on disk the scan never READ is refused; a deleted one passes (#26444)': 1, }), @@ -5331,7 +5362,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // `undefined` and the comparison fails loudly. const SELF_TEST_BATTERY_FLOOR = Object.freeze({ selfTest: 80, - selfTestReadSeams: 73, + selfTestReadSeams: 78, }); // The roster's TOTAL, checked from BOTH verdict sites. Each entry checks only @@ -5339,7 +5370,7 @@ const SELF_TEST_BATTERY_FLOOR = Object.freeze({ // `selfTestReadSeams`'s partition being deleted from the roster wholesale — the // deleted partition's own size pin is not evaluated by anyone once its entry // stops reaching it. This is the one assertion that survives that deletion. -const SELF_TEST_BATTERY_TOTAL_FLOOR = 147; +const SELF_TEST_BATTERY_TOTAL_FLOOR = 152; /** * The floor for ONE partition of the roster, run from that partition's own @@ -8220,6 +8251,114 @@ function selfTestReadSeams() { expectViolation: false, expectSeams: 0, }, + // [#30147] The members pass's receiver forms: a stub that runs its facet + // member on the host (`Facet.fn.call(protocolHost(this), …)`), on the + // statics forwarder (`.call(Statics, …)`), or with no receiver + // (an instance method's plain form, `Facet.fn()`). + { + name: 'passes: #30147 — a moved body\'s `host.guard(error)` whose guard moved by the plain form answers on the facet\'s discriminated branch', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: FACADE_HOST.replace(`private rethrowUnlessGone(error: unknown): void { + if (isMissingTableError(error)) return; + throw error; + }`, `private rethrowUnlessGone(error: unknown): void { + return ProtocolUninstallFacet.rethrowUnlessGone(error); + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: FACET_MODULE.replace('export class ProtocolUninstallFacet {', `export class ProtocolUninstallFacet { + static rethrowUnlessGone(error: unknown): void { + if (isMissingTableError(error)) return; + throw error; + } + static async listRows(host: Pick, id: string) { + try { return await host.findRows(id); } + catch (error) { host.rethrowUnlessGone(error); return []; } + }`), + }, + expectViolation: false, + expectSeams: 1, + expectDiscriminatorsUsed: ['isMissingTableError'], + }, + { + name: 'flags: #30147 — negative control: a plain-form stub whose facet guard asks no error type still invents the answer', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: FACADE_HOST.replace(`private rethrowUnlessGone(error: unknown): void { + if (isMissingTableError(error)) return; + throw error; + }`, `private rethrowUnlessGone(error: unknown): void { + return ProtocolUninstallFacet.rethrowUnlessGone(error); + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: FACET_MODULE.replace('export class ProtocolUninstallFacet {', `export class ProtocolUninstallFacet { + static rethrowUnlessGone(error: unknown): void { void error; } + static async listRows(host: Pick, id: string) { + try { return await host.findRows(id); } + catch (error) { host.rethrowUnlessGone(error); return []; } + }`), + }, + expectViolation: true, + expectSeams: 1, + }, + { + name: 'flags: #30147 — a protocol.ts try around a `Facet.fn.call(protocolHost(this), …)` stub follows the facet member and its `this.x()` host call', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: FACADE_HOST + .replace('return ProtocolUninstallFacet.listRowsMoved(protocolHost(this), id);', 'return ProtocolUninstallFacet.listRowsMoved.call(protocolHost(this), id);') + .replace('/*ROLLBACK*/', ` + async rollBack(id: string) { + try { return await this.listRowsMoved(id); } + catch { return []; } + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: FACET_MODULE + .replace("static async listRowsMoved(host: Pick, id: string) {\n return await host.findRows(id);", + "static async listRowsMoved(this: Pick, id: string) {\n return await this.findRows(id);"), + }, + expectViolation: true, + expectSeams: 1, + }, + { + name: 'passes: #30147 — negative control: a `.call` stub whose receiver is not `protocolHost(this)` is not followed', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: FACADE_HOST + .replace('return ProtocolUninstallFacet.listRowsMoved(protocolHost(this), id);', 'return ProtocolUninstallFacet.listRowsMoved.call(this.someHost, id);') + .replace('/*ROLLBACK*/', ` + async rollBack(id: string) { + try { return await this.listRowsMoved(id); } + catch { return []; } + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: FACET_MODULE + .replace("static async listRowsMoved(host: Pick, id: string) {\n return await host.findRows(id);", + "static async listRowsMoved(this: Pick, id: string) {\n return await this.findRows(id);"), + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'flags: #30147 — a try around a private static whose stub runs on the statics forwarder follows the facet member and its `this.x()` static call', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: ` + export interface ObjectStackProtocolImplementationStaticsHost { + readonly readStatic: (typeof ObjectStackProtocolImplementation)['readStatic']; + } + export const ObjectStackProtocolImplementationStatics = {} as ObjectStackProtocolImplementationStaticsHost; +` + FACADE_HOST + .replace('return ProtocolUninstallFacet.readStaticMoved(engine, t);', 'return ProtocolUninstallFacet.readStaticMoved.call(ObjectStackProtocolImplementationStatics, engine, t);') + .replace('/*ROLLBACK*/', ` + async rollBack(id: string) { + try { return await ObjectStackProtocolImplementation.readStaticMoved(this.engine, id); } + catch { return []; } + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: FACET_MODULE + .replace("import type { ProtocolHost } from './protocol-host.js';", "import type { ProtocolHost } from './protocol-host.js';\n import type { ObjectStackProtocolImplementationStaticsHost } from './protocol.js';") + .replace("static async readStaticMoved(engine: any, t: string) {\n return await engine.find(t, {});", + "static async readStaticMoved(this: Pick, engine: any, t: string) {\n return await this.readStatic(engine, t);"), + }, + expectViolation: true, + expectSeams: 1, + }, { name: 'BOUND (#25467 E1): the `host.x()` hop IS a hop — a third hop through the host is still refused', files: { From bc2ee6c161b9ad3a86d1a45cbdbb76b86b2bf576 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:35:44 +0000 Subject: [PATCH 3/4] test(spec): the allowOrgOverride absence pin reads the moved OVERLAY_ALLOWED_TYPES initializer in its leaf module Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxm997Tr9c3JFQTKGj --- .../metadata-type-registry-allow-org-override-absence.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-absence.test.ts b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-absence.test.ts index e73e7acbc70..5d9c515cfde 100644 --- a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-absence.test.ts +++ b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-absence.test.ts @@ -55,7 +55,9 @@ describe('tree-scoped absence: no TypeScript source in the declared radius reads * name was about a third of the walk's parsing. */ const NEW_READERS = [ - 'packages/metadata-protocol/src/protocol.ts', + // [#30147] The protocol's reader is `OVERLAY_ALLOWED_TYPES`'s initializer, + // which moved whole from `protocol.ts` to this leaf module. + 'packages/metadata-protocol/src/protocol-write-gates-tables.ts', 'packages/metadata-protocol/src/sys-metadata-repository.ts', 'packages/spec/src/kernel/metadata-plugin.zod.ts', ]; From 6a6b307eab78ba32366dff8123ce4d2dffd63356 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:24:12 +0000 Subject: [PATCH 4/4] test(metadata-protocol): the bracketed-opener pin pays the lint root's first load at collection Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxm997Tr9c3JFQTKGj --- .../src/protocol.bracketed-refusal-opener-absence.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts index 01272e9b875..218afea3713 100644 --- a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -50,6 +50,8 @@ import { describe, it, expect, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +// Pays the lint root's first load at collection, never inside a clocked case. +import './lint-root-preload.testkit.js'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING,