From 1631624a959d0790fc7d085a9888b951a42d2959 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:23:27 +0000 Subject: [PATCH 1/3] wip(objectql): codemod second pass facades ObjectRepository and ScopedContext (#30052) Claude-Session: https://claude.ai/code/session_01Utp6Cbo4rDxRL3yugqM5c7 Co-authored-by: Claude --- .../objectql/src/engine-object-repository.ts | 152 ++++ .../objectql/src/engine-scoped-context.ts | 174 +++++ packages/objectql/src/engine-scoped-hosts.ts | 51 ++ packages/objectql/src/engine.ts | 237 +++---- scripts/codemod-split-engine.mjs | 651 +++++++++++++++++- 5 files changed, 1106 insertions(+), 159 deletions(-) create mode 100644 packages/objectql/src/engine-object-repository.ts create mode 100644 packages/objectql/src/engine-scoped-context.ts create mode 100644 packages/objectql/src/engine-scoped-hosts.ts diff --git a/packages/objectql/src/engine-object-repository.ts b/packages/objectql/src/engine-object-repository.ts new file mode 100644 index 00000000000..ea31e5583d4 --- /dev/null +++ b/packages/objectql/src/engine-object-repository.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30052 codemod] The "Repository bound to one object and one execution + * context" section: the bodies of `ObjectRepository`'s methods `find`, + * `findOne`, `insert`, `create`, `update`, `updateById`, `delete`, + * `deleteById`, `count`, `aggregate`, `execute` moved out of `engine.ts` whole + * by `scripts/codemod-split-engine.mjs` (its second pass). + * + * A MOVE, not a redesign. {@link ObjectRepositoryFacet} is a stateless facet of + * `ObjectRepository`: each static method of it is the body of the + * `ObjectRepository` method of the same name exactly as it stood: not + * re-indented, not formatted, not edited. The method stays on the class as a + * one-call stub with its signature, so the published surface, an instance spy + * and a subclass override are what they were. A public method keeps its + * docblock on the class, the contract; a private method's docblock moved here + * with its body. The state stays on `ObjectRepository`. + * + * The bodies reach the instance through {@link ObjectRepositoryHost}: exactly + * the `ObjectRepository` members they read, private ones included. Every stub + * hands its body, by a `.call`, the same host: `objectRepositoryHost` in + * `engine-scoped-hosts.ts`, built once per instance by the class's static + * block. Methods go as closures that call them on the instance at call time; + * fields and accessors go as GETTERS, read when the body reads them, and a + * field a body assigns goes with a SETTER that assigns it on the instance. + * + * Values declared in `engine.ts` are imported from it (lever (a) of + * ruling 6591753301); every use is inside a function body, so the import + * cycle is never read at evaluation time. `index.ts` re-exports nothing from + * this module. + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { markAppAutomationElevation } from '@objectstack/core'; +import { ScopedContext } from './engine.js'; +import type { ObjectRepository } from './engine.js'; + +/** + * The `ObjectRepository` members the bodies of {@link ObjectRepositoryFacet} + * read — every one of them, and no other. Each is typed AS the class member (an + * indexed access on the class), so a change to a member's signature on the + * class is a change here too. `readonly` members are the getters; the others + * are fields a body assigns. + */ +export interface ObjectRepositoryHost { + readonly context: ObjectRepository['context']; + readonly engine: ObjectRepository['engine']; + insert: ObjectRepository['insert']; + readonly objectName: ObjectRepository['objectName']; +} + +/** The bodies of `ObjectRepository`'s methods: a stateless facet, called by the class's stubs. */ +export class ObjectRepositoryFacet { + /** The body of `ObjectRepository#find`; the method's docblock there is its contract. */ + static async find(this: Pick, query: any = {}): Promise { + return this.engine.find(this.objectName, { + ...query, + context: this.context, + }); + } + + /** The body of `ObjectRepository#findOne`; the method's docblock there is its contract. */ + static async findOne(this: Pick, query: any = {}): Promise | null> { + return this.engine.findOne(this.objectName, { + ...query, + context: this.context, + }); + } + + /** The body of `ObjectRepository#insert`; the method's docblock there is its contract. */ + static async insert(this: Pick, data: any): Promise { + return this.engine.insert(this.objectName, data, { + context: this.context, + }); + } + + /** The body of `ObjectRepository#create`; the method's docblock there is its contract. */ + static async create(this: Pick, data: any): Promise { + return this.insert(data); + } + + /** The body of `ObjectRepository#update`; the method's docblock there is its contract. */ + static async update(this: Pick, data: any, options: any = {}): Promise | number | null> { + return this.engine.update(this.objectName, data, { + ...options, + context: this.context, + }); + } + + /** The body of `ObjectRepository#updateById`; the method's docblock there is its contract. */ + static async updateById(this: Pick, id: string | number, data: any): Promise { + return this.engine.update(this.objectName, { ...data, id: id }, { + where: { id: id }, + context: this.context, + }); + } + + /** The body of `ObjectRepository#delete`; the method's docblock there is its contract. */ + static async delete(this: Pick, options: any = {}): Promise { + return this.engine.delete(this.objectName, { + ...options, + context: this.context, + }); + } + + /** The body of `ObjectRepository#deleteById`; the method's docblock there is its contract. */ + static async deleteById(this: Pick, id: string | number): Promise { + return this.engine.delete(this.objectName, { + where: { id: id }, + context: this.context, + }); + } + + /** The body of `ObjectRepository#count`; the method's docblock there is its contract. */ + static async count(this: Pick, query: any = {}): Promise { + return this.engine.count(this.objectName, { + ...query, + context: this.context, + }); + } + + /** The body of `ObjectRepository#aggregate`; the method's docblock there is its contract. */ + static async aggregate(this: Pick, query: any = {}): Promise { + return this.engine.aggregate(this.objectName, { + ...query, + context: this.context, + }); + } + + /** The body of `ObjectRepository#execute`; the method's docblock there is its contract. */ + static async execute(this: Pick, actionName: string, params?: any): Promise { + if (this.engine.executeAction) { + // [#25653] With the `'action'` mark, as `buildActionExecutionContext` + // (runtime) gives the REST and MCP doors' action bodies: the action this + // reaches is app-authored automation running elevated, so its writes to + // the four identity-assignment objects are refused (plugin-security). + const executionContext: ExecutionContext = markAppAutomationElevation( + { ...this.context, isSystem: true }, + 'action', + ); + return this.engine.executeAction(this.objectName, actionName, { + ...params, + userId: this.context.userId, + tenantId: this.context.tenantId, + roles: this.context.positions, + executionContext, + api: new ScopedContext(executionContext, this.engine), + }); + } + throw new Error(`Actions not supported by engine`); + } +} diff --git a/packages/objectql/src/engine-scoped-context.ts b/packages/objectql/src/engine-scoped-context.ts new file mode 100644 index 00000000000..9f65bc826ef --- /dev/null +++ b/packages/objectql/src/engine-scoped-context.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30052 codemod] The "Scoped execution context: repositories, elevation, + * transactions" section: the bodies of `ScopedContext`'s methods `object`, + * `sudo`, `withRunAs`, `releaseJoinedHandle`, `txDriver`, `beginTransaction`, + * `commitTransaction`, `rollbackTransaction`, `serveFormulaField` moved out of + * `engine.ts` whole by `scripts/codemod-split-engine.mjs` (its second pass). + * + * A MOVE, not a redesign. {@link ScopedContextFacet} is a stateless facet of + * `ScopedContext`: each static method of it is the body of the `ScopedContext` + * method of the same name exactly as it stood: not re-indented, not formatted, + * not edited. The method stays on the class as a one-call stub with its + * signature, so the published surface, an instance spy and a subclass override + * are what they were. A public method keeps its docblock on the class, the + * contract; a private method's docblock moved here with its body. The state + * stays on `ScopedContext`. + * + * The bodies reach the instance through {@link ScopedContextHost}: exactly the + * `ScopedContext` members they read, private ones included. Every stub hands + * its body, by a `.call`, the same host: `scopedContextHost` in + * `engine-scoped-hosts.ts`, built once per instance by the class's static + * block. Methods go as closures that call them on the instance at call time; + * fields and accessors go as GETTERS, read when the body reads them, and a + * field a body assigns goes with a SETTER that assigns it on the instance. + * + * Values declared in `engine.ts` are imported from it (lever (a) of + * ruling 6591753301); every use is inside a function body, so the import + * cycle is never read at evaluation time. `index.ts` re-exports nothing from + * this module. + */ + +import { driverSupportsTransactions } from '@objectstack/spec/data'; +import type { IScopedContext } from '@objectstack/spec/contracts'; +import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core'; +import { UnscopedHookApi } from './hook-run-as.js'; +import type { HookRunAs, HookRunAsRef } from './hook-run-as.js'; +import { ObjectRepository } from './engine.js'; +import type { ObjectQL, ScopedContext } from './engine.js'; + +/** + * The `ScopedContext` members the bodies of {@link ScopedContextFacet} read — + * every one of them, and no other. Each is typed AS the class member (an + * indexed access on the class), so a change to a member's signature on the + * class is a change here too. `readonly` members are the getters; the others + * are fields a body assigns. + */ +export interface ScopedContextHost { + readonly appAuthored: ScopedContext['appAuthored']; + derive: ScopedContext['derive']; + readonly engine: ScopedContext['engine']; + readonly executionContext: ScopedContext['executionContext']; + readonly joinedHandles: ScopedContext['joinedHandles']; + releaseJoinedHandle: ScopedContext['releaseJoinedHandle']; + txDriver: ScopedContext['txDriver']; +} + +/** The bodies of `ScopedContext`'s methods: a stateless facet, called by the class's stubs. */ +export class ScopedContextFacet { + /** The body of `ScopedContext#object`; the method's docblock there is its contract. */ + static object(this: Pick, name: string): ObjectRepository { + return new ObjectRepository(name, this.executionContext, this.engine as any); + } + + /** The body of `ScopedContext#sudo`; the method's docblock there is its contract. */ + static sudo(this: Pick): ScopedContext { + const elevated = { ...this.executionContext, isSystem: true }; + return this.derive( + this.appAuthored && !appAutomationElevationOf(elevated) + ? markAppAutomationElevation(elevated, 'hook') + : elevated, + ); + } + + /** The body of `ScopedContext#withRunAs`; the method's docblock there is its contract. */ + static withRunAs(this: Pick, runAs: Exclude, ref: HookRunAsRef): IScopedContext { + if (runAs === 'system') { + return this.derive( + markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'), + ); + } + if (!this.executionContext.userId) return new UnscopedHookApi(ref); + return this.derive({ ...this.executionContext, isSystem: false }); + } + + /** + * Was `handle` JOINED by this context rather than opened by it? Consumes the + * record, so the trio's terminal call is also what forgets the handle. + * + * Two independent signals, because the trio's callers are exactly the ones + * that cannot keep a closure on the stack and may not close on the same + * object they opened on: + * + * 1. this context's own {@link joinedHandles} record, and + * 2. identity with the CURRENT ambient handle — a handle the engine is + * holding open right now is, by construction, not one the trio opened + * (the trio never publishes into `txStore`, so a trio-owned handle is + * never the ambient one). + * + * Both point the same way and both fail SAFE: the ambiguous answer is + * "abstain", never "commit a transaction we do not own". + */ + static releaseJoinedHandle(this: Pick, handle: unknown): boolean { + if (this.joinedHandles.delete(handle)) return true; + if (handle == null) return false; + const ambient = (this.engine as any)?.txStore?.getStore?.() as + | { transaction?: unknown } + | undefined; + return ambient?.transaction === handle; + } + + /** + * Resolve the default driver, if it exposes transaction primitives. + * Shared by {@link transaction} and the discrete begin/commit/rollback trio. + */ + static txDriver(this: Pick): any | undefined { + const engine = this.engine as any; + const driver = engine.defaultDriver + ? engine.drivers?.get(engine.defaultDriver) + : undefined; + // [#18063] Declaration, not bare method presence — the trio's `begin` + // returns `null` and `commit`/`rollback` abstain for a transport that + // declared it cannot honour a handle, which is the same graceful degrade a + // driver with no `beginTransaction` already gets. + return driverSupportsTransactions(driver) ? driver : undefined; + } + + /** The body of `ScopedContext#beginTransaction`; the method's docblock there is its contract. */ + static async beginTransaction(this: Pick): Promise<{ ctx: ScopedContext; handle: unknown; owned: boolean } | null> { + // ADR-0067 D2 — JOIN before the driver lookup, the same first move and the + // same position as both callback faces (#6168 / #6406). An ambient + // transaction IS a transaction, so there is nothing to look a driver up for. + const ambient = (this.engine as any)?.txStore?.getStore?.() as + | { transaction?: unknown } + | undefined; + if (ambient?.transaction) { + // Threaded explicitly into the child context, identity-equal to the + // store's handle — so `buildDriverOptions` binds every op on it to the + // outer connection and `transactionCoversDriverFor` still attributes the + // handle to the OUTER owner (#5351 unchanged). + const ctx = this.derive({ ...this.executionContext, transaction: ambient.transaction }); + this.joinedHandles.add(ambient.transaction); + return { ctx, handle: ambient.transaction, owned: false }; + } + const driver = this.txDriver(); + if (!driver) return null; + const trx = await driver.beginTransaction(); + const ctx = this.derive({ ...this.executionContext, transaction: trx }); + return { ctx, handle: trx, owned: true }; + } + + /** The body of `ScopedContext#commitTransaction`; the method's docblock there is its contract. */ + static async commitTransaction(this: Pick, handle: unknown): Promise { + if (this.releaseJoinedHandle(handle)) return; + const driver = this.txDriver(); + if (!driver) return; + if (driver.commit) await driver.commit(handle); + else if (driver.commitTransaction) await driver.commitTransaction(handle); + } + + /** The body of `ScopedContext#rollbackTransaction`; the method's docblock there is its contract. */ + static async rollbackTransaction(this: Pick, handle: unknown): Promise { + if (this.releaseJoinedHandle(handle)) return; + const driver = this.txDriver(); + if (!driver) return; + if (driver.rollback) await driver.rollback(handle); + else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle); + } + + /** The body of `ScopedContext#serveFormulaField`; the method's docblock there is its contract. */ + static async serveFormulaField(this: Pick, objectName: string, record: Record, field: string): Promise { + return (this.engine as unknown as ObjectQL).serveFormulaField(objectName, record, field, this.executionContext); + } +} diff --git a/packages/objectql/src/engine-scoped-hosts.ts b/packages/objectql/src/engine-scoped-hosts.ts new file mode 100644 index 00000000000..6f05b52d377 --- /dev/null +++ b/packages/objectql/src/engine-scoped-hosts.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30052 codemod] The host of each facet of `ObjectRepository` and `ScopedContext`, + * built ONCE per instance: what every stub of a facet hands that facet's + * bodies as `this`. Each host below is filled by the factory its class's + * static block installs, the one place that can read the class's private + * members; the host is built on the first call that needs it and kept for + * the instance's life. A method goes as a closure that calls it on the + * instance at call time, a field or accessor as a getter (and a setter when + * a body assigns it), so a body reads and writes the instance exactly as + * the method did. + * + * Written by `scripts/codemod-split-engine.mjs` (its second pass). It imports + * types only, so it forms no evaluation cycle and is evaluated before the + * classes whichever module loads first. `index.ts` re-exports nothing from + * this module. + */ + +import type { ObjectRepository, ScopedContext } from './engine.js'; +import type { ObjectRepositoryHost } from './engine-object-repository.js'; +import type { ScopedContextHost } from './engine-scoped-context.js'; + +/** One facet's host for each instance of its class: built on first use, kept for the instance's life. */ +export class InstanceHost { + private readonly hosts = new WeakMap(); + private build: ((instance: E) => H) | undefined; + + constructor(private readonly facet: string, private readonly owner: string) {} + + /** Called once, from the owning class's static block. */ + install(build: (instance: E) => H): void { + this.build = build; + } + + /** The host of `instance`, built on first use. */ + of(instance: E): H { + let host = this.hosts.get(instance); + if (host === undefined) { + if (this.build === undefined) { + throw new Error(`${this.facet}: ${this.owner} has not installed its host factory`); + } + host = this.build(instance); + this.hosts.set(instance, host); + } + return host; + } +} + +export const objectRepositoryHost = new InstanceHost('ObjectRepositoryFacet', 'ObjectRepository'); +export const scopedContextHost = new InstanceHost('ScopedContextFacet', 'ScopedContext'); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e7f68d2a7da..2e0c2aa9d23 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -457,6 +457,12 @@ import { referencesAndMasterDetailHost, registrationHost, secretsAndRoutingHost, securityResolversHost, summariesAndExpansionHost, transactionsSectionHost, } from './engine-facet-hosts.js'; +// [#30052 codemod] Repository bound to one object and one execution context: the methods of the same names on `ObjectRepository` hand the bodies what they read (`ObjectRepositoryHost`). +import { ObjectRepositoryFacet } from './engine-object-repository.js'; +// [#30052 codemod] Scoped execution context: repositories, elevation, transactions: the methods of the same names on `ScopedContext` hand the bodies what they read (`ScopedContextHost`). +import { ScopedContextFacet } from './engine-scoped-context.js'; +// [#30052 codemod] The scoped facets' hosts, one per instance, which each class's static block builds. +import { objectRepositoryHost, scopedContextHost } from './engine-scoped-hosts.js'; // [#29986 codemod] The statics of `ObjectQL` its facet modules read, private ones included, as // the class declares them (an indexed access on the class). The static block at the end of @@ -12896,11 +12902,9 @@ export class ObjectRepository implements IScopedObjectRepository { private engine: IDataEngine & { executeAction?: (o: string, a: string, c: any) => Promise } ) {} - async find(query: any = {}): Promise { - return this.engine.find(this.objectName, { - ...query, - context: this.context, - }); + find(query: any = {}): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.find.call(objectRepositoryHost.of(this), query); } /** @@ -12919,22 +12923,20 @@ export class ObjectRepository implements IScopedObjectRepository { * ⛔ Not a narrowing of the contract: the contract already said this. This * is the implementation coming back to the declaration it published. */ - async findOne(query: any = {}): Promise | null> { - return this.engine.findOne(this.objectName, { - ...query, - context: this.context, - }); + findOne(query: any = {}): Promise | null> { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.findOne.call(objectRepositoryHost.of(this), query); } - async insert(data: any): Promise { - return this.engine.insert(this.objectName, data, { - context: this.context, - }); + insert(data: any): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.insert.call(objectRepositoryHost.of(this), data); } /** Alias for insert() — matches @objectql/core convention */ - async create(data: any): Promise { - return this.insert(data); + create(data: any): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.create.call(objectRepositoryHost.of(this), data); } /** @@ -12948,49 +12950,37 @@ export class ObjectRepository implements IScopedObjectRepository { * matches its contract and there is no drift to repair on this side; that * member is `packages/spec`'s to narrow (its spec half: commit 6059b29c0). */ - async update(data: any, options: any = {}): Promise | number | null> { - return this.engine.update(this.objectName, data, { - ...options, - context: this.context, - }); + update(data: any, options: any = {}): Promise | number | null> { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.update.call(objectRepositoryHost.of(this), data, options); } /** Update a single record by ID */ - async updateById(id: string | number, data: any): Promise { - return this.engine.update(this.objectName, { ...data, id: id }, { - where: { id: id }, - context: this.context, - }); + updateById(id: string | number, data: any): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.updateById.call(objectRepositoryHost.of(this), id, data); } - async delete(options: any = {}): Promise { - return this.engine.delete(this.objectName, { - ...options, - context: this.context, - }); + delete(options: any = {}): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.delete.call(objectRepositoryHost.of(this), options); } /** Delete a single record by ID */ - async deleteById(id: string | number): Promise { - return this.engine.delete(this.objectName, { - where: { id: id }, - context: this.context, - }); + deleteById(id: string | number): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.deleteById.call(objectRepositoryHost.of(this), id); } - async count(query: any = {}): Promise { - return this.engine.count(this.objectName, { - ...query, - context: this.context, - }); + count(query: any = {}): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.count.call(objectRepositoryHost.of(this), query); } /** Aggregate query */ - async aggregate(query: any = {}): Promise { - return this.engine.aggregate(this.objectName, { - ...query, - context: this.context, - }); + aggregate(query: any = {}): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.aggregate.call(objectRepositoryHost.of(this), query); } /** @@ -13052,26 +13042,20 @@ export class ObjectRepository implements IScopedObjectRepository { * already retired by the maintainer's 2026-08-31 ruling on #13533 (system * and user writes materialise sharing grants identically today). */ - async execute(actionName: string, params?: any): Promise { - if (this.engine.executeAction) { - // [#25653] With the `'action'` mark, as `buildActionExecutionContext` - // (runtime) gives the REST and MCP doors' action bodies: the action this - // reaches is app-authored automation running elevated, so its writes to - // the four identity-assignment objects are refused (plugin-security). - const executionContext: ExecutionContext = markAppAutomationElevation( - { ...this.context, isSystem: true }, - 'action', - ); - return this.engine.executeAction(this.objectName, actionName, { - ...params, - userId: this.context.userId, - tenantId: this.context.tenantId, - roles: this.context.positions, - executionContext, - api: new ScopedContext(executionContext, this.engine), - }); - } - throw new Error(`Actions not supported by engine`); + execute(actionName: string, params?: any): Promise { + // [#30052 codemod] The body moved to `engine-object-repository.ts` (see `ObjectRepositoryHost`). + return ObjectRepositoryFacet.execute.call(objectRepositoryHost.of(this), actionName, params); + } + + // [#30052 codemod] Installs the host of `ObjectRepositoryFacet` (`engine-scoped-hosts.ts`): a static + // block adds no member to the class, so the published `.d.ts` does not move. + static { + objectRepositoryHost.install((instance) => ({ + get context() { return instance.context; }, + get engine() { return instance.engine; }, + insert: (...a) => instance.insert(...a), + get objectName() { return instance.objectName; }, + })); } } @@ -13110,7 +13094,8 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { /** Get a repository scoped to this context */ object(name: string): ObjectRepository { - return new ObjectRepository(name, this.executionContext, this.engine as any); + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.object.call(scopedContextHost.of(this), name); } /** @@ -13122,12 +13107,8 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * the `'hook'` mark (unless the context already carries one). */ sudo(): ScopedContext { - const elevated = { ...this.executionContext, isSystem: true }; - return this.derive( - this.appAuthored && !appAutomationElevationOf(elevated) - ? markAppAutomationElevation(elevated, 'hook') - : elevated, - ); + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.sudo.call(scopedContextHost.of(this)); } /** @@ -13177,13 +13158,8 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * fall-open, never a silent re-badge as system). */ withRunAs(runAs: Exclude, ref: HookRunAsRef): IScopedContext { - if (runAs === 'system') { - return this.derive( - markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'), - ); - } - if (!this.executionContext.userId) return new UnscopedHookApi(ref); - return this.derive({ ...this.executionContext, isSystem: false }); + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.withRunAs.call(scopedContextHost.of(this), runAs, ref); } /** @@ -13410,46 +13386,14 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { */ private readonly joinedHandles = new Set(); - /** - * Was `handle` JOINED by this context rather than opened by it? Consumes the - * record, so the trio's terminal call is also what forgets the handle. - * - * Two independent signals, because the trio's callers are exactly the ones - * that cannot keep a closure on the stack and may not close on the same - * object they opened on: - * - * 1. this context's own {@link joinedHandles} record, and - * 2. identity with the CURRENT ambient handle — a handle the engine is - * holding open right now is, by construction, not one the trio opened - * (the trio never publishes into `txStore`, so a trio-owned handle is - * never the ambient one). - * - * Both point the same way and both fail SAFE: the ambiguous answer is - * "abstain", never "commit a transaction we do not own". - */ private releaseJoinedHandle(handle: unknown): boolean { - if (this.joinedHandles.delete(handle)) return true; - if (handle == null) return false; - const ambient = (this.engine as any)?.txStore?.getStore?.() as - | { transaction?: unknown } - | undefined; - return ambient?.transaction === handle; + // [#30052 codemod] The body moved to `engine-scoped-context.ts`, its docblock with it (see `ScopedContextHost`). + return ScopedContextFacet.releaseJoinedHandle.call(scopedContextHost.of(this), handle); } - /** - * Resolve the default driver, if it exposes transaction primitives. - * Shared by {@link transaction} and the discrete begin/commit/rollback trio. - */ private txDriver(): any | undefined { - const engine = this.engine as any; - const driver = engine.defaultDriver - ? engine.drivers?.get(engine.defaultDriver) - : undefined; - // [#18063] Declaration, not bare method presence — the trio's `begin` - // returns `null` and `commit`/`rollback` abstain for a transport that - // declared it cannot honour a handle, which is the same graceful degrade a - // driver with no `beginTransaction` already gets. - return driverSupportsTransactions(driver) ? driver : undefined; + // [#30052 codemod] The body moved to `engine-scoped-context.ts`, its docblock with it (see `ScopedContextHost`). + return ScopedContextFacet.txDriver.call(scopedContextHost.of(this)); } /** @@ -13507,27 +13451,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * exactly as before — join is best-effort on visibility, on this face and on * both callback faces alike. */ - async beginTransaction(): Promise<{ ctx: ScopedContext; handle: unknown; owned: boolean } | null> { - // ADR-0067 D2 — JOIN before the driver lookup, the same first move and the - // same position as both callback faces (#6168 / #6406). An ambient - // transaction IS a transaction, so there is nothing to look a driver up for. - const ambient = (this.engine as any)?.txStore?.getStore?.() as - | { transaction?: unknown } - | undefined; - if (ambient?.transaction) { - // Threaded explicitly into the child context, identity-equal to the - // store's handle — so `buildDriverOptions` binds every op on it to the - // outer connection and `transactionCoversDriverFor` still attributes the - // handle to the OUTER owner (#5351 unchanged). - const ctx = this.derive({ ...this.executionContext, transaction: ambient.transaction }); - this.joinedHandles.add(ambient.transaction); - return { ctx, handle: ambient.transaction, owned: false }; - } - const driver = this.txDriver(); - if (!driver) return null; - const trx = await driver.beginTransaction(); - const ctx = this.derive({ ...this.executionContext, transaction: trx }); - return { ctx, handle: trx, owned: true }; + beginTransaction(): Promise<{ ctx: ScopedContext; handle: unknown; owned: boolean } | null> { + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.beginTransaction.call(scopedContextHost.of(this)); } /** @@ -13537,12 +13463,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * did not open would land the inner writes early and take the outcome away * from the outer owner — the durability half of the D2 defect. */ - async commitTransaction(handle: unknown): Promise { - if (this.releaseJoinedHandle(handle)) return; - const driver = this.txDriver(); - if (!driver) return; - if (driver.commit) await driver.commit(handle); - else if (driver.commitTransaction) await driver.commitTransaction(handle); + commitTransaction(handle: unknown): Promise { + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.commitTransaction.call(scopedContextHost.of(this), handle); } /** @@ -13554,12 +13477,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * outer transaction back from here would be the mirror-image error — an inner * failure silently discarding writes the outer caller has not finished with. */ - async rollbackTransaction(handle: unknown): Promise { - if (this.releaseJoinedHandle(handle)) return; - const driver = this.txDriver(); - if (!driver) return; - if (driver.rollback) await driver.rollback(handle); - else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle); + rollbackTransaction(handle: unknown): Promise { + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.rollbackTransaction.call(scopedContextHost.of(this), handle); } get userId() { return this.executionContext.userId; } @@ -13581,7 +13501,22 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * carries, on that transaction's connection. `undefined` when `field` is not * a declared formula field. */ - async serveFormulaField(objectName: string, record: Record, field: string): Promise { - return (this.engine as unknown as ObjectQL).serveFormulaField(objectName, record, field, this.executionContext); + serveFormulaField(objectName: string, record: Record, field: string): Promise { + // [#30052 codemod] The body moved to `engine-scoped-context.ts` (see `ScopedContextHost`). + return ScopedContextFacet.serveFormulaField.call(scopedContextHost.of(this), objectName, record, field); + } + + // [#30052 codemod] Installs the host of `ScopedContextFacet` (`engine-scoped-hosts.ts`): a static + // block adds no member to the class, so the published `.d.ts` does not move. + static { + scopedContextHost.install((instance) => ({ + get appAuthored() { return instance.appAuthored; }, + derive: (...a) => instance.derive(...a), + get engine() { return instance.engine; }, + get executionContext() { return instance.executionContext; }, + get joinedHandles() { return instance.joinedHandles; }, + releaseJoinedHandle: (...a) => instance.releaseJoinedHandle(...a), + txDriver: (...a) => instance.txDriver(...a), + })); } } diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index be4a6e61fe0..6568db311be 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -280,6 +280,8 @@ const STAY_REASONS = Object.freeze({ 'module-write': 'it assigns a module-scope binding of engine.ts (an import is read-only)', 'already-moved': 'its body is already a hand-series seam stub', 'parameter-shadows': 'a parameter shadows a stub local (`host`, `readX`, `writeX`)', + 'foreign-private': 'it reads a private or protected instance member off a receiver other than `this` (a module function cannot)', + 'private-static': 'it reads a non-public static of its class (the statics route is `ObjectQL`\'s alone; the scoped classes declare none)', }); /** Every reason a top-level declaration ahead of the class stays in engine.ts. */ @@ -559,6 +561,13 @@ function readMethod(tm, ctx, m) { if (kind === SyntaxKind.SuperKeyword) add('super'); if (kind === SyntaxKind.ThisType) add('this-type'); if (Node.isMetaProperty(node) && node.getKeywordToken() === SyntaxKind.NewKeyword) add('new-target'); + if (Node.isPropertyAccessExpression(node) && node.getExpression().getKind() !== SyntaxKind.ThisKeyword) { + // `other.x` where `x` is a private or protected INSTANCE member of the + // class: legal in the class body, refused in a module function. (A + // static is the statics route's question, not this one.) + const foreign = foreignNonPublicMember(tm, ctx, checker, node); + if (foreign) add('foreign-private', foreign); + } if (kind === SyntaxKind.ThisKeyword) { if (thisContainer(tm, node) !== m) continue; if (m.isStatic()) { add('static-this'); continue; } @@ -619,7 +628,7 @@ function readMethod(tm, ctx, m) { // for it) when it stayed. Two things a module function cannot do: // reach a non-public static of the class, and assign an import. const declName = decl.name && ts.isIdentifier(decl.name) ? decl.name.text : node.getText(); - if (ts.isClassDeclaration(decl) && declName === CLASS_NAME) { + if (ts.isClassDeclaration(decl) && declName === (ctx.className ?? CLASS_NAME)) { const access = node.getParent(); const info = !typePos && Node.isPropertyAccessExpression(access) && access.getExpression() === node ? ctx.statics.get(access.getName()) : null; @@ -646,7 +655,10 @@ function readMethod(tm, ctx, m) { } const binding = ctx.scope.bindings.get(name); - if (binding && binding.kind === 'import' && SEAM_SPECIFIER.test(binding.specifier)) add('already-moved', binding.specifier); + // A hand-series seam stub calls the `engine-*` import of its own name; a + // method that only shares the name (`ObjectRepository#count` beside the + // hand-series `count`) and never reads that import is not one. + if (binding && binding.kind === 'import' && SEAM_SPECIFIER.test(binding.specifier) && imports.has(name)) add('already-moved', binding.specifier); for (const p of params) { const pn = p.getName(); if (pn === 'host' || /^(read|write)[A-Z]/.test(pn)) add('parameter-shadows', pn); @@ -661,6 +673,22 @@ function readMethod(tm, ctx, m) { return result(); } +/** The name of the class's non-public instance member `pa` reads, or null. */ +function foreignNonPublicMember(tm, ctx, checker, pa) { + const ts = tm.ts; + const nameNode = pa.getNameNode(); + if (!tm.Node.isIdentifier(nameNode)) return null; + const symbol = checker.getSymbolAtLocation(nameNode.compilerNode); + for (const decl of symbol?.declarations ?? []) { + const owner = ts.isParameter(decl) && decl.parent && ts.isConstructorDeclaration(decl.parent) ? decl.parent.parent : decl.parent; + if (owner !== ctx.cls.compilerNode) continue; + const flags = ts.getCombinedModifierFlags(decl); + if (flags & ts.ModifierFlags.Static) continue; + if (flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return nameNode.getText(); + } + return null; +} + function isModuleScopeDecl(ts, decl) { if (ts.isVariableDeclaration(decl)) { const statement = decl.parent && decl.parent.parent; @@ -702,7 +730,7 @@ const STATICS_HOST_NAME = `${CLASS_NAME}StaticsHost`; const CLASS_TYPE_ALIAS = `${CLASS_NAME}Class`; /** The placeholder above a moved public or protected body: its docblock stays on the class. */ -const placeholderDoc = (name) => `/** The body of \`${CLASS_NAME}#${name}\`; the method's docblock there is its contract. */`; +const placeholderDoc = (name, className = CLASS_NAME) => `/** The body of \`${className}#${name}\`; the method's docblock there is its contract. */`; /** * The statics block `engine.ts` carries for the routed facets: the type and @@ -1160,7 +1188,7 @@ function importPair(specifier, uses) { * engine.ts's import order; then the moved top-level names, from the leaf; * then what stayed in engine.ts (lever (a)), including the class. */ -function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), leafNames = new Map(), routed = false } = {}) { +function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), leafNames = new Map(), routed = false, className = CLASS_NAME } = {}) { const bySpecifier = new Map(); for (const r of users) { for (const [local, use] of r.imports) { @@ -1201,7 +1229,7 @@ function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), fromHost.delete(CLASS_NAME); fromHost.set(`${STATICS_NAME} as ${CLASS_NAME}`, { value: true }); if (needsHost) fromHost.set(`${CLASS_NAME} as ${CLASS_TYPE_ALIAS}`, { value: false }); - } else if (needsHost && !fromHost.has(CLASS_NAME)) fromHost.set(CLASS_NAME, { value: false }); + } else if (needsHost && !fromHost.has(className)) fromHost.set(className, { value: false }); out.push(...importPair(leafSpecifier(), fromLeaf)); out.push(...importPair('./engine.js', fromHost)); return out; @@ -1368,7 +1396,7 @@ const DOC_MOVED = 'its docblock with it'; * call. `node` is the method (base or head: the stub keeps its signature). * Pure: `verify` rebuilds every stub with it and compares. */ -function stubBody({ node, name, usesHost, docMoved }, moduleFile, closeIndent) { +function stubBody({ node, name, usesHost, docMoved }, moduleFile, closeIndent, mark = MARK) { const ind = `${closeIndent} `; const hostName = hostNameOf(moduleFile); // A generic method calls its body INSTANTIATED with its own type @@ -1379,10 +1407,10 @@ function stubBody({ node, name, usesHost, docMoved }, moduleFile, closeIndent) { const args = forwardList(node).join(', '); const doc = docMoved ? `, ${DOC_MOVED}` : ''; if (!usesHost) { - out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`${doc}.`); + out.push(`${ind}// ${mark} The body moved to \`${moduleFile}\`${doc}.`); out.push(`${ind}return ${fn}(${args});`); } else { - out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`${doc} (see \`${hostName}\`).`); + out.push(`${ind}// ${mark} The body moved to \`${moduleFile}\`${doc} (see \`${hostName}\`).`); out.push(`${ind}return ${fn}.call(${hostVarOf(moduleFile)}.of(this)${args ? `, ${args}` : ''});`); } out.push(`${closeIndent}}`); @@ -2026,11 +2054,617 @@ function generatedModulesOnDisk() { return out; } +// ── The scoped classes (#30052) ────────────────────────────────────────────── +// +// The second pass. `engine.ts` declares two more classes after `ObjectQL`: +// `ObjectRepository` (what `ctx.api.object(name)` returns) and `ScopedContext` +// (what a hook reaches as `ctx.api`). This pass facades them with the same cut +// pattern, the same stay rules and the same byte-identity proof as the first, +// and touches nothing else: its input is the `engine.ts` the first pass left +// (any `main` after #29986), it is proved against that file, and so it is +// re-run on the latest `main` at landing, never rebased. +// +// - ONE section per class, so one facet module per class (`SCOPED_CLASSES`). +// Each `engine-.ts` holds one stateless facet class whose static +// methods are the moved bodies, unedited, with `this` typed as a `Pick` of +// the class's host interface, the members typed AS the class members. +// - One host per INSTANCE (`SCOPED_HOSTS_MODULE`), built on the first call that +// needs it by the factory the class's own static block installs, and kept +// for the instance's life: `#30051`'s shape, keyed by the instance instead +// of the engine. The module imports types only, for the same reason the +// first pass's host module does. +// - Neither class declares a static or a top-level declaration ahead of it, +// so no statics route and no leaf: a body that reads a non-public static +// stays (`private-static`). A body that reads a private member off another +// instance stays too (`foreign-private`): the class body may, a module +// function may not. + +const SCOPED_CARD = '#30052'; + +/** The marker every file and stub the second pass writes carries; its proof keys on it. */ +const SCOPED_MARK = `[${SCOPED_CARD} codemod]`; + +/** The scoped classes, in file order: the class, its facet module, its section title. */ +const SCOPED_CLASSES = Object.freeze([ + ['ObjectRepository', 'engine-object-repository.ts', 'Repository bound to one object and one execution context'], + ['ScopedContext', 'engine-scoped-context.ts', 'Scoped execution context: repositories, elevation, transactions'], +]); + +/** The module that holds each scoped facet's per-instance host. Types-only imports, like `HOSTS_MODULE`. */ +const SCOPED_HOSTS_MODULE = 'engine-scoped-hosts.ts'; +const SCOPED_HOSTS_CLASS = 'InstanceHost'; + +/** The marker line of a scoped stub: the module it names, and whether the docblock moved. */ +const scopedStubMarker = () => new RegExp( + `^\\{\\s*// ${SCOPED_MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(engine-[a-z0-9-]+\\.ts)\`(, ${DOC_MOVED})?`); + +/** + * Read `text` (an `engine.ts` whose scoped classes are not split yet) and + * decide, per method of each scoped class, whether its body moves. Pure. + */ +function planScoped(tm, text, { classes = SCOPED_CLASSES } = {}) { + const { Node } = tm; + const sf = parse(tm, HOST_PATH, text); + const scope = moduleScope(tm, sf); + const targets = []; + for (const [className, moduleFile, title] of classes) { + const cls = sf.getClass(className); + if (!cls) refuse(`no class ${className} in ${HOST_BASENAME}`); + for (const n of [facetNameOf(moduleFile), hostNameOf(moduleFile), hostVarOf(moduleFile), SCOPED_HOSTS_CLASS]) { + if (scope.bindings.has(n)) refuse(`${HOST_BASENAME} already binds ${n}, a name this script generates`); + } + const statics = new Map(); + for (const m of cls.getMembers()) { + if (typeof m.isStatic === 'function' && m.isStatic() && typeof m.getName === 'function') { + statics.set(m.getName(), { scope: typeof m.getScope === 'function' ? m.getScope() : 'public', + kind: Node.isMethodDeclaration(m) ? 'method' : 'field', overloaded: false }); + } + } + const ctx = { text, sf, cls, className, members: instanceMembers(tm, cls), scope, statics, pins: {}, needles: {}, classNeedles: {} }; + const records = []; + for (const m of cls.getMethods()) { + const read = readMethod(tm, ctx, m); + if (read.readsNonPublicStatic) read.reasons.push('private-static'); + const name = m.getName(); + for (const p of m.getParameters()) { + if (p.getName() === facetNameOf(moduleFile) || p.getName() === hostVarOf(moduleFile)) read.reasons.push(`parameter-shadows: ${p.getName()}`); + } + const startLine = m.getStartLineNumber(true); + const body = m.getBody(); + const docStart = m.getStart(true); + const doc = m.getScope() === 'private' && docStart < m.getStart() ? text.slice(docStart, m.getStart()) : null; + const record = { + name, node: m, className, section: title, module: moduleFile, startLine, + lineCount: m.getEndLineNumber() - startLine + 1, + bodyLines: body ? body.getEndLineNumber() - body.getStartLineNumber() + 1 : 0, + doc, docLines: doc ? doc.split('\n').length - 1 : 0, ...read, + }; + if (record.reasons.length === 0) { + record.stubLines = stubBody({ node: m, name, usesHost: read.host.size > 0, docMoved: Boolean(doc) }, moduleFile, ' ', SCOPED_MARK).split('\n').length; + } + records.push(record); + } + const movers = records.filter((r) => r.reasons.length === 0); + for (const r of movers) { + for (const local of [...r.imports.keys(), ...r.moduleRefs.keys()]) { + if (local === facetNameOf(moduleFile) || local === hostNameOf(moduleFile)) { + refuse(`${moduleFile}: ${r.name} reads ${local}, a name this script generates in that module`); + } + } + } + targets.push({ className, moduleFile, title, cls, records, movers }); + } + // Lever (a): a staying top-level name of engine.ts a moved body reads, which + // engine.ts does not export yet, is exported for the facet modules. + const hostExports = new Map(); + for (const t of targets) { + for (const r of t.movers) { + for (const [n, use] of r.moduleRefs) { + if (scope.exported.has(n)) continue; + const prev = hostExports.get(n); + hostExports.set(n, { value: Boolean(prev && prev.value) || use.value }); + } + } + } + return { sf, scope, targets, hostExports }; +} + +/** A scoped facet's host members (member -> `method` | `getter`) and the fields its bodies assign. */ +function scopedFacetOf(t) { + const members = new Map(); + const written = new Set(); + for (const r of t.movers) for (const [member, kind] of r.host) members.set(member, kind); + for (const r of t.movers) for (const member of r.writes) written.add(member); + return { className: t.className, moduleFile: t.moduleFile, members, written }; +} + +/** + * The per-instance hosts: `SCOPED_HOSTS_MODULE`'s text, and, per class, the + * static block at the end of the class that installs its facet's factory. + * Each member forwards as the first pass's hosts do: a method as a closure + * called on the instance at call time, a field or accessor as a getter, and a + * field a body assigns with a setter too. Pure: the proof rebuilds both from + * the head's host interfaces and the base classes, and compares. + */ +function scopedHostsTexts(facets) { + const sorted = [...facets].sort((a, b) => (a.moduleFile < b.moduleFile ? -1 : a.moduleFile > b.moduleFile ? 1 : 0)); + const classNames = [...new Set(sorted.map((f) => f.className))].sort(); + const module = [ + '// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.', + '', + '/**', + ` * ${SCOPED_MARK} The host of each facet of ${classNames.map((c) => `\`${c}\``).join(' and ')},`, + ' * built ONCE per instance: what every stub of a facet hands that facet\'s', + ' * bodies as `this`. Each host below is filled by the factory its class\'s', + ' * static block installs, the one place that can read the class\'s private', + ' * members; the host is built on the first call that needs it and kept for', + ' * the instance\'s life. A method goes as a closure that calls it on the', + ' * instance at call time, a field or accessor as a getter (and a setter when', + ' * a body assigns it), so a body reads and writes the instance exactly as', + ' * the method did.', + ' *', + ` * Written by \`${SCRIPT}\` (its second pass). It imports`, + ' * types only, so it forms no evaluation cycle and is evaluated before the', + ' * classes whichever module loads first. `index.ts` re-exports nothing from', + ' * this module.', + ' */', + '', + `import type { ${classNames.join(', ')} } from './engine.js';`, + ...sorted.map((f) => `import type { ${hostNameOf(f.moduleFile)} } from './${f.moduleFile.replace(/\.ts$/, '.js')}';`), + '', + '/** One facet\'s host for each instance of its class: built on first use, kept for the instance\'s life. */', + `export class ${SCOPED_HOSTS_CLASS} {`, + ' private readonly hosts = new WeakMap();', + ' private build: ((instance: E) => H) | undefined;', + '', + ' constructor(private readonly facet: string, private readonly owner: string) {}', + '', + ' /** Called once, from the owning class\'s static block. */', + ' install(build: (instance: E) => H): void {', + ' this.build = build;', + ' }', + '', + ' /** The host of `instance`, built on first use. */', + ' of(instance: E): H {', + ' let host = this.hosts.get(instance);', + ' if (host === undefined) {', + ' if (this.build === undefined) {', + ' throw new Error(`${this.facet}: ${this.owner} has not installed its host factory`);', + ' }', + ' host = this.build(instance);', + ' this.hosts.set(instance, host);', + ' }', + ' return host;', + ' }', + '}', + '', + ...sorted.map((f) => `export const ${hostVarOf(f.moduleFile)} = new ${SCOPED_HOSTS_CLASS}<${f.className}, ${hostNameOf(f.moduleFile)}>` + + `('${facetNameOf(f.moduleFile)}', '${f.className}');`), + '', + ].join('\n'); + const blocks = new Map(); + for (const f of sorted) { + blocks.set(f.className, [ + ` // ${SCOPED_MARK} Installs the host of \`${facetNameOf(f.moduleFile)}\` (\`${SCOPED_HOSTS_MODULE}\`): a static`, + ' // block adds no member to the class, so the published `.d.ts` does not move.', + ' static {', + ` ${hostVarOf(f.moduleFile)}.install((instance) => ({`, + ...[...f.members.keys()].sort().flatMap((member) => { + if (f.members.get(member) === 'getter') { + return [` get ${member}() { return instance.${member}; },`, + ...(f.written.has(member) ? [` set ${member}(v) { instance.${member} = v; },`] : [])]; + } + return [` ${member}: (...a) => instance.${member}(...a),`]; + }), + ' }));', + ' }', + ].join('\n')); + } + return { module, blocks }; +} + +/** A scoped facet module: the header, the imports, the host interface, the facet class. Pure. */ +function scopedModuleText(t, scope) { + const { className, moduleFile, title, movers } = t; + const hostName = hostNameOf(moduleFile); + const facet = facetNameOf(moduleFile); + const { members: hostMembers, written } = scopedFacetOf(t); + const needsHost = hostMembers.size > 0; + const moduleRefs = new Map(); + for (const r of movers) { + for (const [n, use] of r.moduleRefs) { + const prev = moduleRefs.get(n); + moduleRefs.set(n, { value: Boolean(prev && prev.value) || use.value }); + } + } + const readsHostValues = [...moduleRefs.values()].some((use) => use.value); + const names = movers.map((r) => `\`${r.name}\`${r === movers[movers.length - 1] ? '' : ','}`); + const out = []; + out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); + out.push(''); + out.push('/**'); + out.push(...wrapComment([SCOPED_MARK, 'The', ...`"${title}"`.split(' '), 'section:', 'the', 'bodies', 'of', `\`${className}\`'s`, 'methods', + ...names, 'moved', 'out', 'of', '`engine.ts`', 'whole', 'by', `\`${SCRIPT}\``, '(its', 'second', 'pass).'])); + out.push(' *'); + out.push(...wrapComment((`A MOVE, not a redesign. {@link ${facet}} is a stateless facet of \`${className}\`: each static ` + + `method of it is the body of the \`${className}\` method of the same name exactly as it stood: not re-indented, not ` + + 'formatted, not edited. The method stays on the class as a one-call stub with its signature, so the published ' + + 'surface, an instance spy and a subclass override are what they were. A public method keeps its docblock on the ' + + `class, the contract; a private method's docblock moved here with its body. The state stays on \`${className}\`.`).split(' '))); + out.push(' *'); + if (needsHost) { + out.push(...wrapComment((`The bodies reach the instance through {@link ${hostName}}: exactly the \`${className}\` ` + + 'members they read, private ones included. Every stub hands its body, by a `.call`, the same host: ' + + `\`${hostVarOf(moduleFile)}\` in \`${SCOPED_HOSTS_MODULE}\`, built once per instance by the class's static block. ` + + 'Methods go as closures that call them on the instance at call time; fields and accessors go as GETTERS, read when ' + + 'the body reads them, and a field a body assigns goes with a SETTER that assigns it on the instance.').split(' '))); + } else { + out.push(` * No body reads a \`${className}\` member, so each stub calls its body directly.`); + } + out.push(' *'); + if (readsHostValues) { + out.push(' * Values declared in `engine.ts` are imported from it (lever (a) of'); + out.push(' * ruling 6591753301); every use is inside a function body, so the import'); + out.push(' * cycle is never read at evaluation time. `index.ts` re-exports nothing from'); + out.push(' * this module.'); + } else { + out.push(' * The module imports `engine.ts` for types only, so the two modules form no'); + out.push(' * evaluation cycle. `index.ts` re-exports nothing from this module.'); + } + out.push(' */'); + out.push(''); + out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames: new Map(), className })); + if (needsHost) { + out.push(''); + out.push('/**'); + out.push(...wrapComment((`The \`${className}\` members the bodies of {@link ${facet}} read — every one of them, and ` + + 'no other. Each is typed AS the class member (an indexed access on the class), so a change to a member\'s ' + + 'signature on the class is a change here too. `readonly` members are the getters; the others are fields a body ' + + 'assigns.').split(' '))); + out.push(' */'); + out.push(`export interface ${hostName} {`); + for (const member of [...hostMembers.keys()].sort()) { + const ro = hostMembers.get(member) === 'getter' && !written.has(member); + out.push(` ${ro ? 'readonly ' : ''}${member}: ${className}['${member}'];`); + } + out.push('}'); + } + out.push(''); + out.push(`/** The bodies of \`${className}\`'s methods: a stateless facet, called by the class's stubs. */`); + out.push(`export class ${facet} {`); + movers.forEach((r, i) => { + const m = r.node; + const picks = [...r.host.keys()].sort().map((k) => `'${k}'`).join(' | '); + const params = [...(r.host.size > 0 ? [`this: Pick<${hostName}, ${picks}>`] : []), ...paramsText(m)]; + const ret = m.getReturnTypeNode() ? `: ${m.getReturnTypeNode().getText()}` : ''; + if (i > 0) out.push(''); + const typeParams = m.getTypeParameters().length > 0 ? `<${m.getTypeParameters().map((tp) => tp.getText()).join(', ')}>` : ''; + out.push(` ${r.doc ?? `${placeholderDoc(r.name, className)}\n `}static ${m.isAsync() ? 'async ' : ''}${r.name}${typeParams}(${params.join(', ')})${ret} ${m.getBody().getText()}`); + }); + out.push('}'); + out.push(''); + return out.join('\n'); +} + +/** + * Apply a scoped plan to `text`. Returns `{ host, modules, pruned }` as + * `generate` does. Pure. + */ +function generateScoped(tm, text, planned) { + const { sf, scope, targets, hostExports } = planned; + const edits = []; + const importBlock = []; + const facets = []; + for (const t of targets) { + if (t.movers.length === 0) continue; + const hostName = hostNameOf(t.moduleFile); + const anyHost = t.movers.some((r) => r.host.size > 0); + importBlock.push(`\n// ${SCOPED_MARK} ${t.title}: the methods of the same names on \`${t.className}\` hand the bodies` + + `${anyHost ? ` what they read (\`${hostName}\`)` : ' their arguments'}.`); + importBlock.push(`\nimport { ${facetNameOf(t.moduleFile)} } from './${t.moduleFile.replace(/\.ts$/, '.js')}';`); + for (const r of t.movers) { + const body = r.node.getBody(); + const lineStart = text.lastIndexOf('\n', body.getEnd() - 1) + 1; + const closeIndent = text.slice(lineStart, body.getEnd() - 1); + if (!/^ *$/.test(closeIndent)) refuse(`${t.className}#${r.name}: the body's closing brace does not open its own line`); + const stub = stubBody({ node: r.node, name: r.name, usesHost: r.host.size > 0, docMoved: Boolean(r.doc) }, t.moduleFile, closeIndent, SCOPED_MARK); + edits.push({ start: body.getStart(), end: body.getEnd(), text: stub }); + const asyncKw = r.node.getModifiers().find((x) => x.getKind() === tm.SyntaxKind.AsyncKeyword); + if (asyncKw) edits.push({ start: asyncKw.getStart(), end: r.node.getNameNode().getStart(), text: '' }); + if (r.doc) edits.push({ start: r.node.getStart(true), end: r.node.getStart(), text: '' }); + } + const facet = scopedFacetOf(t); + if (facet.members.size > 0) facets.push(facet); + } + const hosts = facets.length > 0 ? scopedHostsTexts(facets) : null; + if (hosts) { + for (const f of facets) { + const t = targets.find((x) => x.className === f.className); + const last = t.cls.getMembers().at(-1); + edits.push({ start: last.getEnd(), end: last.getEnd(), text: `\n\n${hosts.blocks.get(f.className)}` }); + } + importBlock.push(`\n// ${SCOPED_MARK} The scoped facets' hosts, one per instance, which each class's static block builds.`); + importBlock.push(`\n${namedStatement('import', facets.map((f) => hostVarOf(f.moduleFile)).sort(), ` from './${SCOPED_HOSTS_MODULE.replace(/\.ts$/, '.js')}';`)}`); + } + if (hostExports.size > 0) { + const spelled = [...hostExports.keys()].sort().map((n) => { + const st = sf.getStatements().find((s) => (declaredNames(tm, s) ?? []).includes(n)); + return st && (tm.Node.isInterfaceDeclaration(st) || tm.Node.isTypeAliasDeclaration(st)) ? `type ${n}` : n; + }); + importBlock.push(`\n// ${SCOPED_MARK} Exported for the scoped facet modules above (lever (a)); \`index.ts\` re-exports by name, so none reaches the package.`); + importBlock.push(`\n${namedStatement('export', spelled, ';')}`); + } + const lastImport = sf.getImportDeclarations().at(-1); + if (!lastImport) refuse('engine.ts has no import declaration to place the generated imports after'); + if (importBlock.length > 0) edits.push({ start: lastImport.getEnd(), end: lastImport.getEnd(), text: importBlock.join('') }); + edits.sort((a, b) => b.start - a.start || b.end - a.end); + let out = text; + let floor = Infinity; + for (const e of edits) { + if (e.end > floor) refuse('overlapping edits'); + out = out.slice(0, e.start) + e.text + out.slice(e.end); + floor = e.start; + } + const pruned = pruneImports(tm, out, usedImportNames(tm, out)); + const moduleTexts = new Map(); + for (const t of targets) if (t.movers.length > 0) moduleTexts.set(t.moduleFile, scopedModuleText(t, scope)); + if (hosts) moduleTexts.set(SCOPED_HOSTS_MODULE, hosts.module); + return { host: pruned.text, modules: moduleTexts, pruned: pruned.removed }; +} + +/** + * Prove a scoped split against its base, from the texts alone, as `verify` + * proves the first pass: every generated function is its base method's text, + * every stub is the generated one-call stub, the host module and each install + * block are the generated ones, and undoing the split rebuilds the base + * `engine.ts` byte-for-byte once the base's import bindings the head no longer + * reads are pruned from it (each pruned binding must occur in moved text). + * The first pass's text, `ObjectQL` among it, is compared like every other + * byte: it must not move. + */ +function verifyScoped(tm, baseText, headText, moduleTexts, { classes = SCOPED_CLASSES } = {}) { + const { Node } = tm; + const failures = []; + const moved = []; + const baseSf = parse(tm, HOST_PATH, baseText); + const headSf = parse(tm, HOST_PATH, headText); + const prune = pruneImports(tm, baseText, usedImportNames(tm, headText)); + const target = prune.text; + const ownModule = new Map(classes.map(([className, moduleFile]) => [moduleFile, className])); + + const modules = new Map(); + let hostsOnDisk = null; + for (const [file, text] of moduleTexts) { + if (!text.includes(SCOPED_MARK)) continue; + if (file === SCOPED_HOSTS_MODULE) { hostsOnDisk = text; continue; } + if (!ownModule.has(file)) { failures.push(`${file}: carries ${SCOPED_MARK} but is the module of no scoped class`); continue; } + const sf = parse(tm, join(SRC_DIR, file), text); + const fns = new Map(); + const facet = facetNameOf(file); + for (const st of sf.getStatements()) { + if (Node.isImportDeclaration(st)) continue; + if (Node.isInterfaceDeclaration(st) && st.getName() === hostNameOf(file)) continue; + if (Node.isClassDeclaration(st) && st.getName() === facet && !fns.size) { + for (const member of st.getMembers()) { + if (Node.isMethodDeclaration(member) && member.isStatic() && member.getBody() && !fns.has(member.getName())) fns.set(member.getName(), member); + else failures.push(`${file}: ${facet} has a member that is not a moved body: ${member.getText().slice(0, 60)}`); + } + continue; + } + failures.push(`${file}: a top-level statement that is neither an import, the host interface nor the facet class ${facet}: ${st.getText().slice(0, 60)}`); + } + modules.set(file, { sf, text, fns, claimed: new Set() }); + } + + const marker = scopedStubMarker(); + const movedDocs = []; + const edits = []; + const facets = []; + for (const [className, moduleFile] of classes) { + const headCls = headSf.getClass(className); + const baseCls = baseSf.getClass(className); + if (!headCls || !baseCls) { failures.push(`no class ${className} at base or head`); continue; } + const mod = modules.get(moduleFile); + for (const m of headCls.getMethods()) { + const body = m.getBody(); + if (!body) continue; + const hit = marker.exec(body.getText()); + if (!hit) continue; + const name = m.getName(); + if (hit[1] !== moduleFile) { failures.push(`${className}#${name}: its stub names ${hit[1]}, not its class's module ${moduleFile}`); continue; } + const fn = mod && mod.fns.get(name); + if (!fn) { failures.push(`${className}#${name}: its stub names ${moduleFile}, which has no function ${name}`); continue; } + mod.claimed.add(name); + const lineStart = headText.lastIndexOf('\n', body.getEnd() - 1) + 1; + const closeIndent = headText.slice(lineStart, body.getEnd() - 1); + const usesHost = fn.getParameters().some((p) => p.getName() === 'this'); + const want = stubBody({ node: m, name, usesHost, docMoved: Boolean(hit[2]) }, moduleFile, closeIndent, SCOPED_MARK); + if (body.getText() !== want) failures.push(`${className}#${name}: its stub is not the generated one-call stub (want ${JSON.stringify(want.split('\n').at(-2).trim())})`); + const base = baseCls.getMethods().find((x) => x.getName() === name && x.isStatic() === m.isStatic()); + if (!base || !base.getBody()) { failures.push(`${className}#${name}: no method of that name with a body at base`); continue; } + const same = { + body: fn.getBody().getText() === base.getBody().getText(), + params: JSON.stringify(fn.getParameters().filter((p) => p.getName() !== 'this').map((p) => p.getText())) === + JSON.stringify(base.getParameters().map((p) => p.getText())), + returnType: (fn.getReturnTypeNode()?.getText() ?? null) === (base.getReturnTypeNode()?.getText() ?? null), + async: fn.isAsync() === base.isAsync(), + typeParameters: JSON.stringify(fn.getTypeParameters().map((x) => x.getText())) === JSON.stringify(base.getTypeParameters().map((x) => x.getText())), + static: m.isStatic() === base.isStatic(), + }; + const lead = mod.text.slice(fn.getStart(true), fn.getStart()); + const docMoved = Boolean(hit[2]); + if (docMoved) { + if (m.getStart(true) !== m.getStart()) failures.push(`${className}#${name}: its stub says the docblock moved, but the class still carries one`); + if (m.getScope() !== 'private') failures.push(`${className}#${name}: a ${m.getScope()} method's docblock moved off the class (only a private one may)`); + edits.push({ start: m.getStart(), end: m.getStart(), text: lead }); + movedDocs.push(lead); + } else if (lead !== `${placeholderDoc(name, className)}\n `) { + failures.push(`${className}#${name}: the facet member's leading text is neither its moved docblock nor the placeholder`); + } + for (const [what, ok] of Object.entries(same)) if (!ok) failures.push(`${className}#${name}: the generated function's ${what} is not the base method's`); + moved.push({ + name: `${className}#${name}`, module: moduleFile, lines: base.getBody().getEndLineNumber() - base.getBody().getStartLineNumber() + 1, + text: fn.getText(), docMoved, ...same, + }); + edits.push({ start: body.getStart(), end: body.getEnd(), text: fn.getBody().getText() }); + if (fn.isAsync()) edits.push({ start: m.getNameNode().getStart(), end: m.getNameNode().getStart(), text: 'async ' }); + } + // The class's install block: exactly what `scopedHostsTexts` writes for + // the host interface on disk, each member forwarded as the BASE class + // declares it. Then the undo drops it. + const blocks = headCls.getMembers().filter((x) => Node.isClassStaticBlockDeclaration(x) && x.getFullText().includes(SCOPED_MARK)); + const iface = mod ? mod.sf.getInterface(hostNameOf(moduleFile)) : undefined; + if (iface || blocks.length > 0) { + const baseMembers = instanceMembers(tm, baseCls); + const members = new Map(); + const written = new Set(); + for (const p of iface ? iface.getProperties() : []) { + const n = p.getName(); + const info = baseMembers.get(n); + if (!info) { failures.push(`${moduleFile}: ${hostNameOf(moduleFile)}.${n} names no instance member of the base class ${className}`); continue; } + members.set(n, info.kind === 'method' ? 'method' : 'getter'); + if (info.kind !== 'method' && !p.isReadonly()) written.add(n); + } + const facet = { className, moduleFile, members, written }; + if (iface) facets.push(facet); + const want = scopedHostsTexts([facet]).blocks.get(className); + const blockAt = blocks.length === 1 ? headText.lastIndexOf(` // ${SCOPED_MARK} Installs`, blocks[0].getStart()) : -1; + if (!iface || blocks.length !== 1 || blockAt < 0 || headText.slice(blockAt, blocks[0].getEnd()) !== want) { + failures.push(`${className}: its host static block is not the generated installer of ${hostNameOf(moduleFile)}`); + } else { + edits.push({ start: headText.lastIndexOf('\n\n', blockAt), end: blocks[0].getEnd(), text: '' }); + } + } + } + for (const [file, mod] of modules) { + for (const name of mod.fns.keys()) if (!mod.claimed.has(name)) failures.push(`${file}: function ${name} has no stub on ${ownModule.get(file)}`); + } + if (facets.length > 0 || hostsOnDisk !== null) { + if (hostsOnDisk !== scopedHostsTexts(facets).module) failures.push(`${SCOPED_HOSTS_MODULE} is not the generated host module for the scoped facets on disk`); + } + const generatedSpecifier = (spec) => { + const hit = /^\.\/(engine-[a-z0-9-]+)\.js$/.exec(spec ?? ''); + return Boolean(hit && (ownModule.has(`${hit[1]}.ts`) || `${hit[1]}.ts` === SCOPED_HOSTS_MODULE)); + }; + for (const st of headSf.getStatements()) { + const drop = (Node.isImportDeclaration(st) && generatedSpecifier(st.getModuleSpecifierValue())) || + (Node.isExportDeclaration(st) && !st.getModuleSpecifier() && st.getFullText().includes(SCOPED_MARK)); + if (drop) edits.push({ start: st.getFullStart(), end: st.getEnd(), text: '' }); + } + edits.sort((a, b) => b.start - a.start || b.end - a.end); + let rebuilt = headText; + for (const e of edits) rebuilt = rebuilt.slice(0, e.start) + e.text + rebuilt.slice(e.end); + + const movedText = [...moved.map((m) => m.text), ...movedDocs].join('\n'); + for (const n of prune.removed) { + if (!new RegExp(`(^|[^\\w$])${n.replace(/\$/g, '\\$')}($|[^\\w$])`).test(movedText)) { + failures.push(`the head dropped the import \`${n}\`, which no moved body reads`); + } + } + if (rebuilt !== target) { + const a = rebuilt.split('\n'); + const b = target.split('\n'); + let i = 0; + while (i < Math.max(a.length, b.length) && a[i] === b[i]) i += 1; + failures.push(`undoing the scoped split does not rebuild the base engine.ts (its unread imports pruned): first difference at line ${i + 1}` + + `\n rebuilt: ${JSON.stringify(a[i] ?? '')}\n base: ${JSON.stringify(b[i] ?? '')}`); + } + if (moved.length === 0) failures.push('no moved method found: a proof over nothing is not a proof'); + return { failures, moved, pruned: prune.removed, linesBefore: lineCount(baseText), linesAfter: lineCount(headText) }; +} + +function planReportScoped(planned) { + const out = []; + const reasonCounts = new Map(); + let movedLines = 0; + let stubLines = 0; + for (const t of planned.targets) { + const lines = t.movers.reduce((n, r) => n + r.bodyLines, 0); + const stubs = t.movers.reduce((n, r) => n + r.stubLines, 0); + movedLines += lines; + stubLines += stubs; + out.push(`§ ${t.className} -> ${t.moduleFile}: ${t.movers.length}/${t.records.length} methods move (${lines} body lines out, ${stubs} stub lines in)`); + for (const r of t.records) { + if (r.reasons.length === 0) out.push(` move ${r.name} (body ${r.bodyLines} -> stub ${r.stubLines})`); + else out.push(` stay ${r.name} (${r.lineCount}) — ${r.reasons.join('; ')}`); + for (const reason of r.reasons) { + const code = reason.split(':')[0]; + reasonCounts.set(code, (reasonCounts.get(code) ?? 0) + 1); + } + } + } + out.push(''); + out.push(`scoped total: ${planned.targets.reduce((n, t) => n + t.movers.length, 0)}/${planned.targets.reduce((n, t) => n + t.records.length, 0)} ` + + `methods move: ${movedLines} body lines out, ${stubLines} stub lines in`); + out.push(`stay reasons (a method may carry several): ${[...reasonCounts].sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c} ${n}`).join(', ') || 'none'}`); + out.push(`lever (a): ${planned.hostExports.size} engine.ts name(s) exported for the scoped facet modules` + + `${planned.hostExports.size ? ` (${[...planned.hostExports.keys()].sort().join(', ')})` : ''}`); + return out.join('\n'); +} + +function printVerifyScoped(v) { + for (const f of v.failures) console.error(` ✗ ${f}`); + const identical = v.moved.filter((m) => m.body && m.params && m.returnType && m.async).length; + console.log(`byte-identity (scoped classes): ${identical}/${v.moved.length} moved bodies identical to base (body, parameters, return type, async); ` + + `engine.ts ${v.linesBefore} -> ${v.linesAfter} lines; undoing the split ${v.failures.some((f) => f.startsWith('undoing')) ? 'does NOT' : 'does'} rebuild the base byte-for-byte ` + + `once the ${v.pruned.length} import binding(s) only moved text reads are pruned from the base (${v.pruned.join(', ') || 'none'})`); +} + +function scopedModulesOnDisk() { + const out = new Map(); + for (const file of [SCOPED_HOSTS_MODULE, ...SCOPED_CLASSES.map((row) => row[1])]) { + const p = join(SRC_DIR, file); + if (existsSync(p)) out.set(file, readFileSync(p, 'utf8')); + } + return out; +} + +/** `--scoped`: plan, write or re-prove the second pass. */ +async function mainScoped(tm, argv) { + if (argv.includes('--verify')) { + const i = argv.indexOf('--base'); + const ref = i >= 0 ? argv[i + 1] : null; + if (!ref) refuse('--scoped --verify needs --base '); + const v = verifyScoped(tm, gitShow(ref, `${SRC_REL}/${HOST_BASENAME}`), readFileSync(HOST_PATH, 'utf8'), scopedModulesOnDisk()); + printVerifyScoped(v); + return v.failures.length === 0 ? 0 : 1; + } + const input = readFileSync(HOST_PATH, 'utf8'); + if (input.includes(SCOPED_MARK)) { + refuse(`${HOST_BASENAME} already carries ${SCOPED_MARK} stubs: run the second pass on a file whose scoped classes are unsplit (re-run on the latest main, never on its own output)`); + } + const planned = planScoped(tm, input); + console.log(planReportScoped(planned)); + const out = generateScoped(tm, input, planned); + const files = new Map([[HOST_BASENAME, out.host], ...out.modules]); + console.log(`output tree sha256: ${treeHash(files)} (input sha256 ${sha256(input)})`); + const v = verifyScoped(tm, input, out.host, out.modules); + printVerifyScoped(v); + if (v.failures.length > 0) { + console.error('✗ the generated scoped split fails byte-identity: nothing written. Report the fork; never hand-edit the split.'); + return 1; + } + if (!argv.includes('--write')) { + console.log('plan only: nothing written (pass --write to apply).'); + return 0; + } + for (const [file] of out.modules) { + const p = join(SRC_DIR, file); + if (existsSync(p) && !readFileSync(p, 'utf8').includes(SCOPED_MARK)) refuse(`${file} exists and was not written by this script`); + } + writeFileSync(HOST_PATH, out.host); + for (const [file, text] of out.modules) writeFileSync(join(SRC_DIR, file), text); + console.log(`✓ wrote ${HOST_BASENAME} and ${out.modules.size} module(s) under ${SRC_REL}`); + return 0; +} + // ── Entry ──────────────────────────────────────────────────────────────────── const USAGE = [ 'usage: node scripts/codemod-split-engine.mjs [--write] plan (or apply) the split of an UNSPLIT engine.ts', ' node scripts/codemod-split-engine.mjs --verify --base REF re-prove a split on disk against engine.ts at REF', + ' node scripts/codemod-split-engine.mjs --scoped [--write] plan (or apply) the second pass: ObjectRepository and ScopedContext', + ' node scripts/codemod-split-engine.mjs --scoped --verify --base REF re-prove the second pass on disk against engine.ts at REF', ' node scripts/codemod-split-engine.mjs --self-test', ' node scripts/codemod-split-engine.mjs --help', ].join('\n'); @@ -2054,6 +2688,7 @@ async function main(argv) { return 0; } const tm = await loadTsMorph(); + if (argv.includes('--scoped')) return mainScoped(tm, argv); if (argv.includes('--verify')) { const i = argv.indexOf('--base'); const ref = i >= 0 ? argv[i + 1] : null; From 2a7675867c6706a2688e4046d59340ecb378fe0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:26:25 +0000 Subject: [PATCH 2/3] wip: facade gate registers the scoped classes (#30052) Claude-Session: https://claude.ai/code/session_01Utp6Cbo4rDxRL3yugqM5c7 Co-authored-by: Claude --- scripts/check-facade-delegation-only.mjs | 67 ++++++++++--- scripts/codemod-split-engine.mjs | 122 +++++++++++++++++++++++ scripts/facade-delegation-registry.json | 68 +++++++++++++ 3 files changed, 243 insertions(+), 14 deletions(-) diff --git a/scripts/check-facade-delegation-only.mjs b/scripts/check-facade-delegation-only.mjs index 8f0288d4c53..ebf9e4eba95 100644 --- a/scripts/check-facade-delegation-only.mjs +++ b/scripts/check-facade-delegation-only.mjs @@ -31,11 +31,16 @@ * facades { file, class, card } a file that has LANDED in facade form, * the class in it that is the facade, and * the card that landed it. - * exceptions { file, member, card, reason? } + * exceptions { file, class?, member, card, reason? } * a member of a registered facade that may * keep a body for now, and the card that * will move it (ruling 6624345766 item 3). * + * A file may register more than one class: `engine.ts` holds `ObjectQL` and, + * since #30052, its two scoped classes `ObjectRepository` and `ScopedContext`. + * Each is its own `facades` row, and an exception row in such a file names its + * `class` (in a file with one facade the `class` may be left out). + * * The next split adds one `facades` row. ⛔ A file joins only once it is in * facade form on `main`, so the gate never reds `main` ahead of a landing: the * PR that lands the facade (or the first PR after it) adds the row. @@ -164,7 +169,7 @@ export const REGISTRY_FILE = 'scripts/facade-delegation-registry.json'; const CARD_REF = /^#[1-9][0-9]*$/; const FACADE_KEYS = Object.freeze(['file', 'class', 'card']); -const EXCEPTION_KEYS = Object.freeze(['file', 'member', 'card', 'reason']); +const EXCEPTION_KEYS = Object.freeze(['file', 'class', 'member', 'card', 'reason']); /** One level of nested call in an argument: `protocolHost(this)`. Two is logic. */ const MAX_NESTED_CALL_DEPTH = 1; @@ -663,7 +668,9 @@ export function checkRegistry(registry, readFile) { const extraTop = unknownKeys(registry, ['facades', 'exceptions']); if (extraTop.length) problem(REGISTRY_FILE, `unknown top-level key(s) ${extraTop.join(', ')}`); - const byFile = new Map(); + const facadeKey = (file, cls) => `${file}\u0000${cls}`; + const byFacade = new Map(); + const classesOfFile = new Map(); registry.facades.forEach((row, i) => { const where = `${REGISTRY_FILE} facades[${i}]`; if (!row || typeof row !== 'object') return problem(where, 'a row must be an object'); @@ -674,11 +681,15 @@ export function checkRegistry(registry, readFile) { if (typeof row.card !== 'string' || !CARD_REF.test(row.card)) { problem(where, `${row.file}: a row must name the card that landed the facade as \`#\`, got ${JSON.stringify(row.card)}`); } - if (byFile.has(row.file)) return problem(where, `${row.file} is registered twice`); - byFile.set(row.file, { row, judged: null }); + if (byFacade.has(facadeKey(row.file, row.class))) return problem(where, `${row.file} \`${row.class}\` is registered twice`); + const entry = { row, judged: null }; + byFacade.set(facadeKey(row.file, row.class), entry); + if (!classesOfFile.has(row.file)) classesOfFile.set(row.file, []); + classesOfFile.get(row.file).push(entry); }); - for (const [file, entry] of byFile) { + for (const entry of byFacade.values()) { + const file = entry.row.file; const text = readFile(file); if (text === null) { problem(file, `registered as a facade, but the file does not exist — a gate over a missing file judges nothing. Restore the file or remove its row.`); @@ -713,18 +724,30 @@ export function checkRegistry(registry, readFile) { if (typeof row.file !== 'string' || typeof row.member !== 'string' || !row.member) { return problem(where, 'a row must name its `file` and `member`'); } - const label = `${row.file} \`${row.member}\``; + if (row.class !== undefined && (typeof row.class !== 'string' || !row.class)) { + return problem(where, `${row.file} \`${row.member}\`: \`class\`, when present, is a non-empty string`); + } + const label = `${row.file} \`${row.class ? `${row.class}.` : ''}${row.member}\``; if (typeof row.card !== 'string' || !CARD_REF.test(row.card)) { problem(where, `${label}: an exception must name the card that will move the member, as \`#\`; got ${JSON.stringify(row.card)}. An exception nobody owns is a permanent hole.`); } if (row.reason !== undefined && (typeof row.reason !== 'string' || !row.reason)) { problem(where, `${label}: \`reason\`, when present, is a non-empty string`); } - const id = `${row.file}\u0000${row.member}`; + const ofFile = classesOfFile.get(row.file); + if (!ofFile) return problem(where, `${label}: ${row.file} is not a registered facade`); + let entry; + if (row.class) { + entry = byFacade.get(facadeKey(row.file, row.class)); + if (!entry) return problem(where, `${label}: \`${row.class}\` is not a registered facade class of ${row.file} (registered: ${ofFile.map((e) => `\`${e.row.class}\``).join(', ')})`); + } else if (ofFile.length > 1) { + return problem(where, `${label}: ${row.file} registers ${ofFile.length} facade classes (${ofFile.map((e) => `\`${e.row.class}\``).join(', ')}), so the row must name its \`class\``); + } else { + entry = ofFile[0]; + } + const id = `${row.file}\u0000${entry.row.class}\u0000${row.member}`; if (seen.has(id)) return problem(where, `${label} has two exception rows`); seen.add(id); - const entry = byFile.get(row.file); - if (!entry) return problem(where, `${label}: ${row.file} is not a registered facade`); if (!entry.judged) return; // the facade itself was refused above const member = entry.judged.find((m) => m.key === row.member && m.verdict !== 'signature'); if (!member) { @@ -738,7 +761,7 @@ export function checkRegistry(registry, readFile) { return problem(where, `${label}: the member is already a ${member.verdict === 'field' ? 'field' : 'delegation'} — its card ${row.card} has moved it. Delete the row; the member must stay a delegation.`); } member.exception = row; - excepted.push({ file: row.file, member: row.member, card: row.card, line: member.line }); + excepted.push({ file: row.file, class: entry.row.class, member: row.member, card: row.card, line: member.line }); }); for (const f of facades) { @@ -791,6 +814,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'E7 two exception rows for one member → RED': 1, 'E8 an unknown key in a row → RED': 1, 'E9 a row missing its static/get/set prefix → RED naming the nearest member key': 1, + 'E10 two facades in one file: each judged; an exception row there names its class, or → RED': 1, 'N1 a registered facade file that is missing → RED': 1, 'N2 a registered class that is absent from its file → RED': 1, 'N3 a registered facade with zero delegations → RED': 1, @@ -798,7 +822,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'N5 an empty registry → GREEN with nothing guarded, never a crash': 1, 'N6 the committed registry is well-formed against this checkout': 1, }); -const SELF_TEST_BATTERY_FLOOR = 36; +const SELF_TEST_BATTERY_FLOOR = 37; function selfTest() { const FILE = 'pkg/src/facade.ts'; @@ -963,6 +987,20 @@ function selfTest() { ]); return all(red(r, /no member spelled `table`; did you mean `static table`\?/), red(r, /no member spelled `size`; did you mean `get size`\?/)); } }, + { label: 'E10 two facades in one file: each judged; an exception row there names its class, or → RED', run: () => { + const text = `${facadeOf(' update(x: number) { let y = x; return y; }')}export class Second {\n m(a: string) { return F.m(host(this), a); }\n update(x: number) { let y = x; return y; }\n}\n`; + const rows = [row, { file: FILE, class: 'Second', card: '#2' }]; + const both = [{ file: FILE, class: 'Facade', member: 'update', card: '#30020' }, { file: FILE, class: 'Second', member: 'update', card: '#30020' }]; + const ok = run(text, both, rows); + return all( + green(ok), + ok.facades.length === 2 && ok.excepted.length === 2 ? null : `facades ${ok.facades.length}, excepted ${ok.excepted.length}`, + red(run(text, [both[0]], rows), /Second\.update holds a body/), + red(run(text, [{ file: FILE, member: 'update', card: '#30020' }, both[1]], rows), /registers 2 facade classes .* must name its `class`/), + red(run(text, [{ ...both[0], class: 'Third' }, both[1]], rows), /`Third` is not a registered facade class/), + red(run(text, both, [row, row]), /`Facade` is registered twice/), + ); + } }, { label: 'N1 a registered facade file that is missing → RED', run: () => red(checkRegistry(reg([row]), () => null), /pkg\/src\/facade\.ts registered as a facade, but the file does not exist/) }, { label: 'N2 a registered class that is absent from its file → RED', run: () => @@ -1110,7 +1148,7 @@ function main() { if (m.verdict === 'field') fields += 1; if (m.verdict === 'delegation') shapes.set(m.shape, (shapes.get(m.shape) ?? 0) + 1); } - const own = excepted.filter((x) => x.file === f.file); + const own = excepted.filter((x) => x.file === f.file && x.class === f.class); const cards = [...new Set(own.map((x) => x.card))].join(', '); console.log( `✓ ${f.file} \`${f.class}\`: ${f.members.length} members — ` + @@ -1119,7 +1157,8 @@ function main() { ); for (const [shape, n] of [...shapes].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)} ${shape}`); } - console.log(`✓ check-facade-delegation-only: ${facades.length} facade file(s) hold delegations only, ${excepted.length} named exception(s).`); + const files = new Set(facades.map((f) => f.file)).size; + console.log(`✓ check-facade-delegation-only: ${facades.length} facade class(es) in ${files} file(s) hold delegations only, ${excepted.length} named exception(s).`); } if (isEntrypoint(import.meta.url)) { diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index 6568db311be..754180d4b7f 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -2760,6 +2760,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'byte-identity proof': 10, 'import prune': 4, 'refusals': 6, + 'scoped classes (second pass)': 16, }); const SELF_TEST_BATTERY_FLOOR = 5; @@ -2937,6 +2938,80 @@ function needleInMethod(n: number): number { `; } +const FIXTURE_SCOPED_CLASSES = Object.freeze([ + ['ObjectRepository', 'engine-object-repository.ts', 'Repository'], + ['ScopedContext', 'engine-scoped-context.ts', 'Context'], +]); + +/** An `engine.ts` whose `ObjectQL` the first pass already split, ahead of two unsplit scoped classes. */ +function scopedFixtureSource() { + return `import { helperImported } from './other.js'; +import { onlyScopedReads } from './scoped-only.js'; +import { count } from './engine-count-aggregate.js'; +import { LifecycleFacet } from './engine-lifecycle.js'; + +export class ObjectQL { + run(n: number): number { + // ${MARK} The body moved to \`engine-lifecycle.ts\`. + return LifecycleFacet.run(n); + } +} + +export class ObjectRepository { + constructor( + private objectName: string, + private engine: { find(o: string): Promise; count(o: string): Promise }, + ) {} + + /** A public docblock: it stays on the class. */ + async find(query: any = {}): Promise { + return this.engine.find(this.objectName + String(query)); + } + + async count(): Promise { + return this.engine.count(this.objectName); + } + + inferred() { + return 1; + } +} + +export class ScopedContext { + constructor(private ctx: { user?: string }, private engine: any) {} + + private flag = false; + + private derive(ctx: { user?: string }): ScopedContext { + const child = new ScopedContext(ctx, this.engine); + child.flag = this.flag; + return child; + } + + /** A private docblock: it moves with the body. */ + private readFlag(): boolean { + return this.flag && Boolean(helperImported()); + } + + object(name: string): ObjectRepository { + return new ObjectRepository(name, this.engine); + } + + sudo(): ScopedContext { + this.flag = this.readFlag() || Boolean(onlyScopedReads()); + return this.derive({ ...this.ctx }); + } + + handsSelf(): ScopedContext { + void count; + return this; + } + + get user() { return this.ctx.user; } +} +`; +} + async function selfTest() { const tm = await loadTsMorph(); const batterySeen = new Map(); @@ -3117,6 +3192,53 @@ async function selfTest() { check('--help is answered, never refused, whatever engine.ts holds', (await main(['--help']).catch(() => 2)) === 0); } + open('scoped classes (second pass)'); + { + const input = scopedFixtureSource(); + const planned = planScoped(tm, input, { classes: FIXTURE_SCOPED_CLASSES }); + const record = (cls, name) => planned.targets.find((t) => t.className === cls)?.records.find((r) => r.name === name); + const why = (cls, name) => (record(cls, name)?.reasons ?? ['']).join('; '); + check('a forwarding method, an assigning one and one constructing a sibling class move', + ['find', 'count'].every((n) => why('ObjectRepository', n) === '') && ['object', 'sudo', 'readFlag'].every((n) => why('ScopedContext', n) === ''), + ['find', 'count'].map((n) => why('ObjectRepository', n)).concat(['object', 'sudo', 'readFlag'].map((n) => why('ScopedContext', n))).join(' | ')); + check('a method that only shares a hand-series import\'s name is not a hand-series stub', why('ObjectRepository', 'count') === '', why('ObjectRepository', 'count')); + check('a body writing a private member of another instance stays (foreign-private)', why('ScopedContext', 'derive').startsWith('foreign-private'), why('ScopedContext', 'derive')); + check('a body handing the instance on stays (bare-this)', why('ScopedContext', 'handsSelf').startsWith('bare-this'), why('ScopedContext', 'handsSelf')); + check('a public method with an inferred return type stays (inferred-return-type)', why('ObjectRepository', 'inferred').startsWith('inferred-return-type'), why('ObjectRepository', 'inferred')); + const out = generateScoped(tm, input, planned); + const again = generateScoped(tm, input, planScoped(tm, input, { classes: FIXTURE_SCOPED_CLASSES })); + const v = verifyScoped(tm, input, out.host, out.modules, { classes: FIXTURE_SCOPED_CLASSES }); + check('the generated scoped split proves against its base', v.failures.length === 0 && v.moved.length === 5, + [...v.failures, `moved: ${v.moved.map((m) => m.name).join()}`].join(' | ')); + check('two runs on one input write byte-identical trees', + treeHash(new Map([['h', out.host], ...out.modules])) === treeHash(new Map([['h', again.host], ...again.modules]))); + const ctxMod = out.modules.get('engine-scoped-context.ts') ?? ''; + const hostsMod = out.modules.get(SCOPED_HOSTS_MODULE) ?? ''; + check('each stub hands its body the one host of its own instance', + out.host.includes('return ScopedContextFacet.sudo.call(scopedContextHost.of(this));') && + out.host.includes('return ObjectRepositoryFacet.find.call(objectRepositoryHost.of(this), query);'), + out.host.slice(out.host.indexOf('sudo('))); + check('the host module keeps one InstanceHost per class and imports types only', + hostsMod.includes("export const scopedContextHost = new InstanceHost('ScopedContextFacet', 'ScopedContext');") && + !/^import (?!type )/m.test(hostsMod)); + check('a private docblock moves with its body; a public one stays on the class', + ctxMod.includes(' /** A private docblock: it moves with the body. */\n static readFlag(') && !out.host.includes('A private docblock') && + out.host.includes(' /** A public docblock: it stays on the class. */\n find(query: any = {}): Promise {')); + check('a field a body assigns gets a setter in its class\'s install block', + out.host.includes(' get flag() { return instance.flag; },\n set flag(v) { instance.flag = v; },')); + check('the first pass\'s class is not touched', out.host.includes(` run(n: number): number {\n // ${MARK} The body moved to \`engine-lifecycle.ts\`.\n return LifecycleFacet.run(n);\n }`) && + !out.host.slice(out.host.indexOf('export class ObjectQL'), out.host.indexOf('export class ObjectRepository')).includes(SCOPED_MARK)); + const verifyWith = (host, mods) => verifyScoped(tm, input, host, mods, { classes: FIXTURE_SCOPED_CLASSES }).failures.length > 0; + check('one edited character in a moved body fails', verifyWith(out.host, + new Map([...out.modules, ['engine-scoped-context.ts', ctxMod.replace('{ ...this.ctx }', '{ ...this.ctx, x: 1 }')]]))); + check('a stub handing the other class\'s host fails', verifyWith(out.host.replace('ScopedContextFacet.sudo.call(scopedContextHost.of(this))', 'ScopedContextFacet.sudo.call(objectRepositoryHost.of(this))'), out.modules)); + check('a written field\'s setter left out of the install block fails', verifyWith(out.host.replace(' set flag(v) { instance.flag = v; },\n', ''), out.modules)); + check('an edited host module fails', verifyWith(out.host, new Map([...out.modules, [SCOPED_HOSTS_MODULE, hostsMod.replace('this.hosts.set(instance, host);', '')]]))); + check('an import only a moved body read is pruned, and the proof names it', out.pruned.includes('onlyScopedReads') && v.pruned.includes('onlyScopedReads'), out.pruned.join()); + check('a scoped class the file does not declare refuses', + refused(() => planScoped(tm, input, { classes: [['Gone', 'engine-gone.ts', 'Gone']] }))); + } + const declared = Object.keys(SELF_TEST_BATTERIES); if (declared.length < SELF_TEST_BATTERY_FLOOR) { failed += 1; diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 4dd766d44f2..4f746b29003 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -10,6 +10,16 @@ "class": "ObjectQL", "card": "#29986" }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ObjectRepository", + "card": "#30052" + }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ScopedContext", + "card": "#30052" + }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "class": "SqlDriver", @@ -199,184 +209,242 @@ }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "update", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "insert", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "delete", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "find", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "findOne", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "recomputeSummaries", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "resolveReadableIdsSecurity", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "bindHooks", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "readMigrationFlagRowMemoized", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "createContext", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "ownershipQueueService", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "buildHookApi", "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "registerApp", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "registerHook", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "registerDriver", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "init", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "triggerHooks", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "destroy", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "setDatasourceMapping", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "getStatus", "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "seedAutonumber", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "probeReferencingRows", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "resolveMasterDetailParent", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "resolveMasterDetailParents", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "inspectDanglingReferences", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "referenceExists", "card": "#30055", "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "homeAmountServing", "card": "#30147", "reason": "codemod limit: field-call, it calls a field or accessor as a function (#29986 report)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "constructor", "card": "#30020", "reason": "state-owning: gate reading, constructor logic (a fallback and a branch on an env flag)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "projectHomeAmountsOntoPatch", "card": "#30147", "reason": "codemod limit: gate reading, a parameter default computes (calls this.homeAmountServing)" }, { "file": "packages/objectql/src/engine.ts", + "class": "ObjectQL", "member": "serveReferenceNames", "card": "#30020", "reason": "state-owning: gate reading, a hand-series host slot holds a try block; the hand-series host rule is #30020's" }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ScopedContext", + "member": "derive", + "card": "#30147", + "reason": "codemod limit: foreign-private, it writes `appAuthored` on another instance, which a module function cannot (#30052 report)" + }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ScopedContext", + "member": "[APP_AUTHORED_HOOK_API]", + "card": "#30147", + "reason": "codemod limit: not-identifier-name, a symbol-keyed method; it also writes `appAuthored` on another instance (#30052 report)" + }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ScopedContext", + "member": "[SEED_USER_GRANTS]", + "card": "#30020", + "reason": "state-owning: bare-this, the body hands the instance itself on (`return this`); also a symbol-keyed method (#30052 report)" + }, + { + "file": "packages/objectql/src/engine.ts", + "class": "ScopedContext", + "member": "transaction", + "card": "#30020", + "reason": "state-owning: bare-this, the body hands the instance itself on (`callback(this, …)`) (#30052 report: stays on the class)" + }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "constructor", From 8b84cef82a1f421776040e70dddc6ec36db58e85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:27:13 +0000 Subject: [PATCH 3/3] wip: codemod header for the second pass, changeset (#30052) Claude-Session: https://claude.ai/code/session_01Utp6Cbo4rDxRL3yugqM5c7 Co-authored-by: Claude --- .changeset/30052-facade-scoped-classes.md | 12 ++++++++++++ scripts/codemod-split-engine.mjs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .changeset/30052-facade-scoped-classes.md diff --git a/.changeset/30052-facade-scoped-classes.md b/.changeset/30052-facade-scoped-classes.md new file mode 100644 index 00000000000..c1bdf362a0d --- /dev/null +++ b/.changeset/30052-facade-scoped-classes.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +refactor(objectql): `ObjectRepository` and `ScopedContext` in `engine.ts` are facades over their own facet modules, moved by the `engine.ts` codemod's second pass; no behaviour or published declaration changes + +Clause-②: no +Metadata-protocol: none + +- 20 of the 24 methods of the two classes keep their signatures and public docblocks on the class and forward, in one call, to an unedited copy of their body in `engine-object-repository.ts` or `engine-scoped-context.ts`. Each instance gets one host, built on first use (`engine-scoped-hosts.ts`), so a spy or a subclass override on an instance is still the method a body reaches. +- Four `ScopedContext` methods keep their bodies on the class, each named in the facade gate's registry with the card that will move it: `transaction` and the symbol-keyed `SEED_USER_GRANTS` method hand the instance itself on; `derive` and the symbol-keyed `APP_AUTHORED_HOOK_API` method set a private flag on another instance. +- Nothing to do on upgrade: the exported names, the published `.d.ts` surface and what every method does are the same. diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index 754180d4b7f..bccc3efadbb 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -11,6 +11,10 @@ * node scripts/codemod-split-engine.mjs --write # apply the split, then verify it * node scripts/codemod-split-engine.mjs --verify --base * # re-prove a split on disk against engine.ts at + * node scripts/codemod-split-engine.mjs --scoped [--write] + * # the second pass (#30052): plan (or apply) ObjectRepository and ScopedContext + * node scripts/codemod-split-engine.mjs --scoped --verify --base + * # re-prove the second pass on disk against engine.ts at * node scripts/codemod-split-engine.mjs --self-test * node scripts/codemod-split-engine.mjs --help # this usage, on any tree (split or not) * @@ -117,6 +121,21 @@ * two runs on one input write byte-identical trees (the plan prints the output * tree's sha256). Only the proof's leaf-reach walk reads sibling files. * + * ## The second pass: `ObjectRepository` and `ScopedContext` (#30052) + * + * `engine.ts` declares two more classes after `ObjectQL`, the repository a + * hook reaches as `ctx.api.object(name)` and the context it reaches as + * `ctx.api`. `--scoped` facades them with this pass's cut pattern, stay rules + * and byte-identity proof, one facet module per class (`SCOPED_CLASSES`) and + * one host per INSTANCE (`SCOPED_HOSTS_MODULE`, #30051's shape keyed by the + * instance), and it touches no other byte: its input is the `engine.ts` the + * first pass left, and it is proved against that file, so it is re-run on the + * latest `main` at landing like the first. Its marker is `SCOPED_MARK`, so the + * two passes' stubs and files are told apart. Two stay reasons are its own: + * a body that reads a private member off another instance (`foreign-private`: + * the class body may, a module function may not) and one that reads a + * non-public static (`private-static`: the statics route is `ObjectQL`'s). + * * ts-morph is a root devDependency, resolved from the root manifest, as the * pilot takes it. *