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:
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+920
-160
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user