@ktpartners/dgs-platform 3.5.1 → 3.5.3
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/CHANGELOG.md +13 -0
- package/deliver-great-systems/bin/lib/core.cjs +21 -0
- package/deliver-great-systems/bin/lib/core.test.cjs +66 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +39 -11
- package/deliver-great-systems/bin/lib/ideas.test.cjs +32 -3
- package/deliver-great-systems/bin/lib/init.cjs +23 -0
- package/deliver-great-systems/bin/lib/init.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/jobs.cjs +78 -26
- package/deliver-great-systems/bin/lib/jobs.test.cjs +132 -3
- package/deliver-great-systems/bin/lib/overlap.cjs +14 -4
- package/deliver-great-systems/bin/lib/overlap.test.cjs +13 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +19 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +18 -0
- package/deliver-great-systems/bin/lib/phase.cjs +12 -4
- package/deliver-great-systems/bin/lib/phase.test.cjs +69 -0
- package/deliver-great-systems/bin/lib/projects.cjs +13 -3
- package/deliver-great-systems/bin/lib/projects.test.cjs +8 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +14 -0
- package/deliver-great-systems/bin/lib/roadmap.test.cjs +86 -0
- package/deliver-great-systems/bin/lib/search.cjs +62 -15
- package/deliver-great-systems/bin/lib/search.test.cjs +94 -0
- package/deliver-great-systems/bin/lib/verify.cjs +37 -20
- package/deliver-great-systems/bin/lib/verify.test.cjs +58 -0
- package/deliver-great-systems/workflows/audit-milestone.md +1 -1
- package/deliver-great-systems/workflows/cleanup.md +1 -1
- package/deliver-great-systems/workflows/complete-milestone.md +2 -2
- package/deliver-great-systems/workflows/discuss-phase.md +5 -1
- package/deliver-great-systems/workflows/pause-work.md +1 -1
- package/deliver-great-systems/workflows/plan-phase.md +6 -2
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { safeReadFile, getProjectFolders, output, error, getProjectDir } = require('./core.cjs');
|
|
11
|
+
const { safeReadFile, getProjectFolders, output, error, getProjectDir, isValidMilestoneVersion } = require('./core.cjs');
|
|
12
12
|
const { parseProjectsMd, readProjectState } = require('./projects.cjs');
|
|
13
13
|
const { parseReposMd, resolveFileToRepo } = require('./repos.cjs');
|
|
14
14
|
|
|
@@ -32,18 +32,28 @@ function scanProjectPlanFiles(cwd, slug) {
|
|
|
32
32
|
const phasesDir = path.join(getProjectDir(cwd, slug), 'phases');
|
|
33
33
|
const results = [];
|
|
34
34
|
|
|
35
|
+
// Per-milestone versioned layout: descend a single vN.N child dir if present
|
|
36
|
+
// (phases/<version>/NN-slug). Detect via core.isValidMilestoneVersion — no
|
|
37
|
+
// parallel version regex. No version child → stay flat (back-compat).
|
|
38
|
+
let scanRoot = phasesDir;
|
|
35
39
|
try {
|
|
36
|
-
const
|
|
40
|
+
const kids = fs.readdirSync(scanRoot, { withFileTypes: true });
|
|
41
|
+
const vd = kids.find(e => e.isDirectory() && isValidMilestoneVersion(e.name));
|
|
42
|
+
if (vd) scanRoot = path.join(scanRoot, vd.name);
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const phaseDirs = fs.readdirSync(scanRoot, { withFileTypes: true })
|
|
37
47
|
.filter(e => e.isDirectory())
|
|
38
48
|
.map(e => e.name);
|
|
39
49
|
|
|
40
50
|
// Exclude archived phase directories
|
|
41
51
|
const activePhaseDirs = excludeArchivedPhases(
|
|
42
|
-
phaseDirs.map(d => path.join(
|
|
52
|
+
phaseDirs.map(d => path.join(scanRoot, d))
|
|
43
53
|
).map(d => path.basename(d));
|
|
44
54
|
|
|
45
55
|
for (const phaseDir of activePhaseDirs) {
|
|
46
|
-
const fullPhaseDir = path.join(
|
|
56
|
+
const fullPhaseDir = path.join(scanRoot, phaseDir);
|
|
47
57
|
let files;
|
|
48
58
|
try {
|
|
49
59
|
files = fs.readdirSync(fullPhaseDir)
|
|
@@ -85,6 +85,19 @@ describe('scanProjectPlanFiles', () => {
|
|
|
85
85
|
assert.deepStrictEqual(result[0].files, ['web-app/src/app.js', 'web-app/src/index.js']);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it('descends versioned phases/<version>/ layout (260628-p59)', () => {
|
|
89
|
+
createProject(tmpDir, 'project-a');
|
|
90
|
+
// Plan lives one level deeper, under phases/v25.0/01-setup/
|
|
91
|
+
createPlanFile(tmpDir, 'project-a', 'v25.0/01-setup', '01-01-PLAN.md', [
|
|
92
|
+
{ name: 'Task 1', files: ['web-app/src/index.js'], repos: ['web-app'] },
|
|
93
|
+
]);
|
|
94
|
+
const result = scanProjectPlanFiles(tmpDir, 'project-a');
|
|
95
|
+
assert.strictEqual(result.length, 1, 'plan under phases/v25.0/ must be found');
|
|
96
|
+
assert.deepStrictEqual(result[0].repos, ['web-app']);
|
|
97
|
+
assert.deepStrictEqual(result[0].files, ['web-app/src/index.js']);
|
|
98
|
+
assert.strictEqual(result[0].plan, '01-setup/01-01-PLAN.md');
|
|
99
|
+
});
|
|
100
|
+
|
|
88
101
|
it('handles plans with multiple tasks', () => {
|
|
89
102
|
createProject(tmpDir, 'project-a');
|
|
90
103
|
createPlanFile(tmpDir, 'project-a', '01-setup', '01-01-PLAN.md', [
|
|
@@ -25,6 +25,7 @@ const fs = require('fs');
|
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
27
27
|
const { getLocalConfigPath } = require('./config.cjs');
|
|
28
|
+
const { isValidMilestoneVersion } = require('./core.cjs');
|
|
28
29
|
|
|
29
30
|
// ─── Canonical frontmatter constants ─────────────────────────────────────────
|
|
30
31
|
// Every findings[] entry emitted by this module carries these two constant
|
|
@@ -886,6 +887,24 @@ function _resolveReportPath(cwd, opts) {
|
|
|
886
887
|
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
887
888
|
for (const phEnt of phaseEntries) {
|
|
888
889
|
if (!phEnt.isDirectory()) continue;
|
|
890
|
+
// Per-milestone versioned layout: the immediate child is a vN.N dir, so
|
|
891
|
+
// the active phase lives one level deeper (phases/<version>/NN-slug).
|
|
892
|
+
// Detect via core.isValidMilestoneVersion (no parallel version regex)
|
|
893
|
+
// and match the active phase among the version dir's children.
|
|
894
|
+
if (isValidMilestoneVersion(phEnt.name)) {
|
|
895
|
+
const versionDir = path.join(phasesDir, phEnt.name);
|
|
896
|
+
let candidates;
|
|
897
|
+
try { candidates = fs.readdirSync(versionDir, { withFileTypes: true }); } catch { continue; }
|
|
898
|
+
for (const cand of candidates) {
|
|
899
|
+
if (!cand.isDirectory()) continue;
|
|
900
|
+
if (cand.name === activeContext) {
|
|
901
|
+
const cm = cand.name.match(/^(\d+)/);
|
|
902
|
+
const cprefix = cm ? cm[1] : cand.name;
|
|
903
|
+
return path.join(versionDir, cand.name, cprefix + '-PACKAGE-SCAN.md');
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
889
908
|
if (phEnt.name === activeContext) {
|
|
890
909
|
const m = phEnt.name.match(/^(\d+)/);
|
|
891
910
|
const prefix = m ? m[1] : phEnt.name;
|
|
@@ -494,6 +494,24 @@ describe('_resolveReportPath — tier 1 (active phase)', () => {
|
|
|
494
494
|
}
|
|
495
495
|
});
|
|
496
496
|
|
|
497
|
+
test('active_context resolves under versioned phases/<version>/ layout (260628-p59)', () => {
|
|
498
|
+
tmpDir = setupPlanningRoot({
|
|
499
|
+
local: {
|
|
500
|
+
current_project: 'gsd',
|
|
501
|
+
execution: { active_context: '03-foo' },
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
// Phase dir lives one level deeper, under phases/v25.0/03-foo
|
|
505
|
+
const versionedPhaseDir = path.join(tmpDir, 'projects', 'gsd', 'phases', 'v25.0', '03-foo');
|
|
506
|
+
fs.mkdirSync(versionedPhaseDir, { recursive: true });
|
|
507
|
+
try {
|
|
508
|
+
const resolved = _resolveReportPath(tmpDir, {});
|
|
509
|
+
assert.equal(resolved, path.join(versionedPhaseDir, '03-PACKAGE-SCAN.md'));
|
|
510
|
+
} finally {
|
|
511
|
+
cleanup(tmpDir);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
497
515
|
test('active_context points at a phase that does NOT exist → falls through', () => {
|
|
498
516
|
tmpDir = setupPlanningRoot({
|
|
499
517
|
local: {
|
|
@@ -381,14 +381,22 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|
|
381
381
|
updatedContent = content + phaseEntry;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
// Update milestone summary range
|
|
385
|
-
|
|
384
|
+
// Update milestone summary range. Handles both the legacy non-namespaced form
|
|
385
|
+
// ("Phases 124-128" -> "Phases 124-129") and the per-milestone-namespaced form
|
|
386
|
+
// ("Phases v25.1/1-9" -> "Phases v25.1/1-10"). In the live namespaced layout the
|
|
387
|
+
// START endpoint carries the `vX.Y/` prefix while the END endpoint is BARE
|
|
388
|
+
// (e.g. start "v25.1/1", end "9"), so the prefix is derived from whichever
|
|
389
|
+
// endpoint carries it; the START (rangeMatch[1]) is preserved verbatim and the
|
|
390
|
+
// rebuilt END reuses the END endpoint's own prefix (empty in the live form).
|
|
391
|
+
const rangePattern = /Phases\s+((?:v\d+\.\d+\/)?\d+)\s*-\s*((?:v\d+\.\d+\/)?\d+)\s*\(in progress\)/i;
|
|
386
392
|
const rangeMatch = updatedContent.match(rangePattern);
|
|
387
393
|
if (rangeMatch) {
|
|
388
|
-
const
|
|
394
|
+
const endRaw = rangeMatch[2]; // e.g. "9" (bare) or "v25.1/9"
|
|
395
|
+
const prefix = (endRaw.match(/^v\d+\.\d+\//) || [''])[0];
|
|
396
|
+
const rangeEnd = parseInt(endRaw.replace(/^v\d+\.\d+\//, ''), 10);
|
|
389
397
|
if (newPhaseNum > rangeEnd) {
|
|
390
398
|
updatedContent = updatedContent.replace(rangePattern,
|
|
391
|
-
`Phases ${rangeMatch[1]}-${newPhaseNum} (in progress)`);
|
|
399
|
+
`Phases ${rangeMatch[1]}-${prefix}${newPhaseNum} (in progress)`);
|
|
392
400
|
}
|
|
393
401
|
}
|
|
394
402
|
|
|
@@ -585,3 +585,72 @@ describe('cmdPhaseInitVersionedDir (NUM-02 versioned write path)', () => {
|
|
|
585
585
|
}
|
|
586
586
|
});
|
|
587
587
|
});
|
|
588
|
+
|
|
589
|
+
describe('cmdPhaseAdd milestone-summary range bump', () => {
|
|
590
|
+
let phase;
|
|
591
|
+
|
|
592
|
+
beforeEach(() => {
|
|
593
|
+
resetPaths();
|
|
594
|
+
delete require.cache[require.resolve('./phase.cjs')];
|
|
595
|
+
phase = require('./phase.cjs');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
afterEach(() => {
|
|
599
|
+
resetPaths();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('bumps a namespaced "Phases v25.1/1-9 (in progress)" range to v25.1/1-10, preserving the prefix', () => {
|
|
603
|
+
// maxPhase resolves to 9 from the `### Phase 9:` heading → newPhaseNum=10.
|
|
604
|
+
const roadmap = `# Roadmap
|
|
605
|
+
|
|
606
|
+
- 🚧 **v25.1 add-prs** -- Phases v25.1/1-9 (in progress)
|
|
607
|
+
|
|
608
|
+
## Phases
|
|
609
|
+
|
|
610
|
+
### Phase 9: Ninth
|
|
611
|
+
**Goal:** done
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
`;
|
|
615
|
+
const fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': roadmap });
|
|
616
|
+
try {
|
|
617
|
+
captureStdout(() => phase.cmdPhaseAdd(fixture.cwd, 'Tenth phase', false));
|
|
618
|
+
const updated = fs.readFileSync(path.join(fixture.cwd, 'ROADMAP.md'), 'utf-8');
|
|
619
|
+
assert.ok(
|
|
620
|
+
updated.includes('Phases v25.1/1-10 (in progress)'),
|
|
621
|
+
`expected namespaced bump to v25.1/1-10, got:\n${updated}`
|
|
622
|
+
);
|
|
623
|
+
// Prefix preserved on the START endpoint; END stays bare (no v25.1/ on the 10).
|
|
624
|
+
assert.ok(!/Phases v25\.1\/1-v25\.1\/10/.test(updated), 'END endpoint must stay bare');
|
|
625
|
+
assert.ok(!updated.includes('Phases v25.1/1-9 (in progress)'), 'old range must be gone');
|
|
626
|
+
} finally {
|
|
627
|
+
fixture.cleanup();
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('bumps a legacy non-namespaced "Phases 124-129 (in progress)" range to 124-130 (regression)', () => {
|
|
632
|
+
const roadmap = `# Roadmap
|
|
633
|
+
|
|
634
|
+
- 🚧 **v19.0 Git Worktrees** -- Phases 124-129 (in progress)
|
|
635
|
+
|
|
636
|
+
## Phases
|
|
637
|
+
|
|
638
|
+
### Phase 129: One-twenty-nine
|
|
639
|
+
**Goal:** done
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
`;
|
|
643
|
+
const fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': roadmap });
|
|
644
|
+
try {
|
|
645
|
+
captureStdout(() => phase.cmdPhaseAdd(fixture.cwd, 'One thirty', false));
|
|
646
|
+
const updated = fs.readFileSync(path.join(fixture.cwd, 'ROADMAP.md'), 'utf-8');
|
|
647
|
+
assert.ok(
|
|
648
|
+
updated.includes('Phases 124-130 (in progress)'),
|
|
649
|
+
`expected legacy bump to 124-130, got:\n${updated}`
|
|
650
|
+
);
|
|
651
|
+
assert.ok(!updated.includes('Phases 124-129 (in progress)'), 'old range must be gone');
|
|
652
|
+
} finally {
|
|
653
|
+
fixture.cleanup();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { safeReadFile, getProjectFolders, generateSlugInternal, isV2Install, output, error, loadConfig, getProjectDir } = require('./core.cjs');
|
|
11
|
+
const { safeReadFile, getProjectFolders, generateSlugInternal, isV2Install, output, error, loadConfig, getProjectDir, isValidMilestoneVersion } = require('./core.cjs');
|
|
12
12
|
const { writeConfigField } = require('./config.cjs');
|
|
13
13
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
14
14
|
|
|
@@ -126,13 +126,23 @@ function scanProjectReposTags(cwd, slug) {
|
|
|
126
126
|
const phasesDir = path.join(getProjectDir(cwd, slug), 'phases');
|
|
127
127
|
const allRepos = new Set();
|
|
128
128
|
|
|
129
|
+
// Per-milestone versioned layout: descend a single vN.N child dir if present
|
|
130
|
+
// (phases/<version>/NN-slug). Detect via core.isValidMilestoneVersion — no
|
|
131
|
+
// parallel version regex. No version child → stay flat (back-compat).
|
|
132
|
+
let scanRoot = phasesDir;
|
|
129
133
|
try {
|
|
130
|
-
const
|
|
134
|
+
const kids = fs.readdirSync(scanRoot, { withFileTypes: true });
|
|
135
|
+
const vd = kids.find(e => e.isDirectory() && isValidMilestoneVersion(e.name));
|
|
136
|
+
if (vd) scanRoot = path.join(scanRoot, vd.name);
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const phaseDirs = fs.readdirSync(scanRoot, { withFileTypes: true })
|
|
131
141
|
.filter(e => e.isDirectory())
|
|
132
142
|
.map(e => e.name);
|
|
133
143
|
|
|
134
144
|
for (const phaseDir of phaseDirs) {
|
|
135
|
-
const fullPhaseDir = path.join(
|
|
145
|
+
const fullPhaseDir = path.join(scanRoot, phaseDir);
|
|
136
146
|
let planFiles;
|
|
137
147
|
try {
|
|
138
148
|
planFiles = fs.readdirSync(fullPhaseDir)
|
|
@@ -320,6 +320,14 @@ describe('scanProjectReposTags', () => {
|
|
|
320
320
|
const repos = scanProjectReposTags(tmpDir, 'proj');
|
|
321
321
|
assert.deepStrictEqual(repos, []);
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
it('descends versioned phases/<version>/ layout (260628-p59)', () => {
|
|
325
|
+
createProjectManually(tmpDir, 'proj', '# State\n');
|
|
326
|
+
// Plan lives one level deeper, under phases/v25.0/01-setup/
|
|
327
|
+
createPlanFile(tmpDir, 'proj', 'v25.0/01-setup', '01-01-PLAN.md', '<repos>server</repos>\n');
|
|
328
|
+
const repos = scanProjectReposTags(tmpDir, 'proj');
|
|
329
|
+
assert.deepStrictEqual(repos, ['server']);
|
|
330
|
+
});
|
|
323
331
|
});
|
|
324
332
|
|
|
325
333
|
// ─── regenerateProjectsMd ───────────────────────────────────────────────────
|
|
@@ -188,6 +188,20 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
188
188
|
version: 'v' + mMatch[2],
|
|
189
189
|
});
|
|
190
190
|
}
|
|
191
|
+
// Bold-line single-milestone fallback: a `**Milestone:** vX.Y` roadmap has no
|
|
192
|
+
// `## vX.Y` heading, so the scan above yields []. Fire ONLY when the heading
|
|
193
|
+
// scan produced zero milestones — the `milestones.length === 0` guard is the
|
|
194
|
+
// over-match gate, so any roadmap that already has `##` version headings (incl.
|
|
195
|
+
// the `## Active Milestone:` ad-hoc heading, already matched above) ignores it.
|
|
196
|
+
if (milestones.length === 0) {
|
|
197
|
+
const bold = content.match(/^\*\*Milestone:\*\*\s*(v\d+\.\d+)([^\n]*)/im);
|
|
198
|
+
if (bold) {
|
|
199
|
+
milestones.push({
|
|
200
|
+
heading: ('Milestone: ' + bold[1] + bold[2]).trim(),
|
|
201
|
+
version: bold[1],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
191
205
|
|
|
192
206
|
// Find current and next phase
|
|
193
207
|
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for roadmap.cjs::cmdRoadmapAnalyze milestone enumeration.
|
|
3
|
+
*
|
|
4
|
+
* Focus: the bold-line single-milestone fallback (`**Milestone:** vX.Y`) that
|
|
5
|
+
* fires ONLY when the `## vX.Y` heading scan yields zero milestones, gated by
|
|
6
|
+
* `milestones.length === 0` so it never double-counts a heading roadmap.
|
|
7
|
+
*
|
|
8
|
+
* Invokes the dgs-tools.cjs CLI via execSync against isolated temp fixtures
|
|
9
|
+
* (mirrors init.test.cjs's CLI-invocation style).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { describe, it, afterEach } = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { createFixture } = require('./test-helpers.cjs');
|
|
18
|
+
|
|
19
|
+
// Path to CLI entry point
|
|
20
|
+
const CLI = path.resolve(__dirname, '..', 'dgs-tools.cjs');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run `dgs-tools roadmap analyze --raw` in a fixture and return parsed JSON.
|
|
24
|
+
*/
|
|
25
|
+
function runAnalyze(cwd) {
|
|
26
|
+
const stdout = execSync(`node "${CLI}" roadmap analyze --raw`, {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
return JSON.parse(stdout.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('cmdRoadmapAnalyze milestone enumeration', () => {
|
|
35
|
+
let fixture;
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (fixture) fixture.cleanup();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Single-milestone roadmap whose version lives only in the title + bold line —
|
|
42
|
+
// there is NO `## vX.Y` heading, so the heading scan yields [].
|
|
43
|
+
const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
|
|
44
|
+
|
|
45
|
+
**Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
|
|
46
|
+
|
|
47
|
+
## Overview
|
|
48
|
+
|
|
49
|
+
Single-milestone roadmap.
|
|
50
|
+
|
|
51
|
+
### Phase 1: Metrics schema
|
|
52
|
+
**Goal:** Define metrics tables
|
|
53
|
+
|
|
54
|
+
### Phase 2: Admin UI surface
|
|
55
|
+
**Goal:** Surface metrics in admin
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
// Classic multi-milestone heading roadmap WITH a stray bold line: the heading
|
|
59
|
+
// scan enumerates the `## vX.Y` headings; the bold fallback must stay inert.
|
|
60
|
+
const HEADING_ROADMAP = `# Roadmap
|
|
61
|
+
|
|
62
|
+
**Milestone:** v9.9 — stray bold line that must NOT be enumerated
|
|
63
|
+
|
|
64
|
+
## v1.0 Foundation
|
|
65
|
+
### Phase 1: Base
|
|
66
|
+
|
|
67
|
+
## v2.0 Next (In Progress)
|
|
68
|
+
### Phase 2: New
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
it('enumerates a bold-line-only roadmap (>=1 milestone, correct version)', () => {
|
|
72
|
+
fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': BOLD_LINE_ROADMAP });
|
|
73
|
+
const result = runAnalyze(fixture.cwd);
|
|
74
|
+
assert.ok(result.milestones.length >= 1, 'expected at least one milestone');
|
|
75
|
+
assert.equal(result.milestones[0].version, 'v30.1');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('still enumerates a `## vX.Y` heading roadmap via headings (bold fallback inert, no double-count)', () => {
|
|
79
|
+
fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': HEADING_ROADMAP });
|
|
80
|
+
const result = runAnalyze(fixture.cwd);
|
|
81
|
+
const versions = result.milestones.map(m => m.version);
|
|
82
|
+
assert.deepEqual(versions, ['v1.0', 'v2.0']);
|
|
83
|
+
// The stray bold line (v9.9) was NOT enumerated — the length===0 gate held.
|
|
84
|
+
assert.ok(!versions.includes('v9.9'), 'stray bold line must not be enumerated');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -161,8 +161,10 @@ function parseFrontmatter(content) {
|
|
|
161
161
|
// ─── Content Scanners ───────────────────────────────────────────────────────
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Scan ideas
|
|
165
|
-
*
|
|
164
|
+
* Scan ideas for searchable idea files — flat-first.
|
|
165
|
+
* Reads flat ideas/*.md deriving `state` from frontmatter.status (mirror
|
|
166
|
+
* scanSpecs / cmdIdeasList). Legacy ideas/{pending,done,rejected}/ subdirs are
|
|
167
|
+
* an existence-gated dedup fallback so back-compat installs still surface.
|
|
166
168
|
*
|
|
167
169
|
* @param {string} cwd - Working directory
|
|
168
170
|
* @param {object} options - { include_rejected, tags }
|
|
@@ -172,12 +174,60 @@ function scanIdeas(cwd, options) {
|
|
|
172
174
|
const results = [];
|
|
173
175
|
const planRoot = getPlanningRoot(cwd);
|
|
174
176
|
const planRootRel = path.relative(cwd, planRoot) || '.';
|
|
175
|
-
const
|
|
177
|
+
const includeRejected = !!options.include_rejected;
|
|
178
|
+
const seenIds = new Set();
|
|
179
|
+
|
|
180
|
+
// Tag filter: if tags option set, idea must have at least one matching tag (OR logic).
|
|
181
|
+
function tagMatches(tags) {
|
|
182
|
+
if (!options.tags) return true;
|
|
183
|
+
const filterTags = options.tags.split(',').map(t => t.trim().toLowerCase());
|
|
184
|
+
const ideaTags = tags.map(t => t.toLowerCase());
|
|
185
|
+
return filterTags.some(ft => ideaTags.includes(ft));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Flat-first scan: ideas/*.md (excluding subdirs), state from frontmatter.status.
|
|
189
|
+
const flatDir = path.join(planRoot, 'ideas');
|
|
190
|
+
let flatFiles;
|
|
191
|
+
try {
|
|
192
|
+
flatFiles = fs.readdirSync(flatDir).filter(f => f.endsWith('.md')
|
|
193
|
+
&& !fs.statSync(path.join(flatDir, f)).isDirectory());
|
|
194
|
+
} catch {
|
|
195
|
+
flatFiles = [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const file of flatFiles) {
|
|
199
|
+
const content = safeReadFile(path.join(flatDir, file));
|
|
200
|
+
if (!content) continue;
|
|
201
|
+
|
|
202
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
203
|
+
const state = frontmatter.status || 'pending';
|
|
204
|
+
if (!includeRejected && state === 'rejected') continue;
|
|
205
|
+
|
|
206
|
+
const tags = frontmatter.tags || [];
|
|
207
|
+
if (!tagMatches(tags)) continue;
|
|
208
|
+
|
|
209
|
+
results.push({
|
|
210
|
+
type: 'idea',
|
|
211
|
+
id: frontmatter.id,
|
|
212
|
+
title: frontmatter.title || file,
|
|
213
|
+
filePath: path.join(planRootRel, 'ideas', file),
|
|
214
|
+
state,
|
|
215
|
+
tags,
|
|
216
|
+
author: frontmatter.author || '',
|
|
217
|
+
content: (frontmatter.title || '') + ' ' + body,
|
|
218
|
+
});
|
|
219
|
+
if (frontmatter.id !== undefined) seenIds.add(frontmatter.id);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Legacy subdir fallback (existence-gated, deduped by id).
|
|
223
|
+
const legacyStates = includeRejected
|
|
176
224
|
? ['pending', 'done', 'rejected']
|
|
177
225
|
: ['pending', 'done'];
|
|
178
226
|
|
|
179
|
-
for (const
|
|
180
|
-
const dir = path.join(planRoot, 'ideas',
|
|
227
|
+
for (const dirState of legacyStates) {
|
|
228
|
+
const dir = path.join(planRoot, 'ideas', dirState);
|
|
229
|
+
if (!fs.existsSync(dir)) continue;
|
|
230
|
+
|
|
181
231
|
let files;
|
|
182
232
|
try {
|
|
183
233
|
files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
@@ -191,26 +241,22 @@ function scanIdeas(cwd, options) {
|
|
|
191
241
|
if (!content) continue;
|
|
192
242
|
|
|
193
243
|
const { frontmatter, body } = parseFrontmatter(content);
|
|
194
|
-
|
|
244
|
+
if (frontmatter.id !== undefined && seenIds.has(frontmatter.id)) continue;
|
|
195
245
|
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
const filterTags = options.tags.split(',').map(t => t.trim().toLowerCase());
|
|
199
|
-
const ideaTags = tags.map(t => t.toLowerCase());
|
|
200
|
-
const hasMatch = filterTags.some(ft => ideaTags.includes(ft));
|
|
201
|
-
if (!hasMatch) continue;
|
|
202
|
-
}
|
|
246
|
+
const tags = frontmatter.tags || [];
|
|
247
|
+
if (!tagMatches(tags)) continue;
|
|
203
248
|
|
|
204
249
|
results.push({
|
|
205
250
|
type: 'idea',
|
|
206
251
|
id: frontmatter.id,
|
|
207
252
|
title: frontmatter.title || file,
|
|
208
|
-
filePath: path.join(planRootRel, 'ideas',
|
|
209
|
-
state,
|
|
253
|
+
filePath: path.join(planRootRel, 'ideas', dirState, file),
|
|
254
|
+
state: frontmatter.status || dirState,
|
|
210
255
|
tags,
|
|
211
256
|
author: frontmatter.author || '',
|
|
212
257
|
content: (frontmatter.title || '') + ' ' + body,
|
|
213
258
|
});
|
|
259
|
+
if (frontmatter.id !== undefined) seenIds.add(frontmatter.id);
|
|
214
260
|
}
|
|
215
261
|
}
|
|
216
262
|
|
|
@@ -553,6 +599,7 @@ function cmdSearch(cwd, query, options, raw) {
|
|
|
553
599
|
|
|
554
600
|
module.exports = {
|
|
555
601
|
cmdSearch,
|
|
602
|
+
scanIdeas,
|
|
556
603
|
fuzzyMatch,
|
|
557
604
|
levenshteinDistance,
|
|
558
605
|
matchesQuery,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for search.cjs scanIdeas — flat-first idea scanning.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that scanIdeas reads the migrated flat layout (ideas/*.md with
|
|
5
|
+
* `status:` in frontmatter), honours include_rejected, and still surfaces
|
|
6
|
+
* legacy status-subdir ideas as an existence-gated fallback.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { describe, it, afterEach } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { createFixture } = require('./test-helpers.cjs');
|
|
12
|
+
const { resetPaths } = require('./paths.cjs');
|
|
13
|
+
const { scanIdeas } = require('./search.cjs');
|
|
14
|
+
|
|
15
|
+
function ideaContent(id, title, status, tags = []) {
|
|
16
|
+
const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
|
|
17
|
+
return `---\nid: ${id}\ntitle: "${title}"\nstatus: ${status}\ntags: ${tagsStr}\n---\n\n${title} body.\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('search scanIdeas (flat-first)', () => {
|
|
21
|
+
let fixture;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (fixture) fixture.cleanup();
|
|
25
|
+
resetPaths();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('finds flat-layout ideas with state derived from frontmatter.status', () => {
|
|
29
|
+
fixture = createFixture({
|
|
30
|
+
'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending', ['api']),
|
|
31
|
+
'ideas/002-beta.md': ideaContent(2, 'Beta', 'done', ['ui']),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const results = scanIdeas(fixture.cwd, { include_rejected: false });
|
|
35
|
+
assert.equal(results.length, 2);
|
|
36
|
+
|
|
37
|
+
const byId = Object.fromEntries(results.map(r => [r.id, r]));
|
|
38
|
+
assert.equal(byId[1].state, 'pending');
|
|
39
|
+
assert.equal(byId[1].title, 'Alpha');
|
|
40
|
+
assert.equal(byId[2].state, 'done');
|
|
41
|
+
// filePath is the flat location — no status subdir segment.
|
|
42
|
+
assert.match(byId[1].filePath, /ideas[\\/]001-alpha\.md$/);
|
|
43
|
+
assert.ok(!byId[1].filePath.includes('pending'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('hides rejected flat ideas by default and includes them when requested', () => {
|
|
47
|
+
fixture = createFixture({
|
|
48
|
+
'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending'),
|
|
49
|
+
'ideas/009-reject.md': ideaContent(9, 'Rejected idea', 'rejected'),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const hidden = scanIdeas(fixture.cwd, { include_rejected: false });
|
|
53
|
+
assert.equal(hidden.length, 1);
|
|
54
|
+
assert.equal(hidden[0].id, 1);
|
|
55
|
+
|
|
56
|
+
const shown = scanIdeas(fixture.cwd, { include_rejected: true });
|
|
57
|
+
assert.equal(shown.length, 2);
|
|
58
|
+
assert.ok(shown.some(r => r.id === 9 && r.state === 'rejected'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still finds legacy subdir ideas (back-compat)', () => {
|
|
62
|
+
fixture = createFixture({
|
|
63
|
+
'ideas/pending/003-gamma.md': ideaContent(3, 'Gamma', 'pending'),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const results = scanIdeas(fixture.cwd, { include_rejected: false });
|
|
67
|
+
assert.equal(results.length, 1);
|
|
68
|
+
assert.equal(results[0].id, 3);
|
|
69
|
+
assert.equal(results[0].state, 'pending');
|
|
70
|
+
assert.match(results[0].filePath, /ideas[\\/]pending[\\/]003-gamma\.md$/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('dedupes a flat idea against a legacy subdir copy (by id)', () => {
|
|
74
|
+
fixture = createFixture({
|
|
75
|
+
'ideas/003-gamma.md': ideaContent(3, 'Gamma flat', 'pending'),
|
|
76
|
+
'ideas/pending/003-gamma.md': ideaContent(3, 'Gamma legacy', 'pending'),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const results = scanIdeas(fixture.cwd, { include_rejected: false });
|
|
80
|
+
assert.equal(results.length, 1);
|
|
81
|
+
assert.equal(results[0].title, 'Gamma flat');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('applies the OR tag filter', () => {
|
|
85
|
+
fixture = createFixture({
|
|
86
|
+
'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending', ['api']),
|
|
87
|
+
'ideas/002-beta.md': ideaContent(2, 'Beta', 'pending', ['ui']),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const results = scanIdeas(fixture.cwd, { include_rejected: false, tags: 'api' });
|
|
91
|
+
assert.equal(results.length, 1);
|
|
92
|
+
assert.equal(results[0].id, 1);
|
|
93
|
+
});
|
|
94
|
+
});
|