diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 7404f71e..af0516da 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -154,6 +154,9 @@ jobs: - name: Check Bilingual Parity run: node .harness/scripts/ci/04-check-bilingual-parity.mjs + - name: Check Field Label Coverage + run: node .harness/scripts/ci/71-validate-field-label-coverage.mjs + - name: Verify version format run: | BRANCH_NAME=${{ github.ref_name }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c656b50c..9661818c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -90,6 +90,9 @@ jobs: - name: Check bilingual parity run: node .harness/scripts/ci/04-check-bilingual-parity.mjs + - name: Check Field Label Coverage + run: node .harness/scripts/ci/71-validate-field-label-coverage.mjs + # GT-620's negative fixtures for the language heuristic the step above depends # on — including the two cases where it must DECLINE to judge. They ran in no # workflow, so the heuristic that closed GT-620 was itself unguarded. diff --git a/.harness/scripts/ci/71-validate-field-label-coverage.mjs b/.harness/scripts/ci/71-validate-field-label-coverage.mjs new file mode 100644 index 00000000..9603f7bc --- /dev/null +++ b/.harness/scripts/ci/71-validate-field-label-coverage.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Every field the corpus PUBLISHES has a Spanish name, and every Spanish name names a field. + * + * WHY THIS EXISTS. The field labels the Core publishes become the labels of a form in whatever + * consumes them, and one of the two languages is written down rather than derived: an English key + * yields an English label by construction, and no amount of string-splitting yields Spanish. So the + * Spanish lives in a glossary — and a glossary drifts silently in both directions. + * + * A field added to a schema with no entry here reaches a Spanish reader with an English name. That + * is not a crash; it is a form that is half-translated, which nobody notices until a customer does. + * An entry left behind after its field is renamed is the same rot facing the other way: it looks + * like coverage and translates nothing. + * + * Both are invisible to every other check in this repository, which is the whole reason for this + * one. + */ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const SCHEMA_DIR = path.join(ROOT, 'src', 'rulesets', 'schema'); +const GLOSSARY = path.join(ROOT, 'src', 'rulesets', 'i18n', 'field-labels.es.json'); + +/** + * Every name the corpus puts in front of a person: fields, and the sections that hold them. + * + * It mirrors the derivation deliberately — arrays and `$`-prefixed plumbing are not published, so + * demanding a translation for them would be demanding words nobody will ever read. + */ +export function fieldNamesIn(schemas) { + const names = new Map(); + + const walk = (node, file) => { + if (!node?.properties) return; + for (const [key, child] of Object.entries(node.properties)) { + const type = Array.isArray(child.type) ? child.type.find((t) => t !== 'null') : child.type; + if (type === 'array' || key.startsWith('$')) continue; + + // An object is not a field, but it IS the section its leaves are printed under, so its name + // is read by a person too — and a section heading left in English under Spanish field names + // is exactly the half-translation this guard exists to prevent. + if (!names.has(key)) names.set(key, new Set()); + names.get(key).add(file); + + if (type === 'object' && child.properties) walk(child, file); + } + }; + + for (const [file, schema] of Object.entries(schemas)) walk(schema, file); + return names; +} + +/** What is wrong, as data — so the guard can print it and a test can assert it. */ +export function coverageProblems(published, glossary) { + return { + untranslated: [...published.keys()].filter((k) => !glossary[k]).sort(), + orphans: Object.keys(glossary).filter((k) => !published.has(k)).sort(), + blank: Object.entries(glossary) + .filter(([, v]) => !String(v ?? '').trim()) + .map(([k]) => k) + .sort(), + }; +} + +function publishedFieldNames() { + const names = new Map(); + + const walk = (node, file) => { + if (!node?.properties) return; + for (const [key, child] of Object.entries(node.properties)) { + const type = Array.isArray(child.type) ? child.type.find((t) => t !== 'null') : child.type; + if (type === 'array' || key.startsWith('$')) continue; + if (!names.has(key)) names.set(key, new Set()); + names.get(key).add(file); + if (type === 'object' && child.properties) walk(child, file); + } + }; + + for (const file of readdirSync(SCHEMA_DIR).filter((f) => f.endsWith('.json'))) { + try { + walk(JSON.parse(readFileSync(path.join(SCHEMA_DIR, file), 'utf8')), file); + } catch { + // A schema that does not parse is another guard's business; it is not evidence about labels. + } + } + + return names; +} + +function main() { + if (!existsSync(GLOSSARY)) { + console.error(`✗ missing ${path.relative(ROOT, GLOSSARY)}`); + process.exit(1); + } + + const glossary = JSON.parse(readFileSync(GLOSSARY, 'utf8')); + const published = publishedFieldNames(); + const { untranslated, orphans, blank } = coverageProblems(published, glossary); + + for (const key of untranslated) { + const where = [...published.get(key)].slice(0, 3).join(', '); + console.error(`✗ no Spanish name for "${key}" — published by ${where}`); + } + for (const key of orphans) { + console.error(`✗ "${key}" is translated but no schema publishes it — a rename left it behind`); + } + for (const key of blank) { + console.error(`✗ "${key}" has an empty Spanish name, which reads as a missing label, not a word`); + } + + const failures = untranslated.length + orphans.length + blank.length; + if (failures > 0) { + console.error( + `\n${failures} problem(s). Field names are the form a person fills in; half of them in the ` + + `wrong language is not a partial translation, it is a broken screen.`, + ); + process.exit(1); + } + + console.log(`✓ ${published.size} published field names, all named in Spanish`); +} + +// Importing this file for its functions must not run the guard. +if (process.argv[1] && process.argv[1].endsWith('71-validate-field-label-coverage.mjs')) main(); diff --git a/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs b/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs new file mode 100644 index 00000000..657f5944 --- /dev/null +++ b/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs @@ -0,0 +1,78 @@ +/** + * The guard's two directions, asserted against hand-built corpora. + * + * Each was written against a deliberately wrong version first: a coverage check that only ever + * looks for missing entries passes forever once the glossary is full, and never notices the + * entries left behind by a rename — which look exactly like coverage and translate nothing. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { coverageProblems, fieldNamesIn } from './71-validate-field-label-coverage.mjs'; + +const corpus = { + 'prd.json': { + properties: { + status: { type: 'string' }, + metadata: { type: 'object', properties: { identifier: { type: 'string' } } }, + risks: { type: 'array' }, + $schema: { type: 'string' }, + }, + }, +}; + +test('it asks for a name for every field that is published', () => { + assert.deepEqual([...fieldNamesIn(corpus).keys()].sort(), ['identifier', 'metadata', 'status']); +}); + +test('it does not ask for words nobody will read', () => { + const names = fieldNamesIn(corpus); + // A list has no criterion operator that can judge it and is never published as a field; `$schema` + // is JSON Schema plumbing. Demanding Spanish for either is demanding dead words. + assert.equal(names.has('risks'), false); + assert.equal(names.has('$schema'), false); +}); + +test('a section is asked for as well as the fields inside it', () => { + // An object is not a field, but it IS the heading its leaves print under. A Spanish form under + // an English section heading is the same half-translation, one line higher up. + assert.equal(fieldNamesIn(corpus).has('metadata'), true); + assert.equal(fieldNamesIn(corpus).has('identifier'), true); +}); + +test('a field with no entry is reported', () => { + const { untranslated } = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + metadata: 'Metadatos', + }); + assert.deepEqual(untranslated, ['identifier']); +}); + +test('AN ENTRY LEFT BEHIND BY A RENAME IS REPORTED', () => { + // The direction a naive guard misses. It looks like coverage and translates nothing. + const { orphans } = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + identifier: 'Identificador', + metadata: 'Metadatos', + oldNameNobodyPublishes: 'Fantasma', + }); + assert.deepEqual(orphans, ['oldNameNobodyPublishes']); +}); + +test('an empty translation is a missing label, not a word', () => { + const { blank } = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + metadata: 'Metadatos', + identifier: ' ', + }); + assert.deepEqual(blank, ['identifier']); +}); + +test('a full glossary reports nothing', () => { + const problems = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + metadata: 'Metadatos', + identifier: 'Identificador', + }); + assert.deepEqual(problems, { untranslated: [], orphans: [], blank: [] }); +}); 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..14ebe78f --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts @@ -0,0 +1,215 @@ +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'); + }); + + describe('Spanish labels', () => { + const schema = { + type: 'object', + properties: { + status: { type: 'string' }, + cpuCoreLimit: { type: 'integer' }, + untranslated: { type: 'string' }, + overridden: { type: 'string', 'x-title-es': 'En este contexto significa otra cosa' }, + }, + }; + + const glossary = { status: 'Estado', cpuCoreLimit: 'Límite de núcleos de CPU', overridden: 'Genérico' }; + const labelEs = (path: string) => + deriveArtifactFields(schema, { labelsEs: glossary }).fields.find((f) => f.fieldPath === path) + ?.labelEs; + + /** + * Both languages travel together because ONE sync serves MANY readers: the consumer fetches + * this catalogue on a timer, tenant-agnostic and cached, then renders it for whoever is + * looking. One language per request would mean a fetch per reader, or documents in the wrong + * language. + */ + it('carries the Spanish alongside the English, not instead of it', () => { + const field = deriveArtifactFields(schema, { labelsEs: glossary }).fields.find( + (f) => f.fieldPath === 'cpuCoreLimit', + ); + + expect(field?.label).toBe('CPU core limit'); + expect(field?.labelEs).toBe('Límite de núcleos de CPU'); + }); + + it('lets a schema override a glossary word that is wrong in its context', () => { + expect(labelEs('overridden')).toBe('En este contexto significa otra cosa'); + }); + + /** Plain, not broken: the reader gets the English name rather than an empty label. */ + it('leaves an untranslated field without a Spanish label', () => { + expect(labelEs('untranslated')).toBeUndefined(); + expect( + deriveArtifactFields(schema, { labelsEs: glossary }).fields.find( + (f) => f.fieldPath === 'untranslated', + )?.label, + ).toBe('Untranslated'); + }); + + /** + * Without a glossary the corpus still speaks for itself: a schema that wrote its own Spanish + * keeps it. Only the shared words go away, which is what makes the glossary an addition to the + * schemas rather than a replacement for what they say. + */ + it('keeps what a schema wrote itself when no glossary is given', () => { + const fields = deriveArtifactFields(schema).fields; + + expect(fields.find((f) => f.fieldPath === 'overridden')?.labelEs).toBe( + 'En este contexto significa otra cosa', + ); + expect(fields.find((f) => f.fieldPath === 'status')?.labelEs).toBeUndefined(); + }); + }); + + 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..57882ce4 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -0,0 +1,282 @@ +/** + * 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; + /** + * The same field named in Spanish, when the corpus knows the word. + * + * It travels ALONGSIDE the English rather than replacing it, because a consumer serves many + * readers from one sync: the Tracker fetches this catalogue every fifteen minutes, tenant- + * agnostic and cached, and then renders it for whoever is looking. Publishing one language per + * request would mean either a fetch per reader or a document in the wrong language. + */ + labelEs?: string; + /** + * The SECTION this field sits in — the enclosing object, named — or absent at the root. + * + * It is published rather than left for the consumer to split off the path, because the section + * is part of the shape and the shape is this repository's to describe. A consumer deriving it + * would be re-deriving what is already known here, in a language it cannot get to: `technical + * constraints` is available by splitting `technicalConstraints.cpuCoreLimit`, «Restricciones + * técnicas» is not. + */ + group?: string; + groupEs?: string; + required: boolean; + enumValues?: string[]; + description?: string; +} + +/** + * What the derivation is given beyond the schema. + * + * Only the Spanish. English needs nothing: these keys ARE English, so a label derived from + * `cpuCoreLimit` is right by construction. Spanish cannot be derived from an English identifier by + * any amount of string-splitting — the words have to come from somewhere, and that asymmetry is + * why one language is computed and the other is written down. + */ +export interface ArtifactFieldDerivationOptions { + /** + * Name → Spanish label. Keyed by the property NAME, so `status` is «Estado» everywhere, and the + * same table names sections: an object is a property too, and `metadata` is «Metadatos» wherever + * it encloses something. + */ + labelsEs?: Record; +} + +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; + /** Per-field Spanish label, for a name the shared glossary would get wrong in this context. */ + 'x-title-es'?: 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 labelEsFor( + key: string, + node: JsonSchemaNode, + labelsEs: Record | undefined, +): string | undefined { + // A schema that names the field itself wins: the glossary is keyed by leaf name, so it says one + // thing for every `status` in the corpus, and a field whose context makes that wrong needs a way + // to say so without arguing with the other fifty. + const own = node['x-title-es']; + if (own) return own; + + return labelsEs?.[key]; +} + +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, + options: ArtifactFieldDerivationOptions = {}, +): ArtifactFieldDerivation { + const fields: ArtifactField[] = []; + const omitted: { fieldPath: string; reason: string }[] = []; + + const walk = ( + node: JsonSchemaNode, + prefix: string, + requiredHere: Set, + group?: { label: string; labelEs?: 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. It IS the section those + // leaves belong to, though, so its name travels down with them. + walk(child, fieldPath, new Set(child.required ?? []), { + label: labelFor(key, child), + labelEs: labelEsFor(key, child, options.labelsEs), + }); + continue; + } + + const type = typeFor(child); + if (!type) { + omitted.push({ fieldPath, reason: `unsupported type: ${String(childType ?? 'unknown')}` }); + continue; + } + + const labelEs = labelEsFor(key, child, options.labelsEs); + + fields.push({ + fieldPath, + type, + label: labelFor(key, child), + ...(labelEs ? { labelEs } : {}), + ...(group ? { group: group.label } : {}), + ...(group?.labelEs ? { groupEs: group.labelEs } : {}), + 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..fcf3a6cc 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,78 @@ 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. + */ + /** + * The corpus's Spanish field names, read once per call. + * + * A missing or unreadable glossary costs the Spanish labels and nothing else — the catalogue is + * what every gate depends on, and no translation is worth taking it down for. An untranslated + * field reaches a reader with its English name, which is plain rather than broken. + */ + private async labelsEs(rulesetsRoot: string): Promise> { + const file = path.join(rulesetsRoot, 'i18n', 'field-labels.es.json'); + if (!(await this.fs.exists(file))) return {}; + + try { + const parsed = JSON.parse(await this.fs.readFile(file)) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } + } + + private async withFields( + rulesetsRoot: string, + artifacts: RegistryArtifact[], + ): Promise { + const labelsEs = await this.labelsEs(rulesetsRoot); + + 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, { labelsEs }); + 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; + } + }), + ); } /** diff --git a/src/rulesets/i18n/field-labels.es.json b/src/rulesets/i18n/field-labels.es.json new file mode 100644 index 00000000..cf206a24 --- /dev/null +++ b/src/rulesets/i18n/field-labels.es.json @@ -0,0 +1,429 @@ +{ + "acceptanceCriteria": "Criterios de aceptación", + "accountableRole": "Rol responsable", + "actors": "Actores", + "actualRollbackTimeMinutes": "Tiempo real de reversión (min)", + "adr": "ADR", + "adrId": "ID del ADR", + "adrRef": "Referencia del ADR", + "adrTitle": "Título del ADR", + "affectedBoundedContext": "Contexto acotado afectado", + "agentId": "ID del agente", + "anomalies": "Anomalías", + "apiVersion": "Versión de la API", + "appliesFromSdlcPhase": "Aplica desde la fase", + "approach": "Enfoque", + "approvalDate": "Fecha de aprobación", + "approvalEvidence": "Evidencia de aprobación", + "approvalStatus": "Estado de aprobación", + "approvedBy": "Aprobado por", + "approver": "Aprobador", + "architectSignOff": "Visto bueno del arquitecto", + "architecture": "Arquitectura", + "architectureVersion": "Versión de la arquitectura", + "artifacts": "Artefactos", + "asOf": "A fecha de", + "assertedAtUtc": "Declarado el (UTC)", + "assertedBy": "Declarado por", + "assessment": "Evaluación", + "audience": "Audiencia", + "author": "Autor", + "availability": "Disponibilidad", + "availabilitySla": "SLA de disponibilidad", + "averageProcessTime": "Tiempo medio del proceso", + "baseRulesetId": "ID del conjunto de reglas base", + "baselineRepoFacts": "Hechos base del repositorio", + "baselineRuleset": "Conjunto de reglas base", + "blockKind": "Tipo de bloque", + "blockingFailures": "Fallos bloqueantes", + "blocks": "Bloques", + "blueprint": "Blueprint", + "blueprintId": "ID del blueprint", + "blueprintRef": "Referencia del blueprint", + "boundedContext": "Contexto acotado", + "businessApprover": "Aprobador de negocio", + "businessBoundary": "Frontera de negocio", + "businessContext": "Contexto de negocio", + "businessSignOff": "Visto bueno de negocio", + "category": "Categoría", + "changeSetRef": "Referencia del conjunto de cambios", + "changeSummary": "Resumen de cambios", + "checkpoint": "Checkpoint", + "checkpointId": "ID del checkpoint", + "class": "Clase", + "cli": "CLI", + "cloneUrl": "URL de clonado", + "code": "Código", + "coldStartCeilingMs": "Techo de arranque en frío (ms)", + "column": "Columna", + "command": "Comando", + "commit": "Commit", + "compatibility": "Compatibilidad", + "completeForCriticalPaths": "Completo en los caminos críticos", + "complexity": "Complejidad", + "compliance": "Cumplimiento", + "complianceTraceability": "Trazabilidad de cumplimiento", + "component": "Componente", + "concern": "Preocupación", + "concurrencyRequestsSec": "Concurrencia (peticiones/s)", + "confidence": "Confianza", + "configurationContract": "Contrato de configuración", + "confirmation": "Confirmación", + "consequences": "Consecuencias", + "constraintsAndAssumptions": "Restricciones y supuestos", + "construction": "Construcción", + "contentHash": "Hash del contenido", + "content_fingerprint": "Huella del contenido", + "context": "Contexto", + "contextAndProblem": "Contexto y problema", + "core": "Core", + "coreApi": "API del Core", + "corePath": "Ruta del Core", + "coreRef": "Referencia del Core", + "coreVersion": "Versión del Core", + "corpus": "Corpus", + "correlationId": "ID de correlación", + "costCeilingPerExecutionCents": "Techo de coste por ejecución (céntimos)", + "count": "Cantidad", + "coverage": "Cobertura", + "coverageTarget": "Cobertura objetivo", + "cpuCoreLimit": "Límite de núcleos de CPU", + "createdAt": "Creado el", + "credentialRotationIntervalHours": "Intervalo de rotación de credenciales (h)", + "criterion": "Criterio", + "critical": "Críticos", + "criticalFindings": "Hallazgos críticos", + "criticality": "Criticidad", + "currency": "Moneda", + "currentContext": "Contexto actual", + "currentPhase": "Fase actual", + "customConstraints": "Restricciones propias", + "cves": "CVE", + "data": "Datos", + "dataOwnership": "Propiedad de los datos", + "date": "Fecha", + "decision": "Decisión", + "decisionRecommendation": "Recomendación de decisión", + "deployment": "Despliegue", + "description": "Descripción", + "design": "Diseño", + "designBaseline": "Línea base de diseño", + "designProfile": "Perfil de diseño", + "details": "Detalle", + "detected_at": "Detectado el", + "detected_by": "Detectado por", + "devOpsLead": "Responsable de DevOps", + "diagramRef": "Referencia del diagrama", + "dimension": "Dimensión", + "disposition": "Disposición", + "durationMs": "Duración (ms)", + "durationSprints": "Duración (sprints)", + "e2e": "Extremo a extremo", + "edition_or_url": "Edición o URL", + "effectiveDate": "Fecha de vigencia", + "email": "Correo electrónico", + "enabled": "Activo", + "endUtc": "Fin (UTC)", + "engine": "Motor", + "environment": "Entorno", + "epic": "Épica", + "error": "Error", + "errorRate": "Tasa de error", + "errorRatePercent": "Tasa de error (%)", + "errorVolume": "Volumen de errores", + "evaluatedAt": "Evaluado el", + "evaluatedBy": "Evaluado por", + "evaluationDate": "Fecha de evaluación", + "evaluator": "Evaluador", + "evidence": "Evidencia", + "evidence_ref": "Referencia de la evidencia", + "executedAt": "Ejecutado el", + "executionMode": "Modo de ejecución", + "executiveSponsor": "Patrocinador ejecutivo", + "executiveSummary": "Resumen ejecutivo", + "exitCriteria": "Criterios de salida", + "expectedQualityAttributes": "Atributos de calidad esperados", + "expectedResult": "Resultado esperado", + "expirationDate": "Fecha de caducidad", + "extractedAt": "Extraído el", + "extractedBy": "Extraído por", + "extractorVersion": "Versión del extractor", + "facts": "Hechos", + "file": "Fichero", + "findings": "Hallazgos", + "fingerprint": "Huella", + "fixtures": "Fixtures", + "framework": "Framework", + "from": "Desde", + "frozen": "Congelado", + "functionalScope": "Alcance funcional", + "functionalStory": "Historia funcional", + "functionalStoryId": "ID de la historia funcional", + "gate": "Gate", + "gateId": "ID de gate", + "gatePhase": "Fase de la gate", + "gates": "Gates", + "generatedAt": "Generado el", + "generated_at": "Generado el", + "governance": "Gobernanza", + "guard": "Guarda", + "guidance": "Orientación", + "handoffDate": "Fecha de traspaso", + "healthEndpoint": "Endpoint de salud", + "high": "Altos", + "highFindings": "Hallazgos altos", + "href": "Enlace", + "id": "ID", + "identifier": "Identificador", + "implementation": "Implementación", + "implementationGuide": "Guía de implementación", + "indexer": "Indexador", + "indexerVersion": "Versión del indexador", + "initiative": "Iniciativa", + "initiativeGroup": "Grupo de iniciativas", + "initiativeGroupId": "ID del grupo de iniciativas", + "initiativeId": "ID de iniciativa", + "initiativeName": "Nombre de la iniciativa", + "integration": "Integración", + "invalid": "No válidos", + "issuedAt": "Emitido el", + "iterationVersion": "Versión de la iteración", + "justification": "Justificación", + "kind": "Tipo", + "knowledge_id": "ID de conocimiento", + "language": "Lenguaje", + "lastGoodVersion": "Última versión buena", + "lastReviewDate": "Fecha de la última revisión", + "latencyBudgetMs": "Presupuesto de latencia (ms)", + "latencyMs": "Latencia (ms)", + "latencyP95Ms": "Latencia P95 (ms)", + "latencyP99Ms": "Latencia P99 (ms)", + "license": "Licencia", + "licensing": "Licenciamiento", + "line": "Línea", + "linkedAt": "Enlazado el", + "localAdrTagEnforcement": "Exigencia de etiqueta ADR local", + "locator": "Localizador", + "logs": "Registros", + "low": "Bajos", + "maturity": "Madurez", + "maturityGuide": "Guía de madurez", + "maturityLevel": "Nivel de madurez", + "max": "Máximo", + "maxCritical": "Máximo de críticos", + "maxCyclomatic": "Complejidad ciclomática máxima", + "maxHigh": "Máximo de altos", + "maxMedium": "Máximo de medios", + "maxMonthlyComputeHours": "Máximo de horas de cómputo al mes", + "maxRollbackTimeMinutes": "Tiempo máximo de reversión (min)", + "mcp": "MCP", + "medium": "Medios", + "mediumFindings": "Hallazgos medios", + "meetingNotes": "Notas de la reunión", + "memoryGbLimit": "Límite de memoria (GB)", + "message": "Mensaje", + "meta": "Meta", + "metadata": "Metadatos", + "metrics": "Métricas", + "metricsValidation": "Validación de métricas", + "min": "Mínimo", + "missingSpans": "Trazas ausentes", + "mitigationPlan": "Plan de mitigación", + "mode": "Modo", + "name": "Nombre", + "nativeEvaluator": "Evaluador nativo", + "nativeJustification": "Justificación de la opción nativa", + "native_rule": "Regla nativa", + "negative": "Negativos", + "nextReviewDate": "Fecha de la próxima revisión", + "next_review_at": "Próxima revisión", + "normative": "Normativo", + "notes": "Notas", + "observability": "Observabilidad", + "occurredAt": "Ocurrió el", + "occurrences": "Apariciones", + "onCallLead": "Responsable de guardia", + "opa_equivalent": "Equivalente en OPA", + "opa_policy": "Política OPA", + "operatingBurden": "Carga operativa", + "operationalBudgets": "Presupuestos operativos", + "operationalInterfaces": "Interfaces operativas", + "order": "Orden", + "origin": "Procedencia", + "outcome": "Desenlace", + "overallVerdict": "Veredicto global", + "overrides": "Sobrescrituras", + "overridesRef": "Referencia de las sobrescrituras", + "owner": "Responsable", + "parameters": "Parámetros", + "parentCorePath": "Ruta del Core padre", + "parentPRD": "PRD del que depende", + "passed": "Superado", + "passthrough": "Paso directo", + "percentage": "Porcentaje", + "phase": "Fase", + "phase1": "Fase 1", + "phase2": "Fase 2", + "phaseArtifacts": "Artefactos de la fase", + "phaseId": "ID de fase", + "phaseProfiles": "Perfiles de fase", + "phaseRange": "Rango de fases", + "phases": "Fases", + "playbookRef": "Referencia del playbook", + "policy": "Política", + "portability": "Portabilidad", + "positive": "Positivos", + "problem": "Problema", + "problemStatement": "Planteamiento del problema", + "producer": "Productor", + "product": "Producto", + "productId": "ID de producto", + "productOwner": "Product owner", + "productionLive": "En producción", + "profile": "Perfil", + "progressiveAxis": "Eje progresivo", + "projection_version": "Versión de la proyección", + "promoted_at": "Promovido el", + "promoted_by": "Promovido por", + "promotion": "Promoción", + "promotionRequest": "Solicitud de promoción", + "proofOfConcept": "Prueba de concepto", + "proposedBy": "Propuesto por", + "proposedSolution": "Solución propuesta", + "provenance": "Procedencia", + "providerReplaceability": "Reemplazabilidad del proveedor", + "pull_request": "Pull request", + "qaLead": "Responsable de QA", + "quality": "Calidad", + "qualityAttributes": "Atributos de calidad", + "qualityGates": "Gates de calidad", + "ratio": "Proporción", + "rationale": "Justificación", + "rcStamped": "Candidata sellada", + "redistributionConstraints": "Restricciones de redistribución", + "rehearsalDate": "Fecha del ensayo", + "relatedGateId": "ID de la gate relacionada", + "release": "Publicación", + "releaseCandidate": "Candidata a publicación", + "releaseRef": "Referencia de la publicación", + "releaseVersion": "Versión publicada", + "remediation": "Remediación", + "repoFacts": "Hechos del repositorio", + "repoUrl": "URL del repositorio", + "repository": "Repositorio", + "repositoryRef": "Referencia del repositorio", + "required": "Obligatorio", + "requiredCorrection": "Corrección exigida", + "result": "Resultado", + "results": "Resultados", + "retentionPeriod": "Periodo de retención", + "retention_mode": "Modo de retención", + "retrieved_at": "Recuperado el", + "review": "Revisión", + "review_cadence": "Cadencia de revisión", + "review_freshness": "Vigencia de la revisión", + "revision": "Revisión", + "rights_status": "Situación de derechos", + "risk": "Riesgo", + "riskLevel": "Nivel de riesgo", + "role": "Rol", + "rollback": "Reversión", + "rollbackRef": "Referencia del plan de reversión", + "ruleId": "ID de la regla", + "rulesCompliance": "Cumplimiento de reglas", + "ruleset": "Conjunto de reglas", + "rulesetRef": "Referencia del conjunto de reglas", + "rulesetVersion": "Versión del conjunto de reglas", + "runtime": "Entorno de ejecución", + "runtimeVersion": "Versión del entorno de ejecución", + "sandboxTimeoutMs": "Tiempo máximo del sandbox (ms)", + "satelliteOrigin": "Satélite de origen", + "satellitePath": "Ruta del satélite", + "scannedAt": "Analizado el", + "schemaRef": "Referencia del esquema", + "schemaVersion": "Versión del esquema", + "scope": "Alcance", + "sdlc": "SDLC", + "sdlcConfig": "Configuración del SDLC", + "security": "Seguridad", + "securityCompliance": "Cumplimiento de seguridad", + "securityScan": "Análisis de seguridad", + "sensitivity": "Sensibilidad", + "severity": "Severidad", + "shortName": "Nombre corto", + "signOff": "Visto bueno", + "slaAcknowledgement": "Aceptación del SLA", + "sloReference": "Referencia del SLO", + "solution": "Solución", + "source": "Origen", + "sourceRef": "Referencia de origen", + "source_license": "Licencia del origen", + "source_registry_id": "ID del registro de origen", + "spec": "Especificación", + "sponsor": "Patrocinador", + "sshUrl": "URL SSH", + "startUtc": "Inicio (UTC)", + "status": "Estado", + "storageTbLimit": "Límite de almacenamiento (TB)", + "strategicVision": "Visión estratégica", + "strategy": "Estrategia", + "style": "Estilo", + "subpath": "Subruta", + "success": "Correcto", + "successfulBuild": "Compilación correcta", + "summary": "Resumen", + "synthesis": "Síntesis", + "target": "Objetivo", + "techDebt": "Deuda técnica", + "techLead": "Líder técnico", + "technicalConstraints": "Restricciones técnicas", + "technicalFeasibilityId": "ID de viabilidad técnica", + "technicalOnly": "Solo técnico", + "technicalStory": "Historia técnica", + "technicalSummary": "Resumen técnico", + "templateId": "ID de plantilla", + "tenant": "Tenant", + "tenantId": "ID de tenant", + "tenantIsolation": "Aislamiento entre tenants", + "testPyramid": "Pirámide de pruebas", + "testSummaryRef": "Referencia del resumen de pruebas", + "testing": "Pruebas", + "tests": "Pruebas", + "threeYearCost": "Coste a tres años", + "threshold": "Umbral", + "tier": "Nivel", + "title": "Título", + "to": "Hasta", + "tokenBudgetPerExecution": "Presupuesto de tokens por ejecución", + "tool": "Herramienta", + "topologies": "Topologías", + "topology": "Topología", + "topologyRef": "Referencia de la topología", + "topologyType": "Tipo de topología", + "total": "Total", + "totalCost": "Coste total", + "traces": "Trazas", + "trust_level": "Nivel de confianza", + "type": "Tipo", + "unit": "Unitarias", + "upId": "ID de la propuesta upstream", + "updatedAt": "Actualizado el", + "valid": "Válidos", + "validation": "Validación", + "value": "Valor", + "verdict": "Veredicto", + "version": "Versión", + "versions": "Versiones", + "volume": "Volumen", + "waiverAuthority": "Autoridad de la exención", + "waiverId": "ID de la exención", + "waiverRef": "Referencia de la exención", + "whyProhibited": "Por qué está prohibido", + "window": "Ventana", + "withinBaseline": "Dentro de la línea base", + "withinBudget": "Dentro del presupuesto", + "withinSlo": "Dentro del SLO", + "witness": "Testigo", + "work": "Obra", + "workspaceRef": "Referencia del espacio de trabajo" +}