fix(plugins): correct stale plugin-hook availability claims (#2309)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
This commit is contained in:
@@ -126,11 +126,11 @@ frontend contract pair is `docs/plans/plugins/PLUGIN-API.md` plus
|
|||||||
Read `docs/plans/plugins/GRPC-CONTRACT.md` when changing the wire contract or
|
Read `docs/plans/plugins/GRPC-CONTRACT.md` when changing the wire contract or
|
||||||
when the public docs do not answer a low-level compatibility question.
|
when the public docs do not answer a low-level compatibility question.
|
||||||
|
|
||||||
The current frontend branch does not expose `registerTaskPanel`,
|
The frontend and backend matrices in `docs/public/plugins-authoring.md`,
|
||||||
`registerTaskMenuAction`, `host.storage`, `RichTextEditor`,
|
together with `apps/web/lib/plugins/types.ts` and `apps/backend/pkg/pluginsdk`,
|
||||||
`RichTextReadOnly`, or a Kanban-card injection hook. Use the supported slots,
|
are the authoritative record of what exists today. Read them rather than
|
||||||
routes, Host state, and shared store documented in the guide; do not invent
|
asserting from memory that a hook is missing, and do not publish a signature
|
||||||
future signatures.
|
they do not declare.
|
||||||
|
|
||||||
When debugging a contract discrepancy, verify it at the implementation
|
When debugging a contract discrepancy, verify it at the implementation
|
||||||
boundary: manifest and package rules live under
|
boundary: manifest and package rules live under
|
||||||
@@ -156,7 +156,8 @@ for later. In the same change:
|
|||||||
3. Update `docs/public/plugins-authoring.md` in the same change: add the hook to
|
3. Update `docs/public/plugins-authoring.md` in the same change: add the hook to
|
||||||
the frontend or backend matrix, document inputs/props, capability and
|
the frontend or backend matrix, document inputs/props, capability and
|
||||||
lifecycle/cleanup behavior, and add a copy-pasteable recipe or maintained
|
lifecycle/cleanup behavior, and add a copy-pasteable recipe or maintained
|
||||||
fixture link. Remove it from the unavailable-API list when it becomes real.
|
fixture link. Adding the row to the matrix is the update — do not introduce
|
||||||
|
a separate "unavailable" list anywhere.
|
||||||
4. Update `docs/public/plugins-manifest.md` for capability/manifest changes and
|
4. Update `docs/public/plugins-manifest.md` for capability/manifest changes and
|
||||||
update `docs/public/plugins.md`, `docs/plugins-example.md`, or the relevant
|
update `docs/public/plugins.md`, `docs/plugins-example.md`, or the relevant
|
||||||
ADR when their claims or links change. Keep the public guide as a summary;
|
ADR when their claims or links change. Keep the public guide as a summary;
|
||||||
@@ -168,11 +169,14 @@ for later. In the same change:
|
|||||||
`node scripts/validate-public-docs.mjs`, and a stale-reference search. Report
|
`node scripts/validate-public-docs.mjs`, and a stale-reference search. Report
|
||||||
the exact commands and results.
|
the exact commands and results.
|
||||||
|
|
||||||
If a proposed hook is not implemented on the current branch, document it as
|
A hook absent from the relevant frontend/backend matrix and its corresponding
|
||||||
unavailable with the nearest supported recipe; do not publish a speculative
|
authoritative contract source does not exist yet; point the author at the
|
||||||
signature. If the hook is implemented but the matrix, recipe, fixture, or
|
nearest supported recipe instead of publishing a speculative signature, and
|
||||||
authoritative contract is missing, the plugin change is not documentation
|
do not record the gap as a durable list entry in this skill or an
|
||||||
complete.
|
`AGENTS.md` — the matrix and the contract sources are the only place
|
||||||
|
absence or presence is tracked. If the hook is implemented but the
|
||||||
|
matrix, recipe, fixture, or authoritative contract is missing, the plugin
|
||||||
|
change is not documentation complete.
|
||||||
|
|
||||||
## Create A New Repository
|
## Create A New Repository
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ on:
|
|||||||
- ".claude/rules/**/*.md"
|
- ".claude/rules/**/*.md"
|
||||||
- ".cursor/rules/**/*.mdc"
|
- ".cursor/rules/**/*.mdc"
|
||||||
- ".claude/commands/**/*.md"
|
- ".claude/commands/**/*.md"
|
||||||
|
- "apps/web/lib/plugins/types.ts"
|
||||||
|
- "scripts/validate-plugin-api-claims.mjs"
|
||||||
|
- "scripts/validate-plugin-api-claims.test.mjs"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -55,6 +58,9 @@ on:
|
|||||||
- ".claude/rules/**/*.md"
|
- ".claude/rules/**/*.md"
|
||||||
- ".cursor/rules/**/*.mdc"
|
- ".cursor/rules/**/*.mdc"
|
||||||
- ".claude/commands/**/*.md"
|
- ".claude/commands/**/*.md"
|
||||||
|
- "apps/web/lib/plugins/types.ts"
|
||||||
|
- "scripts/validate-plugin-api-claims.mjs"
|
||||||
|
- "scripts/validate-plugin-api-claims.test.mjs"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -79,3 +85,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Lint harness files
|
- name: Lint harness files
|
||||||
run: python3 .github/scripts/lint-harness-files.py --all
|
run: python3 .github/scripts/lint-harness-files.py --all
|
||||||
|
|
||||||
|
- name: Test plugin API claim guard
|
||||||
|
run: node --test scripts/validate-plugin-api-claims.test.mjs
|
||||||
|
|
||||||
|
- name: Validate plugin API claims
|
||||||
|
run: node scripts/validate-plugin-api-claims.mjs
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ Scoped guidance for `apps/web/`. Repo-wide rules (commit format, code-quality li
|
|||||||
|
|
||||||
## Plugin authoring
|
## Plugin authoring
|
||||||
|
|
||||||
For plugin UI work, begin with the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md). Follow: choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test. The frontend contract pair is `../../docs/plans/plugins/PLUGIN-API.md` plus `lib/plugins/types.ts`; concrete shared Host UI exports are in `lib/plugins/host-api.ts`, and registration/cleanup behavior is in `lib/plugins/registry.ts` and `lib/plugins/host.ts`. Keep the guide and that contract pair synchronized; do not invent hooks such as task panels, task-menu actions, per-user `host.storage`, rich-text components, or Kanban-card injection when they are absent from the current source.
|
For plugin UI work, begin with the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md). Follow: choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test. The frontend contract pair is `../../docs/plans/plugins/PLUGIN-API.md` plus `lib/plugins/types.ts`; concrete shared Host UI exports are in `lib/plugins/host-api.ts`, and registration/cleanup behavior is in `lib/plugins/registry.ts` and `lib/plugins/host.ts`. Treat that pair as the source of truth for which hooks exist, and extend it in the same change rather than shipping a signature it does not declare.
|
||||||
|
|
||||||
## UI Components
|
## UI Components
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
for (const name of liveNames) {
|
||||||
|
const boundary = new RegExp(
|
||||||
|
`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
||||||
|
);
|
||||||
|
if (boundary.test(claim.text)) {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { after } from "node:test";
|
||||||
|
import {
|
||||||
|
extractLiveNames,
|
||||||
|
findAbsenceClaims,
|
||||||
|
validatePluginApiClaims,
|
||||||
|
} from "./validate-plugin-api-claims.mjs";
|
||||||
|
|
||||||
|
const tempDirs = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an isolated repo-shaped fixture with a `types.ts` and harness files.
|
||||||
|
*
|
||||||
|
* @param {Record<string, string>} files Fixture files keyed by relative path.
|
||||||
|
* @returns {Promise<string>} Temporary fixture directory (acts as repoRoot).
|
||||||
|
*/
|
||||||
|
async function createFixture(files) {
|
||||||
|
const dir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "kandev-plugin-api-claims-"),
|
||||||
|
);
|
||||||
|
tempDirs.push(dir);
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(files).map(async ([file, content]) => {
|
||||||
|
const target = path.join(dir, file);
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await fs.writeFile(target, content);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIXTURE_TYPES_TS = `
|
||||||
|
export interface PluginRegistry {
|
||||||
|
registerTaskPanel(registration: TaskPanelRegistration): void;
|
||||||
|
registerTaskMenuAction(registration: TaskMenuActionRegistration): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostApi {
|
||||||
|
/**
|
||||||
|
* Curated subset of \`@kandev/ui\` components (Button, Card) plus
|
||||||
|
* first-party app UI (RichTextEditor, RichTextReadOnly).
|
||||||
|
*/
|
||||||
|
ui: Record<string, unknown>;
|
||||||
|
/** Authenticated, per-user key/value storage. */
|
||||||
|
storage: PluginStorageApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named slot the host renders via \`<PluginSlot name .../>\`. Initial slots:
|
||||||
|
* "task-card-indicators" (small icon/badge rendered beside the PR status
|
||||||
|
* icon on every kanban card).
|
||||||
|
*/
|
||||||
|
export type PluginSlotName = string;
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("extractLiveNames finds register* methods, host properties, ui components, and slot names", () => {
|
||||||
|
const names = extractLiveNames(FIXTURE_TYPES_TS);
|
||||||
|
assert.ok(names.has("registerTaskPanel"));
|
||||||
|
assert.ok(names.has("registerTaskMenuAction"));
|
||||||
|
assert.ok(names.has("storage"));
|
||||||
|
assert.ok(names.has("RichTextEditor"));
|
||||||
|
assert.ok(names.has("RichTextReadOnly"));
|
||||||
|
assert.ok(names.has("task-card-indicators"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractLiveNames does not invent names absent from the fixture", () => {
|
||||||
|
const names = extractLiveNames(FIXTURE_TYPES_TS);
|
||||||
|
assert.ok(!names.has("registerNotAHook"));
|
||||||
|
assert.ok(!names.has("nonexistentSlot"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findAbsenceClaims matches the deleted stale paragraph as one claim", () => {
|
||||||
|
const content = `# Some skill
|
||||||
|
|
||||||
|
The current frontend branch does not expose \`registerTaskPanel\`,
|
||||||
|
\`registerTaskMenuAction\`, \`host.storage\`, \`RichTextEditor\`,
|
||||||
|
\`RichTextReadOnly\`, or a Kanban-card injection hook. Use the supported slots,
|
||||||
|
routes, Host state, and shared store documented in the guide; do not invent
|
||||||
|
future signatures.
|
||||||
|
`;
|
||||||
|
const claims = findAbsenceClaims(content);
|
||||||
|
assert.equal(claims.length, 1);
|
||||||
|
assert.equal(claims[0].line, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findAbsenceClaims ignores ordinary prose that just mentions an API name", () => {
|
||||||
|
const content = `# Some skill
|
||||||
|
|
||||||
|
Use \`registerTaskPanel\` to add a task panel. See the authoring guide for the
|
||||||
|
full recipe.
|
||||||
|
`;
|
||||||
|
const claims = findAbsenceClaims(content);
|
||||||
|
assert.equal(claims.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validatePluginApiClaims reports a finding for a restored stale paragraph", async () => {
|
||||||
|
const dir = await createFixture({
|
||||||
|
"types.ts": FIXTURE_TYPES_TS,
|
||||||
|
"AGENTS.md": `# Frontend
|
||||||
|
|
||||||
|
The current frontend branch does not expose \`registerTaskPanel\`,
|
||||||
|
\`registerTaskMenuAction\`, \`host.storage\`, \`RichTextEditor\`,
|
||||||
|
\`RichTextReadOnly\`, or a Kanban-card injection hook via
|
||||||
|
\`task-card-indicators\`. Use the supported slots, routes, Host state, and
|
||||||
|
shared store documented in the guide; do not invent future signatures.
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { findings } = await validatePluginApiClaims({
|
||||||
|
repoRoot: dir,
|
||||||
|
typesPath: path.join(dir, "types.ts"),
|
||||||
|
scanFiles: [path.join(dir, "AGENTS.md")],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(findings.length > 0);
|
||||||
|
const apis = findings.map((f) => f.api).sort();
|
||||||
|
assert.ok(apis.includes("registerTaskPanel"));
|
||||||
|
assert.ok(apis.includes("registerTaskMenuAction"));
|
||||||
|
assert.ok(apis.includes("storage"));
|
||||||
|
assert.ok(apis.includes("RichTextEditor"));
|
||||||
|
assert.ok(apis.includes("RichTextReadOnly"));
|
||||||
|
assert.ok(apis.includes("task-card-indicators"));
|
||||||
|
for (const finding of findings) {
|
||||||
|
assert.equal(finding.file, "AGENTS.md");
|
||||||
|
assert.equal(typeof finding.line, "number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validatePluginApiClaims does not flag a genuinely absent API", async () => {
|
||||||
|
const dir = await createFixture({
|
||||||
|
"types.ts": FIXTURE_TYPES_TS,
|
||||||
|
"AGENTS.md": `# Frontend
|
||||||
|
|
||||||
|
This branch does not expose \`registerNotAHook\` yet. Use the supported hooks
|
||||||
|
documented in the guide instead.
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { findings } = await validatePluginApiClaims({
|
||||||
|
repoRoot: dir,
|
||||||
|
typesPath: path.join(dir, "types.ts"),
|
||||||
|
scanFiles: [path.join(dir, "AGENTS.md")],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(findings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validatePluginApiClaims does not false-positive on ordinary documentation", async () => {
|
||||||
|
const dir = await createFixture({
|
||||||
|
"types.ts": FIXTURE_TYPES_TS,
|
||||||
|
"AGENTS.md": `# Frontend
|
||||||
|
|
||||||
|
Call \`registerTaskPanel\` from \`initialize\` to add a task panel, and use
|
||||||
|
\`host.storage\` for small per-user values. See the authoring guide's matrix
|
||||||
|
for the full contract.
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { findings } = await validatePluginApiClaims({
|
||||||
|
repoRoot: dir,
|
||||||
|
typesPath: path.join(dir, "types.ts"),
|
||||||
|
scanFiles: [path.join(dir, "AGENTS.md")],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(findings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validatePluginApiClaims exits clean against the real kandev tree", async () => {
|
||||||
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||||
|
const { findings } = await validatePluginApiClaims({ repoRoot });
|
||||||
|
assert.deepEqual(findings, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user