Merge branch 'worktree-conformance-milestone-iii-a': conformance milestone iii.a — structural ESLint rules
This commit is contained in:
@@ -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
|
||||||
@@ -32,7 +32,9 @@ See `docs/architecture/feature-conformance-explainer.html` and
|
|||||||
## Stories
|
## Stories
|
||||||
- [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md)
|
- [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md)
|
||||||
- [x] [02 — `assertFeatureConformance` + boot wiring](02-boot-assertions/_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)
|
- [ ] 04 — CI drift gate (later plan)
|
||||||
- [ ] 05 — Generator emits manifest + contracts + test stubs (later plan)
|
- [ ] 05 — Generator emits manifest + contracts + test stubs (later plan)
|
||||||
- [ ] 06 — Documentation rewrite (later plan)
|
- [ ] 06 — Documentation rewrite (later plan)
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import tseslint from "typescript-eslint";
|
|||||||
import turboPlugin from "eslint-plugin-turbo";
|
import turboPlugin from "eslint-plugin-turbo";
|
||||||
import boundaries from "eslint-plugin-boundaries";
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
|
import conformancePlugin from "./plugin.js";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
// <gen:realtime-rules-imports>
|
// <gen:realtime-rules-imports>
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] },
|
{ ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
@@ -23,6 +29,23 @@ export default [
|
|||||||
"turbo/no-undeclared-env-vars": "warn",
|
"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: {
|
rules: {
|
||||||
// Honour the leading-underscore convention for intentionally-unused params/vars.
|
// Honour the leading-underscore convention for intentionally-unused params/vars.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./base": "./base.js",
|
"./base": "./base.js",
|
||||||
"./next": "./next.js",
|
"./next": "./next.js",
|
||||||
"./react-internal": "./react-internal.js"
|
"./react-internal": "./react-internal.js",
|
||||||
|
"./plugin": "./plugin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
|
|||||||
27
packages/core-eslint/plugin.js
Normal file
27
packages/core-eslint/plugin.js
Normal file
@@ -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;
|
||||||
57
packages/core-eslint/rules/_manifest-source.js
Normal file
57
packages/core-eslint/rules/_manifest-source.js
Normal file
@@ -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 `<const M>` 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/<feature>/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);
|
||||||
|
}
|
||||||
74
packages/core-eslint/rules/_manifest-source.test.js
Normal file
74
packages/core-eslint/rules/_manifest-source.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
packages/core-eslint/rules/_workspace.js
Normal file
28
packages/core-eslint/rules/_workspace.js
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
34
packages/core-eslint/rules/_workspace.test.js
Normal file
34
packages/core-eslint/rules/_workspace.test.js
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/core-eslint/rules/feature-must-have-manifest.js
Normal file
49
packages/core-eslint/rules/feature-must-have-manifest.js
Normal file
@@ -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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
packages/core-eslint/rules/required-cores-installed.js
Normal file
68
packages/core-eslint/rules/required-cores-installed.js
Normal file
@@ -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-<name>` 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-<name>` 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-<name> 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
95
packages/core-eslint/rules/required-cores-installed.test.js
Normal file
95
packages/core-eslint/rules/required-cores-installed.test.js
Normal file
@@ -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-<x> 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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/core-eslint/rules/usecase-must-have-test-file.js
Normal file
32
packages/core-eslint/rules/usecase-must-have-test-file.js
Normal file
@@ -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() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user