10-task plan for standing up the conformance ESLint plugin in @repo/core-eslint and shipping three structural rules: - feature-must-have-manifest (warn): use-case files require a manifest - usecase-must-have-test-file (error): TDD sibling test enforcement - required-cores-installed (error): manifest cores ↔ workspace.yaml Scope deliberately excludes manifest-AST analysis (no-undeclared-event- publish, no-undeclared-audit) — those require parsing the manifest's TS source as JS and walking the use-case AST, deferred to plan iii.b. Uses regex-based manifest source extraction to avoid pulling in the TypeScript compiler API at lint time. Manifest shape is constrained by the literal `as const` form that defineFeature enforces, so the regex path is sufficient. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 KiB
Conformance Milestone iii.a — Structural ESLint rules
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Stand up the custom ESLint rule plugin in @repo/core-eslint and ship three structural conformance rules: (1) feature-must-have-manifest, (2) usecase-must-have-test-file, (3) required-cores-installed. Each reads filesystem state (manifest presence, sibling test file, workspace.yaml content) — no cross-file AST analysis. Editor + CLI feedback in <1s.
Architecture: Custom rules live in packages/core-eslint/rules/. A new index file at packages/core-eslint/plugin.js exports them as a flat-config ESLint plugin. base.js references the plugin and enables each rule (initially as error or warn depending on whether all features satisfy it today). Rules use Node's fs to inspect sibling files and js-yaml to read pnpm-workspace.yaml. No TypeScript parsing yet — manifest content (for required-cores-installed) is extracted via a small regex on the source text, which is sufficient for the literal as const manifest shape.
Tech Stack: TypeScript (strict), Vitest, ESLint flat config, @typescript-eslint/parser (already in tree), js-yaml (already a transitive dep via Payload). No new runtime deps.
File structure
Create
packages/core-eslint/rules/feature-must-have-manifest.js— rule modulepackages/core-eslint/rules/feature-must-have-manifest.test.js— RuleTester testspackages/core-eslint/rules/usecase-must-have-test-file.js— rule modulepackages/core-eslint/rules/usecase-must-have-test-file.test.js— RuleTester testspackages/core-eslint/rules/required-cores-installed.js— rule modulepackages/core-eslint/rules/required-cores-installed.test.js— RuleTester testspackages/core-eslint/rules/_manifest-source.js— small helper: load + regex-parsefeature.manifest.tsfornameandrequiredCorespackages/core-eslint/rules/_manifest-source.test.js— manifest-source unit testspackages/core-eslint/rules/_workspace.js— small helper: readpnpm-workspace.yamland return the list of declared package globspackages/core-eslint/plugin.js— flat-config plugin export aggregating the three rulesdocs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md— story 03.a record
Modify
packages/core-eslint/package.json— addjs-yamlas dependency (if not already declared) and add an"exports"entry for./pluginpackages/core-eslint/base.js— import the plugin, register the three rules in the relevantfilesblock(s)docs/work/conformance-system-v1/_epic.md— tick story 03 (which is the umbrella for both 03.a and 03.b) once both ship; for now add 03.a as a checked link, leave 03 uncheckedpnpm-workspace.yaml— no changes (read-only consumption)
Task 1: Story 03.a scaffold
Files:
-
Create:
docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md -
Step 1: Write the story file
---
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: in-progress
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
- [ ] Story 03.a scaffold
- [ ] Manifest source helper (`_manifest-source.js`)
- [ ] Workspace helper (`_workspace.js`)
- [ ] `feature-must-have-manifest` rule + tests
- [ ] `usecase-must-have-test-file` rule + tests
- [ ] `required-cores-installed` rule + tests
- [ ] Plugin module + `exports` entry in `package.json`
- [ ] Wire plugin into `base.js`
- [ ] Verify `pnpm lint` passes against the monorepo
- [ ] Final verification + story closeout
- Step 2: Commit
git add docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md
git commit -m "docs(work): story 03.a — structural ESLint rules"
Task 2: Manifest source helper (_manifest-source.js)
A small filesystem helper that loads a feature.manifest.ts file and extracts two pieces of information via regex: the name literal and the requiredCores array. The manifest is in a known canonical shape (defineFeature({ name: "<x>", requiredCores: [...], ... } as const)), so regex is sufficient — no TypeScript parser required.
Files:
-
Create:
packages/core-eslint/rules/_manifest-source.js -
Create:
packages/core-eslint/rules/_manifest-source.test.js -
Step 1: Write the failing test
packages/core-eslint/rules/_manifest-source.test.js:
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();
});
});
});
- Step 2: Run test to verify it fails
pnpm --filter @repo/core-eslint test _manifest-source
Expected: FAIL — module not found.
- Step 3: Write
_manifest-source.js
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);
}
- Step 4: Run tests
pnpm --filter @repo/core-eslint test _manifest-source
Expected: PASS, 7 tests.
- Step 5: Commit
git add packages/core-eslint/rules/_manifest-source.js packages/core-eslint/rules/_manifest-source.test.js
git commit -m "feat(core-eslint): manifest source helper for conformance rules"
Task 3: Workspace helper (_workspace.js)
Files:
-
Create:
packages/core-eslint/rules/_workspace.js -
Create:
packages/core-eslint/rules/_workspace.test.js -
Step 1: Write the failing test
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([]);
});
});
- Step 2: Run test to verify it fails
pnpm --filter @repo/core-eslint test _workspace
Expected: FAIL.
- Step 3: Write
_workspace.js
Note: we deliberately avoid pulling in js-yaml as a dep. The packages: list in pnpm-workspace.yaml has a stable shape; a simple regex extraction is sufficient and avoids one transitive dependency.
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]);
}
- Step 4: Run tests
pnpm --filter @repo/core-eslint test _workspace
Expected: PASS, 3 tests.
- Step 5: Commit
git add packages/core-eslint/rules/_workspace.js packages/core-eslint/rules/_workspace.test.js
git commit -m "feat(core-eslint): workspace helper for conformance rules"
Task 4: feature-must-have-manifest rule
Fires on any TypeScript file inside packages/<feature>/src/application/use-cases/ if the feature package does NOT have a feature.manifest.ts. Reports once per use-case file (deliberately verbose so the agent sees the error wherever they're looking).
Files:
-
Create:
packages/core-eslint/rules/feature-must-have-manifest.js -
Create:
packages/core-eslint/rules/feature-must-have-manifest.test.js -
Step 1: Write the failing test
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" }],
},
],
});
});
});
- Step 2: Run test to verify it fails
pnpm --filter @repo/core-eslint test feature-must-have-manifest
Expected: FAIL — module not found.
- Step 3: Write
feature-must-have-manifest.js
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 },
});
},
};
},
};
- Step 4: Run tests
pnpm --filter @repo/core-eslint test feature-must-have-manifest
Expected: PASS, 2 tests.
- Step 5: Commit
git add packages/core-eslint/rules/feature-must-have-manifest.js packages/core-eslint/rules/feature-must-have-manifest.test.js
git commit -m "feat(core-eslint): feature-must-have-manifest rule"
Task 5: usecase-must-have-test-file rule
Fires on any *.use-case.ts file that does NOT have a sibling *.use-case.test.ts. No manifest involvement; pure filesystem check.
Files:
-
Create:
packages/core-eslint/rules/usecase-must-have-test-file.js -
Create:
packages/core-eslint/rules/usecase-must-have-test-file.test.js -
Step 1: Write the failing test
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" }],
},
],
});
});
});
- Step 2: Run test to verify it fails
pnpm --filter @repo/core-eslint test usecase-must-have-test-file
Expected: FAIL.
- Step 3: Write
usecase-must-have-test-file.js
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() },
});
},
};
},
};
- Step 4: Run tests
pnpm --filter @repo/core-eslint test usecase-must-have-test-file
Expected: PASS, 2 tests.
- Step 5: Commit
git add packages/core-eslint/rules/usecase-must-have-test-file.js packages/core-eslint/rules/usecase-must-have-test-file.test.js
git commit -m "feat(core-eslint): usecase-must-have-test-file rule"
Task 6: required-cores-installed rule
Fires on a feature's feature.manifest.ts if any entry in its requiredCores array is NOT listed (via glob match) in pnpm-workspace.yaml.
Files:
-
Create:
packages/core-eslint/rules/required-cores-installed.js -
Create:
packages/core-eslint/rules/required-cores-installed.test.js -
Step 1: Write the failing test
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: [],
});
});
});
- Step 2: Run test to verify it fails
pnpm --filter @repo/core-eslint test required-cores-installed
Expected: FAIL.
- Step 3: Write
required-cores-installed.js
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 },
});
}
}
},
};
},
};
- Step 4: Run tests
pnpm --filter @repo/core-eslint test required-cores-installed
Expected: PASS, 3 tests.
- Step 5: Commit
git add packages/core-eslint/rules/required-cores-installed.js packages/core-eslint/rules/required-cores-installed.test.js
git commit -m "feat(core-eslint): required-cores-installed rule"
Task 7: Plugin module + package.json exports
Files:
-
Create:
packages/core-eslint/plugin.js -
Modify:
packages/core-eslint/package.json -
Step 1: Write the plugin module
packages/core-eslint/plugin.js:
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;
- Step 2: Add
./plugintopackage.jsonexports
Open packages/core-eslint/package.json. Find the "exports" object (or "main" if no exports yet). Add an entry for ./plugin:
"./plugin": "./plugin.js"
If the package.json has no "exports" field today, add one. Match the existing field style.
- Step 3: Verify the plugin loads
node -e "import('./packages/core-eslint/plugin.js').then(m => console.log(Object.keys(m.default.rules)))"
Expected output:
[
'feature-must-have-manifest',
'usecase-must-have-test-file',
'required-cores-installed'
]
- Step 4: Commit
git add packages/core-eslint/plugin.js packages/core-eslint/package.json
git commit -m "feat(core-eslint): conformance plugin module + ./plugin export"
Task 8: Wire the plugin into base.js
Files:
-
Modify:
packages/core-eslint/base.js -
Step 1: Add the import + the plugin block
Open packages/core-eslint/base.js. Near the other imports (top of file), add:
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, "..", "..");
(__dirname is needed because the file is ESM. repoRoot resolves to two levels up from packages/core-eslint/.)
Then add a new entry to the exported flat-config array (place it near the existing plugins: { turbo: turboPlugin } block):
{
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 },
],
},
},
- Step 2: Verify the eslint config still parses
pnpm --filter @repo/core-eslint exec eslint --print-config base.js > /dev/null
Expected: no error output (the resolved config is printed and discarded).
- Step 3: Commit
git add packages/core-eslint/base.js
git commit -m "feat(core-eslint): register conformance plugin + structural rules in base config"
Task 9: Verify pnpm lint passes for the monorepo
Files: none (verification only)
- Step 1: Run lint across the monorepo
pnpm lint
Expected outcome:
feature-must-have-manifestis a WARN, so it doesn't fail lintusecase-must-have-test-fileandrequired-cores-installedare ERROR
Likely issues to anticipate:
- (a) Some
*.use-case.tsfiles may not have sibling tests — this would fail lint. Investigate and either add the missing tests OR change the rule towarntemporarily. - (b)
auth/src/feature.manifest.tsdeclaresrequiredCores: []sorequired-cores-installedhas nothing to check. Should pass.
If failures appear, capture them and STOP. Report failures with the file paths so we can decide whether to fix the underlying issue OR adjust the rule's severity in base.js.
- Step 2: If pnpm lint passes, no commit needed (verification step)
If lint fails, DO NOT commit any temporary workarounds. Report failures verbatim.
Task 10: Final verification + story closeout
Files:
-
Modify:
docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md -
Modify:
docs/work/conformance-system-v1/_epic.md -
Step 1: Run the full verification matrix
pnpm typecheck
pnpm test
pnpm lint
pnpm turbo boundaries
All four MUST pass. Stop on any failure.
- Step 2: Tick the story's task checkboxes and flip status
Edit docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md:
(a) Frontmatter: change status: in-progress → status: done
(b) Change all 10 - [ ] task checkboxes to - [x]
- Step 3: Update
_epic.mdto add 03.a as a checked link
Edit docs/work/conformance-system-v1/_epic.md. Find the line:
- [ ] 03 — AST-aware ESLint rules (later plan)
Replace with:
- [ ] 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)
(Indented sub-items represent the split — 03.a is done; 03.b is the follow-up for no-undeclared-event-publish / no-undeclared-audit.)
- Step 4: Commit the milestone close
git add docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md docs/work/conformance-system-v1/_epic.md
git commit -m "docs(work): close story 03.a — structural ESLint rules"
Done — what this leaves behind
@repo/core-eslint/plugin.jsexports a flat-config plugin namedconformancewith three rulesfeature-must-have-manifest(warn): every feature with use-case files must declare a manifestusecase-must-have-test-file(error): every use-case file must have a sibling testrequired-cores-installed(error): every manifest'srequiredCoresentry must exist as acore-<name>package in a workspace glob- Helpers
_manifest-source.jsand_workspace.jsextracted as small modules with their own tests - All wired into
base.js;pnpm lintclean
What comes next (separate plan: milestone iii.b)
The AST-aware rules that read manifest content + walk use-case AST:
no-undeclared-event-publish—bus.publish("X")in a factory body must matchmanifest.useCases[name].publishesno-undeclared-audit—auditLog.record({...})in a factory body must matchmanifest.useCases[name].audits
These require parsing the manifest as JS (not just regex extraction) AND walking the use-case AST to find call expressions. Heavier — a separate plan.