mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 18:51:11 +00:00
Merge remote-tracking branch 'origin/main' into pr-102197-prep
This commit is contained in:
12
.github/actions/ensure-base-commit/action.yml
vendored
12
.github/actions/ensure-base-commit/action.yml
vendored
@@ -44,6 +44,15 @@ runs:
|
||||
fetch "$@"
|
||||
}
|
||||
|
||||
echo "Base commit missing; fetching exact SHA before branch history."
|
||||
if ! fetch_base_ref --no-tags --depth=1 origin "$BASE_SHA"; then
|
||||
echo "::warning title=ensure-base-commit exact fetch failed::Failed to fetch exact base SHA $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Resolved base commit after exact fetch: $BASE_SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
if ! fetch_base_ref --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
@@ -64,4 +73,5 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Base commit still unavailable after fetch attempts: $BASE_SHA"
|
||||
echo "::error title=ensure-base-commit missing base::Base commit still unavailable after fetch attempts: $BASE_SHA"
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Msteams tests cover file consent plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { uploadToConsentUrl } from "./file-consent.js";
|
||||
import { resolveMSTeamsSharePointUploadTimeoutMs } from "./request-timeout.js";
|
||||
import { buildUserAgent } from "./user-agent.js";
|
||||
|
||||
// Helper: a resolveFn that returns a public IP by default
|
||||
@@ -321,6 +322,132 @@ describe("uploadToConsentUrl", () => {
|
||||
expect(opts?.body).toEqual(new Uint8Array(Buffer.from("hello")));
|
||||
});
|
||||
|
||||
it("aborts consent uploads that do not finish before the request timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async (_url, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
observedSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const reason = observedSignal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("request aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadPromise = uploadToConsentUrl({
|
||||
url: "https://contoso.sharepoint.com/upload",
|
||||
buffer: Buffer.from("hello"),
|
||||
fetchFn,
|
||||
timeoutMs: 25,
|
||||
validationOpts: { resolveFn: publicResolve },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
const uploadRejection = expect(uploadPromise).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "request timed out",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await uploadRejection;
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows consent uploads that complete before the request timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async (_url, init) =>
|
||||
await new Promise<Response>((resolve, reject) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
observedSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("consent upload timed out", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
setTimeout(() => resolve(new Response(null, { status: 200 })), 25);
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadPromise = uploadToConsentUrl({
|
||||
url: "https://contoso.sharepoint.com/upload",
|
||||
buffer: Buffer.from("hello"),
|
||||
fetchFn,
|
||||
timeoutMs: 50,
|
||||
validationOpts: { resolveFn: publicResolve },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await expect(uploadPromise).resolves.toBeUndefined();
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows size-budgeted consent uploads that complete after the base upload deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const buffer = Buffer.alloc(512 * 1024);
|
||||
const resolvedTimeoutMs = resolveMSTeamsSharePointUploadTimeoutMs(buffer.length);
|
||||
const baseTimeoutMs = resolveMSTeamsSharePointUploadTimeoutMs(0);
|
||||
const completionMs = baseTimeoutMs + 1_000;
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async (_url, init) =>
|
||||
await new Promise<Response>((resolve, reject) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
observedSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("consent upload timed out", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
setTimeout(() => resolve(new Response(null, { status: 200 })), completionMs);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completionMs).toBeGreaterThan(baseTimeoutMs);
|
||||
expect(completionMs).toBeLessThan(resolvedTimeoutMs);
|
||||
|
||||
const uploadPromise = uploadToConsentUrl({
|
||||
url: "https://contoso.sharepoint.com/upload",
|
||||
buffer,
|
||||
fetchFn,
|
||||
validationOpts: { resolveFn: publicResolve },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(completionMs);
|
||||
await expect(uploadPromise).resolves.toBeUndefined();
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks upload to a disallowed host", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
await expect(
|
||||
@@ -364,15 +491,15 @@ describe("uploadToConsentUrl", () => {
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [url, opts] = firstFetchCall(mockFetch);
|
||||
expect(url).toBe("https://contoso.sharepoint.com/sites/uploads/file.pdf");
|
||||
expect(opts).toEqual({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Range": "bytes 0-11/12",
|
||||
},
|
||||
body: new Uint8Array(buffer),
|
||||
expect(opts?.method).toBe("PUT");
|
||||
expect(opts?.headers).toEqual({
|
||||
"User-Agent": buildUserAgent(),
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Range": "bytes 0-11/12",
|
||||
});
|
||||
expect(opts?.body).toEqual(new Uint8Array(buffer));
|
||||
expect(opts?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(opts?.signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("throws on non-OK response after passing validation", async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveMSTeamsSharePointUploadTimeoutMs } from "./request-timeout.js";
|
||||
import { buildUserAgent } from "./user-agent.js";
|
||||
|
||||
/**
|
||||
@@ -198,6 +200,7 @@ export async function uploadToConsentUrl(params: {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
/** Override for testing — custom allowlist and DNS resolver */
|
||||
validationOpts?: {
|
||||
allowlist?: readonly string[];
|
||||
@@ -207,15 +210,20 @@ export async function uploadToConsentUrl(params: {
|
||||
await validateConsentUploadUrl(params.url, params.validationOpts);
|
||||
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const res = await fetchFn(params.url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
||||
const res = await fetchWithTimeout(
|
||||
params.url,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
params.timeoutMs ?? resolveMSTeamsSharePointUploadTimeoutMs(params.buffer.length),
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Msteams tests cover shared inbound request deadlines.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withMSTeamsRequestDeadline } from "./request-timeout.js";
|
||||
import {
|
||||
resolveMSTeamsSharePointUploadTimeoutMs,
|
||||
withMSTeamsRequestDeadline,
|
||||
} from "./request-timeout.js";
|
||||
|
||||
describe("withMSTeamsRequestDeadline", () => {
|
||||
it("does not start work after the operation deadline has expired", async () => {
|
||||
@@ -21,3 +24,12 @@ describe("withMSTeamsRequestDeadline", () => {
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMSTeamsSharePointUploadTimeoutMs", () => {
|
||||
it("adds transfer budget to the base file-consent upload deadline", () => {
|
||||
const oneHundredMiB = 100 * 1024 * 1024;
|
||||
|
||||
expect(resolveMSTeamsSharePointUploadTimeoutMs(0)).toBe(300_000);
|
||||
expect(resolveMSTeamsSharePointUploadTimeoutMs(oneHundredMiB)).toBe(700_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Msteams plugin module implements request deadline behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
createProviderOperationDeadline,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
export const MSTEAMS_REQUEST_TIMEOUT_MS = 30_000;
|
||||
// File-consent PUTs are data-plane transfers: keep a base stall bound, then
|
||||
// add slow-transfer budget so valid large uploads do not hit a fixed cutoff.
|
||||
const MSTEAMS_SHAREPOINT_UPLOAD_BASE_TIMEOUT_MS = 5 * 60_000;
|
||||
const MSTEAMS_SHAREPOINT_UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
|
||||
|
||||
// Cap optional enrichment before agent dispatch. The Teams SDK still holds the
|
||||
// webhook open for the agent turn, so this budget alone cannot prevent retries.
|
||||
@@ -39,3 +44,15 @@ export async function withMSTeamsRequestDeadline<T>(params: {
|
||||
const timeoutMs = resolveMSTeamsRequestTimeoutMs(params.deadline);
|
||||
return await withTimeout(params.work(), timeoutMs, params.label);
|
||||
}
|
||||
|
||||
export function resolveMSTeamsSharePointUploadTimeoutMs(sizeInBytes: number): number {
|
||||
const bytes = Number.isFinite(sizeInBytes) && sizeInBytes > 0 ? Math.ceil(sizeInBytes) : 0;
|
||||
const transferBudgetMs = Math.ceil(
|
||||
(bytes / MSTEAMS_SHAREPOINT_UPLOAD_MIN_BYTES_PER_SECOND) * 1000,
|
||||
);
|
||||
return resolveTimerTimeoutMs(
|
||||
MSTEAMS_SHAREPOINT_UPLOAD_BASE_TIMEOUT_MS + transferBudgetMs,
|
||||
MSTEAMS_SHAREPOINT_UPLOAD_BASE_TIMEOUT_MS,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
BUNDLED_PLUGIN_PATH_PREFIX,
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
} from "./lib/bundled-plugin-paths.mjs";
|
||||
import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
|
||||
import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
|
||||
import { escapeRegExp } from "./lib/regexp.mjs";
|
||||
import { toLine } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const srcRoot = path.join(repoRoot, "src");
|
||||
@@ -157,10 +159,6 @@ async function walkAllCodeFiles(rootDir, options = {}) {
|
||||
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
||||
}
|
||||
|
||||
function toLine(sourceFile, node) {
|
||||
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
}
|
||||
|
||||
function resolveRelativeSpecifier(specifier, importerFile) {
|
||||
if (!specifier.startsWith(".")) {
|
||||
return null;
|
||||
@@ -213,27 +211,9 @@ function collectPluginSdkImports(filePath, sourceFile) {
|
||||
});
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
push("dynamic-import", node.arguments[0], node.arguments[0].text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifierNode, specifier }) => {
|
||||
push(kind, specifierNode, specifier);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -245,15 +225,7 @@ async function collectCorePluginSdkImports() {
|
||||
continue;
|
||||
}
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const scriptKind =
|
||||
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
);
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
inventory.push(...collectPluginSdkImports(filePath, sourceFile));
|
||||
}
|
||||
return inventory.toSorted(compareImports);
|
||||
@@ -284,20 +256,11 @@ function collectOptionalClusterStaticImports(filePath, sourceFile) {
|
||||
});
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifierNode, specifier }) => {
|
||||
if (kind !== "dynamic-import") {
|
||||
push(kind, specifierNode, specifier);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -310,15 +273,7 @@ async function collectOptionalClusterStaticLeaks() {
|
||||
continue;
|
||||
}
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const scriptKind =
|
||||
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
);
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile));
|
||||
}
|
||||
return inventory.toSorted((left, right) => {
|
||||
|
||||
@@ -14,64 +14,44 @@ import {
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const defaultRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
|
||||
|
||||
function readStringLiteral(node) {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTypeOnlyImportDeclaration(node) {
|
||||
const clause = node.importClause;
|
||||
if (!clause) {
|
||||
return false;
|
||||
}
|
||||
if (clause.isTypeOnly) {
|
||||
return true;
|
||||
}
|
||||
if (clause.name) {
|
||||
return false;
|
||||
}
|
||||
const bindings = clause.namedBindings;
|
||||
return (
|
||||
Boolean(bindings) &&
|
||||
ts.isNamedImports(bindings) &&
|
||||
bindings.elements.length > 0 &&
|
||||
bindings.elements.every((element) => element.isTypeOnly)
|
||||
return Boolean(
|
||||
clause &&
|
||||
(ts.isTypeOnlyImportDeclaration(clause) ||
|
||||
(!clause.name &&
|
||||
ts.isNamedImports(clause.namedBindings) &&
|
||||
clause.namedBindings.elements.length > 0 &&
|
||||
clause.namedBindings.elements.every(ts.isTypeOnlyImportOrExportDeclaration))),
|
||||
);
|
||||
}
|
||||
|
||||
function isTypeOnlyExportDeclaration(node) {
|
||||
if (node.isTypeOnly === true) {
|
||||
return true;
|
||||
}
|
||||
const clause = node.exportClause;
|
||||
return (
|
||||
Boolean(clause) &&
|
||||
ts.isNamedExports(clause) &&
|
||||
clause.elements.length > 0 &&
|
||||
clause.elements.every((element) => element.isTypeOnly)
|
||||
node.isTypeOnly === true ||
|
||||
Boolean(
|
||||
clause &&
|
||||
ts.isNamedExports(clause) &&
|
||||
clause.elements.length > 0 &&
|
||||
clause.elements.every(ts.isTypeOnlyImportOrExportDeclaration),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function readDeclarationName(node) {
|
||||
function isExecuteDeclaration(node) {
|
||||
if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isVariableDeclaration(node)) &&
|
||||
node.name &&
|
||||
ts.isIdentifier(node.name)
|
||||
!ts.isFunctionDeclaration(node) &&
|
||||
!ts.isMethodDeclaration(node) &&
|
||||
!ts.isVariableDeclaration(node) &&
|
||||
!ts.isPropertyAssignment(node)
|
||||
) {
|
||||
return node.name.text;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ts.isPropertyAssignment(node)) {
|
||||
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) {
|
||||
return node.name.text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const name = ts.getNameOfDeclaration(node);
|
||||
return Boolean(
|
||||
name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === "execute",
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredTestHelperContent(content) {
|
||||
@@ -99,7 +79,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
|
||||
const staticRuntimeImports = new Map();
|
||||
const dynamicImports = new Map();
|
||||
const directExecuteImports = [];
|
||||
const declarationStack = [];
|
||||
|
||||
const addLine = (map, specifier, line) => {
|
||||
const lines = map.get(specifier) ?? [];
|
||||
@@ -108,11 +87,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
|
||||
};
|
||||
|
||||
const visit = (node) => {
|
||||
const declarationName = readDeclarationName(node);
|
||||
if (declarationName) {
|
||||
declarationStack.push(declarationName);
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
@@ -135,11 +109,11 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
const specifier = readStringLiteral(node.arguments[0]);
|
||||
const specifier = ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null;
|
||||
if (specifier) {
|
||||
const line = toLine(sourceFile, node);
|
||||
addLine(dynamicImports, specifier, line);
|
||||
if (declarationStack.includes("execute")) {
|
||||
if (ts.findAncestor(node, isExecuteDeclaration)) {
|
||||
directExecuteImports.push({
|
||||
line,
|
||||
reason: `direct dynamic import of "${specifier}" inside execute path; move it behind a cached loader`,
|
||||
@@ -149,9 +123,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
if (declarationName) {
|
||||
declarationStack.pop();
|
||||
}
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
|
||||
@@ -16,19 +16,9 @@ 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 asPropertyAccess(expression) {
|
||||
if (ts.isPropertyAccessExpression(expression)) {
|
||||
return expression;
|
||||
}
|
||||
if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) {
|
||||
return expression;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRawWindowOpenCall(expression) {
|
||||
const propertyAccess = asPropertyAccess(unwrapExpression(expression));
|
||||
if (!propertyAccess || propertyAccess.name.text !== "open") {
|
||||
const propertyAccess = unwrapExpression(expression);
|
||||
if (!ts.isPropertyAccessExpression(propertyAccess) || propertyAccess.name.text !== "open") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,13 +71,7 @@ async function collectViolations() {
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = readFileSync(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
|
||||
function push(kind, specifierNode, specifier) {
|
||||
const subpath = parsePluginSdkSubpath(specifier);
|
||||
|
||||
@@ -49,16 +49,6 @@ function normalizeRawCopyText(raw: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function lineNumberForOffset(source: string, offset: number): number {
|
||||
let line = 1;
|
||||
for (let index = 0; index < offset && index < source.length; index += 1) {
|
||||
if (source.charCodeAt(index) === 10) {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseDoubleQuotedString(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
@@ -118,6 +108,7 @@ export function collectControlUiRawCopyFromSource(params: {
|
||||
const { filePath, source, sourceFile } = params;
|
||||
const repoPath = toRepoPath(filePath);
|
||||
const findings: RawCopyFinding[] = [];
|
||||
const toLine = (offset: number) => sourceFile.getLineAndCharacterOfPosition(offset).line + 1;
|
||||
const staticAttrPattern =
|
||||
/\b(aria-label|placeholder|title)\s*=\s*"((?:(?!\$\{)[^"\\]|\\.)*?\p{L}(?:(?!\$\{)[^"\\]|\\.)*?)"/gu;
|
||||
for (const match of source.matchAll(staticAttrPattern)) {
|
||||
@@ -125,7 +116,7 @@ export function collectControlUiRawCopyFromSource(params: {
|
||||
if (rawText) {
|
||||
pushRawCopyFinding(findings, {
|
||||
kind: "html-attribute",
|
||||
line: lineNumberForOffset(source, match.index ?? 0),
|
||||
line: toLine(match.index ?? 0),
|
||||
name: match[1] ?? "attribute",
|
||||
path: repoPath,
|
||||
text: parseDoubleQuotedString(rawText),
|
||||
@@ -140,7 +131,7 @@ export function collectControlUiRawCopyFromSource(params: {
|
||||
if (rawText) {
|
||||
pushRawCopyFinding(findings, {
|
||||
kind: "object-property",
|
||||
line: lineNumberForOffset(source, match.index ?? 0),
|
||||
line: toLine(match.index ?? 0),
|
||||
name: match[1] ?? "property",
|
||||
path: repoPath,
|
||||
text: parseDoubleQuotedString(rawText),
|
||||
@@ -162,7 +153,7 @@ export function collectControlUiRawCopyFromSource(params: {
|
||||
...node.template.templateSpans.map((span) => span.literal.text),
|
||||
].join(INTERPOLATION_MARKER);
|
||||
}
|
||||
const line = lineNumberForOffset(source, node.template.getStart(sourceFile));
|
||||
const line = toLine(node.template.getStart(sourceFile));
|
||||
for (const match of logicalText.matchAll(attrPattern)) {
|
||||
const rawText = match[2];
|
||||
if (rawText?.includes(INTERPOLATION_MARKER)) {
|
||||
@@ -199,13 +190,7 @@ async function collectFindings(): Promise<RawCopyFinding[]> {
|
||||
const findings: RawCopyFinding[] = [];
|
||||
for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) {
|
||||
const source = await readFile(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
findings.push(...collectControlUiRawCopyFromSource({ filePath, source, sourceFile }));
|
||||
}
|
||||
return findings;
|
||||
|
||||
@@ -239,10 +239,9 @@ export function formatGroupedInventoryHuman(params, inventory) {
|
||||
/** Parse TypeScript files and collect sorted inventory entries from each source file. */
|
||||
export async function collectTypeScriptInventory(params) {
|
||||
const inventory = [];
|
||||
const scriptKind = params.scriptKind ?? params.ts.ScriptKind.TS;
|
||||
|
||||
for (const filePath of params.files) {
|
||||
const cacheKey = `${scriptKind}:${filePath}`;
|
||||
const cacheKey = `${params.scriptKind ?? "auto"}:${filePath}`;
|
||||
let sourceFile = parsedTypeScriptSourceCache.get(cacheKey);
|
||||
if (!sourceFile) {
|
||||
let source = sourceTextCache.get(filePath);
|
||||
@@ -258,7 +257,7 @@ export async function collectTypeScriptInventory(params) {
|
||||
source,
|
||||
params.ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
params.scriptKind,
|
||||
);
|
||||
parsedTypeScriptSourceCache.set(cacheKey, sourceFile);
|
||||
}
|
||||
|
||||
@@ -200,27 +200,8 @@ function isTempDirHelperImportSpec(filePath, specifier) {
|
||||
return stripKnownExtension(resolvedPath) === stripKnownExtension(TEMP_DIR_HELPER_PATH);
|
||||
}
|
||||
|
||||
function scriptKindForFile(filePath) {
|
||||
if (/\.[cm]?tsx$/u.test(filePath)) {
|
||||
return ts.ScriptKind.TSX;
|
||||
}
|
||||
if (/\.[cm]?jsx$/u.test(filePath)) {
|
||||
return ts.ScriptKind.JSX;
|
||||
}
|
||||
if (/\.[cm]?js$/u.test(filePath)) {
|
||||
return ts.ScriptKind.JS;
|
||||
}
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
|
||||
function createSourceFile(filePath, sourceText) {
|
||||
return ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKindForFile(filePath),
|
||||
);
|
||||
return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
function lineForNode(sourceFile, node) {
|
||||
|
||||
@@ -1619,11 +1619,16 @@ describe("ci workflow guards", () => {
|
||||
|
||||
it("bounds shared base commit fetches", () => {
|
||||
const action = readFileSync(".github/actions/ensure-base-commit/action.yml", "utf8");
|
||||
const exactFetch = action.indexOf('fetch_base_ref --no-tags --depth=1 origin "$BASE_SHA"');
|
||||
const branchDeepening = action.indexOf("for deepen_by in 25 100 300");
|
||||
|
||||
expect(action).toContain("fetch_base_ref()");
|
||||
expect(action).toContain("timeout --signal=TERM --kill-after=10s 30s git");
|
||||
expect(action).toContain("-c protocol.version=2");
|
||||
expect(action).not.toContain("if ! git fetch --no-tags");
|
||||
expect(exactFetch).toBeGreaterThan(-1);
|
||||
expect(branchDeepening).toBeGreaterThan(exactFetch);
|
||||
expect(action).toContain("::error title=ensure-base-commit missing base::");
|
||||
});
|
||||
|
||||
it("bounds early unauthenticated checkout fetches", () => {
|
||||
|
||||
Reference in New Issue
Block a user