* fix(#1145): implement query user-story.validate handler `query user-story.validate` was a phantom command invoked by mvp-phase.md (line 102) and verify-work.md (line 170) but had no CJS handler. Every call exited with "Unknown command: user-story". The dotted-form dispatcher strips the `query` prefix, splits on `.`, yielding command='user-story' which fell through to the `default:` case with no registered capability handler. Adds a `case 'user-story':` handler inline in gsd-tools.cjs (same pattern as the #1140 fix in PR #1148). The handler: - Validates "As a [role], I want to [capability], so that [outcome]." - Uses \S anchors to require non-whitespace content in each slot (whitespace-only slots like "As a , I want to ..." now correctly return valid:false — found by adversarial Codex review) - Returns { valid: boolean, errors: string[], slots: {role, capability, outcome} | null } - Supports --pick valid for bare boolean output (verify-work.md usage) - Added 'user-story' to SKIP_ROOT_RESOLUTION (pure string validation) - Added 'user-story' to TOP_LEVEL_USAGE command list Regression tests added to tests/commands.test.cjs (per lint-regression- test-names policy; new bug-NNNN standalone files are banned). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changeset): backfill PR number for #1145 fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/brave-lions-revolt.md
Normal file
5
.changeset/brave-lions-revolt.md
Normal file
@@ -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)
|
||||
@@ -58,6 +58,11 @@
|
||||
* [--name <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 <field> 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.
|
||||
|
||||
@@ -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 <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)}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user