#!/usr/bin/env node import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { HARNESS, isLocalTestArtifact, isSecretScanIgnored, rootPkg, secretScanIgnorePaths, staticCheck, } from "./extensions.mjs"; import { ROOT } from "./lib/common.mjs"; for (const path of [ ".pi/tmp/debug-fmt.log", "extensions/fast/__tests__/.debug-crO6jp/.test-home/.pi/agent/auth.json", "extensions/pfui/.pfui-warned-ABC/index.ts", ".notes-debug-ABC/.test-state/pi-ext/debug/notes/trace.jsonl", ]) assert(isLocalTestArtifact(path), `${path} should be blocked`); for (const path of [ ".pi/skills/modernize/SKILL.md", "extensions/fast/__tests__/debug.test.ts", "extensions/fast/src/pi-ext-debug.ts", ]) assert(!isLocalTestArtifact(path), `${path} should remain tracked`); function writeExtension(root, name, overrides, index = "") { const ext = join(root, name); mkdirSync(join(ext, "__tests__"), { recursive: true }); const pkg = { name: `@bugabinga/pi-ext-${name}`, private: true, type: "module", main: "index.ts", license: "MIT", peerDependencies: { "@earendil-works/pi-coding-agent": "*" }, devDependencies: { [HARNESS]: "^0.6.1" }, ...overrides, }; for (const [key, value] of Object.entries(overrides)) if (value === null) delete pkg[key]; writeFileSync(join(ext, "package.json"), JSON.stringify(pkg), "utf8"); writeFileSync( join(ext, "index.ts"), `${index}import { dbg } from "./src/debug.ts";\ndbg?.("session.start");\n`, "utf8", ); mkdirSync(join(ext, "src"), { recursive: true }); writeFileSync( join(ext, "src", "debug.ts"), 'import { defineDebug } from "./pi-ext-debug.ts";\nexport const { dbg, span, closeDebug } = defineDebug({ events: ["session.start"], spans: [], fields: {} } as const);\n', "utf8", ); writeFileSync(join(ext, "README.md"), `# ${name}\n`, "utf8"); writeFileSync(join(ext, "__tests__", "harness.test.ts"), "", "utf8"); return ext; } function withTemp(fn) { const dir = mkdtempSync(join(tmpdir(), "pi-ext-static-")); try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } withTemp((root) => { const ext = writeExtension(root, "package-script", { scripts: { test: "vitest run" }, }); assert( staticCheck(ext, { root }).includes( "package-script: package scripts are prohibited; use extension-local Mise tasks", ), ); }); withTemp((root) => { const ext = writeExtension( root, "typed", { peerDependencies: { "@earendil-works/pi-coding-agent": "*", typebox: "^1.0.0", }, devDependencies: { [HARNESS]: "^0.6.1", typebox: "^1.0.0" }, optionalDependencies: { typebox: "^1.0.0" }, }, 'import { Type } from "typebox";\nvoid Type;\n', ); const errors = staticCheck(ext, { root }); assert( errors.includes( "typed: Pi package typebox peerDependency range must be '*'", ), ); assert( errors.includes( "typed: Pi package typebox belongs only in peerDependencies, not devDependencies", ), ); assert( errors.includes( "typed: Pi package typebox belongs only in peerDependencies, not optionalDependencies", ), ); }); withTemp((root) => { writeExtension( root, "old-pi-ai", { peerDependencies: { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", }, devDependencies: { [HARNESS]: "^0.6.1" }, }, 'import { complete, StringEnum } from "@earendil-works/pi-ai";\nvoid [complete, StringEnum];\n', ); const errors = staticCheck(join(root, "old-pi-ai"), { root }); assert( errors.includes( "old-pi-ai: moved pi-ai root API import complete in index.ts; use @earendil-works/pi-ai/compat or a specific API entrypoint", ), ); }); withTemp((root) => { const ext = writeExtension( root, "raw-settings", {}, 'export default (pi: any, m: any) => { pi.registerFlag("x", {}); pi.getFlag("x"); m.getGlobalSettings(); m?.getProjectSettings(); };\n', ); writeFileSync( join(ext, "src", "pi-ext-settings.ts"), "export const ok = (pi: any, m: any) => [pi.registerFlag(), pi.getFlag(), m.getGlobalSettings(), m.getProjectSettings()];\n", "utf8", ); const errors = staticCheck(ext, { root }).filter((error) => error.includes("direct "), ); assert.deepEqual(errors, [ "raw-settings: direct registerFlag() in index.ts; register flags through registerSettingFlag from src/pi-ext-settings.ts", "raw-settings: direct getFlag() in index.ts; read flags through resolveSetting from src/pi-ext-settings.ts", "raw-settings: direct getGlobalSettings() in index.ts; read settings through resolveSetting from src/pi-ext-settings.ts", "raw-settings: direct getProjectSettings() in index.ts; read settings through resolveSetting from src/pi-ext-settings.ts", ]); }); withTemp((root) => { const ext = writeExtension(root, "missing-debug", {}); rmSync(join(ext, "src", "debug.ts")); assert( staticCheck(ext, { root }).includes( "missing-debug: missing handwritten src/debug.ts schema", ), ); }); withTemp((root) => { const ext = writeExtension(root, "bad-debug", {}); writeFileSync( join(ext, "src", "debug.ts"), "// defineDebug is not bound.\nexport const dbg = undefined;\n", ); assert( staticCheck(ext, { root }).includes( "bad-debug: src/debug.ts must export dbg and closeDebug bound by defineDebug", ), ); }); withTemp((root) => { const ext = writeExtension(root, "direct-debug", {}); writeFileSync( join(ext, "index.ts"), 'import * as direct from "./src/pi-ext-debug.js";\nexport { dbg } from "./src/pi-ext-debug.ts";\nvoid import("./src/pi-ext-debug.ts");\nvoid direct;\n', ); const errors = staticCheck(ext, { root }); assert( errors.some((error) => error.includes("runtime import of generated debug module"), ), ); assert( errors.includes( "direct-debug: runtime code must import its handwritten src/debug.ts schema", ), ); }); withTemp((root) => { const ext = writeExtension(root, "browser", {}); mkdirSync(join(root, "test")); writeFileSync( join(root, "test", "playwright.base.ts"), "export default {};\n", ); writeFileSync( join(ext, "playwright.config.ts"), 'import base from "../test/playwright.base.ts";\nvoid base;\n', ); assert.deepEqual(staticCheck(ext, { root }), []); }); withTemp((root) => { writeExtension( root, "bad-config", { devDependencies: { [HARNESS]: "^0.6.1" } }, [ 'import path from "node:path";', 'const a = path.join(process.cwd(), "settings.json");', "const d = (pi as SettingsAPI).settingsManager?.getSettings?.();", "void [a, d];", ].join("\n"), ); const errors = staticCheck(join(root, "bad-config"), { root }); assert(errors.some((e) => e.includes("workspace-root settings.json"))); assert(errors.some((e) => e.includes("private settingsManager access"))); }); withTemp((root) => { writeExtension( root, "flat-prompt", { devDependencies: { [HARNESS]: "^0.6.1" } }, [ 'pi.on("before_agent_start", (event) => {', ' event.systemPromptOptions.forceSystemPrompt = "forced";', ' return { systemPrompt: event.systemPrompt + " flat" };', "});", ].join("\n"), ); const errors = staticCheck(join(root, "flat-prompt"), { root }); assert( errors.includes( "flat-prompt: flat before_agent_start system prompt override is forbidden; mutate event.systemPromptOptions.sections in index.ts", ), ); }); withTemp((root) => { mkdirSync(join(root, "scripts")); writeFileSync( join(root, "scripts", "extensions.json"), JSON.stringify({ secretScanIgnore: ["extensions/leaks/__tests__/ux.test.ts"], }), "utf8", ); assert.deepEqual([...secretScanIgnorePaths(root)].sort(), [ "extensions/leaks/__tests__/ux.test.ts", ]); assert.equal( isSecretScanIgnored( join(root, "extensions/leaks/__tests__/ux.test.ts"), new Set(["extensions/leaks/__tests__/ux.test.ts"]), root, ), true, ); assert.equal( isSecretScanIgnored( join(root, "extensions/leaks/index.ts"), new Set(["extensions/leaks/__tests__/ux.test.ts"]), root, ), false, ); }); // The workspace root exports nothing; Pi loads generated dist/ packages. assert.deepEqual(rootPkg().pi, {}); const cliFailure = spawnSync( process.execPath, ["scripts/extensions.mjs", "check", "__missing_extension__"], { cwd: ROOT, encoding: "utf8" }, ); assert.equal(cliFailure.status, 1); assert.match(cliFailure.stderr, /unknown extension/); assert.deepEqual( JSON.parse( readFileSync(join(ROOT, "extensions/the-system/package.json"), "utf8"), ).pi, { extensions: ["index.ts"], }, ); const legacyReferences = [ ["extensions", "super"].join("/"), ["pi-ext", "super"].join("-"), ]; const textExtensions = new Set([ ".html", ".json", ".md", ".mjs", ".toml", ".ts", ".yaml", ".yml", ]); function liveTextFiles(dir = ROOT) { const files = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) { if ( entry.name === ".git" || entry.name === ".jj" || entry.name === ".worktrees" || entry.name === "node_modules" || path === join(ROOT, "docs", "super") ) continue; files.push(...liveTextFiles(path)); } else if ( entry.isFile() && entry.name !== "CHANGELOG.md" && textExtensions.has(entry.name.slice(entry.name.lastIndexOf("."))) ) { files.push(path); } } return files; } withTemp((root) => { mkdirSync(join(root, ".worktrees", "branch"), { recursive: true }); writeFileSync( join(root, ".worktrees", "branch", "ignored.md"), legacyReferences.join("\n"), ); assert.deepEqual(liveTextFiles(root), []); }); const legacyReferenceViolations = liveTextFiles().flatMap((path) => { const content = readFileSync(path, "utf8"); return legacyReferences .filter((reference) => content.includes(reference)) .map((reference) => `${path}: forbidden live reference ${reference}`); }); assert.deepEqual(legacyReferenceViolations, []); withTemp((root) => { const ext = writeExtension(root, "test-secret", {}); writeFileSync( join(ext, "__tests__", "harness.test.ts"), 'const apiKey = "not-a-real-secret-value";\n', "utf8", ); assert( staticCheck(ext, { root }).includes( "test-secret: possible secret in __tests__/harness.test.ts", ), ); }); console.log("OK static-check tests");