fix(review): round 10 of the release review of 6.3.0, for 6.3.1

Round 10 (0f400d3..a1e0e50, WARN: 0 BLOCKER, 11 MAJOR): six findings, by
operator choice. Each began with a test that failed on a1e0e50 on its
assertion, every case on its own; suite 2236 (2234/0/2).

- Files: a new line inside a description opens a new entry when it opens
  with a plain file with a slash, or with one alone on the line or followed
  by a colon or a dash ("src/cli.mjs", "package.json", "Makefile - a
  target"); "child_process.spawnSync, path.join", "Node.js 22 only" and
  "index.js and the CLI" stay description (ecd9da6f).
- Files: a numbered list item (`1.` or `1)`) is an entry, its marker
  dropped like `-` (b4215783).
- Files: a backticked capitalised name is a file only alone in its entry
  before any description; `Parser` inside one is not (39a57fee).
- Files: a backticked span that is no path (`x()`:) starts no description
  with its colon (882cc82a); a plain file with a colon starts it wherever it
  stands ("the entry cli.mjs: wires parse", a6bd9c8c).
- Measured over all 939 Files: fields in the .md files under the
  marketplace and the test repo (560 of them in the 48 plan.md files outside
  worktrees): 0 read differently from a1e0e50, tests-only changed in 0.
- The trailing-json fence reader is built on plan-schema's fenceTracker:
  a ~~~ fence is a fence, so a ```json line inside one no longer swallows
  the review's own block or hides its BLOCKER ids (0d94c5fe). This also
  closes a gate hole in 6.3.0 and a1e0e50: a last block written as ~~~json
  holding a BLOCKER, after a clean ```json block, passed with exit 0; now 2.
- Kept on purpose: a fence line indented four spaces or more still counts
  in that reader (fenceTracker's new anyIndent). Markdown's three-space
  rule made a BLOCKER block inside a list item, four spaces in, invisible,
  and the gate passed it with exit 0 (seen while fixing, before commit); a
  ```json line in a code block indented four spaces fails the gate closed.
  Both are pinned in the 0d94c5fe test.
- The fieldPaths doc names rounds 1-10 (it said 1-7).

Not in this round (operator 2026-10-11): F8 754723c1 (an info string
" json" fails closed, as in 6.3.0), F9 88de9e6b, F10 164c5bd0, F11
e70b28b7 (dc3a467e). F1 878e3960: this title carries a scope; the three
earlier commits are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-11 13:36:17 +02:00
commit 10ac9c5231
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 169 additions and 53 deletions

View file

@ -196,12 +196,15 @@ export function ceremonyFromBrief(briefText) {
* Tracks fenced code blocks the CommonMark way: a fence closes only on the same
* character (` or ~), at least as long as the opener, with nothing after it.
* `feed(line)` returns true when the line is inside a fence or is a fence line.
* A fence line is indented up to three spaces; with `anyIndent` any indent
* counts (the trailing-json readers, findings-schema `fencedBlocks`).
*/
export function fenceTracker() {
export function fenceTracker({ anyIndent = false } = {}) {
const fenceLine = anyIndent ? /^[ \t]*(`{3,}|~{3,})(.*)$/ : /^\s{0,3}(`{3,}|~{3,})(.*)$/;
let open = null; // { ch, len }
return {
feed(line) {
const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
const m = line.match(fenceLine);
if (!open) {
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
open = { ch: m[1][0], len: m[1].length };
@ -291,29 +294,36 @@ export function onFailureAction(text) {
* path. Measured on the plan.md files outside worktrees under the marketplace
* and the test repo: 520 Files: fields in 48 plan files at rounds 7-8 (round 7
* wrote "182 plans" here; round 8's count of the files is 48), and 560 fields
* in 48 plan files at round 9 (2026-10-11), 548 of them with a backticked
* path. In 3 of those a plain path-like word stands beside it, each inside a
* description, and none of them is read as a file:
* in 48 plan files at round 9 (2026-10-11; the same at round 10), 548 of
* them with a backticked path. In 3 of those a plain path-like word stands
* beside it, each inside a description, and none of them is read as a file:
*
* - A field with a backticked path names its backticked spans that pass the
* path test (a `.`, `/` or `\`; no URL, option or call like `parse()`),
* wherever they sit (`a.mjs`/`b.mjs`, inside a note), a backticked build
* file (`Makefile`, `Dockerfile`), and a backticked capitalised name with no
* extension that stands alone in its entry. Beside them a plain word counts
* only when it is clearly a file or a directory, and only before the entry's
* description starts (a dash, or a colon after a path, backticked or plain:
* "src/cli.mjs: wires parse"): a path with a slash that ends in a file or a
* extension that stands alone in its entry before any description. Beside
* them a plain word counts only when it is clearly a file or a directory,
* and only before the entry's description starts (a dash, or a colon after
* a path, backticked or plain, wherever it stands: "src/cli.mjs: wires
* parse", "the entry cli.mjs: wires parse"; a colon after a span that is no
* path, `x()`:, starts none): a path with a slash that ends in a file or a
* `/` anywhere there ("the CLI entry src/cli.mjs"), and a name with an
* extension, a dotfile or a build file only where an entry or a list starts:
* first in its entry, or after "and", "or", "+" or "&" (", Dockerfile", "and
* Makefile", ", package.json"). A description runs on past a comma and onto
* the next line until a new entry starts: a list item on a new line, or a
* backticked path. Round 7: such files were dropped, and a step that builds
* something read as tests-only. Rounds 8-9: "— mocks fs.readFileSync", "runs
* on Node.js 22.x", "no change to package.json", "— mocks fs,
* child_process.spawnSync" or "src/cli.mjs: wires parse and README.md" read
* as files, and a step that holds only its test did not. "covers
* pass/fail", "JSON, YAML" or "v6.3.0 format" is no path.
* the next line until a new entry starts: a list item on a new line (`-`,
* `1.` or `1)`), a backticked path, or a new line that opens with a plain
* file with a slash, or with one alone on the line or followed by a colon or
* a dash ("src/cli.mjs: the fix", "package.json", "Makefile — a target").
* Round 7: such files were dropped, and a step that builds something read as
* tests-only. Rounds 8-9: "— mocks fs.readFileSync", "runs on Node.js 22.x",
* "no change to package.json", "— mocks fs, child_process.spawnSync" or
* "src/cli.mjs: wires parse and README.md" read as files, and a step that
* holds only its test did not. Round 10: a plain file on the next line, a
* numbered item, a `Parser` inside a description, `x()`: and a mid-entry
* "cli.mjs:" were each misread. "covers pass/fail", "JSON, YAML", "v6.3.0
* format" or a next line "index.js and the CLI" is no path.
* - A field with no backticked path names every path-like word: a name with an
* extension, a dotfile, a path with a slash, a directory (`docs/`), a glob
* (`src/*.mjs`) or a build file; a parenthesised note is not read. Another
@ -321,7 +331,7 @@ export function onFailureAction(text) {
* capitalised; "format" or "v6.3.0" in a description is no path.
*
* A backslash is read as a slash, a leading `./` is dropped. Release review of
* 6.3.0, rounds 1-7.
* 6.3.0, rounds 1-10.
*/
const SEG = '[\\w@.*?()-]';
const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$|^\\.[\\w-][\\w.-]*$`);
@ -337,7 +347,7 @@ const BUILD_FILE = /^(?!Profile$)(?:[A-Z][a-z]+|GNUmake)file$|^(?:make|rake|just
// Where an entry's description starts, and the words that start a list item.
const DESCRIPTION_DASH = /^(?:\u2014|\u2013|-|--)$/;
const LIST_JOINER = /^(?:and|or|\+|&)$/i;
const LIST_ITEM = /^\s*(?:[-*+]|\d+\.)\s/;
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
const lastSegment = (p) => p.replace(/\/$/, '').split('/').pop();
const norm = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
// A backticked span that is a path, not a URL, an option or a call (`parse()`, `p.parse()`).
@ -347,6 +357,14 @@ const plainWord = (w) => w.replace(/^[(\[]+/, '').replace(/[.,;:)\]]+$/, '').rep
const isPathWord = (w) => PATH_WORD.test(w) && !NOT_A_PATH.test(w);
// Clearly a file or a directory: what a plain word beside backticked paths must be.
const isFileWord = (w) => BUILD_FILE.test(w) || (isPathWord(w) && (FILE_OR_DIR.test(w) || BUILD_FILE.test(lastSegment(w))));
// A line inside a description that opens a new entry: its first word is a
// plain file with a slash, or a plain file alone on the line or followed by a
// colon or a dash ("src/cli.mjs", "package.json", "Makefile — a target"), not
// "child_process.spawnSync, path.join" or "Node.js 22 only" (round 10).
const opensEntry = (tokens) => {
const w = plainWord(tokens[0]);
return isFileWord(w) && (w.includes('/') || tokens.length === 1 || tokens[0].endsWith(':') || DESCRIPTION_DASH.test(tokens[1]));
};
export function fieldPaths(text) {
const spans = [];
@ -357,41 +375,44 @@ export function fieldPaths(text) {
const tickedField = isTicked.some(Boolean);
const out = [];
// A description runs on past a comma and onto the next line until a new
// entry starts: a list item on a new line, or a backticked path (round 9).
// entry starts: a list item on a new line (`-`, `1.`, `1)`), a backticked
// path, or a line that opens with a plain file (rounds 9-10).
let described = false;
for (const line of masked.split('\n')) {
if (LIST_ITEM.test(line)) described = false;
for (const raw of line.split(',')) {
const item = LIST_ITEM.exec(line);
const body = item ? line.slice(item[0].length) : line;
if (item || (described && body.trim() !== '' && opensEntry(body.trim().split(/\s+/)))) described = false;
for (const raw of body.split(',')) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const tokens = entry.split(/\s+/);
const first = /^\u0000(\d+)\u0000/.exec(entry);
if (first && isTicked[Number(first[1])]) described = false;
// A backticked capitalised name alone in its entry is a file (round 6).
if (first && first[0] === entry && BARE_NAME.test(spans[Number(first[1])])) {
// A backticked capitalised name alone in its entry, before any
// description, is a file (rounds 6, 10).
if (first && first[0] === entry && !described && BARE_NAME.test(spans[Number(first[1])])) {
out.push(spans[Number(first[1])]);
described = false;
continue;
}
if (tickedField) {
// In the order they stand: the backticked paths, and before the
// description a plain word that is clearly a file where it may stand
// (rounds 7-8). A path followed by a colon starts its description,
// backticked or plain (round 9).
// backticked or plain, wherever it stands; a span that is no path
// does not (rounds 9-10).
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.includes('\u0000')) {
out.push(...[...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((n) => isTicked[n]).map((n) => spans[n]));
if (t.endsWith(':')) described = true;
const ticked = [...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((n) => isTicked[n]);
out.push(...ticked.map((n) => spans[n]));
if (ticked.length > 0 && t.endsWith(':')) described = true;
continue;
}
if (DESCRIPTION_DASH.test(t)) described = true;
const w = plainWord(t);
if (described || !isFileWord(w)) continue;
if (w.includes('/') || i === 0 || LIST_JOINER.test(tokens[i - 1])) {
out.push(w);
if (t.endsWith(':')) described = true;
}
if (w.includes('/') || i === 0 || LIST_JOINER.test(tokens[i - 1])) out.push(w);
if (t.endsWith(':')) described = true;
}
continue;
}

View file

@ -25,6 +25,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { issue, fail } from '../util/result.mjs';
import { RULE_KEYS, SEVERITY_VALUES } from './rule-catalogue.mjs';
import { fenceTracker } from '../parsers/plan-schema.mjs';
import { isMain } from '../util/is-main.mjs';
// The fields main + the coordinator depend on. Descriptive fields are not here
@ -37,21 +38,26 @@ export const FINDING_REQUIRED_FIELDS = Object.freeze([
]);
// The fenced blocks of a Markdown text, in order, read as Markdown reads a
// backtick fence: an opening line of three or more backticks whose info string
// holds no backtick, closed only by a line of backticks at least as long as the
// opening run (round 9: any three closed any opener, so a four-backtick example
// of a json block split there). A block never closed runs to the end of the
// text (`closed: false`). Indented or not, a fence counts, also inside a list
// item; a `\r` before a line break is no part of the line, so a CRLF file reads
// as its LF twin. A JSON string cannot hold a line break, so three backticks
// inside one ("a fenced ```bash block") never end a block, and three backticks
// after text on the same line are no fence (release review of 6.3.0, rounds
// 7-9). `info` is the info string as written, trailing blanks dropped; `body`
// is the text between the fence lines, as written.
// fence (plan-schema `fenceTracker`, the one fence rule): an opening line of
// three or more backticks or tildes, closed only by a line of the same
// character at least as long as the opening run (round 9: any three closed any
// opener, so a four-backtick example of a json block split there). A backtick
// opener's info string holds no backtick. Inside a ~~~ block a ```json line
// opens nothing (round 10: it swallowed the real block, and the review
// validator lost every BLOCKER id). A block never closed runs to the end of the
// text (`closed: false`). Indented or not, a fence counts: a fence inside a
// list item can sit four spaces in, and a gate that skipped it would pass a
// BLOCKER written there; the cost is that a ```json line in a code block
// indented four spaces fails the gate closed (exit 2), never open. A `\r`
// before a line break is no part of the line, so a CRLF file reads as its LF
// twin. A JSON string cannot hold a line break, so three backticks inside one
// ("a fenced ```bash block") never end a block, and three backticks after text
// on the same line are no fence (release review of 6.3.0, rounds 7-10).
// `info` is the info string as written, trailing blanks dropped; `body` is the
// text between the fence lines, as written.
// One reader for the verdict gate and every trailing-json reader: the review
// validator, the synthesis digest, fill-finding-ids, recurrence, fidelity-diff.
const FENCE_OPEN = /^[ \t]*(`{3,})([^`]*)$/;
const FENCE_CLOSE = /^[ \t]*(`{3,})[ \t]*$/;
const FENCE_INFO = /^[ \t]*(?:`{3,}|~{3,})(.*)$/;
/**
* @param {string} text
@ -60,20 +66,19 @@ const FENCE_CLOSE = /^[ \t]*(`{3,})[ \t]*$/;
export function fencedBlocks(text) {
const blocks = [];
if (typeof text !== 'string') return blocks;
const fence = fenceTracker({ anyIndent: true });
let open = null;
for (let at = 0; at <= text.length;) {
const nl = text.indexOf('\n', at);
const end = nl === -1 ? text.length : nl;
const line = text.slice(at, end).replace(/\r$/, '');
if (open === null) {
const m = FENCE_OPEN.exec(line);
if (m) open = { run: m[1].length, info: m[2].replace(/[ \t]+$/, ''), start: end + 1 };
} else {
const m = FENCE_CLOSE.exec(line);
if (m && m[1].length >= open.run) {
blocks.push({ info: open.info, body: text.slice(open.start, at), closed: true });
open = null;
}
const wasInside = fence.inside;
fence.feed(line);
if (!wasInside && fence.inside) {
open = { info: FENCE_INFO.exec(line)[1].replace(/[ \t]+$/, ''), start: end + 1 };
} else if (wasInside && !fence.inside) {
blocks.push({ info: open.info, body: text.slice(open.start, at), closed: true });
open = null;
}
if (nl === -1) break;
at = nl + 1;

View file

@ -1193,3 +1193,93 @@ test('review 630 r9 / brief note 8: the Files: reader states one count of plan f
assert.match(text, /560 fields in 48 plan files at round 9/);
assert.doesNotMatch(text.replace(/round 7 wrote "182 plans" here/, ''), /182/, 'the round-7 count is named only as the one replaced');
});
// ==== round 10 of the release review of 6.3.0 (0f400d3..a1e0e50, 2026-10-11), fixed in 6.3.1 ====
test('review 630 r10 / ecd9da6f plan-schema.mjs:363: a new line that opens with a plain file starts a new entry', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs`: the test\n src/cli.mjs', ['tests/a.test.mjs', 'src/cli.mjs']],
['`tests/a.test.mjs` — the test\n src/cli.mjs: the fix', ['tests/a.test.mjs', 'src/cli.mjs']],
['`tests/a.test.mjs` — the test\n src/a.mjs, src/b.mjs', ['tests/a.test.mjs', 'src/a.mjs', 'src/b.mjs']],
['`tests/a.test.mjs` — the test\n package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` — the test\n Makefile — a target', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
// The control: a description that runs onto the next line names no file
// there (round 9), unless the line opens with one in an entry's own shape.
for (const files of ['`tests/a.test.mjs` — mocks fs and\n child_process.spawnSync, path.join',
'`tests/a.test.mjs` — runs on\n Node.js 22 only', '`tests/a.test.mjs` — covers\n index.js and the CLI']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
});
test('review 630 r10 / b4215783 plan-schema.mjs:365: a numbered list item is an entry, `1.` or `1)`', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs`\n 1. package.json\n 2. src/b.mjs', ['tests/a.test.mjs', 'package.json', 'src/b.mjs']],
['`tests/a.test.mjs`\n 1) package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` — the test, mocks fs\n 2) Makefile', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
});
test('review 630 r10 / 39a57fee plan-schema.mjs:371: a backticked capitalised name inside a description is no file', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const files of ['`tests/a.test.mjs` — covers `parse`, `Parser`, `ParseError`',
'`tests/a.test.mjs` — mocks fs, `Date`, and child_process.spawnSync']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
// The control: alone in its own entry, before any description, it is a file (round 6).
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs`, `Parser`')), { testsOnly: false, files: ['tests/a.test.mjs', 'Parser'] });
});
test('review 630 r10 / 882cc82a plan-schema.mjs:385: a backticked span that is no path starts no description with a colon', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs` adds `x()`: y, package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` adds `parse()`: y and Makefile', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
// The control: a backticked path with a colon still starts its description.
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs`: y and package.json')), { testsOnly: true, files: ['tests/a.test.mjs'] });
});
test('review 630 r10 / a6bd9c8c plan-schema.mjs:391: a plain file with a colon starts its description wherever it stands', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const files of ['`tests/a.test.mjs`, the entry cli.mjs: wires parse and README.md',
'`tests/a.test.mjs`, exercises cli.mjs: argv and package.json',
'`tests/a.test.mjs`, then index.js: reads argv and .env']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
});
test('review 630 r10 / 0d94c5fe findings-schema.mjs:53: a ~~~ fence is a fence; a fence four spaces in still counts, so the gate fails closed', async () => {
const { extractFindingsBlock, fencedBlocks } = await import('../../lib/review/findings-schema.mjs');
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
const { validateReviewContent } = await import('../../lib/validators/review-validator.mjs');
const asBlock = (text) => text.replace('verdict: WARN\nfindings: []', `verdict: BLOCK\nfindings:\n - ${'a'.repeat(40)}`);
// A ~~~ example that holds a ```json line, then the review's own block: the
// ```json line opens nothing, so the block, the gate and the BLOCKER ids are read.
const tilde = `~~~\n${B3}json\n~~~\n\n`;
assert.equal(extractFindingsBlock(`${tilde}${B3}json\n{"findings": []}\n${B3}\n`), '{"findings": []}\n');
const v = decideVerdict(r9Review(`${tilde}${r9Clean}`));
assert.equal(v.exit, 0, v.reason);
const r = validateReviewContent(asBlock(r9Review(`${tilde}${B3}json\n${R9_BLOCK}\n${B3}\n`)));
assert.ok([...r.errors, ...r.warnings].some((i) => i.code === 'REVIEW_MISSING_REMEDIATION_STEP'),
`a BLOCKER with no ### Step R<n> is found: ${JSON.stringify([...r.errors, ...r.warnings].map((i) => i.code))}`);
// A ~~~ block is one block, closed only by ~~~; a ~~~json last block is read.
assert.deepEqual(fencedBlocks(`~~~md\n${B3}json\n{}\n~~~\n`).map((b) => [b.info, b.body, b.closed]), [['md', `${B3}json\n{}\n`, true]]);
const tildeJson = decideVerdict(r9Review(`${r9Clean}\n~~~json\n${R9_BLOCK}\n~~~\n`));
assert.notEqual(tildeJson.exit, 0, `a ~~~json block after the clean one is the last block: ${tildeJson.reason}`);
// Indented or not, a fence counts. A BLOCKER block inside a list item, four
// spaces in, after the clean block is the last block and is never passed...
const listItem = `${r9Clean}\n1. The payload:\n\n${`${B3}json\n${R9_BLOCK}\n${B3}`.replace(/^/gm, ' ')}\n`;
assert.notEqual(decideVerdict(r9Review(listItem)).exit, 0, decideVerdict(r9Review(listItem)).reason);
// ...and a ```json line in a code block four spaces in fails the gate closed, never open.
const coded = decideVerdict(r9Review(`x\n\n ${B3}json\n\nx\n\n${r9Clean}`));
assert.equal(coded.exit, 2, coded.reason);
});