Merge branch 'worktree-conformance-milestone-iii-a': conformance milestone iii.a — structural ESLint rules
Some checks failed
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests (push) Has been cancelled

This commit is contained in:
2026-05-12 23:29:37 +02:00
15 changed files with 652 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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";
// <gen:realtime-rules-imports>
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.

View File

@@ -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"

View 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;

View 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);
}

View 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();
});
});
});

View 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]);
}

View 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([]);
});
});

View 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 },
});
},
};
},
};

View File

@@ -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" }],
},
],
});
});
});

View 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 },
});
}
}
},
};
},
};

View 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: [],
});
});
});

View 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() },
});
},
};
},
};

View File

@@ -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" }],
},
],
});
});
});