diff --git a/.changeset/25467-e1-protocol-host-split.md b/.changeset/25467-e1-protocol-host-split.md new file mode 100644 index 00000000000..3c3985b3dd1 --- /dev/null +++ b/.changeset/25467-e1-protocol-host-split.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): one `ObjectStackProtocolImplementation` member body moves out of `protocol.ts` through a host object, the shape the rest of the class follows + +Clause-②: no +Metadata-protocol: none + +No behaviour change. `tenantAuthoredWriteRefusal` keeps its docblock and signature on the class; its body now lives in `protocol-tenant-authored-write.ts`, reached through `protocol-host.ts`, a per-instance object that forwards to the class's own members (built in the class's static block, so the class gains no member). The moved body differs from the method body only by `this.` reading `host.`. The published `index.d.ts` (ESM and CJS) is byte-identical to the one `main` builds before this change. Nothing to migrate. diff --git a/packages/metadata-protocol/scripts/host-split.mjs b/packages/metadata-protocol/scripts/host-split.mjs new file mode 100644 index 00000000000..0fc1c024990 --- /dev/null +++ b/packages/metadata-protocol/scripts/host-split.mjs @@ -0,0 +1,653 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E1] The host split of `ObjectStackProtocolImplementation`: the one + * tool that moves a member's body out of `src/protocol.ts`, and the check that + * the moved body is the member's own body, changed by nothing but the rewrite + * set below. + * + * node scripts/host-split.mjs survey every member: can it move under the set? + * node scripts/host-split.mjs move move one member's body into src/ + * node scripts/host-split.mjs check + * the moved body against the member at + * + * Run from `packages/metadata-protocol`. Not a gate: nothing in CI runs it. Its + * rules are pinned by `host-split.test.mjs` beside it, which the package's + * vitest run collects, negative controls included. + * + * ## The closed rewrite set (ruling E, 6537939895, item E1) + * + * Inside a moved body exactly three rewrites are allowed, and nothing else + * changes (AST identity modulo exactly this set): + * + * 1. `this.` → `host.` (an instance member: method, field, accessor) + * 2. `ObjectStackProtocolImplementation.(…)` → `host.(…)` + * (a PRIVATE static method, called; the static is exposed on the host) + * 3. `this.constructor` → `host` (then `.x` must name a static, exposed on the host) + * + * A `this` counts only when it is the method's own: the walk from it to the + * method crosses arrow functions and nothing else that binds `this`. Anything a + * body does with the class instance outside the set — a bare `this`, `this[k]`, + * `super`, `arguments`, a private static read as a value or a private static + * FIELD, an identifier already named `host` — is a refusal, and the member does + * not move under E1's set. + * + * ## The shape a move leaves + * + * - `src/`: `export [async] function (host: Pick, )` + * whose body is the method's body with the rewrites applied, re-indented one + * level (a line that starts inside a multi-line template literal keeps its + * bytes). The `Pick` names exactly the host members the body reads. + * - `src/protocol.ts`: the method keeps its docblock, modifiers and signature + * (the published `.d.ts` prints them); its body is one comment line and + * `return (protocolHost(this), );`. + * - `src/protocol-host.ts`: `ProtocolHost` gains one entry per member the body + * reads, typed AS the class member (`ObjectStackProtocolImplementation['x']`, + * `(typeof ObjectStackProtocolImplementation)['x']` for a static); the class's + * static block in `protocol.ts` gains the entry that forwards it: a method as + * a closure called at call time, a field as a getter (and a setter when a + * moved body writes it), a static as a closure to the static. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +export const CLASS_NAME = 'ObjectStackProtocolImplementation'; +export const HOST_PARAM = 'host'; +export const HOST_TYPE = 'ProtocolHost'; +export const HOST_OF = 'protocolHost'; +export const HOST_MODULE = 'protocol-host'; + +/** The rewrite kinds, by the name the check counts them under. */ +export const REWRITES = Object.freeze({ + member: 'this. -> host.', + privateStatic: 'ObjectStackProtocolImplementation.(...) -> host.(...)', + constructor: 'this.constructor -> host', +}); + +export function parse(file, text) { + return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +const hasModifier = (node, kind) => !!node.modifiers?.some((m) => m.kind === kind); +const nameOf = (member) => (member.name && (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) ? member.name.text : undefined); + +/** The class declaration `name` in `sf`, or `undefined`. */ +export function classOf(sf, name = CLASS_NAME) { + return sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === name); +} + +/** + * The class's members by name: `instance` (method / field / accessor, constructor + * parameter properties included) and `statics` (with `private`). A name declared + * as both an instance and a static member is reported in `clashes`. + */ +export function memberTable(cls) { + const instance = new Map(); + const statics = new Map(); + for (const m of cls.members) { + if (ts.isConstructorDeclaration(m)) { + for (const p of m.parameters) { + if (ts.isIdentifier(p.name) && p.modifiers?.some((x) => ts.isParameterPropertyModifier?.(x) ?? [ts.SyntaxKind.PrivateKeyword, ts.SyntaxKind.PublicKeyword, ts.SyntaxKind.ProtectedKeyword, ts.SyntaxKind.ReadonlyKeyword].includes(x.kind))) { + instance.set(p.name.text, { kind: 'field' }); + } + } + continue; + } + const name = nameOf(m); + if (!name) continue; + const kind = ts.isMethodDeclaration(m) ? 'method' + : ts.isPropertyDeclaration(m) ? 'field' + : ts.isGetAccessorDeclaration(m) || ts.isSetAccessorDeclaration(m) ? 'accessor' + : undefined; + if (!kind) continue; + if (hasModifier(m, ts.SyntaxKind.StaticKeyword)) { + statics.set(name, { kind, private: hasModifier(m, ts.SyntaxKind.PrivateKeyword) }); + } else if (!instance.has(name) || instance.get(name).kind !== 'method') { + instance.set(name, { kind }); + } + } + const clashes = [...instance.keys()].filter((n) => statics.has(n)); + return { instance, statics, clashes }; +} + +/** The implementation (body-carrying) instance method `name` of `cls`, or `undefined`. */ +export function methodOf(cls, name) { + return cls.members.find((m) => ts.isMethodDeclaration(m) && !hasModifier(m, ts.SyntaxKind.StaticKeyword) && nameOf(m) === name && m.body); +} + +/** Is this `this` keyword the method's own — reached from it through arrow functions only? */ +export function isMethodThis(node, method) { + for (let p = node.parent; p; p = p.parent) { + if (p === method) return true; + if (ts.isArrowFunction(p)) continue; + if (ts.isFunctionLike(p) || ts.isClassLike(p)) return false; + } + return false; +} + +const isAssignmentTarget = (node) => { + const p = node.parent; + if (ts.isBinaryExpression(p) && p.left === node) { + return p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + } + if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) + && (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken)) return true; + return ts.isDeleteExpression(p); +}; +/** Is this identifier a property NAME (`x.host`, `{ host: v }`), not a binding or a reference? */ +const isPropertyName = (node) => { + const p = node.parent; + return (ts.isPropertyAccessExpression(p) && p.name === node) + || ((ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p) || ts.isMethodDeclaration(p) || ts.isPropertySignature(p) + || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p) || ts.isMethodSignature(p)) && p.name === node); +}; +const isCallee = (node) => ts.isCallExpression(node.parent) && node.parent.expression === node; + +/** + * The rewrite sites of one method body, and its refusals. + * + * @returns `{ sites, refusals, used }` — each site `{ start, end, text, kind, member }` + * (replace source[start, end) with `text`); `used.instance` maps a member + * to `{ read, write, call }`; `used.statics` is the set of statics the + * body reaches through the host. + */ +export function rewriteSites(sf, method, table, className = CLASS_NAME) { + const sites = []; + const refusals = []; + const used = { instance: new Map(), statics: new Set() }; + const refuse = (node, reason) => { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + refusals.push({ line: line + 1, reason }); + }; + const useInstance = (name, how) => { + const u = used.instance.get(name) ?? { read: false, write: false, call: false }; + u[how] = true; + used.instance.set(name, u); + }; + const visit = (node) => { + if (node.kind === ts.SyntaxKind.ThisKeyword && isMethodThis(node, method)) { + const p = node.parent; + if (ts.isPropertyAccessExpression(p) && p.expression === node && ts.isIdentifier(p.name)) { + const name = p.name.text; + if (name === 'constructor') { + const g = p.parent; + const st = ts.isPropertyAccessExpression(g) && g.expression === p && ts.isIdentifier(g.name) ? table.statics.get(g.name.text) : undefined; + if (!st) refuse(node, '`this.constructor` not followed by `.`: outside the closed set'); + else { + sites.push({ start: p.getStart(sf), end: p.getEnd(), text: HOST_PARAM, kind: 'constructor', member: g.name.text }); + used.statics.add(g.name.text); + } + return; + } + const member = table.instance.get(name); + if (!member) { refuse(node, `\`this.${name}\` names no declared instance member`); return; } + if (table.statics.has(name)) { refuse(node, `\`${name}\` is both an instance and a static member: the host cannot carry both`); return; } + sites.push({ start: node.getStart(sf), end: node.getEnd(), text: HOST_PARAM, kind: 'member', member: name }); + useInstance(name, isAssignmentTarget(p) ? 'write' : isCallee(p) ? 'call' : 'read'); + ts.forEachChild(p, (c) => { if (c !== node) visit(c); }); + return; + } + refuse(node, ts.isElementAccessExpression(p) && p.expression === node + ? '`this[…]` (element access): outside the closed set' + : 'a bare `this` (not `this.`): outside the closed set'); + return; + } + if (node.kind === ts.SyntaxKind.SuperKeyword) { refuse(node, '`super`: outside the closed set'); return; } + if (ts.isIdentifier(node)) { + if (node.text === 'arguments' && !isPropertyName(node)) { + let p = node.parent; + while (p && p !== method && (ts.isArrowFunction(p) || !ts.isFunctionLike(p))) p = p.parent; + if (p === method) refuse(node, '`arguments` of the method: a moved function\'s `arguments` would carry the host'); + } + if (node.text === HOST_PARAM && !isPropertyName(node)) refuse(node, `an identifier already named \`${HOST_PARAM}\` would shadow or be shadowed by the host parameter`); + if (node.text === className && ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node && ts.isIdentifier(node.parent.name)) { + const name = node.parent.name.text; + const st = table.statics.get(name); + if (st?.private) { + if (st.kind === 'method' && isCallee(node.parent)) { + sites.push({ start: node.getStart(sf), end: node.getEnd(), text: HOST_PARAM, kind: 'privateStatic', member: name }); + used.statics.add(name); + } else { + refuse(node, `private static \`${name}\` (${st.kind}) ${st.kind === 'method' ? 'read as a value' : 'read'}: only a private static METHOD, called, is in the closed set`); + } + } + } + } + ts.forEachChild(node, visit); + }; + if (method.body) ts.forEachChild(method.body, visit); + sites.sort((a, b) => a.start - b.start); + return { sites, refusals, used }; +} + +/** Apply sites (absolute positions in `text` offset by `base`). */ +export function applySites(text, sites, base = 0) { + let out = text; + for (const s of [...sites].sort((a, b) => b.start - a.start)) { + out = out.slice(0, s.start - base) + s.text + out.slice(s.end - base); + } + return out; +} + +/** Line starts (offsets into `text`) that fall inside a multi-line template literal or string token. */ +function linesInsideLiterals(text) { + const inside = new Set(); + // A full parse is the reliable way to find literal spans: a scanner alone + // cannot tell a template's tail from a `}`. + const sf = parse('x.ts', text); + const visit = (node) => { + if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node) || ts.isStringLiteral(node)) { + const start = node.getStart(sf); + const end = node.getEnd(); + for (let i = text.indexOf('\n', start); i !== -1 && i < end - 1; i = text.indexOf('\n', i + 1)) inside.add(i + 1); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return inside; +} + +/** Remove `by` leading spaces from every line but the first, except a line that starts inside a literal. */ +export function dedent(text, by = 4, literalLineStarts = undefined) { + const keep = literalLineStarts ?? new Set(); + let out = ''; + let at = 0; + const lines = text.split('\n'); + lines.forEach((line, i) => { + if (i > 0 && !keep.has(at) && line.startsWith(' '.repeat(by))) out += line.slice(by); + else out += line; + if (i < lines.length - 1) out += '\n'; + at += line.length + 1; + }); + return out; +} + +/** Literal line starts of a body text, found by parsing it as a function body. */ +function literalStartsOfBody(bodyText) { + const prefix = 'function f() '; + const inside = linesInsideLiterals(prefix + bodyText); + return new Set([...inside].map((i) => i - prefix.length)); +} + +/** The method's parameter list text (between the parens), its type parameters and return type. */ +function signatureParts(sf, fn) { + const params = fn.parameters; + const paramsText = params.length ? sf.text.slice(params[0].getStart(sf), params.end).trim() : ''; + return { + typeParams: fn.typeParameters ? `<${sf.text.slice(fn.typeParameters.pos, fn.typeParameters.end).trim()}>` : '', + paramsText, + returnType: fn.type ? fn.type.getText(sf) : undefined, + async: hasModifier(fn, ts.SyntaxKind.AsyncKeyword), + generator: !!fn.asteriskToken, + }; +} + +/** The host members a body's sites name, sorted: what the function's `Pick` lists. */ +export function hostNamesOf(sites) { + return [...new Set(sites.map((s) => s.member))].sort(); +} + +const pickOf = (names) => `Pick<${HOST_TYPE}, ${names.map((n) => `'${n}'`).join(' | ')}>`; + +/** + * Plan one move: the moved function's text, the stub's body text, and the host + * entries the body needs. Refusals are returned, never thrown; a caller writes + * nothing when there is one. + */ +export function planMove(source, member, moduleFile, className = CLASS_NAME) { + const sf = parse('protocol.ts', source); + const cls = classOf(sf, className); + if (!cls) return { refusals: [{ line: 0, reason: `no class ${className}` }] }; + const method = methodOf(cls, member); + if (!method) return { refusals: [{ line: 0, reason: `no instance method ${member} with a body` }] }; + const table = memberTable(cls); + const { sites, refusals, used } = rewriteSites(sf, method, table, className); + const sig = signatureParts(sf, method); + if (sig.generator) refusals.push({ line: 0, reason: 'a generator method' }); + for (const p of method.parameters) { + if (!ts.isIdentifier(p.name)) refusals.push({ line: 0, reason: 'a destructured parameter: the stub forwards parameters by name' }); + } + if (refusals.length) return { refusals, sites, used }; + const body = method.body; + const bodyText = sf.text.slice(body.getStart(sf), body.getEnd()); + const rewritten = applySites(bodyText, sites, body.getStart(sf)); + const movedBody = dedent(rewritten, 4, literalStartsOfBody(rewritten)); + const names = hostNamesOf(sites); + const hostParam = names.length ? `${HOST_PARAM}: ${pickOf(names)}` : `${HOST_PARAM}: ${HOST_TYPE}`; + const params = sig.paramsText ? `${hostParam}, ${sig.paramsText}` : hostParam; + const fnText = `/** The body of \`${className}#${member}\`; the method's docblock there is its contract. */\n` + + `export ${sig.async ? 'async ' : ''}function ${member}${sig.typeParams}(${params})${sig.returnType ? `: ${sig.returnType}` : ''} ${movedBody}\n`; + const args = method.parameters.map((p) => (p.dotDotDotToken ? '...' : '') + p.name.text); + const stubBody = `{\n` + + ` // [#25467 E1] The body moved to \`${moduleFile}\`; it reaches this class through \`${HOST_MODULE}.ts\`.\n` + + ` return ${member}(${[`${HOST_OF}(this)`, ...args].join(', ')});\n` + + ` }`; + return { + refusals: [], + sites, + used, + names, + fnText, + stub: { start: body.getStart(sf), end: body.getEnd(), text: stubBody }, + hostEntries: hostEntriesFor(used, table, className), + }; +} + +/** The interface and factory lines for the members a body reaches, keyed by member name. */ +export function hostEntriesFor(used, table, className = CLASS_NAME) { + const entries = new Map(); + for (const [name, u] of used.instance) { + const kind = table.instance.get(name).kind; + if (kind === 'method') { + entries.set(name, { + iface: ` readonly ${name}: ${className}['${name}'];`, + factory: ` ${name}: (...a) => protocol.${name}(...a),`, + }); + } else { + const writes = u.write; + entries.set(name, { + iface: ` ${writes ? '' : 'readonly '}${name}: ${className}['${name}'];`, + factory: writes + ? ` get ${name}() { return protocol.${name}; },\n set ${name}(value) { protocol.${name} = value; },` + : ` get ${name}() { return protocol.${name}; },`, + }); + } + } + for (const name of used.statics) { + entries.set(name, { + iface: ` readonly ${name}: (typeof ${className})['${name}'];`, + factory: ` ${name}: (...a) => ${className}.${name}(...a),`, + }); + } + return entries; +} + +// ── The check ─────────────────────────────────────────────────────────────── + +/** + * A node's children in source order, EVERY token included: punctuation, + * keywords such as `let`/`const`, and operator tokens. `forEachChild` skips + * those (a `PrefixUnaryExpression` keeps its operator in a field), so a walk + * over it would not see `!x` become `-x`; `getChildren` does. + */ +function children(node, sf) { + return node.getChildren(sf).flatMap((c) => (c.kind === ts.SyntaxKind.SyntaxList ? c.getChildren(sf) : [c])); +} + +/** + * AST identity of a base method body and a moved function body, modulo exactly + * the closed set. Returns `{ problems, counts }`; `problems` is empty when the + * bodies are the same program text under the set. + */ +export function compareBodies(baseSf, baseMethod, table, headSf, headBody, className = CLASS_NAME) { + const problems = []; + const counts = { member: 0, privateStatic: 0, constructor: 0 }; + const where = (sf, node) => `${sf.fileName}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}`; + const isHost = (n) => ts.isIdentifier(n) && n.text === HOST_PARAM; + const walk = (a, b) => { + // Rewrite 3: `this.constructor.` ⇄ `host.`. + if (ts.isPropertyAccessExpression(a) && ts.isPropertyAccessExpression(a.expression) + && a.expression.expression.kind === ts.SyntaxKind.ThisKeyword && isMethodThis(a.expression.expression, baseMethod) + && ts.isIdentifier(a.expression.name) && a.expression.name.text === 'constructor') { + if (ts.isPropertyAccessExpression(b) && isHost(b.expression) && b.name.getText(headSf) === a.name.getText(baseSf) + && table.statics.has(a.name.getText(baseSf))) { counts.constructor++; return; } + problems.push(`${where(headSf, b)}: \`this.constructor.${a.name.getText(baseSf)}\` must read \`${HOST_PARAM}.${a.name.getText(baseSf)}\``); + return; + } + // Rewrite 1: `this.` ⇄ `host.`. + if (ts.isPropertyAccessExpression(a) && a.expression.kind === ts.SyntaxKind.ThisKeyword && isMethodThis(a.expression, baseMethod)) { + const name = a.name.getText(baseSf); + if (ts.isPropertyAccessExpression(b) && isHost(b.expression) && b.name.getText(headSf) === name && table.instance.has(name)) { + counts.member++; + return; + } + problems.push(`${where(headSf, b)}: \`this.${name}\` must read \`${HOST_PARAM}.${name}\`, found \`${b.getText(headSf).slice(0, 80)}\``); + return; + } + // Rewrite 2: `C.(…)` callee ⇄ `host.`. + if (ts.isPropertyAccessExpression(a) && ts.isIdentifier(a.expression) && a.expression.text === className + && table.statics.get(a.name.getText(baseSf))?.private && table.statics.get(a.name.getText(baseSf)).kind === 'method' && isCallee(a)) { + const name = a.name.getText(baseSf); + if (ts.isPropertyAccessExpression(b) && isHost(b.expression) && b.name.getText(headSf) === name && isCallee(b)) { + counts.privateStatic++; + return; + } + problems.push(`${where(headSf, b)}: \`${className}.${name}(…)\` must read \`${HOST_PARAM}.${name}(…)\``); + return; + } + if (a.kind !== b.kind) { + problems.push(`${where(headSf, b)}: ${ts.SyntaxKind[b.kind]} where the base has ${ts.SyntaxKind[a.kind]} (\`${a.getText(baseSf).slice(0, 60)}\`)`); + return; + } + const ca = children(a, baseSf); + const cb = children(b, headSf); + if (!ca.length && !cb.length) { + if (a.getText(baseSf) !== b.getText(headSf)) { + problems.push(`${where(headSf, b)}: \`${b.getText(headSf).slice(0, 60)}\` where the base has \`${a.getText(baseSf).slice(0, 60)}\``); + } + return; + } + if (ca.length !== cb.length) { + problems.push(`${where(headSf, b)}: ${ts.SyntaxKind[b.kind]} has ${cb.length} children where the base has ${ca.length}`); + return; + } + // Operator and modifier tokens are children; a changed operator is a changed token. + for (let i = 0; i < ca.length; i++) walk(ca[i], cb[i]); + }; + walk(baseMethod.body, headBody); + return { problems, counts }; +} + +/** The exported function `name` of a module source, or `undefined`. */ +export function exportedFunctionOf(sf, name) { + return sf.statements.find((s) => ts.isFunctionDeclaration(s) && s.name?.text === name && hasModifier(s, ts.SyntaxKind.ExportKeyword) && s.body); +} + +/** + * The whole check for one moved member: the stub, the moved function's + * signature, AST identity modulo the set, the `Pick`, and the body's text + * (comments included) against the base body rewritten by the same sites. + */ +export function checkMove({ baseSource, headSource, moduleSource, moduleFile, member, className = CLASS_NAME }) { + const problems = []; + const baseSf = parse('base:protocol.ts', baseSource); + const headSf = parse('protocol.ts', headSource); + const modSf = parse(moduleFile, moduleSource); + const baseCls = classOf(baseSf, className); + const headCls = classOf(headSf, className); + const baseMethod = baseCls && methodOf(baseCls, member); + const headMethod = headCls && methodOf(headCls, member); + const fn = exportedFunctionOf(modSf, member); + if (!baseMethod) problems.push(`base: no method ${member}`); + if (!headMethod) problems.push(`head: no method ${member} in protocol.ts`); + if (!fn) problems.push(`${moduleFile}: no exported function ${member}`); + if (problems.length) return { ok: false, problems }; + const table = memberTable(baseCls); + + // The method's signature is unchanged; only its body is the stub. + const sigText = (sf, m) => sf.text.slice(m.getFullStart(), m.body.getStart(sf)); + if (sigText(baseSf, baseMethod) !== sigText(headSf, headMethod)) problems.push(`protocol.ts: ${member}'s docblock, modifiers or signature changed`); + const stmts = headMethod.body.statements; + const ret = stmts.length === 1 && ts.isReturnStatement(stmts[0]) ? stmts[0].expression : undefined; + const args = ret && ts.isCallExpression(ret) && ts.isIdentifier(ret.expression) && ret.expression.text === member ? ret.arguments : undefined; + const expectedArgs = [`${HOST_OF}(this)`, ...baseMethod.parameters.map((p) => (p.dotDotDotToken ? '...' : '') + p.name.getText(baseSf))]; + if (!args || args.map((x) => x.getText(headSf)).join(', ') !== expectedArgs.join(', ')) { + problems.push(`protocol.ts: ${member}'s body is not the stub \`return ${member}(${expectedArgs.join(', ')});\``); + } + + // The function's signature: the host parameter, then the method's own. + const bs = signatureParts(baseSf, baseMethod); + const hostParam = fn.parameters[0]; + const rest = fn.parameters.slice(1); + const restText = rest.length ? modSf.text.slice(rest[0].getStart(modSf), fn.parameters.end).trim() : ''; + if (!hostParam || hostParam.name.getText(modSf) !== HOST_PARAM) problems.push(`${moduleFile}: ${member}'s first parameter is not \`${HOST_PARAM}\``); + if (restText !== bs.paramsText) problems.push(`${moduleFile}: ${member}'s parameters after the host are not the method's own`); + const fs = signatureParts(modSf, fn); + if (fs.typeParams !== bs.typeParams || fs.returnType !== bs.returnType || fs.async !== bs.async || fs.generator !== bs.generator) { + problems.push(`${moduleFile}: ${member}'s type parameters, return type, async or generator flag differ from the method's`); + } + + // AST identity modulo the set. + const ast = compareBodies(baseSf, baseMethod, table, modSf, fn.body, className); + problems.push(...ast.problems); + + // The `Pick` names exactly the host members the body reads. + const { sites, refusals } = rewriteSites(baseSf, baseMethod, table, className); + for (const r of refusals) problems.push(`base: ${member} line ${r.line}: ${r.reason}`); + const want = hostNamesOf(sites); + const pickType = hostParam?.type; + const picked = pickType && ts.isTypeReferenceNode(pickType) && pickType.typeName.getText(modSf) === 'Pick' + ? (() => { + const keys = pickType.typeArguments?.[1]; + const lits = keys ? (ts.isUnionTypeNode(keys) ? keys.types : [keys]) : []; + return lits.map((k) => (ts.isLiteralTypeNode(k) && ts.isStringLiteral(k.literal) ? k.literal.text : '?')).sort(); + })() + : pickType && ts.isTypeReferenceNode(pickType) && pickType.typeName.getText(modSf) === HOST_TYPE && !want.length ? [] : undefined; + if (!picked || picked.join(',') !== want.join(',')) problems.push(`${moduleFile}: ${member}'s host type must be ${want.length ? pickOf(want) : HOST_TYPE}`); + + // Text: comments and layout carried, i.e. the base body rewritten and re-indented. + const bodyStart = baseMethod.body.getStart(baseSf); + const rewritten = applySites(baseSf.text.slice(bodyStart, baseMethod.body.getEnd()), sites, bodyStart); + const expectedText = dedent(rewritten, 4, literalStartsOfBody(rewritten)); + const headText = modSf.text.slice(fn.body.getStart(modSf), fn.body.getEnd()); + let textIdentical = expectedText === headText; + if (!textIdentical) { + const el = expectedText.split('\n'); + const hl = headText.split('\n'); + const at = el.findIndex((l, i) => l !== hl[i]); + problems.push(`${moduleFile}: ${member}'s body text differs from the base body rewritten and re-indented, first at body line ${at + 1}`); + } + return { ok: problems.length === 0, problems, counts: ast.counts, sites: sites.length, textIdentical, hostMembers: want }; +} + +// ── Survey ────────────────────────────────────────────────────────────────── + +/** Every instance method with a body: its line count, sites and refusals under the set. */ +export function survey(source, className = CLASS_NAME) { + const sf = parse('protocol.ts', source); + const cls = classOf(sf, className); + const table = memberTable(cls); + const rows = []; + for (const m of cls.members) { + if (!ts.isMethodDeclaration(m) || !m.body || hasModifier(m, ts.SyntaxKind.StaticKeyword)) continue; + const name = nameOf(m); + const { sites, refusals } = rewriteSites(sf, m, table, className); + const last = m.body.statements[m.body.statements.length - 1]; + let call = last && ts.isReturnStatement(last) ? last.expression : undefined; + if (call && ts.isAwaitExpression(call)) call = call.expression; + const stub = !!call && ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression) + && call.expression.name.text === 'call' && ts.isIdentifier(call.expression.expression) + && !!call.arguments[0] && ts.isIdentifier(call.arguments[0]) ? 'call' + : !!call && ts.isCallExpression(call) && call.arguments[0] && ts.isCallExpression(call.arguments[0]) + && ts.isIdentifier(call.arguments[0].expression) && call.arguments[0].expression.text === HOST_OF ? 'host' : undefined; + const lines = sf.getLineAndCharacterOfPosition(m.getEnd()).line - sf.getLineAndCharacterOfPosition(m.getStart(sf)).line + 1; + rows.push({ name, lines, sites: sites.length, refusals, stub }); + } + return { rows, clashes: table.clashes }; +} + +// ── CLI ───────────────────────────────────────────────────────────────────── + +const SRC = 'src'; +const PROTOCOL = `${SRC}/protocol.ts`; + +/** Insert host entries into the interface (protocol-host.ts) and the static block factory (protocol.ts), sorted, skipping names present. */ +export function insertHostEntries(hostSource, protocolSource, entries) { + const insertSorted = (text, openMarker, closeMarker, lineOf, keyOf) => { + const open = text.indexOf(openMarker); + const close = text.indexOf(closeMarker, open); + if (open < 0 || close < 0) throw new Error(`marker not found: ${openMarker}`); + const head = text.slice(0, open + openMarker.length); + const block = text.slice(open + openMarker.length, close); + const tail = text.slice(close); + const existing = block.split('\n').filter((l) => l.trim()); + const groups = []; + for (const l of existing) { + const k = keyOf(l); + if (k !== undefined) groups.push({ key: k, lines: [l] }); + else if (groups.length) groups[groups.length - 1].lines.push(l); + } + const have = new Set(groups.map((g) => g.key)); + for (const [name, e] of entries) if (!have.has(name)) groups.push({ key: name, lines: lineOf(e).split('\n') }); + groups.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0)); + return `${head}\n${groups.flatMap((g) => g.lines).join('\n')}${tail}`; + }; + const ifaceKey = (l) => /^ {4}(?:readonly )?([A-Za-z_$][\w$]*):/.exec(l)?.[1]; + const factoryKey = (l) => /^ {12}(?:get |set )?([A-Za-z_$][\w$]*)[:(]/.exec(l)?.[1]; + return { + host: insertSorted(hostSource, `export interface ${HOST_TYPE} {`, '\n}', (e) => e.iface, ifaceKey), + protocol: insertSorted(protocolSource, `installProtocolHost((protocol) => ({`, '\n }));', (e) => e.factory, (l) => { + const k = factoryKey(l); + return k !== undefined && !/^ {12}set /.test(l) ? k : undefined; + }), + }; +} + +function main(argv) { + const [cmd, ...rest] = argv; + if (cmd === 'survey') { + const all = survey(readFileSync(PROTOCOL, 'utf8')); + const { clashes } = all; + const stubs = all.rows.filter((r) => r.stub); + console.log(`already stubs: ${stubs.length} (${stubs.filter((r) => r.stub === 'call').length} by \`fn.call(host, …)\` from the per-member series, ${stubs.filter((r) => r.stub === 'host').length} by \`fn(${HOST_OF}(this), …)\`)`); + const rows = all.rows.filter((r) => !r.stub); + const movable = rows.filter((r) => !r.refusals.length); + const reasons = new Map(); + for (const r of rows) for (const f of r.refusals) { + const key = f.reason.replace(/`[^`]*`/g, '`…`'); + reasons.set(key, (reasons.get(key) ?? 0) + 1); + } + console.log(`instance methods with a body, not stubs: ${rows.length}; movable under the closed set: ${movable.length} (${movable.reduce((a, r) => a + r.lines, 0)} lines); refused: ${rows.length - movable.length} (${rows.filter((r) => r.refusals.length).reduce((a, r) => a + r.lines, 0)} lines)`); + console.log(`instance/static name clashes: ${clashes.length ? clashes.join(', ') : 'none'}`); + for (const [k, n] of [...reasons].sort((a, b) => b[1] - a[1])) console.log(` ${n}\t${k}`); + if (rest.includes('--rows')) for (const r of rows) console.log(`${r.refusals.length ? 'REFUSED' : 'movable'}\t${r.name}\t${r.lines}\t${r.sites}${r.refusals.length ? `\t${r.refusals.map((f) => `L${f.line} ${f.reason}`).join(' | ')}` : ''}`); + return 0; + } + if (cmd === 'move') { + const [member, moduleFile] = rest; + if (!member || !moduleFile) throw new Error('usage: move '); + const source = readFileSync(PROTOCOL, 'utf8'); + const plan = planMove(source, member, moduleFile); + if (plan.refusals.length) { + for (const r of plan.refusals) console.error(`REFUSED ${member} line ${r.line}: ${r.reason}`); + return 1; + } + const modPath = `${SRC}/${moduleFile}`; + const existing = existsSync(modPath) ? readFileSync(modPath, 'utf8') : ''; + writeFileSync(modPath, existing ? `${existing.replace(/\n*$/, '\n')}\n${plan.fnText}` : plan.fnText); + const stubbed = source.slice(0, plan.stub.start) + plan.stub.text + source.slice(plan.stub.end); + const hostPath = `${SRC}/${HOST_MODULE}.ts`; + const { host, protocol } = insertHostEntries(readFileSync(hostPath, 'utf8'), stubbed, plan.hostEntries); + writeFileSync(hostPath, host); + writeFileSync(PROTOCOL, protocol); + console.log(`moved ${member} -> ${modPath}: ${plan.sites.length} rewrite site(s); host members ${plan.names.join(', ') || 'none'}`); + console.log(`${modPath}: its imports are not written by this tool; add what tsc names.`); + return 0; + } + if (cmd === 'check') { + const [baseRef, member, moduleFile] = rest; + if (!baseRef || !member || !moduleFile) throw new Error('usage: check '); + const rel = path.relative(execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(), path.resolve(PROTOCOL)); + const baseSource = execFileSync('git', ['show', `${baseRef}:${rel}`], { encoding: 'utf8', maxBuffer: 1 << 28 }); + const r = checkMove({ + baseSource, + headSource: readFileSync(PROTOCOL, 'utf8'), + moduleSource: readFileSync(`${SRC}/${moduleFile}`, 'utf8'), + moduleFile, + member, + }); + console.log(JSON.stringify({ member, module: moduleFile, ok: r.ok, rewrites: r.counts, sites: r.sites, textIdentical: r.textIdentical, hostMembers: r.hostMembers })); + for (const p of r.problems) console.log(`PROBLEM ${p}`); + return r.ok ? 0 : 1; + } + console.error('usage: host-split.mjs survey [--rows] | move | check '); + return 2; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/packages/metadata-protocol/scripts/host-split.test.mjs b/packages/metadata-protocol/scripts/host-split.test.mjs new file mode 100644 index 00000000000..c44607934b9 --- /dev/null +++ b/packages/metadata-protocol/scripts/host-split.test.mjs @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E1] Pins `host-split.mjs`: the closed rewrite set, the check that a + * moved body is the member's own body under exactly that set, and the + * refusals that keep everything outside the set from moving. + * + * Every positive pin here has a negative control beside it: a moved body + * changed by one thing the set does not allow must fail the check, by name. + */ + +import { describe, expect, it } from 'vitest'; +import { + HOST_OF, + checkMove, + classOf, + insertHostEntries, + memberTable, + parse, + planMove, + survey, +} from './host-split.mjs'; + +const C = 'ObjectStackProtocolImplementation'; + +// One class that exercises all three rewrites, a comment, a template literal +// whose second line starts at column 0, an operator and a declaration keyword. +const BASE = `class Base { protected hook(): void {} } +export class ${C} extends Base { + private cache = new Map(); + private count = 0; + private static helper(x: number): number { return x + 1; } + private static readonly LIMIT = 3; + static shared(x: number): number { return x * 2; } + private read(k: string) { return this.cache.get(k); } + + /** The contract of \`target\`. */ + async target(k: string, n: number): Promise { + // a comment the move carries + const v = this.read(k) ?? 0; + this.count += n; + const msg = \`line one +line two \${k}\`; + const same = v === n; + const later = [1, 2].map((x) => this.read(String(x))); + const nested = function (this: unknown) { return this; }; + return ${C}.helper(v) + this.constructor.shared(n) + msg.length + (same ? 1 : 0) + later.length + (nested ? 0 : 1); + } + + bareThis() { return [this]; } + elementThis(k: string) { return this[k]; } + usesSuper() { return super.hook(); } + usesArguments(a: number) { return arguments.length + a; } + namedHost() { const host = 1; return host; } + staticField() { return ${C}.LIMIT; } + staticAsValue() { const f = ${C}.helper; return f(1); } + bareConstructor() { return this.constructor; } + undeclared() { return this.nope; } + *generator() { yield this.count; } + destructured({ a }: { a: number }) { return a + this.count; } + hostAsPropertyName() { return { host: this.count }.host; } + stubbed(k: string) { const self = { count: this.count }; return stubbedBody.call(self, k); } +} +`; + +const HOST_SOURCE = `export interface ProtocolHost { +} +`; +const PROTOCOL_FACTORY = ` + installProtocolHost((protocol) => ({ + })); +`; + +/** Perform the move the tool performs, in memory: the stubbed protocol source and the module source. */ +function move(member = 'target') { + const plan = planMove(BASE, member, 'protocol-target.ts'); + expect(plan.refusals).toEqual([]); + const head = BASE.slice(0, plan.stub.start) + plan.stub.text + BASE.slice(plan.stub.end); + const moduleSource = `import type { ProtocolHost } from './protocol-host.js';\n\n${plan.fnText}`; + return { plan, head, moduleSource }; +} + +const check = (headSource, moduleSource) => checkMove({ + baseSource: BASE, + headSource, + moduleSource, + moduleFile: 'protocol-target.ts', + member: 'target', +}); + +describe('host-split: the move', () => { + it('rewrites exactly the three kinds and names exactly the members the body reads', () => { + const { plan } = move(); + const kinds = plan.sites.map((s) => `${s.kind}:${s.member}`); + expect(kinds).toEqual([ + 'member:read', + 'member:count', + 'member:read', + 'privateStatic:helper', + 'constructor:shared', + ]); + expect(plan.names).toEqual(['count', 'helper', 'read', 'shared']); + // `this` inside a nested `function` is not the method's: it does not move. + expect(plan.fnText).toContain('function (this: unknown) { return this; }'); + // The column-0 line inside the template literal keeps its bytes. + expect(plan.fnText).toContain('\nline two ${k}`;'); + expect(plan.fnText).toContain(' // a comment the move carries\n'); + expect(plan.fnText).toContain( + "export async function target(host: Pick, k: string, n: number): Promise {", + ); + expect(plan.stub.text).toContain(`return target(${HOST_OF}(this), k, n);`); + }); + + it('builds host entries by kind: a method closure, a field accessor pair when written, a static closure', () => { + const { plan } = move(); + const e = Object.fromEntries([...plan.hostEntries].map(([k, v]) => [k, v])); + expect(e.read.iface).toBe(` readonly read: ${C}['read'];`); + expect(e.read.factory).toBe(' read: (...a) => protocol.read(...a),'); + expect(e.count.iface).toBe(` count: ${C}['count'];`); + expect(e.count.factory).toBe(' get count() { return protocol.count; },\n set count(value) { protocol.count = value; },'); + expect(e.helper.iface).toBe(` readonly helper: (typeof ${C})['helper'];`); + expect(e.helper.factory).toBe(` helper: (...a) => ${C}.helper(...a),`); + expect(e.shared.iface).toBe(` readonly shared: (typeof ${C})['shared'];`); + + const { host, protocol } = insertHostEntries(HOST_SOURCE, PROTOCOL_FACTORY, plan.hostEntries); + expect(host.split('\n').filter((l) => l.startsWith(' ')).map((l) => l.trim().split(':')[0])).toEqual([ + 'count', 'readonly helper', 'readonly read', 'readonly shared', + ]); + expect(protocol).toContain(' get count() { return protocol.count; },\n set count(value) { protocol.count = value; },\n helper:'); + // Inserting again adds nothing. + const again = insertHostEntries(host, protocol, plan.hostEntries); + expect(again).toEqual({ host, protocol }); + }); + + it.each([ + ['bareThis', 'a bare `this`'], + ['elementThis', '`this[…]`'], + ['usesSuper', '`super`'], + ['usesArguments', '`arguments` of the method'], + ['namedHost', 'already named `host`'], + ['staticField', 'private static `LIMIT` (field) read'], + ['staticAsValue', 'private static `helper` (method) read as a value'], + ['bareConstructor', '`this.constructor` not followed by `.`'], + ['undeclared', '`this.nope` names no declared instance member'], + ['generator', 'a generator method'], + ['destructured', 'a destructured parameter'], + ])('refuses %s: %s', (member, reason) => { + const plan = planMove(BASE, member, 'protocol-target.ts'); + expect(plan.refusals.map((r) => r.reason).join(' | ')).toContain(reason); + expect(plan.fnText).toBeUndefined(); + }); + + it('does not refuse `host` as a property name', () => { + expect(planMove(BASE, 'hostAsPropertyName', 'protocol-target.ts').refusals).toEqual([]); + }); + + it('survey: tells stubs from movable and refused members', () => { + const { rows, clashes } = survey(BASE); + const by = Object.fromEntries(rows.map((r) => [r.name, r])); + expect(clashes).toEqual([]); + expect(by.stubbed.stub).toBe('call'); + expect(by.target.refusals).toEqual([]); + expect(by.bareThis.refusals).toHaveLength(1); + const { head } = move(); + expect(Object.fromEntries(survey(head).rows.map((r) => [r.name, r.stub])).target).toBe('host'); + }); + + it('reports a name declared both as an instance and a static member', () => { + const table = memberTable(classOf(parse('x.ts', `class ${C} { x() {} static x() {} }`))); + expect(table.clashes).toEqual(['x']); + }); +}); + +describe('host-split: the check', () => { + it('passes the move it made: AST identity modulo the set, each kind counted, text identical', () => { + const { head, moduleSource } = move(); + const r = check(head, moduleSource); + expect(r.problems).toEqual([]); + expect(r.ok).toBe(true); + expect(r.textIdentical).toBe(true); + expect(r.counts).toEqual({ member: 3, privateStatic: 1, constructor: 1 }); + expect(r.hostMembers).toEqual(['count', 'helper', 'read', 'shared']); + }); + + // Negative controls: one change outside the set each, and the check names it. + it.each([ + ['an operator', (m) => m.replace('?? 0', '|| 0'), 'where the base has'], + ['an equality operator', (m) => m.replace('v === n', 'v !== n'), 'where the base has'], + ['a declaration keyword', (m) => m.replace('const v =', 'let v ='), 'where the base has'], + ['a literal', (m) => m.replace('[1, 2]', '[1, 3]'), 'where the base has'], + ['a member rewritten to another member', (m) => m.replace('host.read(k)', 'host.cache(k)'), '`this.read` must read `host.read`'], + ['a member left unrewritten', (m) => m.replace('host.read(k)', 'this.read(k)'), '`this.read` must read `host.read`'], + ['a private static left on the class', (m) => m.replace('host.helper(v)', `${C}.helper(v)`), `\`${C}.helper(…)\` must read \`host.helper(…)\``], + ['`this.constructor.` rewritten wrong', (m) => m.replace('host.shared(n)', 'host.helper(n)'), '`this.constructor.shared` must read `host.shared`'], + ['an extra statement', (m) => m.replace(" return host.helper", " void 0;\n return host.helper"), 'children where the base has'], + ['a comment changed (AST-equal, text not)', (m) => m.replace('a comment the move carries', 'a comment the move changed'), "body text differs"], + ['the Pick naming a member the body does not read', (m) => m.replace("'count' | ", "'cache' | 'count' | "), 'host type must be'], + ['the Pick missing a member the body reads', (m) => m.replace(" | 'shared'", ''), 'host type must be'], + ['the host parameter renamed', (m) => m.replace('target(host:', 'target(h:'), 'first parameter is not `host`'], + ['a parameter retyped', (m) => m.replace('n: number)', 'n: unknown)'), "parameters after the host are not the method's own"], + ['the return type changed', (m) => m.replace('): Promise {', '): Promise {'), 'return type'], + ])('fails %s', (_label, mutate, expected) => { + const { head, moduleSource } = move(); + const mutated = mutate(moduleSource); + expect(mutated).not.toBe(moduleSource); + const r = check(head, mutated); + expect(r.ok).toBe(false); + expect(r.problems.join('\n')).toContain(expected); + }); + + it.each([ + ['the stub forwarding its parameters out of order', (h) => h.replace('target(protocolHost(this), k, n)', 'target(protocolHost(this), n, k)'), 'is not the stub'], + ['the stub handing over anything but the host', (h) => h.replace('target(protocolHost(this), k, n)', 'target(this, k, n)'), 'is not the stub'], + ["the method's docblock edited", (h) => h.replace('The contract of `target`.', 'The contract.'), 'docblock, modifiers or signature changed'], + ])('fails %s', (_label, mutate, expected) => { + const { head, moduleSource } = move(); + const mutated = mutate(head); + expect(mutated).not.toBe(head); + const r = check(mutated, moduleSource); + expect(r.ok).toBe(false); + expect(r.problems.join('\n')).toContain(expected); + }); +}); diff --git a/packages/metadata-protocol/src/protocol-host.ts b/packages/metadata-protocol/src/protocol-host.ts new file mode 100644 index 00000000000..a933011bb8b --- /dev/null +++ b/packages/metadata-protocol/src/protocol-host.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E1] The host: what a body moved out of `ObjectStackProtocolImplementation` + * reaches the class through. + * + * Ruling E (6537939895) moves the class's members out of `protocol.ts` a whole + * section at a time (E2). A moved body is an exported function whose first + * parameter is `host`, and it differs from the method body it was by exactly one + * closed set of rewrites, checked by `scripts/host-split.mjs`: + * + * - `this.` → `host.`; + * - a private static `ObjectStackProtocolImplementation.(…)` → `host.(…)`; + * - `this.constructor` → `host`. + * + * The method stays on the class with its docblock and signature, so the published + * `.d.ts` keeps it, and its body becomes `return (protocolHost(this), …)`. + * + * ## The host is one object per protocol instance + * + * {@link ProtocolHost} lists every class member some moved body reads, each typed + * AS that member (`ObjectStackProtocolImplementation['x']`, or + * `(typeof ObjectStackProtocolImplementation)['x']` for a static), so a change to + * a member's signature on the class is a change here, and naming a member the + * class does not have is a compile error. A moved function takes the `Pick` of + * exactly the members its body reads. + * + * The object is built by the class itself, in its static block in `protocol.ts`: + * only code inside the class body can read its private members, and a static + * block adds no member to the class, so the published `.d.ts` does not move. + * There: + * + * - a METHOD is a closure that calls it on the instance at call time + * (`(...a) => protocol.x(...a)`), never a bound or copied function, so a + * method replaced on the instance or its prototype afterwards (a test double, + * a spy) is still the one a moved body reaches; + * - a FIELD is a getter that reads the instance's field when it is read, and a + * setter too when a moved body assigns it, so a moved body sees and makes the + * same writes the method body did; + * - a private STATIC method is a closure to the static. + * + * The census of read seams (`scripts/check-durability-degradation-log-level.mjs`, + * `HOST_SPLIT`) and the two by-path pins (`protocol.read-verb-canonical-fold`, + * `protocol.lookup-artifact-item-call-sites`) read a moved body through this + * shape: a `host.x(…)` call resolves to the class method `x`, and a stub + * `return fn(protocolHost(this), …)` to `fn`'s body. + */ + +import type { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * Every `ObjectStackProtocolImplementation` member a moved body reads, sorted by + * name. `scripts/host-split.mjs move` adds the entries a move needs, here and in + * the class's static block. + */ +export interface ProtocolHost { + readonly overrideWriteVerdict: ObjectStackProtocolImplementation['overrideWriteVerdict']; + readonly packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner']; + readonly packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; + readonly storedFlowBindingAgrees: ObjectStackProtocolImplementation['storedFlowBindingAgrees']; + readonly uninstalledNamedBaseRefusal: ObjectStackProtocolImplementation['uninstalledNamedBaseRefusal']; +} + +const hosts = new WeakMap(); +let build: ((protocol: ObjectStackProtocolImplementation) => ProtocolHost) | undefined; + +/** + * Called once, from `ObjectStackProtocolImplementation`'s static block: the one + * place that can read the class's private members to build a host. + */ +export function installProtocolHost(factory: (protocol: ObjectStackProtocolImplementation) => ProtocolHost): void { + build = factory; +} + +/** The host of one protocol instance, built on first use and kept for the instance's life. */ +export function protocolHost(protocol: ObjectStackProtocolImplementation): ProtocolHost { + let host = hosts.get(protocol); + if (host === undefined) { + if (build === undefined) { + throw new Error('protocolHost: ObjectStackProtocolImplementation has not installed its host factory'); + } + host = build(protocol); + hosts.set(protocol, host); + } + return host; +} diff --git a/packages/metadata-protocol/src/protocol-tenant-authored-write.ts b/packages/metadata-protocol/src/protocol-tenant-authored-write.ts new file mode 100644 index 00000000000..75c500bf9e9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-tenant-authored-write.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E1] The pilot of the host split: `tenantAuthoredWriteRefusal`'s body, + * moved out of `protocol.ts` by `scripts/host-split.mjs move` to prove the + * machinery E2's section cuts use, on one member. + * + * A MOVE, not a redesign. The function below is the body of the + * `ObjectStackProtocolImplementation` method of the same name as it stood, + * re-indented one level, with `this.` read as `host.` and + * nothing else changed (`node scripts/host-split.mjs check …` proves + * it). The method stays on the class with its docblock and signature, which the + * published `.d.ts` prints, and hands its body the host (`protocol-host.ts`). + * + * What the body reads from elsewhere, it imports from where `protocol.ts` + * imports it. Nothing re-exports this module, so the published surface does + * not move. + */ + +import { + // [#20761, ADR-0126 §2] The ADR-0029 D9.6 reading of a body's own + // provenance stamps — asked by {@link + // ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal} to tell a + // body that CLAIMS to be code-shipped from one that does not. + isCodeArtifactBody, +} from '@objectstack/metadata-core'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import type { ProtocolHost } from './protocol-host.js'; +import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js'; + +/** The body of `ObjectStackProtocolImplementation#tenantAuthoredWriteRefusal`; the method's docblock there is its contract. */ +export async function tenantAuthoredWriteRefusal(host: Pick, request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise { + const folded = canonicalizeMetaRequestType(request); + const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type; + if (singular !== 'flow') return null; + const shipped = host.packagedArtifactOwner(folded) !== undefined; + // [#28412, ADR-0146 D2 / D9] Rule 1's lock does not apply to the + // shipping package's declared override: the one override write gate + // ({@link overrideWriteVerdict}) admits it as the pair's own row, which + // edits nothing the package ships. Any other write of a shipped name + // keeps the lock. + if (shipped && host.overrideWriteVerdict({ + type: singular, + name: folded.name, + item: folded.item, + ...(folded.packageId ? { packageId: folded.packageId } : {}), + }).verdict !== 'admit') { + const locked = host.packagedBaseRefusal({ + type: folded.type, + name: folded.name, + operation: 'save', + ...(folded.packageId ? { packageId: folded.packageId } : {}), + }); + if (locked) return locked; + } + // [#20863] The named-base rule (see the docblock): a base the write + // names must be a package the registry holds as installed. [#26708] + // The rule is {@link uninstalledNamedBaseRefusal}'s, which + // `saveMetaItem` asks for every other type too. + const unbound = host.uninstalledNamedBaseRefusal({ type: singular, name: folded.name, packageId: folded.packageId }); + if (unbound) return unbound; + if (shipped) return null; + if (!isCodeArtifactBody(folded.item)) return null; + const stamped = String((folded.item as { _packageId?: unknown })._packageId); + if (folded.packageId === stamped) return null; + if (await host.storedFlowBindingAgrees(folded.name, stamped)) return null; + const err = new Error( + `Flow '${folded.name}' was sent with a code-package provenance, but no managed package ships a flow of ` + + `that name. A flow written through an authoring door is authored in this deployment: which flows a ` + + `package ships is established by the package loader, never by the definition a caller sends. ` + + `Remove the provenance keys (_packageId, _packageVersion, _provenance) from the definition and send it ` + + `again. Nothing was written. See docs/adr/0126-packaged-metadata-customization-model.md.`, + ); + (err as any).code = 'INVALID_METADATA'; + (err as any).status = 422; + return err; +} diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index 06bc819c9f8..da91dea2621 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -34,15 +34,34 @@ * function the class member delegates to and that member's two calls; the * class member keeps its signature in `protocol.ts`, and `shippedArtifactsOf`'s * stub there hands the moved body the lookup as a closure, one more site. + * + * [#25467 E1] …and every other `protocol-*.ts` module of the split, once: a + * member's body that moves out of `protocol.ts` (ruling E's host split, or the + * per-member series before it) takes its calls with it, so the scan reads every + * module the split can move a body to, by the same rule. A site in a moved body + * is keyed by its module and the function it sits in. Comments are blanked + * before the scan in every file, so a docblock that names the callee (two + * modules' do) is not a site. */ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +// [#25467 E1] Comments blanked (offsets and line numbers kept), so a docblock that +// names the callee is not read as a call. +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8'); /** [#25467 cut 15] The seam module the lookup's body moved to; scanned with its own header shape. */ const SEAM_FILE = 'protocol-artifact-lookup.ts'; -const SEAM = readFileSync(fileURLToPath(new URL(`./${SEAM_FILE}`, import.meta.url)), 'utf8'); +/** + * [#25467 E1] Every module of the split: `protocol-*.ts` beside `protocol.ts`, + * tests excluded — the census's own module pattern (`HOST_SPLIT.module` in + * `scripts/check-durability-degradation-log-level.mjs`). + */ +const SPLIT_MODULES = readdirSync(fileURLToPath(new URL('.', import.meta.url))) + .filter((file) => /^protocol-[a-z0-9-]+\.ts$/.test(file)) + .sort(); +const MODULE_SOURCES = new Map(SPLIT_MODULES.map((file) => [file, readFileSync(fileURLToPath(new URL(`./${file}`, import.meta.url)), 'utf8')])); const CALLEE = 'lookupArtifactItem('; /** * [#25467 E0] The module the protocol.ts preamble's package-binding helpers moved @@ -53,16 +72,16 @@ const BINDINGS = readFileSync(fileURLToPath(new URL(`./${BINDINGS_FILE}`, import /** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */ const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; -/** A module function's header in the seam module: `export`, optional `async`, `function`, its name, its parameter list. */ -const FUNCTION_HEADER = /^export (?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; +/** A module function's header in a split module: optional `export` and `async`, `function`, its name, its parameter list. */ +const FUNCTION_HEADER = /^(?:export )?(?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; interface Site { readonly file?: string; readonly member: string; readonly args: string } -/** Every `lookupArtifactItem(` in `protocol.ts` and the seam module: the file, the member it sits in and its argument text, whitespace-normalised. */ +/** Every `lookupArtifactItem(` in `protocol.ts` and every split module: the file, the member it sits in and its argument text, whitespace-normalised. */ function sitesIn(): Site[] { return [ - ...sitesOf(SOURCE, MEMBER_HEADER), - ...sitesOf(SEAM, FUNCTION_HEADER).map((site) => ({ ...site, file: SEAM_FILE })), + ...sitesOf(maskComments(SOURCE), MEMBER_HEADER), + ...SPLIT_MODULES.flatMap((file) => sitesOf(maskComments(MODULE_SOURCES.get(file)!), FUNCTION_HEADER).map((site) => ({ ...site, file }))), ]; } @@ -173,6 +192,19 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo expect(found.filter((site) => site.file === SEAM_FILE)).toHaveLength(3); }); + it('[#25467 E1] every module of the split is scanned, the host split\'s own included', () => { + // Non-vacuity for the population of files: the E0 preamble modules, the + // per-member series' modules and the host split's are all read. + expect(SPLIT_MODULES.length).toBeGreaterThan(40); + expect(SPLIT_MODULES).toContain(SEAM_FILE); + expect(SPLIT_MODULES).toContain('protocol-host.ts'); + expect(SPLIT_MODULES).toContain('protocol-tenant-authored-write.ts'); + expect(SPLIT_MODULES.some((file) => file.endsWith('.test.ts'))).toBe(false); + // A docblock naming the callee is not a site: two modules carry one. + expect(MODULE_SOURCES.get('protocol-credential-carry-forward.ts')).toContain('`' + CALLEE); + expect(found.filter((site) => site.file === 'protocol-credential-carry-forward.ts')).toEqual([]); + }); + it('a site passing fewer than three arguments is recorded as passing no package', () => { const topLevelArgs = (args: string) => { let depth = 0; diff --git a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts index cee9948c53d..5fc3b35534b 100644 --- a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts +++ b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts @@ -60,6 +60,7 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { META_URL_TO_SINGULAR, PLURAL_TO_SINGULAR, metaUrlSpellingRefusal } from '@objectstack/spec/shared'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -266,12 +267,42 @@ describe('#9157 — the population, re-derived from the code rather than from th } return undefined; }; + // [#25467 E1] A member whose body moved out of protocol.ts (ruling E's + // host split, or the per-member series before it) keeps its signature + // here and its body in a `protocol-*.ts` module: the stub's last line is + // `return fn(protocolHost(this), …)` or `return fn.call(host, …)`, `fn` a + // named import from that module. The verb is judged by the moved body, + // read from the module protocol.ts imports `fn` from: every module, by + // this one rule. + const moduleImports = new Map(); + for (const m of maskComments(src).matchAll(/\nimport \{([^}]*)\} from '\.\/(protocol-[a-z0-9-]+)\.js';/g)) { + for (const spec of m[1]!.split(',')) { + const local = /^\s*(?:[A-Za-z_$][\w$]*\s+as\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(spec)?.[1]; + if (local !== undefined) moduleImports.set(local, `${m[2]}.ts`); + } + } + // The stub's last statement, right before the member's own closing brace + // (a member slice runs on to the next scanned member's header). + const STUB = /\n {8}return (?:await )?([A-Za-z_$][\w$]*)(?:\(protocolHost\(this\)|\.call\([A-Za-z_$][\w$]*)[,)][^\n]*\n {4}\}\n/; + const throughSplit: string[] = []; + const movedBody = (body: string): string => { + const own = body.slice(0, body.indexOf('\n }\n') + 7); + const fn = STUB.exec(own)?.[1]; + if (fn === undefined || !moduleImports.has(fn)) return body; + const file = moduleImports.get(fn)!; + throughSplit.push(fn); + const text = readFileSync(fileURLToPath(new URL(`./${file}`, import.meta.url)), 'utf8'); + const at = text.search(new RegExp(`\\nexport (?:async )?function ${fn}\\b`)); + expect(at, `the stub hands its body to '${fn}', which ${file} does not export`).toBeGreaterThanOrEqual(0); + const end = text.indexOf('\n}\n', at + 1); + return text.slice(at, end < 0 ? text.length : end + 2); + }; const delegateBody = (body: string): string => { const to = /this\.([A-Za-z_][A-Za-z0-9_]*Unaudited)\(request\)/.exec(body)?.[1]; - if (to === undefined) return body; + if (to === undefined) return movedBody(body); const line = lines.findIndex((l) => l.startsWith(` private async ${to}(`)); expect(line, `the wrapper delegates to '${to}', which the scan cannot find`).toBeGreaterThanOrEqual(0); - return memberSlice(line); + return movedBody(memberSlice(line)); }; const unfolded: string[] = []; @@ -299,6 +330,10 @@ describe('#9157 — the population, re-derived from the code rather than from th (/canonicalizeMetaRequestType\(request\)/.test(body) ? folded : unfolded).push(starts[k]!.name); } + // [#25467 E1] Non-vacuity of the split resolution: the host split's pilot + // is judged by its moved body, so a stub regex that stopped matching + // would fail here instead of silently judging a one-line stub. + expect(throughSplit).toContain('tenantAuthoredWriteRefusal'); expect( unfolded, 'A `/meta` verb takes a required `type` without folding it at the request boundary. ' diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a49c188c559..f77f8a23dfc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -69,11 +69,6 @@ import { // it prints it at. findUndeclarableFieldType, isObjectFieldTypeRefused, - // [#20761, ADR-0126 §2] The ADR-0029 D9.6 reading of a body's own - // provenance stamps — asked by {@link - // ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal} to tell a - // body that CLAIMS to be code-shipped from one that does not. - isCodeArtifactBody, // [#21059] The tenant marker the hydrator writes on every stored row it // registers (ADR-0010 `_provenance: 'org'`) — read by // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer @@ -720,6 +715,11 @@ import type { RollbackMetaItemRequest, SaveMetaItemRequest, } from './protocol-door-requests.js'; +// [#25467 E1] The host a moved member body reaches this class through: the +// class builds it in its static block below, and a stub hands it to the body. +import { installProtocolHost, protocolHost } from './protocol-host.js'; +// [#25467 E1] The host split's pilot: one member's whole body, moved through the host. +import { tenantAuthoredWriteRefusal } from './protocol-tenant-authored-write.js'; /** * [#4435/#5138] The 404 a single-record operation answers when the id names no @@ -1264,6 +1264,19 @@ export class ObjectStackProtocolImplementation implements this.authoringChannel = authoringChannel; } + // [#25467 E1] The one place that can read this class's private members builds + // the host its moved bodies reach it through (`protocol-host.ts`). A static + // block adds no member to the class: the published `.d.ts` does not move. + static { + installProtocolHost((protocol) => ({ + overrideWriteVerdict: (...a) => protocol.overrideWriteVerdict(...a), + packagedArtifactOwner: (...a) => protocol.packagedArtifactOwner(...a), + packagedBaseRefusal: (...a) => protocol.packagedBaseRefusal(...a), + storedFlowBindingAgrees: (...a) => protocol.storedFlowBindingAgrees(...a), + uninstalledNamedBaseRefusal: (...a) => protocol.uninstalledNamedBaseRefusal(...a), + })); + } + /** * Register a publish-time materializer for a metadata type (ADR-0086 P2). * Called by domain plugins at init (e.g. service-automation registers the @@ -11118,50 +11131,8 @@ export class ObjectStackProtocolImplementation implements * proceed on these grounds. */ async tenantAuthoredWriteRefusal(request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise { - const folded = canonicalizeMetaRequestType(request); - const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type; - if (singular !== 'flow') return null; - const shipped = this.packagedArtifactOwner(folded) !== undefined; - // [#28412, ADR-0146 D2 / D9] Rule 1's lock does not apply to the - // shipping package's declared override: the one override write gate - // ({@link overrideWriteVerdict}) admits it as the pair's own row, which - // edits nothing the package ships. Any other write of a shipped name - // keeps the lock. - if (shipped && this.overrideWriteVerdict({ - type: singular, - name: folded.name, - item: folded.item, - ...(folded.packageId ? { packageId: folded.packageId } : {}), - }).verdict !== 'admit') { - const locked = this.packagedBaseRefusal({ - type: folded.type, - name: folded.name, - operation: 'save', - ...(folded.packageId ? { packageId: folded.packageId } : {}), - }); - if (locked) return locked; - } - // [#20863] The named-base rule (see the docblock): a base the write - // names must be a package the registry holds as installed. [#26708] - // The rule is {@link uninstalledNamedBaseRefusal}'s, which - // `saveMetaItem` asks for every other type too. - const unbound = this.uninstalledNamedBaseRefusal({ type: singular, name: folded.name, packageId: folded.packageId }); - if (unbound) return unbound; - if (shipped) return null; - if (!isCodeArtifactBody(folded.item)) return null; - const stamped = String((folded.item as { _packageId?: unknown })._packageId); - if (folded.packageId === stamped) return null; - if (await this.storedFlowBindingAgrees(folded.name, stamped)) return null; - const err = new Error( - `Flow '${folded.name}' was sent with a code-package provenance, but no managed package ships a flow of ` - + `that name. A flow written through an authoring door is authored in this deployment: which flows a ` - + `package ships is established by the package loader, never by the definition a caller sends. ` - + `Remove the provenance keys (_packageId, _packageVersion, _provenance) from the definition and send it ` - + `again. Nothing was written. See docs/adr/0126-packaged-metadata-customization-model.md.`, - ); - (err as any).code = 'INVALID_METADATA'; - (err as any).status = 422; - return err; + // [#25467 E1] The body moved to `protocol-tenant-authored-write.ts`; it reaches this class through `protocol-host.ts`. + return tenantAuthoredWriteRefusal(protocolHost(this), request); } /** diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 6e1adca3f55..5a3a7c3e609 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -3076,6 +3076,30 @@ function unwrapExpression(expr) { * costs no depth — counting it would make every move push its callers one * hop closer to the bound. * + * [#25467 E1] Ruling E (6537939895) moves whole sections through ONE host + * instead of a `this` literal per stub: a moved body reads `host.x` where it + * read `this.x`, and the stub hands it `protocolHost(this)` (`protocol-host.ts`). + * Both shapes above follow it, by the same rule and nothing wider: + * + * HOST CALL — `host.x(…)` where `host` is the FIRST parameter of the function + * the call sits in (arrows inherit it), typed by a host interface (bare, or + * through `Pick` naming `x`) that the module declares or + * imports by name from another `./protocol-*.js` module of this package that + * declares it, whose member `x` is typed `ObjectStackProtocolImplementation['x']` + * (an instance method) or `(typeof ObjectStackProtocolImplementation)['x']` + * (a static method — the host carries the class's private statics too). + * + * STUB — a method whose LAST statement returns `fn(protocolHost(this), …)` + * (awaited or not), `fn` as above. + * + * Measured when E1 landed: `main` @ 838a802bf3 reads 98, the E1 tree 100 — + * the two new lines are `protocol-packageless-overlay-migration.ts`'s + * `migratePackagelessOverlays()` guarding `storedPairRow()`, whose + * `host.findEnvironmentRows()` the HOST CALL rule now follows (that helper + * already took its module's own host interface as a first parameter named + * `host`, the shape ruling E makes general). Both read "no invented answer", + * so no verdict and no ledger row moved; the pilot's moved body loses none. + * * Measured when this landed, with the resolver: `main` @ bc496bf7 reads * 95, not 93 — the two new lines are `protocol-batch-data.ts`'s * `runBatchDataLoop()` and `runUpdateManyLoop()` guarding @@ -3100,6 +3124,10 @@ const HOST_SPLIT = Object.freeze({ hostClass: 'ObjectStackProtocolImplementation', module: /^protocol-[a-z0-9-]+\.ts$/, specifier: /^\.\/(protocol-[a-z0-9-]+)\.js$/, + // [#25467 E1] Ruling E's host split: the parameter a moved body reads the + // class through, and the function a stub builds it with. + hostParam: 'host', + hostOf: 'protocolHost', }); /** @@ -3139,22 +3167,26 @@ function createHostSplit(load) { return made; }; - /** The host class's own methods, by name — `undefined` for a name declared twice. */ - const hostMethods = () => { + /** + * The host class's own methods, by name — `undefined` for a name declared + * twice. `static: true` answers its static methods instead (#25467 E1). + */ + const hostMethods = ({ static: wantStatic = false } = {}) => { const e = entry(hostRel); if (!e) return undefined; - if (e.methods) return e.methods; - e.methods = new Map(); + const key = wantStatic ? 'statics' : 'methods'; + if (e[key]) return e[key]; + e[key] = new Map(); for (const stmt of e.sf.statements) { if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== HOST_SPLIT.hostClass) continue; for (const m of stmt.members) { if (!ts.isMethodDeclaration(m) || !m.body || !ts.isIdentifier(m.name)) continue; - const static_ = m.modifiers?.some((x) => x.kind === ts.SyntaxKind.StaticKeyword); - if (static_) continue; - e.methods.set(m.name.text, e.methods.has(m.name.text) ? undefined : m.body); + const static_ = !!m.modifiers?.some((x) => x.kind === ts.SyntaxKind.StaticKeyword); + if (static_ !== wantStatic) continue; + e[key].set(m.name.text, e[key].has(m.name.text) ? undefined : m.body); } } - return e.methods; + return e[key]; }; /** The `./protocol-*.js` named imports of the host file: local name → module path. */ @@ -3190,6 +3222,42 @@ function createHostSplit(load) { ts.isStringLiteral(type.indexType.literal) && type.indexType.literal.text === name; + /** #25467 E1: is `type` the class's STATIC member `name`, spelled `(typeof ObjectStackProtocolImplementation)['name']`? */ + const typedAsHostStatic = (type, name) => { + if (!type || !ts.isIndexedAccessTypeNode(type)) return false; + let obj = type.objectType; + while (obj && ts.isParenthesizedTypeNode(obj)) obj = obj.type; + return !!obj && ts.isTypeQueryNode(obj) && ts.isIdentifier(obj.exprName) && + obj.exprName.text === HOST_SPLIT.hostClass && + ts.isLiteralTypeNode(type.indexType) && + ts.isStringLiteral(type.indexType.literal) && + type.indexType.literal.text === name; + }; + + /** + * #25467 E1: the interface `hostType` names in module `rel` — declared there, + * or imported by name from another `./protocol-*.js` module of the split that + * declares it. `undefined` otherwise. + */ + const hostInterface = (rel, hostType) => { + const e = entry(rel); + if (!e) return undefined; + const own = e.sf.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === hostType); + if (own) return own; + for (const stmt of e.sf.statements) { + if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + const m = HOST_SPLIT.specifier.exec(stmt.moduleSpecifier.text); + const named = stmt.importClause?.namedBindings; + if (!m || !named || !ts.isNamedImports(named)) continue; + const el = named.elements.find((x) => x.name.text === hostType); + if (!el) continue; + const from = entry(`${HOST_SPLIT.dir}/${m[1]}.ts`); + const exported = (el.propertyName ?? el.name).text; + return from?.sf.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === exported); + } + return undefined; + }; + /** * HOST CALL: resolve `this.name(…)` in a module to the host class's method * body, through the module's own host interface — or `undefined`. @@ -3197,14 +3265,17 @@ function createHostSplit(load) { const hostCall = (node, bodies) => { if (!isModule(bodies.relPath)) return undefined; const expr = node.expression; - if (!ts.isPropertyAccessExpression(expr) || expr.expression.kind !== ts.SyntaxKind.ThisKeyword) return undefined; - if (!ts.isIdentifier(expr.name)) return undefined; + if (!ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.name)) return undefined; + // `this.x(…)` (the per-member series) or, #25467 E1, `host.x(…)` (ruling E's host split). + const viaHostParam = ts.isIdentifier(expr.expression) && expr.expression.text === HOST_SPLIT.hostParam; + if (expr.expression.kind !== ts.SyntaxKind.ThisKeyword && !viaHostParam) return undefined; const name = expr.name.text; - // The function whose `this` the call reads: arrows inherit it. + // The function whose `this` (or first parameter) the call reads: arrows inherit it. let fn = node.parent; while (fn && (ts.isArrowFunction(fn) || !ts.isFunctionLike(fn))) fn = fn.parent; const thisParam = fn?.parameters?.[0]; - if (!thisParam || !ts.isIdentifier(thisParam.name) || thisParam.name.text !== 'this') return undefined; + const paramName = viaHostParam ? HOST_SPLIT.hostParam : 'this'; + if (!thisParam || !ts.isIdentifier(thisParam.name) || thisParam.name.text !== paramName) return undefined; let type = thisParam.type; if (!type || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) return undefined; if (type.typeName.text === 'Pick') { @@ -3215,14 +3286,18 @@ function createHostSplit(load) { type = target; } const hostType = type.typeName.text; - const iface = bodies.sourceFile?.statements.find( - (s) => ts.isInterfaceDeclaration(s) && s.name.text === hostType, - ); + // The per-member series' host interface is the module's own; ruling E's + // may be imported from the module that declares it (`protocol-host.ts`). + const iface = viaHostParam + ? hostInterface(bodies.relPath, hostType) + : bodies.sourceFile?.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === hostType); const member = iface?.members.find( (m) => ts.isPropertySignature(m) && m.name && ts.isIdentifier(m.name) && m.name.text === name, ); - if (!member || !typedAsHostMember(member.type, name)) return undefined; - const body = hostMethods()?.get(name); + if (!member) return undefined; + const body = typedAsHostMember(member.type, name) ? hostMethods()?.get(name) + : viaHostParam && typedAsHostStatic(member.type, name) ? hostMethods({ static: true })?.get(name) + : undefined; return body ? { body, bodies: entry(hostRel).bodies } : undefined; }; @@ -3239,11 +3314,23 @@ function createHostSplit(load) { if (!last || !ts.isReturnStatement(last) || !last.expression) return undefined; let call = last.expression; if (ts.isAwaitExpression(call)) call = call.expression; - if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return undefined; + if (!ts.isCallExpression(call)) return undefined; + let fnName; const callee = call.expression; - if (!ts.isIdentifier(callee.name) || callee.name.text !== 'call' || !ts.isIdentifier(callee.expression)) return undefined; - if (!call.arguments[0] || !ts.isIdentifier(call.arguments[0])) return undefined; - const imported = hostImports()?.get(callee.expression.text); + const first = call.arguments[0]; + if (ts.isPropertyAccessExpression(callee)) { + // `fn.call(host, …)` — the per-member series. + if (!ts.isIdentifier(callee.name) || callee.name.text !== 'call' || !ts.isIdentifier(callee.expression)) return undefined; + if (!first || !ts.isIdentifier(first)) return undefined; + fnName = callee.expression.text; + } else if (ts.isIdentifier(callee)) { + // #25467 E1: `fn(protocolHost(this), …)` — ruling E's host split. + if (!first || !ts.isCallExpression(first) || !ts.isIdentifier(first.expression) || + first.expression.text !== HOST_SPLIT.hostOf || first.arguments.length !== 1 || + first.arguments[0].kind !== ts.SyntaxKind.ThisKeyword) return undefined; + fnName = callee.text; + } else return undefined; + const imported = hostImports()?.get(fnName); if (!imported || !isModule(imported.rel)) return undefined; const mod = entry(imported.rel); if (!mod) return undefined; @@ -4928,6 +5015,16 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'flags: #25467 — a protocol.ts try around a stub follows the moved body, and the stub costs no hop': 1, 'BOUND (#25467): the host hop IS a hop — a third hop through the host is still refused': 1, 'BOUND (#25467): the same host ladder IS a seam one hop deeper — the pin above is not vacuous': 1, + 'flags: #25467 E1 — a moved body\'s `host.x()` into a protocol.ts storage wrapper, through the imported host interface, is still that read seam': 1, + 'passes: #25467 E1 — negative control: a moved body whose `host.x()` reaches no storage wrapper is no seam': 1, + 'passes: #25467 E1 — a `host` that is not the function\'s first parameter resolves nowhere': 1, + 'passes: #25467 E1 — a host interface imported from outside the split\'s modules resolves nowhere': 1, + 'passes: #25467 E1 — a host member typed any way but AS the class member resolves nowhere': 1, + 'flags: #25467 E1 — a private static reached as `host.x()`, typed AS the class\'s static, is still that read seam': 1, + 'flags: #25467 E1 — a protocol.ts try around a `fn(protocolHost(this), …)` stub follows the moved body, and the stub costs no hop': 1, + 'passes: #25467 E1 — a stub that hands its body anything but `protocolHost(this)` is not followed': 1, + 'BOUND (#25467 E1): the `host.x()` hop IS a hop — a third hop through the host is still refused': 1, + 'BOUND (#25467 E1): the same host ladder IS a seam one hop deeper — the pin above is not vacuous': 1, 'ledger (read seams): a stale entry PASSES and is reported; an unledgered seam still FAILS (#26444)': 1, 'ledger (read seams): a ledgered file on disk the scan never READ is refused; a deleted one passes (#26444)': 1, }), @@ -6243,6 +6340,37 @@ function selfTestReadSeams() { } /*ROLLBACK*/ }`; + // #25467 E1: ruling E's host split — ONE host interface, declared in + // `protocol-host.ts` and imported by name; a moved body's first parameter + // is \`host\`; a stub hands it \`protocolHost(this)\`. \`readStatic\` is a + // private static the host carries; \`untyped\` is a member typed some other way. + const E1_HOST = ` + import { listRowsMoved } from './protocol-uninstall.js'; + export class ObjectStackProtocolImplementation { + async findRows(t: string) { return await this.engine.find(t, {}); } + async tidy(t: string) { this.memo.delete(t); } + private async lookup(t: string) { return await this.driver.find(t, {}); } + private async findOverlay(t: string) { return await this.lookup(t); } + async getItem(t: string) { return await this.findOverlay(t); } + private static async readStatic(engine: any, t: string) { return await engine.find(t, {}); } + async listRowsMoved(id: string) { + return listRowsMoved(protocolHost(this), id); + } + /*ROLLBACK*/ + }`; + const E1_HOST_MODULE = ` + export interface ProtocolHost { + readonly findRows: ObjectStackProtocolImplementation['findRows']; + readonly tidy: ObjectStackProtocolImplementation['tidy']; + readonly getItem: ObjectStackProtocolImplementation['getItem']; + readonly readStatic: (typeof ObjectStackProtocolImplementation)['readStatic']; + readonly untyped: (t: string) => Promise; + }`; + const E1_MODULE_HEAD = ` + import type { ProtocolHost } from './protocol-host.js'; + export async function listRowsMoved(host: Pick, id: string) { + return await host.findRows(id); + }`; const SPLIT_MODULE_HEAD = ` export interface UninstallHost { findRows: ObjectStackProtocolImplementation['findRows']; @@ -7467,6 +7595,155 @@ function selfTestReadSeams() { expectViolation: true, expectSeams: 1, }, + + // ── #25467 E1: the same two hops through ruling E's host ───────────── + { + name: 'flags: #25467 E1 — a moved body\'s `host.x()` into a protocol.ts storage wrapper, through the imported host interface, is still that read seam', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listRows(host: Pick, id: string) { + try { return await host.findRows(id); } + catch { return []; } + }`, + }, + expectViolation: true, + expectSeams: 1, + }, + { + name: 'passes: #25467 E1 — negative control: a moved body whose `host.x()` reaches no storage wrapper is no seam', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function tidyRows(host: Pick, id: string) { + try { return await host.tidy(id); } + catch { return []; } + }`, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'passes: #25467 E1 — a `host` that is not the function\'s first parameter resolves nowhere', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listRows(id: string, host: Pick) { + try { return await host.findRows(id); } + catch { return []; } + }`, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'passes: #25467 E1 — a host interface imported from outside the split\'s modules resolves nowhere', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/host-types.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: ` + import type { ProtocolHost } from './host-types.js'; + export async function listRows(host: Pick, id: string) { + try { return await host.findRows(id); } + catch { return []; } + }`, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'passes: #25467 E1 — a host member typed any way but AS the class member resolves nowhere', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listRows(host: Pick, id: string) { + try { return await host.untyped(id); } + catch { return []; } + }`, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'flags: #25467 E1 — a private static reached as `host.x()`, typed AS the class\'s static, is still that read seam', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listStatic(host: Pick, engine: any, id: string) { + try { return await host.readStatic(engine, id); } + catch { return []; } + }`, + }, + expectViolation: true, + expectSeams: 1, + }, + { + // \`rollBack\` -> \`listRowsMoved\` (a stub, read as its moved body) -> + // \`host.findRows\` -> \`engine.find\`: the chain the method had before + // its body moved, at the same depth. + name: 'flags: #25467 E1 — a protocol.ts try around a `fn(protocolHost(this), …)` stub follows the moved body, and the stub costs no hop', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST.replace('/*ROLLBACK*/', ` + async rollBack(id: string) { + try { return await this.listRowsMoved(id); } + catch { return []; } + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD, + }, + expectViolation: true, + expectSeams: 1, + }, + { + name: 'passes: #25467 E1 — a stub that hands its body anything but `protocolHost(this)` is not followed', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST + .replace('return listRowsMoved(protocolHost(this), id);', 'return listRowsMoved(this.someHost, id);') + .replace('/*ROLLBACK*/', ` + async rollBack(id: string) { + try { return await this.listRowsMoved(id); } + catch { return []; } + }`), + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'BOUND (#25467 E1): the `host.x()` hop IS a hop — a third hop through the host is still refused', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listDeep(host: Pick, id: string) { + try { return await host.getItem(id); } + catch { return []; } + }`, + }, + expectViolation: false, + expectSeams: 0, + }, + { + name: 'BOUND (#25467 E1): the same host ladder IS a seam one hop deeper — the pin above is not vacuous', + files: { + [`${HOST_SPLIT.dir}/protocol.ts`]: E1_HOST, + [`${HOST_SPLIT.dir}/protocol-host.ts`]: E1_HOST_MODULE, + [`${HOST_SPLIT.dir}/protocol-uninstall.ts`]: E1_MODULE_HEAD + ` + export async function listDeep(host: Pick, id: string) { + try { return await host.getItem(id); } + catch { return []; } + }`, + }, + maxReadWrapperDepth: MAX_READ_WRAPPER_DEPTH + 1, + expectViolation: true, + expectSeams: 1, + }, ]; // The ledger this entry's partition of the floor is evaluated against diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 4259264530b..e8b5fe52e67 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1383,7 +1383,13 @@ export const CROSS_PACKAGE_TEST_INPUTS = { '@objectstack/metadata-protocol': { // src/sys-metadata-repository.draft-drain.test.ts reads the durability // log-level gate's own source to pin that the repository stays inside it. - globs: ['scripts/check-durability-degradation-log-level.mjs'], + // [#25467 E1] The two by-path pins (protocol.read-verb-canonical-fold, + // protocol.lookup-artifact-item-call-sites) blank comments through the + // shared mask before they read the split's modules. + globs: [ + 'scripts/check-durability-degradation-log-level.mjs', + 'scripts/js-comment-mask.mjs', + ], }, '@objectstack/downstream-contract': { // test/source-resolution.pin.test.ts resolves every spec specifier a diff --git a/turbo.json b/turbo.json index 53ebd237f70..5d2257c877e 100644 --- a/turbo.json +++ b/turbo.json @@ -679,7 +679,8 @@ "!dist/**", "!coverage/**", "!.turbo/**", - "$TURBO_ROOT$/scripts/check-durability-degradation-log-level.mjs" + "$TURBO_ROOT$/scripts/check-durability-degradation-log-level.mjs", + "$TURBO_ROOT$/scripts/js-comment-mask.mjs" ] }, "@objectstack/downstream-contract#test": {