491 lines
14 KiB
JavaScript
491 lines
14 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
);
|
|
const defaultTypesPath = path.join(
|
|
repoRoot,
|
|
"apps/web/lib/plugins/types.ts",
|
|
);
|
|
|
|
const SKIP_DIRS = new Set([
|
|
"node_modules",
|
|
".git",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
".next",
|
|
"target",
|
|
"vendor",
|
|
".claude",
|
|
]);
|
|
|
|
const CLAIM_PATTERNS = [
|
|
/\bdoes not expose\b/i,
|
|
/\bnot exposed\b/i,
|
|
/\babsent from\b/i,
|
|
/\b(?:is|are) not available\b/i,
|
|
/\bunavailable\b/i,
|
|
/\bdoes not (?:yet )?(?:exist|support|provide)\b/i,
|
|
/\bno\s+(?:[\w'-]+\s+){0,2}hook\b/i,
|
|
/\bdo not invent hooks such as\b/i,
|
|
];
|
|
|
|
/**
|
|
* Extract the `{ ... }` body of a top-level `export interface <name>` block.
|
|
*
|
|
* @param {string} source TypeScript source.
|
|
* @param {string} interfaceName Interface identifier to locate.
|
|
* @returns {string | null} The interface body, or null if not found.
|
|
*/
|
|
function extractInterfaceBody(source, interfaceName) {
|
|
const marker = `export interface ${interfaceName}`;
|
|
const markerIndex = source.indexOf(marker);
|
|
if (markerIndex === -1) return null;
|
|
const braceStart = source.indexOf("{", markerIndex + marker.length);
|
|
if (braceStart === -1) return null;
|
|
|
|
let depth = 1;
|
|
let i = braceStart + 1;
|
|
while (i < source.length && depth > 0) {
|
|
if (source[i] === "{") depth++;
|
|
else if (source[i] === "}") depth--;
|
|
i++;
|
|
}
|
|
return source.slice(braceStart + 1, i - 1);
|
|
}
|
|
|
|
/**
|
|
* Extract `register*` method names declared in a `PluginRegistry`-shaped body.
|
|
*
|
|
* @param {string} body Interface body text.
|
|
* @returns {Set<string>} Registered method names.
|
|
*/
|
|
function extractRegisterMethods(body) {
|
|
const names = new Set();
|
|
const re = /\b(register[A-Z]\w*)\s*\(/g;
|
|
let match;
|
|
while ((match = re.exec(body))) names.add(match[1]);
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Extract top-level property names from an interface body, skipping
|
|
* comments and properties nested inside inline object types.
|
|
*
|
|
* @param {string} body Interface body text.
|
|
* @returns {Set<string>} Top-level property names.
|
|
*/
|
|
function extractTopLevelProperties(body) {
|
|
const names = new Set();
|
|
let depth = 0;
|
|
let inBlockComment = false;
|
|
|
|
for (const rawLine of body.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
|
|
if (inBlockComment) {
|
|
if (line.includes("*/")) inBlockComment = false;
|
|
continue;
|
|
}
|
|
if (line.startsWith("/*")) {
|
|
if (!line.includes("*/")) inBlockComment = true;
|
|
continue;
|
|
}
|
|
if (line.startsWith("*") || line.startsWith("//")) continue;
|
|
|
|
if (depth === 0) {
|
|
const match = /^(\w+)\??\s*:/.exec(line);
|
|
if (match) names.add(match[1]);
|
|
}
|
|
|
|
for (const ch of line) {
|
|
if (ch === "{") depth++;
|
|
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Extract PascalCase component names listed in the JSDoc comment
|
|
* immediately (contiguously, with no other declaration in between) above a
|
|
* given property declaration.
|
|
*
|
|
* @param {string} body Interface body text.
|
|
* @param {string} propertyName Property whose leading comment to read.
|
|
* @returns {Set<string>} Component names mentioned in the comment.
|
|
*/
|
|
function extractCommentComponentNames(body, propertyName) {
|
|
const names = new Set();
|
|
const propertyRe = new RegExp(`^${propertyName}\\??\\s*:`);
|
|
let pendingComment = [];
|
|
let inBlockComment = false;
|
|
|
|
for (const rawLine of body.split("\n")) {
|
|
const line = rawLine.trim();
|
|
|
|
if (inBlockComment) {
|
|
pendingComment.push(line);
|
|
if (line.includes("*/")) inBlockComment = false;
|
|
continue;
|
|
}
|
|
if (line.startsWith("/*")) {
|
|
pendingComment = [line];
|
|
inBlockComment = !line.includes("*/");
|
|
continue;
|
|
}
|
|
if (line.startsWith("//")) {
|
|
pendingComment = [];
|
|
continue;
|
|
}
|
|
if (line === "") continue;
|
|
|
|
if (propertyRe.test(line)) {
|
|
const commentText = pendingComment
|
|
.map((commentLine) => commentLine.replace(/^\/?\*+\/?/, " "))
|
|
.join(" ");
|
|
const groups = commentText.match(/\(([^)]*)\)/g) || [];
|
|
for (const group of groups) {
|
|
for (const token of group.slice(1, -1).split(",")) {
|
|
const trimmed = token.trim();
|
|
if (/^[A-Z][A-Za-z0-9]*$/.test(trimmed)) names.add(trimmed);
|
|
}
|
|
}
|
|
}
|
|
pendingComment = [];
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Extract quoted named-slot string literals from the JSDoc comment above a
|
|
* given type alias.
|
|
*
|
|
* @param {string} source Full TypeScript source.
|
|
* @param {string} typeName Type alias whose leading comment to read.
|
|
* @returns {Set<string>} Slot name literals.
|
|
*/
|
|
function extractSlotNames(source, typeName) {
|
|
const names = new Set();
|
|
const declarationRe = new RegExp(`^export type ${typeName}\\b`);
|
|
let pendingComment = [];
|
|
let inBlockComment = false;
|
|
|
|
for (const rawLine of source.split("\n")) {
|
|
const line = rawLine.trim();
|
|
|
|
if (inBlockComment) {
|
|
pendingComment.push(line);
|
|
if (line.includes("*/")) inBlockComment = false;
|
|
continue;
|
|
}
|
|
if (line.startsWith("/*")) {
|
|
pendingComment = [line];
|
|
inBlockComment = !line.includes("*/");
|
|
continue;
|
|
}
|
|
if (line.startsWith("//")) {
|
|
pendingComment = [];
|
|
continue;
|
|
}
|
|
if (line === "") continue;
|
|
|
|
if (declarationRe.test(line)) {
|
|
const commentText = pendingComment.join(" ");
|
|
const re = /"([a-z][a-z0-9-]*)"/g;
|
|
let m;
|
|
while ((m = re.exec(commentText))) names.add(m[1]);
|
|
return names;
|
|
}
|
|
pendingComment = [];
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Mechanically derive the set of live plugin API names from the frontend
|
|
* contract file — no curated list, so nothing here can go stale.
|
|
*
|
|
* @param {string} typesSource Contents of `lib/plugins/types.ts`.
|
|
* @returns {Set<string>} Live API, property, component, and slot names.
|
|
*/
|
|
export function extractLiveNames(typesSource) {
|
|
const names = new Set();
|
|
|
|
const registryBody = extractInterfaceBody(typesSource, "PluginRegistry");
|
|
if (registryBody) {
|
|
for (const n of extractRegisterMethods(registryBody)) names.add(n);
|
|
}
|
|
|
|
const hostApiBody = extractInterfaceBody(typesSource, "PluginHostApi");
|
|
if (hostApiBody) {
|
|
for (const n of extractTopLevelProperties(hostApiBody)) names.add(n);
|
|
for (const n of extractCommentComponentNames(hostApiBody, "ui")) {
|
|
names.add(n);
|
|
}
|
|
}
|
|
|
|
for (const n of extractSlotNames(typesSource, "PluginSlotName")) {
|
|
names.add(n);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Replace fenced code blocks with blank lines, preserving line numbers.
|
|
*
|
|
* @param {string} content Markdown content.
|
|
* @returns {string} Content with fenced code block bodies blanked out.
|
|
*/
|
|
function stripFencedCodeBlocks(content) {
|
|
const lines = content.split("\n");
|
|
let inFence = false;
|
|
return lines
|
|
.map((line) => {
|
|
if (/^\s*```/.test(line)) {
|
|
inFence = !inFence;
|
|
return "";
|
|
}
|
|
return inFence ? "" : line;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
/** Matches a line that always starts a fresh markdown block (list item, blockquote, heading, or table row). */
|
|
const BLOCK_START_RE = /^(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\|.*\|$)/;
|
|
|
|
/**
|
|
* Split content into paragraphs, each carrying the 1-indexed line number it
|
|
* starts on. A blank line always ends a paragraph. A list item, blockquote,
|
|
* heading, or table row always starts a fresh paragraph — it never merges
|
|
* with a sibling item — while its own wrapped continuation lines (plain text
|
|
* with no marker) merge forward into it, so a claim wrapped across lines
|
|
* within one block is still matched as one unit.
|
|
*
|
|
* @param {string} content Markdown content (fenced code already stripped).
|
|
* @returns {Array<{line: number, text: string}>} Paragraphs.
|
|
*/
|
|
function splitParagraphs(content) {
|
|
const lines = content.split("\n");
|
|
const paragraphs = [];
|
|
let current = [];
|
|
let startLine = null;
|
|
|
|
function flush() {
|
|
if (current.length) {
|
|
paragraphs.push({ line: startLine, text: current.join(" ") });
|
|
}
|
|
current = [];
|
|
startLine = null;
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
if (trimmed === "") {
|
|
flush();
|
|
continue;
|
|
}
|
|
if (BLOCK_START_RE.test(trimmed)) flush();
|
|
if (startLine === null) startLine = i + 1;
|
|
current.push(trimmed);
|
|
}
|
|
flush();
|
|
|
|
return paragraphs;
|
|
}
|
|
|
|
/**
|
|
* Find paragraphs asserting that some API is unavailable.
|
|
*
|
|
* @param {string} content Markdown/file content.
|
|
* @returns {Array<{line: number, text: string}>} Paragraphs matching an
|
|
* absence-claim pattern.
|
|
*/
|
|
export function findAbsenceClaims(content) {
|
|
const paragraphs = splitParagraphs(stripFencedCodeBlocks(content));
|
|
return paragraphs.filter((p) =>
|
|
CLAIM_PATTERNS.some((pattern) => pattern.test(p.text)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Split prose into sentence-sized claim scopes. API names elsewhere in the
|
|
* same wrapped Markdown paragraph must not inherit an unrelated absence claim.
|
|
* Dots inside identifiers such as `host.storage` are preserved because a
|
|
* boundary requires following whitespace or the end of the paragraph.
|
|
*
|
|
* @param {string} text Paragraph text.
|
|
* @returns {string[]} Sentence-sized fragments.
|
|
*/
|
|
function splitClaimScopes(text) {
|
|
return text.split(/[.!?](?:\s+|$)/).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* API claims in harness Markdown name contracts as inline code. Restricting
|
|
* matching to those spans prevents common host words such as `context` from
|
|
* being attributed to unrelated phrases like "thread context is unavailable."
|
|
*
|
|
* @param {string} scope Sentence-sized absence-claim scope.
|
|
* @param {RegExp} boundary Escaped live-name matcher.
|
|
* @returns {boolean} Whether an inline-code span names the live API.
|
|
*/
|
|
function scopeMentionsLiveAPI(scope, boundary) {
|
|
for (const match of scope.matchAll(/`([^`]+)`/g)) {
|
|
if (boundary.test(match[1])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve the fixed set of harness files this guard scans, de-duplicated by
|
|
* physical (symlink-resolved) path.
|
|
*
|
|
* @param {string} root Repository root.
|
|
* @returns {Promise<string[]>} Absolute, de-duplicated file paths.
|
|
*/
|
|
async function resolveScanFiles(root) {
|
|
const found = [];
|
|
|
|
async function walkForAgentsMd(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
await walkForAgentsMd(full);
|
|
} else if (entry.isFile() && entry.name === "AGENTS.md") {
|
|
found.push(full);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function walkMarkdown(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
await walkMarkdown(full);
|
|
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
found.push(full);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function listMarkdown(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
found.push(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
await walkForAgentsMd(root);
|
|
await walkMarkdown(path.join(root, ".agents/skills"));
|
|
await walkMarkdown(path.join(root, ".agents/agents"));
|
|
await listMarkdown(path.join(root, "docs/plans/plugins"));
|
|
|
|
const seen = new Map();
|
|
for (const file of found) {
|
|
let real;
|
|
try {
|
|
real = await fs.realpath(file);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!seen.has(real)) seen.set(real, file);
|
|
}
|
|
return [...seen.keys()];
|
|
}
|
|
|
|
/**
|
|
* Validate that no harness file claims a live plugin API is unavailable.
|
|
*
|
|
* @param {object} [options] Validation options.
|
|
* @param {string} [options.repoRoot] Repository root to scan.
|
|
* @param {string} [options.typesPath] Path to `lib/plugins/types.ts`.
|
|
* @param {string[]} [options.scanFiles] Explicit file list, overriding the
|
|
* default harness scan set (used by tests).
|
|
* @returns {Promise<{findings: Array<{file: string, line: number, api: string}>}>}
|
|
*/
|
|
export async function validatePluginApiClaims({
|
|
repoRoot: root = repoRoot,
|
|
typesPath = defaultTypesPath,
|
|
scanFiles,
|
|
} = {}) {
|
|
const typesSource = await fs.readFile(typesPath, "utf8");
|
|
const liveNames = extractLiveNames(typesSource);
|
|
const files = scanFiles ?? (await resolveScanFiles(root));
|
|
|
|
const findings = [];
|
|
for (const file of files) {
|
|
const content = await fs.readFile(file, "utf8");
|
|
const claims = findAbsenceClaims(content);
|
|
if (!claims.length) continue;
|
|
|
|
const relFile = path.relative(root, file);
|
|
for (const claim of claims) {
|
|
const claimScopes = splitClaimScopes(claim.text).filter((scope) =>
|
|
CLAIM_PATTERNS.some((pattern) => pattern.test(scope)),
|
|
);
|
|
for (const name of liveNames) {
|
|
const boundary = new RegExp(
|
|
`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
);
|
|
if (claimScopes.some((scope) => scopeMentionsLiveAPI(scope, boundary))) {
|
|
findings.push({ file: relFile, line: claim.line, api: name });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { findings };
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
validatePluginApiClaims()
|
|
.then(({ findings }) => {
|
|
if (!findings.length) {
|
|
console.log("No stale plugin API absence claims found.");
|
|
return;
|
|
}
|
|
for (const finding of findings) {
|
|
console.error(
|
|
`${finding.file}:${finding.line} — claims "${finding.api}" is unavailable, but lib/plugins/types.ts declares it`,
|
|
);
|
|
}
|
|
process.exitCode = 1;
|
|
})
|
|
.catch((error) => {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|