Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<string, JsonSchemaNode>;
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<string>): 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;
}
Loading
Loading