From f8784597c3784492fa0fd53ed7673829e6068c40 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 07:35:46 +0000 Subject: [PATCH] Enforce the one join path in lint; a drive that asserts nothing fails Soul s8 Lows (1) and (2). ESLint bans .joinRoom() calls outside joinEstateRoom.ts (Airbnb's no-restricted-syntax entries kept); a test lints snippets through the repo config. The estate drives' done() returns 5 when no assertion ran; a test pins 0/4/5. Mutations: dropping the selector, the file exclusion, the Airbnb spread, the zero-assert exit and the assert count each fail a test. Co-Authored-By: Claude Sonnet 5.5 --- .eslintrc.cjs | 20 +++++++++++ harness/estate/check.cjs | 9 +++-- harness/estate/check.test.ts | 35 +++++++++++++++++++ src/app/features/estate/joinPath.lint.test.ts | 32 +++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 harness/estate/check.test.ts create mode 100644 src/app/features/estate/joinPath.lint.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d9..d2a138cf 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,3 +1,8 @@ +// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list. +const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[ + 'no-restricted-syntax' +]; + module.exports = { env: { browser: true, @@ -62,6 +67,21 @@ module.exports = { "@typescript-eslint/no-shadow": "error" }, overrides: [ + { + // The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join. + files: ['src/**/*.ts', 'src/**/*.tsx'], + excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'], + rules: { + 'no-restricted-syntax': [ + ...airbnbRestrictedSyntax, + { + selector: "CallExpression[callee.property.name='joinRoom']", + message: + 'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.', + }, + ], + }, + }, { files: ['*.ts'], rules: { diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 656d532e..e69f5b4a 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -2,12 +2,17 @@ // non-zero when the page or the fake homeserver's log says otherwise. const fs = require('fs'); const fails = []; +let asserted = 0; exports.expect = (label, ok, detail = '') => { + asserted += 1; console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); if (!ok) fails.push(label); }; -/** Exit code for the drive: 4 when any assertion failed. */ -exports.done = () => (fails.length ? 4 : 0); +/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */ +exports.done = () => { + if (fails.length) return 4; + return asserted === 0 ? 5 : 0; +}; /** The fake homeserver's request log (HSLOG is shared with the server process). */ exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); /** Bodies of the account-data PUTs for m.direct, in order. */ diff --git a/harness/estate/check.test.ts b/harness/estate/check.test.ts new file mode 100644 index 00000000..97566997 --- /dev/null +++ b/harness/estate/check.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { createRequire } from 'module'; +import { describe, expect, it, vi } from 'vitest'; + +// A drive that asserts nothing proves nothing: its exit code must say so. +const fresh = () => { + const require = createRequire(import.meta.url); + const path = require.resolve('./check.cjs'); + delete require.cache[path]; + return require('./check.cjs') as { + expect: (label: string, ok: boolean, detail?: string) => void; + done: () => number; + }; +}; + +describe('estate drive exit code', () => { + it('is 5 when no assertion ran', () => { + expect(fresh().done()).toBe(5); + }); + + it('is 0 when assertions ran and all held', () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const c = fresh(); + c.expect('holds', true); + expect(c.done()).toBe(0); + }); + + it('is 4 when any assertion failed', () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const c = fresh(); + c.expect('holds', true); + c.expect('breaks', false, 'detail'); + expect(c.done()).toBe(4); + }); +}); diff --git a/src/app/features/estate/joinPath.lint.test.ts b/src/app/features/estate/joinPath.lint.test.ts new file mode 100644 index 00000000..82b2a476 --- /dev/null +++ b/src/app/features/estate/joinPath.lint.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { ESLint } from 'eslint'; + +// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join. +// This pins that the lint config enforces it, not just that the code happens to comply today. +const lint = async (filePath: string, code: string) => { + const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true }); + const [result] = await eslint.lintText(code, { filePath }); + return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax'); +}; +const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n'; + +describe('the one join path is enforced by lint', () => { + it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => { + const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN); + expect(found).toHaveLength(1); + expect(found[0].message).toContain('joinEstateRoom'); + }); + + it('allows the call inside joinEstateRoom.ts itself', async () => { + expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0); + }); + + it('still applies the Airbnb syntax bans in the same files', async () => { + const found = await lint( + 'src/app/foo.ts', + 'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n' + ); + expect(found.some((m) => m.message.includes('for..in'))).toBe(true); + }); +}); -- 2.47.3