feat(roadmap): surface wave dependencies and cross-cutting constraints (#2487)
* feat(roadmap): surface wave dependencies and cross-cutting constraints (#2447) Adds roadmap.annotate-dependencies command that post-processes a phase's ROADMAP plan list to insert wave dependency notes and surface must_haves.truths entries shared across 2+ plans as cross-cutting constraints. Operation is idempotent and purely derived from existing PLAN frontmatter. Closes #2447 * fix(roadmap): address CodeRabbit review findings on PR #2487 - roadmap.cjs: expand idempotency guard to also check for existing cross-cutting constraints header, preventing duplicate injection on re-runs; add content equality check before writing to preserve true idempotency for single-wave phases - plan-phase.md: move ROADMAP annotation (13d) before docs commit (13c) so annotated ROADMAP.md is included in the commit rather than left dirty; include .planning/ROADMAP.md in committed files list - sdk/src/query/index.ts: add annotate-dependencies aliases to QUERY_MUTATION_COMMANDS so the mutation is properly event-wired - sdk/src/query/roadmap.ts: add timeout (15s) and maxBuffer to spawnSync; check result.error before result.status to handle spawn/timeout failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
* roadmap get-phase <phase> Extract phase section from ROADMAP.md
|
||||
* roadmap analyze Full roadmap parse with disk status
|
||||
* roadmap update-plan-progress <N> Update progress table row from disk (PLAN vs SUMMARY counts)
|
||||
* roadmap annotate-dependencies <N> Add wave dependency notes + cross-cutting constraints to ROADMAP.md
|
||||
*
|
||||
* Requirements Operations:
|
||||
* requirements mark-complete <ids> Mark requirement IDs as complete in REQUIREMENTS.md
|
||||
@@ -690,8 +691,10 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
roadmap.cmdRoadmapAnalyze(cwd, raw);
|
||||
} else if (subcommand === 'update-plan-progress') {
|
||||
roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw);
|
||||
} else if (subcommand === 'annotate-dependencies') {
|
||||
roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw);
|
||||
} else {
|
||||
error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress');
|
||||
error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress, annotate-dependencies');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -353,8 +353,171 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
||||
}, raw, `${summaryCount}/${planCount} ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
|
||||
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
|
||||
*
|
||||
* Wave dependency notes: "Wave 2 — blocked on Wave 1 completion" inserted as
|
||||
* bold headers before each wave group in the plan checklist.
|
||||
*
|
||||
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
|
||||
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
|
||||
*
|
||||
* The operation is idempotent: if wave headers already exist in the section
|
||||
* the function returns without modifying the file.
|
||||
*/
|
||||
function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
||||
if (!phaseNum) {
|
||||
error('phase number required for roadmap annotate-dependencies');
|
||||
}
|
||||
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ updated: false, reason: 'ROADMAP.md not found' }, raw, 'no roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
||||
if (!phaseInfo || phaseInfo.plans.length === 0) {
|
||||
output({ updated: false, reason: 'no plans found for phase', phase: phaseNum }, raw, 'no plans');
|
||||
return;
|
||||
}
|
||||
|
||||
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
||||
|
||||
// Read each PLAN.md and extract wave + must_haves.truths
|
||||
const planData = [];
|
||||
for (const planFile of phaseInfo.plans) {
|
||||
const planPath = path.join(path.resolve(cwd, phaseInfo.directory), planFile);
|
||||
try {
|
||||
const content = fs.readFileSync(planPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
const wave = parseInt(fm.wave, 10) || 1;
|
||||
const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
|
||||
const truths = parseMustHavesBlock(content, 'truths') || [];
|
||||
planData.push({ planFile, planId, wave, truths });
|
||||
} catch { /* skip unreadable plans */ }
|
||||
}
|
||||
|
||||
if (planData.length === 0) {
|
||||
output({ updated: false, reason: 'could not read plan frontmatter' }, raw, 'no frontmatter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group plans by wave (sorted)
|
||||
const waveGroups = new Map();
|
||||
for (const p of planData) {
|
||||
if (!waveGroups.has(p.wave)) waveGroups.set(p.wave, []);
|
||||
waveGroups.get(p.wave).push(p);
|
||||
}
|
||||
const waves = [...waveGroups.keys()].sort((a, b) => a - b);
|
||||
|
||||
// Find cross-cutting truths: appear in 2+ plans (de-duplicated, case-insensitive)
|
||||
const truthCounts = new Map();
|
||||
for (const { truths } of planData) {
|
||||
const seen = new Set();
|
||||
for (const t of truths) {
|
||||
const key = t.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
truthCounts.set(key, (truthCounts.get(key) || { count: 0, text: t.trim() }));
|
||||
truthCounts.get(key).count++;
|
||||
}
|
||||
}
|
||||
const crossCuttingTruths = [...truthCounts.values()]
|
||||
.filter(v => v.count >= 2)
|
||||
.map(v => v.text);
|
||||
|
||||
// Patch ROADMAP.md
|
||||
let updated = false;
|
||||
withPlanningLock(cwd, () => {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Find the phase section
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i');
|
||||
const phaseMatch = content.match(phaseHeaderPattern);
|
||||
if (!phaseMatch) return;
|
||||
|
||||
const phaseStart = phaseMatch.index;
|
||||
const restAfterHeader = content.slice(phaseStart);
|
||||
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
|
||||
const phaseSection = content.slice(phaseStart, phaseEnd);
|
||||
|
||||
// Idempotency: skip if annotation markers already present
|
||||
if (
|
||||
/\*\*Wave\s+\d+/i.test(phaseSection) ||
|
||||
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
|
||||
) return;
|
||||
|
||||
// Find the Plans: section within the phase section
|
||||
const plansBlockMatch = phaseSection.match(/(Plans:\s*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)*)/i);
|
||||
if (!plansBlockMatch) return;
|
||||
|
||||
const plansHeader = plansBlockMatch[1];
|
||||
const existingList = plansBlockMatch[2];
|
||||
const listLines = existingList.split('\n').filter(l => /^\s*-\s*\[/.test(l));
|
||||
|
||||
if (listLines.length === 0) return;
|
||||
|
||||
// Build wave-annotated plan list
|
||||
const linesByWave = new Map();
|
||||
for (const line of listLines) {
|
||||
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
|
||||
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
|
||||
const planId = idMatch ? idMatch[1] : null;
|
||||
const planEntry = planId ? planData.find(p => p.planId === planId) : null;
|
||||
const wave = planEntry ? planEntry.wave : 1;
|
||||
if (!linesByWave.has(wave)) linesByWave.set(wave, []);
|
||||
linesByWave.get(wave).push(line);
|
||||
}
|
||||
|
||||
const annotatedLines = [];
|
||||
const sortedWaves = [...linesByWave.keys()].sort((a, b) => a - b);
|
||||
for (let i = 0; i < sortedWaves.length; i++) {
|
||||
const w = sortedWaves[i];
|
||||
const waveLines = linesByWave.get(w);
|
||||
if (sortedWaves.length > 1) {
|
||||
const dep = i > 0 ? ` *(blocked on Wave ${sortedWaves[i - 1]} completion)*` : '';
|
||||
annotatedLines.push(`**Wave ${w}**${dep}`);
|
||||
}
|
||||
annotatedLines.push(...waveLines);
|
||||
if (i < sortedWaves.length - 1) annotatedLines.push('');
|
||||
}
|
||||
|
||||
// Append cross-cutting constraints subsection if any found
|
||||
if (crossCuttingTruths.length > 0) {
|
||||
annotatedLines.push('');
|
||||
annotatedLines.push('**Cross-cutting constraints:**');
|
||||
for (const t of crossCuttingTruths) {
|
||||
annotatedLines.push(`- ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
const newListBlock = annotatedLines.join('\n') + '\n';
|
||||
const newPhaseSection = phaseSection.replace(
|
||||
plansBlockMatch[0],
|
||||
plansHeader + newListBlock
|
||||
);
|
||||
|
||||
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
|
||||
if (nextContent === content) return;
|
||||
atomicWriteFileSync(roadmapPath, nextContent);
|
||||
updated = true;
|
||||
});
|
||||
|
||||
output({
|
||||
updated,
|
||||
phase: phaseNum,
|
||||
waves: waves.length,
|
||||
cross_cutting_constraints: crossCuttingTruths.length,
|
||||
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cmdRoadmapGetPhase,
|
||||
cmdRoadmapAnalyze,
|
||||
cmdRoadmapUpdatePlanProgress,
|
||||
cmdRoadmapAnnotateDependencies,
|
||||
};
|
||||
|
||||
@@ -1149,15 +1149,29 @@ gsd-sdk query state.planned-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME
|
||||
|
||||
This updates STATUS to "Ready to execute", sets the correct plan count, and timestamps Last Activity.
|
||||
|
||||
## 13c. Commit Plans if commit_docs is true
|
||||
## 13c. Annotate ROADMAP with Wave Dependencies and Cross-cutting Constraints
|
||||
|
||||
If `commit_docs` is true (from the init JSON parsed in step 1), commit the generated plan artifacts:
|
||||
After plans are finalized, annotate the ROADMAP.md plan list for this phase with:
|
||||
- **Wave dependency notes** — a bold header before each wave group ("Wave 2 *(blocked on Wave 1 completion)*")
|
||||
- **Cross-cutting constraints** — a "Cross-cutting constraints:" subsection listing `must_haves.truths` entries that appear in 2 or more plans
|
||||
|
||||
This step is derived entirely from existing PLAN frontmatter — no extra LLM pass is required.
|
||||
|
||||
```bash
|
||||
gsd-sdk query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE_DIR}"/*-PLAN.md .planning/STATE.md
|
||||
gsd-sdk query roadmap.annotate-dependencies "${PHASE_NUMBER}"
|
||||
```
|
||||
|
||||
This commits all PLAN.md files for the phase plus the updated STATE.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
|
||||
This operation is idempotent: if wave headers or cross-cutting constraints already exist in the ROADMAP phase section, the command returns without modifying the file. Skip this step if `plan_count` is 0.
|
||||
|
||||
## 13d. Commit Plans if commit_docs is true
|
||||
|
||||
If `commit_docs` is true (from the init JSON parsed in step 1), commit the generated plan artifacts (including any ROADMAP.md annotations from step 13c):
|
||||
|
||||
```bash
|
||||
gsd-sdk query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE_DIR}"/*-PLAN.md .planning/STATE.md .planning/ROADMAP.md
|
||||
```
|
||||
|
||||
This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
|
||||
|
||||
## 14. Present Final Status
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
} from './init.js';
|
||||
import { initNewProject, initProgress, initManager } from './init-complex.js';
|
||||
import { agentSkills } from './skills.js';
|
||||
import { requirementsMarkComplete } from './roadmap.js';
|
||||
import { requirementsMarkComplete, roadmapAnnotateDependencies } from './roadmap.js';
|
||||
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
|
||||
import { statePlannedPhase } from './state-mutation.js';
|
||||
import { verifySchemaDrift } from './verify.js';
|
||||
@@ -141,6 +141,7 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
|
||||
'phase add', 'phase add-batch', 'phase insert', 'phase remove', 'phase complete',
|
||||
'phase scaffold', 'phases clear', 'phases archive',
|
||||
'roadmap.update-plan-progress', 'roadmap update-plan-progress',
|
||||
'roadmap.annotate-dependencies', 'roadmap annotate-dependencies',
|
||||
'requirements.mark-complete', 'requirements mark-complete',
|
||||
'todo.complete', 'todo complete',
|
||||
'milestone.complete', 'milestone complete',
|
||||
@@ -451,6 +452,8 @@ export function createRegistry(
|
||||
registry.register('agent-skills', agentSkills);
|
||||
registry.register('roadmap.update-plan-progress', roadmapUpdatePlanProgress);
|
||||
registry.register('roadmap update-plan-progress', roadmapUpdatePlanProgress);
|
||||
registry.register('roadmap.annotate-dependencies', roadmapAnnotateDependencies);
|
||||
registry.register('roadmap annotate-dependencies', roadmapAnnotateDependencies);
|
||||
registry.register('requirements.mark-complete', requirementsMarkComplete);
|
||||
registry.register('requirements mark-complete', requirementsMarkComplete);
|
||||
registry.register('state.planned-phase', statePlannedPhase);
|
||||
|
||||
@@ -462,6 +462,53 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => {
|
||||
return { data: result };
|
||||
};
|
||||
|
||||
|
||||
// ─── roadmapAnnotateDependencies ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Annotate the ROADMAP.md plan list with wave dependency notes and
|
||||
* cross-cutting constraints derived from PLAN frontmatter.
|
||||
*
|
||||
* Delegates to gsd-tools.cjs which holds the full annotation logic.
|
||||
* Returns { updated, phase, waves, cross_cutting_constraints }.
|
||||
*/
|
||||
export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir) => {
|
||||
const phase = args[0];
|
||||
if (!phase) {
|
||||
return { data: { updated: false, reason: 'phase argument required' } };
|
||||
}
|
||||
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const toolsPath = fileURLToPath(
|
||||
new URL('../../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { data: { updated: false, reason: result.error.message || 'gsd-tools invocation failed' } };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return { data: { updated: false, reason: result.stderr?.trim() || 'gsd-tools error' } };
|
||||
}
|
||||
|
||||
try {
|
||||
return { data: JSON.parse(result.stdout.trim()) };
|
||||
} catch {
|
||||
return { data: { updated: false, reason: 'failed to parse gsd-tools output' } };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ─── requirementsMarkComplete ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
205
tests/enh-2447-roadmap-wave-deps.test.cjs
Normal file
205
tests/enh-2447-roadmap-wave-deps.test.cjs
Normal file
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for ROADMAP wave dependency surfacing (#2447).
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const PLAN_TEMPLATE = (wave, truths = []) => `---
|
||||
phase: "1"
|
||||
plan: "01-0${wave}"
|
||||
type: standard
|
||||
wave: ${wave}
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
requirements: []
|
||||
must_haves:
|
||||
truths:
|
||||
${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'}
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Plan ${wave} objective
|
||||
</objective>
|
||||
`;
|
||||
|
||||
function makePlanProject(files = {}) {
|
||||
const dir = createTempProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content, 'utf-8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('roadmap annotate-dependencies', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('inserts wave headers for multi-wave plan set', () => {
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up project
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Set up DB
|
||||
- [ ] 01-02-PLAN.md — Build API
|
||||
`,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, true);
|
||||
assert.strictEqual(out.waves, 2);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present');
|
||||
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present');
|
||||
assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present');
|
||||
});
|
||||
|
||||
test('does not insert wave headers for single-wave plan set', () => {
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up project
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Set up DB
|
||||
- [ ] 01-02-PLAN.md — Build API
|
||||
`,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set');
|
||||
assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave');
|
||||
});
|
||||
|
||||
test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => {
|
||||
const sharedTruth = 'All endpoints require auth';
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up project
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Set up DB
|
||||
- [ ] 01-02-PLAN.md — Build API
|
||||
`,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cross_cutting_constraints, 1);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present');
|
||||
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
|
||||
});
|
||||
|
||||
test('does not surface constraints that appear in only one plan', () => {
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up project
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Set up DB
|
||||
- [ ] 01-02-PLAN.md — Build API
|
||||
`,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']),
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cross_cutting_constraints, 0);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting');
|
||||
});
|
||||
|
||||
test('is idempotent — running twice does not double-insert wave headers', () => {
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up project
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Set up DB
|
||||
- [ ] 01-02-PLAN.md — Build API
|
||||
`,
|
||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1),
|
||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2),
|
||||
});
|
||||
|
||||
runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(secondResult.success);
|
||||
|
||||
const out = JSON.parse(secondResult.output);
|
||||
assert.strictEqual(out.updated, false, 'second run should be no-op');
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || [];
|
||||
assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)');
|
||||
});
|
||||
|
||||
test('returns no-op when phase has no plans', () => {
|
||||
tmpDir = makePlanProject({
|
||||
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`,
|
||||
});
|
||||
|
||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||||
assert.ok(result.success);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, false);
|
||||
});
|
||||
|
||||
test('plan-phase.md documents annotate-dependencies step', () => {
|
||||
const planPhase = fs.readFileSync(
|
||||
path.join(__dirname, '../get-shit-done/workflows/plan-phase.md'), 'utf-8'
|
||||
);
|
||||
assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command');
|
||||
assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d');
|
||||
assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user