diff --git a/.changeset/graceful-elks-wander.md b/.changeset/graceful-elks-wander.md new file mode 100644 index 000000000..3cce123e4 --- /dev/null +++ b/.changeset/graceful-elks-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4034 +--- +audit-open summary counts now include the display-truncation remainder: a milestone with more than 5 open files reported counts capped at 5 because the _remainder_count display marker was counted as one item instead of the real files it records (#3817) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index cb907d7be..4e6cec476 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -257,10 +257,11 @@ "audit-command-cutover.test.cjs", "audit-fix-command.test.cjs", "audit-milestone-filename-guard.test.cjs", + "audit-open-remainder-count.test.cjs", "audit-uat-acknowledged.test.cjs", "audit-workstream-layouts.test.cjs" ], - "issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures" + "issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 \u2014 regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item" } } } diff --git a/src/audit.cts b/src/audit.cts index 78b0c3596..53d512a40 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -1337,9 +1337,17 @@ function auditOpenArtifacts(cwd: string): AuditResult { try { return scanDeferredItems(planDir, cwd); } catch { return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 }; } })(); - // Count real items (not scan_error sentinels) + // Count real items (not scan_error sentinels). #3817: a `_remainder_count` + // marker is not a phantom — it RECORDS real items the detail list truncated + // away for display, so its value counts toward the total. Truncation limits + // display, never counting; only scan_error (a read failure, not an item) + // contributes zero. const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) => - arr.filter(i => !i.scan_error && !i._remainder_count).length; + arr.reduce((sum, i) => { + if (i.scan_error) return sum; + if (typeof i._remainder_count === 'number') return sum + i._remainder_count; + return sum + 1; + }, 0); const counts: AuditCounts = { debug_sessions: countReal(debugSessions.items), diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs index c240d448f..d4ee199e7 100644 --- a/tests/audit-command-cutover.test.cjs +++ b/tests/audit-command-cutover.test.cjs @@ -2259,7 +2259,9 @@ describe('bug #950: quick-task SUMMARY must carry status: complete', () => { } const before = audit(tmpDir); - assert.equal(before.counts.todos, 5, 'display cap: 5 of 7 shown in one scan'); + // #3817: the display cap truncates the DETAIL list, not the count — + // 7 pending files means counts.todos is 7 (5 shown + remainder 2). + assert.equal(before.counts.todos, 7, 'counts include the truncation remainder (#3817)'); assert.equal(before.has_open_items, true); const shown = before.items.todos.filter((i) => !i.scan_error && !i._remainder_count).map((i) => i.filename); diff --git a/tests/audit-open-remainder-count.test.cjs b/tests/audit-open-remainder-count.test.cjs new file mode 100644 index 000000000..621cbe65b --- /dev/null +++ b/tests/audit-open-remainder-count.test.cjs @@ -0,0 +1,120 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// #3817 — audit-open's counts must include the truncation remainder. +// +// The todo scan truncates its detail list at 5 and pushes a synthetic +// {_remainder_count: N} marker; the counting pass filtered that marker out +// alongside scan_error, so counts.todos and counts.total read systematically +// low by exactly N — the remainder existed only in the "… and N more" prose +// (reporter: 19 pending, reported 5). Truncation limits display, never +// counting. +// ───────────────────────────────────────────────────────────────────────────── + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +function runAuditOpen(cwd) { + const r = runGsdTools(['audit-open', '--json'], cwd); + assert.ok(r.success, r.error); + return JSON.parse(r.output); +} + +describe('#3817: audit-open counts include the truncation remainder', () => { + test('#3817: more than 5 todo files → counts.todos counts every pending file', (t) => { + const tmpDir = createTempProject('gsd-3817-many-'); + t.after(() => cleanup(tmpDir)); + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + const TOTAL = 8; // 5 shown + remainder 3 + for (let i = 1; i <= TOTAL; i++) { + fs.writeFileSync(path.join(pendingDir, `todo-${String(i).padStart(2, '0')}.md`), [ + '---', + 'title: Fix thing ' + i, + 'area: general', + 'created: 2026-08-01', + '---', + '', + 'Body ' + i, + '', + ].join('\n')); + } + + const out = runAuditOpen(tmpDir); + assert.equal( + out.counts.todos, + TOTAL, + `#3817: counts.todos must count every pending todo file (${TOTAL}); got ${out.counts.todos}`, + ); + assert.equal( + out.counts.total, + TOTAL, + `#3817: counts.total must include the remainder; got ${out.counts.total}`, + ); + // The display contract is unchanged: at most 5 detail objects, and the + // prose still announces the remainder. + assert.ok( + Array.isArray(out.items && out.items.todos), + 'todos detail list present', + ); + const detailFiles = out.items.todos.filter((i) => i && i.filename); + assert.ok(detailFiles.length <= 5, `display stays truncated; got ${detailFiles.length}`); + }); + + test('#3817 boundary: exactly 5 files → no marker, counts exact; 6 files → remainder 1', (t) => { + const tmpDir = createTempProject('gsd-3817-boundary-'); + t.after(() => cleanup(tmpDir)); + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + const seed = (n) => fs.writeFileSync(path.join(pendingDir, `todo-${n}.md`), [ + '---', + 'title: T ' + n, + 'area: general', + 'created: 2026-08-01', + '---', + '', + 'Body', + '', + ].join('\n')); + + for (let i = 1; i <= 5; i++) seed(i); + let out = runAuditOpen(tmpDir); + assert.equal(out.counts.todos, 5, 'exactly at the cap: no marker, count exact'); + const markers5 = (out.items.todos || []).filter((i) => i && typeof i._remainder_count === 'number'); + assert.equal(markers5.length, 0, 'no marker emitted at exactly 5'); + + seed(6); + out = runAuditOpen(tmpDir); + assert.equal(out.counts.todos, 6, 'one past the cap: remainder 1 counted'); + const marker = (out.items.todos || []).find((i) => i && typeof i._remainder_count === 'number'); + assert.ok(marker, 'marker present at 6'); + assert.equal(marker._remainder_count, 1, 'marker records exactly the dropped count'); + }); + + test('#3817 control: 5 or fewer todo files → counts unchanged', (t) => { + const tmpDir = createTempProject('gsd-3817-few-'); + t.after(() => cleanup(tmpDir)); + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + for (let i = 1; i <= 3; i++) { + fs.writeFileSync(path.join(pendingDir, `todo-${i}.md`), [ + '---', + 'title: T ' + i, + 'area: general', + 'created: 2026-08-01', + '---', + '', + 'Body', + '', + ].join('\n')); + } + + const out = runAuditOpen(tmpDir); + assert.equal(out.counts.todos, 3, 'no truncation → counts exact'); + assert.equal(out.counts.total, 3); + }); +});