From 95ed2b63c68a32bb3469122a8299109806099e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:19:02 +0000 Subject: [PATCH 1/4] fix(core,metadata,objectql): enforce the #7378 three-cell register ruling in every shipped IMetadataService implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-12 (#7378): row 1 — a data.name disagreeing with the name argument is refused loudly with a locating error; row 2 — type stores are keyed on the canonical type, converging with check:meta-type-normalized's enforced plural->singular direction; row 3 — a non-object data is refused (throw), never accepted-and-dropped and never coerced into storability. The shared guard and fold live in @objectstack/core (metadata-service-contract.ts) — the lowest common dependency — and are called by createMemoryMetadata, MetadataManager and MetadataFacade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../core/src/fallbacks/memory-metadata.ts | 31 +++- packages/core/src/index.ts | 5 + .../core/src/metadata-service-contract.ts | 153 ++++++++++++++++++ packages/metadata/src/metadata-manager.ts | 43 ++++- packages/objectql/src/metadata-facade.ts | 112 ++++++------- 5 files changed, 282 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/metadata-service-contract.ts diff --git a/packages/core/src/fallbacks/memory-metadata.ts b/packages/core/src/fallbacks/memory-metadata.ts index d2f68155b2..da8a202611 100644 --- a/packages/core/src/fallbacks/memory-metadata.ts +++ b/packages/core/src/fallbacks/memory-metadata.ts @@ -1,21 +1,36 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { + assertMetadataRegisterContract, + canonicalMetadataServiceType, +} from '../metadata-service-contract.js'; + /** * In-memory metadata service fallback. * * Implements the IMetadataService contract with a simple Map-of-Maps store. * Used by ObjectKernel as an automatic fallback when no real metadata plugin * (e.g. MetadataPlugin with file-system persistence) is registered. + * + * [#7378] Carries the ruled register/read argument contract + * (`../metadata-service-contract.ts` — the ruling is quoted there): + * `register` refuses a `data.name` that disagrees with the `name` argument and + * refuses a non-document `data` (rows 1/3), and every type store is keyed on + * the CANONICAL type (row 2), so `register('objects', n, d)` and + * `get('object', n)` address one store rather than two. */ export function createMemoryMetadata() { - // type -> name -> data + // canonical type -> name -> data const store = new Map>(); + // [#7378 row 2] The fold lives on the single accessor every member reads + // and writes through, so no member can address a raw-spelling store. function getTypeMap(type: string): Map { - let map = store.get(type); + const canonical = canonicalMetadataServiceType(type); + let map = store.get(canonical); if (!map) { map = new Map(); - store.set(type, map); + store.set(canonical, map); } return map; } @@ -31,6 +46,10 @@ export function createMemoryMetadata() { }, _serviceName: 'metadata', async register(type: string, name: string, data: any): Promise { + // [#7378 rows 1/3] Refuse — before the store is touched — a data.name + // that disagrees with the name argument, and a non-document data. The + // guard's own header carries the ruling and the reasons. + assertMetadataRegisterContract(type, name, data); getTypeMap(type).set(name, data); }, // Mirror MetadataManager.registerInMemory (synchronous, no persistence). @@ -42,7 +61,11 @@ export function createMemoryMetadata() { // so `defineStack({ datasources })` entries silently never reached the // registry and were absent from GET /api/v1/datasources and // GET /api/v1/meta/datasource (ADR-0015 §18). This store is already - // in-memory only, so registerInMemory and register share an implementation. + // in-memory only, so registerInMemory and register share a store — but + // NOT the [#7378] refusals: the ruling names `register`, and this member + // is a boot-time seeding primitive for source-control-owned artefacts + // (see assertMetadataRegisterContract's header for the boundary). It does + // share the row-2 canonical type fold, via getTypeMap. registerInMemory(type: string, name: string, data: any): void { getTypeMap(type).set(name, data); }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eed5e45e71..9b7b1f37ff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,11 @@ export * from './utils/record-not-found.js'; // Export in-memory fallbacks for core-criticality services export * from './fallbacks/index.js'; +// [#7378] The IMetadataService register/read argument contract (three-cell +// maintainer ruling, 2026-08-12) — shared by every shipped implementation so +// the refusals and the canonical type fold have one home instead of three. +export * from './metadata-service-contract.js'; + // Export Phase 2 components - Advanced lifecycle management export * from './health-monitor.js'; export * from './hot-reload.js'; diff --git a/packages/core/src/metadata-service-contract.ts b/packages/core/src/metadata-service-contract.ts new file mode 100644 index 0000000000..56c893a6cd --- /dev/null +++ b/packages/core/src/metadata-service-contract.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7378] The `IMetadataService` register/read argument contract, enforced — + * the shared guard every shipped implementation of the contract's CRUD members + * calls, so the maintainer's three-cell ruling has ONE implementation instead + * of three re-derivations that can drift. + * + * Maintainer ruling, 2026-08-12 (#7378, 裁定人:维护者 huangyiirene), quoted + * verbatim and untranslated: + * + * > 1. **Row 1(key 归属)= (c) 响亮拒绝。** `register(type, name, data)` 中 + * > `name` 参数与 `data.name` 不一致时,所有实现统一拒绝并报错定位 —— + * > 不一致几乎必是作者 bug,任一方向的静默解决都可能把条目放错位置。 + * > 2. **Row 2(objects/object 别名)= 所有实现一个答案,与 + * > `check:meta-type-normalized` 收敛。** 类型名归一化是契约级规则,不是 + * > 各实现自留的民俗。 + * > 3. **Row 3(非对象 data 静默丢弃)= 响亮拒绝(throw)。** 接受后丢失、且 + * > 任何成员都读不回来,无可辩护;拒绝一个实现无法键控的 `data` 与契约 + * > 同样一致。修的是「接受再丢」,不强求「必须存下」。 + * + * This module hosts rows 1 and 3 ({@link assertMetadataRegisterContract}) and + * row 2 ({@link canonicalMetadataServiceType}). It lives in `@objectstack/core` + * because core is the lowest common dependency of the three shipped + * implementations — `createMemoryMetadata` (this package), `MetadataManager` + * (`@objectstack/metadata`) and `MetadataFacade` (`@objectstack/objectql`). + * The contract's own reference double (`packages/spec`) cannot import from + * here — spec is the dependency root — so its copy of these rules rides the + * spec-side half of the ruling, tracked on #7378. + * + * ## Row 2 — why the fold, and whose direction it is + * + * Plural→singular type-name folding is a decided, enforced platform direction, + * not this module's invention. The owners this converges with, per the ruling's + * own instruction to read the gate's existing direction first (「实现者先读该 + * 闸门的既有方向再落」): + * + * - `canonicalMetaType` (`metadata-protocol/src/protocol.ts`, #4432) + * canonicalizes every `/meta` request type at the protocol boundary via the + * same `PLURAL_TO_SINGULAR` map this module reads; + * - `check:meta-type-normalized` (`scripts/check-meta-type-normalized.mjs`) + * is the CI gate whose whole job is to refuse a DECISION made on the + * un-normalized `:type` — its header carries the three authorization + * bypasses (#3984, #5881, #6241) that made the direction a rule. Its scan + * surface is `packages/rest/src`; what this module converges with is its + * DIRECTION: normalize once, at the entry, and let every decision — here, + * every store key — read the normalized value; + * - Prime Directive #3: metadata type names are canonically **singular**. + * + * Before this ruling, `MetadataManager` and `createMemoryMetadata` keyed their + * type stores on the raw string, so `register('objects', n, d)` landed in a + * store `get('object', n)` never read — two stores for one type, differing + * from `MetadataFacade`, whose `SchemaRegistry` reads alias both spellings. + * One answer now: the store key is the canonical type. + * + * ## Rows 1 and 3 — what the refusals close + * + * Row 1: a `data.name` that disagrees with the `name` argument was resolved + * silently in both directions in shipped code — argument-wins + * (`MetadataManager`, `createMemoryMetadata`) and document-wins (the + * pre-ruling `MetadataFacade`) — and either way an author's item could be + * filed under a key the author never wrote. Refusing is the only answer that + * cannot misplace the item. + * + * Row 3: a `data` that is not a plain object cannot be a metadata document. + * The pre-ruling `MetadataFacade` accepted such a write and filed it under the + * literal key `undefined` — readable back through no member (silent loss, the + * #6725 family) — and the interim fix coerced it into a `{ name, content }` + * box, which collides with `content` being a REAL authorable field on live + * metadata types (`doc`, `knowledge_document`). The ruling forbids both: + * refuse, do not coerce into storability. `null` and arrays are refused with + * primitives — neither can carry the document identity a metadata store keys + * on, and `{ ...[a, b] }` is `{ 0: a, 1: b }`, the same corruption one shape + * over. + * + * The executable form of all three rows is `METADATA_ROUNDTRIP_CASES` + * (`@objectstack/spec/contracts`) replayed by + * `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts`. + */ + +import { StandardErrorCode } from '@objectstack/spec/api'; +import { pluralToSingular } from '@objectstack/spec/shared'; + +/** + * The canonical spelling an `IMetadataService` type store is keyed on + * (#7378 row 2). Folds a plural manifest spelling to the singular metadata + * type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the + * platform's one plural↔singular map (`PLURAL_TO_SINGULAR`, + * `@objectstack/spec/shared`); a name with no plural mapping — which includes + * every canonical singular type — passes through unchanged. + */ +export function canonicalMetadataServiceType(type: string): string { + return pluralToSingular(type); +} + +/** + * An ADR-0112-enveloped refusal (`code` + `status` on the error), so a caller + * — and a rejection-class test — can assert the refusal rather than merely + * "it threw". `VALIDATION_ERROR` is the standard catalog's generic + * argument-validation code; the ledger's own guidance is to use the standard + * catalog rather than register a synonym for a generic condition. + */ +function registerRefusal(message: string): Error & { code: string; status: number } { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + return err; +} + +/** + * Enforce rows 1 and 3 of the #7378 ruling on a + * `register(type, name, data)` payload — call it before the first store write, + * so a refusal writes nothing anywhere. + * + * Refuses, with a locating `VALIDATION_ERROR` (status 400): + * + * - **a non-document `data`** (row 3): anything that is not a plain object — + * primitives, `null`, arrays. The contract declares `data: unknown`, so + * this is a runtime refusal, not a type error; + * - **a `data.name` that disagrees with the `name` argument** (row 1), in + * either direction. A document with NO `name` of its own is fine — the + * argument is the key, and there is no disagreement to refuse. + * + * Deliberately NOT called by `registerInMemory`: that optional member is a + * boot-time seeding primitive outside the ruled surface (the ruling names + * `register`), and its callers hand it artefacts whose shape source control + * owns. It shares the row-2 canonical fold — a store key is a store fact, not + * a per-member choice — just not the refusals. + */ +export function assertMetadataRegisterContract( + type: string, + name: string, + data: unknown, +): asserts data is Record { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + const shape = data === null ? 'null' : Array.isArray(data) ? 'an array' : `a ${typeof data}`; + throw registerRefusal( + `IMetadataService.register('${type}', '${name}'): data is ${shape}, not a metadata document. ` + + `register() stores plain-object documents only — accepting a value the service cannot key was measured as ` + + `accept-then-drop on document-keyed stores (#7378 row 3: refuse loudly, never coerce into storability). ` + + `Wrap the value in a document object whose shape the '${type}' type's schema accepts, or store it under a type that declares one.`, + ); + } + const documentName = (data as { name?: unknown }).name; + if (documentName !== undefined && documentName !== name) { + throw registerRefusal( + `IMetadataService.register('${type}', '${name}'): data.name is '${String(documentName)}', which disagrees with the ` + + `name argument '${name}'. A disagreement is almost always an authoring bug, and resolving it silently in either ` + + `direction can file the item under a key the caller never wrote (#7378 row 1: refuse loudly, locate the mismatch). ` + + `Register under one name: pass the intended key as the argument and make data.name match it, or omit data.name.`, + ); + } +} diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 4de2cf65ad..7927781ef9 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -56,7 +56,12 @@ import { validateApiEndpointDeclarations, type ApiEndpoint, } from '@objectstack/spec/api'; -import { createLogger, type Logger } from '@objectstack/core'; +import { + assertMetadataRegisterContract, + canonicalMetadataServiceType, + createLogger, + type Logger, +} from '@objectstack/core'; import { JSONSerializer } from './serializers/json-serializer.js'; import { YAMLSerializer } from './serializers/yaml-serializer.js'; import { TypeScriptSerializer } from './serializers/typescript-serializer.js'; @@ -753,6 +758,16 @@ export class MetadataManager implements IMetadataService { data: unknown, options?: MetadataWriteOptions, ): Promise { + // [#7378] The ruled register contract, before anything is touched: refuse + // a data.name that disagrees with the name argument and refuse a + // non-document data (rows 1/3 — the guard's header carries the ruling), + // and key every downstream store, loader write, cache entry and watcher + // announcement on the CANONICAL type (row 2). A refusal here writes to no + // store and persists to no loader; it fires even on a read-only manager, + // because it judges the arguments, not the persistence posture. + assertMetadataRegisterContract(type, name, data); + type = canonicalMetadataServiceType(type); + // Persistence write gate: when `persistence.writable` is explicitly false // we treat register() as read-only. Default `true` (or omitted) preserves // historical behavior. @@ -835,6 +850,11 @@ export class MetadataManager implements IMetadataService { * consumers will read the pre-write definition until restart. */ registerInMemory(type: string, name: string, data: unknown): void { + // [#7378 row 2] The canonical fold is a store fact and applies here; the + // rows-1/3 refusals deliberately do NOT — the ruling names `register`, + // and this is the boot-time seeding primitive (see + // assertMetadataRegisterContract's header for the boundary). + type = canonicalMetadataServiceType(type); if (!this.registry.has(type)) { this.registry.set(type, new Map()); } @@ -878,6 +898,8 @@ export class MetadataManager implements IMetadataService { * `metadata-manager-get-diagnosed.test.ts`. */ async get(type: string, name: string): Promise { + // [#7378 row 2] Read the store `register` wrote: the canonical type. + type = canonicalMetadataServiceType(type); // Check in-memory registry first const typeStore = this.registry.get(type); if (typeStore?.has(name)) { @@ -916,6 +938,8 @@ export class MetadataManager implements IMetadataService { type: string, name: string ): Promise<{ data: unknown | undefined; degraded: boolean; errors: string[] }> { + // [#7378 row 2] Same fold as `get` — the two are pinned to agree. + type = canonicalMetadataServiceType(type); // Check in-memory registry first — a hit here consulted no loader, so // there is nothing to be degraded about. const typeStore = this.registry.get(type); @@ -942,7 +966,9 @@ export class MetadataManager implements IMetadataService { * `listCache`. */ async list(type: string): Promise { - return (await this.readList(type)).items; + // [#7378 row 2] Fold before the cache/single-flight machinery, so the + // two spellings join one read and one cache entry. + return (await this.readList(canonicalMetadataServiceType(type))).items; } /** @@ -974,7 +1000,8 @@ export class MetadataManager implements IMetadataService { * partial even when the others answered plenty — which is the whole fact. */ async listDiagnosed(type: string): Promise { - const { items, degraded, errors } = await this.readList(type); + // [#7378 row 2] Same fold as `list` — same read, narrowed differently. + const { items, degraded, errors } = await this.readList(canonicalMetadataServiceType(type)); return { items, degraded, errors }; } @@ -1330,6 +1357,8 @@ export class MetadataManager implements IMetadataService { * before the await would buy nothing and would re-open step 1's window. */ async unregister(type: string, name: string, options?: MetadataWriteOptions): Promise { + // [#7378 row 2] Remove from the store `register` wrote: the canonical type. + type = canonicalMetadataServiceType(type); // ── 1. Storage first ──────────────────────────────────────────────── // Delete only from database-backed loaders that declare write capability. for (const loader of this.loaders.values()) { @@ -1466,6 +1495,8 @@ export class MetadataManager implements IMetadataService { * Check if a metadata item exists */ async exists(type: string, name: string): Promise { + // [#7378 row 2] Same store `get` reads: the canonical type. + type = canonicalMetadataServiceType(type); // Check in-memory registry if (this.registry.get(type)?.has(name)) { return true; @@ -1484,6 +1515,8 @@ export class MetadataManager implements IMetadataService { * List all names of metadata items of a given type */ async listNames(type: string): Promise { + // [#7378 row 2] Same store `get` reads: the canonical type. + type = canonicalMetadataServiceType(type); const names = new Set(); // From in-memory registry @@ -2253,6 +2286,10 @@ export class MetadataManager implements IMetadataService { * @returns An unsubscribe function. */ subscribe(type: string, callback: WatchCallback): () => void { + // [#7378 row 2] Announcements are made under the canonical type (register/ + // unregister fold before notifyWatchers), so a subscription under the + // plural spelling must land on the same key or it would never fire. + type = canonicalMetadataServiceType(type); this.addWatchCallback(type, callback); return () => this.removeWatchCallback(type, callback); } diff --git a/packages/objectql/src/metadata-facade.ts b/packages/objectql/src/metadata-facade.ts index d6ce8037f2..719e0e00d8 100644 --- a/packages/objectql/src/metadata-facade.ts +++ b/packages/objectql/src/metadata-facade.ts @@ -1,15 +1,24 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { + assertMetadataRegisterContract, + canonicalMetadataServiceType, +} from '@objectstack/core'; import { SchemaRegistry } from './registry.js'; /** - * The two spellings of the object metadata type. `SchemaRegistry.getItem` / - * `listItems` special-case BOTH to the contributor path, so a write that - * handled only the singular left the plural with the same read/write split - * (#6725). + * Is this (already canonical) type the object metadata type? + * + * Every member of this class folds its `type` argument through + * `canonicalMetadataServiceType` first (#7378 row 2), so the plural spelling + * never reaches this predicate — `'objects'` arrives here as `'object'`. + * `SchemaRegistry.getItem` / `listItems` still special-case BOTH spellings to + * the contributor path (their own callers are not all folded), which is the + * read-side alias #6725's write fix had to match; the fold upstream makes the + * two layers agree instead of merely overlapping. */ function isObjectType(type: string): boolean { - return type === 'object' || type === 'objects'; + return type === 'object'; } /** @@ -22,48 +31,36 @@ function isObjectType(type: string): boolean { const RUNTIME_AUTHORED_PACKAGE_ID = 'sys_metadata'; /** - * [#7378] Reconcile a `register(type, name, data)` payload to the key the - * contract says it is stored under: **the `name` ARGUMENT**. - * - * `IMetadataService.register` (`@objectstack/spec/contracts`) rules that the - * argument is the effective key and `data.name` never overrides it (maintainer - * ruling 2026-08-11 on #7378, option (a)). Every store this class writes into - * derives its key from the DOCUMENT — `SchemaRegistry.registerItem` reads - * `item[keyField]`, `registerObject` keys `objectContributors` on the schema's - * own `name` — so the only way to honour the argument here is to reconcile the - * document to it before either store sees it. Two shapes, one rule: + * [#7378] Admit a `register(type, name, data)` payload and key it by the + * `name` ARGUMENT — under the maintainer's three-cell ruling of 2026-08-12 + * (quoted verbatim in `assertMetadataRegisterContract`'s header, + * `@objectstack/core`), which supersedes the 2026-08-11 option-(a) ruling this + * seam previously implemented. * - * - **An object document** keeps every key it was authored with, with `name` - * set to the argument. This is the same normalization - * `ObjectQL.registerMetadataCollections` already applies on the plugin - * ingest path (`item.name === itemName ? item : { ...item, name: itemName }`, - * engine.ts), so a facade write and a plugin write agree about identity - * rather than each keying on a different field. It is also the only - * coherent answer for the `object` type specifically: an object's `name` IS - * its identity to `getObject`, to the data plane and to every driver, so a - * body whose `name` disagreed with its registry key would be dispatched on - * under one spelling and addressable under the other. - * - **Anything else** — a string, a number, a boolean, an array, `null` — is - * boxed as `{ name, content }`. `data` is declared `unknown`, not `object`, - * and a primitive has no `name` to key on: the previous code passed it - * through untouched, `registerItem` read `item['name']` off a string, and - * the value was filed under the literal key `undefined` — accepted with no - * throw and readable back through no member of this class (#7378 cell 3). - * The box is not a new convention: this class's own reads already unwrap it - * (`get`/`list` return `item?.content ?? item`, `listNames` reads - * `item?.name`), so the write half now produces exactly what the read half - * was already prepared to consume. Arrays are boxed rather than spread for - * the same reason — `{ ...[1, 2] }` is `{ 0: 1, 1: 2 }`, which is the same - * silent corruption one shape over. + * - **Rows 1/3 — refuse, don't resolve.** A `data.name` that disagrees with + * the argument, and a `data` that is not a plain object, are both refused + * loudly by the shared guard before either store is touched. The previous + * behaviours here — reconciling a disagreeing document to the argument, and + * boxing a non-object value as `{ name, content }` — were each a silent + * resolution the ruling forbids: the reconcile could file an item under a + * key the author never intended to be the key, and the box coerced an + * unstorable value into storability (colliding with `content` being a REAL + * authorable field on `doc` / `knowledge_document`, the #7519 seam). * - * ⛔ Not a ruling on the DISAGREEMENT itself. Refusing `data.name !== name` - * loudly (option (c)) is recorded on #7378 as the v18 strictness candidate and - * is deliberately NOT implemented here. + * - **What survives the refusals is pure keying.** An admitted document + * either carries the argument as its own `name` already or carries none; + * `{ ...data, name }` is then not a conflict resolution, only this class's + * way of honouring the argument against two stores that derive their key + * from the DOCUMENT — `SchemaRegistry.registerItem` reads + * `item[keyField]`, `registerObject` keys `objectContributors` on the + * schema's own `name`. It is the same fill-in + * `ObjectQL.registerMetadataCollections` applies on the plugin ingest path + * (`item.name === itemName ? item : { ...item, name: itemName }`, + * engine.ts). */ -function toKeyedDefinition(name: string, data: unknown): any { - return typeof data === 'object' && data !== null && !Array.isArray(data) - ? { ...(data as Record), name } - : { name, content: data }; +function toKeyedDefinition(type: string, name: string, data: unknown): any { + assertMetadataRegisterContract(type, name, data); + return { ...(data as Record), name }; } /** @@ -84,15 +81,17 @@ export class MetadataFacade { constructor(private registry: SchemaRegistry) {} /** - * Register a metadata item under the `name` ARGUMENT. + * Register a metadata item under the `name` ARGUMENT and the CANONICAL type. * - * [#7378] The argument is the effective key and `data.name` never overrides - * it — the contract's ruling, and what {@link toKeyedDefinition} exists to - * honour against two document-keyed stores. Read that helper before changing - * either branch below. + * [#7378, ruling 2026-08-12] A `data.name` disagreeing with the argument and + * a non-document `data` are REFUSED loudly ({@link toKeyedDefinition} calls + * the shared guard — read its header before changing either branch below), + * and the type store is keyed on the canonical type, so + * `register('objects', …)` and `register('object', …)` write one store. */ async register(type: string, name: string, data: any): Promise { - const definition = toKeyedDefinition(name, data); + type = canonicalMetadataServiceType(type); + const definition = toKeyedDefinition(type, name, data); // Pass through the item's own source package id (when stamped by an // artifact loader) so provenance survives re-registration. Never // synthesize one here — unstamped items are runtime-authored by @@ -198,7 +197,8 @@ export class MetadataFacade { * legacy context-free lookup. */ async get(type: string, name: string, currentPackageId?: string): Promise { - const item = this.registry.getItem(type, name, currentPackageId) as any; + // [#7378 row 2] Read the store `register` wrote: the canonical type. + const item = this.registry.getItem(canonicalMetadataServiceType(type), name, currentPackageId) as any; return item?.content ?? item; } @@ -206,14 +206,14 @@ export class MetadataFacade { * Get the raw entry (with metadata wrapper) */ getEntry(type: string, name: string): any { - return this.registry.getItem(type, name); + return this.registry.getItem(canonicalMetadataServiceType(type), name); } /** * List all items of a type */ async list(type: string): Promise { - const items = this.registry.listItems(type); + const items = this.registry.listItems(canonicalMetadataServiceType(type)); return items.map((item: any) => item?.content ?? item); } @@ -235,6 +235,8 @@ export class MetadataFacade { * encodes. It runs first so a refusal removes nothing at all. */ async unregister(type: string, name: string): Promise { + // [#7378 row 2] Same fold as register: one store per canonical type. + type = canonicalMetadataServiceType(type); if (isObjectType(type)) { this.registry.unregisterObject(name); } @@ -245,7 +247,7 @@ export class MetadataFacade { * Check if a metadata item exists */ async exists(type: string, name: string): Promise { - const item = this.registry.getItem(type, name); + const item = this.registry.getItem(canonicalMetadataServiceType(type), name); return item !== undefined && item !== null; } @@ -253,7 +255,7 @@ export class MetadataFacade { * List all names of metadata items of a given type */ async listNames(type: string): Promise { - const items = this.registry.listItems(type); + const items = this.registry.listItems(canonicalMetadataServiceType(type)); return items.map((item: any) => item?.name ?? item?.content?.name ?? '').filter(Boolean); } From 8db25da45bc229917fcdd97aae6c043c34d99bab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:33:48 +0000 Subject: [PATCH 2/4] test(objectql,metadata): flip the #7378 pins to the 2026-08-12 three-cell ruling; spread-actual core mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance driver's DIVERGENCE era ends: RULED_CONTRACT_ANSWERS overrides the (spec-side, still pre-ruling) table for the five ruled rows — four refusals asserted with code AND status plus locating-message and nothing-stored probes, one plural row now readable — with a wiring tripwire that goes red when the spec seat lands the table half, prompting override deletion. Driver-local pins add the no-name-is-not-a-mismatch and both-directions-fold cases the table cannot carry yet. 17 metadata test files mocked @objectstack/core naming only createLogger; they now spread the actual module (the repo's existing precedent shape) so the next core export cannot break them again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../src/loaders/database-loader.test.ts | 6 +- .../src/metadata-manager-cluster.test.ts | 6 +- ...tadata-manager-degraded-list-cache.test.ts | 6 +- .../metadata-manager-get-diagnosed.test.ts | 6 +- .../metadata-manager-list-diagnosed.test.ts | 6 +- ...etadata-manager-list-single-flight.test.ts | 6 +- ...ata-manager-loader-delete-contract.test.ts | 6 +- ...adata-manager-loader-save-contract.test.ts | 6 +- .../metadata-manager-match-endpoint.test.ts | 6 +- ...anager-unregister-invalidate-order.test.ts | 6 +- .../src/metadata-realtime-events.test.ts | 6 +- .../metadata/src/metadata-service.test.ts | 6 +- packages/metadata/src/metadata.test.ts | 6 +- ...tadata-manager-degraded-file-event.test.ts | 6 +- ...e-metadata-manager-fs-invalidation.test.ts | 6 +- .../src/publish-endpoint-gate.test.ts | 6 +- .../src/register-notifies-watchers.test.ts | 6 +- ...data-service-roundtrip-conformance.test.ts | 390 +++++++++--------- 18 files changed, 273 insertions(+), 219 deletions(-) diff --git a/packages/metadata/src/loaders/database-loader.test.ts b/packages/metadata/src/loaders/database-loader.test.ts index e5f6315dab..97597ff898 100644 --- a/packages/metadata/src/loaders/database-loader.test.ts +++ b/packages/metadata/src/loaders/database-loader.test.ts @@ -16,7 +16,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-cluster.test.ts b/packages/metadata/src/metadata-manager-cluster.test.ts index 2c2b3701f3..a02542b158 100644 --- a/packages/metadata/src/metadata-manager-cluster.test.ts +++ b/packages/metadata/src/metadata-manager-cluster.test.ts @@ -7,7 +7,11 @@ import type { MetadataLoader } from './loaders/loader-interface.js'; import type { MetadataLoaderContract, MetadataLoadResult, MetadataSaveResult, MetadataStats } from '@objectstack/spec/system'; import type { IPubSub, PubSubMessage } from '@objectstack/spec/contracts'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/metadata-manager-degraded-list-cache.test.ts b/packages/metadata/src/metadata-manager-degraded-list-cache.test.ts index df27cc617b..e5a75afd78 100644 --- a/packages/metadata/src/metadata-manager-degraded-list-cache.test.ts +++ b/packages/metadata/src/metadata-manager-degraded-list-cache.test.ts @@ -58,7 +58,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-get-diagnosed.test.ts b/packages/metadata/src/metadata-manager-get-diagnosed.test.ts index 9cacb84aa6..92cd154f90 100644 --- a/packages/metadata/src/metadata-manager-get-diagnosed.test.ts +++ b/packages/metadata/src/metadata-manager-get-diagnosed.test.ts @@ -63,7 +63,11 @@ import type { MetadataLoadOptions, MetadataLoadResult } from '@objectstack/spec/ import { MetadataManager } from './metadata-manager.js'; import type { MetadataLoader } from './loaders/loader-interface.js'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); diff --git a/packages/metadata/src/metadata-manager-list-diagnosed.test.ts b/packages/metadata/src/metadata-manager-list-diagnosed.test.ts index 47754c6537..7cc598f894 100644 --- a/packages/metadata/src/metadata-manager-list-diagnosed.test.ts +++ b/packages/metadata/src/metadata-manager-list-diagnosed.test.ts @@ -77,7 +77,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-list-single-flight.test.ts b/packages/metadata/src/metadata-manager-list-single-flight.test.ts index f260b90806..4ce754dd86 100644 --- a/packages/metadata/src/metadata-manager-list-single-flight.test.ts +++ b/packages/metadata/src/metadata-manager-list-single-flight.test.ts @@ -49,7 +49,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts b/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts index 2e7d90afb3..0d2a2c2931 100644 --- a/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts +++ b/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts @@ -64,7 +64,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-loader-save-contract.test.ts b/packages/metadata/src/metadata-manager-loader-save-contract.test.ts index e609397756..c08b07ef07 100644 --- a/packages/metadata/src/metadata-manager-loader-save-contract.test.ts +++ b/packages/metadata/src/metadata-manager-loader-save-contract.test.ts @@ -64,7 +64,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-manager-match-endpoint.test.ts b/packages/metadata/src/metadata-manager-match-endpoint.test.ts index 3538c5c26e..afa77d92cd 100644 --- a/packages/metadata/src/metadata-manager-match-endpoint.test.ts +++ b/packages/metadata/src/metadata-manager-match-endpoint.test.ts @@ -27,7 +27,11 @@ import { MetadataManager } from './metadata-manager.js'; import { MemoryLoader } from './loaders/memory-loader.js'; import type { MetadataLoader } from './loaders/loader-interface.js'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/metadata-manager-unregister-invalidate-order.test.ts b/packages/metadata/src/metadata-manager-unregister-invalidate-order.test.ts index a106b4eb13..28d3327758 100644 --- a/packages/metadata/src/metadata-manager-unregister-invalidate-order.test.ts +++ b/packages/metadata/src/metadata-manager-unregister-invalidate-order.test.ts @@ -61,7 +61,11 @@ const logger = vi.hoisted(() => ({ debug: vi.fn(), })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/metadata-realtime-events.test.ts b/packages/metadata/src/metadata-realtime-events.test.ts index bd9b86af21..c61e4cc9ca 100644 --- a/packages/metadata/src/metadata-realtime-events.test.ts +++ b/packages/metadata/src/metadata-realtime-events.test.ts @@ -28,7 +28,11 @@ import { MetadataManager } from './metadata-manager'; import { MemoryLoader } from './loaders/memory-loader'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index f11181e80d..d95191837d 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -7,7 +7,11 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import type { MetadataOverlay } from '@objectstack/spec/kernel'; // Suppress logger output during tests -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/metadata.test.ts b/packages/metadata/src/metadata.test.ts index c079c5b6f3..cafe55bf11 100644 --- a/packages/metadata/src/metadata.test.ts +++ b/packages/metadata/src/metadata.test.ts @@ -9,7 +9,11 @@ import { MemoryLoader } from './loaders/memory-loader'; import type { MetadataLoader } from './loaders/loader-interface'; // Suppress logger output during tests -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/node-metadata-manager-degraded-file-event.test.ts b/packages/metadata/src/node-metadata-manager-degraded-file-event.test.ts index dae08096ff..7f41f98e89 100644 --- a/packages/metadata/src/node-metadata-manager-degraded-file-event.test.ts +++ b/packages/metadata/src/node-metadata-manager-degraded-file-event.test.ts @@ -54,7 +54,11 @@ const { logger } = vi.hoisted(() => ({ }, })); -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => logger, })); diff --git a/packages/metadata/src/node-metadata-manager-fs-invalidation.test.ts b/packages/metadata/src/node-metadata-manager-fs-invalidation.test.ts index 8da04f18e2..ab6f2e1eed 100644 --- a/packages/metadata/src/node-metadata-manager-fs-invalidation.test.ts +++ b/packages/metadata/src/node-metadata-manager-fs-invalidation.test.ts @@ -40,7 +40,11 @@ import * as path from 'node:path'; import { NodeMetadataManager } from './node-metadata-manager.js'; import type { MetadataManager } from './metadata-manager.js'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/publish-endpoint-gate.test.ts b/packages/metadata/src/publish-endpoint-gate.test.ts index 67f403c891..43707b0d96 100644 --- a/packages/metadata/src/publish-endpoint-gate.test.ts +++ b/packages/metadata/src/publish-endpoint-gate.test.ts @@ -20,7 +20,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MetadataManager } from './metadata-manager.js'; import { MemoryLoader } from './loaders/memory-loader.js'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/metadata/src/register-notifies-watchers.test.ts b/packages/metadata/src/register-notifies-watchers.test.ts index 9dae2d8116..b549b979a9 100644 --- a/packages/metadata/src/register-notifies-watchers.test.ts +++ b/packages/metadata/src/register-notifies-watchers.test.ts @@ -62,7 +62,11 @@ import { MemoryLoader } from './loaders/memory-loader'; import type { MetadataLoader } from './loaders/loader-interface.js'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -vi.mock('@objectstack/core', () => ({ +vi.mock('@objectstack/core', async (orig) => ({ + // [#7378] Spread the REAL module: MetadataManager now also imports the + // shared register-contract guard from @objectstack/core, and a mock that + // names only createLogger breaks on every export the class gains. + ...((await orig()) as object), createLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts index a1812841b5..be3fb8e092 100644 --- a/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts +++ b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts @@ -25,6 +25,36 @@ * subject is the one that would notice a `register` that threw, silently * skipped, or mutated the document on the way to `loader.save`. * + * ## All three rows are now RULED (#7378) — and the table lags the ruling + * + * This file's `// DIVERGENCE` era is over. The maintainer's three-cell ruling + * of 2026-08-12 (#7378, 裁定人:维护者 huangyiirene), quoted verbatim and + * untranslated: + * + * > 1. **Row 1(key 归属)= (c) 响亮拒绝。** `register(type, name, data)` 中 + * > `name` 参数与 `data.name` 不一致时,所有实现统一拒绝并报错定位。 + * > 2. **Row 2(objects/object 别名)= 所有实现一个答案,与 + * > `check:meta-type-normalized` 收敛。** + * > 3. **Row 3(非对象 data 静默丢弃)= 响亮拒绝(throw)。** + * > + * > 三格的 `// DIVERGENCE` pin 在裁定 PR 内同步更新(该测试文件设计意图即如此)。 + * + * Every shipped implementation now enforces it through ONE shared guard — + * `assertMetadataRegisterContract` / `canonicalMetadataServiceType` + * (`@objectstack/core/metadata-service-contract`), whose header carries the + * full ruling text and the row-2 convergence rationale (the direction is + * `check:meta-type-normalized`'s: normalize once at the entry, decide on the + * normalized value — the gate's header carries #3984/#5881/#6241). + * + * **{@link RULED_CONTRACT_ANSWERS} below overrides the table's `expected` for + * the five ruled case rows.** The table's own reference answers still describe + * the PRE-ruling reference semantics, because the table — and the contract's + * reference double beside it — live under `packages/spec/src/**`, whose half + * of this ruling is the `domain:spec` seat's, tracked on #7378. When that half + * lands (table rows re-ruled, reference double refusing/folding), the + * `table lags the ruling` wiring test below goes red on purpose: delete the + * override for each row it names and hold every subject to the table again. + * * ## Two assertion strengths, declared per subject * * `documentFidelity` says whether a subject hands back the document it was @@ -32,32 +62,10 @@ * reference (`verbatim`), so they are held to exact equality. `MetadataFacade` * resolves objects through `SchemaRegistry`, which answers the RUNTIME-EFFECTIVE * object — system fields (`organization_id`, `created_at`, …) injected, - * extensions merged — and copies non-object documents while filling in `name`. - * `toEqual(input)` is therefore the wrong assertion for it, exactly as #7223 - * predicted; it is held to a recursive-subset match plus every key/visibility - * assertion the others get. The weaker match is scoped to the ONE subject that - * needs it rather than applied to the whole table. - * - * ## Two rows were RULED (#7378); one is still a pinned divergence - * - * This file used to carry three `// DIVERGENCE` entries — three cases - * `MetadataFacade` answered differently from every other implementation and - * from the contract's reference double. The maintainer ruling of 2026-08-11 - * (#7378, option (a) — **the `name` argument is the effective key and - * `data.name` never overrides it**) settled two of them, the contract TSDoc - * now says so, and `MetadataFacade` was aligned to it: - * - * - the effective key (`key-is-the-name-argument-object` / `-nonobject`) — - * now `RULED_1` below, asserting the ruled answer rather than the old - * `absent`; - * - the dropped non-object `data` (`primitive-data-roundtrips` and its array - * sibling) — no per-subject entry at all any more: the facade simply - * conforms, held to the table's own reference answer like every other - * subject. - * - * `DIVERGENCE_2` (the plural `objects` alias) survives, deliberately, with the - * measurement for why the same ruling could not carry it — read it before - * assuming it was overlooked. + * extensions merged. `toEqual(input)` is therefore the wrong assertion for it, + * exactly as #7223 predicted; it is held to a recursive-subset match plus every + * key/visibility assertion the others get. The weaker match is scoped to the + * ONE subject that needs it rather than applied to the whole table. * * If you are here because one of these tests failed after a behaviour change: * that is the pin working. A RULED row going red means an implementation @@ -73,6 +81,7 @@ import { type MetadataRoundTripCase, type IMetadataService, } from '@objectstack/spec/contracts'; +import { StandardErrorCode } from '@objectstack/spec/api'; import { SchemaRegistry } from './registry'; import { MetadataFacade } from './metadata-facade'; import { MetadataManager, type MetadataLoader } from '@objectstack/metadata'; @@ -95,31 +104,9 @@ type RoundTrippingService = Pick>; create(): RoundTrippingService; } @@ -186,110 +173,37 @@ class WritableFixtureLoader implements MetadataLoader { } /** - * ── RULED 1 — the effective key is the `name` ARGUMENT ── - * - * Cases `key-is-the-name-argument-object` / `-nonobject`. Was DIVERGENCE 1. - * - * `MetadataFacade.register` used to open with - * `{ ...data, name: data.name ?? name }` and hand the DOCUMENT to - * `SchemaRegistry.registerObject` / `registerItem`, which key on the document's - * own `name`. The argument was therefore only a fallback for a document that - * carried none: when the two disagreed the item landed under `data.name`, and - * `get(type, )` answered `undefined`, `exists` - * answered `false`, and `listNames` reported the other spelling. + * The RULED contract answer for a case, where the 2026-08-12 ruling and the + * table's (spec-side, still pre-ruling) `expected` disagree — see the header + * for why the two can disagree at all and for when each entry here dies. * - * The maintainer ruling of 2026-08-11 (#7378, option (a)) settled it the other - * way — the argument is the effective key, `data.name` never overrides it — - * `IMetadataService.register` / `get` now say so, and the facade was aligned - * (`toKeyedDefinition`). All five implementations answer this row the same way - * today. - * - * What stays subject-specific is only the SHAPE of the answer, and only because - * the facade's stores are keyed by the document itself: reconciling the - * document to the argument means the `name` that comes back IS the argument, - * where the verbatim subjects hand back the authored `name` under the argument - * key. Both honour the ruling — the key is the argument in every case — so this - * entry asserts the ruled answer rather than suppressing the row. + * - `refused` — `register` must reject the case's write with the ADR-0112 + * envelope (`code` AND `status`), a locating message, and NOTHING stored. + * - `readable` — the case's final write is readable back, even though the + * table still says `absent`. */ -const RULED_1 = 'MetadataFacade keys on the `name` argument (#7378 ruling (a)); reconciling its document-keyed stores to that argument also normalizes the stored `name` to it.'; +type RuledAnswer = + | { readonly kind: 'refused'; readonly note: string } + | { readonly kind: 'readable'; readonly note: string }; -/** - * ── DIVERGENCE 2 — the plural `objects` type is aliased to `object` ── - * - * Case `plural-objects-type-is-its-own-store`. - * - * `MetadataFacade`'s `isObjectType` treats `'object'` and `'objects'` as the - * same type on the WRITE side (deliberately, per its header: #6725 left the - * plural with the same read/write split the singular had). The consequence this - * case measures is on the READ side: a `register('objects', n, …)` is visible - * through `get('object', n)`, `exists('object', n)` and `listNames('object')`. - * - * `MetadataManager` and `createMemoryMetadata` key their type stores on the - * string they are handed, so the two spellings are two stores and the item is - * invisible under the singular. - * - * ── Why the #7378 ruling did NOT carry this row (measured 2026-08-11) ── - * - * The ruling aligned the facade on the other two rows and named this one with - * them, but aligning it is not a facade-local change and the two candidate - * routes are both worse than the divergence: - * - * 1. **The alias that decides this row is `SchemaRegistry`'s, not the - * facade's.** `registry.getItem` and `registry.listItems` special-case - * BOTH spellings straight to `getObject` / `getAllObjects` - * (`registry.ts`, "Special handling for 'object' and 'objects' types"), so - * the READ this case makes is aliased one layer below anything - * `metadata-facade.ts` controls. Narrowing the facade's own `isObjectType` - * to the singular would therefore make `register('objects', n, d)` write - * into `metadata['objects']` while `get('objects', n)` still resolves - * through `registry.getItem` → `getObject(n)` → `undefined`: the write - * lands nowhere any read looks. That is #6725 EXACTLY, re-opened for the - * plural spelling — the silent loss this whole table exists because of, and - * the row would go green while the bug got worse. - * 2. **Removing the registry alias runs against the platform's own - * normalization direction.** Plural→singular folding is owned by the layers - * below this contract and is enforced there: `canonicalMetaType` - * (`PLURAL_TO_SINGULAR`) canonicalizes every `/meta` request type at the - * protocol boundary (#4432), `RestServer.metaTypeSingular` does it at the - * REST boundary, and `check:meta-type-normalized` is a CI gate whose whole - * job is to refuse a decision made on the un-normalized `:type` — three - * authorization bypasses (#3984, #5881, #6241) came from exactly that. - * `registry.test.ts` pins `listItems("objects")` as a deliberate alias. - * "The two spellings are one type" is a decided platform-wide position; - * this row asks for the opposite, and that is a ruling of its own, not an - * implementation detail this card can settle. - * - * So this stays pinned as measured, and the question — does `IMetadataService` - * key its type stores on the raw string (reference semantics) or on the - * canonical type (what the rest of the platform does)? — is escalated on #7378 - * rather than answered here. - */ -const DIVERGENCE_2 = 'MetadataFacade aliases the plural `objects` type to `object` — through SchemaRegistry\'s own read-side special-case, not its own; the other implementations keep one store per type string. Still pinned as measured: the #7378 ruling did not carry this row (see the note above), and the open question is escalated there.'; +const RULED_1 = + 'Row 1 (#7378, 2026-08-12): a data.name disagreeing with the name argument is REFUSED loudly by every implementation — silent resolution in either direction can misplace the item. Replaces the option-(a) argument-wins ruling of 2026-08-11 that the table still describes.'; -/** - * ── RULED 3 — a non-object `data` value round-trips (no entry needed) ── - * - * Cases `primitive-data-roundtrips`, `array-data-roundtrips`. Was DIVERGENCE 3, - * and is deliberately NOT replaced by an entry in `divergences` below: the - * facade now conforms to the table's own reference answer, so the row is held - * against it like every other subject's. - * - * What it used to measure: the contract declares `data: unknown`, and - * `MetadataFacade.register` passed a non-object value through unchanged (its - * `{ ...data }` branch is guarded on `typeof data === 'object' && data !== null`) - * and then registered it under the document's own `name` — which a string does - * not have. The write was ACCEPTED (no throw), the registry logged - * `Registered setting: undefined`, and the value was readable back through - * nothing. Silent loss, the same family as #6725. - * - * Under the #7378 ruling the argument is the key, so a value with no `name` of - * its own HAS one; `toKeyedDefinition` boxes it as `{ name, content }`, which - * is the shape this class's own reads already unwrap. The array row is the - * sibling shape that `typeof data === 'object'` got wrong in the other - * direction — spread into `{ 0: …, 1: … }` rather than dropped. - */ +const RULED_2 = + "Row 2 (#7378, 2026-08-12): all implementations give ONE answer, converged with check:meta-type-normalized's enforced direction — plural folds to singular before any decision, so 'objects' and 'object' address one store. The table's `absent` still describes the pre-ruling reference semantics (raw-string type keys)."; -const IMPLEMENTATIONS: readonly PinnedImplementation[] = [ +const RULED_3 = + 'Row 3 (#7378, 2026-08-12): a non-object data is REFUSED (throw) by every implementation — accept-then-drop was indefensible, and coercing into storability (the interim { name, content } box) is equally forbidden. The table still expects the value readable back.'; + +const RULED_CONTRACT_ANSWERS: Readonly> = { + 'key-is-the-name-argument-object': { kind: 'refused', note: RULED_1 }, + 'key-is-the-name-argument-nonobject': { kind: 'refused', note: RULED_1 }, + 'primitive-data-roundtrips': { kind: 'refused', note: RULED_3 }, + 'array-data-roundtrips': { kind: 'refused', note: RULED_3 }, + 'plural-objects-type-is-its-own-store': { kind: 'readable', note: RULED_2 }, +}; + +const IMPLEMENTATIONS: readonly ShippedImplementation[] = [ { label: 'MetadataManager (registry only)', documentFidelity: 'verbatim', @@ -310,11 +224,6 @@ const IMPLEMENTATIONS: readonly PinnedImplementation[] = [ { label: 'MetadataFacade', documentFidelity: 'runtime-effective', - divergences: { - 'key-is-the-name-argument-object': { kind: 'readable-keyed-by-argument', note: RULED_1 }, - 'key-is-the-name-argument-nonobject': { kind: 'readable-keyed-by-argument', note: RULED_1 }, - 'plural-objects-type-is-its-own-store': { kind: 'readable-as-last-write', note: DIVERGENCE_2 }, - }, create: () => new MetadataFacade(new SchemaRegistry({ multiTenant: false })), }, ]; @@ -324,44 +233,15 @@ function lastWrittenDocument(testCase: MetadataRoundTripCase): unknown { return testCase.writes[testCase.writes.length - 1]?.data; } -/** - * The answer this subject is held to for this case: the table's reference - * answer, unless the subject declares a per-subject answer for it. - */ -function expectationFor( - implementation: PinnedImplementation, - testCase: MetadataRoundTripCase, -): { kind: 'readable'; document: unknown } | { kind: 'absent' } { - const answer = implementation.divergences?.[testCase.id]; - if (!answer) return testCase.expected; - switch (answer.kind) { - case 'absent': - return { kind: 'absent' }; - case 'readable-as-last-write': - return { kind: 'readable', document: lastWrittenDocument(testCase) }; - case 'readable-keyed-by-argument': - // The authored document, with its own `name` reconciled to the - // effective key — see RULED_1. Every other authored key is asserted - // unchanged, so this cannot degrade into "something came back". - return { - kind: 'readable', - document: { - ...(lastWrittenDocument(testCase) as Record), - name: testCase.read.name, - }, - }; - } -} - /** * The `name` a case's written document carries when that is NOT the key the * case reads — i.e. the spelling an implementation keying on `data.name` would * file the item under. `undefined` when the case does not pose the question. * - * [#7378] Asserting this stale spelling is ABSENT from `listNames` is what - * keeps the ruled rows from passing for the wrong reason: an implementation - * that stored the item twice, or that kept the document's own name as a second - * key, satisfies every other assertion on those rows and fails only this one. + * [#7378] On the refused rows this is what the locating message must NAME, and + * what the absence assertions probe: an implementation that "refused" but + * still filed the item under the document's own name satisfies the rejection + * assertion and fails only these. */ function staleDocumentName(testCase: MetadataRoundTripCase): string | undefined { const written = lastWrittenDocument(testCase); @@ -371,6 +251,51 @@ function staleDocumentName(testCase: MetadataRoundTripCase): string | undefined : undefined; } +/** + * Replay a REFUSED row (#7378 rows 1/3): the single write must reject with the + * ADR-0112 envelope — `code` AND `status`, a rejection test that checks one is + * not a rejection test — locate the problem in its message, and store NOTHING, + * neither under the argument key nor under the document's own name. + */ +async function assertRefused(service: RoundTrippingService, testCase: MetadataRoundTripCase): Promise { + // The refused rows are single-write by construction; a second write would + // make "nothing stored" ambiguous about which write was refused. + expect(testCase.writes).toHaveLength(1); + const write = testCase.writes[0]; + + const error = await service.register(write.type, write.name, write.data).then( + () => undefined, + (thrown: unknown) => thrown as Error & { code?: string; status?: number }, + ); + expect(error, `register must REFUSE this write (#7378): ${testCase.id}`).toBeDefined(); + expect(error).toMatchObject({ + code: StandardErrorCode.enum.VALIDATION_ERROR, + status: 400, + }); + + // 报错定位 — the message names the write's coordinates… + const message = String(error?.message ?? ''); + expect(message).toContain(`'${write.type}'`); + expect(message).toContain(`'${write.name}'`); + // …and, on the mismatch rows, BOTH disagreeing spellings. + const stale = staleDocumentName(testCase); + if (stale !== undefined) { + expect(message).toContain(`'${stale}'`); + } + + // The refusal wrote nothing: absent under the argument key… + expect(await service.get(testCase.read.type, testCase.read.name)).toBeUndefined(); + expect(await service.exists(testCase.read.type, testCase.read.name)).toBe(false); + const names = await service.listNames(testCase.read.type); + expect(names).not.toContain(testCase.read.name); + // …and never under the document's own name either — the misplacement the + // ruling exists to make impossible. + if (stale !== undefined) { + expect(await service.get(testCase.read.type, stale)).toBeUndefined(); + expect(names).not.toContain(stale); + } +} + describe.each(IMPLEMENTATIONS)( 'IMetadataService round-trip conformance [$label]', (implementation) => { @@ -378,6 +303,12 @@ describe.each(IMPLEMENTATIONS)( '%s', async (_id, testCase) => { const service = implementation.create(); + const ruled = RULED_CONTRACT_ANSWERS[testCase.id]; + + if (ruled?.kind === 'refused') { + await assertRefused(service, testCase); + return; + } for (const write of testCase.writes) { await service.register(write.type, write.name, write.data); @@ -389,7 +320,10 @@ describe.each(IMPLEMENTATIONS)( const got = await service.get(testCase.read.type, testCase.read.name); const exists = await service.exists(testCase.read.type, testCase.read.name); const names = await service.listNames(testCase.read.type); - const expected = expectationFor(implementation, testCase); + const expected = + ruled?.kind === 'readable' + ? { kind: 'readable' as const, document: lastWrittenDocument(testCase) } + : testCase.expected; if (expected.kind === 'readable') { // Anti-vacuity: `toMatchObject` against an absent document @@ -424,26 +358,78 @@ describe.each(IMPLEMENTATIONS)( }, ); +/** + * [#7378 rows 1/2] Driver-local pins the table does not carry (the table is the + * spec seat's half — see the header). These keep the ruled behaviour from + * passing for a wrong, narrower reason. + */ +describe.each(IMPLEMENTATIONS)('#7378 ruled behaviour, beyond the table [$label]', (implementation) => { + it('row 1 is a MISMATCH rule: a document with NO name of its own registers under the argument', async () => { + // The refusal must not widen into "data must carry a name": absence is + // not a disagreement, and the argument is the key either way. + const service = implementation.create(); + await service.register('view', 'pin_nameless', { label: 'No name key at all', type: 'grid' }); + const got = (await service.get('view', 'pin_nameless')) as Record | undefined; + expect(got).toBeDefined(); + expect(got).toMatchObject({ label: 'No name key at all' }); + expect(await service.exists('view', 'pin_nameless')).toBe(true); + expect(await service.listNames('view')).toContain('pin_nameless'); + }); + + it('row 1 is not refusal-happy: a data.name that AGREES with the argument registers', async () => { + // The negative pin the ruling's own wording implies: only 不一致 is + // refused. (The table's plain round-trip rows pin this too; stated + // here so the pair — refuse mismatch, admit match — sits together.) + const service = implementation.create(); + await service.register('view', 'pin_agreeing', { name: 'pin_agreeing', label: 'Agrees', type: 'grid' }); + expect(await service.exists('view', 'pin_agreeing')).toBe(true); + }); + + it("row 2 converges in BOTH directions: register('object', …) is readable through the plural spelling", async () => { + // The table's ruled row covers plural-write → singular-read; this is + // the reverse read, so the fold cannot be a write-side special case — + // the direction check:meta-type-normalized's incidents were about + // (#3984: the plural spelling walking past singular-literal gates). + const service = implementation.create(); + await service.register('object', 'pin_both_ways', { + name: 'pin_both_ways', + label: 'Both spellings, one store', + fields: { title: { type: 'text', label: 'Title' } }, + }); + const viaPlural = (await service.get('objects', 'pin_both_ways')) as Record | undefined; + expect(viaPlural).toBeDefined(); + expect(viaPlural).toMatchObject({ name: 'pin_both_ways' }); + expect(await service.exists('objects', 'pin_both_ways')).toBe(true); + expect(await service.listNames('objects')).toContain('pin_both_ways'); + }); +}); + describe('round-trip conformance table wiring', () => { - it('declares no divergence for a case id the table does not contain', () => { - // A renamed case would otherwise turn its divergence override into a - // dead entry, and the subject would quietly be held to the reference + const ids = new Set(METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id)); + + it('declares no ruled override for a case id the table does not contain', () => { + // A renamed case would otherwise turn its override into a dead entry, + // and every subject would quietly be held to the pre-ruling reference // answer it is known to fail. - const ids = new Set(METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id)); - for (const implementation of IMPLEMENTATIONS) { - for (const id of Object.keys(implementation.divergences ?? {})) { - expect(ids, `${implementation.label} → ${id}`).toContain(id); - } + for (const id of Object.keys(RULED_CONTRACT_ANSWERS)) { + expect(ids, id).toContain(id); } }); - it('holds at least one implementation to every case', () => { - // Guards the opposite failure from the one above: a case that every - // subject declared a divergence for would be pinned by nobody against - // the reference answer. - for (const testCase of METADATA_ROUNDTRIP_CASES) { - const conforming = IMPLEMENTATIONS.filter((i) => !i.divergences?.[testCase.id]); - expect(conforming.length, testCase.id).toBeGreaterThan(0); + it('the table still lags the 2026-08-12 ruling — this red is the handoff signal', () => { + // The overrides above exist ONLY because the table and the reference + // double live under `packages/spec/src/**`, the `domain:spec` seat's + // half of #7378. When that half lands, each assertion here goes red: + // delete the corresponding RULED_CONTRACT_ANSWERS entry and let every + // subject be held to the table's (then-ruled) answer directly. + for (const [id, ruled] of Object.entries(RULED_CONTRACT_ANSWERS)) { + const testCase = METADATA_ROUNDTRIP_CASES.find((candidate) => candidate.id === id); + if (!testCase) continue; // the wiring test above owns this failure + if (ruled.kind === 'refused') { + expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('readable'); + } else { + expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('absent'); + } } }); }); From f66ce208eadb761a209db38bc19c33a12e44935e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:58:28 +0000 Subject: [PATCH 3/4] chore: changeset for the #7378 three-cell register ruling Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../metadata-register-three-cell-ruling.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/metadata-register-three-cell-ruling.md diff --git a/.changeset/metadata-register-three-cell-ruling.md b/.changeset/metadata-register-three-cell-ruling.md new file mode 100644 index 0000000000..f110c63f9a --- /dev/null +++ b/.changeset/metadata-register-three-cell-ruling.md @@ -0,0 +1,37 @@ +--- +"@objectstack/core": minor +"@objectstack/metadata": minor +"@objectstack/objectql": minor +--- + +feat(core,metadata,objectql): `IMetadataService.register` refuses ambiguous writes, and type stores key on the canonical type (#7378) + +The maintainer's three-cell ruling of 2026-08-12 on #7378, implemented in every +shipped `IMetadataService` implementation — `createMemoryMetadata` +(`@objectstack/core`), `MetadataManager` (`@objectstack/metadata`) and +`MetadataFacade` (`@objectstack/objectql`) — through one shared guard, +`assertMetadataRegisterContract` / `canonicalMetadataServiceType`, newly +exported from `@objectstack/core`: + +- **A `data.name` that disagrees with the `name` argument is refused** with a + locating `VALIDATION_ERROR` (status 400), before anything is stored. The + previous behaviours resolved the disagreement silently in opposite + directions per implementation (argument-wins on the Map-backed stores, + document-wins on the pre-#7511 facade), either of which can file an item + under a key the author never wrote. A document carrying no `name` of its own + still registers under the argument — absence is not a disagreement. +- **A non-object `data` (primitive, `null`, array) is refused** the same way. + It was previously accepted-then-dropped by `MetadataFacade` (readable back + through no member) and interim-fixed by boxing into `{ name, content }`; the + ruling forbids both the drop and the coercion. +- **Type stores are keyed on the canonical (singular) type**: `'objects'` and + `'object'` now address ONE store on every implementation, in both the write + and the read direction, converging with the platform's enforced + plural→singular normalization (`PLURAL_TO_SINGULAR`, `canonicalMetaType` + #4432, `check:meta-type-normalized`). + +Callers that register with a matching (or absent) `data.name` and plain-object +documents — every in-tree caller — are unaffected. A caller that relied on a +mismatched `data.name` being silently resolved must pass the intended key as +the argument and make `data.name` match it; a caller storing a bare value must +wrap it in a document whose shape its type's schema accepts. From 3b84dd6d6349a5196a83f5c6b042c262b48e5dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:26:51 +0000 Subject: [PATCH 4/4] fix(core): resolve the register-contract guard in file-mapped alias configs (#7378 patch round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves, both measured before choosing: - packages/core/metadata-service-contract.ts: the '@objectstack/spec/api' import becomes TYPE-ONLY (erased at compile time) with the refusal code as a typed literal against the closed set — the storage-service.ts precedent. The module every consumer loads now makes one runtime subpath demand instead of two. '/shared' cannot be erased: pluralToSingular is a runtime value with ONE owner (copying the map would be the folk normalization the #7378 row-2 ruling forbids). - Five vitest configs alias the bare '@objectstack/spec' to spec/src/index.ts (a FILE, prefix-matched) with per-subpath entries and no '/shared' entry, so the guard's import resolved to spec/src/index.ts/shared - ENOTDIR at load: driver-memory (23 test files dead) and plugin-hono-server (16 dead) in CI, knowledge-ragflow / plugin-dev / knowledge-memory latently. Each gains the '/shared' entry in its config's own established pattern. check:test-source-alias was green throughout - its reachability walk stops at the package boundary, one hop short of the aliased dependency's own import surface; filed as #8351. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../core/src/metadata-service-contract.ts | 21 +++++++++++++++++-- .../drivers/driver-memory/vitest.config.ts | 7 +++++++ .../plugins/knowledge-memory/vitest.config.ts | 7 +++++++ .../knowledge-ragflow/vitest.config.ts | 7 +++++++ packages/plugins/plugin-dev/vitest.config.ts | 7 +++++++ .../plugin-hono-server/vitest.config.ts | 7 +++++++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/metadata-service-contract.ts b/packages/core/src/metadata-service-contract.ts index 56c893a6cd..aca9df9235 100644 --- a/packages/core/src/metadata-service-contract.ts +++ b/packages/core/src/metadata-service-contract.ts @@ -78,9 +78,26 @@ * `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts`. */ -import { StandardErrorCode } from '@objectstack/spec/api'; +// The `/api` import is TYPE-ONLY on purpose — erased at compile time, so this +// module makes no runtime demand on that subpath. This module is loaded by +// every consumer of `@objectstack/core`, and several packages' vitest configs +// alias the bare `@objectstack/spec` specifier to `spec/src/index.ts` (a FILE) +// with per-subpath entries spelled out above it; an alias list matches by +// PREFIX, so any subpath NOT spelled out resolves under the file and dies with +// ENOTDIR at import time (measured: `@objectstack/plugin-hono-server` and +// `@objectstack/driver-memory`, 39 test files dead at load between them). The +// typed literal below keeps the closed-set compile check without the runtime +// import — the `packages/spec/src/contracts/storage-service.ts` pattern. +// `/shared` cannot get the same treatment: `pluralToSingular` is a runtime +// value and its map has ONE owner (#7378 row 2 — copying it here would be the +// per-implementation folk normalization the ruling forbids), so the consumer +// configs carry a `/shared` alias entry instead. +import type { StandardErrorCode } from '@objectstack/spec/api'; import { pluralToSingular } from '@objectstack/spec/shared'; +/** The standard catalog's generic argument-validation code, type-checked against the closed set. */ +const REGISTER_REFUSAL_CODE: StandardErrorCode = 'VALIDATION_ERROR'; + /** * The canonical spelling an `IMetadataService` type store is keyed on * (#7378 row 2). Folds a plural manifest spelling to the singular metadata @@ -102,7 +119,7 @@ export function canonicalMetadataServiceType(type: string): string { */ function registerRefusal(message: string): Error & { code: string; status: number } { const err = new Error(message) as Error & { code: string; status: number }; - err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.code = REGISTER_REFUSAL_CODE; err.status = 400; return err; } diff --git a/packages/drivers/driver-memory/vitest.config.ts b/packages/drivers/driver-memory/vitest.config.ts index c5bcab95dd..999820ae59 100644 --- a/packages/drivers/driver-memory/vitest.config.ts +++ b/packages/drivers/driver-memory/vitest.config.ts @@ -20,6 +20,13 @@ export default defineConfig({ '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), + // [#7378] Reached transitively: `@objectstack/core` (aliased to src above) + // resolves the metadata register contract's plural→singular fold from this + // subpath (`pluralToSingular`). An alias list matches by PREFIX, so without + // this entry the bare `@objectstack/spec` alias below wins and yields the + // nonsensical `spec/src/index.ts/shared` — ENOTDIR at import time for every + // test file that transitively loads `@objectstack/core`. + '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/knowledge-memory/vitest.config.ts b/packages/plugins/knowledge-memory/vitest.config.ts index 7737503420..880f8ca91f 100644 --- a/packages/plugins/knowledge-memory/vitest.config.ts +++ b/packages/plugins/knowledge-memory/vitest.config.ts @@ -19,6 +19,13 @@ export default defineConfig({ '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), + // [#7378] Reached transitively: `@objectstack/core` (aliased to src above) + // resolves the metadata register contract's plural→singular fold from this + // subpath (`pluralToSingular`). An alias list matches by PREFIX, so without + // this entry the bare `@objectstack/spec` alias below wins and yields the + // nonsensical `spec/src/index.ts/shared` — ENOTDIR at import time for every + // test file that transitively loads `@objectstack/core`. + '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/knowledge-ragflow/vitest.config.ts b/packages/plugins/knowledge-ragflow/vitest.config.ts index 7737503420..880f8ca91f 100644 --- a/packages/plugins/knowledge-ragflow/vitest.config.ts +++ b/packages/plugins/knowledge-ragflow/vitest.config.ts @@ -19,6 +19,13 @@ export default defineConfig({ '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), + // [#7378] Reached transitively: `@objectstack/core` (aliased to src above) + // resolves the metadata register contract's plural→singular fold from this + // subpath (`pluralToSingular`). An alias list matches by PREFIX, so without + // this entry the bare `@objectstack/spec` alias below wins and yields the + // nonsensical `spec/src/index.ts/shared` — ENOTDIR at import time for every + // test file that transitively loads `@objectstack/core`. + '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/plugin-dev/vitest.config.ts b/packages/plugins/plugin-dev/vitest.config.ts index 9225bcf19b..b389d11c7b 100644 --- a/packages/plugins/plugin-dev/vitest.config.ts +++ b/packages/plugins/plugin-dev/vitest.config.ts @@ -24,6 +24,13 @@ export default defineConfig({ '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), + // [#7378] Reached transitively: `@objectstack/core` (aliased to src above) + // resolves the metadata register contract's plural→singular fold from this + // subpath (`pluralToSingular`). An alias list matches by PREFIX, so without + // this entry the bare `@objectstack/spec` alias below wins and yields the + // nonsensical `spec/src/index.ts/shared` — ENOTDIR at import time for every + // test file that transitively loads `@objectstack/core`. + '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/plugin-hono-server/vitest.config.ts b/packages/plugins/plugin-hono-server/vitest.config.ts index b60801e244..426987a2f6 100644 --- a/packages/plugins/plugin-hono-server/vitest.config.ts +++ b/packages/plugins/plugin-hono-server/vitest.config.ts @@ -22,6 +22,13 @@ export default defineConfig({ // nonsensical `spec/src/index.ts/security`. '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [#7378] Reached transitively: `@objectstack/core` (aliased to src above) + // resolves the metadata register contract's plural→singular fold from this + // subpath (`pluralToSingular`). An alias list matches by PREFIX, so without + // this entry the bare `@objectstack/spec` alias below wins and yields the + // nonsensical `spec/src/index.ts/shared` — ENOTDIR at import time for every + // test file that transitively loads `@objectstack/core`. + '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, },