fix(stats): correct git and roadmap reporting (#1066)
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
|
||||
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
||||
|
||||
@@ -535,21 +535,42 @@ function cmdScaffold(cwd, type, options, raw) {
|
||||
|
||||
function cmdStats(cwd, format, raw) {
|
||||
const phasesDir = path.join(cwd, '.planning', 'phases');
|
||||
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
||||
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
||||
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
||||
|
||||
// Phase & plan stats (reuse progress pattern)
|
||||
const phases = [];
|
||||
const phasesByNumber = new Map();
|
||||
let totalPlans = 0;
|
||||
let totalSummaries = 0;
|
||||
|
||||
try {
|
||||
const roadmapContent = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8'));
|
||||
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
let match;
|
||||
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
||||
phasesByNumber.set(match[1], {
|
||||
number: match[1],
|
||||
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
|
||||
plans: 0,
|
||||
summaries: 0,
|
||||
status: 'Not Started',
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
||||
const dirs = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(isDirInMilestone)
|
||||
.sort((a, b) => comparePhaseNum(a, b));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
const phaseNum = dm ? dm[1] : dir;
|
||||
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
@@ -560,16 +581,26 @@ function cmdStats(cwd, format, raw) {
|
||||
totalSummaries += summaries;
|
||||
|
||||
let status;
|
||||
if (plans === 0) status = 'Pending';
|
||||
if (plans === 0) status = 'Not Started';
|
||||
else if (summaries >= plans) status = 'Complete';
|
||||
else if (summaries > 0) status = 'In Progress';
|
||||
else status = 'Planned';
|
||||
|
||||
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
|
||||
const existing = phasesByNumber.get(phaseNum);
|
||||
phasesByNumber.set(phaseNum, {
|
||||
number: phaseNum,
|
||||
name: existing?.name || phaseName,
|
||||
plans,
|
||||
summaries,
|
||||
status,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
||||
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
|
||||
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
||||
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
||||
const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
|
||||
|
||||
// Requirements stats
|
||||
let requirementsTotal = 0;
|
||||
@@ -589,7 +620,10 @@ function cmdStats(cwd, format, raw) {
|
||||
try {
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/);
|
||||
const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|
||||
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/^Last activity:\s*(.+)$/im);
|
||||
if (activityMatch) lastActivity = activityMatch[1].trim();
|
||||
}
|
||||
} catch {}
|
||||
@@ -597,14 +631,18 @@ function cmdStats(cwd, format, raw) {
|
||||
// Git stats
|
||||
let gitCommits = 0;
|
||||
let gitFirstCommitDate = null;
|
||||
try {
|
||||
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
|
||||
gitCommits = parseInt(commitCount.trim(), 10) || 0;
|
||||
const firstDate = execGit(cwd, ['log', '--reverse', '--format=%as', '--max-count=1']);
|
||||
gitFirstCommitDate = firstDate.trim() || null;
|
||||
} catch {}
|
||||
|
||||
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
||||
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
|
||||
if (commitCount.exitCode === 0) {
|
||||
gitCommits = parseInt(commitCount.stdout, 10) || 0;
|
||||
}
|
||||
const rootHash = execGit(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
|
||||
if (rootHash.exitCode === 0 && rootHash.stdout) {
|
||||
const firstCommit = rootHash.stdout.split('\n')[0].trim();
|
||||
const firstDate = execGit(cwd, ['show', '-s', '--format=%as', firstCommit]);
|
||||
if (firstDate.exitCode === 0) {
|
||||
gitFirstCommitDate = firstDate.stdout || null;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
milestone_version: milestone.version,
|
||||
@@ -615,6 +653,7 @@ function cmdStats(cwd, format, raw) {
|
||||
total_plans: totalPlans,
|
||||
total_summaries: totalSummaries,
|
||||
percent,
|
||||
plan_percent: planPercent,
|
||||
requirements_total: requirementsTotal,
|
||||
requirements_complete: requirementsComplete,
|
||||
git_commits: gitCommits,
|
||||
@@ -627,7 +666,10 @@ function cmdStats(cwd, format, raw) {
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
||||
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
|
||||
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n`;
|
||||
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
|
||||
if (totalPlans > 0) {
|
||||
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
|
||||
}
|
||||
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
|
||||
if (requirementsTotal > 0) {
|
||||
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
@@ -132,24 +132,16 @@ function isGitIgnored(cwd, targetPath) {
|
||||
}
|
||||
|
||||
function execGit(cwd, args) {
|
||||
try {
|
||||
const escaped = args.map(a => {
|
||||
if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
|
||||
return "'" + a.replace(/'/g, "'\\''") + "'";
|
||||
});
|
||||
const stdout = execSync('git ' + escaped.join(' '), {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout ?? '').toString().trim(),
|
||||
stderr: (err.stderr ?? '').toString().trim(),
|
||||
};
|
||||
}
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout ?? '').toString().trim(),
|
||||
stderr: (result.stderr ?? '').toString().trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,7 +16,7 @@ STATS=$(node "$GSD_TOOLS" stats json)
|
||||
if [[ "$STATS" == @file:* ]]; then STATS=$(cat "${STATS#@file:}"); fi
|
||||
```
|
||||
|
||||
Extract fields from JSON: `milestone_version`, `milestone_name`, `phases`, `total_plans`, `total_summaries`, `percent`, `requirements_total`, `requirements_complete`, `git_commits`, `git_first_commit_date`, `last_activity`.
|
||||
Extract fields from JSON: `milestone_version`, `milestone_name`, `phases`, `phases_completed`, `phases_total`, `total_plans`, `total_summaries`, `percent`, `plan_percent`, `requirements_total`, `requirements_complete`, `git_commits`, `git_first_commit_date`, `last_activity`.
|
||||
</step>
|
||||
|
||||
<step name="present_stats">
|
||||
@@ -26,7 +26,10 @@ Present to the user with this format:
|
||||
# 📊 Project Statistics — {milestone_version} {milestone_name}
|
||||
|
||||
## Progress
|
||||
[████████░░] X/Y plans (Z%)
|
||||
[████████░░] X/Y phases (Z%)
|
||||
|
||||
## Plans
|
||||
X/Y plans complete (Z%)
|
||||
|
||||
## Phases
|
||||
| Phase | Name | Plans | Completed | Status |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execSync } = require('node:child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
@@ -1236,7 +1237,8 @@ describe('stats command', () => {
|
||||
assert.strictEqual(stats.phases_completed, 1);
|
||||
assert.strictEqual(stats.total_plans, 3);
|
||||
assert.strictEqual(stats.total_summaries, 2);
|
||||
assert.strictEqual(stats.percent, 67);
|
||||
assert.strictEqual(stats.percent, 50);
|
||||
assert.strictEqual(stats.plan_percent, 67);
|
||||
});
|
||||
|
||||
test('counts requirements from REQUIREMENTS.md', () => {
|
||||
@@ -1274,6 +1276,105 @@ describe('stats command', () => {
|
||||
assert.strictEqual(stats.last_activity, '2025-06-15');
|
||||
});
|
||||
|
||||
test('reads last activity from plain STATE.md template format', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.last_activity, '2025-06-16 — Finished plan 01-01');
|
||||
});
|
||||
|
||||
test('includes roadmap-only phases in totals and preserves hyphenated names', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening');
|
||||
const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.mkdirSync(p2, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [x] **Phase 14: Auth Hardening**
|
||||
- [x] **Phase 15: Proof Generation**
|
||||
- [ ] **Phase 16: Multi-Claim Verification & UX**
|
||||
|
||||
## Milestone v1.0 Growth
|
||||
|
||||
### Phase 14: Auth Hardening
|
||||
**Goal:** Improve auth checks
|
||||
|
||||
### Phase 15: Proof Generation
|
||||
**Goal:** Improve proof generation
|
||||
|
||||
### Phase 16: Multi-Claim Verification & UX
|
||||
**Goal:** Support multi-claim verification
|
||||
`
|
||||
);
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.phases_total, 3);
|
||||
assert.strictEqual(stats.phases_completed, 2);
|
||||
assert.strictEqual(stats.percent, 67);
|
||||
assert.strictEqual(stats.plan_percent, 100);
|
||||
assert.strictEqual(
|
||||
stats.phases.find(p => p.number === '16')?.name,
|
||||
'Multi-Claim Verification & UX'
|
||||
);
|
||||
assert.strictEqual(
|
||||
stats.phases.find(p => p.number === '16')?.status,
|
||||
'Not Started'
|
||||
);
|
||||
});
|
||||
|
||||
test('reports git commit count and first commit date from repository history', () => {
|
||||
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
||||
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "initial commit"', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z',
|
||||
GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n');
|
||||
execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "second commit"', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z',
|
||||
GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.git_commits, 2);
|
||||
assert.strictEqual(stats.git_first_commit_date, '2026-01-01');
|
||||
});
|
||||
|
||||
test('table format renders readable output', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
@@ -1288,5 +1389,6 @@ describe('stats command', () => {
|
||||
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
|
||||
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
|
||||
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
|
||||
assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user