fix(plugins): correct stale plugin-hook availability claims (#2309)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
This commit is contained in:
Alassane
2026-08-10 05:14:46 -04:00
committed by GitHub
parent 258840fd9f
commit 6aa967f873
5 changed files with 667 additions and 12 deletions
+15 -11
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
+458
View File
@@ -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;
});
}
+181
View File
@@ -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, []);
});