From 46aa46c03f8a39fef8c5471c06e5a86c98b5a787 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 16:15:15 -0400 Subject: [PATCH 1/6] Add component composition metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- .changeset/component-composition-metadata.md | 5 + .../react/script/components-json/README.md | 33 ++ .../react/script/components-json/build.ts | 14 +- .../components-json/composition.schema.json | 127 ++++++ .../script/components-json/composition.ts | 404 ++++++++++++++++++ .../script/components-json/output.schema.json | 3 + .../react/src/ActionMenu/ActionMenu.docs.json | 3 + .../ActionMenu.features.stories.tsx | 28 ++ .../__tests__/component-composition.test.ts | 114 +++++ packages/react/vitest.config.mts | 6 +- 10 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 .changeset/component-composition-metadata.md create mode 100644 packages/react/script/components-json/README.md create mode 100644 packages/react/script/components-json/composition.schema.json create mode 100644 packages/react/script/components-json/composition.ts create mode 100644 packages/react/src/__tests__/component-composition.test.ts diff --git a/.changeset/component-composition-metadata.md b/.changeset/component-composition-metadata.md new file mode 100644 index 00000000000..17fc2eded6c --- /dev/null +++ b/.changeset/component-composition-metadata.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Generated component metadata: Add source-derived composition relationship evidence. diff --git a/packages/react/script/components-json/README.md b/packages/react/script/components-json/README.md new file mode 100644 index 00000000000..5429575c5e5 --- /dev/null +++ b/packages/react/script/components-json/README.md @@ -0,0 +1,33 @@ +# Component metadata generation + +`npm run build:components.json -w @primer/react` produces the published +`@primer/react/generated/components.json` artifact from the canonical +`*.docs.json` component metadata. + +## Composition metadata + +The artifact's `composition` field is generated from repository sources; it is +not authored as a separate documentation surface. + +- `apiParentChild` identifies component references in documented `children` prop + types. Its `required` field reports whether that prop itself is required. +- `observed.parentChild` and `observed.adjacentSibling` report JSX nesting and + adjacent siblings found in colocated default, feature, and example stories, + plus `*.test.tsx` files. +- Every observed relation includes its occurrence count, number of distinct + source units, confidence tier, and sorted source provenance. A relation is + emitted only when it appears in at least two source units. Two sources are + `medium` evidence; three or more are `high`. + +The generator uses docs metadata as the canonical component registry, then +resolves JSX identifiers, aliased named imports, and namespace imports against +that registry. It reads only this package's `src/` tree, so provenance remains +package-relative. It intentionally records source evidence rather than +inferring semantic validity: frequency does not make a composition required, +accessible, or suitable for every use case. Dynamic element selection and +runtime children are not analyzed. + +Downstream consumers can use `apiParentChild` for documented type-level +composition contracts and `observed` for source-backed examples. Both are +structured, versioned metadata available from the same public artifact as +component docs, so consumers do not need a separate documentation format. diff --git a/packages/react/script/components-json/build.ts b/packages/react/script/components-json/build.ts index b9046ded7a1..2036c06a96a 100644 --- a/packages/react/script/components-json/build.ts +++ b/packages/react/script/components-json/build.ts @@ -16,7 +16,9 @@ import chalk from 'chalk' import type {LintError} from 'markdownlint' import {lint as mdLint} from 'markdownlint/sync' import componentSchema from './component.schema.json' +import compositionSchema from './composition.schema.json' import outputSchema from './output.schema.json' +import {buildComposition, getCompositionSources, type DocumentedComponent} from './composition' const args = parseArgs({ options: { @@ -44,6 +46,8 @@ const ajv = new Ajv({ allowUnionTypes: true, }) +ajv.compile(compositionSchema) + function formatMDError(lintError: LintError): string { let range = '' @@ -208,7 +212,15 @@ const components = docsFiles.map(docsFilepath => { } }) -const data = {schemaVersion: 2, components: keyBy(components, 'id')} +const composition = buildComposition( + components.map((component, index) => ({ + ...component, + sourcePath: docsFiles[index], + })) as Array, + getCompositionSources(docsFiles), +) + +const data = {schemaVersion: 2, components: keyBy(components, 'id'), composition} // Validate output const validate = ajv.compile(outputSchema) diff --git a/packages/react/script/components-json/composition.schema.json b/packages/react/script/components-json/composition.schema.json new file mode 100644 index 00000000000..df658c07256 --- /dev/null +++ b/packages/react/script/components-json/composition.schema.json @@ -0,0 +1,127 @@ +{ + "$id": "composition.schema.json", + "type": "object", + "required": ["schemaVersion", "derivation", "sourceSummary", "apiParentChild", "observed"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "number", + "enum": [1] + }, + "derivation": { + "type": "object", + "required": ["sourceKinds", "minimumSourceCount", "highConfidenceSourceCount"], + "additionalProperties": false, + "properties": { + "sourceKinds": { + "type": "array", + "items": {"type": "string", "enum": ["story", "example", "test"]} + }, + "minimumSourceCount": {"type": "number"}, + "highConfidenceSourceCount": {"type": "number"} + } + }, + "sourceSummary": { + "type": "object", + "required": ["componentMetadataFiles", "sourceUnits", "sourceUnitsByKind"], + "additionalProperties": false, + "properties": { + "componentMetadataFiles": {"type": "number"}, + "sourceUnits": {"type": "number"}, + "sourceUnitsByKind": { + "type": "object", + "required": ["story", "example", "test"], + "additionalProperties": false, + "properties": { + "story": {"type": "number"}, + "example": {"type": "number"}, + "test": {"type": "number"} + } + } + } + }, + "apiParentChild": { + "type": "array", + "items": {"$ref": "#/definitions/apiParentChild"} + }, + "observed": { + "type": "object", + "required": ["parentChild", "adjacentSibling"], + "additionalProperties": false, + "properties": { + "parentChild": { + "type": "array", + "items": {"$ref": "#/definitions/observedParentChild"} + }, + "adjacentSibling": { + "type": "array", + "items": {"$ref": "#/definitions/observedSibling"} + } + } + } + }, + "definitions": { + "source": { + "type": "object", + "required": ["kind", "path"], + "additionalProperties": false, + "properties": { + "kind": {"type": "string", "enum": ["story", "example", "test"]}, + "path": {"type": "string"} + } + }, + "apiParentChild": { + "type": "object", + "required": ["parent", "child", "required", "source"], + "additionalProperties": false, + "properties": { + "parent": {"type": "string"}, + "child": {"type": "string"}, + "required": {"type": "boolean"}, + "source": { + "type": "object", + "required": ["path", "prop", "type"], + "additionalProperties": false, + "properties": { + "path": {"type": "string"}, + "prop": {"type": "string"}, + "type": {"type": "string"} + } + } + } + }, + "observedParentChild": { + "type": "object", + "required": ["parent", "child", "occurrences", "sourceCount", "confidence", "sources"], + "additionalProperties": false, + "properties": { + "parent": {"type": "string"}, + "child": {"type": "string"}, + "occurrences": {"type": "number"}, + "sourceCount": {"type": "number"}, + "confidence": {"type": "string", "enum": ["medium", "high"]}, + "sources": { + "type": "array", + "items": {"$ref": "#/definitions/source"} + } + } + }, + "observedSibling": { + "type": "object", + "required": ["parent", "previous", "next", "occurrences", "sourceCount", "confidence", "sources"], + "additionalProperties": false, + "properties": { + "parent": {"type": "string"}, + "previous": {"type": "string"}, + "next": {"type": "string"}, + "occurrences": {"type": "number"}, + "sourceCount": {"type": "number"}, + "confidence": {"type": "string", "enum": ["medium", "high"]}, + "sources": { + "type": "array", + "items": {"$ref": "#/definitions/source"} + } + } + } + } +} diff --git a/packages/react/script/components-json/composition.ts b/packages/react/script/components-json/composition.ts new file mode 100644 index 00000000000..f673eb08cee --- /dev/null +++ b/packages/react/script/components-json/composition.ts @@ -0,0 +1,404 @@ +import {parse} from '@babel/parser' +import {traverse} from '@babel/core' +import type {JSXElement, JSXOpeningElement, Node} from '@babel/types' +import fs from 'node:fs' +import glob from 'fast-glob' + +const minimumSourceCount = 2 +const highConfidenceSourceCount = 3 + +type SourceKind = 'story' | 'example' | 'test' + +interface ComponentProp { + name: string + type: string + required?: boolean +} + +interface DocumentedSubcomponent { + name: string + props: Array +} + +export interface DocumentedComponent { + name: string + props: Array + sourcePath: string + subcomponents?: Array +} + +export interface CompositionSource { + kind: SourceKind + path: string +} + +interface RelationshipSource { + kind: SourceKind + path: string +} + +interface ObservedParentChildRelationship { + parent: string + child: string + occurrences: number + sourceCount: number + confidence: 'medium' | 'high' + sources: Array +} + +interface ObservedSiblingRelationship { + parent: string + previous: string + next: string + occurrences: number + sourceCount: number + confidence: 'medium' | 'high' + sources: Array +} + +interface ApiParentChildRelationship { + parent: string + child: string + required: boolean + source: { + path: string + prop: string + type: string + } +} + +export interface CompositionMetadata { + schemaVersion: 1 + derivation: { + sourceKinds: Array + minimumSourceCount: number + highConfidenceSourceCount: number + } + sourceSummary: { + componentMetadataFiles: number + sourceUnits: number + sourceUnitsByKind: Record + } + apiParentChild: Array + observed: { + parentChild: Array + adjacentSibling: Array + } +} + +interface RelationshipAccumulator { + occurrences: number + sources: Map +} + +export function getCompositionSources(docsFiles: Array): Array { + const storySources = docsFiles.flatMap(docsFile => { + const basePath = docsFile.replace(/\.docs\.json$/, '') + + return [ + {kind: 'story' as const, path: `${basePath}.stories.tsx`}, + {kind: 'story' as const, path: `${basePath}.features.stories.tsx`}, + {kind: 'example' as const, path: `${basePath}.examples.stories.tsx`}, + ] + }) + + const testSources = glob.sync('src/**/*.test.tsx').map(path => ({kind: 'test' as const, path})) + + const sources: Array = [...storySources, ...testSources] + + return sources + .filter(source => fs.existsSync(source.path)) + .sort(compareSources) + .filter((source, index, sources) => index === 0 || source.path !== sources[index - 1].path) +} + +export function buildComposition( + components: Array, + sources: Array, + readFile: (path: string) => string = path => fs.readFileSync(path, 'utf8'), +): CompositionMetadata { + const componentNames = getComponentNames(components) + const uniqueSources = [...sources].sort(compareSources).filter((source, index, sortedSources) => { + return ( + index === 0 || + `${source.kind}\0${source.path}` !== `${sortedSources[index - 1].kind}\0${sortedSources[index - 1].path}` + ) + }) + const parentChild = new Map() + const adjacentSibling = new Map() + + for (const source of uniqueSources) { + const sourceCode = readFile(source.path) + const relationships = extractObservedRelationships(sourceCode, componentNames) + + for (const relationship of relationships.parentChild) { + addRelationship(parentChild, `${relationship.parent}\0${relationship.child}`, source) + } + + for (const relationship of relationships.adjacentSibling) { + addRelationship(adjacentSibling, `${relationship.parent}\0${relationship.previous}\0${relationship.next}`, source) + } + } + + return { + schemaVersion: 1, + derivation: { + sourceKinds: [...new Set(uniqueSources.map(source => source.kind))].sort(), + minimumSourceCount, + highConfidenceSourceCount, + }, + sourceSummary: { + componentMetadataFiles: components.length, + sourceUnits: uniqueSources.length, + sourceUnitsByKind: { + story: uniqueSources.filter(source => source.kind === 'story').length, + example: uniqueSources.filter(source => source.kind === 'example').length, + test: uniqueSources.filter(source => source.kind === 'test').length, + }, + }, + apiParentChild: getApiParentChildRelationships(components, componentNames), + observed: { + parentChild: toParentChildRelationships(parentChild), + adjacentSibling: toSiblingRelationships(adjacentSibling), + }, + } +} + +function getComponentNames(components: Array): Set { + return new Set( + components.flatMap(component => [ + component.name, + ...(component.subcomponents?.map(subcomponent => subcomponent.name) ?? []), + ]), + ) +} + +function getApiParentChildRelationships( + components: Array, + componentNames: Set, +): Array { + const relationships = components.flatMap(component => { + const documentedComponents = [{name: component.name, props: component.props}, ...(component.subcomponents ?? [])] + + return documentedComponents.flatMap(documentedComponent => { + const childrenProp = documentedComponent.props.find(prop => prop.name === 'children') + if (!childrenProp) return [] + + return getComponentReferences(childrenProp.type, componentNames) + .filter(child => child !== documentedComponent.name) + .map(child => ({ + parent: documentedComponent.name, + child, + required: childrenProp.required === true, + source: { + path: component.sourcePath, + prop: childrenProp.name, + type: childrenProp.type, + }, + })) + }) + }) + + return relationships.sort((a, b) => { + return ( + a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child) || a.source.path.localeCompare(b.source.path) + ) + }) +} + +function getComponentReferences(type: string, componentNames: Set): Array { + return [...componentNames] + .sort((a, b) => b.length - a.length || a.localeCompare(b)) + .filter(name => { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`(^|[^A-Za-z0-9_.])${escapedName}(?=$|[^A-Za-z0-9_.])`).test(type) + }) +} + +function extractObservedRelationships(sourceCode: string, componentNames: Set) { + const ast = parse(sourceCode, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }) + const aliases = getImportAliases(ast, componentNames) + const parentChild: Array<{parent: string; child: string}> = [] + const adjacentSibling: Array<{parent: string; previous: string; next: string}> = [] + + traverse(ast, { + JSXElement(path) { + if (path.findParent(parentPath => parentPath.isJSXElement())) return + + walkElement(path.node, undefined, aliases, componentNames, parentChild, adjacentSibling) + }, + }) + + return {parentChild, adjacentSibling} +} + +function getImportAliases(ast: Node, componentNames: Set): Map { + const aliases = new Map() + + traverse(ast, { + ImportDeclaration(path) { + for (const specifier of path.node.specifiers) { + if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier') { + if (componentNames.has(specifier.imported.name)) { + aliases.set(specifier.local.name, specifier.imported.name) + } + } else if (specifier.type === 'ImportDefaultSpecifier' && componentNames.has(specifier.local.name)) { + aliases.set(specifier.local.name, specifier.local.name) + } else if (specifier.type === 'ImportNamespaceSpecifier') { + aliases.set(specifier.local.name, undefined) + } + } + }, + }) + + return aliases +} + +function walkElement( + element: JSXElement, + parent: string | undefined, + aliases: Map, + componentNames: Set, + parentChild: Array<{parent: string; child: string}>, + adjacentSibling: Array<{parent: string; previous: string; next: string}>, +) { + const component = resolveComponentName(element.openingElement.name, aliases, componentNames) + const nearestComponent = component ?? parent + + if (parent && component) { + parentChild.push({parent, child: component}) + } + + const directChildren = getDirectChildElements(element.children) + if (component) { + const directChildComponents = directChildren + .map(child => resolveComponentName(child.openingElement.name, aliases, componentNames)) + .filter((child): child is string => child !== undefined) + + for (let index = 1; index < directChildComponents.length; index += 1) { + adjacentSibling.push({ + parent: component, + previous: directChildComponents[index - 1], + next: directChildComponents[index], + }) + } + } + + for (const child of directChildren) { + walkElement(child, nearestComponent, aliases, componentNames, parentChild, adjacentSibling) + } +} + +function getDirectChildElements(children: Array): Array { + return children.flatMap(child => { + if (child.type === 'JSXElement') return [child] + if (child.type === 'JSXFragment') return getDirectChildElements(child.children) + if (child.type === 'JSXExpressionContainer') return getJSXElements(child.expression) + return [] + }) +} + +function getJSXElements(node: Node | null | undefined): Array { + if (!node || typeof node !== 'object') return [] + if (node.type === 'JSXElement') return [node] + if (node.type === 'JSXFragment') return getDirectChildElements(node.children) + + return Object.values(node).flatMap(value => { + if (Array.isArray(value)) { + return value.flatMap(item => getJSXElements(item as Node)) + } + + return getJSXElements(value as Node) + }) +} + +function resolveComponentName( + name: JSXOpeningElement['name'], + aliases: Map, + componentNames: Set, +): string | undefined { + const parts = getJSXNameParts(name) + if (!parts) return undefined + + const alias = aliases.get(parts[0]) + const [root, suffix] = + aliases.has(parts[0]) && alias === undefined ? [parts[1], parts.slice(2)] : [alias ?? parts[0], parts.slice(1)] + + if (!root) return undefined + + const componentName = [root, ...suffix].join('.') + return componentNames.has(componentName) ? componentName : undefined +} + +function getJSXNameParts(name: JSXOpeningElement['name']): Array | undefined { + if (name.type === 'JSXIdentifier') return [name.name] + if (name.type === 'JSXNamespacedName') return undefined + + const objectParts = getJSXNameParts(name.object) + return objectParts ? [...objectParts, name.property.name] : undefined +} + +function addRelationship(relationships: Map, key: string, source: RelationshipSource) { + const relationship = relationships.get(key) ?? {occurrences: 0, sources: new Map()} + relationship.occurrences += 1 + relationship.sources.set(`${source.kind}\0${source.path}`, {kind: source.kind, path: source.path}) + relationships.set(key, relationship) +} + +function toParentChildRelationships( + relationships: Map, +): Array { + return [...relationships] + .flatMap(([key, relationship]) => { + const [parent, child] = key.split('\0') + return relationship.sources.size >= minimumSourceCount + ? [ + { + parent, + child, + occurrences: relationship.occurrences, + sourceCount: relationship.sources.size, + confidence: getConfidence(relationship.sources.size), + sources: [...relationship.sources.values()].sort(compareSources), + }, + ] + : [] + }) + .sort((a, b) => a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child)) +} + +function toSiblingRelationships( + relationships: Map, +): Array { + return [...relationships] + .flatMap(([key, relationship]) => { + const [parent, previous, next] = key.split('\0') + return relationship.sources.size >= minimumSourceCount + ? [ + { + parent, + previous, + next, + occurrences: relationship.occurrences, + sourceCount: relationship.sources.size, + confidence: getConfidence(relationship.sources.size), + sources: [...relationship.sources.values()].sort(compareSources), + }, + ] + : [] + }) + .sort((a, b) => { + return a.parent.localeCompare(b.parent) || a.previous.localeCompare(b.previous) || a.next.localeCompare(b.next) + }) +} + +function getConfidence(sourceCount: number): 'medium' | 'high' { + return sourceCount >= highConfidenceSourceCount ? 'high' : 'medium' +} + +function compareSources(a: CompositionSource, b: CompositionSource): number { + return a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind) +} diff --git a/packages/react/script/components-json/output.schema.json b/packages/react/script/components-json/output.schema.json index 88c0118b487..036130eb989 100644 --- a/packages/react/script/components-json/output.schema.json +++ b/packages/react/script/components-json/output.schema.json @@ -16,6 +16,9 @@ "$ref": "./component.schema.json#" } } + }, + "composition": { + "$ref": "./composition.schema.json#" } } } diff --git a/packages/react/src/ActionMenu/ActionMenu.docs.json b/packages/react/src/ActionMenu/ActionMenu.docs.json index 23a8ed07e52..d35f7d8eba1 100644 --- a/packages/react/src/ActionMenu/ActionMenu.docs.json +++ b/packages/react/src/ActionMenu/ActionMenu.docs.json @@ -13,6 +13,9 @@ { "id": "components-actionmenu-features--single-select" }, + { + "id": "components-actionmenu-features--grouped-single-select" + }, { "id": "components-actionmenu-features--multi-select" }, diff --git a/packages/react/src/ActionMenu/ActionMenu.features.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.features.stories.tsx index 34395654169..3469df1acc7 100644 --- a/packages/react/src/ActionMenu/ActionMenu.features.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.features.stories.tsx @@ -103,6 +103,34 @@ export const SingleSelect = () => { ) } +export const GroupedSingleSelect = () => { + const options = ['Status', 'Severity', 'Repository'] + const [selectedOption, setSelectedOption] = React.useState(options[0]) + + return ( + + Group by: {selectedOption} + + + + Group by + {options.map(option => ( + setSelectedOption(option)} + > + {option} + + ))} + + + + + ) +} + export const MultiSelect = () => { type Option = {name: string; selected: boolean} diff --git a/packages/react/src/__tests__/component-composition.test.ts b/packages/react/src/__tests__/component-composition.test.ts new file mode 100644 index 00000000000..0731455a794 --- /dev/null +++ b/packages/react/src/__tests__/component-composition.test.ts @@ -0,0 +1,114 @@ +import {describe, expect, it} from 'vitest' +import { + buildComposition, + type CompositionSource, + type DocumentedComponent, +} from '../../script/components-json/composition' + +const components: Array = [ + { + name: 'ActionMenu', + props: [{name: 'children', type: 'React.ReactElement[]', required: true}], + sourcePath: 'src/ActionMenu/ActionMenu.docs.json', + subcomponents: [ + {name: 'ActionMenu.Button', props: []}, + {name: 'ActionMenu.Overlay', props: []}, + ], + }, + { + name: 'ActionList', + props: [{name: 'children', type: 'ActionList.Item[] | ActionList.Divider[]', required: true}], + sourcePath: 'src/ActionList/ActionList.docs.json', + subcomponents: [ + {name: 'ActionList.Item', props: []}, + {name: 'ActionList.Divider', props: []}, + ], + }, +] + +const sourceCode = html` + + + + + + + + + + +` + +describe('component composition metadata', () => { + it('derives documented child-type contracts separately from observed JSX relationships', () => { + const composition = buildComposition(components, sources(), source => sourceCodeByPath[source]) + + expect(composition.apiParentChild).toEqual([ + { + parent: 'ActionList', + child: 'ActionList.Divider', + required: true, + source: { + path: 'src/ActionList/ActionList.docs.json', + prop: 'children', + type: 'ActionList.Item[] | ActionList.Divider[]', + }, + }, + { + parent: 'ActionList', + child: 'ActionList.Item', + required: true, + source: { + path: 'src/ActionList/ActionList.docs.json', + prop: 'children', + type: 'ActionList.Item[] | ActionList.Divider[]', + }, + }, + ]) + expect(composition.observed.parentChild).toContainEqual( + expect.objectContaining({ + parent: 'ActionMenu', + child: 'ActionMenu.Overlay', + occurrences: 3, + sourceCount: 2, + confidence: 'medium', + }), + ) + }) + + it('counts occurrences while deduplicating source provenance and emits stable ordering', () => { + const composition = buildComposition(components, sources(), source => sourceCodeByPath[source]) + const secondComposition = buildComposition(components, [...sources()].reverse(), source => sourceCodeByPath[source]) + + expect(composition).toEqual(secondComposition) + expect(composition.observed.adjacentSibling).toContainEqual({ + parent: 'ActionList', + previous: 'ActionList.Item', + next: 'ActionList.Divider', + occurrences: 3, + sourceCount: 2, + confidence: 'medium', + sources: [ + {kind: 'story', path: 'src/ActionMenu/ActionMenu.stories.tsx'}, + {kind: 'test', path: 'src/ActionMenu/ActionMenu.test.tsx'}, + ], + }) + }) +}) + +function sources(): Array { + return [ + {kind: 'test', path: 'src/ActionMenu/ActionMenu.test.tsx'}, + {kind: 'story', path: 'src/ActionMenu/ActionMenu.stories.tsx'}, + {kind: 'story', path: 'src/ActionMenu/ActionMenu.stories.tsx'}, + ] +} + +const sourceCodeByPath: Record = { + 'src/ActionMenu/ActionMenu.stories.tsx': sourceCode, + 'src/ActionMenu/ActionMenu.test.tsx': `<>${sourceCode}${sourceCode}`, +} + +function html(strings: TemplateStringsArray): string { + return strings.join('') +} diff --git a/packages/react/vitest.config.mts b/packages/react/vitest.config.mts index 64b5287c16f..95e7ab84669 100644 --- a/packages/react/vitest.config.mts +++ b/packages/react/vitest.config.mts @@ -24,7 +24,11 @@ export default defineConfig({ }, test: { name: '@primer/react (node)', - include: ['src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx'], + include: [ + 'src/__tests__/exports.test.ts', + 'src/__tests__/storybook.test.tsx', + 'src/__tests__/component-composition.test.ts', + ], environment: 'node', detectAsyncLeaks: true, }, From 822b8d4fe1214e5f2777e721d34fa87b6483aadb Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 16:43:38 -0400 Subject: [PATCH 2/6] Expand composition metadata evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- .../react/script/components-json/README.md | 33 ++- .../components-json/composition.schema.json | 83 +++++- .../script/components-json/composition.ts | 249 ++++++++++++++++-- .../script/components-json/output.schema.json | 2 +- .../__tests__/component-composition.test.ts | 167 +++++++++++- 5 files changed, 497 insertions(+), 37 deletions(-) diff --git a/packages/react/script/components-json/README.md b/packages/react/script/components-json/README.md index 5429575c5e5..7960704bb0f 100644 --- a/packages/react/script/components-json/README.md +++ b/packages/react/script/components-json/README.md @@ -10,10 +10,16 @@ The artifact's `composition` field is generated from repository sources; it is not authored as a separate documentation surface. - `apiParentChild` identifies component references in documented `children` prop - types. Its `required` field reports whether that prop itself is required. + types. Its `childrenPropRequired` field reports whether the parent prop itself + is required; it does not make an individual referenced child required. +- `apiSubcomponents` records documented compound component APIs without implying + that a subcomponent must be nested under its parent. - `observed.parentChild` and `observed.adjacentSibling` report JSX nesting and adjacent siblings found in colocated default, feature, and example stories, - plus `*.test.tsx` files. + `*.test.tsx` files, and component implementation files. +- `observed.variants` reports static `variant` and `*Variant` JSX prop values. + `observed.relatedComponents` is an undirected structural index derived from + observed parent/child and adjacent-sibling relationships. - Every observed relation includes its occurrence count, number of distinct source units, confidence tier, and sorted source provenance. A relation is emitted only when it appears in at least two source units. Two sources are @@ -21,13 +27,20 @@ not authored as a separate documentation surface. The generator uses docs metadata as the canonical component registry, then resolves JSX identifiers, aliased named imports, and namespace imports against -that registry. It reads only this package's `src/` tree, so provenance remains -package-relative. It intentionally records source evidence rather than -inferring semantic validity: frequency does not make a composition required, -accessible, or suitable for every use case. Dynamic element selection and -runtime children are not analyzed. +that registry. It gathers source units from each documented component's +directory, sorts and deduplicates them before extraction, and reads only this +package's `src/` tree, so provenance remains package-relative. It intentionally +records source evidence rather than inferring semantic validity: frequency does +not make a composition required, accessible, or suitable for every use case. +Dynamic element selection and runtime children are not analyzed. -Downstream consumers can use `apiParentChild` for documented type-level -composition contracts and `observed` for source-backed examples. Both are -structured, versioned metadata available from the same public artifact as +Not every component has qualifying observed evidence. A missing observation +means that the static sources did not meet the configured threshold, not that a +relationship is invalid. When a component name is available from multiple +entrypoints, consumers should combine source provenance with the corresponding +component record's `importPath` when selecting an implementation. + +Downstream consumers can use `apiParentChild` and `apiSubcomponents` for +documented API structure, then use `observed` as source-backed evidence. Both +are structured, versioned metadata available from the same public artifact as component docs, so consumers do not need a separate documentation format. diff --git a/packages/react/script/components-json/composition.schema.json b/packages/react/script/components-json/composition.schema.json index df658c07256..5a105d48807 100644 --- a/packages/react/script/components-json/composition.schema.json +++ b/packages/react/script/components-json/composition.schema.json @@ -1,7 +1,7 @@ { "$id": "composition.schema.json", "type": "object", - "required": ["schemaVersion", "derivation", "sourceSummary", "apiParentChild", "observed"], + "required": ["schemaVersion", "derivation", "sourceSummary", "apiParentChild", "apiSubcomponents", "observed"], "additionalProperties": false, "properties": { "schemaVersion": { @@ -15,7 +15,7 @@ "properties": { "sourceKinds": { "type": "array", - "items": {"type": "string", "enum": ["story", "example", "test"]} + "items": {"type": "string", "enum": ["story", "example", "test", "implementation"]} }, "minimumSourceCount": {"type": "number"}, "highConfidenceSourceCount": {"type": "number"} @@ -30,12 +30,13 @@ "sourceUnits": {"type": "number"}, "sourceUnitsByKind": { "type": "object", - "required": ["story", "example", "test"], + "required": ["story", "example", "test", "implementation"], "additionalProperties": false, "properties": { "story": {"type": "number"}, "example": {"type": "number"}, - "test": {"type": "number"} + "test": {"type": "number"}, + "implementation": {"type": "number"} } } } @@ -44,9 +45,13 @@ "type": "array", "items": {"$ref": "#/definitions/apiParentChild"} }, + "apiSubcomponents": { + "type": "array", + "items": {"$ref": "#/definitions/apiSubcomponent"} + }, "observed": { "type": "object", - "required": ["parentChild", "adjacentSibling"], + "required": ["parentChild", "adjacentSibling", "variants", "relatedComponents"], "additionalProperties": false, "properties": { "parentChild": { @@ -56,6 +61,14 @@ "adjacentSibling": { "type": "array", "items": {"$ref": "#/definitions/observedSibling"} + }, + "variants": { + "type": "array", + "items": {"$ref": "#/definitions/observedVariant"} + }, + "relatedComponents": { + "type": "array", + "items": {"$ref": "#/definitions/observedRelatedComponents"} } } } @@ -66,18 +79,18 @@ "required": ["kind", "path"], "additionalProperties": false, "properties": { - "kind": {"type": "string", "enum": ["story", "example", "test"]}, + "kind": {"type": "string", "enum": ["story", "example", "test", "implementation"]}, "path": {"type": "string"} } }, "apiParentChild": { "type": "object", - "required": ["parent", "child", "required", "source"], + "required": ["parent", "child", "childrenPropRequired", "source"], "additionalProperties": false, "properties": { "parent": {"type": "string"}, "child": {"type": "string"}, - "required": {"type": "boolean"}, + "childrenPropRequired": {"type": "boolean"}, "source": { "type": "object", "required": ["path", "prop", "type"], @@ -90,6 +103,23 @@ } } }, + "apiSubcomponent": { + "type": "object", + "required": ["parent", "subcomponent", "source"], + "additionalProperties": false, + "properties": { + "parent": {"type": "string"}, + "subcomponent": {"type": "string"}, + "source": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": {"type": "string"} + } + } + } + }, "observedParentChild": { "type": "object", "required": ["parent", "child", "occurrences", "sourceCount", "confidence", "sources"], @@ -122,6 +152,43 @@ "items": {"$ref": "#/definitions/source"} } } + }, + "observedVariant": { + "type": "object", + "required": ["component", "prop", "value", "occurrences", "sourceCount", "confidence", "sources"], + "additionalProperties": false, + "properties": { + "component": {"type": "string"}, + "prop": {"type": "string"}, + "value": {"type": "string"}, + "occurrences": {"type": "number"}, + "sourceCount": {"type": "number"}, + "confidence": {"type": "string", "enum": ["medium", "high"]}, + "sources": { + "type": "array", + "items": {"$ref": "#/definitions/source"} + } + } + }, + "observedRelatedComponents": { + "type": "object", + "required": ["first", "second", "relationshipKinds", "occurrences", "sourceCount", "confidence", "sources"], + "additionalProperties": false, + "properties": { + "first": {"type": "string"}, + "second": {"type": "string"}, + "relationshipKinds": { + "type": "array", + "items": {"type": "string", "enum": ["parentChild", "adjacentSibling"]} + }, + "occurrences": {"type": "number"}, + "sourceCount": {"type": "number"}, + "confidence": {"type": "string", "enum": ["medium", "high"]}, + "sources": { + "type": "array", + "items": {"$ref": "#/definitions/source"} + } + } } } } diff --git a/packages/react/script/components-json/composition.ts b/packages/react/script/components-json/composition.ts index f673eb08cee..8eb94ad1c1d 100644 --- a/packages/react/script/components-json/composition.ts +++ b/packages/react/script/components-json/composition.ts @@ -1,13 +1,13 @@ import {parse} from '@babel/parser' import {traverse} from '@babel/core' -import type {JSXElement, JSXOpeningElement, Node} from '@babel/types' +import type {JSXAttribute, JSXElement, JSXOpeningElement, Node} from '@babel/types' import fs from 'node:fs' import glob from 'fast-glob' const minimumSourceCount = 2 const highConfidenceSourceCount = 3 -type SourceKind = 'story' | 'example' | 'test' +type SourceKind = 'story' | 'example' | 'test' | 'implementation' interface ComponentProp { name: string @@ -56,10 +56,30 @@ interface ObservedSiblingRelationship { sources: Array } +interface ObservedVariantRelationship { + component: string + prop: string + value: string + occurrences: number + sourceCount: number + confidence: 'medium' | 'high' + sources: Array +} + +interface ObservedRelatedComponentsRelationship { + first: string + second: string + relationshipKinds: Array<'parentChild' | 'adjacentSibling'> + occurrences: number + sourceCount: number + confidence: 'medium' | 'high' + sources: Array +} + interface ApiParentChildRelationship { parent: string child: string - required: boolean + childrenPropRequired: boolean source: { path: string prop: string @@ -67,6 +87,14 @@ interface ApiParentChildRelationship { } } +interface ApiSubcomponentRelationship { + parent: string + subcomponent: string + source: { + path: string + } +} + export interface CompositionMetadata { schemaVersion: 1 derivation: { @@ -80,9 +108,12 @@ export interface CompositionMetadata { sourceUnitsByKind: Record } apiParentChild: Array + apiSubcomponents: Array observed: { parentChild: Array adjacentSibling: Array + variants: Array + relatedComponents: Array } } @@ -91,7 +122,12 @@ interface RelationshipAccumulator { sources: Map } +interface RelatedComponentsAccumulator extends RelationshipAccumulator { + relationshipKinds: Set<'parentChild' | 'adjacentSibling'> +} + export function getCompositionSources(docsFiles: Array): Array { + const componentDirectories = [...new Set(docsFiles.map(getComponentDirectory))] const storySources = docsFiles.flatMap(docsFile => { const basePath = docsFile.replace(/\.docs\.json$/, '') @@ -101,15 +137,25 @@ export function getCompositionSources(docsFiles: Array): Array `${directory}/**/*.test.tsx`)) + .map(path => ({kind: 'test' as const, path})) + const implementationSources = glob + .sync(componentDirectories.map(directory => `${directory}/**/*.tsx`)) + .filter(isImplementationSource) + .map(path => ({kind: 'implementation' as const, path})) - const testSources = glob.sync('src/**/*.test.tsx').map(path => ({kind: 'test' as const, path})) - - const sources: Array = [...storySources, ...testSources] + const sources: Array = [...storySources, ...testSources, ...implementationSources] return sources .filter(source => fs.existsSync(source.path)) .sort(compareSources) - .filter((source, index, sources) => index === 0 || source.path !== sources[index - 1].path) + .filter((source, index, sortedSources) => { + return ( + index === 0 || + `${source.kind}\0${source.path}` !== `${sortedSources[index - 1].kind}\0${sortedSources[index - 1].path}` + ) + }) } export function buildComposition( @@ -126,6 +172,8 @@ export function buildComposition( }) const parentChild = new Map() const adjacentSibling = new Map() + const variants = new Map() + const relatedComponents = new Map() for (const source of uniqueSources) { const sourceCode = readFile(source.path) @@ -133,10 +181,16 @@ export function buildComposition( for (const relationship of relationships.parentChild) { addRelationship(parentChild, `${relationship.parent}\0${relationship.child}`, source) + addRelatedComponents(relatedComponents, relationship.parent, relationship.child, 'parentChild', source) } for (const relationship of relationships.adjacentSibling) { addRelationship(adjacentSibling, `${relationship.parent}\0${relationship.previous}\0${relationship.next}`, source) + addRelatedComponents(relatedComponents, relationship.previous, relationship.next, 'adjacentSibling', source) + } + + for (const variant of relationships.variants) { + addRelationship(variants, `${variant.component}\0${variant.prop}\0${variant.value}`, source) } } @@ -154,16 +208,33 @@ export function buildComposition( story: uniqueSources.filter(source => source.kind === 'story').length, example: uniqueSources.filter(source => source.kind === 'example').length, test: uniqueSources.filter(source => source.kind === 'test').length, + implementation: uniqueSources.filter(source => source.kind === 'implementation').length, }, }, apiParentChild: getApiParentChildRelationships(components, componentNames), + apiSubcomponents: getApiSubcomponentRelationships(components), observed: { parentChild: toParentChildRelationships(parentChild), adjacentSibling: toSiblingRelationships(adjacentSibling), + variants: toVariantRelationships(variants), + relatedComponents: toRelatedComponentsRelationships(relatedComponents), }, } } +function getComponentDirectory(docsFile: string): string { + return docsFile.slice(0, docsFile.lastIndexOf('/')) +} + +function isImplementationSource(path: string): boolean { + return ( + !path.includes('.stories.') && + !path.includes('.test.') && + !path.includes('.figma.') && + !path.includes('.dev.stories.') + ) +} + function getComponentNames(components: Array): Set { return new Set( components.flatMap(component => [ @@ -189,7 +260,7 @@ function getApiParentChildRelationships( .map(child => ({ parent: documentedComponent.name, child, - required: childrenProp.required === true, + childrenPropRequired: childrenProp.required === true, source: { path: component.sourcePath, prop: childrenProp.name, @@ -199,11 +270,48 @@ function getApiParentChildRelationships( }) }) - return relationships.sort((a, b) => { - return ( - a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child) || a.source.path.localeCompare(b.source.path) + return relationships + .sort((a, b) => { + return ( + a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child) || a.source.path.localeCompare(b.source.path) + ) + }) + .filter((relationship, index, sortedRelationships) => { + const previous = sortedRelationships[index - 1] + return ( + index === 0 || + relationship.parent !== previous.parent || + relationship.child !== previous.child || + relationship.source.path !== previous.source.path + ) + }) +} + +function getApiSubcomponentRelationships(components: Array): Array { + return components + .flatMap(component => + (component.subcomponents ?? []).map(subcomponent => ({ + parent: component.name, + subcomponent: subcomponent.name, + source: {path: component.sourcePath}, + })), ) - }) + .sort((a, b) => { + return ( + a.parent.localeCompare(b.parent) || + a.subcomponent.localeCompare(b.subcomponent) || + a.source.path.localeCompare(b.source.path) + ) + }) + .filter((relationship, index, sortedRelationships) => { + const previous = sortedRelationships[index - 1] + return ( + index === 0 || + relationship.parent !== previous.parent || + relationship.subcomponent !== previous.subcomponent || + relationship.source.path !== previous.source.path + ) + }) } function getComponentReferences(type: string, componentNames: Set): Array { @@ -223,16 +331,17 @@ function extractObservedRelationships(sourceCode: string, componentNames: Set = [] const adjacentSibling: Array<{parent: string; previous: string; next: string}> = [] + const variants: Array<{component: string; prop: string; value: string}> = [] traverse(ast, { JSXElement(path) { if (path.findParent(parentPath => parentPath.isJSXElement())) return - walkElement(path.node, undefined, aliases, componentNames, parentChild, adjacentSibling) + walkElement(path.node, undefined, aliases, componentNames, parentChild, adjacentSibling, variants) }, }) - return {parentChild, adjacentSibling} + return {parentChild, adjacentSibling, variants} } function getImportAliases(ast: Node, componentNames: Set): Map { @@ -264,6 +373,7 @@ function walkElement( componentNames: Set, parentChild: Array<{parent: string; child: string}>, adjacentSibling: Array<{parent: string; previous: string; next: string}>, + variants: Array<{component: string; prop: string; value: string}>, ) { const component = resolveComponentName(element.openingElement.name, aliases, componentNames) const nearestComponent = component ?? parent @@ -272,6 +382,10 @@ function walkElement( parentChild.push({parent, child: component}) } + if (component) { + variants.push(...getStaticVariants(component, element.openingElement)) + } + const directChildren = getDirectChildElements(element.children) if (component) { const directChildComponents = directChildren @@ -288,10 +402,49 @@ function walkElement( } for (const child of directChildren) { - walkElement(child, nearestComponent, aliases, componentNames, parentChild, adjacentSibling) + walkElement(child, nearestComponent, aliases, componentNames, parentChild, adjacentSibling, variants) } } +function getStaticVariants( + component: string, + openingElement: JSXOpeningElement, +): Array<{component: string; prop: string; value: string}> { + return openingElement.attributes.flatMap(attribute => { + if ( + attribute.type !== 'JSXAttribute' || + attribute.name.type !== 'JSXIdentifier' || + (attribute.name.name !== 'variant' && !attribute.name.name.endsWith('Variant')) + ) { + return [] + } + + const value = getStaticAttributeValue(attribute.value) + return value === undefined ? [] : [{component, prop: attribute.name.name, value}] + }) +} + +function getStaticAttributeValue(attributeValue: JSXAttribute['value']): string | undefined { + if (!attributeValue) return 'true' + if (attributeValue.type === 'StringLiteral') return attributeValue.value + if (attributeValue.type !== 'JSXExpressionContainer') return undefined + + const {expression} = attributeValue + if ( + expression.type === 'StringLiteral' || + expression.type === 'NumericLiteral' || + expression.type === 'BooleanLiteral' + ) { + return String(expression.value) + } + + if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) { + return expression.quasis[0]?.value.cooked ?? expression.quasis[0]?.value.raw + } + + return undefined +} + function getDirectChildElements(children: Array): Array { return children.flatMap(child => { if (child.type === 'JSXElement') return [child] @@ -348,6 +501,24 @@ function addRelationship(relationships: Map, ke relationships.set(key, relationship) } +function addRelatedComponents( + relationships: Map, + first: string, + second: string, + relationshipKind: 'parentChild' | 'adjacentSibling', + source: RelationshipSource, +) { + if (first === second) return + + const [sortedFirst, sortedSecond] = [first, second].sort((a, b) => a.localeCompare(b)) + const key = `${sortedFirst}\0${sortedSecond}` + const relationship = relationships.get(key) ?? {occurrences: 0, sources: new Map(), relationshipKinds: new Set()} + relationship.occurrences += 1 + relationship.sources.set(`${source.kind}\0${source.path}`, {kind: source.kind, path: source.path}) + relationship.relationshipKinds.add(relationshipKind) + relationships.set(key, relationship) +} + function toParentChildRelationships( relationships: Map, ): Array { @@ -395,6 +566,54 @@ function toSiblingRelationships( }) } +function toVariantRelationships( + relationships: Map, +): Array { + return [...relationships] + .flatMap(([key, relationship]) => { + const [component, prop, value] = key.split('\0') + return relationship.sources.size >= minimumSourceCount + ? [ + { + component, + prop, + value, + occurrences: relationship.occurrences, + sourceCount: relationship.sources.size, + confidence: getConfidence(relationship.sources.size), + sources: [...relationship.sources.values()].sort(compareSources), + }, + ] + : [] + }) + .sort((a, b) => { + return a.component.localeCompare(b.component) || a.prop.localeCompare(b.prop) || a.value.localeCompare(b.value) + }) +} + +function toRelatedComponentsRelationships( + relationships: Map, +): Array { + return [...relationships] + .flatMap(([key, relationship]) => { + const [first, second] = key.split('\0') + return relationship.sources.size >= minimumSourceCount + ? [ + { + first, + second, + relationshipKinds: [...relationship.relationshipKinds].sort(), + occurrences: relationship.occurrences, + sourceCount: relationship.sources.size, + confidence: getConfidence(relationship.sources.size), + sources: [...relationship.sources.values()].sort(compareSources), + }, + ] + : [] + }) + .sort((a, b) => a.first.localeCompare(b.first) || a.second.localeCompare(b.second)) +} + function getConfidence(sourceCount: number): 'medium' | 'high' { return sourceCount >= highConfidenceSourceCount ? 'high' : 'medium' } diff --git a/packages/react/script/components-json/output.schema.json b/packages/react/script/components-json/output.schema.json index 036130eb989..ecc09261680 100644 --- a/packages/react/script/components-json/output.schema.json +++ b/packages/react/script/components-json/output.schema.json @@ -1,7 +1,7 @@ { "$id": "output.schema.json", "type": "object", - "required": ["schemaVersion", "components"], + "required": ["schemaVersion", "components", "composition"], "properties": { "schemaVersion": { "type": "number", diff --git a/packages/react/src/__tests__/component-composition.test.ts b/packages/react/src/__tests__/component-composition.test.ts index 0731455a794..75034a71593 100644 --- a/packages/react/src/__tests__/component-composition.test.ts +++ b/packages/react/src/__tests__/component-composition.test.ts @@ -1,6 +1,10 @@ +import fs from 'node:fs' +import {processDocsFile} from '@primer/doc-gen' import {describe, expect, it} from 'vitest' import { buildComposition, + getCompositionSources, + type CompositionMetadata, type CompositionSource, type DocumentedComponent, } from '../../script/components-json/composition' @@ -13,6 +17,7 @@ const components: Array = [ subcomponents: [ {name: 'ActionMenu.Button', props: []}, {name: 'ActionMenu.Overlay', props: []}, + {name: 'ActionMenu.Overlay', props: []}, ], }, { @@ -30,7 +35,7 @@ const sourceCode = html` - + @@ -47,7 +52,7 @@ describe('component composition metadata', () => { { parent: 'ActionList', child: 'ActionList.Divider', - required: true, + childrenPropRequired: true, source: { path: 'src/ActionList/ActionList.docs.json', prop: 'children', @@ -57,7 +62,7 @@ describe('component composition metadata', () => { { parent: 'ActionList', child: 'ActionList.Item', - required: true, + childrenPropRequired: true, source: { path: 'src/ActionList/ActionList.docs.json', prop: 'children', @@ -74,6 +79,36 @@ describe('component composition metadata', () => { confidence: 'medium', }), ) + expect(composition.apiSubcomponents).toContainEqual({ + parent: 'ActionMenu', + subcomponent: 'ActionMenu.Overlay', + source: {path: 'src/ActionMenu/ActionMenu.docs.json'}, + }) + expect( + composition.apiSubcomponents.filter( + relationship => relationship.parent === 'ActionMenu' && relationship.subcomponent === 'ActionMenu.Overlay', + ), + ).toHaveLength(1) + expect(composition.observed.variants).toContainEqual( + expect.objectContaining({ + component: 'ActionList', + prop: 'selectionVariant', + value: 'single', + occurrences: 3, + sourceCount: 2, + confidence: 'medium', + }), + ) + expect(composition.observed.relatedComponents).toContainEqual( + expect.objectContaining({ + first: 'ActionList.Divider', + second: 'ActionList.Item', + relationshipKinds: ['adjacentSibling'], + occurrences: 6, + sourceCount: 2, + confidence: 'medium', + }), + ) }) it('counts occurrences while deduplicating source provenance and emits stable ordering', () => { @@ -94,6 +129,106 @@ describe('component composition metadata', () => { ], }) }) + + it('omits observations that do not meet the source threshold', () => { + const composition = buildComposition( + components, + [{kind: 'story', path: 'src/ActionMenu/ActionMenu.stories.tsx'}], + source => sourceCodeByPath[source], + ) + + expect(composition.observed).toEqual({ + parentChild: [], + adjacentSibling: [], + variants: [], + relatedComponents: [], + }) + }) + + it('derives composition evidence across public component families without component-specific rules', () => { + const composition = buildFamilyComposition() + + expect(composition.derivation.sourceKinds).toEqual(['example', 'implementation', 'story', 'test']) + expect(composition.sourceSummary.sourceUnitsByKind.implementation).toBeGreaterThan(0) + + expect(composition.apiParentChild).toEqual( + expect.arrayContaining([ + expect.objectContaining({parent: 'ActionList', child: 'ActionList.Group', childrenPropRequired: true}), + expect.objectContaining({parent: 'FormControl', child: 'TextInput', childrenPropRequired: true}), + expect.objectContaining({parent: 'UnderlineNav', child: 'UnderlineNav.Item', childrenPropRequired: true}), + ]), + ) + expect(composition.apiSubcomponents).toEqual( + expect.arrayContaining([ + {parent: 'ActionMenu', subcomponent: 'ActionMenu.Overlay', source: {path: familyDocsFiles[1]}}, + {parent: 'NavList', subcomponent: 'NavList.Item', source: {path: familyDocsFiles[4]}}, + {parent: 'Dialog', subcomponent: 'Dialog.Body', source: {path: familyDocsFiles[5]}}, + {parent: 'SelectPanel', subcomponent: 'SelectPanel.SearchInput', source: {path: familyDocsFiles[9]}}, + ]), + ) + expect(composition.observed.parentChild).toEqual( + expect.arrayContaining([ + expect.objectContaining({parent: 'ActionMenu.Overlay', child: 'ActionList', confidence: 'high'}), + expect.objectContaining({parent: 'FormControl', child: 'TextInput', confidence: 'high'}), + expect.objectContaining({parent: 'NavList', child: 'NavList.Item', confidence: 'high'}), + expect.objectContaining({parent: 'UnderlineNav', child: 'UnderlineNav.Item', confidence: 'high'}), + expect.objectContaining({parent: 'AnchoredOverlay', child: 'ActionList', confidence: 'medium'}), + expect.objectContaining({parent: 'PageLayout', child: 'PageLayout.Content', confidence: 'high'}), + ]), + ) + expect(composition.observed.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({component: 'ActionList', prop: 'selectionVariant', value: 'single'}), + expect.objectContaining({component: 'SelectPanel', prop: 'variant', value: 'modal'}), + ]), + ) + expect(composition.observed.adjacentSibling).toContainEqual( + expect.objectContaining({ + parent: 'UnderlineNav', + previous: 'UnderlineNav.Item', + next: 'UnderlineNav.Item', + confidence: 'high', + }), + ) + expect(composition.observed.relatedComponents).toContainEqual( + expect.objectContaining({first: 'ActionList', second: 'ActionMenu.Overlay', confidence: 'high'}), + ) + + const actionMenuRelation = composition.observed.parentChild.find( + relation => relation.parent === 'ActionMenu.Overlay' && relation.child === 'ActionList', + ) + expect(actionMenuRelation?.sources).toEqual( + expect.arrayContaining([expect.objectContaining({kind: 'implementation'})]), + ) + expect(composition.apiSubcomponents).toHaveLength( + new Set(composition.apiSubcomponents.map(relation => `${relation.parent}\0${relation.subcomponent}`)).size, + ) + expect(composition.observed.parentChild).not.toContainEqual(expect.objectContaining({parent: 'ConfirmationDialog'})) + }) + + it('includes composition metadata in the generated package artifact', async () => { + await import('../../script/components-json/build') + + const artifact = JSON.parse(fs.readFileSync('generated/components.json', 'utf8')) as { + composition: CompositionMetadata + schemaVersion: number + } + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) as { + exports: Record + files: Array + } + + expect(artifact.schemaVersion).toBe(2) + expect(artifact.composition.schemaVersion).toBe(1) + expect(artifact.composition.apiSubcomponents).toContainEqual( + expect.objectContaining({parent: 'ActionMenu', subcomponent: 'ActionMenu.Overlay'}), + ) + expect(artifact.composition.observed.parentChild).toContainEqual( + expect.objectContaining({parent: 'ActionMenu.Overlay', child: 'ActionList'}), + ) + expect(packageJson.files).toContain('generated') + expect(packageJson.exports['./generated/components.json']).toBe('./generated/components.json') + }, 20_000) }) function sources(): Array { @@ -109,6 +244,32 @@ const sourceCodeByPath: Record = { 'src/ActionMenu/ActionMenu.test.tsx': `<>${sourceCode}${sourceCode}`, } +const familyDocsFiles = [ + 'src/ActionList/ActionList.docs.json', + 'src/ActionMenu/ActionMenu.docs.json', + 'src/FormControl/FormControl.docs.json', + 'src/TextInput/TextInput.docs.json', + 'src/NavList/NavList.docs.json', + 'src/Dialog/Dialog.docs.json', + 'src/ConfirmationDialog/ConfirmationDialog.docs.json', + 'src/UnderlineNav/UnderlineNav.docs.json', + 'src/SelectPanel/SelectPanel.docs.json', + 'src/experimental/SelectPanel2/SelectPanel.docs.json', + 'src/AnchoredOverlay/AnchoredOverlay.docs.json', + 'src/Autocomplete/Autocomplete.docs.json', + 'src/PageLayout/PageLayout.docs.json', + 'src/PageHeader/PageHeader.docs.json', +] + +function buildFamilyComposition(): CompositionMetadata { + const documentedComponents = familyDocsFiles.map(sourcePath => { + const component = processDocsFile(sourcePath) as Omit + return {...component, sourcePath} + }) + + return buildComposition(documentedComponents, getCompositionSources(familyDocsFiles)) +} + function html(strings: TemplateStringsArray): string { return strings.join('') } From 6cca1c8c3a46d426bd94d8dcd2e6c96343f36b06 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 16:55:53 -0400 Subject: [PATCH 3/6] Expose component composition through MCP Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- .changeset/mcp-component-composition.md | 5 ++ packages/mcp/README.md | 14 ++++ packages/mcp/src/primer.ts | 107 +++++++++++++++++++++++- packages/mcp/src/server.test.ts | 66 +++++++++++++++ packages/mcp/src/server.ts | 87 ++++++++++++++++++- 5 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 .changeset/mcp-component-composition.md diff --git a/.changeset/mcp-component-composition.md b/.changeset/mcp-component-composition.md new file mode 100644 index 00000000000..75d323b4d74 --- /dev/null +++ b/.changeset/mcp-component-composition.md @@ -0,0 +1,5 @@ +--- +'@primer/mcp': minor +--- + +MCP: Add package-backed component composition metadata and documentation source selection. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 53f9edffb4c..2b625174197 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -38,6 +38,20 @@ Your MCP servers configuration should look like: } ``` +## Component metadata sources + +`get_component_composition` returns source-derived composition metadata from the +installed `@primer/react` package. It is package-backed, so a local workspace +can exercise newly generated metadata without waiting for hosted Primer Style +documentation to deploy. + +`get_component` uses hosted Primer Style documentation by default. Pass +`source: "package"` to return the installed package's generated component +metadata and composition instead. Set `PRIMER_COMPONENT_DOCS_SOURCE=package` +when starting the server to make package metadata the default; use +`PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the hosted default. The +per-call `source` input takes precedence over the startup default. + ## 🙌 Contributing We love collaborating with folks inside and outside of GitHub and welcome contributions! If you're interested, check out our [contributing docs](contributor-docs/CONTRIBUTING.md) for more info on how to get started. diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index 6a1dfbb13a8..b78e9f03166 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -1,6 +1,8 @@ import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} import octicons from '@primer/octicons/build/data.json' with {type: 'json'} +type ComponentDocsSource = 'hosted' | 'package' + type Component = { id: string name: string @@ -8,6 +10,45 @@ type Component = { slug: string } +interface ComponentDocument { + name: string + importPath: string + [key: string]: unknown +} + +interface ComponentRelationship { + parent?: string + child?: string + previous?: string + next?: string + first?: string + second?: string + component?: string + subcomponent?: string + [key: string]: unknown +} + +interface CompositionMetadata { + schemaVersion: number + derivation: Record + sourceSummary: Record + apiParentChild: Array + apiSubcomponents: Array + observed: { + parentChild: Array + adjacentSibling: Array + variants: Array + relatedComponents: Array + } +} + +interface ComponentsMetadata { + components: Record + composition?: CompositionMetadata +} + +const metadata: ComponentsMetadata = componentsMetadata + function idToSlug(id: string): string { if (id === 'actionbar') { return 'action-bar' @@ -24,7 +65,7 @@ function idToSlug(id: string): string { return id.replaceAll('_', '-') } -const components: Array = Object.entries(componentsMetadata.components).map(([id, component]) => { +const components: Array = Object.entries(metadata.components).map(([id, component]) => { return { id, name: component.name, @@ -37,6 +78,60 @@ function listComponents(): Array { return components } +function getComponentDocsSource(value = process.env.PRIMER_COMPONENT_DOCS_SOURCE): ComponentDocsSource { + if (value === undefined || value === 'hosted' || value === 'package') { + return value ?? 'hosted' + } + + throw new Error('PRIMER_COMPONENT_DOCS_SOURCE must be either "hosted" or "package".') +} + +function getComponentDocument(id: string): ComponentDocument | undefined { + return metadata.components[id] +} + +function getComponentComposition(id: string) { + const component = getComponentDocument(id) + const composition = metadata.composition + if (!component || !composition) return undefined + + return { + schemaVersion: composition.schemaVersion, + derivation: composition.derivation, + sourceSummary: composition.sourceSummary, + apiParentChild: composition.apiParentChild.filter(relation => includesComponent(relation, component.name)), + apiSubcomponents: composition.apiSubcomponents.filter(relation => includesComponent(relation, component.name)), + observed: { + parentChild: composition.observed.parentChild.filter(relation => includesComponent(relation, component.name)), + adjacentSibling: composition.observed.adjacentSibling.filter(relation => + includesComponent(relation, component.name), + ), + variants: composition.observed.variants.filter(relation => includesComponent(relation, component.name)), + relatedComponents: composition.observed.relatedComponents.filter(relation => + includesComponent(relation, component.name), + ), + }, + } +} + +function includesComponent(relation: ComponentRelationship, componentName: string): boolean { + const componentFields = [ + 'parent', + 'child', + 'previous', + 'next', + 'first', + 'second', + 'component', + 'subcomponent', + ] as const + + return componentFields.some(field => { + const value = relation[field] + return value === componentName || value?.startsWith(`${componentName}.`) + }) +} + type Pattern = { id: string name: string @@ -140,4 +235,12 @@ function listIcons(): Array { return icons } -export {listComponents, listPatterns, listIcons} +export { + getComponentComposition, + getComponentDocsSource, + getComponentDocument, + listComponents, + listPatterns, + listIcons, + type ComponentDocsSource, +} diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 219a2b8de58..2024880c12c 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -1,8 +1,19 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js' import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js' import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest' +import {getComponentDocsSource} from './primer' import {server} from './server' +describe('component documentation sources', () => { + it('uses hosted documentation by default and supports package metadata', () => { + expect(getComponentDocsSource(undefined)).toBe('hosted') + expect(getComponentDocsSource('package')).toBe('package') + expect(() => getComponentDocsSource('invalid')).toThrow( + 'PRIMER_COMPONENT_DOCS_SOURCE must be either "hosted" or "package".', + ) + }) +}) + describe('get_component_batch', () => { const client = new Client({name: 'mcp-server-test', version: '1.0.0'}) @@ -101,6 +112,42 @@ describe('get_component_batch', () => { ]) }) + it('returns package metadata and source-derived composition without fetching hosted documentation', async () => { + const result = await client.callTool({ + name: 'get_component', + arguments: {name: 'ActionMenu', source: 'package'}, + }) + + expect(fetch).not.toHaveBeenCalled() + expect(JSON.parse(getTextContent(result))).toMatchObject({ + source: 'package', + component: {name: 'ActionMenu'}, + composition: { + schemaVersion: 1, + apiSubcomponents: expect.arrayContaining([ + expect.objectContaining({parent: 'ActionMenu', subcomponent: 'ActionMenu.Overlay'}), + ]), + }, + }) + }) + + it('returns filtered package-backed composition metadata', async () => { + const result = await client.callTool({ + name: 'get_component_composition', + arguments: {name: 'ActionMenu'}, + }) + + const payload = JSON.parse(getTextContent(result)) + expect(payload.component).toEqual({ + id: 'action_menu', + name: 'ActionMenu', + importPath: '@primer/react', + }) + expect(payload.composition.observed.parentChild).toEqual( + expect.arrayContaining([expect.objectContaining({parent: 'ActionMenu.Overlay', child: 'ActionList'})]), + ) + }) + it('times out stalled requests without leaving timers active', async () => { vi.useFakeTimers() const fetchMock = vi.mocked(fetch) @@ -123,3 +170,22 @@ describe('get_component_batch', () => { expect(vi.getTimerCount()).toBe(0) }) }) + +function getTextContent(result: unknown): string { + if (typeof result !== 'object' || result === null || !('content' in result) || !Array.isArray(result.content)) { + throw new Error('Expected tool content') + } + + const content = result.content.find( + (item): item is {type: 'text'; text: string} => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'text' && + 'text' in item && + typeof item.text === 'string', + ) + if (!content) throw new Error('Expected text tool content') + + return content.text +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 07598034529..b9d15a38d07 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -4,7 +4,14 @@ import * as cheerio from 'cheerio' // eslint-disable-next-line import/no-namespace import * as z from 'zod' import TurndownService from 'turndown' -import {listComponents, listPatterns, listIcons} from './primer' +import { + getComponentComposition, + getComponentDocsSource, + getComponentDocument, + listComponents, + listPatterns, + listIcons, +} from './primer' import { listTokenGroups, loadAllTokensWithGuidelines, @@ -28,6 +35,7 @@ const server = new McpServer({ }) const turndownService = new TurndownService() +const defaultComponentDocsSource = getComponentDocsSource() // Load all tokens with guidelines from primitives const allTokensWithGuidelines: TokenWithGuidelines[] = loadAllTokensWithGuidelines() @@ -106,7 +114,61 @@ server.registerTool( ${components.join('\n')} -You can use the \`get_component\` tool to get more information about a specific component. You can use these components from the @primer/react package.`, +You can use the \`get_component\` tool to get more information about a specific component and \`get_component_composition\` for source-derived composition metadata. You can use these components from the @primer/react package.`, + }, + ], + } + }, +) + +server.registerTool( + 'get_component_composition', + { + description: + 'Retrieve source-derived composition metadata for a Primer React component, including documented compound APIs and observed static JSX relationships.', + inputSchema: { + name: z.string().describe('The name of the component to retrieve composition metadata for'), + }, + annotations: {readOnlyHint: true}, + }, + async ({name}) => { + const component = listComponents().find(candidate => { + return candidate.name === name || candidate.name.toLowerCase() === name.toLowerCase() + }) + if (!component) { + return { + isError: true, + errorMessage: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use \`list_components\` for valid names.`, + content: [], + } + } + + const composition = getComponentComposition(component.id) + if (!composition) { + return { + isError: true, + errorMessage: + 'The installed @primer/react package does not include composition metadata. Use a version that publishes generated/components.json with composition data.', + content: [], + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + component: { + id: component.id, + name: component.name, + importPath: component.importPath, + }, + composition, + }, + null, + 2, + ), }, ], } @@ -120,10 +182,14 @@ server.registerTool( 'Retrieve documentation and usage details for a specific React component from the @primer/react package by its name. This tool provides the official Primer documentation for any listed component, making it easy to inspect, reuse, or integrate components in your project.', inputSchema: { name: z.string().describe('The name of the component to retrieve'), + source: z + .enum(['hosted', 'package']) + .optional() + .describe('Use hosted Primer Style documentation or metadata from the installed @primer/react package'), }, annotations: {readOnlyHint: true}, }, - async ({name}) => { + async ({name, source}) => { const components = listComponents() const match = components.find(component => { return component.name === name || component.name.toLowerCase() === name.toLowerCase() @@ -135,6 +201,21 @@ server.registerTool( content: [], } } + const docsSource = source ?? defaultComponentDocsSource + if (docsSource === 'package') { + const document = getComponentDocument(match.id) + const composition = getComponentComposition(match.id) + + return { + content: [ + { + type: 'text', + text: JSON.stringify({source: 'package', component: document, composition}, null, 2), + }, + ], + } + } + try { const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style') const llmsResponse = await fetch(llmsUrl) From d8aad6b4b27c409abf82c27c7b5befd587e12359 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 19:07:12 -0400 Subject: [PATCH 4/6] Support package metadata in component batches Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- packages/mcp/README.md | 13 ++++----- packages/mcp/src/server.test.ts | 48 +++++++++++++++++++++++---------- packages/mcp/src/server.ts | 16 ++++++++++- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 2b625174197..0ecb61d2796 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -45,12 +45,13 @@ installed `@primer/react` package. It is package-backed, so a local workspace can exercise newly generated metadata without waiting for hosted Primer Style documentation to deploy. -`get_component` uses hosted Primer Style documentation by default. Pass -`source: "package"` to return the installed package's generated component -metadata and composition instead. Set `PRIMER_COMPONENT_DOCS_SOURCE=package` -when starting the server to make package metadata the default; use -`PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the hosted default. The -per-call `source` input takes precedence over the startup default. +`get_component` and `get_component_batch` use hosted Primer Style documentation +by default. Pass `source: "package"` to return the installed package's +generated component metadata and composition instead. Set +`PRIMER_COMPONENT_DOCS_SOURCE=package` when starting the server to make package +metadata the default; use `PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the +hosted default. The per-call `source` input takes precedence over the startup +default. ## 🙌 Contributing diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 2024880c12c..3e9f0877704 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -37,10 +37,10 @@ describe('get_component_batch', () => { await server.close() }) - const callBatch = (names: string[]) => { + const callBatch = (names: string[], source?: 'hosted' | 'package') => { return client.callTool({ name: 'get_component_batch', - arguments: {names}, + arguments: {names, source}, }) } @@ -131,6 +131,20 @@ describe('get_component_batch', () => { }) }) + it('returns package metadata and composition for every batched component', async () => { + const result = await callBatch(['NavList', 'CounterLabel', 'SegmentedControl'], 'package') + const payloads = getTextContents(result).map(content => JSON.parse(content)) + + expect(fetch).not.toHaveBeenCalled() + expect(payloads.map(payload => payload.component.name)).toEqual(['NavList', 'CounterLabel', 'SegmentedControl']) + expect(payloads.every(payload => payload.source === 'package' && payload.composition.schemaVersion === 1)).toBe( + true, + ) + expect(payloads[0].composition.apiSubcomponents).toEqual( + expect.arrayContaining([expect.objectContaining({parent: 'NavList', subcomponent: 'NavList.Item'})]), + ) + }) + it('returns filtered package-backed composition metadata', async () => { const result = await client.callTool({ name: 'get_component_composition', @@ -172,20 +186,26 @@ describe('get_component_batch', () => { }) function getTextContent(result: unknown): string { + const [content] = getTextContents(result) + if (!content) throw new Error('Expected text tool content') + + return content +} + +function getTextContents(result: unknown): Array { if (typeof result !== 'object' || result === null || !('content' in result) || !Array.isArray(result.content)) { throw new Error('Expected tool content') } - const content = result.content.find( - (item): item is {type: 'text'; text: string} => - typeof item === 'object' && - item !== null && - 'type' in item && - item.type === 'text' && - 'text' in item && - typeof item.text === 'string', - ) - if (!content) throw new Error('Expected text tool content') - - return content.text + return result.content + .filter( + (item): item is {type: 'text'; text: string} => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'text' && + 'text' in item && + typeof item.text === 'string', + ) + .map(content => content.text) } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b9d15a38d07..e9b370a6e59 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -255,10 +255,14 @@ server.registerTool( .min(2) .max(10) .describe('Component names to retrieve (e.g. ["ActionMenu", "Button", "Pagination"])'), + source: z + .enum(['hosted', 'package']) + .optional() + .describe('Use hosted Primer Style documentation or metadata from the installed @primer/react package'), }, annotations: {readOnlyHint: true}, }, - async ({names}) => { + async ({names, source}) => { const seenNames = new Set() const deduped = names.filter(name => { const normalizedName = name.toLowerCase() @@ -269,6 +273,7 @@ server.registerTool( return true }) const components = listComponents() + const docsSource = source ?? defaultComponentDocsSource const results = await Promise.all( deduped.map(async name => { @@ -282,6 +287,15 @@ server.registerTool( } } + if (docsSource === 'package') { + const document = getComponentDocument(match.id) + const composition = getComponentComposition(match.id) + return { + type: 'text' as const, + text: JSON.stringify({source: 'package', component: document, composition}, null, 2), + } + } + const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10_000) From 5724d45ececcf6b6c0cd61c060466cbf2096a5cf Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 19:38:48 -0400 Subject: [PATCH 5/6] Compact batch composition metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- packages/mcp/README.md | 3 ++- packages/mcp/src/primer.ts | 42 +++++++++++++++++++++++++++++++++ packages/mcp/src/server.test.ts | 18 ++++++++++---- packages/mcp/src/server.ts | 7 +++--- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 0ecb61d2796..0136fe81846 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -51,7 +51,8 @@ generated component metadata and composition instead. Set `PRIMER_COMPONENT_DOCS_SOURCE=package` when starting the server to make package metadata the default; use `PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the hosted default. The per-call `source` input takes precedence over the startup -default. +default. Package-mode batches return a compact composition-first summary; +`get_component` remains available when full package documentation is needed. ## 🙌 Contributing diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index b78e9f03166..96964b1643d 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -10,6 +10,10 @@ type Component = { slug: string } +type ComponentSummary = Pick & { + status?: string +} + interface ComponentDocument { name: string importPath: string @@ -90,6 +94,14 @@ function getComponentDocument(id: string): ComponentDocument | undefined { return metadata.components[id] } +function getComponentSummary(component: Component): ComponentSummary { + const status = getComponentDocument(component.id)?.status + + return typeof status === 'string' + ? {id: component.id, name: component.name, importPath: component.importPath, status} + : {id: component.id, name: component.name, importPath: component.importPath} +} + function getComponentComposition(id: string) { const component = getComponentDocument(id) const composition = metadata.composition @@ -114,6 +126,34 @@ function getComponentComposition(id: string) { } } +function getComponentCompositionSummary(id: string) { + const composition = getComponentComposition(id) + if (!composition) return undefined + + return { + schemaVersion: composition.schemaVersion, + derivation: composition.derivation, + sourceSummary: composition.sourceSummary, + apiParentChild: composition.apiParentChild.map(compactRelationship), + apiSubcomponents: composition.apiSubcomponents.map(compactRelationship), + observed: { + parentChild: composition.observed.parentChild.map(compactRelationship), + adjacentSibling: composition.observed.adjacentSibling.map(compactRelationship), + variants: composition.observed.variants.map(compactRelationship), + relatedComponents: composition.observed.relatedComponents.map(compactRelationship), + }, + } +} + +function compactRelationship(relationship: ComponentRelationship): ComponentRelationship { + const compact: ComponentRelationship = {} + for (const [key, value] of Object.entries(relationship)) { + if (key !== 'sources') compact[key] = value + } + + return compact +} + function includesComponent(relation: ComponentRelationship, componentName: string): boolean { const componentFields = [ 'parent', @@ -237,8 +277,10 @@ function listIcons(): Array { export { getComponentComposition, + getComponentCompositionSummary, getComponentDocsSource, getComponentDocument, + getComponentSummary, listComponents, listPatterns, listIcons, diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 3e9f0877704..964d959e77a 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -131,15 +131,25 @@ describe('get_component_batch', () => { }) }) - it('returns package metadata and composition for every batched component', async () => { - const result = await callBatch(['NavList', 'CounterLabel', 'SegmentedControl'], 'package') - const payloads = getTextContents(result).map(content => JSON.parse(content)) + it('returns compact, composition-first package metadata for every batched component', async () => { + const result = await callBatch(['NavList', 'SegmentedControl', 'CounterLabel', 'Avatar'], 'package') + const contents = getTextContents(result) + const payloads = contents.map(content => JSON.parse(content)) + + expect(contents.join('\n').length).toBeLessThan(20_000) + expect(contents.every(content => content.indexOf('"composition"') < 100)).toBe(true) expect(fetch).not.toHaveBeenCalled() - expect(payloads.map(payload => payload.component.name)).toEqual(['NavList', 'CounterLabel', 'SegmentedControl']) + expect(payloads.map(payload => payload.component.name)).toEqual([ + 'NavList', + 'SegmentedControl', + 'CounterLabel', + 'Avatar', + ]) expect(payloads.every(payload => payload.source === 'package' && payload.composition.schemaVersion === 1)).toBe( true, ) + expect(payloads.every(payload => !('props' in payload.component) && !('stories' in payload.component))).toBe(true) expect(payloads[0].composition.apiSubcomponents).toEqual( expect.arrayContaining([expect.objectContaining({parent: 'NavList', subcomponent: 'NavList.Item'})]), ) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e9b370a6e59..41b98c40b26 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -6,8 +6,10 @@ import * as z from 'zod' import TurndownService from 'turndown' import { getComponentComposition, + getComponentCompositionSummary, getComponentDocsSource, getComponentDocument, + getComponentSummary, listComponents, listPatterns, listIcons, @@ -288,11 +290,10 @@ server.registerTool( } if (docsSource === 'package') { - const document = getComponentDocument(match.id) - const composition = getComponentComposition(match.id) + const composition = getComponentCompositionSummary(match.id) return { type: 'text' as const, - text: JSON.stringify({source: 'package', component: document, composition}, null, 2), + text: JSON.stringify({source: 'package', composition, component: getComponentSummary(match)}, null, 2), } } From e24f81d17e98682d9894b16d5eac6a86b01dc513 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 22 Jul 2026 20:15:33 -0400 Subject: [PATCH 6/6] Bound batch composition metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824ae552-5d34-4683-9628-05bd0d5bff16 --- packages/mcp/README.md | 4 ++- packages/mcp/src/primer.ts | 43 ++++++++++++++++++++++++++++----- packages/mcp/src/server.test.ts | 26 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 0136fe81846..6f199385a25 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -52,7 +52,9 @@ generated component metadata and composition instead. Set metadata the default; use `PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the hosted default. The per-call `source` input takes precedence over the startup default. Package-mode batches return a compact composition-first summary; -`get_component` remains available when full package documentation is needed. +observed relationship types are deterministically limited to the three strongest +records and report omitted counts. `get_component` remains available when full +package documentation or complete relationship provenance is needed. ## 🙌 Contributing diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index 96964b1643d..113305f9889 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -52,6 +52,7 @@ interface ComponentsMetadata { } const metadata: ComponentsMetadata = componentsMetadata +const maximumObservedRelationshipsPerKind = 3 function idToSlug(id: string): string { if (id === 'actionbar') { @@ -130,21 +131,51 @@ function getComponentCompositionSummary(id: string) { const composition = getComponentComposition(id) if (!composition) return undefined + const parentChild = summarizeObservedRelationships(composition.observed.parentChild) + const adjacentSibling = summarizeObservedRelationships(composition.observed.adjacentSibling) + const variants = summarizeObservedRelationships(composition.observed.variants) + const relatedComponents = summarizeObservedRelationships(composition.observed.relatedComponents) + return { schemaVersion: composition.schemaVersion, - derivation: composition.derivation, - sourceSummary: composition.sourceSummary, apiParentChild: composition.apiParentChild.map(compactRelationship), apiSubcomponents: composition.apiSubcomponents.map(compactRelationship), observed: { - parentChild: composition.observed.parentChild.map(compactRelationship), - adjacentSibling: composition.observed.adjacentSibling.map(compactRelationship), - variants: composition.observed.variants.map(compactRelationship), - relatedComponents: composition.observed.relatedComponents.map(compactRelationship), + parentChild: parentChild.relationships, + adjacentSibling: adjacentSibling.relationships, + variants: variants.relationships, + relatedComponents: relatedComponents.relationships, }, + observedRelationshipLimit: maximumObservedRelationshipsPerKind, + omittedObservedRelationshipCounts: { + parentChild: parentChild.omittedCount, + adjacentSibling: adjacentSibling.omittedCount, + variants: variants.omittedCount, + relatedComponents: relatedComponents.omittedCount, + }, + } +} + +function summarizeObservedRelationships(relationships: Array) { + const orderedRelationships = [...relationships].sort((first, second) => { + return ( + getRelationshipNumber(second, 'sourceCount') - getRelationshipNumber(first, 'sourceCount') || + getRelationshipNumber(second, 'occurrences') - getRelationshipNumber(first, 'occurrences') || + JSON.stringify(compactRelationship(first)).localeCompare(JSON.stringify(compactRelationship(second))) + ) + }) + + return { + relationships: orderedRelationships.slice(0, maximumObservedRelationshipsPerKind).map(compactRelationship), + omittedCount: Math.max(orderedRelationships.length - maximumObservedRelationshipsPerKind, 0), } } +function getRelationshipNumber(relationship: ComponentRelationship, key: string): number { + const value = relationship[key] + return typeof value === 'number' ? value : 0 +} + function compactRelationship(relationship: ComponentRelationship): ComponentRelationship { const compact: ComponentRelationship = {} for (const [key, value] of Object.entries(relationship)) { diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 964d959e77a..77b0d695adc 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -150,11 +150,37 @@ describe('get_component_batch', () => { true, ) expect(payloads.every(payload => !('props' in payload.component) && !('stories' in payload.component))).toBe(true) + expect( + payloads.every(payload => !('derivation' in payload.composition) && !('sourceSummary' in payload.composition)), + ).toBe(true) expect(payloads[0].composition.apiSubcomponents).toEqual( expect.arrayContaining([expect.objectContaining({parent: 'NavList', subcomponent: 'NavList.Item'})]), ) }) + it('bounds high-connectivity package batches below MCP truncation limits', async () => { + const result = await callBatch(['ActionList', 'SegmentedControl'], 'package') + const contents = getTextContents(result) + const payloads = contents.map(content => JSON.parse(content)) + + expect(contents.join('\n').length).toBeLessThan(15_000) + expect(payloads.every(payload => payload.composition.observedRelationshipLimit === 3)).toBe(true) + expect( + payloads.every(payload => + Object.values(payload.composition.observed).every( + relationships => Array.isArray(relationships) && relationships.length <= 3, + ), + ), + ).toBe(true) + expect( + payloads.find(payload => payload.component.name === 'ActionList')?.composition.omittedObservedRelationshipCounts, + ).toMatchObject({ + parentChild: expect.any(Number), + adjacentSibling: expect.any(Number), + relatedComponents: expect.any(Number), + }) + }) + it('returns filtered package-backed composition metadata', async () => { const result = await client.callTool({ name: 'get_component_composition',