diff --git a/.changeset/quick-pumas-climb.md b/.changeset/quick-pumas-climb.md new file mode 100644 index 000000000..ffbabe79d --- /dev/null +++ b/.changeset/quick-pumas-climb.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3451 +--- +ui-plan-gate no longer blocks planning on a UI-token match alone: the gate now requires static frontend evidence (a package.json UI-framework dependency or a component-framework file in the tree) before blocking, so a phase section naming a hyphenated repo like dashboard-financeiro no longer trips the gate in a repo with no frontend. The gate result also surfaces matchedToken/matchedLine so operators can see what triggered the flag. diff --git a/.gitignore b/.gitignore index 092f554bf..df786ab7d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,7 @@ build/ /gsd-core/bin/lib/command-arg-projection.cjs /gsd-core/bin/lib/clock.cjs /gsd-core/bin/lib/ui-safety-gate.cjs +/gsd-core/bin/lib/ui-frontend-evidence.cjs /gsd-core/bin/lib/review-reviewer-selection.cjs /gsd-core/bin/lib/review-lane-descriptor.cjs /gsd-core/bin/lib/review-lane-invocation.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 6cc973dc8..45dbaea76 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -504,6 +504,7 @@ "uat-predicate.cjs", "uat.cjs", "ui-consideration-probe.cjs", + "ui-frontend-evidence.cjs", "ui-safety-gate.cjs", "unusable-input.cjs", "update-context.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 81bc3e2bc..703ee2ab7 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -606,6 +606,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `uat-predicate.cjs` | UAT-passed predicate — markdown-aware evaluation of HUMAN-UAT results; returns pass only when all required checks pass; ignores false-positive contexts (frontmatter, fenced code, blockquotes, HTML comments) | | `ui-consideration-probe.cjs` | Spec-completeness UI-consideration probe (compiled from `src/ui-consideration-probe.cts`, gitignored) — the third adapter of the `probe-core` resolution model (ADR-550 Decision 7): element-kind classification, applicable-category relevance filter, consideration proposal, `proposeElements`/`autoResolve` (propose-then-confirm + the `--auto` never-dismiss floor), and the `{explicit, backstop}` validators; delegates merge/rollup/CLI to `probe-core`; exports `classifyElement`, `applicableCategories`, `proposeConsiderations`, `proposeElements`, `autoResolve`, `analyzeCoverage`, `UI_TAXONOMY` (#1867) | | `ui-safety-gate.cjs` | Shell-free word-boundary UI token detector (#3706, #3718); reads phase-section text from stdin, exits 0 (UI found) or 1 (no UI); also deployed to `gsd-core/bin/lib/` so the GSD installer ships it to `$RUNTIME_DIR` (#448) | +| `ui-frontend-evidence.cjs` | Static frontend-evidence detector (compiled from `src/ui-frontend-evidence.cts`, gitignored) — plan-time structural corroboration for `computeUiPlanGate` (#3312): a `package.json` UI-framework dependency or a component-framework file (`*.tsx/*.jsx/*.vue/*.svelte`) in the tree, so a UI-token match on a hyphenated proper noun (e.g. repo `dashboard-financeiro`) cannot block planning in a repo with no frontend; mirrors the post-wave `computeUiSafetyGate` git-diff corroboration | | `update-context.cjs` | Pure install-context resolver for `/gsd-update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) | | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | | `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async | diff --git a/eslint.config.mjs b/eslint.config.mjs index bbf3fdc62..008aea1b8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -104,6 +104,8 @@ export default tseslint.config( 'gsd-core/bin/lib/command-arg-projection.cjs', 'gsd-core/bin/lib/clock.cjs', 'gsd-core/bin/lib/ui-safety-gate.cjs', + // #3312: tsc-generated runtime artifact — lint the src/ui-frontend-evidence.cts source. + 'gsd-core/bin/lib/ui-frontend-evidence.cjs', 'gsd-core/bin/lib/review-reviewer-selection.cjs', 'gsd-core/bin/lib/review-lane-descriptor.cjs', 'gsd-core/bin/lib/review-lane-invocation.cjs', diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 8a339a7be..6fdcde3a5 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -26,6 +26,7 @@ const { extractFrontmatter } = frontmatterMod; import { stripFencedCode, collectSections } from './markdown-sectionizer.cjs'; import { validatePath } from './security.cjs'; import { checkUiPresence } from './ui-safety-gate.cjs'; +import { hasStaticFrontendEvidence } from './ui-frontend-evidence.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import verifyModule = require('./verify.cjs'); const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule; @@ -477,8 +478,9 @@ function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: bool * ui-plan-gate: given a phase number, checks whether the phase has frontend * indicators and whether a *-UI-SPEC.md already exists in the phase directory. * - * Returns JSON: { frontend: boolean, hasUiSpec: boolean, block: boolean } - * block = frontend && !hasUiSpec (gate fires when UI work is detected but no spec exists) + * Returns JSON: { frontend, hasFrontendEvidence, hasUiSpec, block, uiSpecPath, matchedToken, matchedLine } + * block = frontend && hasFrontendEvidence && !hasUiSpec (#3312: gate fires when + * UI work is detected AND the repo has static frontend evidence but no spec exists) * * Invocable as: gsd_run check ui-plan-gate * @@ -511,16 +513,30 @@ function findUiSpecInDir(phaseDir: string): string { * (b) Runs checkUiPresence (frontend detection) — no reimplementation. * (c) Resolves the phase directory via findPhaseInternal (phase-locator.cjs); checks for *-UI-SPEC.md. * - * Returns: { frontend, hasUiSpec, block, uiSpecPath, phaseLookupFailed } - * block = frontend && !hasUiSpec + * Returns: { frontend, hasFrontendEvidence, hasUiSpec, block, uiSpecPath, matchedToken, matchedLine, phaseLookupFailed } + * block = frontend && hasFrontendEvidence && !hasUiSpec (#3312) * phaseLookupFailed = ROADMAP.md present but phase header not found (surfaced for * onError:halt gates so a missing phase doesn't silently bypass) + * + * #3312 — structural corroboration: `frontend` is a vocabulary signal only. A + * hyphen is a word boundary, so a phase naming the repo `dashboard-financeiro` + * matches the token `dashboard` exactly like the real compound `micro-frontend` + * (the boundary rule of #3718 is intentional and untouched). The gate therefore + * blocks only when the token match is corroborated by static frontend evidence + * in the repo tree (hasStaticFrontendEvidence: package.json UI-framework dep or + * a component-framework file). This mirrors the sibling post-wave gate + * computeUiSafetyGate, which requires `hasUiFiles` (git diff) before blocking. + * matchedToken/matchedLine surface what tripped the sniffer so an operator can + * judge the flag in one second instead of reaching for --skip-ui. */ function computeUiPlanGate(projectDir: string, phase: string): { frontend: boolean; + hasFrontendEvidence: boolean; hasUiSpec: boolean; block: boolean; uiSpecPath: string | null; + matchedToken: string | null; + matchedLine: string | null; phaseLookupFailed?: boolean; } { // (a) Read the phase section text using the same two-pass lookup as roadmap.get-phase. @@ -549,6 +565,10 @@ function computeUiPlanGate(projectDir: string, phase: string): { const presenceResult = checkUiPresence(phaseSection); const frontend = presenceResult.hasUI; + // (b') #3312 — static structural corroboration. Only probed when the sniffer + // matched (evidence is irrelevant otherwise); failures degrade to false. + const hasFrontendEvidence = frontend ? hasStaticFrontendEvidence(projectDir) : false; + // (c) Resolve phase directory via findPhaseInternal and check for *-UI-SPEC.md let phaseDir = ''; try { @@ -568,11 +588,23 @@ function computeUiPlanGate(projectDir: string, phase: string): { const uiSpecPath = findUiSpecInDir(phaseDir); const hasUiSpec = uiSpecPath !== ''; - // block = frontend phase with no UI-SPEC - const block = frontend && !hasUiSpec; + // block = frontend phase with structural frontend evidence and no UI-SPEC (#3312) + const block = frontend && hasFrontendEvidence && !hasUiSpec; - const result: { frontend: boolean; hasUiSpec: boolean; block: boolean; uiSpecPath: string | null; phaseLookupFailed?: boolean } = { - frontend, hasUiSpec, block, uiSpecPath: hasUiSpec ? uiSpecPath : null, + const result: { + frontend: boolean; + hasFrontendEvidence: boolean; + hasUiSpec: boolean; + block: boolean; + uiSpecPath: string | null; + matchedToken: string | null; + matchedLine: string | null; + phaseLookupFailed?: boolean; + } = { + frontend, hasFrontendEvidence, hasUiSpec, block, + uiSpecPath: hasUiSpec ? uiSpecPath : null, + matchedToken: presenceResult.matchedToken, + matchedLine: presenceResult.matchedLine, }; if (phaseLookupFailed) result.phaseLookupFailed = true; return result; diff --git a/src/ui-frontend-evidence.cts b/src/ui-frontend-evidence.cts new file mode 100644 index 000000000..693beca7f --- /dev/null +++ b/src/ui-frontend-evidence.cts @@ -0,0 +1,147 @@ +/** + * Static frontend-evidence detector — plan-time structural corroboration for the + * UI plan gate (#3312). + * + * `checkUiPresence` (ui-safety-gate.cjs) is a *vocabulary* signal: a hyphen is a + * word boundary, so a phase section naming the repo `dashboard-financeiro` matches + * the token `dashboard` exactly like the real UI compound `micro-frontend` does. + * That boundary rule is intentional (#3718) and must not be weakened — instead, + * the plan gate (`computeUiPlanGate` in check-command-router.cjs) corroborates the + * token match against the static repo tree before blocking. + * + * This mirrors what the sibling post-wave gate (`computeUiSafetyGate`) already + * does dynamically: it blocks only when `git diff HEAD~1 HEAD` touches UI files. + * Plan time has no diff to inspect, so the corroboration here is static: + * + * (a) a `package.json` (root) with a known UI-framework dependency — a project + * that ships react/vue/svelte/... in its manifest is a frontend regardless + * of file layout; + * (b) any `*.tsx` / `*.jsx` / `*.vue` / `*.svelte` file in the tree — the + * component-framework subset of `UI_FILE_EXTENSIONS_RE` + * (check-command-router.cjs). The weaker members of that list (css, scss, + * html, ...) are deliberately NOT static evidence: docs sites and + * markdown/bash/config repos routinely carry stray `.html`/`.css`, which + * is precisely the false-positive class #3312 reports. + * + * All I/O failures degrade to `false` (no evidence) — never throw. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Component-framework file extensions — the static-evidence subset of UI_FILE_EXTENSIONS_RE. */ +export const UI_COMPONENT_FILE_RE = /\.(tsx|jsx|vue|svelte)$/i; + +/** + * UI-framework package.json dependencies (dependencies OR devDependencies). + * Component frameworks/renderers only — deliberately excludes meta tooling + * (typescript, eslint, ...) that non-frontend Node projects also carry. + */ +const UI_FRAMEWORK_DEPS: ReadonlySet = new Set([ + 'react', + 'react-dom', + 'vue', + 'svelte', + '@sveltejs/kit', + 'angular', + '@angular/core', + 'preact', + 'solid-js', + 'lit', + 'lit-element', + 'ember-source', + '@remix-run/react', + 'react-native', + 'expo', + 'next', + 'nuxt', + 'gatsby', + 'astro', + '@ionic/react', + '@ionic/vue', + '@ionic/angular', +]); + +/** Directories never walked — dependencies, VCS data, build output, GSD planning state. */ +const SKIP_DIRS: ReadonlySet = new Set([ + 'node_modules', + '.git', + '.planning', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.output', + 'coverage', + 'vendor', + '.cache', +]); + +/** Walk safety cap — beyond this the tree is treated as scanned (evidence decided by then). */ +const MAX_WALK_ENTRIES = 10_000; + +function packageJsonHasUiFramework(projectDir: string): boolean { + let raw: string; + try { + raw = fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'); + } catch { + return false; // no/unreadable package.json → no evidence from this signal + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return false; // malformed package.json → no evidence from this signal + } + if (parsed === null || typeof parsed !== 'object') return false; + const pkg = parsed as Record; + for (const field of ['dependencies', 'devDependencies', 'peerDependencies'] as const) { + const deps = pkg[field]; + if (deps === null || typeof deps !== 'object') continue; + for (const name of Object.keys(deps)) { + if (UI_FRAMEWORK_DEPS.has(name)) return true; + } + } + return false; +} + +function treeHasComponentFile(projectDir: string): boolean { + // Iterative BFS — bounded by MAX_WALK_ENTRIES so a pathological tree cannot + // stall the gate. Symlinks are never followed (withFileTypes + isDirectory). + const queue: string[] = [projectDir]; + let visited = 0; + while (queue.length > 0 && visited < MAX_WALK_ENTRIES) { + const dir = queue.shift() as string; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; // unreadable directory → skip it + } + for (const entry of entries) { + visited++; + if (visited >= MAX_WALK_ENTRIES) return false; + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) queue.push(path.join(dir, entry.name)); + } else if (entry.isFile() && UI_COMPONENT_FILE_RE.test(entry.name)) { + return true; + } + } + } + return false; +} + +/** + * Does the project tree carry static evidence of a frontend? + * + * @param projectDir - Absolute path to the project root (the gate's cwd). + * @returns true when package.json declares a UI-framework dependency or the + * tree contains a component-framework file; false otherwise (including + * on any I/O failure — evidence must be affirmative). + */ +export function hasStaticFrontendEvidence(projectDir: string): boolean { + if (typeof projectDir !== 'string' || projectDir === '') return false; + if (packageJsonHasUiFramework(projectDir)) return true; + return treeHasComponentFile(projectDir); +} diff --git a/src/ui-safety-gate.cts b/src/ui-safety-gate.cts index 88649a74a..cf4959741 100644 --- a/src/ui-safety-gate.cts +++ b/src/ui-safety-gate.cts @@ -16,7 +16,8 @@ * matched; "micro-frontend" and "micro frontend" ARE matched). * * Public API: - * checkUiPresence(text: string): { hasUI: boolean, tokens: string[] } + * checkUiPresence(text: string): { hasUI: boolean, tokens: string[], + * matchedToken: string|null, matchedLine: string|null } * * CLI usage — reads phase-section text from STDIN to avoid ARG_MAX limits: * echo "$PHASE_SECTION" | node gsd-core/bin/lib/ui-safety-gate.cjs @@ -32,6 +33,14 @@ export interface UiPresenceResult { hasUI: boolean; tokens: string[]; + /** + * #3312: first token matched (lowercased) and the line it matched on, so gate + * consumers can surface WHAT tripped the sniffer without re-reading the source. + * Null when nothing matched (or when `**UI hint**: yes` short-circuits with no + * token match — the declaration, not vocabulary, is then the signal). + */ + matchedToken: string | null; + matchedLine: string | null; } export const UI_TOKENS: ReadonlyArray = [ @@ -71,7 +80,7 @@ const UI_GATE_PATTERN_GLOBAL = new RegExp(UI_GATE_PATTERN.source, 'gi'); */ export function checkUiPresence(text: string): UiPresenceResult { if (typeof text !== 'string') { - return { hasUI: false, tokens: [] }; + return { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }; } // Normalise CRLF so the pattern sees consistent line boundaries. @@ -96,22 +105,30 @@ export function checkUiPresence(text: string): UiPresenceResult { .join('\n'); const found = new Set(); + let matchedToken: string | null = null; + let matchedLine: string | null = null; for (const line of sniffable.split('\n')) { // Reset lastIndex before each line so the global pattern restarts from 0. UI_GATE_PATTERN_GLOBAL.lastIndex = 0; for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) { + if (matchedToken === null) { + // #3312: record the FIRST match so gate consumers can surface the + // triggering token/line for one-second operator triage. + matchedToken = m[2].toLowerCase(); + matchedLine = line; + } found.add(m[2].toLowerCase()); } } if (hint === 'no') { - return { hasUI: false, tokens: [] }; + return { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }; } if (hint === 'yes') { - return { hasUI: true, tokens: [...found] }; + return { hasUI: true, tokens: [...found], matchedToken, matchedLine }; } - return { hasUI: found.size > 0, tokens: [...found] }; + return { hasUI: found.size > 0, tokens: [...found], matchedToken, matchedLine }; } // ── CLI entry point ───────────────────────────────────────────────────────── diff --git a/tests/check-ui-plan-gate.test.cjs b/tests/check-ui-plan-gate.test.cjs index 76f99fe75..69a41960e 100644 --- a/tests/check-ui-plan-gate.test.cjs +++ b/tests/check-ui-plan-gate.test.cjs @@ -33,8 +33,11 @@ const { computeUiPlanGate } = require('../gsd-core/bin/lib/check-command-router. * .planning/ROADMAP.md — one phase section with `phaseSection` body * .planning/phases/01-test-phase/ — phase directory * (optionally) a *-UI-SPEC.md inside the phase dir + * (optionally) static frontend evidence (#3312): 'component-file' writes + * src/App.tsx; 'package-json' writes a package.json with a react dependency; + * false (default) writes no frontend evidence at all (markdown/bash repo). */ -function makeProject({ phaseSection = '', hasUiSpec = false } = {}) { +function makeProject({ phaseSection = '', hasUiSpec = false, frontendEvidence = 'component-file' } = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-plan-gate-test-')); const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); @@ -42,6 +45,16 @@ function makeProject({ phaseSection = '', hasUiSpec = false } = {}) { fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({}), 'utf8'); + if (frontendEvidence === 'component-file') { + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() { return null; }\n', 'utf8'); + } else if (frontendEvidence === 'package-json') { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'fixture', dependencies: { react: '^18.3.1' } }), + 'utf8', + ); + } // Minimal ROADMAP.md with one phase section const roadmapContent = [ @@ -100,7 +113,10 @@ describe('computeUiPlanGate — ui.plan-gate check logic (#1026)', () => { assert.ok('uiSpecPath' in result, 'uiSpecPath key must be present'); }); - test('block invariant: block === frontend && !hasUiSpec for all scenarios', () => { + test('block invariant: block === frontend && hasFrontendEvidence && !hasUiSpec for all scenarios', () => { + // #3312: block additionally requires static frontend evidence — token match + // alone no longer blocks (hyphenated proper nouns like `dashboard-financeiro` + // satisfy the sniffer's word boundary). for (const [label, { tmpDir }] of [ ['frontendNoSpec', frontendNoSpec], ['frontendWithSpec', frontendWithSpec], @@ -109,8 +125,8 @@ describe('computeUiPlanGate — ui.plan-gate check logic (#1026)', () => { const r = computeUiPlanGate(tmpDir, '1'); assert.strictEqual( r.block, - r.frontend && !r.hasUiSpec, - `${label}: block invariant violated — frontend=${r.frontend} hasUiSpec=${r.hasUiSpec} block=${r.block}`, + r.frontend && r.hasFrontendEvidence && !r.hasUiSpec, + `${label}: block invariant violated — frontend=${r.frontend} hasFrontendEvidence=${r.hasFrontendEvidence} hasUiSpec=${r.hasUiSpec} block=${r.block}`, ); } }); @@ -315,4 +331,132 @@ describe('computeUiPlanGate — ui.plan-gate check logic (#1026)', () => { } }); }); + + // ── #3312: token match alone must NOT block — static structural corroboration ── + + describe('#3312 structural corroboration — hyphenated proper nouns in non-frontend repos', () => { + test('dashboard-financeiro mention, repo with NO frontend evidence → frontend:true but block:false', () => { + // The reporter's exact shape: a markdown+bash+config repo (no src/, no + // package.json, no component files) whose phase names the repo + // `dashboard-financeiro`. The sniffer legitimately matches `dashboard` + // (hyphen is a word boundary — same rule that catches `micro-frontend`), + // but the gate must not BLOCK without structural frontend evidence. + const proj = makeProject({ + phaseSection: 'Fix broken references in dashboard-financeiro, update .env.tpl and CI workflows.', + hasUiSpec: false, + frontendEvidence: false, + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true, + 'the token match itself still registers (sniffer semantics unchanged)'); + assert.strictEqual(r.hasFrontendEvidence, false, + 'a repo with no component files and no UI-framework dep has no frontend evidence'); + assert.strictEqual(r.block, false, + '#3312: token match without structural evidence must NOT block planning'); + assert.strictEqual(r.uiSpecPath, null); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('same proper-noun mention WITH component file in tree → block:true (corroboration restores block)', () => { + const proj = makeProject({ + phaseSection: 'Fix broken references in dashboard-financeiro and adjust the nav layout.', + hasUiSpec: false, + frontendEvidence: 'component-file', + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true); + assert.strictEqual(r.hasFrontendEvidence, true, 'src/App.tsx is frontend evidence'); + assert.strictEqual(r.block, true, 'token match + evidence + no spec → block'); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('package.json with a UI-framework dependency counts as frontend evidence', () => { + const proj = makeProject({ + phaseSection: 'Rebuild the dashboard for the finance team.', + hasUiSpec: false, + frontendEvidence: 'package-json', + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.hasFrontendEvidence, true, + 'package.json with a react dependency is frontend evidence even with no component files yet'); + assert.strictEqual(r.block, true); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('plain prose UI phase in a repo with NO frontend evidence → block:false (general rule, not a token denylist)', () => { + const proj = makeProject({ + phaseSection: 'Build the user interface and dashboard components for the frontend.', + hasUiSpec: false, + frontendEvidence: false, + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true); + assert.strictEqual(r.hasFrontendEvidence, false); + assert.strictEqual(r.block, false, + 'corroboration is required for EVERY token match, not just proper-noun shapes'); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('matchedToken/matchedLine surface what tripped the sniffer (issue: judge in one second)', () => { + const proj = makeProject({ + phaseSection: 'Deliverables: .env.tpl, CI workflows.\n\nReferences: dashboard-financeiro repo.', + hasUiSpec: false, + frontendEvidence: false, + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.matchedToken, 'dashboard', 'first matched token is surfaced'); + assert.ok( + typeof r.matchedLine === 'string' && r.matchedLine.includes('dashboard-financeiro'), + 'first matching line is surfaced so the operator can see the proper-noun context', + ); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('matchedToken/matchedLine are null for non-frontend phases', () => { + const proj = makeProject({ + phaseSection: 'Add a REST API endpoint and database migration for the user table.', + hasUiSpec: false, + frontendEvidence: 'component-file', + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, false); + assert.strictEqual(r.matchedToken, null); + assert.strictEqual(r.matchedLine, null); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + + test('UI-SPEC present + token match + evidence → block:false (spec still satisfies the gate)', () => { + const proj = makeProject({ + phaseSection: 'Build the frontend dashboard with React components.', + hasUiSpec: true, + frontendEvidence: 'component-file', + }); + try { + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true); + assert.strictEqual(r.hasUiSpec, true); + assert.strictEqual(r.block, false); + } finally { + try { cleanup(proj.tmpDir); } catch { /* ignore */ } + } + }); + }); }); diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index 0e0d75600..6fc0916f9 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -295,19 +295,71 @@ describe('check ui.plan-gate — frontend phase, no UI-SPEC', () => { ].join('\n'), 'utf8', ); + // Static frontend evidence (#3312): block requires token match AND structural + // corroboration — this fixture is a real frontend repo. + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() { return null; }\n', 'utf8'); // No UI-SPEC.md in phase dir }); after(() => cleanup(tmpDir)); - test('[happy] frontend phase + no UI-SPEC: block:true, frontend:true, hasUiSpec:false, exit 0', () => { + test('[happy] frontend phase + evidence + no UI-SPEC: block:true, frontend:true, hasUiSpec:false, exit 0', () => { const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir); assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`); const out = parseEnvelope(result, 'ui-plan-gate-no-spec'); assert.strictEqual(out.frontend, true, 'frontend must be true for frontend-keyword phase'); + assert.strictEqual(out.hasFrontendEvidence, true, 'src/App.tsx is frontend evidence'); assert.strictEqual(out.hasUiSpec, false, 'hasUiSpec must be false when no spec file exists'); - assert.strictEqual(out.block, true, 'block must be true (frontend && !hasUiSpec)'); + assert.strictEqual(out.block, true, 'block must be true (frontend && hasFrontendEvidence && !hasUiSpec)'); assert.strictEqual(out.uiSpecPath, null, 'uiSpecPath must be null when spec absent'); + // matchedToken is the FIRST match in the phase section — the "## Phase 1: + // Dashboard" heading precedes the body, so the first match is `dashboard`. + assert.strictEqual(out.matchedToken, 'dashboard', 'matchedToken surfaces what tripped the sniffer'); + }); +}); + +// ─── 6b. check ui.plan-gate: #3312 — token match without frontend evidence ──── + +describe('check ui.plan-gate — #3312 proper-noun token match, no frontend evidence', () => { + let tmpDir; + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-3312-')); + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(planningDir, 'phases', '01-infra'), { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8'); + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + [ + '# Project Roadmap', + '', + '## Phase 1: Infra', + '', + 'Fix broken references in dashboard-financeiro, update .env.tpl and CI workflows.', + '', + ].join('\n'), + 'utf8', + ); + // Markdown/bash/config repo by design: no src/, no package.json, no component files. + }); + after(() => cleanup(tmpDir)); + + test('[regression] dashboard-financeiro mention in non-frontend repo: frontend:true but block:false', () => { + const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir); + assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`); + const out = parseEnvelope(result, 'ui-plan-gate-3312'); + + assert.strictEqual(out.frontend, true, + 'sniffer still matches `dashboard` at the hyphen boundary (micro-frontend rule)'); + assert.strictEqual(out.hasFrontendEvidence, false, + 'markdown/bash repo has no component files and no UI-framework dependency'); + assert.strictEqual(out.block, false, + '#3312: token match without structural evidence must NOT block planning'); + assert.strictEqual(out.matchedToken, 'dashboard', 'matchedToken surfaces the proper-noun trigger'); + assert.ok( + typeof out.matchedLine === 'string' && out.matchedLine.includes('dashboard-financeiro'), + 'matchedLine surfaces the line for one-second operator triage', + ); }); }); diff --git a/tests/ui-safety-gate.test.cjs b/tests/ui-safety-gate.test.cjs index 6d2d0bd40..8ea706f0f 100644 --- a/tests/ui-safety-gate.test.cjs +++ b/tests/ui-safety-gate.test.cjs @@ -23,13 +23,13 @@ describe('UI_TOKENS', () => { }); describe('checkUiPresence', () => { - test('returns { hasUI: false, tokens: [] } for non-string input', () => { - assert.deepStrictEqual(checkUiPresence(42), { hasUI: false, tokens: [] }); - assert.deepStrictEqual(checkUiPresence(null), { hasUI: false, tokens: [] }); + test('returns { hasUI: false, tokens: [], matchedToken: null, matchedLine: null } for non-string input', () => { + assert.deepStrictEqual(checkUiPresence(42), { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }); + assert.deepStrictEqual(checkUiPresence(null), { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }); }); test('returns false for empty string', () => { - assert.deepStrictEqual(checkUiPresence(''), { hasUI: false, tokens: [] }); + assert.deepStrictEqual(checkUiPresence(''), { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }); }); test('detects standalone UI token (case-insensitive)', () => { @@ -61,6 +61,38 @@ describe('checkUiPresence', () => { assert.ok(result.tokens.includes('frontend')); }); + // ── #3312: hyphenated proper nouns DO match the sniffer (by design — the same + // boundary rule that catches `micro-frontend`). The false-positive fix lives in + // the GATE (computeUiPlanGate structural corroboration), NOT in the sniffer: + // weakening this rule would regress `micro-frontend`. These tests pin the + // sniffer behavior AND the matchedToken/matchedLine introspection fields the + // gate forwards so an operator can see what tripped it. + + test('#3312 hyphenated proper noun (dashboard-financeiro) matches the sniffer — surfaced via matchedToken/matchedLine', () => { + const result = checkUiPresence('Fix broken references in dashboard-financeiro and .env.tpl.'); + assert.strictEqual(result.hasUI, true, + 'the sniffer matches `dashboard` at the hyphen boundary — this is the #3312 trigger, intentionally preserved'); + assert.deepStrictEqual(result.tokens, ['dashboard']); + assert.strictEqual(result.matchedToken, 'dashboard'); + assert.ok( + typeof result.matchedLine === 'string' && result.matchedLine.includes('dashboard-financeiro'), + 'matchedLine carries the first matching line so the operator sees the proper-noun context', + ); + }); + + test('matchedLine is the FIRST line containing a match', () => { + const result = checkUiPresence('Intro line with no tokens.\nBuild the login form.\nAlso a dashboard.'); + assert.strictEqual(result.matchedToken, 'form'); + assert.strictEqual(result.matchedLine, 'Build the login form.'); + }); + + test('`**UI hint**: yes` with no token matches reports null matchedToken/matchedLine', () => { + const result = checkUiPresence('**UI hint**: yes\n\nBackend API refactor.\n'); + assert.strictEqual(result.hasUI, true, 'hint:yes is authoritative'); + assert.strictEqual(result.matchedToken, null); + assert.strictEqual(result.matchedLine, null); + }); + test('normalises CRLF line endings', () => { const result = checkUiPresence('Phase 1\r\nBuild a form\r\nDone'); assert.ok(result.hasUI);