diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e463691b5e..412b196513 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5199,6 +5199,27 @@ jobs: - name: Check no new untranslated declared labels run: pnpm check:i18n-coverage + # The i18n question none of its three neighbours can answer, because it + # judges THEM rather than the tree. Each of them measures a POPULATION the + # CLI extractor produces; none can notice that the population is short a + # whole family. A translation group lands in the spec with a resolver, + # the extractor is updated later or not at all, and in between `os i18n + # extract` scaffolds no key for it — so no translator is asked for one, so + # the coverage ratchet counts a debt of zero and reports OK while the + # strings ship in English. That has now happened five times, and every + # instance was found by a person noticing rather than by an instrument. + # + # This asserts set parity between the groups `TranslationDataSchema` + # DECLARES and the groups `collectExpectedEntries` WALKS, with a + # shrink-only exemption ledger — empty as it ships — for a group that + # legitimately has no extractor face, each entry carrying its reason. + # + # Reads both sides from BUILT output (the spec's schema object, the CLI's + # walker) rather than from source text, so it belongs here with the other + # post-build consumer gates. + - name: Check every declared translation group is walked by the extractor + run: pnpm check:i18n-walk-parity + # The THIRD i18n question, and the one neither step above can answer # (#5750). Both of them read STATIC declarations; the Setup app declares a # shell of empty group anchors and gets its ~50 menu entries at RUNTIME diff --git a/package.json b/package.json index da36813abb..a89015f715 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --source-hashes --out=packages/platform-objects/src/apps/translations", "check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs", "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs", + "check:i18n-walk-parity": "node scripts/check-i18n-walk-parity.mjs --self-test && node scripts/check-i18n-walk-parity.mjs", "check:i18n-stale-fill": "node scripts/check-i18n-stale-fill.mjs --self-test && node scripts/check-i18n-stale-fill.mjs", "check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", diff --git a/scripts/check-i18n-walk-parity.mjs b/scripts/check-i18n-walk-parity.mjs new file mode 100644 index 0000000000..d029dd3b2a --- /dev/null +++ b/scripts/check-i18n-walk-parity.mjs @@ -0,0 +1,587 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-i18n-walk-parity — every translation GROUP `@objectstack/spec` declares + * is a group the CLI extractor actually walks. + * + * node scripts/check-i18n-walk-parity.mjs # the parity verdict + * node scripts/check-i18n-walk-parity.mjs --list # declared / walked / ledger, side by side + * node scripts/check-i18n-walk-parity.mjs --self-test # the classifiers, no build needed + * + * ## The defect class, which has now recurred five times + * + * A translation key family lands in `packages/spec` with a RESOLVER that reads + * it, and the CLI EXTRACTOR that produces the coverage population is updated + * later, or not at all. Between those two moments every gate is green while the + * ratchet measures a population it cannot see — `os i18n extract` scaffolds no + * key for the family, so no translator is ever asked for one, so + * `check:i18n-coverage` counts a debt of zero and reports OK. + * + * flow screens resolver landed, extractor followed one release later + * nested page components `translatePage` learned depth; the walker did not + * bulk actions \ + * datasets > three families at once: declared, each with a + * object validation msgs / reader, and ZERO keys produced + * inline object actions + the walker's own header records this one, from + * object-nested listViews before the four above ("scaffoldable but ungated, + * which is how English approval buttons shipped") + * + * Every one was found by a person noticing. The measurement that closes the + * loop did not exist; this file is it. The property that makes it worth its + * cost is not that it is thorough — it is that it would have failed on the day + * the three-family change landed, which is exactly what the four earlier + * instances lacked. + * + * ## What it compares, and why it is not a derivation + * + * ⛔ This gate does NOT derive the extractor from the resolvers. That was + * measured and rejected: the two enumerations answer different questions over + * different inputs — a resolver reads the SERVED document shape, the walker + * reads the AUTHORED stack config; key identity is the walker's own problem (a + * view container's default list resolves its registry name and emits no key of + * its own); the `inline` / `sourceValue` split exists only walker-side and + * turns on renderer fallbacks that live in another repo; and the deliberate + * exclusions live in a third place again. + * + * What IS machine-readable is the half that keeps drifting: the key FACE. So + * the comparison is deliberately coarse and total — + * + * declared the top-level keys of `translationDataShape()`, read off the + * BUILT `TranslationDataSchema`'s zod shape. The contract, not the + * source text: a gate that greps the `.ts` file would agree with a + * comment and disagree with the schema. + * walked the `path[0]` of every entry `collectExpectedEntries` produces + * over one fixture that authors a member of every group. The + * walker's own output, not a transcription of its header. + * ledger the groups that legitimately have no extractor face at all, each + * carrying the REASON. Starts EMPTY and only shrinks (below). + * + * and the assertion is two set differences: + * + * declared \ (walked ∪ ledger) = ∅ an unwalked group — the defect class + * ledger \ declared = ∅ a stale ledger entry + * ledger ∩ walked = ∅ a stale ledger entry (it IS walked) + * + * ## Why the unit is the TOP-LEVEL group and not every leaf key + * + * Because that is the unit the two sides can both name. `path[0]` is what the + * walker emits and a top-level shape key is what the schema declares; below + * that line the two vocabularies stop corresponding — the shape says + * `dimensions..label` is a record of strict objects, the walker says it is + * one `pushOptional` inside a loop, and neither is derivable from the other. + * The liveness ledger for this same schema draws the boundary in the same + * place and says so out loud: "WALK BOUNDARY: every group is a z.record keyed + * by target names — the drill sees each record's VALUE shape one level; the + * deeper per-key conventions … are governed by the resolvers cited per row". + * A per-leaf gate would be a second, hand-maintained face of the walker, which + * is the thing this repo removes rather than adds. + * + * The cost of the coarse unit is stated rather than hidden: a group that gains + * a NEW LEAF the walker does not emit stays green here. That is a narrower + * defect than a whole family with no keys at all, and it is the one the + * per-family pin tests under `packages/cli/test/i18n-*-coverage.test.ts` are + * for. + * + * ## The exemption ledger, and the two constraints that keep it honest + * + * Some groups may legitimately have no extractor face — a group keyed by + * strings no stack config declares cannot be scaffolded from one. A gate that + * can say "this one legitimately has none" OUT LOUD is worth building; a gate + * whose ledger grows quietly is worse than no gate, because it converts a + * visible drift into an invisible one. So: + * + * 1. Every entry carries the reason the group has no extractor face. A blank + * or placeholder reason fails — see `LEDGER_REASON_FLOOR`. + * 2. It is SHRINK-ONLY, in the `KNOWN_IMPORT_UNSAFE` shape one file over: a + * stale entry fails and names itself, and `LEDGER_CEILING` refuses growth + * the author did not edit in the same diff. Both directions of that + * ratchet are enforced — a ceiling above the real size fails too, so the + * number can only be walked down. + * + * ⛔ A red is not a ledger entry, and the ledger path is MAINTAINER-ONLY. "This + * group legitimately has no extractor face" is a decision with a reason + * attached, owned by whoever owns the walker; putting a group in here to get to + * green is the same silence this gate exists to break, now with a comment on + * it. The unwalked-group message names that owner beside the offer, which is + * what `check:ratchet-remedy-authority` requires of a remedy that expands a + * shrink-only registry (#8435) — remedy 1, emitting the group, is the landing + * author's and is offered freely. + * + * ## What it requires, and what it refuses to do without + * + * Both sides are read from BUILT output — `packages/spec/dist` for the schema + * and `packages/cli/dist` for the walker — the same prerequisite + * `check-i18n-coverage.mjs` states one file over, and for the same reason: an + * unbuilt tree makes the walker unimportable, and a gate that falls back to + * source text would answer a different question with the same exit code. + * "Prefer failing to falling back": the prerequisite verdict is a HARD failure + * that says it measured NOTHING, never a skip. An empty declared or walked set + * is refused for the same reason — zero groups is a broken scan, not a schema + * with nothing in it. + * + * `--self-test` needs neither: it drives the pure classifiers against recorded + * samples, exactly as its sibling does. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +// Imported for the remedy vocabulary — and, deliberately, for what a consumer +// of that module INHERITS as its declared population: `packages/cli/src`, the +// tree whose compiled output is this gate's walked side. A card that deletes an +// emitter from the walker is exactly the card that must see this gate named in +// its brief, and that is the edge this import buys. +import { CLI_BUILD_FIX, workspaceBuildFix } from './cli-build-prerequisite.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** This script lives in `scripts/`, so the repo root is one level up. */ +const REPO_ROOT = resolve(HERE, '..'); +/** The ONE seam between this module's repo-relative vocabulary and the disk. */ +const at = (rel) => join(REPO_ROOT, rel); + +// ── The two built inputs, and the fixture ─────────────────────────────────── + +/** The DECLARED side: `TranslationDataSchema`, from the built spec. */ +const SPEC_SYSTEM_DIST = 'packages/spec/dist/system/index.mjs'; +/** The WALKED side: `collectExpectedEntries`, from the built CLI. */ +const CLI_WALKER_DIST = 'packages/cli/dist/utils/i18n-extract.js'; +/** The schema module a reader edits when a group is added. Named, never read. */ +const TRANSLATION_SCHEMA_SRC = 'packages/spec/src/system/translation.zod.ts'; +/** The walker a reader edits when a group has no emitter. Named, never read. */ +const WALKER_SRC = 'packages/cli/src/utils/i18n-extract.ts'; +/** A stack config authoring one member of every group the walker can reach. */ +const FIXTURE = 'scripts/fixtures/i18n-walk-parity/every-group.stack.json'; + +/** + * The population this gate READS, spelled as a literal array so the dispatch + * derivation can extract it (a computed spelling contributes nothing and the + * gate silently drops out of every card's brief). `packages/cli/src` arrives + * inherited from `cli-build-prerequisite.mjs`; what this declaration adds is + * the OTHER side — the schema whose group set is half of the comparison, and + * the fixture whose thinness could fake a red. + */ +const DECLARED_WATCH_HINTS = [ + 'packages/spec/src/system/translation.zod.ts/**', + 'scripts/fixtures/i18n-walk-parity/**', +]; + +// ── The exemption ledger ──────────────────────────────────────────────────── + +/** + * ⛔ SHRINK-ONLY, and it starts EMPTY. Groups that legitimately have no + * extractor face, keyed by group name, valued by the REASON. + * + * Read the class-level note above before adding anything here. The one-line + * version: a red belongs to whoever owns the walker, and an entry here is a + * decision, not a way past one. + */ +const KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}); + +/** + * The ledger's size ceiling. Growth requires editing this number in the same + * diff as the entry, which is the whole mechanism: it makes growth a reviewed + * act rather than a line nobody re-reads. Shrink-only in both directions — a + * ceiling above the real size fails as slack, so it can only be walked down. + */ +const LEDGER_CEILING = 0; + +/** + * A reason must be a real sentence. The floor is 24 characters and a + * placeholder-word refusal, because the failure this ledger has to survive is + * not a missing reason (unrepresentable — the value IS the reason) but a + * pro-forma one: `datasets: 'n/a'` passes any non-empty test and records + * nothing anyone can re-judge later. + */ +const LEDGER_REASON_FLOOR = 24; +const PLACEHOLDER_REASON = /^(?:todo|tbd|n\/?a|none|nil|-+|\?+)$/i; + +// ── Pure classifiers ──────────────────────────────────────────────────────── + +/** + * Problems with the ledger's own SHAPE, independent of any measurement. + * @param {Record} ledger + * @returns {{ group: string, why: string }[]} + */ +export function ledgerShapeProblems(ledger) { + const problems = []; + for (const [group, reason] of Object.entries(ledger ?? {})) { + if (typeof reason !== 'string' || reason.trim() === '') { + problems.push({ group, why: 'carries no reason — the reason IS the entry' }); + continue; + } + const trimmed = reason.trim(); + if (PLACEHOLDER_REASON.test(trimmed)) { + problems.push({ group, why: `reason is a placeholder (${JSON.stringify(trimmed)}), not a reason` }); + continue; + } + if (trimmed.length < LEDGER_REASON_FLOOR) { + problems.push({ + group, + why: `reason is ${trimmed.length} characters, under the ${LEDGER_REASON_FLOOR}-character floor`, + }); + } + } + return problems; +} + +/** + * The shrink-only ratchet on the ledger's size, both directions. + * @param {Record} ledger + * @param {number} ceiling + */ +export function ledgerRatchetProblems(ledger, ceiling) { + const size = Object.keys(ledger ?? {}).length; + if (size > ceiling) { + return [ + `the ledger holds ${size} entr${size === 1 ? 'y' : 'ies'} against a ceiling of ${ceiling} — ` + + 'growth is a reviewed act: raise LEDGER_CEILING in the same diff as the entry, or do not add it', + ]; + } + if (size < ceiling) { + return [ + `LEDGER_CEILING is ${ceiling} but the ledger holds ${size} — ratchet it down to ${size}. ` + + 'Slack in a shrink-only ceiling is how one silently becomes an allowlist', + ]; + } + return []; +} + +/** + * The parity verdict itself. Sets in, findings out — no I/O, no globals. + * + * @param {{ declared: Iterable, walked: Iterable, ledger: Record }} input + */ +export function parityVerdict({ declared, walked, ledger }) { + const declaredSet = new Set(declared); + const walkedSet = new Set(walked); + const ledgerKeys = Object.keys(ledger ?? {}); + + const unwalked = [...declaredSet].filter((g) => !walkedSet.has(g) && !ledgerKeys.includes(g)).sort(); + const staleUndeclared = ledgerKeys.filter((g) => !declaredSet.has(g)).sort(); + const staleWalked = ledgerKeys.filter((g) => declaredSet.has(g) && walkedSet.has(g)).sort(); + + return { unwalked, staleUndeclared, staleWalked }; +} + +/** Zero groups on either side is a broken scan, never a clean tree. */ +export function emptyPopulationProblems(declared, walked) { + const problems = []; + if ([...declared].length === 0) { + problems.push(`no translation groups read from ${SPEC_SYSTEM_DIST} — a schema with no groups is a broken read, not a verdict`); + } + if ([...walked].length === 0) { + problems.push(`the walker produced no entries for ${FIXTURE} — an empty walk cannot disagree with anything`); + } + return problems; +} + +// ── Reading the two sides ─────────────────────────────────────────────────── + +/** A hard prerequisite failure: says what it did NOT measure, and exits. */ +function reportPrerequisiteNotMet(headline, lines) { + console.error(`❌ check:i18n-walk-parity — PREREQUISITE NOT MET: ${headline}\n`); + for (const line of lines) console.error(` ${line}`); + console.error( + '\n NOTHING was measured. This is not "no unwalked groups" — the comparison' + + '\n never ran. Build the workspace and run it again:' + + `\n\n ${CLI_BUILD_FIX}` + + `\n ${workspaceBuildFix('@objectstack/spec')}` + + '\n', + ); + process.exit(1); +} + +async function loadBuilt(rel, what) { + if (!existsSync(at(rel))) { + reportPrerequisiteNotMet(`${what} is not built`, [ + 'This gate reads the CONTRACT and the WALKER from built output, so that it', + 'compares what ships rather than what the source text says. That output is', + 'not there:', + '', + ` ${rel}`, + ]); + } + try { + return await import(pathToFileURL(at(rel)).href); + } catch (err) { + reportPrerequisiteNotMet(`${what} could not be loaded`, [ + ` ${rel}`, + '', + `${err?.message ?? err}`, + '', + 'A workspace package this module imports has no `dist/` yet, so the walk', + 'could not be run at all.', + ]); + } + return undefined; +} + +/** The declared side: top-level keys of the built `TranslationDataSchema`. */ +export function declaredGroupsOf(mod) { + const schema = mod?.TranslationDataSchema; + const shape = schema?.shape; + if (!shape || typeof shape !== 'object') { + reportPrerequisiteNotMet('TranslationDataSchema exposes no zod shape', [ + ` ${SPEC_SYSTEM_DIST} exported ${schema === undefined ? 'no TranslationDataSchema' : 'a TranslationDataSchema with no .shape'}.`, + '', + 'The declared side is read off the schema OBJECT on purpose — a text scan of', + `${TRANSLATION_SCHEMA_SRC} would agree with a comment and`, + 'disagree with the contract. If the schema stopped being a strict object,', + 'this gate needs re-pointing, not a fallback.', + ]); + } + return Object.keys(shape).sort(); +} + +/** The walked side: `path[0]` of every entry the walker emits for the fixture. */ +export function walkedGroupsOf(mod, config) { + const collect = mod?.collectExpectedEntries; + if (typeof collect !== 'function') { + reportPrerequisiteNotMet('collectExpectedEntries is not exported by the built walker', [ + ` ${CLI_WALKER_DIST}`, + '', + `It is defined once, in ${WALKER_SRC}. A rename here is a`, + 're-point, never a fallback to a second walk.', + ]); + } + // ⛔ An EMPTY warned set on purpose. `collectExpectedEntries` defaults to + // skipping the groups the liveness ledger warns authors away from (`flows` + // today), which is right for `os lint` and wrong here: this gate asks whether + // the walker HAS an emitter, not whether the ledger currently lets it run. + // Defaulting would report a gated group as unwalked and send someone to write + // an emitter that already exists. + const entries = collect(config, { warnedGroups: new Set() }); + return [...new Set(entries.map((e) => e?.path?.[0]).filter((g) => typeof g === 'string' && g))].sort(); +} + +// ── Reporting ─────────────────────────────────────────────────────────────── + +function reportUnwalked(unwalked) { + console.error( + `❌ check:i18n-walk-parity — ${unwalked.length} declared translation group(s) that the extractor does not walk:\n`, + ); + for (const group of unwalked) console.error(` ${group} — declared in TranslationDataSchema, produced by no emitter`); + console.error( + '\n A group with no emitter is a family nobody is ever asked to translate:' + + '\n `os i18n extract` scaffolds no key for it, so `check:i18n-coverage`' + + '\n measures a debt of zero and reports OK while the strings ship in English.' + + '\n' + + '\n There are exactly two honest remedies, and picking between them is the' + + '\n decision this gate exists to force into the open:' + + '\n' + + `\n 1. EMIT IT. Walk the group in ${WALKER_SRC}` + + `\n (\`collectExpectedEntries\`) and author a member of it in ${FIXTURE},` + + '\n in the same change. This is the remedy for every instance so far.' + + '\n' + + '\n 2. LEDGER IT — ⛔ MAINTAINER-ONLY. Adding an entry to' + + '\n KNOWN_NO_EXTRACTOR_FACE (and raising LEDGER_CEILING to fit it)' + + '\n EXPANDS a shrink-only exemption ledger, which is the author' + + '\n excusing themselves from the check they just failed. "This group' + + '\n legitimately has no extractor face" is a decision with a reason and' + + '\n an owner — the maintainer of the walker — not a step the landing' + + '\n author takes to get to green. Report the red and let it be ruled on.' + + '\n' + + '\n ⛔ Not a remedy: deleting the group from the fixture. That answers "does' + + '\n the walker produce this group" with "no" for a perfectly healthy walker.' + + '\n', + ); +} + +function reportStale(staleUndeclared, staleWalked) { + const total = staleUndeclared.length + staleWalked.length; + console.error(`❌ check:i18n-walk-parity — ${total} stale exemption-ledger entry/entries:\n`); + for (const g of staleUndeclared) console.error(` ${g} — no longer declared by TranslationDataSchema`); + for (const g of staleWalked) console.error(` ${g} — the walker DOES emit it now; the exemption is spent`); + console.error( + '\n Good news, and the ledger has to say so: delete each line above from' + + '\n KNOWN_NO_EXTRACTOR_FACE and ratchet LEDGER_CEILING down to match. The' + + '\n list only ever shrinks, and a stale line is how one drifts into an' + + '\n allowlist nobody re-reads.\n', + ); +} + +function list(declared, walked, ledger) { + const ledgerKeys = new Set(Object.keys(ledger)); + const walkedSet = new Set(walked); + console.log('check-i18n-walk-parity --list\n'); + console.log(` declared (${declared.length}) — top-level keys of TranslationDataSchema`); + for (const g of declared) { + const state = walkedSet.has(g) ? 'walked' : ledgerKeys.has(g) ? 'ledgered' : 'UNWALKED'; + console.log(` ${g.padEnd(18)} ${state}`); + } + const extra = walked.filter((g) => !declared.includes(g)); + console.log(`\n walked (${walked.length}) — path[0] over ${FIXTURE}`); + console.log(` ${walked.join(', ') || '(none)'}`); + if (extra.length) console.log(` ⚠️ emitted but NOT declared: ${extra.join(', ')}`); + console.log(`\n ledger (${ledgerKeys.size}/${LEDGER_CEILING})`); + for (const [g, reason] of Object.entries(ledger)) console.log(` ${g}: ${reason}`); + if (!ledgerKeys.size) console.log(' (empty — as it shipped)'); + return 0; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main(wantList) { + const specMod = await loadBuilt(SPEC_SYSTEM_DIST, 'the workspace spec package'); + const cliMod = await loadBuilt(CLI_WALKER_DIST, 'the workspace CLI'); + + const declared = declaredGroupsOf(specMod); + const fixture = JSON.parse(readFileSync(at(FIXTURE), 'utf8')); + const walked = walkedGroupsOf(cliMod, fixture); + const ledger = KNOWN_NO_EXTRACTOR_FACE; + + if (wantList) return list(declared, walked, ledger); + + const empty = emptyPopulationProblems(declared, walked); + if (empty.length) { + reportPrerequisiteNotMet('the population is empty', empty); + } + + const shape = ledgerShapeProblems(ledger); + const ratchet = ledgerRatchetProblems(ledger, LEDGER_CEILING); + if (shape.length || ratchet.length) { + console.error(`❌ check:i18n-walk-parity — the exemption ledger itself is malformed:\n`); + for (const p of shape) console.error(` ${p.group} — ${p.why}`); + for (const line of ratchet) console.error(` ${line}`); + console.error(''); + return 1; + } + + const { unwalked, staleUndeclared, staleWalked } = parityVerdict({ declared, walked, ledger }); + if (unwalked.length) { + reportUnwalked(unwalked); + if (staleUndeclared.length || staleWalked.length) reportStale(staleUndeclared, staleWalked); + return 1; + } + if (staleUndeclared.length || staleWalked.length) { + reportStale(staleUndeclared, staleWalked); + return 1; + } + + console.log( + `✓ check-i18n-walk-parity: ${declared.length} declared group(s), ${walked.length} walked, ` + + `${Object.keys(ledger).length} exempted — every declared group has an extractor face.`, + ); + return 0; +} + +// ── Self-test ─────────────────────────────────────────────────────────────── + +/** + * Today's real names, RECORDED — the snapshot half of the pin. Read off the + * built spec and the built walker on the tree this gate landed on. It is a + * sample, not a second source of truth: the production run always re-measures. + * What it buys is that the classifier's verdict over the REAL shape is pinned, + * so weakening the rule reddens here even on a tree where production is red for + * its own reasons. + */ +const RECORDED_DECLARED = [ + 'apps', 'dashboards', 'datasets', 'flows', 'globalActions', 'messages', + 'metadataForms', 'objects', 'pages', 'settings', 'settingsCommon', +]; +const RECORDED_WALKED = [ + 'apps', 'dashboards', 'datasets', 'flows', 'globalActions', 'metadataForms', 'objects', 'pages', +]; +/** …and the verdict those two produce against an EMPTY ledger. */ +const RECORDED_UNWALKED = ['messages', 'settings', 'settingsCommon']; + +let selfTestReachedVerdict = false; + +function selfTest() { + const failures = []; + let cases = 0; + // Counted, never transcribed: a hand-typed case count in the success line is a + // number that goes stale the first time a case is added, and a self-test whose + // own report is wrong is the last place to keep one. + const eq = (what, got, want) => { + cases += 1; + const a = JSON.stringify(got); + const b = JSON.stringify(want); + if (a !== b) failures.push(`${what}: got ${a}, want ${b}`); + }; + + // 1 — parity holds when every declared group is walked. + eq('clean parity', parityVerdict({ declared: ['a', 'b'], walked: ['a', 'b'], ledger: {} }), + { unwalked: [], staleUndeclared: [], staleWalked: [] }); + + // 2 — a group ADDED to the schema with no walker change is named. + eq('declared group with no emitter', parityVerdict({ declared: ['a', 'b'], walked: ['a'], ledger: {} }).unwalked, ['b']); + + // 3 — a group whose emitter was DELETED is named. Same classifier, the other + // provenance: the card's executable criterion names both directions and a + // rule that only saw one of them would read as covering both. + eq('emitter deleted', parityVerdict({ declared: ['a', 'b', 'c'], walked: ['a', 'c'], ledger: {} }).unwalked, ['b']); + + // 4 — …unless the ledger names it with a reason. + eq('ledgered group is not unwalked', + parityVerdict({ declared: ['a', 'b'], walked: ['a'], ledger: { b: 'no stack config declares a member of this group' } }), + { unwalked: [], staleUndeclared: [], staleWalked: [] }); + + // 5 — a ledger entry with no reason fails. + eq('blank reason', ledgerShapeProblems({ b: '' }).length, 1); + eq('whitespace reason', ledgerShapeProblems({ b: ' ' }).length, 1); + eq('non-string reason', ledgerShapeProblems({ b: null }).length, 1); + + // 6 — …and so does a pro-forma one, which is the shape that actually happens. + eq('placeholder reason', ledgerShapeProblems({ b: 'n/a' }).length, 1); + eq('short reason', ledgerShapeProblems({ b: 'no face' }).length, 1); + eq('real reason accepted', ledgerShapeProblems({ b: 'no stack config declares a member of this group' }).length, 0); + + // 7 — a ledger entry for a group the schema no longer declares is STALE. + eq('stale: undeclared', parityVerdict({ declared: ['a'], walked: ['a'], ledger: { gone: 'a reason long enough to pass the floor' } }).staleUndeclared, ['gone']); + + // 8 — a ledger entry for a group that IS walked is STALE. + eq('stale: now walked', parityVerdict({ declared: ['a', 'b'], walked: ['a', 'b'], ledger: { b: 'a reason long enough to pass the floor' } }).staleWalked, ['b']); + + // 9/10 — the size ratchet, both directions. + eq('ledger growth refused', ledgerRatchetProblems({ b: 'x' }, 0).length, 1); + eq('ceiling slack refused', ledgerRatchetProblems({}, 1).length, 1); + eq('ledger at ceiling', ledgerRatchetProblems({}, 0).length, 0); + + // 11/12 — an empty side is a broken scan, not a verdict. + eq('empty declared refused', emptyPopulationProblems([], ['a']).length, 1); + eq('empty walked refused', emptyPopulationProblems(['a'], []).length, 1); + eq('both populated', emptyPopulationProblems(['a'], ['a']).length, 0); + + // 13 — the recorded sample: the classifier's verdict over today's REAL names. + eq('recorded sample', parityVerdict({ declared: RECORDED_DECLARED, walked: RECORDED_WALKED, ledger: {} }).unwalked, RECORDED_UNWALKED); + eq('recorded sample: ledger is empty', Object.keys(KNOWN_NO_EXTRACTOR_FACE).length, 0); + eq('recorded sample: hints are live', DECLARED_WATCH_HINTS.length > 0, true); + + if (failures.length) { + console.error(`✗ check-i18n-walk-parity self-test: ${failures.length} case(s) failed:\n`); + for (const f of failures) console.error(` ${f}`); + console.error(''); + selfTestReachedVerdict = true; + return 1; + } + + console.log( + `✓ check-i18n-walk-parity self-test: ${cases} cases pass — an added declared group and a deleted ` + + 'emitter are both named, a ledgered group is not, a blank/placeholder/short reason is refused, ' + + 'a stale entry (undeclared, or now walked) fails, the size ratchet refuses growth AND slack, ' + + 'an empty side is refused, and the recorded sample of today\'s real group names reproduces its verdict.', + ); + selfTestReachedVerdict = true; + return 0; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv; + if (argv.includes('--self-test')) { + const code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-i18n-walk-parity self-test: selfTest() returned without reaching its\n' + + 'verdict, so no success line was printed. Exiting 0 here would report a\n' + + 'self-test that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(code); + } + process.exit(await main(argv.includes('--list'))); +} diff --git a/scripts/check-ratchet-remedy-authority.mjs b/scripts/check-ratchet-remedy-authority.mjs index 54f604a19a..b6a060ff1b 100644 --- a/scripts/check-ratchet-remedy-authority.mjs +++ b/scripts/check-ratchet-remedy-authority.mjs @@ -718,6 +718,20 @@ const CONTROL = { why: 'PR for #11927, and the second .mts instance after check-test-typecheck.mts. Its per-site ledger is shrink-only and the message says so in the same breath; adding a site is a real remedy when no contract exists to bind (#11924), so the path is named with its owner rather than denied.', }, + // The tenth, and the fourth to join as a NEW gate rather than by retrofit + // (#14653). Same reading as the seventh, eighth and ninth: the gate hands out + // two remedies with two different owners. Emitting the group in the CLI + // extractor is the landing author's and touches no ratchet, so it is offered + // freely and first. The other path adds a group to a shrink-only exemption + // ledger that ships EMPTY by triage ruling — the author excusing themselves + // from the parity check they just failed — so it is named with its owner. + // `refused` would be untrue: a group keyed by strings no stack config declares + // genuinely has no extractor face, so there IS a legitimate act here. + 'check-i18n-walk-parity.mjs': { + expect: 'marked', + why: 'PR for #14653. Its exemption ledger ships empty and only shrinks; emitting the group is the author\'s remedy and is offered first, while exempting one is a decision with an owner, so that path is named with its owner rather than denied.', + }, + 'check-adr-links.mjs': { expect: 'refused', why: 'Refuses by binding a negation to the verb, over a shrink-only registry.', diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json new file mode 100644 index 0000000000..eb5fa93c9e --- /dev/null +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -0,0 +1,163 @@ +{ + "_readme": [ + "Fixture for scripts/check-i18n-walk-parity.mjs — a normalized stack config that", + "authors ONE member of every translation group the extractor can reach, so the", + "gate's walked set is a property of the WALKER and not of how thin the fixture is.", + "", + "⛔ Do NOT trim a group out of this file to make the gate green. The gate asks", + "'does collectExpectedEntries produce this declared group for SOME config', and a", + "group deleted here answers that question 'no' for a walker that is perfectly", + "healthy — a false red that reads exactly like the real one.", + "", + "Adding a group to TranslationDataSchema? Author a member of it here in the same", + "PR as the walker change. `metadataForms` is deliberately absent: the extractor", + "sources it from METADATA_FORM_REGISTRY unconditionally, independent of config." + ], + + "objects": [ + { + "name": "parity_object", + "label": "Parity Object", + "pluralLabel": "Parity Objects", + "description": "The one object every group in this fixture hangs off.", + "fields": [ + { + "name": "status", + "type": "select", + "label": "Status", + "help": "Where this record sits in the pipeline.", + "placeholder": "Pick a status", + "options": [ + { "value": "open", "label": "Open" }, + { "value": "closed", "label": "Closed" } + ] + } + ], + "validations": [ + { "name": "status_required", "message": "Status is required before closing." } + ], + "actions": [ + { + "name": "approve", + "label": "Approve", + "description": "Approve this record.", + "confirmText": "Approve this record?", + "successMessage": "Record approved.", + "params": [ + { "name": "note", "type": "text", "label": "Note", "helpText": "Shown to the requester.", "placeholder": "Optional note" } + ], + "resultDialog": { + "title": "Approved", + "description": "The record was approved.", + "acknowledge": "Got it" + } + } + ] + } + ], + + "views": [ + { + "name": "parity_object_list", + "object": "parity_object", + "label": "All Parity Objects", + "description": "Every parity object.", + "emptyState": { "title": "Nothing here yet", "message": "Create the first one." }, + "bulkActions": [ + { + "name": "bulk_close", + "label": "Close selected", + "confirmText": "Close the selected records?", + "confirmLabel": "Close them", + "params": [ + { "name": "reason", "type": "text", "label": "Reason", "help": "Why these are being closed.", "placeholder": "Reason" } + ] + } + ] + } + ], + + "actions": [ + { + "name": "export_csv", + "label": "Export CSV", + "description": "Download the current selection.", + "confirmText": "Start the export?", + "successMessage": "Export queued." + } + ], + + "apps": [ + { + "name": "parity_app", + "label": "Parity App", + "description": "The app this fixture's navigation hangs off.", + "navigation": [ + { "id": "parity_home", "label": "Home" } + ] + } + ], + + "dashboards": [ + { + "name": "parity_dashboard", + "label": "Parity Dashboard", + "description": "One dashboard, one widget.", + "widgets": [ + { "id": "parity_tile", "title": "Open records", "description": "Records still open." } + ] + } + ], + + "datasets": [ + { + "name": "parity_metrics", + "label": "Parity Metrics", + "description": "Semantic layer behind the dashboard tile.", + "object": "parity_object", + "dimensions": [ + { "name": "status", "label": "Status", "field": "status", "type": "string" } + ], + "measures": [ + { "name": "record_count", "label": "Records", "aggregate": "count" } + ] + } + ], + + "pages": [ + { + "name": "parity_page", + "label": "Parity Page", + "description": "One page, one header, one addressable component.", + "regions": [ + { + "name": "main", + "components": [ + { "type": "page:header", "properties": { "title": "Parity", "subtitle": "One of everything" } }, + { "id": "parity_card", "type": "page:card", "properties": { "title": "A card", "description": "Card copy." } } + ] + } + ] + } + ], + + "flows": [ + { + "name": "parity_flow", + "label": "Parity Flow", + "nodes": [ + { + "id": "collect_details", + "type": "screen", + "label": "Collect details", + "config": { + "title": "Collect details", + "fields": [ + { "name": "reason", "type": "text", "label": "Reason", "placeholder": "Why?" } + ] + } + } + ] + } + ] +}