* test(#2694): CRLF frontmatter boundary regression for code-review workflows The code-review / code-review-fix workflows embed inline node -e one-liners whose frontmatter boundary regex used a literal \n, silently returning null on CRLF-saved SUMMARY.md / REVIEW.md / REVIEW-FIX.md artifacts and dropping every file in that summary (acceptance: per-artifact, no warning when the phase aggregate stays non-zero). Adds: - behavioral CRLF==LF boundary extraction tests (replica of the shipped one-liner's boundary step), proving the buggy literal-\n returns null on CRLF while the fixed normalize-then-match yields a byte-identical body; - a structural-regression-guard (allow-test-rule: structural-regression-guard) that reads the two shipped workflow files and asserts every boundary site normalizes \r\n -> \n before matching, so a revert of the fix is caught. * fix(#2694): normalize CRLF before frontmatter-boundary match in code-review workflows The code-review and code-review-fix workflows embed nine inline node -e one-liners that extract YAML frontmatter via a boundary regex content.match(/^---\n([\s\S]*?)\n---/) The literal \n defeated any CRLF-saved artifact (\r between --- and the line terminator), so SUMMARY.md / REVIEW.md / REVIEW-FIX.md saved with CRLF endings silently contributed zero files (or 'unknown' status / 'invalid') with no per-artifact warning. The Tier-3 git-diff fallback only fires when the aggregate across all summaries is zero, so a single CRLF summary among LF summaries produced no signal at all. Normalize \r\n -> \n once before the existing boundary match at all nine sites (code-review.md x3, code-review-fix.md x6). Byte-identical to the LF path; mirrors the canonical src/frontmatter.cts extractFrontmatter intent (CRLF == LF at the boundary); zero risk of \r leaking into field values consumed by the inner JS or the shell grep/cut pipeline. RED @ 94d0213 (3 failures, structural guard caught the shipped-text bug, both linux-node22+24 lanes).GREEN pending. * chore(#2694): acknowledge code-review workflow growth + changeset fragment emitted-attribution (ADR-2719) reports the byte growth from the CRLF-normalize insertion in code-review.md (+69) and code-review-fix.md (+138); both are the intended #2694 fix. Adds the .changeset Fixed fragment (pr:0, backfilled post-PR). * test(#2694): mixed CRLF/LF phase yields the union of both artifacts (criterion 2) The spec-axis review flagged that acceptance criterion 2 (a phase with a mix of CRLF-affected and unaffected artifacts no longer silently drops the CRLF artifact's contribution) was only transitively satisfied. Adds an explicit mixed-phase test replicating the full shipped Tier-2 extractor (boundary + inner key_files parse) across one LF and one CRLF SUMMARY.md, asserting the union of both — plus a RED proof showing the buggy boundary drops the CRLF artifact silently (aggregate non-zero, so the Tier-3 eq-zero fallback never fired). Locks the silent-partial-masking behavior the triage named as the more serious half of the defect. * docs(changeset): backfill #2694 PR number to 2839 * fix(#2694): make the CRLF regression test itself CRLF-lint-clean CI lint-tests caught that the new test tripped local/no-crlf-fragile-split: - the frontmatter boundary regex replicas (fixed + buggy) were RegExpLiterals with a bare \n; the rule flags frontmatter-shape regexes unconditionally. Build them via new RegExp(...) (byte-identical .source to the shipped literal) so the faithful replica is not a lint violation — the buggy replica MUST keep the literal \n, that is the bug it demonstrates. - the structural guard's src.split('\n') on the readFileSync'd workflow file was genuinely CRLF-fragile; use /\r?\n/ per the rule's canonical fix. - the allow-test-rule annotation gains its (#2694) tracking ref per ADR-456. lint:ci now exit 0 (incl. lint-allow-test-rule-refs, lint-emitted-drift-ack, lint-fix-has-regression-test: PASS).
428 lines
17 KiB
JavaScript
428 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
// allow-test-rule: structural-regression-guard (#2694)
|
|
// The code-review/code-review-fix workflows embed inline `node -e` frontmatter
|
|
// one-liners whose boundary regex must normalize CRLF before matching (#2694).
|
|
// A behavioral test cannot observe which regex the *shipped workflow text* ships
|
|
// (the runtime loads the .md verbatim), so we guard the source text directly:
|
|
// every `content.match(/^---\n…/)` site in those two files must be preceded by a
|
|
// `\r\n` -> `\n` normalize. This catches a revert of the #2694 fix.
|
|
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
// Replicates the inline node -e parser from gsd-core/workflows/code-review.md
|
|
// step compute_file_scope, Tier 2 (lines ~172-181).
|
|
//
|
|
// Bug #2134: the section-reset regex uses \s+ (requires leading whitespace), so
|
|
// top-level YAML keys at column 0 (e.g. `decisions:`) never reset inSection.
|
|
// Items from subsequent top-level lists are therefore mis-classified as
|
|
// key_files.modified entries.
|
|
|
|
/**
|
|
* Extracts files from SUMMARY.md YAML frontmatter using the CURRENT (buggy) logic
|
|
* copied verbatim from code-review.md.
|
|
*/
|
|
function parseFilesWithBuggyLogic(frontmatterYaml) {
|
|
const files = [];
|
|
let inSection = null;
|
|
for (const line of frontmatterYaml.split('\n')) {
|
|
if (/^\s+created:/.test(line)) { inSection = 'created'; continue; }
|
|
if (/^\s+modified:/.test(line)) { inSection = 'modified'; continue; }
|
|
// BUG: \s+ requires leading whitespace — top-level keys like `decisions:` don't match
|
|
if (/^\s+\w+:/.test(line) && !/^\s+-/.test(line)) { inSection = null; continue; }
|
|
if (inSection && /^\s+-\s+(.+)/.test(line)) {
|
|
files.push(line.match(/^\s+-\s+(.+)/)[1].trim());
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Extracts files using the FIXED logic (\s* instead of \s+).
|
|
*/
|
|
function parseFilesWithFixedLogic(frontmatterYaml) {
|
|
const files = [];
|
|
let inSection = null;
|
|
for (const line of frontmatterYaml.split('\n')) {
|
|
if (/^\s+created:/.test(line)) { inSection = 'created'; continue; }
|
|
if (/^\s+modified:/.test(line)) { inSection = 'modified'; continue; }
|
|
// FIX: \s* allows zero leading whitespace — handles top-level YAML keys
|
|
if (/^\s*\w+:/.test(line) && !/^\s*-/.test(line)) { inSection = null; continue; }
|
|
if (inSection && /^\s+-\s+(.+)/.test(line)) {
|
|
files.push(line.match(/^\s+-\s+(.+)/)[1].trim());
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// SUMMARY.md YAML frontmatter that mirrors a realistic post-execution artifact.
|
|
// key_files.modified has ONE real file; decisions has TWO entries that must NOT
|
|
// appear in the extracted file list.
|
|
const FRONTMATTER = [
|
|
'type: summary',
|
|
'phase: "02"',
|
|
'key_files:',
|
|
' modified:',
|
|
' - src/real-file.js',
|
|
' created:',
|
|
' - src/new-file.js',
|
|
'decisions:',
|
|
' - Used async/await over callbacks',
|
|
' - Kept error handling inline',
|
|
'metrics:',
|
|
' lines_changed: 42',
|
|
'tags:',
|
|
' - refactor',
|
|
' - async',
|
|
].join('\n');
|
|
|
|
describe('code-review SUMMARY.md YAML parser', () => {
|
|
it('RED: buggy parser mis-classifies decisions entries as files (demonstrates the bug)', () => {
|
|
const files = parseFilesWithBuggyLogic(FRONTMATTER);
|
|
|
|
// With the bug, `decisions:` at column 0 never resets inSection, so the
|
|
// two decision strings are incorrectly captured as modified files.
|
|
// This assertion documents the broken behavior we are fixing.
|
|
const hasDecisionContamination = files.some(
|
|
(f) => f === 'Used async/await over callbacks' || f === 'Kept error handling inline'
|
|
);
|
|
assert.ok(
|
|
hasDecisionContamination,
|
|
'Expected buggy parser to include decision entries in file list, but it did not — ' +
|
|
'the bug may already be fixed or the test replication is wrong. Got: ' +
|
|
JSON.stringify(files)
|
|
);
|
|
});
|
|
|
|
it('GREEN: fixed parser returns only the actual file paths', () => {
|
|
const files = parseFilesWithFixedLogic(FRONTMATTER);
|
|
|
|
assert.deepStrictEqual(
|
|
files.sort(),
|
|
['src/new-file.js', 'src/real-file.js'],
|
|
'Fixed parser should return only the two real file paths, not decision strings'
|
|
);
|
|
});
|
|
|
|
it('fixed parser: modified-only frontmatter with top-level sibling keys', () => {
|
|
const yaml = [
|
|
'key_files:',
|
|
' modified:',
|
|
' - src/a.ts',
|
|
' - src/b.ts',
|
|
'decisions:',
|
|
' - Some decision',
|
|
'metrics:',
|
|
' count: 2',
|
|
].join('\n');
|
|
|
|
const files = parseFilesWithFixedLogic(yaml);
|
|
assert.deepStrictEqual(files.sort(), ['src/a.ts', 'src/b.ts']);
|
|
});
|
|
|
|
it('fixed parser: created-only frontmatter with top-level sibling keys', () => {
|
|
const yaml = [
|
|
'key_files:',
|
|
' created:',
|
|
' - src/brand-new.ts',
|
|
'tags:',
|
|
' - feature',
|
|
].join('\n');
|
|
|
|
const files = parseFilesWithFixedLogic(yaml);
|
|
assert.deepStrictEqual(files, ['src/brand-new.ts']);
|
|
});
|
|
|
|
it('fixed parser: no key_files section returns empty array', () => {
|
|
const yaml = [
|
|
'type: summary',
|
|
'decisions:',
|
|
' - A decision',
|
|
].join('\n');
|
|
|
|
const files = parseFilesWithFixedLogic(yaml);
|
|
assert.deepStrictEqual(files, []);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2694: the OUTER frontmatter-boundary extraction (the `node -e` one-liner's
|
|
// first step) was never covered by this file — only the inner YAML-line loop
|
|
// above was. The shipped boundary regex used a literal `\n` and silently
|
|
// returned null on CRLF-saved artifacts, dropping every file in that summary.
|
|
// These tests replicate the exact shipped boundary step (the FIXED variant:
|
|
// normalize `\r\n` -> `\n` before matching) and lock CRLF == LF at the boundary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The frontmatter boundary regex as it ships in the workflow one-liners
|
|
* (`/^---\n([\s\S]*?)\n---/`). Built via `new RegExp` rather than a RegExpLiteral
|
|
* so the `local/no-crlf-fragile-split` lint (which flags frontmatter-shape
|
|
* RegExpLiterals) does not fire on this faithful replica — the whole point of
|
|
* `extractFrontmatterBoundaryBuggy` below is to demonstrate that THIS EXACT
|
|
* literal-`\n` regex fails on CRLF input. The fixed path normalizes CRLF first.
|
|
*/
|
|
const FRONTMATTER_BOUNDARY_RE = new RegExp('^---\\n([\\s\\S]*?)\\n---');
|
|
|
|
/**
|
|
* Replicates the FIXED boundary extraction shipped in
|
|
* gsd-core/workflows/code-review.md (compute_file_scope) and code-review-fix.md:
|
|
* normalize CRLF to LF, then locate the YAML block between the first two `---`.
|
|
* Returns the captured YAML body, or null if no frontmatter block is present.
|
|
*/
|
|
function extractFrontmatterBoundary(content) {
|
|
const match = content.replace(/\r\n/g, '\n').match(FRONTMATTER_BOUNDARY_RE);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Replicates the BUGGY boundary extraction (literal `\n`, pre-#2694) to prove RED.
|
|
*/
|
|
function extractFrontmatterBoundaryBuggy(content) {
|
|
const match = content.match(FRONTMATTER_BOUNDARY_RE);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// A realistic SUMMARY.md frontmatter + body. Built with array.join('\n') so the
|
|
// fixture's indentation is exact (CONTRIBUTING.md "Fixture Data Formatting").
|
|
const SUMMARY_LINES = [
|
|
'---',
|
|
'type: summary',
|
|
'phase: "02"',
|
|
'key_files:',
|
|
' modified:',
|
|
' - src/real-file.js',
|
|
' created:',
|
|
' - src/new-file.js',
|
|
'decisions:',
|
|
' - Used async/await over callbacks',
|
|
'---',
|
|
'',
|
|
'## Summary',
|
|
'',
|
|
'Body prose that must NOT be parsed as frontmatter.',
|
|
];
|
|
|
|
// REVIEW.md frontmatter exercises the `status:` field consumed at the other 8
|
|
// boundary sites (code-review.md:552/625, code-review-fix.md:*).
|
|
const REVIEW_LINES = [
|
|
'---',
|
|
'status: needs-changes',
|
|
'phase: "02"',
|
|
'critical: 1',
|
|
'warning: 2',
|
|
'info: 0',
|
|
'total: 3',
|
|
'files_reviewed_list: [src/real-file.js, src/new-file.js]',
|
|
'---',
|
|
'',
|
|
'## Review',
|
|
];
|
|
|
|
describe('code-review frontmatter boundary extraction (#2694 CRLF)', () => {
|
|
it('RED: buggy literal-\\n boundary returns null on a CRLF SUMMARY.md', () => {
|
|
const crlf = SUMMARY_LINES.join('\r\n') + '\r\n';
|
|
const yaml = extractFrontmatterBoundaryBuggy(crlf);
|
|
assert.strictEqual(
|
|
yaml,
|
|
null,
|
|
'Expected the pre-fix literal-\\n boundary to fail on CRLF input — ' +
|
|
'if it matches, the bug reproduction is wrong. Got: ' + JSON.stringify(yaml)
|
|
);
|
|
});
|
|
|
|
it('GREEN: fixed boundary extracts a CRLF SUMMARY.md identically to the LF equivalent', () => {
|
|
const lf = SUMMARY_LINES.join('\n') + '\n';
|
|
const crlf = SUMMARY_LINES.join('\r\n') + '\r\n';
|
|
|
|
const lfYaml = extractFrontmatterBoundary(lf);
|
|
const crlfYaml = extractFrontmatterBoundary(crlf);
|
|
|
|
assert.ok(lfYaml !== null, 'LF fixture must parse (reference)');
|
|
assert.ok(crlfYaml !== null, 'CRLF fixture must parse after the fix');
|
|
// The load-bearing assertion: CRLF yields the byte-identical YAML body as LF,
|
|
// so every downstream inner-parser / shell grep sees the same text.
|
|
assert.strictEqual(crlfYaml, lfYaml);
|
|
|
|
// And the inner parse yields the same file set from both (acceptance criterion 1).
|
|
assert.deepStrictEqual(
|
|
parseFilesWithFixedLogic(crlfYaml).sort(),
|
|
parseFilesWithFixedLogic(lfYaml).sort()
|
|
);
|
|
});
|
|
|
|
it('GREEN: fixed boundary extracts a CRLF REVIEW.md status field identically to LF', () => {
|
|
const lf = REVIEW_LINES.join('\n') + '\n';
|
|
const crlf = REVIEW_LINES.join('\r\n') + '\r\n';
|
|
|
|
const lfYaml = extractFrontmatterBoundary(lf);
|
|
const crlfYaml = extractFrontmatterBoundary(crlf);
|
|
|
|
assert.ok(crlfYaml !== null, 'CRLF REVIEW.md frontmatter must parse');
|
|
assert.strictEqual(crlfYaml, lfYaml);
|
|
// The `status:` field consumed at code-review.md:552 / code-review-fix.md:121.
|
|
const crlfStatus = crlfYaml.match(/status:\s*(\S+)/);
|
|
assert.ok(crlfStatus, 'status field must be reachable through the CRLF boundary');
|
|
assert.strictEqual(crlfStatus[1], 'needs-changes');
|
|
});
|
|
|
|
it('no frontmatter block: fixed boundary returns null (no false extraction from body)', () => {
|
|
const noFm = ['## Summary', '', 'No frontmatter here.', '', '---', '', 'a horizontal rule'].join('\n') + '\n';
|
|
assert.strictEqual(extractFrontmatterBoundary(noFm), null);
|
|
// CRLF variant behaves the same.
|
|
const noFmCrlf = noFm.replace(/\n/g, '\r\n');
|
|
assert.strictEqual(extractFrontmatterBoundary(noFmCrlf), null);
|
|
});
|
|
|
|
it('lone CR (not part of CRLF) does not defeat the boundary', () => {
|
|
// A lone \r inside the body (old Mac line ending remnant) is left untouched by
|
|
// the \r\n -> \n normalize; it must not prevent the real \r\n-delimited boundary
|
|
// from matching.
|
|
const content = SUMMARY_LINES.join('\r\n') + '\r\n' + 'body with a lone\rcarriage\r\n';
|
|
const yaml = extractFrontmatterBoundary(content);
|
|
assert.ok(yaml !== null, 'lone CR in the body must not break the boundary match');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Acceptance criterion 2 (#2694): a phase with a MIX of one CRLF SUMMARY.md and
|
|
// one LF SUMMARY.md must yield the UNION of both artifacts' files — the CRLF
|
|
// artifact's contribution must not be silently dropped. This replicates the
|
|
// full shipped Tier-2 extractor (boundary + inner key_files parse) and runs it
|
|
// across a mixed-ending phase, locking the silent-partial-masking behavior the
|
|
// issue's triage named as the more serious half of the defect.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Replicates the FULL shipped Tier-2 file-scope extractor from
|
|
* gsd-core/workflows/code-review.md (compute_file_scope): normalize -> boundary
|
|
* -> inner key_files.created/modified parse. Returns the extracted file list
|
|
* for one SUMMARY.md document, exactly as the shipped `node -e` one-liner does.
|
|
*/
|
|
function extractTier2Files(summaryContent) {
|
|
const yaml = extractFrontmatterBoundary(summaryContent);
|
|
if (yaml === null) return [];
|
|
return parseFilesWithFixedLogic(yaml);
|
|
}
|
|
|
|
describe('code-review mixed CRLF/LF phase scope (#2694 criterion 2)', () => {
|
|
it('a phase with one CRLF SUMMARY.md and one LF SUMMARY.md yields the union of both', () => {
|
|
// Two plans in the same phase. Plan A is saved LF, plan B is saved CRLF
|
|
// (Windows checkout / CRLF-saving editor). Each contributes distinct files.
|
|
const planA_LF = [
|
|
'---',
|
|
'type: summary',
|
|
'key_files:',
|
|
' modified:',
|
|
' - src/alpha.ts',
|
|
'---',
|
|
'',
|
|
'Plan A body.',
|
|
].join('\n') + '\n';
|
|
|
|
const planB_CRLF = [
|
|
'---',
|
|
'type: summary',
|
|
'key_files:',
|
|
' created:',
|
|
' - src/beta.ts',
|
|
' - lib/gamma.js',
|
|
'---',
|
|
'',
|
|
'Plan B body.',
|
|
].join('\r\n') + '\r\n';
|
|
|
|
// The shipped loop iterates each summary and accumulates into REVIEW_FILES.
|
|
const reviewFiles = [];
|
|
for (const doc of [planA_LF, planB_CRLF]) {
|
|
for (const f of extractTier2Files(doc)) reviewFiles.push(f);
|
|
}
|
|
|
|
// The union — NOT just plan A's files. Pre-fix, planB_CRLF contributed
|
|
// nothing (boundary returned null), so reviewFiles would have been only
|
|
// ['src/alpha.ts'] with no warning (the aggregate was non-zero, so the
|
|
// Tier-3 eq-zero fallback at code-review.md:217 never fired).
|
|
assert.deepStrictEqual(
|
|
reviewFiles.sort(),
|
|
['lib/gamma.js', 'src/alpha.ts', 'src/beta.ts'],
|
|
'A mixed CRLF/LF phase must review the union of both artifacts. ' +
|
|
'If planB_CRLF dropped out, the fix regressed: ' + JSON.stringify(reviewFiles)
|
|
);
|
|
});
|
|
|
|
it('RED proof: with the buggy boundary, the CRLF artifact contributes nothing (silent partial)', () => {
|
|
// Same scenario, but using the BUGGY boundary replica to demonstrate the
|
|
// exact silent-partial masking the issue reported: the CRLF plan yields [],
|
|
// so the phase scope is just the LF plan's files, with zero warning.
|
|
const planA_LF = ['---', 'key_files:', ' modified:', ' - src/alpha.ts', '---', ''].join('\n') + '\n';
|
|
const planB_CRLF = ['---', 'key_files:', ' created:', ' - src/beta.ts', '---', ''].join('\r\n') + '\r\n';
|
|
|
|
const buggyFiles = [];
|
|
for (const doc of [planA_LF, planB_CRLF]) {
|
|
const yaml = extractFrontmatterBoundaryBuggy(doc);
|
|
if (yaml === null) continue; // buggy boundary returns null on CRLF -> skip
|
|
for (const f of parseFilesWithFixedLogic(yaml)) buggyFiles.push(f);
|
|
}
|
|
|
|
// The bug: only the LF plan's file survives; the CRLF plan's file is gone,
|
|
// and because the aggregate is non-zero (1), no fallback warning fired.
|
|
assert.deepStrictEqual(buggyFiles.sort(), ['src/alpha.ts']);
|
|
assert.ok(
|
|
!buggyFiles.includes('src/beta.ts'),
|
|
'The buggy boundary must have dropped the CRLF artifact file (RED demonstration).'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('shipped workflow frontmatter boundary is CRLF-safe (#2694 structural guard)', () => {
|
|
// The two shipped workflow files whose inline `node -e` one-liners extract
|
|
// frontmatter. Resolved relative to the repo root (the test runner's CWD is the
|
|
// repo root under gsd-test).
|
|
const WORKFLOW_FILES = [
|
|
path.join('gsd-core', 'workflows', 'code-review.md'),
|
|
path.join('gsd-core', 'workflows', 'code-review-fix.md'),
|
|
];
|
|
|
|
for (const rel of WORKFLOW_FILES) {
|
|
it(`${rel}: every frontmatter-boundary site normalizes CRLF before matching`, () => {
|
|
const src = fs.readFileSync(rel, 'utf-8');
|
|
|
|
// Locate every shipped boundary-match site. The shipped line shape is:
|
|
// const match = content...match(/^---\n([\s\S]*?)\n---/);
|
|
// After #2694 the `content...` part must be `content.replace(/\r\n/g, '\n')`.
|
|
// Assert no site uses a raw `content.match(...)` against the boundary regex,
|
|
// and that the CRLF normalize is present at each site.
|
|
const lines = src.split(/\r?\n/);
|
|
const boundarySites = [];
|
|
const unsafeSites = [];
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i];
|
|
if (!/\/\^---\\n\(\[\\s\\S\]\*\?\)\\n---\/\)/.test(line)) continue;
|
|
boundarySites.push(i + 1);
|
|
// SAFE: the match is taken on the result of content.replace(/\r\n/g, '\n').
|
|
// UNSAFE: a direct content.match(/<boundary>/) with no preceding normalize.
|
|
const isNormalized = /content\.replace\(\s*\/\\r\\n\/g\s*,\s*'\\n'\s*\)\.match\(/.test(line);
|
|
const isRawContentMatch = /(^|[^.])\bcontent\.match\(\s*\/\^---\\n/.test(line);
|
|
if (!isNormalized || isRawContentMatch) {
|
|
unsafeSites.push({ line: i + 1, text: line.trim() });
|
|
}
|
|
}
|
|
|
|
assert.ok(
|
|
boundarySites.length >= 3,
|
|
`${rel}: expected several frontmatter-boundary sites; found ${boundarySites.length}. ` +
|
|
'If the workflow no longer uses this regex, update this guard.'
|
|
);
|
|
assert.deepStrictEqual(
|
|
unsafeSites,
|
|
[],
|
|
`${rel}: ${unsafeSites.length} frontmatter-boundary site(s) lack the CRLF normalize ` +
|
|
'(regression of #2694): ' + JSON.stringify(unsafeSites, null, 2)
|
|
);
|
|
});
|
|
}
|
|
});
|