UNPKG

@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
/** * 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