fix(#4657): give the ui consideration probe a text_en language channel (#4804)

* test(#4657): add failing-first coverage for the ui probe's text_en channel

Mirrors the #3717/#4156 test shape onto the UI adapter: a failing-first
proposeConsiderations regression (Danish text + English text_en must classify
as its English equivalent, not land in the #1110 unclassified sentinel),
proposeElements/analyzeCoverage/CLI end-to-end pairs, fail-closed text_en
validation cases (empty/whitespace/non-string, unconditional under an
elements override), a ui-phase.md Step 9.5 workflow-prose contract test, a
reference-doc Inputs parity test, and a fast-check property proving any
cue-matching prose classifies identically under a cue-free Danish rendering
plus text_en. All new assertions are RED until src/ui-consideration-probe.cts
and the workflow/reference docs are updated.

* fix(#4657): give the ui consideration probe a text_en language channel

Element gains an optional text_en; classifyElement's own signature stays
untouched (a locked, directly-tested export) and the text_en ?? text
selection is pushed to the two classification call sites (proposeConsiderations,
proposeElements) instead. text_en is validated fail-closed: an empty or
whitespace-only value throws rather than silently winning the ?? fallback and
degrading classification to zero kinds.

Mirrors #3717/#4156 onto the UI adapter: ui-phase.md Step 9.5 gains the
Non-English projects section (mirroring spec-phase Step 5.5) and the
ELEMENTS_JSON shape comment documents the field with both zero-applicable
guard arms named; the reference doc's Inputs section, the PROBE.ui CONTEXT
predicate (with both derived indexes regenerated), and the nav-override
test expectation stay in sync. The ui-phase contract test carries the
site-scoped allow-test-rule marker and its cluster is registered in the
test-file-count allowlist ratchet.

Emitted-Drift-Ack-Growth: ui-phase.md — Non-English text_en section, ELEMENTS_JSON shape comment, and two-arm guard wording (#4657)

* docs(#4657): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-16 13:46:40 -04:00
committed by GitHub
parent 3014775a3f
commit 5e729445d3
11 changed files with 654 additions and 309 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4804
---
**The UI consideration probe reads an optional `text_en` field** — in a `response_language` project every UI element used to classify to zero categories and land in `unclassified`, dropping the whole state-coverage axis; a faithful English translation per element now classifies exactly like its English equivalent. Mirrors the edge probe's #3717 remedy; an empty or whitespace `text_en` fails closed. (#4657)

View File

@@ -614,6 +614,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`PROBE.prohib.verification=test|judgment`
`PROBE.ui.verification=explicit|backstop`
`PROBE.ui.axis=MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)`
`PROBE.ui.text_en=optional English translation of Element.text read by classification (text_en ?? text), engine input never user-facing output; mirrors the edge adapter's #3717 field; empty/whitespace/non-string fails closed (#4657)`
`PROBE.ui.seam=ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)`
`PROBE.ci.surface=the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)`
`PROHIB.recall=LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)`

View File

@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"count": 285,
"count": 286,
"classes": {
"ARCH": 1,
"CI": 2,
@@ -14,7 +14,7 @@
"PLANNING": 3,
"PR": 2,
"PRED": 68,
"PROBE": 11,
"PROBE": 12,
"PROC": 14,
"PROHIB": 10,
"RELEASE-NOTES": 31,
@@ -646,6 +646,11 @@
"klass": "PROBE",
"value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)"
},
{
"id": "PROBE.ui.text_en",
"klass": "PROBE",
"value": "optional English translation of Element.text read by classification (text_en ?? text), engine input never user-facing output; mirrors the edge adapter's #3717 field; empty/whitespace/non-string fails closed (#4657)"
},
{
"id": "PROBE.ui.verification",
"klass": "PROBE",

File diff suppressed because one or more lines are too long

View File

@@ -15,11 +15,16 @@ here. Forcing them into a closed compiled taxonomy is the wrong model.
## Inputs
A list of UI elements, each a `{ id, text, elements? }` record where `text` is the
researcher-authored description and `elements` is an optional author-supplied override of the
element classification. The six element kinds are: `form`, `list-collection`, `nav`, `media`,
`interactive-control`, `static-content`. When `elements` is absent, a heuristic classifier
proposes kinds from the prose (propose-then-confirm) — the author may correct the kind.
A list of UI elements, each a `{ id, text, text_en?, elements? }` record where `text` is the
researcher-authored description, `elements` is an optional author-supplied override of the
element classification, and `text_en` is an optional English translation of `text`
(#4657). The element cues are English word-boundary patterns, so a non-English `text` (a
project running with `response_language` set) classifies to zero kinds unless `text_en`
carries a faithful English rendering — engine input, never user-facing output; `text`
itself keeps the UI-SPEC's own language and is never translated. The six element kinds are:
`form`, `list-collection`, `nav`, `media`, `interactive-control`, `static-content`. When
`elements` is absent, a heuristic classifier proposes kinds from the prose
(`text_en ?? text`, propose-then-confirm) — the author may correct the kind.
## Taxonomy (8 categories)

View File

@@ -305,6 +305,27 @@ section (re-run), the write-back is idempotent (it REPLACES that section, never
runtime is non-Claude and the probe engine cannot be resolved, the shim FAILS LOUD (below) — it
never silently no-ops (a silent skip would drop the whole state-coverage axis).
**Non-English projects — `text_en` carries the classifier-facing translation; the UI-SPEC is
not.** The element cues the classifier matches are **English** word-boundary patterns, so
element prose written in another language matches nothing, classifies to zero kinds, and lands
every element in `unclassified` (#1110) — the taxonomy contributes nothing and `--auto` leaves
it all `unresolved`. When this project has `response_language` set, add an optional `text_en`
key to each `$ELEMENTS_JSON` entry: a faithful **English** translation of that element's
`text`. `text_en` is **engine input, never user-facing output**, so the `response_language`
rule at the top of this workflow does not govern it — but `text` itself is NOT translated:
write it as the element's own wording, exactly as it appears in the UI-SPEC. The UI-SPEC keeps
the original language — only `text_en` is translated, and element `id`s are never translated
or renumbered (coverage rows join back on `id`). Populate `text_en` for **every** element, not
only the ones that look UI-relevant: the zero-applicable guard below fires when `$APPLICABLE`
is `0` and again on the all-unclassified case (`$UNCLASSIFIED = $APPLICABLE`, #4656), so a
partly-classified surface slips through both arms with no signal at all. When
`response_language` is unset (an English-language project), omit `text_en` —
`text` is already English and the engine falls back to it automatically (`text_en ?? text`).
If an element still classifies to zero kinds with `text_en` populated, it carries no cue in
any language (the recorded recall gap — ADR-857 §98 / ADR-550 D7b, not a translation failure);
author an explicit `elements` array on that element instead of relying on the prose classifier.
(#4657)
**Runtime coverage compute — resolve and invoke ui-consideration-probe.cjs:**
```bash
@@ -345,8 +366,9 @@ fi
# Element extraction (MANUAL BY DESIGN — not an oversight): the agent reads the researcher-authored
# UI-SPEC prose (the described surfaces — the Design System / Copywriting rows and any element the
# researcher named) and writes ONE object per UI element/surface: {"id","text"} where text is the
# prose describing it. This mirrors spec-phase Step 5.5's edge-probe REQS_JSON step VERBATIM — a
# researcher named) and writes ONE object per UI element/surface: {"id","text","text_en"?} where
# text is the prose describing it. This mirrors spec-phase Step 5.5's edge-probe REQS_JSON step
# VERBATIM — a
# hand-populated heredoc guarded by the fail-loud <replace:> check below — the established, shipped
# pattern for feeding a probe from a prose spec. It is NOT mechanized on purpose: a UI-SPEC has no
# single machine-parseable "elements" column — surfaces are distributed across design-token tables
@@ -355,7 +377,10 @@ fi
# element). The agent-authored heredoc + fail-loud guard is the conservative choice, identical to the
# requirement-side edge-probe path (RR-04). If a future UI-SPEC gains a canonical element table,
# revisit to parse it. Populate the heredoc from the UI-SPEC; the guard below fails loud on a
# forgotten substitution (never a no-op).
# forgotten substitution (never a no-op). When `response_language` is set, ALSO add `text_en` — a
# faithful ENGLISH translation of `text` (see the Non-English projects note above); the element
# cues are English-only, so original-language `text` alone classifies to zero kinds. `text` itself
# stays the UI-SPEC's own wording and is never translated.
ELEMENTS_JSON=$(mktemp "${TMPDIR:-/tmp}/ui-probe-elements-XXXXXX") && mv "$ELEMENTS_JSON" "${ELEMENTS_JSON}.json" && ELEMENTS_JSON="${ELEMENTS_JSON}.json" || exit 1
cat > "$ELEMENTS_JSON" <<'JSON'
[

View File

@@ -1,6 +1,15 @@
{
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to \u2264 2, remove its entry entirely. New entries require justification in PR description.",
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to ≤ 2, remove its entry entirely. New entries require justification in PR description.",
"modules": {
"ui-consideration-probe": {
"files": [
"ui-consideration-probe-docs-fixtures.test.cjs",
"ui-consideration-probe-ui-phase-contract.test.cjs",
"ui-consideration-probe.test.cjs"
],
"issue": "4657",
"justification": "The ui-consideration-probe cluster gains its workflow-prose contract file (tests/ui-consideration-probe-ui-phase-contract.test.cjs, #4657), mirroring the edge probe's split (edge-probe.test.cjs / edge-probe-docs-fixtures.test.cjs / edge-probe-spec-phase-contract.test.cjs). The contract file reads shipped gsd-core/workflows/ui-phase.md under the source-text-is-the-product exception, which the unit file deliberately does not carry (it asserts typed returns only)."
},
"adr-parser": {
"files": [
"adr-parser.property.test.cjs",
@@ -40,7 +49,7 @@
"frontmatter.unit.test.cjs"
],
"issue": "3881",
"justification": "Grandfathered by #3227 for the original 4-file cluster (pre-existing over-cap, not new sprawl). #3881 (ADR-3473 \u00a78.1, the js-yaml parser migration) adds two more: frontmatter-golden-parity.test.cjs (D-series golden-corpus diff against the legacy parser, ~900 real documents \u2014 cannot be folded into an existing file without losing its independent-of-current-parser provenance) and frontmatter-roundtrip.property.test.cjs (fast-check property coverage the migration's bijective-contract rule requires). Both are migration-specific, not incidental sprawl."
"justification": "Grandfathered by #3227 for the original 4-file cluster (pre-existing over-cap, not new sprawl). #3881 (ADR-3473 §8.1, the js-yaml parser migration) adds two more: frontmatter-golden-parity.test.cjs (D-series golden-corpus diff against the legacy parser, ~900 real documents — cannot be folded into an existing file without losing its independent-of-current-parser provenance) and frontmatter-roundtrip.property.test.cjs (fast-check property coverage the migration's bijective-contract rule requires). Both are migration-specific, not incidental sprawl."
},
"graphify": {
"files": [
@@ -140,7 +149,7 @@
"verify-work-deferred-promotion.test.cjs",
"verify.test.cjs"
],
"issue": "#3813 \u2014 the live-path #1883 suite drives buildPlanningSnapshot in-process with an fs fault; the verify module suites are CLI-driving and cannot host an fs monkeypatch",
"issue": "#3813 — the live-path #1883 suite drives buildPlanningSnapshot in-process with an fs fault; the verify module suites are CLI-driving and cannot host an fs monkeypatch",
"justification": "verify-command-grounding added by #2401/#3678; allowlist follow-up landed with #3606 (base was red on this lane). #4546 adds verify-work-deferred-promotion (deferred-follow-up promotion writer contract + parity)."
},
"install": {
@@ -155,7 +164,7 @@
"install-write-confinement.test.cjs",
"install.test.cjs"
],
"issue": "#3798 \u2014 the profile-closure spawn test reads the real repo surface (commands+workflows+agents); consolidating it into install.test.cjs would put a whole-repo scan behind the suite's heaviest file"
"issue": "#3798 — the profile-closure spawn test reads the real repo surface (commands+workflows+agents); consolidating it into install.test.cjs would put a whole-repo scan behind the suite's heaviest file"
},
"edge-probe": {
"files": [
@@ -253,8 +262,8 @@
"docs-state-md-locale-parity.test.cjs",
"docs-update.test.cjs"
],
"issue": "3873 | #3839 \u2014 the hook-event parity suite cross-checks the four docs hook tables against src/runtime-hooks-surface.cts registrations; a generated-docs concern distinct from content freshness and registry parity",
"justification": "ADR-3473 \u00a78.8 makes docs/reference/state-md.md and its four locale siblings generated-and-committed and adds the repo's first locale-parity check (verified: no locale-parity lint precedent exists here). That check is a distinct concern from docs-update (content freshness) and docs-parity-live-registry (registry parity); folding it into either would place an unrelated subject inside them purely to satisfy a count. The section it guards is absent from all four translations today and documents the status enum behind #3853."
"issue": "3873 | #3839 — the hook-event parity suite cross-checks the four docs hook tables against src/runtime-hooks-surface.cts registrations; a generated-docs concern distinct from content freshness and registry parity",
"justification": "ADR-3473 §8.8 makes docs/reference/state-md.md and its four locale siblings generated-and-committed and adds the repo's first locale-parity check (verified: no locale-parity lint precedent exists here). That check is a distinct concern from docs-update (content freshness) and docs-parity-live-registry (registry parity); folding it into either would place an unrelated subject inside them purely to satisfy a count. The section it guards is absent from all four translations today and documents the status enum behind #3853."
},
"audit": {
"files": [
@@ -266,7 +275,7 @@
"audit-uat-summary-segmentation.test.cjs",
"audit-workstream-layouts.test.cjs"
],
"issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 \u2014 regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item | #3783 \u2014 dedicated suite for the summary.current_milestone/archived segmentation, matching this module's one-file-per-feature-slice precedent (e.g. audit-uat-acknowledged.test.cjs) rather than growing the unrelated 6900+ line tests/uat.test.cjs (a different module, keyed off uat.cjs)"
"issue": "#3804/#3805 — audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 — regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item | #3783 — dedicated suite for the summary.current_milestone/archived segmentation, matching this module's one-file-per-feature-slice precedent (e.g. audit-uat-acknowledged.test.cjs) rather than growing the unrelated 6900+ line tests/uat.test.cjs (a different module, keyed off uat.cjs)"
},
"shell-command-projection": {
"files": [
@@ -274,7 +283,16 @@
"shell-command-projection-md-normalize.test.cjs",
"shell-command-projection-path-sep.test.cjs"
],
"issue": "#3854 \u2014 the tight-list suite pins the markdown write-normalizer's blank-line policy through the exported normalizeContent/platformWriteSync seam; a distinct concern from dispatch routing and path separation"
"issue": "#3854 — the tight-list suite pins the markdown write-normalizer's blank-line policy through the exported normalizeContent/platformWriteSync seam; a distinct concern from dispatch routing and path separation"
},
"ui-consideration-probe": {
"files": [
"ui-consideration-probe-docs-fixtures.test.cjs",
"ui-consideration-probe-ui-phase-contract.test.cjs",
"ui-consideration-probe.test.cjs"
],
"issue": "4657",
"justification": "The ui-consideration-probe cluster gains its workflow-prose contract file (tests/ui-consideration-probe-ui-phase-contract.test.cjs, #4657), mirroring the edge probe's split (edge-probe.test.cjs / edge-probe-docs-fixtures.test.cjs / edge-probe-spec-phase-contract.test.cjs). The contract file reads shipped gsd-core/workflows/ui-phase.md under the source-text-is-the-product exception, which the unit file deliberately does not carry (it asserts typed returns only)."
}
}
}

View File

@@ -55,10 +55,22 @@ export interface TaxonomyEntry {
consideration: string;
}
/** A UI element to probe; `elements` is an optional authored override of classification. */
/**
* A UI element to probe; `elements` is an optional authored override of classification.
*
* `text_en` (#4657) is an optional English translation of `text`, read by element
* classification in preference to `text` when present (`text_en ?? text`). `UI_CUES`
* are English-only word-boundary patterns, so a non-English `text` (e.g. a project
* running with `response_language` set) classifies to zero kinds and lands every
* element in the `unclassified` soft signal unless `text_en` supplies an English
* rendering. `text` itself is unaffected and keeps its own meaning (the element's own
* description, in whatever language the UI-SPEC uses) — only classification reads
* `text_en` preferentially. Mirrors the edge adapter's `Requirement.text_en` (#3717).
*/
export interface Element {
id: string;
text?: string;
text_en?: string;
elements?: UIElementKind[];
}
@@ -146,7 +158,7 @@ export const UI_VALIDATORS: Validators = {
*/
export function validateRequirement(element: Element): void {
coreValidateRequirement(element);
const r = element as unknown as { elements?: unknown; text?: unknown };
const r = element as unknown as { elements?: unknown; text?: unknown; text_en?: unknown };
if (r.elements != null && !Array.isArray(r.elements)) {
throw new Error(`element ${element.id} elements must be an array when present`);
}
@@ -155,6 +167,15 @@ export function validateRequirement(element: Element): void {
`element ${element.id} text must be a non-empty string when no elements override is provided`,
);
}
// text_en (#4657) is optional, but when present it must be a non-empty string. An empty
// string is NOT caught by `??` (only null/undefined are), so an unvalidated `text_en: ''`
// would silently win `text_en ?? text` and classify against '' — the same fail-open shape
// #1110/#2773 already exist to eliminate, just moved one field over. Validated
// unconditionally (not gated on whether `elements` will make it unused) so bad data fails
// closed even when it happens to be dead for this particular call.
if (r.text_en != null && !(typeof r.text_en === 'string' && r.text_en.trim())) {
throw new Error(`element ${element.id} text_en must be a non-empty string when present`);
}
}
/** Validate a UI-consideration resolution against the UI verification vocabulary (delegated, D-06). */
@@ -184,7 +205,11 @@ export function proposeConsiderations(element: Element): UIConsideration[] {
}
kinds = element.elements;
} else {
kinds = classifyElement(element.text as string);
// #4657: prefer the English translation when present — UI_CUES are English-only
// word-boundary patterns, so a non-English `text` (e.g. response_language projects)
// would otherwise classify to zero kinds. validateRequirement (called above) has
// already guaranteed text_en, if present, is a non-empty string.
kinds = classifyElement((element.text_en ?? element.text) as string);
if (kinds.length === 0) {
// Prose present but no element cue matched. Do NOT silently drop it (#1110): a UI element
// whose phrasing missed every cue would otherwise vanish from coverage with no signal — the
@@ -270,7 +295,7 @@ export function proposeElements(elements: Element[]): ElementProposal[] {
const considerations = proposeConsiderations(el);
const kinds: UIElementKind[] = Array.isArray(el.elements)
? el.elements // already validated inside proposeConsiderations
: classifyElement(el.text as string);
: classifyElement((el.text_en ?? el.text) as string); // #4657: text_en ?? text
const unclassified = !Array.isArray(el.elements) && kinds.length === 0;
const categories = unclassified ? [] : applicableCategories(kinds);
return { id: el.id, kinds, categories, considerations, unclassified };

View File

@@ -55,6 +55,15 @@ describe('ui-consideration-probe doc/code parity (ADPT-02)', () => {
const md = fs.readFileSync(docPath, 'utf8');
assert.match(md, /domain-probes\.md/);
});
test('Inputs section documents the text_en translation channel (#4657)', () => {
// PARSED from the `## Inputs` section only (never a whole-file substring) — a reformat
// that preserves the data does not fail; a dropped field does.
const md = fs.readFileSync(docPath, 'utf8');
const inputs = md.split(/^## Inputs/m)[1].split(/^## /m)[0];
assert.match(inputs, /text_en/, 'Inputs must document the optional text_en field');
assert.match(inputs, /English/, 'Inputs must say text_en is the classifier-facing English rendering');
});
});
describe('UI-SPEC template `## UI Considerations` section (WIRE-02 SC3 de-dup)', () => {

View File

@@ -0,0 +1,104 @@
// ui-phase.md Step 9.5 is the deployed workflow runtime contract under assertion; its text IS
// what the runtime loads. These checks lock the #4657 text_en wiring so the Non-English
// guidance cannot silently rot the way it did for the edge adapter before #3717 — mirroring
// tests/edge-probe-spec-phase-contract.test.cjs onto the ui-phase probe step.
// Assertions scope to the extracted Step 9.5 block to avoid false positives from incidental
// mentions elsewhere in the file.
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const UI_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ui-phase.md');
const { proposeConsiderations } = require(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'ui-consideration-probe.cjs'),
);
function readUiPhase() {
// allow-test-rule: source-text-is-the-product (#4657)
// The workflow file's text IS the deployed runtime contract; assertions below match on it.
return fs.readFileSync(UI_PHASE_PATH, 'utf8');
}
// Slice the Step 9.5 block: from the "## 9.5." heading to the next "## " heading. Scopes
// assertions to the probe step only.
function extractStep95Block(content) {
const startIdx = content.indexOf('## 9.5.');
if (startIdx === -1) return '';
const rest = content.slice(startIdx + '## 9.5.'.length);
const nextHeading = rest.search(/\n## /);
if (nextHeading === -1) return content.slice(startIdx);
return content.slice(startIdx, startIdx + '## 9.5.'.length + nextHeading);
}
describe('ui-consideration-probe ui-phase contract: text_en (#4657)', () => {
test('Step 9.5 documents the Non-English text_en remedy (mirrors spec-phase Step 5.5, #3717)', () => {
const block = extractStep95Block(readUiPhase());
assert.ok(block.length > 0, 'Step 9.5 block must be extractable from ui-phase.md');
assert.match(block, /Non-English/i, 'Step 9.5 must carry the Non-English projects guidance');
assert.match(block, /text_en/, 'Step 9.5 must name text_en — the classifier-facing translation field');
assert.match(block, /response_language/, 'Step 9.5 must tie the remedy to response_language projects');
assert.match(block, /every element/i, 'Step 9.5 must instruct populating text_en for EVERY element, not only UI-obvious ones');
assert.match(block, /engine input/i, 'text_en is engine input, never user-facing output (ADR-550)');
});
test('Step 9.5 ELEMENTS_JSON shape comment includes the optional text_en key', () => {
const block = extractStep95Block(readUiPhase());
assert.match(block, /"text_en"\?/, 'the element record shape must document text_en? alongside id/text/elements');
});
// Property (deterministic: pinned seed, bounded runs, counterexample printed on failure):
// for English element prose built from the classifier's own cue vocabulary, a cue-free
// Danish rendering as `text` plus the original English as `text_en` must classify
// identically to the English original — and the Danish rendering alone must stay
// unclassified. Document-shaped inputs: the English words ARE cue words UI_CUES matches;
// the Danish translations are verified cue-free by the property itself on every run.
test('property: text_en carries classification for any cue-matching prose under a cue-free Danish rendering', () => {
const enToDa = [
['form', 'formular'],
['input', 'indtastning'],
['list', 'liste'],
['table', 'tabel'],
['tabs', 'faner'],
['pagination', 'sideinddeling'],
['image', 'billede'],
['gallery', 'galleri'],
['button', 'knap'],
['dropdown', 'rullemenu'],
['heading', 'overskrift'],
['title', 'titel'],
];
const enWords = enToDa.map(([en]) => en);
const daOf = (w) => enToDa.find(([en]) => en === w)[1];
fc.assert(
fc.property(
fc.constantFrom(...enWords),
fc.constantFrom(...enWords),
fc.nat(2),
(w1, w2, extra) => {
const english = [w1, w2, ...Array(extra).fill(w2)].join(' ');
const danish = [w1, w2, ...Array(extra).fill(w2)].map(daOf).join(' ');
const cats = (el) => proposeConsiderations(el).map((c) => c.category);
assert.deepEqual(
cats({ id: 'P', text: danish }),
['unclassified'],
`the Danish rendering must be cue-free (counterexample text: ${JSON.stringify(danish)})`,
);
assert.deepEqual(
cats({ id: 'P', text: danish, text_en: english }),
cats({ id: 'P', text: english }),
`translated element must classify as its English original (counterexample text_en: ${JSON.stringify(english)})`,
);
},
),
{ seed: 4657, numRuns: 200 },
);
});
});

View File

@@ -19,7 +19,12 @@ process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup, createTempDir } = require('./helpers.cjs');
const BUILT_SCRIPT = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'ui-consideration-probe.cjs');
const uc = require(BUILT_SCRIPT);
@@ -307,3 +312,140 @@ describe('ui-consideration-probe WIRE-02: backward-compat + format-match + idemp
assert.deepEqual(uc.proposeElements(els), uc.proposeElements(els));
});
});
// ══ #4657 — the text_en language channel (mirrors #3717/#4156 onto the UI adapter) ═════════
// UI_CUES are English word-boundary patterns; a non-English element classifies to zero kinds
// and lands in the #1110 unclassified soft signal. text_en carries the classifier-facing
// English translation — engine input, never user-facing output. (The ADR-550 amendment's
// record scopes to the edge adapter's Requirement; #4657 extends the same remedy shape to
// the UI Element.) classifyElement's own signature stays untouched; the text_en ?? text
// selection lives at the two classification call sites (proposeConsiderations, proposeElements).
describe('ui-consideration-probe: text_en language-aware classification (#4657)', () => {
// The reproduction pair from the issue: Danish UI-SPEC prose + faithful English translations.
const daList = 'En liste over projektorer med knapper til at forbinde og afbryde.';
const daForm = 'En formular hvor brugeren indtaster IP-adresse og adgangskode.';
const enList = 'A list of projectors with buttons to connect and disconnect.';
const enForm = 'A form where the user enters IP address and password.';
test('proposeConsiderations: text_en present is used for classification instead of text (failing-first regression)', () => {
// text alone (non-English) classifies to zero kinds -> the unclassified sentinel.
const nonEnglishOnly = uc.proposeConsiderations({ id: 'U1', text: daList });
assert.deepEqual(nonEnglishOnly.map((c) => c.category), ['unclassified']);
// text_en present -> classification runs against the English translation (list-collection
// from "list", interactive-control from "buttons").
const withTextEn = uc.proposeConsiderations({ id: 'U1', text: daList, text_en: enList });
assert.deepEqual(
withTextEn.map((c) => c.category),
['empty', 'loading', 'error', 'populated', 'partial', 'overflow', 'zero-one-many', 'long-text'],
);
});
test('#4657: a non-English element with text_en classifies identically to its English equivalent', () => {
for (const [da, en] of [[daList, enList], [daForm, enForm]]) {
const englishOnly = uc.proposeConsiderations({ id: 'U1', text: en });
const nonEnglishWithTranslation = uc.proposeConsiderations({ id: 'U1', text: da, text_en: en });
assert.deepEqual(
nonEnglishWithTranslation.map((c) => c.category),
englishOnly.map((c) => c.category),
`a translated non-English element must raise the same categories as the English original (${da})`,
);
}
});
test('proposeConsiderations: text_en absent falls back to text (back-compat — English projects unchanged)', () => {
const withoutTextEn = uc.proposeConsiderations({ id: 'U1', text: enForm });
assert.deepEqual(withoutTextEn.map((c) => c.category), ['empty', 'loading', 'error', 'partial', 'long-text']);
});
test('validateRequirement: text_en: null is treated as absent (no throw)', () => {
assert.doesNotThrow(() => uc.validateRequirement({ id: 'U1', text: 'A results table', text_en: null }));
});
test('proposeConsiderations: text_en: null falls back to text', () => {
const viaNull = uc.proposeConsiderations({ id: 'U1', text: enForm, text_en: null });
assert.deepEqual(viaNull.map((c) => c.category), ['empty', 'loading', 'error', 'partial', 'long-text']);
});
test('validateRequirement: rejects empty-string text_en (?? does not catch \'\')', () => {
// Nullish coalescing only falls back on null/undefined — an empty string would otherwise
// win `text_en ?? text` and silently classify against '', degrading to zero kinds with no
// signal (the exact fail-open #1110/#2773 exist to eliminate).
assert.throws(
() => uc.validateRequirement({ id: 'U1', text: daList, text_en: '' }),
/text_en must be a non-empty string when present/i,
);
});
test('validateRequirement: rejects whitespace-only text_en', () => {
assert.throws(
() => uc.validateRequirement({ id: 'U1', text: daList, text_en: ' ' }),
/text_en must be a non-empty string when present/i,
);
});
test('validateRequirement: rejects non-string text_en (number/array/object)', () => {
for (const bad of [42, ['x'], {}]) {
assert.throws(
() => uc.validateRequirement({ id: 'U1', text: daList, text_en: bad }),
/text_en must be a non-empty string when present/i,
);
}
});
test('validateRequirement: rejects empty text_en even when an elements override makes it unused', () => {
// Validation is unconditional — it does not skip the text_en check just because the
// authored-elements branch would never classify prose. Bad data fails closed regardless.
assert.throws(
() => uc.validateRequirement({ id: 'U1', text: daList, text_en: '', elements: ['form'] }),
/text_en must be a non-empty string when present/i,
);
});
test('proposeConsiderations: authored elements override still wins when text_en is also present', () => {
// nav is an applicable element kind for loading, error, overflow AND long-text.
const override = uc.proposeConsiderations({ id: 'U9', text: daList, text_en: enList, elements: ['nav'] });
assert.deepEqual(override.map((c) => c.category), ['loading', 'error', 'overflow', 'long-text']);
});
test('proposeConsiderations: zero-cue text_en still surfaces the unclassified soft signal (#1110)', () => {
const zeroCue = uc.proposeConsiderations({ id: 'U1', text: daForm, text_en: 'Purple elephant dreams.' });
assert.deepEqual(zeroCue.map((c) => c.category), ['unclassified']);
});
test('proposeElements: kinds derive from text_en when present (confirm surface sees the translation)', () => {
const [translated] = uc.proposeElements([{ id: 'U1', text: daList, text_en: enList }]);
assert.deepEqual(translated.kinds, ['list-collection', 'interactive-control']);
assert.equal(translated.unclassified, false);
// and without text_en the same element stays on the unclassified path
const [plain] = uc.proposeElements([{ id: 'U1', text: daList }]);
assert.equal(plain.unclassified, true);
assert.deepEqual(plain.kinds, []);
});
test('analyzeCoverage: the reproduction pair — Danish text + text_en yields the English report\'s categories', () => {
const english = uc.analyzeCoverage([{ id: 'U1', text: enList }, { id: 'U2', text: enForm }]);
const translated = uc.analyzeCoverage([{ id: 'U1', text: daList, text_en: enList }, { id: 'U2', text: daForm, text_en: enForm }]);
assert.equal(translated.coverage.applicable, english.coverage.applicable);
assert.equal(translated.coverage.unclassified, 0);
assert.deepEqual(
translated.items.map((c) => c.category),
english.items.map((c) => c.category),
);
});
test('CLI: elements file with text_en classifies through the built engine (exit 0, no unclassified)', (t) => {
const dir = createTempDir('ui-probe-4657-');
t.after(() => cleanup(dir));
const elementsPath = path.join(dir, 'elements.json');
fs.writeFileSync(elementsPath, JSON.stringify([
{ id: 'U1', text: daList, text_en: enList },
{ id: 'U2', text: daForm, text_en: enForm },
]));
const r = runNode([BUILT_SCRIPT, elementsPath], { timeoutMs: PROBE_TIMEOUT_MS });
throwIfFailed(r, `node ${BUILT_SCRIPT} ${elementsPath}`);
const rep = JSON.parse(r.stdout);
assert.equal(rep.coverage.unclassified, 0);
assert.ok(rep.coverage.applicable > 0, 'translated elements must raise applicable categories');
});
});