Restructure to skill-manager conventions

- Move the TypeScript project under scripts/ (src, tsconfig,
  package.json, pnpm lockfile/workspace, canonical .gitignore);
  drop the npm package-lock
- Add scripts/Taskfile.yml aggregator plus .scripts modules
  (loggers, base, cli) with build, run, and validate tasks
- Move the six SKILL-*.md docs into references/ with kebab names
  and extract the connector-routing sections from SKILL.md into
  references/routing-best-practices.md (SKILL.md 666 -> ~310 lines)
- Add license/metadata/compatibility frontmatter, an Available
  scripts section, and update all CLI paths in README and references

skill-manager validate: 13/13 passed, 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:20:26 +03:00
co-authored by Claude Fable 5
parent 99f1fd07e3
commit 77655d1d97
72 changed files with 1089 additions and 1828 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* Action: validate
*
* Validates that a draw.io diagram compiles correctly against the maxGraph library.
*
* For each page in the file this action:
* 1. Checks that the page XML is well-formed (parseable by DOMParser in text/xml mode)
* 2. Loads the page into a full maxGraph Graph instance via loadGraphStates()
* (which calls graph.view.validate() — the same rendering pass draw.io performs)
* 3. Reports the number of vertices and edges successfully resolved
*
* A page is considered INVALID if:
* - The XML contains a parseerror element (malformed XML)
* - loadGraphStates() throws an exception
* - The resulting graph has 0 vertices AND 0 edges (import silently failed)
*
* Exit criteria (summary.valid):
* true — all pages pass all three checks
* false — at least one page fails
*
* Common causes of failure:
* - HTML comments (<!-- -->) inside the mxGraphModel body: strict XML parsers
* reject them when they appear between sibling elements in certain positions
* - Stray/unmatched closing tags (e.g. orphaned </mxGeometry> or </mxCell>)
* - Unescaped special characters in attribute values
* - Base64/deflate encoding errors in the <diagram> body
*/
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import { inflateRaw } from "pako";
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getAttr(tagStr: string, attr: string): string {
const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i");
const m = re.exec(tagStr);
return m ? (m[1] ?? m[2] ?? "") : "";
}
function decodeDiagramContent(content: string): string {
try {
const decoded = Buffer.from(content.trim(), "base64");
const decompressed = inflateRaw(decoded);
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
} catch {
return content; // already plain XML
}
}
interface PageResult {
pageIndex: number;
pageName: string;
valid: boolean;
xmlParseError: string | null;
vertices: number;
edges: number;
error: string | null;
}
// ---------------------------------------------------------------------------
// XML well-formedness check using jsdom DOMParser (strict text/xml mode)
// ---------------------------------------------------------------------------
function checkXmlWellFormed(xml: string): string | null {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
const parser = new dom.window.DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const err = doc.querySelector("parsererror");
if (err) {
// Return first line of error message
return (err.textContent ?? "unknown parse error").split("\n")[0].trim();
}
return null;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function run(filePath: string): Record<string, unknown> {
const raw = readFileSync(filePath, "utf-8").trim();
// Extract pages (support both mxfile wrapper and bare mxGraphModel)
const pages: Array<{ name: string; xml: string }> = [];
if (/^<mxfile/i.test(raw)) {
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
let match: RegExpExecArray | null;
let idx = 0;
while ((match = re.exec(raw)) !== null) {
const name = getAttr(match[1], "name") || `Page ${idx + 1}`;
const xml = decodeDiagramContent(match[2].trim());
pages.push({ name, xml });
idx++;
}
if (pages.length === 0) {
throw new Error("No <diagram> elements found in mxfile");
}
} else if (/^<mxGraphModel/i.test(raw)) {
pages.push({ name: "Page 1", xml: raw });
} else {
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
}
const results: PageResult[] = [];
for (let i = 0; i < pages.length; i++) {
const { name, xml } = pages[i];
const result: PageResult = {
pageIndex: i,
pageName: name,
valid: false,
xmlParseError: null,
vertices: 0,
edges: 0,
error: null,
};
// Step 1: XML well-formedness
const parseError = checkXmlWellFormed(xml);
if (parseError) {
result.xmlParseError = parseError;
result.error = `XML parse error: ${parseError}`;
results.push(result);
continue;
}
// Step 2: maxGraph compile (graph.view.validate())
try {
const { edgeRoutes, vertexBounds } = loadGraphStates(xml);
result.vertices = vertexBounds.size;
result.edges = edgeRoutes.size;
// Step 3: sanity check — a non-empty diagram must have at least some cells
if (result.vertices === 0 && result.edges === 0) {
result.error =
"maxGraph loaded 0 vertices and 0 edges — XML may be structurally empty or silently failed to import";
} else {
result.valid = true;
}
} catch (err) {
result.error = `maxGraph load error: ${err instanceof Error ? err.message : String(err)}`;
}
results.push(result);
}
const allValid = results.every((r) => r.valid);
return {
action: "validate",
file: filePath,
summary: {
pages: results.length,
valid: allValid,
invalidPages: results.filter((r) => !r.valid).length,
},
pages: results,
};
}