ma-agents 3.0.1 → 3.2.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/.opencode/skills/.ma-agents.json +99 -99
- package/README.md +48 -2
- package/bin/cli.js +546 -2
- package/lib/bmad-extension/module-help.csv +8 -4
- package/lib/bmad-extension/skills/add-sprint/SKILL.md +126 -40
- package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +116 -142
- package/lib/bmad-extension/skills/cleanup-done/.gitkeep +0 -0
- package/lib/bmad-extension/skills/cleanup-done/SKILL.md +159 -0
- package/lib/bmad-extension/skills/cleanup-done/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/create-bug-story/SKILL.md +75 -7
- package/lib/bmad-extension/skills/generate-backlog/SKILL.md +183 -0
- package/lib/bmad-extension/skills/generate-backlog/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/modify-sprint/SKILL.md +63 -0
- package/lib/bmad-extension/skills/prioritize-backlog/.gitkeep +0 -0
- package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +195 -0
- package/lib/bmad-extension/skills/prioritize-backlog/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/remove-from-sprint/.gitkeep +0 -0
- package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +163 -0
- package/lib/bmad-extension/skills/remove-from-sprint/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +199 -138
- package/lib/bmad-extension/workflows/add-sprint/workflow.md +129 -39
- package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +3 -205
- package/lib/bmad-extension/workflows/modify-sprint/workflow.md +5 -0
- package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +3 -192
- package/lib/installer.js +109 -2
- package/lib/templates/project-context.template.md +1 -1
- package/package.json +2 -2
- package/test/cicd-remote-mode.test.js +224 -0
- package/test/config-layout.test.js +230 -0
- package/test/config-lost-on-update.test.js +363 -0
- package/test/config-storage.test.js +275 -0
- package/test/cross-repo-validation.test.js +201 -0
- package/test/generate-project-context.test.js +148 -2
- package/test/portable-paths.test.js +268 -0
- package/test/repo-layout.test.js +246 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.4: Validate Cross-Repo Path Resolution
|
|
4
|
+
* Verifies config-driven path resolution and backward compatibility
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
let failed = 0;
|
|
15
|
+
const errors = [];
|
|
16
|
+
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
console.log(` \u2713 ${name}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(` \u2717 ${name}: ${err.message}`);
|
|
24
|
+
failed++;
|
|
25
|
+
errors.push({ name, error: err.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let writeRepoLayoutConfig, writeConfigField;
|
|
30
|
+
try {
|
|
31
|
+
({ writeRepoLayoutConfig, writeConfigField } = require('../bin/cli.js'));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error('\n FATAL: Cannot load exports from cli.js');
|
|
34
|
+
console.error(' ', e.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Task 1: Audit verification ─────────────────────────────────────────────
|
|
39
|
+
console.log('\nTask 1 — Workflow audit: no hardcoded _bmad-output/ paths');
|
|
40
|
+
|
|
41
|
+
test('no workflow files contain hardcoded _bmad-output/', () => {
|
|
42
|
+
const bmadDir = path.join(__dirname, '..', '_bmad', 'bmm');
|
|
43
|
+
if (!fs.existsSync(bmadDir)) {
|
|
44
|
+
console.log(' SKIP: _bmad/bmm not found (BMAD not installed)');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scanDir(dir) {
|
|
49
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
const results = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const full = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
results.push(...scanDir(full));
|
|
55
|
+
} else if ((entry.name.endsWith('.md') || entry.name.endsWith('.yaml')) && entry.name !== 'config.yaml') {
|
|
56
|
+
// Exclude config.yaml — it stores resolved path values, not hardcoded references
|
|
57
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
58
|
+
// Look for literal _bmad-output/ that isn't in a comment or template variable context
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const line = lines[i];
|
|
62
|
+
if (line.includes('_bmad-output/') && !line.includes('{') && !line.includes('#')) {
|
|
63
|
+
results.push({ file: full, line: i + 1, content: line.trim() });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hardcoded = scanDir(bmadDir);
|
|
72
|
+
assert.strictEqual(hardcoded.length, 0,
|
|
73
|
+
`Found ${hardcoded.length} hardcoded _bmad-output/ references:\n` +
|
|
74
|
+
hardcoded.map(h => ` ${h.file}:${h.line}: ${h.content}`).join('\n'));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── Task 2: Config-driven path resolution ──────────────────────────────────
|
|
78
|
+
console.log('\nTask 2 — Config writes planning_artifacts and implementation_artifacts');
|
|
79
|
+
|
|
80
|
+
test('single-repo: writes default artifact paths', () => {
|
|
81
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
82
|
+
const origCwd = process.cwd();
|
|
83
|
+
try {
|
|
84
|
+
process.chdir(tmpDir);
|
|
85
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
86
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
|
|
88
|
+
writeRepoLayoutConfig({
|
|
89
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
90
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
91
|
+
});
|
|
92
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
93
|
+
assert.ok(content.includes('planning_artifacts: "_bmad-output/planning-artifacts"'),
|
|
94
|
+
`Expected default planning_artifacts. Got:\n${content}`);
|
|
95
|
+
assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
|
|
96
|
+
`Expected default implementation_artifacts. Got:\n${content}`);
|
|
97
|
+
} finally {
|
|
98
|
+
process.chdir(origCwd);
|
|
99
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('multi-repo: writes external artifact paths', () => {
|
|
104
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
105
|
+
const origCwd = process.cwd();
|
|
106
|
+
try {
|
|
107
|
+
process.chdir(tmpDir);
|
|
108
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
109
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
|
|
111
|
+
writeRepoLayoutConfig({
|
|
112
|
+
knowledgebase: { mode: 'local', path: 'd:\\Code\\kb-repo' },
|
|
113
|
+
sprintManagement: { mode: 'local', path: 'd:\\Code\\sprint-repo' }
|
|
114
|
+
});
|
|
115
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
116
|
+
assert.ok(content.includes('planning_artifacts: "d:/Code/kb-repo/_bmad-output/planning-artifacts"'),
|
|
117
|
+
`Expected external planning_artifacts. Got:\n${content}`);
|
|
118
|
+
assert.ok(content.includes('implementation_artifacts: "d:/Code/sprint-repo/_bmad-output/implementation-artifacts"'),
|
|
119
|
+
`Expected external implementation_artifacts. Got:\n${content}`);
|
|
120
|
+
} finally {
|
|
121
|
+
process.chdir(origCwd);
|
|
122
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('mixed mode: kb external, sprint same', () => {
|
|
127
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
128
|
+
const origCwd = process.cwd();
|
|
129
|
+
try {
|
|
130
|
+
process.chdir(tmpDir);
|
|
131
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
132
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
133
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
|
|
134
|
+
writeRepoLayoutConfig({
|
|
135
|
+
knowledgebase: { mode: 'local', path: '/ext/kb' },
|
|
136
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
137
|
+
});
|
|
138
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
139
|
+
assert.ok(content.includes('planning_artifacts: "/ext/kb/_bmad-output/planning-artifacts"'),
|
|
140
|
+
`Got:\n${content}`);
|
|
141
|
+
assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
|
|
142
|
+
`Got:\n${content}`);
|
|
143
|
+
} finally {
|
|
144
|
+
process.chdir(origCwd);
|
|
145
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── Task 3: Single-repo backward compatibility ─────────────────────────────
|
|
150
|
+
console.log('\nTask 3 — Single-repo backward compatibility');
|
|
151
|
+
|
|
152
|
+
test('single-repo config matches pre-Epic-16 defaults', () => {
|
|
153
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
154
|
+
const origCwd = process.cwd();
|
|
155
|
+
try {
|
|
156
|
+
process.chdir(tmpDir);
|
|
157
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
158
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
159
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '{}');
|
|
160
|
+
writeRepoLayoutConfig({
|
|
161
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
162
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
163
|
+
});
|
|
164
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
165
|
+
// These are the default relative paths that workflows used before Epic 16
|
|
166
|
+
assert.ok(content.includes('planning_artifacts: "_bmad-output/planning-artifacts"'));
|
|
167
|
+
assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'));
|
|
168
|
+
assert.ok(content.includes('knowledgebase_path: "."'));
|
|
169
|
+
assert.ok(content.includes('sprint_management_path: "."'));
|
|
170
|
+
} finally {
|
|
171
|
+
process.chdir(origCwd);
|
|
172
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── Task 4: Source-of-truth precedence ─────────────────────────────────────
|
|
177
|
+
console.log('\nTask 4 — Source-of-truth precedence documented');
|
|
178
|
+
|
|
179
|
+
test('validation report exists', () => {
|
|
180
|
+
const reportPath = path.join(__dirname, '..', '_bmad-output', 'implementation-artifacts', '16-4-validation-report.md');
|
|
181
|
+
assert.ok(fs.existsSync(reportPath), 'Validation report should exist');
|
|
182
|
+
const content = fs.readFileSync(reportPath, 'utf-8');
|
|
183
|
+
assert.ok(content.includes('Source-of-Truth Precedence'), 'Should document precedence');
|
|
184
|
+
assert.ok(content.includes('PRIMARY'), 'Should identify primary source');
|
|
185
|
+
assert.ok(content.includes('config.yaml'), 'Should mention config.yaml as primary');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('path accessibility: fs.accessSync pattern available', () => {
|
|
189
|
+
// Verify fs.accessSync works for testing path accessibility
|
|
190
|
+
assert.ok(typeof fs.accessSync === 'function', 'fs.accessSync should be available');
|
|
191
|
+
// Test with known accessible path
|
|
192
|
+
assert.doesNotThrow(() => fs.accessSync(__dirname, fs.constants.R_OK));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─── Summary ────────────────────────────────────────────────────────────────
|
|
196
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
197
|
+
if (errors.length > 0) {
|
|
198
|
+
console.log('\nFailed tests:');
|
|
199
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
200
|
+
}
|
|
201
|
+
if (failed > 0) process.exit(1);
|
|
@@ -47,9 +47,9 @@ async function test(name, fn) {
|
|
|
47
47
|
// Template path derived directly — not imported from module (avoids leaking internals)
|
|
48
48
|
const _TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'templates', 'project-context.template.md');
|
|
49
49
|
|
|
50
|
-
let generateProjectContext, updateProjectContextManifestPaths;
|
|
50
|
+
let generateProjectContext, updateProjectContextManifestPaths, generateRepoLayoutSection, updateProjectContextRepoLayout;
|
|
51
51
|
try {
|
|
52
|
-
({ generateProjectContext, _updateProjectContextManifestPaths: updateProjectContextManifestPaths } = require('../lib/installer'));
|
|
52
|
+
({ generateProjectContext, _updateProjectContextManifestPaths: updateProjectContextManifestPaths, generateRepoLayoutSection, updateProjectContextRepoLayout } = require('../lib/installer'));
|
|
53
53
|
} catch (e) {
|
|
54
54
|
console.error('\n FATAL: Cannot load installer module');
|
|
55
55
|
console.error(' ', e.message);
|
|
@@ -326,6 +326,152 @@ async function runAll() {
|
|
|
326
326
|
fs.rmSync(tmpDir, { recursive: true });
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
// ─── Story 16.3: generateRepoLayoutSection tests ─────────────────────────
|
|
330
|
+
|
|
331
|
+
// 5.1 null layout → empty string
|
|
332
|
+
await test('5.1 generateRepoLayoutSection: null layout returns empty string', async () => {
|
|
333
|
+
assert.strictEqual(generateRepoLayoutSection(null), '');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// 5.2 single-repo (both same) → empty string
|
|
337
|
+
await test('5.2 generateRepoLayoutSection: both same mode returns empty string', async () => {
|
|
338
|
+
const result = generateRepoLayoutSection({
|
|
339
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
340
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
341
|
+
});
|
|
342
|
+
assert.strictEqual(result, '');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// 5.3 multi-repo returns section with markers
|
|
346
|
+
await test('5.3 generateRepoLayoutSection: multi-repo returns section with markers', async () => {
|
|
347
|
+
const result = generateRepoLayoutSection({
|
|
348
|
+
knowledgebase: { mode: 'local', path: '/path/to/kb' },
|
|
349
|
+
sprintManagement: { mode: 'local', path: '/path/to/sprint' }
|
|
350
|
+
});
|
|
351
|
+
assert.ok(result.includes('<!-- ma-agents:repo-layout-start -->'), 'should have start marker');
|
|
352
|
+
assert.ok(result.includes('<!-- ma-agents:repo-layout-end -->'), 'should have end marker');
|
|
353
|
+
assert.ok(result.includes('### Repository Layout'), 'should have section header');
|
|
354
|
+
assert.ok(result.includes('/path/to/kb'), 'should include kb path');
|
|
355
|
+
assert.ok(result.includes('/path/to/sprint'), 'should include sprint path');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// 5.4 mixed config: one external + one same
|
|
359
|
+
await test('5.4 generateRepoLayoutSection: mixed config shows "current repository" for same', async () => {
|
|
360
|
+
const result = generateRepoLayoutSection({
|
|
361
|
+
knowledgebase: { mode: 'local', path: '/ext/kb' },
|
|
362
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
363
|
+
});
|
|
364
|
+
assert.ok(result.includes('/ext/kb'), 'should include external kb path');
|
|
365
|
+
assert.ok(result.includes('current repository (default)'), 'should show default for same mode');
|
|
366
|
+
assert.ok(result.includes('planning artifacts'), 'should have kb usage instruction');
|
|
367
|
+
assert.ok(!result.includes('sprint/story artifacts'), 'should NOT have sprint instruction when sprint is same');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// 5.5 path normalization
|
|
371
|
+
await test('5.5 generateRepoLayoutSection: normalizes backslashes', async () => {
|
|
372
|
+
const result = generateRepoLayoutSection({
|
|
373
|
+
knowledgebase: { mode: 'local', path: 'C:\\Users\\dev\\kb' },
|
|
374
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
375
|
+
});
|
|
376
|
+
assert.ok(result.includes('C:/Users/dev/kb'), 'should normalize backslashes');
|
|
377
|
+
assert.ok(!result.includes('\\'), 'should not contain backslashes');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// 5.6 backward compat: generateProjectContext without layout still works
|
|
381
|
+
await test('5.6 generateProjectContext: no layout param → no crash, no placeholder in output', async () => {
|
|
382
|
+
const content = await generateProjectContext('/some/root', [agent('.claude/skills')]);
|
|
383
|
+
assert.ok(!content.includes('{{REPO_LAYOUT_SECTION}}'), 'placeholder should be removed');
|
|
384
|
+
assert.ok(!content.includes('repo-layout-start'), 'should not have layout markers without layout');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// 5.7 generateProjectContext with multi-repo layout inserts section
|
|
388
|
+
await test('5.7 generateProjectContext: multi-repo layout inserts Repository Layout section', async () => {
|
|
389
|
+
const layout = {
|
|
390
|
+
knowledgebase: { mode: 'local', path: '/ext/kb' },
|
|
391
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
392
|
+
};
|
|
393
|
+
const content = await generateProjectContext('/some/root', [agent('.claude/skills')], layout);
|
|
394
|
+
assert.ok(content.includes('### Repository Layout'), 'should include Repository Layout section');
|
|
395
|
+
assert.ok(content.includes('/ext/kb'), 'should include kb path');
|
|
396
|
+
assert.ok(content.includes('<!-- ma-agents:repo-layout-start -->'), 'should have start marker');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// 5.8 no double blank lines when section is empty
|
|
400
|
+
await test('5.8 generateProjectContext: single-repo layout → no double blank lines', async () => {
|
|
401
|
+
const layout = {
|
|
402
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
403
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
404
|
+
};
|
|
405
|
+
const content = await generateProjectContext('/some/root', [agent('.claude/skills')], layout);
|
|
406
|
+
assert.ok(!content.includes('\n\n\n'), 'should not have triple newlines (double blank lines)');
|
|
407
|
+
assert.ok(!content.includes('repo-layout'), 'should not have any layout markers');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ─── updateProjectContextRepoLayout tests ──────────────────────────────────
|
|
411
|
+
|
|
412
|
+
// 5.9 markers present → update content
|
|
413
|
+
await test('5.9 updateProjectContextRepoLayout: existing markers → updates content', async () => {
|
|
414
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
415
|
+
const outputPath = path.join(tmpDir, 'project-context.md');
|
|
416
|
+
const initial = [
|
|
417
|
+
'# Project Context',
|
|
418
|
+
'<!-- ma-agents:repo-layout-start -->',
|
|
419
|
+
'### Repository Layout',
|
|
420
|
+
'- old content',
|
|
421
|
+
'<!-- ma-agents:repo-layout-end -->',
|
|
422
|
+
'## Technology Stack'
|
|
423
|
+
].join('\n');
|
|
424
|
+
fs.writeFileSync(outputPath, initial, 'utf8');
|
|
425
|
+
|
|
426
|
+
const result = await updateProjectContextRepoLayout(outputPath, {
|
|
427
|
+
knowledgebase: { mode: 'local', path: '/new/kb' },
|
|
428
|
+
sprintManagement: { mode: 'local', path: '/new/sprint' }
|
|
429
|
+
});
|
|
430
|
+
assert.strictEqual(result, true, 'should return true');
|
|
431
|
+
const content = fs.readFileSync(outputPath, 'utf8');
|
|
432
|
+
assert.ok(content.includes('/new/kb'), 'should have new kb path');
|
|
433
|
+
assert.ok(!content.includes('old content'), 'old content should be replaced');
|
|
434
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// 5.10 no markers + multi-repo → inserts before Technology Stack
|
|
438
|
+
await test('5.10 updateProjectContextRepoLayout: no markers → inserts before Technology Stack', async () => {
|
|
439
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
440
|
+
const outputPath = path.join(tmpDir, 'project-context.md');
|
|
441
|
+
const initial = '# Project Context\nSome content\n\n## Technology Stack\nmore content';
|
|
442
|
+
fs.writeFileSync(outputPath, initial, 'utf8');
|
|
443
|
+
|
|
444
|
+
const result = await updateProjectContextRepoLayout(outputPath, {
|
|
445
|
+
knowledgebase: { mode: 'local', path: '/ext/kb' },
|
|
446
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
447
|
+
});
|
|
448
|
+
assert.strictEqual(result, true, 'should return true');
|
|
449
|
+
const content = fs.readFileSync(outputPath, 'utf8');
|
|
450
|
+
assert.ok(content.includes('### Repository Layout'), 'should insert section');
|
|
451
|
+
assert.ok(content.indexOf('Repository Layout') < content.indexOf('## Technology Stack'), 'section before Tech Stack');
|
|
452
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// 5.11 single-repo + no markers → returns false (nothing to do)
|
|
456
|
+
await test('5.11 updateProjectContextRepoLayout: single-repo + no markers → returns false', async () => {
|
|
457
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
458
|
+
const outputPath = path.join(tmpDir, 'project-context.md');
|
|
459
|
+
fs.writeFileSync(outputPath, '# Project Context\n## Technology Stack\n', 'utf8');
|
|
460
|
+
|
|
461
|
+
const result = await updateProjectContextRepoLayout(outputPath, {
|
|
462
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
463
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
464
|
+
});
|
|
465
|
+
assert.strictEqual(result, false, 'should return false for single-repo with no markers');
|
|
466
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// 5.12 missing layout fields → no crash
|
|
470
|
+
await test('5.12 generateRepoLayoutSection: missing layout fields returns empty string', async () => {
|
|
471
|
+
assert.strictEqual(generateRepoLayoutSection({}), '');
|
|
472
|
+
assert.strictEqual(generateRepoLayoutSection({ knowledgebase: null }), '');
|
|
473
|
+
});
|
|
474
|
+
|
|
329
475
|
// ─── Report ────────────────────────────────────────────────────────────────
|
|
330
476
|
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
|
331
477
|
if (failed > 0) {
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.7: Portable Path Storage
|
|
4
|
+
* Tests toPortablePath(), resolveStoredPath(), and round-trip write/read with relative paths
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
let failed = 0;
|
|
15
|
+
const errors = [];
|
|
16
|
+
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
console.log(` \u2713 ${name}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(` \u2717 ${name}: ${err.message}`);
|
|
24
|
+
failed++;
|
|
25
|
+
errors.push({ name, error: err.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Load module ─────────────────────────────────────────────────────────────
|
|
30
|
+
let toPortablePath, resolveStoredPath, normalizePath, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout;
|
|
31
|
+
try {
|
|
32
|
+
({ toPortablePath, resolveStoredPath, normalizePath, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout } = require('../bin/cli.js'));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error('\n FATAL: Cannot load exports from cli.js');
|
|
35
|
+
console.error(' ', e.message);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper
|
|
40
|
+
function withTmpDir() {
|
|
41
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
42
|
+
const origCwd = process.cwd();
|
|
43
|
+
process.chdir(tmpDir);
|
|
44
|
+
return { tmpDir, cleanup() { process.chdir(origCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── toPortablePath ────────────────────────────────────────────────────────
|
|
48
|
+
console.log('\ntoPortablePath');
|
|
49
|
+
|
|
50
|
+
test('sibling path stored as relative: d:/Code/kb from d:/Code/agents becomes ../kb', () => {
|
|
51
|
+
const result = toPortablePath('d:/Code/kb', 'd:/Code/agents');
|
|
52
|
+
assert.strictEqual(result.portable, '../kb');
|
|
53
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('nested relative works: ../../other/kb', () => {
|
|
57
|
+
const result = toPortablePath('/home/user/other/kb', '/home/user/projects/myapp');
|
|
58
|
+
assert.strictEqual(result.portable, '../../other/kb');
|
|
59
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('dot path stays as dot', () => {
|
|
63
|
+
const result = toPortablePath('.', '/any/root');
|
|
64
|
+
assert.strictEqual(result.portable, '.');
|
|
65
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('null/empty returns dot', () => {
|
|
69
|
+
const result = toPortablePath('', '/any/root');
|
|
70
|
+
assert.strictEqual(result.portable, '.');
|
|
71
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('deeply nested (>3 levels up) falls back to absolute', () => {
|
|
75
|
+
const result = toPortablePath('/a/b', '/a/b/c/d/e/f');
|
|
76
|
+
assert.strictEqual(result.isAbsolute, true, 'should be marked absolute');
|
|
77
|
+
// Path should be the normalized absolute path
|
|
78
|
+
assert.ok(!result.portable.startsWith('.'), 'should not be relative');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('same directory returns dot', () => {
|
|
82
|
+
const result = toPortablePath('/home/user/project', '/home/user/project');
|
|
83
|
+
assert.strictEqual(result.portable, '.');
|
|
84
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('subdirectory becomes simple relative', () => {
|
|
88
|
+
const result = toPortablePath('/home/user/project/sub', '/home/user/project');
|
|
89
|
+
assert.strictEqual(result.portable, 'sub');
|
|
90
|
+
assert.strictEqual(result.isAbsolute, false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('backslashes in result are normalized to forward slashes', () => {
|
|
94
|
+
// On Windows, path.relative may return backslashes
|
|
95
|
+
const result = toPortablePath('d:\\Code\\kb', 'd:\\Code\\agents');
|
|
96
|
+
assert.ok(!result.portable.includes('\\'), 'should not contain backslashes');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── resolveStoredPath ─────────────────────────────────────────────────────
|
|
100
|
+
console.log('\nresolveStoredPath');
|
|
101
|
+
|
|
102
|
+
test('dot path stays as dot', () => {
|
|
103
|
+
assert.strictEqual(resolveStoredPath('.', '/any/root'), '.');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('null returns dot (safe default)', () => {
|
|
107
|
+
assert.strictEqual(resolveStoredPath(null, '/any/root'), '.');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('absolute path passes through unchanged', () => {
|
|
111
|
+
assert.strictEqual(resolveStoredPath('/absolute/path', '/any/root'), '/absolute/path');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('relative path resolved from project root', () => {
|
|
115
|
+
const result = resolveStoredPath('../kb', '/home/user/project');
|
|
116
|
+
assert.strictEqual(result, normalizePath(path.resolve('/home/user/project', '../kb')));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('Windows drive letter path passes through', () => {
|
|
120
|
+
assert.strictEqual(resolveStoredPath('d:/Code/kb', '/any/root'), 'd:/Code/kb');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── writeProjectLayoutYaml with portable paths ───────────────────────────
|
|
124
|
+
console.log('\nwriteProjectLayoutYaml (portable paths)');
|
|
125
|
+
|
|
126
|
+
test('writes relative paths for sibling directories', () => {
|
|
127
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
128
|
+
try {
|
|
129
|
+
const siblingPath = path.join(path.dirname(tmpDir), 'kb-repo');
|
|
130
|
+
writeProjectLayoutYaml({
|
|
131
|
+
knowledgebase: { mode: 'local', path: siblingPath },
|
|
132
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
133
|
+
});
|
|
134
|
+
const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
135
|
+
assert.ok(content.includes('../kb-repo'), `should contain relative path, got: ${content}`);
|
|
136
|
+
assert.ok(!content.includes('PORTABILITY'), 'should not have portability warning');
|
|
137
|
+
} finally {
|
|
138
|
+
cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('writes dot path unchanged for same-repo', () => {
|
|
143
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
144
|
+
try {
|
|
145
|
+
writeProjectLayoutYaml({
|
|
146
|
+
knowledgebase: { mode: 'local', path: '.' },
|
|
147
|
+
sprintManagement: { mode: 'local', path: path.join(path.dirname(tmpDir), 'sprint') }
|
|
148
|
+
});
|
|
149
|
+
const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
150
|
+
assert.ok(content.includes('path: "."'), `dot path should be preserved, got: ${content}`);
|
|
151
|
+
assert.ok(content.includes('../sprint'), 'sibling should be relative');
|
|
152
|
+
} finally {
|
|
153
|
+
cleanup();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ─── writeRepoLayoutConfig with portable paths ────────────────────────────
|
|
158
|
+
console.log('\nwriteRepoLayoutConfig (portable paths)');
|
|
159
|
+
|
|
160
|
+
test('writes relative paths to config.yaml', () => {
|
|
161
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
162
|
+
try {
|
|
163
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
164
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# existing config\n{}');
|
|
166
|
+
const siblingKb = path.join(path.dirname(tmpDir), 'kb-repo');
|
|
167
|
+
writeRepoLayoutConfig({
|
|
168
|
+
knowledgebase: { mode: 'local', path: siblingKb },
|
|
169
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
170
|
+
});
|
|
171
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
172
|
+
assert.ok(content.includes('knowledgebase_path: "../kb-repo"'), `should have relative path, got: ${content}`);
|
|
173
|
+
assert.ok(content.includes('sprint_management_path: "."'), `dot path preserved, got: ${content}`);
|
|
174
|
+
} finally {
|
|
175
|
+
cleanup();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('derived artifact paths are relative when base is relative', () => {
|
|
180
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
181
|
+
try {
|
|
182
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
183
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
184
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
|
|
185
|
+
const siblingKb = path.join(path.dirname(tmpDir), 'kb-repo');
|
|
186
|
+
writeRepoLayoutConfig({
|
|
187
|
+
knowledgebase: { mode: 'local', path: siblingKb },
|
|
188
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
189
|
+
});
|
|
190
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
191
|
+
assert.ok(content.includes('planning_artifacts: "../kb-repo/_bmad-output/planning-artifacts"'),
|
|
192
|
+
`planning_artifacts should be relative, got: ${content}`);
|
|
193
|
+
assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
|
|
194
|
+
`impl_artifacts should use default for ".", got: ${content}`);
|
|
195
|
+
} finally {
|
|
196
|
+
cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── Round-trip: write relative → read → resolve to absolute ───────────────
|
|
201
|
+
console.log('\nRound-trip: write relative, read back, resolve to absolute');
|
|
202
|
+
|
|
203
|
+
test('round-trip: sibling path written as relative, read back as resolved absolute', () => {
|
|
204
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
205
|
+
try {
|
|
206
|
+
const siblingPath = normalizePath(path.join(path.dirname(tmpDir), 'kb-repo'));
|
|
207
|
+
writeProjectLayoutYaml({
|
|
208
|
+
knowledgebase: { mode: 'local', path: siblingPath },
|
|
209
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
210
|
+
});
|
|
211
|
+
// Verify file has relative path
|
|
212
|
+
const fileContent = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
213
|
+
assert.ok(fileContent.includes('../kb-repo'), 'file should store relative');
|
|
214
|
+
|
|
215
|
+
// Read back — should resolve to absolute
|
|
216
|
+
const result = readExistingLayout();
|
|
217
|
+
assert.ok(result, 'should read layout');
|
|
218
|
+
const resolvedKb = normalizePath(result.knowledgebase.path);
|
|
219
|
+
assert.strictEqual(resolvedKb, siblingPath, `should resolve back to original: got ${resolvedKb}`);
|
|
220
|
+
} finally {
|
|
221
|
+
cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('round-trip: dot path stays as dot', () => {
|
|
226
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
227
|
+
try {
|
|
228
|
+
writeProjectLayoutYaml({
|
|
229
|
+
knowledgebase: { mode: 'local', path: path.join(path.dirname(tmpDir), 'kb') },
|
|
230
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
231
|
+
});
|
|
232
|
+
const result = readExistingLayout();
|
|
233
|
+
assert.strictEqual(result.sprintManagement.path, '.', 'dot should stay as dot');
|
|
234
|
+
} finally {
|
|
235
|
+
cleanup();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('backward compat: absolute paths in existing files still work', () => {
|
|
240
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
241
|
+
try {
|
|
242
|
+
// Manually write absolute path (old format)
|
|
243
|
+
const dir = path.join(tmpDir, '_bmad-output');
|
|
244
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(path.join(dir, 'project-layout.yaml'), [
|
|
246
|
+
'knowledgebase:',
|
|
247
|
+
' mode: local',
|
|
248
|
+
' path: "/absolute/old/kb"',
|
|
249
|
+
'sprint_management:',
|
|
250
|
+
' mode: same',
|
|
251
|
+
' path: "."',
|
|
252
|
+
].join('\n'));
|
|
253
|
+
|
|
254
|
+
const result = readExistingLayout();
|
|
255
|
+
assert.ok(result, 'should read layout');
|
|
256
|
+
assert.strictEqual(result.knowledgebase.path, '/absolute/old/kb', 'absolute path should pass through');
|
|
257
|
+
} finally {
|
|
258
|
+
cleanup();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ─── Summary ───────────────────────────────────────────────────────────────
|
|
263
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
264
|
+
if (errors.length > 0) {
|
|
265
|
+
console.log('\nFailed tests:');
|
|
266
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
267
|
+
}
|
|
268
|
+
if (failed > 0) process.exit(1);
|