@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.
105 lines • 5.03 kB
JavaScript
/**
* Splits a footgun bucket into its individual `## Footgun:` sections and audits
* each one's structure: that a Status field exists and is canonical, that active
* entries sit above the `## Resolved Entries` marker (and resolved entries below
* it), that active entries carry file:line or `(search: ...)` evidence, and that
* they do not cite retired-file evidence.
*
* This is the schema enforcer behind the footguns README contract: section order,
* the active/resolved boundary, and the evidence requirement are the invariants a
* reviewer relies on. It only reports diagnostic strings; it never mutates the
* bucket or throws on malformed input.
*/
import { EVIDENCE_PATTERN, parseMarkdownFrontmatter, stripStrikethrough, } from "./learning-loop-common.js";
/**
* Slice a footgun bucket body at each `## Footgun:` heading into discrete
* sections, capturing each section's source offset (so callers can compare it
* against the resolved marker) and its lowercased Status value.
*
* @param body - bucket markdown with frontmatter already stripped; section bounds run heading-to-next-heading
* @returns one section per heading in document order; status is null when the section has no Status field
*/
export function splitFootgunSections(body) {
const headings = Array.from(body.matchAll(/^##\s+Footgun:\s+(.+)$/gm), (match) => ({
title: (match[1] ?? "").trim(),
start: match.index,
}));
return headings.map((heading, index) => {
const end = headings[index + 1]?.start ?? body.length;
const content = body.slice(heading.start, end);
const statusMatch = content.match(/\*\*Status:\*\*\s*([^|\n]+)/i);
return {
title: heading.title,
start: heading.start,
content,
status: statusMatch?.[1] !== undefined
? statusMatch[1].trim().toLowerCase()
: null,
};
});
}
/** Detect whether a footgun section has file:line or (search: ...) evidence. */
function hasSectionEvidence(content) {
return EVIDENCE_PATTERN.test(content) || /\(search:/i.test(content);
}
/** Check one active footgun section for placement, evidence, and retired-file patterns. */
function diagnoseActiveSection(section, path, resolvedIndex) {
const out = [];
if (resolvedIndex !== -1 && section.start > resolvedIndex) {
out.push(`${path} has active footgun "${section.title}" below ## Resolved Entries`);
}
if (!hasSectionEvidence(section.content)) {
out.push(`${path} active footgun "${section.title}" missing file:line or (search: ...) evidence`);
}
const cleaned = stripStrikethrough(section.content);
if (/\(file retired/i.test(cleaned) || /\bretired in v\d/i.test(cleaned)) {
out.push(`${path} active footgun "${section.title}" uses retired-file evidence`);
}
return out;
}
/** Check that resolved footguns live below the bucket's resolved marker. */
function diagnoseResolvedSection(section, path, resolvedIndex) {
if (resolvedIndex === -1) {
return [
`${path} has resolved footgun "${section.title}" but no ## Resolved Entries marker`,
];
}
if (section.start < resolvedIndex) {
return [
`${path} has resolved footgun "${section.title}" above ## Resolved Entries`,
];
}
return [];
}
/** Check one footgun section's schema + (if active) its placement and evidence. */
function diagnoseFootgunSection(section, path, resolvedIndex) {
if (section.status === null) {
return [`${path} footgun "${section.title}" missing Status field`];
}
// Schema: status must be exactly "active" or "resolved" (machine-simple per footguns/README.md:14)
if (section.status !== "active" && section.status !== "resolved") {
return [
`${path} footgun "${section.title}" has non-canonical status "${section.status}" (expected "active" or "resolved")`,
];
}
if (section.status === "active") {
return diagnoseActiveSection(section, path, resolvedIndex);
}
return diagnoseResolvedSection(section, path, resolvedIndex);
}
/**
* Audit one footgun bucket and return every structure, schema, and evidence
* violation as a human-readable diagnostic. The path is woven into each message
* so a caller aggregating many buckets can attribute findings without extra state.
*
* @param path - bucket path used to prefix each diagnostic for attribution
* @param content - raw bucket file content; frontmatter is parsed off internally
* @returns one diagnostic per violation; an empty array means the bucket is well-formed
*/
export function collectFootgunStructureDiagnostics(path, content) {
const { body } = parseMarkdownFrontmatter(content);
const resolvedIndex = body.indexOf("## Resolved Entries");
const sections = splitFootgunSections(body);
return sections.flatMap((section) => diagnoseFootgunSection(section, path, resolvedIndex));
}
//# sourceMappingURL=learning-loop-sections.js.map