Files
Danijel Martinek c099d7182b 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.
2026-05-21 11:49:36 +02:00

100 lines
3.0 KiB
JavaScript

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