@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
191 lines • 8.22 kB
JavaScript
const BAND_LABEL = {
fresh: "fresh",
aging: "aging",
stale: "stale",
unknown: "unknown",
};
/** Use `-` for unknown ages so text and Markdown renderers share the same missing-age marker. */
function formatDays(days) {
return days === null ? "-" : `${days}d`;
}
/** Pad a string on the right to the target width. */
function padRight(value, width) {
return value.length >= width
? value
: value + " ".repeat(width - value.length);
}
/** Render one learning-loop bucket section with fixed-width rows for scan-friendly terminal output. */
function renderSectionText(name, section) {
if (!section.exists) {
return `${name} (${section.path}) - directory missing\n`;
}
const header = `${name} (${section.path}) - ${section.buckets.length} bucket(s), ${section.totalEntries} entrie(s)`;
const summary = ` Freshness: ${section.bands.fresh} fresh, ${section.bands.aging} aging, ${section.bands.stale} stale, ${section.bands.unknown} unknown | Refs: ${section.totalStaleRefs} stale, ${section.totalInvalidLineRefs} invalid-line`;
if (section.buckets.length === 0) {
return [header, summary, ""].join("\n");
}
const nameWidth = Math.max(8, ...section.buckets.map((b) => basename(b.path).length));
const lines = section.buckets.map((bucket) => {
const display = basename(bucket.path);
const last = bucket.lastReviewed ?? "-";
const days = formatDays(bucket.freshnessDays);
const band = BAND_LABEL[bucket.freshnessBand] ?? bucket.freshnessBand;
const refs = `${bucket.staleRefs.length}s/${bucket.invalidLineRefs.length}i`;
return ` ${padRight(display, nameWidth)} last=${last} age=${padRight(days, 6)} band=${padRight(band, 7)} entries=${bucket.entryCount} refs=${refs}`;
});
return [header, summary, ...lines, ""].join("\n");
}
/** Return the last segment of a slash-delimited path. */
function basename(path) {
const idx = path.lastIndexOf("/");
return idx === -1 ? path : path.slice(idx + 1);
}
/**
* Render the stats report as human-readable terminal text.
*
* @param report Learning-loop stats payload from `buildStatsReport`.
* @returns Text format optimized for local inspection, not a stable machine contract.
*/
export function renderStatsText(report) {
return (renderSectionText("Footguns", report.footguns) +
"\n" +
renderSectionText("Lessons", report.lessons) +
(report.decisions ? "\n" + renderDecisionsText(report.decisions) : ""));
}
/**
* Render the stats report as JSON.
*
* @param report Learning-loop stats payload from `buildStatsReport`.
* @returns Pretty JSON; the object shape is the CI/API contract, not the text renderer.
*/
export function renderStatsJson(report) {
return JSON.stringify(report, null, 2);
}
/**
* Render the stats report as Markdown for PR comments and release notes.
*
* @param report Learning-loop stats payload from `buildStatsReport`.
* @returns Markdown summary that preserves the same section ordering as text output.
*/
export function renderStatsMarkdown(report) {
const sections = [
markdownSection("Footguns", report.footguns),
markdownSection("Lessons", report.lessons),
...(report.decisions ? [markdownDecisions(report.decisions)] : []),
];
return ["# Learning-loop stats", "", ...sections].join("\n");
}
/** Render ADR warnings in the compact text format used beside footguns and lessons. */
function renderDecisionsText(section) {
if (!section.exists) {
return `Decisions (${section.path}) - directory missing\n`;
}
const adrCount = section.files.filter((file) => /^ADR-\d{3}-[a-z0-9-]+\.md$/.test(file.filename)).length;
const header = `Decisions (${section.path}) - ${adrCount} ADR file(s), ${section.warnings.length} warning(s)`;
if (section.warnings.length === 0)
return `${header}\n`;
return [
header,
...section.warnings.map((warning) => ` - [${warning.rule}] ${warning.message}`),
"",
].join("\n");
}
/** Render ADR warnings and counts as a Markdown section while preserving warning rule ids. */
function markdownDecisions(section) {
if (!section.exists) {
return `## Decisions\n\n_Directory missing: \`${section.path}\`_\n`;
}
const adrCount = section.files.filter((file) => /^ADR-\d{3}-[a-z0-9-]+\.md$/.test(file.filename)).length;
const lines = [
`## Decisions`,
``,
`- Path: \`${section.path}\``,
`- ADR files: ${adrCount}`,
`- Warnings: ${section.warnings.length}`,
``,
];
if (section.warnings.length > 0) {
lines.push(...section.warnings.map((warning) => `- [${warning.rule}] ${warning.message}`), ``);
}
return lines.join("\n");
}
/** Build the markdown section. */
function markdownSection(name, section) {
if (!section.exists) {
return `## ${name}\n\n_Directory missing: \`${section.path}\`_\n`;
}
const head = [
`## ${name}`,
``,
`- Path: \`${section.path}\``,
`- Entries: ${section.totalEntries}`,
`- Freshness: ${section.bands.fresh} fresh / ${section.bands.aging} aging / ${section.bands.stale} stale / ${section.bands.unknown} unknown`,
`- Refs: ${section.totalStaleRefs} stale, ${section.totalInvalidLineRefs} invalid-line`,
``,
];
if (section.buckets.length === 0)
return head.join("\n");
const rows = section.buckets.map((bucket) => `| ${basename(bucket.path)} | ${bucket.lastReviewed ?? "-"} | ${formatDays(bucket.freshnessDays)} | ${bucket.freshnessBand} | ${bucket.entryCount} | ${bucket.staleRefs.length} | ${bucket.invalidLineRefs.length} |`);
return [
...head,
`| File | last_reviewed | age | band | entries | stale refs | invalid-line refs |`,
`| --- | --- | --- | --- | ---: | ---: | ---: |`,
...rows,
``,
].join("\n");
}
/**
* Render a `--check` verdict as text suitable for CI logs.
*
* @param check Pass/fail report produced by `checkStats`.
* @returns Stable text with findings before warnings and remediation hints on frontmatter failures.
*/
export function renderStatsCheckText(check) {
if (check.status === "pass")
return renderStatsCheckPass(check);
return renderStatsCheckFailure(check);
}
/** Pluralize count labels in `--check` summaries without pulling in a formatter dependency. */
function plural(count, noun) {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}
/** Append warning counts only when a passing or failing check actually has advisory warnings. */
function warningSuffix(check) {
return check.warnings.length > 0
? ` (${plural(check.warnings.length, "warning")})`
: "";
}
/** Render a passing check, including warning details because warnings affect review attention. */
function renderStatsCheckPass(check) {
if (check.warnings.length === 0)
return "stats --check: PASS\n";
return [
`stats --check: PASS${warningSuffix(check)}`,
...check.warnings.map((w) => ` - [${w.rule}] ${w.message}`),
"",
].join("\n");
}
/**
* Render a failing check with actionable findings first and advisory warnings second.
*
* Contract: when frontmatter metadata is the reason for failure, append the
* stats maintenance command because reviewers cannot infer the remediation from
* the raw rule names alone.
*/
function renderStatsCheckFailure(check) {
const lines = [
`stats --check: FAIL (${plural(check.findings.length, "finding")}${check.warnings.length > 0 ? `, ${plural(check.warnings.length, "warning")}` : ""})`,
];
for (const finding of check.findings) {
lines.push(` - [${finding.rule}] ${finding.message}`);
}
for (const w of check.warnings) {
lines.push(` - [${w.rule}] ${w.message}`);
}
const hasFrontmatterFindings = check.findings.some((f) => f.rule === "missing-last-reviewed" || f.rule === "invalid-last-reviewed");
if (hasFrontmatterFindings) {
lines.push(" Fix: bash scripts/maintenance/fix-bucket-frontmatter.sh [--dry-run]");
}
return lines.join("\n") + "\n";
}
//# sourceMappingURL=render.js.map