* test(#4378): regression tests for collision-free seed ids * fix(#4378): mint collision-free SEED-YYMMDD-xxx ids, not a shared count plant-seed derived the next seed id from 'ls .planning/seeds/SEED-*.md | wc -l'. .planning/seeds/ is shared but each worktree only sees what has merged, so two workstreams planting before either merges computed the same id and git merged both files silently. The id is now the local date plus a 3-char random base36 suffix -- the shape .planning/quick/ already uses -- computed from knowledge one worktree has alone, with a same-day regen guard. deriveSeedIdentity learns the new canonical grammar alongside legacy SEED-NNN (whose parsing never changes), the --enrich parser and the filename-prefix fallback keep the full new-format id, and the docs that state the filename shape move to it. The prefix fallback previously truncated any non-pure-numeric id at 'SEED-<digits>' -- the same one-id-two-answers ambiguity the issue reports, reproduced one level down. * fix(#4378): harden seed id generation per adversarial review - parse-idea: anchor the --enrich extractor to the flag and capture the complete id, uppercase-tolerant; a leftmost 'SEED-[0-9]+' truncated an uppercase or malformed suffix to its date and enriched an arbitrary same-day seed via head -1. Ambiguous and unmatched targets now fail closed instead. - generate-seed-id: tolerate the expected SIGPIPE under pipefail, abort loudly when the suffix cannot be drawn (an empty suffix would collapse every seed's id to the bare date), and run the same-day regen guard as a find existence test (the 'ls <glob>' shape trips the #3409 drift guard and degenerates under a stray nullglob). - deriveSeedIdentity: document the theoretical legacy/new grammar ambiguity (6-digit counter + 3-char base36 slug, no frontmatter). - changeset: state the residual same-day collision bound instead of implying zero. Emitted-Drift-Ack-Growth: plant-seed.md — the counting step became hardened date+random generation with explicit failure modes; growth is the failure handling, not duplicated logic * fix(#4378): address standards and spec review findings - tests: move the allow-test-rule marker to its suppression site (the file-header placement was inert per CONTRIBUTING site-scoping); add width-boundary coverage (5/7-digit dates, 2/4-char suffixes pin the documented branch behavior); add a writer-to-reader parity property that parses the mint widths out of the shipped workflow so the two grammar owners cannot drift; cover uppercase ids end-to-end in the reader. - plant-seed.md: draw/retry restructured as one loop with a loud terminal failure; SEED_SUFX renamed SEED_SUFFIX; regen guard drops the redundant head -1; the ambiguity error no longer advises an impossible 'complete id' for duplicate legacy ids. - commands.cts: refresh the cmdListSeeds comment still describing SEED-NNN as the only canonical form. - changeset: drop the audit claim the spec axis showed to be an overstatement (audit's id display is filename-derived, pre-existing). - remove a stray untracked artifact file swept into the tree. * test(#4378): correct boundary expectations to the module's real branch behavior The first matrix run on the boundary tests caught my hand-trace of the regex branches, not a module defect: the slug regex's alternation backtracks to the legacy branch whenever the canonical branch cannot complete (so the slug is the remainder after the legacy numeric prefix), and the 7-digit case fails the canonical branch at its 7th digit before the dash. Pin the verified values. * docs(#4378): backfill changeset PR number * fix(#4378): audit seed identity uses the canonical grammar Review of this PR found the audit surface publishing a fused filename stem (SEED-081-region for SEED-081-region.md) where list-seeds reports the canonical id -- one id, two answers across surfaces, the same ambiguity class the issue files. scanSeeds now derives identity through the SAME deriveSeedIdentity the list-seeds gate uses (frontmatter id, then filename id-prefix, then stem), and audit-open acknowledge resolves --seed-id by scanning for the derived identity, falling back to the literal stem so callers scripted against pre-canonical output keep working. Roll-in per the fix-inline rule: found during this PR's review, same seed-identity seam. RED probe: pre-fix audit published seed_id SEED-081-region-becomes / slug 081-region-becomes for a legacy seeded file; post-fix SEED-081 / region-becomes, matching list-seeds. * test(#4378): probe timeout uses the class norm after windows-lane timeout The windows conformance shard failed its bounded sh -c probes at the local 5000ms bound (cold sh.exe spawn under shard load) while the identical code passed this PR's two earlier windows waves. The probe now uses PROBE_TIMEOUT_MS from the class-norm module instead of a local override, per the helpers/timeouts.cjs convention. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -25,6 +25,11 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir, quickDirFrom, todosDir } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
// #4378 (roll-in): scanSeeds publishes the SAME canonical seed identity the
|
||||
// list-seeds gate derives — one grammar, two surfaces, no drift. commands.cjs
|
||||
// does not require this module, so the edge is acyclic.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import commandsModule = require('./commands.cjs');
|
||||
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
@@ -800,6 +805,15 @@ function scanTodos(todosBase: string): ScanOutcome<TodoItem> {
|
||||
return { items: results, acknowledged };
|
||||
}
|
||||
|
||||
// #4378 (roll-in): true when the filename is a well-formed seed name —
|
||||
// `SEED-` prefix, `.md` suffix, and no control bytes anywhere in between (the
|
||||
// scanSeeds admission filter passes such names through to the identity
|
||||
// derivation; a name with an embedded control byte falls back to its raw
|
||||
// stem exactly as the pre-canonical code did).
|
||||
function seedIdMatchRawName(name: string): boolean {
|
||||
return !/[\u0000-\u001f\u007f]/.test(name);
|
||||
}
|
||||
|
||||
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -855,16 +869,18 @@ function scanSeeds(planDir: string): ScanOutcome<SeedItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract seed_id from filename or frontmatter. The regex match is
|
||||
// `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
|
||||
// but the fallback taken when a filename doesn't fully match — e.g. a
|
||||
// `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
|
||||
// the middle, which still passes the `startsWith`/`endsWith` filter above
|
||||
// — is the raw, unconstrained basename. Both branches are routed through
|
||||
// sanitizeLabel below.
|
||||
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
||||
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
||||
const slug = sanitizeLabel(seed_id.replace(/^SEED-/, ''));
|
||||
// #4378 (roll-in): the canonical identity comes from the SAME derivation
|
||||
// the list-seeds surface uses — frontmatter `id:` when it matches a seed
|
||||
// grammar (legacy `SEED-NNN` or date-suffixed `SEED-YYMMDD-xxx`), else the
|
||||
// filename's id prefix, else the whole stem. The old fused
|
||||
// filename-stem id (e.g. `SEED-081-region` for `SEED-081-region.md`)
|
||||
// disagreed with list-seeds and misfiled deferrals; publishing the
|
||||
// canonical id keeps the two surfaces answering identically. The raw-
|
||||
// basename fallback for control-byte-bearing names is preserved.
|
||||
const stem = path.basename(entry.name, '.md');
|
||||
const derived = commandsModule.deriveSeedIdentity(stem, fm.id);
|
||||
const seed_id = seedIdMatchRawName(entry.name) ? derived.seed_id : stem;
|
||||
const slug = sanitizeLabel(derived.slug);
|
||||
|
||||
let title = sanitizeForDisplay(fm.title || '');
|
||||
if (!title) {
|
||||
@@ -1757,8 +1773,44 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
|
||||
} else if (category === 'seeds') {
|
||||
if (!seedId) ioError('--seed-id is required for --category seeds');
|
||||
safeFilePath = requireSafePath(path.join(planDir, 'seeds', `${seedId as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
|
||||
if (!fs.existsSync(safeFilePath)) ioError(`file not found: seeds/${seedId as string}.md`);
|
||||
// #4378 (roll-in): `--seed-id` arrives as whichever id an audit/list
|
||||
// surface published — the canonical identity (frontmatter `id:` or the
|
||||
// derived prefix, e.g. `SEED-081`, `SEED-260914-k3x`) or, for callers
|
||||
// scripted against pre-canonical output, the full filename stem. Resolve
|
||||
// by scanning the seeds directory and matching each candidate's derived
|
||||
// identity (falling back to the literal stem), then prove the winner with
|
||||
// requireSafePath exactly like every other acknowledge target. The direct
|
||||
// `seeds/<seedId>.md` build stays as the final fallback so a genuine
|
||||
// miss still reports the same "file not found" error as before.
|
||||
const seedsAckDir = path.join(planDir, 'seeds');
|
||||
let ackCandidate: string | null = null;
|
||||
let ackEntries: string[] = [];
|
||||
try {
|
||||
ackEntries = fs.readdirSync(seedsAckDir).filter((n) => n.startsWith('SEED-') && n.endsWith('.md'));
|
||||
} catch {
|
||||
ackEntries = [];
|
||||
}
|
||||
for (const name of ackEntries) {
|
||||
const stem = path.basename(name, '.md');
|
||||
let fmId: unknown;
|
||||
try {
|
||||
const rawAck = platformReadSync(path.join(seedsAckDir, name));
|
||||
if (rawAck !== null) fmId = extractFrontmatter(normalizeLineEndings(rawAck), path.join(seedsAckDir, name)).id;
|
||||
} catch {
|
||||
fmId = undefined;
|
||||
}
|
||||
const { seed_id: derivedAckId } = commandsModule.deriveSeedIdentity(stem, fmId);
|
||||
if (derivedAckId === seedId || stem === seedId) {
|
||||
ackCandidate = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ackCandidate !== null) {
|
||||
safeFilePath = requireSafePath(path.join(seedsAckDir, ackCandidate), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
|
||||
} else {
|
||||
safeFilePath = requireSafePath(path.join(seedsAckDir, `${seedId as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
|
||||
ioError(`file not found: seeds/${seedId as string}.md`);
|
||||
}
|
||||
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
|
||||
} else if (category === 'todos') {
|
||||
|
||||
@@ -343,25 +343,43 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
* displayed field is passed through sanitizeForDisplay and each file path is
|
||||
* validated with requireSafePath before reading. Read-only — never mutates.
|
||||
*/
|
||||
/**
|
||||
* Seed id grammars. `SEED-YYMMDD-xxx` (date + 3 base36 chars, the shape
|
||||
* `.planning/quick/` uses) is what plant-seed has minted since #4378 removed
|
||||
* the shared `wc -l` counter; `SEED-NNN` is the legacy counter form, which
|
||||
* keeps parsing forever — existing seeds must never lose their identity.
|
||||
*
|
||||
* Known (theoretical, documented-not-fixed per #4378 review): a frontmatter-less
|
||||
* legacy file whose counter is exactly 6 digits and whose slug opens with
|
||||
* exactly 3 base36 chars parses as new-format. Requires a counter >= 100000 AND
|
||||
* a missing frontmatter id; with frontmatter the legacy id always wins.
|
||||
*/
|
||||
const CANONICAL_SEED_ID_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)$/i;
|
||||
const SEED_ID_PREFIX_RE = /^(SEED-(?:\d{6}-[a-z0-9]{3}|\d+))/i;
|
||||
const SEED_SLUG_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)-(.+)$/i;
|
||||
|
||||
/**
|
||||
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
|
||||
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
|
||||
*
|
||||
* seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
|
||||
* of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
|
||||
* remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
|
||||
* `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
|
||||
* seed_id: frontmatter `id:` when it matches a seed id grammar (`SEED-YYMMDD-xxx`
|
||||
* or legacy `SEED-NNN`), else the id prefix of the filename (`SEED-…-<slug>`),
|
||||
* else the whole stem. The prefix fallback must keep the FULL new-format id —
|
||||
* truncating at the date gives every same-day seed the same id (#4378).
|
||||
* slug: the descriptive remainder after the id, else the stem with a leading
|
||||
* `SEED-` stripped. `rawFmId` is `unknown` because frontmatter values are not
|
||||
* guaranteed strings.
|
||||
*/
|
||||
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
|
||||
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
||||
let seedId: string;
|
||||
if (/^SEED-\d+$/i.test(fmId)) {
|
||||
if (CANONICAL_SEED_ID_RE.test(fmId)) {
|
||||
seedId = fmId;
|
||||
} else {
|
||||
const numMatch = stem.match(/^(SEED-\d+)/i);
|
||||
seedId = numMatch ? numMatch[1] : stem;
|
||||
const prefixMatch = stem.match(SEED_ID_PREFIX_RE);
|
||||
seedId = prefixMatch ? prefixMatch[1] : stem;
|
||||
}
|
||||
const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
|
||||
const slugMatch = stem.match(SEED_SLUG_RE);
|
||||
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
|
||||
return { seed_id: seedId, slug };
|
||||
}
|
||||
@@ -414,9 +432,11 @@ function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolea
|
||||
// sanitizeForDisplay is for output, not comparison.
|
||||
if (wantStatus && status !== wantStatus) continue;
|
||||
|
||||
// Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
|
||||
// back to the numeric prefix of the filename, then to the whole stem. The
|
||||
// descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
|
||||
// Canonical seed ids are `SEED-YYMMDD-xxx` (frontmatter `id:`, what
|
||||
// plant-seed has minted since #4378) or legacy `SEED-NNN`; deriveSeedIdentity
|
||||
// owns that grammar. Fall back to the id prefix of the filename, then to the
|
||||
// whole stem. The descriptive remainder of the filename (`SEED-…-<slug>.md`)
|
||||
// is the slug.
|
||||
const stem = path.basename(entry.name, '.md');
|
||||
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user