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>
1086 lines
36 KiB
Markdown
1086 lines
36 KiB
Markdown
# Conformance Milestone iii.a — Structural ESLint rules
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to 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 module
|
|
- `packages/core-eslint/rules/feature-must-have-manifest.test.js` — RuleTester tests
|
|
- `packages/core-eslint/rules/usecase-must-have-test-file.js` — rule module
|
|
- `packages/core-eslint/rules/usecase-must-have-test-file.test.js` — RuleTester tests
|
|
- `packages/core-eslint/rules/required-cores-installed.js` — rule module
|
|
- `packages/core-eslint/rules/required-cores-installed.test.js` — RuleTester tests
|
|
- `packages/core-eslint/rules/_manifest-source.js` — small helper: load + regex-parse `feature.manifest.ts` for `name` and `requiredCores`
|
|
- `packages/core-eslint/rules/_manifest-source.test.js` — manifest-source unit tests
|
|
- `packages/core-eslint/rules/_workspace.js` — small helper: read `pnpm-workspace.yaml` and return the list of declared package globs
|
|
- `packages/core-eslint/plugin.js` — flat-config plugin export aggregating the three rules
|
|
- `docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md` — story 03.a record
|
|
|
|
### Modify
|
|
- `packages/core-eslint/package.json` — add `js-yaml` as dependency (if not already declared) and add an `"exports"` entry for `./plugin`
|
|
- `packages/core-eslint/base.js` — import the plugin, register the three rules in the relevant `files` block(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 unchecked
|
|
- `pnpm-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**
|
|
|
|
```markdown
|
|
---
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```js
|
|
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.
|
|
|
|
```js
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```js
|
|
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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```js
|
|
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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```js
|
|
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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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 `./plugin` to `package.json` exports**
|
|
|
|
Open `packages/core-eslint/package.json`. Find the `"exports"` object (or `"main"` if no exports yet). Add an entry for `./plugin`:
|
|
|
|
```json
|
|
"./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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```js
|
|
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):
|
|
|
|
```js
|
|
{
|
|
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**
|
|
|
|
```bash
|
|
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-manifest` is a WARN, so it doesn't fail lint
|
|
- `usecase-must-have-test-file` and `required-cores-installed` are ERROR
|
|
|
|
Likely issues to anticipate:
|
|
- (a) Some `*.use-case.ts` files may not have sibling tests — this would fail lint. Investigate and either add the missing tests OR change the rule to `warn` temporarily.
|
|
- (b) `auth/src/feature.manifest.ts` declares `requiredCores: []` so `required-cores-installed` has 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.md` to add 03.a as a checked link**
|
|
|
|
Edit `docs/work/conformance-system-v1/_epic.md`. Find the line:
|
|
|
|
```markdown
|
|
- [ ] 03 — AST-aware ESLint rules (later plan)
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```markdown
|
|
- [ ] 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**
|
|
|
|
```bash
|
|
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.js` exports a flat-config plugin named `conformance` with three rules
|
|
- `feature-must-have-manifest` (warn): every feature with use-case files must declare a manifest
|
|
- `usecase-must-have-test-file` (error): every use-case file must have a sibling test
|
|
- `required-cores-installed` (error): every manifest's `requiredCores` entry must exist as a `core-<name>` package in a workspace glob
|
|
- Helpers `_manifest-source.js` and `_workspace.js` extracted as small modules with their own tests
|
|
- All wired into `base.js`; `pnpm lint` clean
|
|
|
|
## 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 match `manifest.useCases[name].publishes`
|
|
- `no-undeclared-audit` — `auditLog.record({...})` in a factory body must match `manifest.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.
|