Files
msd-core/tests/list-seeds.property.test.cjs
Joe e12a2abfd8 feat(#441): add /gsd-capture --list-seeds for seed listing and audit (#722)
* feat(#441): add /gsd-capture --list-seeds for seed listing and audit

Seeds (.planning/seeds/SEED-NNN-slug.md) could only be created (--seed),
enriched (--enrich), or auto-surfaced at /gsd-new-milestone. There was no way
to browse or audit parked seeds on demand. This adds a read-only listing,
following the established --list → workflow pattern (per the approved scope on

- gsd-tools `list-seeds [status]` (cmdListSeeds in src/commands.cts): scans the
  seeds dir, returns { count, seeds[], summary } JSON with each seed's id,
  slug, status, scope, trigger_when, planted, title. Optional case-insensitive
  status filter. User-controlled content is sanitized (sanitizeForDisplay) and
  every path validated (requireSafePath); read-only. Independent of
  audit.scanSeeds, which only returns unimplemented seeds for the milestone surface.
- /gsd-capture --list-seeds routes to a new read-only list-seeds workflow that
  renders the seed table.

Closes #441

* chore(#441): point changeset fragment at PR #722

* test(#441): allowlist list-seeds test in prompt-injection scan

The test asserts that list-seeds neutralizes injection payloads
(<system>, [INST]) embedded in seed content, so the fixtures legitimately
contain those patterns — same as the sibling security tests already on the
allowlist.

* fix(#441): use canonical /gsd:capture colon form in list-seeds workflow

Claude-facing source (commands/, agents/, gsd-core/workflows/, ...) must use
the /gsd:<cmd> colon form per ADR/CONTEXT.md; the hyphen /gsd-<cmd> form is
retired there (enforced by bug-2543-gsd-slash-namespace.test.cjs). The new
list-seeds workflow used the hyphen form.

* docs(#441): sync help full.md + INVENTORY for --list-seeds

Adds the --list-seeds entry to the help reference (help/modes/full.md, per
bug-2954 argument-hint↔help parity) and registers the new list-seeds workflow
in docs/INVENTORY.md (88→89) and the generated INVENTORY-MANIFEST.json.

* docs(#441): add --list-seeds how-to + drop phantom statuses

Addresses CHANGES_REQUESTED on PR #722 (two documentation blockers):

- USER-GUIDE.md Seeds section (how-to): extend the task to cover
  auditing parked seeds on demand via --list-seeds, including the
  status filter — kept task-oriented per Diataxis how-to mode.
- CLI-TOOLS.md (reference): drop phantom statuses implemented|rejected
  from the list-seeds filter vocabulary; the system only produces
  dormant|active|triggered (src/audit.cts scanSeeds). Reference must
  be factually accurate and complete.

* fix(#441): guard non-scalar status frontmatter in cmdListSeeds

A seed with a bare `status:` line (extractFrontmatter yields {}) or a
`status: [a, b]` value (yields an array) crashed the whole audit list:
`(fm.status || 'dormant').toLowerCase()` throws a TypeError on a non-string.
Coerce every frontmatter read through a `fmStr` helper (mirrors the existing
`typeof fm.id === 'string'` guard), so a non-scalar status falls back to
dormant and non-scalar scope/trigger_when/title can no longer leak a raw
array/object into the JSON contract. Title is now capped symmetrically.

Adds regression coverage for empty and array `status:` and non-scalar fields.

Refs #441

* docs(#441): align list-seeds workflow status vocabulary

The load_seeds step listed `implemented` as an example status filter, but the
real seed vocabulary is dormant|active|triggered (src/audit.cts scanSeeds);
`implemented` has no producer. Matches the earlier CLI-TOOLS.md correction.

Refs #441

* refactor(#441): extract pure deriveSeedIdentity; match raw status in list-seeds

Pull the seed_id/slug derivation out of cmdListSeeds into a pure, exported
deriveSeedIdentity(stem, rawFmId) so the parsing contract can be property-tested
in-process (review minor #1). No behavior change.

Filter comparison now matches the raw lowercased status (both sides already
normalized) instead of sanitizeForDisplay(status); sanitization is for output,
not matching (review nit #3).

* test(#441): add fast-check property coverage and count=1 boundary for list-seeds

Adds tests/list-seeds.property.test.cjs with four fast-check properties over
deriveSeedIdentity (never-throws, string-only contract, canonical id->seed_id/slug
invariant, filename-prefix fallback) per RULESET.TESTS.property-based-testing
(review minor #1).

Adds an N==1 status-filter boundary case to list-seeds.test.cjs (review minor #2).

* chore(#441): sync runtime launcher snippet into list-seeds workflow

Propagate the current _runtime-launcher.snippet.sh (with non-Claude
runtime home probes) into the new list-seeds.md workflow via
scripts/sync-runtime-launcher.cjs, satisfying bug-891 (E) propagation.

* test(#441): record list-seeds.md in workflow size baseline (#1074)

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-22 00:59:41 -04:00

91 lines
3.6 KiB
JavaScript

'use strict';
/**
* Property-based tests for the seed-identity derivation behind `list-seeds` (#441).
*
* Module: gsd-core/bin/lib/commands.cjs
* Exported (pure): deriveSeedIdentity(stem, rawFmId) -> { seed_id, slug }
*
* The `SEED-NNN-<slug>.md` filename + frontmatter `id:` -> `{ seed_id, slug }`
* mapping is a parsing/transformation contract, so per RULESET.TESTS.property-based-testing
* it carries property coverage in addition to the example-based branch tests.
*
* Properties tested:
* (a) never throws on arbitrary (string | non-string) input
* (b) always returns string seed_id and slug
* (c) canonical case: id `SEED-NNN` + stem `SEED-NNN-<slug>` => seed_id === id, slug === <slug>
* (d) no usable frontmatter id => seed_id falls back to the filename's `SEED-NNN` prefix
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('./helpers/fast-check-setup.cjs');
const { deriveSeedIdentity } = require('../gsd-core/bin/lib/commands.cjs');
// SEED number: 1+ digits, no leading-zero constraint (filenames are zero-padded
// but the parser is agnostic — \d+ matches either way).
const seedNum = fc.integer({ min: 1, max: 99999 }).map((n) => String(n));
// Slug remainder: leading alphanumeric then the usual filename-safe set, no slashes.
const slug = fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,30}$/);
describe('list-seeds: deriveSeedIdentity properties', () => {
// (a) Never throws — including non-string frontmatter ids (arrays, objects, undefined).
test('property: deriveSeedIdentity never throws on arbitrary input', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 80 }),
fc.oneof(fc.string({ maxLength: 40 }), fc.array(fc.string()), fc.object(), fc.constant(undefined)),
(stem, rawFmId) => {
assert.doesNotThrow(() => deriveSeedIdentity(stem, rawFmId));
}
)
);
});
// (b) Always returns string fields — the JSON contract never leaks a non-string.
test('property: deriveSeedIdentity always returns string seed_id and slug', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 80 }),
fc.oneof(fc.string({ maxLength: 40 }), fc.array(fc.string()), fc.constant(undefined)),
(stem, rawFmId) => {
const { seed_id, slug: derivedSlug } = deriveSeedIdentity(stem, rawFmId);
assert.strictEqual(typeof seed_id, 'string');
assert.strictEqual(typeof derivedSlug, 'string');
}
)
);
});
// (c) Canonical: matching frontmatter id wins for seed_id; slug is the filename remainder.
test('property: id `SEED-NNN` + stem `SEED-NNN-<slug>` => seed_id === id, slug === <slug>', () => {
fc.assert(
fc.property(seedNum, slug, (n, s) => {
const id = `SEED-${n}`;
const stem = `SEED-${n}-${s}`;
const result = deriveSeedIdentity(stem, id);
assert.strictEqual(result.seed_id, id);
assert.strictEqual(result.slug, s);
})
);
});
// (d) No usable frontmatter id => seed_id falls back to the filename's numeric prefix.
test('property: missing/non-string id => seed_id falls back to the `SEED-NNN` filename prefix', () => {
fc.assert(
fc.property(
seedNum,
slug,
fc.oneof(fc.constant(undefined), fc.constant(''), fc.array(fc.string()), fc.constant('not-a-seed-id')),
(n, s, badId) => {
const stem = `SEED-${n}-${s}`;
const result = deriveSeedIdentity(stem, badId);
assert.strictEqual(result.seed_id, `SEED-${n}`);
assert.strictEqual(result.slug, s);
}
)
);
});
});