From 9a13d2fc0b95c65c98aea1796e9a9ac29be4b159 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 17:30:19 -0400 Subject: [PATCH] fix(#2911): audit-open emits raw human report and parseable JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the audit-open dispatch case in bin/gsd-tools.cjs: 1. Bare output(...) calls (only core.output is in scope) threw ReferenceError: output is not defined on every invocation, blocking the first step of /gsd-complete-milestone. 2. Even after switching to core.output(formattedReport, raw), the human-readable branch JSON-stringified the formatted text because core.output only bypasses JSON encoding when called as core.output(null, true, rawValue). Fix: - --json path: core.output(result, raw) — pass the object, let core.output JSON-stringify (don't pre-stringify). - text path: core.output(null, true, formatAuditReport(result)) — use the rawValue form to emit verbatim section dividers and item lists. Adds tests/bug-2911-audit-open-output-shape.test.cjs which parses both modes structurally — line-by-line for text mode (asserting the report headers exist as standalone lines, not as escaped \n inside a JSON quoted string), and JSON.parse + key-by-key shape assertions for --json mode (matching the contract returned by auditOpenArtifacts). --- get-shit-done/bin/gsd-tools.cjs | 9 +- .../bug-2911-audit-open-output-shape.test.cjs | 120 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/bug-2911-audit-open-output-shape.test.cjs diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 23db8d672..ca73430d2 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -703,12 +703,15 @@ async function runCommand(command, args, cwd, raw, defaultValue) { case 'audit-open': { const { auditOpenArtifacts, formatAuditReport } = require('./lib/audit.cjs'); - const includeRaw = args.includes('--json'); + const wantJson = args.includes('--json'); const result = auditOpenArtifacts(cwd); - if (includeRaw) { + if (wantJson) { + // core.output JSON-stringifies its first arg; pass the object directly. core.output(result, raw); } else { - core.output(formatAuditReport(result), raw); + // Human-readable report must bypass JSON encoding — use the rawValue + // form (third arg) which core.output emits verbatim. + core.output(null, true, formatAuditReport(result)); } break; } diff --git a/tests/bug-2911-audit-open-output-shape.test.cjs b/tests/bug-2911-audit-open-output-shape.test.cjs new file mode 100644 index 000000000..8a74582d1 --- /dev/null +++ b/tests/bug-2911-audit-open-output-shape.test.cjs @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Regression test for #2911. + * + * Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs: + * + * 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError. + * 2. Even after switching to `core.output(formatted, raw)`, the human-readable + * branch JSON-stringifies the formatted string because `core.output` only + * bypasses JSON encoding when called as `core.output(null, true, rawValue)`. + * Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string + * literal) instead of the rendered report. + * + * The shape assertions below catch both regressions structurally — never via + * substring matching on serialized output: + * + * - text mode: parse stdout as a sequence of lines and assert the expected + * section headers exist as standalone lines (i.e. raw text, not escaped). + * If the report is JSON-stringified, the stdout is a single line wrapped + * in double quotes with `\n` escapes — line-array assertions fail. + * - --json mode: JSON.parse the stdout and assert the keys returned by + * `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items) + * are present and well-typed. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('audit-open — output shape (#2911)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-bug-2911-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('text mode emits the formatted report as raw text (not JSON-encoded)', () => { + const result = runGsdTools('audit-open', tmpDir); + assert.ok( + result.success, + `audit-open must not crash. stderr: ${result.error}` + ); + + const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean); + + // The first non-empty line must be the divider character row, *not* a + // JSON-encoded string starting with a quote. If core.output JSON-stringified + // the formatted report, the entire payload sits on one line wrapped in + // double quotes ("━━━…\n…"). + assert.ok( + !result.output.startsWith('"'), + 'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)' + ); + assert.ok( + !result.output.includes('\\n'), + 'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)' + ); + + // Section headers from formatAuditReport that must appear as standalone lines. + assert.ok( + lines.includes('Milestone Close: Open Artifact Audit'), + `expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}` + ); + assert.ok( + lines.includes('All artifact types clear. Safe to proceed.'), + `expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}` + ); + }); + + test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => { + const result = runGsdTools(['audit-open', '--json'], tmpDir); + assert.ok( + result.success, + `audit-open --json must not crash. stderr: ${result.error}` + ); + + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'audit-open --json must emit valid JSON (not a doubly-stringified string)' + ); + + assert.equal(typeof parsed, 'object', 'parsed payload must be an object'); + assert.ok(parsed !== null, 'parsed payload must not be null'); + + // Shape contract from auditOpenArtifacts() in get-shit-done/bin/lib/audit.cjs. + assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp'); + assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean'); + assert.equal(typeof parsed.counts, 'object', 'must include counts object'); + assert.equal(typeof parsed.items, 'object', 'must include items object'); + + const expectedCountKeys = [ + 'debug_sessions', 'quick_tasks', 'threads', 'todos', + 'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total', + ]; + for (const key of expectedCountKeys) { + assert.equal( + typeof parsed.counts[key], 'number', + `counts.${key} must be a number` + ); + } + + const expectedItemKeys = [ + 'debug_sessions', 'quick_tasks', 'threads', 'todos', + 'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', + ]; + for (const key of expectedItemKeys) { + assert.ok( + Array.isArray(parsed.items[key]), + `items.${key} must be an array` + ); + } + }); +});