diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.js b/packages/core-eslint/rules/no-undeclared-event-publish.js new file mode 100644 index 0000000..bc44a5a --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-event-publish.js @@ -0,0 +1,56 @@ +import { parseManifestUseCases } from "./_manifest-ast.js"; +import { useCaseNameFromFile } from "./_usecase-name.js"; +import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js"; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "bus.publish(\"X\") inside a use-case factory must declare X in manifest.useCases[name].publishes.", + }, + schema: [ + { + type: "object", + properties: { repoRoot: { type: "string" } }, + additionalProperties: false, + }, + ], + messages: { + undeclared: + "{{useCase}} calls bus.publish(\"{{event}}\") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.", + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + const filename = context.filename; + const useCaseName = useCaseNameFromFile(filename); + if (!useCaseName) return {}; + const featureRoot = featureRootForFile(filename, repoRoot); + if (!featureRoot) return {}; + const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); + if (!manifest || !manifest[useCaseName]) return {}; + const declared = new Set(manifest[useCaseName].publishes); + return { + 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 && + node.arguments[0].type === "Literal" && + typeof node.arguments[0].value === "string" + ) { + const event = node.arguments[0].value; + if (!declared.has(event)) { + context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.test.js b/packages/core-eslint/rules/no-undeclared-event-publish.test.js new file mode 100644 index 0000000..5a8b880 --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-event-publish.test.js @@ -0,0 +1,80 @@ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import rule from "./no-undeclared-event-publish.js"; + +function makeFixture({ manifestUseCases, useCaseBody }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nuep-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true }); + const useCasesObj = Object.entries(manifestUseCases) + .map(([name, uc]) => + ` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`, + ) + .join("\n"); + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], + useCases: { +${useCasesObj} + }, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts"); + fs.writeFileSync(useCaseFile, useCaseBody); + return { repoRoot, useCaseFile }; +} + +const tester = new RuleTester({ + languageOptions: { + parser: await import("@typescript-eslint/parser"), + ecmaVersion: "latest", + sourceType: "module", + }, +}); + +describe("no-undeclared-event-publish", () => { + it("passes when bus.publish event name matches manifest publishes[]", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: ["demo.signed-up"] } }, + useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); + + it("fires when bus.publish event name is not in manifest", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } }, + useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [], + invalid: [{ + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }], + }], + }); + }); + + it("is a no-op when bus.publish is called with a non-literal argument", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } }, + useCaseBody: `export const signUpUseCase = (bus, name) => async () => { bus.publish(name, {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); +});