diff --git a/.changeset/brave-lions-revolt.md b/.changeset/brave-lions-revolt.md new file mode 100644 index 000000000..6ba37539d --- /dev/null +++ b/.changeset/brave-lions-revolt.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1193 +--- +**`query user-story.validate` now works** — `mvp-phase` and `verify-work` workflows both invoked this command to validate "As a / I want to / so that" user stories, but no CJS handler existed; every call errored with "Unknown command: user-story". (#1193) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index dd674b18b..a779c5079 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -58,6 +58,11 @@ * [--name ] * [--archive-phases] Move phase dirs to milestones/vX.Y-phases/ * + * User Story Validation: + * user-story validate --story "..." Validate "As a / I want to / so that" format + * Returns JSON { valid, errors[], slots: {role,capability,outcome} | null } + * --pick valid Emit bare boolean (for workflow boolean checks) + * * Validation: * validate consistency Check phase numbering, disk/roadmap sync * validate health [--repair] Check .planning/ integrity, optionally repair @@ -508,7 +513,7 @@ async function main() { 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + 'capability, classify-confidence, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + 'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + - 'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + + 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + ' --raw Emit raw output without post-processing\n' + ' --pick Extract a single field from JSON output (dot/bracket notation)\n' + @@ -557,6 +562,7 @@ async function main() { 'verify-summary', 'template', 'frontmatter', 'detect-custom-files', 'worktree', 'prompt-budget', 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence', + 'user-story', // pure string validation — no .planning/ access needed ]); if (!SKIP_ROOT_RESOLUTION.has(command)) { cwd = findProjectRoot(cwd); @@ -1926,6 +1932,76 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + // ─── User Story Validation (bug #1145) ──────────────────────────────────── + // + // Invocation shapes (from mvp-phase.md and verify-work.md): + // gsd_run query user-story.validate --story "$USER_STORY" + // gsd_run query user-story.validate --story "$PHASE_GOAL" --pick valid + // + // Returns JSON: { valid: boolean, errors: string[], slots: { role, capability, outcome } | null } + // - valid: true only when the story fully matches the canonical format + // - errors: per-slot diagnostic strings (empty on success) + // - slots: extracted role/capability/outcome on success; null on failure + // + // Canonical format (user-story-template.md): + // "As a [user role], I want to [capability], so that [outcome]." + // Each slot must be non-empty and contain non-whitespace content. + // + // No .planning/ access needed — pure string validation. + case 'user-story': { + const subcommand = args[1]; + if (subcommand !== 'validate') { + error(`Unknown user-story subcommand: ${subcommand || '(none)'}. Available: validate`, ERROR_REASON.SDK_UNKNOWN_COMMAND); + break; + } + + const storyIdx = args.indexOf('--story'); + const story = (storyIdx !== -1 && args[storyIdx + 1] && !args[storyIdx + 1].startsWith('--')) + ? args[storyIdx + 1] + : ''; + + // Canonical extraction regex — requires non-whitespace content in each slot + // (\S.*? ensures the slot isn't whitespace-only). + // Named groups: role / capability / outcome. + const USER_STORY_RE = /^As a (\S.*?), I want to (\S.*?), so that (\S.*?)\.$/; + + const errors = []; + const trimmed = story.trim(); + let slots = null; + + if (!trimmed) { + errors.push('Story is empty. Required format: "As a [role], I want to [capability], so that [outcome]."'); + } else { + // Per-clause guards produce targeted, actionable error messages before + // attempting the full regex. Guards are ordered: role → capability → outcome → period. + if (!/^As a \S/i.test(trimmed)) { + errors.push('Story must start with "As a [user role]," (role must be non-empty).'); + } + if (!/, I want to \S/i.test(trimmed)) { + errors.push('Story must include ", I want to [capability]," (capability must be non-empty).'); + } + if (!/, so that \S/i.test(trimmed)) { + errors.push('Story must include ", so that [outcome]." (outcome must be non-empty).'); + } + if (!trimmed.endsWith('.')) { + errors.push('Story must end with a period (.).'); + } + // Full-regex check only when per-clause guards all passed — avoids + // redundant "format mismatch" noise on top of specific error messages. + if (errors.length === 0) { + const m = USER_STORY_RE.exec(trimmed); + if (!m) { + errors.push('Story does not match the canonical format: "As a [role], I want to [capability], so that [outcome]."'); + } else { + slots = { role: m[1], capability: m[2], outcome: m[3] }; + } + } + } + + core.output({ valid: errors.length === 0, errors, slots }, raw); + break; + } + default: { // ADR-959: try capability-registry dispatch before emitting the unknown-command error. // An unmigrated command still hits its hardcoded `case` above — untouched. diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index abfea1b9e..e61484a27 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -2223,3 +2223,145 @@ describe('_wsParseRetryAfter (#308)', () => { assert.strictEqual(_wsParseRetryAfter(null), null); }); }); + +// ─── Regressions: bug #1145 — query user-story.validate phantom command ──── +// +// `query user-story.validate` was invoked by mvp-phase.md and verify-work.md +// but had no CJS handler (phantom command). Every invocation errored with +// "Unknown command: user-story — did you mean: user-story validate?". +// +// Calls the CLI via runGsdTools; no readFileSync source-grep. + +describe('user-story validate command (bug #1145)', () => { + // Helper: call `query user-story.validate --story ` and parse JSON. + function validateStory(story) { + const result = runGsdTools(['query', 'user-story.validate', '--story', story]); + assert.equal(result.success, true, `user-story.validate exited non-zero: ${result.error || result.output}`); + let parsed; + try { parsed = JSON.parse(result.output); } catch { + assert.fail(`output was not valid JSON: ${result.output}`); + } + return parsed; + } + + // Helper: call with --pick valid, return trimmed output string. + function validateStoryPickValid(story) { + const result = runGsdTools(['query', 'user-story.validate', '--story', story, '--pick', 'valid']); + assert.equal(result.success, true, `user-story.validate --pick valid exited non-zero: ${result.error || result.output}`); + return result.output.trim(); + } + + test('command is reachable — not a phantom (negative proof of bug #1145)', () => { + // Before the fix: exit 1 with "Unknown command: user-story" + const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can access my account.']); + assert.equal(result.success, true, `Expected exit 0 but got: ${result.error || result.output}`); + }); + + test('canonical well-formed story returns { valid: true, errors: [], slots }', () => { + const out = validateStory('As a new user, I want to register and log in, so that I can access my account.'); + assert.equal(typeof out, 'object'); + assert.equal(out.valid, true, `expected valid:true, got: ${JSON.stringify(out)}`); + assert.ok(!out.errors || out.errors.length === 0, `unexpected errors: ${JSON.stringify(out.errors)}`); + // Slot extraction (see verify-work.md: "returns slot extractions") + assert.ok(out.slots && typeof out.slots === 'object', `expected slots object, got: ${JSON.stringify(out.slots)}`); + assert.equal(out.slots.role, 'new user'); + assert.equal(out.slots.capability, 'register and log in'); + assert.equal(out.slots.outcome, 'I can access my account'); + }); + + test('whitespace-only role slot returns { valid: false } (Codex finding: .+ accepted spaces)', () => { + // "As a ," — role is whitespace-only; must be rejected + const out = validateStory('As a , I want to build reports, so that I can share status.'); + assert.equal(out.valid, false, `whitespace role must be invalid: ${JSON.stringify(out)}`); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + assert.equal(out.slots, null, 'slots must be null on invalid story'); + }); + + test('whitespace-only capability slot returns { valid: false }', () => { + // ", I want to ," — capability is whitespace-only + const out = validateStory('As a user, I want to , so that I can share status.'); + assert.equal(out.valid, false, `whitespace capability must be invalid: ${JSON.stringify(out)}`); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('whitespace-only outcome slot returns { valid: false }', () => { + // ", so that ." — outcome is whitespace-only + const out = validateStory('As a user, I want to build reports, so that .'); + assert.equal(out.valid, false, `whitespace outcome must be invalid: ${JSON.stringify(out)}`); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('empty string returns { valid: false } with non-empty errors array', () => { + const out = validateStory(''); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('story missing "As a" prefix returns { valid: false }', () => { + const out = validateStory('I want to register so that I can log in.'); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('story missing ", I want to" clause returns { valid: false }', () => { + const out = validateStory('As a user, so that I can log in.'); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('story missing ", so that" clause returns { valid: false }', () => { + const out = validateStory('As a user, I want to register and log in.'); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('story missing trailing period returns { valid: false }', () => { + const out = validateStory('As a user, I want to register, so that I can log in'); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('whitespace-only story returns { valid: false }', () => { + const out = validateStory(' '); + assert.equal(out.valid, false); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0); + }); + + test('--pick valid returns bare "true" for valid story (verify-work.md call shape)', () => { + const out = validateStoryPickValid('As a developer, I want to run tests, so that I can catch regressions.'); + assert.equal(out, 'true', `expected bare "true" but got: ${JSON.stringify(out)}`); + }); + + test('--pick valid returns bare "false" for invalid story (verify-work.md call shape)', () => { + const out = validateStoryPickValid('Not a user story at all.'); + assert.equal(out, 'false', `expected bare "false" but got: ${JSON.stringify(out)}`); + }); + + test('mvp-phase.md call shape: result has .valid boolean, .errors array, and .slots', () => { + // gsd_run query user-story.validate --story "$USER_STORY" + // mvp-phase.md uses: jq -r '.valid' and jq -r '.errors[]' + const out = validateStory('As a product manager, I want to export reports, so that I can share progress with stakeholders.'); + assert.ok(Object.prototype.hasOwnProperty.call(out, 'valid'), 'missing "valid" field'); + assert.ok(Object.prototype.hasOwnProperty.call(out, 'errors'), 'missing "errors" field'); + assert.ok(Object.prototype.hasOwnProperty.call(out, 'slots'), 'missing "slots" field'); + assert.equal(typeof out.valid, 'boolean'); + assert.ok(Array.isArray(out.errors)); + // slots is object on success, null on failure + assert.equal(out.valid, true); + assert.equal(typeof out.slots, 'object'); + assert.notEqual(out.slots, null); + }); + + test('dotted-form (user-story.validate) works identically to spaced form', () => { + // Canonical dotted invocation used by workflows + const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can see my dashboard.']); + assert.equal(result.success, true, `dotted form failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.equal(out.valid, true); + }); + + test('boundary — minimal valid story passes', () => { + const out = validateStory('As a X, I want to Y, so that Z.'); + assert.equal(out.valid, true, `minimal valid story should pass: ${JSON.stringify(out)}`); + }); +});