From f22f747aa9e986f546b4d7f674887f41fe1b0dcf Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:15:35 +0200 Subject: [PATCH] =?UTF-8?q?plan(conformance):=20milestone=20iii.a=20?= =?UTF-8?q?=E2=80=94=20structural=20ESLint=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...rmance-milestone-iii-a-structural-rules.md | 1085 +++++++++++++++++ 1 file changed, 1085 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md diff --git a/docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md b/docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md new file mode 100644 index 0000000..90c9004 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md @@ -0,0 +1,1085 @@ +# 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: "", 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 `` to preserve literal types). The + * regex extraction is therefore safe; the AST path is overkill. + * + * Returns: { name: string, requiredCores: string[] } | null + */ +export function readManifestSource(manifestPath) { + let src; + try { + src = fs.readFileSync(manifestPath, "utf8"); + } catch { + return null; + } + const nameMatch = src.match(/name:\s*"([^"]+)"/); + if (!nameMatch) return null; + const coresMatch = src.match(/requiredCores:\s*\[([^\]]*)\]/); + const cores = coresMatch + ? coresMatch[1] + .split(",") + .map((s) => s.trim().replace(/^"/, "").replace(/"$/, "")) + .filter((s) => s.length > 0) + : []; + return { name: nameMatch[1], requiredCores: cores }; +} + +/** + * Canonical manifest path for a given feature package root. + */ +export function manifestPathForFeature(featureRoot) { + return path.join(featureRoot, "src", "feature.manifest.ts"); +} + +/** + * Given an absolute file path and the monorepo root, returns the feature + * package root that contains the file (e.g. /repo/packages/auth). Returns + * null when the file lives outside the packages/ tree. + * + * Assumes the conventional layout `packages//src/...`. + */ +export function featureRootForFile(filepath, repoRoot) { + const packagesDir = path.join(repoRoot, "packages"); + if (!filepath.startsWith(packagesDir + path.sep)) return null; + const rel = filepath.slice(packagesDir.length + 1); // e.g. "auth/src/..." + const slash = rel.indexOf(path.sep); + if (slash === -1) return null; + const featureName = rel.slice(0, slash); + return path.join(packagesDir, featureName); +} +``` + +- [ ] **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//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- 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-` exists under any of the workspace + * globs. The glob set is small and predictable (e.g. ["apps/*", "packages/*"]); + * we simulate matching by checking each glob's directory portion + verifying + * `core-` exists in that directory. + */ +function coreExistsInWorkspace(coreName, repoRoot, packageGlobs) { + for (const glob of packageGlobs) { + const slashStar = glob.endsWith("/*") ? glob.slice(0, -2) : null; + if (!slashStar) continue; + const candidate = path.join(repoRoot, slashStar, `core-${coreName}`); + if (fs.existsSync(candidate)) return true; + } + return false; +} + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Cores declared in a feature.manifest.ts's requiredCores must exist as core- packages within a workspace glob.", + }, + schema: [ + { + type: "object", + properties: { + repoRoot: { type: "string" }, + }, + additionalProperties: false, + }, + ], + messages: { + coreNotInstalled: + "Manifest declares requiredCores: [..., \"{{core}}\", ...] but `core-{{core}}` is not present in any workspace glob. Run `pnpm turbo gen core-package {{core}}` or drop the entry.", + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + return { + Program(node) { + const filename = context.filename; + if (!filename.endsWith("/feature.manifest.ts") && !filename.endsWith("\\feature.manifest.ts")) { + return; + } + const manifest = readManifestSource(filename); + if (!manifest) return; + const globs = readWorkspacePackages(repoRoot); + for (const core of manifest.requiredCores) { + if (!coreExistsInWorkspace(core, repoRoot, globs)) { + context.report({ + node, + messageId: "coreNotInstalled", + data: { core }, + }); + } + } + }, + }; + }, +}; +``` + +- [ ] **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-` 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.