From a8a0b682149ca47fec1dccf69c3a5c32a70484eb Mon Sep 17 00:00:00 2001 From: Louise Lau Date: Thu, 2 Jul 2026 10:25:45 +0800 Subject: [PATCH] feat(exports): maintain-audit --pdf (auditor one-pager) + page footers + exports cheat-sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the export story — every maintain artifact can now leave the terminal in the format its audience consumes: - maintain-audit --pdf: the auditor-facing one-pager — verification status up top (chain INTACT/BROKEN, head match, signature VALID/INVALID/unsigned, overall PASS/FAIL), then the full run chain as a readable table (seq, date, scope, outcome, files, checks, PR link), and the offline-verify instruction at the foot. buildAuditPdfBlocks is PURE (takes the verdict from verifyAuditLog); composes with --sign. - pdf-lite: "Page N of M" footers on multi-page documents (one-pagers stay clean). - ADOPTION.md "Every export, one place": an audience→command→artifact table covering demo/ report/audit/history exports; notes all PDFs come from the dependency-free writer. (axios/axios#11062 still OPEN with no review comments — the "merged upstream" docs update waits for the actual merge, honestly.) --- docs/ADOPTION.md | 17 +++++++++++++++++ src/cli/maintain-audit.ts | 32 ++++++++++++++++++++++++++++++++ src/cli/pdf-lite.ts | 4 ++++ src/index.ts | 21 +++++++++++++++++---- test/maintain-audit.test.ts | 31 ++++++++++++++++++++++++++++++- test/pdf-lite.test.ts | 8 +++++++- 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index 25d6748..72f18b7 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -151,6 +151,23 @@ qodex maintain-audit-verify audit.json # offline verify — exit 1 on tam Integrity needs no key at all; the signature adds authenticity (proof a key-holder exported it). +## Every export, one place + +Everything maintain produces can leave the terminal — pick the artifact for your audience: + +| Audience | Command | You get | +|---|---|---| +| A teammate ("what is this?") | `qodex maintain-demo` | interactive walkthrough page | +| A README / blog post | `qodex maintain-demo --markdown` | the story as Markdown | +| A meeting | `qodex maintain-demo --pdf` | one-page story PDF | +| A PR / standup thread | `qodex maintain-report --markdown` | live receipt-backed numbers, paste-ready | +| A manager / monthly review | `qodex maintain-report --pdf` | numbers one-pager with a real trend bar chart | +| An auditor / compliance | `qodex maintain-audit --sign --pdf` | verification status + the full signed run chain | +| A pipeline / CI | `qodex maintain-audit-verify ` | offline check, exit 1 on tamper | +| Another machine / archive | `qodex maintain-export --sign` | portable, mergeable, signed history snapshot | + +All PDFs are generated by a dependency-free writer (`pdf-lite`) — nothing extra to install. + ## FAQ **What if my project has no tests?** Scopes that need a suite (`dep-bump`) block outright. diff --git a/src/cli/maintain-audit.ts b/src/cli/maintain-audit.ts index 5a88afe..99a9512 100644 --- a/src/cli/maintain-audit.ts +++ b/src/cli/maintain-audit.ts @@ -156,6 +156,38 @@ export interface AuditVerifyResult { count: number; } +/** + * The auditor-facing one-pager: verification status up top (chain / head / signature), then the + * full entry chain as a readable table — what ran, when, the outcome, what it touched, and the PR. + * Feed the blocks to pdf-lite's buildPdf. PURE (pass the verdict computed by verifyAuditLog). + */ +export function buildAuditPdfBlocks(log: SignedAuditLog, verdict: AuditVerifyResult): import('./pdf-lite.js').PdfBlock[] { + const kv = (k: string, v: string): import('./pdf-lite.js').PdfBlock => ({ text: `${k}: ${v}`, size: 10, indent: 10 }); + const sigLine = !verdict.signaturePresent ? 'unsigned (integrity only)' + : verdict.signatureValid === undefined ? `signed (keyId ${log.keyId ?? '?'}) — no key supplied to verify` + : verdict.signatureValid ? `VALID — HMAC-SHA256, keyId ${log.keyId ?? '?'} (authentic)` : 'INVALID — wrong key or forged'; + const blocks: import('./pdf-lite.js').PdfBlock[] = [ + { text: 'Maintain Audit Log', size: 18, bold: true }, + { text: `Exported ${log.exportedAt} - ${log.count} run(s) - tamper-evident hash chain (each entry commits to the previous; altering, reordering, or dropping any entry breaks every downstream hash).`, size: 9, spaceBefore: 4 }, + { text: 'Verification', size: 13, bold: true, spaceBefore: 12 }, + kv('Chain integrity', verdict.chainValid ? 'INTACT — no entry altered, reordered, or dropped' : `BROKEN at #${verdict.brokenAt} — ${verdict.reason}`), + kv('Head', `${verdict.headMatches ? 'matches the chain' : 'MISMATCH'} (${log.head.slice(0, 24)}...)`), + kv('Signature', sigLine), + kv('Overall', verdict.ok ? 'PASS — this log is trustworthy' : 'FAIL — do not trust this log'), + { text: 'Run chain (oldest first)', size: 13, bold: true, spaceBefore: 12 }, + ]; + if (!log.entries.length) blocks.push({ text: 'no runs recorded', size: 10, indent: 10 }); + for (const e of log.entries) { + const outcome = e.status === 'opened' ? '[OK] opened' : e.status === 'blocked' ? '[BLOCKED]' : e.status; + const checks = e.verification.length ? ` - checks: ${e.verification.map(v => `${v.passed ? 'v' : 'x'} ${v.command}`).join(', ')}` : ''; + const files = e.filesChanged ? ` - ${e.filesChanged} file(s)` : ''; + blocks.push({ text: `#${e.seq} ${e.at.slice(0, 10)} ${e.scope} ${outcome}${files}${checks}`, size: 9, mono: true, indent: 10 }); + if (e.prUrl) blocks.push({ text: ` PR: ${e.prUrl}`, size: 8, mono: true, indent: 10 }); + } + blocks.push({ text: 'Verify this log offline anytime: qodex maintain-audit-verify (exit 1 on tamper - CI-friendly).', size: 8, spaceBefore: 12 }); + return blocks; +} + /** * Full audit verification of a parsed log: chain integrity, that the stored head matches the chain, * and (when a key is available) the HMAC signature. PURE. `ok` = everything checkable passed. diff --git a/src/cli/pdf-lite.ts b/src/cli/pdf-lite.ts index 9b9b9e1..3540bb0 100644 --- a/src/cli/pdf-lite.ts +++ b/src/cli/pdf-lite.ts @@ -109,6 +109,10 @@ export function buildPdf(blocks: PdfBlock[]): string { objects.push(`4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>\nendobj\n`); objects.push(`5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`); pages.forEach((lines, i) => { + // Page-number footer on multi-page documents (a one-pager stays clean). + if (pages.length > 1) { + lines.push({ kind: 'text', x: PAGE_W / 2 - 24, y: MARGIN - 24, size: 8, font: 'F1', text: `Page ${i + 1} of ${pages.length}` }); + } const pageNum = firstPageObj + i * 2; const contentNum = pageNum + 1; objects.push( diff --git a/src/index.ts b/src/index.ts index 2c5f28e..41b5be8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -906,11 +906,12 @@ async function gatherAuditableRuns(): Promise', 'write the audit log to this file instead of stdout') + .description('Export a tamper-evident audit log of maintain runs (a hash chain); --sign adds an HMAC signature; --pdf renders the auditor one-pager') + .option('-o, --out ', 'write to this file (PDF default: ~/.qodex/maintain-audit.pdf)') .option('--sign', 'sign the chain head with HMAC-SHA256 using the QODEX_AUDIT_KEY env var') - .action(async (opts: { out?: string; sign?: boolean }) => { - const { buildSignedAuditLog, serializeAuditLog } = await import('./cli/maintain-audit.js'); + .option('--pdf', 'render an auditor-facing PDF (verification status + the full run chain) instead of JSON') + .action(async (opts: { out?: string; sign?: boolean; pdf?: boolean }) => { + const { buildSignedAuditLog, serializeAuditLog, verifyAuditLog, buildAuditPdfBlocks } = await import('./cli/maintain-audit.js'); const runs = await gatherAuditableRuns(); let key: string | undefined; if (opts.sign) { @@ -918,6 +919,18 @@ program if (!key) { console.error('\n✗ --sign needs a key: set QODEX_AUDIT_KEY in your environment (it is never stored).\n'); process.exit(1); } } const log = buildSignedAuditLog(runs, { exportedAt: new Date().toISOString(), key }); + if (opts.pdf) { + const verdict = verifyAuditLog(log, key); // self-check the freshly-built chain → status block + const { buildPdf } = await import('./cli/pdf-lite.js'); + const { QODEX_HOME } = await import('./config/defaults.js'); + const path = await import('path'); + const { promises: fs } = await import('fs'); + const out = opts.out ?? path.join(QODEX_HOME, 'maintain-audit.pdf'); + await fs.mkdir(path.dirname(out), { recursive: true }).catch(() => {}); + await fs.writeFile(out, Buffer.from(buildPdf(buildAuditPdfBlocks(log, verdict)), 'latin1')); + console.log(`\n📄 Audit one-pager → ${out}\n ${log.count} entry(ies)${log.signature ? ` · 🔏 signed (key ${log.keyId})` : ' · unsigned'}\n`); + process.exit(0); + } const json = serializeAuditLog(log); if (opts.out) { const { promises: fs } = await import('fs'); diff --git a/test/maintain-audit.test.ts b/test/maintain-audit.test.ts index 0849297..f3c6380 100644 --- a/test/maintain-audit.test.ts +++ b/test/maintain-audit.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { buildAuditChain, verifyAuditChain, chainHead, buildSignedAuditLog, serializeAuditLog, - verifyAuditLog, signChainHead, verifyChainSignature, keyIdFor, AUDIT_GENESIS, + verifyAuditLog, signChainHead, verifyChainSignature, keyIdFor, buildAuditPdfBlocks, AUDIT_GENESIS, type AuditableRun, } from '../src/cli/maintain-audit.ts'; +import { buildPdf } from '../src/cli/pdf-lite.ts'; const RUNS: AuditableRun[] = [ { at: '2026-06-20T00:00:00Z', scope: 'dead-code', status: 'opened', filesChanged: 1, prUrl: 'https://h/pr/1', verification: [{ command: 'npm test', passed: true }] }, @@ -108,3 +109,31 @@ describe('maintain audit — HMAC signature (authenticity)', () => { expect(v.signaturePresent).toBe(false); }); }); + +describe('maintain audit — auditor PDF one-pager', () => { + const KEY = 'audit-key'; + const log = buildSignedAuditLog(RUNS, { exportedAt: '2026-07-01T00:00:00Z', key: KEY }); + + it('renders verification status + the full run chain into a valid PDF', () => { + const pdf = buildPdf(buildAuditPdfBlocks(log, verifyAuditLog(log, KEY))); + expect(pdf.startsWith('%PDF-1.4\n')).toBe(true); + expect(pdf).toContain('(Maintain Audit Log) Tj'); + expect(pdf).toContain('INTACT'); // chain status + expect(pdf).toContain('VALID'); // signature status + expect(pdf).toContain('PASS'); // overall verdict + expect(pdf).toContain('dead-code'); // entries present + expect(pdf).toContain('unused-locals'); + expect(pdf).toContain('[BLOCKED]'); // blocked run visible + expect(pdf).toContain('https://h/pr/1'); // PR link carried + expect(pdf).toContain('v npm test'); // verification checks (sanitized ✓ → v) + }); + + it('a failed verdict renders FAIL, and unsigned logs say so', () => { + const forged = { ...log, entries: log.entries.map((e, i) => i === 0 ? { ...e, filesChanged: 42 } : e) }; + const bad = buildPdf(buildAuditPdfBlocks(forged, verifyAuditLog(forged, KEY))); + expect(bad).toContain('FAIL - do not trust'); + const unsigned = buildSignedAuditLog(RUNS, { exportedAt: 'x' }); + const updf = buildPdf(buildAuditPdfBlocks(unsigned, verifyAuditLog(unsigned))); + expect(updf).toContain('unsigned'); + }); +}); diff --git a/test/pdf-lite.test.ts b/test/pdf-lite.test.ts index fc40b14..80d3eec 100644 --- a/test/pdf-lite.test.ts +++ b/test/pdf-lite.test.ts @@ -47,11 +47,17 @@ describe('buildPdf', () => { expect(pdf.slice(startxref, startxref + 4)).toBe('xref'); }); - it('paginates long content onto multiple pages', () => { + it('paginates long content onto multiple pages, with page-number footers', () => { const long = buildPdf(Array.from({ length: 120 }, (_, i) => ({ text: `line ${i}` }))); const pageCount = (long.match(/\/Type \/Page\b(?!s)/g) ?? []).length; expect(pageCount).toBeGreaterThan(1); expect(long).toContain(`/Count ${pageCount}`); + expect(long).toContain(`(Page 1 of ${pageCount}) Tj`); // footer on every page… + expect(long).toContain(`(Page ${pageCount} of ${pageCount}) Tj`); + }); + + it('a single-page document has NO page-number footer', () => { + expect(buildPdf([{ text: 'short' }])).not.toContain('(Page 1 of 1)'); }); it('stays pure Latin-1 (writable with Buffer latin1) even with emoji input', () => {