Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/ADOPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` | 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.
Expand Down
32 changes: 32 additions & 0 deletions src/cli/maintain-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> (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.
Expand Down
4 changes: 4 additions & 0 deletions src/cli/pdf-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 17 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,18 +906,31 @@ async function gatherAuditableRuns(): Promise<import('./cli/maintain-audit.js').

program
.command('maintain-audit')
.description('Export a tamper-evident audit log of maintain runs (a hash chain); --sign adds an HMAC signature')
.option('-o, --out <file>', '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 <file>', '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) {
key = process.env.QODEX_AUDIT_KEY;
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');
Expand Down
31 changes: 30 additions & 1 deletion test/maintain-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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 }] },
Expand Down Expand Up @@ -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');
});
});
8 changes: 7 additions & 1 deletion test/pdf-lite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down