From 8cb531e0cd077d80e19e9bf73816f0b5bda996e8 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 11:03:17 +0200 Subject: [PATCH] feat(core-eslint): add usecase-must-be-wired conformance rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches manifest use cases that aren't wired through wireUseCase(...) in bind-production.ts / bind-dev-seed.ts. wireUseCase is the canonical helper that attaches __instrumented / __captured / __audited brands — skipping it produces an unbranded binding that assertFeatureConformance would reject at boot. This rule shifts that detection from ~3s (boot) to <1s (lint), keeping the layered conformance pattern: TS brands (compile), ESLint (lint), boot assertion (dev), smoke tests (CI). CLAUDE.md + conformance-quickref.md updated for the new rule (5 → 6). --- CLAUDE.md | 2 +- docs/guides/conformance-quickref.md | 18 +- packages/core-eslint/base.js | 1 + packages/core-eslint/plugin.js | 2 + .../rules/usecase-must-be-wired.js | 96 ++++++++++ .../rules/usecase-must-be-wired.test.js | 172 ++++++++++++++++++ 6 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 packages/core-eslint/rules/usecase-must-be-wired.js create mode 100644 packages/core-eslint/rules/usecase-must-be-wired.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 96bbc07..8dd4592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, p | **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features | | **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift | -The five conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. +The six conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference. diff --git a/docs/guides/conformance-quickref.md b/docs/guides/conformance-quickref.md index b7783a0..5bc4f15 100644 --- a/docs/guides/conformance-quickref.md +++ b/docs/guides/conformance-quickref.md @@ -86,13 +86,14 @@ The symbol map declares which container symbol each manifest use-case key resolv ## ESLint rules -| Rule | Severity | What it does | -| ----------------------------------------- | -------- | -------------------------------------------------------------------------------------- | -| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest | -| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` | -| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-` packages in pnpm-workspace.yaml | -| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case | -| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` | +| Rule | Severity | What it does | +| ----------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest | +| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` | +| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-` packages in pnpm-workspace.yaml | +| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case | +| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` | +| `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "" })` in `bind-production.ts` / `bind-dev-seed.ts` | ## Workflow ordering for new use cases @@ -105,7 +106,8 @@ For the fast path: `pnpm turbo gen feature ` scaffolds steps 1 + 2 in a si ## Common drift patterns and the gate that catches them -- **Forgot `withSpan` at bind time** → tsc TS2322 + boot assertion +- **Forgot `withSpan` at bind time** → tsc TS2322 + `usecase-must-be-wired` ESLint error + boot assertion + bind-production smoke test +- **Manifest entry has no `wireUseCase` call in either binder** → `usecase-must-be-wired` ESLint error + boot assertion + bind-production smoke test - **Manifest declares `audits: ["X"]` but factory doesn't call `auditLog.record({type:"X"})`** → no automatic catch yet; future story - **Factory calls `bus.publish("Y")` but manifest doesn't declare it** → `conformance/no-undeclared-event-publish` (warn) - **Feature has use cases but no manifest** → `conformance/feature-must-have-manifest` (error) diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 7fff7db..4e607ec 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -47,6 +47,7 @@ export default [ "conformance/required-cores-installed": ["error", { repoRoot }], "conformance/no-undeclared-event-publish": ["warn", { repoRoot }], "conformance/no-undeclared-audit": ["warn", { repoRoot }], + "conformance/usecase-must-be-wired": ["error", { repoRoot }], "conformance/component-must-have-story": "warn", "conformance/component-must-have-test": "warn", "conformance/atomic-tier-import-direction": "warn", diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index b060410..ad7280b 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -3,6 +3,7 @@ import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js"; import requiredCoresInstalled from "./rules/required-cores-installed.js"; import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js"; import noUndeclaredAudit from "./rules/no-undeclared-audit.js"; +import usecaseMustBeWired from "./rules/usecase-must-be-wired.js"; import componentMustHaveStory from "./rules/component-must-have-story.js"; import componentMustHaveTest from "./rules/component-must-have-test.js"; import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js"; @@ -28,6 +29,7 @@ const plugin = { "required-cores-installed": requiredCoresInstalled, "no-undeclared-event-publish": noUndeclaredEventPublish, "no-undeclared-audit": noUndeclaredAudit, + "usecase-must-be-wired": usecaseMustBeWired, "component-must-have-story": componentMustHaveStory, "component-must-have-test": componentMustHaveTest, "atomic-tier-import-direction": atomicTierImportDirection, diff --git a/packages/core-eslint/rules/usecase-must-be-wired.js b/packages/core-eslint/rules/usecase-must-be-wired.js new file mode 100644 index 0000000..b914399 --- /dev/null +++ b/packages/core-eslint/rules/usecase-must-be-wired.js @@ -0,0 +1,96 @@ +import path from "node:path"; +import { parseManifestUseCases } from "./_manifest-ast.js"; +import { + manifestPathForFeature, + featureRootForFile, +} from "./_manifest-source.js"; + +/** + * Every manifest-declared use case must be bound through `wireUseCase(...)` in + * the feature's `bind-production.ts` and `bind-dev-seed.ts`. `wireUseCase` is + * the canonical helper that wraps factories with `withSpan` + `withCapture` + * (+ `withAudit` for mutating use cases) and attaches the corresponding + * brands. Skipping it produces an unbranded binding that `assertFeatureConformance` + * will reject at boot — this rule shifts that check from ~3s (boot) to <1s + * (lint), so drift is caught while typing. + * + * Lint-time complement to: + * - the TypeScript brand check (`ProductionUseCase` slot type) — compile time + * - the boot-time assertion (`assertFeatureConformance`) — runtime + * - the smoke test (`bind-production.smoke.test.ts`) — CI + */ +const BINDER_BASENAMES = new Set(["bind-production.ts", "bind-dev-seed.ts"]); + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "Every manifest use case must be bound through wireUseCase(...) in the feature's bind-production.ts / bind-dev-seed.ts.", + }, + schema: [ + { + type: "object", + properties: { repoRoot: { type: "string" } }, + additionalProperties: false, + }, + ], + messages: { + missing: + 'Use case "{{useCase}}" is declared in {{feature}}.manifest.ts but not wired through wireUseCase({ name: "{{useCase}}", ... }) in this binder. Add the wireUseCase call or remove the manifest entry.', + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + const filename = context.filename; + if (!BINDER_BASENAMES.has(path.basename(filename))) return {}; + + const featureRoot = featureRootForFile(filename, repoRoot); + if (!featureRoot) return {}; + const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); + if (!manifest) return {}; + const declaredNames = Object.keys(manifest); + if (declaredNames.length === 0) return {}; + const featureName = path.basename(featureRoot); + + const wired = new Set(); + return { + CallExpression(node) { + if ( + node.callee.type !== "Identifier" || + node.callee.name !== "wireUseCase" + ) { + return; + } + const arg = node.arguments[0]; + if (!arg || arg.type !== "ObjectExpression") return; + const nameProp = arg.properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "name", + ); + if ( + nameProp && + nameProp.value.type === "Literal" && + typeof nameProp.value.value === "string" + ) { + wired.add(nameProp.value.value); + } + }, + "Program:exit"(programNode) { + for (const useCase of declaredNames) { + if (!wired.has(useCase)) { + context.report({ + node: programNode, + messageId: "missing", + data: { useCase, feature: featureName }, + }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/usecase-must-be-wired.test.js b/packages/core-eslint/rules/usecase-must-be-wired.test.js new file mode 100644 index 0000000..7e2fb0f --- /dev/null +++ b/packages/core-eslint/rules/usecase-must-be-wired.test.js @@ -0,0 +1,172 @@ +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-be-wired.js"; + +function makeFixture({ + manifestUseCases, + binderBody, + binderName = "bind-production.ts", +}) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "umbw-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "di"), { recursive: true }); + const useCasesObj = Object.entries(manifestUseCases) + .map( + ([name, uc]) => + ` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [], consumes: [] },`, + ) + .join("\n"); + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], + useCases: { +${useCasesObj} + }, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + const binderFile = path.join(featureDir, "src", "di", binderName); + fs.writeFileSync(binderFile, binderBody); + return { repoRoot, binderFile }; +} + +const tester = new RuleTester({ + languageOptions: { + parser: await import("@typescript-eslint/parser"), + ecmaVersion: "latest", + sourceType: "module", + }, +}); + +describe("usecase-must-be-wired", () => { + it("passes when every manifest use case has a wireUseCase call", () => { + const { repoRoot, binderFile } = makeFixture({ + manifestUseCases: { + signIn: { mutates: false, audits: [] }, + signUp: { mutates: true, audits: ["user.created"] }, + }, + binderBody: ` + export function bindProductionDemo(ctx) { + wireUseCase({ name: "signIn", factory: signInUseCase }); + wireUseCase({ name: "signUp", factory: signUpUseCase }); + } + `, + }); + tester.run("usecase-must-be-wired", rule, { + valid: [ + { + filename: binderFile, + code: fs.readFileSync(binderFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires when a manifest use case is missing a wireUseCase call", () => { + const { repoRoot, binderFile } = makeFixture({ + manifestUseCases: { + signIn: { mutates: false, audits: [] }, + signUp: { mutates: true, audits: [] }, + }, + binderBody: ` + export function bindProductionDemo(ctx) { + wireUseCase({ name: "signIn", factory: signInUseCase }); + } + `, + }); + tester.run("usecase-must-be-wired", rule, { + valid: [], + invalid: [ + { + filename: binderFile, + code: fs.readFileSync(binderFile, "utf8"), + options: [{ repoRoot }], + errors: [ + { + messageId: "missing", + data: { useCase: "signUp", feature: "demo" }, + }, + ], + }, + ], + }); + }); + + it("also applies to bind-dev-seed.ts", () => { + const { repoRoot, binderFile } = makeFixture({ + binderName: "bind-dev-seed.ts", + manifestUseCases: { signIn: { mutates: false, audits: [] } }, + binderBody: ` + export async function bindDevSeedDemo(ctx) { + // forgot to call wireUseCase + } + `, + }); + tester.run("usecase-must-be-wired", rule, { + valid: [], + invalid: [ + { + filename: binderFile, + code: fs.readFileSync(binderFile, "utf8"), + options: [{ repoRoot }], + errors: [ + { + messageId: "missing", + data: { useCase: "signIn", feature: "demo" }, + }, + ], + }, + ], + }); + }); + + it("is a no-op on files outside src/di/bind-*.ts", () => { + const { repoRoot } = makeFixture({ + manifestUseCases: { signIn: { mutates: false, audits: [] } }, + binderBody: `// not a binder`, + }); + const otherFile = path.join( + repoRoot, + "packages", + "demo", + "src", + "other.ts", + ); + fs.writeFileSync(otherFile, `export const x = 1;`); + tester.run("usecase-must-be-wired", rule, { + valid: [ + { + filename: otherFile, + code: fs.readFileSync(otherFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("is a no-op when the feature manifest declares no use cases", () => { + const { repoRoot, binderFile } = makeFixture({ + manifestUseCases: {}, + binderBody: `export function bindProductionDemo(ctx) {}`, + }); + tester.run("usecase-must-be-wired", rule, { + valid: [ + { + filename: binderFile, + code: fs.readFileSync(binderFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); +});