diff --git a/.moon/tasks/all.yml b/.moon/tasks/all.yml index 62f1c53..d62bfab 100644 --- a/.moon/tasks/all.yml +++ b/.moon/tasks/all.yml @@ -9,6 +9,13 @@ tasks: args: "--check" lint: command: "oxlint --no-error-on-unmatched-pattern" + inputs: + - "**/*" + - "/packages/config/oxlint.config.ts" + - "/packages/config/oxlint/anti-slop/**/*" + - "/packages/config/package.json" + - "/pnpm-workspace.yaml" + - "/pnpm-lock.yaml" lint/fix: extends: "lint" args: "--fix" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 658003e..c31546b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,6 +5,7 @@ import { formatLog, getErrorContext, getRequestId, + type LogContext, } from "@embedly/logging"; import { Platforms } from "@embedly/platforms"; import { httpInstrumentationMiddleware } from "@hono/otel"; @@ -21,6 +22,21 @@ import { version } from "../package.json"; type ScrapeResponse = Awaited>; +interface ApiLogContext extends LogContext { + request_id: string; + trace_id?: string; + span_id?: string; + source: string; + platform: string; + post_id: string; + force: boolean; + cache_status: "skipped" | "miss" | "hit" | "read_error" | "stored" | "write_error"; + outcome: "success" | "error"; + status_code: number; + error_type?: string; + duration_ms?: number; +} + const config: ResolveConfigFn = (env) => { if (!env.OTEL_ENDPOINT) throw new Error("OTEL_ENDPOINT is required."); @@ -52,7 +68,7 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() const { id, platform, force } = c.req.valid("json"); const requestId = getRequestId(c.req.raw); const spanContext = trace.getActiveSpan()?.spanContext(); - const logContext: Record = { + const logContext: ApiLogContext = { request_id: requestId, trace_id: spanContext?.traceId, span_id: spanContext?.spanId, @@ -73,25 +89,20 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() const cachedItem = await cache.get(cacheKey, "json"); if (cachedItem) { logContext.cache_status = "hit"; - return c.json(cachedItem as ScrapeResponse, 200); + return c.json(cachedItem, 200); } } catch (cause) { - const problem = createProblem(EmbedlyErrors.CacheReadFailed, { - request_id: requestId, - context: { ...logContext, ...getErrorContext(cause) }, - }); - Object.assign(logContext, getErrorContext(cause), { - outcome: "error", - status_code: problem.status, - error_type: problem.type, - }); - return c.json(problem, problem.status); + logContext.cache_status = "read_error"; + console.warn( + formatLog("warn", EmbedlyErrors.CacheReadFailed, { + ...logContext, + ...getErrorContext(cause), + }), + ); } } - // oxlint-disable-next-line import/namespace - const p = Platforms[platform as keyof typeof Platforms]; - if (!p) { + if (!Object.hasOwn(Platforms, platform)) { const problem = createProblem(EmbedlyErrors.NoMatchesFound, { request_id: requestId, context: logContext, @@ -104,6 +115,8 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() }); return c.json(problem, problem.status); } + // oxlint-disable-next-line import/namespace -- SAFETY: Object.hasOwn verified this registry key. + const p = Platforms[platform as keyof typeof Platforms]; let raw: unknown; try { @@ -125,6 +138,7 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() let data: ScrapeResponse; try { + // SAFETY: raw came from this platform's fetch implementation. data = await p.transform(raw as any); } catch (cause) { const problem = createProblem(EmbedlyErrors.PlatformTransformFailed, { @@ -145,16 +159,13 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() }); logContext.cache_status = "stored"; } catch (cause) { - const problem = createProblem(EmbedlyErrors.CacheWriteFailed, { - request_id: requestId, - context: { ...logContext, ...getErrorContext(cause) }, - }); - Object.assign(logContext, getErrorContext(cause), { - outcome: "error", - status_code: problem.status, - error_type: problem.type, - }); - return c.json(problem, problem.status); + logContext.cache_status = "write_error"; + console.warn( + formatLog("warn", EmbedlyErrors.CacheWriteFailed, { + ...logContext, + ...getErrorContext(cause), + }), + ); } return c.json(data, 200); diff --git a/apps/bot/compose.yaml b/apps/bot/compose.yaml index e1284a5..ba312b4 100644 --- a/apps/bot/compose.yaml +++ b/apps/bot/compose.yaml @@ -11,7 +11,7 @@ services: timeout: 1s retries: 30 ports: - - "6379:6379" + - "127.0.0.1:6379:6379" bot: container_name: embedly-bot diff --git a/apps/bot/package.json b/apps/bot/package.json index 6c3bc72..a1be8c3 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -30,7 +30,6 @@ "devDependencies": { "@embedly/config": "workspace:*", "@types/node": "catalog:", - "dotenv": "^17.4.2", "oxfmt": "catalog:", "oxlint": "catalog:", "tsdown": "^0.22.0", diff --git a/apps/bot/src/commands/delete.ts b/apps/bot/src/commands/delete.ts index 4fdf076..9133fdc 100644 --- a/apps/bot/src/commands/delete.ts +++ b/apps/bot/src/commands/delete.ts @@ -4,6 +4,7 @@ import { EmbedlyLogs, formatDiscordError, getErrorContext, + type LogContext, } from "@embedly/logging"; import { Command } from "@sapphire/framework"; import { @@ -32,6 +33,25 @@ const DELETE_SUCCESS_MESSAGES = [ "🧹 all tidy! embed removed as requested~", ]; +interface DeleteLogContext extends LogContext { + request_id: string; + trace_id?: string; + span_id?: string; + source: "context_menu"; + interaction_id: string; + channel_id: string | null; + guild_id: string; + user_id: string; + message_id?: string; + original_author_id?: string; + has_manage_permission?: boolean; + outcome: "success" | "error"; + status_code: number; + error_type?: string; + reason?: string; + duration_ms?: number; +} + export class DeleteCommand extends Command { public constructor(context: Command.LoaderContext, options: Command.Options) { super(context, { @@ -62,7 +82,7 @@ export class DeleteCommand extends Command { const startedAt = Date.now(); const requestId = `context_menu:${interaction.id}`; - const logContext: Record = { + const logContext: DeleteLogContext = { request_id: requestId, source: "context_menu", interaction_id: interaction.id, @@ -196,7 +216,6 @@ export class DeleteCommand extends Command { try { await msg.delete(); - await this.container.messageCache.removeBotMessage(msg.id); } catch (error) { const problem = createProblem(EmbedlyErrors.DeleteFailed, { request_id: requestId, @@ -216,6 +235,20 @@ export class DeleteCommand extends Command { return; } + try { + await this.container.messageCache.removeBotMessage(msg.id); + } catch (error) { + botErrors.add(1, { + ...metricContext, + error_type: EmbedlyErrors.MessageCacheFailed.type, + }); + log("warn", EmbedlyErrors.MessageCacheFailed, { + ...logContext, + error_type: EmbedlyErrors.MessageCacheFailed.type, + ...getErrorContext(error), + }); + } + await interaction.editReply( DELETE_SUCCESS_MESSAGES[~~(DELETE_SUCCESS_MESSAGES.length * Math.random())], ); diff --git a/apps/bot/src/lib/handleUrls.ts b/apps/bot/src/lib/handleUrls.ts index 6a6cfbc..e63563d 100644 --- a/apps/bot/src/lib/handleUrls.ts +++ b/apps/bot/src/lib/handleUrls.ts @@ -6,6 +6,7 @@ import { formatLog, getErrorContext, isEmbedlyProblem, + type LogContext, } from "@embedly/logging"; import { matchURL, Platforms } from "@embedly/platforms"; import { Command, container } from "@sapphire/framework"; @@ -29,6 +30,26 @@ type EmbedSource = "message" | "command" | "context_menu"; type EmbedInteraction = Command.ChatInputCommandInteraction | Command.ContextMenuCommandInteraction; type ScrapeResponse = Awaited>; +interface EmbedLogContext extends LogContext { + request_id: string; + trace_id?: string; + span_id?: string; + source: EmbedSource; + platform: string; + post_id: string; + force: boolean; + message_id?: string; + interaction_id?: string; + channel_id: string | null; + guild_id: string; + user_id: string; + bot_message_id?: string; + outcome: "success" | "skipped" | "error"; + status_code: number; + error_type?: string; + duration_ms?: number; +} + export interface EmbedURLRequest { url: string; flags?: Partial; @@ -105,18 +126,51 @@ export async function handleUrls( if (interaction) await interaction.deferReply(); + let matchFailures = 0; + const matchContext = msg + ? { + message_id: msg.id, + channel_id: msg.channelId, + guild_id: msg.guildId ?? "dm", + user_id: msg.author.id, + } + : { + interaction_id: interaction!.id, + channel_id: interaction!.channelId, + guild_id: interaction!.guildId ?? "dm", + user_id: interaction!.user.id, + }; const matches = ( await Promise.all( - urls.map(async (request) => { - const match = await matchURL(request.url); - return match ? { ...request, ...match } : null; + urls.map(async (request, index) => { + try { + const match = await matchURL(request.url); + return match ? { ...request, ...match } : null; + } catch (error) { + matchFailures++; + const requestId = msg + ? `message:${msg.id}:match:${index}` + : `${embedSource}:${interaction!.id}:match:${index}`; + container.logger.warn( + formatLog("warn", EmbedlyErrors.ApiUnexpectedResponse, { + request_id: requestId, + source: embedSource, + ...matchContext, + ...getErrorContext(error), + }), + ); + if (msg) await reactToFailure(); + return null; + } }), ) ).filter((m) => m !== null); if (interaction && matches.length === 0) { const requestId = `${embedSource}:${interaction.id}`; - const problem = createProblem(EmbedlyErrors.NoMatchesFound, { + const error = + matchFailures > 0 ? EmbedlyErrors.ApiUnexpectedResponse : EmbedlyErrors.NoMatchesFound; + const problem = createProblem(error, { request_id: requestId, context: { request_id: requestId, @@ -125,7 +179,7 @@ export async function handleUrls( user_id: interaction.user.id, }, }); - container.logger.warn(formatLog("warn", EmbedlyErrors.NoMatchesFound, problem.context)); + container.logger.warn(formatLog("warn", error, problem.context)); await interaction.editReply({ content: formatDiscordError(problem), }); @@ -135,7 +189,7 @@ export async function handleUrls( for (const [i, { platform, id, flags, force }] of matches.entries()) { const startedAt = Date.now(); const requestId = msg ? `message:${msg.id}:${i}` : `${embedSource}:${interaction!.id}:${i}`; - const logContext: Record = { + const logContext: EmbedLogContext = { request_id: requestId, source: embedSource, platform, @@ -233,6 +287,7 @@ export async function handleUrls( return; } + // SAFETY: a successful response uses the API route's typed success body. post = body as ScrapeResponse; } catch (error) { recordError(requestSpan, error); @@ -295,8 +350,9 @@ export async function handleUrls( }, } as const; + let botMessage: Message; try { - const botMessage = await span( + botMessage = await span( "discord.send", { ...spanAttributes, @@ -315,22 +371,6 @@ export async function handleUrls( return message; }, ); - logContext.bot_message_id = botMessage.id; - if (msg) { - await span( - "message_cache.save", - { - ...spanAttributes, - "discord.source_message_id": msg.id, - "discord.bot_message_id": botMessage.id, - }, - async () => { - await container.messageCache.save(msg.id, botMessage.id, msg.author.id); - }, - ); - } - botEmbedsCreated.add(1, metricContext); - sentEmbed = true; } catch (error) { recordError(requestSpan, error); const problem = createProblem(EmbedlyErrors.DiscordSendFailed, { @@ -348,6 +388,37 @@ export async function handleUrls( return; } await interaction!.editReply(formatDiscordError(problem)); + return; + } + + logContext.bot_message_id = botMessage.id; + botEmbedsCreated.add(1, metricContext); + sentEmbed = true; + + if (msg) { + try { + await span( + "message_cache.save", + { + ...spanAttributes, + "discord.source_message_id": msg.id, + "discord.bot_message_id": botMessage.id, + }, + async () => { + await container.messageCache.save(msg.id, botMessage.id, msg.author.id); + }, + ); + } catch (error) { + botErrors.add(1, { + ...metricContext, + error_type: EmbedlyErrors.MessageCacheFailed.type, + }); + log("warn", EmbedlyErrors.MessageCacheFailed, { + ...logContext, + error_type: EmbedlyErrors.MessageCacheFailed.type, + ...getErrorContext(error), + }); + } } } finally { Object.assign(logContext, getActiveTraceContext(), { diff --git a/apps/bot/src/lib/messageCache.ts b/apps/bot/src/lib/messageCache.ts index 654bc56..9cd5faa 100644 --- a/apps/bot/src/lib/messageCache.ts +++ b/apps/bot/src/lib/messageCache.ts @@ -1,5 +1,5 @@ import { EmbedlyErrors, formatLog, getErrorContext } from "@embedly/logging"; -import { createClient, type RedisClientType } from "redis"; +import { createClient } from "redis"; const MESSAGE_CACHE_TTL_SECONDS = Number(process.env.MESSAGE_CACHE_TTL_SECONDS ?? 60 * 60 * 24); const CACHE_URL = process.env.CACHE_URL ?? "redis://localhost:6379"; @@ -9,7 +9,7 @@ interface SourceMessageCache { } export class MessageCache { - private constructor(private readonly client: RedisClientType) {} + private constructor(private readonly client: ReturnType) {} public static async connect() { const client = createClient({ url: CACHE_URL }); @@ -21,7 +21,7 @@ export class MessageCache { ); }); await client.connect(); - return new MessageCache(client as RedisClientType); + return new MessageCache(client); } public async save(sourceMessageId: string, botMessageId: string, authorId: string) { @@ -83,18 +83,6 @@ export class MessageCache { return sourceMessage?.botMessageIds ?? []; } - public async deleteSourceMessage(sourceMessageId: string) { - const botMessageIds = await this.getBotMessageIds(sourceMessageId); - const keys = [ - this.getSourceMessageKey(sourceMessageId), - ...botMessageIds.map((id) => this.getBotMessageAuthorKey(id)), - ...botMessageIds.map((id) => this.getBotMessageSourceKey(id)), - ]; - - await this.client.del(keys); - return botMessageIds; - } - public async close() { await this.client.close(); } @@ -102,7 +90,19 @@ export class MessageCache { private async getSourceMessage(sourceMessageId: string): Promise { const raw = await this.client.get(this.getSourceMessageKey(sourceMessageId)); if (!raw) return null; - return JSON.parse(raw) as SourceMessageCache; + const parsed: unknown = JSON.parse(raw); + /* oxlint-disable anti-slop/no-runtime-typeof -- External cache data needs runtime validation. */ + if ( + !parsed || + typeof parsed !== "object" || + !("botMessageIds" in parsed) || + !Array.isArray(parsed.botMessageIds) || + !parsed.botMessageIds.every((id) => typeof id === "string") + ) { + throw new Error("Invalid source message cache entry"); + } + /* oxlint-enable anti-slop/no-runtime-typeof */ + return { botMessageIds: parsed.botMessageIds }; } private getSourceMessageKey(messageId: string) { diff --git a/apps/bot/src/lib/observability.ts b/apps/bot/src/lib/observability.ts index f267038..7bf454a 100644 --- a/apps/bot/src/lib/observability.ts +++ b/apps/bot/src/lib/observability.ts @@ -62,6 +62,7 @@ export async function span( }); } +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- Thrown values have no contract. */ export function recordError(activeSpan: Span, error: unknown) { if (error instanceof Error) { activeSpan.recordException(error); @@ -79,6 +80,7 @@ export function recordError(activeSpan: Span, error: unknown) { message: String(error), }); } +/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof */ export function markError(activeSpan: Span, message: string, attributes?: Attributes) { if (attributes) activeSpan.setAttributes(attributes); @@ -125,6 +127,7 @@ function getLogAttributes(logContext?: LogContext) { if (!logContext) return attributes; for (const [key, value] of Object.entries(logContext)) { + /* oxlint-disable-next-line anti-slop/no-runtime-typeof -- Log values need conversion at the telemetry boundary. */ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { attributes[key] = value; } diff --git a/apps/bot/src/lib/utils.ts b/apps/bot/src/lib/utils.ts index a3eeaf1..33dfa93 100644 --- a/apps/bot/src/lib/utils.ts +++ b/apps/bot/src/lib/utils.ts @@ -18,14 +18,6 @@ export function extractURLs(content: string): URLMatch[] { }); } -export function isSpoiler(url: string, content: string): boolean { - return content.split("||").some((part, ind) => ind % 2 === 1 && part.includes(url)); -} - -export function isEscaped(url: string, content: string): boolean { - return content.includes(`<${url}>`); -} - export function truncate(text: string, maxLength: number) { return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; } diff --git a/apps/bot/src/listeners/messageCreate.ts b/apps/bot/src/listeners/messageCreate.ts index af3ff94..6252ac0 100644 --- a/apps/bot/src/listeners/messageCreate.ts +++ b/apps/bot/src/listeners/messageCreate.ts @@ -3,7 +3,7 @@ import { MessageFlags, type Message } from "discord.js"; import type { EmbedFlags } from "../lib/builder"; import { handleUrls, type EmbedURLRequest } from "../lib/handleUrls"; -import { extractURLs, isSpoiler } from "../lib/utils"; +import { extractURLs } from "../lib/utils"; function parseMessageURLs(content: string) { const urls: EmbedURLRequest[] = []; @@ -21,7 +21,7 @@ function parseMessageURLs(content: string) { } const flags: Partial = { - Spoiler: isSpoiler(match.url, content), + Spoiler: content.slice(0, match.index).split("||").length % 2 === 0, }; let force = false; diff --git a/apps/bot/src/listeners/messageDelete.ts b/apps/bot/src/listeners/messageDelete.ts index 1c8c943..6962f4d 100644 --- a/apps/bot/src/listeners/messageDelete.ts +++ b/apps/bot/src/listeners/messageDelete.ts @@ -1,6 +1,6 @@ import { EmbedlyErrors, EmbedlyLogs, formatLog, getErrorContext } from "@embedly/logging"; import { Events, Listener } from "@sapphire/framework"; -import type { Message, PartialMessage } from "discord.js"; +import { DiscordAPIError, RESTJSONErrorCodes, type Message, type PartialMessage } from "discord.js"; export class MessageDeleteListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { @@ -11,17 +11,51 @@ export class MessageDeleteListener extends Listener } public async run(message: Message | PartialMessage) { - const botMessageIds = await this.container.messageCache.deleteSourceMessage(message.id); + const requestId = `message:${message.id}`; + let botMessageIds: string[]; + try { + botMessageIds = await this.container.messageCache.getBotMessageIds(message.id); + } catch (error) { + this.container.logger.warn( + formatLog("warn", EmbedlyErrors.MessageCacheFailed, { + request_id: requestId, + message_id: message.id, + ...getErrorContext(error), + }), + ); + return; + } + if (botMessageIds.length === 0) return; + let deletedCount = 0; for (const botMessageId of botMessageIds) { try { const botMessage = await message.channel.messages.fetch(botMessageId); await botMessage.delete(); + deletedCount++; + } catch (error) { + if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownMessage) { + deletedCount++; + } else { + this.container.logger.warn( + formatLog("warn", EmbedlyErrors.DeleteFailed, { + request_id: requestId, + message_id: message.id, + bot_message_id: botMessageId, + ...getErrorContext(error), + }), + ); + continue; + } + } + + try { + await this.container.messageCache.removeBotMessage(botMessageId); } catch (error) { this.container.logger.warn( - formatLog("warn", EmbedlyErrors.DeleteFailed, { - request_id: `message:${message.id}`, + formatLog("warn", EmbedlyErrors.MessageCacheFailed, { + request_id: requestId, message_id: message.id, bot_message_id: botMessageId, ...getErrorContext(error), @@ -30,12 +64,19 @@ export class MessageDeleteListener extends Listener } } - this.container.logger.info( - formatLog("info", EmbedlyLogs.AutoDeleteSucceeded, { - request_id: `message:${message.id}`, - message_id: message.id, - bot_message_count: botMessageIds.length, - }), - ); + const failedCount = botMessageIds.length - deletedCount; + const context = { + request_id: requestId, + message_id: message.id, + bot_message_count: botMessageIds.length, + deleted_count: deletedCount, + failed_count: failedCount, + }; + if (failedCount > 0) { + this.container.logger.warn(formatLog("warn", EmbedlyErrors.DeleteFailed, context)); + return; + } + + this.container.logger.info(formatLog("info", EmbedlyLogs.AutoDeleteSucceeded, context)); } } diff --git a/packages/config/oxfmt.config.ts b/packages/config/oxfmt.config.ts index 71274e4..77aedc4 100644 --- a/packages/config/oxfmt.config.ts +++ b/packages/config/oxfmt.config.ts @@ -2,5 +2,5 @@ import { defineConfig } from "oxfmt"; export default defineConfig({ sortImports: true, - ignorePatterns: ["dist/**", "types/**"], + ignorePatterns: ["dist/**", "oxlint/anti-slop/**", "types/**"], }); diff --git a/packages/config/oxlint.config.ts b/packages/config/oxlint.config.ts index d33a6d6..500ef2a 100644 --- a/packages/config/oxlint.config.ts +++ b/packages/config/oxlint.config.ts @@ -1,9 +1,44 @@ import { defineConfig } from "oxlint"; export default defineConfig({ + options: { + reportUnusedDisableDirectives: "error", + }, plugins: ["typescript", "unicorn", "oxc", "import"], + jsPlugins: [{ name: "anti-slop", specifier: "@embedly/config/anti-slop" }], rules: { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", "no-debugger": "error", }, - ignorePatterns: ["dist/**", "types/**"], + ignorePatterns: [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "dist/**", + "oxlint/anti-slop/**", + "types/**", + "worker-configuration.d.ts", + ], }); diff --git a/packages/config/oxlint/anti-slop/index.ts b/packages/config/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..2b4ae22 --- /dev/null +++ b/packages/config/oxlint/anti-slop/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/packages/config/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/packages/config/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..0d11852 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/packages/config/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..ae7248d --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-known-value-widening.ts b/packages/config/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..2a6806c --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-module-mocking.ts b/packages/config/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 0000000..d6fb5b4 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-object-parameters.ts b/packages/config/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000..29b990f --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,126 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToObject(member, shadowedAliases, visited), + ); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames( + node, + context.sourceCode.visitorKeys, + ); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-reflect-apply.ts b/packages/config/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 0000000..2cc3045 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-reflect-get.ts b/packages/config/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..cf630ec --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-runtime-typeof.ts b/packages/config/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..6a25c24 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,67 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/packages/config/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..afc00dd --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-unknown-parameters.ts b/packages/config/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..cdc6c23 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === "TSParameterProperty") { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === "AssignmentPattern") { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === "RestElement") { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === "Identifier" + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === "cause") continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-unknown-returns.ts b/packages/config/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000..4b16d6e --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,115 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); + } + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToUnknown(member, shadowedAliases, visited), + ); + } + if ( + type.type === "TSTypeReference" && + type.typeName.type === "Identifier" && + (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") + ) { + const value = type.typeArguments?.params[0]; + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); + } + const name = referencedAliasName(type); + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); + }; + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + return; + } + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/packages/config/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..3e328fd --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/packages/config/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..8c45eed --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/no-widen-then-assert.ts b/packages/config/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..c5e07f7 --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/packages/config/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..f1a2ffc --- /dev/null +++ b/packages/config/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node; + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/packages/config/oxlint/anti-slop/shared/dictionary-types.ts b/packages/config/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..8651700 --- /dev/null +++ b/packages/config/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,502 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: "generic container" } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => + isBroadMappedKey(member, environment, substitutions), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, environment)) { + return { kind: "open dictionary" }; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/packages/config/oxlint/anti-slop/shared/lexical-type-parameters.ts b/packages/config/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..7cdb18c --- /dev/null +++ b/packages/config/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/packages/config/oxlint/anti-slop/shared/reflect-method.ts b/packages/config/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000..39bc218 --- /dev/null +++ b/packages/config/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/packages/config/package.json b/packages/config/package.json index 439db2d..e8cf530 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -6,9 +6,11 @@ "exports": { "./tsconfig.json": "./tsconfig.json", "./oxlint.config.ts": "./oxlint.config.ts", - "./oxfmt.config.ts": "./oxfmt.config.ts" + "./oxfmt.config.ts": "./oxfmt.config.ts", + "./anti-slop": "./oxlint/anti-slop/index.ts" }, "devDependencies": { + "@oxlint/plugins": "1.78.0", "oxfmt": "catalog:", "oxlint": "catalog:" } diff --git a/packages/logging/src/main.ts b/packages/logging/src/main.ts index 428dd23..c9042a6 100644 --- a/packages/logging/src/main.ts +++ b/packages/logging/src/main.ts @@ -1,6 +1,14 @@ export type LogLevel = "debug" | "info" | "warn" | "error"; -export type LogContext = Record; +export type LogValue = string | number | boolean | null | undefined; +export type LogContext = Record; + +export interface ErrorContext { + error_name?: string; + error_message?: string; + upstream_status?: LogValue; + upstream_message?: LogValue; +} export interface EmbedlyEvent { type: string; @@ -168,16 +176,18 @@ export function createProblem( }, ): EmbedlyProblem { const safeContext = getSafeContext(context); - return { + const problem: EmbedlyProblem = { type: event.type, title: event.title, detail: detail ?? event.detail, status: status ?? event.status, request_id, - ...(Object.keys(safeContext).length > 0 ? { context: safeContext } : {}), }; + if (Object.keys(safeContext).length > 0) problem.context = safeContext; + return problem; } +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- External response validation requires runtime checks. */ export function isEmbedlyProblem(value: unknown): value is EmbedlyProblem { if (!value || typeof value !== "object") return false; return ( @@ -193,6 +203,7 @@ export function isEmbedlyProblem(value: unknown): value is EmbedlyProblem { typeof value.request_id === "string" ); } +/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof */ export function formatDiscordError(problem: EmbedlyProblem) { return `**__${problem.title}__**\n${problem.detail}\n\n-# ${problem.type} - ${problem.request_id}`; @@ -225,7 +236,8 @@ export function formatProblemLog(level: LogLevel, problem: EmbedlyProblem) { ); } -export function getErrorContext(error: unknown): LogContext { +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- Thrown values have no contract. */ +export function getErrorContext(error: unknown): ErrorContext { if (error instanceof Error) { return { error_name: error.name, @@ -234,10 +246,10 @@ export function getErrorContext(error: unknown): LogContext { } if (error && typeof error === "object") { - const context: LogContext = {}; - if ("code" in error) context.upstream_status = error.code; - if ("status" in error) context.upstream_status = error.status; - if ("message" in error) context.upstream_message = error.message; + const context: ErrorContext = {}; + if ("code" in error) context.upstream_status = toLogValue(error.code); + if ("status" in error) context.upstream_status = toLogValue(error.status); + if ("message" in error) context.upstream_message = toLogValue(error.message); return context; } @@ -245,6 +257,7 @@ export function getErrorContext(error: unknown): LogContext { error_message: String(error), }; } +/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof */ export function getRequestId(request: Request) { return request.headers.get("X-Embedly-Request-Id") ?? `request:${crypto.randomUUID()}`; @@ -256,16 +269,25 @@ function getSafeContext(context?: LogContext) { for (const [key, value] of Object.entries(context)) { if (value === undefined || value === null) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - safeContext[key] = value; - } + safeContext[key] = value; } return safeContext; } function formatValue(value: string | number | boolean) { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Non-string log values need serialization. if (typeof value !== "string") return String(value); if (/^[A-Za-z0-9._:@/-]+$/.test(value)) return value; return JSON.stringify(value); } + +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- External error properties need runtime normalization. */ +function toLogValue(value: unknown): LogValue { + if (value === undefined || value === null) return value; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + return String(value); +} +/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof */ diff --git a/packages/platforms/src/platforms/bluesky.ts b/packages/platforms/src/platforms/bluesky.ts index 917ad38..3a29c7f 100644 --- a/packages/platforms/src/platforms/bluesky.ts +++ b/packages/platforms/src/platforms/bluesky.ts @@ -124,6 +124,7 @@ async function parseMedia(post: BlueskyPost, record: AppBskyFeedPost.Record) { const resolved: unknown = await didResp.json(); let pds: string | undefined; + /* oxlint-disable anti-slop/no-runtime-typeof -- External DID documents need runtime validation. */ if ( typeof resolved === "object" && resolved !== null && @@ -143,6 +144,7 @@ async function parseMedia(post: BlueskyPost, record: AppBskyFeedPost.Record) { } } } + /* oxlint-enable anti-slop/no-runtime-typeof */ if (!pds) { throw { diff --git a/packages/platforms/src/platforms/instagram.d.ts b/packages/platforms/src/platforms/instagram.d.ts index b1a590a..9bfb656 100644 --- a/packages/platforms/src/platforms/instagram.d.ts +++ b/packages/platforms/src/platforms/instagram.d.ts @@ -1,136 +1,27 @@ -interface IGDisplayResource { - src: string; - config_width: number; - config_height: number; -} - -interface IGOwner { - id: string; - username: string; - is_verified: boolean; - profile_pic_url: string; - blocked_by_viewer: boolean; - restricted_by_viewer: boolean | null; - followed_by_viewer: boolean; - full_name: string; - has_blocked_viewer: boolean; - is_embeds_disabled: boolean; - is_private: boolean; - is_unpublished: boolean; - requested_by_viewer: boolean; - pass_tiering_recommendation: boolean; - edge_owner_to_timeline_media: { count: number }; - edge_followed_by: { count: number }; -} - -interface IGUser { - id: string; - is_verified: boolean; - profile_pic_url: string; - username: string; -} - -interface IGPageInfo { - has_next_page: boolean; - end_cursor: string; -} - -interface IGEdgeCount { - count: number; - edges: unknown[]; -} - -interface IGEdgeCountPaged { - count: number; - page_info: IGPageInfo; - edges: unknown[]; -} - -interface IGMediaBase { - id: string; - shortcode: string; - thumbnail_src: string; - dimensions: { height: number; width: number }; - gating_info: null; - fact_check_overall_rating: null; - fact_check_information: null; - sensitivity_friction_info: null; - sharing_friction_info: { should_have_sharing_friction: boolean; bloks_app_url: string | null }; - media_overlay_info: null; - media_preview: string | null; - display_url: string; - display_resources: IGDisplayResource[]; - accessibility_caption: string | null; - tracking_token: string; - upcoming_event: null; - edge_media_to_tagged_user: { edges: unknown[] }; - owner: IGOwner; - edge_media_to_caption: { edges: Array<{ node: { text: string } }> }; - can_see_insights_as_brand: boolean; - caption_is_edited: boolean; - has_ranked_comments: boolean; - like_and_view_counts_disabled: boolean; - edge_media_to_comment: IGEdgeCountPaged; - comments_disabled: boolean; - commenting_disabled_for_viewer: boolean; - taken_at_timestamp: number; - edge_media_preview_like: IGEdgeCount; - edge_media_to_sponsor_user: { edges: unknown[] }; - is_affiliate: boolean; - is_paid_partnership: boolean; - location: null; - nft_asset_info: null; - viewer_has_liked: boolean; - viewer_has_saved: boolean; - viewer_has_saved_to_collection: boolean; - viewer_in_photo_of_you: boolean; - viewer_can_reshare: boolean; - is_ad: boolean; - edge_web_media_to_related_media: { edges: unknown[] }; - coauthor_producers: IGUser[]; - pinned_for_users: IGUser[]; - edge_related_profiles: { edges: unknown[] }; -} - -export interface XDTGraphImage extends IGMediaBase { - __typename: "XDTGraphImage"; - __isXDTGraphMediaInterface: "XDTGraphImage"; - is_video: false; -} - -export interface XDTGraphSidecar extends IGMediaBase { - __typename: "XDTGraphSidecar"; - __isXDTGraphMediaInterface: "XDTGraphSidecar"; - is_video: false; - edge_sidecar_to_children: { edges: Array<{ node: XDTGraphImage | XDTGraphVideo }> }; -} - -export interface XDTGraphVideo extends IGMediaBase { - __typename: "XDTGraphVideo"; - __isXDTGraphMediaInterface: "XDTGraphVideo"; - is_video: true; - has_audio: boolean; - video_url: string; - video_view_count: number; - video_play_count: number; - video_duration: number; - encoding_status: null; - is_published: boolean; - product_type: string; - title: string; - dash_info: { - is_dash_eligible: boolean; - video_dash_manifest: string; - number_of_qualities: number; +export interface InstagramImageCandidate { + url: string; +} + +export interface InstagramMedia { + __typename?: string; + code: string; + taken_at: number; + caption?: { text?: string } | null; + user: { + username: string; + full_name?: string; + profile_pic_url: string; }; - clips_music_attribution_info: { - artist_name: string; - song_name: string; - uses_original_audio: boolean; - should_mute_audio: boolean; - should_mute_audio_reason: string; - audio_id: string; - } | null; + like_count?: number; + comment_count?: number; + product_type?: string; + play_count?: number; + video_play_count?: number; + view_count?: number; + accessibility_caption?: string; + image_versions2?: { + candidates?: InstagramImageCandidate[]; + }; + video_versions?: Array<{ url: string }>; + carousel_media?: InstagramMedia[]; } - -export type IGMedia = XDTGraphImage | XDTGraphSidecar | XDTGraphVideo; diff --git a/packages/platforms/src/platforms/instagram.ts b/packages/platforms/src/platforms/instagram.ts index c8a0075..8f37eea 100644 --- a/packages/platforms/src/platforms/instagram.ts +++ b/packages/platforms/src/platforms/instagram.ts @@ -1,40 +1,13 @@ import * as cheerio from "cheerio"; import { NormalizedPost, Platform } from "../types"; +import type { InstagramMedia } from "./instagram.d"; const MATCH_RE = /^(?:https?:\/\/)?(?:[\w-]+\.)*instagram\.com\/(?:[A-Za-z0-9_.]+\/)?(?p|share|reels|reel)\/(?[A-Za-z0-9-_]+)/; const PRELOADER_PREFIX = "adp_PolarisLoggedOutDesktopWWWPostRootContentQueryRelayPreloader_"; -interface InstagramImageCandidate { - url: string; -} - -interface InstagramMedia { - __typename?: string; - code: string; - taken_at: number; - caption?: { text?: string } | null; - user: { - username: string; - full_name?: string; - profile_pic_url: string; - }; - like_count?: number; - comment_count?: number; - product_type?: string; - play_count?: number; - video_play_count?: number; - view_count?: number; - accessibility_caption?: string; - image_versions2?: { - candidates?: InstagramImageCandidate[]; - }; - video_versions?: Array<{ url: string }>; - carousel_media?: InstagramMedia[]; -} - function normalizeType(type: string) { if (type === "reels") return "reel"; return type; @@ -75,6 +48,7 @@ function parseRelayMedia(script: string) { item[0] === "RelayPrefetchedStreamCache" && item[3]?.[0]?.startsWith(PRELOADER_PREFIX), ); + // SAFETY: Instagram relay media is checked against live image, reel, and carousel samples. return relayRequire?.[3]?.[1]?.__bbox?.result?.data?.xig_polaris_media ?.if_not_gated_logged_out as InstagramMedia | undefined; } diff --git a/packages/platforms/src/platforms/instagram_old.ts b/packages/platforms/src/platforms/instagram_old.ts deleted file mode 100644 index 2c101e3..0000000 --- a/packages/platforms/src/platforms/instagram_old.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Platform } from "../types"; -import type { IGMedia } from "./instagram.d"; - -const MATCH_RE = - /^(?:https?:\/\/)?(?:[\w-]+\.)*instagram\.com\/(?:[A-Za-z0-9_.]+\/)?(p|share|reels|reel)\/(?[A-Za-z0-9-_]+)/; - -export const Instagram: Platform<"Instagram", IGMedia, {}> = { - type: "Instagram", - async match(url) { - if (url.includes("share")) { - const req = await fetch(url.endsWith("/") ? url : `${url}/`, { - redirect: "follow", - headers: { - "User-Agent": "curl/8.7.1", - }, - }); - url = req.url; - } - - const groups = url.match(MATCH_RE)?.groups; - return groups?.ig_shortcode ?? null; - }, - async fetch(id, env) { - const graphql = new URL(`https://www.instagram.com/api/graphql`); - graphql.searchParams.set("variables", JSON.stringify({ shortcode: id })); - graphql.searchParams.set("doc_id", "10015901848480474"); - graphql.searchParams.set("lsd", "AVqbxe3J_YA"); - - const resp = await fetch(graphql.toString(), { - method: "POST", - headers: { - "User-Agent": env?.EMBED_USER_AGENT ?? "", - "Content-Type": "application/x-www-form-urlencoded", - "X-IG-App-ID": "936619743392459", - "X-FB-LSD": "AVqbxe3J_YA", - "X-ASBD-ID": "129477", - "Sec-Fetch-Site": "same-origin", - }, - }); - - if (!resp.ok) { - throw { code: resp.status, message: resp.statusText }; - } - - const { data } = (await resp.json()) as { data?: { xdt_shortcode_media?: IGMedia } }; - const media = data?.xdt_shortcode_media; - - if (!media) { - throw { - code: 500, - message: "Instagram API returned unexpected structure", - }; - } - - return media; - }, - async transform(raw) { - const caption = raw.edge_media_to_caption.edges[0]?.node.text; - const authorName = raw.owner.full_name || raw.owner.username; - - let media: Array<{ url: string; type: string }>; - let views: number | undefined; - - if (raw.__typename === "XDTGraphVideo") { - media = [{ url: raw.video_url, type: "video" }]; - views = raw.video_play_count; - } else if (raw.__typename === "XDTGraphSidecar") { - media = raw.edge_sidecar_to_children.edges.map(({ node }) => - node.__typename === "XDTGraphVideo" - ? { url: node.video_url, type: "video" } - : { url: node.display_url, type: "photo" }, - ); - } else { - media = [{ url: raw.display_url, type: "photo" }]; - } - - return { - platform: this.type, - author: { - name: authorName, - handle: raw.owner.username, - url: `https://www.instagram.com/${raw.owner.username}/`, - avatar: raw.owner.profile_pic_url, - }, - url: `https://www.instagram.com/p/${raw.shortcode}/`, - text: caption, - timestamp: raw.taken_at_timestamp, - stats: { - likes: raw.edge_media_preview_like.count, - comments: raw.edge_media_to_comment.count, - views, - }, - media, - }; - }, -} as const; diff --git a/packages/platforms/src/platforms/reddit.d.ts b/packages/platforms/src/platforms/reddit.d.ts new file mode 100644 index 0000000..8ff2117 --- /dev/null +++ b/packages/platforms/src/platforms/reddit.d.ts @@ -0,0 +1,46 @@ +export interface RedditAccessTokenResponse { + access_token?: string; +} + +export interface RedditMediaMetadata { + s: { u: string }; +} + +export interface RedditPostData { + author: string; + subreddit_name_prefixed: string; + created_utc: number; + permalink: string; + title: string; + selftext: string; + num_comments: number; + ups: number; + domain?: string; + url_overridden_by_dest?: string; + media_metadata?: Record; + preview?: { + enabled: boolean; + images: Array<{ source: { url: string } }>; + }; + media?: { + reddit_video?: { fallback_url: string }; + }; +} + +export interface RedditProfile { + icon_img: string; +} + +export interface RedditPost extends RedditPostData { + profile: RedditProfile; +} + +export interface RedditListing { + data?: { + children?: Array<{ data?: RedditPostData }>; + }; +} + +export interface RedditProfileResponse { + data?: RedditProfile; +} diff --git a/packages/platforms/src/platforms/reddit.ts b/packages/platforms/src/platforms/reddit.ts index 8f8525b..d869b2b 100644 --- a/packages/platforms/src/platforms/reddit.ts +++ b/packages/platforms/src/platforms/reddit.ts @@ -1,4 +1,11 @@ import { NormalizedPost, Platform } from "../types"; +import type { + RedditAccessTokenResponse, + RedditListing, + RedditPost, + RedditPostData, + RedditProfileResponse, +} from "./reddit.d"; const MATCH_RE = /^(?:https?:\/\/)?(?:www\.|old\.)?(?:reddit\.com\/r\/[A-Za-z0-9_]+\/(?:comments\/[A-Za-z0-9]+(?:\/[^/\s]+)?|s\/[A-Za-z0-9]+)|redd\.it\/[A-Za-z0-9]+)\/?/; @@ -31,8 +38,12 @@ async function fetchAccessToken(env: { throw { code: resp.status, message: resp.statusText }; } - const data = (await resp.json()) as Record; - return data.access_token as string; + // SAFETY: response uses Reddit's OAuth token contract. + const data = (await resp.json()) as RedditAccessTokenResponse; + if (!data.access_token) { + throw { code: 500, message: "Reddit OAuth response missing access token" }; + } + return data.access_token; } async function fetchReddit( @@ -49,8 +60,8 @@ async function fetchReddit( }); } -function parseMedia(raw: Record): NormalizedPost["media"] { - if (raw.domain === "i.redd.it") { +function parseMedia(raw: RedditPostData): NormalizedPost["media"] { + if (raw.domain === "i.redd.it" && raw.url_overridden_by_dest) { return [ { url: raw.url_overridden_by_dest, @@ -60,14 +71,14 @@ function parseMedia(raw: Record): NormalizedPost["media"] { } if (raw.media_metadata) { - return Object.values(raw.media_metadata).map((media: any) => ({ + return Object.values(raw.media_metadata).map((media) => ({ url: media.s.u, type: "unknown", })); } if (raw.preview?.enabled) { - return raw.preview.images.map((media: any) => ({ + return raw.preview.images.map((media) => ({ url: media.source.url, type: "unknown", })); @@ -85,7 +96,7 @@ function parseMedia(raw: Record): NormalizedPost["media"] { return []; } -export const Reddit: Platform<"Reddit", Record, {}> = { +export const Reddit: Platform<"Reddit", RedditPost, {}> = { type: "Reddit", async match(url, env) { const match = url.match(MATCH_RE); @@ -126,7 +137,8 @@ export const Reddit: Platform<"Reddit", Record, {}> = { throw { code: postResp.status, message: postResp.statusText }; } - const postData = (await postResp.json()) as Record; + // SAFETY: response uses Reddit's listing contract. + const postData = (await postResp.json()) as RedditListing[]; const postDataItem = postData?.[0]?.data?.children?.[0]?.data; if (!postDataItem) { throw { @@ -148,7 +160,8 @@ export const Reddit: Platform<"Reddit", Record, {}> = { message: profileResp.statusText, }; } - const { data: profileData } = (await profileResp.json()) as Record; + // SAFETY: response uses Reddit's profile contract. + const { data: profileData } = (await profileResp.json()) as RedditProfileResponse; if (!profileData) { throw { code: 500, diff --git a/packages/platforms/src/platforms/threads.d.ts b/packages/platforms/src/platforms/threads.d.ts new file mode 100644 index 0000000..059271f --- /dev/null +++ b/packages/platforms/src/platforms/threads.d.ts @@ -0,0 +1,37 @@ +export interface ThreadsPost { + carousel_media?: ThreadsPost[]; + video_versions?: Array<{ url: string }>; + image_versions2?: { + candidates?: Array<{ url: string }>; + }; + accessibility_caption?: string; + user: { + full_name: string; + profile_pic_url: string; + username: string; + }; + caption: { text: string }; + text_post_app_info: { + direct_reply_count: number; + reshare_count: number | null; + }; + like_count: number; + code: string; + taken_at: number; + media_overlay_info?: { + buttons?: Array<{ text: string }>; + }; +} + +export interface ThreadsResponse { + status?: string; + data?: { + data?: { + edges?: Array<{ + node?: { + thread_items?: Array<{ post?: ThreadsPost }>; + }; + }>; + }; + }; +} diff --git a/packages/platforms/src/platforms/threads.ts b/packages/platforms/src/platforms/threads.ts index 65d441f..d1f4679 100644 --- a/packages/platforms/src/platforms/threads.ts +++ b/packages/platforms/src/platforms/threads.ts @@ -1,32 +1,31 @@ import * as cheerio from "cheerio"; import { NormalizedPost, Platform } from "../types"; +import type { ThreadsPost, ThreadsResponse } from "./threads.d"; const MATCH_RE = /^(?:https?:\/\/)?(?:[\w-]+\.)*threads\.com\/@.*\/post\/(?[A-Za-z0-9-_]+)/; const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; -function parseMedia(raw: Record): NormalizedPost["media"] { +function parseMedia(raw: ThreadsPost): NormalizedPost["media"] { if (raw.carousel_media) { - return raw.carousel_media.map((media: any) => ({ - url: media.image_versions2.candidates[0].url, - type: "photo", - description: media.accessibility_caption, - })); + return raw.carousel_media.flatMap(parseMedia); } - if (raw.video_versions) { + const video = raw.video_versions?.[0]; + if (video) { return [ { - url: raw.video_versions[0].url, + url: video.url, type: "video", }, ]; } - if (raw.image_versions2?.candidates?.length > 0) { + const image = raw.image_versions2?.candidates?.[0]; + if (image) { return [ { - url: raw.image_versions2.candidates[0].url, + url: image.url, type: "photo", description: raw.accessibility_caption, }, @@ -37,7 +36,7 @@ function parseMedia(raw: Record): NormalizedPost["media"] { export const Threads: Platform< "Threads", - Record[], + ThreadsPost[], { community_note?: string; } @@ -154,13 +153,16 @@ export const Threads: Platform< throw { code: resp.status, message: resp.statusText }; } - const { data, status } = (await resp.json()) as { data?: any; status?: string }; + // SAFETY: Threads payload is checked against live image, video, and carousel samples. + const { data, status } = (await resp.json()) as ThreadsResponse; if (status !== "ok") { throw { code: 500, message: "Threads API errored" }; } - const posts = data?.data?.edges?.[0].node?.thread_items?.map((item: any) => item?.post); + const posts = data?.data?.edges?.[0]?.node?.thread_items + ?.map((item) => item.post) + .filter((post) => post !== undefined); if (!posts) { throw { @@ -173,7 +175,9 @@ export const Threads: Platform< }, async transform(raws, options) { const depth = options?.depth ?? 0; - const raw = raws.at(-1)!; + const raw = raws.at(-1); + if (!raw) throw new Error("Threads transform requires at least one post"); + const parent = raws.at(-2); return { platform: this.type, author: { @@ -187,12 +191,10 @@ export const Threads: Platform< stats: { comments: raw.text_post_app_info.direct_reply_count, likes: raw.like_count, - reposts: raw.text_post_app_info.reshare_count, + reposts: raw.text_post_app_info.reshare_count ?? undefined, }, reply_to: - depth < 1 && raws.length > 1 - ? await this.transform([raws.at(-2)!], { depth: depth + 1 }) - : undefined, + depth < 1 && parent ? await this.transform([parent], { depth: depth + 1 }) : undefined, url: `https://threads.net/@${raw.user.username}/post/${raw.code}`, timestamp: raw.taken_at, community_note: raw.media_overlay_info?.buttons?.[0].text, diff --git a/packages/platforms/src/platforms/tiktok.d.ts b/packages/platforms/src/platforms/tiktok.d.ts new file mode 100644 index 0000000..bbbb662 --- /dev/null +++ b/packages/platforms/src/platforms/tiktok.d.ts @@ -0,0 +1,35 @@ +export interface TikTokImage { + display_image?: { + url_list?: string[]; + }; +} + +export interface TikTokItem { + id_str: string; + author_info: { + unique_id: string; + nickname: string; + avatar_url_list: string[]; + }; + image_post_info?: { + images: TikTokImage[]; + }; + video_info?: { + url_list?: string[]; + }; + create_time?: number | null; + desc: string; + statistics_info: { + comment_count: number; + share_count: number; + digg_count: number; + }; +} + +export interface TikTokPlayerResponse { + items?: TikTokItem[] | null; + results?: Array<{ + id_str: string; + code?: string | number; + }>; +} diff --git a/packages/platforms/src/platforms/tiktok.ts b/packages/platforms/src/platforms/tiktok.ts index d1df5f3..3241467 100644 --- a/packages/platforms/src/platforms/tiktok.ts +++ b/packages/platforms/src/platforms/tiktok.ts @@ -1,16 +1,17 @@ import { NormalizedPost, Platform } from "../types"; +import type { TikTokItem, TikTokPlayerResponse } from "./tiktok.d"; const MATCH_RE = /^(?:https?:\/\/)?(?:[\w-]+\.)*tiktok\.com(?:\/|$)/; const FOLLOWUP_RE = /^https:\/\/(?:m|www|vm)?\.?tiktok\.com\/(?@(?:[\w.-]+)?)\/(?video|photo)\/(?\d+)/; -function parseMedia(raw: Record): NormalizedPost["media"] { +function parseMedia(raw: TikTokItem): NormalizedPost["media"] { if (raw.image_post_info) { - return raw.image_post_info.images - .map((image: any) => image.display_image?.url_list?.[0]) - .filter((url: unknown): url is string => typeof url === "string") - .map((url: string) => ({ url, type: "image" })); + return raw.image_post_info.images.flatMap((image) => { + const url = image.display_image?.url_list?.[0]; + return url ? [{ url, type: "image" }] : []; + }); } const urls = raw.video_info?.url_list ?? []; @@ -18,7 +19,7 @@ function parseMedia(raw: Record): NormalizedPost["media"] { return videoURL ? [{ url: videoURL, type: "video" }] : []; } -export const TikTok: Platform<"TikTok", Record, {}> = { +export const TikTok: Platform<"TikTok", TikTokItem, {}> = { type: "TikTok", async match(url, env) { const match = url.match(MATCH_RE); @@ -54,10 +55,11 @@ export const TikTok: Platform<"TikTok", Record, {}> = { throw { code: resp.status, message: resp.statusText }; } - const data: any = await resp.json(); - const item = data?.items?.find((item: any) => item.id_str === tiktok_id); + // SAFETY: TikTok player payload is checked against live video, photo, and error responses. + const data = (await resp.json()) as TikTokPlayerResponse; + const item = data.items?.find((item) => item.id_str === tiktok_id); if (!item) { - const result = data?.results?.find((result: any) => result.id_str === tiktok_id); + const result = data.results?.find((result) => result.id_str === tiktok_id); throw { code: 500, message: result?.code ?? "TikTok player API returned no item data", diff --git a/packages/platforms/src/platforms/twitter.d.ts b/packages/platforms/src/platforms/twitter.d.ts index 7cef150..8081d6c 100644 --- a/packages/platforms/src/platforms/twitter.d.ts +++ b/packages/platforms/src/platforms/twitter.d.ts @@ -149,6 +149,7 @@ export interface Video { filesize?: number; formats: VideoFormat[]; publisher?: Profile; + altText?: string; } export interface MosaicPhoto { @@ -162,12 +163,13 @@ export interface MosaicPhoto { webp: string; jpeg: string; }; + altText?: string; } export interface UnknownMedia { type: string; url?: string; - [key: string]: unknown; + altText?: string; } export interface BroadcastThumbnail { @@ -255,7 +257,7 @@ export interface Article { content: { blocks: Array<{ key: string; - data: Record; + data: object; }>; entityMap: unknown[]; }; diff --git a/packages/platforms/src/platforms/twitter.ts b/packages/platforms/src/platforms/twitter.ts index 9031740..fe01150 100644 --- a/packages/platforms/src/platforms/twitter.ts +++ b/packages/platforms/src/platforms/twitter.ts @@ -1,7 +1,7 @@ import he from "he"; import { version } from "../../package.json"; -import { Platform } from "../types"; +import { NormalizedPost, Platform } from "../types"; import type { APITwitterStatus, FxTweetResponse, RawText } from "./twitter.d"; const MATCH_RE = @@ -167,6 +167,7 @@ export const Twitter: Platform<"Twitter", APITwitterStatus, TwitterMeta> = { throw { code: resp.status, message: resp.statusText }; } + // SAFETY: FXTwitter response is checked against live text, media, and article samples. const { status, code } = (await resp.json()) as FxTweetResponse; if (code !== 200) { @@ -177,7 +178,11 @@ export const Twitter: Platform<"Twitter", APITwitterStatus, TwitterMeta> = { throw { code, status }; } - return status!; + if (!status) { + throw { code: 500, message: "FXTwitter returned no status" }; + } + + return status; }, async transform(raw, options) { const depth = options?.depth ?? 0; @@ -188,8 +193,13 @@ export const Twitter: Platform<"Twitter", APITwitterStatus, TwitterMeta> = { : (raw.media?.all?.map((m) => ({ url: resolveMediaUrl(m), type: m.type, - description: "altText" in m && typeof m.altText === "string" ? m.altText : undefined, + description: m.altText, })) ?? []); + let replyTo: NormalizedPost | undefined; + if (includeContext && raw.replying_to) { + const parent = await this.fetch(raw.replying_to.status).catch(() => undefined); + if (parent) replyTo = await this.transform(parent, { depth: depth + 1 }); + } return { platform: this.type, @@ -214,10 +224,7 @@ export const Twitter: Platform<"Twitter", APITwitterStatus, TwitterMeta> = { includeContext && raw.quote?.type === "status" ? await this.transform(raw.quote, { depth: depth + 1 }) : undefined, - reply_to: - includeContext && raw.replying_to - ? await this.transform(await this.fetch(raw.replying_to.status), { depth: depth + 1 }) - : undefined, + reply_to: replyTo, article: raw.article, community_note: enrichText(raw.community_note), diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index c15519e..c99e74c 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -40,5 +40,5 @@ export interface Platform; + ): Promise & PlatformMeta & { platform: PlatformName }>; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8592ba..e672fff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ catalogs: specifier: ^0.47.0 version: 0.47.0 oxlint: - specifier: ^1.62.0 - version: 1.62.0 + specifier: 1.78.0 + version: 1.78.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -89,7 +89,7 @@ importers: version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 wrangler: specifier: ^4.114.0 version: 4.114.0 @@ -154,15 +154,12 @@ importers: '@types/node': specifier: 'catalog:' version: 25.6.0 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 oxfmt: specifier: 'catalog:' version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 tsdown: specifier: ^0.22.0 version: 0.22.0(oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(tsx@4.21.0)(typescript@6.0.3) @@ -280,7 +277,7 @@ importers: version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 tailwindcss: specifier: ^4.2.2 version: 4.3.0 @@ -293,12 +290,15 @@ importers: packages/config: devDependencies: + '@oxlint/plugins': + specifier: 1.78.0 + version: 1.78.0 oxfmt: specifier: 'catalog:' version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 packages/logging: devDependencies: @@ -313,7 +313,7 @@ importers: version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 packages/platforms: dependencies: @@ -341,7 +341,7 @@ importers: version: 0.47.0 oxlint: specifier: 'catalog:' - version: 1.62.0 + version: 1.78.0 packages: @@ -1839,128 +1839,132 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.62.0': - resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==} + '@oxlint/binding-android-arm-eabi@1.78.0': + resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.62.0': - resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==} + '@oxlint/binding-android-arm64@1.78.0': + resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.62.0': - resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==} + '@oxlint/binding-darwin-arm64@1.78.0': + resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.62.0': - resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==} + '@oxlint/binding-darwin-x64@1.78.0': + resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.62.0': - resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==} + '@oxlint/binding-freebsd-x64@1.78.0': + resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.62.0': - resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.62.0': - resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==} + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.62.0': - resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==} + '@oxlint/binding-linux-arm64-gnu@1.78.0': + resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.62.0': - resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==} + '@oxlint/binding-linux-arm64-musl@1.78.0': + resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.62.0': - resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==} + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.62.0': - resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==} + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.62.0': - resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==} + '@oxlint/binding-linux-riscv64-musl@1.78.0': + resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.62.0': - resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==} + '@oxlint/binding-linux-s390x-gnu@1.78.0': + resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.62.0': - resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==} + '@oxlint/binding-linux-x64-gnu@1.78.0': + resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.62.0': - resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==} + '@oxlint/binding-linux-x64-musl@1.78.0': + resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.62.0': - resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==} + '@oxlint/binding-openharmony-arm64@1.78.0': + resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.62.0': - resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==} + '@oxlint/binding-win32-arm64-msvc@1.78.0': + resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.62.0': - resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==} + '@oxlint/binding-win32-ia32-msvc@1.78.0': + resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.62.0': - resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==} + '@oxlint/binding-win32-x64-msvc@1.78.0': + resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/plugins@1.78.0': + resolution: {integrity: sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@paper-design/shaders-react@0.0.76': resolution: {integrity: sha512-uPJWrYRf6cJdO2H+fuXlahaqz0QjYglNAyUTaRfIInpzCa/d6guxBIK003soAZQFuQ035yg9FhtfFzKNWm+a5A==} peerDependencies: @@ -4374,15 +4378,18 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint@1.62.0: - resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==} + oxlint@1.78.0: + resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.18.0' + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: optional: true + vite-plus: + optional: true p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} @@ -6702,63 +6709,65 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.47.0': optional: true - '@oxlint/binding-android-arm-eabi@1.62.0': + '@oxlint/binding-android-arm-eabi@1.78.0': optional: true - '@oxlint/binding-android-arm64@1.62.0': + '@oxlint/binding-android-arm64@1.78.0': optional: true - '@oxlint/binding-darwin-arm64@1.62.0': + '@oxlint/binding-darwin-arm64@1.78.0': optional: true - '@oxlint/binding-darwin-x64@1.62.0': + '@oxlint/binding-darwin-x64@1.78.0': optional: true - '@oxlint/binding-freebsd-x64@1.62.0': + '@oxlint/binding-freebsd-x64@1.78.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.62.0': + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.62.0': + '@oxlint/binding-linux-arm-musleabihf@1.78.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.62.0': + '@oxlint/binding-linux-arm64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.62.0': + '@oxlint/binding-linux-arm64-musl@1.78.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.62.0': + '@oxlint/binding-linux-ppc64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.62.0': + '@oxlint/binding-linux-riscv64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.62.0': + '@oxlint/binding-linux-riscv64-musl@1.78.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.62.0': + '@oxlint/binding-linux-s390x-gnu@1.78.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.62.0': + '@oxlint/binding-linux-x64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-x64-musl@1.62.0': + '@oxlint/binding-linux-x64-musl@1.78.0': optional: true - '@oxlint/binding-openharmony-arm64@1.62.0': + '@oxlint/binding-openharmony-arm64@1.78.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.62.0': + '@oxlint/binding-win32-arm64-msvc@1.78.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.62.0': + '@oxlint/binding-win32-ia32-msvc@1.78.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.62.0': + '@oxlint/binding-win32-x64-msvc@1.78.0': optional: true + '@oxlint/plugins@1.78.0': {} + '@paper-design/shaders-react@0.0.76(@types/react@19.2.15)(react@19.2.6)': dependencies: '@paper-design/shaders': 0.0.76 @@ -8124,7 +8133,8 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@17.4.2: {} + dotenv@17.4.2: + optional: true dotenv@8.6.0: {} @@ -9461,27 +9471,27 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.47.0 '@oxfmt/binding-win32-x64-msvc': 0.47.0 - oxlint@1.62.0: + oxlint@1.78.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.62.0 - '@oxlint/binding-android-arm64': 1.62.0 - '@oxlint/binding-darwin-arm64': 1.62.0 - '@oxlint/binding-darwin-x64': 1.62.0 - '@oxlint/binding-freebsd-x64': 1.62.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.62.0 - '@oxlint/binding-linux-arm-musleabihf': 1.62.0 - '@oxlint/binding-linux-arm64-gnu': 1.62.0 - '@oxlint/binding-linux-arm64-musl': 1.62.0 - '@oxlint/binding-linux-ppc64-gnu': 1.62.0 - '@oxlint/binding-linux-riscv64-gnu': 1.62.0 - '@oxlint/binding-linux-riscv64-musl': 1.62.0 - '@oxlint/binding-linux-s390x-gnu': 1.62.0 - '@oxlint/binding-linux-x64-gnu': 1.62.0 - '@oxlint/binding-linux-x64-musl': 1.62.0 - '@oxlint/binding-openharmony-arm64': 1.62.0 - '@oxlint/binding-win32-arm64-msvc': 1.62.0 - '@oxlint/binding-win32-ia32-msvc': 1.62.0 - '@oxlint/binding-win32-x64-msvc': 1.62.0 + '@oxlint/binding-android-arm-eabi': 1.78.0 + '@oxlint/binding-android-arm64': 1.78.0 + '@oxlint/binding-darwin-arm64': 1.78.0 + '@oxlint/binding-darwin-x64': 1.78.0 + '@oxlint/binding-freebsd-x64': 1.78.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 + '@oxlint/binding-linux-arm-musleabihf': 1.78.0 + '@oxlint/binding-linux-arm64-gnu': 1.78.0 + '@oxlint/binding-linux-arm64-musl': 1.78.0 + '@oxlint/binding-linux-ppc64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-musl': 1.78.0 + '@oxlint/binding-linux-s390x-gnu': 1.78.0 + '@oxlint/binding-linux-x64-gnu': 1.78.0 + '@oxlint/binding-linux-x64-musl': 1.78.0 + '@oxlint/binding-openharmony-arm64': 1.78.0 + '@oxlint/binding-win32-arm64-msvc': 1.78.0 + '@oxlint/binding-win32-ia32-msvc': 1.78.0 + '@oxlint/binding-win32-x64-msvc': 1.78.0 p-filter@2.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d33815f..98838ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,6 @@ packages: catalogs: default: oxfmt: ^0.47.0 - oxlint: ^1.62.0 + oxlint: 1.78.0 typescript: ^6.0.3 "@types/node": ^25.6.0