diff --git a/docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md b/docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md new file mode 100644 index 0000000..09d3a8e --- /dev/null +++ b/docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md @@ -0,0 +1,64 @@ +--- +id: 03-a-structural-eslint-rules +epic: conformance-system-v1 +title: Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed) +type: technical-story +status: done +feature: core-eslint +depends-on: [02-boot-assertions] +blocks: [03-b-ast-aware-eslint-rules] +--- + +## Goal +Stand up the custom ESLint rule plugin in `@repo/core-eslint` and ship three +structural conformance rules that don't require cross-file AST analysis. +Editor + CLI feedback fires in <1s. + +## Why +Boot-time assertion catches drift in bound use cases, but cannot catch: +- Features that exist on disk but have no manifest at all +- Use-case files lacking a sibling test file +- Manifest declaring required cores that aren't in `pnpm-workspace.yaml` + +Structural ESLint rules surface these in the editor, before code is even +saved past lint-on-save. + +## Done when +- Custom rule plugin exists at `packages/core-eslint/plugin.js` +- Three rules registered: `conformance/feature-must-have-manifest`, + `conformance/usecase-must-have-test-file`, + `conformance/required-cores-installed` +- Each rule has RuleTester tests with positive + negative cases +- `base.js` registers the plugin and enables the rules +- `pnpm lint` passes for the current monorepo state (rules tuned to today's + reality — see Out of scope) + +## In scope +- Custom rule plugin module at `packages/core-eslint/plugin.js` +- Rule modules in `packages/core-eslint/rules/*.js` +- Manifest text parser (`_manifest-source.js`) — regex-based, sufficient for + literal `as const` manifests +- Workspace.yaml reader (`_workspace.js`) +- Rule integration into `base.js` +- Tests for each rule using ESLint's RuleTester + +## Out of scope +- Cross-file AST analysis rules (`no-undeclared-event-publish`, + `no-undeclared-audit`) — milestone iii.b +- Enforcement on features that don't yet have a manifest — `feature-must-have-manifest` + ships as a WARNING today (only auth has a manifest); flips to ERROR after + blog/media/navigation/marketing-pages get manifests +- Manifest parser based on TypeScript compiler API — regex is sufficient for + literal manifests; the AST path comes in iii.b + +## Tasks +- [x] Story 03.a scaffold +- [x] Manifest source helper (`_manifest-source.js`) +- [x] Workspace helper (`_workspace.js`) +- [x] `feature-must-have-manifest` rule + tests +- [x] `usecase-must-have-test-file` rule + tests +- [x] `required-cores-installed` rule + tests +- [x] Plugin module + `exports` entry in `package.json` +- [x] Wire plugin into `base.js` +- [x] Verify `pnpm lint` passes against the monorepo +- [x] Final verification + story closeout diff --git a/docs/work/conformance-system-v1/_epic.md b/docs/work/conformance-system-v1/_epic.md index befb3d7..a0957d0 100644 --- a/docs/work/conformance-system-v1/_epic.md +++ b/docs/work/conformance-system-v1/_epic.md @@ -32,7 +32,9 @@ See `docs/architecture/feature-conformance-explainer.html` and ## Stories - [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md) - [x] [02 — `assertFeatureConformance` + boot wiring](02-boot-assertions/_story.md) -- [ ] 03 — AST-aware ESLint rules (later plan) +- [ ] 03 — AST-aware ESLint rules (continuing — see 03.a + future 03.b) + - [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md) + - [ ] 03.b — Manifest-aware AST rules (later plan) - [ ] 04 — CI drift gate (later plan) - [ ] 05 — Generator emits manifest + contracts + test stubs (later plan) - [ ] 06 — Documentation rewrite (later plan) diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index bab3131..0b4ba59 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -4,8 +4,14 @@ import tseslint from "typescript-eslint"; import turboPlugin from "eslint-plugin-turbo"; import boundaries from "eslint-plugin-boundaries"; import globals from "globals"; +import conformancePlugin from "./plugin.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; // +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "..", ".."); + export default [ { ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] }, js.configs.recommended, @@ -23,6 +29,23 @@ export default [ "turbo/no-undeclared-env-vars": "warn", }, }, + { + plugins: { conformance: conformancePlugin }, + rules: { + // Structural conformance rules (milestone iii.a). + // `feature-must-have-manifest` is WARN today because only auth has a manifest; + // flip to ERROR after blog/media/navigation/marketing-pages migrate. + "conformance/feature-must-have-manifest": [ + "warn", + { repoRoot }, + ], + "conformance/usecase-must-have-test-file": "error", + "conformance/required-cores-installed": [ + "error", + { repoRoot }, + ], + }, + }, { rules: { // Honour the leading-underscore convention for intentionally-unused params/vars. diff --git a/packages/core-eslint/package.json b/packages/core-eslint/package.json index 0f0da26..5b4190a 100644 --- a/packages/core-eslint/package.json +++ b/packages/core-eslint/package.json @@ -6,7 +6,8 @@ "exports": { "./base": "./base.js", "./next": "./next.js", - "./react-internal": "./react-internal.js" + "./react-internal": "./react-internal.js", + "./plugin": "./plugin.js" }, "scripts": { "test": "vitest run --passWithNoTests" diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js new file mode 100644 index 0000000..55a9a38 --- /dev/null +++ b/packages/core-eslint/plugin.js @@ -0,0 +1,27 @@ +import featureMustHaveManifest from "./rules/feature-must-have-manifest.js"; +import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js"; +import requiredCoresInstalled from "./rules/required-cores-installed.js"; + +/** + * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that + * enforce feature-conformance contracts (manifest presence, sibling tests, + * required-cores ↔ workspace consistency). + * + * Registered as the `conformance` plugin in flat config: + * + * import conformancePlugin from "@repo/core-eslint/plugin"; + * export default [ + * { plugins: { conformance: conformancePlugin } }, + * { rules: { "conformance/feature-must-have-manifest": ["warn", { repoRoot: import.meta.dirname }] } }, + * ]; + */ +const plugin = { + meta: { name: "conformance", version: "0.1.0" }, + rules: { + "feature-must-have-manifest": featureMustHaveManifest, + "usecase-must-have-test-file": usecaseMustHaveTestFile, + "required-cores-installed": requiredCoresInstalled, + }, +}; + +export default plugin; diff --git a/packages/core-eslint/rules/_manifest-source.js b/packages/core-eslint/rules/_manifest-source.js new file mode 100644 index 0000000..dba87ba --- /dev/null +++ b/packages/core-eslint/rules/_manifest-source.js @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Reads a feature.manifest.ts file and extracts the manifest's `name` field + * and `requiredCores` array via regex. Returns null if the file does not + * exist or does not match the expected literal `as const` manifest shape. + * + * The repo's convention is that every feature.manifest.ts uses defineFeature + * with literal `as const` syntax — this is enforced by the type-system + * design (defineFeature has `` to preserve literal types). The + * regex extraction is therefore safe; the AST path is overkill. + * + * Returns: { name: string, requiredCores: string[] } | null + */ +export function readManifestSource(manifestPath) { + let src; + try { + src = fs.readFileSync(manifestPath, "utf8"); + } catch { + return null; + } + const nameMatch = src.match(/name:\s*"([^"]+)"/); + if (!nameMatch) return null; + const coresMatch = src.match(/requiredCores:\s*\[([^\]]*)\]/); + const cores = coresMatch + ? coresMatch[1] + .split(",") + .map((s) => s.trim().replace(/^"/, "").replace(/"$/, "")) + .filter((s) => s.length > 0) + : []; + return { name: nameMatch[1], requiredCores: cores }; +} + +/** + * Canonical manifest path for a given feature package root. + */ +export function manifestPathForFeature(featureRoot) { + return path.join(featureRoot, "src", "feature.manifest.ts"); +} + +/** + * Given an absolute file path and the monorepo root, returns the feature + * package root that contains the file (e.g. /repo/packages/auth). Returns + * null when the file lives outside the packages/ tree. + * + * Assumes the conventional layout `packages//src/...`. + */ +export function featureRootForFile(filepath, repoRoot) { + const packagesDir = path.join(repoRoot, "packages"); + if (!filepath.startsWith(packagesDir + path.sep)) return null; + const rel = filepath.slice(packagesDir.length + 1); // e.g. "auth/src/..." + const slash = rel.indexOf(path.sep); + if (slash === -1) return null; + const featureName = rel.slice(0, slash); + return path.join(packagesDir, featureName); +} diff --git a/packages/core-eslint/rules/_manifest-source.test.js b/packages/core-eslint/rules/_manifest-source.test.js new file mode 100644 index 0000000..bbab369 --- /dev/null +++ b/packages/core-eslint/rules/_manifest-source.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { readManifestSource, manifestPathForFeature, featureRootForFile } from "./_manifest-source.js"; + +function writeTempFile(filename, contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-source-")); + const filepath = path.join(dir, filename); + fs.writeFileSync(filepath, contents); + return { dir, filepath }; +} + +describe("_manifest-source", () => { + describe("readManifestSource", () => { + it("returns { name, requiredCores: [] } for a minimal manifest", () => { + const { filepath } = writeTempFile( + "feature.manifest.ts", + `import { defineFeature } from "@repo/core-shared/conformance"; +export const xManifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);` + ); + expect(readManifestSource(filepath)).toEqual({ name: "test", requiredCores: [] }); + }); + + it("extracts multi-element requiredCores", () => { + const { filepath } = writeTempFile( + "feature.manifest.ts", + `export const xManifest = defineFeature({ + name: "auth", + requiredCores: ["audit", "events"], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);` + ); + expect(readManifestSource(filepath)).toEqual({ name: "auth", requiredCores: ["audit", "events"] }); + }); + + it("returns null when the file does not exist", () => { + expect(readManifestSource("/nonexistent/path/feature.manifest.ts")).toBeNull(); + }); + + it("returns null when the file is unreadable as a manifest (no name field)", () => { + const { filepath } = writeTempFile("feature.manifest.ts", `export const x = 1;`); + expect(readManifestSource(filepath)).toBeNull(); + }); + }); + + describe("manifestPathForFeature", () => { + it("returns the canonical manifest path for a feature root", () => { + expect(manifestPathForFeature("/repo/packages/auth")).toBe( + path.join("/repo/packages/auth", "src", "feature.manifest.ts"), + ); + }); + }); + + describe("featureRootForFile", () => { + it("returns the package root containing a use-case file", () => { + const file = "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts"; + expect(featureRootForFile(file, "/repo")).toBe("/repo/packages/auth"); + }); + + it("returns null for files outside the packages tree", () => { + const file = "/repo/apps/web-next/src/app/page.tsx"; + expect(featureRootForFile(file, "/repo")).toBeNull(); + }); + }); +}); diff --git a/packages/core-eslint/rules/_workspace.js b/packages/core-eslint/rules/_workspace.js new file mode 100644 index 0000000..34cfd73 --- /dev/null +++ b/packages/core-eslint/rules/_workspace.js @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Reads the `packages:` list from `pnpm-workspace.yaml` at the given repo + * root. Returns an array of package glob strings (e.g. ["apps/*", "packages/*"]). + * Returns [] when the file is missing or has no `packages:` key. + * + * Uses regex extraction over YAML parsing to avoid a `js-yaml` dependency; + * the `packages:` block in pnpm-workspace.yaml has a stable, well-known + * shape and this approach is sufficient. + */ +export function readWorkspacePackages(repoRoot) { + const yamlPath = path.join(repoRoot, "pnpm-workspace.yaml"); + let src; + try { + src = fs.readFileSync(yamlPath, "utf8"); + } catch { + return []; + } + const blockMatch = src.match(/^packages:\s*\n((?:\s+-\s+.+\n?)+)/m); + if (!blockMatch) return []; + return blockMatch[1] + .split("\n") + .map((line) => line.match(/^\s+-\s+"?([^"]+?)"?\s*$/)) + .filter((m) => m !== null) + .map((m) => m[1]); +} diff --git a/packages/core-eslint/rules/_workspace.test.js b/packages/core-eslint/rules/_workspace.test.js new file mode 100644 index 0000000..a5f6f33 --- /dev/null +++ b/packages/core-eslint/rules/_workspace.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { readWorkspacePackages } from "./_workspace.js"; + +function writeWorkspaceYaml(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-")); + const filepath = path.join(dir, "pnpm-workspace.yaml"); + fs.writeFileSync(filepath, contents); + return { dir, filepath }; +} + +describe("_workspace", () => { + it("returns the list of declared package globs", () => { + const { dir } = writeWorkspaceYaml( + `packages: + - "apps/*" + - "packages/*" +`, + ); + expect(readWorkspacePackages(dir)).toEqual(["apps/*", "packages/*"]); + }); + + it("returns [] when pnpm-workspace.yaml is missing", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-")); + expect(readWorkspacePackages(dir)).toEqual([]); + }); + + it("returns [] when packages key is absent", () => { + const { dir } = writeWorkspaceYaml(`# empty\n`); + expect(readWorkspacePackages(dir)).toEqual([]); + }); +}); diff --git a/packages/core-eslint/rules/feature-must-have-manifest.js b/packages/core-eslint/rules/feature-must-have-manifest.js new file mode 100644 index 0000000..b54b26e --- /dev/null +++ b/packages/core-eslint/rules/feature-must-have-manifest.js @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js"; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Every feature with use-case files must declare a feature.manifest.ts at its src/ root.", + }, + schema: [ + { + type: "object", + properties: { + repoRoot: { type: "string" }, + }, + additionalProperties: false, + }, + ], + messages: { + missingManifest: + "Feature {{feature}} has use cases but no feature.manifest.ts. Run `pnpm turbo gen feature {{feature}}` or scaffold the manifest manually at {{expected}}.", + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + return { + Program(node) { + const filename = context.filename; + const featureRoot = featureRootForFile(filename, repoRoot); + if (!featureRoot) return; + // Only check use-case files + if (!filename.includes("/application/use-cases/") || !filename.endsWith(".use-case.ts")) { + return; + } + const manifestPath = manifestPathForFeature(featureRoot); + if (fs.existsSync(manifestPath)) return; + const featureName = featureRoot.split("/").pop(); + context.report({ + node, + messageId: "missingManifest", + data: { feature: featureName, expected: manifestPath }, + }); + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/feature-must-have-manifest.test.js b/packages/core-eslint/rules/feature-must-have-manifest.test.js new file mode 100644 index 0000000..20e9059 --- /dev/null +++ b/packages/core-eslint/rules/feature-must-have-manifest.test.js @@ -0,0 +1,54 @@ +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 "./feature-must-have-manifest.js"; + +function makeFeatureFixture({ withManifest }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fmm-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true }); + if (withManifest) { + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ name: "demo", requiredCores: [], useCases: {}, realtimeChannels: [], jobs: [] } as const);`, + ); + } + const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "do-thing.use-case.ts"); + fs.writeFileSync(useCaseFile, `export const doThingUseCase = () => async () => {};`); + return { repoRoot, useCaseFile }; +} + +const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } }); + +describe("feature-must-have-manifest", () => { + it("passes when the feature has a manifest", () => { + const { repoRoot, useCaseFile } = makeFeatureFixture({ withManifest: true }); + tester.run("feature-must-have-manifest", rule, { + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires when the feature has no manifest", () => { + const { repoRoot, useCaseFile } = makeFeatureFixture({ withManifest: false }); + tester.run("feature-must-have-manifest", rule, { + valid: [], + invalid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [{ messageId: "missingManifest" }], + }, + ], + }); + }); +}); diff --git a/packages/core-eslint/rules/required-cores-installed.js b/packages/core-eslint/rules/required-cores-installed.js new file mode 100644 index 0000000..202264a --- /dev/null +++ b/packages/core-eslint/rules/required-cores-installed.js @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import { readManifestSource } from "./_manifest-source.js"; +import { readWorkspacePackages } from "./_workspace.js"; + +/** + * Check whether `packages/core-` exists under any of the workspace + * globs. The glob set is small and predictable (e.g. ["apps/*", "packages/*"]); + * we simulate matching by checking each glob's directory portion + verifying + * `core-` exists in that directory. + */ +function coreExistsInWorkspace(coreName, repoRoot, packageGlobs) { + for (const glob of packageGlobs) { + const slashStar = glob.endsWith("/*") ? glob.slice(0, -2) : null; + if (!slashStar) continue; + const candidate = path.join(repoRoot, slashStar, `core-${coreName}`); + if (fs.existsSync(candidate)) return true; + } + return false; +} + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Cores declared in a feature.manifest.ts's requiredCores must exist as core- packages within a workspace glob.", + }, + schema: [ + { + type: "object", + properties: { + repoRoot: { type: "string" }, + }, + additionalProperties: false, + }, + ], + messages: { + coreNotInstalled: + "Manifest declares requiredCores: [..., \"{{core}}\", ...] but `core-{{core}}` is not present in any workspace glob. Run `pnpm turbo gen core-package {{core}}` or drop the entry.", + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + return { + Program(node) { + const filename = context.filename; + if (!filename.endsWith("/feature.manifest.ts") && !filename.endsWith("\\feature.manifest.ts")) { + return; + } + const manifest = readManifestSource(filename); + if (!manifest) return; + const globs = readWorkspacePackages(repoRoot); + for (const core of manifest.requiredCores) { + if (!coreExistsInWorkspace(core, repoRoot, globs)) { + context.report({ + node, + messageId: "coreNotInstalled", + data: { core }, + }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/required-cores-installed.test.js b/packages/core-eslint/rules/required-cores-installed.test.js new file mode 100644 index 0000000..0725306 --- /dev/null +++ b/packages/core-eslint/rules/required-cores-installed.test.js @@ -0,0 +1,95 @@ +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 "./required-cores-installed.js"; + +function makeFixture({ workspacePackages, manifestCores }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rci-")); + fs.writeFileSync( + path.join(repoRoot, "pnpm-workspace.yaml"), + `packages:\n${workspacePackages.map((p) => ` - "${p}"`).join("\n")}\n`, + ); + const manifestDir = path.join(repoRoot, "packages", "demo", "src"); + fs.mkdirSync(manifestDir, { recursive: true }); + // We also need each declared core to exist as a package directory for the glob to resolve. + for (const core of manifestCores) { + fs.mkdirSync(path.join(repoRoot, "packages", `core-${core}`), { recursive: true }); + } + const manifest = path.join(manifestDir, "feature.manifest.ts"); + fs.writeFileSync( + manifest, + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [${manifestCores.map((c) => `"${c}"`).join(", ")}], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + return { repoRoot, manifest }; +} + +const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } }); + +describe("required-cores-installed", () => { + it("passes when all declared cores are present as core- packages under a workspace glob", () => { + const { repoRoot, manifest } = makeFixture({ + workspacePackages: ["packages/*"], + manifestCores: ["audit", "events"], + }); + tester.run("required-cores-installed", rule, { + valid: [ + { + filename: manifest, + code: fs.readFileSync(manifest, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires for any declared core that has no matching package", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rci-")); + fs.writeFileSync(path.join(repoRoot, "pnpm-workspace.yaml"), `packages:\n - "packages/*"\n`); + const manifestDir = path.join(repoRoot, "packages", "demo", "src"); + fs.mkdirSync(manifestDir, { recursive: true }); + // NB: do NOT create packages/core-realtime — that's the missing one. + fs.mkdirSync(path.join(repoRoot, "packages", "core-audit"), { recursive: true }); + const manifest = path.join(manifestDir, "feature.manifest.ts"); + fs.writeFileSync( + manifest, + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: ["audit", "realtime"], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + + tester.run("required-cores-installed", rule, { + valid: [], + invalid: [ + { + filename: manifest, + code: fs.readFileSync(manifest, "utf8"), + options: [{ repoRoot }], + errors: [{ messageId: "coreNotInstalled", data: { core: "realtime" } }], + }, + ], + }); + }); + + it("is a no-op for files that are not feature.manifest.ts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rci-")); + const other = path.join(dir, "not-a-manifest.ts"); + fs.writeFileSync(other, `export const x = 1;`); + tester.run("required-cores-installed", rule, { + valid: [{ filename: other, code: fs.readFileSync(other, "utf8"), options: [{ repoRoot: dir }] }], + invalid: [], + }); + }); +}); diff --git a/packages/core-eslint/rules/usecase-must-have-test-file.js b/packages/core-eslint/rules/usecase-must-have-test-file.js new file mode 100644 index 0000000..c7e3b71 --- /dev/null +++ b/packages/core-eslint/rules/usecase-must-have-test-file.js @@ -0,0 +1,32 @@ +import fs from "node:fs"; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Every *.use-case.ts file must have a sibling *.use-case.test.ts (TDD discipline).", + }, + schema: [], + messages: { + missingTestFile: + "Use case {{filename}} has no sibling test file at {{expected}}. Write the red test first.", + }, + }, + create(context) { + return { + Program(node) { + const filename = context.filename; + if (!filename.endsWith(".use-case.ts")) return; + const expected = filename.replace(/\.use-case\.ts$/, ".use-case.test.ts"); + if (fs.existsSync(expected)) return; + context.report({ + node, + messageId: "missingTestFile", + data: { filename: filename.split("/").pop(), expected: expected.split("/").pop() }, + }); + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/usecase-must-have-test-file.test.js b/packages/core-eslint/rules/usecase-must-have-test-file.test.js new file mode 100644 index 0000000..fc870f1 --- /dev/null +++ b/packages/core-eslint/rules/usecase-must-have-test-file.test.js @@ -0,0 +1,42 @@ +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 "./usecase-must-have-test-file.js"; + +function makeUseCaseFixture({ withTest }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "umht-")); + const useCase = path.join(dir, "sign-in.use-case.ts"); + fs.writeFileSync(useCase, `export const signInUseCase = () => async () => {};`); + if (withTest) { + fs.writeFileSync(path.join(dir, "sign-in.use-case.test.ts"), `import { it } from "vitest"; it("works", () => {});`); + } + return { useCase }; +} + +const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } }); + +describe("usecase-must-have-test-file", () => { + it("passes when a sibling .test.ts exists", () => { + const { useCase } = makeUseCaseFixture({ withTest: true }); + tester.run("usecase-must-have-test-file", rule, { + valid: [{ filename: useCase, code: fs.readFileSync(useCase, "utf8") }], + invalid: [], + }); + }); + + it("fires when no sibling test file exists", () => { + const { useCase } = makeUseCaseFixture({ withTest: false }); + tester.run("usecase-must-have-test-file", rule, { + valid: [], + invalid: [ + { + filename: useCase, + code: fs.readFileSync(useCase, "utf8"), + errors: [{ messageId: "missingTestFile" }], + }, + ], + }); + }); +});