diff --git a/.gitignore b/.gitignore index c08717e6c..ce01a3157 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ build/ /gsd-core/bin/lib/probe-core.cjs /gsd-core/bin/lib/spec-section.cjs /gsd-core/bin/lib/prohibition-enforcement.cjs +/gsd-core/bin/lib/ui-consideration-probe.cjs /gsd-core/bin/lib/config-types.cjs /gsd-core/bin/lib/cli-exit.cjs /gsd-core/bin/lib/code-review-flags.cjs diff --git a/src/ui-consideration-probe.cts b/src/ui-consideration-probe.cts new file mode 100644 index 000000000..3994006e5 --- /dev/null +++ b/src/ui-consideration-probe.cts @@ -0,0 +1,252 @@ +/** + * 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'; + +/** 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. */ +export interface Element { + id: string; + text?: string; + elements?: UIElementKind[]; +} + +/** A UI consideration item — a probe-core `Item` specialized to the UI verification vocabulary. */ +export type UIConsideration = Item; + +/** + * 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 = { + '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 = 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'], 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'], 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(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 }; + 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`, + ); + } +} + +/** Validate a UI-consideration resolution against the UI verification vocabulary (delegated, D-06). */ +export function validateResolution(resolution: Resolution): 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 { + kinds = classifyElement(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[] = [], +): CoverageReport { + if (!Array.isArray(elements)) { + throw new Error('elements must be an array'); + } + const items: UIConsideration[] = []; + const seenIds = new Set(); + 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); +} + +/* + * CLI entry (invokable surface): `ui-consideration-probe.cjs [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( + (elements, resolutions) => + analyzeCoverage(elements as Element[], resolutions as Resolution[]), + { usage: 'ui-consideration-probe.cjs [resolutions.json]' }, + ); +} diff --git a/tests/ui-consideration-probe.test.cjs b/tests/ui-consideration-probe.test.cjs new file mode 100644 index 000000000..f4316d2ec --- /dev/null +++ b/tests/ui-consideration-probe.test.cjs @@ -0,0 +1,203 @@ +/** + * UI-consideration-probe adapter unit tests (#1867). + * + * Asserts the LOCKED export surface of the THIRD probe-core adapter against the + * BUILT artifact (`gsd-core/bin/lib/ui-consideration-probe.cjs`), which + * `npm run build:lib` (run by pretest) emits from `src/ui-consideration-probe.cts`. + * + * The adapter mirrors `edge-probe` on the UI element/state axis: a closed 8-id + * shape-rooted `UI_TAXONOMY`, an element-kind relevance filter + * (`UI_CUES` → `classifyElement` → `applicableCategories`), the `unclassified` + * soft-signal (#1110), and the `{explicit, backstop}` verification validators — + * all lifecycle/merge/validation delegated to `probe-core` (ADPT-01/02/03, FILT-01). + * + * Structured-value assertions only (local/no-source-grep): every assertion is on a + * typed return of the built module, never on stdout or file-content substrings. + */ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const BUILT_SCRIPT = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'ui-consideration-probe.cjs'); +const uc = require(BUILT_SCRIPT); +// The LIFT-01 primitives are probe-core's (there is no adapter-owned lift function — the lift is +// plan-phase workflow prose); LIFT-01 correctness is proven at the shared primitive level here. +const core = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs')); + +const TAXONOMY_IDS = ['empty', 'loading', 'error', 'populated', 'partial', 'overflow', 'zero-one-many', 'long-text']; + +describe('ui-consideration-probe: classifyElement (D-03/D-04 element-cue filter)', () => { + test('detects form from input/field/validation cues', () => { + assert.ok(uc.classifyElement('A signup form with input fields and validation').includes('form')); + }); + test('detects list-collection from table/rows cues', () => { + assert.ok(uc.classifyElement('A table listing all rows of results').includes('list-collection')); + }); + test('detects static-content from heading/paragraph/copy cues', () => { + assert.ok(uc.classifyElement('A heading and a paragraph of body copy').includes('static-content')); + }); + test('returns [] when no element cue matches (zero-cue prose)', () => { + assert.deepEqual(uc.classifyElement('xyzzy plugh frobnicate wibble'), []); + }); + test('null/undefined text is null-safe and returns []', () => { + assert.deepEqual(uc.classifyElement(null), []); + assert.deepEqual(uc.classifyElement(undefined), []); + }); +}); + +describe('ui-consideration-probe: UI_TAXONOMY + UI_VALIDATORS (ADPT-02/03, D-01/D-02/D-05)', () => { + test('UI_TAXONOMY has exactly the 8 shape-rooted ids in order', () => { + assert.deepEqual(uc.UI_TAXONOMY.map((c) => c.id), TAXONOMY_IDS); + }); + test('every taxonomy entry has name, elements[], and a string consideration', () => { + for (const c of uc.UI_TAXONOMY) { + assert.equal(typeof c.name, 'string'); + assert.ok(Array.isArray(c.elements) && c.elements.length >= 1); + assert.equal(typeof c.consideration, 'string'); + assert.ok(c.consideration.length > 0); + } + }); + test('UNCLASSIFIED_CATEGORY is the soft-signal, kept OUT of the taxonomy (#1110)', () => { + assert.equal(uc.UNCLASSIFIED_CATEGORY, 'unclassified'); + assert.ok(!uc.UI_TAXONOMY.map((c) => c.id).includes('unclassified')); + }); + test('UI_VALIDATORS.categories === the 8 ids plus unclassified; verification is the {explicit,backstop} tiers (singular key)', () => { + assert.deepEqual(uc.UI_VALIDATORS.categories, [...TAXONOMY_IDS, 'unclassified']); + // The probe-core Validators field is `verification` (SINGULAR) — CONTEXT.md D-05's `verifications` is a paraphrase typo. + assert.deepEqual(uc.UI_VALIDATORS.verification, ['explicit', 'backstop']); + assert.equal(uc.UI_VALIDATORS.verifications, undefined); + }); + test('VALID_ELEMENT_KINDS is derived from UI_CUES keys (single source of truth)', () => { + assert.deepEqual([...uc.VALID_ELEMENT_KINDS].sort(), Object.keys(uc.UI_CUES).sort()); + }); +}); + +describe('ui-consideration-probe: applicableCategories (FILT-01 relevance intersection, D-04)', () => { + test('static-content raises only overflow + long-text (no loading/error/empty — SPEC R2 hint)', () => { + assert.deepEqual(uc.applicableCategories(['static-content']).sort(), ['long-text', 'overflow']); + }); + test('list-collection raises the richest set (empty/loading/error/populated/partial/overflow/zero-one-many)', () => { + assert.deepEqual(uc.applicableCategories(['list-collection']).sort(), + ['empty', 'error', 'loading', 'overflow', 'partial', 'populated', 'zero-one-many']); + }); + test('no element kinds raises nothing', () => { + assert.deepEqual(uc.applicableCategories([]), []); + }); + test('result ids are a subset of the taxonomy ids', () => { + const all = uc.applicableCategories(['form', 'list-collection', 'nav', 'media', 'interactive-control', 'static-content']); + for (const id of all) assert.ok(TAXONOMY_IDS.includes(id)); + }); + test('every UIElementKind maps to >= 1 taxonomy category (a classified element never silently yields zero considerations)', () => { + for (const kind of Object.keys(uc.UI_CUES)) { + assert.ok(uc.applicableCategories([kind]).length >= 1, `element kind ${kind} must have >= 1 applicable category`); + } + }); +}); + +describe('ui-consideration-probe: proposeConsiderations (ADPT-01/FILT-01, #1110)', () => { + test('emits exactly one unresolved Item per applicable category, question carried in Item.probe', () => { + const element = { id: 'C1', text: 'A table listing all rows of results' }; + const items = uc.proposeConsiderations(element); + const expected = uc.applicableCategories(uc.classifyElement(element.text)); + assert.deepEqual(items.map((i) => i.category).sort(), [...expected].sort()); + for (const it of items) { + assert.equal(it.requirement_id, 'C1'); + assert.equal(it.status, 'unresolved'); + assert.equal(it.verification, null); + assert.equal(it.resolution, null); + assert.equal(it.reason, null); + assert.equal(typeof it.probe, 'string'); + assert.ok(it.probe.length > 0); + } + }); + test('zero-cue prose yields exactly ONE unclassified item, never a silent drop or minted category (#1110)', () => { + const items = uc.proposeConsiderations({ id: 'Z', text: 'xyzzy plugh frobnicate' }); + assert.equal(items.length, 1); + assert.equal(items[0].category, 'unclassified'); + assert.equal(items[0].status, 'unresolved'); + assert.equal(items[0].verification, null); + }); + test('an explicit `elements: []` opt-out is silent (no items, no unclassified)', () => { + assert.deepEqual(uc.proposeConsiderations({ id: 'O', text: 'anything', elements: [] }), []); + }); + test('an authored array with an invalid element kind throws (fail closed, never silently empty)', () => { + assert.throws(() => uc.proposeConsiderations({ id: 'B', text: 'x', elements: ['not-a-kind'] })); + }); + test('an authored valid element override bypasses prose classification', () => { + const items = uc.proposeConsiderations({ id: 'A', text: 'no cues here at all', elements: ['static-content'] }); + assert.deepEqual(items.map((i) => i.category).sort(), ['long-text', 'overflow']); + }); +}); + +describe('ui-consideration-probe: delegated validation (ADPT-03, D-06 — inherited from probe-core)', () => { + test('validateResolution rejects a dismissed resolution with an empty/blank reason', () => { + assert.throws(() => uc.validateResolution({ + requirement_id: 'C1', category: 'empty', status: 'dismissed', verification: null, resolution: null, reason: ' ', + })); + }); + test('analyzeCoverage rejects an orphan resolution (no matching proposed item)', () => { + const elements = [{ id: 'C1', text: 'A table listing all rows of results' }]; + const orphan = [{ + requirement_id: 'C1', category: 'nonexistent-category', status: 'resolved', + verification: 'explicit', resolution: 'x', reason: null, + }]; + assert.throws(() => uc.analyzeCoverage(elements, orphan)); + }); + test('analyzeCoverage delegates a clean merge to probe-core and reports coverage', () => { + const elements = [{ id: 'C1', text: 'A table listing all rows of results' }]; + const report = uc.analyzeCoverage(elements, []); + assert.ok(report && report.coverage && typeof report.coverage.applicable === 'number'); + assert.ok(Array.isArray(report.items) && report.items.length >= 1); + }); +}); + +// ── LIFT-01 (proven at the shared probe-core primitive level on UI-SPEC-shaped input) ────────── +// A resolved `## UI Considerations` section after resolution: a `covered` (inferable) +// consideration → a plain-string truth; a `backstop` (non-inferable, purely-visual) consideration +// → carries verification: 'backstop'. Measures DISPOSITION, not entry-count (SPEC R7, Goodhart). +const COVERED = 'Empty state for the results table renders the documented "No results" copy.'; +const BACKSTOP = { statement: 'Overflowing long labels truncate with an ellipsis without shifting layout.', verification: 'backstop' }; +const COVERED_2 = 'Loading state shows a skeleton for the results table.'; + +describe('ui-consideration-probe LIFT-01: projectTruths (covered→string, backstop→flat scalar, D-07)', () => { + test('covered consideration projects to a bare string; backstop projects to {statement, verification:backstop}', () => { + const out = core.projectTruths([COVERED, BACKSTOP]); + assert.equal(out[0], COVERED); + assert.deepEqual(out[1], { statement: BACKSTOP.statement, verification: 'backstop' }); + }); + test('projection preserves input order (deterministic lift over taxonomy id order — ordering/stability edge)', () => { + const out = core.projectTruths([COVERED, COVERED_2, BACKSTOP]); + assert.equal(out[0], COVERED); + assert.equal(out[1], COVERED_2); + assert.deepEqual(out[2], { statement: BACKSTOP.statement, verification: 'backstop' }); + }); + test('no covered/backstop consideration is silently dropped', () => { + const input = [COVERED, COVERED_2, BACKSTOP]; + assert.equal(core.projectTruths(input).length, input.length); + }); +}); + +describe('ui-consideration-probe LIFT-01: verify-time disposition (never silent pass, D-09)', () => { + test('a no-evidence backstop consideration routes to insufficient_spec — NEVER a silent green', () => { + const d = core.dispositionForUnverifiableTruth(BACKSTOP, { evidence: [] }); + assert.equal(d.status, 'unverified'); + assert.equal(d.flagged, true); + assert.equal(d.tier, 'backstop'); + assert.equal(d.reason, core.INSUFFICIENT_SPEC); + assert.equal(core.INSUFFICIENT_SPEC, 'insufficient_spec'); + assert.notEqual(d.status, 'green'); + }); + test('a backstop consideration WITH explicit evidence (a passing wired test) disposes green', () => { + const d = core.dispositionForUnverifiableTruth(BACKSTOP, { evidence: [{ kind: 'wired-test', passed: true }] }); + assert.equal(d.status, 'green'); + assert.equal(d.flagged, false); + }); + test('a covered (inferable) consideration disposes green even with no evidence (over-abstention guard)', () => { + const d = core.dispositionForUnverifiableTruth(COVERED, { evidence: [] }); + assert.equal(d.status, 'green'); + assert.equal(d.flagged, false); + }); +});