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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
packages/core-eslint/rules/usecase-must-be-wired.js
Normal file
96
packages/core-eslint/rules/usecase-must-be-wired.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
172
packages/core-eslint/rules/usecase-must-be-wired.test.js
Normal file
172
packages/core-eslint/rules/usecase-must-be-wired.test.js
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user