@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.
237 lines • 9.1 kB
JavaScript
/**
* Snapshot-claim lint (M06b).
*
* Validates numeric claims inside release-frozen documents against the
* matching `workflow/manifest-snapshots/vX.Y.Z.json` snapshot. Two surfaces:
*
* 1. `CHANGELOG.md` - parsed section-by-section via `## vX.Y.Z` headers; each
* section validated against its own snapshot (sections without a snapshot
* are skipped).
* 2. `.goat-flow/scratchpad/release.md` - single-version draft release notes;
* version extracted from the `# GOAT Flow vX.Y.Z Release Notes` H1;
* validated against that version's snapshot.
*
* This stays in TypeScript so snapshot-claim checks share the audit fact model
* and avoid a separate shell parser for release-note text.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getTemplatePath } from "../paths.js";
const SNAPSHOT_CLAIMS = [
{
rule: "changelog-skills-canonical",
pattern: /\b(\d+)\s+canonical\s+skills?\b/gi,
field: "skills_total",
label: "canonical skills",
},
{
rule: "changelog-skill-templates",
pattern: /\b(\d+)\s+skill\s+templates?\b/gi,
field: "skills_total",
label: "skill templates",
},
{
rule: "changelog-setup-checks",
pattern: /\b(\d+)\s+project[-\s]wide\s+setup\s+checks?\b/gi,
field: "checks_setup",
label: "project-wide setup checks",
},
{
rule: "changelog-agent-checks",
pattern: /\b(\d+)\s+per-agent\s+checks?\b/gi,
field: "checks_agent",
label: "per-agent checks",
},
{
rule: "changelog-harness-checks",
pattern: /\b(\d+)\s+(?:AI\s+|advisory\s+)?harness(?:\s+completeness|\s+installation)?\s+checks?\b/gi,
field: "checks_harness",
label: "harness checks",
},
{
rule: "changelog-build-checks",
pattern: /\b(\d+)\s+build\s+checks?\b/gi,
field: "checks_build",
label: "build checks",
},
{
rule: "changelog-dashboard-views",
pattern: /\b(\d+)\s+(?:dashboard\s+)?views?\b/gi,
field: "dashboard_views_count",
label: "dashboard views",
},
{
rule: "changelog-presets",
pattern: /\b(\d+)\s+(?:workspace\s+)?presets?\b/gi,
field: "presets_count",
label: "presets",
},
];
/**
* Parse CHANGELOG.md into sections keyed by `## vX.Y.Z` headers.
* Mutates only an in-memory accumulator because preserving section order keeps finding lines stable.
*
* @param text Full CHANGELOG markdown content.
* @returns Versioned sections with body text and header line numbers.
*/
function parseChangelogSections(text) {
const lines = text.split(/\r?\n/);
const headerRe = /^##\s+v(\d+\.\d+\.\d+)(?:\b|\s|$)/;
const sections = [];
let current = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const headerMatch = headerRe.exec(line);
const captured = headerMatch?.[1];
if (captured) {
if (current) {
sections.push({
version: current.version,
startLine: current.startLine,
body: current.body.join("\n"),
});
}
current = { version: captured, startLine: i + 1, body: [] };
continue;
}
if (current)
current.body.push(line);
}
if (current) {
sections.push({
version: current.version,
startLine: current.startLine,
body: current.body.join("\n"),
});
}
return sections;
}
/**
* Load the snapshot facts for one release version.
* Swallows missing or malformed snapshot files and reports them as null so old release sections can be skipped.
*
* @param version Semver version without the leading `v`.
* @returns Snapshot facts for that version, or null when the file cannot be used.
*/
function loadSnapshotFacts(version) {
const path = getTemplatePath(join("workflow", "manifest-snapshots", `v${version}.json`));
if (!existsSync(path))
return null;
try {
const raw = JSON.parse(readFileSync(path, "utf-8"));
return raw.snapshot_facts ?? null;
}
catch {
return null;
}
}
/** Check whether a line starts or ends a fenced code block. */
function isFenceLine(line) {
return /^\s*```/.test(line);
}
/**
* Scan one CHANGELOG section body against its matching snapshot.
*
* @param section Parsed CHANGELOG section or whole-file release notes wrapper.
* @param snapshot Frozen facts for the section version.
* @param path Display path used in content findings.
* @returns Content findings for numeric claims that disagree with the snapshot.
*/
function scanSectionAgainstSnapshot(section, snapshot, path) {
const findings = [];
const lines = section.body.split(/\r?\n/);
const label = section.startLine === 0
? `${path} (v${section.version})`
: `CHANGELOG v${section.version} section`;
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (isFenceLine(line)) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock)
continue;
for (const claim of SNAPSHOT_CLAIMS) {
const rx = new RegExp(claim.pattern.source, claim.pattern.flags);
let match;
while ((match = rx.exec(line)) !== null) {
const claimed = Number(match[1]);
const actual = snapshot[claim.field];
if (claimed !== actual) {
findings.push({
severity: "warning",
rule: claim.rule,
path,
// startLine is the `## vX.Y.Z` header line (1-indexed) for CHANGELOG
// sections, or 0 for whole-file release.md scans. `i` is the offset
// into the body. +1 to skip the header itself / reach 1-indexed.
line: section.startLine + i + 1,
message: `${label} claims ${claimed} ${claim.label}; v${section.version} snapshot records ${actual}.`,
suggestion: `Update "${match[0]}" to match the snapshot value (${actual}), or capture a new snapshot if the facts legitimately changed for this release.`,
});
}
}
}
}
return findings;
}
/**
* Extract the version from a `# GOAT Flow vX.Y.Z Release Notes` H1.
* Reads only markdown text and returns null when no release-note header is present.
*
* @param text Release notes markdown.
* @returns Semver version without the leading `v`, or null when absent.
*/
function extractReleaseVersion(text) {
const versionMatch = /^#\s+(?:GOAT\s+Flow\s+)?v(\d+\.\d+\.\d+)\b/im.exec(text);
return versionMatch ? (versionMatch[1] ?? null) : null;
}
/** Scan a whole-document release notes file against its snapshot. */
function scanWholeFileAgainstSnapshot(text, snapshot, path, version) {
// Reuse section scan by wrapping the whole file as one section starting at line 1.
return scanSectionAgainstSnapshot({ version, startLine: 0, body: text }, snapshot, path);
}
/**
* Scan release-note surfaces against available manifest snapshots.
* Reports mismatched numeric claims as content findings and skips missing inputs because historical
* release text can exist before a matching snapshot catalog entry.
*
* @param ctx Audit context whose filesystem is rooted at the target project.
* @returns Findings plus the number of release-note files that were actually scanned.
*/
export function runSnapshotClaimChecks(ctx) {
const findings = [];
let filesScanned = 0;
// 1. CHANGELOG.md - section-by-section.
const changelogRel = "CHANGELOG.md";
if (ctx.fs.exists(changelogRel)) {
const text = ctx.fs.readFile(changelogRel);
if (text !== null) {
filesScanned++;
for (const section of parseChangelogSections(text)) {
const snapshot = loadSnapshotFacts(section.version);
if (!snapshot)
continue;
findings.push(...scanSectionAgainstSnapshot(section, snapshot, changelogRel));
}
}
}
// 2. .goat-flow/scratchpad/release.md - whole-file, version from H1.
const releaseRel = ".goat-flow/scratchpad/release.md";
if (ctx.fs.exists(releaseRel)) {
const text = ctx.fs.readFile(releaseRel);
if (text !== null) {
filesScanned++;
const version = extractReleaseVersion(text);
if (version) {
const snapshot = loadSnapshotFacts(version);
if (snapshot) {
findings.push(...scanWholeFileAgainstSnapshot(text, snapshot, releaseRel, version));
}
}
}
}
return { findings, filesScanned };
}
//# sourceMappingURL=check-snapshot-claims.js.map