* 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>
91 lines
3.6 KiB
JavaScript
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);
|
|
}
|
|
)
|
|
);
|
|
});
|
|
});
|