build: check that css template literals were not ended by a comment

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.
This commit is contained in:
2026-08-13 01:07:08 -04:00
parent 49b1194333
commit 0b7ffd5679
5 changed files with 170 additions and 1 deletions
+10
View File
@@ -155,6 +155,16 @@ jobs:
working-directory: /src/frontend working-directory: /src/frontend
run: npx tsc --noEmit run: npx tsc --noEmit
# A backtick inside a comment in a css`` literal ends the literal.
# tsc above does fail on it, with a message about CSSResult
# pointing at a line of prose; this one names the cause. It runs
# after tsc for exactly that reason — whichever fails, the log has
# the sentence in it.
- name: CSS template literals are intact
if: ${{ !cancelled() }}
working-directory: /src
run: make css-check
- name: Component and store suite - name: Component and store suite
working-directory: /src working-directory: /src
run: make ui-test run: make ui-test
+8 -1
View File
@@ -97,6 +97,12 @@ reference, because you need them *before* the failure, not after.
old behaviour. `make dev-headless` prints the esbuild error; a old behaviour. `make dev-headless` prints the esbuild error; a
reload does not. One way to cause one is a stray backtick inside a reload does not. One way to cause one is a stray backtick inside a
comment in a `css` tagged template literal, which ends the literal. comment in a `css` tagged template literal, which ends the literal.
**That one is a check now**`make css-check` (instant, a pre-commit
hook and a CI step) names the file, the line and the cause, because
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice.
- **A failing CI job's log is reachable even when `gitea_ci job_logs` - **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment: API answers, with the `GITEA_TOKEN` already in the environment:
@@ -162,7 +168,8 @@ Two rules about climbing:
do not exist. do not exist.
Before a commit, the gate is `make lint`, `make test`, `make ui-test`, Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
`make bindings-check` and — from `frontend/` `npx tsc --noEmit`. The `make bindings-check`, `make css-check` and — from `frontend/`
`npx tsc --noEmit`. The
first four are lefthook hooks, so skipping them locally only defers the first four are lefthook hooks, so skipping them locally only defers the
failure; the typecheck is a hook too but only CI runs it over the test failure; the typecheck is a hook too but only CI runs it over the test
tree, which is where it has actually broken. tree, which is where it has actually broken.
+7
View File
@@ -107,6 +107,13 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings
@./scripts/bindings-check.sh @./scripts/bindings-check.sh
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. Instant.
.PHONY: css-check
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs
# .pi/ documents commands, and a skill that documents a command wrongly # .pi/ documents commands, and a skill that documents a command wrongly
# is worse than no skill: an agent runs it confidently. Every command # is worse than no skill: an agent runs it confidently. Every command
# in there is a make target on purpose, so this is checkable. # in there is a make target on purpose, so this is checkable.
+136
View File
@@ -0,0 +1,136 @@
#!/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`);
+9
View File
@@ -47,6 +47,15 @@ pre-commit:
root: "frontend/" root: "frontend/"
run: ./node_modules/.bin/tsc --noEmit run: ./node_modules/.bin/tsc --noEmit
# A backtick in a comment inside a css`` literal ends the literal.
# tsc does catch it, as "Class static side incorrectly extends base
# class static side" pointing at a line of prose; this says what
# actually happened. Instant.
css-literals:
glob: "frontend/**/*.ts"
root: "frontend/"
run: node scripts/check-css-literals.mjs
pre-push: pre-push:
parallel: true parallel: true
commands: commands: