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:
99
packages/core-eslint/rules/_event-ast.js
Normal file
99
packages/core-eslint/rules/_event-ast.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ 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}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`,
|
([name, uc]) =>
|
||||||
|
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`,
|
||||||
)
|
)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -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,
|
{
|
||||||
code: fs.readFileSync(useCaseFile, "utf8"),
|
filename: useCaseFile,
|
||||||
options: [{ repoRoot }],
|
code: fs.readFileSync(useCaseFile, "utf8"),
|
||||||
errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }],
|
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({
|
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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user