Summary
wrap() in scripts/render-demo.mjs loops forever when a report line longer than WRAP_COLUMNS contains no space in its wrappable window. String.prototype.lastIndexOf returns -1, and every use of that -1 happens to be silently valid:
function wrap(line) {
if (line.length <= WRAP_COLUMNS) return [line];
const rows = [];
let rest = line;
let indent = "";
while (rest.length > WRAP_COLUMNS - indent.length) {
const slice = rest.slice(0, WRAP_COLUMNS - indent.length);
const breakAt = slice.lastIndexOf(" "); // -1 when there is no space
rows.push(indent + rest.slice(0, breakAt).trimEnd()); // slice(0, -1) drops the LAST char
rest = rest.slice(breakAt + 1); // slice(0) — rest is UNCHANGED
indent = " ";
}
rows.push(indent + rest);
return rows;
}
rest.slice(breakAt + 1) becomes rest.slice(0), so rest never shrinks, the loop condition never becomes false, and rows grows without bound until the process exhausts memory.
Reproduction
A single context or changed file whose path is ~88+ characters is enough. renderMarkdownReport emits changed files as - `<path>` , so the leading "- " is consumed on the first iteration and the bare path — which contains no spaces — is what spins:
const WRAP_COLUMNS = 92;
function wrap(line) { /* as above, with an iteration guard */ }
const longPath = "packages/core/src/components/dashboard/widgets/analytics/RevenueBreakdownByRegion.test.tsx"; // 90 chars
wrap("- `" + longPath + "`");
path length: 90
NO PROGRESS after 20 iterations. rest.length still 92
Without the guard, node scripts/render-demo.mjs hangs indefinitely.
Impact
This is the documented way to regenerate the README hero image:
<!-- Reproducible recording: regenerate with `npm run build:cli && node scripts/render-demo.mjs` -->
The script currently works only because examples/tiny-auth-app has short paths. Pointing it at any realistically nested repository — or simply adding one deeply nested file to the example — turns the documented command into a hang with no error message. It is not covered by npm run ci, so nothing would catch the regression.
Suggested fix
Handle the no-space case by hard-breaking at the column limit:
const width = WRAP_COLUMNS - indent.length;
const slice = rest.slice(0, width);
const breakAt = slice.lastIndexOf(" ");
if (breakAt <= 0) { // no space, or a leading space: hard break
rows.push(indent + slice);
rest = rest.slice(width);
} else {
rows.push(indent + rest.slice(0, breakAt).trimEnd());
rest = rest.slice(breakAt + 1);
}
indent = " ";
breakAt <= 0 also covers the breakAt === 0 case, which currently pushes an empty row for every line that begins with a space.
Worth adding a unit test for wrap() with a long unbroken token — it is a pure function, so it costs one assertion.
Summary
wrap()inscripts/render-demo.mjsloops forever when a report line longer thanWRAP_COLUMNScontains no space in its wrappable window.String.prototype.lastIndexOfreturns-1, and every use of that-1happens to be silently valid:rest.slice(breakAt + 1)becomesrest.slice(0), sorestnever shrinks, the loop condition never becomes false, androwsgrows without bound until the process exhausts memory.Reproduction
A single context or changed file whose path is ~88+ characters is enough.
renderMarkdownReportemits changed files as- `<path>`, so the leading"- "is consumed on the first iteration and the bare path — which contains no spaces — is what spins:Without the guard,
node scripts/render-demo.mjshangs indefinitely.Impact
This is the documented way to regenerate the README hero image:
The script currently works only because
examples/tiny-auth-apphas short paths. Pointing it at any realistically nested repository — or simply adding one deeply nested file to the example — turns the documented command into a hang with no error message. It is not covered bynpm run ci, so nothing would catch the regression.Suggested fix
Handle the no-space case by hard-breaking at the column limit:
breakAt <= 0also covers thebreakAt === 0case, which currently pushes an empty row for every line that begins with a space.Worth adding a unit test for
wrap()with a long unbroken token — it is a pure function, so it costs one assertion.