fix(gsd): seed requirements table from REQUIREMENTS.md on first update

When requirements are authored in REQUIREMENTS.md during the discussion
phase (the standard workflow), the DB requirements table stays empty.
gsd_requirement_update then fails with not_found for every requirement
at milestone completion, burning tokens on retries.

When updateRequirementInDb encounters a requirement ID not in the DB,
it now parses REQUIREMENTS.md via parseRequirementsSections() and seeds
all requirements into the DB before retrying the lookup. This preserves
the original content (class, description, why, source, validation)
instead of creating an empty skeleton.

The seeding is:
- Lazy: only runs on first miss, not on every update
- Collision-safe: skips IDs already in the DB
- Non-blocking: falls through to skeleton if REQUIREMENTS.md is
  missing or unparseable

Adds 1 regression test verifying that updating R005 when the DB is
empty seeds all 3 requirements from REQUIREMENTS.md with their
original content preserved.

Closes #3346
This commit is contained in:
Tibsfox 2026-04-05 05:44:06 -07:00
parent 261e2a6d5f
commit 58c19ed48d
2 changed files with 93 additions and 4 deletions

View file

@ -546,11 +546,35 @@ export async function updateRequirementInDb(
try {
const db = await import('./gsd-db.js');
const existing = db.getRequirementById(id);
let existing = db.getRequirementById(id);
// If requirement doesn't exist in DB, seed the entire requirements table
// from REQUIREMENTS.md first (#3346). This handles the standard workflow
// where requirements are authored in markdown during discussion but never
// imported into the database — making gsd_requirement_update always fail
// with "not_found" at milestone completion.
if (!existing) {
const reqFilePath = resolveGsdRootFile(basePath, 'REQUIREMENTS');
try {
const content = readFileSync(reqFilePath, 'utf-8');
const { parseRequirementsSections } = await import('./md-importer.js');
const parsed = parseRequirementsSections(content);
if (parsed.length > 0) {
logWarning('manifest', `Seeding ${parsed.length} requirements from REQUIREMENTS.md into DB (first update triggers import)`, { fn: 'updateRequirementInDb' });
for (const req of parsed) {
// Only seed if not already in DB (avoid overwriting concurrent inserts)
if (!db.getRequirementById(req.id)) {
db.upsertRequirement(req);
}
}
// Re-check after seeding
existing = db.getRequirementById(id);
}
} catch {
// REQUIREMENTS.md missing or unparseable — fall through to skeleton
}
}
// If requirement doesn't exist in DB, create a skeleton and merge updates.
// This handles the case where requirements were written to REQUIREMENTS.md
// but never imported into the database (see #2919).
const base: Requirement = existing ?? {
id,
class: '',

View file

@ -476,6 +476,71 @@ describe('db-writer', () => {
}
});
test('updateRequirementInDb — seeds from REQUIREMENTS.md when DB empty (#3346)', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
// Write a REQUIREMENTS.md with real content (simulating discussion phase output)
const reqContent = [
'# Requirements',
'',
'## Active',
'',
'### R005 — User authentication',
'- Class: functional',
'- Why: Users need secure access',
'- Source: user-research',
'- Primary owner: M001/S02',
'',
'### R007 — API rate limiting',
'- Class: non-functional',
'- Why: Prevent abuse',
'- Source: architecture',
'- Primary owner: M001/S03',
'',
'## Validated',
'',
'### R001 — Database schema',
'- Class: functional',
'- Why: Foundation for storage',
'- Source: design',
'- Validation: S01 verified',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.gsd', 'REQUIREMENTS.md'), reqContent);
// DB is empty — no requirements seeded. Update R005 to "validated".
// Before #3346 fix: this would create a skeleton with empty fields.
// After fix: this seeds all 3 requirements from REQUIREMENTS.md first.
await updateRequirementInDb('R005', {
status: 'validated',
validation: 'S02 — auth flow verified',
}, tmpDir);
// R005 should have the update AND the original content from markdown
const r005 = getRequirementById('R005');
assert.ok(r005, 'R005 should exist');
assert.equal(r005!.status, 'validated', 'status should be updated');
assert.equal(r005!.validation, 'S02 — auth flow verified', 'validation should be updated');
assert.equal(r005!.class, 'functional', 'class should be preserved from REQUIREMENTS.md');
assert.ok(r005!.description?.includes('authentication') || r005!.full_content?.includes('authentication'),
'original content should be preserved');
// R007 and R001 should also be seeded (not just the one being updated)
const r007 = getRequirementById('R007');
assert.ok(r007, 'R007 should be seeded from REQUIREMENTS.md');
assert.equal(r007!.status, 'active', 'R007 status should be active');
const r001 = getRequirementById('R001');
assert.ok(r001, 'R001 should be seeded from REQUIREMENTS.md');
assert.equal(r001!.status, 'validated', 'R001 status should be validated (from section heading)');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// saveArtifactToDb Tests
// ═══════════════════════════════════════════════════════════════════════════