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 |
|
| **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 |
|
| **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -87,12 +87,13 @@ The symbol map declares which container symbol each manifest use-case key resolv
|
|||||||
## ESLint rules
|
## ESLint rules
|
||||||
|
|
||||||
| Rule | Severity | What it does |
|
| Rule | Severity | What it does |
|
||||||
| ----------------------------------------- | -------- | -------------------------------------------------------------------------------------- |
|
| ----------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest |
|
| `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/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/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-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/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
|
## 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
|
## 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
|
- **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)
|
- **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)
|
- **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/required-cores-installed": ["error", { repoRoot }],
|
||||||
"conformance/no-undeclared-event-publish": ["warn", { repoRoot }],
|
"conformance/no-undeclared-event-publish": ["warn", { repoRoot }],
|
||||||
"conformance/no-undeclared-audit": ["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-story": "warn",
|
||||||
"conformance/component-must-have-test": "warn",
|
"conformance/component-must-have-test": "warn",
|
||||||
"conformance/atomic-tier-import-direction": "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 requiredCoresInstalled from "./rules/required-cores-installed.js";
|
||||||
import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js";
|
import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js";
|
||||||
import noUndeclaredAudit from "./rules/no-undeclared-audit.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 componentMustHaveStory from "./rules/component-must-have-story.js";
|
||||||
import componentMustHaveTest from "./rules/component-must-have-test.js";
|
import componentMustHaveTest from "./rules/component-must-have-test.js";
|
||||||
import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js";
|
import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js";
|
||||||
@@ -28,6 +29,7 @@ const plugin = {
|
|||||||
"required-cores-installed": requiredCoresInstalled,
|
"required-cores-installed": requiredCoresInstalled,
|
||||||
"no-undeclared-event-publish": noUndeclaredEventPublish,
|
"no-undeclared-event-publish": noUndeclaredEventPublish,
|
||||||
"no-undeclared-audit": noUndeclaredAudit,
|
"no-undeclared-audit": noUndeclaredAudit,
|
||||||
|
"usecase-must-be-wired": usecaseMustBeWired,
|
||||||
"component-must-have-story": componentMustHaveStory,
|
"component-must-have-story": componentMustHaveStory,
|
||||||
"component-must-have-test": componentMustHaveTest,
|
"component-must-have-test": componentMustHaveTest,
|
||||||
"atomic-tier-import-direction": atomicTierImportDirection,
|
"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