diff --git a/.agents/skills/create-kandev-plugin/SKILL.md b/.agents/skills/create-kandev-plugin/SKILL.md index 55e5054baa..a1571861de 100644 --- a/.agents/skills/create-kandev-plugin/SKILL.md +++ b/.agents/skills/create-kandev-plugin/SKILL.md @@ -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 when the public docs do not answer a low-level compatibility question. -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. +The frontend and backend matrices in `docs/public/plugins-authoring.md`, +together with `apps/web/lib/plugins/types.ts` and `apps/backend/pkg/pluginsdk`, +are the authoritative record of what exists today. Read them rather than +asserting from memory that a hook is missing, and do not publish a signature +they do not declare. When debugging a contract discrepancy, verify it at the implementation 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 the frontend or backend matrix, document inputs/props, capability and 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 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; @@ -168,11 +169,14 @@ for later. In the same change: `node scripts/validate-public-docs.mjs`, and a stale-reference search. Report the exact commands and results. -If a proposed hook is not implemented on the current branch, document it as -unavailable with the nearest supported recipe; do not publish a speculative -signature. If the hook is implemented but the matrix, recipe, fixture, or -authoritative contract is missing, the plugin change is not documentation -complete. +A hook absent from the relevant frontend/backend matrix and its corresponding +authoritative contract source does not exist yet; point the author at the +nearest supported recipe instead of publishing a speculative signature, and +do not record the gap as a durable list entry in this skill or an +`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 diff --git a/.github/workflows/lint-harness-files.yml b/.github/workflows/lint-harness-files.yml index 7a10d491cf..6d1dfdb753 100644 --- a/.github/workflows/lint-harness-files.yml +++ b/.github/workflows/lint-harness-files.yml @@ -28,6 +28,9 @@ on: - ".claude/rules/**/*.md" - ".cursor/rules/**/*.mdc" - ".claude/commands/**/*.md" + - "apps/web/lib/plugins/types.ts" + - "scripts/validate-plugin-api-claims.mjs" + - "scripts/validate-plugin-api-claims.test.mjs" pull_request: branches: [main] paths: @@ -55,6 +58,9 @@ on: - ".claude/rules/**/*.md" - ".cursor/rules/**/*.mdc" - ".claude/commands/**/*.md" + - "apps/web/lib/plugins/types.ts" + - "scripts/validate-plugin-api-claims.mjs" + - "scripts/validate-plugin-api-claims.test.mjs" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -79,3 +85,9 @@ jobs: - name: Lint harness files 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 diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 9c83ad8a05..21fa10f065 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -4,7 +4,7 @@ Scoped guidance for `apps/web/`. Repo-wide rules (commit format, code-quality li ## 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 diff --git a/scripts/validate-plugin-api-claims.mjs b/scripts/validate-plugin-api-claims.mjs new file mode 100644 index 0000000000..cbedf05752 --- /dev/null +++ b/scripts/validate-plugin-api-claims.mjs @@ -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 ` 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} 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} 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} 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} 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} 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} 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; + }); +} diff --git a/scripts/validate-plugin-api-claims.test.mjs b/scripts/validate-plugin-api-claims.test.mjs new file mode 100644 index 0000000000..fb3fe41fb8 --- /dev/null +++ b/scripts/validate-plugin-api-claims.test.mjs @@ -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} files Fixture files keyed by relative path. + * @returns {Promise} 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; + /** Authenticated, per-user key/value storage. */ + storage: PluginStorageApi; +} + +/** + * Named slot the host renders via \`\`. 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, []); +});