M0a fixup: unignore local Packages/, add missing files, make Linux CI pass

The initial M0a commit was incomplete: .gitignore's `Packages/` rule
(meant for the legacy pre-Xcode-14 SwiftPM checkout dir) silently
swallowed three new files that SHOULD have been committed:

  - scarf/Packages/ScarfCore/Package.swift
  - scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift
  - scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift

The 12 moved models slipped through because `git mv` preserves tracking
across gitignored destinations, but new files in that tree did not.

Fix: add `!scarf/Packages/` override so our local SPM package is always
tracked; keep the top-level `Packages/` ignore for the historical case.

Also verified M0a builds + tests green on Linux via
`docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
To make that work, two small, Apple-platform-preserving guards:

  - `sqliteTransient` in HermesConstants.swift wrapped in
    `#if canImport(SQLite3)` — SQLite3 is not a system module on Linux
    swift-corelibs-foundation. Apple builds compile unchanged.
  - `ToolKind.displayName` and `MCPTransport.displayName` wrapped in
    `#if canImport(Darwin)` — `LocalizedStringResource` is Apple-only.
    Apple builds compile unchanged.

Additionally:

  - Package.swift pinned to Swift 5 language mode, matching the Mac app's
    `SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and
    `ACPToolCallEvent.rawInput`) claim `Sendable` while carrying
    `[String: Any]` — strict Swift 6 rejects that. Comment in Package.swift
    flags this for a future typed-payloads cleanup + bump to `.v6`.
  - ScarfCoreSmokeTests now contains 16 tests exercising every M0a
    `public init` so parameter drift fails CI instead of a reviewer.
  - IOS_PORT_PLAN.md updated with what actually shipped, the Linux-CI
    guards + patterns future phases should reuse, and the Sendable
    follow-up flagged under "Rules next phases can rely on".

Test results (Linux, Swift 6.0.3):
  Suite M0aPublicInitTests: 15 tests passed
  Suite ScarfCoreSmokeTests: 1 test passed
  Total: 16 / 16 passed

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 21:52:26 +00:00
parent bb5045c10f
commit f6f31cabe4
7 changed files with 451 additions and 17 deletions
+51
View File
@@ -0,0 +1,51 @@
// swift-tools-version: 6.0
// Platform-neutral core for the Scarf app family (macOS and iOS).
//
// `ScarfCore` holds types that do not depend on AppKit, UIKit, or any
// platform-specific system service. The macOS and iOS app targets each link
// this package and provide their own platform shells (Sparkle + SwiftTerm on
// macOS; Citadel-based SSH transport on iOS).
//
// Minimums are chosen to match the Mac app (macOS 14.6) and the locked
// v1 iOS decision (iOS 18). Raising iOS later is free; lowering is not
// the ViewModels on `@Observable` / `NavigationStack` are iOS 17+ features
// and we standardize on iOS 18 for feature parity with the Mac codebase.
import PackageDescription
let package = Package(
name: "ScarfCore",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
.iOS(.v18),
],
products: [
.library(
name: "ScarfCore",
targets: ["ScarfCore"]
),
],
targets: [
.target(
name: "ScarfCore",
path: "Sources/ScarfCore",
swiftSettings: [
// Swift 5 language mode mirrors the Mac app target's
// `SWIFT_VERSION = 5.0` build setting. Moving to strict
// Swift 6 concurrency is a real refactor several types
// (`ACPEvent.availableCommands` carrying `[[String: Any]]`,
// `ACPToolCallEvent.rawInput: [String: Any]?`) claim
// `Sendable` without being strictly-Sendable. A follow-up
// phase will replace those with typed payloads, then this
// setting can bump to `.v6`.
.swiftLanguageMode(.v5),
]
),
.testTarget(
name: "ScarfCoreTests",
dependencies: ["ScarfCore"],
path: "Tests/ScarfCoreTests"
),
]
)
@@ -0,0 +1,35 @@
import Foundation
#if canImport(SQLite3)
import SQLite3
#endif
// MARK: - SQLite Constants
#if canImport(SQLite3)
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
///
/// Gated behind `canImport(SQLite3)` so this file compiles on Linux (where
/// SPM has no built-in `SQLite3` system module). Apple platforms the only
/// runtime targets that actually execute this code compile it unchanged.
public nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
#endif
// MARK: - Query Defaults
public enum QueryDefaults: Sendable {
public nonisolated static let sessionLimit = 100
public nonisolated static let messageSearchLimit = 50
public nonisolated static let toolCallLimit = 50
public nonisolated static let sessionPreviewLimit = 10
public nonisolated static let previewContentLength = 100
public nonisolated static let logLineLimit = 200
public nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
public enum FileSizeUnit: Sendable {
public nonisolated static let kilobyte = 1_024.0
public nonisolated static let megabyte = 1_048_576.0
}
@@ -6,12 +6,14 @@ public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiabl
public var id: String { rawValue }
#if canImport(Darwin)
public var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
#endif
}
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
@@ -125,6 +125,7 @@ public enum ToolKind: String, Sendable, CaseIterable {
case browser
case other
#if canImport(Darwin)
public var displayName: LocalizedStringResource {
switch self {
case .read: return "Read"
@@ -135,6 +136,7 @@ public enum ToolKind: String, Sendable, CaseIterable {
case .other: return "Other"
}
}
#endif
public var icon: String {
switch self {
@@ -0,0 +1,295 @@
import Testing
import Foundation
@testable import ScarfCore
/// Smoke test catches "does the package link?"-class regressions.
@Suite struct ScarfCoreSmokeTests {
@Test func packageLinks() {
// If this compiles and runs, ScarfCore loaded.
}
}
/// Exercises every `public init` generated in M0a. If any memberwise init's
/// parameter list drifted away from the stored properties (wrong order, wrong
/// type, missing field), the compiler fails here the whole point of these
/// tests is to give the Linux CI something to catch that before a reviewer
/// has to build on a Mac.
@Suite struct M0aPublicInitTests {
@Test func hermesSessionInitAndDerivations() {
let s = HermesSession(
id: "s1",
source: "cli",
userId: "u",
model: "gpt-4",
title: "Hello",
parentSessionId: nil,
startedAt: Date(timeIntervalSince1970: 0),
endedAt: Date(timeIntervalSince1970: 60),
endReason: nil,
messageCount: 3,
toolCallCount: 1,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
estimatedCostUSD: 0.01,
reasoningTokens: 50,
actualCostUSD: nil,
costStatus: nil,
billingProvider: nil
)
#expect(s.displayTitle == "Hello")
#expect(s.totalTokens == 350)
#expect(s.duration == 60)
#expect(s.isSubagent == false)
#expect(s.costIsActual == false)
#expect(s.displayCostUSD == 0.01)
// Subagent variant
let child = s.withTitle("Child")
#expect(child.displayTitle == "Child")
}
@Test func hermesMessageInitAndRolePredicates() {
let user = HermesMessage(
id: 1, sessionId: "s", role: "user", content: "hi",
toolCallId: nil, toolCalls: [], toolName: nil,
timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil
)
#expect(user.isUser && !user.isAssistant && !user.isToolResult)
let asst = HermesMessage(
id: 2, sessionId: "s", role: "assistant", content: "hello",
toolCallId: nil, toolCalls: [], toolName: nil,
timestamp: nil, tokenCount: nil, finishReason: nil,
reasoning: "thinking..."
)
#expect(asst.isAssistant && asst.hasReasoning)
}
@Test func hermesToolCallExplicitInit() {
let call = HermesToolCall(callId: "c1", functionName: "read_file", arguments: "{\"path\":\"/tmp\"}")
#expect(call.id == "c1")
#expect(call.toolKind == .read)
#expect(call.argumentsSummary == "/tmp")
}
@Test func hermesConfigEmptyAndMemberwise() {
// `.empty` exercises every nested init internally if any nested
// settings struct's init drifted, HermesConfig.empty would fail to
// compile. Importing and touching .empty proves the chain works.
let c = HermesConfig.empty
#expect(c.model == "unknown")
#expect(c.display.skin == "default")
#expect(c.terminal.cwd == ".")
#expect(c.browser.inactivityTimeout == 120)
#expect(c.security.redactSecrets == true)
#expect(c.humanDelay.mode == "off")
#expect(c.compression.enabled == true)
#expect(c.checkpoints.enabled == true)
#expect(c.logging.level == "INFO")
#expect(c.discord.requireMention == true)
#expect(c.telegram.reactions == false)
#expect(c.slack.replyToMode == "first")
#expect(c.matrix.autoThread == true)
#expect(c.mattermost.replyMode == "off")
#expect(c.whatsapp.unauthorizedDMBehavior == "pair")
#expect(c.homeAssistant.cooldownSeconds == 30)
#expect(c.auxiliary.vision.provider == "auto")
}
@Test func hermesCronJobCodableRoundTrip() throws {
let json = """
{
"id": "job1",
"name": "Daily Summary",
"prompt": "summarize yesterday",
"skills": ["email"],
"model": null,
"schedule": { "kind": "daily", "run_at": "09:00", "display": "Every day 9am", "expression": null },
"enabled": true,
"state": "scheduled",
"deliver": "discord:general:chat-chan",
"next_run_at": "2026-04-23T09:00:00Z",
"last_run_at": null,
"last_error": null,
"pre_run_script": null,
"delivery_failures": 0,
"last_delivery_error": null,
"timeout_type": "soft",
"timeout_seconds": 300,
"silent": false
}
"""
let job = try JSONDecoder().decode(HermesCronJob.self, from: Data(json.utf8))
#expect(job.id == "job1")
#expect(job.stateIcon == "clock")
#expect(job.deliveryDisplay == "Discord thread chat-chan in general")
#expect(job.schedule.kind == "daily")
#expect(job.silent == false)
// Re-encode and decode again to confirm encoder output is valid.
let encoded = try JSONEncoder().encode(job)
let roundTripped = try JSONDecoder().decode(HermesCronJob.self, from: encoded)
#expect(roundTripped.id == job.id)
}
@Test func hermesMCPServerInit() {
let server = HermesMCPServer(
name: "gh", transport: .stdio, command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil, auth: nil,
env: ["GITHUB_TOKEN": "x"], headers: [:],
timeout: 30, connectTimeout: 5, enabled: true,
toolsInclude: [], toolsExclude: [],
resourcesEnabled: true, promptsEnabled: true, hasOAuthToken: false
)
#expect(server.id == "gh")
#expect(server.summary == "npx -y @modelcontextprotocol/server-github")
let http = HermesMCPServer(
name: "linear", transport: .http, command: nil, args: [],
url: "https://mcp.linear.app/sse", auth: "oauth",
env: [:], headers: [:], timeout: nil, connectTimeout: nil,
enabled: true, toolsInclude: [], toolsExclude: [],
resourcesEnabled: true, promptsEnabled: true, hasOAuthToken: true
)
#expect(http.summary == "https://mcp.linear.app/sse")
}
@Test func mcpServerPresetGalleryReadable() {
#expect(!MCPServerPreset.gallery.isEmpty)
#expect(MCPServerPreset.gallery.contains { $0.id == "filesystem" })
// Every preset in the gallery should have a docsURL.
for p in MCPServerPreset.gallery {
#expect(!p.docsURL.isEmpty)
}
}
@Test func hermesPathSetDerivations() {
let local = HermesPathSet(home: "/Users/alan/.hermes", isRemote: false, binaryHint: nil)
#expect(local.stateDB == "/Users/alan/.hermes/state.db")
#expect(local.memoryMD == "/Users/alan/.hermes/memories/MEMORY.md")
#expect(local.userMD == "/Users/alan/.hermes/memories/USER.md")
#expect(local.projectsRegistry == "/Users/alan/.hermes/scarf/projects.json")
// hermesBinary on local looks up real fs we can only guarantee it
// returns one of the candidates (or the fallback).
#expect(HermesPathSet.hermesBinaryCandidates.contains(local.hermesBinary)
|| local.hermesBinary == HermesPathSet.hermesBinaryCandidates[0])
let remote = HermesPathSet(home: "~/.hermes", isRemote: true, binaryHint: "/usr/local/bin/hermes")
#expect(remote.hermesBinary == "/usr/local/bin/hermes")
let remoteNoHint = HermesPathSet(home: "~/.hermes", isRemote: true, binaryHint: nil)
#expect(remoteNoHint.hermesBinary == "hermes")
}
@Test func hermesSkillInit() {
let skill = HermesSkill(
id: "email.send", name: "send email", category: "Email",
path: "/a/b", files: ["send.py"], requiredConfig: ["SMTP_HOST"]
)
let cat = HermesSkillCategory(id: "email", name: "Email", skills: [skill])
#expect(cat.skills.first?.id == "email.send")
}
@Test func hermesSlashCommandInit() {
let acp = HermesSlashCommand(name: "/clear", description: "Clear context", argumentHint: nil, source: .acp)
let quick = HermesSlashCommand(name: "/brief", description: "Summary", argumentHint: "topic", source: .quickCommand)
#expect(acp.source == .acp)
#expect(quick.source == .quickCommand)
#expect(acp.id == "/clear")
}
@Test func hermesToolInitAndKnownPlatformIcon() {
let ts = HermesToolset(name: "browser", description: "Web", icon: "safari", enabled: true)
#expect(ts.id == "browser")
let plat = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
#expect(plat.id == "cli")
// KnownPlatforms lookup guards that the icon-map path didn't break.
#expect(KnownPlatforms.icon(for: "discord") == "bubble.left.and.bubble.right")
#expect(KnownPlatforms.icon(for: "unknown") == "bubble.left")
#expect(KnownPlatforms.all.count >= 13)
}
@Test func acpRequestAndEvents() throws {
let req = ACPRequest(id: 1, method: "session/new", params: ["foo": AnyCodable("bar")])
let data = try JSONEncoder().encode(req)
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(decoded?["method"] as? String == "session/new")
#expect(decoded?["jsonrpc"] as? String == "2.0")
let evt = ACPToolCallEvent(
toolCallId: "t1", title: "read_file: /tmp", kind: "read",
status: "pending", content: "", rawInput: ["path": "/tmp"]
)
#expect(evt.functionName == "read_file")
#expect(evt.argumentsSummary == "/tmp")
let upd = ACPToolCallUpdateEvent(
toolCallId: "t1", kind: "read", status: "completed",
content: "hello", rawOutput: nil
)
#expect(upd.status == "completed")
let perm = ACPPermissionRequestEvent(
toolCallTitle: "write_file: /etc/passwd", toolCallKind: "edit",
options: [(optionId: "allow_once", name: "Allow once")]
)
#expect(perm.options.first?.optionId == "allow_once")
let prompt = ACPPromptResult(
stopReason: "end_turn", inputTokens: 100, outputTokens: 50,
thoughtTokens: 20, cachedReadTokens: 10
)
#expect(prompt.stopReason == "end_turn")
}
@Test func projectDashboardInitChain() {
let point = ChartDataPoint(x: "Mon", y: 3)
let series = ChartSeries(name: "Calls", color: "blue", data: [point])
let item = ListItem(text: "task 1", status: "done")
let widget = DashboardWidget(
type: "chart", title: "Calls per day",
value: .number(12), icon: nil, color: nil, subtitle: nil,
label: nil, content: nil, format: nil,
columns: nil, rows: nil,
chartType: "line", xLabel: "day", yLabel: "count",
series: [series], items: [item],
url: nil, height: nil
)
#expect(widget.id == "chart:Calls per day")
#expect(widget.value?.displayString == "12")
let theme = DashboardTheme(accent: "blue")
let section = DashboardSection(title: "Main", columns: 2, widgets: [widget])
let dash = ProjectDashboard(
version: 1, title: "Demo", description: nil,
updatedAt: "2026-01-01", theme: theme, sections: [section]
)
#expect(dash.sections.first?.columnCount == 2)
let entry = ProjectEntry(name: "demo", path: "/a/b/demo")
#expect(entry.dashboardPath == "/a/b/demo/.scarf/dashboard.json")
let reg = ProjectRegistry(projects: [entry])
#expect(reg.projects.first?.id == "demo")
}
@Test func widgetValueCodable() throws {
let a = try JSONDecoder().decode(WidgetValue.self, from: Data("42".utf8))
#expect(a == .number(42))
#expect(a.displayString == "42")
let b = try JSONDecoder().decode(WidgetValue.self, from: Data("\"hi\"".utf8))
#expect(b == .string("hi"))
// Fraction formatting path
let c = WidgetValue.number(1.5)
#expect(c.displayString.contains("1.5") || c.displayString.contains("1,5"))
}
@Test func queryDefaultsAndFileSizeUnit() {
#expect(QueryDefaults.sessionLimit == 100)
#expect(FileSizeUnit.kilobyte == 1_024.0)
}
}
+61 -17
View File
@@ -160,25 +160,69 @@ the Mac app in a working state:
## Progress Log
### M0a — in progress
**Goal:** Create `Packages/ScarfCore` scaffolding and migrate the 13 leaf
Model files to it. `ServerContext.swift` stays in the Mac target (it
depends on Transport + HermesFileService and is not a leaf).
### M0a — shipped in PR #31
Expected artifacts when done:
- `Packages/ScarfCore/Package.swift` exists.
- 13 model files moved under `Sources/ScarfCore/Models/`.
- `HermesConstants.swift` split: portable parts (`sqliteTransient`,
`QueryDefaults`, `FileSizeUnit`) move to ScarfCore; the deprecated
`HermesPaths` enum stays behind (it references `ServerContext.local`).
- Every moved type annotated `public` with explicit `public init`.
- `scarf.xcodeproj/project.pbxproj` gains one `XCLocalSwiftPackageReference`
for `Packages/ScarfCore` and one `XCSwiftPackageProductDependency` on the
`scarf` target (and `scarfTests` + `scarfUITests` inherit via the target).
- 35 main-target files get `import ScarfCore` added.
- Mac app builds and runs identically to before.
**Shipped:**
(This section will be finalized when M0a is merged.)
- `Packages/ScarfCore/Package.swift` (Swift tools 6.0, targets macOS 14 +
iOS 18). **Language mode pinned at `.v5`** to match the Mac app's
`SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and
`ACPToolCallEvent.rawInput`) claim `Sendable` while carrying
`[String: Any]` payloads — strict Swift 6 rejects that. A future
cleanup phase should replace those with typed payloads and bump to
`.v6`.
- 13 leaf model files moved under `Sources/ScarfCore/Models/`.
- `HermesConstants.swift` split: `sqliteTransient` + `QueryDefaults` +
`FileSizeUnit` are in ScarfCore; the deprecated `HermesPaths` enum is
parked in the Mac target at `HermesPaths+Deprecated.swift`. Zero
callers in-tree — it can be deleted in M0b alongside `ServerContext`.
- Every moved type, member, and (where needed) nested `CodingKeys` is
`public`. Every struct got an explicit `public init(...)` — Swift's
synthesized memberwise init is `internal` and would have broken
cross-module construction. A throwaway Python generator did the
mechanical work; tests in `ScarfCoreTests` exercise every generated
init so parameter drift would fail CI, not a reviewer.
- `scarf.xcodeproj/project.pbxproj` gains one
`XCLocalSwiftPackageReference` for `Packages/ScarfCore` and links the
product into the `scarf` target.
- 49 main-target files (not 35 as originally estimated — many `View`
files only `import SwiftUI` without `Foundation`) got
`import ScarfCore`.
**Linux-CI compatibility additions (for `swift test` in containers):**
- `SQLite3` system module exists on macOS/iOS but not on Linux
swift-corelibs. `sqliteTransient` in `HermesConstants.swift` is
wrapped in `#if canImport(SQLite3)`. Apple platforms compile it
unchanged; Linux just doesn't see it (no one on Linux will execute
Hermes DB code anyway).
- `LocalizedStringResource` is an Apple-only Foundation type.
`ToolKind.displayName` (in `HermesMessage.swift`) and
`MCPTransport.displayName` (in `HermesMCPServer.swift`) are wrapped
in `#if canImport(Darwin)`. Apple platforms compile them unchanged;
Linux builds skip them.
**Test coverage (`ScarfCoreTests`):** 16 tests that construct every
moved type via its `public init`, verify computed properties, round-trip
Codable (`HermesCronJob`, `WidgetValue`), exercise nested config
`.empty` chains, and assert `KnownPlatforms` / `MCPServerPreset.gallery`
statics are readable. Run via `docker run --rm -v
$PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
**Rules next phases can rely on:**
- The `public init` pattern is now established for ScarfCore structs.
M0b+ should add explicit `public init(...)` to every new struct moved
into the package.
- `#if canImport(Darwin)` is the package's "Apple-only API" guard.
Prefer this over `os(iOS) || os(macOS) || ...` — it's shorter and
catches the same platforms.
- `#if canImport(SQLite3)` is the pattern for anything that needs
Apple's built-in SQLite. When HermesDataService moves in M0c, use
this same guard for the actual Swift-SQLite bindings.
- The Mac app still uses Swift 5 language mode. Do **not** add
`nonisolated` to new ScarfCore APIs pre-emptively; match the
surrounding conventions.
### M0b — pending
### M0c — pending