Files
kandev/scripts/validate-plugin-api-claims.test.mjs

219 lines
6.8 KiB
JavaScript

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 keeps unrelated absence prose sentence-scoped", async () => {
const dir = await createFixture({
"types.ts": FIXTURE_TYPES_TS,
"AGENTS.md": `# Frontend
The Host can be unavailable during construction, so resolve it when handling
work. Honor context cancellation and use host.storage for user settings.
`,
});
const { findings } = await validatePluginApiClaims({
repoRoot: dir,
typesPath: path.join(dir, "types.ts"),
scanFiles: [path.join(dir, "AGENTS.md")],
});
assert.deepEqual(findings, []);
});
test("validatePluginApiClaims ignores ordinary words that share an API name", async () => {
const dir = await createFixture({
"types.ts": FIXTURE_TYPES_TS,
"AGENTS.md": `# Reviews
Expand the review before deciding whether its thread context is unavailable.
`,
});
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, []);
});