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.
100 lines
3.0 KiB
JavaScript
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;
|
|
}
|