diff --git a/scripts/check-channel-agnostic-boundaries.mjs b/scripts/check-channel-agnostic-boundaries.mjs index 3b63911e86d..3a1e553acde 100644 --- a/scripts/check-channel-agnostic-boundaries.mjs +++ b/scripts/check-channel-agnostic-boundaries.mjs @@ -2,10 +2,16 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectTypeScriptFiles, + getPropertyNameText, + resolveRepoRoot, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const acpCoreProtectedSources = [ path.join(repoRoot, "src", "acp"), @@ -57,50 +63,6 @@ const comparisonOperators = new Set([ const allowedViolations = new Set([]); -function isTestLikeFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".test-utils.ts") || - filePath.endsWith(".test-harness.ts") || - filePath.endsWith(".e2e-harness.ts") - ); -} - -async function collectTypeScriptFiles(targetPath) { - const stat = await fs.stat(targetPath); - if (stat.isFile()) { - if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { - return []; - } - return [targetPath]; - } - - const entries = await fs.readdir(targetPath, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const entryPath = path.join(targetPath, entry.name); - if (entry.isDirectory()) { - files.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (!entryPath.endsWith(".ts")) { - continue; - } - if (isTestLikeFile(entryPath)) { - continue; - } - files.push(entryPath); - } - return files; -} - -function toLine(sourceFile, node) { - return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; -} - function isChannelsPropertyAccess(node) { if (ts.isPropertyAccessExpression(node)) { return node.name.text === "channels"; @@ -130,13 +92,6 @@ function matchesChannelModuleSpecifier(specifier) { return channelSegmentRe.test(specifier.replaceAll("\\", "/")); } -function getPropertyNameText(name) { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { - return name.text; - } - return null; -} - const userFacingChannelNameRe = /\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i; const systemMarkLiteral = "⚙️"; @@ -348,16 +303,12 @@ export async function main() { for (const ruleSet of boundaryRuleSets) { const files = ( await Promise.all( - ruleSet.sources.map(async (sourcePath) => { - try { - return await collectTypeScriptFiles(sourcePath); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return []; - } - throw error; - } - }), + ruleSet.sources.map( + async (sourcePath) => + await collectTypeScriptFiles(sourcePath, { + ignoreMissing: true, + }), + ), ) ).flat(); for (const filePath of files) { @@ -389,17 +340,4 @@ export async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/check-no-pairing-store-group-auth.mjs b/scripts/check-no-pairing-store-group-auth.mjs index 316411c460e..2ee94af82e1 100644 --- a/scripts/check-no-pairing-store-group-auth.mjs +++ b/scripts/check-no-pairing-store-group-auth.mjs @@ -1,11 +1,16 @@ #!/usr/bin/env node -import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectFileViolations, + getPropertyNameText, + resolveRepoRoot, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; const allowedFiles = new Set([ @@ -31,43 +36,6 @@ const allowedResolverCallNames = new Set([ "resolveIrcEffectiveAllowlists", ]); -function isTestLikeFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".test-utils.ts") || - filePath.endsWith(".test-harness.ts") || - filePath.endsWith(".e2e-harness.ts") - ); -} - -async function collectTypeScriptFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out = []; - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { - continue; - } - out.push(entryPath); - } - return out; -} - -function toLine(sourceFile, node) { - return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; -} - -function getPropertyNameText(name) { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { - return name.text; - } - return null; -} - function getDeclarationNameText(name) { if (ts.isIdentifier(name)) { return name.text; @@ -190,24 +158,12 @@ function findViolations(content, filePath) { } async function main() { - const files = ( - await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) - ).flat(); - - const violations = []; - for (const filePath of files) { - if (allowedFiles.has(filePath)) { - continue; - } - const content = await fs.readFile(filePath, "utf8"); - const fileViolations = findViolations(content, filePath); - for (const violation of fileViolations) { - violations.push({ - path: path.relative(repoRoot, filePath), - ...violation, - }); - } - } + const violations = await collectFileViolations({ + sourceRoots, + repoRoot, + findViolations, + skipFile: (filePath) => allowedFiles.has(filePath), + }); if (violations.length === 0) { return; @@ -223,17 +179,4 @@ async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs index af7b56a371f..170f9a3a994 100644 --- a/scripts/check-no-random-messaging-tmp.mjs +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -2,10 +2,16 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectTypeScriptFiles, + resolveRepoRoot, + runAsScript, + toLine, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [ path.join(repoRoot, "src", "channels"), path.join(repoRoot, "src", "infra", "outbound"), @@ -15,38 +21,6 @@ const sourceRoots = [ ]; const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); -function isTestLikeFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".test-utils.ts") || - filePath.endsWith(".test-harness.ts") || - filePath.endsWith(".e2e-harness.ts") - ); -} - -async function collectTypeScriptFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out = []; - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (!entryPath.endsWith(".ts")) { - continue; - } - if (isTestLikeFile(entryPath)) { - continue; - } - out.push(entryPath); - } - return out; -} - function collectOsTmpdirImports(sourceFile) { const osModuleSpecifiers = new Set(["node:os", "os"]); const osNamespaceOrDefault = new Set(); @@ -81,25 +55,6 @@ function collectOsTmpdirImports(sourceFile) { return { osNamespaceOrDefault, namedTmpdir }; } -function unwrapExpression(expression) { - let current = expression; - while (true) { - if (ts.isParenthesizedExpression(current)) { - current = current.expression; - continue; - } - if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { - current = current.expression; - continue; - } - if (ts.isNonNullExpression(current)) { - current = current.expression; - continue; - } - return current; - } -} - export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); @@ -114,11 +69,9 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { ts.isIdentifier(callee.expression) && osNamespaceOrDefault.has(callee.expression.text) ) { - const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; - lines.push(line); + lines.push(toLine(sourceFile, callee)); } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { - const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; - lines.push(line); + lines.push(toLine(sourceFile, callee)); } } ts.forEachChild(node, visit); @@ -130,7 +83,14 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { export async function main() { const files = ( - await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + await Promise.all( + sourceRoots.map( + async (dir) => + await collectTypeScriptFiles(dir, { + ignoreMissing: true, + }), + ), + ) ).flat(); const violations = []; @@ -158,17 +118,4 @@ export async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 91c61e7f12c..616e9c23464 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -2,10 +2,16 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectTypeScriptFiles, + resolveRepoRoot, + runAsScript, + toLine, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [ path.join(repoRoot, "src", "telegram"), path.join(repoRoot, "src", "discord"), @@ -65,69 +71,6 @@ const allowedRawFetchCallsites = new Set([ "src/slack/monitor/media.ts:108", ]); -function isTestLikeFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".test-utils.ts") || - filePath.endsWith(".test-harness.ts") || - filePath.endsWith(".e2e-harness.ts") || - filePath.endsWith(".browser.test.ts") || - filePath.endsWith(".node.test.ts") - ); -} - -async function collectTypeScriptFiles(targetPath) { - const stat = await fs.stat(targetPath); - if (stat.isFile()) { - if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { - return []; - } - return [targetPath]; - } - const entries = await fs.readdir(targetPath, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const entryPath = path.join(targetPath, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules") { - continue; - } - files.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (!entryPath.endsWith(".ts")) { - continue; - } - if (isTestLikeFile(entryPath)) { - continue; - } - files.push(entryPath); - } - return files; -} - -function unwrapExpression(expression) { - let current = expression; - while (true) { - if (ts.isParenthesizedExpression(current)) { - current = current.expression; - continue; - } - if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { - current = current.expression; - continue; - } - if (ts.isNonNullExpression(current)) { - current = current.expression; - continue; - } - return current; - } -} - function isRawFetchCall(expression) { const callee = unwrapExpression(expression); if (ts.isIdentifier(callee)) { @@ -148,9 +91,7 @@ export function findRawFetchCallLines(content, fileName = "source.ts") { const lines = []; const visit = (node) => { if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) { - const line = - sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; - lines.push(line); + lines.push(toLine(sourceFile, node.expression)); } ts.forEachChild(node, visit); }; @@ -161,13 +102,13 @@ export function findRawFetchCallLines(content, fileName = "source.ts") { export async function main() { const files = ( await Promise.all( - sourceRoots.map(async (sourceRoot) => { - try { - return await collectTypeScriptFiles(sourceRoot); - } catch { - return []; - } - }), + sourceRoots.map( + async (sourceRoot) => + await collectTypeScriptFiles(sourceRoot, { + extraTestSuffixes: [".browser.test.ts", ".node.test.ts"], + ignoreMissing: true, + }), + ), ) ).flat(); @@ -198,17 +139,4 @@ export async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs index 930bfe60a61..5ac43cf24ab 100644 --- a/scripts/check-no-raw-window-open.mjs +++ b/scripts/check-no-raw-window-open.mjs @@ -2,63 +2,19 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectTypeScriptFiles, + resolveRepoRoot, + runAsScript, + toLine, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const uiSourceDir = path.join(repoRoot, "ui", "src", "ui"); const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]); -function isTestFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".browser.test.ts") || - filePath.endsWith(".node.test.ts") - ); -} - -async function collectTypeScriptFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out = []; - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (!entryPath.endsWith(".ts")) { - continue; - } - if (isTestFile(entryPath)) { - continue; - } - out.push(entryPath); - } - return out; -} - -function unwrapExpression(expression) { - let current = expression; - while (true) { - if (ts.isParenthesizedExpression(current)) { - current = current.expression; - continue; - } - if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { - current = current.expression; - continue; - } - if (ts.isNonNullExpression(current)) { - current = current.expression; - continue; - } - return current; - } -} - function asPropertyAccess(expression) { if (ts.isPropertyAccessExpression(expression)) { return expression; @@ -87,9 +43,7 @@ export function findRawWindowOpenLines(content, fileName = "source.ts") { const visit = (node) => { if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) { - const line = - sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; - lines.push(line); + lines.push(toLine(sourceFile, node.expression)); } ts.forEachChild(node, visit); }; @@ -99,7 +53,10 @@ export function findRawWindowOpenLines(content, fileName = "source.ts") { } export async function main() { - const files = await collectTypeScriptFiles(uiSourceDir); + const files = await collectTypeScriptFiles(uiSourceDir, { + extraTestSuffixes: [".browser.test.ts", ".node.test.ts"], + ignoreMissing: true, + }); const violations = []; for (const filePath of files) { @@ -126,17 +83,4 @@ export async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/check-pairing-account-scope.mjs b/scripts/check-pairing-account-scope.mjs index 21db11a87a2..984e3846fc6 100644 --- a/scripts/check-pairing-account-scope.mjs +++ b/scripts/check-pairing-account-scope.mjs @@ -1,50 +1,18 @@ #!/usr/bin/env node -import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { + collectFileViolations, + getPropertyNameText, + resolveRepoRoot, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; -function isTestLikeFile(filePath) { - return ( - filePath.endsWith(".test.ts") || - filePath.endsWith(".test-utils.ts") || - filePath.endsWith(".test-harness.ts") || - filePath.endsWith(".e2e-harness.ts") - ); -} - -async function collectTypeScriptFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out = []; - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await collectTypeScriptFiles(entryPath))); - continue; - } - if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { - continue; - } - out.push(entryPath); - } - return out; -} - -function toLine(sourceFile, node) { - return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; -} - -function getPropertyNameText(name) { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { - return name.text; - } - return null; -} - function isUndefinedLikeExpression(node) { if (ts.isIdentifier(node) && node.text === "undefined") { return true; @@ -114,21 +82,11 @@ function findViolations(content, filePath) { } async function main() { - const files = ( - await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) - ).flat(); - const violations = []; - - for (const filePath of files) { - const content = await fs.readFile(filePath, "utf8"); - const fileViolations = findViolations(content, filePath); - for (const violation of fileViolations) { - violations.push({ - path: path.relative(repoRoot, filePath), - ...violation, - }); - } - } + const violations = await collectFileViolations({ + sourceRoots, + repoRoot, + findViolations, + }); if (violations.length === 0) { return; @@ -141,17 +99,4 @@ async function main() { process.exit(1); } -const isDirectExecution = (() => { - const entry = process.argv[1]; - if (!entry) { - return false; - } - return path.resolve(entry) === fileURLToPath(import.meta.url); -})(); - -if (isDirectExecution) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} +runAsScript(import.meta.url, main); diff --git a/scripts/dev/discord-acp-plain-language-smoke.ts b/scripts/dev/discord-acp-plain-language-smoke.ts index a4ef3dabb4d..ce3f283f1f5 100644 --- a/scripts/dev/discord-acp-plain-language-smoke.ts +++ b/scripts/dev/discord-acp-plain-language-smoke.ts @@ -340,39 +340,17 @@ async function discordApi(params: { body?: unknown; retries?: number; }): Promise { - const retries = params.retries ?? 6; - for (let attempt = 0; attempt <= retries; attempt += 1) { - const response = await fetch(`${DISCORD_API_BASE}${params.path}`, { - method: params.method, - headers: { - Authorization: params.authHeader, - "Content-Type": "application/json", - }, - body: params.body === undefined ? undefined : JSON.stringify(params.body), - }); - - if (response.status === 429) { - const body = (await response.json().catch(() => ({}))) as { retry_after?: number }; - const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1; - await sleep(Math.ceil(waitSeconds * 1000)); - continue; - } - - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, - ); - } - - if (response.status === 204) { - return undefined as T; - } - - return (await response.json()) as T; - } - - throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`); + return requestDiscordJson({ + method: params.method, + path: params.path, + headers: { + Authorization: params.authHeader, + "Content-Type": "application/json", + }, + body: params.body, + retries: params.retries, + errorPrefix: "Discord API", + }); } async function discordWebhookApi(params: { @@ -383,15 +361,33 @@ async function discordWebhookApi(params: { query?: string; retries?: number; }): Promise { - const retries = params.retries ?? 6; const suffix = params.query ? `?${params.query}` : ""; const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`; + return requestDiscordJson({ + method: params.method, + path, + headers: { + "Content-Type": "application/json", + }, + body: params.body, + retries: params.retries, + errorPrefix: "Discord webhook API", + }); +} + +async function requestDiscordJson(params: { + method: string; + path: string; + headers: Record; + body?: unknown; + retries?: number; + errorPrefix: string; +}): Promise { + const retries = params.retries ?? 6; for (let attempt = 0; attempt <= retries; attempt += 1) { - const response = await fetch(`${DISCORD_API_BASE}${path}`, { + const response = await fetch(`${DISCORD_API_BASE}${params.path}`, { method: params.method, - headers: { - "Content-Type": "application/json", - }, + headers: params.headers, body: params.body === undefined ? undefined : JSON.stringify(params.body), }); @@ -405,7 +401,7 @@ async function discordWebhookApi(params: { if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( - `Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, + `${params.errorPrefix} ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, ); } @@ -416,7 +412,7 @@ async function discordWebhookApi(params: { return (await response.json()) as T; } - throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`); + throw new Error(`${params.errorPrefix} ${params.method} ${params.path} exceeded retry budget.`); } async function readThreadBindings(filePath: string): Promise { @@ -487,6 +483,24 @@ function toRecentMessageRow(message: DiscordMessage) { }; } +async function loadParentRecentMessages(params: { + args: Args; + readAuthHeader: string; +}): Promise { + if (params.args.driverMode === "openclaw") { + return await readMessagesWithOpenclaw({ + openclawBin: params.args.openclawBin, + target: params.args.channelId, + limit: 20, + }); + } + return await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(params.args.channelId)}/messages?limit=20`, + authHeader: params.readAuthHeader, + }); +} + function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) { if (params.json) { // eslint-disable-next-line no-console @@ -714,18 +728,7 @@ async function run(): Promise { if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) { let parentRecent: DiscordMessage[] = []; try { - parentRecent = - args.driverMode === "openclaw" - ? await readMessagesWithOpenclaw({ - openclawBin: args.openclawBin, - target: args.channelId, - limit: 20, - }) - : await discordApi({ - method: "GET", - path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, - authHeader: readAuthHeader, - }); + parentRecent = await loadParentRecentMessages({ args, readAuthHeader }); } catch { // Best effort diagnostics only. } @@ -782,18 +785,7 @@ async function run(): Promise { if (!ackMessage) { let parentRecent: DiscordMessage[] = []; try { - parentRecent = - args.driverMode === "openclaw" - ? await readMessagesWithOpenclaw({ - openclawBin: args.openclawBin, - target: args.channelId, - limit: 20, - }) - : await discordApi({ - method: "GET", - path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, - authHeader: readAuthHeader, - }); + parentRecent = await loadParentRecentMessages({ args, readAuthHeader }); } catch { // Best effort diagnostics only. } diff --git a/scripts/lib/ts-guard-utils.mjs b/scripts/lib/ts-guard-utils.mjs new file mode 100644 index 00000000000..bdf69246c56 --- /dev/null +++ b/scripts/lib/ts-guard-utils.mjs @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const baseTestSuffixes = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"]; + +export function resolveRepoRoot(importMetaUrl) { + return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", ".."); +} + +export function isTestLikeTypeScriptFile(filePath, options = {}) { + const extraTestSuffixes = options.extraTestSuffixes ?? []; + return [...baseTestSuffixes, ...extraTestSuffixes].some((suffix) => filePath.endsWith(suffix)); +} + +export async function collectTypeScriptFiles(targetPath, options = {}) { + const includeTests = options.includeTests ?? false; + const extraTestSuffixes = options.extraTestSuffixes ?? []; + const skipNodeModules = options.skipNodeModules ?? true; + const ignoreMissing = options.ignoreMissing ?? false; + + let stat; + try { + stat = await fs.stat(targetPath); + } catch (error) { + if ( + ignoreMissing && + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } + + if (stat.isFile()) { + if (!targetPath.endsWith(".ts")) { + return []; + } + if (!includeTests && isTestLikeTypeScriptFile(targetPath, { extraTestSuffixes })) { + return []; + } + return [targetPath]; + } + + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(targetPath, entry.name); + if (entry.isDirectory()) { + if (skipNodeModules && entry.name === "node_modules") { + continue; + } + out.push(...(await collectTypeScriptFiles(entryPath, options))); + continue; + } + if (!entry.isFile() || !entryPath.endsWith(".ts")) { + continue; + } + if (!includeTests && isTestLikeTypeScriptFile(entryPath, { extraTestSuffixes })) { + continue; + } + out.push(entryPath); + } + return out; +} + +export async function collectFileViolations(params) { + const files = ( + await Promise.all( + params.sourceRoots.map( + async (root) => + await collectTypeScriptFiles(root, { + ignoreMissing: true, + extraTestSuffixes: params.extraTestSuffixes, + }), + ), + ) + ).flat(); + + const violations = []; + for (const filePath of files) { + if (params.skipFile?.(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + const fileViolations = params.findViolations(content, filePath); + for (const violation of fileViolations) { + violations.push({ + path: path.relative(params.repoRoot, filePath), + ...violation, + }); + } + } + return violations; +} + +export function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +export function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +export function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function isDirectExecution(importMetaUrl) { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(importMetaUrl); +} + +export function runAsScript(importMetaUrl, main) { + if (!isDirectExecution(importMetaUrl)) { + return; + } + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts index d39d1a7de6f..f445693d93c 100644 --- a/test/scripts/ios-team-id.test.ts +++ b/test/scripts/ios-team-id.test.ts @@ -6,12 +6,45 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh"); +const XCODE_PLIST_PATH = path.join("Library", "Preferences", "com.apple.dt.Xcode.plist"); + +const DEFAULTS_WITH_ACCOUNT_SCRIPT = `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`; async function writeExecutable(filePath: string, body: string): Promise { await writeFile(filePath, body, "utf8"); chmodSync(filePath, 0o755); } +async function setupFixture(params?: { + provisioningProfiles?: Record; +}): Promise<{ homeDir: string; binDir: string }> { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, XCODE_PLIST_PATH), ""); + + const provisioningProfiles = params?.provisioningProfiles; + if (provisioningProfiles) { + const profilesDir = path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"); + await mkdir(profilesDir, { recursive: true }); + for (const [name, body] of Object.entries(provisioningProfiles)) { + await writeFile(path.join(profilesDir, name), body); + } + } + + return { homeDir, binDir }; +} + +async function writeDefaultsWithSignedInAccount(binDir: string): Promise { + await writeExecutable(path.join(binDir, "defaults"), DEFAULTS_WITH_ACCOUNT_SCRIPT); +} + function runScript( homeDir: string, extraEnv: Record = {}, @@ -47,33 +80,18 @@ function runScript( describe("scripts/ios-team-id.sh", () => { it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); - const binDir = path.join(homeDir, "bin"); - await mkdir(binDir, { recursive: true }); - await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); - await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { - recursive: true, + const { homeDir, binDir } = await setupFixture({ + provisioningProfiles: { + "one.mobileprovision": "stub", + }, }); - await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); - await writeFile( - path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), - "stub", - ); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeExecutable( - path.join(binDir, "defaults"), - `#!/usr/bin/env bash -if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then - echo '(identifier = "dev@example.com";)' - exit 0 -fi -exit 0`, - ); + await writeDefaultsWithSignedInAccount(binDir); await writeExecutable( path.join(binDir, "security"), `#!/usr/bin/env bash @@ -101,11 +119,7 @@ exit 0`, }); it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); - const binDir = path.join(homeDir, "bin"); - await mkdir(binDir, { recursive: true }); - await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); - await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + const { homeDir, binDir } = await setupFixture(); await writeExecutable( path.join(binDir, "plutil"), @@ -135,37 +149,19 @@ exit 1`, }); it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); - const binDir = path.join(homeDir, "bin"); - await mkdir(binDir, { recursive: true }); - await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); - await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { - recursive: true, + const { homeDir, binDir } = await setupFixture({ + provisioningProfiles: { + "one.mobileprovision": "stub1", + "two.mobileprovision": "stub2", + }, }); - await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); - await writeFile( - path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), - "stub1", - ); - await writeFile( - path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "two.mobileprovision"), - "stub2", - ); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeExecutable( - path.join(binDir, "defaults"), - `#!/usr/bin/env bash -if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then - echo '(identifier = "dev@example.com";)' - exit 0 -fi -exit 0`, - ); + await writeDefaultsWithSignedInAccount(binDir); await writeExecutable( path.join(binDir, "security"), `#!/usr/bin/env bash @@ -194,26 +190,14 @@ exit 0`, }); it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); - const binDir = path.join(homeDir, "bin"); - await mkdir(binDir, { recursive: true }); - await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); - await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + const { homeDir, binDir } = await setupFixture(); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeExecutable( - path.join(binDir, "defaults"), - `#!/usr/bin/env bash -if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then - echo '(identifier = "dev@example.com";)' - exit 0 -fi -exit 0`, - ); + await writeDefaultsWithSignedInAccount(binDir); await writeExecutable( path.join(binDir, "fake-python"), `#!/usr/bin/env bash