diff --git a/package-lock.json b/package-lock.json index 2a3b01b..27f11e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "devDependencies": { "@salesforce/webapp-template-app-react-sample-b2e-experimental": "^1.112.6", "@salesforce/webapp-template-app-react-sample-b2x-experimental": "^1.112.6", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.1.1", "tsx": "^4.21.0" } }, @@ -762,6 +764,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -805,6 +814,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1643,6 +1659,19 @@ "node": ">=10" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/js2xmlparser": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", diff --git a/package.json b/package.json index e484cc3..838db66 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "devDependencies": { "@salesforce/webapp-template-app-react-sample-b2e-experimental": "^1.112.6", "@salesforce/webapp-template-app-react-sample-b2x-experimental": "^1.112.6", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.1.1", "tsx": "^4.21.0" }, "scripts": { diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts index 9087b52..5a23937 100644 --- a/scripts/validate-skills.ts +++ b/scripts/validate-skills.ts @@ -10,6 +10,7 @@ import { execSync } from "child_process" import fs from "fs" import path from "path" import { parseArgs } from "util" +import yaml from "js-yaml" const SKILLS_DIR = path.join(__dirname, "..", "skills") @@ -18,6 +19,8 @@ interface SkillContext { dirName: string dirPath: string content: string + /** YAML source between `---` lines; `null` if the block is missing. */ + rawFrontmatter: string | null frontmatter: Record | null body: string } @@ -136,6 +139,29 @@ const CONTENT_CHECKS: ContentCheck[] = [ return { errors: [] } }, }, + { + description: + "Frontmatter must parse as JSON-compatible YAML (js-yaml JSON_SCHEMA); unquoted colons in values break strict parsers", + run({ dirName, rawFrontmatter }) { + if (rawFrontmatter === null) return { errors: [] } + try { + yaml.load(rawFrontmatter, { schema: yaml.JSON_SCHEMA }) + } catch (e) { + if (e instanceof yaml.YAMLException) { + const detail = formatYamlFrontmatterParseError(e) + return { + errors: [ + `skills/${dirName}/SKILL.md: frontmatter is not valid YAML. +${detail} +Wrap values that contain \`: \` (colon + space), such as long descriptions with parenthetical hints, in single or double quotes.`, + ], + } + } + throw e + } + return { errors: [] } + }, + }, { description: 'Frontmatter "name" must be present and match the directory name', run({ dirName, frontmatter }) { @@ -244,17 +270,46 @@ function getChangedSkillDirs(base: string): string[] { ].filter((dir) => fs.existsSync(path.join(SKILLS_DIR, dir))) } +/** Formats js-yaml YAMLException with guarded mark fields and optional snippet. */ +function formatYamlFrontmatterParseError(e: yaml.YAMLException): string { + const reason = + typeof e.reason === "string" && e.reason.trim() !== "" ? e.reason : "YAML parse error" + const m = e.mark + if (!m) return reason + + const line = typeof m.line === "number" ? m.line + 1 : "?" + const col = typeof m.column === "number" ? m.column + 1 : "?" + const index = typeof m.position === "number" ? m.position + 1 : "?" + const head = `${reason} — line ${line}, column ${col} (1-based), character index ${index} in frontmatter YAML` + const snippet = typeof m.snippet === "string" && m.snippet.trim() !== "" ? m.snippet.trimEnd() : "" + return snippet ? `${head}\n${snippet}` : head +} + +/** + * Raw YAML between the opening and closing `---` lines (no delimiters). + * `null` if the block is missing. + */ +function parseFrontmatterBlock(content: string): { raw: string; fullMatchLen: number } | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/) + if (!match) return null + return { raw: match[1], fullMatchLen: match[0].length } +} + /** * Parses a SKILL.md file into its frontmatter fields and body. * `frontmatter` is `null` if the `--- ... ---` block is missing or malformed. * Wrapping quotes on frontmatter values are stripped. */ -function parseSkillMd(content: string): { frontmatter: Record | null; body: string } { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/) - if (!match) return { frontmatter: null, body: content } +function parseSkillMd(content: string): { + rawFrontmatter: string | null + frontmatter: Record | null + body: string +} { + const block = parseFrontmatterBlock(content) + if (!block) return { rawFrontmatter: null, frontmatter: null, body: content } const frontmatter: Record = {} - for (const line of match[1].split(/\r?\n/)) { + for (const line of block.raw.split(/\r?\n/)) { const colonIdx = line.indexOf(":") if (colonIdx === -1) continue const key = line.slice(0, colonIdx).trim() @@ -263,7 +318,11 @@ function parseSkillMd(content: string): { frontmatter: Record | frontmatter[key] = raw.replace(/^(['"])([\s\S]*)\1$/, "$2") } - return { frontmatter, body: content.slice(match[0].length) } + return { + rawFrontmatter: block.raw, + frontmatter, + body: content.slice(block.fullMatchLen), + } } /** @@ -293,8 +352,8 @@ function validateSkill(dirName: string, dirPath: string): SkillResult { } const content = fs.readFileSync(path.join(dirPath, "SKILL.md"), "utf8") - const { frontmatter, body } = parseSkillMd(content) - const ctx: SkillContext = { dirName, dirPath, content, frontmatter, body } + const { rawFrontmatter, frontmatter, body } = parseSkillMd(content) + const ctx: SkillContext = { dirName, dirPath, content, rawFrontmatter, frontmatter, body } for (const check of CONTENT_CHECKS) { if (collectIssues(check.run(ctx))) return { errors, warnings }