singularity-forge/scripts/check-sf-extension-inventory.mjs

386 lines
9.7 KiB
JavaScript
Raw Permalink Normal View History

import { execFileSync } from "node:child_process";
2026-05-05 14:46:18 +02:00
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
const repoRoot = resolve(import.meta.dirname, "..");
const sfRoot = join(repoRoot, "src", "resources", "extensions", "sf");
2026-05-05 00:03:47 +02:00
const extensionsRoot = join(repoRoot, "src", "resources", "extensions");
const manifestPath = join(sfRoot, "extension-manifest.json");
const RESOURCE_SOURCE_RE = /\.(?:js|mjs|cjs|json|md|yaml|yml|d\.ts)$/;
const DYNAMIC_TOOL_NAMES = ["bash", "edit", "read", "write"];
const BASE_DIRECT_COMMAND_NAMES = ["kill", "wt"];
const BASE_RUNTIME_COMMAND_NAMES = new Set([
"settings",
"model",
"scoped-models",
"export",
"share",
"copy",
"name",
"session",
"changelog",
"hotkeys",
"fork",
"tree",
"provider",
"login",
"logout",
"new",
"compact",
"resume",
"reload",
"thinking",
"edit-mode",
"terminal",
"stop",
"exit",
"quit",
]);
const HIDDEN_OR_ALIAS_SUBCOMMANDS = new Set([
"?",
2026-05-14 19:54:56 +02:00
"agent", // internal persistent-agent diagnostics, not part of the product command catalog
"auto",
"footer-config", // alias for /statusline
"h",
2026-05-09 16:36:04 +02:00
"stop", // platform-intercepted via BASE_RUNTIME_COMMANDS — never reaches SF handler
"undo-turn", // alias for /rewind
"wt",
]);
2026-05-05 14:46:18 +02:00
function _rel(path) {
return path.replace(`${repoRoot}/`, "");
}
function read(path) {
return readFileSync(path, "utf8");
}
2026-05-05 00:03:47 +02:00
function readJsonOrNull(path) {
try {
return JSON.parse(read(path));
} catch {
return null;
}
}
function uniqueSorted(values) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
function failSection(title, values) {
return [`${title}:`, ...values.map((value) => ` - ${value}`)].join("\n");
}
function ignoredResourceSources() {
const output = execFileSync(
"git",
2026-05-05 14:31:16 +02:00
[
"ls-files",
"-o",
"-i",
"--exclude-standard",
"src/resources/extensions/**",
],
{ cwd: repoRoot, encoding: "utf8" },
);
return output
.split(/\r?\n/)
.filter(Boolean)
.filter((path) => RESOURCE_SOURCE_RE.test(path));
}
function untrackedResourceSources() {
const output = execFileSync(
"git",
["ls-files", "-o", "--exclude-standard", "src/resources/extensions/**"],
{ cwd: repoRoot, encoding: "utf8" },
);
return output
.split(/\r?\n/)
.filter(Boolean)
.filter((path) => RESOURCE_SOURCE_RE.test(path));
}
2026-05-05 00:03:47 +02:00
function isLoadableExtensionDir(dirPath) {
const packageJsonPath = join(dirPath, "package.json");
if (existsSync(packageJsonPath)) {
const pkg = readJsonOrNull(packageJsonPath);
if (pkg?.pi && typeof pkg.pi === "object") {
return Array.isArray(pkg.pi.extensions) && pkg.pi.extensions.length > 0;
}
}
2026-05-05 14:31:16 +02:00
return (
existsSync(join(dirPath, "index.js")) ||
existsSync(join(dirPath, "index.ts"))
);
2026-05-05 00:03:47 +02:00
}
function manifestlessLoadableExtensions() {
return readdirSync(extensionsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => {
const dirPath = join(extensionsRoot, name);
return (
isLoadableExtensionDir(dirPath) &&
!existsSync(join(dirPath, "extension-manifest.json"))
);
})
.sort((a, b) => a.localeCompare(b));
}
function parseManifest() {
const raw = JSON.parse(read(manifestPath));
return {
tools: uniqueSorted(raw?.provides?.tools ?? []),
commands: uniqueSorted(raw?.provides?.commands ?? []),
};
}
function parseRegisteredTools() {
const files = [
"bootstrap/db-tools.js",
"bootstrap/exec-tools.js",
"bootstrap/journal-tools.js",
"bootstrap/judgment-tools.js",
"bootstrap/memory-tools.js",
"bootstrap/product-audit-tool.js",
"bootstrap/query-tools.js",
"subagent/index.js",
"tools/sift-search-tool.js",
];
const names = new Set(DYNAMIC_TOOL_NAMES);
for (const file of files) {
const source = read(join(sfRoot, file));
for (const match of source.matchAll(/\bname:\s*["`]([^"`]+)["`]/g)) {
names.add(match[1]);
}
}
return uniqueSorted(names);
}
function parseTopLevelCatalogCommands() {
const source = read(join(sfRoot, "commands", "catalog.js"));
const start = source.indexOf("export const TOP_LEVEL_SUBCOMMANDS");
const end = source.indexOf("const NESTED_COMPLETIONS");
if (start === -1 || end === -1 || end <= start) {
2026-05-05 14:31:16 +02:00
throw new Error(
"Could not locate TOP_LEVEL_SUBCOMMANDS in commands/catalog.js",
);
}
return uniqueSorted(
2026-05-05 14:31:16 +02:00
[...source.slice(start, end).matchAll(/\bcmd:\s*"([^"]+)"/g)].map(
(match) => match[1],
),
);
}
2026-05-14 20:04:53 +02:00
function parsePublicDirectCommands() {
const source = read(join(sfRoot, "commands", "catalog.js"));
const start = source.indexOf("export const PUBLIC_DIRECT_COMMANDS");
const end = source.indexOf("export const PUBLIC_TOP_LEVEL_SUBCOMMANDS");
if (start === -1 || end === -1 || end <= start) {
throw new Error(
"Could not locate PUBLIC_DIRECT_COMMANDS in commands/catalog.js",
);
}
return new Set(
[...source.slice(start, end).matchAll(/"([^"]+)"/g)].map(
(match) => match[1],
),
);
}
function parseHandledTopLevelCommands() {
const handlerFiles = [
"core.js",
"autonomous.js",
"parallel.js",
"workflow.js",
"ops.js",
];
const commands = new Set();
for (const file of handlerFiles) {
const source = read(join(sfRoot, "commands", "handlers", file));
for (const match of source.matchAll(/trimmed\s*(?:===|!==)\s*"([^"]+)"/g)) {
commands.add(match[1].trim().split(/\s+/)[0]);
}
for (const match of source.matchAll(/trimmed\.startsWith\(\s*"([^"]+)"/g)) {
commands.add(match[1].trim().split(/\s+/)[0]);
}
}
return uniqueSorted(commands);
}
function parseDirectRegisteredCommands() {
fix(lint): fix all pre-existing lint failures - check-sf-extension-inventory.mjs: expand parseDirectRegisteredCommands() scan to include 7 more files (guards/inturn.js, notifications/notify.js, permissions/index.js, ui/usage-bar.js, commands/legacy/audit.js, commands/legacy/create-extension.js, commands/legacy/create-slash-command.js) and filter results by BASE_RUNTIME_COMMAND_NAMES to exclude doc-string false positives ("name" in create-slash-command.js template text) - extension-manifest.json: remove 'clear' (subcommand of logs/notifications, never a top-level pi.registerCommand) - packages/pi-agent-core/src/db/sf-db.ts: fix 23 noVoidTypeReturn errors - openDatabase: void → boolean (caller uses return value at line 5625) - claimEscalationOverride: void → boolean (caller checks at escalation.js:243) - resolveSelfFeedbackEntry: void → boolean (caller checks at self-feedback.js:387) - copyWorktreeDb: void → boolean (caller checks at reconcileWorktreeDb) - compactUokMessages: void → {before,after} (caller returns value at message-bus.js:238) - insertSessionTurn: void → bigint|null (caller uses id at session-recorder.js:104) - expireStaleMemories: void → number (caller uses count at auto-start.js:1047) - deleteMemorySourceRow: void → boolean (caller returns value at memory-source-store.js:107) - deleteMemoryEmbedding: void → boolean (caller returns value at memory-embeddings.js:328) - updateBacklogItemStatus: remove dead return expression (callers discard value) - removeBacklogItem: remove dead return expression (callers discard value) - updateGateCircuitBreaker: remove dead return {total,avgMs,...} (wrong-type code accidentally merged from getGateLatencyStats, never reachable) - markUokMessageRead: remove dead return true/false (callers discard value) - Auto-fix formatting and organizeImports in ~30 source files (biome --write) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 04:02:31 +02:00
const files = [
"commands/legacy/audit.js",
"commands/legacy/create-extension.js",
"commands/legacy/create-slash-command.js",
"guards/inturn.js",
"notifications/notify.js",
"permissions/index.js",
"subagent/index.js",
"ui/color-band.js",
"ui/emoji.js",
"ui/usage-bar.js",
];
const commands = new Set();
for (const file of files) {
const source = read(join(sfRoot, file));
for (const match of source.matchAll(/pi\.registerCommand\(\s*"([^"]+)"/g)) {
commands.add(match[1]);
}
}
return uniqueSorted(commands);
}
function main() {
const failures = [];
const ignoredSources = ignoredResourceSources();
if (ignoredSources.length > 0) {
failures.push(
failSection(
`Runtime extension source files are hidden by .gitignore (${ignoredSources.length})`,
2026-05-05 14:31:16 +02:00
ignoredSources
.slice(0, 40)
.concat(
ignoredSources.length > 40
? [`... ${ignoredSources.length - 40} more`]
: [],
),
),
);
}
const untrackedSources = untrackedResourceSources();
if (untrackedSources.length > 0) {
failures.push(
failSection(
`Runtime extension source files are visible but untracked (${untrackedSources.length})`,
2026-05-05 14:31:16 +02:00
untrackedSources
.slice(0, 40)
.concat(
untrackedSources.length > 40
? [`... ${untrackedSources.length - 40} more`]
: [],
),
),
);
}
2026-05-05 00:03:47 +02:00
const manifestlessExtensions = manifestlessLoadableExtensions();
if (manifestlessExtensions.length > 0) {
failures.push(
failSection(
`Loadable bundled extensions missing extension-manifest.json (${manifestlessExtensions.length})`,
manifestlessExtensions,
),
);
}
const manifest = parseManifest();
const registeredTools = parseRegisteredTools();
const catalogCommands = parseTopLevelCatalogCommands();
2026-05-14 20:04:53 +02:00
const publicDirectCommands = parsePublicDirectCommands();
const directCommandNames = uniqueSorted(
BASE_DIRECT_COMMAND_NAMES.concat(
catalogCommands.filter(
2026-05-14 20:04:53 +02:00
(command) =>
publicDirectCommands.has(command) &&
!BASE_RUNTIME_COMMAND_NAMES.has(command),
),
fix(lint): fix all pre-existing lint failures - check-sf-extension-inventory.mjs: expand parseDirectRegisteredCommands() scan to include 7 more files (guards/inturn.js, notifications/notify.js, permissions/index.js, ui/usage-bar.js, commands/legacy/audit.js, commands/legacy/create-extension.js, commands/legacy/create-slash-command.js) and filter results by BASE_RUNTIME_COMMAND_NAMES to exclude doc-string false positives ("name" in create-slash-command.js template text) - extension-manifest.json: remove 'clear' (subcommand of logs/notifications, never a top-level pi.registerCommand) - packages/pi-agent-core/src/db/sf-db.ts: fix 23 noVoidTypeReturn errors - openDatabase: void → boolean (caller uses return value at line 5625) - claimEscalationOverride: void → boolean (caller checks at escalation.js:243) - resolveSelfFeedbackEntry: void → boolean (caller checks at self-feedback.js:387) - copyWorktreeDb: void → boolean (caller checks at reconcileWorktreeDb) - compactUokMessages: void → {before,after} (caller returns value at message-bus.js:238) - insertSessionTurn: void → bigint|null (caller uses id at session-recorder.js:104) - expireStaleMemories: void → number (caller uses count at auto-start.js:1047) - deleteMemorySourceRow: void → boolean (caller returns value at memory-source-store.js:107) - deleteMemoryEmbedding: void → boolean (caller returns value at memory-embeddings.js:328) - updateBacklogItemStatus: remove dead return expression (callers discard value) - removeBacklogItem: remove dead return expression (callers discard value) - updateGateCircuitBreaker: remove dead return {total,avgMs,...} (wrong-type code accidentally merged from getGateLatencyStats, never reachable) - markUokMessageRead: remove dead return true/false (callers discard value) - Auto-fix formatting and organizeImports in ~30 source files (biome --write) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 04:02:31 +02:00
parseDirectRegisteredCommands().filter(
(command) => !BASE_RUNTIME_COMMAND_NAMES.has(command),
),
),
);
2026-05-05 14:31:16 +02:00
const missingManifestTools = registeredTools.filter(
(tool) => !manifest.tools.includes(tool),
);
const staleManifestTools = manifest.tools.filter(
(tool) => !registeredTools.includes(tool),
);
if (missingManifestTools.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Registered tools missing from extension-manifest.json",
missingManifestTools,
),
);
}
if (staleManifestTools.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Manifest tools not registered by SF bootstrap",
staleManifestTools,
),
);
}
const missingManifestCommands = directCommandNames.filter(
(command) => !manifest.commands.includes(command),
);
const staleManifestCommands = manifest.commands.filter(
(command) => !directCommandNames.includes(command),
);
if (missingManifestCommands.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Direct commands missing from extension-manifest.json",
missingManifestCommands,
),
);
}
if (staleManifestCommands.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Manifest direct commands not registered by SF bootstrap",
staleManifestCommands,
),
);
}
const handledCommands = parseHandledTopLevelCommands().filter(
(command) => !HIDDEN_OR_ALIAS_SUBCOMMANDS.has(command),
);
const missingCatalogCommands = handledCommands.filter(
(command) => !catalogCommands.includes(command),
);
const unroutedCatalogCommands = catalogCommands.filter(
2026-05-09 16:36:04 +02:00
(command) =>
command !== "help" &&
!HIDDEN_OR_ALIAS_SUBCOMMANDS.has(command) &&
!handledCommands.includes(command),
);
if (missingCatalogCommands.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Handled SF commands missing from TOP_LEVEL_SUBCOMMANDS",
2026-05-05 14:31:16 +02:00
missingCatalogCommands,
),
);
}
if (unroutedCatalogCommands.length > 0) {
2026-05-05 14:31:16 +02:00
failures.push(
failSection(
"Catalog SF commands with no routed handler",
2026-05-05 14:31:16 +02:00
unroutedCatalogCommands,
),
);
}
if (failures.length > 0) {
console.error(failures.join("\n\n"));
process.exit(1);
}
console.log(
`SF extension inventory OK: ${registeredTools.length} tools, ${directCommandNames.length} direct commands, ${catalogCommands.length} catalog commands.`,
);
}
main();