import { parseManifestUseCases } from "./_manifest-ast.js"; import { manifestPathForFeature } from "./_manifest-source.js"; import { repoRootSchema } from "./_rule-schema.js"; import { resolveRuleContext } from "./_rule-context.js"; import { eventNameFromFile, resolveRelativeImport } from "./_event-ast.js"; /** @type {import("eslint").Rule.RuleModule} */ export default { meta: { type: "problem", docs: { description: "bus.publish(event) inside a use-case factory must declare the event in manifest.useCases[name].publishes. Resolves both a string-literal event name and an imported event descriptor (defineEvent(...) or an inline { name } object).", }, schema: repoRootSchema, messages: { undeclared: '{{useCase}} publishes "{{event}}" via bus.publish but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.', }, }, create(context) { const rc = resolveRuleContext(context); if (!rc) return {}; const { useCaseName, featureRoot } = rc; const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); if (!manifest || !manifest[useCaseName]) return {}; const declared = new Set(manifest[useCaseName].publishes); // Local import name -> import source string. Populated as `ImportDeclaration` // nodes are visited; imports always precede the use-case body, so the map // is complete by the time a `bus.publish` call is reached. const importSources = new Map(); /** * Resolve a `bus.publish()` first argument to its event name. Returns null * when the argument can't be statically analysed — an unresolvable case is * skipped rather than reported, so the rule never false-positives. */ function eventNameFor(arg) { if (arg.type === "Literal" && typeof arg.value === "string") { return arg.value; } if (arg.type === "Identifier") { const source = importSources.get(arg.name); if (!source) return null; const file = resolveRelativeImport(source, context.filename); if (!file) return null; return eventNameFromFile(file, arg.name); } return null; } return { ImportDeclaration(node) { const source = node.source.value; for (const spec of node.specifiers) { if ( spec.type === "ImportSpecifier" || spec.type === "ImportDefaultSpecifier" ) { importSources.set(spec.local.name, source); } } }, CallExpression(node) { if ( node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "bus" && node.callee.property.type === "Identifier" && node.callee.property.name === "publish" && node.arguments.length > 0 ) { const event = eventNameFor(node.arguments[0]); if (event === null) return; if (!declared.has(event)) { context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName }, }); } } }, }; }, };