fix(core-eslint): resolve event descriptors in no-undeclared-event-publish

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(<identifier>)` 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.
This commit is contained in:
2026-05-21 11:49:36 +02:00
parent f2d633c02a
commit c099d7182b
3 changed files with 284 additions and 23 deletions

View File

@@ -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;
}

View File

@@ -2,6 +2,7 @@ import { parseManifestUseCases } from "./_manifest-ast.js";
import { manifestPathForFeature } from "./_manifest-source.js"; import { manifestPathForFeature } from "./_manifest-source.js";
import { repoRootSchema } from "./_rule-schema.js"; import { repoRootSchema } from "./_rule-schema.js";
import { resolveRuleContext } from "./_rule-context.js"; import { resolveRuleContext } from "./_rule-context.js";
import { eventNameFromFile, resolveRelativeImport } from "./_event-ast.js";
/** @type {import("eslint").Rule.RuleModule} */ /** @type {import("eslint").Rule.RuleModule} */
export default { export default {
@@ -9,12 +10,12 @@ export default {
type: "problem", type: "problem",
docs: { docs: {
description: 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, schema: repoRootSchema,
messages: { messages: {
undeclared: 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) { create(context) {
@@ -24,7 +25,43 @@ export default {
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
if (!manifest || !manifest[useCaseName]) return {}; if (!manifest || !manifest[useCaseName]) return {};
const declared = new Set(manifest[useCaseName].publishes); 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 { 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) { CallExpression(node) {
if ( if (
node.callee.type === "MemberExpression" && node.callee.type === "MemberExpression" &&
@@ -32,11 +69,10 @@ export default {
node.callee.object.name === "bus" && node.callee.object.name === "bus" &&
node.callee.property.type === "Identifier" && node.callee.property.type === "Identifier" &&
node.callee.property.name === "publish" && node.callee.property.name === "publish" &&
node.arguments.length > 0 && node.arguments.length > 0
node.arguments[0].type === "Literal" &&
typeof node.arguments[0].value === "string"
) { ) {
const event = node.arguments[0].value; const event = eventNameFor(node.arguments[0]);
if (event === null) return;
if (!declared.has(event)) { if (!declared.has(event)) {
context.report({ context.report({
node, node,

View File

@@ -5,12 +5,15 @@ import os from "node:os";
import fs from "node:fs"; import fs from "node:fs";
import rule from "./no-undeclared-event-publish.js"; 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 repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nuep-"));
const featureDir = path.join(repoRoot, "packages", "demo"); 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) const useCasesObj = Object.entries(manifestUseCases)
.map(([name, uc]) => .map(
([name, uc]) =>
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`, ` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`,
) )
.join("\n"); .join("\n");
@@ -26,7 +29,21 @@ ${useCasesObj}
jobs: [], jobs: [],
} as const);`, } 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); fs.writeFileSync(useCaseFile, useCaseBody);
return { repoRoot, useCaseFile }; return { repoRoot, useCaseFile };
} }
@@ -42,39 +59,148 @@ const tester = new RuleTester({
describe("no-undeclared-event-publish", () => { describe("no-undeclared-event-publish", () => {
it("passes when bus.publish event name matches manifest publishes[]", () => { it("passes when bus.publish event name matches manifest publishes[]", () => {
const { repoRoot, useCaseFile } = makeFixture({ 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", {}); };`, useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
}); });
tester.run("no-undeclared-event-publish", rule, { 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: [], invalid: [],
}); });
}); });
it("fires when bus.publish event name is not in manifest", () => { it("fires when bus.publish event name is not in manifest", () => {
const { repoRoot, useCaseFile } = makeFixture({ 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", {}); };`, useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
}); });
tester.run("no-undeclared-event-publish", rule, { tester.run("no-undeclared-event-publish", rule, {
valid: [], valid: [],
invalid: [{ invalid: [
{
filename: useCaseFile, filename: useCaseFile,
code: fs.readFileSync(useCaseFile, "utf8"), code: fs.readFileSync(useCaseFile, "utf8"),
options: [{ repoRoot }], options: [{ repoRoot }],
errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }], 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({ 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, {}); };`, useCaseBody: `export const signUpUseCase = (bus, name) => async () => { bus.publish(name, {}); };`,
}); });
tester.run("no-undeclared-event-publish", rule, { 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: [], 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" },
},
],
},
],
});
});
}); });