From d3944f40db59adef28cf90b2b14025e3524fd801 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 21 May 2026 11:49:45 +0200 Subject: [PATCH] feat(core-eslint): add entity-must-have-test and no-relative-parent-import rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CLAUDE.md conventions had no mechanical gate, so both drifted: entity models shipped without sibling tests, and feature test files imported src modules via `../` instead of the `@/` alias. - `entity-must-have-test` — every entities/models/.ts needs a sibling .test.ts (errors and barrels excluded). - `no-relative-parent-import-in-tests` — feature test files must import src via `@/`, not `../`. Scoped to feature packages; core packages are governed by their own generator templates. Both register at warn level, bringing the conformance rule count to 15. --- CLAUDE.md | 4 +- packages/core-eslint/base.js | 2 + packages/core-eslint/plugin.js | 6 +- .../rules/entity-must-have-test.js | 55 ++++++++++++++ .../rules/entity-must-have-test.test.js | 74 +++++++++++++++++++ .../no-relative-parent-import-in-tests.js | 49 ++++++++++++ ...no-relative-parent-import-in-tests.test.js | 73 ++++++++++++++++++ 7 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 packages/core-eslint/rules/entity-must-have-test.js create mode 100644 packages/core-eslint/rules/entity-must-have-test.test.js create mode 100644 packages/core-eslint/rules/no-relative-parent-import-in-tests.js create mode 100644 packages/core-eslint/rules/no-relative-parent-import-in-tests.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 595c62f..bb5107e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ pnpm dev # Start all dev servers pnpm build # Build all packages pnpm test # Run all tests pnpm typecheck # TypeScript across all packages -pnpm lint # ESLint (incl. 13 conformance/* rules) +pnpm lint # ESLint (incl. 15 conformance/* rules) pnpm conformance # Cross-feature event closure pnpm fallow # Whole-codebase: dead exports, dupes, complexity pnpm fallow:audit # AI-change audit (run before commits) @@ -73,7 +73,7 @@ Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, p | **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features | | **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift | -The thirteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. +The fifteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference. diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 6508ef3..e1cd1cf 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -55,6 +55,8 @@ export default [ "conformance/pii-declaration-must-be-complete": "warn", "conformance/no-undeclared-consent-check": ["warn", { repoRoot }], "conformance/no-undeclared-rate-limit": ["warn", { repoRoot }], + "conformance/entity-must-have-test": "warn", + "conformance/no-relative-parent-import-in-tests": "warn", }, }, { diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index 4675574..48eb3f7 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -11,6 +11,8 @@ import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js"; import piiDeclarationMustBeComplete from "./rules/pii-declaration-must-be-complete.js"; import noUndeclaredConsentCheck from "./rules/no-undeclared-consent-check.js"; import noUndeclaredRateLimit from "./rules/no-undeclared-rate-limit.js"; +import entityMustHaveTest from "./rules/entity-must-have-test.js"; +import noRelativeParentImportInTests from "./rules/no-relative-parent-import-in-tests.js"; /** * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that @@ -26,7 +28,7 @@ import noUndeclaredRateLimit from "./rules/no-undeclared-rate-limit.js"; * ]; */ const plugin = { - meta: { name: "conformance", version: "0.3.0" }, + meta: { name: "conformance", version: "0.4.0" }, rules: { "feature-must-have-manifest": featureMustHaveManifest, "usecase-must-have-test-file": usecaseMustHaveTestFile, @@ -41,6 +43,8 @@ const plugin = { "pii-declaration-must-be-complete": piiDeclarationMustBeComplete, "no-undeclared-consent-check": noUndeclaredConsentCheck, "no-undeclared-rate-limit": noUndeclaredRateLimit, + "entity-must-have-test": entityMustHaveTest, + "no-relative-parent-import-in-tests": noRelativeParentImportInTests, }, }; diff --git a/packages/core-eslint/rules/entity-must-have-test.js b/packages/core-eslint/rules/entity-must-have-test.js new file mode 100644 index 0000000..1f3281b --- /dev/null +++ b/packages/core-eslint/rules/entity-must-have-test.js @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Entity models (`entities/models/.ts`) are pure domain logic — schemas, + * invariants, derivations. They are the cheapest layer to test and the most + * expensive to get wrong, so every model file must carry a sibling test. + * + * Scope is `entities/models/` only. Error classes (`entities/errors/`) are + * conventionally covered by a consolidated `errors.test.ts`, and barrels + * (`index.ts`) hold no logic — both are excluded. + */ +function isEntityModelFile(filename) { + const normalized = filename.replace(/\\/g, "/"); + if (!normalized.includes("/entities/models/")) return false; + if (!normalized.endsWith(".ts")) return false; + if (normalized.endsWith(".test.ts")) return false; + if (normalized.endsWith(".d.ts")) return false; + if (normalized.endsWith("/index.ts")) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Every entity model file (entities/models/.ts) must have a sibling .test.ts.", + }, + schema: [], + messages: { + missingTest: + "Entity model {{filename}} has no sibling test at {{expected}}. Entity models are pure domain logic — cover them with a unit test.", + }, + }, + create(context) { + return { + Program(node) { + const filename = context.filename; + if (!isEntityModelFile(filename)) return; + const expected = filename.replace(/\.ts$/, ".test.ts"); + if (fs.existsSync(expected)) return; + context.report({ + node, + messageId: "missingTest", + data: { + filename: path.basename(filename), + expected: path.basename(expected), + }, + }); + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/entity-must-have-test.test.js b/packages/core-eslint/rules/entity-must-have-test.test.js new file mode 100644 index 0000000..9c057bd --- /dev/null +++ b/packages/core-eslint/rules/entity-must-have-test.test.js @@ -0,0 +1,74 @@ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import rule from "./entity-must-have-test.js"; + +function makeModelsDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "emht-")); + const modelsDir = path.join(dir, "src", "entities", "models"); + fs.mkdirSync(modelsDir, { recursive: true }); + return modelsDir; +} + +function makeEntityFixture({ withTest }) { + const modelsDir = makeModelsDir(); + const entity = path.join(modelsDir, "cookie.ts"); + fs.writeFileSync(entity, `export const cookie = {};`); + if (withTest) { + fs.writeFileSync( + path.join(modelsDir, "cookie.test.ts"), + `import { it } from "vitest"; it("works", () => {});`, + ); + } + return { entity }; +} + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: "latest", sourceType: "module" }, +}); + +describe("entity-must-have-test", () => { + it("passes when a sibling .test.ts exists", () => { + const { entity } = makeEntityFixture({ withTest: true }); + tester.run("entity-must-have-test", rule, { + valid: [{ filename: entity, code: fs.readFileSync(entity, "utf8") }], + invalid: [], + }); + }); + + it("fires when no sibling test file exists", () => { + const { entity } = makeEntityFixture({ withTest: false }); + tester.run("entity-must-have-test", rule, { + valid: [], + invalid: [ + { + filename: entity, + code: fs.readFileSync(entity, "utf8"), + errors: [{ messageId: "missingTest" }], + }, + ], + }); + }); + + it("ignores files outside entities/models", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "emht-")); + const other = path.join(dir, "helper.ts"); + fs.writeFileSync(other, `export const x = 1;`); + tester.run("entity-must-have-test", rule, { + valid: [{ filename: other, code: "export const x = 1;" }], + invalid: [], + }); + }); + + it("ignores the index.ts barrel inside entities/models", () => { + const modelsDir = makeModelsDir(); + const index = path.join(modelsDir, "index.ts"); + fs.writeFileSync(index, `export {};`); + tester.run("entity-must-have-test", rule, { + valid: [{ filename: index, code: "export {};" }], + invalid: [], + }); + }); +}); diff --git a/packages/core-eslint/rules/no-relative-parent-import-in-tests.js b/packages/core-eslint/rules/no-relative-parent-import-in-tests.js new file mode 100644 index 0000000..8a3306f --- /dev/null +++ b/packages/core-eslint/rules/no-relative-parent-import-in-tests.js @@ -0,0 +1,49 @@ +/** + * Feature test files import from `src/` through the `@/` alias, never via + * `../` parent traversal (CLAUDE.md "Key Conventions"). A `../` import in a + * test file is always reaching across `src/` directories — `@/` keeps those + * imports stable under file moves and makes the test's dependencies legible. + * + * Scoped to feature packages (`packages//src/`, excluding `core-*`): + * the convention is part of the feature template's contract. Core packages + * are generated and governed by their own templates, and tooling packages + * (turbo/generators, scripts) legitimately use relative paths. + */ +function isFeatureSrcTestFile(filename) { + const normalized = filename.replace(/\\/g, "/"); + if (!normalized.endsWith(".test.ts") && !normalized.endsWith(".test.tsx")) { + return false; + } + return /\/packages\/(?!core-)[^/]+\/src\//.test(normalized); +} + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Feature test files must import src modules via the @/ alias, not ../ parent paths.", + }, + schema: [], + messages: { + relativeParentImport: + 'Test file imports "{{source}}" with a ../ parent path. Use the "@/" alias for src imports (e.g. "@/application/...").', + }, + }, + create(context) { + if (!isFeatureSrcTestFile(context.filename)) return {}; + return { + ImportDeclaration(node) { + const source = node.source.value; + if (typeof source === "string" && source.startsWith("../")) { + context.report({ + node: node.source, + messageId: "relativeParentImport", + data: { source }, + }); + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-relative-parent-import-in-tests.test.js b/packages/core-eslint/rules/no-relative-parent-import-in-tests.test.js new file mode 100644 index 0000000..a3a2fee --- /dev/null +++ b/packages/core-eslint/rules/no-relative-parent-import-in-tests.test.js @@ -0,0 +1,73 @@ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "./no-relative-parent-import-in-tests.js"; + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: "latest", sourceType: "module" }, +}); + +const featureTest = "/repo/packages/auth/src/di/container.test.ts"; +const featureSrc = "/repo/packages/auth/src/di/container.ts"; +const coreTest = "/repo/packages/core-audit/src/di/bind-audit.test.ts"; + +describe("no-relative-parent-import-in-tests", () => { + it("passes when a feature test uses the @/ alias", () => { + tester.run("no-relative-parent-import-in-tests", rule, { + valid: [ + { + filename: featureTest, + code: `import { x } from "@/infrastructure/x";`, + }, + ], + invalid: [], + }); + }); + + it("passes for same-directory ./ imports", () => { + tester.run("no-relative-parent-import-in-tests", rule, { + valid: [ + { filename: featureTest, code: `import { x } from "./container";` }, + ], + invalid: [], + }); + }); + + it("fires when a feature test imports via ../", () => { + tester.run("no-relative-parent-import-in-tests", rule, { + valid: [], + invalid: [ + { + filename: featureTest, + code: `import { x } from "../infrastructure/x";`, + errors: [ + { + messageId: "relativeParentImport", + data: { source: "../infrastructure/x" }, + }, + ], + }, + ], + }); + }); + + it("ignores ../ imports in non-test source files", () => { + tester.run("no-relative-parent-import-in-tests", rule, { + valid: [ + { + filename: featureSrc, + code: `import { x } from "../infrastructure/x";`, + }, + ], + invalid: [], + }); + }); + + it("ignores core-package test files (governed by their own templates)", () => { + tester.run("no-relative-parent-import-in-tests", rule, { + valid: [ + { filename: coreTest, code: `import { x } from "../noop-audit-log";` }, + ], + invalid: [], + }); + }); +});