refactor(scripts): dedupe guard checks and smoke helpers

This commit is contained in:
Peter Steinberger
2026-03-02 08:51:27 +00:00
parent 5d53b61d9e
commit 00a2456b72
9 changed files with 344 additions and 576 deletions

View File

@@ -2,10 +2,16 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 = [ const acpCoreProtectedSources = [
path.join(repoRoot, "src", "acp"), path.join(repoRoot, "src", "acp"),
@@ -57,50 +63,6 @@ const comparisonOperators = new Set([
const allowedViolations = 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) { function isChannelsPropertyAccess(node) {
if (ts.isPropertyAccessExpression(node)) { if (ts.isPropertyAccessExpression(node)) {
return node.name.text === "channels"; return node.name.text === "channels";
@@ -130,13 +92,6 @@ function matchesChannelModuleSpecifier(specifier) {
return channelSegmentRe.test(specifier.replaceAll("\\", "/")); 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 = const userFacingChannelNameRe =
/\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i; /\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i;
const systemMarkLiteral = "⚙️"; const systemMarkLiteral = "⚙️";
@@ -348,16 +303,12 @@ export async function main() {
for (const ruleSet of boundaryRuleSets) { for (const ruleSet of boundaryRuleSets) {
const files = ( const files = (
await Promise.all( await Promise.all(
ruleSet.sources.map(async (sourcePath) => { ruleSet.sources.map(
try { async (sourcePath) =>
return await collectTypeScriptFiles(sourcePath); await collectTypeScriptFiles(sourcePath, {
} catch (error) { ignoreMissing: true,
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { }),
return []; ),
}
throw error;
}
}),
) )
).flat(); ).flat();
for (const filePath of files) { for (const filePath of files) {
@@ -389,17 +340,4 @@ export async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -1,11 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
const allowedFiles = new Set([ const allowedFiles = new Set([
@@ -31,43 +36,6 @@ const allowedResolverCallNames = new Set([
"resolveIrcEffectiveAllowlists", "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) { function getDeclarationNameText(name) {
if (ts.isIdentifier(name)) { if (ts.isIdentifier(name)) {
return name.text; return name.text;
@@ -190,24 +158,12 @@ function findViolations(content, filePath) {
} }
async function main() { async function main() {
const files = ( const violations = await collectFileViolations({
await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) sourceRoots,
).flat(); repoRoot,
findViolations,
const violations = []; skipFile: (filePath) => allowedFiles.has(filePath),
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,
});
}
}
if (violations.length === 0) { if (violations.length === 0) {
return; return;
@@ -223,17 +179,4 @@ async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -2,10 +2,16 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 = [ const sourceRoots = [
path.join(repoRoot, "src", "channels"), path.join(repoRoot, "src", "channels"),
path.join(repoRoot, "src", "infra", "outbound"), path.join(repoRoot, "src", "infra", "outbound"),
@@ -15,38 +21,6 @@ const sourceRoots = [
]; ];
const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); 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) { function collectOsTmpdirImports(sourceFile) {
const osModuleSpecifiers = new Set(["node:os", "os"]); const osModuleSpecifiers = new Set(["node:os", "os"]);
const osNamespaceOrDefault = new Set(); const osNamespaceOrDefault = new Set();
@@ -81,25 +55,6 @@ function collectOsTmpdirImports(sourceFile) {
return { osNamespaceOrDefault, namedTmpdir }; 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") { export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile);
@@ -114,11 +69,9 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
ts.isIdentifier(callee.expression) && ts.isIdentifier(callee.expression) &&
osNamespaceOrDefault.has(callee.expression.text) osNamespaceOrDefault.has(callee.expression.text)
) { ) {
const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; lines.push(toLine(sourceFile, callee));
lines.push(line);
} else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) {
const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; lines.push(toLine(sourceFile, callee));
lines.push(line);
} }
} }
ts.forEachChild(node, visit); ts.forEachChild(node, visit);
@@ -130,7 +83,14 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
export async function main() { export async function main() {
const files = ( 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(); ).flat();
const violations = []; const violations = [];
@@ -158,17 +118,4 @@ export async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -2,10 +2,16 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 = [ const sourceRoots = [
path.join(repoRoot, "src", "telegram"), path.join(repoRoot, "src", "telegram"),
path.join(repoRoot, "src", "discord"), path.join(repoRoot, "src", "discord"),
@@ -65,69 +71,6 @@ const allowedRawFetchCallsites = new Set([
"src/slack/monitor/media.ts:108", "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) { function isRawFetchCall(expression) {
const callee = unwrapExpression(expression); const callee = unwrapExpression(expression);
if (ts.isIdentifier(callee)) { if (ts.isIdentifier(callee)) {
@@ -148,9 +91,7 @@ export function findRawFetchCallLines(content, fileName = "source.ts") {
const lines = []; const lines = [];
const visit = (node) => { const visit = (node) => {
if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) { if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) {
const line = lines.push(toLine(sourceFile, node.expression));
sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1;
lines.push(line);
} }
ts.forEachChild(node, visit); ts.forEachChild(node, visit);
}; };
@@ -161,13 +102,13 @@ export function findRawFetchCallLines(content, fileName = "source.ts") {
export async function main() { export async function main() {
const files = ( const files = (
await Promise.all( await Promise.all(
sourceRoots.map(async (sourceRoot) => { sourceRoots.map(
try { async (sourceRoot) =>
return await collectTypeScriptFiles(sourceRoot); await collectTypeScriptFiles(sourceRoot, {
} catch { extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
return []; ignoreMissing: true,
} }),
}), ),
) )
).flat(); ).flat();
@@ -198,17 +139,4 @@ export async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -2,63 +2,19 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 uiSourceDir = path.join(repoRoot, "ui", "src", "ui");
const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]); 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) { function asPropertyAccess(expression) {
if (ts.isPropertyAccessExpression(expression)) { if (ts.isPropertyAccessExpression(expression)) {
return expression; return expression;
@@ -87,9 +43,7 @@ export function findRawWindowOpenLines(content, fileName = "source.ts") {
const visit = (node) => { const visit = (node) => {
if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) { if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) {
const line = lines.push(toLine(sourceFile, node.expression));
sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1;
lines.push(line);
} }
ts.forEachChild(node, visit); ts.forEachChild(node, visit);
}; };
@@ -99,7 +53,10 @@ export function findRawWindowOpenLines(content, fileName = "source.ts") {
} }
export async function main() { 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 = []; const violations = [];
for (const filePath of files) { for (const filePath of files) {
@@ -126,17 +83,4 @@ export async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -1,50 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript"; 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 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) { function isUndefinedLikeExpression(node) {
if (ts.isIdentifier(node) && node.text === "undefined") { if (ts.isIdentifier(node) && node.text === "undefined") {
return true; return true;
@@ -114,21 +82,11 @@ function findViolations(content, filePath) {
} }
async function main() { async function main() {
const files = ( const violations = await collectFileViolations({
await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) sourceRoots,
).flat(); repoRoot,
const violations = []; findViolations,
});
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,
});
}
}
if (violations.length === 0) { if (violations.length === 0) {
return; return;
@@ -141,17 +99,4 @@ async function main() {
process.exit(1); process.exit(1);
} }
const isDirectExecution = (() => { runAsScript(import.meta.url, main);
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);
});
}

View File

@@ -340,39 +340,17 @@ async function discordApi<T>(params: {
body?: unknown; body?: unknown;
retries?: number; retries?: number;
}): Promise<T> { }): Promise<T> {
const retries = params.retries ?? 6; return requestDiscordJson<T>({
for (let attempt = 0; attempt <= retries; attempt += 1) { method: params.method,
const response = await fetch(`${DISCORD_API_BASE}${params.path}`, { path: params.path,
method: params.method, headers: {
headers: { Authorization: params.authHeader,
Authorization: params.authHeader, "Content-Type": "application/json",
"Content-Type": "application/json", },
}, body: params.body,
body: params.body === undefined ? undefined : JSON.stringify(params.body), retries: params.retries,
}); errorPrefix: "Discord API",
});
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.`);
} }
async function discordWebhookApi<T>(params: { async function discordWebhookApi<T>(params: {
@@ -383,15 +361,33 @@ async function discordWebhookApi<T>(params: {
query?: string; query?: string;
retries?: number; retries?: number;
}): Promise<T> { }): Promise<T> {
const retries = params.retries ?? 6;
const suffix = params.query ? `?${params.query}` : ""; const suffix = params.query ? `?${params.query}` : "";
const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`; const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`;
return requestDiscordJson<T>({
method: params.method,
path,
headers: {
"Content-Type": "application/json",
},
body: params.body,
retries: params.retries,
errorPrefix: "Discord webhook API",
});
}
async function requestDiscordJson<T>(params: {
method: string;
path: string;
headers: Record<string, string>;
body?: unknown;
retries?: number;
errorPrefix: string;
}): Promise<T> {
const retries = params.retries ?? 6;
for (let attempt = 0; attempt <= retries; attempt += 1) { 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, method: params.method,
headers: { headers: params.headers,
"Content-Type": "application/json",
},
body: params.body === undefined ? undefined : JSON.stringify(params.body), body: params.body === undefined ? undefined : JSON.stringify(params.body),
}); });
@@ -405,7 +401,7 @@ async function discordWebhookApi<T>(params: {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new Error( 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<T>(params: {
return (await response.json()) as T; 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<ThreadBindingRecord[]> { async function readThreadBindings(filePath: string): Promise<ThreadBindingRecord[]> {
@@ -487,6 +483,24 @@ function toRecentMessageRow(message: DiscordMessage) {
}; };
} }
async function loadParentRecentMessages(params: {
args: Args;
readAuthHeader: string;
}): Promise<DiscordMessage[]> {
if (params.args.driverMode === "openclaw") {
return await readMessagesWithOpenclaw({
openclawBin: params.args.openclawBin,
target: params.args.channelId,
limit: 20,
});
}
return await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(params.args.channelId)}/messages?limit=20`,
authHeader: params.readAuthHeader,
});
}
function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) { function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) {
if (params.json) { if (params.json) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -714,18 +728,7 @@ async function run(): Promise<SuccessResult | FailureResult> {
if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) { if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) {
let parentRecent: DiscordMessage[] = []; let parentRecent: DiscordMessage[] = [];
try { try {
parentRecent = parentRecent = await loadParentRecentMessages({ args, readAuthHeader });
args.driverMode === "openclaw"
? await readMessagesWithOpenclaw({
openclawBin: args.openclawBin,
target: args.channelId,
limit: 20,
})
: await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch { } catch {
// Best effort diagnostics only. // Best effort diagnostics only.
} }
@@ -782,18 +785,7 @@ async function run(): Promise<SuccessResult | FailureResult> {
if (!ackMessage) { if (!ackMessage) {
let parentRecent: DiscordMessage[] = []; let parentRecent: DiscordMessage[] = [];
try { try {
parentRecent = parentRecent = await loadParentRecentMessages({ args, readAuthHeader });
args.driverMode === "openclaw"
? await readMessagesWithOpenclaw({
openclawBin: args.openclawBin,
target: args.channelId,
limit: 20,
})
: await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch { } catch {
// Best effort diagnostics only. // Best effort diagnostics only.
} }

View File

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

View File

@@ -6,12 +6,45 @@ import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh"); 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<void> { async function writeExecutable(filePath: string, body: string): Promise<void> {
await writeFile(filePath, body, "utf8"); await writeFile(filePath, body, "utf8");
chmodSync(filePath, 0o755); chmodSync(filePath, 0o755);
} }
async function setupFixture(params?: {
provisioningProfiles?: Record<string, string>;
}): 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<void> {
await writeExecutable(path.join(binDir, "defaults"), DEFAULTS_WITH_ACCOUNT_SCRIPT);
}
function runScript( function runScript(
homeDir: string, homeDir: string,
extraEnv: Record<string, string> = {}, extraEnv: Record<string, string> = {},
@@ -47,33 +80,18 @@ function runScript(
describe("scripts/ios-team-id.sh", () => { describe("scripts/ios-team-id.sh", () => {
it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => { 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 { homeDir, binDir } = await setupFixture({
const binDir = path.join(homeDir, "bin"); provisioningProfiles: {
await mkdir(binDir, { recursive: true }); "one.mobileprovision": "stub",
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); },
await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), {
recursive: true,
}); });
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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, echo '{}'`,
); );
await writeExecutable( await writeDefaultsWithSignedInAccount(binDir);
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable( await writeExecutable(
path.join(binDir, "security"), path.join(binDir, "security"),
`#!/usr/bin/env bash `#!/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 () => { 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 { homeDir, binDir } = await setupFixture();
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"), "");
await writeExecutable( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
@@ -135,37 +149,19 @@ exit 1`,
}); });
it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { 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 { homeDir, binDir } = await setupFixture({
const binDir = path.join(homeDir, "bin"); provisioningProfiles: {
await mkdir(binDir, { recursive: true }); "one.mobileprovision": "stub1",
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); "two.mobileprovision": "stub2",
await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { },
recursive: true,
}); });
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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, echo '{}'`,
); );
await writeExecutable( await writeDefaultsWithSignedInAccount(binDir);
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable( await writeExecutable(
path.join(binDir, "security"), path.join(binDir, "security"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
@@ -194,26 +190,14 @@ exit 0`,
}); });
it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { 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 { homeDir, binDir } = await setupFixture();
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"), "");
await writeExecutable( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, echo '{}'`,
); );
await writeExecutable( await writeDefaultsWithSignedInAccount(binDir);
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable( await writeExecutable(
path.join(binDir, "fake-python"), path.join(binDir, "fake-python"),
`#!/usr/bin/env bash `#!/usr/bin/env bash