/** * export.ts — bundle find/changed artifacts for a pygienium run. * * `/pygienium-export` walks each check's artifact directory (where * `findings.md` and `changes.md` live), applies `--check=` / `--status=` * filters, and writes a single bundle to `.pygienium/export.{md|json}`. * * Artifact root: `/.pygienium/checks//` — the single canonical * location every shipped check writes to. * * Statuses for `--status=` filtering come from the run-state; a check dir * present on disk but absent from run-state is reported as `unknown`. * * @module pygienium/export */ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { RunState } from "./run-state.js"; export type ExportFormat = "md" | "json"; /** Directory name (relative to cwd) that holds `checks/` and `export.md`. */ export const PYGIENIUM_ARTIFACT_DIR = ".pygienium"; /** Subdirectory holding per-check `findings.md`/`changes.md`. */ export const CHECKS_SUBDIR = "checks"; /** Base filename for the bundle (`export.md` / `export.json`). */ export const EXPORT_FILENAME_BASE = "export"; /** Resolve `/.pygienium/` (the artifact root). */ export function pygieniumArtifactDir(cwd: string): string { return join(cwd, PYGIENIUM_ARTIFACT_DIR); } /** Resolve `/.pygienium/checks/`. */ export function canonicalChecksRoot(cwd: string): string { return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR); } /** Resolve `/.pygienium/export.`. */ export function exportBundlePath(cwd: string, format: ExportFormat): string { return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`); } /** A single gathered check artifact entry (post-filter). */ export interface ExportEntry { /** Check name (the directory under `checks/`). */ name: string; /** Status from run-state, or `unknown` when not present there. */ status: string; /** `findings.md` contents, when present on disk. */ findings?: string; /** `changes.md` contents, when present on disk. */ changes?: string; /** Absolute path to `findings.md`, when read from disk. */ findingsPath?: string; /** Absolute path to `changes.md`, when read from disk. */ changesPath?: string; } /** Parsed `--check=` / `--status=` / `--out=` filters. */ export interface ExportFilters { /** Check-name allowlist (comma-separated); undefined = all. */ check?: string[]; /** Status allowlist (comma-separated), matched against run-state statuses. */ status?: string[]; /** Output format. Defaults to `md`. */ out?: ExportFormat; } /** Result of {@link exportRun}. */ export interface ExportResult { /** Format used. */ format: ExportFormat; /** Absolute path the bundle was written to. */ path: string; /** Entries included after filtering (in alphabetical order). */ entries: ExportEntry[]; /** Bundle size in bytes. */ bytes: number; } const FLAG_CHECK = "--check="; const FLAG_STATUS = "--status="; const FLAG_OUT = "--out="; /** Parse export flags from the raw arg string (flags + optional positional). */ export function parseExportFilters(args: string): ExportFilters { const filters: ExportFilters = { out: "md" }; const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; for (const tok of tokens) { if (tok.startsWith(FLAG_CHECK)) { filters.check = tok .slice(FLAG_CHECK.length) .split(",") .map((s) => s.trim()) .filter(Boolean); } else if (tok.startsWith(FLAG_STATUS)) { filters.status = tok .slice(FLAG_STATUS.length) .split(",") .map((s) => s.trim()) .filter(Boolean); } else if (tok.startsWith(FLAG_OUT)) { const v = tok.slice(FLAG_OUT.length).toLowerCase().trim(); if (v === "json" || v === "md") { filters.out = v; } } } return filters; } async function readArtifact(path: string): Promise { try { return await readFile(path, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw err; } } async function gatherFromRoot( root: string, state: RunState | undefined, merged: Map, ): Promise { let entries: import("node:fs").Dirent[]; try { entries = await readdir(root, { withFileTypes: true }); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return; throw err; } for (const entry of entries) { if (!entry.isDirectory()) continue; const name = entry.name; const dir = join(root, name); const fpath = join(dir, "findings.md"); const cpath = join(dir, "changes.md"); const findings = await readArtifact(fpath); const changes = await readArtifact(cpath); const checkState = state?.checks[name]; merged.set(name, { name, status: checkState?.status ?? "unknown", findings, changes, findingsPath: findings != null ? fpath : undefined, changesPath: changes != null ? cpath : undefined, }); } } /** * Gather artifact entries from the canonical `/.pygienium/checks/` root, * one entry per check directory. Entries are sorted alphabetically. Marks an * entry `unknown` when its check is absent from `state`. */ export async function gatherExportEntries( cwd: string, state?: RunState, ): Promise { const merged = new Map(); await gatherFromRoot(canonicalChecksRoot(cwd), state, merged); return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); } /** Apply `--check=` / `--status=` filters to gathered entries. */ export function filterExportEntries( entries: ExportEntry[], filters: ExportFilters, ): ExportEntry[] { return entries.filter((e) => { if (filters.check && !filters.check.includes(e.name)) return false; if (filters.status && !filters.status.includes(e.status)) return false; return true; }); } /** Render the markdown bundle. */ export function renderExportMarkdown( state: RunState | undefined, entries: ExportEntry[], ): string { const lines: string[] = []; lines.push("# Pygienium export"); if (state) { lines.push(""); lines.push(`- status: ${state.status}`); lines.push(`- started: ${new Date(state.startedAt).toISOString()}`); lines.push(`- updated: ${new Date(state.updatedAt).toISOString()}`); lines.push(`- cwd: ${state.cwd}`); lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`); } lines.push(`- checks: ${entries.length}`); lines.push(""); for (const e of entries) { lines.push(`## ${e.name} (${e.status})`); if (e.findings != null) { lines.push(""); lines.push("### findings"); lines.push(""); lines.push(e.findings.replace(/\s+$/, "")); } if (e.changes != null) { lines.push(""); lines.push("### changes"); lines.push(""); lines.push(e.changes.replace(/\s+$/, "")); } if (e.findings == null && e.changes == null) { lines.push(""); lines.push("_(no findings.md or changes.md on disk)_"); } lines.push(""); } return lines.join("\n") + "\n"; } /** Render the JSON bundle. */ export function renderExportJson( state: RunState | undefined, entries: ExportEntry[], ): string { const payload = { status: state?.status ?? "unknown", startedAt: state?.startedAt ?? null, updatedAt: state?.updatedAt ?? null, cwd: state?.cwd ?? null, recon: state ? state.recon.complete : null, checks: entries.map((e) => ({ name: e.name, status: e.status, findings: e.findings ?? null, findingsPath: e.findingsPath ?? null, changes: e.changes ?? null, changesPath: e.changesPath ?? null, })), }; return JSON.stringify(payload, null, 2) + "\n"; } /** * Gather, filter, and write the export bundle. Returns the (would-be) path * and the included entries. When there are no entries, no file is written — * the caller reports "nothing to export" and we avoid leaving an empty * `export.{md|json}` on disk. */ export async function exportRun( cwd: string, state: RunState | undefined, filters: ExportFilters, ): Promise { const all = await gatherExportEntries(cwd, state); const entries = filterExportEntries(all, filters); const format: ExportFormat = filters.out ?? "md"; const path = exportBundlePath(cwd, format); if (entries.length === 0) { return { format, path, entries, bytes: 0 }; } const body = format === "json" ? renderExportJson(state, entries) : renderExportMarkdown(state, entries); await mkdir(dirname(path), { recursive: true }); await writeFile(path, body, "utf8"); return { format, path, entries, bytes: Buffer.byteLength(body) }; }