Files
fusion/scripts/sync-fusion-skill-tools.mjs
gsxdsm 211c0fd557 perf(test): cut inner-loop fixed overhead to sub-second on cache-fresh runs
- skill-sync check conditioned on content hash of its inputs (skips ~0.3s spawn)
- ensure-test-artifacts: git-blob content-hash staleness; branch switches no longer trigger spurious ~2.6s tsc rebuilds (mtime fallback when dirty)
- isolation guard: cheap --before-fast reusing prior post-run baseline (~2.1s -> ~0.07s); detection proven preserved via injected-leak failure test
- vitest-setup: CI skips 4040-4045 discovery probe unless FUSION_RESERVED_PORTS set; kill-guard wrapper untouched, asymmetry pinned by port-probe-policy tests
- cache-fresh fast path skips sync/artifacts/HOME-prune entirely (mode line: fast-path=cache-fresh)
2026-06-03 17:57:29 -07:00

613 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Syncs Fusion skill docs from the canonical pi extension registrations.
*
* Full flow:
* packages/cli/src/extension.ts
* → scripts/sync-fusion-skill-tools.mjs
* → packages/cli/skill/fusion/SKILL.md (tool categories)
* → packages/cli/skill/fusion/references/extension-tools.md (tool sections + params)
* → packages/cli/skill/fusion/references/fusion-capabilities.md (tool table)
*
* Usage:
* node scripts/sync-fusion-skill-tools.mjs
* node scripts/sync-fusion-skill-tools.mjs --check
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import {
computeContentHash,
fusionCacheDir,
readJsonCache,
} from "./lib/content-hash.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts");
const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md");
const extensionToolsPath = resolve(
repoRoot,
"packages/cli/skill/fusion/references/extension-tools.md",
);
const capabilitiesPath = resolve(
repoRoot,
"packages/cli/skill/fusion/references/fusion-capabilities.md",
);
// ---------------------------------------------------------------------------
// U3: skip-the-spawn cache for the --check path.
//
// The inner loop (scripts/test-changed.mjs) used to spawn this script on every
// `pnpm test`. The --check pass is deterministic over a small, fixed set of
// inputs: the extension source of truth, the three generated docs, and this
// script itself (its logic affects the output). When none of those have changed
// since the last passing --check, the spawn is pure overhead. We cache the
// content hash of those inputs after a clean --check and let the caller skip
// spawning when the hash is unchanged.
// ---------------------------------------------------------------------------
/** Repo-relative input paths whose content determines the --check result. */
export const SKILL_SYNC_INPUT_PATHS = [
"packages/cli/src/extension.ts",
"packages/cli/skill/fusion/SKILL.md",
"packages/cli/skill/fusion/references/extension-tools.md",
"packages/cli/skill/fusion/references/fusion-capabilities.md",
"scripts/sync-fusion-skill-tools.mjs",
];
const SKILL_SYNC_CACHE_VERSION = 1;
function skillSyncCachePath(rootDir = repoRoot) {
return join(fusionCacheDir(rootDir), "skill-sync-cache.json");
}
/**
* Compute the content hash over the skill-sync inputs.
*
* @param {string} [rootDir]
* @param {object} [deps] Injectable git/read fns for tests.
* @returns {string}
*/
export function computeSkillSyncHash(rootDir = repoRoot, deps = {}) {
return computeContentHash({
rootDir,
inputPaths: SKILL_SYNC_INPUT_PATHS,
versionPrefix: `skill-sync-v${SKILL_SYNC_CACHE_VERSION}`,
...deps,
});
}
/**
* Return true when a clean --check is already cached for the current inputs, so
* the caller can skip spawning the check entirely. Full runs (CI / --full)
* bypass this and always run.
*
* @param {string} [rootDir]
* @param {object} [deps]
* @returns {boolean}
*/
export function isSkillSyncCheckCached(rootDir = repoRoot, deps = {}) {
const cache = readJsonCache(skillSyncCachePath(rootDir), null);
if (!cache || cache.version !== SKILL_SYNC_CACHE_VERSION || typeof cache.hash !== "string") {
return false;
}
return cache.hash === computeSkillSyncHash(rootDir, deps);
}
/**
* Persist a passing --check result so the next run can skip the spawn.
*
* @param {string} [rootDir]
* @param {object} [deps]
*/
export function recordSkillSyncCheckPass(rootDir = repoRoot, deps = {}) {
try {
const dir = fusionCacheDir(rootDir);
mkdirSync(dir, { recursive: true });
const payload = {
version: SKILL_SYNC_CACHE_VERSION,
hash: computeSkillSyncHash(rootDir, deps),
passedAt: new Date().toISOString(),
};
writeFileSync(skillSyncCachePath(rootDir), JSON.stringify(payload, null, 2));
} catch {
// Cache is an optimization; a write failure just means we spawn next time.
}
}
const SKILL_BEGIN =
"<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
const SKILL_END = "<!-- END: tool-categories -->";
const EXT_TOOLS_BEGIN =
"<!-- BEGIN: extension-tools (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
const EXT_TOOLS_END = "<!-- END: extension-tools -->";
const CAP_TABLE_BEGIN =
"<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
const CAP_TABLE_END = "<!-- END: fusion-capabilities-tool-table -->";
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"];
const CATEGORY_LABELS = {
Task: "Task tools",
GitHub: "GitHub tools",
Mission: "Mission tools",
Goal: "Goal tools",
Agent: "Agent tools",
Skills: "Skills tools",
Insight: "Insight tools",
Other: "Other tools",
};
const CATEGORY_HEADERS = {
Task: "## Task Tools",
GitHub: "## GitHub Tools",
Mission: "## Mission Tools",
Goal: "## Goal Tools",
Agent: "## Agent Tools",
Skills: "## Skills Tools",
Insight: "## Insight Tools",
Other: "## Other Tools",
};
function categorize(name) {
if (name.includes("github")) return "GitHub";
if (name.startsWith("fn_task_")) return "Task";
if (
name.startsWith("fn_mission_") ||
name.startsWith("fn_milestone_") ||
name.startsWith("fn_slice_") ||
name.startsWith("fn_feature_")
) {
return "Mission";
}
if (name.startsWith("fn_goal_")) return "Goal";
if (
name.startsWith("fn_agent_") ||
name === "fn_list_agents" ||
name === "fn_delegate_task"
) {
return "Agent";
}
if (name.startsWith("fn_skills_")) return "Skills";
if (name.startsWith("fn_insight_")) return "Insight";
return "Other";
}
function parseStringLiterals(expression) {
const literals = [];
const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"/g;
for (const match of expression.matchAll(regex)) {
literals.push(match[1].replace(/\\n/g, " ").replace(/\\"/g, '"'));
}
return literals;
}
function normalizeWhitespace(text) {
return text.replace(/\s+/g, " ").trim();
}
function findMatchingBrace(source, openIndex) {
let depth = 0;
let inSingle = false;
let inDouble = false;
let inTemplate = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
for (let i = openIndex; i < source.length; i++) {
const char = source[i];
const next = source[i + 1];
if (inLineComment) {
if (char === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inSingle || inDouble || inTemplate) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (inSingle && char === "'") inSingle = false;
else if (inDouble && char === '"') inDouble = false;
else if (inTemplate && char === "`") inTemplate = false;
continue;
}
if (char === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (char === "'") {
inSingle = true;
continue;
}
if (char === '"') {
inDouble = true;
continue;
}
if (char === "`") {
inTemplate = true;
continue;
}
if (char === "{") depth++;
if (char === "}") {
depth--;
if (depth === 0) return i;
}
}
throw new Error("Unbalanced braces while parsing extension.ts");
}
function splitTopLevelProperties(objectBody) {
const props = [];
let start = 0;
let depthParen = 0;
let depthBrace = 0;
let depthBracket = 0;
let inSingle = false;
let inDouble = false;
let inTemplate = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
for (let i = 0; i < objectBody.length; i++) {
const ch = objectBody[i];
const next = objectBody[i + 1];
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inSingle || inDouble || inTemplate) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (inSingle && ch === "'") inSingle = false;
else if (inDouble && ch === '"') inDouble = false;
else if (inTemplate && ch === "`") inTemplate = false;
continue;
}
if (ch === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (ch === "`") {
inTemplate = true;
continue;
}
if (ch === "(") depthParen++;
else if (ch === ")") depthParen--;
else if (ch === "{") depthBrace++;
else if (ch === "}") depthBrace--;
else if (ch === "[") depthBracket++;
else if (ch === "]") depthBracket--;
if (
ch === "," &&
depthParen === 0 &&
depthBrace === 0 &&
depthBracket === 0
) {
const prop = objectBody.slice(start, i).trim();
if (prop) props.push(prop);
start = i + 1;
}
}
const tail = objectBody.slice(start).trim();
if (tail) props.push(tail);
return props;
}
function extractDescription(block) {
const descMatch = block.match(/description:\s*([\s\S]*?),\s*promptSnippet:/);
if (!descMatch) {
const inline = block.match(/description:\s*([\s\S]*?),\s*parameters:/);
if (!inline) return "";
return normalizeWhitespace(parseStringLiterals(inline[1]).join(" "));
}
return normalizeWhitespace(parseStringLiterals(descMatch[1]).join(" "));
}
function mapType(raw) {
const value = raw.trim();
if (/^Type\.String\(/.test(value)) return "string";
if (/^Type\.Number\(/.test(value)) return "number";
if (/^Type\.Boolean\(/.test(value)) return "boolean";
if (/^Type\.Array\(/.test(value)) return "array";
if (/^Type\.Union\(/.test(value)) return "union";
if (/^StringEnum\(/.test(value)) return "string(enum)";
if (/^Type\.Null\(/.test(value)) return "null";
return "unknown";
}
function parseParameters(block) {
const paramsStart = block.indexOf("parameters:");
if (paramsStart === -1) return [];
const objectStart = block.indexOf("Type.Object(", paramsStart);
if (objectStart === -1) return [];
const braceStart = block.indexOf("{", objectStart);
if (braceStart === -1) return [];
const braceEnd = findMatchingBrace(block, braceStart);
const body = block.slice(braceStart + 1, braceEnd);
const params = [];
for (const prop of splitTopLevelProperties(body)) {
const match = prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/);
if (!match) continue;
const [, name, rawValue] = match;
const optional = /^Type\.Optional\(/.test(rawValue.trim());
const inner = optional
? rawValue.trim().replace(/^Type\.Optional\(/, "").replace(/\)\s*$/, "")
: rawValue.trim();
const descMatch = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
const description = descMatch
? normalizeWhitespace(descMatch[1].replace(/\\n/g, " "))
: "";
params.push({
name,
type: mapType(inner),
required: !optional,
description,
});
}
return params;
}
function extractTools(source) {
const tools = [];
const registerToken = "pi.registerTool(";
let fromIndex = 0;
while (true) {
const start = source.indexOf(registerToken, fromIndex);
if (start === -1) break;
const braceStart = source.indexOf("{", start);
const braceEnd = findMatchingBrace(source, braceStart);
const block = source.slice(braceStart, braceEnd + 1);
const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/);
if (nameMatch) {
const name = nameMatch[1];
const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
const label = labelMatch ? labelMatch[1] : "";
const description = extractDescription(block);
const parameters = parseParameters(block);
tools.push({ name, label, description, parameters });
}
fromIndex = braceEnd + 1;
}
if (tools.length === 0) {
throw new Error(`No fn_* tool registrations found in ${extensionPath}.`);
}
return tools;
}
function buildSkillCategoriesBlock(tools) {
const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []]));
for (const tool of tools) {
grouped.get(categorize(tool.name)).push(tool.name);
}
const lines = [SKILL_BEGIN];
for (const category of CATEGORY_ORDER) {
const names = grouped.get(category);
if (!names.length) continue;
lines.push(
`- **${CATEGORY_LABELS[category]}** — ${names.map((n) => `\`${n}\``).join(", ")}`,
);
}
lines.push(SKILL_END);
return lines.join("\n");
}
function buildExtensionToolsBlock(tools) {
const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []]));
for (const tool of tools) {
grouped.get(categorize(tool.name)).push(tool);
}
const lines = [EXT_TOOLS_BEGIN, ""];
for (const category of CATEGORY_ORDER) {
const categoryTools = grouped.get(category);
if (!categoryTools.length) continue;
lines.push(CATEGORY_HEADERS[category]);
lines.push("");
for (const tool of categoryTools) {
lines.push(`### ${tool.name}`);
lines.push("");
lines.push(tool.description || tool.label || "No description provided.");
lines.push("");
if (tool.parameters.length === 0) {
lines.push("No parameters.");
} else {
lines.push("| Parameter | Type | Required | Description |");
lines.push("|-----------|------|----------|-------------|");
for (const param of tool.parameters) {
lines.push(
`| \`${param.name}\` | ${param.type} | ${param.required ? "✓" : "—"} | ${param.description || ""} |`,
);
}
}
lines.push("");
}
}
lines.push(EXT_TOOLS_END);
return lines.join("\n");
}
function buildCapabilitiesTableBlock(tools) {
const lines = [
CAP_TABLE_BEGIN,
"| Tool | Purpose |",
"|------|---------|",
];
for (const tool of tools) {
lines.push(`| \`${tool.name}\` | ${tool.description || tool.label || ""} |`);
}
lines.push(CAP_TABLE_END);
return lines.join("\n");
}
function replaceBlock(content, beginMarker, endMarker, newBlock, filePath) {
const beginIdx = content.indexOf(beginMarker);
const endIdx = content.indexOf(endMarker);
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
throw new Error(
`Could not find markers in ${filePath}: ${beginMarker} ... ${endMarker}`,
);
}
const endLineEnd = endIdx + endMarker.length;
return content.slice(0, beginIdx) + newBlock + content.slice(endLineEnd);
}
function main() {
const checkOnly = process.argv.includes("--check");
const extensionSource = readFileSync(extensionPath, "utf-8");
const tools = extractTools(extensionSource);
const files = [
{
path: skillPath,
begin: SKILL_BEGIN,
end: SKILL_END,
block: buildSkillCategoriesBlock(tools),
label: "SKILL.md tool-categories",
},
{
path: extensionToolsPath,
begin: EXT_TOOLS_BEGIN,
end: EXT_TOOLS_END,
block: buildExtensionToolsBlock(tools),
label: "extension-tools.md",
},
{
path: capabilitiesPath,
begin: CAP_TABLE_BEGIN,
end: CAP_TABLE_END,
block: buildCapabilitiesTableBlock(tools),
label: "fusion-capabilities.md tool table",
},
];
let changedCount = 0;
const driftLabels = [];
for (const file of files) {
const current = readFileSync(file.path, "utf-8");
const updated = replaceBlock(current, file.begin, file.end, file.block, file.path);
if (updated !== current) {
changedCount++;
driftLabels.push(file.label);
if (!checkOnly) {
writeFileSync(file.path, updated);
}
}
}
if (checkOnly) {
if (changedCount > 0) {
console.error(
`[sync-fusion-skill-tools] Drift detected in ${driftLabels.join(", ")}.\n` +
"Run `node scripts/sync-fusion-skill-tools.mjs` to regenerate synced docs.",
);
process.exit(1);
}
// U3: cache the passing result so the inner loop can skip the next spawn.
recordSkillSyncCheckPass(repoRoot);
return;
}
if (changedCount === 0) {
console.log(
`[sync-fusion-skill-tools] All generated docs are up to date (${tools.length} tools).`,
);
return;
}
console.log(
`[sync-fusion-skill-tools] Updated ${changedCount} file(s) from extension.ts (${tools.length} tools).`,
);
}
// Only run the sync when invoked directly as a script — importing the module
// (e.g. from tests for the cache helpers) must not trigger a full sync.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}