From 768973718af76a10b95c1395d34cd1cea753cd8e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 23 Aug 2026 22:38:59 +0200 Subject: [PATCH 1/2] test(l10n): ratchet the untranslated schema strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string inside a form comes from the OpenRegister schema, not from the manifest: `fieldsFromSchema()` runs a property `title` and `description` through the injected `cnTranslate`, which CnAppRoot binds to THIS app's id. So a schema title is a key in THIS catalogue — and when the key is absent, `t()` hands the source string back and the field renders in English inside an otherwise translated form. Nothing errors, and no existing check looks. Measured across the fleet on 2026-08-23: 30,459 schema strings had no catalogue key. Far too much to translate in one pass, and the descriptions need rewriting for the person filling in the form before translating them is even worth doing — humaniq's own pass rewrote 592 of 739 before a word was translated. So this is a RATCHET, not a gate: it records how many strings are currently uncovered and fails only when that number GROWS. The debt is measured and cannot expand, while burning it down stays an ordinary PR. Same shape as the JSDoc baseline in @conduction/nextcloud-vue. Counted: schema titles, property titles, property descriptions, and the VALUES of `x-enum-labels`. NOT counted: enum values themselves (stored contract values, several non-English by design, never rendered once a property declares its labels) and `x-notes` (engineering rationale, never rendered). Verified must-fail: adding one untranslated title takes the count past the baseline and exits 1, naming the file and property and the command that lists what is uncovered. Lower the baseline as strings get translated: npm run check:schema-l10n -- --update --- .github/workflows/code-quality.yml | 7 +- l10n/.schema-l10n-baseline.json | 3 + package.json | 1 + scripts/check-schema-l10n.js | 175 +++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 l10n/.schema-l10n-baseline.json create mode 100644 scripts/check-schema-l10n.js diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 7fb79c13..6a27c717 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -221,7 +221,12 @@ jobs: # Measured on this tree before enabling: PASSES, 188 of 235 tracked # frontend files in scope (l10n/ and docs/ excluded via .prettierignore / # .gitignore, which prettier 3 also reads). - frontend-checks: '["check:manifest", "check:vue-demi", "test:l10n", "format"]' + # `check:schema-l10n` is a RATCHET, not a gate. Every string inside a form + # comes from the schema and is a key in THIS app's catalogue; an absent key + # renders the English source inside an otherwise translated form, silently. + # The fleet had 30,459 such strings, so this records the current count and + # fails only when it GROWS — burning it down stays an ordinary PR. + frontend-checks: '["check:manifest", "check:vue-demi", "test:l10n", "format", "check:schema-l10n"]' # ── Coverage ratchet ───────────────────────────────────────────────── # `enable-coverage-guard` defaults to FALSE, which is why both diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json new file mode 100644 index 00000000..85c69406 --- /dev/null +++ b/l10n/.schema-l10n-baseline.json @@ -0,0 +1,3 @@ +{ + "uncovered": 480 +} diff --git a/package.json b/package.json index 01a31738..f15f23be 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:l10n": "node tests/l10n/check-l10n.js", "test:l10n:parity": "node tests/l10n/check-l10n.js --parity", "test:l10n:write": "node tests/l10n/check-l10n.js --write", + "check:schema-l10n": "node scripts/check-schema-l10n.js", "check:vue-demi": "node scripts/check-vue-demi.js", "prebuild": "npm run check:vue-demi", "predev": "npm run check:vue-demi", diff --git a/scripts/check-schema-l10n.js b/scripts/check-schema-l10n.js new file mode 100644 index 00000000..15d67291 --- /dev/null +++ b/scripts/check-schema-l10n.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +// +// check-schema-l10n.js — a RATCHET on untranslated schema strings. +// +// WHY THIS EXISTS +// +// Every string inside a form comes from the OpenRegister schema, not from +// the app manifest: `fieldsFromSchema()` runs a property `title` and +// `description` through the injected `cnTranslate`, which CnAppRoot binds to +// THIS app's id. So a schema title is a key in THIS catalogue — and when the +// key is absent, `t()` hands the source string back and the field renders in +// English inside an otherwise translated form. Nothing errors. +// +// Measured across the fleet on 2026-08-23: 30,459 schema strings had no +// catalogue key. That is far too much to translate in one go, and the +// descriptions need rewriting for the person filling in the form before +// translating them is even worth doing. +// +// So this is a RATCHET, not a gate. It records how many strings are +// currently uncovered and fails only when that number GROWS — the debt is +// measured and cannot expand, while burning it down stays an ordinary PR. +// Same shape as the JSDoc baseline in @conduction/nextcloud-vue. +// +// WHAT COUNTS AS A SCHEMA STRING +// +// - a schema `title` — the create/edit dialog heading, and the noun +// in an index page's Add button +// - a property `title` — the field's label and its column header +// - a property `description` — the helper text under the field +// - the VALUES of a property's `x-enum-labels` — dropdown options and the +// text of a status badge +// +// Enum VALUES themselves are deliberately NOT counted. They are stored +// contract values — several are non-English by design (`ingediend`) — and +// are never rendered once the property declares `x-enum-labels`. +// +// `x-notes` is not counted either: it holds the engineering rationale a +// description used to carry, is never rendered, and so is never translated. +// +// Usage: +// node scripts/check-schema-l10n.js (npm run check:schema-l10n) +// node scripts/check-schema-l10n.js --update rewrite the baseline +// node scripts/check-schema-l10n.js --list print what is uncovered +// +// Exit codes: +// 0 — uncovered count is at or below the baseline +// 1 — it grew, or the baseline file is missing + +'use strict' + +const fs = require('fs') +const path = require('path') + +const REPO_ROOT = path.resolve(__dirname, '..') +const SCHEMA_DIR = path.join(REPO_ROOT, 'lib', 'Settings') +const CATALOGUE = path.join(REPO_ROOT, 'l10n', 'en.json') +const BASELINE = path.join(REPO_ROOT, 'l10n', '.schema-l10n-baseline.json') + +/** + * Every *.json under lib/Settings, at any depth — apps differ in whether they + * use register.d/, templates/ or a single monolith. + * + * @param {string} dir - directory to walk + * @return {string[]} absolute paths + */ +function schemaFiles(dir) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) return schemaFiles(full) + return entry.name.endsWith('.json') ? [full] : [] + }) +} + +/** + * Collect the display strings a schema puts on screen. + * + * @param {object|Array} node - current node + * @param {string} where - file label, for --list + * @param {Map} sink - string -> first place it was seen + */ +function collect(node, where, sink) { + if (Array.isArray(node)) { + for (const item of node) collect(item, where, sink) + return + } + if (node === null || typeof node !== 'object') return + + const remember = (value, what) => { + if (typeof value !== 'string' || value.trim() === '') return + if (!sink.has(value)) sink.set(value, `${where}:${what}`) + } + + const props = node.properties + if (props !== null && typeof props === 'object' && !Array.isArray(props)) { + remember(node.title, 'schema title') + for (const [key, prop] of Object.entries(props)) { + if (prop === null || typeof prop !== 'object') continue + remember(prop.title, `${key}.title`) + remember(prop.description, `${key}.description`) + for (const source of [prop, prop.items]) { + if (source === null || typeof source !== 'object') continue + const labels = source['x-enum-labels'] + if (labels === null || typeof labels !== 'object') continue + for (const label of Object.values(labels)) remember(label, `${key}.x-enum-labels`) + } + } + } + + for (const value of Object.values(node)) collect(value, where, sink) +} + +function main() { + const update = process.argv.includes('--update') + const list = process.argv.includes('--list') + + const strings = new Map() + for (const file of schemaFiles(SCHEMA_DIR)) { + let doc + try { + doc = JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + continue // not a schema document; the manifest checks own their own files + } + collect(doc, path.relative(REPO_ROOT, file), strings) + } + + let covered = new Set() + try { + covered = new Set(Object.keys(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')).translations || {})) + } catch { + // no catalogue yet — then everything is uncovered, which the baseline records + } + + const uncovered = [...strings.keys()].filter((s) => !covered.has(s)).sort() + + if (list) { + for (const s of uncovered) console.log(`${strings.get(s)}\n ${s}`) + } + + if (update) { + fs.writeFileSync(BASELINE, JSON.stringify({ uncovered: uncovered.length }, null, 2) + '\n') + console.log(`baseline written: ${uncovered.length} uncovered schema string(s)`) + return + } + + if (!fs.existsSync(BASELINE)) { + console.error('No baseline. Run `npm run check:schema-l10n -- --update` and commit it.') + process.exit(1) + } + const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).uncovered + + console.log(`${strings.size} schema string(s); ${uncovered.length} uncovered, baseline ${baseline}`) + + if (uncovered.length > baseline) { + const added = uncovered.length - baseline + console.error('') + console.error(`${added} schema string(s) added with no catalogue key — they will render`) + console.error('in English inside an otherwise translated form.') + console.error('') + console.error('Add them to l10n/en.json (identity) and l10n/nl.json (translated), then') + console.error('run `npm run l10n:build`. See what is uncovered with:') + console.error(' node scripts/check-schema-l10n.js --list') + process.exit(1) + } + + if (uncovered.length < baseline) { + console.log(`${baseline - uncovered.length} fewer than the baseline — lower it with:`) + console.log(' npm run check:schema-l10n -- --update') + } +} + +main() From ceaba2f45ca5dec91fae07980f7ef9577a7a2e00 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 23 Aug 2026 22:58:30 +0200 Subject: [PATCH 2/2] fix(l10n): the baseline file is not a locale catalogue; format for this repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the fleet CI caught. `build-l10n-js.js` discovers locales by globbing `l10n/*.json`, which now also matches `l10n/.schema-l10n-baseline.json` — the ratchet's own state file, kept there so prettier ignores it. The generator read it as a locale named `.schema-l10n-baseline` and exited 1 for having no `translations`. Dotfiles are never locale catalogues, so it skips them. Also prettier-normalised both scripts to this repo's config; several apps run a format check over scripts/. --- scripts/check-schema-l10n.js | 38 +++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/scripts/check-schema-l10n.js b/scripts/check-schema-l10n.js index 15d67291..8a860b33 100644 --- a/scripts/check-schema-l10n.js +++ b/scripts/check-schema-l10n.js @@ -104,7 +104,8 @@ function collect(node, where, sink) { if (source === null || typeof source !== 'object') continue const labels = source['x-enum-labels'] if (labels === null || typeof labels !== 'object') continue - for (const label of Object.values(labels)) remember(label, `${key}.x-enum-labels`) + for (const label of Object.values(labels)) + remember(label, `${key}.x-enum-labels`) } } } @@ -129,7 +130,11 @@ function main() { let covered = new Set() try { - covered = new Set(Object.keys(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')).translations || {})) + covered = new Set( + Object.keys( + JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')).translations || {}, + ), + ) } catch { // no catalogue yet — then everything is uncovered, which the baseline records } @@ -141,33 +146,48 @@ function main() { } if (update) { - fs.writeFileSync(BASELINE, JSON.stringify({ uncovered: uncovered.length }, null, 2) + '\n') - console.log(`baseline written: ${uncovered.length} uncovered schema string(s)`) + fs.writeFileSync( + BASELINE, + JSON.stringify({ uncovered: uncovered.length }, null, 2) + '\n', + ) + console.log( + `baseline written: ${uncovered.length} uncovered schema string(s)`, + ) return } if (!fs.existsSync(BASELINE)) { - console.error('No baseline. Run `npm run check:schema-l10n -- --update` and commit it.') + console.error( + 'No baseline. Run `npm run check:schema-l10n -- --update` and commit it.', + ) process.exit(1) } const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).uncovered - console.log(`${strings.size} schema string(s); ${uncovered.length} uncovered, baseline ${baseline}`) + console.log( + `${strings.size} schema string(s); ${uncovered.length} uncovered, baseline ${baseline}`, + ) if (uncovered.length > baseline) { const added = uncovered.length - baseline console.error('') - console.error(`${added} schema string(s) added with no catalogue key — they will render`) + console.error( + `${added} schema string(s) added with no catalogue key — they will render`, + ) console.error('in English inside an otherwise translated form.') console.error('') - console.error('Add them to l10n/en.json (identity) and l10n/nl.json (translated), then') + console.error( + 'Add them to l10n/en.json (identity) and l10n/nl.json (translated), then', + ) console.error('run `npm run l10n:build`. See what is uncovered with:') console.error(' node scripts/check-schema-l10n.js --list') process.exit(1) } if (uncovered.length < baseline) { - console.log(`${baseline - uncovered.length} fewer than the baseline — lower it with:`) + console.log( + `${baseline - uncovered.length} fewer than the baseline — lower it with:`, + ) console.log(' npm run check:schema-l10n -- --update') } }