diff --git a/.changeset/field-reference-target-unanswerable.md b/.changeset/field-reference-target-unanswerable.md new file mode 100644 index 0000000000..78d0372fc2 --- /dev/null +++ b/.changeset/field-reference-target-unanswerable.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +Refuse `GET /api/v1/meta/field/./references` instead of clearing it for deletion + +A `field` metadata item is addressed by the composite key `.` (e.g. `account.owner`), but every metadata property that names a field holds the **bare** field name — `view.list.columns[].field`, `dataset.dimensions[].field`, `object.validations[].field`, `object.fields{}` and 150 further non-recursive paths across nine source types. The two sides are drawn from disjoint vocabularies, so the reference scan answered `{ references: [] }` for every field, on every deployment, regardless of real usage. + +The admin "Used by" panel renders that empty answer verbatim as *"Nothing in the metadata graph points at this item. Safe to delete."* — an unanswerable question shown as a positive clearance, on the screen where someone decides to delete. + +`findReferencesToMeta` now refuses a `field` target with `501 NOT_IMPLEMENTED` in the ADR-0112 envelope, carrying the answerable alternative (`GET /api/v1/meta/object//references`). Per ADR-0110 D3, a miss and a fault are different facts. Nothing is added to the success response, and no new error code is introduced — this is the same code the route already returns when the protocol cannot compute the graph at all. + +Every other target type is unaffected: a genuine "nothing points at this item" still answers `{ references: [] }`. diff --git a/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts new file mode 100644 index 0000000000..8e4f633a6e --- /dev/null +++ b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9327] The `field` metadata type can never match as a reference TARGET, and + * is now refused instead of cleared. + * + * ## What was wrong, stated as the operator experienced it + * + * A `field` item is addressed by the COMPOSITE key `.` — + * `GET /api/v1/meta/field/account.owner/references`. Every metadata property + * that names a field holds the BARE name (`owner`): `view.list.columns[].field`, + * `dataset.dimensions[].field`, `object.validations[].field`, `object.fields{}` + * and 150 further non-recursive paths across nine source types. The two sides + * are drawn from disjoint vocabularies, so the scan answered `{ references: [] }` + * for every field, on every deployment, regardless of real usage. + * + * `objectui`'s metadata-admin renders that empty case verbatim as *"Nothing in + * the metadata graph points at this item. Safe to delete."* — a question that + * was never answerable, rendered as a positive clearance, on the screen where + * someone decides to delete (ADR-0110 D3, the #8896 harm shape). + * + * ## What these pins assert, and what they deliberately do not + * + * ⭐ The load-bearing property of this file is that **a test asserting + * `references: []` comes back would have passed against the defect itself**. + * So every pin here asserts the REFUSAL — its `code` and `status` per the + * ADR-0112 envelope — and the second `describe` seeds a field that is genuinely + * referenced from four real sites, which is the case the old behaviour cleared + * for deletion. + * + * ⛔ These do NOT pin fix shape (1) (qualifying bare names against the owning + * object) — that is a capability upgrade with its own card, and it needs object + * context this walker does not have. Nor shape (2) (matching a bare `owner` + * against the key `account.owner`), which is rejected on-card: it swaps false + * negatives for FALSE POSITIVES on delete confirmations, the worse direction on + * this screen. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { REFERENCE_SITES } from './reference-sites.js'; + +/** Same registry-backed stub the sibling derivation suite uses. */ +function protocolWith(items: Record>>) { + const engine: any = { + async find() { return []; }, + async findOne() { return null; }, + async count() { return 0; }, + registry: { + listItems: (type: string) => items[type] ?? [], + getItem: () => undefined, + getObject: () => undefined, + isPackageDisabled: () => false, + getPackage: () => undefined, + registerItem: () => {}, + registerObject: () => {}, + applyNavContributions: (app: unknown) => app, + }, + }; + return new ObjectStackProtocolImplementation(engine as never); +} + +/** + * Assert the ADR-0112 refusal envelope, not merely that something threw. + * + * ⚠️ A bare `.rejects.toThrow()` is blind in both directions here: it passes on + * any stray `Error` the walk might raise, and it says nothing about the status + * the route will serve — which is the whole wire-visible point of the fix. + */ +async function expectUnanswerableRefusal(run: () => Promise): Promise { + let caught: unknown; + try { + await run(); + } catch (err) { + caught = err; + } + expect(caught, 'expected a refusal, got a resolved answer').toBeInstanceOf(Error); + const err = caught as Error & { code?: string; status?: number }; + expect(err.code).toBe('NOT_IMPLEMENTED'); + expect(err.status).toBe(501); + return err; +} + +describe('[#9327] a `field` TARGET is refused, not cleared', () => { + it('THE PIN: the composite key that always answered `[]` now refuses with 501 NOT_IMPLEMENTED', async () => { + // Nothing is seeded on purpose: the pre-fix behaviour returned + // `{ references: [] }` here too, so a test that accepted an empty list + // would have been green against the defect. Only the refusal separates + // the two. + const protocol = protocolWith({}); + + const err = await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }), + ); + + // The message is the operator's whole diagnosis at the moment they were + // about to delete, so its first sentence is contract too. + expect(err.message).toContain('cannot be computed'); + expect(err.message).toContain('account.owner'); + }); + + it('the refusal is PRESCRIPTIVE — it names the answerable question (ADR-0110 D3)', async () => { + // A refusal that only says "no" moves the operator from a false + // clearance to a dead end. A field's dependents ARE reachable, through + // the object that owns it, and the owning object is recoverable from + // the key the caller already typed. + const protocol = protocolWith({}); + + const err = await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }), + ); + + expect(err.message).toContain('GET /api/v1/meta/object/account/references'); + }); + + it('a bare field name is refused too — the key form is the fault, not the spelling', async () => { + // `GET /meta/field/owner/references` is the same unanswerable question + // wearing a shorter key: `owner` is not an addressable field item + // either. Refusing only the DOTTED form would answer "nothing depends + // on it" for the exact spelling an operator reaches for first. + const protocol = protocolWith({}); + + const err = await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'owner' }), + ); + + expect(err.message).toContain('.'); + }); + + it('the plural spelling folds to the same refusal, not to a 200', async () => { + // #9157's canonical fold runs first, so `fields` reaches the refusal as + // `field`. Worth pinning: a fold that ran AFTER the refusal check would + // leave the plural URL answering `{ references: [] }` — the defect + // surviving behind an alias. + const protocol = protocolWith({}); + + await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'fields', name: 'account.owner' }), + ); + }); +}); + +describe('[#9327] the refusal replaces a clearance that was measurably false', () => { + it('a field with four real dependents was cleared as "safe to delete" — that answer is gone', async () => { + // Every item below genuinely names `owner`. Pre-fix, this exact fixture + // answered `{ references: [] }`, which the "Used by" panel renders as + // "Nothing in the metadata graph points at this item. Safe to delete." + const protocol = protocolWith({ + view: [{ + name: 'account_list', + label: 'Accounts', + object: 'account', + list: { columns: [{ field: 'owner' }], sort: [{ field: 'owner' }] }, + }], + dataset: [{ name: 'by_owner', dimensions: [{ field: 'owner' }] }], + object: [{ + name: 'account', + label: 'Account', + fields: { owner: { name: 'owner', type: 'lookup', reference: 'user' } }, + validations: [{ field: 'owner', message: 'required' }], + }], + }); + + await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }), + ); + }); + + it('sibling target types are untouched — the refusal is scoped to the key-form fault', async () => { + // The failure mode of a refusal is over-refusing. `object` is addressed + // by its own `name`, so its question stays answerable and its answer + // stays exact. + const protocol = protocolWith({ + object: [{ + name: 'task', + label: 'Task', + fields: { account_id: { name: 'account_id', type: 'lookup', reference: 'account' } }, + }], + }); + + const result = await protocol.findReferencesToMeta({ type: 'object', name: 'account' }); + + expect(result.references).toEqual([ + { + type: 'object', + name: 'task', + label: 'Task', + path: 'fields.account_id.reference', + kind: 'object reference', + }, + ]); + }); + + it('an ordinary target with no dependents still answers `[]` — a MISS is still a miss', async () => { + // ADR-0110 D3 cuts both ways: turning genuine "nothing points at this" + // into a fault would be this card's harm inverted, and would make the + // panel useless for the types it serves correctly. + const protocol = protocolWith({ view: [{ name: 'lead_list', object: 'lead' }] }); + + const result = await protocol.findReferencesToMeta({ type: 'object', name: 'orphan' }); + + expect(result.references).toEqual([]); + }); +}); + +describe('[#9327] the refused set is derived, and stays honest on its own', () => { + it('THE PIN: exactly one declared type is unanswerable as a target, and it is named', () => { + // ⚠️ If this set GROWS, some other type became unaddressable-by-name + // and its "Used by" panel is now refusing where it used to answer. If + // it SHRINKS to empty, the refusal silently stopped firing and every + // field is being cleared for deletion again. Both directions are the + // failure; do not "fix" a red here by widening the expectation. + expect(REFERENCE_SITES.unanswerableTargetTypes).toEqual(['field']); + }); + + it('`field` is refused as a TARGET while remaining walkable as a SOURCE', () => { + // The distinction that made this a sibling rather than a widening of + // `unwalkableSourceTypes`: that set is about a shape that could not be + // READ. `field`'s shape reads fine — it contributes sites of its own — + // so it never was and never will be a member there. + expect(REFERENCE_SITES.unwalkableSourceTypes).not.toContain('field'); + expect(REFERENCE_SITES.unanswerableTargetTypes).not.toContain('external_catalog'); + }); + + it('the sites that can never match still EXIST — refusing is not the same as having no sites', () => { + // This is why a "no sites → empty answer" shortcut would have been the + // wrong fix: the index is full of properties naming `field`. They are + // real declarations; what is impossible is matching them against the + // key this endpoint is addressed by. + const sites = REFERENCE_SITES.byTarget.get('field') ?? []; + expect(sites.length).toBeGreaterThan(0); + expect(sites.map((s) => `${s.fromType}.${s.property}`)).toContain('view.field'); + expect(sites.map((s) => `${s.fromType}.${s.property}`)).toContain('object.fields'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8b582ed815..e02f89fe6c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18208,6 +18208,17 @@ export class ObjectStackProtocolImplementation implements * `SEMANTIC_REFERENCE_SITES` carries the properties whose name does not * spell their target. Both are argued in `reference-sites.ts`. * + * [#9327] A TARGET type that could never MATCH is a third fact, and it is + * refused rather than answered. `field` items are addressed by the + * composite key `.` while every property that names a field + * holds the bare name, so the two sides are drawn from disjoint + * vocabularies and `{ references: [] }` was returned for every field, on + * every deployment, whatever was stored. + * {@link REFERENCE_SITES.unanswerableTargetTypes} carries that set and this + * method turns it into a `501 NOT_IMPLEMENTED` — the same code and envelope + * the route's sibling refusal (#9326) already uses, so no response field + * and no error code are added. + * * [#8896] A source type that could not be READ is a different fact and is * no longer answered the same way. This list is what an admin consults * before a rename / delete / type-narrowing, so a silently short answer @@ -18259,6 +18270,54 @@ export class ObjectStackProtocolImplementation implements // Canonical by construction from the fold above — NOT a second fold. const singularTarget = request.type; const targetName = request.name; + + // [#9327] REFUSE a target type whose addressing key no reference site + // can hold. This is the TARGET-side sibling of #9190's + // `unwalkableSourceTypes`, and it needed to be a sibling rather than a + // widening: that set records a source shape that could not be READ, + // while `field` reads perfectly, is walked as a source, and is named as + // a target by twelve derived sites — every one of which holds a BARE + // field name (`owner`) while this endpoint is addressed by the + // composite key (`account.owner`). Disjoint vocabularies, so the answer + // was `{ references: [] }` for every field, always, regardless of real + // usage. + // + // ⚠️ Why this refuses on the wire instead of being recorded at build + // time like its sibling. #9190 could move its discriminator OFF the + // response because the gap it records is BOUNDED — some answers get + // shorter. This gap is TOTAL: every answer for the type is empty, and + // the admin "Used by" panel renders that, verbatim, as "Nothing in the + // metadata graph points at this item. Safe to delete." A constant in a + // build does not reach the operator standing in front of that sentence, + // so recording it would leave the destructive clearance exactly where + // #8896 and ADR-0110 D3 say it must not be. + // + // ⛔ NOT the response-shape discriminator #9190 fenced to the spec seat. + // Nothing is added to the 200 body; this reuses the ADR-0112 nested + // envelope and the SAME `501 NOT_IMPLEMENTED` code the sibling refusal + // on this exact route already returns when the protocol cannot compute + // the graph at all (#9326). One route, one dialect for "the question + // was never asked". + // + // The message is prescriptive per ADR-0110 D3: it names the answerable + // question, because a field's dependents ARE reachable — through the + // object that owns it, which is where a field is authored and where the + // reference graph has real edges. + if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) { + const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : ''; + const err = new Error( + `[unanswerable_target] References to a '${singularTarget}' item cannot be computed. ` + + `A '${singularTarget}' is addressed by the composite key '.' ` + + `(here '${targetName}'), while every metadata property that names a field holds the ` + + `BARE field name — so no reference site can ever match this key and an empty answer ` + + `would mean "not computable", not "nothing depends on it". ` + + `Ask the owning object instead: GET /api/v1/meta/object/${owner}/references.`, + ); + (err as any).code = 'NOT_IMPLEMENTED'; + (err as any).status = 501; + throw err; + } + const sites = REFERENCE_SITES.byTarget.get(singularTarget); if (!sites || sites.length === 0) { return { references: [] }; diff --git a/packages/metadata-protocol/src/reference-sites.derivation.test.ts b/packages/metadata-protocol/src/reference-sites.derivation.test.ts index 45a7d81286..809b5746b3 100644 --- a/packages/metadata-protocol/src/reference-sites.derivation.test.ts +++ b/packages/metadata-protocol/src/reference-sites.derivation.test.ts @@ -139,6 +139,63 @@ describe('[#9190] derived reference sites — the gaps are named, not hidden', ( }); }); +describe('[#9327] the TARGET-side gap is named too, and it is a sibling not a widening', () => { + it('THE PIN: exactly one declared type is unanswerable AS A TARGET, and it is named', () => { + // `field` items are addressed by the composite key `.` + // while every property that names a field holds the BARE name, so the + // two sides are disjoint and no site can ever match. That is not "no + // references" — it is "not computable", and it is refused at the + // protocol seam rather than rendered as "Safe to delete." + // + // ⚠️ GROWS → some other type stopped being addressable by its own name + // and its panel now refuses where it answered. SHRINKS → the refusal + // stopped firing and every field is being cleared for deletion again. + // Do not widen this expectation to make a red go away. + expect(REFERENCE_SITES.unanswerableTargetTypes).toEqual(['field']); + }); + + it('the two honest-gap sets are DISJOINT, because they record opposite causes', () => { + // `unwalkableSourceTypes` = the shape could not be READ. + // `unanswerableTargetTypes` = the shape read fine and still cannot MATCH. + // A type in both would mean one of the two is being used as a general + // "something is wrong here" bucket, which is how a discriminator stops + // discriminating. + const overlap = REFERENCE_SITES.unanswerableTargetTypes + .filter((t) => REFERENCE_SITES.unwalkableSourceTypes.includes(t)); + expect(overlap).toEqual([]); + }); + + it('a refused TARGET is still walked as a SOURCE and still HAS sites naming it', () => { + // Both halves matter. `field` contributes its own sites (so it is not + // unwalkable), and it is named by many (so "refuse when there are no + // sites" would have been a different, wrong rule that also refused + // every genuinely-unreferenced type). + expect(REFERENCE_SITES.unwalkableSourceTypes).not.toContain('field'); + expect(sitesFor('field').length).toBeGreaterThan(0); + expect(hasSite('field', 'dataset', 'field')).toBe(true); + }); + + it('every unanswerable target is a DECLARED type — the seed cannot name a ghost', () => { + // Same non-recurrence property as the source-side pin above: the set is + // filtered against `DEFAULT_METADATA_TYPE_REGISTRY`, so a seed member + // that is retired from the registry drops out instead of leaving a + // refusal wired to a type nobody can address. + const declared = DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type); + for (const t of REFERENCE_SITES.unanswerableTargetTypes) expect(declared).toContain(t); + }); + + it('the seed is guarded by the schemas: a type whose NAME admits its own key is not refused', () => { + // The derived half. `email_template` and `capability` both declare + // dotted names — there the name IS the key, the question is answerable, + // and refusing them would be this card's harm inverted (a false fault + // in place of a false clearance). They must never appear here, and the + // guard that keeps them out is the same one that would drop `field` if + // `FieldSchema.name` were ever widened to admit a dot. + expect(REFERENCE_SITES.unanswerableTargetTypes).not.toContain('email_template'); + expect(REFERENCE_SITES.unanswerableTargetTypes).not.toContain('capability'); + }); +}); + describe('[#9190] derived reference sites — derivation is a pure function of the schemas', () => { it('two derivations of the same schemas agree exactly', () => { // The module-load singleton must not be doing anything a caller cannot diff --git a/packages/metadata-protocol/src/reference-sites.ts b/packages/metadata-protocol/src/reference-sites.ts index 7941a48c48..ca4ca04ff0 100644 --- a/packages/metadata-protocol/src/reference-sites.ts +++ b/packages/metadata-protocol/src/reference-sites.ts @@ -89,6 +89,19 @@ * `reference-sites.derivation.test.ts` pins that set, so a type that stops * being walkable is a red test rather than a silently shorter list. * + * ⚠️ [#9327] That build-time discriminator covers the SOURCE side only, and the + * distinction is load-bearing rather than pedantic. A source type that cannot + * be read shortens some answers; a TARGET type whose key form no site can hold + * makes EVERY answer for it empty, and moving that fact into the build leaves + * the operator reading "Safe to delete." exactly as before. A build-time record + * is the right home for a bounded gap and the wrong home for a total one, so + * {@link ReferenceSiteIndex.unanswerableTargetTypes} is consumed as a REFUSAL + * at the protocol seam (`findReferencesToMeta`) rather than merely recorded — + * through the ADR-0112 error envelope the same route already refuses through + * (#9326's `501 NOT_IMPLEMENTED`), so it adds no response field and no new + * error code. The success-shape discriminator remains fenced to `packages/spec` + * and is deliberately NOT what this uses. + * * ⚠️ Two residues remain open and are NOT closed here — see this module's * card. Neither can be closed inside this package. * @@ -129,6 +142,14 @@ export interface ReferenceSiteIndex { * module header on why this lives here and not on the response. */ readonly unwalkableSourceTypes: readonly string[]; + /** + * Declared types that cannot be answered FOR as a target, because the key + * the endpoint addresses them by is not a string any reference site can + * hold. The SOURCE-side sibling above records a shape that could not be + * read; this one records a shape that was read fine and still cannot + * match. See {@link COMPOSITE_KEYED_TARGET_TYPES}. + */ + readonly unanswerableTargetTypes: readonly string[]; } /** @@ -159,6 +180,111 @@ const SEMANTIC_REFERENCE_SITES: readonly ReferenceSite[] = [ { fromType: 'object', property: 'reference', target: 'object' }, ]; +/** + * The declared metadata-type universe, as a TYPE. Seeds below are keyed to it + * so a member naming a type the registry does not declare is a compile error + * rather than a row that silently never matches. + */ +type DeclaredMetadataType = (typeof DEFAULT_METADATA_TYPE_REGISTRY)[number]['type']; + +/** + * Target types whose ADDRESSABLE KEY is COMPOSITE — the string + * `GET /api/v1/meta/:type/:name/references` is addressed by is built from more + * than the item's own `name`, so it is not a value any reference site holds. + * + * ## Why this is a different fact from `unwalkableSourceTypes` + * + * The SOURCE-side record above is about a shape that could not be READ: + * `external_catalog` resolves no schema, so nothing can be said about what it + * points at. This set is the TARGET-side sibling and its cause is the + * opposite — the shape reads perfectly. `field` is walked as a source, and + * twelve derived sites across nine source types name it as a target. Every one + * of them still misses, on every deployment, for every field, because the two + * sides spell the same field differently: + * + * - the ENDPOINT addresses a field item as `.` — the composite + * key `isNestedArtifactField` splits on its first dot (`account.owner`); + * - every SITE holds the bare field name (`owner`). Measured across the + * declared schemas: `view.list.columns[].field`, `dataset.dimensions[].field`, + * `object.validations[].field`, `object.fields{}` (a `z.record` keyed by the + * bare name), and 150 further non-recursive paths — all of them documented + * as *"Field name (snake_case)"*, none of them as a metadata item key. + * + * So the answer was `{ references: [] }` for every field, always, regardless of + * real usage — and the admin "Used by" panel renders that empty case, verbatim, + * as *"Nothing in the metadata graph points at this item. Safe to delete."* + * An unanswerable question rendered as a positive clearance, on the screen + * where someone decides to delete. ADR-0110 D3: a MISS and a FAULT are + * different facts, and this one was never a miss. + * + * ## Why a seed here rather than a derivation + * + * "This type is addressed by a composite key" is a PROTOCOL fact, stated in + * prose on `isNestedArtifactField` and nowhere a machine can read — the same + * class as {@link SEMANTIC_REFERENCE_SITES}, and it gets the same treatment: a + * seed small enough to argue, never a table to grow. `field` is the only member + * and that is measured, not assumed — #7743 swept the whole registry and found + * `field` is the ONE declared type whose artifacts are not standalone registry + * items. + * + * ⛔ Do NOT add a member to make some other type "refuse cleanly". The seed is + * a statement about KEY FORM, not a severity dial; a type whose key IS its + * `name` answers honestly already, and refusing for it would trade this card's + * false clearance for a false fault one type over. + * + * What is NOT hand-written is whether the seed is still TRUE — see + * {@link nameVocabularyRejectsKey}. A member whose premise dissolves drops out + * of the derived set on the next boot, so widening `FieldSchema.name` to admit + * a dot turns the pin red instead of leaving a stale refusal in place. + */ +const COMPOSITE_KEYED_TARGET_TYPES: readonly { + readonly type: DeclaredMetadataType; + readonly separator: string; +}[] = [ + { type: 'field', separator: '.' }, +]; + +/** + * Is `type`'s composite key provably OUTSIDE the vocabulary its own `name` + * declares — i.e. can no legal `name` ever equal the key the endpoint uses? + * + * This is the derived half of {@link COMPOSITE_KEYED_TARGET_TYPES}. `FieldSchema` + * declares `name` as `^[a-z_][a-z0-9_]*$`, which cannot contain `.`, while the + * key is `.` — so a site value and an addressed key are drawn + * from disjoint sets and the match is impossible rather than merely absent. + * + * Deliberately conservative in both directions: + * + * - an UNREADABLE shape answers `true` (it cannot promise the key is + * representable, and this module never converts an unknown into a clearance); + * - an UNCONSTRAINED `name` answers `false` — `email_template` and `capability` + * both declare dotted names, where the name IS the key and the question is + * answerable. Refusing those would be this card's harm inverted. + */ +function nameVocabularyRejectsKey(type: DeclaredMetadataType, separator: string): boolean { + const schema = getMetadataTypeSchema(type); + if (!schema) return true; + let json: JsonSchemaNode; + try { + json = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode; + } catch { + return true; + } + const nameNode = (json.properties as Record | undefined)?.name; + const pattern = nameNode?.pattern; + if (typeof pattern !== 'string') return false; + let compiled: RegExp; + try { + compiled = new RegExp(pattern); + } catch { + return false; + } + // A two-segment probe is enough: if the declared vocabulary admits the + // separator at all, some composite key is representable and the type is + // not unanswerable BY KEY FORM. + return !compiled.test(`a${separator}b`); +} + /** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */ function camelCaseOf(type: string): string { return type.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()); @@ -379,7 +505,17 @@ export function deriveReferenceSites(): ReferenceSiteIndex { bucket.sort((a, b) => a.fromType.localeCompare(b.fromType) || a.property.localeCompare(b.property)); } - return { byTarget, unwalkableSourceTypes: unwalkable.sort() }; + // The TARGET-side honest record. Derived from the declared universe like + // everything else here: a seeded type that is not declared contributes + // nothing, and a seeded type whose `name` vocabulary has since widened to + // admit its own key drops out. See COMPOSITE_KEYED_TARGET_TYPES. + const declared = new Set(declaredTypes); + const unanswerableTargets = COMPOSITE_KEYED_TARGET_TYPES + .filter(({ type, separator }) => declared.has(type) && nameVocabularyRejectsKey(type, separator)) + .map(({ type }) => type) + .sort(); + + return { byTarget, unwalkableSourceTypes: unwalkable.sort(), unanswerableTargetTypes: unanswerableTargets }; } /**