From 321daf629f88062f3b6a336a1aef0b8a9b2a3deb Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 23 Aug 2026 09:06:36 -0500 Subject: [PATCH 1/4] feat(reference): publish what an artifact CONTAINS, not just that it has a shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: an $id is an identity, deliberately not a location, and nothing dereferences it. That gap is not academic. The satellite waiting on it evaluates gate criteria against a flat field map, and with no fields a tenant can configure a rule over a document that nothing will ever read — the gate ends up checking that a file exists and never what it says. Measured on a live tenant before this: 34 catalogued artifacts, zero field schemas with any field, zero criteria. So each registry entry now carries its FIELDS, derived from the schema the Core already ships. The schemas are not rewritten flat: they stay the source and this is a projection, so a schema change propagates on the next read instead of needing a second file kept in sync. Across the corpus that is 529 fields from 50 schemas — the PRD alone goes from nothing to 18, with types and requiredness. The type vocabulary is small on purpose: exactly what the existing criterion operators can judge. A type outside it yields a field no criterion can evaluate, which is worse than a missing one because it can be selected and never satisfied. Collections are omitted for the same reason — gte, in-set and regex all assume a single value — and REPORTED rather than dropped quietly, so someone counting 13 sections against 18 fields can see the difference is arrays and not a truncated schema. An unreadable schema leaves that one artifact without fields instead of failing the registry: one malformed file must not take down the catalogue every other artifact needs. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../artifact-field-derivation.spec.ts | 128 +++++++++++++ .../services/artifact-field-derivation.ts | 175 ++++++++++++++++++ .../services/core-reference-query.service.ts | 76 +++++++- 3 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts create mode 100644 src/apps/core-api/src/application/services/artifact-field-derivation.ts 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..ae6773a5 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts @@ -0,0 +1,128 @@ +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' }, + }, + }); + + expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive Summary'); + expect(fields.find((f) => f.fieldPath === 'titled')?.label).toBe('A proper title'); + }); + + 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..00cf35c9 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -0,0 +1,175 @@ +/** + * 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; +} + +/** A humane label when the schema gives none: `executiveSummary` → `Executive Summary`. */ +function labelFor(key: string, node: JsonSchemaNode): string { + if (node.title) return node.title; + const spaced = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[-_.]/g, ' ') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * 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; + } + }), + ); } /** From e91fdc650e22829b51d34ed84d52336afce18a36 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 23 Aug 2026 10:30:15 -0500 Subject: [PATCH 2/4] fix(reference): a derived label reads like a question, not like a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels these produce become the labels of a FORM — the satellite renders one input per field — so the case matters. Title Case makes a form read like a menu of commands rather than a set of questions, and it clashes with the sentence case the rendering surfaces use everywhere else; two cases on one screen look like two systems sharing it. The acronym list exists because there is no rule to replace it. Lowercasing every word turns `technicalFeasibilityId` into a label ending in "id", which reads as a mistake, and leaving the camel case alone gives "Id", which reads as a typo. Nothing in the spelling separates `id` from `is`, so the terms that get shouted are named one by one. The list is short deliberately: a term missing from it comes out as an ordinary word, which is merely plain, while a term wrongly in it comes out shouting. None of this runs for a schema that publishes a `title`. That is words chosen by whoever owns the shape, and no amount of string-splitting here improves on them. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../artifact-field-derivation.spec.ts | 29 +++++++++++- .../services/artifact-field-derivation.ts | 47 +++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) 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 index ae6773a5..ed4524c5 100644 --- 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 @@ -101,10 +101,37 @@ describe('artifact field derivation', () => { }, }); - expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive Summary'); + // 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([]); 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 index 00cf35c9..858b1714 100644 --- a/src/apps/core-api/src/application/services/artifact-field-derivation.ts +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -58,14 +58,51 @@ interface JsonSchemaNode { items?: JsonSchemaNode; } -/** A humane label when the schema gives none: `executiveSummary` → `Executive Summary`. */ +/** + * 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 spaced = key + + const words = key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[-_.]/g, ' ') - .trim(); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); + .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(' '); } /** From 5725cbb378e27004d8a709b709dd8f212f5dbac0 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 24 Aug 2026 08:43:30 -0500 Subject: [PATCH 3/4] feat(reference): name every published field in Spanish too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels this publishes become the labels of a FORM in whatever consumes them, and until now every one of them was English. A Spanish reader got a Spanish screen wrapped around English field names, which is not a partial translation — it is the seam showing through at the exact place a person is being asked to write something. One language is computed and the other is written down, and the asymmetry is real rather than arbitrary: these keys ARE English, so a label derived from `cpuCoreLimit` is right by construction, while no amount of string-splitting turns an English identifier into Spanish. Word order alone defeats it — `technicalFeasibilityId` is «ID de viabilidad técnica», not three words in the order they were written. So the Spanish has to come from somewhere, and this is where. It is keyed by LEAF NAME, not by field path. 559 published fields across the corpus are only 348 distinct names, and keying by name is what makes `status` read «Estado» in all nineteen schemas that declare it instead of nineteen chances to say it differently. A schema whose context makes a shared word wrong can override it with `x-title-es` on the property — the glossary is an addition to what the schemas say, never a replacement. Both languages travel TOGETHER on every field. A consumer syncs this catalogue on a timer, tenant-agnostic and cached, and then renders it for whoever happens to be looking; publishing one language per request would mean either a fetch per reader or documents in the wrong language. Nothing here can take the catalogue down. A missing or unreadable glossary costs the Spanish labels and nothing else, and a field with no entry reaches a reader with its English name — plain, not broken. The catalogue is what every gate depends on, and no translation is worth failing it for. The guard closes both directions, because a glossary rots two ways. A field added with no entry half-translates a form, which nobody notices until a customer does. An entry left behind by a rename looks exactly like coverage and translates nothing. Neither is visible to any other check in this repository, which is the whole reason this one exists — and its tests were written against a version that only looked for the first kind. Descriptions are still English. They are 252 sentences rather than 348 names, they are prose rather than labels, and they belong in their own pass. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .github/workflows/docs-release.yml | 3 + .github/workflows/docs.yml | 3 + .../ci/71-validate-field-label-coverage.mjs | 126 +++++++ .../71-validate-field-label-coverage.test.mjs | 70 ++++ .../artifact-field-derivation.spec.ts | 60 +++ .../services/artifact-field-derivation.ts | 46 ++- .../services/core-reference-query.service.ts | 23 +- src/rulesets/i18n/field-labels.es.json | 350 ++++++++++++++++++ 8 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 .harness/scripts/ci/71-validate-field-label-coverage.mjs create mode 100644 .harness/scripts/ci/71-validate-field-label-coverage.test.mjs create mode 100644 src/rulesets/i18n/field-labels.es.json 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..f2c90d9e --- /dev/null +++ b/.harness/scripts/ci/71-validate-field-label-coverage.mjs @@ -0,0 +1,126 @@ +#!/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'); + +/** + * The leaf field names the corpus publishes. + * + * 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; + if (type === 'object' && child.properties) { + walk(child, file); + continue; + } + if (!names.has(key)) names.set(key, new Set()); + names.get(key).add(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 (type === 'object' && child.properties) { + walk(child, file); + continue; + } + if (!names.has(key)) names.set(key, new Set()); + names.get(key).add(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..b987004e --- /dev/null +++ b/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs @@ -0,0 +1,70 @@ +/** + * 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', '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 nested field is asked for by its own name, not its parent', () => { + assert.equal(fieldNamesIn(corpus).has('metadata'), false); + assert.equal(fieldNamesIn(corpus).has('identifier'), true); +}); + +test('a field with no entry is reported', () => { + const { untranslated } = coverageProblems(fieldNamesIn(corpus), { status: 'Estado' }); + 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', + oldNameNobodyPublishes: 'Fantasma', + }); + assert.deepEqual(orphans, ['oldNameNobodyPublishes']); +}); + +test('an empty translation is a missing label, not a word', () => { + const { blank } = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + identifier: ' ', + }); + assert.deepEqual(blank, ['identifier']); +}); + +test('a full glossary reports nothing', () => { + const problems = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + 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 index ed4524c5..14ebe78f 100644 --- 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 @@ -132,6 +132,66 @@ describe('artifact field derivation', () => { 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([]); 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 index 858b1714..28e8126d 100644 --- a/src/apps/core-api/src/application/services/artifact-field-derivation.ts +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -28,11 +28,33 @@ export interface ArtifactField { 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; 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 { + /** Field name → Spanish label. Keyed by the LEAF name, so `status` is «Estado» everywhere. */ + labelsEs?: Record; +} + export interface ArtifactFieldDerivation { fields: ArtifactField[]; /** @@ -49,6 +71,8 @@ export interface ArtifactFieldDerivation { 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[]; @@ -83,6 +107,20 @@ const ACRONYMS = new Set([ * 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; @@ -141,7 +179,10 @@ function typeFor(node: JsonSchemaNode): ArtifactFieldType | null { * 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 { +export function deriveArtifactFields( + schema: unknown, + options: ArtifactFieldDerivationOptions = {}, +): ArtifactFieldDerivation { const fields: ArtifactField[] = []; const omitted: { fieldPath: string; reason: string }[] = []; @@ -177,10 +218,13 @@ export function deriveArtifactFields(schema: unknown): ArtifactFieldDerivation { continue; } + const labelEs = labelEsFor(key, child, options.labelsEs); + fields.push({ fieldPath, type, label: labelFor(key, child), + ...(labelEs ? { labelEs } : {}), required, ...(type === 'enum' && Array.isArray(child.enum) ? { enumValues: child.enum.map((v) => String(v)) } 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 adf5c544..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 @@ -159,10 +159,31 @@ export class CoreReferenceQueryService { * 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; @@ -175,7 +196,7 @@ export class CoreReferenceQueryService { try { const schema = JSON.parse(await this.fs.readFile(schemaFile)); - const { fields, omitted } = deriveArtifactFields(schema); + const { fields, omitted } = deriveArtifactFields(schema, { labelsEs }); return { ...artifact, fields, diff --git a/src/rulesets/i18n/field-labels.es.json b/src/rulesets/i18n/field-labels.es.json new file mode 100644 index 00000000..99271b4b --- /dev/null +++ b/src/rulesets/i18n/field-labels.es.json @@ -0,0 +1,350 @@ +{ + "accountableRole": "Rol responsable", + "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", + "approvalStatus": "Estado de aprobación", + "approvedBy": "Aprobado por", + "approver": "Aprobador", + "architectSignOff": "Visto bueno del arquitecto", + "architecture": "Arquitectura", + "architectureVersion": "Versión de la arquitectura", + "asOf": "A fecha de", + "assertedAtUtc": "Declarado el (UTC)", + "assertedBy": "Declarado por", + "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", + "businessContext": "Contexto de negocio", + "businessSignOff": "Visto bueno de negocio", + "category": "Categoría", + "changeSetRef": "Referencia del conjunto de cambios", + "changeSummary": "Resumen de cambios", + "checkpointId": "ID del checkpoint", + "class": "Clase", + "cloneUrl": "URL de clonado", + "code": "Código", + "coldStartCeilingMs": "Techo de arranque en frío (ms)", + "column": "Columna", + "command": "Comando", + "commit": "Commit", + "completeForCriticalPaths": "Completo en los caminos críticos", + "complexity": "Complejidad", + "compliance": "Cumplimiento", + "component": "Componente", + "concern": "Preocupación", + "concurrencyRequestsSec": "Concurrencia (peticiones/s)", + "confidence": "Confianza", + "configurationContract": "Contrato de configuración", + "construction": "Construcción", + "contentHash": "Hash del contenido", + "content_fingerprint": "Huella del contenido", + "context": "Contexto", + "core": "Core", + "corePath": "Ruta del Core", + "coreVersion": "Versión del Core", + "correlationId": "ID de correlación", + "costCeilingPerExecutionCents": "Techo de coste por ejecución (céntimos)", + "count": "Cantidad", + "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", + "currentPhase": "Fase actual", + "customConstraints": "Restricciones propias", + "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", + "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)", + "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", + "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", + "exitCriteria": "Criterios de salida", + "expectedResult": "Resultado esperado", + "expirationDate": "Fecha de caducidad", + "extractedAt": "Extraído el", + "extractedBy": "Extraído por", + "extractorVersion": "Versión del extractor", + "file": "Fichero", + "fingerprint": "Huella", + "framework": "Framework", + "from": "Desde", + "frozen": "Congelado", + "functionalScope": "Alcance funcional", + "functionalStory": "Historia funcional", + "functionalStoryId": "ID de la historia funcional", + "gateId": "ID de gate", + "gatePhase": "Fase de la gate", + "generatedAt": "Generado el", + "generated_at": "Generado el", + "guard": "Guarda", + "handoffDate": "Fecha de traspaso", + "healthEndpoint": "Endpoint de salud", + "high": "Altos", + "highFindings": "Hallazgos altos", + "href": "Enlace", + "id": "ID", + "identifier": "Identificador", + "indexer": "Indexador", + "indexerVersion": "Versión del indexador", + "initiative": "Iniciativa", + "initiativeGroupId": "ID del grupo de iniciativas", + "initiativeId": "ID de iniciativa", + "initiativeName": "Nombre de la iniciativa", + "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", + "line": "Línea", + "linkedAt": "Enlazado el", + "localAdrTagEnforcement": "Exigencia de etiqueta ADR local", + "locator": "Localizador", + "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)", + "medium": "Medios", + "mediumFindings": "Hallazgos medios", + "meetingNotes": "Notas de la reunión", + "memoryGbLimit": "Límite de memoria (GB)", + "message": "Mensaje", + "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", + "occurredAt": "Ocurrió el", + "occurrences": "Apariciones", + "onCallLead": "Responsable de guardia", + "opa_equivalent": "Equivalente en OPA", + "opa_policy": "Política OPA", + "operatingBurden": "Carga operativa", + "order": "Orden", + "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", + "phaseArtifacts": "Artefactos de la fase", + "phaseId": "ID de fase", + "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", + "projection_version": "Versión de la proyección", + "promoted_at": "Promovido el", + "promoted_by": "Promovido por", + "proposedBy": "Propuesto por", + "proposedSolution": "Solución propuesta", + "provenance": "Procedencia", + "providerReplaceability": "Reemplazabilidad del proveedor", + "pull_request": "Pull request", + "qaLead": "Responsable de QA", + "quality": "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", + "repoUrl": "URL del repositorio", + "repository": "Repositorio", + "repositoryRef": "Referencia del repositorio", + "required": "Obligatorio", + "requiredCorrection": "Corrección exigida", + "result": "Resultado", + "retentionPeriod": "Periodo de retención", + "retention_mode": "Modo de retención", + "retrieved_at": "Recuperado el", + "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", + "rollbackRef": "Referencia del plan de reversión", + "ruleId": "ID de la regla", + "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", + "sdlcConfig": "Configuración del SDLC", + "securityCompliance": "Cumplimiento de seguridad", + "sensitivity": "Sensibilidad", + "severity": "Severidad", + "shortName": "Nombre corto", + "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", + "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", + "techLead": "Líder técnico", + "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", + "testSummaryRef": "Referencia del resumen de pruebas", + "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", + "trust_level": "Nivel de confianza", + "type": "Tipo", + "upId": "ID de la propuesta upstream", + "updatedAt": "Actualizado el", + "valid": "Válidos", + "value": "Valor", + "verdict": "Veredicto", + "version": "Versión", + "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", + "withinBaseline": "Dentro de la línea base", + "withinBudget": "Dentro del presupuesto", + "withinSlo": "Dentro del SLO", + "work": "Obra", + "workspaceRef": "Referencia del espacio de trabajo" +} From d869cc4cc11f6c58448d72842e4afadad9beb391 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 24 Aug 2026 09:03:20 -0500 Subject: [PATCH 4/4] feat(reference): publish the section a field sits in, in both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field names came out in Spanish and the headings above them did not, which is the same half-translation one line higher up: «Límite de núcleos de CPU» under «Technical constraints». The section was being recovered downstream by splitting the path, and that is the whole problem — splitting `technicalConstraints.cpuCoreLimit` yields «Technical constraints» and can never yield «Restricciones técnicas». The words belong to whoever owns the shape, headings included, so the enclosing object's name now travels down with the fields it holds. An object still is not a field: publishing the container as a field would offer a path whose value is a document, which no operator can compare. It is a name a person reads, which is a different thing, and it is carried as one. The glossary needed the 96 container names, and the guard needed to start demanding them. That second part is the point: without it the corpus could grow a section nobody translated and the only symptom would be one English heading in a Spanish form — which is exactly the kind of thing that ships. The guard's own tests were updated to say so, including the one that used to assert a container was NOT asked for. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../ci/71-validate-field-label-coverage.mjs | 17 ++-- .../71-validate-field-label-coverage.test.mjs | 16 +++- .../services/artifact-field-derivation.ts | 34 +++++++- src/rulesets/i18n/field-labels.es.json | 79 +++++++++++++++++++ 4 files changed, 129 insertions(+), 17 deletions(-) diff --git a/.harness/scripts/ci/71-validate-field-label-coverage.mjs b/.harness/scripts/ci/71-validate-field-label-coverage.mjs index f2c90d9e..9603f7bc 100644 --- a/.harness/scripts/ci/71-validate-field-label-coverage.mjs +++ b/.harness/scripts/ci/71-validate-field-label-coverage.mjs @@ -23,7 +23,7 @@ const SCHEMA_DIR = path.join(ROOT, 'src', 'rulesets', 'schema'); const GLOSSARY = path.join(ROOT, 'src', 'rulesets', 'i18n', 'field-labels.es.json'); /** - * The leaf field names the corpus publishes. + * 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. @@ -36,12 +36,14 @@ export function fieldNamesIn(schemas) { 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 (type === 'object' && child.properties) { - walk(child, file); - 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); } }; @@ -69,12 +71,9 @@ function publishedFieldNames() { 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 (type === 'object' && child.properties) { - walk(child, file); - continue; - } if (!names.has(key)) names.set(key, new Set()); names.get(key).add(file); + if (type === 'object' && child.properties) walk(child, file); } }; diff --git a/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs b/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs index b987004e..657f5944 100644 --- a/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs +++ b/.harness/scripts/ci/71-validate-field-label-coverage.test.mjs @@ -22,7 +22,7 @@ const corpus = { }; test('it asks for a name for every field that is published', () => { - assert.deepEqual([...fieldNamesIn(corpus).keys()].sort(), ['identifier', 'status']); + assert.deepEqual([...fieldNamesIn(corpus).keys()].sort(), ['identifier', 'metadata', 'status']); }); test('it does not ask for words nobody will read', () => { @@ -33,13 +33,18 @@ test('it does not ask for words nobody will read', () => { assert.equal(names.has('$schema'), false); }); -test('a nested field is asked for by its own name, not its parent', () => { - assert.equal(fieldNamesIn(corpus).has('metadata'), 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' }); + const { untranslated } = coverageProblems(fieldNamesIn(corpus), { + status: 'Estado', + metadata: 'Metadatos', + }); assert.deepEqual(untranslated, ['identifier']); }); @@ -48,6 +53,7 @@ test('AN ENTRY LEFT BEHIND BY A RENAME IS REPORTED', () => { const { orphans } = coverageProblems(fieldNamesIn(corpus), { status: 'Estado', identifier: 'Identificador', + metadata: 'Metadatos', oldNameNobodyPublishes: 'Fantasma', }); assert.deepEqual(orphans, ['oldNameNobodyPublishes']); @@ -56,6 +62,7 @@ test('AN ENTRY LEFT BEHIND BY A RENAME IS REPORTED', () => { 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']); @@ -64,6 +71,7 @@ test('an empty translation is a missing label, not a word', () => { 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.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.ts index 28e8126d..57882ce4 100644 --- a/src/apps/core-api/src/application/services/artifact-field-derivation.ts +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -37,6 +37,17 @@ export interface ArtifactField { * 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; @@ -51,7 +62,11 @@ export interface ArtifactField { * why one language is computed and the other is written down. */ export interface ArtifactFieldDerivationOptions { - /** Field name → Spanish label. Keyed by the LEAF name, so `status` is «Estado» everywhere. */ + /** + * 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; } @@ -186,7 +201,12 @@ export function deriveArtifactFields( const fields: ArtifactField[] = []; const omitted: { fieldPath: string; reason: string }[] = []; - const walk = (node: JsonSchemaNode, prefix: string, requiredHere: Set): void => { + const walk = ( + node: JsonSchemaNode, + prefix: string, + requiredHere: Set, + group?: { label: string; labelEs?: string }, + ): void => { const properties = node.properties; if (!properties) return; @@ -207,8 +227,12 @@ export function deriveArtifactFields( 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 ?? [])); + // 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; } @@ -225,6 +249,8 @@ export function deriveArtifactFields( 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)) } diff --git a/src/rulesets/i18n/field-labels.es.json b/src/rulesets/i18n/field-labels.es.json index 99271b4b..cf206a24 100644 --- a/src/rulesets/i18n/field-labels.es.json +++ b/src/rulesets/i18n/field-labels.es.json @@ -1,5 +1,7 @@ { + "acceptanceCriteria": "Criterios de aceptación", "accountableRole": "Rol responsable", + "actors": "Actores", "actualRollbackTimeMinutes": "Tiempo real de reversión (min)", "adr": "ADR", "adrId": "ID del ADR", @@ -12,15 +14,18 @@ "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", @@ -37,37 +42,50 @@ "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", @@ -77,8 +95,10 @@ "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", @@ -88,6 +108,7 @@ "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", @@ -97,6 +118,7 @@ "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", @@ -105,6 +127,7 @@ "engine": "Motor", "environment": "Entorno", "epic": "Épica", + "error": "Error", "errorRate": "Tasa de error", "errorRatePercent": "Tasa de error (%)", "errorVolume": "Volumen de errores", @@ -116,25 +139,35 @@ "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", @@ -142,12 +175,16 @@ "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", @@ -162,10 +199,12 @@ "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", @@ -177,11 +216,13 @@ "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", @@ -198,13 +239,17 @@ "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", @@ -217,8 +262,13 @@ "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", @@ -231,9 +281,13 @@ "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", @@ -241,6 +295,8 @@ "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", @@ -252,15 +308,18 @@ "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", @@ -268,8 +327,10 @@ "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", @@ -282,17 +343,23 @@ "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)", @@ -307,7 +374,9 @@ "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", @@ -316,7 +385,11 @@ "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", @@ -329,22 +402,28 @@ "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" }