@@ -132,10 +132,27 @@ export interface ReferenceSite {
132132 readonly target : string ;
133133}
134134
135+ /**
136+ * A site whose record KEYS name the target only in their HEAD: each key is the
137+ * target's name, then `separator`, then a part this index does not read. See
138+ * {@link COMPOSITE_KEY_REFERENCE_SITES}.
139+ */
140+ export interface CompositeKeyReferenceSite extends ReferenceSite {
141+ /** Ends the target's name inside a key; the name is the text before its FIRST occurrence. */
142+ readonly separator : string ;
143+ }
144+
135145/** The derived index, plus the honest record of what could not be derived. */
136146export interface ReferenceSiteIndex {
137147 /** Target metadata type → every site that may point at it. */
138148 readonly byTarget : ReadonlyMap < string , readonly ReferenceSite [ ] > ;
149+ /**
150+ * Target metadata type → every site that names it as the HEAD of a
151+ * composite record key. Deliberately a SEPARATE map rather than more rows
152+ * in {@link byTarget}: read {@link COMPOSITE_KEY_REFERENCE_SITES} on why
153+ * those rows there would corrupt `duplicatePackage`.
154+ */
155+ readonly compositeKeyByTarget : ReadonlyMap < string , readonly CompositeKeyReferenceSite [ ] > ;
139156 /**
140157 * Declared types whose shape could NOT be read, so their references are
141158 * genuinely not computable rather than absent. Pinned by a test — see the
@@ -262,27 +279,127 @@ const COMPOSITE_KEYED_TARGET_TYPES: readonly {
262279 * answerable. Refusing those would be this card's harm inverted.
263280 */
264281function nameVocabularyRejectsKey ( type : DeclaredMetadataType , separator : string ) : boolean {
282+ const verdict = nameVocabularyVerdict ( type , separator ) ;
283+ return verdict === 'unreadable' || verdict === 'rejects' ;
284+ }
285+
286+ /** A declared type's JSON Schema, or `undefined` when it resolves no schema or cannot be converted. */
287+ function metadataTypeJsonSchema ( type : string ) : JsonSchemaNode | undefined {
265288 const schema = getMetadataTypeSchema ( type ) ;
266- if ( ! schema ) return true ;
267- let json : JsonSchemaNode ;
289+ if ( ! schema ) return undefined ;
268290 try {
269- json = z . toJSONSchema ( schema , { unrepresentable : 'any' , io : 'input' } ) as JsonSchemaNode ;
291+ return z . toJSONSchema ( schema , { unrepresentable : 'any' , io : 'input' } ) as JsonSchemaNode ;
270292 } catch {
271- return true ;
293+ return undefined ;
272294 }
295+ }
296+
297+ /**
298+ * What `type`'s declared `name` vocabulary says about `separator`:
299+ * `'rejects'` (a readable pattern no name containing it can match), `'admits'`
300+ * (a readable pattern some such name matches), `'unconstrained'` (no pattern,
301+ * or one that does not compile) or `'unreadable'` (no schema to read). The two
302+ * callers read the unknowns in OPPOSITE directions, each toward its own safe
303+ * side, so the four answers stay distinct here.
304+ */
305+ function nameVocabularyVerdict (
306+ type : string ,
307+ separator : string ,
308+ ) : 'rejects' | 'admits' | 'unconstrained' | 'unreadable' {
309+ const json = metadataTypeJsonSchema ( type ) ;
310+ if ( ! json ) return 'unreadable' ;
273311 const nameNode = ( json . properties as Record < string , JsonSchemaNode > | undefined ) ?. name ;
274312 const pattern = nameNode ?. pattern ;
275- if ( typeof pattern !== 'string' ) return false ;
313+ if ( typeof pattern !== 'string' ) return 'unconstrained' ;
276314 let compiled : RegExp ;
277315 try {
278316 compiled = new RegExp ( pattern ) ;
279317 } catch {
280- return false ;
318+ return 'unconstrained' ;
281319 }
282320 // A two-segment probe is enough: if the declared vocabulary admits the
283321 // separator at all, some composite key is representable and the type is
284322 // not unanswerable BY KEY FORM.
285- return ! compiled . test ( `a${ separator } b` ) ;
323+ return compiled . test ( `a${ separator } b` ) ? 'admits' : 'rejects' ;
324+ }
325+
326+ /**
327+ * Sites whose record KEYS are COMPOSITE and name the target in their HEAD —
328+ * today exactly one: `PermissionSetSchema.fields`, keyed `<object>.<field>`.
329+ *
330+ * ## The miss this closes
331+ *
332+ * The naming rule reads `permission.fields` as a site for `field` items (its
333+ * property spells that type's plural) and never for an object. Every key of it
334+ * names an object too: the security plugin reads a set's rules for an object
335+ * by the key prefix `<object>.` (`PermissionEvaluator.getFieldPermissions`). So
336+ * a set whose ONLY rule for an object was a field rule was missing from that
337+ * object's answer, and the "Used by" panel could clear the object as "Safe to
338+ * delete." while the set still masked its fields — the harm this module's
339+ * header names.
340+ *
341+ * ## Why a seed, and why NOT more rows in `byTarget`
342+ *
343+ * The key form is stated in prose only — `fields` is `z.record(z.string(), …)`
344+ * with the `<entity_name>.<field_name>` shape in its TSDoc, and its JSON Schema
345+ * is indistinguishable from `object.fields`, which is keyed by the BARE field
346+ * name. That is the {@link COMPOSITE_KEYED_TARGET_TYPES} class (a protocol fact
347+ * no machine can read), and it gets the same treatment: a seed small enough to
348+ * argue, with its premises DERIVED (see {@link compositeKeySiteHolds}).
349+ *
350+ * It is kept out of {@link ReferenceSiteIndex.byTarget} on purpose, measured
351+ * both ways with a plain `{ permission, fields, object }` row there:
352+ *
353+ * - the answer did not change: the walk compares a WHOLE value with the
354+ * target's name, and `refsrc_customer.full_name` is never `refsrc_customer`;
355+ * - `duplicatePackage` broke: it reads `byTarget.get('object')` as a flat set of
356+ * property NAMES that name an object anywhere in any item, so `fields` joined
357+ * it and every copied object's own `fields` record — keyed by bare field
358+ * name — had a key renamed after a renamed object. That copy rewrites a
359+ * permission set's `fields` keys through its own shape-keyed step instead.
360+ *
361+ * ⛔ Do NOT grow this to "improve coverage", and do not move it into
362+ * `byTarget`. The durable close is an annotation on the producer's record key
363+ * in `packages/spec`, which derivation would then read like any other schema
364+ * fact.
365+ *
366+ * The target is typed `'object'` alone: the walk splits a key on its FIRST
367+ * separator, which is exact only because no object name can contain one — a
368+ * premise argued for the object type, not for every type.
369+ */
370+ const COMPOSITE_KEY_REFERENCE_SITES : readonly {
371+ readonly fromType : DeclaredMetadataType ;
372+ readonly property : string ;
373+ readonly target : 'object' ;
374+ readonly separator : string ;
375+ } [ ] = [
376+ { fromType : 'permission' , property : 'fields' , target : 'object' , separator : '.' } ,
377+ ] ;
378+
379+ /**
380+ * Do a composite-key seed's two premises still hold in the declared schemas?
381+ *
382+ * 1. the source type still declares `property` at its ROOT as a record — the
383+ * keys are what is read, so a renamed or reshaped property must not keep a
384+ * site that reads nothing, or reads something else;
385+ * 2. the target's `name` vocabulary provably REJECTS the separator, so the
386+ * text before a key's first separator is the whole target name and never a
387+ * prefix of one (`ObjectSchema.name` is `^[a-z_][a-z0-9_]*$`).
388+ *
389+ * Both fail CLOSED, the opposite direction from {@link nameVocabularyRejectsKey}:
390+ * an unreadable or unconstrained shape drops the site, because a split that
391+ * cannot be proven exact would attribute a key to the wrong object. A seed that
392+ * drops out turns the derivation pin red rather than shortening answers quietly.
393+ */
394+ function compositeKeySiteHolds ( site : ( typeof COMPOSITE_KEY_REFERENCE_SITES ) [ number ] ) : boolean {
395+ if ( nameVocabularyVerdict ( site . target , site . separator ) !== 'rejects' ) return false ;
396+ const json = metadataTypeJsonSchema ( site . fromType ) ;
397+ if ( ! json ) return false ;
398+ const defs = ( json . $defs as Record < string , JsonSchemaNode > | undefined ) ?? { } ;
399+ const node = ( json . properties as Record < string , JsonSchemaNode > | undefined ) ?. [ site . property ] ;
400+ return alternatives ( node , defs , new Set ( ) , 0 ) . some (
401+ ( alt ) => alt . type === 'object' && ! ! alt . additionalProperties && typeof alt . additionalProperties === 'object' ,
402+ ) ;
286403}
287404
288405/** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */
@@ -515,7 +632,22 @@ export function deriveReferenceSites(): ReferenceSiteIndex {
515632 . map ( ( { type } ) => type )
516633 . sort ( ) ;
517634
518- return { byTarget, unwalkableSourceTypes : unwalkable . sort ( ) , unanswerableTargetTypes : unanswerableTargets } ;
635+ // Composite-key sites, filtered the same way: both ends declared, and both
636+ // premises still true of the schemas. See COMPOSITE_KEY_REFERENCE_SITES.
637+ const compositeKeyByTarget = new Map < string , CompositeKeyReferenceSite [ ] > ( ) ;
638+ for ( const site of COMPOSITE_KEY_REFERENCE_SITES ) {
639+ if ( ! declared . has ( site . fromType ) || ! declared . has ( site . target ) || ! compositeKeySiteHolds ( site ) ) continue ;
640+ const bucket = compositeKeyByTarget . get ( site . target ) ;
641+ if ( bucket ) bucket . push ( { ...site } ) ;
642+ else compositeKeyByTarget . set ( site . target , [ { ...site } ] ) ;
643+ }
644+
645+ return {
646+ byTarget,
647+ compositeKeyByTarget,
648+ unwalkableSourceTypes : unwalkable . sort ( ) ,
649+ unanswerableTargetTypes : unanswerableTargets ,
650+ } ;
519651}
520652
521653/**
0 commit comments