mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
Merge remote-tracking branch 'origin/main' into jesse/non-clawhub-untrusted-installs
# Conflicts: # src/agents/tools/crestodian-tool.ts # src/cli/hooks-cli.test.ts # src/plugins/bundled-sources.ts
This commit is contained in:
+1226
-39
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}"
|
||||
SKIP_BUILD=0
|
||||
SKIP_INSTALL=0
|
||||
DRY_RUN=0
|
||||
SCENES=(home chat voice settings)
|
||||
SCENES=(home chat voice settings gateway voice-wake)
|
||||
EMULATOR_PID=""
|
||||
EMULATOR_LOG=""
|
||||
STARTED_EMULATOR=0
|
||||
@@ -455,6 +455,10 @@ scene_ready_text() {
|
||||
chat) printf '%s\n' "Ready when you are" ;;
|
||||
voice) printf '%s\n' "Ready to talk" ;;
|
||||
settings) printf '%s\n' "OpenClaw mobile" ;;
|
||||
voice-wake) printf '%s\n' "Wake listener" ;;
|
||||
# Connected fixtures can push Add Gateway below the composed viewport, so
|
||||
# wait for the gateway detail's always-visible subtitle instead.
|
||||
gateway) printf '%s\n' "Connection between this phone and OpenClaw." ;;
|
||||
*)
|
||||
echo "Unknown Android screenshot scene: $1" >&2
|
||||
return 1
|
||||
|
||||
+884
-148
File diff suppressed because it is too large
Load Diff
@@ -618,7 +618,6 @@ function describeCronSeamKinds(relativePath, source) {
|
||||
"./state.js",
|
||||
"../schedule.js",
|
||||
"../store.js",
|
||||
"../run-log.js",
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
|
||||
type CommandCase = {
|
||||
@@ -474,7 +475,7 @@ function parseRepeatableFlag(flag: string): string[] {
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
const value = process.argv[i + 1];
|
||||
if (process.argv[i] === flag && value && !value.startsWith("-")) {
|
||||
values.push(process.argv[i + 1]);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
@@ -483,7 +484,7 @@ function parseRepeatableFlag(flag: string): string[] {
|
||||
function validateCliArgs(argv: readonly string[] = process.argv.slice(2)): void {
|
||||
const seenSingleValueFlags = new Set<string>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const arg = expectDefined(argv[index], `CLI benchmark argument at index ${index}`);
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
if (arg !== "--case") {
|
||||
if (seenSingleValueFlags.has(arg)) {
|
||||
@@ -575,9 +576,13 @@ function median(values: number[]): number {
|
||||
const sorted = [...values].toSorted((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
return (
|
||||
(expectDefined(sorted[mid - 1], "lower middle CLI benchmark sample") +
|
||||
expectDefined(sorted[mid], "upper middle CLI benchmark sample")) /
|
||||
2
|
||||
);
|
||||
}
|
||||
return sorted[mid];
|
||||
return expectDefined(sorted[mid], "middle CLI benchmark sample");
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { writeGatewayRestartIntentSync } from "../src/infra/restart.js";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
import { delay, stopChild, type StopChildResult } from "./lib/gateway-bench-child.ts";
|
||||
@@ -342,7 +343,7 @@ function resolveOutputPath(raw: string | undefined): string | undefined {
|
||||
|
||||
function resolveCases(caseIds: string[]): GatewayBenchCase[] {
|
||||
if (caseIds.length === 0) {
|
||||
return [GATEWAY_CASES[0]];
|
||||
return [expectDefined(GATEWAY_CASES[0], "default gateway restart benchmark case")];
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
const byId = new Map(GATEWAY_CASES.map((benchCase) => [benchCase.id, benchCase]));
|
||||
@@ -412,7 +413,11 @@ function median(values: number[]): number {
|
||||
const sorted = [...values].toSorted((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
return (
|
||||
(expectDefined(sorted[middle - 1], "lower middle gateway restart sample") +
|
||||
expectDefined(sorted[middle], "upper middle gateway restart sample")) /
|
||||
2
|
||||
);
|
||||
}
|
||||
return sorted[middle] ?? 0;
|
||||
}
|
||||
@@ -510,8 +515,8 @@ function slope(values: Array<number | null>): number | null {
|
||||
if (points.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const first = expectDefined(points[0], "first gateway restart slope point");
|
||||
const last = expectDefined(points[points.length - 1], "last gateway restart slope point");
|
||||
const denominator = Math.max(1, last.index - first.index);
|
||||
return (last.value - first.value) / denominator;
|
||||
}
|
||||
@@ -910,10 +915,11 @@ function collectTraceLine(
|
||||
"u",
|
||||
).exec(line);
|
||||
if (phaseMatch) {
|
||||
trace[phaseMatch[1]] = Number(phaseMatch[2]);
|
||||
trace[`${phaseMatch[1]}.total`] = Number(phaseMatch[3]);
|
||||
const phase = expectDefined(phaseMatch[1], `${prefix} phase name`);
|
||||
trace[phase] = Number(expectDefined(phaseMatch[2], `${prefix} phase duration`));
|
||||
trace[`${phase}.total`] = Number(expectDefined(phaseMatch[3], `${prefix} total duration`));
|
||||
for (const metric of parseTraceMetrics(phaseMatch[4] ?? "")) {
|
||||
trace[`${phaseMatch[1]}.${metric.key}`] = metric.value;
|
||||
trace[`${phase}.${metric.key}`] = metric.value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -921,8 +927,10 @@ function collectTraceLine(
|
||||
if (!detailMatch) {
|
||||
return false;
|
||||
}
|
||||
for (const metric of parseTraceMetrics(detailMatch[2])) {
|
||||
trace[`${detailMatch[1]}.${metric.key}`] = metric.value;
|
||||
const phase = expectDefined(detailMatch[1], `${prefix} detail phase name`);
|
||||
const metrics = expectDefined(detailMatch[2], `${prefix} detail metrics`);
|
||||
for (const metric of parseTraceMetrics(metrics)) {
|
||||
trace[`${phase}.${metric.key}`] = metric.value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -934,8 +942,8 @@ function parseTraceMetrics(raw: string): Array<{ key: string; value: number }> {
|
||||
if (!metricMatch) {
|
||||
continue;
|
||||
}
|
||||
const key = metricMatch[1];
|
||||
const value = Number(metricMatch[2]);
|
||||
const key = expectDefined(metricMatch[1], "gateway restart trace metric key");
|
||||
const value = Number(expectDefined(metricMatch[2], `gateway restart ${key} metric value`));
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1687,4 +1695,3 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
import { delay, stopChild } from "./lib/gateway-bench-child.ts";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
readProcessTreeCpuMs,
|
||||
requestProbeStatus,
|
||||
} from "./lib/gateway-bench-probes.ts";
|
||||
import { selectSlowStartupTraceDurations } from "./lib/gateway-startup-trace-ranking.js";
|
||||
|
||||
type GatewayBenchCase = {
|
||||
config: Record<string, unknown>;
|
||||
@@ -351,7 +353,11 @@ function median(values: number[]): number {
|
||||
const sorted = [...values].toSorted((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
return (
|
||||
(expectDefined(sorted[middle - 1], "lower middle gateway startup sample") +
|
||||
expectDefined(sorted[middle], "upper middle gateway startup sample")) /
|
||||
2
|
||||
);
|
||||
}
|
||||
return sorted[middle] ?? 0;
|
||||
}
|
||||
@@ -535,7 +541,7 @@ function formatStats(stats: SummaryStats | null): string {
|
||||
return `p50=${formatMs(stats.p50)} avg=${formatMs(stats.avg)} min=${formatMs(stats.min)} max=${formatMs(stats.max)}`;
|
||||
}
|
||||
|
||||
function formatMemoryStats(stats: SummaryStats | null): string {
|
||||
function formatMemoryStats(stats: SummaryStats | null | undefined): string {
|
||||
if (!stats) {
|
||||
return "n/a";
|
||||
}
|
||||
@@ -549,13 +555,6 @@ function formatRatioStats(stats: SummaryStats | null): string {
|
||||
return `p50=${formatRatio(stats.p50)} avg=${formatRatio(stats.avg)} min=${formatRatio(stats.min)} max=${formatRatio(stats.max)}`;
|
||||
}
|
||||
|
||||
function getStartupTraceStat(
|
||||
startupTrace: Record<string, SummaryStats>,
|
||||
key: string,
|
||||
): SummaryStats | null {
|
||||
return startupTrace[key] ?? null;
|
||||
}
|
||||
|
||||
async function waitForProbe(params: {
|
||||
deadlineAt: number;
|
||||
isDone?: () => boolean;
|
||||
@@ -694,10 +693,13 @@ function sanitizedEnv(
|
||||
function collectStartupTrace(line: string, startupTrace: Record<string, number>): void {
|
||||
const phaseMatch = /startup trace: ([^ ]+) ([0-9.]+)ms total=([0-9.]+)ms(?: (.*))?/u.exec(line);
|
||||
if (phaseMatch) {
|
||||
startupTrace[phaseMatch[1]] = Number(phaseMatch[2]);
|
||||
startupTrace[`${phaseMatch[1]}.total`] = Number(phaseMatch[3]);
|
||||
const phase = expectDefined(phaseMatch[1], "gateway startup trace phase name");
|
||||
startupTrace[phase] = Number(expectDefined(phaseMatch[2], `${phase} startup duration`));
|
||||
startupTrace[`${phase}.total`] = Number(
|
||||
expectDefined(phaseMatch[3], `${phase} total startup duration`),
|
||||
);
|
||||
for (const metric of parseStartupTraceMetrics(phaseMatch[4] ?? "")) {
|
||||
startupTrace[`${phaseMatch[1]}.${metric.key}`] = metric.value;
|
||||
startupTrace[`${phase}.${metric.key}`] = metric.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -705,8 +707,10 @@ function collectStartupTrace(line: string, startupTrace: Record<string, number>)
|
||||
if (!detailMatch) {
|
||||
return;
|
||||
}
|
||||
for (const metric of parseStartupTraceMetrics(detailMatch[2])) {
|
||||
startupTrace[`${detailMatch[1]}.${metric.key}`] = metric.value;
|
||||
const phase = expectDefined(detailMatch[1], "gateway startup detail phase name");
|
||||
const detail = expectDefined(detailMatch[2], `${phase} startup detail metrics`);
|
||||
for (const metric of parseStartupTraceMetrics(detail)) {
|
||||
startupTrace[`${phase}.${metric.key}`] = metric.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,8 +731,8 @@ function parseStartupTraceMetrics(raw: string): Array<{ key: string; value: numb
|
||||
if (!metricMatch) {
|
||||
continue;
|
||||
}
|
||||
const key = metricMatch[1];
|
||||
const value = Number(metricMatch[2]);
|
||||
const key = expectDefined(metricMatch[1], "gateway startup trace metric key");
|
||||
const value = Number(expectDefined(metricMatch[2], `${key} startup trace metric value`));
|
||||
if (
|
||||
!Number.isFinite(value) ||
|
||||
(key !== "eventLoopMax" &&
|
||||
@@ -929,15 +933,12 @@ function printResult(result: CaseResult): void {
|
||||
console.log(` /readyz: ${formatStats(result.summary.readyzMs)}`);
|
||||
console.log(` max RSS: ${formatMemoryStats(result.summary.maxRssMb)}`);
|
||||
console.log(
|
||||
` ready memory: rss=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.ready.rssMb"))} heap=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.ready.heapUsedMb"))} external=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.ready.externalMb"))}`,
|
||||
` ready memory: rss=${formatMemoryStats(result.summary.startupTrace["memory.ready.rssMb"])} heap=${formatMemoryStats(result.summary.startupTrace["memory.ready.heapUsedMb"])} external=${formatMemoryStats(result.summary.startupTrace["memory.ready.externalMb"])}`,
|
||||
);
|
||||
console.log(
|
||||
` post-ready memory: rss=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.post-ready.rssMb"))} heap=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.post-ready.heapUsedMb"))} external=${formatMemoryStats(getStartupTraceStat(result.summary.startupTrace, "memory.post-ready.externalMb"))}`,
|
||||
` post-ready memory: rss=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.rssMb"])} heap=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.heapUsedMb"])} external=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.externalMb"])}`,
|
||||
);
|
||||
const trace = Object.entries(result.summary.startupTrace)
|
||||
.filter(([name]) => !name.endsWith(".total") && !name.startsWith("memory."))
|
||||
.toSorted((a, b) => (b[1].avg ?? 0) - (a[1].avg ?? 0))
|
||||
.slice(0, 8);
|
||||
const trace = selectSlowStartupTraceDurations(result.summary.startupTrace, 8);
|
||||
if (trace.length > 0) {
|
||||
console.log(" trace top:");
|
||||
for (const [name, stats] of trace) {
|
||||
@@ -1023,4 +1024,3 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Bench Model script supports OpenClaw repository automation.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { completeSimple, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
|
||||
type Usage = {
|
||||
@@ -111,9 +112,13 @@ function median(values: number[]): number {
|
||||
const sorted = [...values].toSorted((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
return Math.round(
|
||||
(expectDefined(sorted[mid - 1], "lower middle model benchmark sample") +
|
||||
expectDefined(sorted[mid], "upper middle model benchmark sample")) /
|
||||
2,
|
||||
);
|
||||
}
|
||||
return sorted[mid];
|
||||
return expectDefined(sorted[mid], "middle model benchmark sample");
|
||||
}
|
||||
|
||||
async function runModel(opts: {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SQLite reliability stress proof exercises snapshots during concurrent writes.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { CliUsageError, parseSqliteReliabilityCli } from "./lib/sqlite-reliability-cli.js";
|
||||
import type { ReliabilityReport } from "./lib/sqlite-reliability-contract.js";
|
||||
import { runReliabilityStress } from "./lib/sqlite-reliability-runner.js";
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`OpenClaw SQLite reliability stress proof
|
||||
|
||||
Usage:
|
||||
node --import tsx scripts/bench-sqlite-reliability.ts [options]
|
||||
|
||||
Options:
|
||||
--profile <smoke|default|large> Stress profile (default: default)
|
||||
--agent <id> Stress one per-agent database (default: global)
|
||||
--state-dir <path> Reuse a state directory and retain proof artifacts
|
||||
--repository <path> Snapshot repository path
|
||||
--output <path> Write machine-readable JSON report
|
||||
--help Show this text
|
||||
`);
|
||||
}
|
||||
|
||||
function printProofLines(report: ReliabilityReport): void {
|
||||
console.log(`SQLITE_RELIABILITY_PROFILE=${report.profile}`);
|
||||
console.log(`SQLITE_RELIABILITY_TARGET=${report.target}`);
|
||||
console.log(`SQLITE_RELIABILITY_PLATFORM=${report.platform}`);
|
||||
console.log(`SQLITE_RELIABILITY_ARCH=${report.arch}`);
|
||||
console.log(`SQLITE_RELIABILITY_ITERATIONS=${report.iterations}`);
|
||||
console.log(`SQLITE_RELIABILITY_RETAINED_BATCHES=${report.retainedBatches}`);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_CONCURRENT_RESTORES_VERIFIED=${report.concurrentRestoresVerified}`,
|
||||
);
|
||||
console.log(`SQLITE_RELIABILITY_RESTORES_VERIFIED=${report.restoresVerified}`);
|
||||
console.log(`SQLITE_RELIABILITY_WRITER_ROWS=${report.writer.rowsCommitted}`);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_WAL_SENTINEL=${report.transactionProof.committedWalSentinel ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(`SQLITE_RELIABILITY_HELD_BATCH=${report.transactionProof.heldBatch}`);
|
||||
console.log(`SQLITE_RELIABILITY_SNAPSHOT_P95_MS=${report.timingsMs.snapshotP95.toFixed(3)}`);
|
||||
console.log(`SQLITE_RELIABILITY_RESTORE_P95_MS=${report.timingsMs.restoreP95.toFixed(3)}`);
|
||||
console.log(`SQLITE_RELIABILITY_SNAPSHOT_BYTES_MAX=${report.snapshotBytes.max}`);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_COMPACT_RECLAIMED_BYTES=${report.maintenanceProof.compaction.reclaimedBytes}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_POST_COMPACT_RESTORE=${report.maintenanceProof.postCompact.restoreVerified ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(`SQLITE_RELIABILITY_FINAL_ROWS=${report.maintenanceProof.postCompact.state.rows}`);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_FINAL_STATE_SHA256=${report.maintenanceProof.postCompact.state.sha256}`,
|
||||
);
|
||||
console.log(`SQLITE_RELIABILITY_WAL_PEAK_BYTES=${report.walBytes.peak}`);
|
||||
console.log(`SQLITE_RELIABILITY_WAL_LIMIT_BYTES=${report.walBytes.limit}`);
|
||||
}
|
||||
|
||||
async function main(argv: string[]): Promise<void> {
|
||||
try {
|
||||
const cli = parseSqliteReliabilityCli(argv);
|
||||
if (cli.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const { options } = cli;
|
||||
const report = await runReliabilityStress(options);
|
||||
if (options.output) {
|
||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||
fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
printProofLines(report);
|
||||
} catch (error) {
|
||||
if (error instanceof CliUsageError) {
|
||||
console.error(`error: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main(process.argv.slice(2));
|
||||
}
|
||||
+41
-110
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
@@ -13,15 +14,18 @@ import {
|
||||
openOpenClawStateDatabase,
|
||||
} from "../src/state/openclaw-state-db.js";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
|
||||
type ProfileId = "smoke" | "default" | "large";
|
||||
import {
|
||||
CliUsageError,
|
||||
parseSqliteStateBenchmarkCli,
|
||||
type ProfileId,
|
||||
} from "./lib/sqlite-state-benchmark-cli.js";
|
||||
|
||||
type ProfileConfig = {
|
||||
agentCacheEntries: number;
|
||||
agentCount: number;
|
||||
channelIngressEvents: number;
|
||||
cronJobs: number;
|
||||
cronRunLogs: number;
|
||||
cronTaskRuns: number;
|
||||
deliveryQueueEntries: number;
|
||||
pluginStateEntries: number;
|
||||
queryRuns: number;
|
||||
@@ -53,7 +57,7 @@ type BenchmarkReport = {
|
||||
agentDatabases: number;
|
||||
channelIngressEvents: number;
|
||||
cronJobs: number;
|
||||
cronRunLogs: number;
|
||||
cronTaskRuns: number;
|
||||
deliveryQueueEntries: number;
|
||||
pluginStateEntries: number;
|
||||
stateRows: number;
|
||||
@@ -77,7 +81,7 @@ const PROFILES: Record<ProfileId, ProfileConfig> = {
|
||||
agentCount: 2,
|
||||
channelIngressEvents: 1_000,
|
||||
cronJobs: 100,
|
||||
cronRunLogs: 1_000,
|
||||
cronTaskRuns: 1_000,
|
||||
deliveryQueueEntries: 1_000,
|
||||
pluginStateEntries: 1_000,
|
||||
queryRuns: 12,
|
||||
@@ -87,7 +91,7 @@ const PROFILES: Record<ProfileId, ProfileConfig> = {
|
||||
agentCount: 5,
|
||||
channelIngressEvents: 10_000,
|
||||
cronJobs: 1_000,
|
||||
cronRunLogs: 50_000,
|
||||
cronTaskRuns: 50_000,
|
||||
deliveryQueueEntries: 50_000,
|
||||
pluginStateEntries: 20_000,
|
||||
queryRuns: 30,
|
||||
@@ -97,86 +101,13 @@ const PROFILES: Record<ProfileId, ProfileConfig> = {
|
||||
agentCount: 10,
|
||||
channelIngressEvents: 100_000,
|
||||
cronJobs: 5_000,
|
||||
cronRunLogs: 250_000,
|
||||
cronTaskRuns: 250_000,
|
||||
deliveryQueueEntries: 200_000,
|
||||
pluginStateEntries: 100_000,
|
||||
queryRuns: 40,
|
||||
},
|
||||
};
|
||||
|
||||
type CliOptions = {
|
||||
output: string | null;
|
||||
profile: ProfileId;
|
||||
stateDir: string | null;
|
||||
};
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--help"]);
|
||||
const VALUE_FLAGS = new Set(["--output", "--profile", "--state-dir"]);
|
||||
|
||||
class CliUsageError extends Error {
|
||||
override name = "CliUsageError";
|
||||
}
|
||||
|
||||
function parseFlagValue(flag: string, argv: string[]): string | undefined {
|
||||
const index = argv.indexOf(flag);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new CliUsageError(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasFlag(flag: string, argv = process.argv.slice(2)): boolean {
|
||||
return argv.includes(flag);
|
||||
}
|
||||
|
||||
function validateArgs(argv: string[]): void {
|
||||
const seenValueFlags = new Set<string>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
if (seenValueFlags.has(arg)) {
|
||||
throw new CliUsageError(`${arg} was provided more than once`);
|
||||
}
|
||||
seenValueFlags.add(arg);
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new CliUsageError(`${arg} requires a value`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new CliUsageError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProfile(raw: string | undefined): ProfileId {
|
||||
if (!raw) {
|
||||
return "default";
|
||||
}
|
||||
if (raw === "smoke" || raw === "default" || raw === "large") {
|
||||
return raw;
|
||||
}
|
||||
throw new CliUsageError(
|
||||
`--profile must be one of smoke, default, large; got ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseOptions(argv = process.argv.slice(2)): CliOptions {
|
||||
validateArgs(argv);
|
||||
return {
|
||||
output: parseFlagValue("--output", argv) ?? null,
|
||||
profile: parseProfile(parseFlagValue("--profile", argv)),
|
||||
stateDir: parseFlagValue("--state-dir", argv) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyScale(config: ProfileConfig): ProfileConfig {
|
||||
const scale = parseStrictIntegerOption({
|
||||
fallback: 1,
|
||||
@@ -192,7 +123,7 @@ function applyScale(config: ProfileConfig): ProfileConfig {
|
||||
agentCount: config.agentCount,
|
||||
channelIngressEvents: config.channelIngressEvents * scale,
|
||||
cronJobs: config.cronJobs * scale,
|
||||
cronRunLogs: config.cronRunLogs * scale,
|
||||
cronTaskRuns: config.cronTaskRuns * scale,
|
||||
deliveryQueueEntries: config.deliveryQueueEntries * scale,
|
||||
pluginStateEntries: config.pluginStateEntries * scale,
|
||||
queryRuns: config.queryRuns,
|
||||
@@ -236,7 +167,7 @@ function stateRowCount(config: ProfileConfig): number {
|
||||
return (
|
||||
config.channelIngressEvents +
|
||||
config.cronJobs +
|
||||
config.cronRunLogs +
|
||||
config.cronTaskRuns +
|
||||
config.deliveryQueueEntries +
|
||||
config.pluginStateEntries
|
||||
);
|
||||
@@ -246,7 +177,7 @@ function seedStateDatabase(db: DatabaseSync, config: ProfileConfig): void {
|
||||
db.exec("BEGIN IMMEDIATE;");
|
||||
try {
|
||||
seedCronJobs(db, config.cronJobs);
|
||||
seedCronRunLogs(db, config.cronRunLogs);
|
||||
seedCronTaskRuns(db, config.cronTaskRuns);
|
||||
seedDeliveryQueue(db, config.deliveryQueueEntries);
|
||||
seedPluginState(db, config.pluginStateEntries);
|
||||
seedChannelIngress(db, config.channelIngressEvents);
|
||||
@@ -313,38 +244,38 @@ function seedCronJobs(db: DatabaseSync, count: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function seedCronRunLogs(db: DatabaseSync, count: number): void {
|
||||
function seedCronTaskRuns(db: DatabaseSync, count: number): void {
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO cron_run_logs (
|
||||
store_key, job_id, seq, ts, status, error, summary, diagnostics_summary,
|
||||
delivery_status, delivery_error, delivered, session_id, session_key, run_id,
|
||||
run_at_ms, duration_ms, next_run_at_ms, model, provider, total_tokens,
|
||||
entry_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO task_runs (
|
||||
task_id, runtime, source_id, requester_session_key, owner_key, scope_kind,
|
||||
child_session_key, run_id, task, status, delivery_status, notify_policy,
|
||||
created_at, started_at, ended_at, last_event_at, error, terminal_summary,
|
||||
terminal_outcome, detail_json
|
||||
) VALUES (?, 'cron', ?, '', '', 'system', ?, ?, ?, ?, 'not_applicable', 'silent',
|
||||
?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const jobId = `job-${String(i % Math.max(1, Math.floor(count / 20))).padStart(8, "0")}`;
|
||||
const ts = 1_700_000_000_000 + i;
|
||||
const succeeded = i % 17 !== 0;
|
||||
const runId = `run-${i}`;
|
||||
const status = succeeded ? "ok" : "error";
|
||||
const storeKey = `/state/cron/jobs-${i % 8}.json`;
|
||||
insert.run(
|
||||
`/state/cron/jobs-${i % 8}.json`,
|
||||
`cron-benchmark-${i}`,
|
||||
jobId,
|
||||
Math.floor(i / 20),
|
||||
ts,
|
||||
i % 17 === 0 ? "failed" : "completed",
|
||||
`run ${i}`,
|
||||
i % 17 === 0 ? "failed" : "sent",
|
||||
i % 17 === 0 ? 0 : 1,
|
||||
`session-${i}`,
|
||||
`agent:agent-${i % 16}:main`,
|
||||
`run-${i}`,
|
||||
runId,
|
||||
jobId,
|
||||
succeeded ? "succeeded" : "failed",
|
||||
ts,
|
||||
20 + (i % 1_000),
|
||||
ts + 60_000,
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai",
|
||||
100 + (i % 2_000),
|
||||
JSON.stringify({ ts, jobId, action: "finished" }),
|
||||
ts,
|
||||
ts + 20 + (i % 1_000),
|
||||
ts + 20 + (i % 1_000),
|
||||
succeeded ? null : `run ${i} failed`,
|
||||
`run ${i}`,
|
||||
succeeded ? "succeeded" : null,
|
||||
JSON.stringify({ kind: "cron-run", storeKey, action: "finished", status, runId }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -459,7 +390,7 @@ function percentile(values: number[], pct: number): number {
|
||||
}
|
||||
const sorted = values.toSorted((left, right) => left - right);
|
||||
const index = Math.min(sorted.length - 1, Math.ceil((pct / 100) * sorted.length) - 1);
|
||||
return Number(sorted[index].toFixed(3));
|
||||
return Number(expectDefined(sorted[index], `SQLite benchmark percentile ${pct}`).toFixed(3));
|
||||
}
|
||||
|
||||
function runTimedQuery(
|
||||
@@ -569,12 +500,12 @@ function printProofLines(report: BenchmarkReport): void {
|
||||
|
||||
function main(): void {
|
||||
const argv = process.argv.slice(2);
|
||||
validateArgs(argv);
|
||||
if (hasFlag("--help", argv)) {
|
||||
const cli = parseSqliteStateBenchmarkCli(argv);
|
||||
if (cli.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const options = parseOptions(argv);
|
||||
const { options } = cli;
|
||||
const config = applyScale(PROFILES[options.profile]);
|
||||
const stateDir =
|
||||
options.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-perf-"));
|
||||
@@ -628,7 +559,7 @@ function main(): void {
|
||||
agentDatabases: config.agentCount,
|
||||
channelIngressEvents: config.channelIngressEvents,
|
||||
cronJobs: config.cronJobs,
|
||||
cronRunLogs: config.cronRunLogs,
|
||||
cronTaskRuns: config.cronTaskRuns,
|
||||
deliveryQueueEntries: config.deliveryQueueEntries,
|
||||
pluginStateEntries: config.pluginStateEntries,
|
||||
stateRows: stateRowCount(config),
|
||||
|
||||
+11
-7
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs";
|
||||
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
|
||||
@@ -586,13 +587,16 @@ export function restoreBuildAllStepCacheOutputs(cacheState, params = {}) {
|
||||
|
||||
export function formatBuildAllDuration(durationMs) {
|
||||
const clampedMs = Math.max(0, durationMs);
|
||||
if (clampedMs < 1000) {
|
||||
return `${Math.round(clampedMs)}ms`;
|
||||
}
|
||||
if (clampedMs < 10000) {
|
||||
return `${(clampedMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
return `${(clampedMs / 1000).toFixed(1)}s`;
|
||||
const roundedMs =
|
||||
clampedMs < 1000
|
||||
? Math.round(clampedMs)
|
||||
: clampedMs < 10_000
|
||||
? Math.round(clampedMs / 10) * 10
|
||||
: Math.round(clampedMs / 100) * 100;
|
||||
return prettyMilliseconds(roundedMs, {
|
||||
millisecondsDecimalDigits: 0,
|
||||
secondsDecimalDigits: clampedMs < 10_000 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBuildAllTimingSummary(timings) {
|
||||
|
||||
@@ -5,7 +5,6 @@ export type ChangedLane =
|
||||
| "extensions"
|
||||
| "extensionTests"
|
||||
| "scripts"
|
||||
| "strictRatchet"
|
||||
| "testRoot"
|
||||
| "apps"
|
||||
| "docs"
|
||||
@@ -28,7 +27,6 @@ export type DetectChangedLanesOptions = {
|
||||
packageJsonChangeKind?: "liveDockerTooling" | "tooling" | null;
|
||||
};
|
||||
|
||||
export function normalizeChangedPath(inputPath: unknown): string;
|
||||
export function createEmptyChangedLanes(): ChangedLanes;
|
||||
export function isChangedLaneTestPath(changedPath: string): boolean;
|
||||
export function detectChangedLanes(
|
||||
@@ -55,4 +53,3 @@ export function isPackageScriptOnlyChange(before: string, after: string): boolea
|
||||
|
||||
export const LIVE_DOCKER_AUTH_SHELL_TARGETS: string[];
|
||||
export const RELEASE_METADATA_PATHS: Set<string>;
|
||||
export const STRICT_RATCHET_PACKAGE_DIRS: string[];
|
||||
|
||||
@@ -5,7 +5,6 @@ import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
|
||||
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
||||
export { normalizeChangedPath } from "./lib/changed-path-facts.mjs";
|
||||
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
|
||||
@@ -13,28 +12,9 @@ const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
|
||||
|
||||
const SCRIPTS_TYPECHECK_PATH_RE =
|
||||
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
|
||||
// Keep aligned with tsconfig.strict-ratchet.json includes and its oxlint override.
|
||||
export const STRICT_RATCHET_PACKAGE_DIRS = [
|
||||
"packages/markdown-core",
|
||||
"packages/net-policy",
|
||||
"packages/media-understanding-common",
|
||||
"packages/terminal-core",
|
||||
"packages/normalization-core",
|
||||
"packages/model-catalog-core",
|
||||
"packages/web-content-core",
|
||||
"packages/ai",
|
||||
"packages/agent-core",
|
||||
"packages/acp-core",
|
||||
"packages/gateway-client",
|
||||
"packages/gateway-protocol",
|
||||
"packages/llm-core",
|
||||
"packages/media-core",
|
||||
"packages/media-generation-core",
|
||||
"packages/plugin-package-contract",
|
||||
"packages/sdk",
|
||||
];
|
||||
const TEST_ROOT_TYPECHECK_PATH_RE =
|
||||
/^(?:test\/(?!fixtures\/).*\.(?:[cm]?ts|[cm]?tsx)|test\/tsconfig\/tsconfig\.test\.root\.json)$/u;
|
||||
/** @internal Shared repository-script contract. */
|
||||
export const LIVE_DOCKER_AUTH_SHELL_TARGETS = [
|
||||
"scripts/lib/live-docker-auth.sh",
|
||||
"scripts/test-live-acp-bind-docker.sh",
|
||||
@@ -55,6 +35,7 @@ const PUBLIC_EXTENSION_CONTRACT_RE =
|
||||
/^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u;
|
||||
/**
|
||||
* Files whose changes are treated as release metadata only.
|
||||
* @internal Shared repository-script contract.
|
||||
*/
|
||||
export const RELEASE_METADATA_PATHS = new Set([
|
||||
"CHANGELOG.md",
|
||||
@@ -69,7 +50,7 @@ export const RELEASE_METADATA_PATHS = new Set([
|
||||
"package.json",
|
||||
]);
|
||||
|
||||
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "strictRatchet" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
|
||||
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -83,6 +64,7 @@ export const RELEASE_METADATA_PATHS = new Set([
|
||||
|
||||
/**
|
||||
* Creates the default changed-lanes result object.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function createEmptyChangedLanes() {
|
||||
return {
|
||||
@@ -92,7 +74,6 @@ export function createEmptyChangedLanes() {
|
||||
extensions: false,
|
||||
extensionTests: false,
|
||||
scripts: false,
|
||||
strictRatchet: false,
|
||||
testRoot: false,
|
||||
apps: false,
|
||||
docs: false,
|
||||
@@ -103,6 +84,7 @@ export function createEmptyChangedLanes() {
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Shared repository-script contract. */
|
||||
export function isChangedLaneTestPath(changedPath) {
|
||||
return getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest;
|
||||
}
|
||||
@@ -114,6 +96,7 @@ export function isChangedLaneTestPath(changedPath) {
|
||||
*/
|
||||
/**
|
||||
* Classifies a list of changed paths into docs, app, extension, core, and tooling lanes.
|
||||
* @internal Shared repository-script contract.
|
||||
*/
|
||||
export function detectChangedLanes(changedPaths, options = {}) {
|
||||
const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))]
|
||||
@@ -152,14 +135,6 @@ export function detectChangedLanes(changedPaths, options = {}) {
|
||||
if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) {
|
||||
lanes.scripts = true;
|
||||
}
|
||||
if (
|
||||
changedPath === "tsconfig.strict-ratchet.json" ||
|
||||
STRICT_RATCHET_PACKAGE_DIRS.some(
|
||||
(packageDir) => changedPath === packageDir || changedPath.startsWith(`${packageDir}/`),
|
||||
)
|
||||
) {
|
||||
lanes.strictRatchet = true;
|
||||
}
|
||||
if (TEST_ROOT_TYPECHECK_PATH_RE.test(changedPath)) {
|
||||
lanes.testRoot = true;
|
||||
}
|
||||
@@ -286,6 +261,7 @@ export function detectChangedLanes(changedPaths, options = {}) {
|
||||
*/
|
||||
/**
|
||||
* Classifies changed paths with optional package.json before/after contents.
|
||||
* @internal Shared repository-script contract.
|
||||
*/
|
||||
export function detectChangedLanesForPaths(params) {
|
||||
const base = params.staged
|
||||
@@ -423,6 +399,7 @@ function classifyPackageJsonChangeFromGit(params) {
|
||||
|
||||
/**
|
||||
* Checks whether package scripts changed only live Docker script entries.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function isLiveDockerPackageScriptOnlyChange(before, after) {
|
||||
const beforePackage = JSON.parse(before);
|
||||
@@ -440,6 +417,7 @@ export function isLiveDockerPackageScriptOnlyChange(before, after) {
|
||||
|
||||
/**
|
||||
* Checks whether package.json changes are limited to scripts.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function isPackageScriptOnlyChange(before, after) {
|
||||
const beforePackage = JSON.parse(before);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pMap from "p-map";
|
||||
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
|
||||
import {
|
||||
collectModuleReferencesFromSource,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
resolveRepoSpecifier,
|
||||
writeLine,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import { mapWithConcurrency } from "./lib/source-file-scan-cache.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
@@ -175,13 +175,17 @@ export async function collectArchitectureSmells() {
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
const entriesByFile = await mapWithConcurrency(files, undefined, async (filePath) => {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const entries = scanPluginSdkExtensionFacadeSmells(source, filePath);
|
||||
entries.push(...scanRuntimeTypeImplementationSmells(source, filePath));
|
||||
entries.push(...scanRuntimeServiceLocatorSmells(source, filePath));
|
||||
return entries;
|
||||
});
|
||||
const entriesByFile = await pMap(
|
||||
files,
|
||||
async (filePath) => {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const entries = scanPluginSdkExtensionFacadeSmells(source, filePath);
|
||||
entries.push(...scanRuntimeTypeImplementationSmells(source, filePath));
|
||||
entries.push(...scanRuntimeServiceLocatorSmells(source, filePath));
|
||||
return entries;
|
||||
},
|
||||
{ concurrency: 32, stopOnError: true },
|
||||
);
|
||||
return entriesByFile.flat().toSorted(compareEntries);
|
||||
})();
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolveLocalHeavyCheckEnv,
|
||||
} from "./lib/local-heavy-check-runtime.mjs";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mjs";
|
||||
import { isProductionTypeScriptFile } from "./lib/ts-loc-policy.mjs";
|
||||
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mjs";
|
||||
|
||||
const SHRINKWRAP_POLICY_PATH_RE =
|
||||
@@ -314,6 +315,16 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
};
|
||||
|
||||
add("conflict markers", ["check:no-conflict-markers"]);
|
||||
if (result.paths.some(isProductionTypeScriptFile)) {
|
||||
// Deliberately omit --head here: local changed checks must inspect worktree and untracked
|
||||
// content. Exact-tree CI calls check:loc directly with both refs.
|
||||
add("TypeScript LOC ratchet", [
|
||||
"check:loc",
|
||||
...(options.staged ? ["--staged"] : ["--base", options.base ?? "origin/main"]),
|
||||
"--",
|
||||
...result.paths,
|
||||
]);
|
||||
}
|
||||
add("changelog attributions", ["check:changelog-attributions"]);
|
||||
add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]);
|
||||
add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]);
|
||||
@@ -447,9 +458,6 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
if (lanes.scripts) {
|
||||
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
|
||||
}
|
||||
if (lanes.strictRatchet) {
|
||||
addTypecheck("typecheck strict ratchet", ["tsgo:strict-ratchet"]);
|
||||
}
|
||||
if (lanes.testRoot) {
|
||||
addTypecheck("typecheck test root", ["tsgo:test:root"]);
|
||||
}
|
||||
|
||||
@@ -97,8 +97,10 @@ function resolveDefaultLimitsMb(platform = process.platform) {
|
||||
help: platform === "darwin" ? 300 : 100,
|
||||
// Plugin discovery is heavier than help, but must stay below the doctor/channel
|
||||
// runtime graph that an empty metadata-only invocation must not import.
|
||||
pluginsList: platform === "darwin" ? 500 : 350,
|
||||
statusJson: 400,
|
||||
pluginsList: platform === "darwin" ? 500 : 400,
|
||||
// Node 24 status startup sits near 400 MB; retain regression signal without
|
||||
// failing on sub-megabyte runner RSS variance.
|
||||
statusJson: 425,
|
||||
gatewayStatus: 500,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ControlUiAssetMetrics = {
|
||||
file: string;
|
||||
type: "js" | "css";
|
||||
rawBytes: number;
|
||||
gzipBytes: number;
|
||||
brotliBytes: number;
|
||||
};
|
||||
|
||||
export type ControlUiAssetSummary = {
|
||||
requests: number;
|
||||
rawBytes: number;
|
||||
gzipBytes: number;
|
||||
brotliBytes: number;
|
||||
};
|
||||
|
||||
export type ControlUiPerformanceMetrics = {
|
||||
schemaVersion: 1;
|
||||
startup: {
|
||||
js: ControlUiAssetSummary;
|
||||
css: ControlUiAssetSummary;
|
||||
assets: ControlUiAssetMetrics[];
|
||||
};
|
||||
total: { js: ControlUiAssetSummary; css: ControlUiAssetSummary };
|
||||
largest: { js: ControlUiAssetMetrics; css: ControlUiAssetMetrics };
|
||||
};
|
||||
|
||||
export type ControlUiPerformanceBudgets = {
|
||||
startupJsRequests: number;
|
||||
startupCssRequests: number;
|
||||
startupJsGzipBytes: number;
|
||||
startupCssGzipBytes: number;
|
||||
largestJsGzipBytes: number;
|
||||
largestCssGzipBytes: number;
|
||||
};
|
||||
|
||||
export type ControlUiPerformanceBudgetViolation = {
|
||||
metric: string;
|
||||
actual: number;
|
||||
limit: number;
|
||||
unit: "count" | "bytes";
|
||||
};
|
||||
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly<ControlUiPerformanceBudgets>;
|
||||
export function extractControlUiStartupAssetPaths(html: string): string[];
|
||||
export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics;
|
||||
export function evaluateControlUiPerformanceBudgets(
|
||||
metrics: ControlUiPerformanceMetrics,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
): ControlUiPerformanceBudgetViolation[];
|
||||
export function formatControlUiPerformanceBytes(bytes: number): string;
|
||||
export function formatControlUiPerformanceReport(
|
||||
metrics: ControlUiPerformanceMetrics,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
): string;
|
||||
export function runControlUiPerformanceCheck(
|
||||
distDir: string,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
): {
|
||||
metrics: ControlUiPerformanceMetrics;
|
||||
budgets: Readonly<ControlUiPerformanceBudgets>;
|
||||
violations: ControlUiPerformanceBudgetViolation[];
|
||||
report: string;
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
// Reports and enforces compressed Control UI asset budgets after a production build.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const KIB = 1024;
|
||||
|
||||
// Small, explicit headroom over the optimized baseline. Budget changes should
|
||||
// accompany an intentional loading or chunking decision.
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 28,
|
||||
startupCssRequests: 1,
|
||||
startupJsGzipBytes: 370 * KIB,
|
||||
startupCssGzipBytes: 42 * KIB,
|
||||
largestJsGzipBytes: 215 * KIB,
|
||||
largestCssGzipBytes: 42 * KIB,
|
||||
});
|
||||
|
||||
function controlUiAssetPathFromUrl(value) {
|
||||
const normalized = value.split(/[?#]/u, 1)[0]?.replace(/\\/gu, "/") ?? "";
|
||||
const markerIndex = normalized.lastIndexOf("assets/");
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
const assetPath = normalized.slice(markerIndex);
|
||||
if (assetPath.includes("../") || !/\.(?:css|js)$/u.test(assetPath)) {
|
||||
return null;
|
||||
}
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
export function extractControlUiStartupAssetPaths(html) {
|
||||
const assets = new Set();
|
||||
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
|
||||
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
|
||||
const assetPath = attribute ? controlUiAssetPathFromUrl(attribute[1]) : null;
|
||||
if (assetPath) {
|
||||
assets.add(assetPath);
|
||||
}
|
||||
}
|
||||
return [...assets].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function readAssetMetrics(assetsDir, entry) {
|
||||
const file = `assets/${entry.name}`;
|
||||
const sourcePath = path.join(assetsDir, entry.name);
|
||||
const gzipPath = `${sourcePath}.gz`;
|
||||
const brotliPath = `${sourcePath}.br`;
|
||||
for (const sidecarPath of [gzipPath, brotliPath]) {
|
||||
if (!fs.existsSync(sidecarPath)) {
|
||||
throw new Error(`Control UI performance check missing ${path.basename(sidecarPath)}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
file,
|
||||
type: entry.name.endsWith(".js") ? "js" : "css",
|
||||
rawBytes: fs.statSync(sourcePath).size,
|
||||
gzipBytes: fs.statSync(gzipPath).size,
|
||||
brotliBytes: fs.statSync(brotliPath).size,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeAssets(assets) {
|
||||
return assets.reduce(
|
||||
(summary, asset) => ({
|
||||
requests: summary.requests + 1,
|
||||
rawBytes: summary.rawBytes + asset.rawBytes,
|
||||
gzipBytes: summary.gzipBytes + asset.gzipBytes,
|
||||
brotliBytes: summary.brotliBytes + asset.brotliBytes,
|
||||
}),
|
||||
{ requests: 0, rawBytes: 0, gzipBytes: 0, brotliBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function largestAsset(assets) {
|
||||
return assets.toSorted(
|
||||
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
|
||||
)[0];
|
||||
}
|
||||
|
||||
export function collectControlUiPerformanceMetrics(distDir) {
|
||||
const assetsDir = path.join(distDir, "assets");
|
||||
const html = fs.readFileSync(path.join(distDir, "index.html"), "utf8");
|
||||
const assets = fs
|
||||
.readdirSync(assetsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && /\.(?:css|js)$/u.test(entry.name))
|
||||
.map((entry) => readAssetMetrics(assetsDir, entry));
|
||||
const assetsByFile = new Map(assets.map((asset) => [asset.file, asset]));
|
||||
const startup = extractControlUiStartupAssetPaths(html).map((file) => {
|
||||
const asset = assetsByFile.get(file);
|
||||
if (!asset) {
|
||||
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
const jsAssets = assets.filter((asset) => asset.type === "js");
|
||||
const cssAssets = assets.filter((asset) => asset.type === "css");
|
||||
if (jsAssets.length === 0 || cssAssets.length === 0 || startup.length === 0) {
|
||||
throw new Error("Control UI performance check found an incomplete production bundle");
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
startup: {
|
||||
js: summarizeAssets(startup.filter((asset) => asset.type === "js")),
|
||||
css: summarizeAssets(startup.filter((asset) => asset.type === "css")),
|
||||
assets: startup,
|
||||
},
|
||||
total: {
|
||||
js: summarizeAssets(jsAssets),
|
||||
css: summarizeAssets(cssAssets),
|
||||
},
|
||||
largest: {
|
||||
js: largestAsset(jsAssets),
|
||||
css: largestAsset(cssAssets),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateControlUiPerformanceBudgets(
|
||||
metrics,
|
||||
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
||||
) {
|
||||
const checks = [
|
||||
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
|
||||
["startup CSS requests", metrics.startup.css.requests, budgets.startupCssRequests, "count"],
|
||||
["startup JS gzip", metrics.startup.js.gzipBytes, budgets.startupJsGzipBytes, "bytes"],
|
||||
["startup CSS gzip", metrics.startup.css.gzipBytes, budgets.startupCssGzipBytes, "bytes"],
|
||||
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
|
||||
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
|
||||
];
|
||||
return checks.flatMap(([metric, actual, limit, unit]) =>
|
||||
actual > limit ? [{ metric, actual, limit, unit }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function formatControlUiPerformanceBytes(bytes) {
|
||||
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
|
||||
}
|
||||
|
||||
function formatRequestCount(count) {
|
||||
return `${count} ${count === 1 ? "request" : "requests"}`;
|
||||
}
|
||||
|
||||
function formatAssetSummary(summary) {
|
||||
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip, ${formatControlUiPerformanceBytes(summary.brotliBytes)} br`;
|
||||
}
|
||||
|
||||
function formatViolation(violation) {
|
||||
const actual =
|
||||
violation.unit === "bytes"
|
||||
? formatControlUiPerformanceBytes(violation.actual)
|
||||
: String(violation.actual);
|
||||
const limit =
|
||||
violation.unit === "bytes"
|
||||
? formatControlUiPerformanceBytes(violation.limit)
|
||||
: String(violation.limit);
|
||||
return `${violation.metric}: ${actual} exceeds ${limit}`;
|
||||
}
|
||||
|
||||
export function formatControlUiPerformanceReport(
|
||||
metrics,
|
||||
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
||||
) {
|
||||
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
|
||||
const lines = [
|
||||
"Control UI performance:",
|
||||
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
|
||||
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, ${formatControlUiPerformanceBytes(budgets.startupCssGzipBytes)} gzip)`,
|
||||
` largest JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
|
||||
` largest CSS: ${metrics.largest.css.file}, ${formatControlUiPerformanceBytes(metrics.largest.css.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestCssGzipBytes)})`,
|
||||
` all JS: ${formatAssetSummary(metrics.total.js)}`,
|
||||
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
|
||||
];
|
||||
if (violations.length > 0) {
|
||||
lines.push(
|
||||
" violations:",
|
||||
...violations.map((violation) => ` - ${formatViolation(violation)}`),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
|
||||
const metrics = collectControlUiPerformanceMetrics(distDir);
|
||||
return {
|
||||
metrics,
|
||||
budgets,
|
||||
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
|
||||
report: formatControlUiPerformanceReport(metrics, budgets),
|
||||
};
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const unknown = argv.filter((arg) => arg !== "--json");
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown option: ${unknown[0]}`);
|
||||
}
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
|
||||
if (argv.includes("--json")) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${result.report}\n`);
|
||||
}
|
||||
if (result.violations.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Verifies each generated Control UI sidecar encodes the final emitted asset bytes.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const assetsDir = path.join(repoRoot, "dist", "control-ui", "assets");
|
||||
const errors = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const suffix = entry.name.endsWith(".br") ? ".br" : entry.name.endsWith(".gz") ? ".gz" : null;
|
||||
if (!suffix) {
|
||||
continue;
|
||||
}
|
||||
const sidecarPath = path.join(assetsDir, entry.name);
|
||||
const sourcePath = sidecarPath.slice(0, -suffix.length);
|
||||
try {
|
||||
const encoded = fs.readFileSync(sidecarPath);
|
||||
const decoded = suffix === ".br" ? brotliDecompressSync(encoded) : gunzipSync(encoded);
|
||||
const source = fs.readFileSync(sourcePath);
|
||||
if (!decoded.equals(source)) {
|
||||
errors.push(`${entry.name}: decoded bytes differ from ${path.basename(sourcePath)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
if (checked === 0) {
|
||||
errors.push("no precompressed Control UI assets found");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Control UI precompressed asset verification failed:\n${errors.join("\n")}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`verified ${checked} finalized Control UI sidecars\n`);
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
|
||||
export function parseKnipCompactUnusedExports(output: string): string[];
|
||||
/** Parses compact Knip export sections and reports whether Knip emitted one. */
|
||||
export function parseKnipCompactUnusedExportsResult(output: string): {
|
||||
entries: string[];
|
||||
sawExportSection: boolean;
|
||||
};
|
||||
/** Compares detected unused exports against the checked-in baseline. */
|
||||
export function compareUnusedExportsToBaseline(
|
||||
actualEntries: string[],
|
||||
baselineEntries: string[],
|
||||
optionalBaselineEntries?: string[],
|
||||
): {
|
||||
actual: string[];
|
||||
allowed: string[];
|
||||
unexpected: string[];
|
||||
stale: string[];
|
||||
duplicateAllowedCount: number;
|
||||
allowlistIsSorted: boolean;
|
||||
};
|
||||
/** Emits the checked-in baseline module used by --update. */
|
||||
export function formatUnusedExportBaseline(
|
||||
requiredEntries: string[],
|
||||
optionalEntries?: string[],
|
||||
): string;
|
||||
/** Checks Knip output against the current baseline. */
|
||||
export function checkUnusedExports(
|
||||
output: string,
|
||||
baselineEntries?: string[],
|
||||
optionalBaselineEntries?: string[],
|
||||
): {
|
||||
ok: boolean;
|
||||
comparison: {
|
||||
actual: string[];
|
||||
allowed: string[];
|
||||
unexpected: string[];
|
||||
stale: string[];
|
||||
duplicateAllowedCount: number;
|
||||
allowlistIsSorted: boolean;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
// Enforces a ratcheting baseline for Knip's unused exports.
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
|
||||
KNIP_UNUSED_EXPORT_BASELINE,
|
||||
} from "./deadcode-exports.baseline.mjs";
|
||||
import {
|
||||
compareStringListToAllowlist,
|
||||
isLikelyRepoFilePath,
|
||||
runKnip,
|
||||
uniqueSorted,
|
||||
} from "./deadcode-knip-runner.mjs";
|
||||
|
||||
const KNIP_ARGS = [
|
||||
"--config",
|
||||
"config/knip.config.ts",
|
||||
"--production",
|
||||
"--no-progress",
|
||||
"--reporter",
|
||||
"compact",
|
||||
"--include",
|
||||
"exports,types,enumMembers",
|
||||
"--no-config-hints",
|
||||
];
|
||||
|
||||
const BASELINE_HEADER = `// Pre-existing unused exports awaiting deletion.
|
||||
// New entries fail CI. After deleting dead code, run \`pnpm deadcode:exports:update\`.
|
||||
// Do not add entries to avoid fixing new findings.`;
|
||||
|
||||
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
|
||||
export function parseKnipCompactUnusedExportsResult(output) {
|
||||
const entries = [];
|
||||
let inExportSection = false;
|
||||
let sawExportSection = false;
|
||||
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const sectionMatch = /^Unused (exports|exported types|exported enum members) \(\d+\)$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (sectionMatch) {
|
||||
inExportSection = true;
|
||||
sawExportSection = true;
|
||||
continue;
|
||||
}
|
||||
if (/^Unused .+ \(\d+\)$/u.test(line)) {
|
||||
inExportSection = false;
|
||||
continue;
|
||||
}
|
||||
if (!inExportSection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf(": ");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const file = line.slice(0, separatorIndex).trim();
|
||||
if (!isLikelyRepoFilePath(file)) {
|
||||
continue;
|
||||
}
|
||||
const symbols = line.slice(separatorIndex + 2).split(", ");
|
||||
for (const symbol of symbols) {
|
||||
const trimmedSymbol = symbol.trim();
|
||||
if (trimmedSymbol) {
|
||||
entries.push(`${file}: ${trimmedSymbol}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: uniqueSorted(entries), sawExportSection };
|
||||
}
|
||||
|
||||
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
|
||||
export function parseKnipCompactUnusedExports(output) {
|
||||
return parseKnipCompactUnusedExportsResult(output).entries;
|
||||
}
|
||||
|
||||
/** Compares detected unused exports against the checked-in baseline. */
|
||||
export function compareUnusedExportsToBaseline(
|
||||
actualEntries,
|
||||
baselineEntries,
|
||||
optionalBaselineEntries = [],
|
||||
) {
|
||||
return compareStringListToAllowlist(actualEntries, baselineEntries, optionalBaselineEntries);
|
||||
}
|
||||
|
||||
function formatUnusedExportComparison(comparison) {
|
||||
const lines = [];
|
||||
if (!comparison.allowlistIsSorted) {
|
||||
lines.push("deadcode unused-export baseline is not sorted.");
|
||||
}
|
||||
if (comparison.duplicateAllowedCount > 0) {
|
||||
lines.push(
|
||||
`deadcode unused-export baseline contains ${comparison.duplicateAllowedCount} duplicate entr${
|
||||
comparison.duplicateAllowedCount === 1 ? "y" : "ies"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
if (comparison.unexpected.length > 0) {
|
||||
lines.push("Unexpected unused exports:");
|
||||
lines.push(...comparison.unexpected.map((entry) => ` ${entry}`));
|
||||
}
|
||||
if (comparison.stale.length > 0) {
|
||||
lines.push("Stale required baseline entries:");
|
||||
lines.push(...comparison.stale.map((entry) => ` ${entry}`));
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
lines.push("Run `pnpm deadcode:exports:update` after removing dead code.");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatArrayExport(name, entries) {
|
||||
if (entries.length === 0) {
|
||||
return `export const ${name} = [];`;
|
||||
}
|
||||
return `export const ${name} = [\n${entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n")}\n];`;
|
||||
}
|
||||
|
||||
/** Emits the checked-in baseline module used by --update. */
|
||||
export function formatUnusedExportBaseline(
|
||||
requiredEntries,
|
||||
optionalEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
|
||||
) {
|
||||
// Optional entries are platform-variant: promoting one into the required
|
||||
// list would make CI stale-fail on platforms where it is legitimately absent.
|
||||
const optionalSet = new Set(uniqueSorted(optionalEntries));
|
||||
return `${BASELINE_HEADER}\n${formatArrayExport(
|
||||
"KNIP_UNUSED_EXPORT_BASELINE",
|
||||
uniqueSorted(requiredEntries).filter((entry) => !optionalSet.has(entry)),
|
||||
)}\n\n// Platform-variant findings. Allowed when present; never required.\n${formatArrayExport(
|
||||
"KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE",
|
||||
uniqueSorted(optionalEntries),
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
/** Checks Knip output against the current baseline. */
|
||||
export function checkUnusedExports(
|
||||
output,
|
||||
baselineEntries = KNIP_UNUSED_EXPORT_BASELINE,
|
||||
optionalBaselineEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
|
||||
) {
|
||||
const actual = parseKnipCompactUnusedExports(output);
|
||||
const comparison = compareUnusedExportsToBaseline(
|
||||
actual,
|
||||
baselineEntries,
|
||||
optionalBaselineEntries,
|
||||
);
|
||||
return {
|
||||
ok:
|
||||
comparison.allowlistIsSorted &&
|
||||
comparison.duplicateAllowedCount === 0 &&
|
||||
comparison.unexpected.length === 0 &&
|
||||
comparison.stale.length === 0,
|
||||
comparison,
|
||||
message: formatUnusedExportComparison(comparison),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const update = process.argv.slice(2).includes("--update");
|
||||
const result = await runKnip(KNIP_ARGS, { scanName: "unused-export scan" });
|
||||
if (result.errorCode || result.status === null) {
|
||||
console.error(
|
||||
`deadcode unused-export scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
|
||||
result.errorMessage ? `: ${result.errorMessage}` : ""
|
||||
}`,
|
||||
);
|
||||
if (result.output) {
|
||||
console.error(result.output);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKnipCompactUnusedExportsResult(result.output);
|
||||
// Knip's compact reporter omits empty sections, so a clean scan (exit 0)
|
||||
// legitimately prints no export sections; sectionless output is only a
|
||||
// failure signal when Knip also exited nonzero (crash/config error).
|
||||
if (!parsed.sawExportSection && result.status !== 0) {
|
||||
console.error("deadcode unused-export scan produced no export sections.");
|
||||
if (result.output) {
|
||||
console.error(result.output);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const actual = parsed.entries;
|
||||
if (update) {
|
||||
const baselinePath = fileURLToPath(new URL("./deadcode-exports.baseline.mjs", import.meta.url));
|
||||
await writeFile(
|
||||
baselinePath,
|
||||
formatUnusedExportBaseline(actual, KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE),
|
||||
"utf8",
|
||||
);
|
||||
console.log(`[deadcode] Updated unused-export baseline with ${actual.length} entries.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = checkUnusedExports(result.output);
|
||||
if (!check.ok) {
|
||||
console.error(check.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`[deadcode] Knip unused-export baseline matched ${actual.length} entries.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,32 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs knip unused-file detection and compares results to the allowlist.
|
||||
import { spawn } from "node:child_process";
|
||||
// Runs Knip unused-file detection and compares results to the allowlist.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
compareStringListToAllowlist,
|
||||
isLikelyRepoFilePath,
|
||||
KNIP_MAX_BUFFER_BYTES,
|
||||
runKnip,
|
||||
uniqueSorted,
|
||||
} from "./deadcode-knip-runner.mjs";
|
||||
import {
|
||||
KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
|
||||
KNIP_UNUSED_FILE_ALLOWLIST,
|
||||
} from "./deadcode-unused-files.allowlist.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
|
||||
|
||||
const KNIP_VERSION = "6.8.0";
|
||||
/**
|
||||
* Timeout for the unused-file knip child process.
|
||||
*/
|
||||
const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* Grace period before force-killing a timed-out knip child process.
|
||||
*/
|
||||
const KNIP_KILL_GRACE_MS = 5_000;
|
||||
const KNIP_PROCESS_TREE_EXIT_POLL_MS = 25;
|
||||
const KNIP_POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
/**
|
||||
* Heartbeat interval used while knip runs without output.
|
||||
*/
|
||||
const KNIP_HEARTBEAT_MS = 60_000;
|
||||
/**
|
||||
* Maximum buffered knip output retained for diagnostics.
|
||||
*/
|
||||
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
export { KNIP_MAX_BUFFER_BYTES };
|
||||
|
||||
const KNIP_ARGS = [
|
||||
"--config",
|
||||
"config/knip.config.ts",
|
||||
@@ -38,23 +26,7 @@ const KNIP_ARGS = [
|
||||
"--no-config-hints",
|
||||
];
|
||||
|
||||
function normalizeRepoPath(value) {
|
||||
return value.replaceAll("\\", "/").replace(/^\.\//u, "");
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values.map(normalizeRepoPath))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyRepoFilePath(value) {
|
||||
return /^(apps|docs|extensions|packages|scripts|src|test|ui)\//u.test(normalizeRepoPath(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses compact knip output into unused file paths.
|
||||
*/
|
||||
/** Parses compact Knip output into unused file paths. */
|
||||
export function parseKnipCompactUnusedFiles(output) {
|
||||
const files = [];
|
||||
let inUnusedFilesSection = false;
|
||||
@@ -71,10 +43,7 @@ export function parseKnipCompactUnusedFiles(output) {
|
||||
}
|
||||
|
||||
const separatorIndex = line.lastIndexOf(": ");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
if (sawUnusedFilesSection && !inUnusedFilesSection) {
|
||||
if (separatorIndex === -1 || (sawUnusedFilesSection && !inUnusedFilesSection)) {
|
||||
continue;
|
||||
}
|
||||
const file = line.slice(separatorIndex + 2).trim();
|
||||
@@ -86,34 +55,15 @@ export function parseKnipCompactUnusedFiles(output) {
|
||||
return uniqueSorted(files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares detected unused files against the checked-in allowlist.
|
||||
*/
|
||||
/** Compares detected unused files against the checked-in allowlist. */
|
||||
export function compareUnusedFilesToAllowlist(
|
||||
actualFiles,
|
||||
allowlistFiles,
|
||||
optionalAllowlistFiles = [],
|
||||
) {
|
||||
const actual = uniqueSorted(actualFiles);
|
||||
const allowed = uniqueSorted(allowlistFiles);
|
||||
const optionalAllowed = uniqueSorted(optionalAllowlistFiles);
|
||||
const allowedOrOptionalSet = new Set([...allowed, ...optionalAllowed]);
|
||||
const actualSet = new Set(actual);
|
||||
|
||||
return {
|
||||
actual,
|
||||
allowed,
|
||||
unexpected: actual.filter((file) => !allowedOrOptionalSet.has(file)),
|
||||
stale: allowed.filter((file) => !actualSet.has(file)),
|
||||
duplicateAllowedCount: allowlistFiles.length - new Set(allowlistFiles).size,
|
||||
allowlistIsSorted:
|
||||
JSON.stringify(allowlistFiles.map(normalizeRepoPath)) === JSON.stringify(allowed),
|
||||
};
|
||||
return compareStringListToAllowlist(actualFiles, allowlistFiles, optionalAllowlistFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats unused-file allowlist drift for CLI output.
|
||||
*/
|
||||
function formatUnusedFileComparison(comparison) {
|
||||
const lines = [];
|
||||
if (!comparison.allowlistIsSorted) {
|
||||
@@ -137,244 +87,12 @@ function formatUnusedFileComparison(comparison) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function spawnErrorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
function signalProcessTree(child, signal) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
process.kill(child.pid, signal);
|
||||
} else {
|
||||
process.kill(-child.pid, signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the timeout and signal delivery.
|
||||
}
|
||||
}
|
||||
|
||||
function processTreeAlive(child) {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessTreeExit(child, timeoutMs) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, KNIP_PROCESS_TREE_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processTreeAlive(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs knip and returns parsed unused-file results.
|
||||
*/
|
||||
/** Runs Knip and returns parsed unused-file results. */
|
||||
export async function runKnipUnusedFiles(params = {}) {
|
||||
const run = params.spawnCommand ?? spawn;
|
||||
const timeoutMs = params.timeoutMs ?? KNIP_TIMEOUT_MS;
|
||||
const heartbeatMs = params.heartbeatMs ?? KNIP_HEARTBEAT_MS;
|
||||
const maxBufferBytes = params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES;
|
||||
const killGraceMs = params.killGraceMs ?? KNIP_KILL_GRACE_MS;
|
||||
const writeStatus = params.writeStatus ?? ((message) => process.stderr.write(`${message}\n`));
|
||||
const args = [
|
||||
"--config.minimum-release-age=0",
|
||||
"dlx",
|
||||
"--package",
|
||||
`knip@${KNIP_VERSION}`,
|
||||
"knip",
|
||||
...KNIP_ARGS,
|
||||
];
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let bufferExceeded = false;
|
||||
let outputBytes = 0;
|
||||
const output = [];
|
||||
let killTimer;
|
||||
let exitStatus = null;
|
||||
let exitSignal = null;
|
||||
|
||||
const pnpm = createPnpmRunnerSpawnSpec({
|
||||
detached: process.platform !== "win32",
|
||||
env: params.env,
|
||||
nodeExecPath: params.nodeExecPath,
|
||||
npmExecPath: params.npmExecPath,
|
||||
platform: params.platform,
|
||||
pnpmArgs: args,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const child = run(pnpm.command, pnpm.args, {
|
||||
...pnpm.options,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const parentSignalHandlers = [];
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const { signal, handler } of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
parentSignalHandlers.length = 0;
|
||||
};
|
||||
const relayParentSignal = (signal) => {
|
||||
const handler = () => {
|
||||
signalProcessTree(child, signal);
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
cleanupParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
parentSignalHandlers.push({ signal, handler });
|
||||
process.once(signal, handler);
|
||||
};
|
||||
if (process.platform !== "win32") {
|
||||
relayParentSignal("SIGINT");
|
||||
relayParentSignal("SIGTERM");
|
||||
relayParentSignal("SIGHUP");
|
||||
}
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan still running after ${Math.round(
|
||||
(Date.now() - startedAt) / 1000,
|
||||
)}s.`,
|
||||
);
|
||||
}, heartbeatMs);
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
clearInterval(heartbeatTimer);
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan timed out after ${Math.round(timeoutMs / 1000)}s; terminating.`,
|
||||
);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(killTimer);
|
||||
cleanupParentSignalHandlers();
|
||||
resolve({
|
||||
...result,
|
||||
output: output.join(""),
|
||||
});
|
||||
};
|
||||
const finishAfterProcessTreeCleanup = async (result) => {
|
||||
if (processTreeAlive(child)) {
|
||||
await waitForProcessTreeExit(child, killGraceMs);
|
||||
}
|
||||
if (processTreeAlive(child)) {
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
await waitForProcessTreeExit(child, KNIP_POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
finish(result);
|
||||
};
|
||||
|
||||
const appendOutput = (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remainingBytes = maxBufferBytes - outputBytes;
|
||||
if (buffer.length <= remainingBytes) {
|
||||
output.push(buffer.toString("utf8"));
|
||||
outputBytes += buffer.length;
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
|
||||
outputBytes = maxBufferBytes;
|
||||
}
|
||||
if (!bufferExceeded) {
|
||||
bufferExceeded = true;
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan exceeded ${maxBufferBytes} output bytes; terminating.`,
|
||||
);
|
||||
child.stdout?.off?.("data", appendOutput);
|
||||
child.stderr?.off?.("data", appendOutput);
|
||||
child.stdout?.destroy?.();
|
||||
child.stderr?.destroy?.();
|
||||
clearInterval(heartbeatTimer);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", appendOutput);
|
||||
child.stderr?.on("data", appendOutput);
|
||||
child.on("error", (error) =>
|
||||
finish({
|
||||
errorCode: spawnErrorCode(error),
|
||||
errorMessage: error.message,
|
||||
signal: null,
|
||||
status: null,
|
||||
}),
|
||||
);
|
||||
child.on("exit", (status, signal) => {
|
||||
exitStatus = status;
|
||||
exitSignal = signal;
|
||||
});
|
||||
child.on("close", (status, signal) => {
|
||||
exitStatus = exitStatus ?? status;
|
||||
exitSignal = exitSignal ?? signal;
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (timedOut) {
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ETIMEDOUT",
|
||||
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ENOBUFS",
|
||||
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
errorCode: undefined,
|
||||
errorMessage: undefined,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
return await runKnip(KNIP_ARGS, { ...params, scanName: "unused-file scan" });
|
||||
}
|
||||
/**
|
||||
* Checks detected unused files against the current allowlist.
|
||||
*/
|
||||
|
||||
/** Checks detected unused files against the current allowlist. */
|
||||
export function checkUnusedFiles(
|
||||
output,
|
||||
allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path, { dirname, join, resolve } from "node:path";
|
||||
import pMap from "p-map";
|
||||
import { parsePositiveInt } from "./lib/numeric-options.mjs";
|
||||
import {
|
||||
forwardSignalToVitestProcessGroup,
|
||||
@@ -631,29 +632,29 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
|
||||
export async function runNodeStepsWithConcurrency(steps, concurrency) {
|
||||
const abortController = new AbortController();
|
||||
let firstFailure = null;
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({ length: Math.min(concurrency, steps.length) }, async () => {
|
||||
while (true) {
|
||||
await pMap(
|
||||
steps,
|
||||
async (step) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= steps.length) {
|
||||
return;
|
||||
try {
|
||||
step.onStart?.();
|
||||
const result = await runNodeStepAsync(step.label, step.args, step.timeoutMs, {
|
||||
abortController,
|
||||
onFailure(error) {
|
||||
firstFailure ??= error;
|
||||
},
|
||||
});
|
||||
step.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
// Keep the mapper fulfilled so pMap waits for active process-group cleanup.
|
||||
firstFailure ??= error;
|
||||
abortSiblingSteps(abortController);
|
||||
}
|
||||
const step = steps[index];
|
||||
step.onStart?.();
|
||||
const result = await runNodeStepAsync(step.label, step.args, step.timeoutMs, {
|
||||
abortController,
|
||||
onFailure(error) {
|
||||
firstFailure ??= error;
|
||||
},
|
||||
});
|
||||
step.onSuccess?.(result);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
},
|
||||
{ concurrency, stopOnError: false },
|
||||
);
|
||||
if (firstFailure) {
|
||||
throw toLintErrorObject(firstFailure, "Non-Error thrown");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
// Check File Utils helper supports OpenClaw script workflows.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]);
|
||||
export const REPO_SCAN_ROOTS = ["src", "test", "extensions", "packages", "ui", "scripts"] as const;
|
||||
export const REPO_SCAN_SKIPPED_DIR_NAMES: ReadonlySet<string> = new Set([
|
||||
".artifacts",
|
||||
".generated",
|
||||
"coverage",
|
||||
"dist",
|
||||
"fixtures",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
]);
|
||||
|
||||
export function isCodeFile(filePath: string): boolean {
|
||||
if (filePath.endsWith(".d.ts")) {
|
||||
@@ -11,6 +22,20 @@ export function isCodeFile(filePath: string): boolean {
|
||||
return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath);
|
||||
}
|
||||
|
||||
export function isTestRelatedFile(relativePath: string): boolean {
|
||||
return (
|
||||
/(?:^|[/.])(?:test|spec)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
||||
/\.(?:e2e|live)\.test\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
||||
/\.(?:test-helpers|test-utils|test-harness|test-support)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
||||
/-(?:test-helpers|test-utils|test-harness|test-support)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
||||
/(?:^|\/)(?:test|tests|test-helpers|test-utils|test-harness|test-support)\//u.test(
|
||||
relativePath,
|
||||
) ||
|
||||
relativePath.startsWith("scripts/e2e/") ||
|
||||
/^scripts\/.*-(?:client|e2e|harness|probe|smoke)\.[cm]?[jt]s$/u.test(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectFilesSync(
|
||||
rootDir: string,
|
||||
options: {
|
||||
@@ -51,6 +76,42 @@ export function collectFilesSync(
|
||||
return files;
|
||||
}
|
||||
|
||||
export function listRepoFilesSync(
|
||||
repoRoot: string,
|
||||
options: {
|
||||
includeFile: (relativePath: string) => boolean;
|
||||
roots?: readonly string[];
|
||||
skipDirNames?: ReadonlySet<string>;
|
||||
},
|
||||
): string[] {
|
||||
const roots = options.roots ?? REPO_SCAN_ROOTS;
|
||||
try {
|
||||
return execFileSync("git", ["-C", repoRoot, "ls-files", "--", ...roots], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map(toPosixPath)
|
||||
.filter(options.includeFile)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return roots
|
||||
.flatMap((root) => {
|
||||
const absoluteRoot = path.join(repoRoot, root);
|
||||
if (!fs.existsSync(absoluteRoot)) {
|
||||
return [];
|
||||
}
|
||||
return collectFilesSync(absoluteRoot, {
|
||||
includeFile: (filePath) =>
|
||||
options.includeFile(toPosixPath(path.relative(repoRoot, filePath))),
|
||||
skipDirNames: options.skipDirNames ?? REPO_SCAN_SKIPPED_DIR_NAMES,
|
||||
}).map((filePath) => toPosixPath(path.relative(repoRoot, filePath)));
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
}
|
||||
|
||||
export function toPosixPath(filePath: string): string {
|
||||
if (path.sep === "/") {
|
||||
return filePath;
|
||||
|
||||
@@ -19,15 +19,9 @@ const ts = require("typescript");
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const sourceRoots = [path.join(repoRoot, "src")];
|
||||
|
||||
const kyselyRawAllowPaths = new Set([
|
||||
"src/infra/kysely-node-sqlite.test.ts",
|
||||
"src/infra/kysely-sync.ts",
|
||||
]);
|
||||
const kyselyRawAllowPaths = new Set(["src/infra/kysely-sync.ts"]);
|
||||
|
||||
const compiledRawAllowPaths = new Set([
|
||||
"src/infra/kysely-node-sqlite.ts",
|
||||
"src/infra/kysely-node-sqlite.test.ts",
|
||||
]);
|
||||
const compiledRawAllowPaths = new Set(["src/infra/kysely-node-sqlite.ts"]);
|
||||
|
||||
const rawSqliteAllowPathGroups = {
|
||||
"native Kysely adapter and sync execution": [
|
||||
@@ -36,36 +30,48 @@ const rawSqliteAllowPathGroups = {
|
||||
],
|
||||
"SQLite database lifecycle, schema, transactions, and pragmas": [
|
||||
"src/infra/node-sqlite.ts",
|
||||
"src/infra/sqlite-index-schema.ts",
|
||||
"src/infra/sqlite-integrity.ts",
|
||||
"src/infra/sqlite-pragma.test-support.ts",
|
||||
"src/infra/sqlite-schema-contract.ts",
|
||||
"src/infra/sqlite-transaction.ts",
|
||||
"src/infra/sqlite-user-version.ts",
|
||||
"src/infra/sqlite-wal.ts",
|
||||
"src/state/openclaw-agent-db-session-migrations.ts",
|
||||
"src/state/openclaw-agent-db-session-provenance.ts",
|
||||
"src/state/openclaw-agent-db.ts",
|
||||
"src/state/openclaw-state-db-schema-helpers.ts",
|
||||
"src/state/openclaw-state-db.ts",
|
||||
"src/state/sqlite-schema-shape.test-support.ts",
|
||||
],
|
||||
"backup snapshot maintenance": ["src/commands/backup-verify.ts", "src/infra/backup-create.ts"],
|
||||
"backup snapshot maintenance": [
|
||||
"src/commands/backup-verify.ts",
|
||||
"src/infra/backup-create.ts",
|
||||
"src/snapshot/local-repository.ts",
|
||||
],
|
||||
"agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"],
|
||||
"read-only shared state database access": ["src/state/openclaw-state-db-readonly.ts"],
|
||||
"read-only SQLite status probes": [
|
||||
"src/commands/doctor-db-bloat.ts",
|
||||
"src/commands/status.scan.shared.ts",
|
||||
],
|
||||
"doctor SQLite maintenance and legacy state migration": [
|
||||
"src/commands/doctor/cron/legacy-run-log-migration.ts",
|
||||
"src/commands/doctor/cron/migration-ledger.ts",
|
||||
"src/commands/doctor-sqlite-compact.ts",
|
||||
"src/commands/doctor-session-sqlite.ts",
|
||||
"src/commands/doctor-session-sqlite-readers.ts",
|
||||
"src/commands/doctor-session-sqlite-recover-report.ts",
|
||||
"src/commands/doctor-state-sqlite-compact.ts",
|
||||
"src/infra/state-migrations.ts",
|
||||
"src/infra/state-migrations.task-sidecar-rows.ts",
|
||||
"src/infra/state-migrations.storage.ts",
|
||||
"src/infra/state-migrations.cron-run-logs.ts",
|
||||
"src/infra/state-migrations.debug-proxy.ts",
|
||||
],
|
||||
"shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"],
|
||||
"Kysely-backed stores that own a DatabaseSync boundary": [
|
||||
"src/acp/event-ledger.ts",
|
||||
"src/agents/subagent-registry.store.ts",
|
||||
"src/cron/run-log.ts",
|
||||
"src/cron/run-log/sqlite-store.ts",
|
||||
"src/cron/store.ts",
|
||||
"src/infra/outbound/current-conversation-bindings.ts",
|
||||
"src/media/store.ts",
|
||||
|
||||
@@ -55,14 +55,6 @@ function findViolations(content, filePath) {
|
||||
reason: "readChannelAllowFromStore call must pass explicit accountId as 3rd arg",
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
callName === "readLegacyChannelAllowFromStore" ||
|
||||
callName === "readLegacyChannelAllowFromStoreSync"
|
||||
) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `${callName} is legacy-only; use account-scoped readChannelAllowFromStore* APIs`,
|
||||
});
|
||||
} else if (callName === "upsertChannelPairingRequest") {
|
||||
const firstArg = node.arguments[0];
|
||||
if (!firstArg || !hasRequiredAccountIdProperty(firstArg)) {
|
||||
|
||||
@@ -16,6 +16,8 @@ export function extractSwiftHandledEvents(
|
||||
source: unknown,
|
||||
constants?: Map<unknown, unknown>,
|
||||
): Set<unknown>;
|
||||
/** Extracts generated Kotlin enum entries whose constructor stores a wire string. */
|
||||
export function extractKotlinEnumStringConstants(source: unknown): Map<string, string>;
|
||||
/**
|
||||
* Extracts event names a Kotlin source handles: string-literal case labels of
|
||||
* `when (event)` blocks plus `event == "..."` comparisons, both scoped to
|
||||
@@ -26,7 +28,10 @@ export function extractSwiftHandledEvents(
|
||||
* stays tree-wide because Swift consumption always reads `.event` off a
|
||||
* received EventFrame, which does not have that false-positive shape.
|
||||
*/
|
||||
export function extractKotlinHandledEvents(source: unknown): Set<unknown>;
|
||||
export function extractKotlinHandledEvents(
|
||||
source: unknown,
|
||||
constants?: ReadonlyMap<string, string>,
|
||||
): Set<string>;
|
||||
/**
|
||||
* Compares a client's handled events against the gateway catalog and its
|
||||
* allowlist. Returns human-readable error strings. Client-only names (e.g. the
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
// `.event == "..."` comparisons, and Kotlin `when (event) { "..." -> }` blocks
|
||||
// plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies.
|
||||
// Swift case labels may use qualified static string constants; those are
|
||||
// resolved across the scanned source tree so deleting the real handler cannot
|
||||
// hide behind an allowlist entry. Events a client intentionally does not
|
||||
// consume live in scripts/protocol-event-coverage.allowlist.json.
|
||||
// resolved across the scanned source tree. Kotlin labels and comparisons may
|
||||
// likewise use generated enum `rawValue` constants. Events a client
|
||||
// intentionally does not consume live in scripts/protocol-event-coverage.allowlist.json.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -26,9 +26,11 @@ const ALLOWLIST_FILE = "scripts/protocol-event-coverage.allowlist.json";
|
||||
|
||||
// Scan roots per client. The sentinel files are the primary event dispatch
|
||||
// surfaces; if one moves, the check must fail loudly instead of silently
|
||||
// passing with an empty handled set.
|
||||
// passing with an empty handled set. Apple event mapping is shared by iOS and
|
||||
// macOS, so the iOS coverage owner lives in OpenClawChatUI.
|
||||
const IOS_SCAN_ROOTS = ["apps/ios/Sources", "apps/shared/OpenClawKit/Sources"];
|
||||
const IOS_SENTINEL_FILE = "apps/ios/Sources/Chat/IOSGatewayChatTransport.swift";
|
||||
const IOS_SENTINEL_FILE =
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift";
|
||||
const ANDROID_SCAN_ROOT = "apps/android/app/src/main/java/ai/openclaw/app";
|
||||
const ANDROID_SENTINEL_FILES = [
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
|
||||
@@ -52,9 +54,15 @@ const KOTLIN_EVENT_WHEN_RE = /\bwhen\s*\(\s*event\s*\)\s*\{/u;
|
||||
// Handlers named differently surface as loud "unhandled" failures, which is
|
||||
// the safe direction for a coverage gate.
|
||||
const KOTLIN_HANDLER_FUN_RE = /\bfun\s+handle\w*Event\s*\(/u;
|
||||
const KOTLIN_CASE_LABEL_RE = /^\s*((?:"[^"]+"\s*,\s*)*"[^"]+")\s*->/u;
|
||||
const KOTLIN_CASE_SEGMENT_RE = /^\s*(.+?)\s*->/u;
|
||||
const KOTLIN_ENUM_DECLARATION_RE = /^\s*enum\s+class\s+([A-Za-z_]\w*)\s*\(/u;
|
||||
const KOTLIN_ENUM_STRING_ENTRY_RE = /^\s*([A-Za-z_]\w*)\s*\(\s*"([^"]+)"\s*\)\s*,?/u;
|
||||
const KOTLIN_STRING_CASE_EXPRESSION_RE = /^"([^"]+)"$/u;
|
||||
const KOTLIN_RAW_VALUE_EXPRESSION_RE = /^([A-Za-z_]\w*\.[A-Za-z_]\w*)\.rawValue$/u;
|
||||
const SWIFT_EVENT_COMPARISON_RE = /\.event\s*==\s*"([^"]+)"/gu;
|
||||
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"/gu;
|
||||
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"(?!\s*(?:\.|\?\.|\+|\[|\())/gu;
|
||||
const KOTLIN_EVENT_CONSTANT_COMPARISON_RE =
|
||||
/\bevent\s*==\s*([A-Za-z_]\w*\.[A-Za-z_]\w*)\.rawValue\b(?!\s*(?:\.|\?\.|\+|\[|\())/gu;
|
||||
const STRING_LITERAL_RE = /"([^"]+)"/gu;
|
||||
|
||||
function isMainModule() {
|
||||
@@ -151,6 +159,129 @@ function pushStringLiterals(segment, names) {
|
||||
}
|
||||
}
|
||||
|
||||
function stripKotlinComments(source) {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
let blockDepth = 0;
|
||||
let lineComment = false;
|
||||
/** @type {Array<{type: "code" | "raw" | "quoted" | "char", templateDepth: number | null}>} */
|
||||
const contexts = [{ type: "code", templateDepth: null }];
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
const pair = source.slice(index, index + 2);
|
||||
const triple = source.slice(index, index + 3);
|
||||
const context = contexts.at(-1);
|
||||
|
||||
if (lineComment) {
|
||||
output += char === "\n" ? "\n" : " ";
|
||||
lineComment = char !== "\n";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockDepth > 0) {
|
||||
if (pair === "/*") {
|
||||
output += " ";
|
||||
blockDepth += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (pair === "*/") {
|
||||
output += " ";
|
||||
blockDepth -= 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
output += char === "\n" ? "\n" : " ";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.type === "raw") {
|
||||
if (triple === '"""') {
|
||||
output += triple;
|
||||
contexts.pop();
|
||||
index += 3;
|
||||
} else if (pair === "${") {
|
||||
output += pair;
|
||||
contexts.push({ type: "code", templateDepth: 1 });
|
||||
index += 2;
|
||||
} else {
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.type === "quoted" || context.type === "char") {
|
||||
output += char;
|
||||
if (char === "\\" && index + 1 < source.length) {
|
||||
output += source[index + 1];
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (context.type === "quoted" && pair === "${") {
|
||||
output += source[index + 1];
|
||||
contexts.push({ type: "code", templateDepth: 1 });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
const delimiter = context.type === "quoted" ? '"' : "'";
|
||||
if (char === delimiter) {
|
||||
contexts.pop();
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair === "//") {
|
||||
output += " ";
|
||||
lineComment = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (pair === "/*") {
|
||||
output += " ";
|
||||
blockDepth = 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (triple === '"""') {
|
||||
output += triple;
|
||||
contexts.push({ type: "raw" });
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
output += char;
|
||||
contexts.push({ type: "quoted" });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
output += char;
|
||||
contexts.push({ type: "char" });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (context.templateDepth !== null) {
|
||||
if (char === "{") {
|
||||
context.templateDepth += 1;
|
||||
} else if (char === "}") {
|
||||
context.templateDepth -= 1;
|
||||
if (context.templateDepth === 0) {
|
||||
contexts.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts qualified static string constants declared at Swift type scope.
|
||||
* Type qualification avoids resolving unrelated constants that share a short
|
||||
@@ -190,6 +321,43 @@ export function extractSwiftStaticStringConstants(source) {
|
||||
return constants;
|
||||
}
|
||||
|
||||
/** Extracts generated Kotlin enum entries whose constructor stores a wire string. */
|
||||
export function extractKotlinEnumStringConstants(source) {
|
||||
const constants = new Map();
|
||||
const lines = stripKotlinComments(source).split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const declaration = KOTLIN_ENUM_DECLARATION_RE.exec(lines[i]);
|
||||
if (!declaration) {
|
||||
continue;
|
||||
}
|
||||
const typeName = declaration[1];
|
||||
let depth = 0;
|
||||
let opened = false;
|
||||
for (let j = i; j < lines.length; j += 1) {
|
||||
const line = lines[j];
|
||||
if (opened && depth === 1) {
|
||||
const entry = KOTLIN_ENUM_STRING_ENTRY_RE.exec(line);
|
||||
if (entry) {
|
||||
constants.set(`${typeName}.${entry[1]}`, entry[2]);
|
||||
}
|
||||
}
|
||||
const sanitized = sanitizeLineForBraces(line);
|
||||
for (const char of sanitized) {
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
opened = true;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if (opened && depth <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
/** Extracts Swift gateway-event case labels, including qualified constants. */
|
||||
export function extractSwiftHandledEvents(source, constants = new Map()) {
|
||||
const names = collectBlockCaseLabels(source, SWIFT_EVENT_SWITCH_RE, (line, sink) => {
|
||||
@@ -217,15 +385,16 @@ export function extractSwiftHandledEvents(source, constants = new Map()) {
|
||||
function extractKotlinHandlerBodies(source) {
|
||||
const bodies = [];
|
||||
const lines = source.split("\n");
|
||||
const uncommentedLines = stripKotlinComments(source).split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (!KOTLIN_HANDLER_FUN_RE.test(lines[i])) {
|
||||
if (!KOTLIN_HANDLER_FUN_RE.test(uncommentedLines[i])) {
|
||||
continue;
|
||||
}
|
||||
let depth = 0;
|
||||
let opened = false;
|
||||
const body = [];
|
||||
for (let j = i; j < lines.length; j += 1) {
|
||||
const sanitized = sanitizeLineForBraces(lines[j]);
|
||||
const sanitized = sanitizeLineForBraces(uncommentedLines[j]);
|
||||
if (opened) {
|
||||
body.push(lines[j]);
|
||||
}
|
||||
@@ -258,20 +427,39 @@ function extractKotlinHandlerBodies(source) {
|
||||
* stays tree-wide because Swift consumption always reads `.event` off a
|
||||
* received EventFrame, which does not have that false-positive shape.
|
||||
*/
|
||||
export function extractKotlinHandledEvents(source) {
|
||||
export function extractKotlinHandledEvents(source, constants = new Map()) {
|
||||
const names = [];
|
||||
for (const body of extractKotlinHandlerBodies(source)) {
|
||||
const uncommentedBody = stripKotlinComments(body);
|
||||
names.push(
|
||||
...collectBlockCaseLabels(body, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
|
||||
const label = KOTLIN_CASE_LABEL_RE.exec(line);
|
||||
if (label) {
|
||||
pushStringLiterals(label[1], sink);
|
||||
...collectBlockCaseLabels(uncommentedBody, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
|
||||
const segment = KOTLIN_CASE_SEGMENT_RE.exec(line)?.[1];
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
for (const expression of segment.split(",").map((value) => value.trim())) {
|
||||
const literal = KOTLIN_STRING_CASE_EXPRESSION_RE.exec(expression);
|
||||
if (literal) {
|
||||
sink.push(literal[1]);
|
||||
continue;
|
||||
}
|
||||
const reference = KOTLIN_RAW_VALUE_EXPRESSION_RE.exec(expression)?.[1];
|
||||
const value = reference ? constants.get(reference) : undefined;
|
||||
if (value) {
|
||||
sink.push(value);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const comparison of body.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
|
||||
for (const comparison of uncommentedBody.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
|
||||
names.push(comparison[1]);
|
||||
}
|
||||
for (const comparison of uncommentedBody.matchAll(KOTLIN_EVENT_CONSTANT_COMPARISON_RE)) {
|
||||
const value = constants.get(comparison[1]);
|
||||
if (value) {
|
||||
names.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Set(names);
|
||||
}
|
||||
@@ -416,6 +604,20 @@ function collectSwiftStaticStringConstants(sources) {
|
||||
return constants;
|
||||
}
|
||||
|
||||
function collectKotlinEnumStringConstants(sources) {
|
||||
const constants = new Map();
|
||||
for (const source of sources) {
|
||||
for (const [name, value] of extractKotlinEnumStringConstants(source)) {
|
||||
const existing = constants.get(name);
|
||||
if (existing !== undefined && existing !== value) {
|
||||
throw new Error(`Conflicting Kotlin enum string values for ${name}.`);
|
||||
}
|
||||
constants.set(name, value);
|
||||
}
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the full coverage check against a repo checkout and returns error
|
||||
* strings plus a summary for logging.
|
||||
@@ -449,6 +651,7 @@ function collectProtocolEventCoverageErrors(params = {}) {
|
||||
roots: [ANDROID_SCAN_ROOT],
|
||||
extension: ".kt",
|
||||
extract: extractKotlinHandledEvents,
|
||||
buildExtractContext: collectKotlinEnumStringConstants,
|
||||
sentinels: ANDROID_SENTINEL_FILES,
|
||||
fsImpl,
|
||||
}),
|
||||
|
||||
@@ -66,10 +66,20 @@ const sessionStoreRuntimeFileBackedCompatNames = new Set([
|
||||
]);
|
||||
const embeddedAgentSessionFileRuntimeNames = new Set(["resolveSessionFilePath"]);
|
||||
|
||||
export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([]);
|
||||
// Shipped beta.5 official plugins import these deprecated helpers during
|
||||
// doctor migrations. Remove this ratchet with the compatibility bridge once
|
||||
// beta.5 is outside the supported upgrade window; do not add runtime callers.
|
||||
export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([
|
||||
"loadSessionStore",
|
||||
"resolveSessionFilePath",
|
||||
"resolveSessionStoreEntry",
|
||||
"updateSessionStore",
|
||||
]);
|
||||
|
||||
export const migratedSessionAccessorFiles = new Set([
|
||||
"packages/memory-host-sdk/src/host/session-files.ts",
|
||||
"src/acp/control-plane/manager.background-task.ts",
|
||||
"src/acp/control-plane/manager.core.ts",
|
||||
"src/acp/runtime/session-meta.ts",
|
||||
"src/agents/acp-spawn.ts",
|
||||
"src/agents/auth-profiles/session-override.ts",
|
||||
@@ -157,6 +167,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/agents/embedded-agent-runner/run/attempt.ts",
|
||||
"src/agents/live-model-switch.ts",
|
||||
"src/agents/main-session-restart-recovery.ts",
|
||||
"src/agents/session-suspension.ts",
|
||||
"src/auto-reply/reply/abort.ts",
|
||||
"src/agents/subagent-control.ts",
|
||||
"src/agents/subagent-registry-helpers.ts",
|
||||
|
||||
@@ -67,6 +67,7 @@ export const migratedSessionTranscriptReaderFiles = new Set([
|
||||
"src/gateway/gateway-models.profiles.live.test.ts",
|
||||
"src/gateway/managed-image-attachments.test.ts",
|
||||
"src/gateway/managed-image-attachments.ts",
|
||||
"src/gateway/mcp-app-reconstruction.ts",
|
||||
"src/gateway/server-methods/artifacts.test.ts",
|
||||
"src/gateway/server-methods/artifacts.ts",
|
||||
"src/gateway/server-methods/chat.ts",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pMap, { pMapSkip } from "p-map";
|
||||
|
||||
type QuoteChar = "'" | '"' | "`";
|
||||
|
||||
@@ -74,7 +75,7 @@ function findMatchingParen(source: string, openIndex: number): number {
|
||||
let depth = 1;
|
||||
const quoteState: QuoteScanState = { quote: null, escaped: false };
|
||||
for (let i = openIndex + 1; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
const ch = source.charAt(i);
|
||||
if (consumeQuotedChar(quoteState, ch)) {
|
||||
continue;
|
||||
}
|
||||
@@ -192,7 +193,8 @@ function hasDynamicTmpdirJoin(source: string): boolean {
|
||||
if (closeParenIndex !== -1) {
|
||||
const argsSource = scanSource.slice(openParenIndex + 1, closeParenIndex);
|
||||
const args = splitTopLevelArguments(argsSource);
|
||||
if (args.length >= 2 && isOsTmpdirExpression(args[0])) {
|
||||
const firstArg = args[0];
|
||||
if (firstArg && isOsTmpdirExpression(firstArg)) {
|
||||
for (const arg of args.slice(1)) {
|
||||
const trimmed = arg.trim();
|
||||
if (trimmed.startsWith("`") && trimmed.includes("${")) {
|
||||
@@ -224,42 +226,21 @@ async function readRuntimeSourceFiles(
|
||||
repoRoot: string,
|
||||
absolutePaths: string[],
|
||||
): Promise<RuntimeSourceGuardrailFile[]> {
|
||||
const output: Array<RuntimeSourceGuardrailFile | undefined> = Array.from({
|
||||
length: absolutePaths.length,
|
||||
});
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= absolutePaths.length) {
|
||||
return;
|
||||
}
|
||||
const absolutePath = absolutePaths[index];
|
||||
if (!absolutePath) {
|
||||
continue;
|
||||
}
|
||||
let source: string;
|
||||
return await pMap(
|
||||
absolutePaths,
|
||||
async (absolutePath) => {
|
||||
try {
|
||||
source = await fs.readFile(absolutePath, "utf8");
|
||||
return {
|
||||
relativePath: path.relative(repoRoot, absolutePath),
|
||||
source: await fs.readFile(absolutePath, "utf8"),
|
||||
};
|
||||
} catch {
|
||||
// File tracked by git but deleted on disk (e.g. pending deletion).
|
||||
continue;
|
||||
return pMapSkip;
|
||||
}
|
||||
output[index] = {
|
||||
relativePath: path.relative(repoRoot, absolutePath),
|
||||
source,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(FILE_READ_CONCURRENCY, Math.max(1, absolutePaths.length)) },
|
||||
() => worker(),
|
||||
},
|
||||
{ concurrency: FILE_READ_CONCURRENCY, stopOnError: false },
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return output.filter((entry): entry is RuntimeSourceGuardrailFile => entry !== undefined);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
+319
-67
@@ -1,96 +1,348 @@
|
||||
// Check Ts Max Loc script supports OpenClaw repository automation.
|
||||
// Enforces a changed-file TypeScript size ratchet without a repository-wide baseline.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { isProductionTypeScriptFile } from "./lib/ts-loc-policy.mjs";
|
||||
|
||||
function writeStdoutLine(message: string): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
export { isProductionTypeScriptFile } from "./lib/ts-loc-policy.mjs";
|
||||
|
||||
type ParsedArgs = {
|
||||
const DEFAULT_MAX_LINES = 500;
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
type ComparisonMode = "head" | "staged" | "worktree";
|
||||
|
||||
export type ParsedArgs = {
|
||||
base: string;
|
||||
head?: string;
|
||||
maxLines: number;
|
||||
paths: string[];
|
||||
staged: boolean;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
let maxLines = 500;
|
||||
export type GitDiffEntry = {
|
||||
path: string;
|
||||
previousPath?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
export type ChangedFileLoc = GitDiffEntry & {
|
||||
baseLines?: number;
|
||||
lines: number;
|
||||
};
|
||||
|
||||
export type LocRatchetViolation = ChangedFileLoc & {
|
||||
reason: "crossed-limit" | "grew" | "new-file";
|
||||
};
|
||||
|
||||
function readValue(argv: string[], index: number, option: string): string {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error(`${option} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): ParsedArgs {
|
||||
let base = "origin/main";
|
||||
let baseWasExplicit = false;
|
||||
let head: string | undefined;
|
||||
let maxLines = DEFAULT_MAX_LINES;
|
||||
let staged = false;
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const optionArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
const paths = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizePath);
|
||||
|
||||
for (let index = 0; index < optionArgs.length; index += 1) {
|
||||
const arg = optionArgs[index];
|
||||
if (arg === "--base") {
|
||||
base = readValue(optionArgs, index, "--base");
|
||||
baseWasExplicit = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
head = readValue(optionArgs, index, "--head");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max") {
|
||||
const next = argv[index + 1];
|
||||
if (!next || !/^\d+$/u.test(next)) {
|
||||
const value = readValue(optionArgs, index, "--max");
|
||||
if (!/^\d+$/u.test(value)) {
|
||||
throw new Error("--max requires a positive integer");
|
||||
}
|
||||
maxLines = Number(next);
|
||||
maxLines = Number(value);
|
||||
if (!Number.isSafeInteger(maxLines) || maxLines <= 0) {
|
||||
throw new Error("--max requires a positive integer");
|
||||
}
|
||||
index++;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
staged = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return { maxLines };
|
||||
if (staged && (baseWasExplicit || head)) {
|
||||
throw new Error("--staged cannot be combined with --base or --head");
|
||||
}
|
||||
return { base, head, maxLines, paths: paths.filter(Boolean), staged };
|
||||
}
|
||||
|
||||
function gitLsFilesAll(): string[] {
|
||||
// Include untracked files too so local refactors don’t “pass” by accident.
|
||||
const stdout = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
export function normalizePath(filePath: string): string {
|
||||
return filePath.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/$/u, "");
|
||||
}
|
||||
|
||||
async function countLines(filePath: string): Promise<number> {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
// Count physical lines. Keeps the rule simple + predictable.
|
||||
return content.split("\n").length;
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)): Promise<number> {
|
||||
// Makes `... | head` safe.
|
||||
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EPIPE") {
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const { maxLines } = parseArgs(argv);
|
||||
const files = gitLsFilesAll()
|
||||
.filter((filePath) => existsSync(filePath))
|
||||
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map(async (filePath) => ({ filePath, lines: await countLines(filePath) })),
|
||||
);
|
||||
|
||||
const offenders = results
|
||||
.filter((result) => result.lines > maxLines)
|
||||
.toSorted((a, b) => b.lines - a.lines);
|
||||
|
||||
if (!offenders.length) {
|
||||
export function countPhysicalLines(content: string): number {
|
||||
if (content.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Minimal, grep-friendly output.
|
||||
for (const offender of offenders) {
|
||||
writeStdoutLine(`${offender.lines}\t${offender.filePath}`);
|
||||
}
|
||||
|
||||
return 1;
|
||||
const splitCount = content.split("\n").length;
|
||||
return content.endsWith("\n") ? splitCount - 1 : splitCount;
|
||||
}
|
||||
|
||||
try {
|
||||
const exitCode = await main();
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
export function parseNameStatusZ(output: string): GitDiffEntry[] {
|
||||
const fields = output.split("\0");
|
||||
const entries: GitDiffEntry[] = [];
|
||||
for (let index = 0; index < fields.length;) {
|
||||
const statusField = fields[index++];
|
||||
if (!statusField) {
|
||||
continue;
|
||||
}
|
||||
const status = statusField[0] ?? "";
|
||||
if (status === "R" || status === "C") {
|
||||
const previousPath = fields[index++];
|
||||
const filePath = fields[index++];
|
||||
if (!previousPath || !filePath) {
|
||||
throw new Error(`Malformed git name-status entry: ${statusField}`);
|
||||
}
|
||||
entries.push({
|
||||
path: normalizePath(filePath),
|
||||
previousPath: normalizePath(previousPath),
|
||||
status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const filePath = fields[index++];
|
||||
if (!filePath) {
|
||||
throw new Error(`Malformed git name-status entry: ${statusField}`);
|
||||
}
|
||||
entries.push({ path: normalizePath(filePath), status });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function tryRunGit(args: string[], cwd: string): string | undefined {
|
||||
try {
|
||||
return runGit(args, cwd).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommit(ref: string, cwd: string): string {
|
||||
const commit = tryRunGit(["rev-parse", "--verify", `${ref}^{commit}`], cwd);
|
||||
if (!commit) {
|
||||
throw new Error(`Invalid TypeScript LOC comparison ref: ${ref}`);
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
export function resolveComparisonBase(params: { base: string; cwd: string; head: string }): string {
|
||||
const baseCommit = resolveCommit(params.base, params.cwd);
|
||||
const headCommit = resolveCommit(params.head, params.cwd);
|
||||
// Shallow CI checkouts may not contain ancestry between the exact event base and head.
|
||||
// The event base is still the correct merge target, so fall back to it when needed.
|
||||
return tryRunGit(["merge-base", baseCommit, headCommit], params.cwd) ?? baseCommit;
|
||||
}
|
||||
|
||||
function listChangedEntries(params: {
|
||||
base: string;
|
||||
cwd: string;
|
||||
head?: string;
|
||||
mode: ComparisonMode;
|
||||
}): GitDiffEntry[] {
|
||||
const args = ["diff", "--name-status", "-z", "--find-renames", "--find-copies"];
|
||||
if (params.mode === "staged") {
|
||||
args.push("--cached", params.base);
|
||||
} else if (params.mode === "head") {
|
||||
args.push(params.base, params.head ?? "HEAD");
|
||||
} else {
|
||||
args.push(params.base);
|
||||
}
|
||||
args.push("--");
|
||||
const entries = parseNameStatusZ(runGit(args, params.cwd));
|
||||
if (params.mode !== "worktree") {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const trackedEntryIndices = new Map(entries.map((entry, index) => [entry.path, index]));
|
||||
for (const filePath of runGit(["ls-files", "--others", "--exclude-standard", "-z"], params.cwd)
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map(normalizePath)) {
|
||||
const trackedIndex = trackedEntryIndices.get(filePath);
|
||||
if (trackedIndex === undefined) {
|
||||
entries.push({ path: filePath, status: "A" });
|
||||
} else if (entries[trackedIndex]?.status === "D") {
|
||||
// A staged deletion can hide a recreated untracked file at the same path.
|
||||
// Treat the worktree content as a modification against the base blob.
|
||||
entries[trackedIndex] = { path: filePath, status: "M" };
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function pathMatchesScope(filePath: string, scopes: string[]): boolean {
|
||||
return scopes.some((scope) => filePath === scope || filePath.startsWith(`${scope}/`));
|
||||
}
|
||||
|
||||
function entryMatchesScopes(entry: GitDiffEntry, scopes: string[]): boolean {
|
||||
return (
|
||||
scopes.length === 0 ||
|
||||
pathMatchesScope(entry.path, scopes) ||
|
||||
(entry.previousPath !== undefined && pathMatchesScope(entry.previousPath, scopes))
|
||||
);
|
||||
}
|
||||
|
||||
function readBlob(ref: string, filePath: string, cwd: string): string | undefined {
|
||||
try {
|
||||
return runGit(["show", `${ref}:${filePath}`], cwd);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCurrentContent(params: {
|
||||
cwd: string;
|
||||
head?: string;
|
||||
mode: ComparisonMode;
|
||||
path: string;
|
||||
}): Promise<string> {
|
||||
if (params.mode === "worktree") {
|
||||
return await readFile(resolve(params.cwd, params.path), "utf8");
|
||||
}
|
||||
return runGit(["show", `${params.head ?? "HEAD"}:${params.path}`], params.cwd);
|
||||
}
|
||||
|
||||
function readIndexBlob(filePath: string, cwd: string): string {
|
||||
return runGit(["show", `:${filePath}`], cwd);
|
||||
}
|
||||
|
||||
export async function collectChangedFileLocs(params: {
|
||||
base?: string;
|
||||
cwd?: string;
|
||||
head?: string;
|
||||
paths?: string[];
|
||||
staged?: boolean;
|
||||
}): Promise<ChangedFileLoc[]> {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const mode: ComparisonMode = params.staged ? "staged" : params.head ? "head" : "worktree";
|
||||
const head = params.head ?? "HEAD";
|
||||
const requestedBase = params.base ?? "origin/main";
|
||||
const base = params.staged
|
||||
? resolveCommit("HEAD", cwd)
|
||||
: mode === "head"
|
||||
? resolveCommit(requestedBase, cwd)
|
||||
: resolveComparisonBase({ base: requestedBase, cwd, head });
|
||||
const scopes = (params.paths ?? []).map(normalizePath).filter(Boolean);
|
||||
const entries = listChangedEntries({ base, cwd, head, mode });
|
||||
const results: ChangedFileLoc[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.status === "D" ||
|
||||
!entryMatchesScopes(entry, scopes) ||
|
||||
!isProductionTypeScriptFile(entry.path)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const currentContent =
|
||||
mode === "staged"
|
||||
? readIndexBlob(entry.path, cwd)
|
||||
: await readCurrentContent({ cwd, head, mode, path: entry.path });
|
||||
|
||||
let basePath: string | undefined;
|
||||
if (
|
||||
entry.status === "R" &&
|
||||
entry.previousPath &&
|
||||
isProductionTypeScriptFile(entry.previousPath)
|
||||
) {
|
||||
basePath = entry.previousPath;
|
||||
} else if (entry.status !== "A" && entry.status !== "C") {
|
||||
basePath = entry.path;
|
||||
}
|
||||
const baseContent = basePath ? readBlob(base, basePath, cwd) : undefined;
|
||||
results.push({
|
||||
...entry,
|
||||
...(baseContent === undefined ? {} : { baseLines: countPhysicalLines(baseContent) }),
|
||||
lines: countPhysicalLines(currentContent),
|
||||
});
|
||||
}
|
||||
|
||||
return results.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
export function findLocRatchetViolations(
|
||||
results: ChangedFileLoc[],
|
||||
maxLines = DEFAULT_MAX_LINES,
|
||||
): LocRatchetViolation[] {
|
||||
const violations: LocRatchetViolation[] = [];
|
||||
for (const result of results) {
|
||||
if (result.lines <= maxLines) {
|
||||
continue;
|
||||
}
|
||||
if (result.baseLines === undefined) {
|
||||
violations.push({ ...result, reason: "new-file" });
|
||||
} else if (result.baseLines <= maxLines) {
|
||||
violations.push({ ...result, reason: "crossed-limit" });
|
||||
} else if (result.lines > result.baseLines) {
|
||||
violations.push({ ...result, reason: "grew" });
|
||||
}
|
||||
}
|
||||
return violations.toSorted(
|
||||
(left, right) => right.lines - left.lines || left.path.localeCompare(right.path),
|
||||
);
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
||||
const args = parseArgs(argv);
|
||||
const results = await collectChangedFileLocs(args);
|
||||
const violations = findLocRatchetViolations(results, args.maxLines);
|
||||
for (const violation of violations) {
|
||||
process.stderr.write(
|
||||
`${violation.lines}\t${violation.baseLines ?? "-"}\t${violation.reason}\t${violation.path}\n`,
|
||||
);
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(
|
||||
`TypeScript LOC ratchet failed: new files must stay at or below ${args.maxLines} lines; oversized legacy files may not grow.\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(
|
||||
`TypeScript LOC ratchet: checked ${results.length} changed production files.\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
|
||||
if (invokedPath === import.meta.url) {
|
||||
try {
|
||||
process.exitCode = await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+1
-1
@@ -87,6 +87,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
parallel: true,
|
||||
commands: [
|
||||
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
||||
{ name: "TypeScript LOC ratchet", args: ["check:loc"] },
|
||||
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
|
||||
{ name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] },
|
||||
{
|
||||
@@ -119,7 +120,6 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
: [
|
||||
{ name: "typecheck prod", args: ["tsgo:prod"] },
|
||||
{ name: "typecheck scripts", args: ["tsgo:scripts"] },
|
||||
{ name: "typecheck strict ratchet", args: ["tsgo:strict-ratchet"] },
|
||||
{ name: "typecheck test root", args: ["tsgo:test:root"] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ChangedScopeArgs = {
|
||||
|
||||
export function detectChangedScope(changedPaths: string[]): ChangedScope;
|
||||
export function shouldRunNativeI18n(changedPaths: string[]): boolean;
|
||||
export function shouldRunTsLoc(changedPaths: string[]): boolean;
|
||||
export function detectNodeFastScope(changedPaths: string[]): NodeFastScope;
|
||||
export function detectInstallSmokeScope(changedPaths: string[]): InstallSmokeScope;
|
||||
export function listChangedPaths(
|
||||
@@ -43,6 +44,8 @@ export function writeGitHubOutput(
|
||||
installSmokeScope?: InstallSmokeScope,
|
||||
nodeFastScope?: NodeFastScope,
|
||||
runNativeI18n?: boolean,
|
||||
runTsLoc?: boolean,
|
||||
changedPaths?: string[] | null,
|
||||
): void;
|
||||
|
||||
export function parseArgs(argv: string[]): ChangedScopeArgs;
|
||||
|
||||
@@ -4,11 +4,14 @@ import { appendFileSync } from "node:fs";
|
||||
import { getChangedPathFacts } from "./lib/changed-path-facts.mjs";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
||||
import { isProductionTypeScriptFile } from "./lib/ts-loc-policy.mjs";
|
||||
|
||||
/** @typedef {{ runNode: boolean; runMacos: boolean; runIosBuild: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean; runUiTests: boolean }} ChangedScope */
|
||||
/** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */
|
||||
/** @typedef {{ runFastInstallSmoke: boolean; runFullInstallSmoke: boolean }} InstallSmokeScope */
|
||||
|
||||
const CHANGED_PATHS_OUTPUT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const FULL_SCOPE = {
|
||||
runNode: true,
|
||||
runMacos: true,
|
||||
@@ -40,14 +43,14 @@ const APPLE_SWIFT_CONFIG_RE = /^config\/(?:swiftformat|swiftlint\.yml)$/;
|
||||
const MACOS_NATIVE_RE =
|
||||
/^(apps\/macos\/|apps\/macos-mlx-tts\/|apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/)/;
|
||||
const MACOS_SCRIPT_SCOPE_RE =
|
||||
/^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/;
|
||||
/^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|install-xcodegen|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/;
|
||||
const IOS_BUILD_RE =
|
||||
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|npm-publish-plan\.mjs|version-script-args\.ts)$)/;
|
||||
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|npm-publish-plan\.mjs|version-script-args\.ts)$)/;
|
||||
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
|
||||
const NODE_SCOPE_RE =
|
||||
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
|
||||
const WINDOWS_SCOPE_RE =
|
||||
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/format-generated-module\.mjs)$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
const WINDOWS_TEST_SCOPE_RE =
|
||||
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
|
||||
const WINDOWS_DAEMON_SCOPE_RE =
|
||||
@@ -67,7 +70,7 @@ const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE =
|
||||
const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE =
|
||||
/^src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry|tts-contract-suites)\.ts$/;
|
||||
const NODE_FAST_CI_ROUTING_SCOPE_RE =
|
||||
/^(scripts\/(?:ci-changed-scope|check-changed|run-vitest|test-projects(?:\.test-support)?)\.mjs$|scripts\/(?:test-projects\.test-support|lib\/changed-path-facts)\.d\.mts$|scripts\/lib\/changed-path-facts\.mjs$|src\/commands\/status\.scan-result\.test\.ts$|src\/scripts\/ci-changed-scope\.test\.ts$|test\/scripts\/(?:changed-lanes|changed-path-facts|run-vitest|test-projects)\.test\.ts$)/;
|
||||
/^(scripts\/(?:ci-changed-scope|check-changed|run-vitest|test-projects(?:\.test-support)?)\.mjs$|scripts\/(?:test-projects\.test-support|lib\/(?:changed-path-facts|ci-changed-node-test-plan))\.d\.mts$|scripts\/lib\/(?:changed-path-facts|ci-changed-node-test-plan)\.mjs$|src\/commands\/status\.scan-result\.test\.ts$|src\/scripts\/ci-changed-scope\.test\.ts$|test\/scripts\/(?:changed-lanes|changed-path-facts|ci-changed-node-test-plan|run-vitest|test-projects)\.test\.ts$)/;
|
||||
const NODE_FAST_SCOPE_RE = new RegExp(
|
||||
`${NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.source}|${NODE_FAST_CI_ROUTING_SCOPE_RE.source}`,
|
||||
);
|
||||
@@ -187,6 +190,14 @@ export function shouldRunNativeI18n(changedPaths) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether the changed paths include TypeScript governed by the LOC ratchet. */
|
||||
export function shouldRunTsLoc(changedPaths) {
|
||||
return (
|
||||
!Array.isArray(changedPaths) ||
|
||||
changedPaths.some((path) => isProductionTypeScriptFile(path.trim()))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} changedPaths
|
||||
* @returns {NodeFastScope}
|
||||
@@ -292,7 +303,7 @@ export function listChangedPaths(
|
||||
cwd,
|
||||
preferFirstParent: preferMergeHeadFirstParent,
|
||||
});
|
||||
const output = execFileSync("git", ["diff", "--name-only", diffBase, head], {
|
||||
const output = execFileSync("git", ["diff", "--no-renames", "--name-only", diffBase, head], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
@@ -320,6 +331,8 @@ export function writeGitHubOutput(
|
||||
},
|
||||
nodeFastScope = { runFastOnly: false, runPluginContracts: false, runCiRouting: false },
|
||||
runNativeI18n = true,
|
||||
runTsLoc = true,
|
||||
changedPaths = null,
|
||||
) {
|
||||
if (!outputPath) {
|
||||
throw new Error("GITHUB_OUTPUT is required");
|
||||
@@ -351,6 +364,13 @@ export function writeGitHubOutput(
|
||||
appendFileSync(outputPath, `run_control_ui_i18n=${scope.runControlUiI18n}\n`, "utf8");
|
||||
appendFileSync(outputPath, `run_ui_tests=${scope.runUiTests}\n`, "utf8");
|
||||
appendFileSync(outputPath, `run_native_i18n=${runNativeI18n}\n`, "utf8");
|
||||
appendFileSync(outputPath, `run_ts_loc=${runTsLoc}\n`, "utf8");
|
||||
const changedPathsJson = JSON.stringify(changedPaths);
|
||||
appendFileSync(
|
||||
outputPath,
|
||||
`changed_paths_json=${Buffer.byteLength(changedPathsJson, "utf8") <= CHANGED_PATHS_OUTPUT_MAX_BYTES ? changedPathsJson : "null"}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function isDirectRun() {
|
||||
@@ -397,7 +417,15 @@ if (isDirectRun()) {
|
||||
args.mergeHeadFirstParent,
|
||||
);
|
||||
if (changedPaths.length === 0) {
|
||||
writeGitHubOutput(EMPTY_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, false);
|
||||
writeGitHubOutput(
|
||||
EMPTY_SCOPE,
|
||||
process.env.GITHUB_OUTPUT,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
false,
|
||||
[],
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
writeGitHubOutput(
|
||||
@@ -406,8 +434,18 @@ if (isDirectRun()) {
|
||||
detectInstallSmokeScope(changedPaths),
|
||||
detectNodeFastScope(changedPaths),
|
||||
shouldRunNativeI18n(changedPaths),
|
||||
shouldRunTsLoc(changedPaths),
|
||||
changedPaths,
|
||||
);
|
||||
} catch {
|
||||
writeGitHubOutput(FULL_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, true);
|
||||
writeGitHubOutput(
|
||||
FULL_SCOPE,
|
||||
process.env.GITHUB_OUTPUT,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,6 @@ _CLR_MAGENTA='\033[0;35m'
|
||||
_CLR_CYAN='\033[0;36m'
|
||||
_CLR_RED='\033[0;31m'
|
||||
|
||||
# Styled command output (green + bold)
|
||||
_clr_cmd() {
|
||||
echo -e "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
|
||||
}
|
||||
|
||||
# Inline command for use in sentences
|
||||
_cmd() {
|
||||
echo "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
|
||||
|
||||
+54
-34
@@ -7,11 +7,14 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { completeSimple, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import * as ts from "typescript";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { formatErrorMessage } from "../src/infra/errors.ts";
|
||||
import { formatDurationCompact } from "../src/infra/format-time/format-duration.ts";
|
||||
import {
|
||||
compareStringArrays,
|
||||
createControlUiLocaleSyncPlan,
|
||||
flattenTranslations,
|
||||
resolveLocaleMetaProvenance,
|
||||
type GlossaryEntry,
|
||||
type LocaleEntry,
|
||||
type LocaleMeta,
|
||||
@@ -489,27 +492,25 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildBatchPrompt(items: readonly TranslationBatchItem[]): string {
|
||||
export function buildBatchPrompt(
|
||||
items: readonly TranslationBatchItem[],
|
||||
validationError?: string,
|
||||
): string {
|
||||
const payload = Object.fromEntries(items.map((item) => [item.key, item.text]));
|
||||
return [
|
||||
"Translate this JSON object.",
|
||||
"Return ONLY a JSON object with the same keys.",
|
||||
"",
|
||||
JSON.stringify(payload, null, 2),
|
||||
].join("\n");
|
||||
const lines = ["Translate this JSON object.", "Return ONLY a JSON object with the same keys."];
|
||||
if (validationError) {
|
||||
lines.push(
|
||||
"",
|
||||
"Your previous response failed validation. Correct that exact failure in the new response:",
|
||||
validationError,
|
||||
);
|
||||
}
|
||||
lines.push("", JSON.stringify(payload, null, 2));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1_000) {
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
if (ms < 60_000) {
|
||||
return `${(ms / 1_000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
||||
}
|
||||
const totalSeconds = Math.round(ms / 1_000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
return formatDurationCompact(ms, { spaced: true }) ?? "0ms";
|
||||
}
|
||||
|
||||
function logProgress(message: string) {
|
||||
@@ -1314,7 +1315,26 @@ function extractTranslationResult(message: AssistantMessage): string {
|
||||
function parseTranslationReply(raw: string): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/.exec(trimmed);
|
||||
return JSON.parse(fenced ? fenced[1] : trimmed) as Record<string, unknown>;
|
||||
const json = fenced ? expectDefined(fenced[1], "fenced translation JSON body") : trimmed;
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
export function parseTranslationBatchReply(
|
||||
raw: string,
|
||||
items: readonly TranslationBatchItem[],
|
||||
locale: string,
|
||||
): Map<string, string> {
|
||||
const parsed = parseTranslationReply(raw);
|
||||
const translated = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
const value = parsed[item.key];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`missing translation for ${item.key}`);
|
||||
}
|
||||
translated.set(item.key, value);
|
||||
}
|
||||
assertPlaceholderParity(new Map(items.map((item) => [item.key, item.text])), translated, locale);
|
||||
return translated;
|
||||
}
|
||||
|
||||
async function translateBatch(
|
||||
@@ -1325,28 +1345,26 @@ async function translateBatch(
|
||||
const batchLabel = formatBatchLabel(context);
|
||||
const splitDepth = context.splitDepth ?? 0;
|
||||
let lastError: Error | null = null;
|
||||
let validationError: string | undefined;
|
||||
for (let attempt = 0; attempt < TRANSLATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const attemptNumber = attempt + 1;
|
||||
const attemptLabel = `${batchLabel} attempt ${attemptNumber}/${TRANSLATE_MAX_ATTEMPTS}`;
|
||||
const startedAt = Date.now();
|
||||
logProgress(`${attemptLabel}: start keys=${items.length}`);
|
||||
let promptCompleted = false;
|
||||
try {
|
||||
const raw = await (
|
||||
await clientAccess.getClient()
|
||||
).prompt(buildBatchPrompt(items), attemptLabel);
|
||||
const parsed = parseTranslationReply(raw);
|
||||
const translated = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
const value = parsed[item.key];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`missing translation for ${item.key}`);
|
||||
}
|
||||
translated.set(item.key, value);
|
||||
}
|
||||
).prompt(buildBatchPrompt(items, validationError), attemptLabel);
|
||||
promptCompleted = true;
|
||||
const translated = parseTranslationBatchReply(raw, items, context.locale);
|
||||
logProgress(`${attemptLabel}: done (${formatDuration(Date.now() - startedAt)})`);
|
||||
return translated;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (promptCompleted) {
|
||||
validationError = lastError.message;
|
||||
}
|
||||
await clientAccess.resetClient();
|
||||
logProgress(
|
||||
`${attemptLabel}: failed after ${formatDuration(Date.now() - startedAt)}: ${lastError.message}`,
|
||||
@@ -1528,16 +1546,18 @@ async function syncLocale(
|
||||
// legitimately stay identical to English. Track fallback keys from actual
|
||||
// fallback decisions and previous fallback metadata instead.
|
||||
|
||||
const nextProvider = allowTranslate
|
||||
? resolveConfiguredProvider()
|
||||
: (previousMeta?.provider ?? "");
|
||||
const nextModel = allowTranslate ? resolveConfiguredModel() : (previousMeta?.model ?? "");
|
||||
const provenance = resolveLocaleMetaProvenance({
|
||||
didTranslate: allowTranslate && plan.pending.length > 0,
|
||||
model: allowTranslate ? resolveConfiguredModel() : "",
|
||||
previousMeta,
|
||||
provider: allowTranslate ? resolveConfiguredProvider() : "",
|
||||
});
|
||||
const artifacts = plan.render({
|
||||
defaultGlossary: DEFAULT_GLOSSARY,
|
||||
generatedAt: new Date().toISOString(),
|
||||
glossary,
|
||||
model: nextModel,
|
||||
provider: nextProvider,
|
||||
model: provenance.model,
|
||||
provider: provenance.provider,
|
||||
workflow: CONTROL_UI_I18N_WORKFLOW,
|
||||
});
|
||||
assertPlaceholderParity(sourceFlat, artifacts.nextFlat, entry.locale);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
function historyMessage(role: "assistant" | "user", text: string, timestamp: number) {
|
||||
return { content: [{ type: "text", text }], role, timestamp };
|
||||
}
|
||||
|
||||
export function buildBackgroundTasksMock(baseTime: number) {
|
||||
const now = Date.now();
|
||||
const taskSessionKey = "agent:openclaw-mock:subagent:mock-task-1";
|
||||
return {
|
||||
"chat.history": {
|
||||
cases: [
|
||||
{
|
||||
match: { sessionKey: taskSessionKey },
|
||||
response: {
|
||||
messages: [
|
||||
historyMessage(
|
||||
"user",
|
||||
"Map the run-status indicator code and report the active execution path.",
|
||||
baseTime + 40 * 60_000,
|
||||
),
|
||||
historyMessage(
|
||||
"assistant",
|
||||
"Tracing task events from the gateway through the chat background-tasks rail.",
|
||||
baseTime + 40 * 60_000 + 8_000,
|
||||
),
|
||||
],
|
||||
sessionId: "control-ui-mock-task-session",
|
||||
thinkingLevel: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// One live subagent task exercises the rail, collapsed badge, and running-task status row.
|
||||
"tasks.list": {
|
||||
tasks: [
|
||||
{
|
||||
id: "task-mock-running",
|
||||
taskId: "task-mock-running",
|
||||
status: "running",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: "Map run-status indicator code",
|
||||
createdAt: now - 25_000,
|
||||
startedAt: now - 25_000,
|
||||
updatedAt: now,
|
||||
toolUseCount: 7,
|
||||
lastToolName: "read",
|
||||
childSessionKey: taskSessionKey,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Channels-page fixtures for the Control UI mock dev harness: a deterministic
|
||||
// channels.status snapshot plus a scripted setup-wizard step sequence.
|
||||
|
||||
export function buildChannelsStatusMock(baseTime: number) {
|
||||
const channelMeta = [
|
||||
{ id: "whatsapp", label: "WhatsApp", detailLabel: "WhatsApp Web" },
|
||||
{ id: "telegram", label: "Telegram", detailLabel: "Telegram Bot" },
|
||||
{ id: "discord", label: "Discord", detailLabel: "Discord Bot" },
|
||||
{ id: "slack", label: "Slack", detailLabel: "Slack App" },
|
||||
{ id: "signal", label: "Signal", detailLabel: "signal-cli" },
|
||||
{ id: "imessage", label: "iMessage", detailLabel: "macOS Messages" },
|
||||
{ id: "googlechat", label: "Google Chat", detailLabel: "Chat API" },
|
||||
{ id: "nostr", label: "Nostr", detailLabel: "Nostr relays" },
|
||||
];
|
||||
const account = (params: {
|
||||
accountId?: string;
|
||||
configured?: boolean;
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
lastInboundAt?: number;
|
||||
lastError?: string;
|
||||
}) => ({
|
||||
accountId: params.accountId ?? "default",
|
||||
enabled: params.configured ?? true,
|
||||
configured: params.configured ?? true,
|
||||
running: params.running ?? false,
|
||||
connected: params.connected ?? null,
|
||||
...(params.lastInboundAt ? { lastInboundAt: params.lastInboundAt } : {}),
|
||||
...(params.lastError ? { lastError: params.lastError } : {}),
|
||||
});
|
||||
return {
|
||||
ts: baseTime,
|
||||
channelOrder: channelMeta.map((entry) => entry.id),
|
||||
channelLabels: Object.fromEntries(channelMeta.map((entry) => [entry.id, entry.label])),
|
||||
channelDetailLabels: Object.fromEntries(
|
||||
channelMeta.map((entry) => [entry.id, entry.detailLabel]),
|
||||
),
|
||||
channelMeta,
|
||||
channels: {
|
||||
whatsapp: {
|
||||
configured: true,
|
||||
linked: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
lastConnectedAt: baseTime - 90_000,
|
||||
lastMessageAt: baseTime - 120_000,
|
||||
authAgeMs: 6 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
telegram: {
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
lastStartAt: baseTime - 600_000,
|
||||
},
|
||||
discord: {
|
||||
configured: true,
|
||||
running: false,
|
||||
lastError: "Disallowed intents: enable Message Content in the developer portal.",
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: [account({ running: true, connected: true, lastInboundAt: baseTime - 120_000 })],
|
||||
telegram: [account({ running: true, connected: true, lastInboundAt: baseTime - 45_000 })],
|
||||
discord: [
|
||||
account({
|
||||
running: false,
|
||||
lastError: "Disallowed intents: enable Message Content in the developer portal.",
|
||||
}),
|
||||
],
|
||||
},
|
||||
channelDefaultAccountId: {
|
||||
whatsapp: "default",
|
||||
telegram: "default",
|
||||
discord: "default",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelWizardMocks() {
|
||||
const channelSelectStep = {
|
||||
id: "mock-wizard-step-channel",
|
||||
type: "select",
|
||||
message: "Which channel do you want to set up?",
|
||||
options: [
|
||||
{ value: "telegram", label: "Telegram", hint: "bot via @BotFather" },
|
||||
{ value: "slack", label: "Slack", hint: "socket mode app" },
|
||||
{ value: "signal", label: "Signal", hint: "signal-cli link" },
|
||||
{ value: "imessage", label: "iMessage", hint: "macOS Messages" },
|
||||
{ value: "__skip__", label: "Skip for now" },
|
||||
],
|
||||
initialValue: "telegram",
|
||||
executor: "client",
|
||||
};
|
||||
const tokenStep = {
|
||||
id: "mock-wizard-step-token",
|
||||
type: "text",
|
||||
message: "Paste the bot token from @BotFather",
|
||||
placeholder: "paste the token here",
|
||||
sensitive: true,
|
||||
executor: "client",
|
||||
};
|
||||
return {
|
||||
start: {
|
||||
sessionId: "mock-wizard-session",
|
||||
done: false,
|
||||
status: "running",
|
||||
step: channelSelectStep,
|
||||
},
|
||||
next: {
|
||||
cases: [
|
||||
{
|
||||
match: {
|
||||
answer: { stepId: "mock-wizard-step-channel", value: "telegram" },
|
||||
},
|
||||
response: { done: false, status: "running", step: tokenStep },
|
||||
},
|
||||
{ response: { done: true, status: "done", channels: ["telegram"] } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import qrcode from "qrcode";
|
||||
import { createServer, type Plugin, type ViteDevServer } from "vite";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js";
|
||||
import {
|
||||
createControlUiMockBootstrapConfig,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
resolveSourcePackageAliasesForVite,
|
||||
resolveTsconfigPathAliasesForVite,
|
||||
} from "../ui/vite.config.ts";
|
||||
import { buildBackgroundTasksMock } from "./control-ui-mock-background-tasks.ts";
|
||||
import { buildChannelsStatusMock, buildChannelWizardMocks } from "./control-ui-mock-channels.ts";
|
||||
import { buildPluginCatalogMock } from "./control-ui-mock-plugins.ts";
|
||||
import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js";
|
||||
|
||||
type CliOptions = {
|
||||
allowedHosts: string[];
|
||||
@@ -43,7 +48,7 @@ function mockFileHash(value: string): string {
|
||||
function parseArgs(args: string[]): CliOptions {
|
||||
const options: CliOptions = { allowedHosts: [], host: "127.0.0.1", port: 5187 };
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
const arg = expectDefined(args[i], `control UI mock argument at index ${i}`);
|
||||
if (arg === "--allowed-host") {
|
||||
const allowedHost = args[++i]?.trim();
|
||||
if (allowedHost) {
|
||||
@@ -565,6 +570,41 @@ function buildScrollableChatHistory(baseTime: number): unknown[] {
|
||||
);
|
||||
}
|
||||
|
||||
// Completed work turn: commentary + tool results ahead of the final reply
|
||||
// exercise the collapsed "Worked for X" rollup at the end of the thread.
|
||||
const workTurnBase = baseTime + 37 * 60_000;
|
||||
messages.push(
|
||||
chatHistoryMessage(
|
||||
"user",
|
||||
"Mock work request: refactor the render guard and rerun the suite.",
|
||||
workTurnBase,
|
||||
),
|
||||
chatHistoryMessage(
|
||||
"assistant",
|
||||
"Checking the guard implementation before editing.",
|
||||
workTurnBase + 5_000,
|
||||
),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "mock-work-read",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "Read ui/src/pages/chat/chat-thread.ts (120 lines)." }],
|
||||
timestamp: workTurnBase + 12_000,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "mock-work-exec",
|
||||
toolName: "exec",
|
||||
content: [{ type: "text", text: "pnpm test chat-thread — 12 passed." }],
|
||||
timestamp: workTurnBase + 95_000,
|
||||
},
|
||||
chatHistoryMessage(
|
||||
"assistant",
|
||||
"Refactored the render guard and reran the suite; all 12 tests pass.",
|
||||
workTurnBase + 172_000,
|
||||
),
|
||||
);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -827,6 +867,8 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
// heatmap stay filled no matter when the mock harness runs.
|
||||
const profileUsage = buildProfileUsageMocks(Date.now());
|
||||
const modelProviders = buildModelProviderMocks(Date.now());
|
||||
const skillWorkshop = buildSkillWorkshopMocks(Date.now());
|
||||
const channelWizard = buildChannelWizardMocks();
|
||||
return {
|
||||
assistantAgentId: "openclaw-mock",
|
||||
assistantName: "OpenClaw mock",
|
||||
@@ -834,7 +876,17 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.diff", "sessions.files.set"],
|
||||
historyMessages: buildScrollableChatHistory(baseTime),
|
||||
methodResponses: {
|
||||
...buildBackgroundTasksMock(baseTime),
|
||||
"sessions.diff": buildSessionDiffMock(),
|
||||
"plugins.list": buildPluginCatalogMock(),
|
||||
"channels.status": buildChannelsStatusMock(baseTime),
|
||||
"wizard.start": channelWizard.start,
|
||||
"wizard.next": channelWizard.next,
|
||||
"wizard.cancel": { status: "cancelled" },
|
||||
"skills.proposals.list": skillWorkshop.list,
|
||||
"skills.proposals.inspect": skillWorkshop.inspect,
|
||||
"skills.proposals.historyStatus": skillWorkshop.historyStatus,
|
||||
"skills.proposals.historyScan": skillWorkshop.historyScan,
|
||||
"usage.cost": profileUsage.cost,
|
||||
"sessions.usage": profileUsage.sessions,
|
||||
"models.authStatus": modelProviders.authStatus,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Plugin-catalog fixtures for the Control UI mock dev harness.
|
||||
|
||||
export function buildPluginCatalogMock() {
|
||||
const entry = (params: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
installed: boolean;
|
||||
enabled?: boolean;
|
||||
featured?: boolean;
|
||||
}) => ({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
version: "1.4.0",
|
||||
installed: params.installed,
|
||||
enabled: params.installed && (params.enabled ?? true),
|
||||
state: params.installed ? ((params.enabled ?? true) ? "enabled" : "disabled") : "not-installed",
|
||||
category: params.category,
|
||||
featured: params.featured ?? false,
|
||||
removable: params.installed,
|
||||
});
|
||||
return {
|
||||
plugins: [
|
||||
entry({
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "Chat with your agent from Telegram DMs and groups.",
|
||||
category: "channel",
|
||||
installed: true,
|
||||
}),
|
||||
entry({
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Bridge agents into Discord servers and DMs.",
|
||||
category: "channel",
|
||||
installed: true,
|
||||
enabled: false,
|
||||
}),
|
||||
entry({
|
||||
id: "memory-wiki",
|
||||
name: "Memory Wiki",
|
||||
description: "Long-term wiki-style memory for people and projects.",
|
||||
category: "memory",
|
||||
installed: true,
|
||||
}),
|
||||
entry({
|
||||
id: "browser",
|
||||
name: "Browser",
|
||||
description: "Drive a managed browser profile for research and automation.",
|
||||
category: "tool",
|
||||
installed: false,
|
||||
featured: true,
|
||||
}),
|
||||
entry({
|
||||
id: "canvas",
|
||||
name: "Canvas",
|
||||
description: "Generate and preview visual artifacts from sessions.",
|
||||
category: "tool",
|
||||
installed: false,
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export function buildSkillWorkshopMocks(baseTime: number) {
|
||||
const hour = 60 * 60 * 1000;
|
||||
const day = 24 * hour;
|
||||
const proposals = [
|
||||
{
|
||||
id: "prop-release-tweets",
|
||||
kind: "update",
|
||||
status: "pending",
|
||||
title: "Tighten release tweet drafting",
|
||||
description: "Capture the changelog-to-tweet flow the agent keeps re-deriving.",
|
||||
skillName: "release-tweets",
|
||||
skillKey: "release-tweets",
|
||||
createdAt: new Date(baseTime - 2 * hour).toISOString(),
|
||||
updatedAt: new Date(baseTime - hour).toISOString(),
|
||||
scanState: "clean",
|
||||
},
|
||||
{
|
||||
id: "prop-crawler-etiquette",
|
||||
kind: "create",
|
||||
status: "pending",
|
||||
title: "Add crawler etiquette skill",
|
||||
description: "Rate limits and robots.txt handling learned during the docs sweep.",
|
||||
skillName: "crawler-etiquette",
|
||||
skillKey: "crawler-etiquette",
|
||||
createdAt: new Date(baseTime - 3 * day).toISOString(),
|
||||
updatedAt: new Date(baseTime - 2 * day).toISOString(),
|
||||
scanState: "clean",
|
||||
},
|
||||
{
|
||||
id: "prop-changelog-style",
|
||||
kind: "update",
|
||||
status: "applied",
|
||||
title: "Changelog bullet style",
|
||||
description: "One bullet per entry, no hard wraps.",
|
||||
skillName: "changelog-style",
|
||||
skillKey: "changelog-style",
|
||||
createdAt: new Date(baseTime - 6 * day).toISOString(),
|
||||
updatedAt: new Date(baseTime - 5 * day).toISOString(),
|
||||
scanState: "clean",
|
||||
},
|
||||
];
|
||||
return {
|
||||
list: {
|
||||
schema: "openclaw.skill-workshop.proposals-manifest.v1",
|
||||
updatedAt: new Date(baseTime - hour).toISOString(),
|
||||
proposals,
|
||||
},
|
||||
inspect: {
|
||||
cases: proposals.map((proposal) => ({
|
||||
match: { proposalId: proposal.id },
|
||||
response: {
|
||||
record: {
|
||||
...proposal,
|
||||
proposedVersion: "2",
|
||||
target: { skillName: proposal.skillName, skillKey: proposal.skillKey },
|
||||
},
|
||||
content: [
|
||||
`# ${proposal.title}`,
|
||||
"",
|
||||
proposal.description,
|
||||
"",
|
||||
"## Steps",
|
||||
"1. Gather the source material.",
|
||||
"2. Apply the documented workflow.",
|
||||
].join("\n"),
|
||||
supportFiles: [],
|
||||
},
|
||||
})),
|
||||
},
|
||||
historyStatus: {
|
||||
schema: "openclaw.skill-workshop.history-scan.v1",
|
||||
hasScanned: false,
|
||||
reviewedSessions: 0,
|
||||
ideasFound: 0,
|
||||
hasMore: false,
|
||||
lastScanReviewed: 0,
|
||||
lastScanIdeas: 0,
|
||||
},
|
||||
historyScan: {
|
||||
schema: "openclaw.skill-workshop.history-scan.v1",
|
||||
hasScanned: true,
|
||||
reviewedSessions: 34,
|
||||
ideasFound: 2,
|
||||
hasMore: true,
|
||||
lastScanReviewed: 20,
|
||||
lastScanIdeas: 2,
|
||||
lastScanAt: new Date(baseTime).toISOString(),
|
||||
oldestReviewedAt: new Date(baseTime - 25 * day).toISOString(),
|
||||
newestReviewedAt: new Date(baseTime).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ function shouldCopyBundledPluginMetadata(id, env, buildablePluginDirs) {
|
||||
|
||||
/**
|
||||
* Rewrites package extension entries for bundled metadata output.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function rewritePackageExtensions(entries) {
|
||||
if (!Array.isArray(entries)) {
|
||||
|
||||
+94
-12
@@ -10,6 +10,7 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statfsSync,
|
||||
statSync,
|
||||
@@ -32,6 +33,7 @@ import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpe
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const CRABBOX_METADATA_PROBE_TIMEOUT_MS = 5_000;
|
||||
const REMOTE_CHANGED_GATE_BUNDLE_FILE = ".openclaw-crabbox-changed-gate.bundle";
|
||||
// A cold Crabbox (first call after an upgrade, or one on a loaded machine) can
|
||||
// exceed the snappy default probe timeout while it renders `run --help` or does
|
||||
// first-run init. Retry the metadata probes once with this generous timeout so a
|
||||
@@ -2002,19 +2004,27 @@ function mergeBaseForChangedGate() {
|
||||
|
||||
function remoteGitBootstrapForChangedGate(changedGateBase) {
|
||||
const quotedBase = shellQuote(changedGateBase);
|
||||
const quotedBundleFile = shellQuote(REMOTE_CHANGED_GATE_BUNDLE_FILE);
|
||||
return [
|
||||
"openclaw_changed_gate_base=${OPENCLAW_CHANGED_GATE_BASE:-" + quotedBase + "};",
|
||||
`openclaw_changed_gate_base=${quotedBase};`,
|
||||
'if ! command -v git >/dev/null 2>&1; then echo "git is required for OpenClaw remote changed-gate sync" >&2; exit 2; fi;',
|
||||
'openclaw_changed_gate_remote_base="$(git rev-parse --verify refs/remotes/origin/main 2>/dev/null || true)";',
|
||||
'if ! git status --short >/dev/null 2>&1 || [ "$openclaw_changed_gate_remote_base" != "$openclaw_changed_gate_base" ]; then',
|
||||
"rm -rf .git;",
|
||||
"git init -q;",
|
||||
"git remote add origin https://github.com/openclaw/openclaw.git 2>/dev/null || git remote set-url origin https://github.com/openclaw/openclaw.git;",
|
||||
'git fetch -q --depth=1 origin "$openclaw_changed_gate_base:refs/remotes/origin/main";',
|
||||
"git reset --mixed --quiet refs/remotes/origin/main;",
|
||||
"git add -A;",
|
||||
"if ! git diff --cached --quiet; then git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-changed-gate-tree; fi;",
|
||||
"fi",
|
||||
`openclaw_changed_gate_bundle=${quotedBundleFile};`,
|
||||
'if [ ! -f "$openclaw_changed_gate_bundle" ]; then echo "missing changed-gate bundle: $openclaw_changed_gate_bundle" >&2; exit 2; fi;',
|
||||
'openclaw_changed_gate_bundle_tmp="$(mktemp /tmp/openclaw-changed-gate.XXXXXX)" || exit 2;',
|
||||
"trap 'rm -f \"$openclaw_changed_gate_bundle_tmp\"' EXIT HUP INT TERM;",
|
||||
'cp "$openclaw_changed_gate_bundle" "$openclaw_changed_gate_bundle_tmp" || exit 2;',
|
||||
'rm -rf -- "$openclaw_changed_gate_bundle" || exit 2;',
|
||||
"rm -rf .git || exit 2;",
|
||||
"git init -q || exit 2;",
|
||||
"git remote add origin https://github.com/openclaw/openclaw.git 2>/dev/null || git remote set-url origin https://github.com/openclaw/openclaw.git || exit 2;",
|
||||
'git fetch -q --depth=2 origin "$openclaw_changed_gate_base:refs/remotes/origin/main" || exit 2;',
|
||||
'if [ ! -f "$openclaw_changed_gate_bundle_tmp" ]; then echo "changed-gate bundle disappeared before import" >&2; exit 2; fi;',
|
||||
"openclaw_changed_gate_target=refs/remotes/origin/main;",
|
||||
'if [ -s "$openclaw_changed_gate_bundle_tmp" ]; then git fetch -q "$openclaw_changed_gate_bundle_tmp" HEAD:refs/heads/openclaw-changed-gate-tree || exit 2; openclaw_changed_gate_tree="$(git rev-parse refs/heads/openclaw-changed-gate-tree^{tree})" || exit 2; openclaw_changed_gate_head="$(git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit-tree "$openclaw_changed_gate_tree" -p refs/remotes/origin/main -m remote-changed-gate-tree)" || exit 2; git update-ref refs/heads/openclaw-changed-gate-head "$openclaw_changed_gate_head" || exit 2; openclaw_changed_gate_target=refs/heads/openclaw-changed-gate-head; fi;',
|
||||
'rm -f "$openclaw_changed_gate_bundle_tmp" || exit 2;',
|
||||
"trap - EXIT HUP INT TERM;",
|
||||
'git reset --hard --quiet "$openclaw_changed_gate_target" || exit 2;',
|
||||
"git clean -fd -q || exit 2",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -3054,6 +3064,7 @@ function prepareFullCheckoutForSync(options = {}) {
|
||||
assertFullCheckoutSyncDisk(syncRoot);
|
||||
const dir = mkdtempSync(resolve(syncRoot, "openclaw-crabbox-sync-"));
|
||||
let active = false;
|
||||
let resolvedChangedGateBase = options.changedGateBase ?? "";
|
||||
|
||||
function create() {
|
||||
const add = gitOutput(["worktree", "add", "--detach", dir, "HEAD"]);
|
||||
@@ -3071,12 +3082,83 @@ function prepareFullCheckoutForSync(options = {}) {
|
||||
}
|
||||
|
||||
if (options.changedGateBase) {
|
||||
const bundlePath = resolve(dir, REMOTE_CHANGED_GATE_BUNDLE_FILE);
|
||||
let bundleTempDir;
|
||||
try {
|
||||
bundleTempDir = mkdtempSync(resolve(syncRoot, "openclaw-crabbox-bundle-"));
|
||||
const bundleTempPath = resolve(bundleTempDir, "changed-gate.bundle");
|
||||
const head = gitOutput(["-C", dir, "rev-parse", "HEAD"]);
|
||||
const base = gitOutput(["-C", dir, "rev-parse", options.changedGateBase]);
|
||||
if (head.status !== 0 || base.status !== 0 || !head.stdout || !base.stdout) {
|
||||
throw new Error(`git rev-parse failed: ${head.text || base.text}`);
|
||||
}
|
||||
resolvedChangedGateBase = base.stdout;
|
||||
if (head.stdout === base.stdout) {
|
||||
writeFileSync(bundleTempPath, "", "utf8");
|
||||
} else {
|
||||
const headTree = gitOutput(["-C", dir, "rev-parse", "HEAD^{tree}"]);
|
||||
if (headTree.status !== 0 || !headTree.stdout) {
|
||||
throw new Error(headTree.text || "git rev-parse HEAD tree failed");
|
||||
}
|
||||
// A parentless carrier makes the bundle self-contained while sending
|
||||
// only the final tree. The remote attaches the fetched base as parent.
|
||||
const transportCommit = gitOutput([
|
||||
"-C",
|
||||
dir,
|
||||
"-c",
|
||||
"user.name=OpenClaw",
|
||||
"-c",
|
||||
"user.email=ci@openclaw.local",
|
||||
"commit-tree",
|
||||
headTree.stdout,
|
||||
"-m",
|
||||
"remote-changed-gate-tree",
|
||||
]);
|
||||
if (transportCommit.status !== 0 || !transportCommit.stdout) {
|
||||
throw new Error(transportCommit.text || "git commit-tree failed");
|
||||
}
|
||||
const updateHead = gitOutput(["-C", dir, "update-ref", "HEAD", transportCommit.stdout]);
|
||||
if (updateHead.status !== 0) {
|
||||
throw new Error(updateHead.text || "git update-ref HEAD failed");
|
||||
}
|
||||
const bundle = gitOutput(["-C", dir, "bundle", "create", bundleTempPath, "HEAD"]);
|
||||
if (bundle.status !== 0) {
|
||||
throw new Error(bundle.text || `git bundle exited with status ${bundle.status}`);
|
||||
}
|
||||
}
|
||||
// HEAD controls this checkout path and may make it a symlink. Remove the
|
||||
// entry, then atomically install the private temp file without following it.
|
||||
rmSync(bundlePath, { recursive: true, force: true });
|
||||
renameSync(bundleTempPath, bundlePath);
|
||||
} catch (error) {
|
||||
cleanupFullCheckout(dir, active);
|
||||
active = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`git bundle for changed-gate sync failed: ${message}`, { cause: error });
|
||||
} finally {
|
||||
if (bundleTempDir) {
|
||||
rmSync(bundleTempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const reset = gitOutput(["-C", dir, "reset", "--mixed", "--quiet", options.changedGateBase]);
|
||||
if (reset.status !== 0) {
|
||||
cleanupFullCheckout(dir, active);
|
||||
active = false;
|
||||
throw new Error(`git reset for changed-gate sync failed: ${reset.text}`);
|
||||
}
|
||||
const stageBundle = gitOutput([
|
||||
"-C",
|
||||
dir,
|
||||
"add",
|
||||
"-f",
|
||||
"--",
|
||||
REMOTE_CHANGED_GATE_BUNDLE_FILE,
|
||||
]);
|
||||
if (stageBundle.status !== 0) {
|
||||
cleanupFullCheckout(dir, active);
|
||||
active = false;
|
||||
throw new Error(`git add for changed-gate bundle failed: ${stageBundle.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3084,7 +3166,7 @@ function prepareFullCheckoutForSync(options = {}) {
|
||||
|
||||
return {
|
||||
dir,
|
||||
changedGateBase: options.changedGateBase ?? "",
|
||||
changedGateBase: resolvedChangedGateBase,
|
||||
restoreIfMissing() {
|
||||
try {
|
||||
if (statSync(dir).isDirectory()) {
|
||||
|
||||
@@ -10,10 +10,10 @@ set -euo pipefail
|
||||
# DMG_VOLUME_NAME default: CFBundleName
|
||||
# DMG_BACKGROUND_PATH default: apps/macos/Packaging/dmg-background.png
|
||||
# DMG_BACKGROUND_SMALL default: apps/macos/Packaging/dmg-background-small.png (recommended)
|
||||
# DMG_WINDOW_BOUNDS default: "400 100 900 420" (500x320)
|
||||
# DMG_ICON_SIZE default: 128
|
||||
# DMG_APP_POS default: "125 160"
|
||||
# DMG_APPS_POS default: "375 160"
|
||||
# DMG_WINDOW_BOUNDS default: "400 100 1080 530" (680x430)
|
||||
# DMG_ICON_SIZE default: 144
|
||||
# DMG_APP_POS default: "170 305"
|
||||
# DMG_APPS_POS default: "510 305"
|
||||
# SKIP_DMG_STYLE=1 skip Finder styling
|
||||
# DMG_EXTRA_SECTORS extra sectors to keep when shrinking RW image (default: 2048)
|
||||
|
||||
@@ -43,10 +43,10 @@ DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-$APP_NAME}"
|
||||
DMG_BACKGROUND_SMALL="${DMG_BACKGROUND_SMALL:-$ROOT_DIR/apps/macos/Packaging/dmg-background-small.png}"
|
||||
DMG_BACKGROUND_PATH="${DMG_BACKGROUND_PATH:-$ROOT_DIR/apps/macos/Packaging/dmg-background.png}"
|
||||
|
||||
DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 900 420}"
|
||||
DMG_ICON_SIZE="${DMG_ICON_SIZE:-128}"
|
||||
DMG_APP_POS="${DMG_APP_POS:-125 160}"
|
||||
DMG_APPS_POS="${DMG_APPS_POS:-375 160}"
|
||||
DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 1080 530}"
|
||||
DMG_ICON_SIZE="${DMG_ICON_SIZE:-144}"
|
||||
DMG_APP_POS="${DMG_APP_POS:-170 305}"
|
||||
DMG_APPS_POS="${DMG_APPS_POS:-510 305}"
|
||||
DMG_EXTRA_SECTORS="${DMG_EXTRA_SECTORS:-2048}"
|
||||
|
||||
require_integer_list() {
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
// Pre-existing unused exports awaiting deletion.
|
||||
// New entries fail CI. After deleting dead code, run `pnpm deadcode:exports:update`.
|
||||
// Do not add entries to avoid fixing new findings.
|
||||
export const KNIP_UNUSED_EXPORT_BASELINE = [
|
||||
"extensions/acpx/src/process-reaper.ts: AcpxProcessInfo",
|
||||
"extensions/acpx/src/process-reaper.ts: isOpenClawOwnedAcpxProcessCommand",
|
||||
"extensions/canvas/src/host/a2ui.ts: createA2uiHttpRequestHandler",
|
||||
"extensions/canvas/src/host/a2ui.ts: injectCanvasRuntime",
|
||||
"extensions/canvas/src/host/a2ui.ts: isA2uiPath",
|
||||
"extensions/canvas/src/host/server.ts: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES",
|
||||
"extensions/codex/src/session-catalog.ts: CODEX_TERMINAL_RESUME_COMMAND",
|
||||
"extensions/copilot/src/auth-bridge.ts: COPILOT_DEFAULT_AGENT_ID",
|
||||
"extensions/copilot/src/auth-bridge.ts: COPILOT_TOKEN_PROFILE_ERROR",
|
||||
"extensions/copilot/src/auth-bridge.ts: sanitizeAgentId",
|
||||
"extensions/copilot/src/dual-write-transcripts.ts: mirrorCopilotTranscript",
|
||||
"extensions/copilot/src/native-subagent-task-mirror.ts: CopilotNativeSubagentTaskMirror",
|
||||
"extensions/copilot/src/permission-bridge.ts: CopilotPermissionContext",
|
||||
"extensions/copilot/src/permission-bridge.ts: REJECT_ALL_FEEDBACK",
|
||||
"extensions/copilot/src/provider-bridge.ts: COPILOT_BYOK_ENDPOINT_POLICY_ERROR",
|
||||
"extensions/copilot/src/provider-bridge.ts: COPILOT_BYOK_PROVIDER_ERROR",
|
||||
"extensions/copilot/src/provider-bridge.ts: COPILOT_BYOK_TRANSPORT_POLICY_ERROR",
|
||||
"extensions/copilot/src/sdk-loader.ts: COPILOT_SDK_SPEC",
|
||||
"extensions/copilot/src/sdk-loader.ts: resetCopilotSdkCacheForTests",
|
||||
"extensions/copilot/src/sdk-loader.ts: resolveCopilotSdkFallbackDir",
|
||||
"extensions/copilot/src/tool-bridge.ts: convertOpenClawToolToSdkTool",
|
||||
"extensions/copilot/src/tool-bridge.ts: CopilotToolBridgeInput",
|
||||
"extensions/copilot/src/tool-bridge.ts: supportsModelTools",
|
||||
"extensions/copilot/src/workspace-bootstrap.ts: remapCopilotBootstrapContextFiles",
|
||||
"extensions/copilot/src/workspace-bootstrap.ts: renderCopilotWorkspaceBootstrapInstructions",
|
||||
"extensions/copilot/src/workspace-bootstrap.ts: TESTING_EXPORTS",
|
||||
"extensions/crabbox/src/crabbox-worker-provider.ts: CrabboxCommandRunner",
|
||||
"extensions/crabbox/src/crabbox-worker-provider.ts: resolveCrabboxBinary",
|
||||
"extensions/diagnostics-prometheus/src/service.ts: testApi",
|
||||
"extensions/diffs/src/browser.ts: resetSharedBrowserStateForTests",
|
||||
"extensions/diffs/src/config.ts: DEFAULT_DIFFS_PLUGIN_SECURITY",
|
||||
"extensions/diffs/src/config.ts: DEFAULT_DIFFS_TOOL_DEFAULTS",
|
||||
"extensions/diffs/src/plugin.ts: resolveDiffsLanguagePackAvailability",
|
||||
"extensions/diffs/src/shiki-curated-languages.ts: bundledLanguages",
|
||||
"extensions/diffs/src/shiki-curated-languages.ts: bundledLanguagesAlias",
|
||||
"extensions/diffs/src/viewer-assets.ts: LANGUAGE_PACK_VIEWER_LOADER_PATH",
|
||||
"extensions/diffs/src/viewer-assets.ts: resolveViewerRuntimeFileUrl",
|
||||
"extensions/diffs/src/viewer-assets.ts: VIEWER_LOADER_PATH",
|
||||
"extensions/discord/src/actions/runtime.guild.ts: discordGuildActionRuntime",
|
||||
"extensions/discord/src/actions/runtime.moderation.ts: discordModerationActionRuntime",
|
||||
"extensions/discord/src/approval-native.ts: createDiscordNativeApprovalAdapter",
|
||||
"extensions/discord/src/audit-core.ts: collectDiscordAuditChannelIdsForGuilds",
|
||||
"extensions/discord/src/chunk.ts: chunkDiscordText",
|
||||
"extensions/discord/src/components-registry.ts: clearDiscordComponentEntries",
|
||||
"extensions/discord/src/components-registry.ts: resolveDiscordComponentEntry",
|
||||
"extensions/discord/src/components-registry.ts: resolveDiscordModalEntry",
|
||||
"extensions/discord/src/directory-cache.ts: resetDiscordDirectoryCacheForTest",
|
||||
"extensions/discord/src/internal/client.ts: AnyListener",
|
||||
"extensions/discord/src/internal/client.ts: ClientOptions",
|
||||
"extensions/discord/src/internal/client.ts: ComponentRegistry",
|
||||
"extensions/discord/src/internal/command-deploy.ts: testing",
|
||||
"extensions/discord/src/internal/interactions.ts: BaseInteraction",
|
||||
"extensions/discord/src/internal/rest-scheduler.ts: RestSchedulerOptions",
|
||||
"extensions/discord/src/internal/rest.ts: QueuedRequest",
|
||||
"extensions/discord/src/monitor.gateway.ts: WaitForDiscordGatewayStopParams",
|
||||
"extensions/discord/src/monitor/agent-components.dispatch.ts: resolveDiscordComponentOriginatingTo",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentButton",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentChannelSelect",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentMentionableSelect",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentRoleSelect",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentStringSelect",
|
||||
"extensions/discord/src/monitor/agent-components.ts: createDiscordComponentUserSelect",
|
||||
"extensions/discord/src/monitor/allow-list.ts: resolveDiscordRoleAllowed",
|
||||
"extensions/discord/src/monitor/auto-presence.ts: resolveDiscordAutoPresenceDecision",
|
||||
"extensions/discord/src/monitor/channel-access.ts: resolveDiscordChannelOwnerIdSafe",
|
||||
"extensions/discord/src/monitor/exec-approvals.ts: ExecApprovalButton",
|
||||
"extensions/discord/src/monitor/gateway-metadata.ts: fetchDiscordGatewayInfo",
|
||||
"extensions/discord/src/monitor/gateway-metadata.ts: parseDiscordGatewayInfoBody",
|
||||
"extensions/discord/src/monitor/gateway-supervisor.ts: classifyDiscordGatewayEvent",
|
||||
"extensions/discord/src/monitor/inbound-context.ts: buildDiscordUntrustedContext",
|
||||
"extensions/discord/src/monitor/message-channel-info.ts: resetDiscordChannelInfoCacheForTest",
|
||||
"extensions/discord/src/monitor/model-picker-preferences.ts: buildDiscordModelPickerPreferenceKey",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: computeAlphaBuckets",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: DISCORD_CUSTOM_ID_MAX_CHARS",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX",
|
||||
"extensions/discord/src/monitor/model-picker.state.ts: parseDiscordModelPickerCustomId",
|
||||
"extensions/discord/src/monitor/native-command-ui.ts: DispatchDiscordCommandInteraction",
|
||||
"extensions/discord/src/monitor/native-command.runtime.ts: testing",
|
||||
"extensions/discord/src/monitor/native-command.ts: testing",
|
||||
"extensions/discord/src/monitor/provider.lifecycle.ts: resolveDiscordGatewayReadyTimeoutMs",
|
||||
"extensions/discord/src/monitor/provider.lifecycle.ts: resolveDiscordGatewayRuntimeReadyTimeoutMs",
|
||||
"extensions/discord/src/monitor/provider.ts: testing",
|
||||
"extensions/discord/src/monitor/thread-title.ts: normalizeGeneratedThreadTitle",
|
||||
"extensions/discord/src/resolve-allowlist-common.ts: findDiscordGuildByName",
|
||||
"extensions/discord/src/retry.ts: isRetryableDiscordPreConnectError",
|
||||
"extensions/discord/src/retry.ts: isRetryableDiscordTransientError",
|
||||
"extensions/discord/src/runtime.ts: DiscordRuntime",
|
||||
"extensions/discord/src/voice-message.ts: VoiceMessageMetadata",
|
||||
"extensions/discord/src/voice/prompt.ts: DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT",
|
||||
"extensions/duckduckgo/src/config.ts: DEFAULT_DDG_SAFE_SEARCH",
|
||||
"extensions/file-transfer/src/node-host/dir-fetch.ts: testing",
|
||||
"extensions/file-transfer/src/node-host/dir-list.ts: DIR_LIST_DEFAULT_MAX_ENTRIES",
|
||||
"extensions/file-transfer/src/node-host/dir-list.ts: DIR_LIST_HARD_MAX_ENTRIES",
|
||||
"extensions/file-transfer/src/node-host/file-fetch.ts: FILE_FETCH_DEFAULT_MAX_BYTES",
|
||||
"extensions/file-transfer/src/node-host/file-fetch.ts: FILE_FETCH_HARD_MAX_BYTES",
|
||||
"extensions/file-transfer/src/shared/append-bounded-text-tail.ts: appendBoundedTextTail",
|
||||
"extensions/file-transfer/src/shared/errors.ts: err",
|
||||
"extensions/file-transfer/src/shared/node-invoke-policy.ts: testing",
|
||||
"extensions/file-transfer/src/tools/dir-fetch-tool.ts: testing",
|
||||
"extensions/file-transfer/src/tools/dir-fetch-tool.ts: validateTarUncompressedBudget",
|
||||
"extensions/firecrawl/src/config.ts: DEFAULT_FIRECRAWL_MAX_AGE_MS",
|
||||
"extensions/firecrawl/src/config.ts: DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS",
|
||||
"extensions/firecrawl/src/config.ts: DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS",
|
||||
"extensions/firecrawl/src/config.ts: resolveFirecrawlSearchConfig",
|
||||
"extensions/google-meet/src/calendar.ts: extractGoogleMeetUriFromCalendarEvent",
|
||||
"extensions/google-meet/src/config-compat.ts: migrateGoogleMeetLegacyRealtimeProvider",
|
||||
"extensions/google-meet/src/config.ts: resolveGoogleMeetConfigWithEnv",
|
||||
"extensions/google-meet/src/meet.ts: normalizeGoogleMeetSpaceName",
|
||||
"extensions/google-meet/src/oauth.ts: refreshGoogleMeetAccessToken",
|
||||
"extensions/google-meet/src/realtime.ts: extendGoogleMeetOutputEchoSuppression",
|
||||
"extensions/google-meet/src/runtime.ts: normalizeMeetUrl",
|
||||
"extensions/google-meet/src/transports/chrome-create.ts: CREATE_MEET_FROM_BROWSER_SCRIPT",
|
||||
"extensions/google-meet/src/transports/chrome.ts: testing",
|
||||
"extensions/google/src/gemini-web-search-provider.ts: testing",
|
||||
"extensions/googlechat/src/approval-card-actions.ts: clearGoogleChatApprovalCardBindingsForTest",
|
||||
"extensions/googlechat/src/auth.ts: testing",
|
||||
"extensions/googlechat/src/google-auth.runtime.ts: __testing",
|
||||
"extensions/googlechat/src/google-auth.runtime.ts: createGoogleAuthFetch",
|
||||
"extensions/googlechat/src/monitor-routing.ts: handleGoogleChatWebhookRequest",
|
||||
"extensions/googlechat/src/monitor.ts: testing",
|
||||
"extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType",
|
||||
"extensions/imessage/src/approval-reaction-poller.ts: clearIMessageApprovalReactionPollerStateForTest",
|
||||
"extensions/imessage/src/cli-output.ts: IMESSAGE_CLI_STDERR_TAIL_BYTES",
|
||||
"extensions/imessage/src/cli-output.ts: IMESSAGE_CLI_STDOUT_MAX_BYTES",
|
||||
"extensions/imessage/src/client.ts: PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR",
|
||||
"extensions/imessage/src/monitor-reply-cache.ts: resetIMessageShortIdState",
|
||||
"extensions/imessage/src/monitor/catchup.ts: loadIMessageCatchupCursor",
|
||||
"extensions/imessage/src/monitor/catchup.ts: resetIMessageCatchupCursorStoreForTest",
|
||||
"extensions/imessage/src/monitor/catchup.ts: saveIMessageCatchupCursor",
|
||||
"extensions/imessage/src/monitor/coalesce.ts: IMESSAGE_URL_BALLOON_BUNDLE_ID",
|
||||
"extensions/imessage/src/monitor/coalesce.ts: MAX_COALESCED_ATTACHMENTS",
|
||||
"extensions/imessage/src/monitor/coalesce.ts: MAX_COALESCED_ENTRIES",
|
||||
"extensions/imessage/src/monitor/coalesce.ts: MAX_COALESCED_TEXT_CHARS",
|
||||
"extensions/imessage/src/monitor/conversation-repair.ts: isIMessageAnchorless",
|
||||
"extensions/imessage/src/monitor/group-allowlist-warnings.ts: resetGroupAllowlistWarningsForTesting",
|
||||
"extensions/imessage/src/monitor/inbound-processing.ts: describeIMessageEchoDropLog",
|
||||
"extensions/imessage/src/monitor/monitor-provider.ts: describeIMessageInboundDropDiagnostic",
|
||||
"extensions/imessage/src/monitor/monitor-provider.ts: formatIMessageInboundMediaBody",
|
||||
"extensions/imessage/src/monitor/monitor-provider.ts: resolveIMessageInboundMediaInput",
|
||||
"extensions/imessage/src/monitor/monitor-provider.ts: shouldThrottleIMessageInboundDropDiagnostic",
|
||||
"extensions/imessage/src/monitor/persisted-echo-cache.ts: resetPersistedIMessageEchoCacheForTest",
|
||||
"extensions/imessage/src/monitor/recovery-cursor.ts: IMESSAGE_RECOVERY_CURSOR_MAX_ENTRIES",
|
||||
"extensions/imessage/src/monitor/recovery-cursor.ts: IMESSAGE_RECOVERY_CURSOR_NAMESPACE",
|
||||
"extensions/imessage/src/monitor/strip-imsg-length-prefixed-text.ts: tryStripImessageLengthPrefixedUtf8Buffer",
|
||||
"extensions/imessage/src/probe.ts: clearIMessagePrivateApiCache",
|
||||
"extensions/imessage/src/runtime.ts: clearIMessageRuntime",
|
||||
"extensions/irc/src/client.ts: buildFallbackNick",
|
||||
"extensions/irc/src/client.ts: buildIrcNickServCommands",
|
||||
"extensions/irc/src/config-schema.ts: IrcConfigSchema",
|
||||
"extensions/irc/src/control-chars.ts: isIrcControlChar",
|
||||
"extensions/irc/src/monitor.ts: resolveIrcInboundTarget",
|
||||
"extensions/irc/src/runtime.ts: clearIrcRuntime",
|
||||
"extensions/line/src/accounts.ts: DEFAULT_ACCOUNT_ID",
|
||||
"extensions/line/src/auto-reply-delivery.ts: LineAutoReplyDeps",
|
||||
"extensions/line/src/bot-handlers.ts: LineRetryableWebhookError",
|
||||
"extensions/line/src/monitor.ts: clearLineRuntimeStateForTests",
|
||||
"extensions/line/src/monitor.ts: getLineRuntimeState",
|
||||
"extensions/line/src/rich-menu.ts: datetimePickerAction",
|
||||
"extensions/line/src/rich-menu.ts: messageAction",
|
||||
"extensions/line/src/rich-menu.ts: postbackAction",
|
||||
"extensions/line/src/rich-menu.ts: uriAction",
|
||||
"extensions/line/src/runtime.ts: clearLineRuntime",
|
||||
"extensions/line/src/template-messages.ts: messageAction",
|
||||
"extensions/llama-cpp/src/embedding-provider.ts: createLlamaCppMemoryEmbeddingProvider",
|
||||
"extensions/llama-cpp/src/embedding-provider.ts: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL",
|
||||
"extensions/llama-cpp/src/embedding-provider.ts: formatLlamaCppSetupError",
|
||||
"extensions/lmstudio/src/stream.ts: resetLmstudioPreloadCooldownForTest",
|
||||
"extensions/logbook/src/analyze.ts: clockToMs",
|
||||
"extensions/logbook/src/analyze.ts: extractJsonPayload",
|
||||
"extensions/matrix/src/approval-reactions.ts: clearMatrixApprovalReactionTargetsForTest",
|
||||
"extensions/matrix/src/matrix/client/config.ts: setMatrixAuthClientDepsForTest",
|
||||
"extensions/matrix/src/matrix/monitor/handler.ts: MatrixRetryableInboundError",
|
||||
"extensions/matrix/src/matrix/monitor/index.ts: testing",
|
||||
"extensions/matrix/src/matrix/monitor/room-history.ts: createRoomHistoryTrackerForTests",
|
||||
"extensions/matrix/src/runtime.ts: clearMatrixRuntime",
|
||||
"extensions/mattermost/src/mattermost/interactions.ts: buildButtonAttachments",
|
||||
"extensions/mattermost/src/mattermost/reactions.ts: resetMattermostReactionBotUserCacheForTests",
|
||||
"extensions/mattermost/src/mattermost/runtime-api.ts: buildInboundHistoryFromMap",
|
||||
"extensions/mattermost/src/mattermost/runtime-api.ts: buildPendingHistoryContextFromMap",
|
||||
"extensions/mattermost/src/mattermost/runtime-api.ts: recordPendingHistoryEntryIfEnabled",
|
||||
"extensions/mattermost/src/mattermost/slash-http.ts: resetMattermostSlashCommandValidationCacheForTests",
|
||||
"extensions/mattermost/src/mattermost/target-resolution.ts: resetMattermostOpaqueTargetCacheForTests",
|
||||
"extensions/mattermost/src/mattermost/thread-participation.ts: clearMattermostThreadParticipationCache",
|
||||
"extensions/msteams/src/pending-uploads.ts: clearPendingUploads",
|
||||
"extensions/msteams/src/pending-uploads.ts: getPendingUploadCount",
|
||||
"extensions/msteams/src/sent-message-cache.ts: clearMSTeamsSentMessageCache",
|
||||
"extensions/msteams/src/team-identity.ts: _teamGroupIdCacheForTest",
|
||||
"extensions/msteams/src/thread-parent-context.ts: resetThreadParentContextCachesForTest",
|
||||
"extensions/msteams/src/user-agent.ts: resetUserAgentCache",
|
||||
"extensions/nextcloud-talk/src/monitor.ts: NextcloudTalkRetryableWebhookError",
|
||||
"extensions/nextcloud-talk/src/monitor.ts: readNextcloudTalkWebhookBody",
|
||||
"extensions/nextcloud-talk/src/room-info.ts: testing",
|
||||
"extensions/oc-path/src/cli.ts: formatUnifiedDiff",
|
||||
"extensions/oc-path/src/cli.ts: pathEmitCommand",
|
||||
"extensions/oc-path/src/cli.ts: pathFindCommand",
|
||||
"extensions/oc-path/src/cli.ts: pathResolveCommand",
|
||||
"extensions/oc-path/src/cli.ts: pathSetCommand",
|
||||
"extensions/oc-path/src/cli.ts: pathValidateCommand",
|
||||
"extensions/oc-path/src/oc-path/jsonc/parse.ts: MAX_JSONC_INPUT_BYTES",
|
||||
"extensions/oc-path/src/oc-path/oc-path.ts: getPathLayout",
|
||||
"extensions/oc-path/src/oc-path/oc-path.ts: isPattern",
|
||||
"extensions/oc-path/src/oc-path/oc-path.ts: isValidOcPath",
|
||||
"extensions/oc-path/src/oc-path/oc-path.ts: MAX_PATH_LENGTH",
|
||||
"extensions/oc-path/src/oc-path/universal.ts: detectInsertion",
|
||||
"extensions/ollama/src/discovery-shared.ts: isHostedOllamaCloud",
|
||||
"extensions/ollama/src/node-inference.ts: OLLAMA_CHAT_COMMAND",
|
||||
"extensions/ollama/src/node-inference.ts: OLLAMA_MODELS_COMMAND",
|
||||
"extensions/ollama/src/provider-models.ts: parseOllamaNumCtxParameter",
|
||||
"extensions/ollama/src/provider-models.ts: resetOllamaModelShowInfoCacheForTest",
|
||||
"extensions/ollama/src/web-search-provider.ts: runOllamaWebSearch",
|
||||
"extensions/ollama/src/web-search-provider.ts: testing",
|
||||
"extensions/ollama/src/wsl2-crash-loop-check.ts: hasWslCuda",
|
||||
"extensions/ollama/src/wsl2-crash-loop-check.ts: isOllamaEnabledWithRestartAlways",
|
||||
"extensions/ollama/src/wsl2-crash-loop-check.ts: parseSystemctlShowProperties",
|
||||
"extensions/openshell/src/backend.ts: buildOpenShellDirectoryUploadArgs",
|
||||
"extensions/openshell/src/backend.ts: buildOpenShellSandboxName",
|
||||
"extensions/openshell/src/backend.ts: buildOpenShellSshExecEnv",
|
||||
"extensions/openshell/src/backend.ts: ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT",
|
||||
"extensions/openshell/src/backend.ts: OpenShellSandboxBackend",
|
||||
"extensions/openshell/src/backend.ts: PINNED_REMOTE_PATH_MUTATION_SCRIPT",
|
||||
"extensions/openshell/src/cli.ts: applyGatewayEndpointToSshConfig",
|
||||
"extensions/openshell/src/cli.ts: buildExecRemoteCommand",
|
||||
"extensions/openshell/src/cli.ts: buildOpenShellBaseArgv",
|
||||
"extensions/openshell/src/cli.ts: resolveOpenShellCommand",
|
||||
"extensions/openshell/src/cli.ts: shellEscape",
|
||||
"extensions/parallel/src/parallel-mcp-search.runtime.ts: extractMcpToolPayload",
|
||||
"extensions/parallel/src/parallel-mcp-search.runtime.ts: iterMcpMessages",
|
||||
"extensions/parallel/src/parallel-mcp-search.runtime.ts: selectMcpEnvelope",
|
||||
"extensions/qa-channel/src/inbound.ts: isHttpMediaUrl",
|
||||
"extensions/qa-lab/src/gateway-process-boundary.ts: __testing",
|
||||
"extensions/qa-lab/src/qa-agent-workspace.ts: __testing",
|
||||
"extensions/qa-lab/src/runtime-parity.ts: __testing",
|
||||
"extensions/qa-lab/src/suite-runtime-agent-session.ts: setSessionStoreLockRetryDelaysMsForTests",
|
||||
"extensions/qa-matrix/src/cli.ts: matrixQaAdapterFactory",
|
||||
"extensions/qa-matrix/src/cli.ts: matrixQaCliRegistration",
|
||||
"extensions/qa-matrix/src/runners/contract/runtime.ts: testing",
|
||||
"extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts: resolveMatrixQaOpenClawCliEntryPath",
|
||||
"extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts: testing",
|
||||
"extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts: testing",
|
||||
"extensions/qa-matrix/src/runners/contract/scenarios.ts: MatrixQaScenarioContext",
|
||||
"extensions/qa-matrix/src/runners/contract/scenarios.ts: testing",
|
||||
"extensions/qa-matrix/src/shared/live-transport-scenarios.ts: findMissingLiveTransportStandardScenarios",
|
||||
"extensions/qa-matrix/src/shared/live-transport-scenarios.ts: LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS",
|
||||
"extensions/qa-matrix/src/substrate/client.ts: testing",
|
||||
"extensions/qa-matrix/src/substrate/e2ee-client.ts: testing",
|
||||
"extensions/qa-matrix/src/substrate/fault-proxy.ts: MatrixQaFaultProxy",
|
||||
"extensions/qa-matrix/src/substrate/harness.runtime.ts: testing",
|
||||
"extensions/qa-matrix/src/substrate/harness.runtime.ts: writeMatrixQaHarnessFiles",
|
||||
"extensions/qa-matrix/src/windows-system-tools.ts: resolveMatrixQaWindowsSystemRoot",
|
||||
"extensions/raft/src/config-schema.ts: RaftConfigSchema",
|
||||
"extensions/signal/src/aliases.ts: resolveSignalAliasTarget",
|
||||
"extensions/signal/src/client-adapter.ts: detectSignalApiMode",
|
||||
"extensions/signal/src/client-adapter.ts: fetchAttachment",
|
||||
"extensions/signal/src/client-adapter.ts: SignalRpcOptions",
|
||||
"extensions/signal/src/client-container.ts: containerRemoveReaction",
|
||||
"extensions/signal/src/client-container.ts: containerRestRequest",
|
||||
"extensions/signal/src/client-container.ts: containerSendMessage",
|
||||
"extensions/signal/src/client-container.ts: containerSendReaction",
|
||||
"extensions/signal/src/client-container.ts: containerSendReceipt",
|
||||
"extensions/signal/src/client-container.ts: containerSendTyping",
|
||||
"extensions/signal/src/client.ts: SignalSseEvent",
|
||||
"extensions/signal/src/daemon.ts: classifySignalCliLogLine",
|
||||
"extensions/signal/src/daemon.ts: SignalDaemonExitEvent",
|
||||
"extensions/signal/src/daemon.ts: testApi",
|
||||
"extensions/signal/src/reply-authors.ts: clearSignalReplyAuthorsForTest",
|
||||
"extensions/signal/src/reply-authors.ts: SignalPersistedReplyContext",
|
||||
"extensions/signal/src/runtime.ts: clearSignalRuntime",
|
||||
"extensions/signal/src/setup-core.ts: parseSignalAllowFromEntries",
|
||||
"extensions/sms/src/channel.ts: resolveSmsTextChunkLimit",
|
||||
"extensions/sms/src/config-schema.ts: SmsConfigSchema",
|
||||
"extensions/sms/src/gateway.ts: registerSmsWebhookRoute",
|
||||
"extensions/sms/src/twilio.ts: computeTwilioSignature",
|
||||
"extensions/sms/src/twilio.ts: parseTwilioFormBody",
|
||||
"extensions/sms/src/twilio.ts: TwilioSmsApiError",
|
||||
"extensions/sms/src/webhook.ts: testing",
|
||||
"extensions/synology-chat/src/channel.ts: createSynologyChatPlugin",
|
||||
"extensions/synology-chat/src/client.ts: fetchChatUsers (synologyClient)",
|
||||
"extensions/synology-chat/src/webhook-handler.ts: clearSynologyWebhookRateLimiterStateForTest",
|
||||
"extensions/tavily/src/config.ts: DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS",
|
||||
"extensions/tavily/src/config.ts: DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS",
|
||||
"extensions/tavily/src/config.ts: resolveTavilySearchConfig",
|
||||
"extensions/telegram/src/account-throttler.ts: clearAccountThrottlersForTest",
|
||||
"extensions/telegram/src/account-throttler.ts: createTelegramAccountThrottler",
|
||||
"extensions/telegram/src/bot-info-cache.ts: setTelegramBotInfoCacheStoreForTest",
|
||||
"extensions/telegram/src/bot-info-cache.ts: TELEGRAM_BOT_INFO_CACHE_MAX_AGE_MS",
|
||||
"extensions/telegram/src/bot-message-dispatch.ts: resetTelegramReplyFenceForTests",
|
||||
"extensions/telegram/src/bot-native-commands.ts: __testing",
|
||||
"extensions/telegram/src/bot-native-commands.ts: testing",
|
||||
"extensions/telegram/src/bot-processing-outcome.ts: withTelegramSpooledReplayUpdate",
|
||||
"extensions/telegram/src/bot.ts: setTelegramBotRuntimeForTest",
|
||||
"extensions/telegram/src/channel-actions.ts: telegramMessageActionRuntime",
|
||||
"extensions/telegram/src/error-policy.ts: resetTelegramErrorPolicyStoreForTest",
|
||||
"extensions/telegram/src/group-migration.ts: migrateTelegramGroupsInPlace",
|
||||
"extensions/telegram/src/message-cache.ts: resetTelegramMessageCacheBucketsForTest",
|
||||
"extensions/telegram/src/message-cache.ts: TelegramMessageCachePersistentStore",
|
||||
"extensions/telegram/src/message-dispatch-dedupe.ts: buildTelegramMessageDispatchReplayKey",
|
||||
"extensions/telegram/src/message-dispatch-dedupe.ts: forgetTelegramMessageDispatchReplay",
|
||||
"extensions/telegram/src/message-dispatch-dedupe.ts: TelegramMessageDispatchReplayForgetError",
|
||||
"extensions/telegram/src/message-dispatch-dedupe.ts: TelegramMessageDispatchReplayGuard",
|
||||
"extensions/telegram/src/miniapp/command.ts: createTelegramMiniAppDashboardCommand",
|
||||
"extensions/telegram/src/network-config.ts: resetTelegramNetworkConfigStateForTests",
|
||||
"extensions/telegram/src/polling-lease.ts: resetTelegramPollingLeasesForTests",
|
||||
"extensions/telegram/src/polling-session.ts: testing",
|
||||
"extensions/telegram/src/runtime.ts: clearTelegramRuntime",
|
||||
"extensions/telegram/src/sent-message-cache.ts: clearSentMessageCache",
|
||||
"extensions/telegram/src/sent-message-cache.ts: resetSentMessageCacheForTest",
|
||||
"extensions/telegram/src/sent-message-cache.ts: setTelegramSentMessageStoreForTest",
|
||||
"extensions/telegram/src/startup-probe-limiter.ts: resetTelegramStartupProbeLimiterForTests",
|
||||
"extensions/telegram/src/sticker-cache-store.ts: clearTelegramStickerCacheForTest",
|
||||
"extensions/telegram/src/sticker-cache-store.ts: setTelegramStickerCacheStoreForTest",
|
||||
"extensions/telegram/src/telegram-ingress-spool.ts: claimTelegramSpooledUpdate",
|
||||
"extensions/telegram/src/telegram-ingress-spool.ts: completeTelegramSpooledUpdate",
|
||||
"extensions/telegram/src/telegram-ingress-spool.ts: TELEGRAM_SPOOLED_UPDATE_PROCESSING_STALE_MS",
|
||||
"extensions/telegram/src/thread-bindings.ts: __testing",
|
||||
"extensions/telegram/src/thread-bindings.ts: setTelegramThreadBindingStoreForTest",
|
||||
"extensions/telegram/src/topic-name-cache.ts: resetTopicNameCacheForTest",
|
||||
"extensions/telegram/src/topic-name-cache.ts: setTelegramTopicNameStoreFactoryForTest",
|
||||
"extensions/telegram/src/update-offset-store.ts: setTelegramUpdateOffsetStoreForTest",
|
||||
"extensions/telegram/src/update-offset-store.ts: TelegramUpdateOffsetState",
|
||||
"extensions/tlon/src/config-schema.ts: TlonAuthorizationSchema",
|
||||
"extensions/tlon/src/config-schema.ts: TlonConfigSchema",
|
||||
"extensions/tlon/src/monitor/approval.ts: generateApprovalId",
|
||||
"extensions/tlon/src/monitor/media.ts: downloadMedia",
|
||||
"extensions/tlon/src/monitor/media.ts: extractImageBlocks",
|
||||
"extensions/twitch/src/client-manager-registry.ts: clearRegistryForTest",
|
||||
"extensions/twitch/src/config.ts: ResolvedTwitchAccountContext",
|
||||
"extensions/twitch/src/monitor.ts: testing",
|
||||
"extensions/twitch/src/monitor.ts: TwitchMonitorOptions",
|
||||
"extensions/twitch/src/monitor.ts: TwitchMonitorResult",
|
||||
"extensions/twitch/src/monitor.ts: TwitchRuntimeEnv",
|
||||
"extensions/twitch/src/send.ts: SendMessageResult",
|
||||
"extensions/twitch/src/token.ts: TwitchTokenSource",
|
||||
"extensions/vault/src/cli.ts: testing",
|
||||
"extensions/voice-call/src/cli.ts: testing",
|
||||
"extensions/voice-call/src/runtime-state.ts: clearVoiceCallStateRuntime",
|
||||
"extensions/whatsapp/src/agent-tools-call.ts: createWhatsAppCallTool",
|
||||
"extensions/whatsapp/src/agent-tools-call.ts: testing",
|
||||
"extensions/whatsapp/src/auto-reply/mentions.ts: isBotMentionedFromTargets",
|
||||
"extensions/whatsapp/src/auto-reply/mentions.ts: resolveMentionTargets",
|
||||
"extensions/whatsapp/src/auto-reply/monitor/group-gating.ts: resetGroupDropWarningsForTests",
|
||||
"extensions/whatsapp/src/auto-reply/monitor/message-line.ts: formatReplyContext",
|
||||
"extensions/whatsapp/src/connection-controller-runtime-context.ts: WhatsAppConnectionControllerHandle",
|
||||
"extensions/whatsapp/src/group-session-key.ts: testing",
|
||||
"extensions/whatsapp/src/inbound.ts: extractExternalAdReplyContext",
|
||||
"extensions/whatsapp/src/inbound/access-control.ts: __testing",
|
||||
"extensions/whatsapp/src/inbound/access-control.ts: InboundAccessControlResult",
|
||||
"extensions/whatsapp/src/inbound/monitor.ts: WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES",
|
||||
"extensions/whatsapp/src/inbound/types.ts: WhatsAppInboundEvent",
|
||||
"extensions/whatsapp/src/inbound/types.ts: WhatsAppInboundPayload",
|
||||
"extensions/whatsapp/src/inbound/types.ts: WhatsAppInboundPlatform",
|
||||
"extensions/whatsapp/src/qr-image.ts: renderQrPngBase64",
|
||||
"extensions/whatsapp/src/reconnect.ts: DEFAULT_HEARTBEAT_SECONDS",
|
||||
"extensions/whatsapp/src/session.runtime.ts: DisconnectReason",
|
||||
"extensions/whatsapp/src/session.ts: OPENCLAW_WHATSAPP_WEB_SOCKET_URL_ENV",
|
||||
"extensions/whatsapp/src/socket-timing.ts: WhatsAppSocketOperationTimeoutError",
|
||||
"extensions/whatsapp/src/targets-runtime.ts: LidLookup",
|
||||
"extensions/whatsapp/src/text-runtime.ts: LidLookup",
|
||||
"extensions/workboard/src/command.ts: handleWorkboardCommand",
|
||||
"extensions/workboard/src/store.ts: PersistedWorkboardAttachment",
|
||||
"extensions/workboard/src/store.ts: PersistedWorkboardBoard",
|
||||
"extensions/workboard/src/store.ts: PersistedWorkboardCard",
|
||||
"extensions/workboard/src/store.ts: PersistedWorkboardNotificationSubscription",
|
||||
"extensions/workboard/src/store.ts: WorkboardKeyedStore",
|
||||
"extensions/workspaces/src/broadcast.ts: resetWorkspaceBroadcastForTest",
|
||||
"extensions/workspaces/src/cli.ts: parseWorkspaceBindingShorthand",
|
||||
"extensions/workspaces/src/manifest.ts: loadWidgetManifest",
|
||||
"extensions/workspaces/src/serve.ts: parseWidgetRequestPath",
|
||||
"extensions/workspaces/src/serve.ts: WIDGET_CSP",
|
||||
"extensions/xai/src/responses-tool-shared.ts: testing",
|
||||
"extensions/xai/src/web-search-shared.ts: wrapXaiWebSearchError",
|
||||
"extensions/zalo/src/channel.ts: zaloMessageAdapter",
|
||||
"extensions/zalo/src/monitor.ts: __testing",
|
||||
"extensions/zalo/src/monitor.ts: handleZaloWebhookRequest",
|
||||
"extensions/zalo/src/monitor.ts: testing",
|
||||
"extensions/zalo/src/monitor.ts: ZALO_MEDIA_READ_IDLE_TIMEOUT_MS",
|
||||
"extensions/zalo/src/monitor.ts: ZALO_MEDIA_RESPONSE_HEADER_TIMEOUT_MS",
|
||||
"extensions/zalo/src/monitor.ts: ZaloRuntimeEnv",
|
||||
"extensions/zalo/src/outbound-media.ts: clearHostedZaloMediaForTest",
|
||||
"extensions/zalo/src/send.ts: sendPhotoZalo",
|
||||
"extensions/zalouser/src/accounts.ts: listEnabledZalouserAccounts",
|
||||
"extensions/zalouser/src/accounts.ts: resolveZalouserAccount",
|
||||
"extensions/zalouser/src/group-policy.ts: normalizeZalouserGroupSlug",
|
||||
"extensions/zalouser/src/message-sid.ts: parseZalouserMessageSidFull",
|
||||
"extensions/zalouser/src/monitor.ts: __testing",
|
||||
"extensions/zalouser/src/monitor.ts: testing",
|
||||
"extensions/zalouser/src/monitor.ts: ZalouserMonitorOptions",
|
||||
"extensions/zalouser/src/monitor.ts: ZalouserMonitorResult",
|
||||
"extensions/zalouser/src/tool.ts: executeZalouserTool",
|
||||
"extensions/zalouser/src/zalo-js.ts: __testing",
|
||||
"extensions/zalouser/src/zalo-js.ts: testing",
|
||||
"extensions/zalouser/src/zca-client.ts: Listener",
|
||||
"extensions/zalouser/src/zca-client.ts: LoginQRCallbackEventType",
|
||||
"extensions/zalouser/src/zca-client.ts: Reactions",
|
||||
"extensions/zalouser/src/zca-client.ts: Style",
|
||||
"extensions/zalouser/src/zca-client.ts: ThreadType",
|
||||
"src/acp/control-plane/active-turns.ts: resetAcpActiveTurnsForTests",
|
||||
"src/acp/runtime/registry.ts: AcpRuntimeBackend",
|
||||
"src/agents/agent-bundle-lsp-runtime.ts: spawnLspServerProcess",
|
||||
"src/agents/agent-hooks/compaction-instructions.ts: DEFAULT_COMPACTION_INSTRUCTIONS",
|
||||
"src/agents/agent-hooks/compaction-safeguard.ts: testing",
|
||||
"src/agents/agent-hooks/context-pruning.ts: computeEffectiveSettings",
|
||||
"src/agents/agent-hooks/context-pruning.ts: DEFAULT_CONTEXT_PRUNING_SETTINGS",
|
||||
"src/agents/agent-hooks/context-pruning.ts: pruneContextMessages",
|
||||
"src/agents/agent-hooks/context-pruning/settings.ts: DEFAULT_CONTEXT_PRUNING_SETTINGS",
|
||||
"src/agents/agent-project-settings-snapshot.ts: DEFAULT_EMBEDDED_AGENT_PROJECT_SETTINGS_POLICY",
|
||||
"src/agents/agent-steering-queue.ts: buildMergedAgentSteeringPrompt",
|
||||
"src/agents/agent-steering-queue.ts: listPendingAgentSteeringItemsFromSubagentRuns",
|
||||
"src/agents/agent-tools.before-tool-call.ts: BeforeToolCallBlockedError",
|
||||
"src/agents/agent-tools.before-tool-call.ts: testing",
|
||||
"src/agents/agent-tools.read.ts: REQUIRED_PARAM_GROUPS",
|
||||
"src/agents/agent-tools.read.ts: resolveToolPathAgainstWorkspaceRoot",
|
||||
"src/agents/apply-patch.ts: applyPatch",
|
||||
"src/agents/auth-profiles/external-auth.ts: testing",
|
||||
"src/agents/auth-profiles/oauth-identity.ts: isSameOAuthIdentity",
|
||||
"src/agents/auth-profiles/oauth.ts: isRefreshTokenReusedError",
|
||||
"src/agents/auth-profiles/oauth.ts: resetOAuthRefreshQueuesForTest",
|
||||
"src/agents/auth-profiles/runtime-snapshots.ts: getRuntimeAuthProfileStoreCredentialMutationRevision",
|
||||
"src/agents/auth-profiles/runtime-snapshots.ts: getRuntimeAuthProfileStoreStateMutationRevision",
|
||||
"src/agents/auth-profiles/runtime-snapshots.ts: testing",
|
||||
"src/agents/auth-profiles/store.ts: testing",
|
||||
"src/agents/auth-profiles/usage.ts: testing",
|
||||
"src/agents/bash-process-registry.ts: resetProcessRegistryForTests",
|
||||
"src/agents/cache-trace.ts: summarizeMessages",
|
||||
"src/agents/chutes-oauth.ts: CHUTES_TOKEN_ENDPOINT",
|
||||
"src/agents/chutes-oauth.ts: CHUTES_USERINFO_ENDPOINT",
|
||||
"src/agents/cli-auth-epoch.ts: resetCliAuthEpochTestDeps",
|
||||
"src/agents/cli-auth-epoch.ts: setCliAuthEpochTestDeps",
|
||||
"src/agents/cli-backends.ts: resolveCliBackendLiveTest",
|
||||
"src/agents/cli-backends.ts: testing",
|
||||
"src/agents/cli-credentials.ts: MiniMaxCliCredential (cliCredentials)",
|
||||
"src/agents/cli-credentials.ts: readClaudeCliCredentials",
|
||||
"src/agents/cli-credentials.ts: readCodexCliCredentials (cliCredentials)",
|
||||
"src/agents/cli-credentials.ts: resetCliCredentialCachesForTest",
|
||||
"src/agents/cli-runner/claude-live-session.ts: buildClaudeLiveArgs",
|
||||
"src/agents/cli-runner/claude-live-session.ts: resetClaudeLiveSessionsForTest",
|
||||
"src/agents/cli-runner/execute.ts: buildCliEnvAuthLog",
|
||||
"src/agents/cli-runner/execute.ts: buildCliExecLogLine",
|
||||
"src/agents/cli-runner/execute.ts: setCliRunnerExecuteTestDeps",
|
||||
"src/agents/cli-runner/helpers.ts: loadPromptRefImages",
|
||||
"src/agents/cli-runner/helpers.ts: writeCliImages",
|
||||
"src/agents/cli-runner/prepare.ts: setCliRunnerPrepareTestDeps",
|
||||
"src/agents/cli-runner/prepare.ts: shouldSkipLocalCliCredentialEpoch",
|
||||
"src/agents/cli-runner/session-history.ts: MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS",
|
||||
"src/agents/cli-runner/session-history.ts: MAX_CLI_SESSION_HISTORY_FILE_BYTES",
|
||||
"src/agents/cli-runner/session-history.ts: MAX_CLI_SESSION_HISTORY_MESSAGES",
|
||||
"src/agents/cli-runner/session-history.ts: MAX_CLI_SESSION_RESEED_HISTORY_CHARS",
|
||||
"src/agents/code-mode-namespaces.ts: clearCodeModeNamespacesForTest",
|
||||
"src/agents/code-mode-namespaces.ts: CodeModeNamespaceContext",
|
||||
"src/agents/code-mode-namespaces.ts: CodeModeNamespaceRegistration",
|
||||
"src/agents/code-mode-namespaces.ts: CodeModeNamespaceScope",
|
||||
"src/agents/code-mode-namespaces.ts: CodeModeNamespaceToolCall",
|
||||
"src/agents/code-mode-namespaces.ts: CodeModeNamespaceToolInputMapper",
|
||||
"src/agents/code-mode-namespaces.ts: createCodeModeNamespaceTool",
|
||||
"src/agents/code-mode-namespaces.ts: listCodeModeNamespaces",
|
||||
"src/agents/code-mode-namespaces.ts: registerCodeModeNamespaceForPlugin",
|
||||
"src/agents/code-mode-namespaces.ts: RegisteredCodeModeNamespace",
|
||||
"src/agents/code-mode.ts: CodeModeConfig",
|
||||
"src/agents/code-mode.ts: isCodeModeControlTool",
|
||||
"src/agents/code-mode.ts: testing",
|
||||
"src/agents/command/attempt-execution.helpers.ts: claudeCliSessionTranscriptPath",
|
||||
"src/agents/command/attempt-execution.helpers.ts: formatClaudeCliFallbackPrelude",
|
||||
"src/agents/command/delivery.ts: normalizeAgentCommandReplyPayloads",
|
||||
"src/agents/compaction-planning-worker.ts: compactionPlanningWorkerTesting",
|
||||
"src/agents/compaction-planning.worker.ts: runCompactionPlanningWorkerInput",
|
||||
"src/agents/compaction.ts: buildCompactionSummarizationInstructions",
|
||||
"src/agents/compaction.ts: summarizeWithFallback",
|
||||
"src/agents/core-tool-factory-descriptors.ts: CORE_TOOL_FACTORY_DESCRIPTORS",
|
||||
"src/agents/embedded-agent-helpers/turns.ts: mergeConsecutiveUserTurns",
|
||||
"src/agents/embedded-agent-runner/context-engine-maintenance.ts: buildContextEngineMaintenanceRuntimeContext",
|
||||
"src/agents/embedded-agent-runner/context-engine-maintenance.ts: createDeferredTurnMaintenanceAbortSignal",
|
||||
"src/agents/embedded-agent-runner/context-engine-maintenance.ts: resetDeferredTurnMaintenanceStateForTest",
|
||||
"src/agents/embedded-agent-runner/context-truncation-notice.ts: CONTEXT_LIMIT_TRUNCATION_NOTICE",
|
||||
"src/agents/embedded-agent-runner/extra-params.ts: testing",
|
||||
"src/agents/embedded-agent-runner/model-discovery-cache.ts: resetModelDiscoveryCacheForTest",
|
||||
"src/agents/embedded-agent-runner/openrouter-model-capabilities.ts: OpenRouterModelCapabilities",
|
||||
"src/agents/embedded-agent-runner/prompt-cache-observability.ts: resetPromptCacheObservabilityForTest",
|
||||
"src/agents/embedded-agent-runner/run.ts: testing",
|
||||
"src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts: NON_DELIVERABLE_TERMINAL_TURN_REASON",
|
||||
"src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts: insertRuntimeContextMessageForPrompt",
|
||||
"src/agents/embedded-agent-runner/run/attempt.queue-message.ts: cancelQueuedSteeringMessage",
|
||||
"src/agents/embedded-agent-runner/run/attempt.queue-message.ts: steerAndWaitForTranscriptCommit",
|
||||
"src/agents/embedded-agent-runner/run/attempt.session-lock.ts: resetEmbeddedAttemptSessionFileOwnersForTest",
|
||||
"src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts: ATTEMPT_CACHE_TTL_CUSTOM_TYPE",
|
||||
"src/agents/embedded-agent-runner/run/failover-observation.ts: normalizeFailoverDecisionObservationBase",
|
||||
"src/agents/embedded-agent-runner/run/history-image-prune.ts: PRUNED_HISTORY_IMAGE_MARKER",
|
||||
"src/agents/embedded-agent-runner/run/history-image-prune.ts: PRUNED_HISTORY_MEDIA_REFERENCE_MARKER",
|
||||
"src/agents/embedded-agent-runner/run/incomplete-turn.ts: EMPTY_RESPONSE_RETRY_INSTRUCTION",
|
||||
"src/agents/embedded-agent-runner/run/incomplete-turn.ts: REASONING_ONLY_RETRY_INSTRUCTION",
|
||||
"src/agents/embedded-agent-runner/run/message-merge-strategy.ts: DEFAULT_MESSAGE_MERGE_STRATEGY_ID",
|
||||
"src/agents/embedded-agent-runner/run/message-merge-strategy.ts: registerMessageMergeStrategyForTest",
|
||||
"src/agents/embedded-agent-runner/run/setup.ts: resolveEffectiveRuntimeModel",
|
||||
"src/agents/embedded-agent-runner/runs.ts: clearEmbeddedRunAbandonment",
|
||||
"src/agents/embedded-agent-runner/runs.ts: forceClearEmbeddedAgentRun",
|
||||
"src/agents/embedded-agent-runner/runs.ts: markEmbeddedRunAbandoned",
|
||||
"src/agents/embedded-agent-runner/runs.ts: testing",
|
||||
"src/agents/embedded-agent-runner/session-manager-cache.ts: createSessionManagerCache",
|
||||
"src/agents/embedded-agent-runner/thinking.ts: isAssistantMessageWithContent",
|
||||
"src/agents/embedded-agent-runner/thinking.ts: OMITTED_ASSISTANT_REASONING_TEXT",
|
||||
"src/agents/embedded-agent-runner/tool-call-argument-decoding.ts: decodeHtmlEntitiesInObject",
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts: calculateMaxToolResultChars",
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts: getToolResultTextLength",
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts: truncateOversizedToolResultsInRuntimeTranscript",
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts: truncateOversizedToolResultsInSession",
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts: truncateToolResultText",
|
||||
"src/agents/embedded-agent-subscribe.handlers.messages.ts: buildAssistantStreamData",
|
||||
"src/agents/embedded-agent-subscribe.handlers.messages.ts: recordPendingAssistantReplyDirectives",
|
||||
"src/agents/embedded-agent-subscribe.handlers.messages.ts: resolveSilentReplyFallbackText",
|
||||
"src/agents/embedded-agent-subscribe.tools.ts: isToolResultMediaTrusted",
|
||||
"src/agents/fallback-skip-cache.ts: peekFallbackSkipBucketsForTest",
|
||||
"src/agents/fallback-skip-cache.ts: resetFallbackSkipCacheForTest",
|
||||
"src/agents/harness/lifecycle-hook-helpers.ts: clearAgentHarnessFinalizeRetryBudget",
|
||||
"src/agents/lanes.ts: AGENT_LANE_CRON_NESTED",
|
||||
"src/agents/lanes.ts: AGENT_LANE_NESTED",
|
||||
"src/agents/live-auth-keys.ts: collectAnthropicApiKeys",
|
||||
"src/agents/live-auth-keys.ts: isAnthropicBillingError",
|
||||
"src/agents/live-model-switch.ts: hasDifferentLiveSessionModelSelection",
|
||||
"src/agents/live-model-switch.ts: resolveLiveSessionModelSelection",
|
||||
"src/agents/mcp-ui-resource.ts: MCP_APP_RESOURCE_MAX_BYTES",
|
||||
"src/agents/mcp-ui-resource.ts: MCP_APP_RESOURCE_MIME_TYPE",
|
||||
"src/agents/mcp-ui-resource.ts: testing",
|
||||
"src/agents/media-generation-task-status-shared.ts: findRecentStartedMediaGenerationTaskForSession",
|
||||
"src/agents/media-generation-task-status-shared.ts: resetRecentMediaGenerationDuplicateGuardsForTests",
|
||||
"src/agents/model-fallback-observation.ts: resetModelFallbackDecisionLogCoalescingForTest",
|
||||
"src/agents/model-fallback.ts: FallbackSummaryError",
|
||||
"src/agents/model-fallback.ts: testing",
|
||||
"src/agents/model-selection-shared.ts: providerWildcardModelKey",
|
||||
"src/agents/model-selection-shared.ts: resolveAllowedModelSelection",
|
||||
"src/agents/model-suppression.ts: clearModelSuppressionResolverCacheForTest",
|
||||
"src/agents/models-config-state.ts: resetModelsJsonReadyCacheForTest",
|
||||
"src/agents/models-config.plan.ts: planOpenClawModelsJsonWithDeps",
|
||||
"src/agents/models-config.plan.ts: ResolveImplicitProvidersForModelsJson",
|
||||
"src/agents/models-config.plan.ts: resolveProvidersForModelsJsonWithDeps",
|
||||
"src/agents/models-config.providers.implicit.ts: resolvePluginMetadataProviderOwnersForTest",
|
||||
"src/agents/models-config.providers.implicit.ts: resolveProviderDiscoveryFilterForTest",
|
||||
"src/agents/models-config.providers.secret-helpers.ts: resolveAwsSdkApiKeyVarName",
|
||||
"src/agents/models-config.ts: ensureModelsFileModeForModelsJson",
|
||||
"src/agents/models-config.ts: resetModelsJsonReadyCacheForTest",
|
||||
"src/agents/models-config.ts: writeModelsFileAtomicForModelsJson",
|
||||
"src/agents/modes/interactive/theme/theme.ts: setTheme",
|
||||
"src/agents/openai-completions-compat.ts: resolveOpenAICompletionsCompatDefaults",
|
||||
"src/agents/openai-transport-stream.ts: testing",
|
||||
"src/agents/plugin-model-catalog.ts: PLUGIN_MODEL_CATALOG_FILE",
|
||||
"src/agents/provider-attribution.ts: listProviderAttributionPolicies",
|
||||
"src/agents/provider-attribution.ts: resolveProviderAttributionIdentity",
|
||||
"src/agents/provider-attribution.ts: resolveProviderAttributionPolicy",
|
||||
"src/agents/provider-transport-stream.ts: isTransportAwareApiSupported",
|
||||
"src/agents/run-wait.ts: testing",
|
||||
"src/agents/session-suspension.ts: testing",
|
||||
"src/agents/session-write-lock.ts: resetSessionWriteLockStateForTest",
|
||||
"src/agents/session-write-lock.ts: testing",
|
||||
"src/agents/sessions/keybindings.ts: AppKeybindings",
|
||||
"src/agents/sessions/keybindings.ts: Keybinding",
|
||||
"src/agents/sessions/keybindings.ts: KEYBINDINGS",
|
||||
"src/agents/sessions/keybindings.ts: KeyId",
|
||||
"src/agents/sessions/prompt-templates.ts: parseCommandArgs",
|
||||
"src/agents/sessions/prompt-templates.ts: substituteArgs",
|
||||
"src/agents/sessions/tools/bash.ts: resolveBashTimeoutMs",
|
||||
"src/agents/shell-snapshot.ts: resetShellSnapshotCacheForTests",
|
||||
"src/agents/shell-snapshot.ts: resolveShellSnapshotDir",
|
||||
"src/agents/shell-utils.ts: getPosixShellArgs",
|
||||
"src/agents/shell-utils.ts: resolvePowerShellPath",
|
||||
"src/agents/shell-utils.ts: resolveShellFromPath",
|
||||
"src/agents/shell-utils.ts: resolveShellFromWhich",
|
||||
"src/agents/shell-utils.ts: resolveWindowsBashPath",
|
||||
"src/agents/stream-message-shared.ts: buildAssistantMessageWithZeroUsage",
|
||||
"src/agents/subagent-announce-delivery.ts: testing",
|
||||
"src/agents/subagent-announce-dispatch.ts: mapSteerOutcomeToDeliveryResult",
|
||||
"src/agents/subagent-announce-output.ts: testing",
|
||||
"src/agents/subagent-attachments.ts: decodeStrictBase64",
|
||||
"src/agents/subagent-control.ts: testing",
|
||||
"src/agents/subagent-registry-helpers.ts: resolveSubagentSessionStatus",
|
||||
"src/agents/subagent-registry-maintenance.ts: listSessionMaintenanceProtectedSubagentSessionKeys",
|
||||
"src/agents/subagent-registry-read.ts: getSubagentRunByChildSessionKey",
|
||||
"src/agents/subagent-registry.store.ts: saveSubagentRegistryToDisk",
|
||||
"src/agents/subagent-registry.ts: addSubagentRunForTests",
|
||||
"src/agents/subagent-registry.ts: countPendingDescendantRunsExcludingRun",
|
||||
"src/agents/subagent-registry.ts: finalizeInterruptedSubagentRun",
|
||||
"src/agents/subagent-registry.ts: getSubagentSessionRuntimeMs",
|
||||
"src/agents/subagent-registry.ts: getSubagentSessionStartedAt",
|
||||
"src/agents/subagent-registry.ts: isSubagentSessionRunActive",
|
||||
"src/agents/subagent-registry.ts: listSessionMaintenanceProtectedSubagentSessionKeys",
|
||||
"src/agents/subagent-registry.ts: listSubagentRunsForRequester",
|
||||
"src/agents/subagent-registry.ts: releaseSubagentRun",
|
||||
"src/agents/subagent-registry.ts: resetSubagentRegistryForTests",
|
||||
"src/agents/subagent-registry.ts: resolveRequesterForChildSession",
|
||||
"src/agents/subagent-registry.ts: resolveSubagentSessionStatus",
|
||||
"src/agents/subagent-registry.ts: shouldIgnorePostCompletionAnnounceForSession",
|
||||
"src/agents/subagent-registry.ts: testing",
|
||||
"src/agents/subagent-spawn.ts: testing",
|
||||
"src/agents/system-prompt-config.ts: resolveAgentSystemPromptConfig",
|
||||
"src/agents/tool-call-id.ts: sanitizeToolCallId",
|
||||
"src/agents/tool-loop-detection.ts: CRITICAL_THRESHOLD",
|
||||
"src/agents/tool-loop-detection.ts: GLOBAL_CIRCUIT_BREAKER_THRESHOLD",
|
||||
"src/agents/tool-loop-detection.ts: hashToolCall",
|
||||
"src/agents/tool-loop-detection.ts: TOOL_CALL_HISTORY_SIZE",
|
||||
"src/agents/tool-loop-detection.ts: WARNING_THRESHOLD",
|
||||
"src/agents/tool-policy-pipeline.ts: resetToolPolicyWarningCacheForTest",
|
||||
"src/agents/tool-search.ts: testing",
|
||||
"src/agents/tool-search.ts: ToolSearchCatalogSession",
|
||||
"src/agents/tools/agent-step.ts: testing",
|
||||
"src/agents/tools/image-generate-tool.ts: resolveImageGenerationModelConfigForTool",
|
||||
"src/agents/tools/image-tool.ts: resolveImageModelConfigForTool",
|
||||
"src/agents/tools/image-tool.ts: testing",
|
||||
"src/agents/tools/model-config.helpers.ts: hasDirectProviderApiKeyAuthForTool",
|
||||
"src/agents/tools/sessions-resolution.ts: looksLikeSessionKey",
|
||||
"src/agents/tools/sessions-resolution.ts: testing",
|
||||
"src/agents/tools/sessions-send-tool.a2a.ts: testing",
|
||||
"src/agents/tools/video-generate-tool.ts: resolveVideoGenerationModelConfigForTool",
|
||||
"src/agents/tools/web-fetch.ts: sanitizeWebFetchUrl",
|
||||
"src/agents/tools/web-fetch.ts: WEB_FETCH_SPILL_MAX_CHARS",
|
||||
"src/agents/utility-model.ts: resolveProviderDefaultUtilityModelRef",
|
||||
"src/agents/utils/tools-manager.ts: getToolPath",
|
||||
"src/agents/utils/tools-manager.ts: testing",
|
||||
"src/agents/workspace-templates.ts: resetWorkspaceTemplateDirCache",
|
||||
"src/agents/worktrees/run-lease.ts: testing",
|
||||
"src/audit/agent-event-audit.ts: projectAgentEventToAudit",
|
||||
"src/audit/agent-event-audit.ts: projectToolExecutionEventToAudit",
|
||||
"src/audit/agent-event-audit.ts: resetAgentEventAuditForTest",
|
||||
"src/audit/audit-event-store.ts: auditEventStoreLimits",
|
||||
"src/audit/audit-event-store.ts: testApi",
|
||||
"src/audit/audit-event-types.ts: AgentRunAuditEventInput",
|
||||
"src/audit/audit-event-types.ts: InboundMessageAuditEventInput",
|
||||
"src/audit/audit-event-types.ts: OutboundMessageAuditEventInput",
|
||||
"src/audit/audit-event-types.ts: OutboundMessageAuditTerminal",
|
||||
"src/audit/audit-event-writer.ts: testApi",
|
||||
"src/audit/message-audit-events.ts: resetMessageAuditEventsForTest",
|
||||
"src/auto-reply/reply/abort.ts: testing",
|
||||
"src/auto-reply/reply/acp-reset-target.ts: testing",
|
||||
"src/auto-reply/reply/agent-runner-execution.ts: buildContextOverflowRecoveryText",
|
||||
"src/auto-reply/reply/agent-runner-execution.ts: computeContextAwareReserveTokensFloor",
|
||||
"src/auto-reply/reply/agent-runner-execution.ts: MAX_LIVE_SWITCH_RETRIES",
|
||||
"src/auto-reply/reply/agent-runner-memory.ts: setAgentRunnerMemoryTestDeps",
|
||||
"src/auto-reply/reply/agent-runner-session-reset.ts: setAgentRunnerSessionResetTestDeps",
|
||||
"src/auto-reply/reply/commands-login.ts: testing",
|
||||
"src/auto-reply/reply/dispatch-from-config.ts: getDispatcherFinalOutcomeCounts",
|
||||
"src/auto-reply/reply/dispatch-from-config.ts: testing",
|
||||
"src/auto-reply/reply/get-reply-directives-apply.ts: formatModelOverrideResetEvent",
|
||||
"src/auto-reply/reply/get-reply-fast-path.ts: markCompleteReplyConfig",
|
||||
"src/auto-reply/reply/get-reply-fast-path.ts: withFastReplyConfig",
|
||||
"src/auto-reply/reply/get-reply-run.ts: buildExecOverridePromptHint",
|
||||
"src/auto-reply/reply/get-reply-run.ts: resolvePromptSessionContextForSystemEvent",
|
||||
"src/auto-reply/reply/get-reply-run.ts: resolvePromptSilentReplyConversationType",
|
||||
"src/auto-reply/reply/inbound-dedupe.ts: buildInboundDedupeKey",
|
||||
"src/auto-reply/reply/progress-narrator.ts: createProgressNarrator",
|
||||
"src/auto-reply/reply/prompt-prelude.ts: buildReplyPromptBodies",
|
||||
"src/auto-reply/reply/queue/cleanup.ts: testing",
|
||||
"src/auto-reply/reply/queue/drain.ts: resolveFollowupAuthorizationKey",
|
||||
"src/auto-reply/reply/queue/enqueue.ts: resetRecentQueuedMessageIdDedupe",
|
||||
"src/auto-reply/reply/reply-run-registry.ts: testing",
|
||||
"src/auto-reply/reply/stage-sandbox-media.ts: appendScpStderrTail",
|
||||
"src/auto-reply/reply/stage-sandbox-media.ts: SCP_STDERR_TAIL_CHARS",
|
||||
"src/auto-reply/reply/stage-sandbox-media.ts: testing",
|
||||
"src/auto-reply/usage-bar/template.ts: clearUsageBarTemplateCacheForTest",
|
||||
"src/cli/channel-options.ts: testing",
|
||||
"src/cli/command-path-policy.ts: resolveCliCatalogCommandPath",
|
||||
"src/cli/command-secret-gateway.ts: testing",
|
||||
"src/cli/cron-cli/register.cron-simple.ts: loadCronJobForShow",
|
||||
"src/cli/daemon-cli/response.ts: buildDaemonHintItems",
|
||||
"src/cli/gateway-cli/run.ts: testing",
|
||||
"src/cli/nodes-camera.ts: writeUrlToFile",
|
||||
"src/cli/plugins-install-command.ts: loadConfigForInstall",
|
||||
"src/cli/plugins-list-format.ts: formatPluginLine",
|
||||
"src/cli/ports.ts: listPortListeners",
|
||||
"src/cli/ports.ts: parseLsofOutput",
|
||||
"src/cli/ports.ts: probePortFree",
|
||||
"src/cli/startup-metadata.ts: testing",
|
||||
"src/cli/update-cli/progress.ts: inferUpdateFailureHints",
|
||||
"src/cli/update-cli/update-command-plugins.ts: buildInvalidConfigPostCoreUpdateResult",
|
||||
"src/cli/update-cli/update-command-plugins.ts: collectMissingPluginInstallPayloads",
|
||||
"src/cli/update-cli/update-command-plugins.ts: resolvePostSyncPluginUpdateSkipIds",
|
||||
"src/cli/update-cli/update-command-service.ts: formatPostUpdateGatewayRecoveryInstructions",
|
||||
"src/cli/update-cli/update-command-service.ts: recoverInstalledLaunchAgentAfterUpdate",
|
||||
"src/cli/update-cli/update-command-service.ts: recoverLaunchAgentAndRecheckGatewayHealth",
|
||||
"src/cli/update-cli/update-command-service.ts: shouldUseLegacyProcessRestartAfterUpdate",
|
||||
"src/commands/audit.ts: testApi",
|
||||
"src/commands/auth-choice-options.ts: buildAuthChoiceOptions",
|
||||
"src/commands/backup-shared.ts: encodeAbsolutePathForBackupArchive",
|
||||
"src/commands/backup-shared.ts: formatBackupArchiveTimestamp",
|
||||
"src/commands/backup-shared.ts: resolveBackupPlanFromPaths",
|
||||
"src/commands/daemon-install-helpers.ts: collectPreservedExistingServiceEnvVars",
|
||||
"src/commands/daemon-install-plan.shared.ts: resolveDaemonOpenClawBinDir",
|
||||
"src/commands/daemon-install-plan.shared.ts: resolveGatewayDevMode",
|
||||
"src/commands/doctor-auth-oauth-sidecar.ts: testing",
|
||||
"src/commands/doctor-auth.ts: formatOAuthRefreshFailureDoctorLine",
|
||||
"src/commands/doctor-auth.ts: legacyCodexProviderOverrideToHealthFinding",
|
||||
"src/commands/doctor-auth.ts: resolveUnusableProfileHint",
|
||||
"src/commands/doctor-config-analysis.ts: collectImplicitFallbackClobberWarnings",
|
||||
"src/commands/doctor-heartbeat-main-session-repair.ts: clearTuiLastSessionPointers",
|
||||
"src/commands/doctor-heartbeat-main-session-repair.ts: moveHeartbeatMainSessionEntry",
|
||||
"src/commands/doctor-heartbeat-main-session-repair.ts: resolveHeartbeatMainSessionRepairCandidate",
|
||||
"src/commands/doctor-install-policy.ts: collectInstallPolicyHealthLines",
|
||||
"src/commands/doctor-sandbox.ts: resolveSandboxScript",
|
||||
"src/commands/doctor-session-snapshots.ts: resolveSessionSnapshotBundledSkillsDir",
|
||||
"src/commands/doctor-session-snapshots.ts: scanSessionStoreForStaleRuntimeSnapshotPaths",
|
||||
"src/commands/doctor-session-state-providers.ts: applySessionRouteStateRepair",
|
||||
"src/commands/doctor-session-state-providers.ts: resolveConfiguredDoctorSessionStateRoute",
|
||||
"src/commands/doctor-session-state-providers.ts: scanSessionRouteStateOwners",
|
||||
"src/commands/doctor-session-state-providers.ts: storeMayContainPluginSessionRouteState",
|
||||
"src/commands/doctor-session-transcripts.ts: repairBrokenSessionTranscriptFile",
|
||||
"src/commands/doctor-skills.ts: describeGhConfigDirHintFromDiscovery",
|
||||
"src/commands/doctor-skills.ts: formatUnavailableSkillDoctorLines",
|
||||
"src/commands/doctor-whatsapp-responsiveness.ts: listLocalTuiProcesses",
|
||||
"src/commands/doctor-whatsapp-responsiveness.ts: terminateLocalTuiProcesses",
|
||||
"src/commands/doctor/cron/warnings.ts: collectCronDeliveryTargetAdvisory",
|
||||
"src/commands/doctor/shared/codex-native-assets.ts: scanCodexNativeAssets",
|
||||
"src/commands/doctor/shared/codex-route-warnings.ts: repairCodexSessionStoreRoutes",
|
||||
"src/commands/doctor/shared/context-engine-host-compat.ts: collectConfiguredContextEngineAgentRunHosts",
|
||||
"src/commands/doctor/shared/legacy-oauth-sidecar.ts: legacyOAuthSidecarInternalTestUtils",
|
||||
"src/commands/doctor/shared/plugin-dependency-cleanup.ts: testing",
|
||||
"src/commands/doctor/shared/plugin-registry-migration.ts: FORCE_PLUGIN_REGISTRY_MIGRATION_ENV",
|
||||
"src/commands/doctor/shared/preview-warnings.ts: collectChannelBoundMessageToolPolicyWarnings",
|
||||
"src/commands/doctor/shared/preview-warnings.ts: collectProfileConfiguredToolSectionWarnings",
|
||||
"src/commands/doctor/shared/preview-warnings.ts: collectVisibleReplyToolPolicyWarnings",
|
||||
"src/commands/doctor/shared/release-configured-plugin-installs.ts: collectReleaseConfiguredPluginIds",
|
||||
"src/commands/doctor/shared/release-configured-plugin-installs.ts: shouldRunConfiguredPluginInstallReleaseStep",
|
||||
"src/commands/doctor/shared/stale-auth-order.ts: repairStaleConfiguredAuthOrders",
|
||||
"src/commands/doctor/shared/stale-oauth-profile-shadows.ts: testing",
|
||||
"src/commands/onboard-inference.ts: detectNativeCodexAppServer",
|
||||
"src/commands/onboard-non-interactive/local.ts: resolveGatewayHealthProbeToken",
|
||||
"src/commands/onboard-non-interactive/local.ts: resolveInstallDaemonGatewayHealthTiming",
|
||||
"src/commands/onboard-skills.ts: testing",
|
||||
"src/commands/onboarding-plugin-install.ts: testing",
|
||||
"src/commands/sessions-tail.ts: setSessionsTailFollowIntervalMsForTests",
|
||||
"src/commands/sessions.ts: testing",
|
||||
"src/commands/status.command.ts: resolvePairingRecoveryContext",
|
||||
"src/commitments/extraction.ts: validateCommitmentCandidates",
|
||||
"src/commitments/runtime.ts: configureCommitmentExtractionRuntime",
|
||||
"src/commitments/runtime.ts: drainCommitmentExtractionQueue",
|
||||
"src/commitments/runtime.ts: resetCommitmentExtractionRuntimeForTests",
|
||||
"src/commitments/store.ts: loadCommitmentStore",
|
||||
"src/commitments/store.ts: saveCommitmentStore",
|
||||
"src/compat/legacy-names.ts: MACOS_APP_SOURCES_DIR",
|
||||
"src/compat/legacy-names.ts: PROJECT_NAME",
|
||||
"src/context-engine/registry.ts: clearContextEngineRuntimeQuarantine",
|
||||
"src/context-engine/registry.ts: ContextEngineRegistrationResult",
|
||||
"src/context-engine/registry.ts: getContextEngineFactory",
|
||||
"src/context-engine/registry.ts: listContextEngineIds",
|
||||
"src/crestodian/agent-turn.ts: CrestodianAgentTurnDeps",
|
||||
"src/crestodian/agent-turn.ts: CrestodianAgentTurnDirective",
|
||||
"src/crestodian/agent-turn.ts: runCrestodianAgentTurnWithDeps",
|
||||
"src/cron/command-output-summary.ts: cronCommandSummaryNeedsExternalRedaction",
|
||||
"src/cron/delivery-context.ts: cronDeliveryFromContext",
|
||||
"src/cron/delivery-preview.ts: resolveCronDeliveryPreview",
|
||||
"src/cron/isolated-agent.ts: RunCronAgentTurnResult",
|
||||
"src/cron/isolated-agent/delivery-dispatch.ts: getCompletedDirectCronDeliveriesCountForTests",
|
||||
"src/cron/isolated-agent/delivery-dispatch.ts: resetCompletedDirectCronDeliveriesForTests",
|
||||
"src/cron/isolated-agent/helpers.ts: pickDeliverablePayloads",
|
||||
"src/cron/isolated-agent/helpers.ts: pickLastDeliverablePayload",
|
||||
"src/cron/isolated-agent/helpers.ts: pickSummaryFromPayloads",
|
||||
"src/cron/isolated-agent/run-executor.ts: createCronPromptExecutor",
|
||||
"src/cron/isolated-agent/run.ts: resolveCronDeliveryContext",
|
||||
"src/cron/isolated-agent/run.ts: RunCronAgentTurnResult",
|
||||
"src/cron/job-session-bindings.ts: CronJobSessionBinding",
|
||||
"src/cron/run-diagnostics.ts: MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE",
|
||||
"src/cron/schedule.ts: clearCronScheduleCacheForTest",
|
||||
"src/cron/schedule.ts: getCronScheduleCacheMaxForTest",
|
||||
"src/cron/schedule.ts: getCronScheduleCacheSizeForTest",
|
||||
"src/cron/schedule.ts: hasCronInCacheForTest",
|
||||
"src/cron/service/active-run-cancellation.ts: cancelActiveCronTaskRun",
|
||||
"src/cron/service/active-run-cancellation.ts: CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS",
|
||||
"src/cron/service/active-run-cancellation.ts: resetActiveCronTaskRunsForTests",
|
||||
"src/cron/service/timeout-policy.ts: AGENT_TURN_SAFETY_TIMEOUT_MS",
|
||||
"src/cron/service/timeout-policy.ts: DEFAULT_JOB_TIMEOUT_MS",
|
||||
"src/cron/service/timer.ts: executeJobCore",
|
||||
"src/cron/service/timer.ts: onTimer",
|
||||
"src/cron/session-reaper.ts: resetReaperThrottle",
|
||||
"src/cron/session-reaper.ts: resolveRetentionMs",
|
||||
"src/cron/stagger.ts: isRecurringTopOfHourCronExpr",
|
||||
"src/cron/store/row-codec.ts: bindScheduleColumns",
|
||||
"src/cron/store/row-codec.ts: scheduleFromRow",
|
||||
"src/cron/task-run-history.ts: ReadCronTaskRunHistoryPageOptions",
|
||||
"src/entry.compile-cache.ts: buildOpenClawCompileCacheRespawnPlan",
|
||||
"src/entry.compile-cache.ts: isNodeVersionAffectedByCompileCacheDeadlock",
|
||||
"src/entry.compile-cache.ts: isSourceCheckoutInstallRoot",
|
||||
"src/entry.compile-cache.ts: resolveOpenClawCompileCacheDirectory",
|
||||
"src/entry.compile-cache.ts: runOpenClawCompileCacheRespawnPlan",
|
||||
"src/entry.compile-cache.ts: shouldEnableOpenClawCompileCache",
|
||||
"src/fleet/backup.runtime.ts: resolveRestoreOwner",
|
||||
"src/fleet/cell-profile.ts: FLEET_BASE_PORT",
|
||||
"src/fleet/cell-profile.ts: FLEET_CONTAINER_AUTH_SECRET_DIR",
|
||||
"src/fleet/cell-profile.ts: FLEET_CONTAINER_STATE_DIR",
|
||||
"src/fleet/containers.runtime.ts: FleetContainerCommandExecutor",
|
||||
"src/fleet/containers.runtime.ts: FleetContainerStreamExecutor",
|
||||
"src/fleet/registry.ts: ReserveFleetCellParams",
|
||||
"src/fleet/service.runtime.ts: FleetServiceOptions",
|
||||
"src/flows/doctor-health-contributions.ts: createDoctorHealthContribution",
|
||||
"src/flows/doctor-health-contributions.ts: resolveDoctorHealthContributions",
|
||||
"src/flows/doctor-health-contributions.ts: shouldSkipLegacyUpdateDoctorConfigWrite",
|
||||
"src/flows/health-check-adapter.ts: defineSplitHealthCheck",
|
||||
"src/gateway/terminal/launch.ts: shellQuote",
|
||||
"src/hooks/bundled/session-memory/transcript.ts: getRecentSessionContent",
|
||||
"src/hooks/bundled/session-memory/transcript.ts: sanitizeSessionMemoryTranscriptText",
|
||||
"src/hooks/gmail-setup-utils.ts: resetGmailSetupUtilsCachesForTest",
|
||||
"src/hooks/gmail-setup-utils.ts: resolvePythonExecutablePath",
|
||||
"src/hooks/install.ts: installHooksFromArchive",
|
||||
"src/hooks/internal-hook-types.ts: KNOWN_INTERNAL_HOOK_EVENT_KEYS",
|
||||
"src/hooks/module-loader.ts: resolveFileModuleUrl",
|
||||
"src/hooks/workspace.ts: loadHookEntriesFromDir",
|
||||
"src/image-generation/runtime.ts: ImageGenerationRuntimeDeps",
|
||||
"src/llm/providers/stream-wrappers/anthropic-family-cache-semantics.ts: isAnthropicBedrockModel",
|
||||
"src/llm/utils/node-http-proxy.ts: resolveHttpProxyUrlForTarget",
|
||||
"src/llm/utils/node-http-proxy.ts: UNSUPPORTED_PROXY_PROTOCOL_MESSAGE",
|
||||
"src/llm/utils/oauth/anthropic.ts: refreshAnthropicToken",
|
||||
"src/llm/utils/oauth/anthropic.ts: testing",
|
||||
"src/llm/utils/oauth/github-copilot.ts: refreshGitHubCopilotToken",
|
||||
"src/llm/utils/oauth/github-copilot.ts: testing",
|
||||
"src/llm/utils/oauth/openai-chatgpt.ts: loginOpenAICodex",
|
||||
"src/llm/utils/oauth/openai-chatgpt.ts: refreshOpenAICodexToken",
|
||||
"src/logging/diagnostic-phase.ts: recordDiagnosticPhase",
|
||||
"src/logging/diagnostic-run-activity.ts: markDiagnosticModelStartedForTest",
|
||||
"src/logging/diagnostic-run-activity.ts: markDiagnosticRunProgressForTest",
|
||||
"src/logging/diagnostic-run-activity.ts: markDiagnosticToolStartedForTest",
|
||||
"src/logging/diagnostic-session-context.ts: parseCronRunSessionKey",
|
||||
"src/logging/diagnostic-session-context.ts: readLastAssistantFromSessionFile",
|
||||
"src/logging/redact-internal.ts: withFullContextToolPayloadRedaction",
|
||||
"src/logging/secret-redaction-registry.ts: resetSecretRedactionRegistryForTest",
|
||||
"src/mcp/channel-bridge.ts: shouldRetryInitialMcpGatewayConnect",
|
||||
"src/mcp/channel-server.ts: createOpenClawChannelMcpServer",
|
||||
"src/mcp/channel-server.ts: OpenClawChannelBridge",
|
||||
"src/mcp/openclaw-tools-serve-config.ts: OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV",
|
||||
"src/mcp/openclaw-tools-serve-config.ts: OpenClawToolsMcpToolId",
|
||||
"src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpCrestodianApproval",
|
||||
"src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpCrestodianSurface",
|
||||
"src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpToolSelection",
|
||||
"src/media-understanding/apply.ts: sanitizeMimeType",
|
||||
"src/media-understanding/local-audio.ts: LocalAudioSelection",
|
||||
"src/media-understanding/runner.entries.ts: formatMissingProviderHint",
|
||||
"src/media/input-files.ts: fetchWithGuard",
|
||||
"src/media/parse.ts: SplitMediaFromOutputOptions",
|
||||
"src/music-generation/capabilities.ts: resolveMusicGenerationMode",
|
||||
"src/music-generation/runtime.ts: MusicGenerationRuntimeDeps",
|
||||
"src/node-host/exec-policy.ts: formatSystemRunAllowlistMissMessage",
|
||||
"src/node-host/invoke-system-run.ts: HandleSystemRunInvokeOptions",
|
||||
"src/node-host/invoke.ts: buildNodeEventParams",
|
||||
"src/node-host/invoke.ts: decodeCapturedOutputBuffer",
|
||||
"src/node-host/invoke.ts: sanitizeEnv",
|
||||
"src/node-host/invoke.ts: testing",
|
||||
"src/node-host/mcp.ts: buildNodeMcpToolDescriptors",
|
||||
"src/node-host/mcp.ts: countConfiguredNodeHostMcpServers",
|
||||
"src/node-host/mcp.ts: NodeHostMcpErrorCode",
|
||||
"src/node-host/node-invoke-progress.ts: resolveNodeInvokeHeartbeatInterval",
|
||||
"src/node-host/runner.ts: buildNodeInvokeResultParams",
|
||||
"src/node-host/runner.ts: handleNodeHostReconnectPaused",
|
||||
"src/node-host/runner.ts: resolveNodeHostGatewayCredentials",
|
||||
"src/node-host/runner.ts: resolveNodeHostGatewayDeviceFamily",
|
||||
"src/node-host/runner.ts: resolveNodeHostGatewayPlatform",
|
||||
"src/node-host/runner.ts: shouldExitNodeHostOnReconnectPaused",
|
||||
"src/node-host/runtime.ts: dispatchNodeInvokeInput",
|
||||
"src/node-host/runtime.ts: registerNodeInvokeInputHandler",
|
||||
"src/pairing/pairing-store.types.ts: ReadChannelAllowFromStoreForAccount",
|
||||
"src/pairing/pairing-store.types.ts: UpsertChannelPairingRequestForAccount",
|
||||
"src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore",
|
||||
"src/plugin-state/plugin-state-store.sqlite.ts: seedPluginStateDatabaseEntriesForTests",
|
||||
"src/plugin-state/plugin-state-store.sqlite.ts: setMaxPluginStateEntriesPerPluginForTests",
|
||||
"src/plugin-state/plugin-state-store.ts: clearPluginStateStoreForTests",
|
||||
"src/process/command-queue.ts: CommandLaneTaskTimeoutError",
|
||||
"src/process/command-queue.ts: resetCommandQueueStateForTest",
|
||||
"src/process/gateway-work-admission.ts: tryBeginGatewayIndependentRootWorkAdmission",
|
||||
"src/process/terminal-pty.ts: resolveTerminalPtyInvocation",
|
||||
"src/process/terminal-pty.ts: TerminalPtyHandle",
|
||||
"src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_ENABLED",
|
||||
"src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_SESSION_ID",
|
||||
"src/proxy-capture/proxy-server.ts: assertDebugProxyDirectUpstreamAllowed",
|
||||
"src/proxy-capture/proxy-server.ts: parseConnectTarget",
|
||||
"src/realtime-transcription/websocket-session.ts: REALTIME_TRANSCRIPTION_WS_MAX_PAYLOAD_BYTES",
|
||||
"src/secrets/channel-contract-api.ts: loadBundledChannelSecretContractApi",
|
||||
"src/secrets/channel-contract-api.ts: loadBundledChannelSecurityContractApi",
|
||||
"src/secrets/provider-env-vars.ts: PROVIDER_AUTH_ENV_VAR_CANDIDATES",
|
||||
"src/secrets/provider-env-vars.ts: PROVIDER_ENV_VARS",
|
||||
"src/secrets/provider-env-vars.ts: resolveProviderAuthEvidence",
|
||||
"src/secrets/provider-env-vars.ts: testing",
|
||||
"src/secrets/target-registry-data.ts: getSourceSecretTargetRegistry",
|
||||
"src/secrets/target-registry-pattern.ts: parsePathPattern",
|
||||
"src/secrets/unsupported-surface-policy.ts: getUnsupportedSecretRefSurfacePatterns",
|
||||
"src/security/audit.ts: collectElevatedFindings",
|
||||
"src/security/audit.ts: collectExecRuntimeFindings",
|
||||
"src/security/audit.ts: collectFilesystemFindings",
|
||||
"src/security/audit.ts: collectGatewayConfigFindings",
|
||||
"src/security/audit.ts: collectLoggingFindings",
|
||||
"src/security/audit.ts: collectPluginSecurityAuditFindings",
|
||||
"src/security/fix.ts: applySecurityFixConfigMutations",
|
||||
"src/security/fix.ts: collectSecurityPermissionTargets",
|
||||
"src/security/install-policy.ts: InstallPolicyRequest",
|
||||
"src/sessions/session-key-utils.ts: isCasePreservingPeer",
|
||||
"src/sessions/session-lifecycle-admission.ts: runExclusiveSessionLifecycle",
|
||||
"src/sessions/session-state-events.ts: pruneSessionStateEvents",
|
||||
"src/sessions/session-state-events.ts: recordSessionStateEvent",
|
||||
"src/sessions/session-state-events.ts: sessionStateEventStoreLimits",
|
||||
"src/sessions/user-turn-transcript.ts: persistUserTurnTranscript",
|
||||
"src/skills/discovery/chat-commands.ts: testing",
|
||||
"src/skills/discovery/filter.ts: normalizeSkillFilterForComparison",
|
||||
"src/skills/discovery/skill-index.ts: isSkillPromptVisible",
|
||||
"src/skills/discovery/skill-index.ts: isSkillRuntimeVisible",
|
||||
"src/skills/discovery/skill-index.ts: isSkillUserInvocable",
|
||||
"src/skills/lifecycle/gh-config-discovery.ts: GhConfigDirMismatch",
|
||||
"src/skills/lifecycle/install.ts: testing",
|
||||
"src/skills/lifecycle/upload-store.ts: createSkillUploadStore",
|
||||
"src/skills/lifecycle/upload-store.ts: MAX_ACTIVE_SKILL_UPLOADS",
|
||||
"src/skills/loading/plugin-skills.ts: testing",
|
||||
"src/skills/runtime/refresh.ts: bumpSkillsSnapshotVersion",
|
||||
"src/skills/runtime/refresh.ts: resetSkillsRefreshForTest",
|
||||
"src/skills/runtime/refresh.ts: shouldIgnoreSkillsWatchPath",
|
||||
"src/skills/runtime/remote-skills.ts: resetRemoteNodeSkillsForTests",
|
||||
"src/skills/runtime/session-snapshot.ts: resetResolvedSkillsCacheForTests",
|
||||
"src/skills/workshop/curator.ts: ARCHIVE_AFTER_MS",
|
||||
"src/skills/workshop/curator.ts: CURATOR_INITIAL_DELAY_MS",
|
||||
"src/skills/workshop/curator.ts: CURATOR_SWEEP_INTERVAL_MS",
|
||||
"src/skills/workshop/curator.ts: DOCTOR_WEDGED_AFTER_MS",
|
||||
"src/skills/workshop/curator.ts: recordSkillUsage",
|
||||
"src/skills/workshop/curator.ts: registerSkillUsageTracking",
|
||||
"src/skills/workshop/curator.ts: runSkillCuratorSweep",
|
||||
"src/skills/workshop/curator.ts: STALE_AFTER_MS",
|
||||
"src/status/status-text.ts: resolveStatusChannelFeatureLine",
|
||||
"src/talk/agent-consult-runtime.ts: setRealtimeVoiceAgentConsultDepsForTest",
|
||||
"src/tasks/detached-task-runtime-state.ts: DetachedTaskLifecycleRuntime",
|
||||
"src/tasks/detached-task-runtime.ts: cancelDetachedTaskRunById",
|
||||
"src/tasks/detached-task-runtime.ts: getDetachedTaskLifecycleRuntimeRegistration",
|
||||
"src/tasks/detached-task-runtime.ts: registerDetachedTaskRuntime",
|
||||
"src/tasks/detached-task-runtime.ts: resetDetachedTaskLifecycleRuntimeForTests",
|
||||
"src/tasks/detached-task-runtime.ts: setDetachedTaskLifecycleRuntime",
|
||||
"src/tasks/generated-media-task-activity.ts: resetGeneratedMediaTaskActivityForTests",
|
||||
"src/tasks/runtime-internal.ts: resetTaskRegistryForTests",
|
||||
"src/tasks/task-executor.ts: runTaskInFlow",
|
||||
"src/tasks/task-flow-registry.store.ts: configureTaskFlowRegistryRuntime",
|
||||
"src/tasks/task-flow-registry.ts: createFlowRecord",
|
||||
"src/tasks/task-flow-registry.ts: resetTaskFlowRegistryForTests",
|
||||
"src/tasks/task-flow-registry.ts: syncFlowFromTask",
|
||||
"src/tasks/task-flow-runtime-internal.ts: resetTaskFlowRegistryForTests",
|
||||
"src/tasks/task-registry.ts: findLatestTaskForFlowId",
|
||||
"src/tasks/task-registry.ts: findLatestTaskForRelatedSessionKey",
|
||||
"src/tasks/task-registry.ts: maybeDeliverTaskStateChangeUpdate",
|
||||
"src/tasks/task-registry.ts: resetTaskRegistryControlRuntimeForTests",
|
||||
"src/tasks/task-registry.ts: resetTaskRegistryDeliveryRuntimeForTests",
|
||||
"src/tasks/task-registry.ts: resetTaskRegistryForTests",
|
||||
"src/tasks/task-registry.ts: setTaskRegistryControlRuntimeForTests",
|
||||
"src/tasks/task-registry.ts: setTaskRegistryDeliveryRuntimeForTests",
|
||||
"src/tasks/task-retention.ts: resolveTaskRetentionMs",
|
||||
"src/tui/components/filterable-select-list.ts: FilterableSelectListTheme",
|
||||
"src/tui/gateway-chat.ts: resolveBoundGatewayConnection",
|
||||
"src/tui/gateway-chat.ts: resolveGatewayConnection",
|
||||
"src/tui/theme/theme.ts: lightMode",
|
||||
"src/tui/theme/theme.ts: lightPalette",
|
||||
"src/tui/theme/theme.ts: palette",
|
||||
"src/tui/tui-busy-notice.ts: TUI_AGENT_BUSY_MESSAGE",
|
||||
"src/tui/tui-last-session.ts: isHeartbeatLikeTuiSession",
|
||||
"src/tui/tui-last-session.ts: resolveTuiLastSessionStatePath",
|
||||
"src/tui/tui-plugin-approvals.ts: parseTuiPluginApproval",
|
||||
"src/tui/tui-task-suggestions.ts: parseTuiTaskSuggestion",
|
||||
"src/tui/tui-waiting.ts: pickWaitingPhrase",
|
||||
"src/wizard/clack-navigation-prompts.ts: formatNavigationFooter",
|
||||
"src/wizard/i18n/index.ts: listWizardI18nKeys",
|
||||
"src/wizard/i18n/index.ts: resolveWizardLocale",
|
||||
"src/wizard/i18n/index.ts: resolveWizardLocaleFromEnv",
|
||||
"src/wizard/i18n/index.ts: WIZARD_SUPPORTED_LOCALES",
|
||||
"src/wizard/setup.migration-recovery.ts: testing",
|
||||
"src/wizard/setup.official-plugins.ts: resolveOfficialPluginOnboardingInstallEntries",
|
||||
"src/wizard/setup.official-plugins.ts: testing",
|
||||
];
|
||||
|
||||
// Platform-variant findings. Allowed when present; never required.
|
||||
export const KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE = [];
|
||||
@@ -0,0 +1,270 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
|
||||
|
||||
const KNIP_VERSION = "6.8.0";
|
||||
const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const KNIP_KILL_GRACE_MS = 5_000;
|
||||
const KNIP_PROCESS_TREE_EXIT_POLL_MS = 25;
|
||||
const KNIP_POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
const KNIP_HEARTBEAT_MS = 60_000;
|
||||
|
||||
/** Maximum buffered Knip output retained for diagnostics. */
|
||||
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export function normalizeRepoPath(value) {
|
||||
return value.replaceAll("\\", "/").replace(/^\.\//u, "");
|
||||
}
|
||||
|
||||
export function uniqueSorted(values) {
|
||||
return [...new Set(values.map(normalizeRepoPath))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyRepoFilePath(value) {
|
||||
return /^(apps|docs|extensions|packages|scripts|src|test|ui)\//u.test(normalizeRepoPath(value));
|
||||
}
|
||||
|
||||
/** Compares a detected string list with required and optional baseline entries. */
|
||||
export function compareStringListToAllowlist(actualValues, allowlistValues, optionalValues = []) {
|
||||
const actual = uniqueSorted(actualValues);
|
||||
const allowed = uniqueSorted(allowlistValues);
|
||||
const optionalAllowed = uniqueSorted(optionalValues);
|
||||
const allowedOrOptionalSet = new Set([...allowed, ...optionalAllowed]);
|
||||
const actualSet = new Set(actual);
|
||||
|
||||
return {
|
||||
actual,
|
||||
allowed,
|
||||
unexpected: actual.filter((value) => !allowedOrOptionalSet.has(value)),
|
||||
stale: allowed.filter((value) => !actualSet.has(value)),
|
||||
duplicateAllowedCount: allowlistValues.length - new Set(allowlistValues).size,
|
||||
allowlistIsSorted:
|
||||
JSON.stringify(allowlistValues.map(normalizeRepoPath)) === JSON.stringify(allowed),
|
||||
};
|
||||
}
|
||||
|
||||
function spawnErrorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
function signalProcessTree(child, signal) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
process.kill(child.pid, signal);
|
||||
} else {
|
||||
process.kill(-child.pid, signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the timeout and signal delivery.
|
||||
}
|
||||
}
|
||||
|
||||
function processTreeAlive(child) {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessTreeExit(child, timeoutMs) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, KNIP_PROCESS_TREE_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processTreeAlive(child);
|
||||
}
|
||||
|
||||
/** Runs pinned Knip with the supplied CLI arguments. */
|
||||
export async function runKnip(knipArgs, params = {}) {
|
||||
const run = params.spawnCommand ?? spawn;
|
||||
const timeoutMs = params.timeoutMs ?? KNIP_TIMEOUT_MS;
|
||||
const heartbeatMs = params.heartbeatMs ?? KNIP_HEARTBEAT_MS;
|
||||
const maxBufferBytes = params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES;
|
||||
const killGraceMs = params.killGraceMs ?? KNIP_KILL_GRACE_MS;
|
||||
const scanName = params.scanName ?? "scan";
|
||||
const writeStatus = params.writeStatus ?? ((message) => process.stderr.write(`${message}\n`));
|
||||
const args = [
|
||||
"--config.minimum-release-age=0",
|
||||
"dlx",
|
||||
"--package",
|
||||
`knip@${KNIP_VERSION}`,
|
||||
"knip",
|
||||
...knipArgs,
|
||||
];
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let bufferExceeded = false;
|
||||
let outputBytes = 0;
|
||||
const output = [];
|
||||
let killTimer;
|
||||
let exitStatus = null;
|
||||
let exitSignal = null;
|
||||
|
||||
const pnpm = createPnpmRunnerSpawnSpec({
|
||||
detached: process.platform !== "win32",
|
||||
env: params.env,
|
||||
nodeExecPath: params.nodeExecPath,
|
||||
npmExecPath: params.npmExecPath,
|
||||
platform: params.platform,
|
||||
pnpmArgs: args,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const child = run(pnpm.command, pnpm.args, {
|
||||
...pnpm.options,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const parentSignalHandlers = [];
|
||||
const cleanupParentSignalHandlers = () => {
|
||||
for (const { signal, handler } of parentSignalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
parentSignalHandlers.length = 0;
|
||||
};
|
||||
const relayParentSignal = (signal) => {
|
||||
const handler = () => {
|
||||
signalProcessTree(child, signal);
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
cleanupParentSignalHandlers();
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
parentSignalHandlers.push({ signal, handler });
|
||||
process.once(signal, handler);
|
||||
};
|
||||
if (process.platform !== "win32") {
|
||||
relayParentSignal("SIGINT");
|
||||
relayParentSignal("SIGTERM");
|
||||
relayParentSignal("SIGHUP");
|
||||
}
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
writeStatus(
|
||||
`[deadcode] Knip ${scanName} still running after ${Math.round((Date.now() - startedAt) / 1000)}s.`,
|
||||
);
|
||||
}, heartbeatMs);
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
clearInterval(heartbeatTimer);
|
||||
writeStatus(
|
||||
`[deadcode] Knip ${scanName} timed out after ${Math.round(timeoutMs / 1000)}s; terminating.`,
|
||||
);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(killTimer);
|
||||
cleanupParentSignalHandlers();
|
||||
resolve({ ...result, output: output.join("") });
|
||||
};
|
||||
const finishAfterProcessTreeCleanup = async (result) => {
|
||||
if (processTreeAlive(child)) {
|
||||
await waitForProcessTreeExit(child, killGraceMs);
|
||||
}
|
||||
if (processTreeAlive(child)) {
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
await waitForProcessTreeExit(child, KNIP_POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
finish(result);
|
||||
};
|
||||
|
||||
const appendOutput = (chunk) => {
|
||||
if (settled || bufferExceeded) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remainingBytes = maxBufferBytes - outputBytes;
|
||||
if (buffer.length <= remainingBytes) {
|
||||
output.push(buffer.toString("utf8"));
|
||||
outputBytes += buffer.length;
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
|
||||
outputBytes = maxBufferBytes;
|
||||
}
|
||||
bufferExceeded = true;
|
||||
writeStatus(
|
||||
`[deadcode] Knip ${scanName} exceeded ${maxBufferBytes} output bytes; terminating.`,
|
||||
);
|
||||
child.stdout?.off?.("data", appendOutput);
|
||||
child.stderr?.off?.("data", appendOutput);
|
||||
child.stdout?.destroy?.();
|
||||
child.stderr?.destroy?.();
|
||||
clearInterval(heartbeatTimer);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
};
|
||||
|
||||
child.stdout?.on("data", appendOutput);
|
||||
child.stderr?.on("data", appendOutput);
|
||||
child.on("error", (error) =>
|
||||
finish({
|
||||
errorCode: spawnErrorCode(error),
|
||||
errorMessage: error.message,
|
||||
signal: null,
|
||||
status: null,
|
||||
}),
|
||||
);
|
||||
child.on("exit", (status, signal) => {
|
||||
exitStatus = status;
|
||||
exitSignal = signal;
|
||||
});
|
||||
child.on("close", (status, signal) => {
|
||||
exitStatus = exitStatus ?? status;
|
||||
exitSignal = exitSignal ?? signal;
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (timedOut) {
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ETIMEDOUT",
|
||||
errorMessage: `Knip ${scanName} timed out after ${elapsedSeconds}s`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ENOBUFS",
|
||||
errorMessage: `Knip ${scanName} exceeded ${maxBufferBytes} output bytes`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
errorCode: undefined,
|
||||
errorMessage: undefined,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
|
||||
"extensions/diffs/src/viewer-client.ts",
|
||||
"extensions/diffs/src/viewer-payload.ts",
|
||||
"extensions/matrix/src/plugin-entry.runtime.js",
|
||||
"extensions/mxc/src/mxc-spawn-launcher.mjs",
|
||||
"src/agents/subagent-registry.runtime.ts",
|
||||
"src/auto-reply/reply/get-reply.test-loader.ts",
|
||||
"src/commands/doctor/shared/deprecation-compat.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { normalizeOptionalString } from "../packages/normalization-core/src/string-coerce.js";
|
||||
import { readBoundedResponseText as readBoundedResponseTextWithLimit } from "./lib/bounded-response.ts";
|
||||
import {
|
||||
@@ -44,7 +45,7 @@ const parseArgs = (args = process.argv.slice(2)): Args => {
|
||||
let sessionKey: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const arg = expectDefined(args[i], `Claude usage argument at index ${i}`);
|
||||
if (arg === "--agent") {
|
||||
agentId = parseNonBlankArgValue(parseRequiredArgValue(args, i, "--agent"), "--agent");
|
||||
i += 1;
|
||||
|
||||
@@ -283,7 +283,7 @@ function validateCliArgs(argv: string[]): void {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--") && arg.includes("=")) {
|
||||
const [flag] = arg.split("=", 1);
|
||||
const flag = arg.slice(0, arg.indexOf("="));
|
||||
if (VALUE_OPTIONS.has(flag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17,20 +17,47 @@ fi
|
||||
|
||||
echo "==> Pre-flight: ensure supported Node is already present"
|
||||
node -e '
|
||||
const version = process.versions.node.split(".").map(Number);
|
||||
const [major, minor, patch] = process.versions.node.split(".").map(Number);
|
||||
const ok =
|
||||
version.length >= 2 &&
|
||||
(version[0] > 22 || (version[0] === 22 && version[1] >= 19));
|
||||
(major === 22 && (minor > 22 || (minor === 22 && patch >= 3))) ||
|
||||
(major === 24 && minor >= 15) ||
|
||||
(major === 25 && minor >= 9) ||
|
||||
major >= 26;
|
||||
if (!ok) {
|
||||
process.stderr.write(`unsupported node ${process.versions.node}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
let sqliteVersion;
|
||||
try {
|
||||
require("node:sqlite");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(":memory:");
|
||||
try {
|
||||
sqliteVersion = db.prepare("SELECT sqlite_version() AS version").get()?.version;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const match =
|
||||
typeof sqliteVersion === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(sqliteVersion) : null;
|
||||
const sqliteMajor = Number(match?.[1]);
|
||||
const sqliteMinor = Number(match?.[2]);
|
||||
const sqlitePatch = Number(match?.[3]);
|
||||
const sqliteSafe =
|
||||
sqliteMajor > 3 ||
|
||||
(sqliteMajor === 3 &&
|
||||
(sqliteMinor > 51 ||
|
||||
(sqliteMinor === 51 && sqlitePatch >= 3) ||
|
||||
(sqliteMinor === 50 && sqlitePatch >= 7) ||
|
||||
(sqliteMinor === 44 && sqlitePatch >= 6)));
|
||||
if (!sqliteSafe) {
|
||||
process.stderr.write(
|
||||
`unsupported node ${process.versions.node}: unsafe SQLite ${String(sqliteVersion)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
'
|
||||
command -v npm >/dev/null
|
||||
|
||||
|
||||
@@ -45,19 +45,28 @@ func translateDocBodyChunked(ctx context.Context, translator docsTranslator, rel
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return body, nil
|
||||
}
|
||||
blocks := splitDocBodyIntoBlocks(body)
|
||||
placeholderState := NewPlaceholderState(body)
|
||||
placeholders := make([]string, 0, 8)
|
||||
mapping := map[string]string{}
|
||||
maskedBody := maskMarkdownFencedLiterals(body, placeholderState.Next, &placeholders, mapping)
|
||||
maskedBody = maskMarkdownDocSyntax(maskedBody, placeholderState.Next, &placeholders, mapping)
|
||||
blocks := splitDocBodyIntoBlocks(maskedBody)
|
||||
groups := groupDocBlocks(blocks, docsI18nDocChunkMaxBytes())
|
||||
logDocChunkPlan(relPath, blocks, groups)
|
||||
out := strings.Builder{}
|
||||
for index, group := range groups {
|
||||
chunkID := fmt.Sprintf("%s.chunk-%03d", relPath, index+1)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, srcLang, tgtLang)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, placeholders, srcLang, tgtLang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(translated)
|
||||
}
|
||||
translatedBody := out.String()
|
||||
if err := validatePlaceholders(translatedBody, placeholders); err != nil {
|
||||
return "", fmt.Errorf("%s: restore fenced literals: %w", relPath, err)
|
||||
}
|
||||
translatedBody = unmaskMarkdown(translatedBody, placeholders, mapping)
|
||||
if err := validateDocBodyFencedLiterals(body, translatedBody); err != nil {
|
||||
return "", fmt.Errorf("%s: final document validation: %w", relPath, err)
|
||||
}
|
||||
@@ -70,6 +79,12 @@ func validateDocBodyFencedLiterals(source, translated string) error {
|
||||
}
|
||||
sourceStructure := summarizeDocChunkStructure(source)
|
||||
translatedStructure := summarizeDocChunkStructure(translated)
|
||||
if !sameI18NProtocolMarkers(source, translated) {
|
||||
return fmt.Errorf("i18n placeholder mismatch")
|
||||
}
|
||||
if sourceStructure.fenceCount != translatedStructure.fenceCount {
|
||||
return fmt.Errorf("code fence mismatch: source=%d translated=%d", sourceStructure.fenceCount, translatedStructure.fenceCount)
|
||||
}
|
||||
if !slices.Equal(sourceStructure.listShapes, translatedStructure.listShapes) {
|
||||
return fmt.Errorf("list structure mismatch: source=%v translated=%v", sourceStructure.listShapes, translatedStructure.listShapes)
|
||||
}
|
||||
@@ -85,18 +100,21 @@ func validateDocBodyFencedLiterals(source, translated string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, srcLang, tgtLang string) (string, error) {
|
||||
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
source := strings.Join(blocks, "")
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return source, nil
|
||||
}
|
||||
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkPlanSplit(chunkID, plan, source)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
normalizedSource, commonIndent := stripCommonIndent(source)
|
||||
log.Printf("docs-i18n: chunk start %s blocks=%d bytes=%d", chunkID, len(blocks), len(source))
|
||||
translated, err := translator.TranslateRaw(ctx, normalizedSource, srcLang, tgtLang)
|
||||
if err == nil {
|
||||
err = validatePlaceholders(translated, placeholdersInText(normalizedSource, protectedPlaceholders))
|
||||
}
|
||||
if err == nil {
|
||||
translated = sanitizeDocChunkProtocolWrappers(source, translated)
|
||||
translated = reapplyCommonIndent(translated, commonIndent)
|
||||
@@ -108,27 +126,27 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
|
||||
}
|
||||
}
|
||||
if len(blocks) <= 1 {
|
||||
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, srcLang, tgtLang); fallbackErr == nil {
|
||||
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, protectedPlaceholders, srcLang, tgtLang); fallbackErr == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
if plan, ok := planSingletonDocChunkRetry(source, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkPlanSplit(chunkID, plan, source)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", chunkID, err)
|
||||
}
|
||||
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkSplit(chunkID, len(blocks), err)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
if plan, ok := splitDocChunkBlocksMidpointSimple(blocks); ok {
|
||||
logDocChunkSplit(chunkID, len(blocks), err)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", chunkID, err)
|
||||
}
|
||||
|
||||
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source, srcLang, tgtLang string) (string, error) {
|
||||
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
sourceStructure := summarizeDocChunkStructure(source)
|
||||
if sourceStructure.fenceCount != 0 {
|
||||
return "", fmt.Errorf("%s: raw leaf fallback not applicable", chunkID)
|
||||
@@ -139,6 +157,9 @@ func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunk
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validatePlaceholders(translated, placeholdersInText(maskedSource, protectedPlaceholders)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
translated, err = restoreDocComponentTags(translated, placeholders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -222,6 +243,12 @@ func validateDocChunkTranslation(source, translated string) error {
|
||||
sourceLower := strings.ToLower(source)
|
||||
translatedLower := strings.ToLower(translated)
|
||||
for _, token := range docsProtocolTokens {
|
||||
if token == "__OC_I18N_" {
|
||||
if !sameI18NProtocolMarkers(source, translated) {
|
||||
return fmt.Errorf("protocol token leaked: %s", token)
|
||||
}
|
||||
continue
|
||||
}
|
||||
tokenLower := strings.ToLower(token)
|
||||
if strings.Contains(sourceLower, tokenLower) {
|
||||
continue
|
||||
@@ -264,6 +291,18 @@ func validateDocChunkTranslation(source, translated string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameI18NProtocolMarkers(source, translated string) bool {
|
||||
source = strings.ReplaceAll(source, `\_`, "_")
|
||||
translated = strings.ReplaceAll(translated, `\_`, "_")
|
||||
if !sameStringMultiset(placeholderRe.FindAllString(source, -1), placeholderRe.FindAllString(translated, -1)) {
|
||||
return false
|
||||
}
|
||||
sourceResidual := placeholderRe.ReplaceAllString(source, "")
|
||||
translatedResidual := placeholderRe.ReplaceAllString(translated, "")
|
||||
return strings.Count(strings.ToLower(sourceResidual), "__oc_i18n_") ==
|
||||
strings.Count(strings.ToLower(translatedResidual), "__oc_i18n_")
|
||||
}
|
||||
|
||||
func sameStringMultiset(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -495,16 +534,72 @@ func containsProtocolWrapperToken(text string) bool {
|
||||
return strings.Contains(lower, strings.ToLower(bodyTagStart)) || strings.Contains(lower, strings.ToLower(frontmatterTagStart))
|
||||
}
|
||||
|
||||
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID string, groups [][]string, srcLang, tgtLang string) (string, error) {
|
||||
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID, source string, groups [][]string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
var out strings.Builder
|
||||
translatedGroups := make([]string, 0, len(groups))
|
||||
for index, group := range groups {
|
||||
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, srcLang, tgtLang)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, protectedPlaceholders, srcLang, tgtLang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
translatedGroups = append(translatedGroups, translated)
|
||||
out.WriteString(translated)
|
||||
}
|
||||
return out.String(), nil
|
||||
translated := out.String()
|
||||
if merged, ok := mergeSplitPureFencedDocTranslations(source, translatedGroups); ok {
|
||||
translated = merged
|
||||
}
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
return "", fmt.Errorf("%s: recombined split validation: %w", chunkID, err)
|
||||
}
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func mergeSplitPureFencedDocTranslations(source string, translatedGroups []string) (string, bool) {
|
||||
if len(translatedGroups) <= 1 {
|
||||
return "", false
|
||||
}
|
||||
if summarizeDocChunkStructure(source).fenceCount != 2 {
|
||||
return "", false
|
||||
}
|
||||
prefix, opening, _, closing, suffix, ok := splitPureFencedDocSection(source)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var inner strings.Builder
|
||||
for _, translated := range translatedGroups {
|
||||
_, _, groupInner, _, _, groupOK := splitPureFencedDocSection(translated)
|
||||
if !groupOK {
|
||||
return "", false
|
||||
}
|
||||
inner.WriteString(groupInner)
|
||||
}
|
||||
return prefix + opening + inner.String() + closing + suffix, true
|
||||
}
|
||||
|
||||
func splitPureFencedDocSection(text string) (prefix, opening, inner, closing, suffix string, ok bool) {
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
if len(lines) < 2 {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
openingIndex := firstNonEmptyLineIndex(lines)
|
||||
closingIndex := lastNonEmptyLineIndex(lines)
|
||||
if openingIndex == -1 || closingIndex <= openingIndex {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
opening = lines[openingIndex]
|
||||
delimiter := leadingFenceDelimiter(opening)
|
||||
if delimiter == "" || !isClosingFenceLine(lines[closingIndex], delimiter) {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
prefix = strings.Join(lines[:openingIndex], "")
|
||||
suffix = strings.Join(lines[closingIndex+1:], "")
|
||||
if strings.TrimSpace(prefix) != "" || strings.TrimSpace(suffix) != "" {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
inner = strings.Join(lines[openingIndex+1:closingIndex], "")
|
||||
closing = lines[closingIndex]
|
||||
return prefix, opening, inner, closing, suffix, true
|
||||
}
|
||||
|
||||
func planDocChunkSplit(blocks []string, maxBytes, promptBudget int) (docChunkSplitPlan, bool) {
|
||||
@@ -665,27 +760,10 @@ func splitDocBlockSections(block string) []string {
|
||||
}
|
||||
|
||||
func splitPureFencedDocSectionWithMode(block string, maxBytes, promptBudget int, force bool) ([][]string, bool) {
|
||||
lines := strings.SplitAfter(block, "\n")
|
||||
if len(lines) < 2 {
|
||||
_, opening, inner, closing, _, ok := splitPureFencedDocSection(block)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
openingIndex := firstNonEmptyLineIndex(lines)
|
||||
closingIndex := lastNonEmptyLineIndex(lines)
|
||||
if openingIndex == -1 || closingIndex <= openingIndex {
|
||||
return nil, false
|
||||
}
|
||||
opening := lines[openingIndex]
|
||||
delimiter := leadingFenceDelimiter(opening)
|
||||
if delimiter == "" || !isClosingFenceLine(lines[closingIndex], delimiter) {
|
||||
return nil, false
|
||||
}
|
||||
prefix := strings.Join(lines[:openingIndex], "")
|
||||
suffix := strings.Join(lines[closingIndex+1:], "")
|
||||
if strings.TrimSpace(prefix) != "" || strings.TrimSpace(suffix) != "" {
|
||||
return nil, false
|
||||
}
|
||||
closing := lines[closingIndex]
|
||||
inner := strings.Join(lines[openingIndex+1:closingIndex], "")
|
||||
groups, ok := splitPlainDocSectionWithMode(inner, maxBytes-len(opening)-len(closing), promptBudget, force)
|
||||
if !ok {
|
||||
return nil, false
|
||||
|
||||
@@ -77,6 +77,9 @@ func processFileDoc(ctx context.Context, translator docsTranslator, docsRoot, fi
|
||||
}
|
||||
|
||||
output := updatedFront + translatedBody
|
||||
if !sameI18NProtocolMarkers(string(content), output) {
|
||||
return false, "", fmt.Errorf("protocol token leaked in final output: __OC_I18N_")
|
||||
}
|
||||
return false, outputPath, os.WriteFile(outputPath, []byte(output), 0o644)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -35,15 +36,15 @@ type docLeafFallbackTranslator struct{}
|
||||
|
||||
func (docLeafFallbackTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
replacer := strings.NewReplacer(
|
||||
"Gateway refuses to start unless `local`.", "Gateway 只有在 `local` 时才会启动。",
|
||||
"Gateway refuses to start unless", "Gateway 只有在",
|
||||
"`gateway.auth.mode: \"trusted-proxy\"`", "`gateway.auth.mode: \"trusted-proxy\"`",
|
||||
)
|
||||
return replacer.Replace(text), nil
|
||||
}
|
||||
|
||||
func (docLeafFallbackTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
if strings.Contains(text, "Gateway refuses to start unless `local`.") {
|
||||
return strings.Replace(text, "Gateway refuses to start unless `local`.", "<Tip>Gateway only starts in local mode.</Tip>", 1), nil
|
||||
if strings.Contains(text, "Gateway refuses to start unless") {
|
||||
return strings.Replace(text, "Gateway refuses to start unless", "<Tip>Gateway only starts in local mode.</Tip>", 1), nil
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -177,7 +178,7 @@ func (t *docPromptBudgetTranslator) Translate(_ context.Context, text, _, _ stri
|
||||
func (t *docPromptBudgetTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
t.rawInputs = append(t.rawInputs, text)
|
||||
replacer := strings.NewReplacer(
|
||||
"First chunk with `json5` and { braces }", "第一块,含 `json5` 和 { braces }",
|
||||
"First chunk with", "第一块,含",
|
||||
"Second chunk with | table | pipes |", "第二块,含 | table | pipes |",
|
||||
)
|
||||
return replacer.Replace(text), nil
|
||||
@@ -257,6 +258,67 @@ func (splitProtocolMarkerTranslator) TranslateRaw(_ context.Context, text, _, _
|
||||
|
||||
func (splitProtocolMarkerTranslator) Close() {}
|
||||
|
||||
type fencedLiteralMaskingTranslator struct {
|
||||
rawInputs []string
|
||||
}
|
||||
|
||||
func (t *fencedLiteralMaskingTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (t *fencedLiteralMaskingTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
t.rawInputs = append(t.rawInputs, text)
|
||||
return strings.NewReplacer(
|
||||
"Outside [Warning].", "Fuera [Advertencia].",
|
||||
"Human prose.", "Prosa humana.",
|
||||
"Speak this.", "Di esto.",
|
||||
"[Replying to <sender>]", "[Respondiendo a <remitente>]",
|
||||
"[/Replying]", "[/Respondiendo]",
|
||||
"[[tts:text]]", "[[tts:texto]]",
|
||||
"[Notice kind=system]", "[Aviso kind=system]",
|
||||
).Replace(text), nil
|
||||
}
|
||||
|
||||
func (t *fencedLiteralMaskingTranslator) Close() {}
|
||||
|
||||
type docSyntaxMaskingTranslator struct {
|
||||
rawInputs []string
|
||||
}
|
||||
|
||||
func (t *docSyntaxMaskingTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (t *docSyntaxMaskingTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
t.rawInputs = append(t.rawInputs, text)
|
||||
translated := strings.ReplaceAll(text, "Visible prose", "Видимый текст")
|
||||
translated = regexp.MustCompile(`(?m)^\s+(__OC_I18N_\d+__)`).ReplaceAllString(translated, "$1")
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func (t *docSyntaxMaskingTranslator) Close() {}
|
||||
|
||||
type duplicateFirstFencedPlaceholderTranslator struct {
|
||||
rawCalls int
|
||||
}
|
||||
|
||||
func (t *duplicateFirstFencedPlaceholderTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (t *duplicateFirstFencedPlaceholderTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
t.rawCalls++
|
||||
if t.rawCalls == 1 {
|
||||
placeholder := placeholderRe.FindString(text)
|
||||
if placeholder != "" {
|
||||
return strings.Replace(text, placeholder, placeholder+placeholder, 1), nil
|
||||
}
|
||||
}
|
||||
return strings.ReplaceAll(text, "Human prose.", "Prosa humana."), nil
|
||||
}
|
||||
|
||||
func (t *duplicateFirstFencedPlaceholderTranslator) Close() {}
|
||||
|
||||
func TestParseTaggedDocumentRejectsMissingBodyCloseAtEOF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -467,7 +529,7 @@ func TestTranslateDocBodyChunkedFallsBackToMaskedTranslateForLeafValidationFailu
|
||||
if strings.Contains(translated, "<Tip>") {
|
||||
t.Fatalf("expected masked fallback to remove hallucinated component tags:\n%s", translated)
|
||||
}
|
||||
if !strings.Contains(translated, "Gateway 只有在 `local` 时才会启动。") {
|
||||
if !strings.Contains(translated, "Gateway 只有在 `local`.") {
|
||||
t.Fatalf("expected fallback translation to be applied:\n%s", translated)
|
||||
}
|
||||
}
|
||||
@@ -502,6 +564,55 @@ func TestValidateDocChunkTranslationRejectsInventedI18NPlaceholder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsAdditionalI18NPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "```text\n__OC_I18N_900000__\n```\n"
|
||||
translated := "```text\n__OC_I18N_900000__\n```\n__OC_I18N_900014__\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected additional i18n placeholder to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "protocol token leaked: __OC_I18N_") {
|
||||
t.Fatalf("expected i18n placeholder leakage error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsMalformedI18NPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, leaked := range []string{"__oc_i18n_900014__", "__OC_I18N_invalid__", `\_\_OC\_I18N\_900014\_\_`} {
|
||||
err := validateDocChunkTranslation("Regular paragraph.\n", "Обычный абзац.\n"+leaked+"\n")
|
||||
if err == nil {
|
||||
t.Fatalf("expected malformed i18n placeholder %q to be rejected", leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocBodyFencedLiteralsRejectsRestoredPlaceholderLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Before.\n\n```ts\nconst value = \"<user-id>\";\n```\n\nAfter.\n"
|
||||
translated := "До.\n\n__OC_I18N_900014__\n\nПосле.\n"
|
||||
|
||||
err := validateDocBodyFencedLiterals(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected restored placeholder leak to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalDocOutputRejectsI18NPlaceholderLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if sameI18NProtocolMarkers("Regular prose.\n", "Обычный текст.\n__OC_I18N_900014__\n") {
|
||||
t.Fatal("expected final output placeholder leak to be rejected")
|
||||
}
|
||||
if !sameI18NProtocolMarkers("Example __OC_I18N_42__.\n", "Пример __OC_I18N_42__.\n") {
|
||||
t.Fatal("expected source-authored placeholder example to remain valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsHeadingLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -662,6 +773,21 @@ func TestValidateDocChunkTranslationRejectsChangedTripleBacktickCodeSpan(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsChangedLineStartTripleBacktickCodeSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "```foo``` is the value.\n```\ncode\n```\n"
|
||||
translated := "```bar``` es el valor.\n```\ncode\n```\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected changed line-start triple-backtick code span to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "inline code mismatch") {
|
||||
t.Fatalf("expected inline code mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsChangedMultilineCodeSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -785,6 +911,20 @@ func TestValidateDocChunkTranslationRejectsCodeAfterComponentFence(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationAllowsTranslatedProseInIsolatedIndentedFence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := " ```json5\n provider: \"firecrawl\", // optional; omit for auto-detect\n ```\n"
|
||||
translated := " ```json5\n provider: \"firecrawl\", // необязательно; опустите для автоопределения\n ```\n"
|
||||
|
||||
if values := extractMarkdownInlineCodeValues(source); len(values) != 0 {
|
||||
t.Fatalf("expected custom indented fence to be excluded from inline code, got %q", values)
|
||||
}
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
t.Fatalf("expected translated fence prose to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsChangedCodeInSplitComponentBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1227,6 +1367,35 @@ func TestValidateDocBodyFencedLiteralsRejectsFenceBalanceChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocBodyFencedLiteralsRejectsBalancedFenceCountChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "```json5\n{ session: { enabled: true } }\n```\n"
|
||||
translated := "```json5\n{ session: {\n```\n```json5\nenabled: true } }\n```\n"
|
||||
|
||||
err := validateDocBodyFencedLiterals(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected recombined fence count change to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code fence mismatch: source=2 translated=4") {
|
||||
t.Fatalf("expected code fence mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSplitPureFencedDocTranslationsRejectsAdjacentFences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "```text\nFirst block.\n```\n```text\nSecond block.\n```\n"
|
||||
translatedGroups := []string{
|
||||
"```text\nПервый блок.\n```\n",
|
||||
"```text\nВторой блок.\n```\n",
|
||||
}
|
||||
|
||||
if merged, ok := mergeSplitPureFencedDocTranslations(source, translatedGroups); ok {
|
||||
t.Fatalf("expected adjacent source fences not to merge, got:\n%s", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationAllowsFencedBracketLabelsToTranslate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1451,25 +1620,96 @@ func TestValidateDocChunkTranslationStopsFencedLiteralsAtContainerBoundary(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedRevalidatesMarkersAfterSplit(t *testing.T) {
|
||||
func TestTranslateDocBodyChunkedPreservesMarkersAfterSplit(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```text",
|
||||
"[Notice kind=system]",
|
||||
"Line 01",
|
||||
"Line 02",
|
||||
"Line 03",
|
||||
"Line 04",
|
||||
"[Notice kind=system]",
|
||||
"[/Notice]",
|
||||
"```",
|
||||
"",
|
||||
}, "\n")
|
||||
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "32")
|
||||
translator := &fencedLiteralMaskingTranslator{}
|
||||
|
||||
_, err := translateDocBodyChunked(context.Background(), splitProtocolMarkerTranslator{}, "channels/example.md", body, "en", "es")
|
||||
if err == nil {
|
||||
t.Fatal("expected recombined split marker mutation to be rejected")
|
||||
translated, err := translateDocBodyChunked(context.Background(), translator, "channels/example.md", body, "en", "es")
|
||||
if err != nil {
|
||||
t.Fatalf("expected split translation to preserve masked protocol markers, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "final document validation") || !strings.Contains(err.Error(), "fenced protocol marker mismatch") {
|
||||
t.Fatalf("expected final fenced protocol validation error, got %v", err)
|
||||
if !strings.Contains(translated, "[Notice kind=system]") || !strings.Contains(translated, "[/Notice]") {
|
||||
t.Fatalf("expected original protocol markers after split translation:\n%s", translated)
|
||||
}
|
||||
if strings.Contains(translated, "[Aviso kind=system]") {
|
||||
t.Fatalf("expected raw translator mutation to remain impossible after masking:\n%s", translated)
|
||||
}
|
||||
for _, input := range translator.rawInputs {
|
||||
if strings.Contains(input, "[Notice kind=system]") {
|
||||
t.Fatalf("expected continuation-chunk marker to be masked before splitting:\n%s", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedMasksFencedLiteralsBeforeTranslation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := strings.Join([]string{
|
||||
"Outside [Warning].",
|
||||
"",
|
||||
"> ```text",
|
||||
"> [Replying to <sender>]",
|
||||
"> Human prose.",
|
||||
"> [/Replying]",
|
||||
"> [[tts:text]]Speak this.[[/tts:text]]",
|
||||
"> ```",
|
||||
"",
|
||||
}, "\n")
|
||||
translator := &fencedLiteralMaskingTranslator{}
|
||||
|
||||
translated, err := translateDocBodyChunked(context.Background(), translator, "channels/example.md", body, "en", "es")
|
||||
if err != nil {
|
||||
t.Fatalf("expected fenced literals to survive translation, got %v", err)
|
||||
}
|
||||
for _, input := range translator.rawInputs {
|
||||
for _, literal := range []string{"[Replying to <sender>]", "[/Replying]", "[[tts:text]]", "[[/tts:text]]"} {
|
||||
if strings.Contains(input, literal) {
|
||||
t.Fatalf("expected fenced literal %q to be masked from raw translator input:\n%s", literal, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Fuera [Advertencia].",
|
||||
"> [Replying to <sender>]",
|
||||
"> Prosa humana.",
|
||||
"> [/Replying]",
|
||||
"> [[tts:text]]Di esto.[[/tts:text]]",
|
||||
} {
|
||||
if !strings.Contains(translated, want) {
|
||||
t.Fatalf("expected translated output to contain %q:\n%s", want, translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedRetriesDuplicatedFencedPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "```text\n[Replying to <sender>]\nHuman prose.\n[/Replying]\n```\n"
|
||||
translator := &duplicateFirstFencedPlaceholderTranslator{}
|
||||
|
||||
translated, err := translateDocBodyChunked(context.Background(), translator, "channels/example.md", body, "en", "es")
|
||||
if err != nil {
|
||||
t.Fatalf("expected duplicate placeholder response to recover through chunk retry, got %v", err)
|
||||
}
|
||||
if translator.rawCalls < 2 {
|
||||
t.Fatalf("expected duplicate placeholder to trigger a chunk retry, got %d raw call(s)", translator.rawCalls)
|
||||
}
|
||||
if strings.Count(translated, "[Replying to <sender>]") != 1 || strings.Count(translated, "[/Replying]") != 1 {
|
||||
t.Fatalf("expected each restored marker exactly once:\n%s", translated)
|
||||
}
|
||||
if !strings.Contains(translated, "Prosa humana.") {
|
||||
t.Fatalf("expected human prose to remain translatable after retry:\n%s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1796,7 +2036,7 @@ func TestTranslateDocBodyChunkedPreSplitsOversizedPromptBudget(t *testing.T) {
|
||||
t.Fatalf("translateDocBodyChunked returned error: %v", err)
|
||||
}
|
||||
for _, input := range translator.rawInputs {
|
||||
if strings.Contains(input, "First chunk with `json5` and { braces }") && strings.Contains(input, "Second chunk with | table | pipes |") {
|
||||
if strings.Contains(input, "First chunk with") && strings.Contains(input, "Second chunk with | table | pipes |") {
|
||||
t.Fatalf("expected prompt budget guard to split before raw translation, saw combined input:\n%s", input)
|
||||
}
|
||||
}
|
||||
@@ -1897,6 +2137,61 @@ func TestTranslateDocBodyChunkedSplitsOversizedFenceBeforeTrailingProse(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"- Visible prose uses `openclaw config`.",
|
||||
" 1. Visible prose keeps ``nested `ticks` `` exact.",
|
||||
"- Channel configs:",
|
||||
" - Telegram: Visible prose.",
|
||||
" - WhatsApp: Visible prose.",
|
||||
"> - Visible prose inside a quote.",
|
||||
"",
|
||||
"```md",
|
||||
"- Visible prose and `fenced example` stay exposed.",
|
||||
"```",
|
||||
"",
|
||||
"> ```md",
|
||||
"> - Visible prose and `quoted fenced example` stay exposed.",
|
||||
"> ```",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
translator := &docSyntaxMaskingTranslator{}
|
||||
translated, err := translateDocBodyChunked(context.Background(), translator, "gateway/configuration.md", body, "en", "ru")
|
||||
if err != nil {
|
||||
t.Fatalf("translateDocBodyChunked returned error: %v", err)
|
||||
}
|
||||
if len(translator.rawInputs) == 0 {
|
||||
t.Fatal("expected raw translator inputs")
|
||||
}
|
||||
for _, input := range translator.rawInputs {
|
||||
if strings.Contains(input, "`openclaw config`") || strings.Contains(input, "``nested `ticks` ``") {
|
||||
t.Fatalf("expected inline code outside fences to be masked:\n%s", input)
|
||||
}
|
||||
if strings.Contains(input, "- Visible prose uses") || strings.Contains(input, "1. Visible prose keeps") || strings.Contains(input, "> - Visible prose inside a quote.") {
|
||||
t.Fatalf("expected list prefixes outside fences to be masked:\n%s", input)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^(?:\s+|>\s*)__OC_I18N_\d+__`).MatchString(input) {
|
||||
t.Fatalf("expected list indentation and quote containers to be masked with their markers:\n%s", input)
|
||||
}
|
||||
}
|
||||
for _, exact := range []string{
|
||||
"- Видимый текст uses `openclaw config`.",
|
||||
" 1. Видимый текст keeps ``nested `ticks` `` exact.",
|
||||
"- Channel configs:\n - Telegram: Видимый текст.\n - WhatsApp: Видимый текст.",
|
||||
"> - Видимый текст inside a quote.",
|
||||
"```md\n- Видимый текст and `fenced example` stay exposed.\n```",
|
||||
"> ```md\n> - Видимый текст and `quoted fenced example` stay exposed.\n> ```",
|
||||
} {
|
||||
if !strings.Contains(translated, exact) {
|
||||
t.Fatalf("expected restored syntax %q:\n%s", exact, translated)
|
||||
}
|
||||
}
|
||||
if err := validateDocBodyFencedLiterals(body, translated); err != nil {
|
||||
t.Fatalf("expected final structure to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedRetriesSingletonFenceAfterValidationFailure(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```md",
|
||||
@@ -1932,6 +2227,9 @@ func TestTranslateDocBodyChunkedRetriesSingletonFenceAfterValidationFailure(t *t
|
||||
if !strings.Contains(translated, "Translated line 01") || !strings.Contains(translated, "Translated line 04") {
|
||||
t.Fatalf("expected singleton fence retry to reassemble translated output:\n%s", translated)
|
||||
}
|
||||
if sourceCount, translatedCount := summarizeDocChunkStructure(body).fenceCount, summarizeDocChunkStructure(translated).fenceCount; sourceCount != translatedCount {
|
||||
t.Fatalf("expected singleton fence retry to preserve one fenced block, source=%d translated=%d:\n%s", sourceCount, translatedCount, translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedUnwrapsTaggedLeafProtocolLeakage(t *testing.T) {
|
||||
@@ -2027,8 +2325,8 @@ func TestProcessFileDocUsesFieldLevelFrontmatterTranslation(t *testing.T) {
|
||||
if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") {
|
||||
t.Fatalf("expected translated read_when entry in output:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "prompt_version: 11") {
|
||||
t.Fatalf("expected prompt version 11 in output metadata:\n%s", text)
|
||||
if !strings.Contains(text, "prompt_version: 24") {
|
||||
t.Fatalf("expected prompt version 24 in output metadata:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,19 @@ func TestLocalizeBodyLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmaskMarkdownRestoresNestedPlaceholders(t *testing.T) {
|
||||
placeholders := []string{"__OC_I18N_900000__", "__OC_I18N_900001__"}
|
||||
mapping := map[string]string{
|
||||
"__OC_I18N_900000__": "```ts\nconst value = true;\n```",
|
||||
"__OC_I18N_900001__": "Before\n__OC_I18N_900000__\nAfter",
|
||||
}
|
||||
got := unmaskMarkdown("__OC_I18N_900001__", placeholders, mapping)
|
||||
want := "Before\n```ts\nconst value = true;\n```\nAfter"
|
||||
if got != want {
|
||||
t.Fatalf("nested placeholders not fully restored\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDocsTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ func markdownListParentItemPath(list *ast.List) string {
|
||||
|
||||
func extractMarkdownInlineCodeValues(body string) []string {
|
||||
parseSource := []byte(normalizeDocComponentsForMarkdownParse(body))
|
||||
fencedRanges := markdownClosedLiteralFenceByteRanges(string(parseSource))
|
||||
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(parseSource))
|
||||
values := []string{}
|
||||
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
@@ -191,6 +192,9 @@ func extractMarkdownInlineCodeValues(body string) []string {
|
||||
}
|
||||
span, ok := node.(*ast.CodeSpan)
|
||||
if ok {
|
||||
if byteRange, found := markdownCodeSpanContentRange(span); found && rangeOverlapsAny(byteRange, fencedRanges) {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
values = append(values, string(span.Text(parseSource)))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
@@ -319,8 +323,12 @@ func parseMarkdownLiteralFenceOpening(line string) (markdownLiteralFenceState, b
|
||||
if delimiter == "" {
|
||||
return markdownLiteralFenceState{}, false
|
||||
}
|
||||
infoText := strings.TrimSpace(remaining[len(delimiter):])
|
||||
if delimiter[0] == '`' && strings.Contains(infoText, "`") {
|
||||
return markdownLiteralFenceState{}, false
|
||||
}
|
||||
info := ""
|
||||
if fields := strings.Fields(strings.TrimSpace(remaining[len(delimiter):])); len(fields) > 0 {
|
||||
if fields := strings.Fields(infoText); len(fields) > 0 {
|
||||
info = strings.ToLower(fields[0])
|
||||
}
|
||||
return markdownLiteralFenceState{delimiter: delimiter, quoteDepth: quoteDepth, info: info, containerIndent: containerIndent}, true
|
||||
@@ -620,7 +628,7 @@ func fencedMarkerName(value string) (string, bool) {
|
||||
}
|
||||
|
||||
func extractFallbackBacktickValues(body string) []string {
|
||||
fenced := markdownFencedCodeRanges(body)
|
||||
fenced := append(markdownFencedCodeRanges(body), markdownClosedLiteralFenceByteRanges(body)...)
|
||||
values := []string{}
|
||||
for _, span := range markdownBlockBacktickRanges(body) {
|
||||
if rangeOverlapsAny(span, fenced) {
|
||||
@@ -638,6 +646,27 @@ func extractFallbackBacktickValues(body string) []string {
|
||||
return values
|
||||
}
|
||||
|
||||
func markdownCodeSpanContentRange(span *ast.CodeSpan) ([2]int, bool) {
|
||||
start, end := -1, -1
|
||||
for child := span.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
textNode, ok := child.(*ast.Text)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
segment := textNode.Segment
|
||||
if start < 0 || segment.Start < start {
|
||||
start = segment.Start
|
||||
}
|
||||
if segment.Stop > end {
|
||||
end = segment.Stop
|
||||
}
|
||||
}
|
||||
if start < 0 || end < start {
|
||||
return [2]int{}, false
|
||||
}
|
||||
return [2]int{start, end}, true
|
||||
}
|
||||
|
||||
func markdownFencedCodeRanges(body string) [][2]int {
|
||||
source := []byte(body)
|
||||
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(source))
|
||||
@@ -869,22 +898,8 @@ func markdownCodeSpanRanges(body string) [][2]int {
|
||||
if !ok {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
start, end := -1, -1
|
||||
for child := span.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
textNode, ok := child.(*ast.Text)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
segment := textNode.Segment
|
||||
if start < 0 || segment.Start < start {
|
||||
start = segment.Start
|
||||
}
|
||||
if segment.Stop > end {
|
||||
end = segment.Stop
|
||||
}
|
||||
}
|
||||
if start >= 0 && end >= start {
|
||||
ranges = append(ranges, [2]int{start, end})
|
||||
if byteRange, found := markdownCodeSpanContentRange(span); found {
|
||||
ranges = append(ranges, byteRange)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,6 +12,7 @@ var (
|
||||
angleLinkRe = regexp.MustCompile(`<https?://[^>]+>`)
|
||||
linkURLRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
|
||||
placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`)
|
||||
listMarkerRe = regexp.MustCompile(`^([ \t]*(?:>[ \t]*)*)([-+*]|[0-9]+[.)])([ \t]+)`)
|
||||
)
|
||||
|
||||
func maskMarkdown(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
|
||||
@@ -20,6 +22,167 @@ func maskMarkdown(text string, nextPlaceholder func() string, placeholders *[]st
|
||||
return masked
|
||||
}
|
||||
|
||||
func maskMarkdownFencedLiterals(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
|
||||
angleValues, protocolValues, directiveValues := extractMarkdownFencedLiteralValues(text)
|
||||
unique := map[string]struct{}{}
|
||||
for _, value := range append(append(angleValues, protocolValues...), directiveValues...) {
|
||||
if value != "" {
|
||||
unique[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(unique))
|
||||
for value := range unique {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return len(values[i]) > len(values[j])
|
||||
})
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, regexp.QuoteMeta(value))
|
||||
}
|
||||
literalRE := regexp.MustCompile(strings.Join(quoted, "|"))
|
||||
|
||||
state := markdownLiteralFenceState{}
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
for index, line := range lines {
|
||||
if state.delimiter == "" {
|
||||
if opening, ok := parseMarkdownLiteralFenceOpening(line); ok {
|
||||
state = opening
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !continuesMarkdownLiteralFenceContainer(line, state) {
|
||||
state = markdownLiteralFenceState{}
|
||||
if opening, ok := parseMarkdownLiteralFenceOpening(line); ok {
|
||||
state = opening
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isMarkdownLiteralFenceClosing(line, state) {
|
||||
state = markdownLiteralFenceState{}
|
||||
continue
|
||||
}
|
||||
lines[index] = maskMatches(line, literalRE, nextPlaceholder, placeholders, mapping)
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func maskMarkdownDocSyntax(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
|
||||
inlineRanges := make([][2]int, 0)
|
||||
fencedRanges := markdownLiteralFenceByteRanges(text)
|
||||
for _, span := range markdownBlockBacktickRanges(text) {
|
||||
if !rangeOverlapsAny(span, fencedRanges) {
|
||||
inlineRanges = append(inlineRanges, span)
|
||||
}
|
||||
}
|
||||
masked := maskByteRanges(text, inlineRanges, nextPlaceholder, placeholders, mapping)
|
||||
|
||||
listRanges := make([][2]int, 0)
|
||||
fenceState := markdownLiteralFenceState{}
|
||||
offset := 0
|
||||
for _, line := range strings.SplitAfter(masked, "\n") {
|
||||
insideFence := false
|
||||
if fenceState.delimiter != "" {
|
||||
if continuesMarkdownLiteralFenceContainer(line, fenceState) {
|
||||
insideFence = true
|
||||
if isMarkdownLiteralFenceClosing(line, fenceState) {
|
||||
fenceState = markdownLiteralFenceState{}
|
||||
}
|
||||
} else {
|
||||
fenceState = markdownLiteralFenceState{}
|
||||
}
|
||||
}
|
||||
if !insideFence {
|
||||
if opening, ok := parseMarkdownLiteralFenceOpening(line); ok {
|
||||
fenceState = opening
|
||||
insideFence = true
|
||||
}
|
||||
}
|
||||
if !insideFence {
|
||||
if match := listMarkerRe.FindStringSubmatchIndex(line); len(match) >= 6 {
|
||||
listRanges = append(listRanges, [2]int{offset + match[0], offset + match[1]})
|
||||
}
|
||||
}
|
||||
offset += len(line)
|
||||
}
|
||||
return maskByteRanges(masked, listRanges, nextPlaceholder, placeholders, mapping)
|
||||
}
|
||||
|
||||
func markdownLiteralFenceByteRanges(text string) [][2]int {
|
||||
return markdownLiteralFenceByteRangesWithMode(text, true)
|
||||
}
|
||||
|
||||
func markdownClosedLiteralFenceByteRanges(text string) [][2]int {
|
||||
return markdownLiteralFenceByteRangesWithMode(text, false)
|
||||
}
|
||||
|
||||
func markdownLiteralFenceByteRangesWithMode(text string, includeUnclosed bool) [][2]int {
|
||||
ranges := make([][2]int, 0)
|
||||
state := markdownLiteralFenceState{}
|
||||
start := -1
|
||||
offset := 0
|
||||
for _, line := range strings.SplitAfter(text, "\n") {
|
||||
if state.delimiter != "" {
|
||||
if continuesMarkdownLiteralFenceContainer(line, state) {
|
||||
if isMarkdownLiteralFenceClosing(line, state) {
|
||||
ranges = append(ranges, [2]int{start, offset + len(line)})
|
||||
state = markdownLiteralFenceState{}
|
||||
start = -1
|
||||
}
|
||||
offset += len(line)
|
||||
continue
|
||||
}
|
||||
if includeUnclosed {
|
||||
ranges = append(ranges, [2]int{start, offset})
|
||||
}
|
||||
state = markdownLiteralFenceState{}
|
||||
start = -1
|
||||
}
|
||||
if opening, ok := parseMarkdownLiteralFenceOpening(line); ok {
|
||||
state = opening
|
||||
start = offset
|
||||
}
|
||||
offset += len(line)
|
||||
}
|
||||
if includeUnclosed && state.delimiter != "" {
|
||||
ranges = append(ranges, [2]int{start, len(text)})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
func maskByteRanges(text string, ranges [][2]int, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
|
||||
if len(ranges) == 0 {
|
||||
return text
|
||||
}
|
||||
sort.Slice(ranges, func(i, j int) bool {
|
||||
if ranges[i][0] == ranges[j][0] {
|
||||
return ranges[i][1] < ranges[j][1]
|
||||
}
|
||||
return ranges[i][0] < ranges[j][0]
|
||||
})
|
||||
var out strings.Builder
|
||||
pos := 0
|
||||
for _, span := range ranges {
|
||||
start, end := span[0], span[1]
|
||||
if start < pos || start < 0 || end <= start || end > len(text) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(text[pos:start])
|
||||
placeholder := nextPlaceholder()
|
||||
mapping[placeholder] = text[start:end]
|
||||
*placeholders = append(*placeholders, placeholder)
|
||||
out.WriteString(placeholder)
|
||||
pos = end
|
||||
}
|
||||
out.WriteString(text[pos:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func maskMatches(text string, re *regexp.Regexp, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
|
||||
matches := re.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
@@ -72,7 +235,10 @@ func maskLinkURLs(text string, nextPlaceholder func() string, placeholders *[]st
|
||||
|
||||
func unmaskMarkdown(text string, placeholders []string, mapping map[string]string) string {
|
||||
out := text
|
||||
for _, placeholder := range placeholders {
|
||||
// Later masking passes can capture placeholders emitted by earlier passes.
|
||||
// Restore in stack order so nested placeholders are expanded completely.
|
||||
for index := len(placeholders) - 1; index >= 0; index-- {
|
||||
placeholder := placeholders[index]
|
||||
original := mapping[placeholder]
|
||||
out = strings.ReplaceAll(out, placeholder, original)
|
||||
}
|
||||
@@ -81,9 +247,23 @@ func unmaskMarkdown(text string, placeholders []string, mapping map[string]strin
|
||||
|
||||
func validatePlaceholders(text string, placeholders []string) error {
|
||||
for _, placeholder := range placeholders {
|
||||
if !strings.Contains(text, placeholder) {
|
||||
count := strings.Count(text, placeholder)
|
||||
if count == 0 {
|
||||
return fmt.Errorf("placeholder missing: %s", placeholder)
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("placeholder duplicated: %s count=%d", placeholder, count)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func placeholdersInText(text string, placeholders []string) []string {
|
||||
found := make([]string, 0, len(placeholders))
|
||||
for _, placeholder := range placeholders {
|
||||
if strings.Contains(text, placeholder) {
|
||||
found = append(found, placeholder)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -88,12 +88,13 @@ var localeRules = map[string]string{
|
||||
"vi": `Locale rules:
|
||||
- Write standard Vietnamese in a neutral technical tone. Use “bạn” consistently when direct address is necessary and avoid unnecessary English outside protected terms.`,
|
||||
"nl": `Locale rules:
|
||||
- Write standard Dutch in a concise, neutral technical tone. Keep forms of address consistent and avoid unnecessary English outside protected terms.`,
|
||||
- Write standard Dutch in a concise, neutral technical tone. Use informal “je/jouw” consistently for direct address; do not switch to formal “u/uw” except inside protected literal quotations. Avoid unnecessary English outside protected terms.`,
|
||||
"fa": `Locale rules:
|
||||
- Write standard Iranian Persian in a neutral technical tone. Use Persian ی and ک rather than Arabic ي and ك, and use standard Persian half-spaces where required.
|
||||
- Keep prose naturally right-to-left without reordering or altering left-to-right code, commands, URLs, placeholders, or product names.`,
|
||||
"ru": `Locale rules:
|
||||
- Write standard Russian in a neutral technical style. Prefer established Russian technical terminology and avoid unnecessary English outside protected terms.`,
|
||||
- Write standard Russian in a neutral technical style. Prefer established Russian technical terminology and avoid unnecessary English outside protected terms.
|
||||
- Translate the generic noun “plugin” as “плагин”; inflect it for Russian case and number, and capitalize it when normal Russian syntax requires. Never force English “Plugin” into ordinary prose. Preserve it only inside protected code or identifiers, or when a higher-precedence literal label rule applies.`,
|
||||
"tr": `Locale rules:
|
||||
- Write standard Turkish in a concise, neutral technical tone. Preserve Turkish dotted and dotless I correctly and avoid unnecessary English outside protected terms.`,
|
||||
"uk": `Locale rules:
|
||||
@@ -143,7 +144,7 @@ Rules:
|
||||
|
||||
- Glossary terms are mandatory under the label precedence rules above. When a source term matches a glossary entry, use its target exactly, including headings, link labels, and short UI-style labels.
|
||||
- If a glossary target is identical to the source text, preserve that term exactly as written.
|
||||
- Keep product names in English: OpenClaw, Raspberry Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
|
||||
- Keep product names in English: OpenClaw, Raspberry Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. When they name the documented product, provider, protocol, integration, runtime, or plugin, also preserve ambiguous names exactly: Render, Matrix, Raft, Chutes, fal (title: Fal), Fireworks, Inferrs, Meta, Runway, Synthetic, Upstash Box, Lobster, Mantis, Tokenjuice. Translate the same words normally when the source clearly uses them as ordinary prose instead of a name.
|
||||
- Never output an empty response; if unsure, return the source text unchanged.
|
||||
|
||||
%s
|
||||
|
||||
@@ -46,6 +46,8 @@ func TestTranslationPromptUsesSharedContractAndLocaleOverlayForEverySupportedLoc
|
||||
"Preserve HTML/MDX tag names, attribute names, nesting, and structural attribute values exactly",
|
||||
"Fenced text, transcript, output, and documentation examples are an exception to the preceding block rule",
|
||||
"Translate user-visible prose inside string-valued component attributes such as “title”, “label”, “description”, and “placeholder”",
|
||||
"When they name the documented product, provider, protocol, integration, runtime, or plugin, also preserve ambiguous names exactly: Render, Matrix, Raft, Chutes, fal (title: Fal), Fireworks, Inferrs, Meta, Runway, Synthetic, Upstash Box, Lobster, Mantis, Tokenjuice",
|
||||
"Translate the same words normally when the source clearly uses them as ordinary prose instead of a name",
|
||||
"Locale rules:",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
@@ -142,7 +144,9 @@ func TestTranslationPromptAddsRepresentativeLocaleRules(t *testing.T) {
|
||||
{locale: "ja-JP", wants: []string{"technical Japanese", "〜でございます", "「 and 」"}},
|
||||
{locale: "de", wants: []string{"Sie/Ihr/Ihnen", "du/dein/dir"}},
|
||||
{locale: "pt-BR", wants: []string{"Brazilian Portuguese, not European Portuguese"}},
|
||||
{locale: "nl", wants: []string{"Use informal “je/jouw” consistently", "do not switch to formal “u/uw” except inside protected literal quotations"}},
|
||||
{locale: "fa", wants: []string{"Persian ی and ک", "right-to-left"}},
|
||||
{locale: "ru", wants: []string{"generic noun “plugin” as “плагин”", "inflect it for Russian case and number", "Never force English “Plugin” into ordinary prose"}},
|
||||
{locale: "uk", wants: []string{"Ukrainian terminology rather than Russian calques"}},
|
||||
{locale: "th", wants: []string{"Do not insert spaces between every Thai word"}},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -24,14 +26,25 @@ func postprocessLocalizedDocs(docsRoot, targetLang string, localizedFiles []stri
|
||||
frontMatter, body := splitFrontMatter(string(content))
|
||||
rewrittenBody := routes.localizeBodyLinks(body)
|
||||
updatedFrontMatter := setPostprocessVersion(frontMatter, localizedLinkPostprocessVersion)
|
||||
if rewrittenBody == body && updatedFrontMatter == frontMatter {
|
||||
continue
|
||||
}
|
||||
|
||||
output := rewrittenBody
|
||||
if updatedFrontMatter != "" {
|
||||
output = "---\n" + updatedFrontMatter + "\n---\n\n" + rewrittenBody
|
||||
}
|
||||
sourcePath, err := localizedSourcePath(docsRoot, targetLang, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sameI18NProtocolMarkers(string(source), output) {
|
||||
return fmt.Errorf("protocol token leaked after localized link postprocess: %s", path)
|
||||
}
|
||||
|
||||
if rewrittenBody == body && updatedFrontMatter == frontMatter {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(output), 0o644); err != nil {
|
||||
return err
|
||||
@@ -41,6 +54,25 @@ func postprocessLocalizedDocs(docsRoot, targetLang string, localizedFiles []stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func localizedSourcePath(docsRoot, targetLang, localizedPath string) (string, error) {
|
||||
localizedRoot, err := filepath.Abs(filepath.Join(docsRoot, targetLang))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath, err := filepath.Abs(localizedPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
relPath, err := filepath.Rel(localizedRoot, absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if relPath == "." || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("localized file %s not under locale root %s", absPath, localizedRoot)
|
||||
}
|
||||
return filepath.Join(docsRoot, relPath), nil
|
||||
}
|
||||
|
||||
func setPostprocessVersion(frontMatter, version string) string {
|
||||
if strings.TrimSpace(frontMatter) == "" {
|
||||
return frontMatter
|
||||
|
||||
@@ -50,6 +50,32 @@ func TestPostprocessLocalizedDocsFixesStaleLinksAfterLaterPagesExist(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostprocessLocalizedDocsRejectsProtocolMarkerLeak(t *testing.T) {
|
||||
docsRoot := t.TempDir()
|
||||
writeFile(t, filepath.Join(docsRoot, "docs.json"), `{"redirects":[]}`)
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "index.md"), "# Gateway\n")
|
||||
pagePath := filepath.Join(docsRoot, "zh-CN", "gateway", "index.md")
|
||||
writeFile(t, pagePath, stringsJoin(
|
||||
"---",
|
||||
"x-i18n:",
|
||||
" source_hash: test",
|
||||
" postprocess_version: pending",
|
||||
"---",
|
||||
"",
|
||||
"# 网关",
|
||||
"",
|
||||
"__OC_I18N_900014__",
|
||||
))
|
||||
|
||||
err := postprocessLocalizedDocs(docsRoot, "zh-CN", []string{pagePath})
|
||||
if err == nil {
|
||||
t.Fatal("expected postprocess protocol marker leak rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "protocol token leaked after localized link postprocess") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostprocessLocalizedDocsRewritesPublishedPageLinksForEachLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -120,6 +146,7 @@ func TestPostprocessLocalizedDocsDoesNotTreatThreeLetterSourceDirsAsLocales(t *t
|
||||
writeFile(t, filepath.Join(docsRoot, "cli", "index.md"), "# CLI\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "cli", "AGENTS.md"), "# CLI docs guide\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "web", "index.md"), "# Web\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "index.md"), "# Gateway\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "zh-CN", "AGENTS.md"), "# zh-CN\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "zh-CN", ".i18n", "README.md"), "# zh-CN i18n\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "zh-CN", "cli", "index.md"), "# CLI 本地化\n")
|
||||
@@ -158,6 +185,7 @@ func TestPostprocessLocalizedDocsOnlyTouchesScopedFiles(t *testing.T) {
|
||||
|
||||
docsRoot := t.TempDir()
|
||||
writeFile(t, filepath.Join(docsRoot, "docs.json"), `{"redirects":[]}`)
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "index.md"), "# Gateway\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "troubleshooting.md"), "# Troubleshooting\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "zh-CN", "gateway", "troubleshooting.md"), "# 故障排除\n")
|
||||
|
||||
@@ -200,6 +228,8 @@ func TestPostprocessLocalizedDocsContinuesAfterUnchangedFile(t *testing.T) {
|
||||
|
||||
docsRoot := t.TempDir()
|
||||
writeFile(t, filepath.Join(docsRoot, "docs.json"), `{"redirects":[]}`)
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "already-localized.md"), "# Already localized\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "index.md"), "# Gateway\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "troubleshooting.md"), "# Troubleshooting\n")
|
||||
writeFile(t, filepath.Join(docsRoot, "zh-CN", "gateway", "troubleshooting.md"), "# 故障排除\n")
|
||||
|
||||
@@ -237,6 +267,7 @@ func TestPostprocessLocalizedDocsFinalizesPostprocessVersionWithoutBodyRewrite(t
|
||||
docsRoot := t.TempDir()
|
||||
path := filepath.Join(docsRoot, "zh-CN", "gateway", "index.md")
|
||||
writeFile(t, filepath.Join(docsRoot, "docs.json"), `{"redirects":[]}`)
|
||||
writeFile(t, filepath.Join(docsRoot, "gateway", "index.md"), "# Gateway\n")
|
||||
writeFile(t, path, stringsJoin(
|
||||
"---",
|
||||
"title: 网关",
|
||||
|
||||
@@ -189,6 +189,7 @@ func isRetryableTranslateError(err error) bool {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(message, "placeholder missing") ||
|
||||
strings.Contains(message, "placeholder duplicated") ||
|
||||
strings.Contains(message, "rate limit") ||
|
||||
strings.Contains(message, "429") ||
|
||||
strings.Contains(message, "500") ||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const (
|
||||
workflowVersion = 16
|
||||
promptVersion = 11
|
||||
promptVersion = 24
|
||||
docsI18nEngineName = "codex"
|
||||
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
|
||||
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
func TestCacheNamespaceIncludesPromptVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if want := "prompt=11"; !strings.Contains(cacheNamespace(), want) {
|
||||
if want := "prompt=24"; !strings.Contains(cacheNamespace(), want) {
|
||||
t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS e2e-runner
|
||||
|
||||
# python3 covers package/plugin install paths that execute helper scripts.
|
||||
# openssl provisions short-lived fixture certificates for HTTPS-only provider
|
||||
# routes. python3 covers package/plugin install paths that execute helper scripts.
|
||||
# procps provides pgrep for E2E watchdogs that assert no package-manager work is
|
||||
# still running after Gateway readiness.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git procps python3 \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git openssl procps python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -38,6 +38,15 @@ ASSERT_MAX_TRANSCRIPT_SCAN_BYTES="$(
|
||||
AGENT_TURN_TIMEOUT_SECONDS="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS 420
|
||||
)"
|
||||
SESSION_STORE_CONTRACT="${OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT:-}"
|
||||
if [[ -z "$SESSION_STORE_CONTRACT" ]]; then
|
||||
if [[ -f "$CANDIDATE_ROOT/src/config/sessions/session-accessor.sqlite.ts" ]]; then
|
||||
SESSION_STORE_CONTRACT="sqlite"
|
||||
else
|
||||
# Trusted current harnesses also validate frozen targets from before the SQLite cutover.
|
||||
SESSION_STORE_CONTRACT="legacy-json"
|
||||
fi
|
||||
fi
|
||||
run_log=""
|
||||
|
||||
cleanup() {
|
||||
@@ -146,6 +155,7 @@ if ! docker_e2e_run_with_harness \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-codex/gpt-5.4}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT="$SESSION_STORE_CONTRACT" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES=$ASSERT_MAX_ERROR_TAIL_BYTES" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES=$ASSERT_MAX_TRANSCRIPT_FILES" \
|
||||
|
||||
@@ -62,7 +62,6 @@ if [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" = "1" ]]; then
|
||||
OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB \
|
||||
OPENCLAW_CLAWHUB_URL \
|
||||
CLAWHUB_URL \
|
||||
OPENCLAW_CLAWHUB_TOKEN \
|
||||
CLAWHUB_TOKEN \
|
||||
CLAWHUB_AUTH_TOKEN; do
|
||||
env_value="${!env_name:-}"
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
const command = process.argv[2];
|
||||
const allowBetaCompatDiagnostics =
|
||||
process.env.OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS === "1";
|
||||
const sessionStoreContract =
|
||||
process.env.OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT || "sqlite";
|
||||
const MAX_TEXT_FILE_BYTES = readPositiveIntEnv(
|
||||
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES",
|
||||
1024 * 1024,
|
||||
@@ -120,6 +122,75 @@ function readCodexBinding(sessionId, sessionKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacySessionEntry(sessionId) {
|
||||
const storePath = path.join(stateDir(), "agents", "main", "sessions", "sessions.json");
|
||||
const store = readJson(storePath);
|
||||
const sessionMatch = Object.entries(store).find(
|
||||
([, candidate]) => candidate?.sessionId === sessionId,
|
||||
);
|
||||
if (!sessionMatch) {
|
||||
throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`);
|
||||
}
|
||||
const [sessionKey, entry] = sessionMatch;
|
||||
if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) {
|
||||
throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`);
|
||||
}
|
||||
const transcriptEventCount = readTextFileBounded(
|
||||
entry.sessionFile,
|
||||
"OpenClaw legacy session transcript",
|
||||
)
|
||||
.split("\n")
|
||||
.filter((line) => line.trim()).length;
|
||||
return { entry, sessionKey, transcriptEventCount };
|
||||
}
|
||||
|
||||
function readSessionEntry(sessionId) {
|
||||
if (sessionStoreContract === "legacy-json") {
|
||||
return readLegacySessionEntry(sessionId);
|
||||
}
|
||||
if (sessionStoreContract !== "sqlite") {
|
||||
throw new Error(
|
||||
`OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT must be sqlite or legacy-json; got ${sessionStoreContract}`,
|
||||
);
|
||||
}
|
||||
const dbPath = path.join(stateDir(), "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`missing agent session database: ${dbPath}`);
|
||||
}
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT se.session_key, se.entry_json, s.agent_harness_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM transcript_events AS te
|
||||
WHERE te.session_id = s.session_id) AS transcript_event_count
|
||||
FROM sessions AS s
|
||||
INNER JOIN session_entries AS se ON se.session_id = s.session_id
|
||||
WHERE s.session_id = ?
|
||||
ORDER BY se.updated_at DESC, se.session_key
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId);
|
||||
if (!row || typeof row.session_key !== "string" || typeof row.entry_json !== "string") {
|
||||
throw new Error(`missing session store entry for ${sessionId}`);
|
||||
}
|
||||
const entry = JSON.parse(row.entry_json);
|
||||
return {
|
||||
entry: {
|
||||
...entry,
|
||||
agentHarnessId:
|
||||
typeof row.agent_harness_id === "string" ? row.agent_harness_id : entry.agentHarnessId,
|
||||
sessionId,
|
||||
},
|
||||
sessionKey: row.session_key,
|
||||
transcriptEventCount: Number(row.transcript_event_count),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function configure() {
|
||||
const modelRef = process.argv[3] || "codex/gpt-5.4";
|
||||
const state = stateDir();
|
||||
@@ -442,24 +513,15 @@ function assertAgentTurn() {
|
||||
);
|
||||
}
|
||||
|
||||
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const store = readJson(storePath);
|
||||
const sessionMatch = Object.entries(store).find(
|
||||
([, candidate]) => candidate?.sessionId === sessionId,
|
||||
);
|
||||
if (!sessionMatch) {
|
||||
throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`);
|
||||
}
|
||||
const [sessionKey, entry] = sessionMatch;
|
||||
const { entry, sessionKey, transcriptEventCount } = readSessionEntry(sessionId);
|
||||
if (entry.agentHarnessId !== "codex") {
|
||||
throw new Error(`expected codex harness in session entry, got ${entry.agentHarnessId}`);
|
||||
}
|
||||
if (entry.modelOverride && entry.modelOverride !== modelRef) {
|
||||
throw new Error(`unexpected session model override: ${entry.modelOverride}`);
|
||||
}
|
||||
if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) {
|
||||
throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`);
|
||||
if (!Number.isSafeInteger(transcriptEventCount) || transcriptEventCount < 1) {
|
||||
throw new Error(`missing OpenClaw transcript events for ${sessionId}`);
|
||||
}
|
||||
|
||||
const binding = readCodexBinding(sessionId, sessionKey);
|
||||
|
||||
@@ -64,7 +64,7 @@ function writeOpenAiWebSearchMinimalConfig() {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://api.openai.com/v1",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
request: { allowPrivateNetwork: true },
|
||||
models: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Assertions for live plugin tool E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
|
||||
@@ -33,6 +34,7 @@ const AGENT_OUTPUT_MAX_BYTES = readPositiveIntEnv(
|
||||
1024 * 1024,
|
||||
);
|
||||
const SESSION_FILE_LIST_LIMIT = 20;
|
||||
const LIVE_PLUGIN_TOOL_SESSION_ID = "live-plugin-tool";
|
||||
const SESSION_SCAN_MAX_ENTRIES = readPositiveIntEnv(
|
||||
"OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES",
|
||||
50_000,
|
||||
@@ -373,6 +375,41 @@ function scanSessionTranscripts(sessionsDir, toolName, expected) {
|
||||
return { checkedFiles, filesChecked, found: false, missingDir: false };
|
||||
}
|
||||
|
||||
function scanSqliteSessionTranscript(databasePath, sessionId, toolName, expected) {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
return { eventsChecked: 0, found: false };
|
||||
}
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const table = database
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transcript_events'")
|
||||
.get();
|
||||
if (!table) {
|
||||
return { eventsChecked: 0, found: false };
|
||||
}
|
||||
const rows = database
|
||||
.prepare("SELECT event_json FROM transcript_events WHERE session_id = ? ORDER BY seq LIMIT ?")
|
||||
.all(sessionId, SESSION_SCAN_MAX_ENTRIES + 1);
|
||||
if (rows.length > SESSION_SCAN_MAX_ENTRIES) {
|
||||
throw new Error(`session transcript scan exceeded ${SESSION_SCAN_MAX_ENTRIES} SQLite events`);
|
||||
}
|
||||
|
||||
const tracker = createToolEvidenceTracker(toolName, expected);
|
||||
for (const row of rows) {
|
||||
if (typeof row.event_json !== "string") {
|
||||
continue;
|
||||
}
|
||||
const message = transcriptMessageFromLine(row.event_json);
|
||||
if (message && tracker.recordMessage(message)) {
|
||||
return { eventsChecked: rows.length, found: true };
|
||||
}
|
||||
}
|
||||
return { eventsChecked: rows.length, found: false };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function realPathMaybe(filePath) {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
@@ -610,13 +647,22 @@ function assertAgentTurn() {
|
||||
`live agent reply did not contain tool slug ${expected}:\nstdout tail=${tailText(stdout, ERROR_DETAIL_TAIL_BYTES)}\nstderr tail=${stderrTail}`,
|
||||
);
|
||||
}
|
||||
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
|
||||
const scan = scanSessionTranscripts(sessionsDir, toolName, expected);
|
||||
if (!scan.found) {
|
||||
const checkedFiles = scan.checkedFiles.length > 0 ? scan.checkedFiles.join(", ") : "<none>";
|
||||
const missingDir = scan.missingDir ? " sessions directory was missing." : "";
|
||||
const agentStateDir = path.join(stateDir(), "agents", "main");
|
||||
const sqliteScan = scanSqliteSessionTranscript(
|
||||
path.join(agentStateDir, "agent", "openclaw-agent.sqlite"),
|
||||
LIVE_PLUGIN_TOOL_SESSION_ID,
|
||||
toolName,
|
||||
expected,
|
||||
);
|
||||
const fileScan = sqliteScan.found
|
||||
? { checkedFiles: [], filesChecked: 0, found: false, missingDir: false }
|
||||
: scanSessionTranscripts(path.join(agentStateDir, "sessions"), toolName, expected);
|
||||
if (!sqliteScan.found && !fileScan.found) {
|
||||
const checkedFiles =
|
||||
fileScan.checkedFiles.length > 0 ? fileScan.checkedFiles.join(", ") : "<none>";
|
||||
const missingDir = fileScan.missingDir ? " sessions directory was missing." : "";
|
||||
throw new Error(
|
||||
`session transcript did not show ${toolName} returning ${expected}; missing causal tool-result evidence after checking ${scan.filesChecked} jsonl file(s): ${checkedFiles}.${missingDir}`,
|
||||
`session transcript did not show ${toolName} returning ${expected}; missing causal tool-result evidence after checking ${sqliteScan.eventsChecked} SQLite event(s) and ${fileScan.filesChecked} jsonl file(s): ${checkedFiles}.${missingDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Mock server for minimal OpenAI web-search E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { readTcpPortEnv } from "../env-limits.mjs";
|
||||
import {
|
||||
boundedRequestLogBody,
|
||||
@@ -14,6 +16,12 @@ const port = readTcpPortEnv("MOCK_PORT");
|
||||
const requestLog = process.env.MOCK_REQUEST_LOG;
|
||||
const successMarker = process.env.SUCCESS_MARKER;
|
||||
const rawSchemaError = process.env.RAW_SCHEMA_ERROR;
|
||||
const tlsCertPath = process.env.MOCK_TLS_CERT?.trim();
|
||||
const tlsKeyPath = process.env.MOCK_TLS_KEY?.trim();
|
||||
|
||||
if (Boolean(tlsCertPath) !== Boolean(tlsKeyPath)) {
|
||||
throw new Error("MOCK_TLS_CERT and MOCK_TLS_KEY must be set together");
|
||||
}
|
||||
|
||||
function writeOpenAiReject(res) {
|
||||
writeJson(res, 400, {
|
||||
@@ -88,9 +96,9 @@ function responseEvents(text) {
|
||||
];
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const handleRequest = (req, res) => {
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const url = new URL(req.url ?? "/", "https://api.openai.com");
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
writeJson(res, 200, { ok: true });
|
||||
return;
|
||||
@@ -159,8 +167,19 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
res.destroy(error instanceof Error ? error : new Error(message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const server =
|
||||
tlsCertPath && tlsKeyPath
|
||||
? https.createServer(
|
||||
{
|
||||
cert: fs.readFileSync(tlsCertPath),
|
||||
key: fs.readFileSync(tlsKeyPath),
|
||||
},
|
||||
handleRequest,
|
||||
)
|
||||
: http.createServer(handleRequest);
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`mock-openai listening on ${port}`);
|
||||
console.log(`mock-openai listening on ${port} (${tlsCertPath ? "HTTPS" : "HTTP"})`);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ GATEWAY_LOG="$scenario_tmp/gateway.log"
|
||||
MOCK_LOG="$scenario_tmp/mock.log"
|
||||
CLIENT_SUCCESS_LOG="$scenario_tmp/client-success.log"
|
||||
CLIENT_REJECT_LOG="$scenario_tmp/client-reject.log"
|
||||
TLS_DIR="$scenario_tmp/tls"
|
||||
TLS_CA_CERT="$TLS_DIR/ca.crt"
|
||||
TLS_CA_KEY="$TLS_DIR/ca.key"
|
||||
TLS_SERVER_CERT="$TLS_DIR/server.crt"
|
||||
TLS_SERVER_CSR="$TLS_DIR/server.csr"
|
||||
TLS_SERVER_EXT="$TLS_DIR/server.ext"
|
||||
TLS_SERVER_KEY="$TLS_DIR/server.key"
|
||||
mock_pid=""
|
||||
gateway_pid=""
|
||||
|
||||
@@ -51,20 +58,49 @@ dump_debug_logs() {
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
entry="$(openclaw_e2e_resolve_entrypoint)"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR" "$TLS_DIR"
|
||||
|
||||
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-patch-behavior
|
||||
|
||||
node scripts/e2e/lib/fixture.mjs openai-web-search-minimal-config
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \
|
||||
-subj "/CN=OpenClaw E2E CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
-keyout "$TLS_CA_KEY" \
|
||||
-out "$TLS_CA_CERT" >/dev/null 2>&1
|
||||
openssl req -newkey rsa:2048 -nodes -sha256 \
|
||||
-subj "/CN=api.openai.com" \
|
||||
-keyout "$TLS_SERVER_KEY" \
|
||||
-out "$TLS_SERVER_CSR" >/dev/null 2>&1
|
||||
cat >"$TLS_SERVER_EXT" <<'EOF'
|
||||
basicConstraints=critical,CA:FALSE
|
||||
subjectAltName=DNS:api.openai.com
|
||||
keyUsage=critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage=serverAuth
|
||||
EOF
|
||||
openssl x509 -req \
|
||||
-in "$TLS_SERVER_CSR" \
|
||||
-CA "$TLS_CA_CERT" \
|
||||
-CAkey "$TLS_CA_KEY" \
|
||||
-CAcreateserial \
|
||||
-days 1 \
|
||||
-sha256 \
|
||||
-extfile "$TLS_SERVER_EXT" \
|
||||
-out "$TLS_SERVER_CERT" >/dev/null 2>&1
|
||||
export NODE_EXTRA_CA_CERTS="$TLS_CA_CERT"
|
||||
|
||||
MOCK_PORT="$MOCK_PORT" \
|
||||
MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" \
|
||||
MOCK_TLS_CERT="$TLS_SERVER_CERT" \
|
||||
MOCK_TLS_KEY="$TLS_SERVER_KEY" \
|
||||
SUCCESS_MARKER="$SUCCESS_MARKER" \
|
||||
RAW_SCHEMA_ERROR="$RAW_SCHEMA_ERROR" \
|
||||
node scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs >"$MOCK_LOG" 2>&1 &
|
||||
mock_pid="$!"
|
||||
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT" 80 400 "https://api.openai.com:$MOCK_PORT"
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$PORT"
|
||||
|
||||
@@ -28,77 +28,3 @@ parallels_macos_resolve_desktop_home() {
|
||||
printf '/Users/%s\n' "$user"
|
||||
fi
|
||||
}
|
||||
|
||||
parallels_macos_current_user_available() {
|
||||
local vm_name="$1"
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1
|
||||
}
|
||||
|
||||
parallels_macos_desktop_user_exec_with_secret_file() {
|
||||
local vm_name="$1"
|
||||
local user_flag="$2"
|
||||
local user_name="$3"
|
||||
local home="$4"
|
||||
local path_value="$5"
|
||||
local api_key_env="$6"
|
||||
local api_key_value="$7"
|
||||
shift 7
|
||||
|
||||
local secret_path
|
||||
secret_path="/tmp/openclaw-secret-${api_key_env:-env}-$RANDOM-$RANDOM"
|
||||
|
||||
if [[ -n "$api_key_env" && -n "$api_key_value" ]]; then
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$secret_path"
|
||||
else
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$secret_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
local wrapper
|
||||
local wrapper_path
|
||||
wrapper_path="/tmp/openclaw-secret-env-wrapper-$RANDOM-$RANDOM.sh"
|
||||
wrapper='#!/bin/bash
|
||||
set -e
|
||||
cleanup() {
|
||||
rm -f "${OPENCLAW_WRAPPER_FILE:-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
if [ -n "${OPENCLAW_SECRET_ENV_NAME:-}" ] && [ -n "${OPENCLAW_SECRET_FILE:-}" ] && [ -f "$OPENCLAW_SECRET_FILE" ]; then
|
||||
secret_value="$(cat "$OPENCLAW_SECRET_FILE")"
|
||||
rm -f "$OPENCLAW_SECRET_FILE"
|
||||
export "${OPENCLAW_SECRET_ENV_NAME}=${secret_value}"
|
||||
fi
|
||||
"$@"
|
||||
'
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$wrapper_path"
|
||||
else
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$wrapper_path"
|
||||
fi
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/env \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
prlctl exec "$vm_name" /usr/bin/sudo -H -u "$user_name" /usr/bin/env \
|
||||
"HOME=$home" \
|
||||
"USER=$user_name" \
|
||||
"LOGNAME=$user_name" \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
}
|
||||
|
||||
@@ -844,11 +844,7 @@ async function assertClawHubPreflight() {
|
||||
process.env.CLAWHUB_URL ||
|
||||
"https://clawhub.ai"
|
||||
).replace(/\/+$/, "");
|
||||
const token =
|
||||
process.env.OPENCLAW_CLAWHUB_TOKEN ||
|
||||
process.env.CLAWHUB_TOKEN ||
|
||||
process.env.CLAWHUB_AUTH_TOKEN ||
|
||||
"";
|
||||
const token = process.env.CLAWHUB_TOKEN || process.env.CLAWHUB_AUTH_TOKEN || "";
|
||||
const preflightUrl = `${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`;
|
||||
const response = await withTimeout(
|
||||
`ClawHub package preflight for ${packageName}`,
|
||||
|
||||
@@ -176,7 +176,7 @@ export async function countSessionLogMentions(params: {
|
||||
continue;
|
||||
}
|
||||
for (const [key, needle] of Object.entries(params.needles)) {
|
||||
counts[key] += countOccurrences(scanText, needle);
|
||||
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ async function countSqliteTranscriptMentions(params: {
|
||||
continue;
|
||||
}
|
||||
for (const [key, needle] of Object.entries(params.needles)) {
|
||||
counts[key] += countOccurrences(scanText, needle);
|
||||
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
|
||||
@@ -478,20 +478,11 @@ function assertSessionMetadataMigrated(stateDir) {
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const agentSessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const targetStorePath = path.join(agentSessionsDir, "sessions.json");
|
||||
const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
assert(
|
||||
!fs.existsSync(legacyStorePath),
|
||||
`legacy sessions.json survived migration: ${legacyStorePath}`,
|
||||
);
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
assert(
|
||||
fs.existsSync(path.join(agentSessionsDir, `${sessionId}.jsonl`)),
|
||||
`legacy session transcript was not moved for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const store = readMigratedSessionStore(stateDir, targetStorePath);
|
||||
const main = store["agent:main:main"];
|
||||
@@ -500,18 +491,44 @@ function assertSessionMetadataMigrated(stateDir) {
|
||||
assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing");
|
||||
assert(direct?.sessionId === LEGACY_SESSION_DIRECT_ID, "direct legacy session row missing");
|
||||
assert(group?.sessionId === LEGACY_SESSION_GROUP_ID, "channel legacy session row missing");
|
||||
assert(
|
||||
main?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
"main legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
direct?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
"direct legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
group?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
"channel legacy session row still points at the old sessions directory",
|
||||
);
|
||||
const migratedSessionIds = [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
];
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const count = db.prepare(
|
||||
"SELECT COUNT(*) AS count FROM transcript_events WHERE session_id = ?",
|
||||
);
|
||||
for (const sessionId of migratedSessionIds) {
|
||||
const row = count.get(sessionId);
|
||||
assert(
|
||||
Number(row?.count ?? 0) > 0,
|
||||
`legacy session transcript was not imported for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} else {
|
||||
for (const [sessionId, entry] of [
|
||||
[LEGACY_SESSION_MAIN_ID, main],
|
||||
[LEGACY_SESSION_DIRECT_ID, direct],
|
||||
[LEGACY_SESSION_GROUP_ID, group],
|
||||
]) {
|
||||
const expectedPath = path.join(agentSessionsDir, `${sessionId}.jsonl`);
|
||||
assert(
|
||||
fs.existsSync(expectedPath),
|
||||
`legacy session transcript was not moved for ${sessionId}`,
|
||||
);
|
||||
assert(
|
||||
entry?.sessionFile === expectedPath,
|
||||
`legacy session row still points at the old sessions directory for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert(
|
||||
main.skillsSnapshot?.prompt === "legacy prompt survives as metadata",
|
||||
"legacy session metadata prompt was not preserved",
|
||||
@@ -533,15 +550,28 @@ function readMigratedSessionStore(stateDir, targetStorePath) {
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const rows = db
|
||||
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
|
||||
.all("session_entries");
|
||||
const hasSessionEntries = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_entries'")
|
||||
.get();
|
||||
const rows = hasSessionEntries
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT se.session_key AS key, sr.session_id, se.entry_json AS value_json
|
||||
FROM session_entries AS se
|
||||
INNER JOIN session_routes AS sr ON sr.session_key = se.session_key`,
|
||||
)
|
||||
.all()
|
||||
: db
|
||||
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
|
||||
.all("session_entries");
|
||||
const store = {};
|
||||
for (const row of rows) {
|
||||
if (typeof row?.key !== "string" || typeof row?.value_json !== "string") {
|
||||
continue;
|
||||
}
|
||||
store[row.key] = JSON.parse(row.value_json);
|
||||
const entry = JSON.parse(row.value_json);
|
||||
store[row.key] =
|
||||
typeof row.session_id === "string" ? { ...entry, sessionId: row.session_id } : entry;
|
||||
}
|
||||
return store;
|
||||
} finally {
|
||||
|
||||
@@ -150,7 +150,7 @@ openclaw_e2e_enable_openclaw_cli_timeout
|
||||
fixture_dir="$(mktemp -d /tmp/openclaw-live-plugin-tool.XXXXXX)"
|
||||
plugin_dir="$fixture_dir/package"
|
||||
mkdir -p "$plugin_dir"
|
||||
node scripts/e2e/lib/live-plugin-tool/assertions.mjs write-fixture "$plugin_dir"
|
||||
node --disable-warning=ExperimentalWarning scripts/e2e/lib/live-plugin-tool/assertions.mjs write-fixture "$plugin_dir"
|
||||
(cd "$plugin_dir" && npm pack --pack-destination "$fixture_dir" --silent) \
|
||||
>/tmp/openclaw-live-plugin-tool-pack.log 2>&1
|
||||
plugin_tgzs=()
|
||||
@@ -166,11 +166,11 @@ plugin_tgz="${plugin_tgzs[0]}"
|
||||
|
||||
echo "Installing fixture plugin from npm-pack: $plugin_tgz"
|
||||
openclaw plugins install "npm-pack:$plugin_tgz" --force >/tmp/openclaw-plugin-install.log 2>&1
|
||||
node scripts/e2e/lib/live-plugin-tool/assertions.mjs configure
|
||||
node --disable-warning=ExperimentalWarning scripts/e2e/lib/live-plugin-tool/assertions.mjs configure
|
||||
openclaw plugins enable "$PLUGIN_ID" >/tmp/openclaw-plugin-enable.log 2>&1
|
||||
openclaw plugins list --json >/tmp/openclaw-plugins-list.json
|
||||
openclaw plugins inspect "$PLUGIN_ID" --runtime --json >/tmp/openclaw-plugin-inspect.json
|
||||
node scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-installed
|
||||
node --disable-warning=ExperimentalWarning scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-installed
|
||||
|
||||
echo "Running live OpenAI agent turn that must call $TOOL_NAME..."
|
||||
openclaw agent --local \
|
||||
@@ -182,7 +182,7 @@ openclaw agent --local \
|
||||
--timeout "${OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS:-300}" \
|
||||
--json >/tmp/openclaw-agent.json 2>/tmp/openclaw-agent.err
|
||||
|
||||
node scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-agent-turn
|
||||
node --disable-warning=ExperimentalWarning scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-agent-turn
|
||||
|
||||
echo "Live plugin tool Docker E2E passed"
|
||||
EOF
|
||||
|
||||
@@ -196,7 +196,7 @@ function writeImageGeneration(res) {
|
||||
}
|
||||
|
||||
function resolveResponseText(bodyText) {
|
||||
const matches = Array.from(bodyText.matchAll(/\bOPENCLAW_E2E_OK(?:_\d+)?\b/gu));
|
||||
const matches = Array.from(bodyText.matchAll(/\bOPENCLAW_E2E_[A-Z0-9]+(?:_[A-Z0-9]+)*\b/gu));
|
||||
return matches.at(-1)?.[0] ?? successMarker;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,13 @@ async function availableLoopbackPort() {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
if (!port) {
|
||||
throw new Error("failed to allocate pond gateway port");
|
||||
@@ -304,6 +310,8 @@ async function prepareRoleState(baseDir, role, token, nodeLabel, options = {}) {
|
||||
command: process.execPath,
|
||||
args: [mcpServerPath],
|
||||
transport: "stdio",
|
||||
// Keep the baseline surface singular; the hot-plug scenario owns the slow tool.
|
||||
toolFilter: { include: [MCP_TOOL_NAME] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -184,4 +184,3 @@ export const testing = {
|
||||
resolveTrustedOpenClawCommand,
|
||||
shouldFailPackageTelegramRun,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -7,8 +7,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-web-search-minimal-e2e" OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_E2E_IMAGE)"
|
||||
SKIP_BUILD="${OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_E2E_SKIP_BUILD:-0}"
|
||||
PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)"
|
||||
# Keep the mock on port 80 so the aliased api.openai.com base URL remains canonical.
|
||||
MOCK_PORT="80"
|
||||
# Keep the TLS mock on the canonical official endpoint used by native web_search.
|
||||
MOCK_PORT="443"
|
||||
TOKEN="openai-web-search-minimal-e2e-$$"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" openai-web-search-minimal "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
|
||||
|
||||
@@ -69,12 +69,15 @@ function compareOpenClawPackageVersions(left: string, right: string): number {
|
||||
if (!match) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
const [, year, month, patch] = match;
|
||||
if (!year || !month || !patch) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
return [Number(year), Number(month), Number(patch)];
|
||||
};
|
||||
const leftParts = parse(left);
|
||||
const rightParts = parse(right);
|
||||
for (let index = 0; index < leftParts.length; index++) {
|
||||
const delta = leftParts[index] - rightParts[index];
|
||||
const [leftYear, leftMonth, leftPatch] = parse(left);
|
||||
const [rightYear, rightMonth, rightPatch] = parse(right);
|
||||
for (const delta of [leftYear - rightYear, leftMonth - rightMonth, leftPatch - rightPatch]) {
|
||||
if (delta !== 0) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clampTimerTimeoutMs,
|
||||
finiteSecondsToTimerSafeMilliseconds,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { formatDurationCompact } from "../../../src/infra/format-time/format-duration.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
@@ -506,10 +507,7 @@ function platformRecord<T>(value: T): Record<Platform, T> {
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${remainder}s` : `${remainder}s`;
|
||||
return formatDurationCompact(durationMs, { spaced: true }) ?? "0ms";
|
||||
}
|
||||
|
||||
function readHarnessCheckoutVersion(): string {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function resolveUbuntuVmName(requested: string, explicit = false): string
|
||||
names
|
||||
.map((name) => ({ name, parts: parseUbuntuVersionParts(name) }))
|
||||
.filter((item): item is { name: string; parts: number[] } => Boolean(item.parts))
|
||||
.filter((item) => item.parts[0] >= 24)
|
||||
.filter((item) => item.parts[0] !== undefined && item.parts[0] >= 24)
|
||||
.toSorted((a, b) => compareVersions(b.parts, a.parts))[0]?.name ??
|
||||
names.find(isSafeUbuntuFallbackName);
|
||||
if (!fallback) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Snapshots script supports OpenClaw repository automation.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { die, run } from "./host-command.ts";
|
||||
import type { Mode } from "./types.ts";
|
||||
import type { SnapshotInfo } from "./types.ts";
|
||||
@@ -96,20 +97,29 @@ function stringSimilarity(a: string, b: string): number {
|
||||
const cols = b.length + 1;
|
||||
const matrix = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
|
||||
for (let i = 0; i < rows; i++) {
|
||||
matrix[i][0] = i;
|
||||
expectDefined(matrix[i], `snapshot similarity matrix row ${i}`)[0] = i;
|
||||
}
|
||||
const firstRow = expectDefined(matrix[0], "snapshot similarity first matrix row");
|
||||
for (let j = 0; j < cols; j++) {
|
||||
matrix[0][j] = j;
|
||||
firstRow[j] = j;
|
||||
}
|
||||
for (let i = 1; i < rows; i++) {
|
||||
const row = expectDefined(matrix[i], `snapshot similarity matrix row ${i}`);
|
||||
const previousRow = expectDefined(matrix[i - 1], `snapshot similarity matrix row ${i - 1}`);
|
||||
for (let j = 1; j < cols; j++) {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
row[j] = Math.min(
|
||||
expectDefined(previousRow[j], `snapshot similarity deletion cell ${i},${j}`) + 1,
|
||||
expectDefined(row[j - 1], `snapshot similarity insertion cell ${i},${j - 1}`) + 1,
|
||||
expectDefined(
|
||||
previousRow[j - 1],
|
||||
`snapshot similarity substitution cell ${i - 1},${j - 1}`,
|
||||
) + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
const distance = matrix[a.length][b.length];
|
||||
const distance = expectDefined(
|
||||
expectDefined(matrix[a.length], "snapshot similarity final matrix row")[b.length],
|
||||
"snapshot similarity final distance",
|
||||
);
|
||||
return 1 - distance / Math.max(a.length, b.length, 1);
|
||||
}
|
||||
|
||||
@@ -215,6 +215,9 @@ export function parseArgs(argv: string[]): WindowsOptions {
|
||||
};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === undefined) {
|
||||
die(`missing argument at index ${i}`);
|
||||
}
|
||||
if (arg === "--") {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ if [[ "${OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB:-0}" = "1" ]]; then
|
||||
for env_name in \
|
||||
OPENCLAW_CLAWHUB_URL \
|
||||
CLAWHUB_URL \
|
||||
OPENCLAW_CLAWHUB_TOKEN \
|
||||
CLAWHUB_TOKEN \
|
||||
CLAWHUB_AUTH_TOKEN \
|
||||
OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY; do
|
||||
|
||||
@@ -5,7 +5,9 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import {
|
||||
buildRuntimeContextCustomMessage,
|
||||
resolveRuntimeContextPromptParts,
|
||||
@@ -32,14 +34,6 @@ function setEnvValue(key: string, value: string): void {
|
||||
Reflect.set(process.env, key, value);
|
||||
}
|
||||
|
||||
async function readJsonl(filePath: string): Promise<TranscriptEntry[]> {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as TranscriptEntry);
|
||||
}
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -91,7 +85,7 @@ async function verifyRuntimeContextTranscriptShape(root: string) {
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
|
||||
const entries = await readJsonl(sessionFile);
|
||||
const entries = sessionManager.getEntries() as TranscriptEntry[];
|
||||
const customEntry = entries.find((entry) => entry.type === "custom_message");
|
||||
assert(!customEntry, "runtime custom message should not be persisted without its user turn");
|
||||
assert(
|
||||
@@ -218,7 +212,25 @@ async function verifyDoctorRepair(root: string) {
|
||||
result.status === 0,
|
||||
`doctor --fix failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
const entries = await readJsonl(sessionFile);
|
||||
const databasePath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
let migratedSessionId: string | undefined;
|
||||
try {
|
||||
const row = database
|
||||
.prepare("SELECT session_id FROM session_routes WHERE session_key = ?")
|
||||
.get("agent:main:qa:docker-runtime-context");
|
||||
if (typeof row?.session_id === "string") {
|
||||
migratedSessionId = row.session_id;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
assert(migratedSessionId, "doctor did not migrate session");
|
||||
const entries = (await readSessionTranscriptEvents({
|
||||
agentId: "main",
|
||||
sessionId: migratedSessionId,
|
||||
sessionKey: "agent:main:qa:docker-runtime-context",
|
||||
})) as TranscriptEntry[];
|
||||
const ids = entries.map((entryValue) => (entryValue as { id?: string }).id).filter(Boolean);
|
||||
assert(
|
||||
JSON.stringify(ids) ===
|
||||
|
||||
@@ -240,7 +240,7 @@ function trimToValue(value: string | undefined) {
|
||||
const positiveIntegerPattern = /^[1-9]\d*$/u;
|
||||
const SHORT_OPTION_TOKENS = new Set(["-h"]);
|
||||
|
||||
function isMissingOptionValue(value: string | undefined) {
|
||||
function isMissingOptionValue(value: string) {
|
||||
return !value || SHORT_OPTION_TOKENS.has(value) || value.startsWith("--");
|
||||
}
|
||||
|
||||
@@ -330,9 +330,12 @@ export function parseArgs(argvInput: string[]): Options {
|
||||
const seenSingleValueOptions = new Set<string>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === undefined) {
|
||||
usage();
|
||||
}
|
||||
const readValue = (options: { repeatable?: boolean } = {}) => {
|
||||
const value = argv[index + 1];
|
||||
if (isMissingOptionValue(value)) {
|
||||
if (value === undefined || isMissingOptionValue(value)) {
|
||||
usage();
|
||||
}
|
||||
if (!options.repeatable) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as v8 from "node:v8";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
|
||||
type Mode = "production" | "closure-extracted" | "closure-inline" | "synthetic-leak";
|
||||
type Abortable = <T>(signal: AbortSignal, promise: Promise<T>) => Promise<T>;
|
||||
@@ -65,7 +66,7 @@ function parseArgs(argv: string[]): Options {
|
||||
};
|
||||
const seenValueFlags = new Set<string>();
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
const arg = expectDefined(argv[i], `embedded abort benchmark argument at index ${i}`);
|
||||
const next = argv[i + 1];
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
if (seenValueFlags.has(arg)) {
|
||||
|
||||
@@ -8,14 +8,27 @@ export function parseArgs(argv: unknown): {
|
||||
inputs: {
|
||||
provider: string;
|
||||
mode: string;
|
||||
release_profile: string;
|
||||
release_profile?: string;
|
||||
rerun_group: string;
|
||||
reuse_evidence: string;
|
||||
};
|
||||
};
|
||||
export function releaseProfileForTarget(
|
||||
targetSha: string,
|
||||
readPackageJson?: (sha: string) => string,
|
||||
): "beta" | "stable";
|
||||
export function releaseEvidenceVerificationArgs(parentRunId: unknown): string[];
|
||||
export function releaseEvidenceVerifierPath(worktreeRoot: unknown): string;
|
||||
export function resolveRemoteTargetRefSha(
|
||||
targetRef: string,
|
||||
executeGit?: (args: string[]) => string,
|
||||
): string;
|
||||
export type WorkflowRunCheckSuite = {
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
workflowRun?: { url?: string } | null;
|
||||
};
|
||||
export function selectWorkflowRunCheckSuite(
|
||||
nodes: WorkflowRunCheckSuite[],
|
||||
parentRunId: unknown,
|
||||
): WorkflowRunCheckSuite | undefined;
|
||||
|
||||
@@ -13,10 +13,31 @@ const RELEASE_TAG_PATTERN = /^v[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)\.[0-9]
|
||||
const DEFAULT_INPUTS = {
|
||||
provider: "openai",
|
||||
mode: "both",
|
||||
release_profile: "full",
|
||||
rerun_group: "all",
|
||||
reuse_evidence: "true",
|
||||
};
|
||||
const WORKFLOW_RUN_CHECK_SUITE_QUERY = `
|
||||
query($owner: String!, $repo: String!, $oid: GitObjectID!, $after: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
object(oid: $oid) {
|
||||
... on Commit {
|
||||
checkSuites(first: 100, after: $after) {
|
||||
nodes {
|
||||
status
|
||||
conclusion
|
||||
workflowRun {
|
||||
url
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--target-ref <canonical-release-branch-or-tag>] [--workflow-sha <trusted-main-ref>] [--keep-branch] [--dry-run] [-- -f key=value ...]
|
||||
@@ -25,8 +46,10 @@ Creates a temporary remote branch pinned to trusted main release tooling,
|
||||
dispatches Full Release Validation with the target commit as its ref input,
|
||||
watches the parent run, verifies all child workflow head SHAs match the trusted
|
||||
workflow lineage through the release evidence manifest, then deletes the
|
||||
temporary branch by default. Exact-target evidence reuse stays enabled; pass
|
||||
-f reuse_evidence=false to force a fresh run.`);
|
||||
temporary branch by default. Exact-target and changelog-only Release SHA
|
||||
evidence reuse stay enabled; pass -f reuse_evidence=false to force a fresh
|
||||
run. The release profile defaults to beta for alpha/beta package versions and
|
||||
stable otherwise; pass -f release_profile=full for the broad advisory sweep.`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
@@ -135,6 +158,12 @@ export function parseArgs(argv) {
|
||||
if (!["true", "false"].includes(args.inputs.reuse_evidence)) {
|
||||
throw new Error("reuse_evidence must be true or false");
|
||||
}
|
||||
if (
|
||||
args.inputs.release_profile &&
|
||||
!["beta", "stable", "full"].includes(args.inputs.release_profile)
|
||||
) {
|
||||
throw new Error("release_profile must be beta, stable, or full");
|
||||
}
|
||||
if (Object.hasOwn(args.inputs, "ref")) {
|
||||
throw new Error("SHA-pinned release validation reserves the ref input for --sha");
|
||||
}
|
||||
@@ -179,6 +208,22 @@ function resolveSha(requestedSha) {
|
||||
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
|
||||
}
|
||||
|
||||
export function releaseProfileForTarget(
|
||||
targetSha,
|
||||
readPackageJson = (sha) => run("git", ["show", `${sha}:package.json`]),
|
||||
) {
|
||||
let version;
|
||||
try {
|
||||
version = JSON.parse(readPackageJson(targetSha)).version;
|
||||
} catch {
|
||||
throw new Error(`Could not read package.json from target SHA ${targetSha}`);
|
||||
}
|
||||
if (typeof version !== "string" || !/^[0-9]{4}\.[0-9]+\.[0-9]+(?:-.+)?$/u.test(version)) {
|
||||
throw new Error(`Target SHA ${targetSha} has an invalid package version`);
|
||||
}
|
||||
return /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version) ? "beta" : "stable";
|
||||
}
|
||||
|
||||
function resolveTrustedWorkflowSha(requestedSha) {
|
||||
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
|
||||
stdio: "inherit",
|
||||
@@ -223,6 +268,91 @@ function findLatestRunId(branch, sha) {
|
||||
return match?.databaseId ? String(match.databaseId) : "";
|
||||
}
|
||||
|
||||
export function selectWorkflowRunCheckSuite(nodes, parentRunId) {
|
||||
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
|
||||
throw new Error("parent run ID must be a positive decimal");
|
||||
}
|
||||
const expectedUrl = `https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`;
|
||||
return nodes.find((node) => node?.workflowRun?.url === expectedUrl);
|
||||
}
|
||||
|
||||
function readWorkflowRunCheckSuite(parentRunId, workflowSha) {
|
||||
let after;
|
||||
for (let page = 0; page < 20; page += 1) {
|
||||
const queryArgs = [
|
||||
"api",
|
||||
"graphql",
|
||||
"-F",
|
||||
"owner=openclaw",
|
||||
"-F",
|
||||
"repo=openclaw",
|
||||
"-F",
|
||||
`oid=${workflowSha}`,
|
||||
"-f",
|
||||
`query=${WORKFLOW_RUN_CHECK_SUITE_QUERY}`,
|
||||
];
|
||||
if (after) {
|
||||
queryArgs.push("-F", `after=${after}`);
|
||||
}
|
||||
const response = JSON.parse(run("gh", queryArgs));
|
||||
const checkSuites = response?.data?.repository?.object?.checkSuites;
|
||||
if (!checkSuites || !Array.isArray(checkSuites.nodes)) {
|
||||
throw new Error("GraphQL response did not include commit check suites");
|
||||
}
|
||||
const match = selectWorkflowRunCheckSuite(checkSuites.nodes, parentRunId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
if (!checkSuites.pageInfo?.hasNextPage) {
|
||||
return undefined;
|
||||
}
|
||||
after = checkSuites.pageInfo.endCursor;
|
||||
if (!after) {
|
||||
throw new Error("GraphQL check-suite pagination omitted its end cursor");
|
||||
}
|
||||
}
|
||||
throw new Error("Full Release Validation check suite was not found within 20 pages");
|
||||
}
|
||||
|
||||
function waitForWorkflowRun(parentRunId, workflowSha) {
|
||||
let lastSummary = "";
|
||||
let consecutiveErrors = 0;
|
||||
for (let attempt = 0; attempt < 480; attempt += 1) {
|
||||
let suite;
|
||||
try {
|
||||
suite = readWorkflowRunCheckSuite(parentRunId, workflowSha);
|
||||
consecutiveErrors = 0;
|
||||
} catch (error) {
|
||||
consecutiveErrors += 1;
|
||||
if (consecutiveErrors >= 3) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Parent run status query failed; retrying: ${message}`);
|
||||
}
|
||||
|
||||
const summary = suite
|
||||
? `${String(suite.status).toLowerCase()}/${String(suite.conclusion ?? "pending").toLowerCase()}`
|
||||
: "awaiting-check-suite";
|
||||
if (summary !== lastSummary) {
|
||||
console.log(`Parent run status: ${summary}`);
|
||||
lastSummary = summary;
|
||||
}
|
||||
if (suite?.status === "COMPLETED") {
|
||||
if (suite.conclusion === "SUCCESS") {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Full Release Validation concluded ${String(suite.conclusion).toLowerCase()}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
||||
);
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 45_000);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Full Release Validation: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function releaseEvidenceVerificationArgs(parentRunId) {
|
||||
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
|
||||
throw new Error("parent run ID must be a positive decimal");
|
||||
@@ -276,6 +406,7 @@ function verifyReleaseEvidence(parentRunId, workflowSha) {
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const targetSha = resolveSha(args.sha);
|
||||
args.inputs.release_profile ??= releaseProfileForTarget(targetSha);
|
||||
const targetContextRef = verifyTargetRef(args.targetRef, targetSha);
|
||||
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
|
||||
const shortSha = workflowSha.slice(0, 12);
|
||||
@@ -325,18 +456,7 @@ function main() {
|
||||
}
|
||||
|
||||
console.log(`Parent run: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`);
|
||||
const watch = runStatus(
|
||||
"gh",
|
||||
["run", "watch", parentRunId, "--exit-status", "--interval", "30"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (watch.status !== 0) {
|
||||
throw new Error(
|
||||
`Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
||||
);
|
||||
}
|
||||
waitForWorkflowRun(parentRunId, workflowSha);
|
||||
verifyReleaseEvidence(parentRunId, workflowSha);
|
||||
} finally {
|
||||
if (!args.keepBranch) {
|
||||
|
||||
@@ -11,6 +11,25 @@ export function buildGatewayWatchTmuxCommand(params?: {
|
||||
sessionName?: string;
|
||||
}): string;
|
||||
|
||||
export function runGatewayWatchServiceHandoff(params?: {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodePath?: string;
|
||||
spawnSync?: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
) => {
|
||||
error?: Error;
|
||||
signal?: NodeJS.Signals | null;
|
||||
status?: number | null;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
};
|
||||
stderr?: { write: (message: string) => void };
|
||||
}): number;
|
||||
|
||||
export function runGatewayWatchTmuxMain(params?: {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
|
||||
+194
-11
@@ -15,6 +15,10 @@ const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
||||
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
||||
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
||||
const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs";
|
||||
const RUN_NODE_SCRIPT = "scripts/run-node.mjs";
|
||||
const GATEWAY_WATCH_TMUX_SCRIPT = "scripts/gateway-watch-tmux.mjs";
|
||||
const SERVICE_HANDOFF_ARG = "--handoff-managed-service";
|
||||
const DEFAULT_GATEWAY_PORT = "18789";
|
||||
const TMUX_CWD_ENV_KEY = "OPENCLAW_GATEWAY_WATCH_CWD";
|
||||
const TMUX_CWD_OPTION_KEY = "@openclaw.gateway_watch.cwd";
|
||||
const TMUX_CHILD_ENV_KEYS = [
|
||||
@@ -64,6 +68,52 @@ const readArgValue = (args, flag) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasArg = (args, flag) =>
|
||||
args.some((arg) => arg === flag || (typeof arg === "string" && arg.startsWith(`${flag}=`)));
|
||||
|
||||
const parsePortValue = (raw, { allowHost = false } = {}) => {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
let portText = /^\d+$/.test(trimmed) ? trimmed : null;
|
||||
if (!portText && allowHost) {
|
||||
const bracketed = trimmed.match(/^\[[^\]]+\]:(\d+)$/);
|
||||
if (bracketed?.[1]) {
|
||||
portText = bracketed[1];
|
||||
} else {
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
const suffix =
|
||||
firstColon > 0 && firstColon === lastColon ? trimmed.slice(firstColon + 1) : "";
|
||||
portText = /^\d+$/.test(suffix) ? suffix : null;
|
||||
}
|
||||
}
|
||||
const port = portText ? Number(portText) : Number.NaN;
|
||||
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
|
||||
};
|
||||
|
||||
const resolveGatewayWatchPort = ({ args, env }) => {
|
||||
// Keep CLI precedence and Compose-style env parsing aligned with the Gateway
|
||||
// owners in src/cli/gateway-cli/run.ts and src/config/paths.ts.
|
||||
if (hasArg(args, "--port")) {
|
||||
return { explicitCli: true, port: parsePortValue(readArgValue(args, "--port")) };
|
||||
}
|
||||
return {
|
||||
explicitCli: false,
|
||||
port: parsePortValue(env.OPENCLAW_GATEWAY_PORT, { allowHost: true }),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGatewayWatchProfile = ({ args, env }) => {
|
||||
if (hasArg(args, "--profile")) {
|
||||
return readArgValue(args, "--profile") ?? "";
|
||||
}
|
||||
const gatewayIndex = args.indexOf("gateway");
|
||||
const devIndex = args.indexOf("--dev");
|
||||
if (devIndex >= 0 && (gatewayIndex < 0 || devIndex < gatewayIndex)) {
|
||||
return "dev";
|
||||
}
|
||||
return env.OPENCLAW_PROFILE || null;
|
||||
};
|
||||
|
||||
const joinArtifactPath = (dir, basename) => {
|
||||
const normalizedDir = String(dir || DEFAULT_BENCHMARK_PROFILE_DIR).replace(/[\\/]+$/g, "");
|
||||
return `${normalizedDir || "."}/${basename}`;
|
||||
@@ -144,18 +194,15 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
* Resolves the tmux session name for gateway watch arguments/environment.
|
||||
*/
|
||||
export const resolveGatewayWatchTmuxSessionName = ({ args = [], env = process.env } = {}) => {
|
||||
const profile =
|
||||
env.OPENCLAW_PROFILE ||
|
||||
readArgValue(args, "--profile") ||
|
||||
(args.includes("--dev") ? "dev" : null);
|
||||
const port = env.OPENCLAW_GATEWAY_PORT || readArgValue(args, "--port");
|
||||
const profile = resolveGatewayWatchProfile({ args, env });
|
||||
const { port } = resolveGatewayWatchPort({ args, env });
|
||||
const parts = [
|
||||
"openclaw",
|
||||
"gateway",
|
||||
"watch",
|
||||
sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME),
|
||||
];
|
||||
if (port && port !== "18789") {
|
||||
if (port && String(port) !== DEFAULT_GATEWAY_PORT) {
|
||||
parts.push(sanitizeSessionPart(port));
|
||||
}
|
||||
return parts.join("-");
|
||||
@@ -199,12 +246,22 @@ export const buildGatewayWatchTmuxCommand = ({
|
||||
env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`],
|
||||
),
|
||||
];
|
||||
const childEnvCommand = childEnv.map(shellQuote);
|
||||
const handoffCommand = [
|
||||
...childEnvCommand,
|
||||
shellQuote(nodePath),
|
||||
shellQuote(GATEWAY_WATCH_TMUX_SCRIPT),
|
||||
shellQuote(SERVICE_HANDOFF_ARG),
|
||||
...args.map(shellQuote),
|
||||
"&&",
|
||||
];
|
||||
const watchCommand = [
|
||||
"cd",
|
||||
shellQuote(cwd),
|
||||
"&&",
|
||||
...handoffCommand,
|
||||
"exec",
|
||||
...childEnv.map(shellQuote),
|
||||
...childEnvCommand,
|
||||
shellQuote(nodePath),
|
||||
shellQuote(RAW_WATCH_SCRIPT),
|
||||
...args.map(shellQuote),
|
||||
@@ -212,6 +269,96 @@ export const buildGatewayWatchTmuxCommand = ({
|
||||
return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`;
|
||||
};
|
||||
|
||||
const parseTrailingJsonObject = (raw) => {
|
||||
const text = String(raw ?? "").trim();
|
||||
for (let index = text.lastIndexOf("{"); index >= 0; index = text.lastIndexOf("{", index - 1)) {
|
||||
try {
|
||||
return JSON.parse(text.slice(index));
|
||||
} catch {
|
||||
// Build output can precede the CLI JSON; keep scanning for the outer object.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Stops the matching managed service without targeting an unrelated listener. */
|
||||
export const runGatewayWatchServiceHandoff = (params = {}) => {
|
||||
const args = params.args ?? [];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const env = params.env ? { ...params.env } : { ...process.env };
|
||||
const nodePath = params.nodePath ?? process.execPath;
|
||||
const spawnSyncImpl = params.spawnSync ?? spawnSync;
|
||||
const stderr = params.stderr ?? process.stderr;
|
||||
const profile = resolveGatewayWatchProfile({ args, env });
|
||||
const profileArgs = profile === null ? [] : ["--profile", profile];
|
||||
const statusResult = spawnSyncImpl(
|
||||
nodePath,
|
||||
[RUN_NODE_SCRIPT, ...profileArgs, "gateway", "status", "--json", "--no-probe"],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
if (statusResult.error || statusResult.status !== 0) {
|
||||
const detail =
|
||||
statusResult.error?.message || String(statusResult.stderr || "").trim() || "unknown error";
|
||||
log(stderr, `failed to inspect the managed Gateway service before watch: ${detail}`);
|
||||
return statusResult.status || 1;
|
||||
}
|
||||
const status = parseTrailingJsonObject(statusResult.stdout);
|
||||
if (!status || typeof status !== "object") {
|
||||
log(stderr, "failed to parse managed Gateway service status before watch");
|
||||
return 1;
|
||||
}
|
||||
const managedServiceActive =
|
||||
status.service?.loaded === true || status.service?.runtime?.status === "running";
|
||||
if (!managedServiceActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { explicitCli, port: requestedPort } = resolveGatewayWatchPort({ args, env });
|
||||
if (explicitCli && requestedPort === null) {
|
||||
// The watcher will report the invalid CLI value without disrupting a healthy service.
|
||||
return 0;
|
||||
}
|
||||
const managedPort = parsePortValue(status.gateway?.port);
|
||||
const currentConfigPort = parsePortValue(status.portCli?.port ?? status.gateway?.port);
|
||||
const watchPort = requestedPort ?? currentConfigPort;
|
||||
if (managedPort === null || watchPort === null) {
|
||||
log(stderr, "failed to resolve the Gateway watch port before service handoff");
|
||||
return 1;
|
||||
}
|
||||
if (watchPort !== managedPort) {
|
||||
log(
|
||||
stderr,
|
||||
`gateway:watch leaving managed Gateway on port ${managedPort}; watching port ${watchPort}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If the service unloads after status, keep the unmanaged fallback scoped to
|
||||
// the watch target instead of a lower-precedence environment port.
|
||||
const stopEnv = { ...env };
|
||||
if (explicitCli) {
|
||||
stopEnv.OPENCLAW_GATEWAY_PORT = String(watchPort);
|
||||
}
|
||||
const stopResult = spawnSyncImpl(nodePath, [RUN_NODE_SCRIPT, ...profileArgs, "gateway", "stop"], {
|
||||
cwd,
|
||||
env: stopEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (stopResult.error) {
|
||||
log(
|
||||
stderr,
|
||||
`failed to stop the managed Gateway service before watch: ${stopResult.error.message}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return stopResult.status ?? (stopResult.signal ? 1 : 0);
|
||||
};
|
||||
|
||||
const runForegroundWatcher = ({ args, cwd, env, nodePath, spawnSyncImpl, stdio = "inherit" }) => {
|
||||
const result = spawnSyncImpl(nodePath, [RAW_WATCH_SCRIPT, ...args], {
|
||||
cwd,
|
||||
@@ -279,6 +426,9 @@ const setTmuxSessionMetadata = ({ cwd, sessionName, spawnSyncImpl, stderr }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const retainTmuxPaneOnExit = ({ sessionName, spawnSyncImpl }) =>
|
||||
runTmux(spawnSyncImpl, ["set-option", "-w", "-t", sessionName, "remain-on-exit", "on"]);
|
||||
|
||||
/**
|
||||
* Runs the gateway-watch tmux wrapper main flow.
|
||||
*/
|
||||
@@ -355,10 +505,37 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const startSession = () =>
|
||||
runTmux(deps.spawnSync, ["new-session", "-d", "-s", sessionName, "-c", deps.cwd, command]);
|
||||
const restartSession = () =>
|
||||
const launchPane = () =>
|
||||
runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]);
|
||||
const prepareSession = () => retainTmuxPaneOnExit({ sessionName, spawnSyncImpl: deps.spawnSync });
|
||||
const startSession = () => {
|
||||
// Create a durable shell pane first so remain-on-exit is active before the
|
||||
// watcher can fail. Agents can then capture the original startup error.
|
||||
const created = runTmux(deps.spawnSync, [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
sessionName,
|
||||
"-c",
|
||||
deps.cwd,
|
||||
]);
|
||||
if (created.error || created.status !== 0) {
|
||||
return created;
|
||||
}
|
||||
const prepared = prepareSession();
|
||||
if (prepared.error || prepared.status !== 0) {
|
||||
runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]);
|
||||
return prepared;
|
||||
}
|
||||
return launchPane();
|
||||
};
|
||||
const restartSession = () => {
|
||||
const prepared = prepareSession();
|
||||
if (prepared.error || prepared.status !== 0) {
|
||||
return prepared;
|
||||
}
|
||||
return launchPane();
|
||||
};
|
||||
const action = hasSession.status === 0 ? "restarted" : "started";
|
||||
let result = hasSession.status === 0 ? restartSession() : startSession();
|
||||
if (hasSession.status === 0 && isMissingTmuxTarget(result)) {
|
||||
@@ -410,6 +587,7 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
return 0;
|
||||
}
|
||||
deps.stdout.write(`Attach: tmux attach -t ${sessionName}\n`);
|
||||
deps.stdout.write(`Logs: tmux capture-pane -ep -t ${sessionName} -S -200\n`);
|
||||
deps.stdout.write(`Cwd: tmux show-options -v -t ${sessionName} ${TMUX_CWD_OPTION_KEY}\n`);
|
||||
deps.stdout.write("Restart: rerun the same pnpm gateway:watch command\n");
|
||||
deps.stdout.write(`Stop: tmux kill-session -t ${sessionName}\n`);
|
||||
@@ -417,5 +595,10 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exit(runGatewayWatchTmuxMain());
|
||||
const args = process.argv.slice(2);
|
||||
process.exit(
|
||||
args[0] === SERVICE_HANDOFF_ARG
|
||||
? runGatewayWatchServiceHandoff({ args: args.slice(1) })
|
||||
: runGatewayWatchTmuxMain({ args }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ async function collectBundledChannelConfigMetadata(params?: { repoRoot?: string
|
||||
if (!modulePath) {
|
||||
continue;
|
||||
}
|
||||
const surface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot });
|
||||
const surface = await loadChannelConfigSurfaceModule(modulePath);
|
||||
if (!surface?.schema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,6 @@ export function resolvePackageDirs(args: string[]): {
|
||||
jobs: number;
|
||||
packageDirs: unknown[];
|
||||
};
|
||||
export function runBoundedTasks<Item, Result>(
|
||||
items: Item[],
|
||||
jobs: number,
|
||||
runTask: (item: Item) => Promise<Result>,
|
||||
): Promise<Result[]>;
|
||||
export function resolveShrinkwrapJobs(
|
||||
rawValue: unknown,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
@@ -89,6 +84,7 @@ export function disableShrinkwrappedOverrideConflictSources(
|
||||
): string[];
|
||||
export function exactOverrideRulesFromOverrides(overrides: unknown): unknown;
|
||||
export function exactVersionFromOverrideSpec(spec: unknown): string | null;
|
||||
export function normalizeOverrides(overrides: unknown): Record<string, unknown>;
|
||||
export function mergeOverrides(
|
||||
packageOverrides: unknown,
|
||||
workspaceOverrides: unknown,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
|
||||
import pMap from "p-map";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { listChangedPathsFromGit, listStagedChangedPaths } from "./changed-lanes.mjs";
|
||||
import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
@@ -46,7 +47,23 @@ function normalizeOverrides(overrides) {
|
||||
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
|
||||
return {};
|
||||
}
|
||||
return normalizeOverrideValue(overrides);
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const scopedSeparator = key.indexOf(">");
|
||||
if (scopedSeparator > 0) {
|
||||
const parentSelector = key.slice(0, scopedSeparator).trim();
|
||||
const dependencyName = key.slice(scopedSeparator + 1).trim();
|
||||
if (parentSelector && dependencyName) {
|
||||
const current = normalized[parentSelector];
|
||||
const nested = isPlainObject(current) ? current : {};
|
||||
nested[dependencyName] = normalizeOverrideValue(value);
|
||||
normalized[parentSelector] = nested;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
normalized[key] = normalizeOverrideValue(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
@@ -403,6 +420,7 @@ function packageJsonForShrinkwrap(packageJson, shrinkwrapOverrides) {
|
||||
|
||||
/**
|
||||
* Resolves the npm command invocation used by shrinkwrap generation.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function createNpmShrinkwrapCommand(args, options = {}) {
|
||||
return resolveNpmRunner({
|
||||
@@ -417,6 +435,7 @@ export function createNpmShrinkwrapCommand(args, options = {}) {
|
||||
|
||||
/**
|
||||
* Reads a positive integer env override for shrinkwrap subprocess limits.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function readPositiveIntEnv(name, fallback, env = process.env) {
|
||||
const text = String(env[name] ?? fallback).trim();
|
||||
@@ -432,6 +451,7 @@ export function readPositiveIntEnv(name, fallback, env = process.env) {
|
||||
|
||||
/**
|
||||
* Builds execFileSync options with bounded timeout and output buffer limits.
|
||||
* @internal Directly tested script implementation detail.
|
||||
*/
|
||||
export function createNpmShrinkwrapExecOptions(invocation, cwd, env = process.env) {
|
||||
return {
|
||||
@@ -1195,6 +1215,7 @@ function listCheckChangedPaths() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Directly tested script implementation detail. */
|
||||
export function resolvePackageDirs(args) {
|
||||
const packageDirs = [];
|
||||
const check = args.includes("--check");
|
||||
@@ -1327,20 +1348,7 @@ function updateOrCheckPackage(packageDir, check, changedPaths = []) {
|
||||
return `${label}: npm-shrinkwrap.json is current.`;
|
||||
}
|
||||
|
||||
export async function runBoundedTasks(items, jobs, runTask) {
|
||||
const results = Array.from({ length: items.length });
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({ length: Math.min(jobs, items.length) }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await runTask(items[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** @internal Directly tested script implementation detail. */
|
||||
export function resolveShrinkwrapJobs(
|
||||
rawValue,
|
||||
env = process.env,
|
||||
@@ -1385,19 +1393,23 @@ async function runPackageWorker(packageDir, check, changedPaths) {
|
||||
}
|
||||
|
||||
async function updateOrCheckPackages({ check, changedPaths, jobs, packageDirs }) {
|
||||
const outcomes = await runBoundedTasks(packageDirs, jobs, async (packageDir) => {
|
||||
try {
|
||||
const output =
|
||||
jobs === 1
|
||||
? updateOrCheckPackage(packageDir, check, changedPaths)
|
||||
: await runPackageWorker(packageDir, check, changedPaths);
|
||||
return { output };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
const outcomes = await pMap(
|
||||
packageDirs,
|
||||
async (packageDir) => {
|
||||
try {
|
||||
const output =
|
||||
jobs === 1
|
||||
? updateOrCheckPackage(packageDir, check, changedPaths)
|
||||
: await runPackageWorker(packageDir, check, changedPaths);
|
||||
return { output };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
{ concurrency: jobs, stopOnError: false },
|
||||
);
|
||||
|
||||
const errors = [];
|
||||
for (const outcome of outcomes) {
|
||||
@@ -1447,6 +1459,7 @@ function handleMainError(error) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** @internal Directly tested and shared repository-script contracts. */
|
||||
export {
|
||||
// Test-facing helpers cover lockfile normalization, override merging, and
|
||||
// changed-package detection without invoking npm.
|
||||
@@ -1457,6 +1470,7 @@ export {
|
||||
exactOverrideRulesFromOverrides,
|
||||
exactVersionFromOverrideSpec,
|
||||
mergeOverrides,
|
||||
normalizeOverrides,
|
||||
applyPackageExtensionPeerMetadata,
|
||||
normalizeNpmVersionDrift,
|
||||
packageJsonForShrinkwrap,
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { createPrivateKey, createSign } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { readBoundedResponseText } from "./lib/bounded-response.ts";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
|
||||
@@ -50,7 +51,7 @@ type GitHubBodyReadOptions = {
|
||||
|
||||
export function parseRepoArg(args: string[]): string | null {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
const arg = expectDefined(args[i], `GitHub CLI argument at index ${i}`);
|
||||
if (arg === "-R" || arg === "--repo") {
|
||||
return normalizeRepo(args[i + 1] ?? null);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Finds a prior green Full Release Validation run for the exact target SHA.
|
||||
# Cross-SHA evidence reuse is intentionally left to the granular delta manifest,
|
||||
# which can require fresh package/install/provider closure per changed artifact.
|
||||
# Finds a prior green Full Release Validation run for the exact target SHA or
|
||||
# for its immediate product-equivalent predecessor. Cross-SHA reuse is limited
|
||||
# to a descendant whose complete tree delta is CHANGELOG.md; package/install
|
||||
# proof still runs against the release SHA after that changelog is committed.
|
||||
# Always exits 0 with reuse=true/false; callers fail open to a full validation.
|
||||
|
||||
REPO="${GH_REPO:-}"
|
||||
@@ -34,8 +35,9 @@ Scans recent successful Full Release Validation runs for an exact-target
|
||||
validation manifest whose recorded lane-selection inputs match --inputs-json
|
||||
and whose normalized strict-v3 evidence is accepted by the current trusted-main
|
||||
verifier identified by --workflow-sha. The historical producer workflow SHA
|
||||
remains independent. Writes reuse=true plus evidence_* outputs when found;
|
||||
reuse=false otherwise.
|
||||
remains independent. A descendant target may reuse product validation only
|
||||
when GitHub proves the entire delta is CHANGELOG.md. Writes reuse=true plus
|
||||
evidence_* outputs when found; reuse=false otherwise.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -287,20 +289,42 @@ for ((index = 0; index < run_count; index += 1)); do
|
||||
fi
|
||||
|
||||
prior_sha="$(jq -r '.root.targetSha' <<< "$validation_record")"
|
||||
evidence_policy="exact-target-full-validation-v1"
|
||||
changed_paths="[]"
|
||||
if [[ "$prior_sha" != "$TARGET_SHA" ]]; then
|
||||
echo "[evidence-reuse] run ${run_id}: target ${prior_sha} differs from ${TARGET_SHA}; cross-SHA reuse requires granular artifact evidence" >&2
|
||||
continue
|
||||
compare_json=""
|
||||
if ! compare_json="$(
|
||||
gh api "repos/${REPO}/compare/${prior_sha}...${TARGET_SHA}"
|
||||
)"; then
|
||||
echo "[evidence-reuse] run ${run_id}: could not compare ${prior_sha}...${TARGET_SHA}; skipping" >&2
|
||||
continue
|
||||
fi
|
||||
if ! jq -e \
|
||||
--arg prior_sha "$prior_sha" '
|
||||
.status == "ahead"
|
||||
and .merge_base_commit.sha == $prior_sha
|
||||
and (.files | type == "array" and length == 1)
|
||||
and .files[0].filename == "CHANGELOG.md"
|
||||
and .files[0].status == "modified"
|
||||
and ((.files[0].previous_filename // "") == "")
|
||||
' <<< "$compare_json" >/dev/null; then
|
||||
echo "[evidence-reuse] run ${run_id}: target ${TARGET_SHA} is not a CHANGELOG.md-only descendant of ${prior_sha}; skipping" >&2
|
||||
continue
|
||||
fi
|
||||
evidence_policy="changelog-only-release-v1"
|
||||
changed_paths='["CHANGELOG.md"]'
|
||||
fi
|
||||
|
||||
run_url="$(jq -r '.root.url' <<< "$validation_record")"
|
||||
echo "[evidence-reuse] reusing exact-target run ${run_id} (${run_url}) for ${TARGET_SHA}" >&2
|
||||
echo "[evidence-reuse] reusing ${evidence_policy} run ${run_id} (${run_url}) for ${TARGET_SHA}" >&2
|
||||
write_output reuse true
|
||||
write_output evidence_run_id "$run_id"
|
||||
write_output evidence_root_run_id "$run_id"
|
||||
write_output evidence_run_url "$run_url"
|
||||
write_output evidence_sha "$prior_sha"
|
||||
write_output changed_path_count "0"
|
||||
write_output changed_paths "[]"
|
||||
write_output evidence_policy "$evidence_policy"
|
||||
write_output changed_path_count "$(jq 'length' <<< "$changed_paths")"
|
||||
write_output changed_paths "$changed_paths"
|
||||
write_output evidence_manifest "$(jq -c '.manifest' <<< "$validation_record")"
|
||||
exit 0
|
||||
done
|
||||
|
||||
+78
-16
@@ -64,14 +64,17 @@ resolve_openclaw_effective_home() {
|
||||
OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)"
|
||||
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
|
||||
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
|
||||
NODE_VERSION="${OPENCLAW_NODE_VERSION:-22.22.2}"
|
||||
DEFAULT_NODE_VERSION="24.15.0"
|
||||
ARMV7_DEFAULT_NODE_VERSION="22.22.3"
|
||||
NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}"
|
||||
NODE_VERSION_REQUESTED=0
|
||||
if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
|
||||
NODE_VERSION_REQUESTED=1
|
||||
fi
|
||||
MIN_NODE_VERSION="22.19.0"
|
||||
MIN_NODE_23_VERSION="23.11.0"
|
||||
SUPPORTED_NODE_VERSION_LABEL="Node 22.19+, Node 23.11+, or Node 24+"
|
||||
MIN_NODE_22_VERSION="22.22.3"
|
||||
MIN_NODE_24_VERSION="24.15.0"
|
||||
MIN_NODE_25_VERSION="25.9.0"
|
||||
SUPPORTED_NODE_VERSION_LABEL="Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+"
|
||||
APK_NODE_BIN_DIR="/usr/bin"
|
||||
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
|
||||
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
|
||||
@@ -92,7 +95,7 @@ Usage: install-cli.sh [options]
|
||||
--git, --github Shortcut for --install-method git
|
||||
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
|
||||
--version <ver> OpenClaw version (default: latest)
|
||||
--node-version <ver> Node version (default: 22.22.2)
|
||||
--node-version <ver> Node version (default: 24.15.0; 22.22.3 on Linux ARMv7)
|
||||
--onboard Run "openclaw onboard" after install
|
||||
--no-onboard Skip onboarding (default)
|
||||
--set-npm-prefix Force npm prefix to ~/.npm-global if current prefix is not writable (Linux)
|
||||
@@ -354,11 +357,23 @@ arch_detect() {
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
arm64|aarch64) echo "arm64" ;;
|
||||
armv7|armv7l) echo "armv7l" ;;
|
||||
x86_64|amd64) echo "x64" ;;
|
||||
*) fail "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
select_node_version_for_platform() {
|
||||
local os="$1"
|
||||
local arch="$2"
|
||||
if [[ "$NODE_VERSION_REQUESTED" == "0" && "$os" == "linux" && "$arch" == "armv7l" ]]; then
|
||||
NODE_VERSION="$ARMV7_DEFAULT_NODE_VERSION"
|
||||
fi
|
||||
if [[ "$os" == "linux" && "$arch" == "armv7l" && "${NODE_VERSION%%.*}" != "22" ]]; then
|
||||
fail "Linux ARMv7 requires Node 22.22.3+ because official Node 24+ binaries are unavailable; use --node-version 22.22.3."
|
||||
fi
|
||||
}
|
||||
|
||||
node_dir() {
|
||||
echo "${PREFIX}/tools/node-v${NODE_VERSION}"
|
||||
}
|
||||
@@ -444,7 +459,45 @@ linked_node_is_usable() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$(node_bin)" -e "const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); const statement = db.prepare('SELECT 1'); const supported = typeof statement.columns === 'function'; db.close(); if (!supported) process.exit(1);" >/dev/null 2>&1
|
||||
"$(node_bin)" -e '
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(":memory:");
|
||||
try {
|
||||
const value = db.prepare("SELECT sqlite_version() AS version").get()?.version;
|
||||
const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null;
|
||||
const major = Number(match?.[1]);
|
||||
const minor = Number(match?.[2]);
|
||||
const patch = Number(match?.[3]);
|
||||
const safe =
|
||||
major > 3 ||
|
||||
(major === 3 &&
|
||||
(minor > 51 ||
|
||||
(minor === 51 && patch >= 3) ||
|
||||
(minor === 50 && patch >= 7) ||
|
||||
(minor === 44 && patch >= 6)));
|
||||
if (!safe) process.exitCode = 1;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
linked_node_sqlite_version() {
|
||||
if [[ ! -x "$(node_bin)" ]]; then
|
||||
printf 'unavailable\n'
|
||||
return
|
||||
fi
|
||||
local version
|
||||
version="$("$(node_bin)" -e '
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(":memory:");
|
||||
try {
|
||||
process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? "unknown"));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
' 2>/dev/null || true)"
|
||||
printf '%s\n' "${version:-unavailable}"
|
||||
}
|
||||
|
||||
semver_at_least() {
|
||||
@@ -491,14 +544,18 @@ node_version_is_supported() {
|
||||
done
|
||||
|
||||
if ((major == 22)); then
|
||||
semver_at_least "$version" "$MIN_NODE_VERSION"
|
||||
semver_at_least "$version" "$MIN_NODE_22_VERSION"
|
||||
return
|
||||
fi
|
||||
if ((major == 23)); then
|
||||
semver_at_least "$version" "$MIN_NODE_23_VERSION"
|
||||
if ((major == 24)); then
|
||||
semver_at_least "$version" "$MIN_NODE_24_VERSION"
|
||||
return
|
||||
fi
|
||||
((major > 23))
|
||||
if ((major == 25)); then
|
||||
semver_at_least "$version" "$MIN_NODE_25_VERSION"
|
||||
return
|
||||
fi
|
||||
((major > 25))
|
||||
}
|
||||
|
||||
required_node_version() {
|
||||
@@ -506,7 +563,7 @@ required_node_version() {
|
||||
printf '%s\n' "$NODE_VERSION"
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$MIN_NODE_VERSION"
|
||||
printf '%s\n' "$MIN_NODE_22_VERSION"
|
||||
}
|
||||
|
||||
try_link_usable_node_runtime_from_path() {
|
||||
@@ -536,6 +593,7 @@ try_link_usable_node_runtime_from_path() {
|
||||
install_alpine_node() {
|
||||
local installed_version
|
||||
local required_version
|
||||
local sqlite_version
|
||||
|
||||
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}"
|
||||
if try_link_usable_node_runtime_from_path; then
|
||||
@@ -562,7 +620,8 @@ install_alpine_node() {
|
||||
if ! linked_node_is_usable; then
|
||||
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
|
||||
required_version="$(required_node_version)"
|
||||
fail "Alpine Node package must provide Node >= ${required_version} with node:sqlite; found ${installed_version}."
|
||||
sqlite_version="$(linked_node_sqlite_version)"
|
||||
fail "Alpine Node package must provide Node >= ${required_version} with WAL-reset-safe SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+); found Node ${installed_version}, SQLite ${sqlite_version}."
|
||||
fi
|
||||
|
||||
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
|
||||
@@ -808,12 +867,12 @@ install_node() {
|
||||
local expected_sha
|
||||
local actual_sha
|
||||
|
||||
os="$(os_detect)"
|
||||
arch="$(arch_detect)"
|
||||
select_node_version_for_platform "$os" "$arch"
|
||||
if ! node_version_is_supported "$NODE_VERSION"; then
|
||||
fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
|
||||
fi
|
||||
|
||||
os="$(os_detect)"
|
||||
arch="$(arch_detect)"
|
||||
dir="$(node_dir)"
|
||||
|
||||
if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then
|
||||
@@ -861,9 +920,11 @@ install_node() {
|
||||
if ! linked_node_is_usable; then
|
||||
local installed_version
|
||||
local required_version
|
||||
local sqlite_version
|
||||
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
|
||||
required_version="$(required_node_version)"
|
||||
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with node:sqlite; found ${installed_version}. Re-run with --node-version 22.22.2 (or newer)"
|
||||
sqlite_version="$(linked_node_sqlite_version)"
|
||||
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with WAL-reset-safe SQLite; found Node ${installed_version}, SQLite ${sqlite_version}. Re-run with --node-version 24.15.0 (or newer)"
|
||||
fi
|
||||
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}"
|
||||
}
|
||||
@@ -1230,6 +1291,7 @@ main() {
|
||||
RUN_ONBOARD=0
|
||||
fi
|
||||
|
||||
select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
|
||||
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
|
||||
export PATH
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user