fix(#1145): implement query user-story.validate handler (#1193)

* 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:
Tom Boucher
2026-06-13 23:34:44 -04:00
committed by GitHub
parent 3827054954
commit 3ebd13c45d
3 changed files with 224 additions and 1 deletions

View File

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