Skip to content

Commit d23f19e

Browse files
refactor(metadata-protocol): move the artifact registry's lookups out of protocol.ts into protocol-artifact-lookup.ts (#25467 cut 15) (#26104)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7JKgiM29GUcitRvoGGj5BU)_ Part of #25467 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Cut 15 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–14 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740, #25797, #25939, #25972 and #26014. The claim is `6414543503`; the scope is the cut 15 proposal of the cut 14 report (`6412300878`), as the server seat approved it in its dispatch order: the whole family, with the #22024 pin widened (the pin edit held, so the pin-free fallback was not needed). Four `ObjectStackProtocolImplementation` members move unedited into a new module, `packages/metadata-protocol/src/protocol-artifact-lookup.ts` (#23050 shape B). They are the artifact registry's lookups from the ADR-0010 section: - `lookupArtifactItem`: an item's artifact. - `shippedArtifactsOf`: every package's artifact for an item. - `shippingPackagesOf`: the packages that can ship an item of a type. - `artifactLockLayerAt`: the item-lock resolution's artifact layer. `protocol.ts` loses 48 net lines (29,488 to 29,440). The cut is move-only, and the card stays open for later cuts. Branch `claude/project-thread-ybjm3h` is the harness-pinned branch for this thread, restarted from `main` `3117289002` after cut 14 merged. It names no issue, so the claim comment is the identity record. It holds the commit `dd85dd1b4` (the move and the pin edit together: the move alone turns the pin red, as the control below shows) and two merges of `main`: `be7897799` (`59e0b7e0f`) and `aa2f8e31f` (`9840a879d`). Neither touches `packages/metadata-protocol/`: the package's `src/` tree is `af425909` at `3117289002` and at both merged `main` heads. **Serial with the file's in-flight PRs.** Measured just before this PR was opened: of the 39 open PRs, one touches `packages/metadata-protocol/src/`. #26005 edits `protocol.meta-types-degenerate-derivation.test.ts`, which does not overlap this cut. No open PR edits `protocol.ts`, the new module or the #22024 pin. ## What moves | Member | Base line | Visibility | Body lines | AST tokens (params + return + body) | |---|---|---|---|---| | `lookupArtifactItem` | `:16493` | private | 24 | 162 | | `artifactLockLayerAt` | `:16533` | private | 3 | 43 | | `shippedArtifactsOf` | `:16558` | private | 14 | 184 | | `shippingPackagesOf` | `:16594` | private | 39 | 406 | 80 body lines (the proposal said about 80). ## The shape (#23050 shape B, as cuts 1–14 apply it) - **The class keeps every member** with its name, docblock, signature and position. Each body is now one host literal plus a `.call`, marked `[#25467 cut 15]`. - **Typing.** `ArtifactLookupHost` types every member as the class member itself (an indexed access on `ObjectStackProtocolImplementation`). Each moved function types its `this` as the `Pick` of the host over exactly the members its body reads. Each stub annotates its literal as `ThisParameterType` of `typeof fn`. No member changes visibility. - **Members read, measured by AST walk** (property and element accesses on `this`, with `as` casts, parentheses and `!` stripped; bare `this` checked too): - `lookupArtifactItem` and `shippingPackagesOf` read `engine` only, through a live getter. - `shippedArtifactsOf` reads `lookupArtifactItem` and `shippingPackagesOf`, and `artifactLockLayerAt` reads `shippedArtifactsOf`; each goes as a closure. - So the family reaches its own members **through the class methods**, not by calling the module's functions directly. A spy or double set on the instance (for example on `lookupArtifactItem`) is still the one `shippedArtifactsOf` reaches, as before the move. - **No private-static read.** The AST walk found no access on the `ObjectStackProtocolImplementation` identifier and none on `this.constructor`, in any of the four bodies. 0 `new Error(` (and no other error constructor), so no `PRODUCERS` row. - **Every other caller still calls the class method**: the 18 `lookupArtifactItem(` sites in `protocol.ts` (the #22024 table), `getEffectiveLock`, the read doors and the list for `artifactLockLayerAt`, and `readFlattenedMetaItems` and the others for `shippingPackagesOf`. ## Module-level declarations, by reader - **None gains `export`, none moves.** The bodies read `PLURAL_TO_SINGULAR` (from `@objectstack/spec/shared`), `resolveArtifactLockLayer` and the type `ItemAddress` (from `./item-lock.js`), which the new module imports from where `protocol.ts` imports them, and `ShippingPackages`, `protocol.ts`'s own exported type, imported as a type. The module's only import from `./protocol.js` is a type import, so it adds no runtime cycle. - **Imports in `protocol.ts`.** `resolveArtifactLockLayer` loses its last reader and leaves the `./item-lock.js` import (its two `{@link}` mentions in docblocks stay as prose). The one new statement is the import of the new module. ## The #22024 call-site pin, widened `protocol.lookup-artifact-item-call-sites.test.ts` records a disposition for every `lookupArtifactItem(` in the source. The move takes three of them into the new module (the function the class member delegates to, and `shippedArtifactsOf`'s two calls) and adds one in `protocol.ts` (the stub's host closure, `lookupArtifactItem: (...a) => this.lookupArtifactItem(...a)`). The edit: - The pin reads `protocol-artifact-lookup.ts` beside `protocol.ts`. It scans the seam with a module-function header (`export [async] function NAME(`), and keys each site by file as well as member and argument text. - `shippedArtifactsOf`'s two rows move to the seam file, unchanged: same arguments, same disposition, same reason. - **Two new rows.** The seam function's own definition. And the stub's closure `(...a)`: disposition `no`, recorded as passing no package because it forwards the package-less call too, and its disposition is the two forwarded calls'. - The non-vacuity case now expects one definition in each file. One new case asserts that the seam file's found sites are exactly its recorded rows, three of them, so a scan that read the wrong file or none cannot pass by accident. - The describe and existing test titles are unchanged, so the suite comparison below keys on the same names. The header prose says the population is two files now. **Controls** (each on disk, restored from `HEAD` under a trap, hash equal to `HEAD`'s after): - **The widened pin catches a re-scoped call in the seam.** `this.lookupArtifactItem(type, name, packageId)` was changed to `this.lookupArtifactItem(type, name)` in the module. On-disk counts read first: 0 scoped calls, 2 package-less. Result: 2 failed, 3 passed: - "the sites in the source are exactly the recorded ones" fails: a recorded site that is no longer in the source. - The new seam-scan case fails. - **The pin had to be widened.** The pin file at base, run against the cut: 2 failed, 2 passed: - "a site with no recorded disposition", which is the new closure site. - `expected 18 to be 19`. With the widened pin, 5/5 pass. ## The published `.d.ts` The series bar is ruling `6348891160`, sorted-union identity, measured first. Base `3117289002` and head `dd85dd1b4` were built with `pnpm --filter @objectstack/metadata-protocol build` against one dependency tree. The closure was built at head by a full `pnpm build` (119/119). For the base build, the package's `src/` was swapped tree-only to base and then restored from `HEAD` under a trap; `git diff HEAD` was empty, `git status` clean, and all three touched blobs equal to `HEAD`'s after. Measured twice: on the pre-merge tree (base `3117289002`, head `dd85dd1b4`) and after merging `main` (head `59e0b7e0f`, closure rebuilt by a full `pnpm build`, 119/119; the later merge `9840a879d` changed only CI workflow files and scripts under `ui/` and `scripts/`, no package source). The readings below hold for both. - **Raw sha256**: `08914e65f936a2b031c3f3f215481a97f53d132eefd869e4f42d928947ebe173` at base and at head, on both `dist/index.d.ts` and `dist/index.d.cts`, 775,874 bytes each. **Raw identity holds**, so sorted-union identity holds too. All four members stay `private NAME;` lines. - **Normalised sha256**: `46ccd7e5c4db673652513d1801ec9c9f06bf34a0a7fb6ba97fb2531ef0cc544b` at both ends (80 string-literal union runs sorted; the cut 12 normaliser). - **The two builds differ where they should**: `index.js` hashes `13ff6c73088affd6…` at base and `5671669a82bc3837…` at head; each of the four functions' `.call(host` sites occurs 0 times in the base bundles and once in the head bundles (ESM and CJS alike). - **Positive control** on the head tree: `private` was dropped from `lookupArtifactItem` (on disk, restored from `HEAD` under a trap) and the package rebuilt. Both `.d.ts` files move to `4290e8428fa3c8f5…` and print `lookupArtifactItem(type: string, name: string, currentPackageId?: string, member?: string): unknown;`, so the bar can see a change on these members. - **Export tables.** The ESM and CJS bundles, loaded at runtime from the package, each export the same 78 names at base and head; the sorted list hashes to `9f18a0177ac736ed…` on all four. No moved function, the host type or `resolveArtifactLockLayer` appears. No changeset: `skip-changeset`, per ruling `6348891160`. ## Proof of move-only **Bodies, by AST token** (the TypeScript compiler's AST leaf tokens, JSDoc skipped, over parameters less the added `this`, return type and body), base `protocol.ts` at `3117289002` against the head module: ``` lookupArtifactItem: base tokens 162, head tokens 162, differ 0, text-equal(ws-collapsed, comments incl.) true artifactLockLayerAt: base tokens 43, head tokens 43, differ 0, text-equal(ws-collapsed, comments incl.) true shippedArtifactsOf: base tokens 184, head tokens 184, differ 0, text-equal(ws-collapsed, comments incl.) true shippingPackagesOf: base tokens 406, head tokens 406, differ 0, text-equal(ws-collapsed, comments incl.) true TOTAL tokens=795 differ=0 members=4 CLASS members base=300 head=300 order-same=true byte-identical=296 stubs-with-identical-docblock+signature=4 other=[] TOP base=243 head=244 byte-identical=242 ``` **Negative control**, one token changed in an in-memory copy of the head module (`id !== 'sys_metadata'` to `id === 'sys_metadata'`, one occurrence): ``` MUTATED packages/metadata-protocol/src/protocol-artifact-lookup.ts "id !== 'sys_metadata'" -> "id === 'sys_metadata'" lookupArtifactItem: base tokens 162, head tokens 162, differ 0, text-equal(ws-collapsed, comments incl.) true artifactLockLayerAt: base tokens 43, head tokens 43, differ 0, text-equal(ws-collapsed, comments incl.) true shippedArtifactsOf: base tokens 184, head tokens 184, differ 0, text-equal(ws-collapsed, comments incl.) true shippingPackagesOf: base tokens 406, head tokens 406, differ 1, text-equal(ws-collapsed, comments incl.) false TOTAL tokens=795 differ=1 members=4 ``` - **Class side.** 300 members at base and at head, in the same order. The 296 that did not move are byte-identical to base, leading comments included. Each of the 4 stubs sits at its base position and keeps its docblock and signature byte-identical (everything before the body's opening brace). - **Top level of `protocol.ts`.** 242 of the 243 base statements are byte-identical, leading comments included. The other one is the `./item-lock.js` import, which loses `resolveArtifactLockLayer`. Head adds one statement, the new module's import. - **Size.** +231 / −87 over 3 files, 318 changed lines (the proposal's estimate was 330–400). `protocol-artifact-lookup.ts` has 158 lines; the pin edit is +50/−16. ## Readers of the moved lines Every test and script that reads `protocol.ts` by path, every test file in `packages/` and `ui/`, and `docs/`, `scripts/` and `content/` were grepped for the four member names. - **By path:** only the #22024 pin reads a moved body's text (above). `protocol.register-item-call-sites.test.ts` names `shippedArtifactsOf` only inside a docblock; its by-path regex is on `anotherPackageShips`, which stays. The other by-path readers of `protocol.ts` (`protocol.hydrate-overlay-canonical-type`, `protocol.read-verb-canonical-fold`, `protocol.object-registry-write-through-spelling`, `protocol.recovery-doors-emit-mutation`, `protocol.recovery-doors-mutation-projector`) do not read these bodies; all ran in the package suite and pass at both ends. - **In prose or behaviour, no edit owed.** - metadata-protocol `protocol.lock-gate-canonical-type.test.ts` drives the lookup through the gate (behaviour). `protocol.declines-stored-row-published` and `protocol.i18n-bundle-list-merge` name it in comments. All pass at both ends. - Run at head under the verify lock: - objectql `protocol-registry-shadow`, `protocol-boot-hydration-scoped` and `environment-overlay-email-locale-restart` (comments; behaviour through the class): 3 files, 61/61. - rest `meta-published-overlay`: 16/16. - runtime `meta-published-runtime-publish`: 12/12. - ui `PermissionMatrixEditor.artifactTier.test.tsx` and `ResourceEditPage.resetVerdict.test.tsx` name it in comments only. - No hit in `docs/`, `scripts/` or `content/`; `docs/qa/` cites none of the four. - **ADR anchors.** `check-adr-anchors` exits 0 at both ends: 88 anchored files, citations 59,047 → 59,048 (the new module's header cites ADR-0010), files 8,289 → 8,290, decision-letter citations **1,864 at both ends**. The moved comments cite `ADR-0010 §3.3`, `ADR-0048` and `ADR-0029 D3`. The `D3` one sits in `shippedArtifactsOf`'s docblock, which stays in `protocol.ts`, so no decision-letter citation leaves the anchored file. - **Durability checker**: `check-durability-degradation-log-level` exits 0 at both ends with identical output, and its `--list` is identical once `[@:][0-9]+` is stripped (159 lines). `shippingPackagesOf`'s `try { … } catch { return []; }` around a registry listing is not on the list (it reads no store). - **Engine-double contract** (`check-engine-double-contract`): exits 0 at both ends. The `updateData`/`deleteData` seams keep their entries at new line numbers (14086 → 14087, 14111 → 14112). `productionFiles` is 3,718 at base and 3,720 at head: +1 is the new file, +1 is the gitignored `packages/spec/src/migrations/registry.ts` the spec build writes in the head tree. - **Identical output at both ends:** `check-adr-symbol-anchors`, `check-system-context-census`, `check-tenant-audit-census`, `check-query-options-erasure-ratchet`, `check-filter-alias-parity`. `check-nul-bytes` differs only in the scanned file count, +1. The base-end readings were taken in a clean detached worktree at `3117289002`. - **Governed surface.** `check-governed-merges --branch claude/project-thread-ybjm3h`: 0 of 3 paths hit the register, NOT governed; 318 changed lines, under the 3,000-line human-merge bar. ## Verification - `tsc --noEmit` in `packages/metadata-protocol` exits 0 before and after the merge (after the full build; the first run, on a stale dependency tree, failed only in `protocol-package-install*.ts` on `@objectstack/metadata-core` exports that the build then supplied). `pnpm exec eslint --no-inline-config packages/metadata-protocol` (the whole touched package; it has no `lint` script, these are the root `lint` script's flags) exits 0, no output. - **`packages/metadata-protocol` suite**, base and head, compared per test name and status (vitest `--maxWorkers=2`, JSON reporter, each run under the shared verify lock, `VERDICT command-exit 0` both legs), on the pre-merge tree (the merge changed no file of the package): - Base `3117289002`: 21,557 tests (21,538 passed, 0 failed, 19 skipped), 269 files. - Head `dd85dd1b4`: 21,558 tests (21,539 passed, 0 failed, 19 skipped), 269 files. - None lost, none changed status, one added: the pin's new seam-scan case. The base leg is the package `src/` swapped tree-only from `3117289002` (the new module removed, the pin at its base blob) and restored from `HEAD` under a trap. - `check-platform-checklist --changed-from origin/main` (merged tree): OK, 1 anchored file in the change set, 29 anchors judged, 0 lost. - **Derived gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 59 gates on 3 changed paths from the merged tree `9840a879d`, with no stale-tree warning (the first derivation, at `59e0b7e0f`, flagged `ci.yml` and `check-closing-keyword-parity.mjs` as changed on `main`, so `main` was merged once more before running). All 59 exit 0 after the full `pnpm build`. `dispatch-gates --ran`: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. ## Acceptance notes - **Per-call cost.** `lookupArtifactItem` is the family's hot member (18 class call sites, some in list loops). Its stub now builds a one-getter host literal per call, as every shape-B stub in this series does. No behaviour or result changes; this is noted only because the lookup runs per listed item. - **The family reaches itself through the class.** `shippedArtifactsOf` and `artifactLockLayerAt` call their siblings through host closures on the protocol, not through the module's functions. That keeps instance spies and doubles effective, as above. A later redesign that wants the module to stand alone would be a behaviour change for such doubles, not a move. ## Carry-forward Unchanged from cut 14: these stay in `protocol.ts`: - Public members whose return type is inferred. - The bodies kept by a reader: `probeRecord`, `getMetaItemCached`, `findReferencesToMeta`, `packagelessAddress`, `refuseUnmintableMetaType`, `metaTypeNamespaceExists`, `viewContainerNameCollisionRefusal` with `overlaidShippedContainerViewNames`, `reportPageRequiresAbsentAtLoad`, the 7 `applyRegistryWriteThrough` callers, and `readCodeLayerForCarryForward`. - The private-static readers: `refusePackagedBaseRemoval`, `packagedBaseRefusal`, `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts` and `discardPackageDrafts`. - The field writers: `warnClosureReadUnavailable`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6e8f95 commit d23f19e

3 files changed

Lines changed: 231 additions & 87 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 15] The artifact registry's lookups of
5+
* `ObjectStackProtocolImplementation` (ADR-0010 metadata protection): the
6+
* artifact an installed code package registered for an item
7+
* (`lookupArtifactItem`), every package's artifact for it
8+
* (`shippedArtifactsOf`), the packages that can ship an item of a type
9+
* (`shippingPackagesOf`), and the item-lock resolution's artifact layer over
10+
* them (`artifactLockLayerAt`), moved out of `protocol.ts` whole.
11+
*
12+
* A MOVE, not a redesign. Each function below is the body of the
13+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
14+
* re-indented one level and otherwise unedited: the same registry reads, in
15+
* the same order, answering the same artifacts.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
20+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
21+
* The class keeps every method — the published `dist/*.d.ts` lists each one's
22+
* name, docblock and declared signature — and each body is one literal of
23+
* exactly the {@link ArtifactLookupHost} members that method reads, plus a
24+
* `.call`. The engine is handed over as a getter, so the body reads the
25+
* protocol's engine at call time. A method is handed over as a closure that
26+
* calls it on the protocol at call time, so a test double or spy set on the
27+
* instance is still the one the body reaches. That holds between the four
28+
* functions here too: `shippedArtifactsOf` reaches the lookup and the
29+
* shipping-package read through the class methods, not by calling the
30+
* functions below directly. Every other reader of these four methods keeps
31+
* calling the class method.
32+
*
33+
* #22024's call-site pin (`protocol.lookup-artifact-item-call-sites.test.ts`)
34+
* scans this module beside `protocol.ts`: the lookup's definition and
35+
* `shippedArtifactsOf`'s two calls of it live here now.
36+
*
37+
* What the bodies read at module level they import from where `protocol.ts`
38+
* imported it; `ShippingPackages` is `protocol.ts`'s own exported type.
39+
* Nothing re-exports this module, so the published surface does not move.
40+
*/
41+
42+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
43+
import { resolveArtifactLockLayer, type ItemAddress } from './item-lock.js';
44+
import type { ObjectStackProtocolImplementation, ShippingPackages } from './protocol.js';
45+
46+
/**
47+
* The `ObjectStackProtocolImplementation` members the functions below read —
48+
* every one of them, and no other; each function's `this` is the `Pick` of the
49+
* members IT reads. Each is typed AS the class member (an indexed access on
50+
* the class), so a change to a member's signature on the class is a change
51+
* here too, and naming a member the class does not have is a compile error.
52+
*/
53+
export interface ArtifactLookupHost {
54+
readonly engine: ObjectStackProtocolImplementation['engine'];
55+
lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem'];
56+
shippingPackagesOf: ObjectStackProtocolImplementation['shippingPackagesOf'];
57+
shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf'];
58+
}
59+
60+
/** The body of `ObjectStackProtocolImplementation#lookupArtifactItem`; the method's docblock there is its contract. */
61+
export function lookupArtifactItem(
62+
this: Pick<ArtifactLookupHost, 'engine'>,
63+
type: string, name: string, currentPackageId?: string, member?: string,
64+
): unknown {
65+
const registry = (this.engine as any)?.registry;
66+
if (!registry) return undefined;
67+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
68+
// Prefer the artifact-only lookup: it scans composite
69+
// (`<packageId>:<name>`) entries first, so an overlay row hydrated
70+
// into the plain key (getMetaItems / loadMetaFromDb) can never
71+
// shadow the packaged artifact's protection envelope (ADR-0010
72+
// §3.3 — pre-fix, that shadow made a `_lock: full` app read back
73+
// as unlocked after PUT+GET until restart). `currentPackageId`
74+
// (ADR-0048) makes that scan package-scoped (prefer-local).
75+
if (typeof registry.getArtifactItem === 'function') {
76+
return registry.getArtifactItem(singular, name, currentPackageId, member)
77+
?? registry.getArtifactItem(type, name, currentPackageId, member);
78+
}
79+
// Partial registry mocks in tests — fall back to getItem and apply
80+
// the same package-provenance filter inline.
81+
if (typeof registry.getItem !== 'function') return undefined;
82+
const item = registry.getItem(singular, name, currentPackageId) ?? registry.getItem(type, name, currentPackageId);
83+
if (!item || !(item as any)._packageId || (item as any)._packageId === 'sys_metadata') {
84+
return undefined;
85+
}
86+
return item;
87+
}
88+
89+
/** The body of `ObjectStackProtocolImplementation#artifactLockLayerAt`; the method's docblock there is its contract. */
90+
export function artifactLockLayerAt(
91+
this: Pick<ArtifactLookupHost, 'shippedArtifactsOf'>,
92+
address: ItemAddress, shipping?: ShippingPackages,
93+
): readonly unknown[] {
94+
return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, shipping));
95+
}
96+
97+
/** The body of `ObjectStackProtocolImplementation#shippedArtifactsOf`; the method's docblock there is its contract. */
98+
export function shippedArtifactsOf(
99+
this: Pick<ArtifactLookupHost, 'lookupArtifactItem' | 'shippingPackagesOf'>,
100+
type: string, name: string, shipping?: ShippingPackages,
101+
): unknown[] {
102+
const shipped: unknown[] = [];
103+
const add = (artifact: unknown): void => {
104+
if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact);
105+
};
106+
add(this.lookupArtifactItem(type, name));
107+
if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped;
108+
const packages = shipping ?? this.shippingPackagesOf(type);
109+
for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) {
110+
const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined;
111+
if (own?._packageId === packageId) add(own);
112+
}
113+
return shipped;
114+
}
115+
116+
/** The body of `ObjectStackProtocolImplementation#shippingPackagesOf`; the method's docblock there is its contract. */
117+
export function shippingPackagesOf(
118+
this: Pick<ArtifactLookupHost, 'engine'>,
119+
type: string,
120+
): ShippingPackages {
121+
const registry = (this.engine as any)?.registry;
122+
const byName = new Map<string, Set<string>>();
123+
const anyName = new Set<string>();
124+
if (!registry) return { byName, anyName };
125+
const isPackage = (id: unknown): id is string => typeof id === 'string' && id !== '' && id !== 'sys_metadata';
126+
const listing = (read: () => unknown): readonly unknown[] => {
127+
try {
128+
const listed = read();
129+
return Array.isArray(listed) ? listed : [];
130+
} catch {
131+
return []; // See this method's header: the package-less lookup still answers.
132+
}
133+
};
134+
if (typeof registry.listItems === 'function') {
135+
for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) {
136+
for (const entry of listing(() => registry.listItems(spelling))) {
137+
const { _packageId: id, name } = (entry ?? {}) as { _packageId?: unknown; name?: unknown };
138+
if (!isPackage(id)) continue;
139+
if (typeof name !== 'string') {
140+
anyName.add(id);
141+
continue;
142+
}
143+
const ids = byName.get(name);
144+
if (ids) ids.add(id);
145+
else byName.set(name, new Set([id]));
146+
}
147+
}
148+
}
149+
if (typeof registry.getAllPackages === 'function') {
150+
const canTell = typeof registry.isPackageDisabled === 'function';
151+
for (const record of listing(() => registry.getAllPackages())) {
152+
const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined;
153+
const id = r?.manifest?.id ?? r?.id;
154+
if (isPackage(id) && (!canTell || registry.isPackageDisabled(id))) anyName.add(id);
155+
}
156+
}
157+
return { byName, anyName };
158+
}

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,46 @@
2222
*
2323
* The population is the card's own enumeration command,
2424
* `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`
25-
* (tests excluded), which is one file: every match is a call or the
26-
* definition. Each is keyed by the class member it sits in and its argument
27-
* text, which survive line moves and fail on any change of scope. A new call,
28-
* a removed one or a re-scoped one turns this red until its row is recorded
29-
* here with its disposition (and the card's table in the PR that adds it).
25+
* (tests excluded), which is two files: every match is a call or a
26+
* definition. Each is keyed by the file, the class member or module function
27+
* it sits in, and its argument text, which survive line moves and fail on any
28+
* change of scope. A new call, a removed one or a re-scoped one turns this red
29+
* until its row is recorded here with its disposition (and the card's table in
30+
* the PR that adds it).
31+
*
32+
* [#25467 cut 15] The second file is `protocol-artifact-lookup.ts`. The
33+
* lookup's body moved there with `shippedArtifactsOf`'s, so it holds the
34+
* function the class member delegates to and that member's two calls; the
35+
* class member keeps its signature in `protocol.ts`, and `shippedArtifactsOf`'s
36+
* stub there hands the moved body the lookup as a closure, one more site.
3037
*/
3138
import { readFileSync } from 'node:fs';
3239
import { fileURLToPath } from 'node:url';
3340
import { describe, expect, it } from 'vitest';
3441

3542
const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8');
43+
/** [#25467 cut 15] The seam module the lookup's body moved to; scanned with its own header shape. */
44+
const SEAM_FILE = 'protocol-artifact-lookup.ts';
45+
const SEAM = readFileSync(fileURLToPath(new URL(`./${SEAM_FILE}`, import.meta.url)), 'utf8');
3646
const CALLEE = 'lookupArtifactItem(';
3747

3848
/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */
3949
const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
50+
/** A module function's header in the seam module: `export`, optional `async`, `function`, its name, its parameter list. */
51+
const FUNCTION_HEADER = /^export (?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
52+
53+
interface Site { readonly file?: string; readonly member: string; readonly args: string }
4054

41-
interface Site { readonly member: string; readonly args: string }
55+
/** Every `lookupArtifactItem(` in `protocol.ts` and the seam module: the file, the member it sits in and its argument text, whitespace-normalised. */
56+
function sitesIn(): Site[] {
57+
return [
58+
...sitesOf(SOURCE, MEMBER_HEADER),
59+
...sitesOf(SEAM, FUNCTION_HEADER).map((site) => ({ ...site, file: SEAM_FILE })),
60+
];
61+
}
4262

43-
/** Every `lookupArtifactItem(` in the source: the member it sits in and its argument text, whitespace-normalised. */
44-
function sitesOf(source: string): Site[] {
63+
/** Every `lookupArtifactItem(` in one source: the member it sits in (by `header`) and its argument text, whitespace-normalised. */
64+
function sitesOf(source: string, header: RegExp): Site[] {
4565
const lines = source.split('\n');
4666
const sites: Site[] = [];
4767
for (let at = source.indexOf(CALLEE); at !== -1; at = source.indexOf(CALLEE, at + 1)) {
@@ -53,8 +73,8 @@ function sitesOf(source: string): Site[] {
5373
const args = source.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, '');
5474
let member = '(no member)';
5575
for (let line = source.slice(0, at).split('\n').length - 1; line >= 0; line--) {
56-
const header = MEMBER_HEADER.exec(lines[line]);
57-
if (header) { member = header[1]; break; }
76+
const found = header.exec(lines[line]);
77+
if (found) { member = found[1]; break; }
5878
}
5979
sites.push({ member, args });
6080
}
@@ -70,7 +90,9 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
7090
*/
7191
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
7292
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
73-
why: 'the definition itself' },
93+
why: 'the definition itself: the class member\'s declared signature (its body delegates to the seam function below)' },
94+
{ file: SEAM_FILE, member: 'lookupArtifactItem', args: 'this: Pick<ArtifactLookupHost, \'engine\'>, type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
95+
why: '[#25467 cut 15] the definition\'s body, the function the class member delegates to' },
7496
{ member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope',
7597
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' },
7698
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope',
@@ -91,10 +113,12 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
91113
why: 'a boolean containment test over the one owner of an object' },
92114
{ member: 'packagedArtifactOwner', args: 'folded.type, folded.name', noPackage: true, disposition: 'no',
93115
why: 'a shipped-package check by name: it returns a package id, never an envelope on a served body' },
94-
{ member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
116+
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
95117
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
96-
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
118+
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
97119
why: 'scoped to a named package, and kept only when it is that package\'s own' },
120+
{ member: 'shippedArtifactsOf', args: '...a', noPackage: true, disposition: 'no',
121+
why: '[#25467 cut 15] the class member\'s host closure: it forwards the moved body\'s two calls (the rows above) unchanged, so its disposition is theirs' },
98122
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
99123
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package; [#23074] at the row\'s own bundle member' },
100124
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
@@ -109,10 +133,10 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
109133
why: 'the save door\'s collision check: the package id names a shipper in a refusal, never an envelope on a served body' },
110134
];
111135

112-
const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`;
136+
const keyOf = (site: Site) => `${site.file ?? 'protocol.ts'} ${site.member}: ${CALLEE}${site.args})`;
113137

114138
describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded disposition', () => {
115-
const found = sitesOf(SOURCE);
139+
const found = sitesIn();
116140

117141
it('the sites in the source are exactly the recorded ones', () => {
118142
const recorded = SITES.map(keyOf).sort();
@@ -128,11 +152,21 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo
128152

129153
it('the population is the card\'s enumeration: one definition and every call, each in a member', () => {
130154
// Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident.
131-
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string'))).toHaveLength(1);
155+
// [#25467 cut 15] One definition per file: the class member's signature and the seam function it delegates to.
156+
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string')).map((site) => site.file ?? 'protocol.ts'))
157+
.toEqual(['protocol.ts', SEAM_FILE]);
132158
expect(found.filter((site) => site.member === '(no member)')).toEqual([]);
133159
expect(found.length).toBe(SITES.length);
134160
});
135161

162+
it('[#25467 cut 15] the seam module is scanned: the definition\'s body and both of shippedArtifactsOf\'s calls are found there', () => {
163+
// Non-vacuity for the second file: a scan that read the wrong file, or none, finds no site there.
164+
expect(found.filter((site) => site.file === SEAM_FILE).map(keyOf).sort()).toEqual(
165+
SITES.filter((site) => site.file === SEAM_FILE).map(keyOf).sort(),
166+
);
167+
expect(found.filter((site) => site.file === SEAM_FILE)).toHaveLength(3);
168+
});
169+
136170
it('a site passing fewer than three arguments is recorded as passing no package', () => {
137171
const topLevelArgs = (args: string) => {
138172
let depth = 0;

0 commit comments

Comments
 (0)