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 index bd06d5bb22..06e08bc959 100644 --- a/packages/catalog-model/examples/ai-resources/use-internal-apis-rule.yaml +++ b/packages/catalog-model/examples/ai-resources/use-internal-apis-rule.yaml @@ -8,3 +8,8 @@ spec: 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/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 550a79c45c..dfd6882004 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -14,7 +14,8 @@ export const aiResourceEntityModel: CatalogModelLayer; // @alpha export type AiResourceEntityV1alpha1 = | AiResourceEntityV1alpha1Default - | SkillAiResourceEntityV1alpha1; + | SkillAiResourceEntityV1alpha1 + | RuleAiResourceEntityV1alpha1; // @alpha export interface AiResourceEntityV1alpha1Default extends Entity { @@ -484,6 +485,11 @@ export const isAiResourceEntity: ( entity: Entity, ) => entity is AiResourceEntityV1alpha1; +// @alpha +export const isRuleAiResourceEntity: ( + entity: Entity, +) => entity is RuleAiResourceEntityV1alpha1; + // @alpha export const isSkillAiResourceEntity: ( entity: Entity, @@ -494,6 +500,27 @@ export type KindValidator = { check(entity: Entity): Promise; }; +// @alpha +export interface RuleAiResourceEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1'; + // (undocumented) + kind: 'AiResource'; + // (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 Entity { // (undocumented) diff --git a/packages/catalog-model/src/alpha.ts b/packages/catalog-model/src/alpha.ts index d7c7399865..219513f167 100644 --- a/packages/catalog-model/src/alpha.ts +++ b/packages/catalog-model/src/alpha.ts @@ -31,12 +31,15 @@ export type { AiResourceEntityV1alpha1, AiResourceEntityV1alpha1Default, SkillAiResourceEntityV1alpha1, + RuleAiResourceEntityV1alpha1, } from './kinds/AiResourceEntityV1alpha1'; export { aiResourceEntityV1alpha1Validator, skillAiResourceEntityV1alpha1Validator, + ruleAiResourceEntityV1alpha1Validator, isAiResourceEntity, isSkillAiResourceEntity, + isRuleAiResourceEntity, aiResourceEntityModel, } from './kinds/AiResourceEntityV1alpha1'; export * from './model'; diff --git a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts index aa75b414c5..e3fa82754a 100644 --- a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.test.ts @@ -18,10 +18,13 @@ 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', () => { @@ -199,6 +202,101 @@ describe('AiResourceV1alpha1 skill validator', () => { }); }); +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('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); + }); +}); + describe('isAiResourceEntity', () => { it('returns true when apiVersion and kind match', () => { const entity: Entity = { diff --git a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts index 53fa20d17a..1655e0fdbc 100644 --- a/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/AiResourceEntityV1alpha1.ts @@ -21,6 +21,7 @@ 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. @@ -59,6 +60,26 @@ export interface SkillAiResourceEntityV1alpha1 extends Entity { }; } +/** + * AiResource entity with spec.type 'rule'. Represents a governance rule + * or constraint for AI coding tools. + * + * @alpha + */ +export interface RuleAiResourceEntityV1alpha1 extends Entity { + apiVersion: 'backstage.io/v1alpha1'; + kind: 'AiResource'; + 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. @@ -67,7 +88,8 @@ export interface SkillAiResourceEntityV1alpha1 extends Entity { */ export type AiResourceEntityV1alpha1 = | AiResourceEntityV1alpha1Default - | SkillAiResourceEntityV1alpha1; + | SkillAiResourceEntityV1alpha1 + | RuleAiResourceEntityV1alpha1; const defaultValidator = entityKindSchemaValidator(defaultJsonSchema); @@ -115,6 +137,29 @@ export const isSkillAiResourceEntity = ( ): entity is SkillAiResourceEntityV1alpha1 => isAiResourceEntity(entity) && entity.spec?.type === 'skill'; +const ruleValidator = entityKindSchemaValidator(ruleJsonSchema); + +/** + * Entity data validator for {@link RuleAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const ruleAiResourceEntityV1alpha1Validator: KindValidator = { + async check(data: Entity) { + return ruleValidator(data) === data; + }, +}; + +/** + * Type guard for {@link RuleAiResourceEntityV1alpha1}. + * + * @alpha + */ +export const isRuleAiResourceEntity = ( + entity: Entity, +): entity is RuleAiResourceEntityV1alpha1 => + isAiResourceEntity(entity) && entity.spec?.type === 'rule'; + const baseRelationFields = [ { selector: { path: 'spec.owner' }, @@ -172,6 +217,14 @@ export const aiResourceEntityModel = createCatalogModelLayer({ 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 + } + } + } + } + } + ] +}