From 14550cb58bbc1a0994345b329d59608acb01114e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 14:00:53 +0000 Subject: [PATCH] fix(service-datasource): generate an object draft that `os build` accepts `generateObjectDraft` rendered a `*.object.ts` the platform's own validator refuses, on two independent counts: the object name carried no `${namespace}_` prefix (`defineStack()` refuses it, ADR-0028) and no `sharingModel` was emitted (`security-owd-unset` refuses it, ADR-0090 D1). The namespace is derived from the datasource's own owning package and applied through `validateObjectNamespacePrefix`, the single source of that rule. The OWD follows the shape #9666 settled for generated scaffolds: an explicit `'private'`, rendered with the reason attached. An unresolvable namespace keeps the bare name plus a loud TODO rather than inventing a prefix. Fixes #10712 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../external-object-draft-passes-os-build.md | 32 +++ .../external-object-draft-os-build.test.ts | 258 ++++++++++++++++++ .../src/external-datasource-service.ts | 124 ++++++++- .../services/service-datasource/src/plugin.ts | 45 +++ 4 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 .changeset/external-object-draft-passes-os-build.md create mode 100644 packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts diff --git a/.changeset/external-object-draft-passes-os-build.md b/.changeset/external-object-draft-passes-os-build.md new file mode 100644 index 0000000000..84dbebd253 --- /dev/null +++ b/.changeset/external-object-draft-passes-os-build.md @@ -0,0 +1,32 @@ +--- +"@objectstack/service-datasource": patch +--- + +`os datasource introspect` now generates an object draft that `os build` +accepts (#10712). The review-before-commit flow was handing the user a +`*.object.ts` the platform's own validator refuses, on two independent counts: + +- **The object name carried no `${namespace}_` prefix**, so `defineStack()` + refused it outright (ADR-0028) — measured as + `Object 'customers' is missing the package namespace prefix.` The prefix is + now derived from the datasource's OWN owning package (`_packageId` → + that package's `manifest.namespace`), and applied through + `validateObjectNamespacePrefix` — the same function `defineStack()` and the + runtime publish gate call, so an already-prefixed remote table + (`wh_accounts` under namespace `wh`) is not double-prefixed. +- **No `sharingModel` was emitted**, so the author-time rule set refused it + (`security-owd-unset`, ADR-0090 D1) — the same rule family #9666 hit for the + `os init` template. The draft now declares `sharingModel: 'private'` + explicitly, following the shape #9666 settled on for generated scaffolds: + the rule's own recommended default, rendered with the reason attached. + +When no namespace can be resolved (a datasource with no package provenance, or +a package that declares none) the draft keeps the bare remote-table name and +the rendered source carries a loud `TODO(namespace)`. It does not invent a +prefix — mirroring `defineStack`, which skips the check entirely rather than +inventing one, and avoiding an `_customers` that would trade one invalid draft +for another. + +Note the `opts.primaryKey` path still does not build: it emits +`fields..primaryKey`, which is not an authorable spec field key. That is +#11000, a separate open contract question, untouched here. diff --git a/packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts b/packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts new file mode 100644 index 0000000000..0cf031820e --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The generated object draft has to survive the platform's OWN validator. + * + * `generateObjectDraft` renders a `*.object.ts` a human is meant to review and + * commit. Two things in that output made it un-committable: + * + * 1. the object name carried no `${namespace}_` prefix, so `defineStack()` + * refused it outright (ADR-0028) — measured on the pre-fix tree as + * `Object 'customers' is missing the package namespace prefix.`; + * 2. no `sharingModel` was emitted, so the author-time rule set `os build` + * runs refused it (`security-owd-unset` at `objects[0].sharingModel`, + * ADR-0090 D1) — the same rule family #9666 hit for the `os init` template. + * + * ## Why the two are pinned SEPARATELY + * + * A single "the draft builds now" assertion cannot say which of the two it is + * measuring, and cannot fail informatively when one of them regresses on its + * own. Each defect therefore gets its own case, asserting its own signal. + * + * ## Why `still-generates` is here at all + * + * Both defects above are satisfiable by emitting LESS. An implementation that + * returned a minimal valid stub — right name, right OWD, no fields — would go + * green on a validator-only suite while destroying the only thing this + * generator exists to do. The `still-generates` block is the counterweight: + * the introspected columns, the remote table name and the `external` binding + * are asserted to survive the fix. + * + * ## The instrument + * + * The prefix assertion calls `validateObjectNamespacePrefix` — the same + * function `defineStack()` and the runtime publish gate call — rather than + * re-spelling `startsWith`. A hand-rolled check here could pass while the real + * gate refuses, which is precisely the drift that produced defect (1). + * The OWD assertion runs a full `ObjectSchema.safeParse`, because what is + * being guarded is a VALUE's verdict, not merely a key's presence. + */ + +import { describe, it, expect } from 'vitest'; +import type { IntrospectedSchema } from '@objectstack/spec/contracts'; +import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; +import { ObjectSchema } from '@objectstack/spec/data'; +import { + ExternalDatasourceService, + type DatasourceLike, + type ExternalDatasourceServiceConfig, +} from '../external-datasource-service.js'; + +/** + * A remote schema with an ALREADY-prefixed table alongside a bare one, so the + * double-prefix case (`wh_wh_accounts`) is reachable from the same fixture. + * + * Hand-written rather than driven off a live `SqlDriver` — deliberately, and + * for a different reason than the blindness `external-introspection-seam.test.ts` + * exists to close. Neither defect pinned here reads a column's PK-ness at all: + * the object NAME comes from the table name and the OWD is a constant, so a + * real introspection would add cost and no coverage. + * + * Every column is spelled `primaryKey: false` on purpose. That keeps the whole + * file on the `opts.primaryKey`-unset path, where the generator emits no + * `fields..primaryKey` — the key that is NOT authorable (#11000, an open + * contract question in `packages/spec`, deliberately untouched here). Pinning + * these two repairs on a draft that also carries #11000's key would produce + * cases that cannot go green until a card this lane does not own is decided. + */ +function remoteSchema(): IntrospectedSchema { + return { + dialect: 'postgres', + introspectedAt: '2026-08-22T00:00:00.000Z', + tables: { + 'mart.customers': { + name: 'mart.customers', + indexes: [], + columns: [ + { name: 'id', type: 'text', nullable: false, primaryKey: false }, + { name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false }, + { name: 'signed_up_at', type: 'timestamptz', nullable: true, primaryKey: false }, + ], + }, + 'mart.wh_accounts': { + name: 'mart.wh_accounts', + indexes: [], + columns: [{ name: 'id', type: 'text', nullable: false, primaryKey: false }], + }, + }, + }; +} + +/** + * The service wired exactly as `plugin.ts` wires it, with the namespace + * resolution the plugin injects made explicit per-test. + * + * `namespace: undefined` is NOT the same as omitting `getNamespace`: the first + * is "a resolver ran and found nothing", the second is "no resolver at all". + * Both must land on the bare name, and both are exercised below. + */ +function serviceWith( + namespace?: string | undefined, + opts: { wireResolver?: boolean } = {}, +): ExternalDatasourceService { + const config: ExternalDatasourceServiceConfig = { + introspect: async () => remoteSchema(), + getDatasource: async (name): Promise => ({ name, schemaMode: 'external' }), + getObject: async () => undefined, + listObjects: async () => [], + ...(opts.wireResolver === false ? {} : { getNamespace: () => namespace }), + }; + return new ExternalDatasourceService(config); +} + +/** The canonical OWD values, read off the live schema rather than restated. */ +const CANONICAL_OWD: readonly string[] = ( + ObjectSchema.shape.sharingModel as unknown as { def: { innerType: { options: string[] } } } +).def.innerType.options; + +describe('defect 1 — the generated object name carries the package namespace prefix', () => { + it('prefixes the derived name, and the SINGLE-SOURCE rule accepts it', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + + expect(draft.name).toBe('wh_customers'); + // The instrument that matters: the very function `defineStack()` calls. + expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull(); + // …and the rendered file agrees with the structured definition. + expect(draft.definition.name).toBe('wh_customers'); + expect(draft.source).toContain("name: 'wh_customers'"); + expect(draft.source).toContain('const wh_customers: ServiceObject = {'); + }); + + it('does NOT double-prefix a remote table that already carries the namespace', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'wh_accounts'); + + expect(draft.name).toBe('wh_accounts'); + expect(draft.name).not.toContain('wh_wh_'); + expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull(); + }); + + it('keeps the LABEL derived from the short name — the prefix is addressing, not display', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + expect(draft.definition.label).toBe('Customers'); + }); +}); + +describe('defect 2 — the generated draft declares an explicit sharingModel', () => { + it('emits the OWD the #9666 precedent settled on, and the spec accepts the VALUE', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + + expect(draft.definition.sharingModel).toBe('private'); + // Not just "some string": a value the canonical enum still declares. If + // ADR-0090's four ever change, this reddens instead of drifting. + expect(CANONICAL_OWD).toContain(draft.definition.sharingModel); + + // A value verdict needs a full parse, not an absence-of-unknown-keys check. + const parsed = ObjectSchema.safeParse(draft.definition); + expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true); + }); + + it('renders the OWD into the committed source, with the reason attached', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + + expect(draft.source).toContain("sharingModel: 'private'"); + expect(draft.source).toContain('security-owd-unset'); + }); + + it('emits the OWD even when no namespace resolves — the two defects are independent', async () => { + const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers'); + expect(draft.definition.sharingModel).toBe('private'); + expect(draft.source).toContain("sharingModel: 'private'"); + }); +}); + +describe('still-generates — the fix must not be satisfied by emitting a valid stub', () => { + it('keeps every introspected column, its mapped type, and the review notes', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + const fields = draft.definition.fields as Record; + + expect(Object.keys(fields)).toEqual(['id', 'name', 'signed_up_at']); + expect(fields.id.type).toBe('text'); + expect(fields.name.type).toBe('text'); + expect(fields.signed_up_at.type).toBe('datetime'); + + expect(draft.source).toContain("id: { type: 'text' }"); + expect(draft.source).toContain("signed_up_at: { type: 'datetime' }"); + }); + + it('keeps the external binding pointed at the REMOTE table, not the renamed object', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + const external = draft.definition.external as { remoteName?: string; remoteSchema?: string }; + + // The object was renamed to `wh_customers`; the remote table was not. + expect(external.remoteName).toBe('customers'); + expect(external.remoteSchema).toBe('mart'); + expect(draft.definition.datasource).toBe('warehouse'); + expect(draft.source).toContain("remoteSchema: 'mart', remoteName: 'customers'"); + }); +}); + +describe('an absent or blank namespace must not trade one invalid draft for another', () => { + /** + * `_customers` is the failure mode this block exists to forbid: a name that + * satisfies "has a prefix" while being neither valid nor meaningful. The + * bare name is the defensible outcome — `defineStack()` skips the prefix + * check entirely for a stack with no `manifest.namespace`, and deliberately + * does not invent one on the author's behalf, so the draft stays committable + * exactly where an unprefixed name is legal. + */ + it.each([ + ['no resolver wired at all', undefined, false], + ['a resolver that finds nothing', undefined, true], + ['an empty string', '', true], + ['whitespace only', ' ', true], + ])('%s → the bare name, never a leading underscore', async (_label, ns, wired) => { + const draft = await serviceWith(ns as string | undefined, { + wireResolver: wired as boolean, + }).generateObjectDraft('warehouse', 'customers'); + + expect(draft.name).toBe('customers'); + expect(draft.name.startsWith('_')).toBe(false); + expect(draft.source).not.toContain('_customers:'); + expect(ObjectSchema.safeParse(draft.definition).success).toBe(true); + }); + + it('says so loudly in the rendered file rather than failing silently', async () => { + const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers'); + + expect(draft.source).toContain('TODO(namespace)'); + expect(draft.source).toContain('ADR-0028'); + }); + + it('carries NO namespace TODO once a namespace did resolve', async () => { + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); + expect(draft.source).not.toContain('TODO(namespace)'); + }); +}); + +describe('importObject inherits both repairs from the draft pipeline', () => { + it('persists the prefixed name and the explicit OWD', async () => { + const persisted: Array<{ name: string; def: Record }> = []; + const svc = new ExternalDatasourceService({ + introspect: async () => remoteSchema(), + getDatasource: async (name): Promise => ({ name, schemaMode: 'external' }), + getObject: async () => undefined, + listObjects: async () => [], + getNamespace: () => 'wh', + persistObject: async (name, def) => { + persisted.push({ name, def }); + }, + }); + + const result = await svc.importObject('warehouse', 'customers'); + + expect(result.name).toBe('wh_customers'); + expect(persisted[0]?.name).toBe('wh_customers'); + expect(persisted[0]?.def.sharingModel).toBe('private'); + expect(ObjectSchema.safeParse(persisted[0]?.def).success).toBe(true); + }); +}); diff --git a/packages/services/service-datasource/src/external-datasource-service.ts b/packages/services/service-datasource/src/external-datasource-service.ts index 5707f16a98..60cc5dfde7 100644 --- a/packages/services/service-datasource/src/external-datasource-service.ts +++ b/packages/services/service-datasource/src/external-datasource-service.ts @@ -33,6 +33,12 @@ import { type SqlDialect, type FieldType, } from '@objectstack/spec/data'; +// The SINGLE source of the ADR-0028 object-name prefix rule, shared verbatim +// with `defineStack()` (compile time) and `MetadataManager.publishPackage` +// (runtime). Imported rather than re-implemented: a generator that hand-rolled +// its own `startsWith` would be free to drift from the check that refuses its +// output, which is the shape of this whole defect. +import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; /** Minimal datasource shape the service reads (subset of `Datasource`). */ export interface DatasourceLike { @@ -88,6 +94,23 @@ export interface ExternalDatasourceServiceConfig { * throws (the deployment is GitOps-only / has no writable metadata store). */ persistObject?: (name: string, definition: Record) => Promise; + /** + * Resolve the package namespace that generated object names must be + * prefixed with — `${namespace}_${shortName}`, ADR-0028. Injected for the + * same reason every other read here is: the service stays kernel-free, and + * the plugin decides where a namespace comes from (it reads the + * datasource's own owning package — see `plugin.ts`). + * + * Optional, and allowed to resolve nothing. A deployment whose datasource + * carries no package provenance, or whose package declares no `namespace`, + * gets a draft with the BARE remote-table name plus a loud TODO in the + * rendered source. That is deliberate and mirrors `defineStack`, which + * skips the prefix check entirely when `manifest.namespace` is absent + * rather than inventing a prefix on the author's behalf. Emitting + * `_customers` on a blank namespace would trade one invalid draft for + * another, so a blank/whitespace value is treated as absent. + */ + getNamespace?: (datasource: string) => Promise | string | undefined; logger?: Logger; } @@ -167,6 +190,53 @@ function toObjectName(remoteName: string): string { .toLowerCase(); } +/** + * The org-wide default every generated federated object declares. + * + * NOT a judgement call made here — it is the shape #9666 settled for the + * CLI's own `os init` scaffolds (`packages/cli/src/commands/init.ts`), which + * hit this same rule family: declare the value explicitly, and pick the one + * `security-owd-unset`'s own hint calls the recommended default. It is also + * what ADR-0090 D1 already resolves an unset OWD to at runtime, so a draft + * carrying it describes the posture the platform would apply anyway — the + * change is that the baseline becomes an AUTHORED decision instead of an + * accident, which is the entire point of the rule. + * + * It is the most restrictive of the four canonical values, so a generated + * federated object can never be published wider than its author intended by + * this default alone; widening it is a one-line edit the review-before-commit + * flow exists to invite. + */ +const GENERATED_SHARING_MODEL = 'private'; + +/** + * Normalise an injected namespace. Blank / whitespace-only reads as ABSENT: + * `validateObjectNamespacePrefix` skips a falsy namespace, but `' '` is + * truthy and would render ` _customers` — one invalid draft traded for + * another, which is the failure this card is closing. + */ +function normaliseNamespace(raw: string | undefined): string | undefined { + const ns = raw?.trim(); + return ns ? ns : undefined; +} + +/** + * Apply the ADR-0028 package prefix to a derived object name. + * + * The decision is delegated to `validateObjectNamespacePrefix` — the same + * function `defineStack` and the runtime publish gate call — so "is this name + * already compliant?" has exactly one answer in the tree. `null` means the + * name is already acceptable (no namespace to apply, a `sys_*` reserved name, + * or a remote table that is ALREADY prefixed, e.g. `crm_accounts` under + * namespace `crm` — which must not become `crm_crm_accounts`). + */ +function applyNamespacePrefix(objectName: string, namespace: string | undefined): string { + if (!namespace) return objectName; + return validateObjectNamespacePrefix(objectName, namespace) === null + ? objectName + : `${namespace}_${objectName}`; +} + /** snake_case → Title Case label. */ function toLabel(name: string): string { return name @@ -289,23 +359,35 @@ export class ExternalDatasourceService implements IExternalDatasourceService { fields[fieldName] = isPk ? { type: fieldType, primaryKey: true } : { type: fieldType }; } - const name = toObjectName(resolvedRemoteName); + // ADR-0028: every object a package defines must be named + // `${namespace}_${shortName}`. `defineStack()` refuses an unprefixed name + // outright, so a draft without the prefix cannot be committed into a + // namespaced stack — the generator has to resolve the namespace itself. + const namespace = normaliseNamespace(await this.config.getNamespace?.(datasource)); + const shortName = toObjectName(resolvedRemoteName); + const name = applyNamespacePrefix(shortName, namespace); const definition: Record = { name, - label: toLabel(name), + // Derived from the SHORT name on purpose: the prefix is an addressing + // requirement, not a display one, and keeping the label at 'Customers' + // rather than 'Wh Customers' leaves this draft's human-facing text + // exactly where it was before the prefix landed. + label: toLabel(shortName), datasource, external: { ...(remoteSchema ? { remoteSchema } : {}), remoteName: resolvedRemoteName, }, fields, + // ADR-0090 D1 / `security-owd-unset` — see GENERATED_SHARING_MODEL. + sharingModel: GENERATED_SHARING_MODEL, }; return { name, datasource, definition, - source: renderObjectSource(definition, fields, review), + source: renderObjectSource(definition, fields, review, namespace), review, }; } @@ -565,11 +647,22 @@ export class ExternalDatasourceService implements IExternalDatasourceService { } } -/** Render a reviewable `*.object.ts` source string for an object draft. */ +/** + * Render a reviewable `*.object.ts` source string for an object draft. + * + * The output is annotated `ServiceObject`, which makes `tsc` over this string + * a complete acceptance instrument for the draft's shape — use it that way. + * + * `namespace` is passed in rather than re-derived from `definition.name`, + * because the two absent cases are NOT the same file: a name that is already + * prefixed and a name that could not be prefixed both read as "starts with + * something" from here, and only the second one needs the TODO. + */ function renderObjectSource( definition: Record, fields: Record, review: ObjectDraft['review'], + namespace?: string, ): string { const reviewByColumn = new Map(review.map((r) => [r.column, r.note])); const external = definition.external as { remoteSchema?: string; remoteName?: string }; @@ -585,8 +678,25 @@ function renderObjectSource( ? ` external: { remoteSchema: '${external.remoteSchema}', remoteName: '${external.remoteName}' },` : ` external: { remoteName: '${external.remoteName}' },`; + // No namespace resolved → the name is bare. That is legal in a stack whose + // manifest declares no `namespace` (`defineStack` skips the check), so the + // draft is emitted rather than refused — but it is NOT legal in a namespaced + // stack, and this generator cannot tell which one the author will paste it + // into. Say so loudly in the file instead of guessing: the same + // "emit it with a TODO the validator accepts" shape #9666 settled on. + const namespaceTodo = namespace + ? [] + : [ + `// TODO(namespace): no package namespace could be resolved for this`, + `// datasource, so this object name is UNPREFIXED. If the stack you commit`, + `// this into declares 'manifest.namespace', rename it to`, + `// '_${definition.name as string}' — 'defineStack()' refuses an`, + `// unprefixed object name (ADR-0028).`, + ]; + return [ `// Generated by \`os datasource introspect\` (ADR-0015). Review before committing.`, + ...namespaceTodo, `import type { ServiceObject } from '@objectstack/spec/data';`, ``, `const ${definition.name as string}: ServiceObject = {`, @@ -597,6 +707,12 @@ function renderObjectSource( ` fields: {`, ...fieldLines, ` },`, + ` // Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1`, + ` // requires this to be an authored decision rather than an accident — the`, + ` // \`security-owd-unset\` author-time rule refuses an object without it, so a`, + ` // draft that omitted it could not compile. '${GENERATED_SHARING_MODEL}' is the rule's own`, + ` // recommended default: owner + explicit shares. Widen it deliberately.`, + ` sharingModel: '${definition.sharingModel as string}',`, `};`, ``, `export default ${definition.name as string};`, diff --git a/packages/services/service-datasource/src/plugin.ts b/packages/services/service-datasource/src/plugin.ts index 69d24797a7..83ca163a57 100644 --- a/packages/services/service-datasource/src/plugin.ts +++ b/packages/services/service-datasource/src/plugin.ts @@ -99,6 +99,51 @@ export class ExternalDatasourceServicePlugin implements Plugin { }, } : {}), + /** + * Where a generated object's `${namespace}_` prefix comes from (ADR-0028). + * + * The datasource's OWN owning package — not an ambient "current package", + * which does not exist at this seam. A federated object is bound to one + * datasource (`definition.datasource`), so the package that declared that + * datasource is the package the object belongs in, and its + * `manifest.namespace` is the prefix `defineStack()` will demand. + * + * Both links are read, not assumed: + * - `_packageId` is stamped onto every registered metadata item that has + * package coords (`applyProtection`, `@objectstack/spec/shared`), by + * both load paths — the artifact loader and `registry.registerItem`. + * `'sys_metadata'` is the rehydration sentinel, not a real package, so + * it is excluded exactly as the registry's own `isCodeArtifactBody` + * excludes it. + * - the package record is what `installPackage` stored under + * `manifest.id`, i.e. the same `{ manifest }` shape the runtime publish + * gate reads for this identical check. + * + * Every step is allowed to come up empty (a DB-only datasource, a + * GitOps deployment with no package registry, a legacy package that + * declares no namespace). Empty resolves to `undefined`, and the service + * then emits a bare name with a loud TODO rather than inventing a prefix. + */ + getNamespace: async (datasource: string) => { + try { + const ds = (await metadata?.get('datasource', datasource)) as + | { _packageId?: unknown } + | undefined; + const pkgId = typeof ds?._packageId === 'string' ? ds._packageId : undefined; + if (!pkgId || pkgId === 'sys_metadata') return undefined; + const pkg = (await metadata?.get('package', pkgId)) as + | { manifest?: { namespace?: unknown } } + | undefined; + const ns = pkg?.manifest?.namespace; + return typeof ns === 'string' ? ns : undefined; + } catch { + // Namespace resolution is best-effort provenance, never a reason to + // fail a draft: an unresolvable namespace has a defined, documented + // outcome (bare name + TODO), so a throwing metadata store must land + // there too rather than taking the whole introspection down. + return undefined; + } + }, logger: this.options.logger, };