mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-31 04:09:51 +02:00
Extends scripts/ci/validate-skills.js to also scan docs/{locale}/skills/
translated mirrors, not just curated skills/. Adds detection for the
YAML defect classes from #2630 without a parser dependency: unquoted
values containing ": " (glued next key / dropped quoting), values
starting with the reserved '@'/'`' indicators, and missing frontmatter
blocks entirely (required only for docs mirrors; curated skills/ keeps
its existing tolerant behavior).
333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Validate curated skill directories (skills/ in repo) and their
|
|
* translated mirrors (docs/{locale}/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 is present and declares both `name:` and
|
|
* `description:` fields.
|
|
* 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 skills/ plus translated docs/{locale}/skills/ mirrors.
|
|
* Learned/imported/evolved roots are out of scope. If neither root
|
|
* exists, exit 0 (nothing to validate).
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SKILLS_DIR = path.join(__dirname, '../../skills');
|
|
const DOCS_DIR = path.join(__dirname, '../../docs');
|
|
|
|
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);
|
|
const syntaxErrors = [];
|
|
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;
|
|
|
|
const isQuoted = /^"(?:[^"\\]|\\.)*"$/.test(valueNoComment) || /^'(?:[^']|'')*'$/.test(valueNoComment);
|
|
|
|
if (!isQuoted && valueNoComment !== '') {
|
|
// A plain (unquoted) YAML scalar can never contain ": " — that
|
|
// sequence starts a new mapping key. When the translation pass
|
|
// drops a value's quoting, or glues the next frontmatter key onto
|
|
// the end of a value, this is exactly what shows up (see #2630).
|
|
if (valueNoComment.includes(': ')) {
|
|
syntaxErrors.push(
|
|
`${key}: unquoted value contains ': ' — invalid YAML; ` + `quote the value or the next key was likely glued onto this line`
|
|
);
|
|
}
|
|
|
|
// '@' and '`' are reserved YAML indicators and cannot start a
|
|
// plain scalar (see #2630 — a reordering during translation moved
|
|
// '@' into the first column of an unquoted description).
|
|
if (/^[@`]/.test(valueNoComment)) {
|
|
syntaxErrors.push(`${key}: unquoted value starts with reserved character '${valueNoComment[0]}' — quote the value`);
|
|
}
|
|
}
|
|
|
|
// 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, syntaxErrors };
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Curated skills/ tolerates a SKILL.md with no frontmatter block at all
|
|
* (frontmatter checks only apply when a block is present) — this mirrors
|
|
* pre-existing behavior and is covered by an explicit regression test.
|
|
*
|
|
* @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');
|
|
return validateSkillFile(skillMd, `${dir}/SKILL.md`, reportFrontmatterFinding, { requireFrontmatter: false });
|
|
}
|
|
|
|
/**
|
|
* Validate a single SKILL.md file at an arbitrary path.
|
|
*
|
|
* Shared by the curated skills/ scan and the translated
|
|
* docs/{locale}/skills/ scan — same checks apply to both, since a
|
|
* translated mirror's frontmatter must be just as parseable as the
|
|
* English original (see #2630).
|
|
*
|
|
* `requireFrontmatter: true` (used for docs/{locale}/skills/ mirrors)
|
|
* flags a completely missing frontmatter block as a finding — the
|
|
* translated mirror must carry the same `name`/`description` as its
|
|
* English original. Curated skills/ (requireFrontmatter: false) keeps
|
|
* the pre-existing tolerant behavior of skipping checks entirely when no
|
|
* block is present.
|
|
*
|
|
* @param {string} skillMd
|
|
* @param {string} label
|
|
* @param {(msg: string) => void} reportFrontmatterFinding
|
|
* @param {{requireFrontmatter?: boolean}} [opts]
|
|
* @returns {{fatal: boolean}}
|
|
*/
|
|
function validateSkillFile(skillMd, label, reportFrontmatterFinding, opts = {}) {
|
|
const { requireFrontmatter = false } = opts;
|
|
|
|
if (!fs.existsSync(skillMd)) {
|
|
console.error(`ERROR: ${label} - Missing SKILL.md`);
|
|
return { fatal: true };
|
|
}
|
|
|
|
let content;
|
|
try {
|
|
content = fs.readFileSync(skillMd, 'utf-8');
|
|
} catch (err) {
|
|
console.error(`ERROR: ${label} - ${err.message}`);
|
|
return { fatal: true };
|
|
}
|
|
if (content.trim().length === 0) {
|
|
console.error(`ERROR: ${label} - Empty file`);
|
|
return { fatal: true };
|
|
}
|
|
|
|
const fm = extractFrontmatter(content);
|
|
if (!fm.present) {
|
|
if (requireFrontmatter) {
|
|
reportFrontmatterFinding(`${label} - no frontmatter block found (missing name/description)`);
|
|
}
|
|
return { fatal: false };
|
|
}
|
|
|
|
const { values, descriptionIndicator, syntaxErrors } = inspectFrontmatter(fm.lines);
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
|
|
reportFrontmatterFinding(`${label} - frontmatter missing required field: name`);
|
|
} else if (values.name === '') {
|
|
reportFrontmatterFinding(`${label} - frontmatter 'name' is empty`);
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(values, 'description')) {
|
|
reportFrontmatterFinding(`${label} - frontmatter missing required field: description`);
|
|
} else if (values.description === '') {
|
|
reportFrontmatterFinding(`${label} - frontmatter 'description' is empty`);
|
|
}
|
|
|
|
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
|
|
reportFrontmatterFinding(
|
|
`${label} - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
|
|
);
|
|
}
|
|
|
|
for (const syntaxError of syntaxErrors) {
|
|
reportFrontmatterFinding(`${label} - frontmatter ${syntaxError}`);
|
|
}
|
|
|
|
return { fatal: false };
|
|
}
|
|
|
|
/**
|
|
* Find every SKILL.md under docs/{locale}/skills/*, mirroring the
|
|
* curated skills/ layout one locale directory deeper.
|
|
*
|
|
* @param {string} docsDir
|
|
* @returns {Array<{skillMd: string, label: string}>}
|
|
*/
|
|
function findDocsSkillFiles(docsDir) {
|
|
if (!fs.existsSync(docsDir)) return [];
|
|
|
|
const files = [];
|
|
const locales = fs
|
|
.readdirSync(docsDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
.map(e => e.name);
|
|
|
|
for (const locale of locales) {
|
|
const localeSkillsDir = path.join(docsDir, locale, 'skills');
|
|
if (!fs.existsSync(localeSkillsDir)) continue;
|
|
|
|
const skillDirs = fs
|
|
.readdirSync(localeSkillsDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
.map(e => e.name);
|
|
|
|
for (const skillDir of skillDirs) {
|
|
files.push({
|
|
skillMd: path.join(localeSkillsDir, skillDir, 'SKILL.md'),
|
|
label: `docs/${locale}/skills/${skillDir}/SKILL.md`
|
|
});
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function validateSkills() {
|
|
const curatedExists = fs.existsSync(SKILLS_DIR);
|
|
const docsSkillFiles = findDocsSkillFiles(DOCS_DIR);
|
|
|
|
if (!curatedExists && docsSkillFiles.length === 0) {
|
|
console.log('No skills directory (skills/ or docs/*/skills/), skipping');
|
|
process.exit(0);
|
|
}
|
|
|
|
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++;
|
|
}
|
|
};
|
|
|
|
if (curatedExists) {
|
|
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
|
|
|
for (const dir of dirs) {
|
|
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
|
|
if (fatal) {
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
validCount++;
|
|
}
|
|
}
|
|
|
|
for (const { skillMd, label } of docsSkillFiles) {
|
|
const { fatal } = validateSkillFile(skillMd, label, reportFrontmatterFinding, { requireFrontmatter: true });
|
|
if (fatal) {
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
validCount++;
|
|
}
|
|
|
|
if (hasErrors) {
|
|
process.exit(1);
|
|
}
|
|
|
|
let msg = `Validated ${validCount} skill directories`;
|
|
if (warnCount > 0) {
|
|
msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`;
|
|
}
|
|
console.log(msg);
|
|
}
|
|
|
|
validateSkills();
|