diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts new file mode 100644 index 00000000..ed4524c5 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts @@ -0,0 +1,155 @@ +import { + deriveArtifactFields, + schemaFileNameFromId, +} from './artifact-field-derivation'; + +/** + * The half of the contract a satellite could not use. + * + * Publishing a schema `$id` told a consumer that a PRD has a canonical shape somewhere; it never + * told it what a PRD contains, and an `$id` is an identity that nothing dereferences. These pin + * the derivation that closes it — and, just as importantly, what it refuses to publish, because a + * field no criterion can evaluate is worse than a missing one: it can be selected and never + * satisfied. + */ +describe('artifact field derivation', () => { + it('flattens nested objects into the dotted paths a criterion addresses', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + required: ['metadata'], + properties: { + metadata: { + type: 'object', + required: ['identifier'], + properties: { + identifier: { type: 'string', description: 'PRD identifier' }, + product: { type: 'string' }, + }, + }, + }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['metadata.identifier', 'metadata.product']); + expect(fields.find((f) => f.fieldPath === 'metadata.identifier')?.required).toBe(true); + expect(fields.find((f) => f.fieldPath === 'metadata.product')?.required).toBe(false); + }); + + it('does not publish the container itself, only its leaves', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { metadata: { type: 'object', properties: { a: { type: 'string' } } } }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['metadata.a']); + expect(fields.some((f) => f.fieldPath === 'metadata')).toBe(false); + }); + + it('maps each schema type onto something an operator can judge', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + ready: { type: 'boolean' }, + due: { type: 'string', format: 'date' }, + link: { type: 'string', format: 'uri' }, + status: { type: 'string', enum: ['Draft', 'Approved'] }, + body: { type: 'string', maxLength: 4000 }, + }, + }); + + const byPath = Object.fromEntries(fields.map((f) => [f.fieldPath, f.type])); + expect(byPath).toEqual({ + name: 'text', + count: 'number', + ready: 'boolean', + due: 'date', + link: 'url', + status: 'enum', + body: 'rich-text', + }); + expect(fields.find((f) => f.fieldPath === 'status')?.enumValues).toEqual(['Draft', 'Approved']); + }); + + /** + * A list cannot be compared by `gte`, `in-set` or `regex` — every operator assumes one value — + * so publishing it would hand a consumer a field it can select and never satisfy. It is + * REPORTED rather than dropped quietly, so someone counting 13 sections against 9 fields can + * see the difference is collections and not a truncated schema. + */ + it('omits collections, and says so', () => { + const { fields, omitted } = deriveArtifactFields({ + type: 'object', + properties: { + title: { type: 'string' }, + risks: { type: 'array', items: { type: 'string' } }, + }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['title']); + expect(omitted).toEqual([ + { fieldPath: 'risks', reason: 'collection — no criterion operator can evaluate a list' }, + ]); + }); + + it('gives a readable label when the schema offers none', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + executiveSummary: { type: 'string' }, + titled: { type: 'string', title: 'A proper title' }, + }, + }); + + // Sentence case: these become the labels of a FORM, and Title Case makes a form read like a + // menu of commands rather than a set of questions. + expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive summary'); + expect(fields.find((f) => f.fieldPath === 'titled')?.label).toBe('A proper title'); + }); + + /** + * `technicalFeasibilityId` ending in «Id» looks like a typo, and «id» like a mistake. There is + * no rule that separates an acronym from a short word — `id` is one and `is` is not — so the + * list is explicit and short. + */ + it('shouts an acronym instead of lowercasing it into a typo', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + technicalFeasibilityId: { type: 'string' }, + cpuCoreLimit: { type: 'integer' }, + apiBaseUrl: { type: 'string', format: 'uri' }, + 'is-approved': { type: 'boolean' }, + }, + }); + + const label = (path: string) => fields.find((f) => f.fieldPath === path)?.label; + + expect(label('technicalFeasibilityId')).toBe('Technical feasibility ID'); + expect(label('cpuCoreLimit')).toBe('CPU core limit'); + expect(label('apiBaseUrl')).toBe('API base URL'); + // A word that merely looks like one is left alone. + expect(label('is-approved')).toBe('Is approved'); + }); + + it('survives a schema with nothing in it', () => { + expect(deriveArtifactFields({}).fields).toEqual([]); + expect(deriveArtifactFields(null).fields).toEqual([]); + }); + + /** + * The one place that knows both the identity and where it lives today. Matching on the last + * segment is what lets the host change without breaking resolution — which is the churn `$id` + * exists to absorb in the first place. + */ + it('resolves a schema id to its file without depending on the host', () => { + expect(schemaFileNameFromId('https://evolith.dev/schema/prd.schema.json')).toBe( + 'prd.schema.json', + ); + expect(schemaFileNameFromId('https://example.test/elsewhere/prd.schema.json')).toBe( + 'prd.schema.json', + ); + expect(schemaFileNameFromId('not-a-schema')).toBeUndefined(); + expect(schemaFileNameFromId('')).toBeUndefined(); + }); +}); diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.ts new file mode 100644 index 00000000..858b1714 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -0,0 +1,212 @@ +/** + * Derives the FLAT FIELD LIST of an artifact from the JSON Schema the Core already publishes. + * + * WHY THIS EXISTS. The registry names an artifact and points at its schema's `$id`. A consumer + * therefore learns that a PRD is required in discovery and still cannot find out what a PRD is + * supposed to contain — the `$id` is an identity, not a location, and nothing dereferences it. + * The satellite waiting on this (`evolith_tracker`) evaluates gate criteria against a flat field + * map, so «a PRD has a field called metadata.identifier, it is a string, and it is required» is + * the fact it needs. Without it a tenant can configure a criterion over a document and nothing + * will ever read it, which makes the gate a presence check. + * + * The schemas are NOT rewritten to a flat shape. They stay the source; this derives a projection, + * so a schema change propagates on the next read rather than needing a second file kept in sync. + */ + +/** The field types a consumer's criteria can actually evaluate. */ +export type ArtifactFieldType = + | 'text' + | 'rich-text' + | 'number' + | 'date' + | 'boolean' + | 'enum' + | 'url'; + +export interface ArtifactField { + /** Dotted path from the document root — `metadata.identifier`. Stable: criteria reference it. */ + fieldPath: string; + type: ArtifactFieldType; + label: string; + required: boolean; + enumValues?: string[]; + description?: string; +} + +export interface ArtifactFieldDerivation { + fields: ArtifactField[]; + /** + * Paths deliberately left out, and why. Collections have no operator that can judge them — + * `gte`, `in-set` and `regex` all assume a single value — so publishing them as fields would + * offer a consumer something it can select and never satisfy. + * + * Reported rather than dropped in silence: a caller comparing 13 sections against 9 fields + * deserves to know the difference is arrays, not an incomplete schema. + */ + omitted: { fieldPath: string; reason: string }[]; +} + +interface JsonSchemaNode { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + required?: string[]; + enum?: unknown[]; + format?: string; + maxLength?: number; + items?: JsonSchemaNode; +} + +/** + * Words that are ALWAYS shouted, because lowercasing them makes a label look misspelt: + * `technicalFeasibilityId` should end in «ID», not «Id» and not «id». + * + * A list rather than a rule, because there is no rule: `id` is an acronym and `is` is not, and + * nothing in the spelling separates them. It is short on purpose — a term that is not here comes + * out as an ordinary word, which is merely plain, whereas a term wrongly here comes out shouting. + */ +const ACRONYMS = new Set([ + 'id', 'api', 'url', 'uri', 'cpu', 'gpu', 'ram', 'gb', 'mb', 'tb', 'ms', + 'qa', 'ci', 'cd', 'ui', 'ux', 'db', 'sql', 'http', 'https', 'json', 'xml', 'yaml', + 'sla', 'slo', 'sli', 'kpi', 'okr', 'roi', 'tco', 'rto', 'rpo', 'mttr', 'cfr', + 'prd', 'adr', 'sdlc', 'pii', 'dns', 'tls', 'sso', 'rbac', 'abac', 'vpc', +]); + +/** + * A humane label when the schema gives none: `executiveSummary` → `Executive summary`. + * + * SENTENCE case, not Title Case. A form whose labels are Title Cased reads like a menu of + * commands rather than a set of questions, and it is the house style of the surfaces that render + * these — mixing the two would look like two systems sharing one screen. + * + * This is the fallback. A schema that publishes a `title` has already been given words by whoever + * owns the shape, and no amount of string-splitting here can improve on them. + */ +function labelFor(key: string, node: JsonSchemaNode): string { + if (node.title) return node.title; + + const words = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[-_.]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => (ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.toLowerCase())); + + if (words.length === 0) return ''; + + const [first, ...rest] = words; + const head = ACRONYMS.has(first.toLowerCase()) + ? first + : first.charAt(0).toUpperCase() + first.slice(1); + + return [head, ...rest].join(' '); +} + +/** + * Maps a JSON Schema node onto the consumer's vocabulary. + * + * The vocabulary is deliberately small: it is exactly what the existing criterion operators can + * judge. A type outside it produces a field no criterion can evaluate, which is the same as no + * field at all. + */ +function typeFor(node: JsonSchemaNode): ArtifactFieldType | null { + const raw = Array.isArray(node.type) ? node.type.find((t) => t !== 'null') : node.type; + + if (Array.isArray(node.enum) && node.enum.length > 0) return 'enum'; + + switch (raw) { + case 'integer': + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'string': + if (node.format === 'date' || node.format === 'date-time') return 'date'; + if (node.format === 'uri' || node.format === 'url') return 'url'; + // Long free text is still text to a criterion; the distinction is for the editor, which + // should give it room rather than a single line. + if ((node.maxLength ?? 0) > 500) return 'rich-text'; + return 'text'; + default: + return null; + } +} + +/** + * Walks a JSON Schema and produces the flat field list. + * + * Nested objects are flattened with dotted paths because that is how a criterion addresses them. + * Arrays are omitted and reported — see {@link ArtifactFieldDerivation.omitted}. + */ +export function deriveArtifactFields(schema: unknown): ArtifactFieldDerivation { + const fields: ArtifactField[] = []; + const omitted: { fieldPath: string; reason: string }[] = []; + + const walk = (node: JsonSchemaNode, prefix: string, requiredHere: Set): void => { + const properties = node.properties; + if (!properties) return; + + for (const [key, child] of Object.entries(properties)) { + const fieldPath = prefix ? `${prefix}.${key}` : key; + const required = requiredHere.has(key); + const childType = Array.isArray(child.type) + ? child.type.find((t) => t !== 'null') + : child.type; + + if (childType === 'array') { + omitted.push({ + fieldPath, + reason: 'collection — no criterion operator can evaluate a list', + }); + continue; + } + + if (childType === 'object' && child.properties) { + // An object is not a field: its LEAVES are. Publishing the container as well would offer + // a path whose value is a document, which no operator can compare. + walk(child, fieldPath, new Set(child.required ?? [])); + continue; + } + + const type = typeFor(child); + if (!type) { + omitted.push({ fieldPath, reason: `unsupported type: ${String(childType ?? 'unknown')}` }); + continue; + } + + fields.push({ + fieldPath, + type, + label: labelFor(key, child), + required, + ...(type === 'enum' && Array.isArray(child.enum) + ? { enumValues: child.enum.map((v) => String(v)) } + : {}), + ...(child.description ? { description: child.description } : {}), + }); + } + }; + + const root = (schema ?? {}) as JsonSchemaNode; + walk(root, '', new Set(root.required ?? [])); + + return { fields, omitted }; +} + +/** + * Resolves a schema `$id` to the file that publishes it. + * + * The `$id` is an identity and the filename is where it lives today; this is the ONE place that + * knows both, so the rest of the code can keep using the identity. Matching on the last path + * segment survives the host changing, which is precisely the kind of churn `$id` exists to + * absorb. + */ +export function schemaFileNameFromId(schemaId: string): string | undefined { + const trimmed = (schemaId ?? '').trim(); + if (!trimmed) return undefined; + const last = trimmed.split('/').filter(Boolean).pop(); + return last && last.endsWith('.json') ? last : undefined; +} diff --git a/src/apps/core-api/src/application/services/core-reference-query.service.ts b/src/apps/core-api/src/application/services/core-reference-query.service.ts index ec645b98..adf5c544 100644 --- a/src/apps/core-api/src/application/services/core-reference-query.service.ts +++ b/src/apps/core-api/src/application/services/core-reference-query.service.ts @@ -1,4 +1,9 @@ import * as path from 'path'; +import { + deriveArtifactFields, + schemaFileNameFromId, + type ArtifactField, +} from './artifact-field-derivation'; import { Injectable, Inject } from '@nestjs/common'; import type { IFileSystem } from '@beyondnet/evolith-core-domain/domain/interfaces'; import { @@ -40,6 +45,20 @@ export interface RegistryArtifact { schemaId?: string; templateRef?: string; producedBy?: { format: string; note?: string }; + + /** + * The artifact's fields, derived from the schema its `schemaId` names. + * + * Absent when the artifact publishes no schema — a tool's own output declares `producedBy` + * instead, and restating what the tool already publishes would rot the day the tool changes. + */ + fields?: ArtifactField[]; + + /** + * Paths the derivation deliberately left out, with the reason. Reported so a consumer counting + * sections against fields can see the difference is collections, not a truncated schema. + */ + omittedFields?: { fieldPath: string; reason: string }[]; } export interface ArtifactRegistry { @@ -118,14 +137,57 @@ export class CoreReferenceQueryService { if (!(await this.fs.exists(file))) return undefined; const registry = JSON.parse(await this.fs.readFile(file)) as ArtifactRegistry; - if (!phase) return registry; - // An unknown phase yields an EMPTY artifact list, never the whole registry. Falling back to - // everything would answer a question nobody asked and read as "this phase requires all of it". - return { - ...registry, - artifacts: registry.artifacts.filter((a) => a.phases.includes(phase)), - }; + const scoped = phase + // An unknown phase yields an EMPTY artifact list, never the whole registry. Falling back to + // everything would answer a question nobody asked and read as "this phase requires all of it". + ? { ...registry, artifacts: registry.artifacts.filter((a) => a.phases.includes(phase)) } + : registry; + + return { ...scoped, artifacts: await this.withFields(rulesetsRoot, scoped.artifacts) }; + } + + /** + * Attaches each artifact's FIELDS, derived from the schema its `schemaId` names. + * + * This closes the half of the contract a satellite could not use. Publishing the `$id` told a + * consumer that a PRD has a canonical shape somewhere; it did not tell it what a PRD contains, + * and nothing dereferences an identity. Gate criteria resolve a field path, so without this the + * tenant can configure a rule over a document that nothing will ever read — the gate checks that + * a file exists and never what it says. + * + * A schema that cannot be read leaves the artifact WITHOUT fields rather than failing the whole + * registry: one unreadable file must not take down the catalogue every other artifact needs. + */ + private async withFields( + rulesetsRoot: string, + artifacts: RegistryArtifact[], + ): Promise { + return Promise.all( + artifacts.map(async (artifact) => { + if (!artifact.schemaId) return artifact; + + const fileName = schemaFileNameFromId(artifact.schemaId); + if (!fileName) return artifact; + + const schemaFile = path.join(rulesetsRoot, 'schema', fileName); + if (!(await this.fs.exists(schemaFile))) return artifact; + + try { + const schema = JSON.parse(await this.fs.readFile(schemaFile)); + const { fields, omitted } = deriveArtifactFields(schema); + return { + ...artifact, + fields, + ...(omitted.length > 0 ? { omittedFields: omitted } : {}), + }; + } catch { + // Malformed schema: the artifact still exists and is still demanded, it just cannot say + // what it contains yet. + return artifact; + } + }), + ); } /**