@lumenflow/cli 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/init-config-lanes.test.js +131 -0
- package/dist/__tests__/init-docs-structure.test.js +119 -0
- package/dist/__tests__/init-lane-inference.test.js +125 -0
- package/dist/__tests__/init-onboarding-docs.test.js +132 -0
- package/dist/__tests__/init-quick-ref.test.js +145 -0
- package/dist/__tests__/init-scripts.test.js +96 -0
- package/dist/__tests__/init-template-portability.test.js +97 -0
- package/dist/__tests__/init.test.js +7 -2
- package/dist/__tests__/initiative-add-wu.test.js +420 -0
- package/dist/__tests__/initiative-plan-replacement.test.js +162 -0
- package/dist/__tests__/initiative-remove-wu.test.js +458 -0
- package/dist/__tests__/onboarding-smoke-test.test.js +211 -0
- package/dist/__tests__/path-centralization-cli.test.js +234 -0
- package/dist/__tests__/plan-create.test.js +126 -0
- package/dist/__tests__/plan-edit.test.js +157 -0
- package/dist/__tests__/plan-link.test.js +239 -0
- package/dist/__tests__/plan-promote.test.js +181 -0
- package/dist/__tests__/wu-create-strict.test.js +118 -0
- package/dist/__tests__/wu-edit-strict.test.js +109 -0
- package/dist/__tests__/wu-validate-strict.test.js +113 -0
- package/dist/flow-bottlenecks.js +4 -2
- package/dist/gates.js +22 -0
- package/dist/init.js +580 -87
- package/dist/initiative-add-wu.js +112 -16
- package/dist/initiative-remove-wu.js +248 -0
- package/dist/onboarding-smoke-test.js +400 -0
- package/dist/plan-create.js +199 -0
- package/dist/plan-edit.js +235 -0
- package/dist/plan-link.js +233 -0
- package/dist/plan-promote.js +231 -0
- package/dist/wu-block.js +16 -5
- package/dist/wu-claim.js +15 -9
- package/dist/wu-create.js +50 -2
- package/dist/wu-deps.js +3 -1
- package/dist/wu-done.js +14 -5
- package/dist/wu-edit.js +35 -0
- package/dist/wu-spawn.js +8 -0
- package/dist/wu-unblock.js +34 -2
- package/dist/wu-validate.js +25 -17
- package/package.json +10 -6
- package/templates/core/AGENTS.md.template +2 -2
- package/dist/__tests__/init-plan.test.js +0 -340
- package/dist/agent-issues-query.d.ts +0 -16
- package/dist/agent-log-issue.d.ts +0 -10
- package/dist/agent-session-end.d.ts +0 -10
- package/dist/agent-session.d.ts +0 -10
- package/dist/backlog-prune.d.ts +0 -84
- package/dist/cli-entry-point.d.ts +0 -8
- package/dist/deps-add.d.ts +0 -91
- package/dist/deps-remove.d.ts +0 -17
- package/dist/docs-sync.d.ts +0 -50
- package/dist/file-delete.d.ts +0 -84
- package/dist/file-edit.d.ts +0 -82
- package/dist/file-read.d.ts +0 -92
- package/dist/file-write.d.ts +0 -90
- package/dist/flow-bottlenecks.d.ts +0 -16
- package/dist/flow-report.d.ts +0 -16
- package/dist/gates.d.ts +0 -94
- package/dist/git-branch.d.ts +0 -65
- package/dist/git-diff.d.ts +0 -58
- package/dist/git-log.d.ts +0 -69
- package/dist/git-status.d.ts +0 -58
- package/dist/guard-locked.d.ts +0 -62
- package/dist/guard-main-branch.d.ts +0 -50
- package/dist/guard-worktree-commit.d.ts +0 -59
- package/dist/index.d.ts +0 -10
- package/dist/init-plan.d.ts +0 -80
- package/dist/init-plan.js +0 -337
- package/dist/init.d.ts +0 -46
- package/dist/initiative-add-wu.d.ts +0 -22
- package/dist/initiative-bulk-assign-wus.d.ts +0 -16
- package/dist/initiative-create.d.ts +0 -28
- package/dist/initiative-edit.d.ts +0 -34
- package/dist/initiative-list.d.ts +0 -12
- package/dist/initiative-status.d.ts +0 -11
- package/dist/lumenflow-upgrade.d.ts +0 -103
- package/dist/mem-checkpoint.d.ts +0 -16
- package/dist/mem-cleanup.d.ts +0 -29
- package/dist/mem-create.d.ts +0 -17
- package/dist/mem-export.d.ts +0 -10
- package/dist/mem-inbox.d.ts +0 -35
- package/dist/mem-init.d.ts +0 -15
- package/dist/mem-ready.d.ts +0 -16
- package/dist/mem-signal.d.ts +0 -16
- package/dist/mem-start.d.ts +0 -16
- package/dist/mem-summarize.d.ts +0 -22
- package/dist/mem-triage.d.ts +0 -22
- package/dist/metrics-cli.d.ts +0 -90
- package/dist/metrics-snapshot.d.ts +0 -18
- package/dist/orchestrate-init-status.d.ts +0 -11
- package/dist/orchestrate-initiative.d.ts +0 -12
- package/dist/orchestrate-monitor.d.ts +0 -11
- package/dist/release.d.ts +0 -117
- package/dist/rotate-progress.d.ts +0 -48
- package/dist/session-coordinator.d.ts +0 -74
- package/dist/spawn-list.d.ts +0 -16
- package/dist/state-bootstrap.d.ts +0 -92
- package/dist/sync-templates.d.ts +0 -52
- package/dist/trace-gen.d.ts +0 -84
- package/dist/validate-agent-skills.d.ts +0 -50
- package/dist/validate-agent-sync.d.ts +0 -36
- package/dist/validate-backlog-sync.d.ts +0 -37
- package/dist/validate-skills-spec.d.ts +0 -40
- package/dist/validate.d.ts +0 -60
- package/dist/wu-block.d.ts +0 -16
- package/dist/wu-claim.d.ts +0 -74
- package/dist/wu-cleanup.d.ts +0 -35
- package/dist/wu-create.d.ts +0 -69
- package/dist/wu-delete.d.ts +0 -21
- package/dist/wu-deps.d.ts +0 -13
- package/dist/wu-done.d.ts +0 -225
- package/dist/wu-edit.d.ts +0 -63
- package/dist/wu-infer-lane.d.ts +0 -17
- package/dist/wu-preflight.d.ts +0 -47
- package/dist/wu-prune.d.ts +0 -16
- package/dist/wu-recover.d.ts +0 -37
- package/dist/wu-release.d.ts +0 -19
- package/dist/wu-repair.d.ts +0 -60
- package/dist/wu-spawn-completion.d.ts +0 -10
- package/dist/wu-spawn.d.ts +0 -192
- package/dist/wu-status.d.ts +0 -25
- package/dist/wu-unblock.d.ts +0 -16
- package/dist/wu-unlock-lane.d.ts +0 -19
- package/dist/wu-validate.d.ts +0 -16
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plan:edit command (WU-1313)
|
|
3
|
+
*
|
|
4
|
+
* The plan:edit command edits existing plan files in the repo-native plansDir.
|
|
5
|
+
* Uses micro-worktree isolation for atomic commits.
|
|
6
|
+
*
|
|
7
|
+
* TDD: These tests are written BEFORE the implementation.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
/** Test constants - avoid sonarjs/no-duplicate-string */
|
|
14
|
+
const TEST_PLANS_DIR = 'docs/04-operations/plans';
|
|
15
|
+
const TEST_WU_ID = 'WU-1313';
|
|
16
|
+
// Mock modules before importing
|
|
17
|
+
vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
|
|
18
|
+
getGitForCwd: vi.fn(() => ({
|
|
19
|
+
branch: vi.fn().mockResolvedValue({ current: 'main' }),
|
|
20
|
+
status: vi.fn().mockResolvedValue({ isClean: () => true }),
|
|
21
|
+
})),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
|
|
24
|
+
ensureOnMain: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
|
|
27
|
+
withMicroWorktree: vi.fn(async ({ execute }) => {
|
|
28
|
+
const tempDir = join(tmpdir(), `plan-edit-test-${Date.now()}`);
|
|
29
|
+
mkdirSync(tempDir, { recursive: true });
|
|
30
|
+
return execute({ worktreePath: tempDir });
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
describe('plan:edit command', () => {
|
|
34
|
+
let tempDir;
|
|
35
|
+
let originalCwd;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
tempDir = join(tmpdir(), `plan-edit-test-${Date.now()}`);
|
|
38
|
+
mkdirSync(tempDir, { recursive: true });
|
|
39
|
+
originalCwd = process.cwd();
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.chdir(originalCwd);
|
|
43
|
+
if (existsSync(tempDir)) {
|
|
44
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
describe('updatePlanSection', () => {
|
|
49
|
+
it('should update a section in the plan', async () => {
|
|
50
|
+
const { updatePlanSection } = await import('../plan-edit.js');
|
|
51
|
+
// Setup plan file
|
|
52
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
53
|
+
mkdirSync(plansDir, { recursive: true });
|
|
54
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
55
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
56
|
+
|
|
57
|
+
## Goal
|
|
58
|
+
|
|
59
|
+
Original goal content.
|
|
60
|
+
|
|
61
|
+
## Scope
|
|
62
|
+
|
|
63
|
+
In scope items.
|
|
64
|
+
`);
|
|
65
|
+
// Update goal section
|
|
66
|
+
const changed = updatePlanSection(planPath, 'Goal', 'New goal content from edit.');
|
|
67
|
+
expect(changed).toBe(true);
|
|
68
|
+
const content = readFileSync(planPath, 'utf-8');
|
|
69
|
+
expect(content).toContain('New goal content from edit.');
|
|
70
|
+
expect(content).not.toContain('Original goal content.');
|
|
71
|
+
expect(content).toContain('In scope items.'); // Other sections unchanged
|
|
72
|
+
});
|
|
73
|
+
it('should return false if section not found', async () => {
|
|
74
|
+
const { updatePlanSection } = await import('../plan-edit.js');
|
|
75
|
+
// Setup plan file without the target section
|
|
76
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
77
|
+
mkdirSync(plansDir, { recursive: true });
|
|
78
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
79
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
80
|
+
|
|
81
|
+
## Goal
|
|
82
|
+
|
|
83
|
+
Goal content.
|
|
84
|
+
`);
|
|
85
|
+
// Try to update non-existent section
|
|
86
|
+
const changed = updatePlanSection(planPath, 'NonExistent', 'New content');
|
|
87
|
+
expect(changed).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('should throw if plan file not found', async () => {
|
|
90
|
+
const { updatePlanSection } = await import('../plan-edit.js');
|
|
91
|
+
const planPath = join(tempDir, 'nonexistent.md');
|
|
92
|
+
expect(() => updatePlanSection(planPath, 'Goal', 'content')).toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('appendToSection', () => {
|
|
96
|
+
it('should append content to an existing section', async () => {
|
|
97
|
+
const { appendToSection } = await import('../plan-edit.js');
|
|
98
|
+
// Setup plan file
|
|
99
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
100
|
+
mkdirSync(plansDir, { recursive: true });
|
|
101
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
102
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
103
|
+
|
|
104
|
+
## Risks
|
|
105
|
+
|
|
106
|
+
- Risk 1
|
|
107
|
+
|
|
108
|
+
## References
|
|
109
|
+
`);
|
|
110
|
+
// Append to risks section
|
|
111
|
+
const changed = appendToSection(planPath, 'Risks', '- Risk 2 from append');
|
|
112
|
+
expect(changed).toBe(true);
|
|
113
|
+
const content = readFileSync(planPath, 'utf-8');
|
|
114
|
+
expect(content).toContain('- Risk 1');
|
|
115
|
+
expect(content).toContain('- Risk 2 from append');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('getPlanPath', () => {
|
|
119
|
+
it('should resolve plan path from ID', async () => {
|
|
120
|
+
const { getPlanPath } = await import('../plan-edit.js');
|
|
121
|
+
// Setup plan file
|
|
122
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
123
|
+
mkdirSync(plansDir, { recursive: true });
|
|
124
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
125
|
+
writeFileSync(planPath, '# Plan');
|
|
126
|
+
process.chdir(tempDir);
|
|
127
|
+
const resolved = getPlanPath('WU-1313');
|
|
128
|
+
expect(resolved).toContain(`${TEST_WU_ID}-plan.md`);
|
|
129
|
+
});
|
|
130
|
+
it('should throw if plan not found', async () => {
|
|
131
|
+
const { getPlanPath } = await import('../plan-edit.js');
|
|
132
|
+
process.chdir(tempDir);
|
|
133
|
+
expect(() => getPlanPath('WU-9999')).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('getCommitMessage', () => {
|
|
137
|
+
it('should generate correct commit message', async () => {
|
|
138
|
+
const { getCommitMessage } = await import('../plan-edit.js');
|
|
139
|
+
expect(getCommitMessage('WU-1313', 'Goal')).toBe('docs: update Goal section in wu-1313 plan');
|
|
140
|
+
expect(getCommitMessage('INIT-001', 'Scope')).toBe('docs: update Scope section in init-001 plan');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('plan:edit CLI exports', () => {
|
|
145
|
+
it('should export main function for CLI entry', async () => {
|
|
146
|
+
const planEdit = await import('../plan-edit.js');
|
|
147
|
+
expect(typeof planEdit.main).toBe('function');
|
|
148
|
+
});
|
|
149
|
+
it('should export all required functions', async () => {
|
|
150
|
+
const planEdit = await import('../plan-edit.js');
|
|
151
|
+
expect(typeof planEdit.updatePlanSection).toBe('function');
|
|
152
|
+
expect(typeof planEdit.appendToSection).toBe('function');
|
|
153
|
+
expect(typeof planEdit.getPlanPath).toBe('function');
|
|
154
|
+
expect(typeof planEdit.getCommitMessage).toBe('function');
|
|
155
|
+
expect(typeof planEdit.LOG_PREFIX).toBe('string');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plan:link command (WU-1313)
|
|
3
|
+
*
|
|
4
|
+
* The plan:link command links existing plan files to WUs (via spec_refs)
|
|
5
|
+
* or initiatives (via related_plan). This replaces the initiative:plan command.
|
|
6
|
+
*
|
|
7
|
+
* TDD: These tests are written BEFORE the implementation.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { parseYAML, stringifyYAML } from '@lumenflow/core/dist/wu-yaml.js';
|
|
14
|
+
/** Test constants - avoid sonarjs/no-duplicate-string */
|
|
15
|
+
const TEST_WU_DIR = 'docs/04-operations/tasks/wu';
|
|
16
|
+
const TEST_INIT_DIR = 'docs/04-operations/tasks/initiatives';
|
|
17
|
+
const TEST_PLANS_DIR = 'docs/04-operations/plans';
|
|
18
|
+
const TEST_WU_ID = 'WU-1313';
|
|
19
|
+
const TEST_INIT_ID = 'INIT-001';
|
|
20
|
+
const TEST_WU_PLAN_URI = `lumenflow://plans/${TEST_WU_ID}-plan.md`;
|
|
21
|
+
const TEST_INIT_PLAN_URI = `lumenflow://plans/${TEST_INIT_ID}-plan.md`;
|
|
22
|
+
const TEST_LANE = 'Framework: CLI';
|
|
23
|
+
const TEST_INIT_SLUG = 'test-initiative';
|
|
24
|
+
const TEST_INIT_TITLE = 'Test Initiative';
|
|
25
|
+
const TEST_WU_TITLE = 'Test WU';
|
|
26
|
+
const TEST_STATUS_OPEN = 'open';
|
|
27
|
+
const TEST_DATE = '2026-01-25';
|
|
28
|
+
// Mock modules before importing
|
|
29
|
+
vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
|
|
30
|
+
getGitForCwd: vi.fn(() => ({
|
|
31
|
+
branch: vi.fn().mockResolvedValue({ current: 'main' }),
|
|
32
|
+
status: vi.fn().mockResolvedValue({ isClean: () => true }),
|
|
33
|
+
})),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
|
|
36
|
+
ensureOnMain: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
|
|
39
|
+
withMicroWorktree: vi.fn(async ({ execute }) => {
|
|
40
|
+
const tempDir = join(tmpdir(), `plan-link-test-${Date.now()}`);
|
|
41
|
+
mkdirSync(tempDir, { recursive: true });
|
|
42
|
+
return execute({ worktreePath: tempDir });
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
describe('plan:link command', () => {
|
|
46
|
+
let tempDir;
|
|
47
|
+
let originalCwd;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tempDir = join(tmpdir(), `plan-link-test-${Date.now()}`);
|
|
50
|
+
mkdirSync(tempDir, { recursive: true });
|
|
51
|
+
originalCwd = process.cwd();
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
process.chdir(originalCwd);
|
|
55
|
+
if (existsSync(tempDir)) {
|
|
56
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
});
|
|
60
|
+
describe('linkPlanToWU', () => {
|
|
61
|
+
it('should add spec_refs field to WU YAML', async () => {
|
|
62
|
+
const { linkPlanToWU } = await import('../plan-link.js');
|
|
63
|
+
// Setup mock WU file
|
|
64
|
+
const wuDir = join(tempDir, ...TEST_WU_DIR.split('/'));
|
|
65
|
+
mkdirSync(wuDir, { recursive: true });
|
|
66
|
+
const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
|
|
67
|
+
const wuDoc = {
|
|
68
|
+
id: TEST_WU_ID,
|
|
69
|
+
title: TEST_WU_TITLE,
|
|
70
|
+
lane: TEST_LANE,
|
|
71
|
+
status: 'ready',
|
|
72
|
+
type: 'feature',
|
|
73
|
+
};
|
|
74
|
+
writeFileSync(wuPath, stringifyYAML(wuDoc));
|
|
75
|
+
// Link plan
|
|
76
|
+
const changed = linkPlanToWU(tempDir, TEST_WU_ID, TEST_WU_PLAN_URI);
|
|
77
|
+
expect(changed).toBe(true);
|
|
78
|
+
// Verify the file was updated
|
|
79
|
+
const updated = parseYAML(readFileSync(wuPath, 'utf-8'));
|
|
80
|
+
expect(updated.spec_refs).toContain(TEST_WU_PLAN_URI);
|
|
81
|
+
});
|
|
82
|
+
it('should append to existing spec_refs', async () => {
|
|
83
|
+
const { linkPlanToWU } = await import('../plan-link.js');
|
|
84
|
+
// Setup mock WU file with existing spec_refs
|
|
85
|
+
const wuDir = join(tempDir, ...TEST_WU_DIR.split('/'));
|
|
86
|
+
mkdirSync(wuDir, { recursive: true });
|
|
87
|
+
const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
|
|
88
|
+
const wuDoc = {
|
|
89
|
+
id: TEST_WU_ID,
|
|
90
|
+
title: TEST_WU_TITLE,
|
|
91
|
+
lane: TEST_LANE,
|
|
92
|
+
status: 'ready',
|
|
93
|
+
type: 'feature',
|
|
94
|
+
spec_refs: ['lumenflow://plans/existing-plan.md'],
|
|
95
|
+
};
|
|
96
|
+
writeFileSync(wuPath, stringifyYAML(wuDoc));
|
|
97
|
+
// Link additional plan
|
|
98
|
+
const changed = linkPlanToWU(tempDir, TEST_WU_ID, TEST_WU_PLAN_URI);
|
|
99
|
+
expect(changed).toBe(true);
|
|
100
|
+
// Verify both refs are present
|
|
101
|
+
const updated = parseYAML(readFileSync(wuPath, 'utf-8'));
|
|
102
|
+
expect(updated.spec_refs).toContain('lumenflow://plans/existing-plan.md');
|
|
103
|
+
expect(updated.spec_refs).toContain(TEST_WU_PLAN_URI);
|
|
104
|
+
});
|
|
105
|
+
it('should be idempotent if plan already linked', async () => {
|
|
106
|
+
const { linkPlanToWU } = await import('../plan-link.js');
|
|
107
|
+
// Setup mock WU file with plan already linked
|
|
108
|
+
const wuDir = join(tempDir, ...TEST_WU_DIR.split('/'));
|
|
109
|
+
mkdirSync(wuDir, { recursive: true });
|
|
110
|
+
const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
|
|
111
|
+
const wuDoc = {
|
|
112
|
+
id: TEST_WU_ID,
|
|
113
|
+
title: TEST_WU_TITLE,
|
|
114
|
+
lane: TEST_LANE,
|
|
115
|
+
status: 'ready',
|
|
116
|
+
type: 'feature',
|
|
117
|
+
spec_refs: [TEST_WU_PLAN_URI],
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(wuPath, stringifyYAML(wuDoc));
|
|
120
|
+
// Link same plan again
|
|
121
|
+
const changed = linkPlanToWU(tempDir, TEST_WU_ID, TEST_WU_PLAN_URI);
|
|
122
|
+
expect(changed).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
it('should throw if WU not found', async () => {
|
|
125
|
+
const { linkPlanToWU } = await import('../plan-link.js');
|
|
126
|
+
expect(() => linkPlanToWU(tempDir, 'WU-9999', 'lumenflow://plans/plan.md')).toThrow();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('linkPlanToInitiative', () => {
|
|
130
|
+
it('should add related_plan field to initiative YAML', async () => {
|
|
131
|
+
const { linkPlanToInitiative } = await import('../plan-link.js');
|
|
132
|
+
// Setup mock initiative file
|
|
133
|
+
const initDir = join(tempDir, ...TEST_INIT_DIR.split('/'));
|
|
134
|
+
mkdirSync(initDir, { recursive: true });
|
|
135
|
+
const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
|
|
136
|
+
const initDoc = {
|
|
137
|
+
id: TEST_INIT_ID,
|
|
138
|
+
slug: TEST_INIT_SLUG,
|
|
139
|
+
title: TEST_INIT_TITLE,
|
|
140
|
+
status: TEST_STATUS_OPEN,
|
|
141
|
+
created: TEST_DATE,
|
|
142
|
+
};
|
|
143
|
+
writeFileSync(initPath, stringifyYAML(initDoc));
|
|
144
|
+
// Link plan
|
|
145
|
+
const changed = linkPlanToInitiative(tempDir, TEST_INIT_ID, TEST_INIT_PLAN_URI);
|
|
146
|
+
expect(changed).toBe(true);
|
|
147
|
+
// Verify the file was updated
|
|
148
|
+
const updated = parseYAML(readFileSync(initPath, 'utf-8'));
|
|
149
|
+
expect(updated.related_plan).toBe(TEST_INIT_PLAN_URI);
|
|
150
|
+
});
|
|
151
|
+
it('should be idempotent if plan already linked', async () => {
|
|
152
|
+
const { linkPlanToInitiative } = await import('../plan-link.js');
|
|
153
|
+
// Setup mock initiative file with plan already linked
|
|
154
|
+
const initDir = join(tempDir, ...TEST_INIT_DIR.split('/'));
|
|
155
|
+
mkdirSync(initDir, { recursive: true });
|
|
156
|
+
const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
|
|
157
|
+
const initDoc = {
|
|
158
|
+
id: TEST_INIT_ID,
|
|
159
|
+
slug: TEST_INIT_SLUG,
|
|
160
|
+
title: TEST_INIT_TITLE,
|
|
161
|
+
status: TEST_STATUS_OPEN,
|
|
162
|
+
created: TEST_DATE,
|
|
163
|
+
related_plan: TEST_INIT_PLAN_URI,
|
|
164
|
+
};
|
|
165
|
+
writeFileSync(initPath, stringifyYAML(initDoc));
|
|
166
|
+
// Link same plan again
|
|
167
|
+
const changed = linkPlanToInitiative(tempDir, TEST_INIT_ID, TEST_INIT_PLAN_URI);
|
|
168
|
+
expect(changed).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
it('should warn but proceed if replacing existing plan', async () => {
|
|
171
|
+
const { linkPlanToInitiative } = await import('../plan-link.js');
|
|
172
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
173
|
+
// Setup mock initiative with different plan
|
|
174
|
+
const initDir = join(tempDir, ...TEST_INIT_DIR.split('/'));
|
|
175
|
+
mkdirSync(initDir, { recursive: true });
|
|
176
|
+
const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
|
|
177
|
+
const initDoc = {
|
|
178
|
+
id: TEST_INIT_ID,
|
|
179
|
+
slug: TEST_INIT_SLUG,
|
|
180
|
+
title: TEST_INIT_TITLE,
|
|
181
|
+
status: TEST_STATUS_OPEN,
|
|
182
|
+
created: TEST_DATE,
|
|
183
|
+
related_plan: 'lumenflow://plans/old-plan.md',
|
|
184
|
+
};
|
|
185
|
+
writeFileSync(initPath, stringifyYAML(initDoc));
|
|
186
|
+
// Link new plan
|
|
187
|
+
const changed = linkPlanToInitiative(tempDir, TEST_INIT_ID, 'lumenflow://plans/new-plan.md');
|
|
188
|
+
expect(changed).toBe(true);
|
|
189
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Replacing existing'));
|
|
190
|
+
consoleSpy.mockRestore();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('validatePlanExists', () => {
|
|
194
|
+
it('should pass for existing plan file', async () => {
|
|
195
|
+
const { validatePlanExists } = await import('../plan-link.js');
|
|
196
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
197
|
+
mkdirSync(plansDir, { recursive: true });
|
|
198
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
199
|
+
writeFileSync(planPath, '# Plan');
|
|
200
|
+
expect(() => validatePlanExists(tempDir, TEST_WU_PLAN_URI)).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
it('should throw for non-existent plan file', async () => {
|
|
203
|
+
const { validatePlanExists } = await import('../plan-link.js');
|
|
204
|
+
expect(() => validatePlanExists(tempDir, 'lumenflow://plans/nonexistent.md')).toThrow();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
describe('resolveTargetType', () => {
|
|
208
|
+
it('should detect WU IDs', async () => {
|
|
209
|
+
const { resolveTargetType } = await import('../plan-link.js');
|
|
210
|
+
expect(resolveTargetType(TEST_WU_ID)).toBe('wu');
|
|
211
|
+
expect(resolveTargetType('WU-001')).toBe('wu');
|
|
212
|
+
expect(resolveTargetType('WU-99999')).toBe('wu');
|
|
213
|
+
});
|
|
214
|
+
it('should detect initiative IDs', async () => {
|
|
215
|
+
const { resolveTargetType } = await import('../plan-link.js');
|
|
216
|
+
expect(resolveTargetType(TEST_INIT_ID)).toBe('initiative');
|
|
217
|
+
expect(resolveTargetType('INIT-TOOLING')).toBe('initiative');
|
|
218
|
+
});
|
|
219
|
+
it('should throw for invalid IDs', async () => {
|
|
220
|
+
const { resolveTargetType } = await import('../plan-link.js');
|
|
221
|
+
expect(() => resolveTargetType('invalid')).toThrow();
|
|
222
|
+
expect(() => resolveTargetType('')).toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('plan:link CLI exports', () => {
|
|
227
|
+
it('should export main function for CLI entry', async () => {
|
|
228
|
+
const planLink = await import('../plan-link.js');
|
|
229
|
+
expect(typeof planLink.main).toBe('function');
|
|
230
|
+
});
|
|
231
|
+
it('should export all required functions', async () => {
|
|
232
|
+
const planLink = await import('../plan-link.js');
|
|
233
|
+
expect(typeof planLink.linkPlanToWU).toBe('function');
|
|
234
|
+
expect(typeof planLink.linkPlanToInitiative).toBe('function');
|
|
235
|
+
expect(typeof planLink.validatePlanExists).toBe('function');
|
|
236
|
+
expect(typeof planLink.resolveTargetType).toBe('function');
|
|
237
|
+
expect(typeof planLink.LOG_PREFIX).toBe('string');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plan:promote command (WU-1313)
|
|
3
|
+
*
|
|
4
|
+
* The plan:promote command promotes a plan from draft to approved status,
|
|
5
|
+
* or creates WUs from plan sections.
|
|
6
|
+
*
|
|
7
|
+
* TDD: These tests are written BEFORE the implementation.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
/** Test constants - avoid sonarjs/no-duplicate-string */
|
|
14
|
+
const TEST_PLANS_DIR = 'docs/04-operations/plans';
|
|
15
|
+
const TEST_WU_ID = 'WU-1313';
|
|
16
|
+
// Mock modules before importing
|
|
17
|
+
vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
|
|
18
|
+
getGitForCwd: vi.fn(() => ({
|
|
19
|
+
branch: vi.fn().mockResolvedValue({ current: 'main' }),
|
|
20
|
+
status: vi.fn().mockResolvedValue({ isClean: () => true }),
|
|
21
|
+
})),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
|
|
24
|
+
ensureOnMain: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
|
|
27
|
+
withMicroWorktree: vi.fn(async ({ execute }) => {
|
|
28
|
+
const tempDir = join(tmpdir(), `plan-promote-test-${Date.now()}`);
|
|
29
|
+
mkdirSync(tempDir, { recursive: true });
|
|
30
|
+
return execute({ worktreePath: tempDir });
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
describe('plan:promote command', () => {
|
|
34
|
+
let tempDir;
|
|
35
|
+
let originalCwd;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
tempDir = join(tmpdir(), `plan-promote-test-${Date.now()}`);
|
|
38
|
+
mkdirSync(tempDir, { recursive: true });
|
|
39
|
+
originalCwd = process.cwd();
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.chdir(originalCwd);
|
|
43
|
+
if (existsSync(tempDir)) {
|
|
44
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
describe('promotePlan', () => {
|
|
49
|
+
it('should add approved status marker to plan', async () => {
|
|
50
|
+
const { promotePlan } = await import('../plan-promote.js');
|
|
51
|
+
// Setup plan file
|
|
52
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
53
|
+
mkdirSync(plansDir, { recursive: true });
|
|
54
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
55
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
56
|
+
|
|
57
|
+
Created: 2026-02-01
|
|
58
|
+
|
|
59
|
+
## Goal
|
|
60
|
+
|
|
61
|
+
Implement plan tooling.
|
|
62
|
+
`);
|
|
63
|
+
// Promote plan
|
|
64
|
+
const changed = promotePlan(planPath);
|
|
65
|
+
expect(changed).toBe(true);
|
|
66
|
+
const content = readFileSync(planPath, 'utf-8');
|
|
67
|
+
expect(content).toContain('Status: approved');
|
|
68
|
+
expect(content).toContain('Approved:');
|
|
69
|
+
});
|
|
70
|
+
it('should return false if plan already approved', async () => {
|
|
71
|
+
const { promotePlan } = await import('../plan-promote.js');
|
|
72
|
+
// Setup plan file with approved status
|
|
73
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
74
|
+
mkdirSync(plansDir, { recursive: true });
|
|
75
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
76
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
77
|
+
|
|
78
|
+
Created: 2026-02-01
|
|
79
|
+
Status: approved
|
|
80
|
+
Approved: 2026-02-01
|
|
81
|
+
|
|
82
|
+
## Goal
|
|
83
|
+
|
|
84
|
+
Implement plan tooling.
|
|
85
|
+
`);
|
|
86
|
+
// Try to promote again
|
|
87
|
+
const changed = promotePlan(planPath);
|
|
88
|
+
expect(changed).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
it('should throw if plan file not found', async () => {
|
|
91
|
+
const { promotePlan } = await import('../plan-promote.js');
|
|
92
|
+
const planPath = join(tempDir, 'nonexistent.md');
|
|
93
|
+
expect(() => promotePlan(planPath)).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('validatePlanComplete', () => {
|
|
97
|
+
it('should pass for complete plan', async () => {
|
|
98
|
+
const { validatePlanComplete } = await import('../plan-promote.js');
|
|
99
|
+
// Setup complete plan file
|
|
100
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
101
|
+
mkdirSync(plansDir, { recursive: true });
|
|
102
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
103
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
104
|
+
|
|
105
|
+
Created: 2026-02-01
|
|
106
|
+
|
|
107
|
+
## Goal
|
|
108
|
+
|
|
109
|
+
Clear goal statement here.
|
|
110
|
+
|
|
111
|
+
## Scope
|
|
112
|
+
|
|
113
|
+
- In scope: A
|
|
114
|
+
- Out of scope: B
|
|
115
|
+
|
|
116
|
+
## Approach
|
|
117
|
+
|
|
118
|
+
Step 1: Do X
|
|
119
|
+
Step 2: Do Y
|
|
120
|
+
`);
|
|
121
|
+
const result = validatePlanComplete(planPath);
|
|
122
|
+
expect(result.valid).toBe(true);
|
|
123
|
+
expect(result.errors).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
it('should fail for plan with empty sections', async () => {
|
|
126
|
+
const { validatePlanComplete } = await import('../plan-promote.js');
|
|
127
|
+
// Setup incomplete plan file
|
|
128
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
129
|
+
mkdirSync(plansDir, { recursive: true });
|
|
130
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
131
|
+
writeFileSync(planPath, `# WU-1313 Plan
|
|
132
|
+
|
|
133
|
+
Created: 2026-02-01
|
|
134
|
+
|
|
135
|
+
## Goal
|
|
136
|
+
|
|
137
|
+
## Scope
|
|
138
|
+
|
|
139
|
+
## Approach
|
|
140
|
+
`);
|
|
141
|
+
const result = validatePlanComplete(planPath);
|
|
142
|
+
expect(result.valid).toBe(false);
|
|
143
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
144
|
+
expect(result.errors.some((e) => e.includes('Goal'))).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('getPlanPath', () => {
|
|
148
|
+
it('should resolve plan path from ID', async () => {
|
|
149
|
+
const { getPlanPath } = await import('../plan-promote.js');
|
|
150
|
+
// Setup plan file
|
|
151
|
+
const plansDir = join(tempDir, ...TEST_PLANS_DIR.split('/'));
|
|
152
|
+
mkdirSync(plansDir, { recursive: true });
|
|
153
|
+
const planPath = join(plansDir, `${TEST_WU_ID}-plan.md`);
|
|
154
|
+
writeFileSync(planPath, '# Plan');
|
|
155
|
+
process.chdir(tempDir);
|
|
156
|
+
const resolved = getPlanPath('WU-1313');
|
|
157
|
+
expect(resolved).toContain(`${TEST_WU_ID}-plan.md`);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
describe('getCommitMessage', () => {
|
|
161
|
+
it('should generate correct commit message', async () => {
|
|
162
|
+
const { getCommitMessage } = await import('../plan-promote.js');
|
|
163
|
+
expect(getCommitMessage('WU-1313')).toBe('docs: promote wu-1313 plan to approved');
|
|
164
|
+
expect(getCommitMessage('INIT-001')).toBe('docs: promote init-001 plan to approved');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('plan:promote CLI exports', () => {
|
|
169
|
+
it('should export main function for CLI entry', async () => {
|
|
170
|
+
const planPromote = await import('../plan-promote.js');
|
|
171
|
+
expect(typeof planPromote.main).toBe('function');
|
|
172
|
+
});
|
|
173
|
+
it('should export all required functions', async () => {
|
|
174
|
+
const planPromote = await import('../plan-promote.js');
|
|
175
|
+
expect(typeof planPromote.promotePlan).toBe('function');
|
|
176
|
+
expect(typeof planPromote.validatePlanComplete).toBe('function');
|
|
177
|
+
expect(typeof planPromote.getPlanPath).toBe('function');
|
|
178
|
+
expect(typeof planPromote.getCommitMessage).toBe('function');
|
|
179
|
+
expect(typeof planPromote.LOG_PREFIX).toBe('string');
|
|
180
|
+
});
|
|
181
|
+
});
|