Files
msd-core/src/ui-consideration-probe.cts
Tom Boucher 5e729445d3 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>
2026-09-16 13:46:40 -04:00

349 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UI-consideration probe — the THIRD adapter of the probe-core resolution model
* (ADR-457 build model; ADR-550 Decision 7 seam; #1867).
*
* The generic resolution lifecycle, the status×verification re-cut, `validateResolution`,
* `validateRequirement`, the `analyzeCoverage` merge/rollup/orphan-reject engine, and the
* `runProbeCli` scaffold all live in `src/probe-core.cts`. This module keeps ONLY the
* UI-specific cluster: the six element kinds, the closed 8-category shape-rooted UI state
* taxonomy, element classification, consideration proposal, and the `{ explicit, backstop }`
* verification validators — mirroring `edge-probe` on the UI element/state axis.
*
* MIXED-axis boundary (spike verdict, ADR-550 pattern): this compiled taxonomy covers ONLY the
* finite, project-independent shape-rooted *content/robustness* states (empty/loading/error/…).
* Open, domain-specific UX considerations (real-time/offline, deep a11y/WCAG breadth, i18n/RTL
* depth, emerging interaction paradigms) are prose-owned in `references/domain-probes.md`, NOT
* here — forcing them into a closed compiled taxonomy is the wrong model.
*
* Authored as strict TypeScript (`src/ui-consideration-probe.cts`) and compiled by
* `tsc -p tsconfig.build.json` to the gitignored runtime artifact
* `gsd-core/bin/lib/ui-consideration-probe.cjs`. Do NOT hand-write the `.cjs`; it is emitted.
* Tests `require()` the built artifact; `pretest` runs `build:lib` first.
*/
import {
type Item,
type Resolution,
type CoverageReport,
type Validators,
validateRequirement as coreValidateRequirement,
validateResolution as coreValidateResolution,
analyzeCoverage as coreAnalyzeCoverage,
runProbeCli,
} from './probe-core.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitModule = require('./cli-exit.cjs');
const { runMain } = cliExitModule;
/** The six UI element kinds a described component can be (the closed relevance axis, D-03). */
export type UIElementKind =
| 'form'
| 'list-collection'
| 'nav'
| 'media'
| 'interactive-control'
| 'static-content';
/** The UI probe's verification tiers (mirrors EdgeVerification — the `verification` axis values). */
export type UIVerification = 'explicit' | 'backstop';
/** A single UI-state taxonomy category. `elements` lists which kinds make it applicable. */
export interface TaxonomyEntry {
id: string;
name: string;
elements: UIElementKind[];
consideration: string;
}
/**
* 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[];
}
/** A UI consideration item — a probe-core `Item` specialized to the UI verification vocabulary. */
export type UIConsideration = Item<UIVerification>;
/**
* Word-boundary cues mapping element prose -> UI element kind.
* Heuristic and intentionally lossy; an authored `elements` array overrides it. Every pattern is a
* flat linear `\b(a|b|c)\b` alternation with NO nested/overlapping quantifiers (no catastrophic
* backtracking — mirrors SHAPE_CUES).
*/
export const UI_CUES: Record<UIElementKind, RegExp> = {
'form': /\b(forms?|inputs?|fields?|submit|validation|validate|password|email|checkbox|radio|textarea)\b/i,
'list-collection': /\b(lists?|listing|tables?|grids?|collections?|rows?|items?|cards?|feed|results?)\b/i,
'nav': /\b(nav|navigation|menus?|tabs?|breadcrumbs?|pagination|sidebars?)\b/i,
'media': /\b(images?|img|videos?|avatars?|thumbnails?|photos?|gallery|icons?)\b/i,
'interactive-control': /\b(buttons?|toggles?|switch|switches|dropdowns?|sliders?|controls?|pickers?)\b/i,
'static-content': /\b(labels?|headings?|titles?|paragraphs?|copy|descriptions?|text)\b/i,
};
/** The locked element vocabulary — exactly the keys of UI_CUES (single source of truth). */
export const VALID_ELEMENT_KINDS: ReadonlySet<string> = new Set(Object.keys(UI_CUES));
/** Detect which element kinds a description's prose matches (heuristic). */
export function classifyElement(text: string): UIElementKind[] {
const kinds: UIElementKind[] = [];
const subject = String(text == null ? '' : text);
for (const kind of Object.keys(UI_CUES) as UIElementKind[]) {
if (UI_CUES[kind].test(subject)) kinds.push(kind);
}
return kinds;
}
/**
* Closed taxonomy of 8 shape-rooted UI *content/robustness* state categories. `elements` lists
* which element kinds make the category relevant. These ids are the CLOSED/compiled subset — the
* open UX subset (real-time/offline, deep a11y, i18n/RTL depth) is prose-owned in
* `references/domain-probes.md` and deliberately absent here (D-02).
*/
export const UI_TAXONOMY: TaxonomyEntry[] = [
{ id: 'empty', name: 'Empty / no data', elements: ['form', 'list-collection', 'media'], consideration: 'What is shown when there is no data — zero items, an unfilled form, or absent media?' },
{ id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
{ id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
{ id: 'populated', name: 'Populated / happy path', elements: ['list-collection', 'media'], consideration: 'What does the normal populated (happy-path) state look like at a typical volume of content?' },
{ id: 'partial', name: 'Partial / incomplete', elements: ['form', 'list-collection'], consideration: 'What is shown for partial or incomplete data — some fields or rows present, others missing?' },
{ id: 'overflow', name: 'Overflow / truncation', elements: ['list-collection', 'nav', 'static-content'], consideration: 'What happens when content exceeds its container — scroll, clip, wrap, or truncate?' },
{ id: 'zero-one-many', name: 'Zero / one / many', elements: ['list-collection'], consideration: 'How does the layout read at zero, one, and many items (singular vs plural copy, spacing)?' },
{ id: 'long-text', name: 'Long text', elements: ['form', 'static-content', 'interactive-control', 'nav'], consideration: 'What happens with unusually long text — truncation, wrapping, ellipsis, or reflow?' },
];
/** Return taxonomy category ids whose applicable element kinds intersect the input set. */
export function applicableCategories(kinds: UIElementKind[]): string[] {
const set = new Set<UIElementKind>(kinds);
return UI_TAXONOMY.filter((c) => c.elements.some((k) => set.has(k))).map((c) => c.id);
}
/**
* Pseudo-category for an element whose prose matched NO element cue (#1110). It is a soft
* "review manually" signal, NOT a 9th taxonomy category: it stays out of `UI_TAXONOMY` (the closed
* eight) and only joins `UI_VALIDATORS.categories` so `analyzeCoverage` accepts the item.
*/
export const UNCLASSIFIED_CATEGORY = 'unclassified';
const UNCLASSIFIED_PROBE = 'unclassified — review manually';
/**
* The UI adapter's injected runtime validators (ADR-550 #5). `categories` is the closed taxonomy
* plus the unclassified soft-signal; both verification tiers require a non-empty `resolution` so
* plan-phase has a criterion to lift. NOTE the probe-core Validators field is `verification`
* (SINGULAR); CONTEXT.md D-05's `verifications` is a paraphrase typo, not the real field name.
*/
export const UI_VALIDATORS: Validators = {
categories: [...UI_TAXONOMY.map((c) => c.id), UNCLASSIFIED_CATEGORY],
verification: ['explicit', 'backstop'],
requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
};
/**
* Validate a single element — the generic id/text checks (probe-core) plus the UI adapter's
* `elements`-must-be-an-array check. The `text` prose is REQUIRED (it is the classification
* signal), so reject a missing/empty `text` when no authored `elements` override is present.
* Without this, a `{ id }` element classifies to zero kinds → zero considerations → it is silently
* DROPPED from coverage. An explicit `elements` array (including `[]` for "no applicable
* categories") is the legitimate way to opt out of prose classification.
*/
export function validateRequirement(element: Element): void {
coreValidateRequirement(element);
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`);
}
if (r.elements == null && !(typeof r.text === 'string' && r.text.trim())) {
throw new Error(
`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). */
export function validateResolution(resolution: Resolution<UIVerification>): true {
return coreValidateResolution(resolution, UI_VALIDATORS);
}
/**
* Propose candidate considerations for an element. Uses authored `elements` when present, else
* classifies from prose. Every proposed consideration starts unresolved (verification null); the
* taxonomy entry's `consideration` question is carried in the item's `probe` field.
*/
export function proposeConsiderations(element: Element): UIConsideration[] {
validateRequirement(element);
let kinds: UIElementKind[];
if (Array.isArray(element.elements)) {
// Fail closed: an authored array must contain only locked element kinds. A non-empty but
// invalid array would otherwise intersect no category and silently suppress every probe — the
// gate reads green while nothing was checked. An empty array stays a valid "no applicable
// categories" override (silent opt-out).
for (const k of element.elements) {
if (typeof k !== 'string' || !VALID_ELEMENT_KINDS.has(k)) {
throw new Error(
`invalid element kind ${JSON.stringify(k)} for element ${element.id} — must be one of: ${[...VALID_ELEMENT_KINDS].join(', ')}`,
);
}
}
kinds = element.elements;
} else {
// #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
// exact blind spot this probe exists to catch. Surface ONE soft, dismissible "unclassified —
// review manually" candidate. The explicit `elements: []` opt-out (above) stays silent.
return [{
requirement_id: element.id,
category: UNCLASSIFIED_CATEGORY,
status: 'unresolved',
verification: null,
resolution: null,
reason: null,
probe: UNCLASSIFIED_PROBE,
}];
}
}
return applicableCategories(kinds).map((catId): UIConsideration => {
const cat = UI_TAXONOMY.find((c) => c.id === catId);
return {
requirement_id: element.id,
category: catId,
status: 'unresolved',
verification: null,
resolution: null,
reason: null,
probe: cat ? cat.consideration : '',
};
});
}
/**
* Propose considerations for every element (deterministic propose), then delegate the
* merge/rollup/orphan-reject to probe-core. UI-specific pre-checks: elements must be an array,
* element ids must be unique. Throws on any invalid resolution.
*/
export function analyzeCoverage(
elements: Element[],
resolutions: Resolution<UIVerification>[] = [],
): CoverageReport<UIVerification> {
if (!Array.isArray(elements)) {
throw new Error('elements must be an array');
}
const items: UIConsideration[] = [];
const seenIds = new Set<string>();
for (const el of elements) {
validateRequirement(el);
if (seenIds.has(el.id)) {
throw new Error(`duplicate element id ${JSON.stringify(el.id)}`);
}
seenIds.add(el.id);
for (const consideration of proposeConsiderations(el)) items.push(consideration);
}
return coreAnalyzeCoverage(items, resolutions, UI_VALIDATORS);
}
/**
* A per-element propose-then-confirm view (WIRE-01, #1867): the detected element `kinds`, the
* `categories` they raise, the proposed `considerations`, and an `unclassified` flag. The ui-phase
* probe step surfaces `kinds` to the user so a human can ADD a kind the heuristic missed — the
* classifier is a SIGNAL, not ground truth (Goodhart). A single tripped cue on a multi-kind surface
* under-covers; the confirm step, not the heuristic, is what makes coverage sound.
*/
export interface ElementProposal {
id: string;
kinds: UIElementKind[];
categories: string[];
considerations: UIConsideration[];
unclassified: boolean;
}
/**
* Build the propose-then-confirm view for every element (WIRE-01). Deterministic: a pure function
* of the input array (no Date/random/iteration-order surprise), so re-running the probe on an
* unchanged UI-SPEC yields byte-identical rows (the idempotency substrate WIRE-02 relies on). An
* aggregating VIEW over the existing Phase-1 functions — it adds no new classification logic.
*
* `unclassified` is true ONLY when prose classified to zero cues (#1110); an explicit `elements: []`
* opt-out stays silent (`unclassified: false`, empty considerations), matching proposeConsiderations.
*/
export function proposeElements(elements: Element[]): ElementProposal[] {
return elements.map((el): ElementProposal => {
validateRequirement(el);
const considerations = proposeConsiderations(el);
const kinds: UIElementKind[] = Array.isArray(el.elements)
? el.elements // already validated inside proposeConsiderations
: 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 };
});
}
/**
* The deterministic `--auto` resolution FLOOR (WIRE-01, SC2). For each proposed consideration:
* - an `unclassified` item stays `unresolved` — NEVER auto-backstopped (a missing cue is not
* evidence a consideration applies, #1110);
* - every applicable item auto-resolves to a conservative `backstop` (carrying the taxonomy
* question as its `resolution` so probe-core's "backstop requires a resolution" check passes).
* - it NEVER emits `dismissed` under any branch — a wrong auto-dismissal is the exact silent
* failure this probe eliminates (the never-dismiss invariant, asserted on the typed return).
*
* This is the CODE floor only. It mirrors spec-phase.md Step 5.5's prose `--auto` rule
* (auto-`covered` where a defensible acceptance criterion can be written, else auto-`backstop`,
* never auto-`dismiss`) but deliberately keeps the covered-vs-backstop JUDGMENT in the ui-phase
* workflow (an LLM MAY upgrade an item to `explicit`/covered when it can write a real acceptance
* criterion). Encoding the never-dismiss FLOOR in code is what makes the invariant unit-testable;
* the covered-upgrade stays prose because "a defensible criterion exists" is not a code predicate.
* Keep the two in sync: if spec-phase's `--auto` policy changes, revisit this floor.
*/
export function autoResolve(items: UIConsideration[]): Resolution<UIVerification>[] {
return items.map((item): Resolution<UIVerification> => {
if (item.category === UNCLASSIFIED_CATEGORY) {
return { requirement_id: item.requirement_id, category: item.category, status: 'unresolved', verification: null, resolution: null, reason: null };
}
return { requirement_id: item.requirement_id, category: item.category, status: 'resolved', verification: 'backstop', resolution: item.probe, reason: null };
});
}
/*
* CLI entry (invokable surface): `ui-consideration-probe.cjs <elements.json> [resolutions.json]`.
* The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's
* `runProbeCli`; this adapter supplies its `analyzeCoverage`. Guarded by `require.main === module`
* so it runs only when the compiled `.cjs` is executed directly.
*/
if (require.main === module) {
// runProbeCli's default `exit` now throws ExitError (src/probe-core.cts) rather
// than calling process.exit directly, so this entry point must run under
// runMain to translate that throw into process.exitCode.
runMain(() => {
runProbeCli(
(elements, resolutions) =>
analyzeCoverage(elements as Element[], resolutions as Resolution<UIVerification>[]),
{ usage: 'ui-consideration-probe.cjs <elements.json> [resolutions.json]' },
);
});
}