From c099d7182bdbdfba34e8d699c4260e29a8aeed52 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 21 May 2026 11:49:36 +0200 Subject: [PATCH] fix(core-eslint): resolve event descriptors in no-undeclared-event-publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule only matched bus.publish("string-literal", ...), but the canonical pattern that `gen event` prescribes is bus.publish(eventDescriptor, payload) — an imported identifier, never a literal. The rule therefore never fired on real code, which is how the auth signUp publish drifted from its manifest undetected. Add `_event-ast.js`: resolves a `bus.publish()` argument by following the import to the event-contract file and extracting the name from either `defineEvent("...", schema)` or an inline `{ name }` object. Unresolvable arguments are skipped, so the rule never false-positives. --- packages/core-eslint/rules/_event-ast.js | 99 +++++++++++ .../rules/no-undeclared-event-publish.js | 48 +++++- .../rules/no-undeclared-event-publish.test.js | 160 ++++++++++++++++-- 3 files changed, 284 insertions(+), 23 deletions(-) create mode 100644 packages/core-eslint/rules/_event-ast.js diff --git a/packages/core-eslint/rules/_event-ast.js b/packages/core-eslint/rules/_event-ast.js new file mode 100644 index 0000000..fa74941 --- /dev/null +++ b/packages/core-eslint/rules/_event-ast.js @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parse } from "@typescript-eslint/parser"; + +/** Unwrap `as`/`satisfies` expressions, return a string literal's value. */ +function stringFromNode(node) { + if (!node) return null; + if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") { + return stringFromNode(node.expression); + } + if (node.type === "Literal" && typeof node.value === "string") { + return node.value; + } + return null; +} + +/** + * Extract the event `name` from an event-descriptor initializer. Handles the + * two shapes the codebase and `pnpm turbo gen event` produce: + * - `defineEvent("feature.event", schema)` — the core-events helper + * - `{ name: "feature.event" as const, schema }` — the inline descriptor + * (used when core-events is not installed) + */ +function nameFromInit(init) { + if (!init) return null; + if (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression") { + return nameFromInit(init.expression); + } + if ( + init.type === "CallExpression" && + init.callee.type === "Identifier" && + init.callee.name === "defineEvent" && + init.arguments.length > 0 + ) { + return stringFromNode(init.arguments[0]); + } + if (init.type === "ObjectExpression") { + const nameProp = init.properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "name", + ); + return nameProp ? stringFromNode(nameProp.value) : null; + } + return null; +} + +/** + * Resolve a relative import source (evaluated from `fromFile`) to a `.ts` + * file on disk. Returns null for bare/package specifiers or paths that don't + * resolve — callers treat null as "can't analyse, skip". + */ +export function resolveRelativeImport(source, fromFile) { + if (typeof source !== "string" || !source.startsWith(".")) return null; + const base = path.resolve(path.dirname(fromFile), source); + for (const candidate of [`${base}.ts`, path.join(base, "index.ts")]) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Read an event-contract file and return the `name` of the event descriptor + * exported under `exportName`. Returns null when the file can't be read or + * parsed, or doesn't export a recognised descriptor under that name. + */ +export function eventNameFromFile(filePath, exportName) { + let source; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + let ast; + try { + ast = parse(source, { loc: false, range: false }); + } catch { + return null; + } + for (const stmt of ast.body) { + let varDecl = null; + if ( + stmt.type === "ExportNamedDeclaration" && + stmt.declaration?.type === "VariableDeclaration" + ) { + varDecl = stmt.declaration; + } else if (stmt.type === "VariableDeclaration") { + varDecl = stmt; + } + if (!varDecl) continue; + for (const d of varDecl.declarations) { + if (d.id.type === "Identifier" && d.id.name === exportName) { + return nameFromInit(d.init); + } + } + } + return null; +} diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.js b/packages/core-eslint/rules/no-undeclared-event-publish.js index 845601a..44f7cac 100644 --- a/packages/core-eslint/rules/no-undeclared-event-publish.js +++ b/packages/core-eslint/rules/no-undeclared-event-publish.js @@ -2,6 +2,7 @@ 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 { @@ -9,12 +10,12 @@ export default { type: "problem", docs: { description: - 'bus.publish("X") inside a use-case factory must declare X in manifest.useCases[name].publishes.', + "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}} calls bus.publish("{{event}}") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.', + '{{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) { @@ -24,7 +25,43 @@ export default { 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" && @@ -32,11 +69,10 @@ export default { 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" + node.arguments.length > 0 ) { - const event = node.arguments[0].value; + const event = eventNameFor(node.arguments[0]); + if (event === null) return; if (!declared.has(event)) { context.report({ node, diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.test.js b/packages/core-eslint/rules/no-undeclared-event-publish.test.js index 5a8b880..2faea37 100644 --- a/packages/core-eslint/rules/no-undeclared-event-publish.test.js +++ b/packages/core-eslint/rules/no-undeclared-event-publish.test.js @@ -5,13 +5,16 @@ import os from "node:os"; import fs from "node:fs"; import rule from "./no-undeclared-event-publish.js"; -function makeFixture({ manifestUseCases, useCaseBody }) { +function makeFixture({ manifestUseCases, useCaseBody, eventFile }) { 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 }); + 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: [] },`, + .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( @@ -26,7 +29,21 @@ ${useCasesObj} jobs: [], } as const);`, ); - const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts"); + if (eventFile) { + const eventsDir = path.join(featureDir, "src", "events"); + fs.mkdirSync(eventsDir, { recursive: true }); + fs.writeFileSync( + path.join(eventsDir, eventFile.filename), + eventFile.contents, + ); + } + const useCaseFile = path.join( + featureDir, + "src", + "application", + "use-cases", + "sign-up.use-case.ts", + ); fs.writeFileSync(useCaseFile, useCaseBody); return { repoRoot, useCaseFile }; } @@ -42,39 +59,148 @@ const tester = new RuleTester({ 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"] } }, + 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 }] }], + 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: [] } }, + 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" } }], - }], + 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", () => { + it("is a no-op when bus.publish is called with an unresolvable identifier", () => { const { repoRoot, useCaseFile } = makeFixture({ - manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } }, + 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 }] }], + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], invalid: [], }); }); + + it("passes when an imported inline event descriptor is declared", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { + signUp: { mutates: true, audits: [], publishes: ["demo.signed-up"] }, + }, + eventFile: { + filename: "signed-up.event.ts", + contents: `export const signedUpEvent = { name: "demo.signed-up" as const, schema: {} };`, + }, + useCaseBody: `import { signedUpEvent } from "../../events/signed-up.event"; +export const signUpUseCase = (bus) => async () => { bus.publish(signedUpEvent, {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires when an imported inline event descriptor is not declared", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { + signUp: { mutates: true, audits: [], publishes: [] }, + }, + eventFile: { + filename: "signed-up.event.ts", + contents: `export const signedUpEvent = { name: "demo.signed-up" as const, schema: {} };`, + }, + useCaseBody: `import { signedUpEvent } from "../../events/signed-up.event"; +export const signUpUseCase = (bus) => async () => { bus.publish(signedUpEvent, {}); };`, + }); + 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("fires when an imported defineEvent() descriptor is not declared", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { + signUp: { mutates: true, audits: [], publishes: [] }, + }, + eventFile: { + filename: "signed-up.event.ts", + contents: `export const signedUpEvent = defineEvent("demo.signed-up", {});`, + }, + useCaseBody: `import { signedUpEvent } from "../../events/signed-up.event"; +export const signUpUseCase = (bus) => async () => { bus.publish(signedUpEvent, {}); };`, + }); + 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" }, + }, + ], + }, + ], + }); + }); });