fix: round 5 of the release review of 6.3.0, the structural fix

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-11 00:38:42 +02:00
commit c3641ffa42
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
17 changed files with 409 additions and 111 deletions

View file

@ -10,6 +10,14 @@
// 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*$/;
@ -27,7 +35,7 @@ export function splitFile(text) {
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. */
/** 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)');
@ -39,6 +47,24 @@ export function plainScalarHazards(value) {
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.
@ -101,7 +127,7 @@ export function readFrontmatterFields(text) {
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]+/, '');
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}"` });
@ -111,16 +137,23 @@ export function readFrontmatterFields(text) {
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);
// 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 (rest === '') {
} 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);
@ -129,15 +162,18 @@ export function readFrontmatterFields(text) {
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();
} 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(rest)) hazards.push({ line: lineNo, message: `${key}: ${h}` });
fields[key] = { value: rest, line: lineNo, style: 'plain' };
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;