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

@@ -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 */ }
}
});
});
});

View File

@@ -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',
);
});
});

View File

@@ -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);