diff --git a/.changeset/add-ai-resource-entity-model.md b/.changeset/add-ai-resource-entity-model.md new file mode 100644 index 0000000000..6fcdb79a53 --- /dev/null +++ b/.changeset/add-ai-resource-entity-model.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend-module-ai-model': minor +'@backstage/catalog-model': minor +--- + +Introduced the `AiResource` catalog entity kind. Entity types, validators, type guards, and the model layer are exported from `@backstage/catalog-model/alpha`. Install `@backstage/plugin-catalog-backend-module-ai-model` in your backend to register the kind with the catalog. diff --git a/app-config.yaml b/app-config.yaml index 42e7a62966..91c2c58b0c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -280,6 +280,7 @@ catalog: pullRequestBranchName: backstage-integration rules: - allow: + - AiResource - Component - API - Resource diff --git a/packages/backend/package.json b/packages/backend/package.json index a7fc1b37e9..90e4463c37 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,6 +40,7 @@ "@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", + "@backstage/plugin-catalog-backend-module-ai-model": "workspace:^", "@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^", "@backstage/plugin-catalog-backend-module-logs": "workspace:^", "@backstage/plugin-catalog-backend-module-openapi": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index e77c83baac..4152121a45 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -46,6 +46,7 @@ backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider')); backend.add(import('@backstage/plugin-app-backend')); backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); +backend.add(import('@backstage/plugin-catalog-backend-module-ai-model')); backend.add( import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), ); diff --git a/packages/catalog-model/examples/ai-resources/frontend-design-skill.yaml b/packages/catalog-model/examples/ai-resources/frontend-design-skill.yaml new file mode 100644 index 0000000000..9621733471 --- /dev/null +++ b/packages/catalog-model/examples/ai-resources/frontend-design-skill.yaml @@ -0,0 +1,18 @@ +apiVersion: backstage.io/v1alpha1 +kind: AiResource +metadata: + name: frontend-design + description: Skill for creating production-grade frontend interfaces using the internal design system +spec: + type: skill + lifecycle: production + owner: team-a + system: artist-engagement-portal + disciplines: + - web + categories: + - framework + - design-system + agents: + - claude-code + - copilot diff --git a/packages/catalog-model/examples/ai-resources/use-internal-apis-rule.yaml b/packages/catalog-model/examples/ai-resources/use-internal-apis-rule.yaml new file mode 100644 index 0000000000..06e08bc959 --- /dev/null +++ b/packages/catalog-model/examples/ai-resources/use-internal-apis-rule.yaml @@ -0,0 +1,15 @@ +apiVersion: backstage.io/v1alpha1 +kind: AiResource +metadata: + name: use-internal-apis + description: Agents must use internal API clients instead of raw HTTP calls +spec: + type: rule + lifecycle: production + owner: team-a + system: artist-engagement-portal + disciplines: + - web + - backend + category: architecture + rationale: Ensures consistent error handling, authentication, and observability across all service calls diff --git a/packages/catalog-model/examples/all-ai-resources.yaml b/packages/catalog-model/examples/all-ai-resources.yaml new file mode 100644 index 0000000000..afa70f821a --- /dev/null +++ b/packages/catalog-model/examples/all-ai-resources.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-ai-resources + description: A collection of all Backstage example AI resources +spec: + targets: + - ./ai-resources/frontend-design-skill.yaml + - ./ai-resources/use-internal-apis-rule.yaml diff --git a/packages/catalog-model/examples/all.yaml b/packages/catalog-model/examples/all.yaml index c8e073345c..9512a39b4f 100644 --- a/packages/catalog-model/examples/all.yaml +++ b/packages/catalog-model/examples/all.yaml @@ -5,6 +5,7 @@ metadata: description: A collection of all Backstage example entities, except users, groups, and templates spec: targets: + - ./all-ai-resources.yaml - ./all-apis.yaml - ./all-components.yaml - ./all-domains.yaml diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 7483d0b6a6..d1f5e4ef61 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -3,13 +3,40 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import type { Entity } from '@backstage/catalog-model'; +import type { Entity as Entity_2 } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { SerializedError } from '@backstage/errors'; // @alpha -export interface AlphaEntity extends Entity { +export const aiResourceEntityModel: CatalogModelLayer; + +// @alpha +export type AiResourceEntityV1alpha1 = + | AiResourceEntityV1alpha1Default + | SkillAiResourceEntityV1alpha1 + | RuleAiResourceEntityV1alpha1; + +// @alpha +export interface AiResourceEntityV1alpha1Default extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1'; + // (undocumented) + kind: 'AiResource'; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + system?: string; + }; +} + +// @alpha +export const aiResourceEntityV1alpha1Validator: KindValidator; + +// @alpha +export interface AlphaEntity extends Entity_2 { status?: EntityStatus; } @@ -400,6 +427,43 @@ export function createCatalogModelLayerBuilder(options: { // @alpha export const defaultCatalogEntityModel: CatalogModelLayer; +// @public +export type Entity = { + apiVersion: string; + kind: string; + metadata: EntityMeta; + spec?: JsonObject; + relations?: EntityRelation[]; +}; + +// @public +export type EntityLink = { + url: string; + title?: string; + icon?: string; + type?: string; +}; + +// @public +export type EntityMeta = JsonObject & { + uid?: string; + etag?: string; + name: string; + namespace?: string; + title?: string; + description?: string; + labels?: Record; + annotations?: Record; + tags?: string[]; + links?: EntityLink[]; +}; + +// @public +export type EntityRelation = { + type: string; + targetRef: string; +}; + // @alpha export type EntityStatus = { items?: EntityStatusItem[]; @@ -416,5 +480,62 @@ export type EntityStatusItem = { // @alpha export type EntityStatusLevel = 'info' | 'warning' | 'error'; +// @alpha +export const isAiResourceEntity: ( + entity: Entity, +) => entity is AiResourceEntityV1alpha1; + +// @alpha +export const isRuleAiResourceEntity: ( + entity: Entity, +) => entity is RuleAiResourceEntityV1alpha1; + +// @alpha +export const isSkillAiResourceEntity: ( + entity: Entity, +) => entity is SkillAiResourceEntityV1alpha1; + +// @public +export type KindValidator = { + check(entity: Entity): Promise; +}; + +// @alpha +export interface RuleAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { + // (undocumented) + spec: { + type: 'rule'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + category: string; + rationale: string; + }; +} + +// @alpha +export const ruleAiResourceEntityV1alpha1Validator: KindValidator; + +// @alpha +export interface SkillAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { + // (undocumented) + spec: { + type: 'skill'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + categories?: string[]; + agents?: string[]; + dependsOn?: string[]; + }; +} + +// @alpha +export const skillAiResourceEntityV1alpha1Validator: KindValidator; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/catalog-model/src/alpha.ts b/packages/catalog-model/src/alpha.ts index 9381d92329..219513f167 100644 --- a/packages/catalog-model/src/alpha.ts +++ b/packages/catalog-model/src/alpha.ts @@ -14,11 +14,33 @@ * limitations under the License. */ +export type { + Entity, + EntityLink, + EntityMeta, + EntityRelation, +} from './entity/Entity'; +export type { KindValidator } from './kinds/types'; export type { AlphaEntity } from './entity/AlphaEntity'; export type { EntityStatus, EntityStatusItem, EntityStatusLevel, } from './entity/EntityStatus'; +export type { + AiResourceEntityV1alpha1, + AiResourceEntityV1alpha1Default, + SkillAiResourceEntityV1alpha1, + RuleAiResourceEntityV1alpha1, +} from './kinds/AiResourceEntityV1alpha1'; +export { + aiResourceEntityV1alpha1Validator, + skillAiResourceEntityV1alpha1Validator, + ruleAiResourceEntityV1alpha1Validator, + isAiResourceEntity, + isSkillAiResourceEntity, + isRuleAiResourceEntity, + aiResourceEntityModel, +} from './kinds/AiResourceEntityV1alpha1'; export * from './model'; export { defaultCatalogEntityModel } from './model/defaultCatalogEntityModel'; diff --git a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts new file mode 100644 index 0000000000..be295c81dd --- /dev/null +++ b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts @@ -0,0 +1,359 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '../entity/Entity'; +import { + type AiResourceEntityV1alpha1Default, + type SkillAiResourceEntityV1alpha1, + type RuleAiResourceEntityV1alpha1, + aiResourceEntityV1alpha1Validator as defaultValidator, + skillAiResourceEntityV1alpha1Validator as skillValidator, + ruleAiResourceEntityV1alpha1Validator as ruleValidator, + isAiResourceEntity, + isSkillAiResourceEntity, + isRuleAiResourceEntity, +} from './AiResourceEntityV1alpha1'; + +describe('AiResourceV1alpha1 default validator', () => { + let entity: AiResourceEntityV1alpha1Default; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'internal-design-system', + }, + spec: { + type: 'rule', + lifecycle: 'production', + owner: 'frontend-platform', + system: 'ai-tooling', + }, + }; + }); + + it('accepts valid data', async () => { + await expect(defaultValidator.check(entity)).resolves.toBe(true); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(defaultValidator.check(entity)).resolves.toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(defaultValidator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing type', async () => { + delete (entity as any).spec.type; + await expect(defaultValidator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects wrong type', async () => { + (entity as any).spec.type = 7; + await expect(defaultValidator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + await expect(defaultValidator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing lifecycle', async () => { + delete (entity as any).spec.lifecycle; + await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects wrong lifecycle', async () => { + (entity as any).spec.lifecycle = 7; + await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects empty lifecycle', async () => { + (entity as any).spec.lifecycle = ''; + await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong owner', async () => { + (entity as any).spec.owner = 7; + await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(defaultValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(defaultValidator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(defaultValidator.check(entity)).rejects.toThrow(/system/); + }); +}); + +describe('AiResourceV1alpha1 skill validator', () => { + let entity: SkillAiResourceEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'frontend-design', + }, + spec: { + type: 'skill', + lifecycle: 'production', + owner: 'ai-platform-team', + system: 'ai-tooling', + disciplines: ['web', 'backend'], + categories: ['framework'], + agents: ['claude-code'], + dependsOn: ['airesource:default/base-coding-standards'], + }, + }; + }); + + it('accepts valid skill data with all fields', async () => { + await expect(skillValidator.check(entity)).resolves.toBe(true); + }); + + it('accepts skill with only required fields', async () => { + entity.spec = { + type: 'skill', + lifecycle: 'experimental', + owner: 'team-a', + }; + await expect(skillValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects non-skill type', async () => { + (entity as any).spec.type = 'rule'; + await expect(skillValidator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing lifecycle', async () => { + delete (entity as any).spec.lifecycle; + await expect(skillValidator.check(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(skillValidator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing optional fields', async () => { + delete (entity as any).spec.system; + delete (entity as any).spec.disciplines; + delete (entity as any).spec.categories; + delete (entity as any).spec.agents; + delete (entity as any).spec.dependsOn; + await expect(skillValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects disciplines with empty strings', async () => { + (entity as any).spec.disciplines = ['']; + await expect(skillValidator.check(entity)).rejects.toThrow(/disciplines/); + }); + + it('rejects categories with wrong type', async () => { + (entity as any).spec.categories = 'not-an-array'; + await expect(skillValidator.check(entity)).rejects.toThrow(/categories/); + }); + + it('rejects agents with wrong item type', async () => { + (entity as any).spec.agents = [42]; + await expect(skillValidator.check(entity)).rejects.toThrow(/agents/); + }); + + it('rejects dependsOn with empty strings', async () => { + (entity as any).spec.dependsOn = ['']; + await expect(skillValidator.check(entity)).rejects.toThrow(/dependsOn/); + }); +}); + +describe('AiResourceV1alpha1 rule validator', () => { + let entity: RuleAiResourceEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'use-internal-apis', + }, + spec: { + type: 'rule', + lifecycle: 'production', + owner: 'frontend-platform', + disciplines: ['web', 'backend'], + category: 'architecture', + rationale: 'Ensures consistent error handling across all service calls', + }, + }; + }); + + it('accepts valid rule data with all fields', async () => { + await expect(ruleValidator.check(entity)).resolves.toBe(true); + }); + + it('accepts rule with only required fields', async () => { + entity.spec = { + type: 'rule', + lifecycle: 'production', + owner: 'team-a', + category: 'security', + rationale: 'Prevents credential leaks', + }; + await expect(ruleValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects non-rule type', async () => { + (entity as any).spec.type = 'skill'; + await expect(ruleValidator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing category', async () => { + delete (entity as any).spec.category; + await expect(ruleValidator.check(entity)).rejects.toThrow(/category/); + }); + + it('rejects empty category', async () => { + (entity as any).spec.category = ''; + await expect(ruleValidator.check(entity)).rejects.toThrow(/category/); + }); + + it('rejects missing rationale', async () => { + delete (entity as any).spec.rationale; + await expect(ruleValidator.check(entity)).rejects.toThrow(/rationale/); + }); + + it('rejects empty rationale', async () => { + (entity as any).spec.rationale = ''; + await expect(ruleValidator.check(entity)).rejects.toThrow(/rationale/); + }); + + it('accepts missing optional fields', async () => { + delete (entity as any).spec.system; + delete (entity as any).spec.disciplines; + await expect(ruleValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects disciplines with empty strings', async () => { + (entity as any).spec.disciplines = ['']; + await expect(ruleValidator.check(entity)).rejects.toThrow(/disciplines/); + }); +}); + +describe('isAiResourceEntity', () => { + it('returns true when apiVersion and kind match', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + }; + expect(isAiResourceEntity(entity)).toBe(true); + }); + + it('returns false for a different kind', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'test' }, + }; + expect(isAiResourceEntity(entity)).toBe(false); + }); + + it('returns false for a different apiVersion', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'AiResource', + metadata: { name: 'test' }, + }; + expect(isAiResourceEntity(entity)).toBe(false); + }); +}); + +describe('isSkillAiResourceEntity', () => { + it('returns true for a skill AiResource', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + spec: { type: 'skill' }, + }; + expect(isSkillAiResourceEntity(entity)).toBe(true); + }); + + it('returns false for a non-skill AiResource', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + spec: { type: 'rule' }, + }; + expect(isSkillAiResourceEntity(entity)).toBe(false); + }); + + it('returns false for a different kind', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'test' }, + spec: { type: 'skill' }, + }; + expect(isSkillAiResourceEntity(entity)).toBe(false); + }); +}); + +describe('isRuleAiResourceEntity', () => { + it('returns true for a rule AiResource', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + spec: { type: 'rule' }, + }; + expect(isRuleAiResourceEntity(entity)).toBe(true); + }); + + it('returns false for a non-rule AiResource', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + spec: { type: 'skill' }, + }; + expect(isRuleAiResourceEntity(entity)).toBe(false); + }); +}); diff --git a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts new file mode 100644 index 0000000000..f73c7a1682 --- /dev/null +++ b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; +import type { Entity } from '../entity/Entity'; +import { entityKindSchemaValidator } from '../validation'; +import type { KindValidator } from './types'; +import type { JsonObject } from '@backstage/types'; +import defaultJsonSchema from '../schema/kinds/AiResource.v1alpha1.schema.json'; +import skillJsonSchema from '../schema/kinds/AiResource.v1alpha1.skill.schema.json'; +import ruleJsonSchema from '../schema/kinds/AiResource.v1alpha1.rule.schema.json'; + +/** + * Default AiResource entity for types that don't have a structured spec. + * + * @remarks + * + * The actual content of skills and rules is not stored in the entity spec. + * Instead, the source file is referenced via the standard + * `backstage.io/source-location` annotation, consistent with how other + * Backstage entities reference their source files. + * + * @alpha + */ +export interface AiResourceEntityV1alpha1Default extends Entity { + apiVersion: 'backstage.io/v1alpha1'; + kind: 'AiResource'; + spec: { + type: string; + lifecycle: string; + owner: string; + system?: string; + }; +} + +/** + * AiResource entity with spec.type 'skill'. Represents reusable contextual + * knowledge consumed by AI coding tools. + * + * @alpha + */ +export interface SkillAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { + spec: { + type: 'skill'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + categories?: string[]; + agents?: string[]; + dependsOn?: string[]; + }; +} + +/** + * AiResource entity with spec.type 'rule'. Represents a governance rule + * or constraint for AI coding tools. + * + * @alpha + */ +export interface RuleAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { + spec: { + type: 'rule'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + category: string; + rationale: string; + }; +} + +/** + * Backstage catalog AiResource kind Entity. Represents contextual information + * consumed by AI coding tools, such as skills and rules. + * + * @alpha + */ +export type AiResourceEntityV1alpha1 = + | AiResourceEntityV1alpha1Default + | SkillAiResourceEntityV1alpha1 + | RuleAiResourceEntityV1alpha1; + +const defaultValidator = entityKindSchemaValidator(defaultJsonSchema); + +/** + * Entity data validator for the default {@link AiResourceEntityV1alpha1}. + * + * @alpha + */ +export const aiResourceEntityV1alpha1Validator: KindValidator = { + async check(data: Entity) { + return defaultValidator(data) === data; + }, +}; + +const skillValidator = entityKindSchemaValidator(skillJsonSchema); + +/** + * Entity data validator for {@link SkillAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const skillAiResourceEntityV1alpha1Validator: KindValidator = { + async check(data: Entity) { + return skillValidator(data) === data; + }, +}; + +/** + * Type guard for {@link AiResourceEntityV1alpha1}. + * + * @alpha + */ +export const isAiResourceEntity = ( + entity: Entity, +): entity is AiResourceEntityV1alpha1 => + entity.apiVersion === 'backstage.io/v1alpha1' && entity.kind === 'AiResource'; + +/** + * Type guard for {@link SkillAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const isSkillAiResourceEntity = ( + entity: Entity, +): entity is SkillAiResourceEntityV1alpha1 => + isAiResourceEntity(entity) && entity.spec?.type === 'skill'; + +/** + * Type guard for {@link RuleAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const isRuleAiResourceEntity = ( + entity: Entity, +): entity is RuleAiResourceEntityV1alpha1 => + isAiResourceEntity(entity) && entity.spec?.type === 'rule'; + +const ruleValidator = entityKindSchemaValidator(ruleJsonSchema); + +/** + * Entity data validator for {@link RuleAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const ruleAiResourceEntityV1alpha1Validator: KindValidator = { + async check(data: Entity) { + return ruleValidator(data) === data; + }, +}; + +const baseRelationFields = [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit' as const, + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit' as const, + }, +]; + +/** + * Extends the catalog model with the AiResource kind. + * + * @alpha + */ +export const aiResourceEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-ai-resource', + builder: model => { + model.addKind({ + group: 'backstage.io', + names: { + kind: 'AiResource', + singular: 'airesource', + plural: 'airesources', + }, + description: + 'An AI resource represents contextual information consumed by AI coding tools, such as skills and rules.', + versions: [ + { + name: 'v1alpha1', + relationFields: baseRelationFields, + schema: { + jsonSchema: defaultJsonSchema as JsonObject, + }, + }, + { + name: 'v1alpha1', + specType: 'skill', + relationFields: [ + ...baseRelationFields, + { + selector: { path: 'spec.dependsOn' }, + relation: 'dependsOn', + defaultKind: 'AiResource', + defaultNamespace: 'inherit' as const, + }, + ], + schema: { + jsonSchema: skillJsonSchema as JsonObject, + }, + }, + { + name: 'v1alpha1', + specType: 'rule', + relationFields: baseRelationFields, + schema: { + jsonSchema: ruleJsonSchema as JsonObject, + }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.rule.schema.json b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.rule.schema.json new file mode 100644 index 0000000000..48c0f3ee1e --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.rule.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AiResourceV1alpha1Rule", + "description": "An AI resource of type 'rule', representing a governance rule or constraint for AI coding tools.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "AiResource", + "metadata": { + "name": "use-internal-apis", + "description": "Agents must use internal API clients instead of raw HTTP calls" + }, + "spec": { + "type": "rule", + "lifecycle": "production", + "owner": "frontend-platform", + "disciplines": ["web", "backend"], + "category": "architecture", + "rationale": "Ensures consistent error handling, authentication, and observability across all service calls" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1"] + }, + "kind": { + "enum": ["AiResource"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner", "category", "rationale"], + "properties": { + "type": { + "type": "string", + "enum": ["rule"], + "description": "The type of AI resource." + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the AI resource.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the AI resource.", + "examples": ["frontend-platform", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the AI resource belongs to.", + "minLength": 1 + }, + "disciplines": { + "type": "array", + "description": "Engineering disciplines this rule applies to.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "category": { + "type": "string", + "description": "The category of this rule.", + "examples": ["security", "architecture", "style"], + "minLength": 1 + }, + "rationale": { + "type": "string", + "description": "Explanation of why this rule exists.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.schema.json new file mode 100644 index 0000000000..3077543a64 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AiResourceV1alpha1", + "description": "An AI resource represents contextual information consumed by AI coding tools, such as skills and rules.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "AiResource", + "metadata": { + "name": "frontend-design", + "description": "Skill for creating production-grade frontend interfaces" + }, + "spec": { + "type": "skill", + "lifecycle": "production", + "owner": "ai-platform-team", + "system": "ai-tooling" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1"] + }, + "kind": { + "enum": ["AiResource"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner"], + "properties": { + "type": { + "type": "string", + "description": "The type of AI resource.", + "examples": ["skill", "rule"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the AI resource.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the AI resource.", + "examples": ["ai-platform-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the AI resource belongs to.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.skill.schema.json b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.skill.schema.json new file mode 100644 index 0000000000..374c47f360 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/AiResource.v1alpha1.skill.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AiResourceV1alpha1Skill", + "description": "An AI resource of type 'skill', representing reusable contextual knowledge consumed by AI coding tools.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "AiResource", + "metadata": { + "name": "frontend-design", + "description": "Skill for creating production-grade frontend interfaces" + }, + "spec": { + "type": "skill", + "lifecycle": "production", + "owner": "ai-platform-team", + "system": "ai-tooling", + "disciplines": ["web"], + "categories": ["framework"], + "agents": ["claude-code"] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1"] + }, + "kind": { + "enum": ["AiResource"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner"], + "properties": { + "type": { + "type": "string", + "enum": ["skill"], + "description": "The type of AI resource." + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the AI resource.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the AI resource.", + "examples": ["ai-platform-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the AI resource belongs to.", + "minLength": 1 + }, + "disciplines": { + "type": "array", + "description": "Engineering disciplines this skill applies to.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "categories": { + "type": "array", + "description": "Descriptive categories for the skill.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "agents": { + "type": "array", + "description": "AI coding tools this skill is designed for.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "dependsOn": { + "type": "array", + "description": "Entity references to other AI resources this skill depends on.", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/plugins/catalog-backend-module-ai-model/.eslintrc.js b/plugins/catalog-backend-module-ai-model/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/catalog-backend-module-ai-model/README.md b/plugins/catalog-backend-module-ai-model/README.md new file mode 100644 index 0000000000..7b85733ffe --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/README.md @@ -0,0 +1,45 @@ +# @backstage/plugin-catalog-backend-module-ai-model + +Adds support for the `AiResource` entity kind to the catalog backend plugin. AI resources represent contextual information consumed by AI coding tools, such as skills and rules. + +## Installation + +Add the module to your backend: + +```ts +backend.add(import('@backstage/plugin-catalog-backend-module-ai-model')); +``` + +## Entity shape + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: AiResource +metadata: + name: frontend-design + description: Skill for creating production-grade frontend interfaces +spec: + type: skill + lifecycle: production + owner: ai-platform-team + system: ai-tooling + disciplines: + - web + categories: + - framework + agents: + - claude-code + dependsOn: + - airesource:default/base-coding-standards +``` + +The `type` field determines which spec fields are available. Currently supported types: + +- **`skill`** — reusable contextual knowledge for AI coding tools. Supports additional fields: `disciplines`, `categories`, `agents`, `dependsOn`. +- **`rule`** — governance rules and constraints for AI coding tools. Supports additional fields: `disciplines`, `category` (required), `rationale` (required). + +Any other `type` value is accepted with the base spec fields: `type`, `lifecycle`, `owner`, and optionally `system`. + +## Accessing skill and rule content + +The actual content of skills and rules is not stored in the entity spec. Instead, the source file is referenced via the standard `backstage.io/source-location` annotation, consistent with how other Backstage entities reference their source files. Entity providers that generate `AiResource` entities from `SKILL.md` or rule files should set this annotation to point to the source file. diff --git a/plugins/catalog-backend-module-ai-model/catalog-info.yaml b/plugins/catalog-backend-module-ai-model/catalog-info.yaml new file mode 100644 index 0000000000..a9b022ecb5 --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-catalog-backend-module-ai-model + title: '@backstage/plugin-catalog-backend-module-ai-model' + description: Adds support for the AIResource entity kind to the catalog backend plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/catalog-backend-module-ai-model/package.json b/plugins/catalog-backend-module-ai-model/package.json new file mode 100644 index 0000000000..540afa2798 --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-catalog-backend-module-ai-model", + "version": "0.0.0", + "description": "Adds support for the AiResource entity kind to the catalog backend plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-backend-module-ai-model" + }, + "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^" + } +} diff --git a/plugins/catalog-backend-module-ai-model/report.api.md b/plugins/catalog-backend-module-ai-model/report.api.md new file mode 100644 index 0000000000..b1249ee146 --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@backstage/plugin-catalog-backend-module-ai-model" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const catalogModuleAiResourceEntityModel: BackendFeature; +export default catalogModuleAiResourceEntityModel; +``` diff --git a/plugins/catalog-backend-module-ai-model/src/index.ts b/plugins/catalog-backend-module-ai-model/src/index.ts new file mode 100644 index 0000000000..fedbd274eb --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adds support for the AiResource entity kind to the catalog backend plugin. + * + * @packageDocumentation + */ + +export { catalogModuleAiResourceEntityModel as default } from './module'; diff --git a/plugins/catalog-backend-module-ai-model/src/module.test.ts b/plugins/catalog-backend-module-ai-model/src/module.test.ts new file mode 100644 index 0000000000..89fba1fdc3 --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/src/module.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { startTestBackend } from '@backstage/backend-test-utils'; +import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; +import { catalogModuleAiResourceEntityModel } from './module'; + +describe('catalogModuleAiResourceEntityModel', () => { + it('should register the model source', async () => { + const extensionPoint = { + setFieldValidators: jest.fn(), + setEntityDataParser: jest.fn(), + addModelSource: jest.fn(), + }; + + await startTestBackend({ + extensionPoints: [[catalogModelExtensionPoint, extensionPoint]], + features: [catalogModuleAiResourceEntityModel], + }); + + expect(extensionPoint.addModelSource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/catalog-backend-module-ai-model/src/module.ts b/plugins/catalog-backend-module-ai-model/src/module.ts new file mode 100644 index 0000000000..f2807a1cab --- /dev/null +++ b/plugins/catalog-backend-module-ai-model/src/module.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + CatalogModelSources, + aiResourceEntityModel, +} from '@backstage/catalog-model/alpha'; +import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; + +/** + * Registers support for the AiResource entity kind in the catalog. + * + * @public + */ +export const catalogModuleAiResourceEntityModel = createBackendModule({ + pluginId: 'catalog', + moduleId: 'ai-model', + register(reg) { + reg.registerInit({ + deps: { + model: catalogModelExtensionPoint, + }, + async init({ model }) { + model.addModelSource( + CatalogModelSources.static([aiResourceEntityModel]), + ); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index dfe197dbc8..0ee61b18f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4759,6 +4759,18 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-catalog-backend-module-ai-model@workspace:^, @backstage/plugin-catalog-backend-module-ai-model@workspace:plugins/catalog-backend-module-ai-model": + version: 0.0.0-use.local + resolution: "@backstage/plugin-catalog-backend-module-ai-model@workspace:plugins/catalog-backend-module-ai-model" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + languageName: unknown + linkType: soft + "@backstage/plugin-catalog-backend-module-aws@workspace:plugins/catalog-backend-module-aws": version: 0.0.0-use.local resolution: "@backstage/plugin-catalog-backend-module-aws@workspace:plugins/catalog-backend-module-aws" @@ -29292,6 +29304,7 @@ __metadata: "@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" + "@backstage/plugin-catalog-backend-module-ai-model": "workspace:^" "@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^" "@backstage/plugin-catalog-backend-module-logs": "workspace:^" "@backstage/plugin-catalog-backend-module-openapi": "workspace:^"