diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/report.api.md b/plugins/catalog-backend-module-ai-resource-entity-model/report.api.md index 6133a5d7a9..6e62498abe 100644 --- a/plugins/catalog-backend-module-ai-resource-entity-model/report.api.md +++ b/plugins/catalog-backend-module-ai-resource-entity-model/report.api.md @@ -8,7 +8,12 @@ import { Entity } from '@backstage/catalog-model'; import { KindValidator } from '@backstage/catalog-model'; // @public -export interface AIResourceEntityV1alpha1 extends Entity { +export type AIResourceEntityV1alpha1 = + | AIResourceEntityV1alpha1Default + | SkillAIResourceEntityV1alpha1; + +// @public +export interface AIResourceEntityV1alpha1Default extends Entity { // (undocumented) apiVersion: 'backstage.io/v1alpha1'; // (undocumented) @@ -33,4 +38,31 @@ export default catalogModuleAIResourceEntityModel; export const isAIResourceEntity: ( entity: Entity, ) => entity is AIResourceEntityV1alpha1; + +// @public +export const isSkillAIResourceEntity: ( + entity: Entity, +) => entity is SkillAIResourceEntityV1alpha1; + +// @public +export interface SkillAIResourceEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1'; + // (undocumented) + kind: 'AIResource'; + // (undocumented) + spec: { + type: 'skill'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + categories?: string[]; + agents?: string[]; + dependsOn?: string[]; + }; +} + +// @public +export const skillAIResourceEntityV1alpha1Validator: KindValidator; ``` diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.test.ts b/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.test.ts index 1de67dda74..8acbc5bc4a 100644 --- a/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.test.ts +++ b/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.test.ts @@ -16,13 +16,110 @@ import type { Entity } from '@backstage/catalog-model'; import { - type AIResourceEntityV1alpha1, - aiResourceEntityV1alpha1Validator as validator, + type AIResourceEntityV1alpha1Default, + type SkillAIResourceEntityV1alpha1, + aiResourceEntityV1alpha1Validator as defaultValidator, + skillAIResourceEntityV1alpha1Validator as skillValidator, isAIResourceEntity, + isSkillAIResourceEntity, } from './AIResourceEntityV1alpha1'; -describe('AIResourceV1alpha1Validator', () => { - let entity: 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 = { @@ -36,82 +133,69 @@ describe('AIResourceV1alpha1Validator', () => { 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 data', async () => { - await expect(validator.check(entity)).resolves.toBe(true); + it('accepts valid skill data with all fields', async () => { + await expect(skillValidator.check(entity)).resolves.toBe(true); }); - it('ignores unknown apiVersion', async () => { - (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(validator.check(entity)).resolves.toBe(false); + 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('ignores unknown kind', async () => { - (entity as any).kind = 'Wizard'; - await expect(validator.check(entity)).resolves.toBe(false); - }); - - it('rejects missing type', async () => { - delete (entity as any).spec.type; - await expect(validator.check(entity)).rejects.toThrow(/type/); - }); - - it('rejects wrong type', async () => { - (entity as any).spec.type = 7; - await expect(validator.check(entity)).rejects.toThrow(/type/); - }); - - it('rejects empty type', async () => { - (entity as any).spec.type = ''; - await expect(validator.check(entity)).rejects.toThrow(/type/); + 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(validator.check(entity)).rejects.toThrow(/lifecycle/); - }); - - it('rejects wrong lifecycle', async () => { - (entity as any).spec.lifecycle = 7; - await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); - }); - - it('rejects empty lifecycle', async () => { - (entity as any).spec.lifecycle = ''; - await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); + await expect(skillValidator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects missing owner', async () => { delete (entity as any).spec.owner; - await expect(validator.check(entity)).rejects.toThrow(/owner/); + await expect(skillValidator.check(entity)).rejects.toThrow(/owner/); }); - it('rejects wrong owner', async () => { - (entity as any).spec.owner = 7; - await expect(validator.check(entity)).rejects.toThrow(/owner/); - }); - - it('rejects empty owner', async () => { - (entity as any).spec.owner = ''; - await expect(validator.check(entity)).rejects.toThrow(/owner/); - }); - - it('accepts missing system', async () => { + it('accepts missing optional fields', async () => { delete (entity as any).spec.system; - await expect(validator.check(entity)).resolves.toBe(true); + 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 wrong system', async () => { - (entity as any).spec.system = 7; - await expect(validator.check(entity)).rejects.toThrow(/system/); + it('rejects disciplines with empty strings', async () => { + (entity as any).spec.disciplines = ['']; + await expect(skillValidator.check(entity)).rejects.toThrow(/disciplines/); }); - it('rejects empty system', async () => { - (entity as any).spec.system = ''; - await expect(validator.check(entity)).rejects.toThrow(/system/); + 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/); }); }); @@ -143,3 +227,35 @@ describe('isAIResourceEntity', () => { 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); + }); +}); diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.ts b/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.ts index b272ffb02a..269a70d365 100644 --- a/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.ts +++ b/plugins/catalog-backend-module-ai-resource-entity-model/src/AIResourceEntityV1alpha1.ts @@ -21,15 +21,15 @@ import { } from '@backstage/catalog-model'; import { createCatalogModelLayer } from '@backstage/catalog-model/alpha'; import type { JsonObject } from '@backstage/types'; -import jsonSchema from './schema/AIResource.v1alpha1.schema.json'; +import defaultJsonSchema from './schema/AIResource.v1alpha1.schema.json'; +import skillJsonSchema from './schema/AIResource.v1alpha1.skill.schema.json'; /** - * Backstage catalog AIResource kind Entity. Represents contextual information - * consumed by AI coding tools, such as skills and rules. + * Default AIResource entity for types that don't have a structured spec. * * @public */ -export interface AIResourceEntityV1alpha1 extends Entity { +export interface AIResourceEntityV1alpha1Default extends Entity { apiVersion: 'backstage.io/v1alpha1'; kind: 'AIResource'; spec: { @@ -40,16 +40,60 @@ export interface AIResourceEntityV1alpha1 extends Entity { }; } -const validator = entityKindSchemaValidator(jsonSchema); +/** + * AIResource entity with spec.type 'skill'. Represents reusable contextual + * knowledge consumed by AI coding tools. + * + * @public + */ +export interface SkillAIResourceEntityV1alpha1 extends Entity { + apiVersion: 'backstage.io/v1alpha1'; + kind: 'AIResource'; + spec: { + type: 'skill'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + categories?: string[]; + agents?: string[]; + dependsOn?: string[]; + }; +} /** - * Entity data validator for {@link AIResourceEntityV1alpha1}. + * Backstage catalog AIResource kind Entity. Represents contextual information + * consumed by AI coding tools, such as skills and rules. + * + * @public + */ +export type AIResourceEntityV1alpha1 = + | AIResourceEntityV1alpha1Default + | SkillAIResourceEntityV1alpha1; + +const defaultValidator = entityKindSchemaValidator(defaultJsonSchema); + +/** + * Entity data validator for the default {@link AIResourceEntityV1alpha1}. * * @public */ export const aiResourceEntityV1alpha1Validator: KindValidator = { async check(data: Entity) { - return validator(data) === data; + return defaultValidator(data) === data; + }, +}; + +const skillValidator = entityKindSchemaValidator(skillJsonSchema); + +/** + * Entity data validator for {@link SkillAIResourceEntityV1alpha1}. + * + * @public + */ +export const skillAIResourceEntityV1alpha1Validator: KindValidator = { + async check(data: Entity) { + return skillValidator(data) === data; }, }; @@ -63,6 +107,16 @@ export const isAIResourceEntity = ( ): entity is AIResourceEntityV1alpha1 => entity.apiVersion === 'backstage.io/v1alpha1' && entity.kind === 'AIResource'; +/** + * Type guard for {@link SkillAIResourceEntityV1alpha1}. + * + * @public + */ +export const isSkillAIResourceEntity = ( + entity: Entity, +): entity is SkillAIResourceEntityV1alpha1 => + isAIResourceEntity(entity) && entity.spec.type === 'skill'; + /** * Extends the catalog model with the AIResource kind. * @@ -99,7 +153,35 @@ export const aiResourceEntityModel = createCatalogModelLayer({ }, ], schema: { - jsonSchema: jsonSchema as JsonObject, + jsonSchema: defaultJsonSchema as JsonObject, + }, + }, + { + name: 'v1alpha1', + specType: 'skill', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit', + }, + { + selector: { path: 'spec.dependsOn' }, + relation: 'dependsOn', + defaultKind: 'AIResource', + defaultNamespace: 'inherit', + }, + ], + schema: { + jsonSchema: skillJsonSchema as JsonObject, }, }, ], diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/src/index.ts b/plugins/catalog-backend-module-ai-resource-entity-model/src/index.ts index cf1b636db1..afd2bc82d2 100644 --- a/plugins/catalog-backend-module-ai-resource-entity-model/src/index.ts +++ b/plugins/catalog-backend-module-ai-resource-entity-model/src/index.ts @@ -20,9 +20,15 @@ * @packageDocumentation */ -export type { AIResourceEntityV1alpha1 } from './AIResourceEntityV1alpha1'; +export type { + AIResourceEntityV1alpha1, + AIResourceEntityV1alpha1Default, + SkillAIResourceEntityV1alpha1, +} from './AIResourceEntityV1alpha1'; export { aiResourceEntityV1alpha1Validator, + skillAIResourceEntityV1alpha1Validator, isAIResourceEntity, + isSkillAIResourceEntity, } from './AIResourceEntityV1alpha1'; export { catalogModuleAIResourceEntityModel as default } from './module'; diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/src/module.ts b/plugins/catalog-backend-module-ai-resource-entity-model/src/module.ts index 6ccab581df..d0c00d810d 100644 --- a/plugins/catalog-backend-module-ai-resource-entity-model/src/module.ts +++ b/plugins/catalog-backend-module-ai-resource-entity-model/src/module.ts @@ -15,7 +15,6 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { CatalogModelSources } from '@backstage/catalog-model/alpha'; import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; import { aiResourceEntityModel } from './AIResourceEntityV1alpha1'; @@ -33,9 +32,11 @@ export const catalogModuleAIResourceEntityModel = createBackendModule({ model: catalogModelExtensionPoint, }, async init({ model }) { - model.addModelSource( - CatalogModelSources.static([aiResourceEntityModel]), - ); + model.addModelSource({ + async *read() { + yield { data: [{ layer: aiResourceEntityModel }] }; + }, + }); }, }); }, diff --git a/plugins/catalog-backend-module-ai-resource-entity-model/src/schema/AIResource.v1alpha1.skill.schema.json b/plugins/catalog-backend-module-ai-resource-entity-model/src/schema/AIResource.v1alpha1.skill.schema.json new file mode 100644 index 0000000000..32f220582c --- /dev/null +++ b/plugins/catalog-backend-module-ai-resource-entity-model/src/schema/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 + } + } + } + } + } + } + ] +}