ext: no-download-logic ESLint gate in the extension-lint CI job

CLAUDE.md §3's rule was prose only for extension/ (GUI already has
gui_no_download_logic as a ctest). eslint.config.mjs adds a
no-restricted-syntax/no-restricted-globals rule banning fetch/XHR/Request,
ReadableStream.getReader, Range/Content-Range header construction, and
IndexedDB in src/**/*.ts. Verified red on a planted violation (fetch +
Range header + stream reader) and green on ordinary code; that check is
now a permanent regression test (tests/lint/no-download-logic.test.ts)
rather than a one-off manual run. Wired into the existing extension-lint
job in .github/workflows/ci.yml, ahead of web-ext lint.

Generated protocol code (src/shared/protocol/**) is excluded from lint
entirely — it must never be hand-edited, so flagging it as fixable would
be a lie.

Answers gui/docs/ext-requests-m1.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
2026-09-11 12:25:19 +04:00
co-authored by Claude Sonnet 5
parent 0a38867579
commit 2cb1959bff
5 changed files with 1062 additions and 162 deletions
+6 -1
View File
@@ -105,11 +105,16 @@ jobs:
if: steps.check.outputs.present == 'true'
with:
node-version: '22'
- name: web-ext lint
- name: eslint (no-download-logic gate + general rules)
if: steps.check.outputs.present == 'true'
working-directory: extension
run: |
npm ci
npx eslint .
- name: web-ext lint
if: steps.check.outputs.present == 'true'
working-directory: extension
run: |
npx web-ext lint --source-dir .
# --- build + test matrix ----------------------------------------------------------
+90
View File
@@ -0,0 +1,90 @@
// ESLint config for the extension.
//
// The "no download logic in extension/" rule (CLAUDE.md §3) used to be prose only.
// GUI turned its half into a ctest (gui/tests/no_download_logic.cmake); this is EXT's
// equivalent — a build-failing gate instead of something a reviewer has to remember to
// look for. See gui/docs/ext-requests-m1.md for the request this answers.
//
// The extension's whole job is: collect URL + headers + cookies, hand them to veloxd,
// render what comes back. It must never fetch bytes, assemble a Range request, or read
// a response body itself — that is download logic, and it belongs in core/daemon only.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
const noDownloadLogic = {
name: 'velox/no-download-logic',
files: ['src/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "NewExpression[callee.name='XMLHttpRequest']",
message:
'No XMLHttpRequest in extension/ — the extension hands URLs to veloxd, it never fetches bytes itself (CLAUDE.md §3).',
},
{
selector: "NewExpression[callee.name='Request']",
message:
'No `new Request(...)` in extension/ — that is download-side plumbing. Hand the URL to veloxd instead (CLAUDE.md §3).',
},
{
selector: "CallExpression[callee.name='fetch']",
message:
'No fetch() in extension/ — the extension never retrieves download bytes itself (CLAUDE.md §3). Talking to veloxd goes through transport/, not fetch.',
},
{
selector: "MemberExpression[property.name='getReader']",
message:
'No ReadableStream reader in extension/ — reading a response body here is download logic (CLAUDE.md §3); the daemon owns transfer bytes.',
},
{
selector:
"Property[key.name='Range'], Property[key.name='range'], Property[key.name='Content-Range'], Property[key.name='content-range']",
message:
'No Range/Content-Range header construction in extension/ — resumption is the daemon\'s job (CLAUDE.md §3, docs/05).',
},
{
selector: "NewExpression[callee.object.name='indexedDB'], CallExpression[callee.object.name='indexedDB']",
message: 'No IndexedDB in extension/ for moving bytes — hand off to veloxd instead (CLAUDE.md §3).',
},
],
'no-restricted-globals': [
'error',
{ name: 'fetch', message: 'No fetch() in extension/ — see CLAUDE.md §3.' },
{ name: 'XMLHttpRequest', message: 'No XMLHttpRequest in extension/ — see CLAUDE.md §3.' },
{ name: 'indexedDB', message: 'No IndexedDB in extension/ — see CLAUDE.md §3.' },
],
},
};
export default tseslint.config(
{
// src/shared/protocol/** is generated (contracts/codegen/gen_ts.py) and must never
// be hand-edited — linting it as if we could fix a finding would be a lie.
ignores: ['dist/**', 'node_modules/**', 'scripts/**', 'src/shared/protocol/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
parserOptions: {
project: false,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
noDownloadLogic,
{
// Tests legitimately construct fake Requests/fetch mocks to exercise transport code
// against a fake server; the rule protects src/, not the harness that pokes at it.
files: ['tests/**/*.ts'],
rules: {
'no-restricted-syntax': 'off',
'no-restricted-globals': 'off',
},
},
);
+920 -160
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -10,13 +10,18 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "web-ext lint --source-dir ."
"lint": "npm run lint:eslint && npm run lint:webext",
"lint:eslint": "eslint .",
"lint:webext": "web-ext lint --source-dir ."
},
"devDependencies": {
"@types/firefox-webext-browser": "^120.0.4",
"@types/ws": "^8.5.12",
"esbuild": "^0.24.0",
"eslint": "^9.39.5",
"happy-dom": "^15.11.7",
"typescript": "^5.6.0",
"typescript-eslint": "^8.70.0",
"vitest": "^2.1.0",
"web-ext": "^8.3.0",
"ws": "^8.18.0"
@@ -0,0 +1,40 @@
// Regression test for the "no download logic in extension/" ESLint gate
// (eslint.config.mjs, answering gui/docs/ext-requests-m1.md). Runs ESLint's Node API
// directly against fixture source so a future edit to the rule set can't silently stop
// catching the patterns it was written for.
import { ESLint } from 'eslint';
import { describe, expect, it } from 'vitest';
async function lint(code: string): Promise<number> {
const eslint = new ESLint({ cwd: new URL('../..', import.meta.url).pathname });
// Path only needs to match the `files: ['src/**/*.ts']` glob in eslint.config.mjs.
const [result] = await eslint.lintText(code, { filePath: 'src/background/__fixture.ts' });
return result.messages.filter((m) => m.severity === 2).length;
}
describe('no-download-logic ESLint gate', () => {
it('goes red on fetch() + a hand-built Range header + a stream reader', async () => {
const errors = await lint(`
export async function grabBytes(url: string) {
const res = await fetch(url, { headers: { Range: 'bytes=0-1023' } });
const reader = res.body!.getReader();
return reader.read();
}
`);
expect(errors).toBeGreaterThan(0);
});
it('goes red on XMLHttpRequest', async () => {
const errors = await lint(`const x = new XMLHttpRequest();`);
expect(errors).toBeGreaterThan(0);
});
it('stays green on ordinary transport/RPC code', async () => {
const errors = await lint(`
export function greet(name: string): string {
return \`hello \${name}\`;
}
`);
expect(errors).toBe(0);
});
});