fix(#3312): require static frontend evidence before ui-plan-gate blocks (#3451)

* fix(#3312): require static frontend evidence before ui-plan-gate blocks

* fix(#3312): fill changeset pr reference

* fix(#3312): align regression assertions with section heading and legacy artifact shape

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 09:27:48 -04:00
committed by GitHub
parent 43475a2e0f
commit 29c27e948b
11 changed files with 457 additions and 23 deletions

View File

@@ -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 <phase>
*
@@ -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;

View File

@@ -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<string> = 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<string> = 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<string, unknown>;
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);
}

View File

@@ -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<string> = [
@@ -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<string>();
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 ─────────────────────────────────────────────────────────