usecase-must-be-wired returned {} when the manifest parsed to null,
silently disabling the error-level gate when the manifest existed but
could not be read. It now reports unparseableManifest on Program in
that case; a genuinely missing manifest stays a no-op (that is
feature-must-have-manifest's job).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
3.9 KiB
JavaScript
115 lines
3.9 KiB
JavaScript
import fs from "node:fs";
|
|
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.',
|
|
unparseableManifest:
|
|
"feature.manifest.ts for {{feature}} exists but could not be parsed by the conformance rules; the wiring gate cannot run. Fix the manifest shape (defineFeature({...} as const)).",
|
|
},
|
|
},
|
|
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 manifestPath = manifestPathForFeature(featureRoot);
|
|
const manifest = parseManifestUseCases(manifestPath);
|
|
if (!manifest) {
|
|
// An unparseable manifest must FAIL the gate, not silently disable it.
|
|
if (fs.existsSync(manifestPath)) {
|
|
return {
|
|
Program(node) {
|
|
context.report({
|
|
node,
|
|
messageId: "unparseableManifest",
|
|
data: { feature: path.basename(featureRoot) },
|
|
});
|
|
},
|
|
};
|
|
}
|
|
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 },
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|