@lumenflow/cli 2.3.2 → 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 +199 -3
- 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/flow-report.js +3 -2
- package/dist/gates.js +202 -2
- package/dist/init.js +720 -40
- package/dist/initiative-add-wu.js +112 -16
- package/dist/initiative-plan.js +3 -2
- package/dist/initiative-remove-wu.js +248 -0
- package/dist/mem-context.js +0 -0
- package/dist/metrics-snapshot.js +3 -2
- 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/rotate-progress.js +8 -5
- package/dist/spawn-list.js +4 -3
- package/dist/state-bootstrap.js +6 -4
- package/dist/state-doctor-fix.js +5 -4
- package/dist/state-doctor.js +32 -2
- package/dist/trace-gen.js +6 -3
- 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-infer-lane.js +3 -1
- package/dist/wu-spawn.js +8 -0
- package/dist/wu-unblock.js +34 -2
- package/dist/wu-validate.js +25 -17
- package/package.json +12 -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,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
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file wu-create-strict.test.ts
|
|
3
|
+
* Test suite for wu:create strict validation behavior (WU-1329)
|
|
4
|
+
*
|
|
5
|
+
* WU-1329: Make wu:create run strict validation by default
|
|
6
|
+
*
|
|
7
|
+
* Tests:
|
|
8
|
+
* - Strict validation runs by default (validates code_paths/test_paths exist)
|
|
9
|
+
* - --no-strict flag bypasses strict validation
|
|
10
|
+
* - --no-strict usage is logged
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
// Import test utilities (WU-1329)
|
|
14
|
+
import { validateCreateSpec } from '../wu-create.js';
|
|
15
|
+
// WU-1329: Constants for test file paths
|
|
16
|
+
const NON_EXISTENT_CODE_PATH = 'non-existent/file.ts';
|
|
17
|
+
const NON_EXISTENT_TEST_PATH = 'non-existent/test.test.ts';
|
|
18
|
+
describe('wu:create strict validation (WU-1329)', () => {
|
|
19
|
+
describe('validateCreateSpec strict mode', () => {
|
|
20
|
+
const baseOpts = {
|
|
21
|
+
id: 'WU-9999',
|
|
22
|
+
lane: 'Framework: CLI',
|
|
23
|
+
title: 'Test WU',
|
|
24
|
+
priority: 'P2',
|
|
25
|
+
type: 'feature',
|
|
26
|
+
opts: {
|
|
27
|
+
description: 'Context: test context.\nProblem: test problem.\nSolution: test solution that exceeds minimum length requirement.',
|
|
28
|
+
acceptance: ['Acceptance criterion 1'],
|
|
29
|
+
codePaths: ['packages/@lumenflow/cli/src/wu-create.ts'],
|
|
30
|
+
testPathsUnit: ['packages/@lumenflow/cli/src/__tests__/wu-create-strict.test.ts'],
|
|
31
|
+
exposure: 'backend-only',
|
|
32
|
+
specRefs: ['lumenflow://plans/WU-9999-plan.md'],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
it('should pass validation with valid spec in non-strict mode', () => {
|
|
36
|
+
const result = validateCreateSpec({
|
|
37
|
+
...baseOpts,
|
|
38
|
+
opts: {
|
|
39
|
+
...baseOpts.opts,
|
|
40
|
+
strict: false,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect(result.valid).toBe(true);
|
|
44
|
+
expect(result.errors).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
// WU-1329: This test verifies that strict validation is the default
|
|
47
|
+
it('should validate code_paths existence by default (strict=true)', () => {
|
|
48
|
+
const result = validateCreateSpec({
|
|
49
|
+
...baseOpts,
|
|
50
|
+
opts: {
|
|
51
|
+
...baseOpts.opts,
|
|
52
|
+
codePaths: [NON_EXISTENT_CODE_PATH],
|
|
53
|
+
// strict is not explicitly set - should default to true
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
// In strict mode, non-existent paths should be caught
|
|
57
|
+
expect(result.valid).toBe(false);
|
|
58
|
+
expect(result.errors.some((e) => e.includes('code_paths') || e.includes('not found'))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
// WU-1329: This test verifies --no-strict bypasses path validation
|
|
61
|
+
it('should skip code_paths existence check when strict=false', () => {
|
|
62
|
+
const result = validateCreateSpec({
|
|
63
|
+
...baseOpts,
|
|
64
|
+
opts: {
|
|
65
|
+
...baseOpts.opts,
|
|
66
|
+
codePaths: [NON_EXISTENT_CODE_PATH],
|
|
67
|
+
strict: false,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
// In non-strict mode, non-existent paths should not fail validation
|
|
71
|
+
// (other schema validation may still fail)
|
|
72
|
+
expect(result.errors.every((e) => !e.includes('not found') && !e.includes('does not exist'))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
// WU-1329: This test verifies test_paths validation in strict mode
|
|
75
|
+
it('should validate test_paths existence by default (strict=true)', () => {
|
|
76
|
+
const result = validateCreateSpec({
|
|
77
|
+
...baseOpts,
|
|
78
|
+
opts: {
|
|
79
|
+
...baseOpts.opts,
|
|
80
|
+
testPathsUnit: [NON_EXISTENT_TEST_PATH],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
// In strict mode, non-existent test paths should be caught
|
|
84
|
+
expect(result.valid).toBe(false);
|
|
85
|
+
expect(result.errors.some((e) => e.includes('test') || e.includes('not found'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('--no-strict logging', () => {
|
|
89
|
+
let consoleSpy;
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
92
|
+
});
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
consoleSpy.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
// WU-1329: This test verifies --no-strict usage is logged
|
|
97
|
+
it('should log warning when --no-strict is used', () => {
|
|
98
|
+
validateCreateSpec({
|
|
99
|
+
id: 'WU-9999',
|
|
100
|
+
lane: 'Framework: CLI',
|
|
101
|
+
title: 'Test WU',
|
|
102
|
+
priority: 'P2',
|
|
103
|
+
type: 'feature',
|
|
104
|
+
opts: {
|
|
105
|
+
description: 'Context: test context.\nProblem: test problem.\nSolution: test solution that exceeds minimum.',
|
|
106
|
+
acceptance: ['Acceptance criterion'],
|
|
107
|
+
codePaths: [NON_EXISTENT_CODE_PATH],
|
|
108
|
+
testPathsUnit: [NON_EXISTENT_TEST_PATH],
|
|
109
|
+
exposure: 'backend-only',
|
|
110
|
+
specRefs: ['lumenflow://plans/WU-9999-plan.md'],
|
|
111
|
+
strict: false,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
// Should log that strict validation was bypassed
|
|
115
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('strict validation bypassed'));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file wu-edit-strict.test.ts
|
|
3
|
+
* Test suite for wu:edit strict validation behavior (WU-1329)
|
|
4
|
+
*
|
|
5
|
+
* WU-1329: Make wu:edit run strict validation by default
|
|
6
|
+
*
|
|
7
|
+
* Tests:
|
|
8
|
+
* - applyEdits function works correctly (unit test for edit logic)
|
|
9
|
+
* - validateDoneWUEdits properly validates done WU edits
|
|
10
|
+
* - The main() function validates paths in strict mode (covered by e2e tests)
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
// Import test utilities (WU-1329)
|
|
14
|
+
import { applyEdits, validateDoneWUEdits, validateExposureValue } from '../wu-edit.js';
|
|
15
|
+
// WU-1329: Constants for test file paths
|
|
16
|
+
const TEST_NEW_FILE_PATH = 'new/file.ts';
|
|
17
|
+
const TEST_NEW_TEST_PATH = 'new/test.test.ts';
|
|
18
|
+
describe('wu:edit strict validation (WU-1329)', () => {
|
|
19
|
+
describe('applyEdits code_paths handling', () => {
|
|
20
|
+
const baseWU = {
|
|
21
|
+
id: 'WU-9999',
|
|
22
|
+
title: 'Test WU',
|
|
23
|
+
lane: 'Framework: CLI',
|
|
24
|
+
type: 'feature',
|
|
25
|
+
status: 'ready',
|
|
26
|
+
priority: 'P2',
|
|
27
|
+
description: 'Context: test context.\nProblem: test problem.\nSolution: test solution that exceeds minimum length.',
|
|
28
|
+
acceptance: ['Existing criterion'],
|
|
29
|
+
code_paths: ['packages/@lumenflow/cli/src/wu-edit.ts'],
|
|
30
|
+
tests: {
|
|
31
|
+
unit: ['packages/@lumenflow/cli/src/__tests__/wu-edit-strict.test.ts'],
|
|
32
|
+
manual: [],
|
|
33
|
+
e2e: [],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
// WU-1329: applyEdits transforms WU object - path validation is done separately
|
|
37
|
+
it('should apply code_paths edits correctly', () => {
|
|
38
|
+
const result = applyEdits(baseWU, {
|
|
39
|
+
codePaths: [TEST_NEW_FILE_PATH],
|
|
40
|
+
replaceCodePaths: true,
|
|
41
|
+
});
|
|
42
|
+
// applyEdits transforms the WU, validation happens later in main()
|
|
43
|
+
expect(result.code_paths).toContain(TEST_NEW_FILE_PATH);
|
|
44
|
+
expect(result.code_paths).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
it('should append code_paths by default', () => {
|
|
47
|
+
const result = applyEdits(baseWU, {
|
|
48
|
+
codePaths: [TEST_NEW_FILE_PATH],
|
|
49
|
+
});
|
|
50
|
+
expect(result.code_paths).toContain('packages/@lumenflow/cli/src/wu-edit.ts');
|
|
51
|
+
expect(result.code_paths).toContain(TEST_NEW_FILE_PATH);
|
|
52
|
+
expect(result.code_paths).toHaveLength(2);
|
|
53
|
+
});
|
|
54
|
+
it('should apply test_paths edits correctly', () => {
|
|
55
|
+
const result = applyEdits(baseWU, {
|
|
56
|
+
testPathsUnit: [TEST_NEW_TEST_PATH],
|
|
57
|
+
});
|
|
58
|
+
// Should append test paths
|
|
59
|
+
expect(result.tests.unit).toContain('packages/@lumenflow/cli/src/__tests__/wu-edit-strict.test.ts');
|
|
60
|
+
expect(result.tests.unit).toContain(TEST_NEW_TEST_PATH);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('validateDoneWUEdits', () => {
|
|
64
|
+
// WU-1329: Done WUs only allow initiative/phase/exposure edits
|
|
65
|
+
it('should allow exposure edits on done WUs', () => {
|
|
66
|
+
const result = validateDoneWUEdits({ exposure: 'backend-only' });
|
|
67
|
+
expect(result.valid).toBe(true);
|
|
68
|
+
expect(result.disallowedEdits).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
it('should disallow code_paths edits on done WUs', () => {
|
|
71
|
+
const result = validateDoneWUEdits({ codePaths: [TEST_NEW_FILE_PATH] });
|
|
72
|
+
expect(result.valid).toBe(false);
|
|
73
|
+
expect(result.disallowedEdits).toContain('--code-paths');
|
|
74
|
+
});
|
|
75
|
+
it('should disallow description edits on done WUs', () => {
|
|
76
|
+
const result = validateDoneWUEdits({ description: 'new description' });
|
|
77
|
+
expect(result.valid).toBe(false);
|
|
78
|
+
expect(result.disallowedEdits).toContain('--description');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('validateExposureValue', () => {
|
|
82
|
+
it('should accept valid exposure values', () => {
|
|
83
|
+
expect(validateExposureValue('ui').valid).toBe(true);
|
|
84
|
+
expect(validateExposureValue('api').valid).toBe(true);
|
|
85
|
+
expect(validateExposureValue('backend-only').valid).toBe(true);
|
|
86
|
+
expect(validateExposureValue('documentation').valid).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('should reject invalid exposure values', () => {
|
|
89
|
+
const result = validateExposureValue('invalid-exposure');
|
|
90
|
+
expect(result.valid).toBe(false);
|
|
91
|
+
expect(result.error).toContain('Invalid exposure value');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('noStrict option support', () => {
|
|
95
|
+
// WU-1329: Verify CLI option parsing pattern
|
|
96
|
+
it('should support noStrict option in CLI argument pattern', () => {
|
|
97
|
+
// CLI uses --no-strict which Commander.js parses as noStrict
|
|
98
|
+
// The main() function converts this to strict: !noStrict
|
|
99
|
+
const cliArgs = { noStrict: true };
|
|
100
|
+
const strict = !cliArgs.noStrict;
|
|
101
|
+
expect(strict).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it('should default to strict=true when noStrict is undefined', () => {
|
|
104
|
+
const cliArgs = { noStrict: undefined };
|
|
105
|
+
const strict = !cliArgs.noStrict;
|
|
106
|
+
expect(strict).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|