feat: add rule specType with disciplines, category, and rationale

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-05-19 10:48:59 +02:00
parent 333b10c2ab
commit 21835f6f52
6 changed files with 275 additions and 2 deletions
@@ -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
+28 -1
View File
@@ -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<boolean>;
};
// @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)
+3
View File
@@ -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';
@@ -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 = {
@@ -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,
},
},
],
});
},
@@ -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
}
}
}
}
}
]
}