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).
97 lines
3.3 KiB
JavaScript
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 },
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|