fix(#2150): treat the UI hint yes/no line as authoritative in checkUiPresence

checkUiPresence ran UI_TOKENS (which includes the bare token 'UI') over the raw
phase-section text, so GSD's own '**UI hint**: no' metadata line matched the 'UI'
token and reported hasUI=true — blocking non-frontend phases that explicitly
declare themselves non-frontend via the documented convention (the plan-phase
UI-SPEC gate fired under the default workflow.ui_safety_gate=true).

- An explicit '**UI hint**: yes|no' line is now authoritative (mirrors how
  progress.md / new-project.md already parse it via 'UI hint.*yes'). hint:no ->
  hasUI=false; hint:yes -> hasUI=true.
- Any '**UI hint**:' line is stripped before token-sniffing, so a hint without a
  recognised yes/no cannot false-positive on the bare 'UI' token.
- With no hint line, behaviour is unchanged (token-sniffing on the rest).

Closes #2150
This commit is contained in:
Tom Boucher
2026-07-12 15:10:54 -04:00
parent 578a7fbc17
commit 0e59e9f05b

View File

@@ -77,8 +77,24 @@ export function checkUiPresence(text: string): UiPresenceResult {
// Normalise CRLF so the pattern sees consistent line boundaries.
const normalised = text.replace(/\r\n/g, '\n');
// #2150: an explicit `**UI hint**: yes|no` metadata line is the author's
// authoritative declaration of whether the phase has a UI surface — progress.md
// and new-project.md already parse this line (`UI hint.*yes`). The bare token
// `UI` in the line itself must not count as a UI indicator, and the declaration
// overrides token-sniffing.
const hintMatch = normalised.match(/\*\*UI hint\*\*\s*:\s*(yes|no)/i);
const hint = hintMatch ? hintMatch[1].toLowerCase() : null;
// Strip ANY `**UI hint**:` line before token-sniffing so a hint without a
// recognised yes/no (or one we did not short-circuit on) cannot false-positive
// on the bare `UI` token.
const sniffable = normalised
.split('\n')
.filter((line) => !/^\s*\*\*UI hint\*\*\s*:/i.test(line))
.join('\n');
const found = new Set<string>();
for (const line of normalised.split('\n')) {
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)) {
@@ -86,6 +102,13 @@ export function checkUiPresence(text: string): UiPresenceResult {
}
}
if (hint === 'no') {
return { hasUI: false, tokens: [] };
}
if (hint === 'yes') {
return { hasUI: true, tokens: [...found] };
}
return { hasUI: found.size > 0, tokens: [...found] };
}