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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docs-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
125 changes: 125 additions & 0 deletions .harness/scripts/ci/71-validate-field-label-coverage.mjs
Original file line number Diff line number Diff line change
@@ -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();
78 changes: 78 additions & 0 deletions .harness/scripts/ci/71-validate-field-label-coverage.test.mjs
Original file line number Diff line number Diff line change
@@ -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: [] });
});
Loading
Loading