A backtick inside a comment in a css`` literal ends the literal. It has cost four sessions across three plans, it is written down in CLAUDE.md, the skill and NOTES.md, and it was read twice in the session it then cost a cycle in. Knowledge that has been ignored three times is not a knowledge problem. The expense is the report, not the mistake: the literal ends early, the rest of the CSS parses as JavaScript, and tsc says 'Class static side incorrectly extends base class static side' pointing at a line of prose -- or, in a shared module, every test in the suite fails to import and the output reads like a broken test runner. make dev-headless mean- while keeps serving the last good bundle. Detection is exact rather than heuristic: if a backtick in a comment closed the literal early, the text the parser took as the literal contains an unterminated /*. Nothing else produces that. Verified both ways -- clean on the tree, and red on a deliberately broken comment.
137 lines
3.7 KiB
JavaScript
137 lines
3.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* A backtick inside a comment in a `css` tagged template literal ends
|
|
* the literal.
|
|
*
|
|
* This has cost four sessions across three plans. It is written down in
|
|
* CLAUDE.md, in the yellowjacket-dev skill and in NOTES.md, and it was
|
|
* read twice in the session it then cost a cycle in — so it is a check
|
|
* now rather than a fourth paragraph. Knowledge that has been ignored
|
|
* three times is not a knowledge problem.
|
|
*
|
|
* What makes it expensive is not the mistake but the *report*. The
|
|
* literal ends early, the rest of the CSS is parsed as JavaScript, and
|
|
* what comes back is `Expected "]" but found "inline"` pointing at a
|
|
* line of prose — or, when it happens in a shared module like
|
|
* `tokens.css.ts`, every test in the suite failing to import and an
|
|
* output that reads like a broken test runner. And `make dev-headless`
|
|
* leaves the dev server serving the last good bundle, so the page still
|
|
* works and still shows the old behaviour.
|
|
*
|
|
* The detection is exact rather than heuristic. If a backtick inside a
|
|
* comment closed the literal early, then the text the parser *did* take
|
|
* as the literal contains an unterminated `/*`. Nothing else produces
|
|
* that, and a legitimate literal cannot contain one.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { globSync } from 'node:fs';
|
|
|
|
const TAGS = ['css', 'html', 'svg'];
|
|
|
|
/**
|
|
* Find the end of a template literal that starts at `start` (the index
|
|
* of its opening backtick), respecting escapes and `${}` substitutions.
|
|
* Returns the index of the closing backtick, or -1.
|
|
*/
|
|
function endOfTemplate(src, start) {
|
|
let depth = 0;
|
|
|
|
for (let i = start + 1; i < src.length; i++) {
|
|
const c = src[i];
|
|
|
|
if (c === '\\') {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c === '$' && src[i + 1] === '{') {
|
|
depth++;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c === '}' && depth > 0) {
|
|
depth--;
|
|
continue;
|
|
}
|
|
|
|
if (c === '`' && depth === 0) return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/** Strip `${...}` substitutions, which may legitimately contain anything. */
|
|
function stripSubstitutions(text) {
|
|
let out = '';
|
|
let depth = 0;
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (text[i] === '$' && text[i + 1] === '{') {
|
|
depth++;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (text[i] === '}' && depth > 0) {
|
|
depth--;
|
|
continue;
|
|
}
|
|
|
|
if (depth === 0) out += text[i];
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function lineOf(src, index) {
|
|
return src.slice(0, index).split('\n').length;
|
|
}
|
|
|
|
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
|
|
const problems = [];
|
|
|
|
for (const file of files) {
|
|
const src = readFileSync(file, 'utf8');
|
|
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
|
|
|
|
let match;
|
|
|
|
while ((match = tagPattern.exec(src)) !== null) {
|
|
const open = match.index + match[0].length - 1;
|
|
const close = endOfTemplate(src, open);
|
|
|
|
if (close === -1) continue;
|
|
|
|
const body = stripSubstitutions(src.slice(open + 1, close));
|
|
const opens = (body.match(/\/\*/g) ?? []).length;
|
|
const closes = (body.match(/\*\//g) ?? []).length;
|
|
|
|
if (opens > closes) {
|
|
problems.push({
|
|
file,
|
|
line: lineOf(src, open),
|
|
tag: match[2],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
for (const p of problems) {
|
|
console.error(
|
|
`${p.file}:${p.line}: unterminated /* inside a ${p.tag}\`\` literal — ` +
|
|
'a backtick in a comment ends the literal early',
|
|
);
|
|
}
|
|
|
|
console.error(
|
|
`\ncss-literal-check: ${problems.length} problem(s). ` +
|
|
'Remove the backticks from the comment; markdown quoting does not ' +
|
|
'survive a tagged template.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`css-literal-check: ${files.length} files, no broken literals`);
|