feat(FN-2569): sync fusion skill docs with runtime tools

- Add generated engine-tools reference and refresh extension/capabilities docs from source definitions
- Enhance sync-fusion-skill-tools script to verify and maintain skill documentation consistency
- Expand skill-sync tests and enforce sync:fusion-skill:check in the workspace test pipeline
- Add FN-2569 changeset documenting the published @runfusion/fusion patch update
This commit is contained in:
Fusion
2026-04-25 20:07:49 -07:00
committed by gsxdsm
parent 4addaa8f41
commit 26f9c74d8f
8 changed files with 914 additions and 198 deletions

View File

@@ -6,17 +6,243 @@ import { spawnSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const cliRoot = resolve(__dirname, "../..");
const repoRoot = resolve(cliRoot, "../..");
const skillDir = resolve(cliRoot, "skill/fusion");
const extensionPath = resolve(cliRoot, "src/extension.ts");
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 engineToolSourceFiles = [
resolve(repoRoot, "packages/engine/src/agent-tools.ts"),
resolve(repoRoot, "packages/engine/src/triage.ts"),
resolve(repoRoot, "packages/engine/src/executor.ts"),
resolve(repoRoot, "packages/engine/src/merger.ts"),
resolve(repoRoot, "packages/engine/src/agent-heartbeat.ts"),
];
function findMatchingBrace(source: string, openIndex: number): number {
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");
}
function splitTopLevelProperties(objectBody: string): string[] {
const props: string[] = [];
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 getRegisterToolBlocks(): Array<{ name: string; block: string }> {
const src = readFileSync(extensionPath, "utf-8");
const blocks: Array<{ name: string; block: string }> = [];
const token = "pi.registerTool(";
let from = 0;
while (true) {
const start = src.indexOf(token, from);
if (start === -1) break;
const braceStart = src.indexOf("{", start);
const braceEnd = findMatchingBrace(src, braceStart);
const block = src.slice(braceStart, braceEnd + 1);
const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/);
if (nameMatch) {
blocks.push({ name: nameMatch[1], block });
}
from = braceEnd + 1;
}
return blocks;
}
/**
* Extract all tool names registered via pi.registerTool({ name: "..." })
* from the extension source code.
*/
function getExtensionToolNames(): string[] {
const src = readFileSync(extensionPath, "utf-8");
const matches = [...src.matchAll(/name:\s*"(fn_[a-z_]+)"/g)];
return matches.map((m) => m[1]).sort();
return getRegisterToolBlocks()
.map((entry) => entry.name)
.sort();
}
function getExtensionToolParamNames(): Map<string, string[]> {
const result = new Map<string, string[]>();
for (const { name, block } of getRegisterToolBlocks()) {
const paramsStart = block.indexOf("parameters:");
if (paramsStart === -1) {
result.set(name, []);
continue;
}
const objectStart = block.indexOf("Type.Object(", paramsStart);
if (objectStart === -1) {
result.set(name, []);
continue;
}
const braceStart = block.indexOf("{", objectStart);
const braceEnd = findMatchingBrace(block, braceStart);
const body = block.slice(braceStart + 1, braceEnd);
const params = splitTopLevelProperties(body)
.map((prop) => prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/)?.[1])
.filter((value): value is string => Boolean(value))
.sort();
result.set(name, params);
}
return result;
}
/**
@@ -31,13 +257,28 @@ function getDocumentedToolNames(): string[] {
return matches.map((m) => m[1]).sort();
}
function getDocumentedToolParamNames(): Map<string, string[]> {
const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8");
const map = new Map<string, string[]>();
const toolRegex = /^### (fn_[a-z_]+)\n([\s\S]*?)(?=^### fn_|^## [A-Za-z]|^<!-- END: extension-tools -->)/gm;
for (const match of doc.matchAll(toolRegex)) {
const [, toolName, section] = match;
const params = [...section.matchAll(/\| `([A-Za-z_][A-Za-z0-9_]*)` \|/g)]
.map((m) => m[1])
.sort();
map.set(toolName, params);
}
return map;
}
/**
* Extract tool names listed in SKILL.md under the tool categories.
*/
function getSkillMdToolNames(): string[] {
const doc = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8");
const matches = [...doc.matchAll(/`(fn_[a-z_]+)`/g)];
// Deduplicate
return [...new Set(matches.map((m) => m[1]))].sort();
}
@@ -53,6 +294,22 @@ function getCapabilitiesToolNames(): string[] {
return matches.map((m) => m[1]).sort();
}
function getEngineSessionToolNames(): string[] {
const names = new Set<string>();
for (const path of engineToolSourceFiles) {
const src = readFileSync(path, "utf-8");
for (const match of src.matchAll(/name:\s*"(fn_[a-z_]+)"/g)) {
names.add(match[1]);
}
}
return [...names].sort();
}
function getDocumentedEngineToolNames(): string[] {
const doc = readFileSync(resolve(skillDir, "references/engine-tools.md"), "utf-8");
return [...new Set([...doc.matchAll(/`(fn_[a-z_]+)`/g)].map((m) => m[1]))].sort();
}
function collectMarkdownFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -105,6 +362,17 @@ describe("Skill-Extension Sync", () => {
expect(documentedTools).toEqual(extensionTools);
});
it("extension-tools.md tool parameter tables match extension.ts registrations", () => {
const extensionTools = getExtensionToolParamNames();
const documentedTools = getDocumentedToolParamNames();
for (const [toolName, extensionParams] of extensionTools.entries()) {
expect(documentedTools.has(toolName), `missing documented section for ${toolName}`).toBe(true);
const documentedParams = documentedTools.get(toolName) ?? [];
expect(documentedParams).toEqual(extensionParams);
}
});
it("SKILL.md tool listing includes all registered tools", () => {
const extensionTools = getExtensionToolNames();
const skillTools = getSkillMdToolNames();
@@ -123,6 +391,26 @@ describe("Skill-Extension Sync", () => {
(t) => !capTools.includes(t),
);
expect(missingFromCaps).toEqual([]);
expect(capTools).toEqual(extensionTools);
});
it("fusion-capabilities.md tool table is auto-generated with markers", () => {
const doc = readFileSync(resolve(skillDir, "references/fusion-capabilities.md"), "utf-8");
expect(doc).toContain(CAP_TABLE_BEGIN);
expect(doc).toContain(CAP_TABLE_END);
});
it("extension-tools.md has auto-generated markers", () => {
const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8");
expect(doc).toContain(EXT_TOOLS_BEGIN);
expect(doc).toContain(EXT_TOOLS_END);
});
it("engine-tools.md documents all engine session-scoped tools", () => {
const engineTools = getEngineSessionToolNames();
const documented = getDocumentedEngineToolNames();
const missing = engineTools.filter((name) => !documented.includes(name));
expect(missing).toEqual([]);
});
it("covers the full Fusion skill markdown surface", () => {
@@ -134,6 +422,7 @@ describe("Skill-Extension Sync", () => {
"SKILL.md",
"references/best-practices.md",
"references/cli-commands.md",
"references/engine-tools.md",
"references/extension-tools.md",
"references/fusion-capabilities.md",
"references/skill-patterns.md",
@@ -151,8 +440,6 @@ describe("Skill-Extension Sync", () => {
toolName.replace(/^fn_/, ""),
);
// These names are intentionally unprefixed engine/runtime tools and are allowed
// to appear in docs that explain capability boundaries.
const allowedUnprefixedInternalTools = new Set([
"task_create",
"task_update",
@@ -195,8 +482,7 @@ describe("Skill-Extension Sync", () => {
expect(dashboardCli).toContain("/fn");
});
it("SKILL.md tool-categories block matches the sync script output (no drift)", () => {
const repoRoot = resolve(cliRoot, "../..");
it("SKILL.md and reference generated blocks match sync script output (no drift)", () => {
const script = resolve(repoRoot, "scripts/sync-fusion-skill-tools.mjs");
const result = spawnSync("node", [script, "--check"], {
encoding: "utf-8",