* 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:
5
.changeset/quick-pumas-climb.md
Normal file
5
.changeset/quick-pumas-climb.md
Normal file
@@ -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.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
147
src/ui-frontend-evidence.cts
Normal file
147
src/ui-frontend-evidence.cts
Normal 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);
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user