Files
PaperClipAI/scripts/check-feature-map.mjs
T
2026-10-05 17:45:36 -05:00

179 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const sections = [
"Sub-features",
"How to get to it (user POV)",
"Driving it",
"Gotchas",
];
function walk(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? walk(path) : entry.isFile() ? [path] : [];
}).sort();
}
function withoutFences(text) {
return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm, "");
}
function entryIds(text, label, errors) {
const headings = [...text.matchAll(/^### (.+)$/gm)].map((match) => match[1]);
const ids = headings.map((heading) => /^`([a-z][a-z0-9-]*)`$/.exec(heading)?.[1]);
if (!ids.length || ids.some((id) => !id) || new Set(ids).size !== ids.length) {
errors.push(`${label}: use unique H3 entry-point IDs, e.g. ### \`task-thread\``);
}
return ids.filter(Boolean);
}
export function checkFeatureMap(repoRoot = defaultRoot) {
const errors = [];
const mapRoot = resolve(repoRoot, "feature-map");
const read = (path) => readFileSync(resolve(repoRoot, path), "utf8");
const normalize = (path) => relative(repoRoot, path).split(sep).join("/");
const isFile = (path) => existsSync(path) && statSync(path).isFile();
const localFile = (base, path) => {
const target = resolve(base, path);
const rel = relative(repoRoot, target);
return rel !== ".." && !rel.startsWith(`..${sep}`) && isFile(target);
};
const documents = readdirSync(mapRoot).filter((file) => file.endsWith(".md")).sort();
const recipes = documents.filter((file) => file !== "README.md");
if (!recipes.length) errors.push("feature-map: expected at least one recipe");
const index = read("feature-map/README.md");
const featureSection = index.split("\n## Features\n")[1]?.split("\n## ")[0] ?? "";
const indexed = [...featureSection.matchAll(/\]\(\.\/([\w-]+\.md)\)/g)].map((m) => m[1]);
for (const file of recipes) {
if (indexed.filter((link) => link === file).length !== 1) {
errors.push(`README.md: Features must link ${file} exactly once`);
}
}
for (const file of indexed) {
if (!recipes.includes(file)) errors.push(`README.md: unknown recipe ${file}`);
}
let entryPointCount = 0;
for (const file of documents) {
const raw = read(`feature-map/${file}`);
const text = withoutFences(raw);
// Map documents use inline Markdown links. Anchors and external links are
// allowed; local targets must be existing files inside the checkout.
for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) {
const path = target.split("#")[0];
if (!path || /^[a-z][a-z0-9+.-]*:/i.test(path)) continue;
if (!localFile(mapRoot, path)) errors.push(`${file}: broken local link ${target}`);
}
// Also validate test paths in runnable code blocks or backticked references.
// This deliberately checks filenames, not test titles or behavioral coverage.
const testRefs = [...raw.matchAll(/(?<![\w-])((?:\.github|ui|server|packages|tests|cli|scripts)\/[\w./-]+\.(?:test|spec)\.(?:[cm]?[jt]sx?))\b/g)]
.map((match) => match[1]);
for (const path of new Set(testRefs)) {
if (!localFile(repoRoot, path)) errors.push(`${file}: missing test ${path}`);
}
if (file === "README.md") continue;
if (!/^# [^\n]+\n\s*\n\S/.test(text)) {
errors.push(`${file}: start with an H1 and a description paragraph`);
}
const headings = [...text.matchAll(/^## (.+)$/gm)].map((match) => match[1]);
if (JSON.stringify(headings) !== JSON.stringify(sections)) {
errors.push(`${file}: expected sections in order: ${sections.join(", ")}`);
continue;
}
const bodies = sections.map((title) => text.split(`\n## ${title}\n`)[1]?.split("\n## ")[0] ?? "");
if (bodies.some((body) => !body.trim())) errors.push(`${file}: sections must not be empty`);
const subIds = [...bodies[0].matchAll(/^- `([a-z][a-z0-9-]*)`:/gm)].map((match) => match[1]);
if (!subIds.length || new Set(subIds).size !== subIds.length) {
errors.push(`${file}: Sub-features needs unique backticked IDs`);
}
const entries = entryIds(bodies[1], `${file} entry points`, errors);
const drives = entryIds(bodies[2], `${file} driving recipes`, errors);
entryPointCount += entries.length;
if (JSON.stringify([...entries].sort()) !== JSON.stringify([...drives].sort())) {
errors.push(`${file}: entry points and driving recipe IDs must match`);
}
if (!bodies[2].trimStart().startsWith("Preconditions:")) {
errors.push(`${file}: Driving it must start with Preconditions:`);
}
for (const id of drives) {
const recipe = bodies[2].split(`### \`${id}\`\n`)[1]?.split("\n### ")[0] ?? "";
if (!/^Automated: \S/m.test(recipe) || !/^Manual: \S/m.test(recipe)) {
errors.push(`${file}#${id}: describe Automated: evidence/gaps and Manual: steps`);
}
}
if (!testRefs.length) errors.push(`${file}: reference at least one existing test file`);
}
const inventory = JSON.parse(read("feature-map/coverage.json"));
if (inventory.version !== 1 || !Array.isArray(inventory.areas) || !inventory.areas.length) {
errors.push("coverage.json: expected version 1 and nonempty areas");
return { errors, recipes: recipes.length, entryPoints: entryPointCount, pages: 0 };
}
const pages = walk(resolve(repoRoot, "ui/src/pages"))
.filter((path) => path.endsWith(".tsx") && !/\.(test|spec|stories)\.tsx$/.test(path))
.map(normalize);
if (!pages.length) errors.push("coverage.json: expected UI page modules");
const accounted = new Set();
const areaIds = new Set();
for (const area of inventory.areas) {
if (!area || typeof area !== "object") {
errors.push("coverage.json: each area must be an object");
continue;
}
const label = `coverage.json: ${area.id ?? "unnamed area"}`;
if (typeof area.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(area.id) || areaIds.has(area.id)) {
errors.push(`${label}: expected a unique stable area ID`);
}
areaIds.add(area.id);
if (typeof area.title !== "string" || !area.title.trim()) errors.push(`${label}: title required`);
if (!["mapped", "partial", "unmapped"].includes(area.status)) errors.push(`${label}: invalid status`);
if (area.status !== "mapped" && (typeof area.gap !== "string" || !area.gap.trim())) {
errors.push(`${label}: explain the remaining gap`);
}
if (!Array.isArray(area.features)) {
errors.push(`${label}: features must be an array`);
} else {
if ((area.status === "unmapped") !== (area.features.length === 0)) {
errors.push(`${label}: mapped/partial areas need recipes; unmapped areas must have none`);
}
for (const feature of area.features) {
if (!recipes.includes(feature)) errors.push(`${label}: unknown recipe ${feature}`);
}
}
if (!Array.isArray(area.paths) || !area.paths.length) {
errors.push(`${label}: paths must be a nonempty array`);
continue;
}
for (const path of area.paths) {
if (!pages.includes(path)) errors.push(`${label}: stale or non-page path ${path}`);
if (accounted.has(path)) errors.push(`${label}: duplicate page ${path}`);
accounted.add(path);
}
}
for (const path of pages) {
if (!accounted.has(path)) errors.push(`coverage.json: unclassified page ${path}; map it or record a gap`);
}
return { errors, recipes: recipes.length, entryPoints: entryPointCount, pages: pages.length };
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const result = checkFeatureMap();
if (result.errors.length) {
console.error(result.errors.join("\n"));
process.exitCode = 1;
} else {
console.log(`Feature map OK: ${result.recipes} recipes, ${result.entryPoints} entry points, ${result.pages} page modules accounted for (not live verification).`);
}
} catch (error) {
console.error(`Feature map check failed: ${error.message}`);
process.exitCode = 1;
}
}