Files
agentic-dev/packages/core-eslint/rules/usecase-must-be-wired.js
Danijel Martinek 8cb531e0cd 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).
2026-05-18 11:03:17 +02:00

97 lines
3.3 KiB
JavaScript

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 },
});
}
}
},
};
},
};