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