ci(swift): enforce shared OpenClawKit dead-code coverage (#105770)

* ci(swift): enforce shared kit dead-code coverage

* chore(i18n): sync native source inventory

* ci(swift): install pinned iOS scan tools
This commit is contained in:
Peter Steinberger
2026-07-12 17:35:47 -07:00
committed by GitHub
parent ff166425d1
commit e01d1e85f3
36 changed files with 717 additions and 555 deletions
@@ -0,0 +1,263 @@
name: Shared OpenClawKit Periphery
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: shared-openclawkit-periphery-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
pull-requests: read
jobs:
scope:
name: Detect shared OpenClawKit scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName === "workflow_dispatch") {
core.setOutput("should-scan", "true");
return;
}
if (context.payload.pull_request?.draft) {
core.setOutput("should-scan", "false");
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const isScanPath = (filename) =>
typeof filename === "string" && (
filename.startsWith("apps/ios/") ||
filename.startsWith("apps/macos/") ||
filename.startsWith("apps/shared/OpenClawKit/") ||
filename === ".github/workflows/shared-openclawkit-periphery.yml" ||
filename === "scripts/periphery-intersection.mjs" ||
filename === "scripts/ios-configure-signing.sh" ||
filename === "scripts/ios-write-swift-filelist.mjs" ||
filename === "scripts/ios-write-version-xcconfig.sh" ||
filename === "test/scripts/periphery-intersection.test.ts"
);
const shouldScan = files.some(
({ filename, previous_filename: previousFilename }) =>
isScanPath(filename) || isScanPath(previousFilename)
);
core.setOutput("should-scan", String(shouldScan));
scan-ios:
name: Scan shared kit from iOS
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS scan tooling
run: |
brew update
brew install xcodegen periphery
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
./scripts/install-swift-tools.sh "$swift_tools_dir"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
"$swift_tools_dir/swiftformat" --version
"$swift_tools_dir/swiftlint" version
- name: Generate iOS project
run: |
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
node scripts/ios-write-swift-filelist.mjs
cd apps/ios
xcodegen generate
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-ios"
mkdir -p "$output_dir"
cd apps/ios
set +e
periphery scan \
--config .periphery.yml \
--clean-build \
--format json \
--report-include '../shared/OpenClawKit/Sources/**' \
--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift' \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Upload iOS consumer report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-ios-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-ios
if-no-files-found: error
retention-days: 14
scan-macos:
name: Scan shared kit from macOS
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Install Periphery
run: |
brew update
brew install periphery
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-macos"
mkdir -p "$output_dir"
cd apps/macos
set +e
periphery scan \
--config .periphery.yml \
--clean-build \
--format json \
--report-include '../shared/OpenClawKit/Sources/**' \
--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift' \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Upload macOS consumer report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-macos-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-macos
if-no-files-found: error
retention-days: 14
intersect:
name: Intersect shared OpenClawKit dead code
needs: [scope, scan-ios, scan-macos]
if: ${{ always() && needs.scope.outputs.should-scan == 'true' && needs.scan-ios.result != 'cancelled' && needs.scan-macos.result != 'cancelled' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Download iOS consumer report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: shared-periphery-ios-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-ios
- name: Download macOS consumer report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: shared-periphery-macos-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-macos
- name: Intersect exact Swift identities
run: |
node scripts/periphery-intersection.mjs \
--ios-results "$RUNNER_TEMP/shared-periphery-ios/periphery.json" \
--ios-status "$RUNNER_TEMP/shared-periphery-ios/periphery.status" \
--macos-results "$RUNNER_TEMP/shared-periphery-macos/periphery.json" \
--macos-status "$RUNNER_TEMP/shared-periphery-macos/periphery.status" \
--output "$RUNNER_TEMP/shared-periphery-intersection/periphery.json"
- name: Upload shared intersection
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-intersection-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-intersection
if-no-files-found: warn
retention-days: 14
+4 -4
View File
@@ -32707,7 +32707,7 @@
},
{
"kind": "conditional-branch",
"line": 547,
"line": 548,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift",
"source": "Image",
"surface": "apple",
@@ -33155,7 +33155,7 @@
},
{
"kind": "ui-localized-call",
"line": 1178,
"line": 1179,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
"surface": "apple",
@@ -33163,7 +33163,7 @@
},
{
"kind": "ui-localized-call",
"line": 1234,
"line": 1235,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "Remove attachments or wait for delivery to resolve before starting a new chat.",
"surface": "apple",
@@ -33595,7 +33595,7 @@
},
{
"kind": "conditional-branch",
"line": 198,
"line": 191,
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift",
"source": "\\(preview)…",
"surface": "apple",
@@ -17,6 +17,7 @@ public struct OpenClawChatHaptics: Sendable {
self.performer = Self.defaultPerformer
}
// periphery:ignore - package tests inject a recorder; app consumers use the live performer.
public init(performer: @escaping @Sendable (Event) -> Void) {
self.performer = performer
}
@@ -236,6 +236,7 @@ struct ChatMarkdownProse {
}
}
// periphery:ignore - package tests inspect parsed math spans without exposing renderer internals.
var inlineMathLatex: [String] {
self.inlineContent?.compactMap { content in
if case let .math(span) = content {
@@ -376,6 +376,7 @@ public struct OpenClawChatInFlightRun: Codable, Sendable {
public let runId: String
public let text: String
// periphery:ignore - package tests construct history fixtures; app consumers decode this payload.
public init(runId: String, text: String) {
self.runId = runId
self.text = text
@@ -385,6 +386,7 @@ public struct OpenClawChatInFlightRun: Codable, Sendable {
public struct OpenClawChatSessionInfo: Codable, Sendable {
public let hasActiveRun: Bool?
// periphery:ignore - package tests construct history fixtures; app consumers decode this payload.
public init(hasActiveRun: Bool?) {
self.hasActiveRun = hasActiveRun
}
@@ -455,6 +457,7 @@ public struct OpenClawChatEventPayload: Codable, Sendable {
public let message: AnyCodable?
public let errorMessage: String?
// periphery:ignore - package tests construct transport events; app consumers decode them.
public init(
runId: String?,
sessionKey: String?,
@@ -479,6 +482,7 @@ public struct OpenClawSessionMessageEventPayload: Codable, Sendable {
public let messageId: String?
public let messageSeq: Int?
// periphery:ignore - package tests construct transport events; app consumers decode them.
public init(
sessionKey: String?,
agentId: String? = nil,
@@ -59,6 +59,7 @@ public struct OpenClawChatModelPatchResult: Decodable, Sendable, Equatable {
public let thinkingLevel: String?
public let thinkingLevels: [OpenClawChatThinkingLevelOption]?
// periphery:ignore - package tests construct patch responses; app consumers decode them.
public init(
key: String? = nil,
modelProvider: String?,
@@ -71,7 +71,7 @@ extension OpenClawChatTranscriptCache {
/// Optional atomic merge seam for cache owners that also provide a durable
/// outbox. Keeping this separate preserves source compatibility for read-only
/// transcript-cache conformers.
public protocol OpenClawChatCanonicalTranscriptMerging: OpenClawChatTranscriptCache {
protocol OpenClawChatCanonicalTranscriptMerging: OpenClawChatTranscriptCache {
func mergeCanonicalTranscriptMessage(
sessionKey: String,
agentID: String?,
@@ -455,6 +455,7 @@ public final class OpenClawChatViewModel {
self.applySessionSwitch(to: sessionKey, intent: .externalSync)
}
// periphery:ignore - package tests vary one identity field while preserving the current routing contract.
public func syncActiveAgentId(_ agentId: String?) {
self.syncDeliveryIdentity(
activeAgentId: agentId,
@@ -98,9 +98,9 @@ public struct TalkWaveformView: View {
/// Pure waveform math, split from the view for unit testing and so the Android
/// port has one canonical reference for every constant.
public enum TalkWaveformMath {
enum TalkWaveformMath {
/// Per-phase drive for the wave amplitude in 0...1.
public static func power(for phase: TalkWaveformPhase, time: Double) -> Double {
static func power(for phase: TalkWaveformPhase, time: Double) -> Double {
switch phase {
case .idle:
return 0.05
@@ -121,7 +121,7 @@ public enum TalkWaveformMath {
}
/// One wave = max envelope of three drifting lobes, mirrored around the midline.
public static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path {
static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path {
let midX = Double(size.width) / 2
let midY = Double(size.height) / 2
@@ -1,13 +1,5 @@
import Foundation
public struct BridgeBaseFrame: Codable, Sendable {
public let type: String
public init(type: String) {
self.type = type
}
}
public struct BridgeInvokeRequest: Codable, Sendable {
public let type: String
public let id: String
@@ -54,216 +46,3 @@ public struct BridgeInvokeResponse: Codable, Sendable {
self.error = error
}
}
public struct BridgeEventFrame: Codable, Sendable {
public let type: String
public let event: String
public let payloadJSON: String?
public init(type: String = "event", event: String, payloadJSON: String? = nil) {
self.type = type
self.event = event
self.payloadJSON = payloadJSON
}
}
public struct BridgeHello: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let token: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public init(
type: String = "hello",
nodeId: String,
displayName: String?,
token: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil,
permissions: [String: Bool]? = nil)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.token = token
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
}
}
public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public let mainSessionKey: String?
public init(
type: String = "hello-ok",
serverName: String,
mainSessionKey: String? = nil)
{
self.type = type
self.serverName = serverName
self.mainSessionKey = mainSessionKey
}
}
public struct BridgePairRequest: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public let remoteAddress: String?
public let silent: Bool?
public init(
type: String = "pair-request",
nodeId: String,
displayName: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil,
permissions: [String: Bool]? = nil,
remoteAddress: String? = nil,
silent: Bool? = nil)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
self.remoteAddress = remoteAddress
self.silent = silent
}
}
public struct BridgePairOk: Codable, Sendable {
public let type: String
public let token: String
public init(type: String = "pair-ok", token: String) {
self.type = type
self.token = token
}
}
public struct BridgePing: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "ping", id: String) {
self.type = type
self.id = id
}
}
public struct BridgePong: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "pong", id: String) {
self.type = type
self.id = id
}
}
public struct BridgeErrorFrame: Codable, Sendable {
public let type: String
public let code: String
public let message: String
public init(type: String = "error", code: String, message: String) {
self.type = type
self.code = code
self.message = message
}
}
// MARK: - Optional RPC (node -> bridge)
public struct BridgeRPCRequest: Codable, Sendable {
public let type: String
public let id: String
public let method: String
public let paramsJSON: String?
public init(type: String = "req", id: String, method: String, paramsJSON: String? = nil) {
self.type = type
self.id = id
self.method = method
self.paramsJSON = paramsJSON
}
}
public struct BridgeRPCError: Codable, Sendable, Equatable {
public let code: String
public let message: String
public init(code: String, message: String) {
self.code = code
self.message = message
}
}
public struct BridgeRPCResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payloadJSON: String?
public let error: BridgeRPCError?
public init(
type: String = "res",
id: String,
ok: Bool,
payloadJSON: String? = nil,
error: BridgeRPCError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payloadJSON = payloadJSON
self.error = error
}
}
@@ -72,28 +72,6 @@ public enum CameraCapturePipelineSupport {
}
}
/// Keeps the flat overload source-compatible while the options form owns the implementation.
public static func prepareMovieSession(
preferFrontCamera: Bool,
deviceId: String?,
includeAudio: Bool,
durationMs: Int,
pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
cameraUnavailableError: @autoclosure () -> Error,
mapSetupError: (CameraSessionConfigurationError) -> Error) throws
-> (session: AVCaptureSession, output: AVCaptureMovieFileOutput)
{
try self.prepareMovieSession(
options: CameraMovieSessionOptions(
preferFrontCamera: preferFrontCamera,
deviceId: deviceId,
includeAudio: includeAudio,
durationMs: durationMs),
pickCamera: pickCamera,
cameraUnavailableError: cameraUnavailableError(),
mapSetupError: mapSetupError)
}
public static func prepareWarmMovieSession(
options: CameraMovieSessionOptions,
pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
@@ -119,28 +97,6 @@ public enum CameraCapturePipelineSupport {
return prepared
}
/// Keeps the flat overload source-compatible while the options form owns the implementation.
public static func prepareWarmMovieSession(
preferFrontCamera: Bool,
deviceId: String?,
includeAudio: Bool,
durationMs: Int,
pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
cameraUnavailableError: @autoclosure () -> Error,
mapSetupError: (CameraSessionConfigurationError) -> Error) async throws
-> (session: AVCaptureSession, output: AVCaptureMovieFileOutput)
{
try await self.prepareWarmMovieSession(
options: CameraMovieSessionOptions(
preferFrontCamera: preferFrontCamera,
deviceId: deviceId,
includeAudio: includeAudio,
durationMs: durationMs),
pickCamera: pickCamera,
cameraUnavailableError: cameraUnavailableError(),
mapSetupError: mapSetupError)
}
public static func withWarmMovieSession<T>(
options: CameraMovieSessionOptions,
pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
@@ -161,29 +117,6 @@ public enum CameraCapturePipelineSupport {
operation: { try await operation(prepared.output) })
}
/// Keeps the flat overload source-compatible while the options form owns the implementation.
public static func withWarmMovieSession<T>(
preferFrontCamera: Bool,
deviceId: String? = nil,
includeAudio: Bool,
durationMs: Int,
pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
cameraUnavailableError: @autoclosure () -> Error,
mapSetupError: (CameraSessionConfigurationError) -> Error,
operation: (AVCaptureMovieFileOutput) async throws -> T) async throws -> T
{
try await self.withWarmMovieSession(
options: CameraMovieSessionOptions(
preferFrontCamera: preferFrontCamera,
deviceId: deviceId,
includeAudio: includeAudio,
durationMs: durationMs),
pickCamera: pickCamera,
cameraUnavailableError: cameraUnavailableError(),
mapSetupError: mapSetupError,
operation: operation)
}
static func withCaptureSessionLifecycle<T>(
start: () -> Void,
stop: () -> Void,
@@ -25,8 +25,8 @@ public enum CameraSessionConfigurationError: LocalizedError {
}
}
public enum CameraSessionConfiguration {
public static func addCameraInput(session: AVCaptureSession, camera: AVCaptureDevice) throws {
enum CameraSessionConfiguration {
static func addCameraInput(session: AVCaptureSession, camera: AVCaptureDevice) throws {
let input = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(input) else {
throw CameraSessionConfigurationError.addCameraInputFailed
@@ -34,7 +34,7 @@ public enum CameraSessionConfiguration {
session.addInput(input)
}
public static func addPhotoOutput(session: AVCaptureSession) throws -> AVCapturePhotoOutput {
static func addPhotoOutput(session: AVCaptureSession) throws -> AVCapturePhotoOutput {
let output = AVCapturePhotoOutput()
guard session.canAddOutput(output) else {
throw CameraSessionConfigurationError.addPhotoOutputFailed
@@ -44,7 +44,7 @@ public enum CameraSessionConfiguration {
return output
}
public static func addMovieOutput(
static func addMovieOutput(
session: AVCaptureSession,
includeAudio: Bool,
durationMs: Int) throws -> AVCaptureMovieFileOutput
@@ -11,10 +11,6 @@ public enum OpenClawCanvasA2UICommand: String, Codable, Sendable {
public struct OpenClawCanvasA2UIPushParams: Codable, Sendable, Equatable {
public var messages: [AnyCodable]
public init(messages: [AnyCodable]) {
self.messages = messages
}
}
public struct OpenClawCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
@@ -13,13 +13,6 @@ public struct OpenClawCanvasPlacement: Codable, Sendable, Equatable {
public var y: Double?
public var width: Double?
public var height: Double?
public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
public struct OpenClawCanvasPresentParams: Codable, Sendable, Equatable {
@@ -67,10 +60,4 @@ public struct OpenClawCanvasSnapshotParams: Codable, Sendable, Equatable {
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawCanvasSnapshotFormat?
public init(maxWidth: Int? = nil, quality: Double? = nil, format: OpenClawCanvasSnapshotFormat? = nil) {
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
@@ -61,27 +61,6 @@ public enum GatewayDeviceAuthPayload {
].joined(separator: "|")
}
/// Keeps the flat overload source-compatible while `Fields` owns canonical serialization.
public static func buildConnectCompatibilityPayload(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int64,
token: String? = nil,
nonce: String) -> String
{
self.buildConnectCompatibilityPayload(fields: Fields(
deviceId: deviceId,
client: Client(id: clientId, mode: clientMode),
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: token,
nonce: nonce))
}
public static func buildV3(
fields: Fields,
platform: String?,
@@ -106,32 +85,6 @@ public enum GatewayDeviceAuthPayload {
].joined(separator: "|")
}
/// Keeps the flat overload source-compatible while `Fields` owns canonical serialization.
public static func buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int64,
token: String? = nil,
nonce: String,
platform: String? = nil,
deviceFamily: String? = nil) -> String
{
self.buildV3(
fields: Fields(
deviceId: deviceId,
client: Client(id: clientId, mode: clientMode),
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: token,
nonce: nonce),
platform: platform,
deviceFamily: deviceFamily)
}
static func normalizeMetadataField(_ value: String?) -> String {
guard let value else { return "" }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,7 +1,6 @@
#if Talk && canImport(ElevenLabsKit)
@_exported import ElevenLabsKit
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
public typealias ElevenLabsTTSRequest = ElevenLabsKit.ElevenLabsTTSRequest
public typealias ElevenLabsTTSClient = ElevenLabsKit.ElevenLabsTTSClient
public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation
@@ -681,17 +681,6 @@ public actor GatewayNodeSession {
}
}
public func send(method: String, paramsJSON: String?) async throws {
guard let channel else {
throw NSError(domain: "Gateway", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let params = try decodeParamsJSON(paramsJSON)
try await channel.send(method: method, params: params)
}
public func request(
method: String,
paramsJSON: String?,
@@ -1086,14 +1075,17 @@ extension GatewayNodeSession {
}
#if DEBUG
// periphery:ignore - package tests observe admission rollover without exposing mutable state.
func _test_admissionGeneration() -> UInt64 {
self.admissionGeneration
}
// periphery:ignore - package tests drive the private connection callback deterministically.
func _test_notifyConnectedIfNeeded(admissionGeneration: UInt64) async {
await self.notifyConnectedIfNeeded(admissionGeneration: admissionGeneration)
}
// periphery:ignore - package tests inject gateway pushes without a live socket.
func _test_handlePush(_ push: GatewayPush, socketGeneration: UInt64) async {
await self.handlePush(
push,
@@ -1101,6 +1093,7 @@ extension GatewayNodeSession {
socketGeneration: socketGeneration)
}
// periphery:ignore - package tests inject socket retirement without a live channel.
func _test_handleChannelDisconnected(_ reason: String, socketGeneration: UInt64) async {
await self.handleChannelDisconnected(
reason,
@@ -1108,6 +1101,7 @@ extension GatewayNodeSession {
socketGeneration: socketGeneration)
}
// periphery:ignore - package tests verify event stream filtering without a live gateway.
func _test_broadcastServerEvent(_ event: EventFrame) {
self.broadcastServerEvent(event)
}
@@ -1247,6 +1241,7 @@ extension GatewayNodeSession {
}
#if DEBUG
// periphery:ignore - package tests exercise receipt dedupe around the private invoke path.
func invokeComputerWithReceiptForTesting(
requestId: String,
paramsJSON: String,
@@ -1275,6 +1270,7 @@ extension GatewayNodeSession {
onInvoke: onInvoke)
}
// periphery:ignore - package tests assert receipt joining without exposing the receipt store.
func computerReceiptJoinCountForTesting(
idempotencyKey: String,
receiptScope: String) -> Int
@@ -9,12 +9,4 @@ public enum GatewayPayloadDecoding {
let data = try JSONEncoder().encode(payload)
return try JSONDecoder().decode(T.self, from: data)
}
public static func decodeIfPresent<T: Decodable>(
_ payload: AnyCodable?,
as _: T.Type = T.self) throws -> T?
{
guard let payload else { return nil }
return try self.decode(payload, as: T.self)
}
}
@@ -72,11 +72,11 @@ public struct GatewayTLSValidationError: LocalizedError, Sendable {
}
}
public protocol GatewayTLSFailureProviding: AnyObject {
protocol GatewayTLSFailureProviding: AnyObject {
func consumeLastTLSFailure() -> GatewayTLSValidationFailure?
}
public protocol GatewayDeviceTokenRetryTrustProviding: AnyObject {
protocol GatewayDeviceTokenRetryTrustProviding: AnyObject {
var allowsDeviceTokenRetryAuth: Bool { get }
}
@@ -3,13 +3,13 @@ import Foundation
import ImageIO
import UniformTypeIdentifiers
public enum JPEGTranscodeError: LocalizedError, Sendable {
enum JPEGTranscodeError: LocalizedError, Sendable {
case decodeFailed
case propertiesMissing
case encodeFailed
case sizeLimitExceeded(maxBytes: Int, actualBytes: Int)
public var errorDescription: String? {
var errorDescription: String? {
switch self {
case .decodeFailed:
"Failed to decode image data"
@@ -23,8 +23,8 @@ public enum JPEGTranscodeError: LocalizedError, Sendable {
}
}
public struct JPEGTranscoder: Sendable {
public static func clampQuality(_ quality: Double) -> Double {
struct JPEGTranscoder: Sendable {
static func clampQuality(_ quality: Double) -> Double {
min(1.0, max(0.05, quality))
}
@@ -32,7 +32,7 @@ public struct JPEGTranscoder: Sendable {
///
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
/// relied on).
public static func transcodeToJPEG(
static func transcodeToJPEG(
imageData: Data,
maxWidthPx: Int?,
quality: Double,
@@ -51,7 +51,7 @@ public struct JPEGTranscoder: Sendable {
/// When `maxLongEdgePx` is provided it takes precedence over `maxWidthPx`.
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
/// relied on).
public static func transcodeToJPEG(
static func transcodeToJPEG(
imageData: Data,
maxWidthPx: Int? = nil,
maxLongEdgePx: Int?,
@@ -25,12 +25,6 @@ extension LocationServiceCommon {
public func accuracyAuthorization() -> CLAccuracyAuthorization {
LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
}
public func requestLocationOnce() async throws -> CLLocation {
try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
self.locationRequestContinuation = continuation
}
}
}
extension ConcurrentLocationServiceCommon {
@@ -61,8 +55,8 @@ extension ConcurrentLocationServiceCommon {
}
}
public enum LocationServiceSupport {
public static func accuracyAuthorization(manager: CLLocationManager) -> CLAccuracyAuthorization {
enum LocationServiceSupport {
static func accuracyAuthorization(manager: CLLocationManager) -> CLAccuracyAuthorization {
if #available(iOS 14.0, macOS 11.0, *) {
return manager.accuracyAuthorization
}
@@ -70,7 +64,7 @@ public enum LocationServiceSupport {
}
@MainActor
public static func requestLocation(
static func requestLocation(
manager: CLLocationManager,
setContinuation: @escaping (CheckedContinuation<CLLocation, Error>) -> Void) async throws -> CLLocation
{
@@ -10,25 +10,6 @@ public enum OpenClawScreenSnapshotFormat: String, Codable, Sendable {
case png
}
public struct OpenClawScreenSnapshotParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawScreenSnapshotFormat?
public init(
screenIndex: Int? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: OpenClawScreenSnapshotFormat? = nil)
{
self.screenIndex = screenIndex
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?
@@ -12,15 +12,11 @@ public struct OpenClawSessionsCompactResponse: Decodable, Sendable {
}
}
public struct OpenClawSessionsCompactError: Error, LocalizedError, Sendable {
public let reason: String?
struct OpenClawSessionsCompactError: Error, LocalizedError, Sendable {
let reason: String?
public var errorDescription: String? {
var errorDescription: String? {
let detail = self.reason?.trimmingCharacters(in: .whitespacesAndNewlines)
return detail?.isEmpty == false ? detail : "Session compaction failed"
}
public init(reason: String?) {
self.reason = reason
}
}
@@ -24,103 +24,6 @@ public enum OpenClawNotificationDelivery: String, Codable, Sendable {
case auto
}
public struct OpenClawSystemRunApprovalFileOperand: Codable, Sendable, Equatable {
public var argvIndex: Int
public var path: String
public var sha256: String
public init(argvIndex: Int, path: String, sha256: String) {
self.argvIndex = argvIndex
self.path = path
self.sha256 = sha256
}
}
public struct OpenClawSystemRunApprovalPlan: Codable, Sendable, Equatable {
public var argv: [String]
public var cwd: String?
public var commandText: String
public var commandPreview: String?
public var agentId: String?
public var sessionKey: String?
public var policySnapshot: OpenClawSystemRunApprovalPolicySnapshot?
public var mutableFileOperand: OpenClawSystemRunApprovalFileOperand?
public init(
argv: [String],
cwd: String?,
commandText: String,
commandPreview: String? = nil,
agentId: String?,
sessionKey: String?,
policySnapshot: OpenClawSystemRunApprovalPolicySnapshot? = nil,
mutableFileOperand: OpenClawSystemRunApprovalFileOperand? = nil)
{
self.argv = argv
self.cwd = cwd
self.commandText = commandText
self.commandPreview = commandPreview
self.agentId = agentId
self.sessionKey = sessionKey
self.policySnapshot = policySnapshot
self.mutableFileOperand = mutableFileOperand
}
}
public struct OpenClawSystemRunParams: Codable, Sendable, Equatable {
public var command: [String]
public var rawCommand: String?
public var cwd: String?
public var env: [String: String]?
public var timeoutMs: Int?
public var needsScreenRecording: Bool?
public var agentId: String?
public var sessionKey: String?
public var runId: String?
public var systemRunPlan: OpenClawSystemRunApprovalPlan?
public var approved: Bool?
public var approvalDecision: String?
public var approvalSource: String?
public init(
command: [String],
rawCommand: String? = nil,
cwd: String? = nil,
env: [String: String]? = nil,
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil,
runId: String? = nil,
systemRunPlan: OpenClawSystemRunApprovalPlan? = nil,
approved: Bool? = nil,
approvalDecision: String? = nil,
approvalSource: String? = nil)
{
self.command = command
self.rawCommand = rawCommand
self.cwd = cwd
self.env = env
self.timeoutMs = timeoutMs
self.needsScreenRecording = needsScreenRecording
self.agentId = agentId
self.sessionKey = sessionKey
self.runId = runId
self.systemRunPlan = systemRunPlan
self.approved = approved
self.approvalDecision = approvalDecision
self.approvalSource = approvalSource
}
}
public struct OpenClawSystemWhichParams: Codable, Sendable, Equatable {
public var bins: [String]
public init(bins: [String]) {
self.bins = bins
}
}
public struct OpenClawSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String
public var body: String
@@ -181,16 +181,16 @@ public final class PCMPlaybackEnvelope {
/// via its built-in metering at ~30 Hz. Detach clears the level to nil so the
/// consumer can distinguish "silent" from "not playing".
@MainActor
public final class AudioPlayerLevelMeter {
final class AudioPlayerLevelMeter {
private let onLevel: @MainActor (Double?) -> Void
private var pollTask: Task<Void, Never>?
private weak var player: AVAudioPlayer?
public init(onLevel: @escaping @MainActor (Double?) -> Void) {
init(onLevel: @escaping @MainActor (Double?) -> Void) {
self.onLevel = onLevel
}
public func attach(_ player: AVAudioPlayer) {
func attach(_ player: AVAudioPlayer) {
self.detach()
player.isMeteringEnabled = true
self.player = player
@@ -204,7 +204,7 @@ public final class AudioPlayerLevelMeter {
}
}
public func detach() {
func detach() {
self.pollTask?.cancel()
self.pollTask = nil
self.player = nil
@@ -16,10 +16,6 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
private var currentToken = UUID()
private var watchdog: Task<Void, Never>?
public var isSpeaking: Bool {
self.synth.isSpeaking
}
override private init() {
super.init()
self.synth.delegate = self
@@ -14,13 +14,6 @@ public struct ToolDisplaySummary: Sendable, Equatable {
if let detail, !detail.isEmpty { parts.append(detail) }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
public var summaryLine: String {
if let detailLine {
return "\(self.emoji) \(self.label): \(detailLine)"
}
return "\(self.emoji) \(self.label)"
}
}
public enum ToolDisplayRegistry {
@@ -1,4 +1,5 @@
extension WakeParams {
// periphery:ignore - Shipped before sessionKey; remove only at a breaking protocol API window.
public init(
mode: AnyCodable,
text: String)
@@ -1,10 +1,10 @@
import OpenClawKit
import CoreGraphics
import ImageIO
import Testing
import UniformTypeIdentifiers
@testable import OpenClawKit
@Suite struct JPEGTranscoderTests {
struct JPEGTranscoderTests {
private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data {
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
@@ -54,7 +54,7 @@ import UniformTypeIdentifiers
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in
return try data.withUnsafeMutableBytes { rawBuffer -> Data in
guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw NSError(domain: "JPEGTranscoderTests", code: 6)
}
@@ -90,11 +90,9 @@ import UniformTypeIdentifiers
}
return encoded as Data
}
return out
}
@Test func downscalesToMaxWidthPx() throws {
@Test func `downscales to max width px`() throws {
let input = try makeSolidJPEG(width: 2000, height: 1000)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1600)
@@ -102,14 +100,14 @@ import UniformTypeIdentifiers
#expect(out.data.count > 0)
}
@Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws {
@Test func `does not upscale when smaller than max width px`() throws {
let input = try makeSolidJPEG(width: 800, height: 600)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 800)
#expect(out.heightPx == 600)
}
@Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws {
@Test func `normalizes orientation and uses oriented width for max width px`() throws {
// Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000.
let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
@@ -117,7 +115,7 @@ import UniformTypeIdentifiers
#expect(out.heightPx == 2000)
}
@Test func respectsMaxBytes() throws {
@Test func `respects max bytes`() throws {
let input = try makeNoiseJPEG(width: 1600, height: 1200)
let out = try JPEGTranscoder.transcodeToJPEG(
imageData: input,
@@ -127,7 +125,7 @@ import UniformTypeIdentifiers
#expect(out.data.count <= 180_000)
}
@Test func explicitlyFailsWhenSizeLimitCannotBeMet() throws {
@Test func `explicitly fails when size limit cannot be met`() throws {
let input = try makeSolidJPEG(width: 800, height: 600)
do {
@@ -137,7 +135,7 @@ import UniformTypeIdentifiers
quality: 0.9,
maxBytes: 1)
Issue.record("Expected a size-limit error")
} catch JPEGTranscodeError.sizeLimitExceeded(let maxBytes, let actualBytes) {
} catch let JPEGTranscodeError.sizeLimitExceeded(maxBytes, actualBytes) {
#expect(maxBytes == 1)
#expect(actualBytes > maxBytes)
} catch {
@@ -1,16 +1,16 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClawKit
struct SessionMutationResponsesTests {
@Test
func compactResponseAcceptsSuccess() throws {
func `compact response accepts success`() throws {
try OpenClawSessionsCompactResponse.requireSuccess(
from: Data(#"{"ok":true,"key":"agent:main:main","compacted":true}"#.utf8))
}
@Test
func compactResponseSurfacesGatewayFailureReason() {
func `compact response surfaces gateway failure reason`() {
let data = Data(
#"{"ok":false,"key":"agent:main:main","compacted":false,"reason":"turn failed"}"#.utf8)
do {
@@ -1,8 +1,8 @@
import AVFoundation
import Foundation
import OpenClawChatUI
import OpenClawKit
import Testing
@testable import OpenClawChatUI
@testable import OpenClawKit
struct TalkWaveformMathTests {
@Test
+2
View File
@@ -52,6 +52,8 @@ dispatch.
| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch |
Standalone Periphery workflows enforce zero dead-code findings for the iOS and macOS apps. The shared OpenClawKit workflow scans both consumers in parallel and reports a declaration only when Periphery emits the same Swift USR from both builds. Its generated `OpenClawProtocol/GatewayModels.swift` schema contract is retained as generator-owned code rather than treated as app-local dead code.
## Fail-fast order
1. `runner-admission` waits only for canonical `main` pushes; a newer push cancels the run before Blacksmith registration.
+30
View File
@@ -0,0 +1,30 @@
export type PeripheryFinding = {
ids: string[];
kind: string;
location: string;
name: string;
hints?: string[];
[key: string]: unknown;
};
export type PeripheryIntersectionOptions = {
iosResults: string;
iosStatus: string;
macosResults: string;
macosStatus: string;
output: string;
};
export function parseArgs(args: string[]): PeripheryIntersectionOptions;
export function validateFindings(value: unknown, label: string): PeripheryFinding[];
export function intersectFindings(iosFindings: unknown, macosFindings: unknown): PeripheryFinding[];
export function parseRepoLocation(location: string): {
column: string;
file: string;
line: string;
};
export function escapeCommandData(value: unknown): string;
export function escapeCommandProperty(value: unknown): string;
export function formatAnnotation(finding: PeripheryFinding): string;
export function buildSummary(findings: PeripheryFinding[]): string;
export function run(args: string[], env?: NodeJS.ProcessEnv): number;
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const SHARED_LOCATION_PREFIX = "../shared/OpenClawKit/Sources/";
function requireValue(args, index, option) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${option} requires a value`);
}
return value;
}
export function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const option = args[index];
switch (option) {
case "--ios-results":
options.iosResults = requireValue(args, index, option);
index += 1;
break;
case "--ios-status":
options.iosStatus = requireValue(args, index, option);
index += 1;
break;
case "--macos-results":
options.macosResults = requireValue(args, index, option);
index += 1;
break;
case "--macos-status":
options.macosStatus = requireValue(args, index, option);
index += 1;
break;
case "--output":
options.output = requireValue(args, index, option);
index += 1;
break;
default:
throw new Error(`unknown option: ${option}`);
}
}
for (const key of ["iosResults", "iosStatus", "macosResults", "macosStatus", "output"]) {
if (!options[key]) {
throw new Error(`missing required option: ${key}`);
}
}
return options;
}
export function validateFindings(value, label) {
if (!Array.isArray(value)) {
throw new Error(`${label} results must be a JSON array`);
}
return value.map((finding, index) => {
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
throw new Error(`${label} finding ${index} must be an object`);
}
if (
!Array.isArray(finding.ids) ||
finding.ids.length === 0 ||
finding.ids.some((id) => typeof id !== "string" || id.length === 0)
) {
throw new Error(`${label} finding ${index} has no usable Swift USR`);
}
if (
typeof finding.location !== "string" ||
!finding.location.startsWith(SHARED_LOCATION_PREFIX)
) {
throw new Error(`${label} finding ${index} is outside shared OpenClawKit sources`);
}
if (typeof finding.kind !== "string" || typeof finding.name !== "string") {
throw new Error(`${label} finding ${index} is missing its kind or name`);
}
return finding;
});
}
export function intersectFindings(iosFindings, macosFindings) {
const ios = validateFindings(iosFindings, "iOS");
const macos = validateFindings(macosFindings, "macOS");
const macosIds = new Set(macos.flatMap((finding) => finding.ids));
return ios
.filter((finding) => finding.ids.some((id) => macosIds.has(id)))
.toSorted((left, right) =>
[left.location, left.kind, left.name]
.join("\0")
.localeCompare([right.location, right.kind, right.name].join("\0")),
);
}
export function parseRepoLocation(location) {
const match = /^(.*):(\d+):(\d+)$/.exec(location);
if (!match || !match[1].startsWith("../shared/")) {
throw new Error(`invalid shared Periphery location: ${location}`);
}
return {
column: match[3],
file: `apps/shared/${match[1].slice("../shared/".length)}`,
line: match[2],
};
}
export function escapeCommandData(value) {
return String(value ?? "")
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A");
}
export function escapeCommandProperty(value) {
return escapeCommandData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
}
export function formatAnnotation(finding) {
const location = parseRepoLocation(finding.location);
const title = `${finding.kind || "Unused code"} ${finding.name}`.trim();
return `::error file=${escapeCommandProperty(location.file)},line=${location.line},col=${location.column},title=Dead shared Swift code::${escapeCommandData(title)}`;
}
export function buildSummary(findings) {
if (findings.length === 0) {
return [
"### Shared OpenClawKit Periphery",
"",
"No declarations were reported dead by both the iOS and macOS consumer scans.",
].join("\n");
}
return [
"### Shared OpenClawKit Periphery",
"",
`Found ${findings.length} shared Swift ${findings.length === 1 ? "declaration" : "declarations"} reported dead by both consumer scans.`,
"",
"The gate matches Periphery's Swift USRs, not declaration names.",
].join("\n");
}
function readStatus(file, label) {
const raw = fs.readFileSync(file, "utf8").trim();
if (!/^\d+$/.test(raw)) {
throw new Error(`${label} Periphery status is invalid`);
}
const status = Number(raw);
if (status !== 0) {
throw new Error(`${label} Periphery scan exited with status ${status}`);
}
}
function readFindings(file, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
throw new Error(`${label} Periphery results are not valid JSON`, { cause: error });
}
return validateFindings(parsed, label);
}
export function run(args, env = process.env) {
const options = parseArgs(args);
readStatus(options.iosStatus, "iOS");
readStatus(options.macosStatus, "macOS");
const findings = intersectFindings(
readFindings(options.iosResults, "iOS"),
readFindings(options.macosResults, "macOS"),
);
fs.mkdirSync(path.dirname(options.output), { recursive: true });
fs.writeFileSync(options.output, `${JSON.stringify(findings, null, 2)}\n`);
for (const finding of findings) {
console.log(formatAnnotation(finding));
}
const summary = buildSummary(findings);
if (env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
} else {
console.log(summary);
}
return findings.length === 0 ? 0 : 1;
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
process.exitCode = run(process.argv.slice(2));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`::error title=Shared Periphery intersection failed::${escapeCommandData(message)}`,
);
process.exitCode = 2;
}
}
+6
View File
@@ -503,6 +503,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
["test/scripts/ios-periphery-comment-workflow.test.ts"],
],
[".github/workflows/ios-periphery.yml", ["test/scripts/ios-periphery-comment-workflow.test.ts"]],
[
".github/workflows/shared-openclawkit-periphery.yml",
["test/scripts/periphery-intersection.test.ts"],
],
[
".github/workflows/live-media-runner-image.yml",
["test/scripts/package-acceptance-workflow.test.ts"],
@@ -789,6 +793,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
],
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
["scripts/periphery-intersection.mjs", ["test/scripts/periphery-intersection.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/apple-app-i18n.ts", ["test/scripts/apple-app-i18n.test.ts"]],
@@ -2060,6 +2065,7 @@ const TOOLING_DECLARATION_SOURCE_MIRRORS = [
["scripts/ci-changed-scope.d.mts", "scripts/ci-changed-scope.mjs"],
["scripts/copy-bundled-plugin-metadata.d.mts", "scripts/copy-bundled-plugin-metadata.mjs"],
["scripts/docs-link-audit.d.mts", "scripts/docs-link-audit.mjs"],
["scripts/periphery-intersection.d.mts", "scripts/periphery-intersection.mjs"],
[
"scripts/lib/bundled-plugin-build-entries.d.mts",
"scripts/lib/bundled-plugin-build-entries.mjs",
+157
View File
@@ -0,0 +1,157 @@
import { readFileSync } from "node:fs";
import { compileFunction } from "node:vm";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import {
buildSummary,
formatAnnotation,
intersectFindings,
parseRepoLocation,
validateFindings,
} from "../../scripts/periphery-intersection.mjs";
const WORKFLOW_PATH = ".github/workflows/shared-openclawkit-periphery.yml";
type WorkflowStep = {
id?: string;
name?: string;
run?: string;
with?: { name?: string; path?: string; script?: string };
};
type Workflow = {
jobs?: Record<
string,
{
name?: string;
needs?: string[] | string;
steps?: WorkflowStep[];
}
>;
};
function finding(overrides: Record<string, unknown> = {}) {
return {
ids: ["s:11OpenClawKit7ExampleV"],
kind: "struct",
location: "../shared/OpenClawKit/Sources/OpenClawKit/Example.swift:12:8",
name: "Example",
...overrides,
};
}
describe("Periphery intersection", () => {
it("matches exact Swift USRs instead of declaration names", () => {
const sameNameDifferentUsr = finding({ ids: ["s:11OpenClawKit7ExampleV_other"] });
expect(intersectFindings([finding()], [sameNameDifferentUsr])).toEqual([]);
expect(intersectFindings([finding()], [finding()])).toEqual([finding()]);
});
it("matches any USR emitted for a declaration compiled into multiple iOS modules", () => {
const ios = finding({ ids: ["s:16OpenClawWatchApp7ExampleV", "s:11OpenClawKit7ExampleV"] });
expect(intersectFindings([ios], [finding()])).toEqual([ios]);
});
it("sorts findings deterministically", () => {
const later = finding({
ids: ["s:11OpenClawKit5LaterV"],
location: "../shared/OpenClawKit/Sources/OpenClawKit/Later.swift:2:1",
name: "Later",
});
expect(intersectFindings([later, finding()], [finding(), later])).toEqual([finding(), later]);
});
it("fails closed when a finding has no USR", () => {
expect(() => validateFindings([finding({ ids: [] })], "iOS")).toThrow(
"iOS finding 0 has no usable Swift USR",
);
});
it("rejects findings outside shared OpenClawKit", () => {
expect(() =>
validateFindings([finding({ location: "Sources/App.swift:1:1" })], "macOS"),
).toThrow("macOS finding 0 is outside shared OpenClawKit sources");
});
it("maps relative scan locations to repository annotations", () => {
expect(parseRepoLocation(finding().location)).toEqual({
column: "8",
file: "apps/shared/OpenClawKit/Sources/OpenClawKit/Example.swift",
line: "12",
});
expect(formatAnnotation(finding())).toBe(
"::error file=apps/shared/OpenClawKit/Sources/OpenClawKit/Example.swift,line=12,col=8,title=Dead shared Swift code::struct Example",
);
});
it("reports the zero-findings policy in the summary", () => {
expect(buildSummary([])).toContain("No declarations were reported dead by both");
expect(buildSummary([finding()])).toContain("Found 1 shared Swift declaration");
});
});
describe("shared OpenClawKit Periphery workflow", () => {
const workflow = parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow;
it("runs two consumer scans and a same-run intersection", () => {
expect(workflow.jobs?.["scan-ios"]?.name).toBe("Scan shared kit from iOS");
expect(workflow.jobs?.["scan-macos"]?.name).toBe("Scan shared kit from macOS");
expect(workflow.jobs?.intersect?.needs).toEqual(["scope", "scan-ios", "scan-macos"]);
const iosUpload = workflow.jobs?.["scan-ios"]?.steps?.find(
(step) => step.name === "Upload iOS consumer report",
);
const macosUpload = workflow.jobs?.["scan-macos"]?.steps?.find(
(step) => step.name === "Upload macOS consumer report",
);
expect(iosUpload?.with?.name).toContain("shared-periphery-ios-");
expect(macosUpload?.with?.name).toContain("shared-periphery-macos-");
});
it("retains the generated protocol contract and leaves findings for the intersection", () => {
for (const jobName of ["scan-ios", "scan-macos"]) {
const scan = workflow.jobs?.[jobName]?.steps?.find((step) => step.name === "Scan shared kit");
expect(scan?.run).toContain("--report-include '../shared/OpenClawKit/Sources/**'");
expect(scan?.run).toContain(
"--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift'",
);
expect(scan?.run).not.toContain("--strict");
}
const macosScan = workflow.jobs?.["scan-macos"]?.steps?.find(
(step) => step.name === "Scan shared kit",
);
expect(macosScan?.run).not.toContain("--exclude-tests");
});
it("scopes native consumer, shared package, and workflow changes", async () => {
const script = workflow.jobs?.scope?.steps?.find((step) => step.id === "scope")?.with?.script;
expect(script).toBeTruthy();
const execute = compileFunction(`return (async () => {\n${script}\n})();`, [
"context",
"core",
"github",
]) as (context: unknown, core: unknown, github: unknown) => Promise<void>;
for (const filename of [
"apps/ios/Sources/App.swift",
"apps/macos/Sources/OpenClaw/App.swift",
"apps/shared/OpenClawKit/Sources/OpenClawKit/Example.swift",
WORKFLOW_PATH,
]) {
const outputs = new Map<string, string>();
await execute(
{
eventName: "pull_request",
payload: { pull_request: { draft: false, number: 1 } },
repo: {},
},
{ setOutput: (name: string, value: string) => outputs.set(name, value) },
{
paginate: async () => [{ filename }],
rest: { pulls: { listFiles() {} } },
},
);
expect(outputs.get("should-scan"), filename).toBe("true");
}
});
});