diff --git a/.changeset/lively-newts-romp.md b/.changeset/lively-newts-romp.md new file mode 100644 index 000000000..dce18464b --- /dev/null +++ b/.changeset/lively-newts-romp.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 539 +--- +**UI Design Contract gate no longer silently no-ops in installed projects** — `/gsd-plan-phase` §5.6 and the autonomous workflow now resolve `ui-safety-gate.cjs` against the GSD install dir (`RUNTIME_DIR`) instead of the consuming project's git root, so frontend phases correctly trigger the UI-SPEC prompt. `ui-safety-gate.cjs` is now also deployed to `get-shit-done/bin/lib/` (the path the GSD installer copies to `$RUNTIME_DIR`) and probed there first, ensuring it is found in installed runtimes where root `bin/lib/` is not present. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 3c7f59355..0a1c4bd64 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-30", + "generated": "2026-05-31", "families": { "agents": [ "gsd-advisor-researcher", @@ -328,6 +328,7 @@ "task-command-router.cjs", "template.cjs", "uat.cjs", + "ui-safety-gate.cjs", "update-context.cjs", "validate-command-router.cjs", "validate.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 39bc446e1..36e97f8aa 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (78 shipped) +## CLI Modules (79 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -436,6 +436,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `task-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools task` | | `template.cjs` | Template selection and filling with variable substitution | | `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support | +| `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 `get-shit-done/bin/lib/` so the GSD installer ships it to `$RUNTIME_DIR` (#448) | | `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/get-shit-done/bin/lib/ui-safety-gate.cjs b/get-shit-done/bin/lib/ui-safety-gate.cjs new file mode 100644 index 000000000..008d51b55 --- /dev/null +++ b/get-shit-done/bin/lib/ui-safety-gate.cjs @@ -0,0 +1,111 @@ +'use strict'; + +/** + * UI Safety Gate — shell-free implementation (#3706, #3718) + * + * Replaces the bash shell-based one-liner that silently degraded on Windows + * PowerShell / cmd.exe because the locale env-var prefix was not recognised. + * This module runs inside Node.js — no shell dependency, works identically + * on bash, Git-Bash, PowerShell, and cmd.exe. + * + * Word-boundary anchoring: + * (^|[^a-zA-Z0-9])(TOKEN)([^a-zA-Z0-9]|$) + * Equivalent to POSIX ERE [^[:alnum:]] — matches tokens only when they are not + * interior substrings of alphanumeric compound words (e.g. "microfrontend" is NOT + * matched; "micro-frontend" and "micro frontend" ARE matched). + * + * Public API: + * checkUiPresence(text: string): { hasUI: boolean, tokens: string[] } + * + * CLI usage — reads phase-section text from STDIN to avoid ARG_MAX limits: + * echo "$PHASE_SECTION" | node get-shit-done/bin/lib/ui-safety-gate.cjs + * echo $? → 0 if UI tokens found, 1 if not, 2 on usage error + * + * Exit codes mirror grep: 0 = match found, 1 = no match, 2 = usage error. + * + * Canonical location: get-shit-done/bin/lib/ui-safety-gate.cjs (#448) + * This path is deployed by the GSD installer to $RUNTIME_DIR/get-shit-done/bin/lib/. + * bin/lib/ui-safety-gate.cjs (root) is retained for source-repo and npm usage. + */ + +const UI_TOKENS = [ + 'UI', + 'interface', + 'frontend', + 'component', + 'layout', + 'page', + 'screen', + 'view', + 'form', + 'dashboard', + 'widget', +]; + +/** + * Built once at module load — no per-call compilation overhead. + * ASCII word boundaries — matches the original ASCII-grep intent of #3706. + * Note: JS [a-zA-Z0-9] is ASCII-only and NOT equivalent to POSIX [[:alnum:]], + * which is locale-sensitive and includes accented characters. + */ +const UI_GATE_PATTERN = new RegExp( + '(^|[^a-zA-Z0-9])(' + UI_TOKENS.join('|') + ')([^a-zA-Z0-9]|$)', + 'i' +); + +// Global-flagged variant for extracting ALL matches per line (matchAll). +const UI_GATE_PATTERN_GLOBAL = new RegExp(UI_GATE_PATTERN.source, 'gi'); + +/** + * Check a roadmap phase section string for frontend UI indicators. + * + * @param {string} text - The roadmap phase section content (may be multi-line, CRLF or LF). + * @returns {{ hasUI: boolean, tokens: string[] }} + * hasUI — true if any UI token was matched as a standalone word. + * tokens — matched token strings (lowercased), deduplicated. + */ +function checkUiPresence(text) { + if (typeof text !== 'string') { + return { hasUI: false, tokens: [] }; + } + + // Normalise CRLF so the pattern sees consistent line boundaries. + const normalised = text.replace(/\r\n/g, '\n'); + + const found = new Set(); + for (const line of normalised.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)) { + found.add(m[2].toLowerCase()); + } + } + + return { hasUI: found.size > 0, tokens: [...found] }; +} + +module.exports = { checkUiPresence, UI_TOKENS }; + +// ── CLI entry point ───────────────────────────────────────────────────────── +// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits. +// Invoked by workflow .md bash blocks as: echo "$PHASE_SECTION" | node .../ui-safety-gate.cjs +// Exit 0 = UI found, 1 = no UI, 2 = startup error. + +if (require.main === module) { + // Collect stdin chunks asynchronously. + const chunks = []; + process.stdin.setEncoding('utf-8'); + + process.stdin.on('data', (chunk) => chunks.push(chunk)); + + process.stdin.on('end', () => { + const input = chunks.join(''); + const result = checkUiPresence(input); + process.exit(result.hasUI ? 0 : 1); + }); + + process.stdin.on('error', (err) => { + process.stderr.write(`ERROR: ui-safety-gate.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/get-shit-done/workflows/autonomous.md b/get-shit-done/workflows/autonomous.md index 43056699f..cae91ebfd 100644 --- a/get-shit-done/workflows/autonomous.md +++ b/get-shit-done/workflows/autonomous.md @@ -284,11 +284,11 @@ Check if this phase has frontend indicators and whether a UI-SPEC already exists PHASE_SECTION=$(gsd_run query roadmap.get-phase ${PHASE_NUM} 2>/dev/null) # Shell-free word-boundary gate (#3718): Node.js helper — no locale env-var dependency. # Reads via stdin to avoid OS ARG_MAX limits on large phase text. -# Path anchored to repo root; falls back to CWD if git is unavailable -# Exit codes mirror grep: 0 = UI tokens found, 1 = not found. -GSD_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") -printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/bin/lib/ui-safety-gate.cjs" > /dev/null 2>&1 -HAS_UI=$? +# Resolve the helper against the GSD install dir via RUNTIME_DIR (#448) — NOT the consuming +# project's git root — falling back to git toplevel / $HOME/.claude. Exit codes mirror grep (0=UI,1=none). +_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +UI_GATE_JS=$(for _c in "$_GSD_RT/get-shit-done/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/get-shit-done/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done) +if [ -n "$UI_GATE_JS" ]; then printf '%s' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else echo "WARN: ui-safety-gate.cjs not found via RUNTIME_DIR/\$HOME (#448) — assuming UI present" >&2; HAS_UI=0; fi UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1) ``` diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index eb6be4b0d..700efb366 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -625,11 +625,11 @@ Check if phase has frontend indicators: PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" 2>/dev/null) # Shell-free word-boundary gate (#3718): Node.js helper — no locale env-var dependency. # Reads via stdin to avoid OS ARG_MAX limits on large phase text. -# Path anchored to repo root; falls back to CWD if git is unavailable -# Exit codes mirror grep: 0 = UI tokens found, 1 = not found. -GSD_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") -printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/bin/lib/ui-safety-gate.cjs" > /dev/null 2>&1 -HAS_UI=$? +# Resolve the helper against the GSD install dir via RUNTIME_DIR (#448) — NOT the consuming +# project's git root — falling back to git toplevel / $HOME/.claude. Exit codes mirror grep (0=UI,1=none). +_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +UI_GATE_JS=$(for _c in "$_GSD_RT/get-shit-done/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/get-shit-done/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done) +if [ -n "$UI_GATE_JS" ]; then printf '%s' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else echo "WARN: ui-safety-gate.cjs not found via RUNTIME_DIR/\$HOME (#448) — assuming UI present" >&2; HAS_UI=0; fi ``` **If `HAS_UI` is 0 (frontend indicators found):** diff --git a/tests/autonomous-ui-steps.test.cjs b/tests/autonomous-ui-steps.test.cjs index b659104b6..f2ff1d840 100644 --- a/tests/autonomous-ui-steps.test.cjs +++ b/tests/autonomous-ui-steps.test.cjs @@ -30,16 +30,17 @@ describe('autonomous workflow ui-phase and ui-review integration (#1375)', () => }); test('UI design contract step detects frontend indicators via shell-free Node gate (#3718)', () => { - // After #3718 fix: the gate is implemented in bin/lib/ui-safety-gate.cjs (Node.js) - // piped from stdin, path anchored via git rev-parse. This avoids silent failure - // on Windows PowerShell and ARG_MAX limits for large phase text. + // After #3718: the gate is implemented in bin/lib/ui-safety-gate.cjs (Node.js) + // piped from stdin, avoiding silent failure on Windows PowerShell and ARG_MAX. + // After #448: the helper is resolved against the GSD install dir (RUNTIME_DIR), + // not the consuming project's git root, so it is actually found at runtime. assert.ok( content.includes('ui-safety-gate.cjs'), 'should invoke shell-free Node gate for cross-platform portability (#3718)' ); assert.ok( - content.includes('GSD_REPO_ROOT'), - 'should anchor gate path to GSD_REPO_ROOT to avoid CWD-sensitive failure' + content.includes('RUNTIME_DIR'), + 'should resolve the gate helper against the GSD install dir (RUNTIME_DIR), not the consuming project root (#448)' ); }); diff --git a/tests/bug-3706-ui-safety-gate-false-positives.test.cjs b/tests/bug-3706-ui-safety-gate-false-positives.test.cjs index 733e86363..531f5d0dd 100644 --- a/tests/bug-3706-ui-safety-gate-false-positives.test.cjs +++ b/tests/bug-3706-ui-safety-gate-false-positives.test.cjs @@ -28,6 +28,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('node:child_process'); +const os = require('node:os'); const HELPER_PATH = path.join(__dirname, '..', 'bin', 'lib', 'ui-safety-gate.cjs'); const PLAN_PHASE_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md'); @@ -68,19 +69,33 @@ describe('Workflow .md structural guard (#3718)', () => { ['plan-phase.md', PLAN_PHASE_PATH], ['autonomous.md', AUTONOMOUS_PATH], ]) { - test(`${label} must invoke ui-safety-gate.cjs via stdin, anchored to GSD_REPO_ROOT`, () => { + test(`${label} must invoke ui-safety-gate.cjs via stdin, anchored to the GSD install dir`, () => { const content = fs.readFileSync(filePath, 'utf-8'); assert.ok( content.includes('ui-safety-gate.cjs'), `${label}: must reference ui-safety-gate.cjs for cross-shell portability (#3718)` ); + + // Scope structural assertions to the gate invocation region so we test the + // gate's OWN resolution, not §1's unrelated RUNTIME_DIR usage for gsd-tools. + const gi = content.indexOf('ui-safety-gate.cjs'); + const region = content.slice(Math.max(0, gi - 800), gi + 200); + + // #448: the helper ships inside the GSD package, so it must be resolved + // against the GSD install dir (RUNTIME_DIR), NOT the consuming project's + // git root — otherwise the node call fails and the gate silently no-ops. assert.ok( - content.includes('GSD_REPO_ROOT'), - `${label}: must anchor path to GSD_REPO_ROOT to avoid CWD-sensitive failure (#3718)` + region.includes('RUNTIME_DIR'), + `${label}: UI gate must resolve ui-safety-gate.cjs against RUNTIME_DIR (the GSD install dir), not the consuming project's git root (#448)` ); assert.ok( - content.includes('git rev-parse --show-toplevel'), - `${label}: must derive GSD_REPO_ROOT from git rev-parse --show-toplevel` + !region.includes('GSD_REPO_ROOT'), + `${label}: UI gate must NOT anchor the helper to GSD_REPO_ROOT (the consuming project's git root) — that silently no-ops in installed repos (#448)` + ); + // Retain a git rev-parse --show-toplevel fallback when RUNTIME_DIR is unset (#3718). + assert.ok( + region.includes('git rev-parse --show-toplevel'), + `${label}: must retain a git rev-parse --show-toplevel fallback for the install-dir resolution` ); // Confirm stdin pipe usage (printf or echo piped to node) assert.ok( @@ -251,6 +266,80 @@ function runBehavioralTests(label) { runBehavioralTests('ui-safety-gate.cjs'); +// ── Install-dir resolution from a consuming project (#448) ──────────────────── + +describe('UI gate resolves the helper against RUNTIME_DIR, not the consuming repo (#448)', () => { + const REPO_ROOT = path.join(__dirname, '..'); + + // Mirrors the §5.6 / §3a.5 resolution. The structural guard above forces the + // workflows to keep using this RUNTIME_DIR-anchored form; this proves the + // candidate path is correct and the helper is actually found + executed when + // the CWD is a consuming project that has no bin/lib of its own. + const GATE_SNIPPET = [ + '_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', + 'UI_GATE_JS=$(for _c in "$_GSD_RT/get-shit-done/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/get-shit-done/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done)', + 'if [ -n "$UI_GATE_JS" ]; then printf \'%s\' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else HAS_UI=0; fi', + 'echo "$HAS_UI"', + ].join('\n'); + + function runGateFrom(consumingDir, phaseSection) { + return spawnSync('bash', ['-c', GATE_SNIPPET], { + cwd: consumingDir, + encoding: 'utf-8', + env: { ...process.env, RUNTIME_DIR: REPO_ROOT, PHASE_SECTION: phaseSection }, + }); + } + + test('UI text is detected (HAS_UI=0) from a project without bin/lib', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const res = runGateFrom(tmp, 'UI Refactor: migrate all screens'); + assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); + assert.strictEqual(res.stdout.trim(), '0', + 'helper must be found via RUNTIME_DIR and report UI present — not silently no-op'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('non-UI text returns HAS_UI=1 via the RUNTIME_DIR-resolved helper', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const res = runGateFrom(tmp, 'Requirements: backend REST API only'); + assert.strictEqual(res.stdout.trim(), '1'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('UI gate found via get-shit-done/bin/lib/ in installed layout (no root bin/lib/)', () => { + // Regression for #448: installed RUNTIME_DIR has get-shit-done/bin/lib/ but NOT root bin/lib/. + // The probe must find the helper at the installed path, not silently no-op to HAS_UI=0. + const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installed-rt-')); + const consumingProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const installedLibDir = path.join(fakeRuntime, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(installedLibDir, { recursive: true }); + fs.copyFileSync( + path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'ui-safety-gate.cjs'), + path.join(installedLibDir, 'ui-safety-gate.cjs') + ); + + const res = spawnSync('bash', ['-c', GATE_SNIPPET], { + cwd: consumingProject, + encoding: 'utf-8', + env: { ...process.env, RUNTIME_DIR: fakeRuntime, PHASE_SECTION: 'Build the analytics dashboard' }, + }); + assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); + assert.strictEqual(res.stdout.trim(), '0', + 'helper must be found via get-shit-done/bin/lib/ in installed layout and report UI present'); + } finally { + fs.rmSync(fakeRuntime, { recursive: true, force: true }); + fs.rmSync(consumingProject, { recursive: true, force: true }); + } + }); +}); + // ── checkUiPresence() return value API ─────────────────────────────────────── describe('checkUiPresence() return value API', () => {