mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
feat(validate-skills): require js-yaml-parsable SKILL frontmatter @W-21338965@ (#91)
* feat(validate-skills): require js-yaml-parsable SKILL frontmatter Validate the raw YAML between --- delimiters with js-yaml so frontmatter matches strict parsers (e.g. descriptions with unquoted colon+space). - Add js-yaml devDependency - Extract parseFrontmatterBlock for shared delimiter parsing - On YAMLException, report reason, line/column, character index, and snippet Made-with: Cursor * refactor(validate-skills): address PR review (YAMLException, JSON_SCHEMA, context) - Catch only YAMLException; re-throw unexpected errors - Parse frontmatter with yaml.JSON_SCHEMA - Add rawFrontmatter to SkillContext; single parseFrontmatterBlock per file - Type-guard mark fields and reason in formatYamlFrontmatterParseError - Rename delimiterLen to fullMatchLen - Collapse error message into one template literal Made-with: Cursor
This commit is contained in:
parent
c0ebed377d
commit
37ab25279e
29
package-lock.json
generated
29
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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<string, string> | 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<string, string> | 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<string, string> | null
|
||||
body: string
|
||||
} {
|
||||
const block = parseFrontmatterBlock(content)
|
||||
if (!block) return { rawFrontmatter: null, frontmatter: null, body: content }
|
||||
|
||||
const frontmatter: Record<string, string> = {}
|
||||
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<string, string> |
|
||||
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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user