diff --git a/AGENTS.md b/AGENTS.md index 7d78d54a8..739e69802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,13 @@ Electron: `src/main/` (Node, SQLite, BLE, MQTT), `src/preload/` (bridge), `src/r Path alias `@/*` → `src/*` (see `tsconfig.json`). -| Boundary | Path | Role | -| -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Main | `src/main/` | SQLite (`database.ts`, `db-compat.ts`), BLE (`noble-ble-manager.ts`), MQTT (`mqtt-manager.ts`, `meshcore-mqtt-adapter.ts`), logging (`log-service.ts`, `sanitize-log-message.ts`), IPC handlers (`index.ts` plus namespaced modules in `src/main/ipc/` — Reticulum, TAK, GPS), window, GPS, updater | -| Preload | `src/preload/` | `contextBridge` exposing namespaced `electronAPI` only; never expose `ipcRenderer` | -| Renderer | `src/renderer/` | React 19 + Vite + Zustand: `components/`, `hooks/`, `runtime/`, `stores/`, `lib/` (includes `lib/diagnostics/`, `lib/meshcore/`, `lib/radio/`, `lib/transport/`), `workers/` | -| Shared | `src/shared/` | IPC contracts (`electron-api.types.ts`), protocol-neutral helpers | +| Boundary | Path | Role | +| ------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Main | `src/main/` | SQLite (`database.ts`, `db-compat.ts`), BLE (`noble-ble-manager.ts`), MQTT (`mqtt-manager.ts`, `meshcore-mqtt-adapter.ts`), logging (`log-service.ts`, `sanitize-log-message.ts`), IPC handlers (`index.ts` plus namespaced modules in `src/main/ipc/` — Reticulum, TAK, GPS), window, GPS, updater | +| Preload | `src/preload/` | `contextBridge` exposing namespaced `electronAPI` only; never expose `ipcRenderer` | +| Renderer | `src/renderer/` | React 19 + Vite + Zustand: `components/`, `hooks/`, `runtime/`, `stores/`, `lib/` (includes `lib/diagnostics/`, `lib/meshcore/`, `lib/radio/`, `lib/transport/`), `workers/` | +| Shared | `src/shared/` | IPC contracts (`electron-api.types.ts`), protocol-neutral helpers | +| Architecture | `src/architecture/` | Vitest source-policy registry (file-local invariants; prefer over new `check-*.mjs`) | Entry points: `src/main/index.ts`, `src/preload/index.ts`, `src/renderer/main.tsx`, `src/renderer/App.tsx`. @@ -88,12 +89,13 @@ Adding a cross-boundary feature: ## 5. Testing -- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`). +- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`, `src/architecture/**/*.test.ts`, `src/shared` / preload / scripts). +- **Source policy (Vitest registry):** file-local / small-glob invariants live in [`src/architecture/sourcePolicyRules.ts`](src/architecture/sourcePolicyRules.ts) + walker [`sourcePolicy.ts`](src/architecture/sourcePolicy.ts) — prefer adding a rule there over a new `scripts/check-*.mjs`. Suppress with `// source-policy-ok `. Pre-commit always appends `src/architecture/sourcePolicy.test.ts` when any TypeScript under `src/` is staged (the registry is not import-related). Keep cross-cutting always-on hygiene in existing `check:*` scanners. - **Reticulum sidecar (Rust):** Clippy + rustfmt via `pnpm run check:reticulum-sidecar` (full-feature fmt + Clippy + test when `cargo` is on `PATH` **and** sidecar-related paths are staged) and the same feature set in `reticulum-sidecar.yaml`. Coverage threshold (`cargo llvm-cov --fail-under-lines`) is enforced only in `tests.yaml` when sidecar paths change — not in pre-commit. - **Temp dirs in tests:** Use `mkdtempSync(path.join(os.tmpdir(), 'prefix-'))` — never write to a fixed name under `os.tmpdir()` (CodeQL + `check:insecure-temp-files`). - Vitest worker pool sizes and shared Vite dep inline lists live in `vitest.harness.ts` — update when adding deps that need inlining. - Prefer `mockConsoleWarn` / `withMockedConsoleWarn` from `src/renderer/lib/vitestConsoleMock.ts` over ad-hoc `vi.spyOn(console, 'warn')` in renderer tests. -- Monolithic runtimes (`useMeshtasticRuntime`, `useMeshcoreRuntime`, `noble-ble-manager`) may use **source contract tests** (`sourceContractTestHelpers.ts`, `*.reconnect*.test.ts`) when full integration mocking is impractical — see [development-environment.md](docs/development-environment.md#vitest-projects-and-worker-allocation). +- Monolithic runtimes (`useMeshtasticRuntime`, `useMeshcoreRuntime`, `noble-ble-manager`) may use **source contract tests** (`sourceContractTestHelpers.ts`, `*.reconnect*.test.ts`) when full integration mocking is impractical — see [development-environment.md](docs/development-environment.md#vitest-projects-and-worker-allocation). Runtime contract tests that load `use*Runtime.ts` must use `loadRuntimeSource()` (enforced by source policy). - Mock console before spying logged errors: `vi.spyOn(console, 'warn').mockImplementation(() => {})` in `beforeEach` when shared. - Update `src/main/index.contract.test.ts` when CSP, build config, IPC limits, or log filters change. @@ -101,7 +103,7 @@ Adding a cross-boundary feature: - **Dev:** `@axe-core/react` runs in `pnpm run dev` (`src/renderer/main.tsx`); treat `serious` axe console output as a bug. - **CI:** Use `vitest-axe` (`import { axe } from 'vitest-axe'`); assert `toHaveNoViolations()` on the rendered subtree. -- **Do not mock `themeColors` in component axe tests** — call `hydrateAxeThemeColors()` from `src/renderer/lib/a11yTestHelpers.ts` so color-contrast runs against real hex values (jsdom does not load Tailwind CSS). +- **Do not mock `themeColors` in component axe tests** — call `hydrateAxeThemeColors()` from `src/renderer/lib/a11yTestHelpers.ts` so color-contrast runs against real hex values (jsdom does not load Tailwind CSS). Enforced by source-policy rule `axe-tests-hydrate-theme-colors`. - **When to add tests:** New or changed UI with custom foreground/background pairs (badges, pills, buttons)—especially `text-[10px]` / `text-xs` on saturated fills. - **Theme tokens:** `readable-green` is for white-on-green fills; the default must pass **4.5:1** contrast with white (enforced in `src/renderer/lib/themeColors.test.ts`). - **`animate-pulse`:** Never on the same element as small text with strict contrast fills. Use a separate `aria-hidden` decorative pulse layer; the text-bearing element stays fully opaque (see `ProtocolUnreadBadge.tsx`). Connection-status header pulses remain the documented exception. diff --git a/scripts/precommit-tests.mjs b/scripts/precommit-tests.mjs index 2898043a1..45af6b5e3 100644 --- a/scripts/precommit-tests.mjs +++ b/scripts/precommit-tests.mjs @@ -24,6 +24,10 @@ const ROOT = path.resolve(__dirname, '..'); const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/i; const TEST_SIBLING_RE = /\.test\.(?:[cm]?[jt]sx?)$/i; +/** Central source-policy suite — always related when any TypeScript under src/ is staged. */ +export const SOURCE_POLICY_TEST_PATH = 'src/architecture/sourcePolicy.test.ts'; +const SRC_TS_PATH_RE = /^src\/.+\.(?:ts|tsx)$/; + const FORCE_FULL_PATTERNS = [ /^vitest\.config\./, /^vitest\.harness(\.|$)/, @@ -103,6 +107,37 @@ export function expandWithSiblingTests(stagedPaths, opts = {}) { return [...out].sort(); } +/** + * Registry walker is not imported by production files, so `vitest related` would + * miss it. Append whenever any TypeScript under `src/` is in the related set. + * @param {string[]} relatedPaths + * @returns {string[]} + */ +export function appendSourcePolicyTestIfNeeded(relatedPaths) { + const normalized = relatedPaths.map((p) => p.replace(/\\/g, '/')); + const needsPolicy = normalized.some((p) => SRC_TS_PATH_RE.test(p)); + if (!needsPolicy) return [...relatedPaths].sort(); + if (normalized.includes(SOURCE_POLICY_TEST_PATH)) return [...normalized].sort(); + return [...normalized, SOURCE_POLICY_TEST_PATH].sort(); +} + +/** + * @param {string} filePath + * @returns {boolean} + */ +function isMainProjectPath(filePath) { + const p = filePath.replace(/\\/g, '/'); + return ( + p.startsWith('src/main/') || + p.startsWith('src/shared/') || + p.startsWith('src/preload/') || + p.startsWith('src/architecture/') || + p.startsWith('scripts/') || + p === 'vitest.harness.ts' || + p === 'vitest.harness.test.ts' + ); +} + /** * Pick Vitest projects for a related-file set. * Ambiguous / unknown paths fall back to all three projects. @@ -114,38 +149,36 @@ export function pickProjects(relatedPaths) { const normalized = relatedPaths.map((p) => p.replace(/\\/g, '/')); - const onlyMain = normalized.every( - (p) => - p.startsWith('src/main/') || - p.startsWith('src/shared/') || - p.startsWith('src/preload/') || - p.startsWith('scripts/') || - p === 'vitest.harness.ts' || - p === 'vitest.harness.test.ts', - ); + const onlyMain = normalized.every((p) => isMainProjectPath(p)); if (onlyMain) return ['main']; const onlyRendererLibOrStores = normalized.every( (p) => p.startsWith('src/renderer/lib/') || p.startsWith('src/renderer/stores/') || - p === 'src/renderer/locales/locale-quality.test.ts', + p === 'src/renderer/locales/locale-quality.test.ts' || + p === SOURCE_POLICY_TEST_PATH, ); if (onlyRendererLibOrStores) { - // Logic owns most lib tests; UI owns borderline lib exclusions — run both, skip main. + // Logic owns most lib tests; UI owns borderline lib exclusions — run both. + // Source-policy lives in the main project; include it when appended. + if (normalized.includes(SOURCE_POLICY_TEST_PATH)) { + return ['renderer-logic', 'renderer-ui', 'main']; + } return ['renderer-logic', 'renderer-ui']; } - const onlyRenderer = normalized.every((p) => p.startsWith('src/renderer/')); - if (onlyRenderer) return ['renderer-ui', 'renderer-logic']; - - const hasMain = normalized.some( - (p) => - p.startsWith('src/main/') || - p.startsWith('src/shared/') || - p.startsWith('src/preload/') || - p.startsWith('scripts/'), + const onlyRendererOrPolicy = normalized.every( + (p) => p.startsWith('src/renderer/') || p === SOURCE_POLICY_TEST_PATH, ); + if (onlyRendererOrPolicy) { + if (normalized.includes(SOURCE_POLICY_TEST_PATH)) { + return ['renderer-ui', 'renderer-logic', 'main']; + } + return ['renderer-ui', 'renderer-logic']; + } + + const hasMain = normalized.some((p) => isMainProjectPath(p)); const hasRenderer = normalized.some((p) => p.startsWith('src/renderer/')); if (hasMain && hasRenderer) return ['renderer-ui', 'renderer-logic', 'main']; if (hasMain) return ['main']; @@ -167,7 +200,7 @@ export function planPrecommitTests(stagedPaths) { }; } - const relatedPaths = expandWithSiblingTests(stagedPaths); + const relatedPaths = appendSourcePolicyTestIfNeeded(expandWithSiblingTests(stagedPaths)); if (relatedPaths.length === 0) { return { mode: 'skip', relatedPaths: [], projects: [] }; } diff --git a/scripts/precommit-tests.test.mjs b/scripts/precommit-tests.test.mjs index aa085fd48..db44fb808 100644 --- a/scripts/precommit-tests.test.mjs +++ b/scripts/precommit-tests.test.mjs @@ -77,6 +77,18 @@ describe('precommit-tests related planning', () => { expect(plan.mode).toBe('related'); expect(plan.projects).toEqual(['main']); expect(plan.relatedPaths).toContain('src/main/foo.ts'); + expect(plan.relatedPaths).toContain('src/architecture/sourcePolicy.test.ts'); + }); + + it('appends source-policy test for renderer staged files and includes main project', () => { + const plan = planPrecommitTests(['src/renderer/components/ChatPanel.tsx']); + expect(plan.mode).toBe('related'); + expect(plan.relatedPaths).toContain('src/architecture/sourcePolicy.test.ts'); + expect(plan.projects).toContain('main'); + }); + + it('picks main for architecture paths', () => { + expect(pickProjects(['src/architecture/sourcePolicy.test.ts'])).toEqual(['main']); }); }); diff --git a/src/architecture/sourcePolicy.test.ts b/src/architecture/sourcePolicy.test.ts new file mode 100644 index 000000000..e8c3b2cd1 --- /dev/null +++ b/src/architecture/sourcePolicy.test.ts @@ -0,0 +1,163 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { collectSourcePolicyViolations, matchSimpleGlob } from './sourcePolicy'; +import type { SourcePolicyRule } from './sourcePolicyRules'; +import { SOURCE_POLICY_RULES } from './sourcePolicyRules'; + +describe('matchSimpleGlob', () => { + it('matches ** and brace extensions', () => { + expect(matchSimpleGlob('src/renderer/foo.test.tsx', 'src/renderer/**/*.test.tsx')).toBe(true); + expect(matchSimpleGlob('src/main/chatExportFormat.ts', 'src/main/chatExportFormat.ts')).toBe( + true, + ); + expect(matchSimpleGlob('src/a/b.ts', 'src/**/*.{ts,tsx}')).toBe(true); + expect(matchSimpleGlob('src/a/b.tsx', 'src/**/*.{ts,tsx}')).toBe(true); + expect(matchSimpleGlob('src/a/b.js', 'src/**/*.{ts,tsx}')).toBe(false); + }); +}); + +describe('collectSourcePolicyViolations (fixtures)', () => { + it('reports forbid, honors line suppress, and require suppress', () => { + const root = mkdtempSync(join(tmpdir(), 'source-policy-')); + try { + const dir = join(root, 'src', 'fixture'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'bad.ts'), + [ + 'const x = FORBIDDEN_CALL();', + 'const y = FORBIDDEN_CALL(); // source-policy-ok demo-forbid intentional', + '', + ].join('\n'), + 'utf-8', + ); + writeFileSync(join(dir, 'missing.ts'), 'export const ok = 1;\n', 'utf-8'); + writeFileSync( + join(dir, 'missing-ok.ts'), + '// source-policy-ok demo-require documented skip\nexport const ok = 1;\n', + 'utf-8', + ); + + const rules: SourcePolicyRule[] = [ + { + id: 'demo-forbid', + include: ['src/fixture/bad.ts'], + forbid: /FORBIDDEN_CALL\s*\(/, + message: 'no forbidden call', + }, + { + id: 'demo-require', + include: ['src/fixture/missing.ts', 'src/fixture/missing-ok.ts'], + require: /REQUIRED_TOKEN/, + message: 'must have token', + }, + ]; + + const violations = collectSourcePolicyViolations({ root, rules }); + expect(violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ruleId: 'demo-forbid', + file: 'src/fixture/bad.ts', + line: 1, + }), + expect.objectContaining({ + ruleId: 'demo-require', + file: 'src/fixture/missing.ts', + }), + ]), + ); + expect(violations.some((v) => v.file === 'src/fixture/missing-ok.ts')).toBe(false); + expect( + violations.filter((v) => v.ruleId === 'demo-forbid' && v.file === 'src/fixture/bad.ts'), + ).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('applies when+require only when when matches', () => { + const root = mkdtempSync(join(tmpdir(), 'source-policy-when-')); + try { + const dir = join(root, 'src', 'fixture'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'with-axe.tsx'), 'await axe(container);\n', 'utf-8'); + writeFileSync(join(dir, 'no-axe.tsx'), 'export const x = 1;\n', 'utf-8'); + + const rules: SourcePolicyRule[] = [ + { + id: 'demo-axe', + include: ['src/fixture/*.tsx'], + when: /\baxe\s*\(/, + require: /hydrateAxeThemeColors/, + message: 'hydrate required', + }, + ]; + + const violations = collectSourcePolicyViolations({ root, rules }); + expect(violations).toEqual([ + expect.objectContaining({ ruleId: 'demo-axe', file: 'src/fixture/with-axe.tsx' }), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('advances past zero-length forbid matches even when suppressed', () => { + const root = mkdtempSync(join(tmpdir(), 'source-policy-zerolen-')); + try { + const dir = join(root, 'src', 'fixture'); + mkdirSync(dir, { recursive: true }); + // Zero-width lookbehind-style /(?=a)/g matches empty string before each 'a'. + writeFileSync( + join(dir, 'zero.ts'), + ['a // source-policy-ok demo-zerolen intentional', 'a', ''].join('\n'), + 'utf-8', + ); + + const rules: SourcePolicyRule[] = [ + { + id: 'demo-zerolen', + include: ['src/fixture/zero.ts'], + forbid: /(?=a)/g, + message: 'zero-length forbid', + }, + ]; + + const violations = collectSourcePolicyViolations({ root, rules }); + expect(violations).toEqual([ + expect.objectContaining({ + ruleId: 'demo-zerolen', + file: 'src/fixture/zero.ts', + line: 2, + }), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('SOURCE_POLICY_RULES (repo)', () => { + it('has unique rule ids and the three starter rules', () => { + const ids = SOURCE_POLICY_RULES.map((r) => r.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual( + expect.arrayContaining([ + 'runtime-tests-use-loadRuntimeSource', + 'chat-export-incremental-cap', + 'axe-tests-hydrate-theme-colors', + ]), + ); + }); + + /** Repo-wide gate: every `SOURCE_POLICY_RULES` include under src/ stays clean. */ + it('reports no violations against the current tree', () => { + const violations = collectSourcePolicyViolations(); + expect(violations).toEqual([]); + }); +}); diff --git a/src/architecture/sourcePolicy.ts b/src/architecture/sourcePolicy.ts new file mode 100644 index 000000000..02ae287c2 --- /dev/null +++ b/src/architecture/sourcePolicy.ts @@ -0,0 +1,192 @@ +import { globSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { SOURCE_POLICY_RULES, type SourcePolicyRule } from './sourcePolicyRules'; + +export interface SourcePolicyViolation { + ruleId: string; + file: string; + line: number | null; + message: string; + detail: string; +} + +export interface CollectSourcePolicyViolationsOptions { + /** Repo root (defaults to process.cwd()). */ + root?: string; + /** Rules to evaluate (defaults to SOURCE_POLICY_RULES). */ + rules?: readonly SourcePolicyRule[]; +} + +const TEST_FILE_RE = /\.test\.(?:ts|tsx|mjs|js|jsx|mts|cts)$/; +const SUPPRESS_MARKER_RE = /\/\/\s*source-policy-ok\s+(\S+)/; + +function ruleTargetsTests(rule: SourcePolicyRule): boolean { + return rule.include.some((p) => p.includes('.test.') || p.includes('*.test')); +} + +function isExcluded(relPath: string, exclude: string[] | undefined): boolean { + if (!exclude || exclude.length === 0) return false; + return exclude.some((pattern) => matchSimpleGlob(relPath, pattern)); +} + +/** Minimal glob matcher for `**`, `*`, and `{a,b}` brace sets (repo-relative paths). */ +export function matchSimpleGlob(relPath: string, pattern: string): boolean { + const normalized = relPath.replace(/\\/g, '/'); + const pat = pattern.replace(/\\/g, '/'); + const regex = globToRegExp(pat); + return regex.test(normalized); +} + +function globToRegExp(pattern: string): RegExp { + let i = 0; + let out = '^'; + while (i < pattern.length) { + const ch = pattern.charAt(i); + if (ch === '*' && pattern.charAt(i + 1) === '*') { + if (pattern.charAt(i + 2) === '/') { + out += '(?:.*/)?'; + i += 3; + } else { + out += '.*'; + i += 2; + } + continue; + } + if (ch === '*') { + out += '[^/]*'; + i += 1; + continue; + } + if (ch === '?') { + out += '[^/]'; + i += 1; + continue; + } + if (ch === '{') { + const end = pattern.indexOf('}', i); + if (end === -1) { + out += '\\{'; + i += 1; + continue; + } + const inner = pattern.slice(i + 1, end); + const alts = inner.split(',').map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + out += `(?:${alts.join('|')})`; + i = end + 1; + continue; + } + if ('\\.[]()+|^$'.includes(ch)) out += `\\${ch}`; + else out += ch; + i += 1; + } + out += '$'; + // eslint-disable-next-line security/detect-non-literal-regexp -- glob→regex from trusted rule include patterns + return new RegExp(out); +} + +function collectFilesForRule(root: string, rule: SourcePolicyRule): string[] { + const out = new Set(); + const targetsTests = ruleTargetsTests(rule); + for (const pattern of rule.include) { + const matches = globSync(pattern, { + cwd: root, + }); + for (const rel of matches) { + const normalized = rel.replace(/\\/g, '/'); + if (normalized.endsWith('/')) continue; + if (isExcluded(normalized, rule.exclude)) continue; + if (!targetsTests && TEST_FILE_RE.test(normalized)) continue; + out.add(normalized); + } + } + return [...out].sort(); +} + +function lineNumberAt(source: string, index: number): number { + let line = 1; + for (let i = 0; i < index && i < source.length; i++) { + if (source[i] === '\n') line++; + } + return line; +} + +function lineHasSuppress(source: string, matchIndex: number, ruleId: string): boolean { + const lineStart = source.lastIndexOf('\n', matchIndex - 1) + 1; + const lineEnd = source.indexOf('\n', matchIndex); + const line = source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd); + const m = SUPPRESS_MARKER_RE.exec(line); + return m?.[1] === ruleId; +} + +function fileHasSuppress(source: string, ruleId: string): boolean { + for (const line of source.split('\n')) { + const m = SUPPRESS_MARKER_RE.exec(line); + if (m?.[1] === ruleId) return true; + } + return false; +} + +function cloneRegExp(re: RegExp): RegExp { + // eslint-disable-next-line security/detect-non-literal-regexp -- clone compiled SourcePolicyRule pattern with /g + return new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`); +} + +/** + * Walk include globs and return policy violations. + * Failures are reported with rule id, relative path, and optional line number. + */ +export function collectSourcePolicyViolations( + opts: CollectSourcePolicyViolationsOptions = {}, +): SourcePolicyViolation[] { + const root = opts.root ?? process.cwd(); + const rules = opts.rules ?? SOURCE_POLICY_RULES; + const violations: SourcePolicyViolation[] = []; + + for (const rule of rules) { + const files = collectFilesForRule(root, rule); + for (const rel of files) { + const abs = join(root, rel); + let source: string; + try { + source = readFileSync(abs, 'utf-8'); + } catch (err) { + console.error(`[source-policy] failed to read ${rel}:`, err); + throw err; + } + + if (rule.when && !rule.when.test(source)) continue; + + if (rule.forbid) { + const forbidRe = cloneRegExp(rule.forbid); + let match: RegExpExecArray | null; + while ((match = forbidRe.exec(source)) !== null) { + // Always advance past zero-length matches (including suppressed ones) + // so /(?:)/g-style patterns cannot infinite-loop on continue. + if (match[0].length === 0) forbidRe.lastIndex++; + if (lineHasSuppress(source, match.index, rule.id)) continue; + violations.push({ + ruleId: rule.id, + file: rel, + line: lineNumberAt(source, match.index), + message: rule.message, + detail: `forbid matched: ${match[0].slice(0, 80)}`, + }); + } + } + + if (rule.require && !rule.require.test(source)) { + if (fileHasSuppress(source, rule.id)) continue; + violations.push({ + ruleId: rule.id, + file: rel, + line: null, + message: rule.message, + detail: `require missing: /${rule.require.source}/`, + }); + } + } + } + + return violations; +} diff --git a/src/architecture/sourcePolicyRules.ts b/src/architecture/sourcePolicyRules.ts new file mode 100644 index 000000000..b4c247892 --- /dev/null +++ b/src/architecture/sourcePolicyRules.ts @@ -0,0 +1,42 @@ +/** + * Declarative source-policy rules for the Vitest registry walker. + * Prefer this over a new `scripts/check-*.mjs` for file-local / small-glob invariants. + * Suppress with `// source-policy-ok ` on the violating line (forbid) + * or anywhere in the file (require). + */ +export interface SourcePolicyRule { + id: string; + /** Paths relative to repo root (glob or exact). */ + include: string[]; + exclude?: string[]; + /** When set, forbid/require apply only if this matches the file contents. */ + when?: RegExp; + /** Fail if this matches (unless suppressed on that line). */ + forbid?: RegExp; + /** Fail if this does not match when the file is included (and `when` passes). */ + require?: RegExp; + message: string; +} + +export const SOURCE_POLICY_RULES: SourcePolicyRule[] = [ + { + id: 'runtime-tests-use-loadRuntimeSource', + include: ['src/renderer/runtime/**/*.test.ts', 'src/renderer/runtime/**/*.contract.test.ts'], + forbid: /readFileSync\s*\(\s*join\([^)]*use(?:Meshtastic|Meshcore|Reticulum)Runtime\.ts/, + message: 'Use loadRuntimeSource() from sourceContractTestHelpers', + }, + { + id: 'chat-export-incremental-cap', + include: ['src/main/chatExportFormat.ts'], + forbid: /formatChatExportLinesWithTotalCap[\s\S]*?formatChatExportLines\s*\(/, + require: /byteLength \+ nextBytes/, + message: 'Total export cap must be enforced per line before append', + }, + { + id: 'axe-tests-hydrate-theme-colors', + include: ['src/renderer/**/*.test.tsx'], + when: /\baxe\s*\(/, + require: /hydrateAxeThemeColors/, + message: 'Call hydrateAxeThemeColors() before axe() so contrast checks use real theme tokens', + }, +]; diff --git a/src/renderer/components/AppPanel.test.tsx b/src/renderer/components/AppPanel.test.tsx index 332987aba..694328ca2 100644 --- a/src/renderer/components/AppPanel.test.tsx +++ b/src/renderer/components/AppPanel.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { MESSAGE_RETENTION_KEYS } from '../lib/messageRetention'; import AppPanel from './AppPanel'; import { ToastProvider } from './Toast'; @@ -23,6 +24,7 @@ describe('AppPanel accessibility', () => { , ); await act(async () => {}); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 0c43021e7..0571e5cc9 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import * as chatNotifications from '../lib/chatNotifications'; import { draftsStorageKey, lastReadStorageKey, saveDraft } from '../lib/chatPanelProtocolStorage'; import { getDistFromChatBottom, VIRTUALIZER_SCROLL_END_THRESHOLD } from '../lib/chatScrollUtils'; @@ -125,6 +126,7 @@ describe('ChatPanel accessibility', () => { , ); await screen.findByPlaceholderText('Connect to send messages'); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index 312023c80..f3fc770f7 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -4,6 +4,7 @@ import { axe } from 'vitest-axe'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { setMeshtasticConnectedMyNodeNum } from '../lib/meshtasticConnectedNodeRef'; import { MESHCORE_CAPABILITIES, @@ -96,6 +97,7 @@ describe('DiagnosticsPanel accessibility', () => { protocol="meshtastic" />, ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/FirmwareStatusIndicator.test.tsx b/src/renderer/components/FirmwareStatusIndicator.test.tsx index dd71ea3fc..54ba5a340 100644 --- a/src/renderer/components/FirmwareStatusIndicator.test.tsx +++ b/src/renderer/components/FirmwareStatusIndicator.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import FirmwareStatusIndicator from './FirmwareStatusIndicator'; describe('FirmwareStatusIndicator', () => { @@ -64,6 +65,7 @@ describe('FirmwareStatusIndicator', () => { const { container } = render( , ); + hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); @@ -71,6 +73,7 @@ describe('FirmwareStatusIndicator', () => { const { container } = render( , ); + hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); @@ -82,6 +85,7 @@ describe('FirmwareStatusIndicator', () => { onOpenReleases={noop} />, ); + hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); }); diff --git a/src/renderer/components/MapPanel.test.tsx b/src/renderer/components/MapPanel.test.tsx index 11c166367..1b7cfb697 100644 --- a/src/renderer/components/MapPanel.test.tsx +++ b/src/renderer/components/MapPanel.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { MAP_BASEMAPS } from '../lib/mapBasemapUtils'; import type { PathRecord } from '../lib/pathHistoryTypes'; import { useMapLayerStore } from '../stores/mapLayerStore'; @@ -332,6 +333,7 @@ describe('MapPanel accessibility', () => { ); // Exclude the mocked leaflet map container from axe scope // (third-party DOM with potentially non-standard attributes) + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/MeshcoreContactSettingsSection.test.tsx b/src/renderer/components/MeshcoreContactSettingsSection.test.tsx index d6641533d..8bdef7c17 100644 --- a/src/renderer/components/MeshcoreContactSettingsSection.test.tsx +++ b/src/renderer/components/MeshcoreContactSettingsSection.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { enrichMeshCoreSelfInfo } from '../lib/meshcoreTelemetryPrivacy'; import { renderWithToast } from '../lib/testRenderHelpers'; import MeshcoreContactSettingsSection from './MeshcoreContactSettingsSection'; @@ -136,6 +137,7 @@ describe('MeshcoreContactSettingsSection consistency', () => { onApply={vi.fn()} />, ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index 3691312bf..9e6db735f 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -7,6 +7,7 @@ import { axe } from 'vitest-axe'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { markDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { mergeAppSetting } from '../lib/appSettingsStorage'; import { meshcoreRepeaterCredentialSettingForNode } from '../lib/meshcoreRepeaterCredentialStorage'; import { clearAllMeshcoreRepeaterEphemeralPasswords } from '../lib/meshcoreRepeaterSession'; @@ -117,6 +118,7 @@ describe('NodeDetailModal accessibility', () => { homeNode={null} />, ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 51d8b5450..442d0a0e9 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -5,6 +5,7 @@ import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { MESHTASTIC_CONTACT_GROUP_BUILTIN_GPS, MESHTASTIC_CONTACT_GROUP_BUILTIN_RF_MQTT, @@ -134,6 +135,7 @@ describe('NodeListPanel accessibility', () => { onToggleFavorite={vi.fn()} />, ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -364,6 +366,7 @@ describe('NodeListPanel import contacts', () => { expect(screen.getByText('RelayPeer')).toBeInTheDocument(); expect(screen.getByLabelText(HYBRID_MQTT_PATH_ARIA)).toBeInTheDocument(); expect(screen.queryByText('relay')).not.toBeInTheDocument(); + hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); diff --git a/src/renderer/components/NomadMicronPageView.test.tsx b/src/renderer/components/NomadMicronPageView.test.tsx index e7b96801d..9e3f0d36a 100644 --- a/src/renderer/components/NomadMicronPageView.test.tsx +++ b/src/renderer/components/NomadMicronPageView.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import NomadMicronPageView from './NomadMicronPageView'; const LXMF_HASH = '368f994c056de0d8882855eb0d627497'; @@ -43,6 +44,7 @@ describe('NomadMicronPageView', () => { const { container } = render( , ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/RadioPanel.test.tsx b/src/renderer/components/RadioPanel.test.tsx index 0b8d65164..1f6dc4099 100644 --- a/src/renderer/components/RadioPanel.test.tsx +++ b/src/renderer/components/RadioPanel.test.tsx @@ -7,6 +7,7 @@ import type { MeshCoreSelfInfo } from '@/renderer/lib/meshcore/meshcoreHookTypes import { MESHCORE_CAPABILITIES } from '@/renderer/lib/radio/BaseRadioProvider'; import { generateConfigUrl, MESHTASTIC_CHANNEL_ROLE } from '@/shared/meshtasticUrlEncoder'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import RadioPanel, { ConfigNumber } from './RadioPanel'; import { ToastProvider } from './Toast'; @@ -69,6 +70,7 @@ describe('RadioPanel accessibility', () => { , ); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/RepeatersPanel.test.tsx b/src/renderer/components/RepeatersPanel.test.tsx index 5e02fdae9..02c6a46cb 100644 --- a/src/renderer/components/RepeatersPanel.test.tsx +++ b/src/renderer/components/RepeatersPanel.test.tsx @@ -4,6 +4,7 @@ import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { meshcoreStoredUserMessage } from '../lib/meshcore/meshcoreMessageI18n'; import type { MeshNode } from '../lib/types'; import { computePathHash, usePathHistoryStore } from '../stores/pathHistoryStore'; @@ -107,6 +108,7 @@ describe('RepeatersPanel', () => { , ); expect(screen.getByRole('button', { name: 'CLI interface' })).toBeInTheDocument(); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -114,6 +116,7 @@ describe('RepeatersPanel', () => { it('hides CLI interface button when onSendCliCommand is omitted', async () => { const { container } = render(); expect(screen.queryByRole('button', { name: 'CLI interface' })).not.toBeInTheDocument(); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/Sidebar.test.tsx b/src/renderer/components/Sidebar.test.tsx index 15eed546d..989f33327 100644 --- a/src/renderer/components/Sidebar.test.tsx +++ b/src/renderer/components/Sidebar.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import Sidebar from './Sidebar'; const defaultProps = { @@ -198,7 +199,9 @@ describe('Sidebar', () => { onToggle={vi.fn()} />, ); - expect(await axe(screen.getByText('99+'))).toHaveNoViolations(); + const badge = screen.getByText('99+'); + hydrateAxeThemeColors(badge); + expect(await axe(badge)).toHaveNoViolations(); }); it('shows Rooms unread badge when roomsUnread > 0', () => { @@ -299,7 +302,9 @@ describe('Sidebar', () => { onToggle={vi.fn()} />, ); - expect(await axe(screen.getByText('3'))).toHaveNoViolations(); + const roomsBadge = screen.getByText('3'); + hydrateAxeThemeColors(roomsBadge); + expect(await axe(roomsBadge)).toHaveNoViolations(); }); it('has no axe violations when Rooms unread badge shows 99+', async () => { @@ -315,7 +320,9 @@ describe('Sidebar', () => { onToggle={vi.fn()} />, ); - expect(await axe(screen.getByText('99+'))).toHaveNoViolations(); + const roomsBadge99 = screen.getByText('99+'); + hydrateAxeThemeColors(roomsBadge99); + expect(await axe(roomsBadge99)).toHaveNoViolations(); }); it('does not show Chat and Rooms badges on the same tab (mutually exclusive slot ids)', () => { diff --git a/src/renderer/components/TakServerPanel.test.tsx b/src/renderer/components/TakServerPanel.test.tsx index dfc5c7417..0c859e7ff 100644 --- a/src/renderer/components/TakServerPanel.test.tsx +++ b/src/renderer/components/TakServerPanel.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import TakServerPanel from './TakServerPanel'; describe('TakServerPanel', () => { @@ -138,6 +139,7 @@ describe('TakServerPanel', () => { it('has no axe accessibility violations', async () => { const { container } = render(); await act(async () => {}); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/components/TelemetryPanel.test.tsx b/src/renderer/components/TelemetryPanel.test.tsx index d64200a04..58b40511e 100644 --- a/src/renderer/components/TelemetryPanel.test.tsx +++ b/src/renderer/components/TelemetryPanel.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { MESHCORE_CAPABILITIES } from '../lib/radio/BaseRadioProvider'; import TelemetryPanel from './TelemetryPanel'; @@ -30,6 +31,7 @@ describe('TelemetryPanel', () => { expect( screen.getByRole('img', { name: /Temperature and humidity chart/i }), ).toHaveAccessibleName(/21\.3/); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/renderer/runtime/runtimeAttachDetach.contract.test.ts b/src/renderer/runtime/runtimeAttachDetach.contract.test.ts index 7c26feea1..fc54a8342 100644 --- a/src/renderer/runtime/runtimeAttachDetach.contract.test.ts +++ b/src/renderer/runtime/runtimeAttachDetach.contract.test.ts @@ -3,14 +3,12 @@ * cleanupSubscriptions invokes; MeshCore conn attach similarly tears down via * meshcoreIngressDetachRef / teardownMeshcoreConnEventListeners. */ -import { readFileSync } from 'fs'; -import { join } from 'path'; import { describe, expect, it } from 'vitest'; -import { extractUseCallbackBody } from '../lib/sourceContractTestHelpers'; +import { extractUseCallbackBody, loadRuntimeSource } from '../lib/sourceContractTestHelpers'; -const MESHTASTIC_RUNTIME = readFileSync(join(__dirname, 'useMeshtasticRuntime.ts'), 'utf-8'); -const MESHCORE_RUNTIME = readFileSync(join(__dirname, 'useMeshcoreRuntime.ts'), 'utf-8'); +const MESHTASTIC_RUNTIME = loadRuntimeSource('useMeshtasticRuntime.ts'); +const MESHCORE_RUNTIME = loadRuntimeSource('useMeshcoreRuntime.ts'); describe('runtime attach/detach source contracts', () => { it('Meshtastic cleanupSubscriptions invokes meshtasticIngressDetachRef', () => { diff --git a/src/renderer/runtime/useMeshcoreRuntime.favorited-identity.test.ts b/src/renderer/runtime/useMeshcoreRuntime.favorited-identity.test.ts index 2ded5c0ce..6d4aa6f5b 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.favorited-identity.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.favorited-identity.test.ts @@ -1,10 +1,9 @@ // @vitest-environment jsdom -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { describe, expect, it } from 'vitest'; -const SOURCE = readFileSync(join(__dirname, '../runtime/useMeshcoreRuntime.ts'), 'utf-8'); +import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const SOURCE = loadRuntimeSource('useMeshcoreRuntime.ts'); describe('useMeshcoreRuntime setNodeFavorited identity bucket', () => { it('prefers active connection identity over protocol default bucket', () => { diff --git a/tsconfig.main.json b/tsconfig.main.json index 551792078..291063037 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -21,6 +21,7 @@ "src/main/**/*", "src/preload/**/*", "src/shared/**/*", + "src/architecture/**/*", "src/main/types/**/*", "vitest.harness.ts", "vitest.harness.test.ts" diff --git a/vitest.config.ts b/vitest.config.ts index bc085fd12..51bb77dba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -275,6 +275,7 @@ export default defineConfig({ 'src/main/**/*.test.ts', 'src/shared/**/*.test.ts', 'src/preload/**/*.test.ts', + 'src/architecture/**/*.test.ts', 'scripts/**/*.test.mjs', 'vitest.harness.test.ts', ],