Round 5 (v6.2.0..d37e538, WARN: 11 MAJOR, 2 MINOR). The patching did not converge (the same two places every round), so the operator chose a structural fix on 2026-10-11. Each change behind a test that failed first. - A check runs with no shell. criteria-runner's runCommand starts the runner with the very words the screen checked (spawnSync(words[0], words.slice(1)), without NODE_TEST_CONTEXT); Phase 7 criteria, Regression: (its exec copy is gone, MINOR 0e98d5c7), proof of red, the done line and the test lock all run through it. No quote, glob, tilde or variable can rewrite a checked word any more. The screen stays and says why: a word that means something else in a shell (quote, backslash, $, brace, leading ~) is refused; a glob reaches only node --test, which expands its own patterns (sh read `tests/.?/` as `tests/../`); no path climbs out with .. or starts at /; a word starting with @ is refused (pytest reads @file as more arguments). - The K6 plan-file sweep has v6.2.0's own Files: reader again (516dffa): backticked paths, and plain words only in a field without them. The shared reader from round 3 planned directories a description named and dropped paths v6.2.0 planned. fieldPaths stays for the tests-only check, with a path test on spans (`parse()`, `p.parse()` are no paths), plain descriptions ignored when the field has backticked paths, dotfiles, backslash paths and glued spans read. - Skill frontmatter reads a value as YAML does: trailing space is nothing, and a comment ends a plain value, a flow list or a block indicator (`model: opus # pinned`). A comment that cuts a plain description, or is the whole value, is still a hazard. - release-check reads the diff with --no-renames: a test moved into an allowed place after the review shows as removed. - The example plan's step 2 heading no longer says "must FAIL initially"; steps 1-2 keep the tests-only warning (operator 2026-10-09,73262b6). Open: MINOR c1fef1ad (one shared isMain helper) is not in this round. Measured: 0 of 463 Files: fields with backticked paths (520 fields in 182 plans across the marketplace repos) carry another path-like word. Suite 2185 (2183/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
9 KiB
JavaScript
190 lines
9 KiB
JavaScript
// 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.
|
||
//
|
||
// A value is read as YAML reads it: trailing space on the line is nothing, and
|
||
// a comment (` #`, or `#` at the start) ends a plain value, a flow list or a
|
||
// block indicator (`model: opus # pinned` is `opus`). Two comments are still
|
||
// traps: one that is the whole value (the value is empty), and one that cuts a
|
||
// plain `description`, the text Claude Code routes on (`Use when issue #42
|
||
// …` would route on "Use when issue"); quote the description if "#" belongs to
|
||
// it. Release review of 6.3.0, rounds 3-5.
|
||
|
||
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, meant whole. */
|
||
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;
|
||
}
|
||
|
||
// Where a YAML comment starts in a value: `#` at the start or after a space or
|
||
// tab, outside a quoted item (a quote opens only at the start of a token, so
|
||
// the apostrophe in `don't` is text). -1 when there is none.
|
||
function commentAt(v) {
|
||
let q = null;
|
||
for (let i = 0; i < v.length; i++) {
|
||
const c = v[i];
|
||
if (q) {
|
||
if (q === '"' && c === '\\') i++;
|
||
else if (c === q) q = null;
|
||
continue;
|
||
}
|
||
if ((c === '"' || c === "'") && (i === 0 || /[\s,[]/.test(v[i - 1]))) { q = c; continue; }
|
||
if (c === '#' && (i === 0 || v[i - 1] === ' ' || v[i - 1] === '\t')) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
// 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].trim();
|
||
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(); }
|
||
|
||
// A quoted value keeps its own comment rule (unquote); any other value
|
||
// ends where a comment starts.
|
||
const quoted = rest[0] === '"' || rest[0] === "'";
|
||
const cut = quoted ? -1 : commentAt(rest);
|
||
const value = cut < 0 ? rest : rest.slice(0, cut).trimEnd();
|
||
|
||
const block = BLOCK_RE.exec(value);
|
||
if (block) {
|
||
fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' };
|
||
} else if (value === '') {
|
||
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' };
|
||
}
|
||
if (cut === 0 && !items.length) hazards.push({ line: lineNo, message: `${key}: "#" at the start is a YAML comment: the value is 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(value)) {
|
||
const inner = value.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(value)) hazards.push({ line: lineNo, message: `${key}: ${h}` });
|
||
if (cut > 0 && key === 'description') {
|
||
hazards.push({ line: lineNo, message: `${key}: " #" starts a YAML comment: the description is cut to "${value}"; quote it if "#" belongs to it` });
|
||
}
|
||
fields[key] = { value, 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);
|
||
}
|