feat(core-eslint): add usecase-must-be-wired conformance rule

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).
This commit is contained in:
2026-05-18 11:03:17 +02:00
parent bd770cad5d
commit 8cb531e0cd
6 changed files with 282 additions and 9 deletions

View File

@@ -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`) | ~3060s | 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.

View File

@@ -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-<name>` 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-<name>` 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: "<key>" })` 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 <name>` 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)

View File

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

View File

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

View File

@@ -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<I,O>` 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 },
});
}
}
},
};
},
};

View File

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