test(ui): add semantic defect gate for inline scripts (#111 hardening) (#113)

#111 shipped 8 bad-auto-merge scars into the SPA past a CI that only ever
lints/tests `src/` — the 27k-line docs/index.html had (and still has) no
static gate. The parse test added with the #111 fix catches outright syntax
failures; this adds the *semantic* layer that parsing misses.

It runs ESLint's high-precision "possible problem" rules programmatically
over each inline <script> (no new dependency — `eslint` is already a
devDependency; no eslint.config or CI change — rides the existing `npm test`):
no-redeclare, no-unreachable, no-dupe-keys, no-dupe-args, no-dupe-else-if,
no-duplicate-case, no-const-assign, no-import-assign, no-class-assign — the
exact shapes a conflict-concatenating merge leaves behind.

Tight, always-a-bug rules, each script linted in isolation → zero false
positives on the real file. no-undef / no-unused-vars are excluded (the SPA
relies on cross-<script> and browser globals a per-block lint can't see) and
no-func-assign is excluded (the app legitimately monkey-patches navigateTo).

Verified: 0 findings on the fixed tree, and it rejects the pre-fix main
(catches the duplicate `response`). Full suite green.

Also drops a now-unused eslint-disable directive in the parse test.
This commit is contained in:
Lyubomir Bozhinov authored and GitHub committed 2026-07-26 14:53:12 -04:00
1 parent 7824041c26
commit 32b81708ea
1 file changed
+49 -1
+49 -1
View File
@@ -14,6 +14,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import vm from 'node:vm'; import vm from 'node:vm';
import { Linter } from 'eslint';
const html = readFileSync(new URL('../../docs/index.html', import.meta.url), 'utf8'); const html = readFileSync(new URL('../../docs/index.html', import.meta.url), 'utf8');
@@ -44,7 +45,6 @@ describe('docs/index.html inline scripts (issue #111 regression)', () => {
const failures: string[] = []; const failures: string[] = [];
blocks.forEach((src, i) => { blocks.forEach((src, i) => {
try { try {
// eslint-disable-next-line no-new
new vm.Script(src, { filename: `docs/index.html#inline-${i}` }); new vm.Script(src, { filename: `docs/index.html#inline-${i}` });
} catch (e) { } catch (e) {
failures.push(`inline #${i}: ${(e as Error).message}`); failures.push(`inline #${i}: ${(e as Error).message}`);
@@ -53,3 +53,51 @@ describe('docs/index.html inline scripts (issue #111 regression)', () => {
expect(failures, failures.join('\n')).toEqual([]); expect(failures, failures.join('\n')).toEqual([]);
}); });
}); });
/**
* Second layer — semantic defect gate (#111 hardening).
*
* The vm.Script check above catches outright *parse* failures. This catches the
* shapes a conflict-concatenating auto-merge leaves behind that still PARSE but are
* always bugs — duplicate declarations, code after a `return`, duplicate object
* keys — using ESLint's high-precision "possible problem" rules run programmatically.
* No new dependency (`eslint` is already a devDependency) and no eslint.config / CI
* change: this rides the existing `npm test`. #111 shipped 8 such scars past a CI
* that only ever looked at `src/`; this closes that blind spot for the UI too.
*
* The rule set is deliberately a tight, always-a-bug set, and each `<script>` is
* linted in isolation, so it is false-positive-free on the real file. Notably
* EXCLUDED, by design:
* - `no-undef` / `no-unused-vars` — the SPA relies on hundreds of cross-<script>
* and browser globals a per-block lint can't see; enabling them would flood.
* - `no-func-assign` — the app legitimately monkey-patches `navigateTo` (wraps it
* to add CTF-range init), a pattern that rule flags.
*/
const SCAR_RULES: Linter.RulesRecord = {
'no-redeclare': 'error',
'no-unreachable': 'error',
'no-dupe-keys': 'error',
'no-dupe-args': 'error',
'no-dupe-else-if': 'error',
'no-duplicate-case': 'error',
'no-const-assign': 'error',
'no-import-assign': 'error',
'no-class-assign': 'error',
};
describe('docs/index.html inline scripts — semantic defect gate (#111 hardening)', () => {
it('no duplicate declarations, unreachable code, or duplicate keys in any inline <script>', () => {
const linter = new Linter();
const config: Linter.Config = {
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
rules: SCAR_RULES,
};
const findings: string[] = [];
inlineScripts(html).forEach((src, i) => {
for (const m of linter.verify(src, config)) {
findings.push(`inline #${i} L${m.line}:${m.column} [${m.ruleId ?? 'parse'}] ${m.message}`);
}
});
expect(findings, findings.join('\n')).toEqual([]);
});
});