fix: clamp progress bar percent to prevent RangeError crash (#715)

When orphaned SUMMARY.md files cause totalSummaries > totalPlans, the
progress percentage exceeds 100%, making String.repeat() throw RangeError
on negative arguments. Clamp percent to Math.min(100, ...) at all three
computation sites (state, commands, roadmap).

Closes #633

Co-authored-by: vinicius-tersi <vinicius-tersi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
TÂCHES
2026-02-23 09:51:28 -06:00
committed by GitHub
parent 6b27d8d74b
commit 3704829aa6
4 changed files with 31 additions and 3 deletions

View File

@@ -421,7 +421,7 @@ function cmdProgressRender(cwd, format, raw) {
}
} catch {}
const percent = totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0;
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
if (format === 'table') {
// Render markdown table

View File

@@ -208,7 +208,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
completed_phases: completedPhases,
total_plans: totalPlans,
total_summaries: totalSummaries,
progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0,
progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0,
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,

View File

@@ -238,7 +238,7 @@ function cmdStateUpdateProgress(cwd, raw) {
}
}
const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0;
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);

View File

@@ -473,6 +473,34 @@ describe('progress command', () => {
assert.ok(result.output.includes('Phase'), 'should have table header');
assert.ok(result.output.includes('foundation'), 'should include phase name');
});
test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
// 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion)
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary');
// bar format - should not crash with RangeError
const barResult = runGsdTools('progress bar --raw', tmpDir);
assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`);
assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%');
// table format - should not crash with RangeError
const tableResult = runGsdTools('progress table --raw', tmpDir);
assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`);
// json format - percent should be clamped
const jsonResult = runGsdTools('progress json', tmpDir);
assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`);
const output = JSON.parse(jsonResult.output);
assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────