146 lines
9.0 KiB
JavaScript
146 lines
9.0 KiB
JavaScript
"use strict";
|
|
|
|
const YAML = require("yaml");
|
|
const { MAX_FRONTMATTER_DEPTH, MAX_FRONTMATTER_NODES } = require("./constants");
|
|
|
|
const VALID_RISKS = new Set(["none", "safe", "critical", "offensive", "unknown"]);
|
|
|
|
function splitFrontmatter(content) {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
if (!match) return { metadata: null, body: content, errors: ["missing_or_malformed_frontmatter"] };
|
|
if (Buffer.byteLength(match[1], "utf8") > 32 * 1024) {
|
|
return { metadata: null, body: content.slice(match[0].length), errors: ["frontmatter_too_large"] };
|
|
}
|
|
if (/(^|[\s\[{,:])[*&][A-Za-z0-9_-]+/m.test(match[1]) || /(^|\s)![^\s]+/m.test(match[1])) {
|
|
return { metadata: null, body: content.slice(match[0].length), errors: ["yaml_alias_anchor_or_tag_forbidden"] };
|
|
}
|
|
try {
|
|
const document = YAML.parseDocument(match[1], {
|
|
maxAliasCount: 20,
|
|
prettyErrors: false,
|
|
strict: true,
|
|
uniqueKeys: true,
|
|
});
|
|
if (document.errors.length) return { metadata: null, body: content.slice(match[0].length), errors: ["invalid_yaml"] };
|
|
let nodes = 0;
|
|
function visit(node, depth) {
|
|
if (!node) return;
|
|
nodes += 1;
|
|
if (depth > MAX_FRONTMATTER_DEPTH || nodes > MAX_FRONTMATTER_NODES) throw new Error("yaml_structure_limit");
|
|
if (Array.isArray(node.items)) {
|
|
for (const item of node.items) {
|
|
if (item?.key !== undefined || item?.value !== undefined) { visit(item.key, depth + 1); visit(item.value, depth + 1); }
|
|
else visit(item, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
visit(document.contents, 1);
|
|
const metadata = document.toJS({ maxAliasCount: 20 });
|
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
return { metadata: null, body: content.slice(match[0].length), errors: ["frontmatter_not_mapping"] };
|
|
}
|
|
return { metadata, body: content.slice(match[0].length), errors: [] };
|
|
} catch {
|
|
return { metadata: null, body: content.slice(match[0].length), errors: ["invalid_yaml"] };
|
|
}
|
|
}
|
|
|
|
function deterministicValidation(content, skillId) {
|
|
const { metadata, body, errors } = splitFrontmatter(content);
|
|
const checks = [];
|
|
const check = (id, passed, detail) => checks.push({ id, passed: Boolean(passed), detail });
|
|
check("frontmatter", errors.length === 0, errors.join(", ") || "valid");
|
|
check("name", typeof metadata?.name === "string" && metadata.name.trim() === skillId.split("/").at(-1), "name matches canonical leaf id");
|
|
check("description", typeof metadata?.description === "string" && metadata.description.trim().length >= 20, "description has at least 20 characters");
|
|
check("risk", VALID_RISKS.has(metadata?.risk), "risk uses an AAS value");
|
|
check("source", typeof metadata?.source === "string" && metadata.source.trim().length > 0, "source is present");
|
|
check("source_fidelity", typeof metadata?.source === "string" && !/\b(?:unknown|todo|tbd)\b/i.test(metadata.source), "source is attributable");
|
|
check("when_to_use", /^##\s+When to Use\b/im.test(body), "When to Use section is present");
|
|
check("limitations", /^##\s+Limitations\b/im.test(body), "Limitations section is present");
|
|
const risky = ["critical", "offensive"].includes(metadata?.risk);
|
|
check("safety_boundary", !risky || /\b(authorized|approval|permission|safety|do not|never|before)\b/i.test(body), "high-risk skills declare a safety boundary");
|
|
check("repo_conventions", !/^#\s*$/m.test(body) && !/\bTODO\b/i.test(body), "body avoids empty headings and TODO markers");
|
|
check("substantive_body", body.trim().length >= 100, "body has at least 100 characters");
|
|
const passed = checks.filter((item) => item.passed).length;
|
|
return {
|
|
implementation: "aas-compatible-validation-v1",
|
|
score: passed / checks.length,
|
|
checks,
|
|
};
|
|
}
|
|
|
|
function tesslAlignedValidation(bundle, skillId) {
|
|
const primary = bundle.files[0];
|
|
const { metadata, body, errors: parseErrors } = splitFrontmatter(primary.text);
|
|
const checks = [];
|
|
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
const lineCount = primary.text.split(/\r?\n/).length;
|
|
add("skill_md_line_count", lineCount > 500 ? "warning" : "passed", { lineCount, limit: 500 });
|
|
add("frontmatter_valid", parseErrors.length ? "error" : "passed", { errors: parseErrors });
|
|
const name = metadata?.name;
|
|
const validName = typeof name === "string" && name.length >= 1 && name.length <= 64
|
|
&& /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) && name === skillId.split("/").at(-1);
|
|
add("name_field", validName ? "passed" : "error", { length: typeof name === "string" ? name.length : 0 });
|
|
const description = metadata?.description;
|
|
const validDescription = typeof description === "string" && description.length >= 1 && description.length <= 1024;
|
|
add("description_field", validDescription ? "passed" : "error", { length: typeof description === "string" ? description.length : 0 });
|
|
const compatibility = metadata?.compatibility;
|
|
add("compatibility_field", compatibility === undefined || (typeof compatibility === "string" && compatibility.length >= 1 && compatibility.length <= 500) ? "passed" : "error", { present: compatibility !== undefined });
|
|
const allowedTools = metadata?.["allowed-tools"];
|
|
add("allowed_tools_field", allowedTools === undefined || typeof allowedTools === "string" ? "passed" : "error", { present: allowedTools !== undefined });
|
|
const metadataField = metadata?.metadata;
|
|
const metadataMap = metadataField && typeof metadataField === "object" && !Array.isArray(metadataField);
|
|
const version = metadataMap ? metadataField.version : undefined;
|
|
const validVersion = typeof version === "string" && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
|
|
add("metadata_version", metadataField === undefined || validVersion ? "passed" : "warning", { present: version !== undefined });
|
|
const validMetadata = metadataField === undefined || (metadataMap && Object.entries(metadataField).every(([key, value]) => typeof key === "string" && typeof value === "string"));
|
|
add("metadata_field", validMetadata ? "passed" : "warning", { present: metadataField !== undefined });
|
|
const license = metadata?.license;
|
|
add("license_field", license === undefined || (typeof license === "string" && license.trim()) ? "passed" : "error", { present: license !== undefined });
|
|
const allowed = new Set(["name", "description", "compatibility", "allowed-tools", "metadata", "license"]);
|
|
const unknown = Object.keys(metadata || {}).filter((key) => !allowed.has(key)).sort();
|
|
add("frontmatter_unknown_keys", unknown.length ? "warning" : "passed", { count: unknown.length });
|
|
add("body_present", body.trim() ? "passed" : "error", { bytes: Buffer.byteLength(body, "utf8") });
|
|
for (const root of ["scripts", "references", "assets"]) {
|
|
add(`${root}_directory`, "passed", { files: bundle.files.filter((file) => file.path.includes(`/${root}/`)).length });
|
|
}
|
|
const base = primary.path.slice(0, -"SKILL.md".length);
|
|
const available = new Set(bundle.files.map((file) => file.path.slice(base.length)));
|
|
const exists = (target) => available.has(target) || [...available].some((item) => item.startsWith(`${target.replace(/\/$/, "")}/`));
|
|
const classify = (targets) => {
|
|
const issues = { missing: [], too_deep: [], suspicious: [] };
|
|
for (const raw of [...new Set(targets)]) {
|
|
let target = raw.replace(/^<|>$/g, "").split("#", 1)[0].split("?", 1)[0];
|
|
try { target = decodeURIComponent(target); } catch { issues.suspicious.push(raw); continue; }
|
|
if (!target || /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i.test(target)) continue;
|
|
if (target.includes("\\") || target.split("/").includes("..") || target.split("/").includes(".")) { issues.suspicious.push(raw); continue; }
|
|
const parts = target.replace(/\/$/, "").split("/").filter(Boolean);
|
|
if (parts.length > 2) { issues.too_deep.push(raw); continue; }
|
|
if (!exists(target)) issues.missing.push(raw);
|
|
}
|
|
return Object.fromEntries(Object.entries(issues).filter(([, values]) => values.length));
|
|
};
|
|
const linkTargets = [];
|
|
for (const match of body.matchAll(/!?\[[^\]]*\]\(\s*(?:<([^>]+)>|([^\s)]+))/g)) linkTargets.push(match[1] || match[2]);
|
|
const linkIssues = classify(linkTargets);
|
|
add("relative_links", Object.keys(linkIssues).length ? "warning" : "passed", linkIssues);
|
|
const mentioned = [];
|
|
for (const match of body.matchAll(/\b(?:scripts|references|assets)\/[A-Za-z0-9._/-]+/g)) mentioned.push(match[0].replace(/[.,;:]+$/, ""));
|
|
const pathIssues = classify(mentioned);
|
|
add("referenced_paths_exist", Object.keys(pathIssues).length ? "warning" : "passed", pathIssues);
|
|
const errors = checks.filter((item) => item.status === "error").length;
|
|
const warnings = checks.filter((item) => item.status === "warning").length;
|
|
const normalized = Math.max(0, (checks.length - errors - 0.5 * warnings) / checks.length);
|
|
return {
|
|
implementation: "tessl-aligned-validation-v2",
|
|
overallPassed: errors === 0,
|
|
errorCount: errors,
|
|
warningCount: warnings,
|
|
normalized,
|
|
score: normalized,
|
|
checks,
|
|
};
|
|
}
|
|
|
|
module.exports = { deterministicValidation, splitFrontmatter, tesslAlignedValidation };
|