From 6cbad9d6ae91c7694807bf0166982b45dfe16a9d Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 18 Aug 2026 14:37:31 +0000 Subject: [PATCH 1/2] fix(test-harness): one anchored rule for every `@objectstack/spec` subpath in the vitest alias tables (#9457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/runtime`'s vitest alias table enumerated twelve `@objectstack/spec` subpaths by hand and had gone stale in three places — `cloud`, `integration` and `studio` are published subpath exports with no entry. A string `find` matches by PREFIX, so with a FILE replacement the bare `@objectstack/spec` entry swallowed each of them and resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a config that reads as correct. The one-line alias is not the fix; the hand-maintained population is the defect. All nine enumerated configs are converted to the anchored-regex shape `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, `service-settings` and `plugin-audit` already carry — one rule for every namespace, which cannot go stale as tests reach new subpaths. `packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts` pins the RULE: it derives the subpath population from spec's published `exports` map, so a namespace this rule stops covering fails there rather than surfacing as an `ENOTDIR` inside whichever plugin performed the import. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk --- .../drivers/driver-memory/vitest.config.ts | 51 +++-- packages/drivers/driver-sql/vitest.config.ts | 45 +++-- packages/metadata/vitest.config.ts | 49 +++-- .../plugins/embedder-openai/vitest.config.ts | 34 +++- .../plugins/knowledge-memory/vitest.config.ts | 55 ++++-- .../knowledge-ragflow/vitest.config.ts | 53 +++-- packages/plugins/plugin-dev/vitest.config.ts | 51 +++-- .../plugin-hono-server/vitest.config.ts | 54 ++--- .../spec-subpath-alias-coverage.pin.test.ts | 184 ++++++++++++++++++ packages/runtime/vitest.config.ts | 58 ++++-- 10 files changed, 475 insertions(+), 159 deletions(-) create mode 100644 packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts diff --git a/packages/drivers/driver-memory/vitest.config.ts b/packages/drivers/driver-memory/vitest.config.ts index 66544db7d3..681a9746c0 100644 --- a/packages/drivers/driver-memory/vitest.config.ts +++ b/packages/drivers/driver-memory/vitest.config.ts @@ -9,26 +9,37 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, // [ADR-0105 D1] `resolveTenancyPosture()`, read by the #6915 tenancy guard. - '@objectstack/types': path.resolve(__dirname, '../../types/src/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - '@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/qa': path.resolve(__dirname, '../../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - }, + { find: '@objectstack/types', replacement: path.resolve(__dirname, '../../types/src/index.ts') }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture) and `shared` via `@objectstack/core`'s plural→singular fold + // (`pluralToSingular`, #7378). Neither is a reason to keep listing + // namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + ], }, }); diff --git a/packages/drivers/driver-sql/vitest.config.ts b/packages/drivers/driver-sql/vitest.config.ts index 16c0c561dc..ac7e1966a2 100644 --- a/packages/drivers/driver-sql/vitest.config.ts +++ b/packages/drivers/driver-sql/vitest.config.ts @@ -9,20 +9,35 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), - '@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'), - // Reached transitively via `@objectstack/core` (#3777's `nextUtcCalendarDay` - // import pulls core's src barrel in, which fans out to these subpaths). - '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - '@objectstack/spec/qa': path.resolve(__dirname, '../../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), - }, + alias: [ + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture), and `api` / `kernel` / `qa` through `@objectstack/core`'s src + // barrel, pulled in by #3777's `nextUtcCalendarDay` import. Neither is a + // reason to keep listing namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + ], }, }); diff --git a/packages/metadata/vitest.config.ts b/packages/metadata/vitest.config.ts index b3b2d04409..932526ed42 100644 --- a/packages/metadata/vitest.config.ts +++ b/packages/metadata/vitest.config.ts @@ -5,21 +5,40 @@ import path from 'node:path'; export default defineConfig({ resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'), - '@objectstack/spec/cloud': path.resolve(__dirname, '../spec/src/cloud/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'), - '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'), - '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'), - // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). - '@objectstack/spec/security': path.resolve(__dirname, '../spec/src/security/index.ts'), - '@objectstack/spec/qa': path.resolve(__dirname, '../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'), - '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'), - }, + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../core/src/index.ts') }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reason outlives it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture). That is not a reason to keep listing namespaces by hand. + // + // This package is also where the missing-subpath cost was first measured: + // `MetadataPlugin._parseAndRegisterArtifact` does `await import( + // '@objectstack/spec/cloud')`, which is why `packages/runtime`'s tests died + // with an `ENOTDIR` naming this plugin rather than their own alias table. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../spec/src/index.ts') }, + { find: '@objectstack/types', replacement: path.resolve(__dirname, '../types/src/index.ts') }, + ], }, test: { globals: true, diff --git a/packages/plugins/embedder-openai/vitest.config.ts b/packages/plugins/embedder-openai/vitest.config.ts index 4db09eb32e..0a273614c7 100644 --- a/packages/plugins/embedder-openai/vitest.config.ts +++ b/packages/plugins/embedder-openai/vitest.config.ts @@ -9,12 +9,32 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), - // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). - '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - }, + alias: [ + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reason outlives it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture). That is not a reason to keep listing namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: 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 568c18d87c..b53e4302da 100644 --- a/packages/plugins/knowledge-memory/vitest.config.ts +++ b/packages/plugins/knowledge-memory/vitest.config.ts @@ -9,26 +9,39 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), - '@objectstack/service-knowledge': path.resolve(__dirname, '../../services/service-knowledge/src/index.ts'), - '@objectstack/spec/ai': path.resolve(__dirname, '../../spec/src/ai/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - '@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/qa': path.resolve(__dirname, '../../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - }, + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { + find: '@objectstack/service-knowledge', + replacement: path.resolve(__dirname, '../../services/service-knowledge/src/index.ts'), + }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture) and `shared` via `@objectstack/core`'s plural→singular fold + // (`pluralToSingular`, #7378). Neither is a reason to keep listing + // namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: 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 880f8ca91f..b53e4302da 100644 --- a/packages/plugins/knowledge-ragflow/vitest.config.ts +++ b/packages/plugins/knowledge-ragflow/vitest.config.ts @@ -9,24 +9,39 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), - '@objectstack/service-knowledge': path.resolve(__dirname, '../../services/service-knowledge/src/index.ts'), - '@objectstack/spec/ai': path.resolve(__dirname, '../../spec/src/ai/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - '@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'), - }, + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { + find: '@objectstack/service-knowledge', + replacement: path.resolve(__dirname, '../../services/service-knowledge/src/index.ts'), + }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture) and `shared` via `@objectstack/core`'s plural→singular fold + // (`pluralToSingular`, #7378). Neither is a reason to keep listing + // namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: 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 7d959caedc..35da5fb631 100644 --- a/packages/plugins/plugin-dev/vitest.config.ts +++ b/packages/plugins/plugin-dev/vitest.config.ts @@ -14,25 +14,36 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), - '@objectstack/types': path.resolve(__dirname, '../../types/src/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - '@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/qa': path.resolve(__dirname, '../../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - }, + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { find: '@objectstack/types', replacement: path.resolve(__dirname, '../../types/src/index.ts') }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture) and `shared` via `@objectstack/core`'s plural→singular fold + // (`pluralToSingular`, #7378). Neither is a reason to keep listing + // namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: 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 181246b8a7..d46a6faebd 100644 --- a/packages/plugins/plugin-hono-server/vitest.config.ts +++ b/packages/plugins/plugin-hono-server/vitest.config.ts @@ -9,28 +9,36 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), - '@objectstack/observability': path.resolve(__dirname, '../../observability/src/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), - // Reached transitively: `@objectstack/types` resolves the tenancy posture - // (ADR-0105 D1) from this subpath. Without the entry the bare - // `@objectstack/spec` alias below wins by prefix and yields the - // 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/qa': path.resolve(__dirname, '../../spec/src/qa/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), - }, + alias: [ + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { find: '@objectstack/observability', replacement: path.resolve(__dirname, '../../observability/src/index.ts') }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in place + // of the hand-maintained list of the subpaths this package's tests happened + // to reach. A string `find` matches by PREFIX, so with a FILE replacement the + // bare `@objectstack/spec` entry also swallowed every published namespace the + // list had not reached — `cloud`, `integration` and `studio` among them — and + // resolved it to `…/spec/src/index.ts/`: `ENOTDIR`, at run time, from a + // config that reads as correct, naming whichever module performed the import + // rather than this table. + // + // spec's export map is UNIFORM — every published namespace is + // `src//index.ts`, with no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is — so one rule covers all of them + // and cannot go stale as tests reach new namespaces. Same shape as + // `packages/qa/downstream-contract` (PR #8129), `service-knowledge`, + // `service-settings` and `plugin-audit`; `packages/runtime` carries the pin + // over the rule (`src/spec-subpath-alias-coverage.pin.test.ts`). + // + // Kept from the enumeration because the reasons outlive it: `security` is + // reached transitively via `@objectstack/types` ([ADR-0105 D1] tenancy + // posture) and `shared` via `@objectstack/core`'s plural→singular fold + // (`pluralToSingular`, #7378). Neither is a reason to keep listing + // namespaces by hand. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '../..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + ], }, }); diff --git a/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts b/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts new file mode 100644 index 0000000000..ffa2e34fa3 --- /dev/null +++ b/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +/** + * #9457 — the alias table in `vitest.config.ts` must resolve EVERY published + * `@objectstack/spec` subpath, not the ones today's tests happen to reach. + * + * ## What went wrong, and why a one-subpath test would not have caught it + * + * The table used to enumerate twelve subpaths by hand. Three published ones — + * `cloud`, `integration` and `studio` — were missing, and a string `find` + * matches by PREFIX, so with a FILE replacement the bare `@objectstack/spec` + * entry swallowed each of them: `@objectstack/spec/cloud` resolved to + * `…/spec/src/index.ts/cloud`. Measured on `origin/main` in this package: + * + * Error: ENOTDIR: not a directory, open + * '…/packages/spec/src/index.ts/cloud' + * + * raised out of `MetadataPlugin._parseAndRegisterArtifact`, which does + * `await import('@objectstack/spec/cloud')`. The error named the metadata + * plugin; the defect was this table, one package away. That diagnostic distance + * is the real cost, and it recurs for every subpath a hand-written list has not + * reached yet — so this pin is written over the RULE (every published subpath + * resolves into `spec/src`), never over `cloud`. + * + * ## The three axes, and why each is separate + * + * 1. **Resolution really happens** — every published subpath is `import()`ed, + * so this is Vite's own resolution rather than a re-implementation of it. + * This is the axis that fails with `ENOTDIR`. + * 2. **It lands on SOURCE, not `dist`** — axis 1 alone would stay green with + * the alias table deleted outright, because the specifiers would then + * resolve through `exports` to `packages/spec/dist`, a build artifact (the + * #7668 / #7991 failure `pnpm check:test-source-alias` exists for). The + * discriminator is `@objectstack/spec/conversions`, which exists in the + * source tree and is deliberately absent from spec's `exports` map: through + * `exports` it is `ERR_PACKAGE_PATH_NOT_EXPORTED`, so it can only resolve + * through a source alias. The same discriminator + * `packages/qa/downstream-contract/test/source-resolution.pin.test.ts` uses. + * 3. **The rule stays a rule** — the config's own alias array is read and + * Vite's first-match-wins resolution simulated over it, asserting the + * winning entry produces `…/spec/src//index.ts` and that no + * `@objectstack/spec` entry matches by prefix. This is the axis that goes + * red on a revert to the enumerated shape, rather than staying green until + * somebody reaches an unlisted namespace. + * + * The population for all three comes from `@objectstack/spec`'s published + * `exports` map, read through node's own resolution of the dependency — never a + * repo-relative path climbing out of this package, which is the read + * `pnpm check:cross-package-test-inputs` exists to keep declared. An installed + * dependency reached by its package specifier is covered by the ordinary + * dependency edge instead. + * + * Both `import()` specifiers below are held in variables rather than written as + * literals, deliberately: a literal is resolved by `tsc` as well as by Vite, and + * this file sits inside the program `check:type-check-debt` re-measures. One + * axis, one config — a vitest alias defect must not be able to surface as a type + * error, nor to be masked by `tsconfig.json`. + */ + +/** Vite normalizes an alias object into this array shape; the config uses it directly. */ +interface AliasEntry { + find: string | RegExp; + replacement: string; +} + +const require_ = createRequire(import.meta.url); + +/** `@objectstack/spec`'s own `package.json`, resolved as a dependency rather than by path. */ +const specPackageJson = require_('@objectstack/spec/package.json') as { exports: Record }; + +const SPEC_SRC = path.join(path.dirname(require_.resolve('@objectstack/spec/package.json')), 'src'); + +/** + * Every namespace subpath `@objectstack/spec` publishes. + * + * `.` is the bare entry (covered by its own case) and the `*.json` keys are data + * files rather than namespaces — the anchored `([a-z-]+)` rule deliberately does + * not match a key containing a dot, so those fall through to node resolution and + * land on the real files instead of being mangled by a prefix match. + */ +const PUBLISHED_SUBPATHS = Object.keys(specPackageJson.exports) + .filter((key) => key.startsWith('./') && !key.includes('.', 2)) + .map((key) => `@objectstack/spec/${key.slice(2)}`) + .sort(); + +let aliasEntries: AliasEntry[]; + +beforeAll(async () => { + const configModule = '../vitest.config.ts'; + const loaded = (await import(/* @vite-ignore */ configModule)) as { default?: { resolve?: { alias?: unknown } } }; + const alias = loaded.default?.resolve?.alias; + if (!Array.isArray(alias)) { + throw new Error('vitest.config.ts must use the ARRAY alias form — only it accepts a RegExp `find`.'); + } + aliasEntries = alias as AliasEntry[]; +}); + +/** + * Resolve `specifier` the way Vite does: entries in order, FIRST MATCH WINS, a + * string `find` replacing by PREFIX and a RegExp `find` by `String.replace`. + * `null` means no entry matched — a real answer, not an error: it means the + * specifier falls through to node resolution, i.e. to `dist`. + */ +function resolveThroughAlias(specifier: string): string | null { + for (const entry of aliasEntries) { + if (typeof entry.find === 'string') { + if (specifier.startsWith(entry.find)) return specifier.replace(entry.find, entry.replacement); + } else if (entry.find.test(specifier)) { + return specifier.replace(entry.find, entry.replacement); + } + } + return null; +} + +describe('every published `@objectstack/spec` subpath resolves to spec SOURCE (#9457)', () => { + it('finds a population to check — the guard on this guard', () => { + // A short population would make every loop below pass vacuously, which is + // the exact shape of the defect this file pins. + expect(PUBLISHED_SUBPATHS.length).toBeGreaterThanOrEqual(15); + // The three the hand-written enumeration had missed, named individually so + // that a filter bug dropping them cannot be invisible. + expect(PUBLISHED_SUBPATHS).toContain('@objectstack/spec/cloud'); + expect(PUBLISHED_SUBPATHS).toContain('@objectstack/spec/integration'); + expect(PUBLISHED_SUBPATHS).toContain('@objectstack/spec/studio'); + }); + + it('imports every one of them — real Vite resolution, not a simulation of it', async () => { + const failures: string[] = []; + for (const specifier of PUBLISHED_SUBPATHS) { + try { + expect(await import(/* @vite-ignore */ specifier)).toBeTypeOf('object'); + } catch (error) { + failures.push(`${specifier}: ${(error as Error).message}`); + } + } + expect(failures, 'published subpaths this vitest config cannot resolve').toEqual([]); + }); + + it('resolves a namespace the published `exports` map does NOT publish', async () => { + // `packages/spec/src/conversions/` exists in source and is deliberately + // absent from the `exports` map, so this specifier can only arrive through + // the source alias — through `exports` it is + // `ERR_PACKAGE_PATH_NOT_EXPORTED`. That asymmetry is what makes it a real + // source-vs-dist discriminator rather than a check that passes either way. + const sourceOnlySubpath = '@objectstack/spec/conversions'; + const conversions = (await import(/* @vite-ignore */ sourceOnlySubpath)) as { CONVERSION_NOTICE_CODE?: unknown }; + + expect(conversions.CONVERSION_NOTICE_CODE).toBeTypeOf('string'); + }); + + it('maps every published subpath onto `spec/src//index.ts`, never dist', () => { + for (const specifier of PUBLISHED_SUBPATHS) { + const namespace = specifier.slice('@objectstack/spec/'.length); + const target = resolveThroughAlias(specifier); + + expect(target, `${specifier} matches no alias entry, so it falls through to dist`).not.toBeNull(); + expect(target, `${specifier} does not resolve to spec/src/${namespace}/index.ts`).toBe( + path.join(SPEC_SRC, namespace, 'index.ts'), + ); + } + }); + + it('keeps the bare package entry on source too', () => { + expect(resolveThroughAlias('@objectstack/spec')).toBe(path.join(SPEC_SRC, 'index.ts')); + }); + + it('refuses a `@objectstack/spec` entry that matches subpaths by prefix', () => { + // The trap itself, pinned so that a revert to the enumerated shape is loud + // here rather than silent until someone reaches an unlisted namespace. A + // string `find` of `@objectstack/spec` swallows every subpath; a string + // `find` of `@objectstack/spec/` is the enumeration this card removed. + for (const entry of aliasEntries) { + if (typeof entry.find !== 'string') continue; + expect( + entry.find.startsWith('@objectstack/spec'), + `alias entry '${entry.find}' matches @objectstack/spec specifiers by PREFIX — anchor it instead`, + ).toBe(false); + } + }); +}); diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 81eeed6a9a..52ee912a58 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -53,25 +53,45 @@ export default defineConfig({ replacement: path.resolve(__dirname, '../platform-objects/src/index.ts'), }, { find: '@objectstack/rest', replacement: path.resolve(__dirname, '../rest/src/index.ts') }, - { find: '@objectstack/spec/ai', replacement: path.resolve(__dirname, '../spec/src/ai/index.ts') }, - { find: '@objectstack/spec/api', replacement: path.resolve(__dirname, '../spec/src/api/index.ts') }, - // `AppPlugin` reads a bundle function's declared effect off this - // namespace (#4396). - { find: '@objectstack/spec/automation', replacement: path.resolve(__dirname, '../spec/src/automation/index.ts') }, - { find: '@objectstack/spec/contracts', replacement: path.resolve(__dirname, '../spec/src/contracts/index.ts') }, - { find: '@objectstack/spec/data', replacement: path.resolve(__dirname, '../spec/src/data/index.ts') }, - // Reached via `@objectstack/platform-objects` (sys-user.object.ts), which - // notifications.hono.integration.test.ts pulls in for the real - // `sys_notification` declaration. - { find: '@objectstack/spec/identity', replacement: path.resolve(__dirname, '../spec/src/identity/index.ts') }, - { find: '@objectstack/spec/kernel', replacement: path.resolve(__dirname, '../spec/src/kernel/index.ts') }, - { find: '@objectstack/spec/shared', replacement: path.resolve(__dirname, '../spec/src/shared/index.ts') }, - { find: '@objectstack/spec/system', replacement: path.resolve(__dirname, '../spec/src/system/index.ts') }, - { find: '@objectstack/spec/ui', replacement: path.resolve(__dirname, '../spec/src/ui/index.ts') }, - // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). - { find: '@objectstack/spec/security', replacement: path.resolve(__dirname, '../spec/src/security/index.ts') }, - { find: '@objectstack/spec/qa', replacement: path.resolve(__dirname, '../spec/src/qa/index.ts') }, - { find: '@objectstack/spec', replacement: path.resolve(__dirname, '../spec/src/index.ts') }, + // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in + // place of the hand-maintained list of the twelve subpaths tests happened + // to reach on the day it was written. That list had gone stale in three + // places — `cloud`, `integration` and `studio` are published subpath + // exports with no entry — and a bare string `find` matches by PREFIX with + // a FILE replacement, so `@objectstack/spec/cloud` resolved to the + // garbage path `…/spec/src/index.ts/cloud` and died with `ENOTDIR` inside + // whichever module happened to import it. Measured before this change: + // `MetadataPlugin._parseAndRegisterArtifact` (`await + // import('@objectstack/spec/cloud')`) took every artifact-loading test in + // this package down with an error naming the metadata plugin rather than + // this table — the diagnostic distance is the real defect, the missing + // subpath is only its trigger. + // + // `@objectstack/spec`'s export map is UNIFORM: every published namespace + // is `src//index.ts`, and it has no FILE-shaped subpath of the kind + // `@objectstack/platform-objects/plugin` is (which is exactly why the + // rule above it needs a hand-written entry and this one does not). So one + // rule covers all fifteen and cannot go stale as tests reach new + // namespaces — the same shape `packages/qa/downstream-contract` (PR + // #8129), `service-knowledge`, `service-settings` and `plugin-audit` + // already carry. + // + // What the enumeration recorded, kept because the reasons outlive it: + // `automation` carries the declared effect `AppPlugin` reads off a bundle + // function (#4396); `identity` is reached through + // `@objectstack/platform-objects` (`sys-user.object.ts`) by + // notifications.hono.integration.test.ts; `security` is reached + // transitively via `@objectstack/types` ([ADR-0105 D1] tenancy posture). + // None of the three is a reason to keep listing namespaces by hand. + // + // `src/spec-subpath-alias-coverage.pin.test.ts` pins the RULE rather than + // any one subpath: it derives the population from spec's published + // `exports` map, so a subpath this rule stops covering fails there. + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../spec/src/index.ts') }, { find: '@objectstack/types', replacement: path.resolve(__dirname, '../types/src/index.ts') }, // Dev-only: app-plugin.jobs.test.ts drives the REAL CronJobAdapter, so // the #4567 regression (croner rejecting the expression envelope) is From b81e1fd9fbb322aa26ffbe1c1758541bf685f6c6 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 18 Aug 2026 15:25:16 +0000 Subject: [PATCH 2/2] test(runtime): give the subpath-coverage pin a timeout that outlives a cold Vite transform (#9457) Measured in both directions: the fifteen namespace imports take ~5.3s on a cold transform, against vitest's 5000ms default, so the case reported `Test timed out in 5000ms` instead of the resolution failure it exists to name. That is the points-at-the-wrong-thing diagnostic this card is about, reproduced inside its own pin. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk --- .../spec-subpath-alias-coverage.pin.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts b/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts index ffa2e34fa3..d1f1849a70 100644 --- a/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts +++ b/packages/runtime/src/spec-subpath-alias-coverage.pin.test.ts @@ -128,17 +128,30 @@ describe('every published `@objectstack/spec` subpath resolves to spec SOURCE (# expect(PUBLISHED_SUBPATHS).toContain('@objectstack/spec/studio'); }); - it('imports every one of them — real Vite resolution, not a simulation of it', async () => { - const failures: string[] = []; - for (const specifier of PUBLISHED_SUBPATHS) { - try { - expect(await import(/* @vite-ignore */ specifier)).toBeTypeOf('object'); - } catch (error) { - failures.push(`${specifier}: ${(error as Error).message}`); + // The explicit timeout is load-bearing rather than defensive. This case + // transforms up to fifteen spec namespaces through Vite, and when it is the + // first file in the run to reach one of them that is a cold transform: + // measured at ~5.3s for the set, against vitest's 5000ms default. A default + // timeout makes the case report `Test timed out in 5000ms` INSTEAD of the + // resolution failure it exists to name — measured in both directions, so it + // is a fault in both: flaky green-path noise when the file runs alone, and a + // misleading diagnostic on the red path, which is precisely the + // points-at-the-wrong-thing failure this card is about. + it( + 'imports every one of them — real Vite resolution, not a simulation of it', + async () => { + const failures: string[] = []; + for (const specifier of PUBLISHED_SUBPATHS) { + try { + expect(await import(/* @vite-ignore */ specifier)).toBeTypeOf('object'); + } catch (error) { + failures.push(`${specifier}: ${(error as Error).message}`); + } } - } - expect(failures, 'published subpaths this vitest config cannot resolve').toEqual([]); - }); + expect(failures, 'published subpaths this vitest config cannot resolve').toEqual([]); + }, + 60_000, + ); it('resolves a namespace the published `exports` map does NOT publish', async () => { // `packages/spec/src/conversions/` exists in source and is deliberately