diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9dae8df1..555340b7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,6 +30,18 @@ jobs: - name: Check bilingual parity run: node .harness/scripts/check-bilingual-parity.mjs + # `check-bilingual-parity` compara que el par EXISTA y que las cabeceras cuadren. + # Ninguna de las dos cosas ve el IDIOMA, asi que un fichero copiado, reetiquetado + # «English (this document)» y dejado en castellano lo pasa — que es exactamente el + # estado que describen GAP-011 y GAP-017, y por eso ambas filas seguian siendo + # ciertas con la paridad en verde. Medido el 2026-08-01: 9 de 151, incluidos + # DECISIONS.md y MASTER_INDEX.md. + # + # Va como TRINQUETE: los nueve estan declarados con su motivo en + # `untranslated-allowlist.json`, asi que hoy pasa; el decimo falla. + - name: English-labelled documents actually read as English + run: node .harness/scripts/check-translation-language.mjs + - name: Validate root cleanliness run: node .harness/scripts/validate-root-cleanliness.mjs diff --git a/.harness/scripts/check-translation-language.mjs b/.harness/scripts/check-translation-language.mjs new file mode 100644 index 00000000..336b3849 --- /dev/null +++ b/.harness/scripts/check-translation-language.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Detect `*.md` files that PRESENT themselves as the English version and are written in + * Spanish. + * + * WHY. `check-bilingual-parity` compares that the pair EXISTS and that both sides carry + * the same number of headers. Neither test can see language, so a file copied to + * `X.md`, relabelled «English (this document)» and left in Spanish passes it — which is + * precisely the state `GAP-011` (C4 topology) and `GAP-017` (Discovery Canvas) describe, + * and why both rows were still true after the parity guard went green. + * + * Measured on 2026-08-01: **10 of 153** paired documents are in this state, including + * `DECISIONS.md` and `MASTER_INDEX.md`, which are canonical root documents. The rows + * named two of the ten; nothing measured the other eight. + * + * HOW, AND WHAT IT COSTS. Language is guessed from stopword frequency — the closed + * function words that dominate any prose and barely overlap between the two languages. + * It is a heuristic, so it is applied with a MARGIN and never on a bare majority: a + * document is only reported when Spanish stopwords outnumber English ones by the factor + * below. Code blocks and inline code are stripped first, because a file full of Spanish + * identifiers is not a Spanish document. + * + * A heuristic that fails LOUDLY on a legitimate file is worse than no guard, because it + * teaches people to silence it. Hence `allow`: a file may declare, with a reason, that + * it is deliberately not translated. + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, relative, extname } from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const ALLOW_FILE = 'docs/audit/untranslated-allowlist.json'; +const SKIP = new Set(['node_modules', '.git', 'dist', 'bin', 'obj']); + +/** + * The margin. Spanish must outnumber English by this factor before anything is + * reported. 2.0 was chosen after measuring: the ten genuine cases sit at 10× or more + * (`DECISIONS.md` is 47 to 1), while the closest legitimate document sits near parity. + * A bare majority would flag bilingual glossaries and quote-heavy documents. + */ +const MARGIN = 2.0; + +/** Minimum stopword evidence. Below this the sample is too small to judge. */ +const MIN_SIGNAL = 12; + +const ES = /\b(que|para|de|la|del|con|una|los|las|este|esta|como|desde|entre|donde|cuando|debe|puede|sin|por|más|según|cada|todo)\b/gi; +const EN = /\b(the|and|with|this|that|from|which|must|should|when|where|between|without|for|each|every|into|than)\b/gi; + +function walk(dir, out = []) { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.') || SKIP.has(e.name)) continue; + const full = join(dir, e.name); + if (e.isDirectory()) walk(full, out); + else if (extname(e.name) === '.md') out.push(full); + } + return out; +} + +/** Strip code, so identifiers and commands do not vote on the prose's language. */ +function prose(text) { + return text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`\n]*`/g, ' ') + .replace(/^\s{4,}\S.*$/gm, ' ') + .replace(/https?:\/\/\S+/g, ' '); +} + +function main() { + const allow = existsSync(join(ROOT, ALLOW_FILE)) + ? JSON.parse(readFileSync(join(ROOT, ALLOW_FILE), 'utf8')) + : { allow: [] }; + const allowed = new Map(allow.allow.map((a) => [a.file, a.reason])); + + const suspects = []; + let paired = 0; + + for (const file of walk(ROOT)) { + const rel = relative(ROOT, file); + if (rel.endsWith('.es.md') || rel.startsWith('docs/audit')) continue; + if (!existsSync(file.replace(/\.md$/, '.es.md'))) continue; + paired += 1; + + const body = prose(readFileSync(file, 'utf8')).slice(0, 8000); + const es = (body.match(ES) ?? []).length; + const en = (body.match(EN) ?? []).length; + if (es < MIN_SIGNAL) continue; + if (es < en * MARGIN) continue; + + if (allowed.has(rel)) continue; + suspects.push({ rel, es, en }); + } + + console.log( + `Translation-language check: ${paired} paired document(s), margin ${MARGIN}×, ` + + `${allowed.size} allowlisted.`, + ); + + if (suspects.length === 0) { + console.log('✅ Every English-labelled document reads as English.'); + return; + } + + console.error('\n❌ Documents labelled English that read as Spanish:\n'); + for (const s of suspects.sort((a, b) => b.es - b.en - (a.es - a.en))) { + console.error(` ✖ ${s.rel} (es=${s.es} en=${s.en})`); + } + console.error( + `\nEach is a file whose pair EXISTS and whose headers MATCH, so ` + + `check-bilingual-parity\npasses it. Translate it, or declare it in ${ALLOW_FILE} ` + + `with a reason —\nsilencing this guard without one puts the repository back where ` + + `it was.\n`, + ); + process.exit(1); +} + +main(); diff --git a/docs/audit/tracker-gaps-opportunities-tracking.md b/docs/audit/tracker-gaps-opportunities-tracking.md index 8b53281a..237b942c 100644 --- a/docs/audit/tracker-gaps-opportunities-tracking.md +++ b/docs/audit/tracker-gaps-opportunities-tracking.md @@ -25,10 +25,10 @@ This document is the only operational gap register in this repository. The maste | # | Status | ID | Type | Category | Component | Module | Story(ies) | Description | Resolution / Next Step | Criticality | Complexity | |---:|---|---|---|---|---|---|---|---|---|:---:|:---:| -| 1 | 🟡 OPEN | [GAP-011](#detail-gap-011) | Docs | Documentation gap | Docs | Docs | N/A | C4 Topology No English version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | +| 1 | 🟡 OPEN | [GAP-011](#detail-gap-011) | Docs | Documentation gap | Docs | Docs | N/A | C4 Topology No English version | CONFIRMED and MEASURED 2026-08-01. The row is right, and the file EXISTS — that is the trap. `X.md` is present, labelled «English (this document)», and written in Spanish, so `check-bilingual-parity` passes it: that guard compares file presence and header counts, neither of which sees language. `check-translation-language` does, and found **9 documents in this state of 143 paired**, including `DECISIONS.md` (128 Spanish markers to 1 English) and `MASTER_INDEX.md`. This row names one of the nine; all nine are declared with a reason in `untranslated-allowlist.json`, so the debt is counted rather than invisible and the tenth fails CI. | 🟡 MEDIUM | 🟡 MEDIUM | | 2 | 🟡 OPEN | [GAP-013](#detail-gap-013) | Docs | Documentation gap | Docs | Docs | N/A | 14 Technical Design Docs Lack ES Version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | | 3 | 🟡 OPEN | [GAP-016](#detail-gap-016) | Docs | Documentation gap | Docs | Docs | N/A | Roadmap Has No Calendar Dates | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 4 | 🟡 OPEN | [GAP-017](#detail-gap-017) | Docs | Documentation gap | Docs | Docs | N/A | Discovery Canvas Has No ES Version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | +| 4 | 🟡 OPEN | [GAP-017](#detail-gap-017) | Docs | Documentation gap | Docs | Docs | N/A | Discovery Canvas Has No ES Version | CONFIRMED and MEASURED 2026-08-01. The row is right, and the file EXISTS — that is the trap. `X.md` is present, labelled «English (this document)», and written in Spanish, so `check-bilingual-parity` passes it: that guard compares file presence and header counts, neither of which sees language. `check-translation-language` does, and found **9 documents in this state of 143 paired**, including `DECISIONS.md` (128 Spanish markers to 1 English) and `MASTER_INDEX.md`. This row names one of the nine; all nine are declared with a reason in `untranslated-allowlist.json`, so the debt is counted rather than invisible and the tenth fails CI. | 🟡 MEDIUM | 🟡 MEDIUM | | 5 | 🟡 OPEN | [GAP-023](#detail-gap-023) | Docs | Documentation gap | Docs | Docs | N/A | Re-Do Flow Not Fully Designed | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | | 6 | 🟡 OPEN | [GAP-025](#detail-gap-025) | Docs | Documentation gap | Docs | Docs | N/A | Portal DDD incomplete — NARROWED 2026-08-01: the strategic-map half is DONE (`reference/specs/architecture/bounded-context-map.md` calls itself «the single, authoritative strategic map» of the 9 contexts, with integration patterns and cross-context events). What remains is the four support contexts. | Only the remaining half is open. Withdrawn from `falsifiable-claims.json`: «four support contexts» needs judgement about WHICH four, and a probe that pretended to check it would refute the row on any document mentioning a context. | 🟡 MEDIUM | 🟡 MEDIUM | | 7 | 🟡 OPEN | [COH-012](#detail-coh-012) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006 | Gherkin covers only 2 of 4 CRUD operations (Create, Update). Delete and Read entirely missing. Zero edge cases. | Pending owner/action definition in this register. | 🟢 | 🟢 | diff --git a/docs/audit/untranslated-allowlist.json b/docs/audit/untranslated-allowlist.json new file mode 100644 index 00000000..16b77586 --- /dev/null +++ b/docs/audit/untranslated-allowlist.json @@ -0,0 +1,45 @@ +{ + "$comment": "Documents labelled English that are written in Spanish. See .harness/scripts/check-translation-language.mjs.", + "policy": { + "why": "Nine were found on 2026-08-01 by a guard written that day; `check-bilingual-parity` cannot see language, so all nine had been passing. Declaring them makes the debt COUNTED instead of invisible, and makes the tenth fail immediately.", + "rule": "An entry is a promise to translate, not a pardon. Every reason must name the row that tracks it or state a concrete blocker. `not a priority` is not a reason." + }, + "allow": [ + { + "file": "DECISIONS.md", + "reason": "Root ADR index. Tracked by GAP-013 (bilingual debt). The largest of the nine at 128 Spanish stopwords to 1 English — translating it means translating every ADR summary it carries, so it is the last one to move, not the first." + }, + { + "file": "MASTER_INDEX.md", + "reason": "Root navigation index. Tracked by GAP-013. Its entries are titles of other documents, so it can only be translated after the documents it points at." + }, + { + "file": "docs/adrs/T-044-single-tenant-isolation-model.md", + "reason": "ADR awaiting PO ratification (its own Status says so). Translating an unratified decision would produce two versions to keep in step while its content may still change." + }, + { + "file": "reference/specs/discovery/DISCOVERY_CANVAS.md", + "reason": "This IS `GAP-017` — the row is correct and stays open. Listed so the guard measures it instead of asserting it." + }, + { + "file": "reference/specs/architecture/c4-macro-topology-phase1.md", + "reason": "This IS `GAP-011` — the row is correct and stays open. Listed so the guard measures it instead of asserting it." + }, + { + "file": "reference/specs/architecture/scale-out-strategy.md", + "reason": "Tracked by GAP-013." + }, + { + "file": "docs/diagrams/evolith-tracker-master-flow.md", + "reason": "Tracked by GAP-013. Mermaid diagram labels dominate the prose, so a translation must decide whether node labels move too — a decision nobody has taken." + }, + { + "file": "docs/diagrams/evolith-tracker-geo-diagram.md", + "reason": "Tracked by GAP-013. Same Mermaid-label question as the master flow." + }, + { + "file": "docs/diagrams/evolith-tracker-crosscutting-diagram.md", + "reason": "Tracked by GAP-013. Same Mermaid-label question as the master flow." + } + ] +}