voyage/lib/skills/skill-frontmatter.mjs
Kjell Tore Guttormsen ab6dcd838e
fix: round 4 of the release review of 6.3.0
Round 4 (v6.2.0..41ea8fc, WARN: 9 MAJOR, 3 MINOR); the nine MAJOR fixed,
each behind a test that failed first (operator 2026-10-10). Three were
regressions from round 3's shared Files: reader.

- fieldPaths keeps a backticked path with punctuation after it and one
  inside a note, a directory (docs/) and a glob (src/*.mjs) in plain text,
  and app/(auth)/x.ts whole; a bare word counts only alone and capitalised
  (Makefile), so "format" or "v6.3.0" in a description is no path. The K6
  sweep plans what v6.2.0 planned for `a.mjs`: and `b.mjs`.
- skill frontmatter: a quoted value may carry a YAML comment after it.
- criteria runner: no word with a leading ~ (sh expands it), and nothing
  after `make test` (another target would run).
- release-check: scope_sha_end must be a commit sha (a ref name passed
  coverage whatever was reviewed), and README lines changed after the review
  may differ only in the badge's version. The round-3 leading-zero example
  is 7 characters now, the shortest sha accepted.

The three MINOR (one word screen for git, the exec copy in regression-check,
a shared isMain helper) are not in this round.

Suite 2175 (2173/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:48:52 +02:00

154 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/skills/skill-frontmatter.mjs
// Frontmatter reader for skill, command and agent files: the fields the skill
// workshop needs, with the line each one is on and the YAML traps it hits.
//
// Why not lib/util/frontmatter.mjs: that parser deliberately rejects
// multi-line strings, and Voyage's agents write `description: |`. This reader
// takes plain, quoted and block-scalar (`|`, `>`, with `-`/`+`) values, flow
// lists, and indented lists or maps (kept as raw text). It is not a YAML
// parser: it reads the subset a SKILL.md uses and reports what would break a
// real one. Claude Code loads a skill whose YAML does not parse "with no
// fields set", so the description is gone and the skill cannot match; a trap
// here is an error, not a style note.
const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/;
const BLOCK_RE = /^([|>])([+-]?)\s*$/;
/**
* Split a file into its frontmatter lines and body.
* @returns {{ ok: boolean, fmLines: string[], body: string, bodyStartLine: number }}
*/
export function splitFile(text) {
const src = String(text).replace(/^/, '');
const lines = src.split(/\r?\n/);
if (lines[0] !== '---') return { ok: false, fmLines: [], body: src, bodyStartLine: 1 };
const end = lines.indexOf('---', 1);
if (end < 0) return { ok: false, fmLines: [], body: src, bodyStartLine: 1 };
return { ok: true, fmLines: lines.slice(1, end), body: lines.slice(end + 1).join('\n'), bodyStartLine: end + 2 };
}
/** The YAML traps in one plain (unquoted, single-line) scalar value. */
export function plainScalarHazards(value) {
const out = [];
if (/:\s/.test(value) || /:$/.test(value)) out.push('unquoted ": " (YAML reads a mapping and the line fails)');
if (/^#/.test(value)) out.push('"#" at the start is a YAML comment: the value is empty');
if (/\s#/.test(value)) out.push('" #" starts a YAML comment: the rest of the value is cut');
if (/^[`@%*&!|>,?{]/.test(value)) out.push(`a value may not start with "${value[0]}" unquoted`);
if (/^\[/.test(value) && !/\]$/.test(value)) out.push('an unclosed flow list');
if (/^-\s/.test(value)) out.push('"- " at the start reads as a list item');
return out;
}
// A quoted value ends at its closing quote; after it only a YAML comment may
// follow (`name: "x" # pinned`). Inside double quotes a backslash escapes the
// next character; inside single quotes '' is one quote.
function unquote(v) {
const q = v[0];
if (q !== '"' && q !== "'") return null;
let end = -1;
for (let i = 1; i < v.length; i++) {
if (q === '"' && v[i] === '\\') { i++; continue; }
if (v[i] !== q) continue;
if (q === "'" && v[i + 1] === "'") { i++; continue; }
end = i;
break;
}
if (end < 0) return { error: q === '"' ? 'unclosed double quote' : 'unclosed single quote' };
const rest = v.slice(end + 1);
if (rest !== '' && !/^\s+#/.test(rest)) return { error: 'text after the closing quote that is not a comment' };
const body = v.slice(0, end + 1);
if (q === "'") return { value: body.slice(1, -1).replace(/''/g, "'") };
try { return { value: JSON.parse(body) }; } catch { return { error: 'bad escape in a double-quoted value' }; }
}
function blockValue(lines, style, chomp) {
const nonBlank = lines.filter((l) => l.trim() !== '');
const indent = nonBlank.length ? Math.min(...nonBlank.map((l) => l.match(/^ */)[0].length)) : 0;
const stripped = lines.map((l) => l.slice(indent));
while (stripped.length && stripped[stripped.length - 1].trim() === '') stripped.pop();
let text;
if (style === '|') text = stripped.join('\n');
else {
// Folded: lines join with a space; a blank line is a newline.
text = '';
for (const l of stripped) {
if (l === '') text += '\n';
else text += (text === '' || text.endsWith('\n') ? '' : ' ') + l;
}
}
if (chomp === '-') return text;
return `${text}\n`;
}
/**
* Read the frontmatter of a skill/command/agent file.
* @returns {{ ok: boolean, fields: Object<string, {value: any, line: number, style: string}>,
* hazards: {line: number, message: string}[], error?: string }}
*/
export function readFrontmatterFields(text) {
const split = splitFile(text);
if (!split.ok) return { ok: false, fields: {}, hazards: [], error: 'no frontmatter: the file must start with a "---" line and close it' };
const { fmLines } = split;
const fields = {};
const hazards = [];
let i = 0;
while (i < fmLines.length) {
const line = fmLines[i];
const lineNo = i + 2; // line 1 is the opening ---
if (/\t/.test(line)) hazards.push({ line: lineNo, message: 'a tab in frontmatter: YAML forbids tabs for indentation' });
if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; }
if (/^\s/.test(line)) { hazards.push({ line: lineNo, message: 'an indented line that belongs to no key' }); i++; continue; }
const m = KEY_RE.exec(line);
if (!m) { hazards.push({ line: lineNo, message: `not a "key: value" line: ${line.slice(0, 60)}` }); i++; continue; }
const key = m[1];
const rest = m[2].replace(/^[ \t]+/, '');
if (m[2] !== '' && !/^[ \t]/.test(m[2])) hazards.push({ line: lineNo, message: `"${key}:" needs a space after the colon` });
if (key in fields) hazards.push({ line: lineNo, message: `duplicate key "${key}"` });
// Collect the indented continuation lines (block scalar, list or map).
let j = i + 1;
const cont = [];
while (j < fmLines.length && (fmLines[j].trim() === '' || /^\s/.test(fmLines[j]))) { cont.push(fmLines[j]); j++; }
while (cont.length && cont[cont.length - 1].trim() === '') { cont.pop(); }
const block = BLOCK_RE.exec(rest);
if (block) {
fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' };
} else if (rest === '') {
const items = cont.filter((l) => l.trim() !== '');
if (items.length && items.every((l) => /^\s+-\s/.test(l))) {
fields[key] = { value: items.map((l) => l.replace(/^\s+-\s+/, '').trim()), line: lineNo, style: 'list' };
} else {
fields[key] = { value: items.join('\n'), line: lineNo, style: items.length ? 'map' : 'empty' };
}
} else {
if (cont.length) hazards.push({ line: lineNo, message: `"${key}" has a value and indented lines under it` });
const q = unquote(rest);
if (q && q.error) {
hazards.push({ line: lineNo, message: q.error });
fields[key] = { value: rest, line: lineNo, style: 'broken' };
} else if (q) {
fields[key] = { value: q.value, line: lineNo, style: 'quoted' };
} else if (/^\[.*\]$/.test(rest)) {
const inner = rest.slice(1, -1).trim();
fields[key] = {
value: inner === '' ? [] : inner.split(',').map((s) => { const t = s.trim(); const u = unquote(t); return u && !u.error ? u.value : t; }),
line: lineNo, style: 'flow',
};
} else {
for (const h of plainScalarHazards(rest)) hazards.push({ line: lineNo, message: `${key}: ${h}` });
fields[key] = { value: rest, line: lineNo, style: 'plain' };
}
}
i = j;
}
return { ok: hazards.length === 0, fields, hazards };
}
/** The description of a file as one string ('' when absent). */
export function descriptionOf(text) {
const r = readFrontmatterFields(text);
const d = r.fields.description;
if (!d) return '';
return Array.isArray(d.value) ? d.value.join(' ') : String(d.value);
}