mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-12 07:37:24 +08:00
fix(ci): flag SKILL.md frontmatter defects in validate-skills (#1669)
* fix(ci): flag SKILL.md frontmatter defects in validate-skills Issue #1663 reported two SKILL.md frontmatter defects (missing `name:` on skill-stocktake; literal block-scalar `description: |-` on openclaw-persona-forge) that PR #1664 addresses at the data level. This change is complementary: it extends `scripts/ci/validate-skills.js` to catch the same class of defect statically going forward, so the frontmatter-vs-renderer problems do not silently reappear as new skills land. ## Checks added - Frontmatter must declare a `name:` field. - Frontmatter `description:` must not use a literal block scalar (`|` / `|-` / `|+`) — these preserve internal newlines and break flat-table renderers keyed off `description`. Folded (`>`) and inline strings are accepted. ## Behavior - Frontmatter findings default to WARN (exit 0) so this PR does not break CI while the two known offenders are still on main. Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote them to ERROR (exit 1). Structural findings (missing / empty SKILL.md) remain errors as before. - Today against main, the validator reports exactly two warnings — the same two files called out in #1663 — and exits 0. When #1664 lands, the validator reports zero warnings, at which point strict mode can be enabled in CI. ## Parser notes - Bespoke frontmatter parser mirrors the style of `validate-agents.js` (tolerant of UTF-8 BOM and CRLF; no new npm dependency). - Block-scalar continuation lines are skipped so keys inside a block scalar are not mistaken for top-level keys. - Hidden directories (`.something/`) under skills/ are now skipped. ## Tests Adds five focused tests to `tests/ci/validators.test.js`: - warns when frontmatter is missing `name` (default mode) - errors when frontmatter is missing `name` (--strict mode) - warns on literal block-scalar description (|-) - accepts folded (>) and inline descriptions under --strict - skips hidden directories under skills/ ## Docs Adds two bullets to the `Skill Checklist` in CONTRIBUTING.md covering the two rules now surfaced by the validator. Refs #1663. Complements (does not compete with) #1664. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): harden SKILL.md frontmatter checks after bot review Address findings from CodeRabbit, Greptile, and cubic on #1669: - Guard empty or whitespace-only `name:` values. Previously `name: ` silently passed because the presence check only tested key-set membership; now inspectFrontmatter captures trimmed values and validate flags an explicit 'name is empty' WARN/ERROR. - Broaden block-scalar detection to cover YAML 1.2 indent indicators (`|2`, `|-2`, `>2-`) and trailing comments (`|- # note`). The old regex required a bare `|`/`>` with optional `+`/`-`, which let valid-but-disallowed forms slip through. - Update CONTRIBUTING.md checklist to list `|+` alongside `|` and `|-` for parity with the validator. - Extend runSkillsValidator to accept env overrides and add four regression tests: empty name, |+ description, |-2 + comment, and CI_STRICT_SKILLS=1. * fix(ci): address round-2 review on validate-skills frontmatter - Tighten extractFrontmatter closing delimiter to require a newline or end-of-file after the closing `---`, so body lines beginning with `---text` are not parsed as frontmatter (CodeRabbit). - Strip both trailing and comment-only values in inspectFrontmatter, so `name: # todo` is surfaced as empty rather than silently passing (cubic P2). - Extract validateSkillDir helper so the per-directory validation block moves out of validateSkills, keeping both functions under the 50-line guideline (CodeRabbit nit). - Hoist runSkillsValidator to module scope in the test harness and share the spawnSync import with execFileSync so the helper stops re-requiring child_process on every invocation (CodeRabbit nit). - Add regression tests: comment-only `name:` values must fail strict mode; `---trailing` body lines must not be parsed as frontmatter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update tests/ci/validators.test.js Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate curated skill directories (skills/ in repo).
|
||||
*
|
||||
* Checks:
|
||||
* 1. Each sub-directory of skills/ contains a SKILL.md file.
|
||||
* 2. SKILL.md is non-empty.
|
||||
* 3. SKILL.md frontmatter (if present) declares a `name:` field.
|
||||
* 4. SKILL.md frontmatter `description:` uses an inline scalar — not a
|
||||
* literal block scalar (`|` / `|-` / `|+`), which preserves internal
|
||||
* newlines and breaks flat-table renderers keyed off `description`.
|
||||
*
|
||||
* Frontmatter findings default to WARN so CI does not break while
|
||||
* pre-existing data defects are being cleaned up out of band (see #1663).
|
||||
* Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote frontmatter
|
||||
* findings to errors (exit 1).
|
||||
*
|
||||
* Structural findings (missing/empty SKILL.md) are always errors.
|
||||
*
|
||||
* Scope: curated only. Learned/imported/evolved roots are out of scope.
|
||||
* If skills/ does not exist, exit 0 (no curated skills to validate).
|
||||
*/
|
||||
@@ -10,6 +26,144 @@ const path = require('path');
|
||||
|
||||
const SKILLS_DIR = path.join(__dirname, '../../skills');
|
||||
|
||||
const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1';
|
||||
|
||||
/**
|
||||
* Parse the leading YAML frontmatter of a markdown document.
|
||||
*
|
||||
* Returns `{ present, lines }` so callers can inspect raw lines
|
||||
* (needed to detect block-scalar `description:` values).
|
||||
*
|
||||
* Tolerant of UTF-8 BOM and CRLF line endings, matching the other
|
||||
* validators in this directory.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {{present: boolean, lines: string[]}}
|
||||
*/
|
||||
function extractFrontmatter(content) {
|
||||
// Strip BOM if present (UTF-8 BOM: U+FEFF).
|
||||
const clean = content.replace(/^\uFEFF/, '');
|
||||
const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!match) return { present: false, lines: [] };
|
||||
return {
|
||||
present: true,
|
||||
lines: match[1].split(/\r?\n/)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract top-level keys (with trimmed values) and flag block-scalar
|
||||
* `description:` values.
|
||||
*
|
||||
* Lines that continue a block scalar (`|` or `>`) are skipped — we only
|
||||
* care about the top-level key set and the raw indicator on the
|
||||
* `description:` line. Block-scalar indicators accept YAML chomp and
|
||||
* indent modifiers and trailing comments, e.g. `|`, `|-`, `|+`, `|2`,
|
||||
* `|-2`, `>- # note`.
|
||||
*
|
||||
* @param {string[]} lines
|
||||
* @returns {{values: Record<string,string>, descriptionIndicator: string|null}}
|
||||
*/
|
||||
function inspectFrontmatter(lines) {
|
||||
const values = Object.create(null);
|
||||
let descriptionIndicator = null;
|
||||
let inBlockScalar = false;
|
||||
let blockScalarIndent = -1;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
if (inBlockScalar) {
|
||||
// Stay inside the block until a line with indent <= the opener's
|
||||
// indent (or an empty continuation).
|
||||
const leadingSpaces = rawLine.match(/^(\s*)/)[1].length;
|
||||
if (rawLine.trim() === '' || leadingSpaces > blockScalarIndent) {
|
||||
continue;
|
||||
}
|
||||
inBlockScalar = false;
|
||||
blockScalarIndent = -1;
|
||||
}
|
||||
|
||||
const match = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
// Strip unquoted comments for value/indicator inspection. Handles both
|
||||
// trailing comments (`foo: bar # note`) and comment-only values
|
||||
// (`foo: # todo`) so the latter is treated as empty.
|
||||
const valueNoComment = rawValue
|
||||
.replace(/^\s*#.*$/, '')
|
||||
.replace(/\s+#.*$/, '')
|
||||
.trim();
|
||||
values[key] = valueNoComment;
|
||||
|
||||
// Detect literal / folded block-scalar indicators. Accept chomp
|
||||
// modifiers (`-` / `+`) and optional indent-indicator digits in
|
||||
// either order, per YAML 1.2.
|
||||
if (/^[|>](?:[+-]?\d+|\d+[+-]?|[+-])?$/.test(valueNoComment)) {
|
||||
if (key === 'description') {
|
||||
descriptionIndicator = valueNoComment;
|
||||
}
|
||||
inBlockScalar = true;
|
||||
blockScalarIndent = rawLine.match(/^(\s*)/)[1].length;
|
||||
}
|
||||
}
|
||||
|
||||
return { values, descriptionIndicator };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single skill directory.
|
||||
*
|
||||
* Returns `{ fatal }` where `fatal` indicates a structural error that
|
||||
* should be surfaced via `console.error` and abort CI (missing/empty
|
||||
* SKILL.md). Frontmatter findings are routed through
|
||||
* `reportFrontmatterFinding`, which owns the WARN/ERROR decision based
|
||||
* on strict mode.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string} skillsDir
|
||||
* @param {(msg: string) => void} reportFrontmatterFinding
|
||||
* @returns {{fatal: boolean}}
|
||||
*/
|
||||
function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
|
||||
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) {
|
||||
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillMd, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
|
||||
return { fatal: true };
|
||||
}
|
||||
if (content.trim().length === 0) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
if (fm.present) {
|
||||
const { values, descriptionIndicator } = inspectFrontmatter(fm.lines);
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
|
||||
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`);
|
||||
} else if (values.name === '') {
|
||||
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`);
|
||||
}
|
||||
|
||||
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
|
||||
reportFrontmatterFinding(
|
||||
`${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { fatal: false };
|
||||
}
|
||||
|
||||
function validateSkills() {
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.log('No curated skills directory (skills/), skipping');
|
||||
@@ -17,32 +171,28 @@ function validateSkills() {
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
||||
|
||||
let hasErrors = false;
|
||||
let warnCount = 0;
|
||||
let validCount = 0;
|
||||
|
||||
const reportFrontmatterFinding = msg => {
|
||||
if (STRICT) {
|
||||
console.error(`ERROR: ${msg}`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.warn(`WARN: ${msg}`);
|
||||
warnCount++;
|
||||
}
|
||||
};
|
||||
|
||||
for (const dir of dirs) {
|
||||
const skillMd = path.join(SKILLS_DIR, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) {
|
||||
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
|
||||
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
|
||||
if (fatal) {
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillMd, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
if (content.trim().length === 0) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
validCount++;
|
||||
}
|
||||
|
||||
@@ -50,7 +200,11 @@ function validateSkills() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Validated ${validCount} skill directories`);
|
||||
let msg = `Validated ${validCount} skill directories`;
|
||||
if (warnCount > 0) {
|
||||
msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`;
|
||||
}
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
validateSkills();
|
||||
|
||||
Reference in New Issue
Block a user