voyage/lib/skills/skill-frontmatter.mjs
Kjell Tore Guttormsen 0e612c88ac
fix: round 3 of the release review of 6.3.0
Round 3 (v6.2.0..96cb977, BLOCK: 1 BLOCKER, 8 MAJOR, 2 MINOR), each fixed
behind a test that failed first.

- Every allowlisted runner, not only git, takes plain words and a short list
  of options that only read: an option that writes a file or loads code
  (--test-reporter-destination, --outputFile, --junitxml, --basetemp,
  --import, --config, -p, -o, -exec, a reporter path) is on no list; after
  `npm test --` only paths follow; `make test` takes no VAR=value. A built-in
  node test reporter (tap, spec, dot, junit, lcov) stays allowed.
- /trekexecute: a restore the guard refuses (destructive git, in the mode
  that shows no prompts) or the operator declines is never recorded as
  reverted: simplify = "revert-refused" and the run stops before review; a
  refused step revert is reported with its paths.
- stepTestsOnly: a fixture document under tests/ is a test file, "e.g." is
  not a path, ./ is dropped; the plan-file sweep reads Files: with the same
  reader (fieldPaths); the validator hands its parsed fields over.
- plan-validator --steps-from asks for Verify: on a 1.8 plan too.
- skill frontmatter: a value starting with # is a hazard; a quote after an
  escaped backslash closes the value. Routing reads kind tags with hasOwn.
- release-check reads scope_sha_end as written (an all-digit sha keeps its
  leading zero), and Voyage's own artifacts under .claude/projects/ and
  .claude/voyage/ committed after the review do not count as unreviewed.

Suite 2170 (2168/0/2).

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

146 lines
6.9 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;
}
function unquote(v) {
if (v.startsWith('"')) {
// The last quote closes the value unless an odd run of backslashes escapes it.
const escaped = (v.slice(1, -1).match(/\\*$/)[0].length % 2) === 1;
if (v.length < 2 || !v.endsWith('"') || escaped) return { error: 'unclosed double quote' };
try { return { value: JSON.parse(v) }; } catch { return { error: 'bad escape in a double-quoted value' }; }
}
if (v.startsWith("'")) {
if (v.length < 2 || !v.endsWith("'")) return { error: 'unclosed single quote' };
return { value: v.slice(1, -1).replace(/''/g, "'") };
}
return null;
}
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);
}