From 8459e1661babbc1c0921bb3bc90a917ace7bf7c0 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 23:06:57 +0300 Subject: [PATCH] Add fabricated violation tests for boundary rules Fix eslint.config.js: add import/resolver settings so boundaries plugin can resolve .ts/.tsx imports, and merge no-restricted-imports blocks to prevent ESLint 9 flat config from overriding earlier rule definitions. --- eslint.config.js | 26 +++---- tests/eslint/boundaries.test.ts | 130 ++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 3 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 tests/eslint/boundaries.test.ts diff --git a/eslint.config.js b/eslint.config.js index c3970d77..214ca8a2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,6 +59,11 @@ export default [ boundaries, }, settings: { + "import/resolver": { + node: { + extensions: [".ts", ".tsx", ".js", ".jsx"], + }, + }, "boundaries/elements": [ { type: "routes", pattern: "src/routes/*" }, { type: "mf", pattern: "src/mf/*" }, @@ -107,10 +112,13 @@ export default [ }, }, // --- Restricted imports (master plan ยง1A-3) --- - // OTel SDK internals: only src/observability/metrics/otel.ts may import them + // OTel SDK internals + react-i18next: merged into one block so ESLint 9 + // flat config doesn't override one rule with the other. + // otel.ts is allowed to import OTel SDK; provider.tsx is allowed to import react-i18next. + // Neither file needs the other's exemption, so combining ignores is safe. { files: ["src/**/*.{ts,tsx}"], - ignores: ["src/observability/metrics/otel.ts"], + ignores: ["src/observability/metrics/otel.ts", "src/i18n/provider.tsx"], rules: { "no-restricted-imports": [ "error", @@ -124,20 +132,6 @@ export default [ name: "@opentelemetry/sdk-node", message: "Import from @opentelemetry/api or use getMeter()/getTracer() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.", }, - ], - }, - ], - }, - }, - // react-i18next: only src/i18n/provider.tsx may import it - { - files: ["src/**/*.{ts,tsx}"], - ignores: ["src/i18n/provider.tsx"], - rules: { - "no-restricted-imports": [ - "error", - { - paths: [ { name: "react-i18next", message: "Import useTranslation from @/i18n/provider instead. Direct react-i18next imports are restricted to src/i18n/provider.tsx.", diff --git a/tests/eslint/boundaries.test.ts b/tests/eslint/boundaries.test.ts new file mode 100644 index 00000000..72e0152a --- /dev/null +++ b/tests/eslint/boundaries.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +const ROOT = path.resolve(import.meta.dirname, "../.."); + +/** Short random suffix to avoid collisions when tests run in parallel. */ +function uid(): string { + return crypto.randomBytes(4).toString("hex"); +} + +/** + * Removes a file and any empty parent directories up to (but not including) the src/ tree root. + */ +function cleanupProbe(absPath: string): void { + try { + fs.unlinkSync(absPath); + } catch { + /* file may not exist */ + } + let dir = path.dirname(absPath); + const srcDir = path.join(ROOT, "src"); + while (dir.length > srcDir.length) { + try { + fs.rmdirSync(dir); + } catch { + break; + } + dir = path.dirname(dir); + } +} + +/** + * Creates a probe source file, optionally a target file, runs ESLint, and cleans up. + */ +function lintProbe( + sourcePath: string, + sourceContent: string, + targetPath?: string, + targetContent?: string, +): string { + const absSrc = path.join(ROOT, sourcePath); + const absTarget = targetPath ? path.join(ROOT, targetPath) : null; + fs.mkdirSync(path.dirname(absSrc), { recursive: true }); + fs.writeFileSync(absSrc, sourceContent, "utf8"); + if (absTarget && targetContent) { + fs.mkdirSync(path.dirname(absTarget), { recursive: true }); + fs.writeFileSync(absTarget, targetContent, "utf8"); + } + try { + execSync(`pnpm exec eslint "${absSrc}" --format json`, { + cwd: ROOT, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + return "PASS"; + } catch (err: unknown) { + const error = err as { stdout?: string }; + return error.stdout ?? "UNKNOWN_ERROR"; + } finally { + cleanupProbe(absSrc); + if (absTarget) { + cleanupProbe(absTarget); + } + } +} + +const TARGET_CONTENT = "export const something = 1;\n"; + +describe("boundaries rules", () => { + it("features/ cannot import from routes/", () => { + const id = uid(); + const result = lintProbe( + `src/features/online-board/__bnd_probe_${id}__.ts`, + `import { something } from "../../routes/__probe_${id}__/__target_probe__";\nexport const x = something;\n`, + `src/routes/__probe_${id}__/__target_probe__.ts`, + TARGET_CONTENT, + ); + expect(result).toContain("boundaries/element-types"); + }); + + it("features/ cannot import from mf/", () => { + const id = uid(); + const result = lintProbe( + `src/features/online-board/__bnd_probe_${id}__.ts`, + `import { something } from "../../mf/expose/__target_probe_${id}__";\nexport const x = something;\n`, + `src/mf/expose/__target_probe_${id}__.ts`, + TARGET_CONTENT, + ); + expect(result).toContain("boundaries/element-types"); + }); + + it("ui/ cannot import from features/", () => { + const id = uid(); + const result = lintProbe( + `src/ui/__probe_${id}__/__bnd_probe__.ts`, + 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', + ); + expect(result).toContain("boundaries/element-types"); + }); + + it("shared/ cannot import from features/", () => { + const id = uid(); + const result = lintProbe( + `src/shared/__probe_${id}__/__bnd_probe__.ts`, + 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', + ); + expect(result).toContain("boundaries/element-types"); + }); + + it("observability/ cannot import from features/", () => { + const id = uid(); + const result = lintProbe( + `src/observability/logger/__bnd_probe_${id}__.ts`, + 'import { something } from "../../features/online-board/index";\nexport const x = something;\n', + ); + expect(result).toContain("boundaries/element-types"); + }); + + it("features/ CAN import from env/ (allowed direction)", () => { + const id = uid(); + const result = lintProbe( + `src/features/online-board/__bnd_probe_${id}__.ts`, + 'import { getEnv } from "../../env/index";\nexport const x = getEnv;\n', + ); + expect(result).not.toContain("boundaries/element-types"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a69388d8..57d54d21 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ environment: "node", globals: true, passWithNoTests: true, - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"], coverage: { provider: "v8", reporter: ["text", "json-summary", "lcov"],