Move tool audit persistence into shared layers
This commit is contained in:
1 parent
487cfa5a35
commit
e4f00922ce
11 files changed
+440
-145
No files matched your search
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPersistence
|
||||
import Testing
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
struct ToolAuditRepositoryTests {
|
||||
@Test
|
||||
func inMemoryRepositoryPreservesAuditBehavior() throws {
|
||||
let repository = InMemoryToolAuditRepository()
|
||||
let recoveryRepository = InMemoryToolAuditRepository()
|
||||
|
||||
try exerciseStorageBehavior(repository: repository, freshRepository: { repository })
|
||||
try exerciseRecoveryBehavior(repository: recoveryRepository, freshRepository: { recoveryRepository })
|
||||
}
|
||||
|
||||
@Test
|
||||
func swiftDataRepositoryPersistsAuditBehavior() throws {
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
let repository = SwiftDataToolAuditRepository(context: ModelContext(container))
|
||||
|
||||
try exerciseStorageBehavior(
|
||||
repository: repository,
|
||||
freshRepository: { SwiftDataToolAuditRepository(context: ModelContext(container)) }
|
||||
)
|
||||
try exerciseRecoveryBehavior(
|
||||
repository: SwiftDataToolAuditRepository(context: ModelContext(container)),
|
||||
freshRepository: { SwiftDataToolAuditRepository(context: ModelContext(container)) }
|
||||
)
|
||||
}
|
||||
|
||||
private func exerciseStorageBehavior(
|
||||
repository: any ToolAuditRepository,
|
||||
freshRepository: () -> any ToolAuditRepository
|
||||
) throws {
|
||||
let oldest = call(summary: "oldest", startedAt: 10)
|
||||
let newest = call(summary: "newest", startedAt: 30)
|
||||
var middle = call(summary: "middle", startedAt: 20)
|
||||
|
||||
try repository.insert(oldest, limit: 2)
|
||||
try repository.insert(middle, limit: 2)
|
||||
try repository.insert(newest, limit: 2)
|
||||
|
||||
let reloaded = freshRepository()
|
||||
#expect(try reloaded.entries().map(\.id) == [newest.id, middle.id])
|
||||
#expect(try reloaded.value(id: oldest.id) == nil)
|
||||
|
||||
middle.summary = "updated"
|
||||
middle.status = .ok
|
||||
middle.finishedAt = Date(timeIntervalSince1970: 25)
|
||||
try reloaded.update(middle)
|
||||
|
||||
let storedMiddle = try freshRepository().value(id: middle.id)
|
||||
let updated = try #require(storedMiddle)
|
||||
#expect(updated == middle)
|
||||
|
||||
try reloaded.clear()
|
||||
#expect(try freshRepository().entries().isEmpty)
|
||||
}
|
||||
|
||||
private func exerciseRecoveryBehavior(
|
||||
repository: any ToolAuditRepository,
|
||||
freshRepository: () -> any ToolAuditRepository
|
||||
) throws {
|
||||
let running = call(summary: "running", status: .running, startedAt: 100)
|
||||
let okFinishedAt = Date(timeIntervalSince1970: 210)
|
||||
let errorFinishedAt = Date(timeIntervalSince1970: 220)
|
||||
let deniedFinishedAt = Date(timeIntervalSince1970: 230)
|
||||
let ok = call(summary: "ok", status: .ok, startedAt: 200, finishedAt: okFinishedAt)
|
||||
let error = call(summary: "error", status: .error, startedAt: 201, finishedAt: errorFinishedAt)
|
||||
let denied = call(summary: "denied", status: .denied, startedAt: 202, finishedAt: deniedFinishedAt)
|
||||
|
||||
for value in [running, ok, error, denied] {
|
||||
try repository.insert(value, limit: 10)
|
||||
}
|
||||
try repository.recoverInterruptedCalls()
|
||||
|
||||
let reloaded = freshRepository()
|
||||
let storedRunning = try reloaded.value(id: running.id)
|
||||
let recoveredRunning = try #require(storedRunning)
|
||||
#expect(recoveredRunning.status == .error)
|
||||
#expect(recoveredRunning.finishedAt != nil)
|
||||
|
||||
let storedOK = try reloaded.value(id: ok.id)
|
||||
let unchangedOK = try #require(storedOK)
|
||||
#expect(unchangedOK == ok)
|
||||
let storedError = try reloaded.value(id: error.id)
|
||||
let unchangedError = try #require(storedError)
|
||||
#expect(unchangedError == error)
|
||||
let storedDenied = try reloaded.value(id: denied.id)
|
||||
let unchangedDenied = try #require(storedDenied)
|
||||
#expect(unchangedDenied == denied)
|
||||
}
|
||||
|
||||
private func call(
|
||||
summary: String,
|
||||
status: ExternalToolCall.Status = .running,
|
||||
startedAt: TimeInterval,
|
||||
finishedAt: Date? = nil
|
||||
) -> ExternalToolCall {
|
||||
ExternalToolCall(
|
||||
runID: UUID(), clientName: "Client", toolName: "list_hosts",
|
||||
summary: summary, risk: .readOnly, status: status,
|
||||
startedAt: Date(timeIntervalSince1970: startedAt), finishedAt: finishedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -2,7 +2,7 @@ import Foundation
|
||||
import TermindDomain
|
||||
|
||||
@MainActor
|
||||
protocol ToolAuditRepository: AnyObject {
|
||||
public protocol ToolAuditRepository: AnyObject {
|
||||
func entries() throws -> [ExternalToolCall]
|
||||
func clear() throws
|
||||
func insert(_ value: ExternalToolCall, limit: Int) throws
|
||||
@@ -12,34 +12,34 @@ protocol ToolAuditRepository: AnyObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InMemoryToolAuditRepository: ToolAuditRepository {
|
||||
public final class InMemoryToolAuditRepository: ToolAuditRepository {
|
||||
private var values: [ExternalToolCall]
|
||||
|
||||
init(entries: [ExternalToolCall] = []) { values = entries }
|
||||
public init(entries: [ExternalToolCall] = []) { values = entries }
|
||||
|
||||
func entries() throws -> [ExternalToolCall] {
|
||||
public func entries() throws -> [ExternalToolCall] {
|
||||
values.sorted { $0.startedAt > $1.startedAt }
|
||||
}
|
||||
|
||||
func clear() throws { values.removeAll() }
|
||||
public func clear() throws { values.removeAll() }
|
||||
|
||||
func insert(_ value: ExternalToolCall, limit: Int) throws {
|
||||
public func insert(_ value: ExternalToolCall, limit: Int) throws {
|
||||
values.append(value)
|
||||
values.sort { $0.startedAt > $1.startedAt }
|
||||
if values.count > limit { values.removeLast(values.count - limit) }
|
||||
}
|
||||
|
||||
func update(_ value: ExternalToolCall) throws {
|
||||
public func update(_ value: ExternalToolCall) throws {
|
||||
if let index = values.firstIndex(where: { $0.id == value.id }) {
|
||||
values[index] = value
|
||||
}
|
||||
}
|
||||
|
||||
func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
|
||||
public func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
|
||||
values.first { $0.id == id }
|
||||
}
|
||||
|
||||
func recoverInterruptedCalls() throws {
|
||||
public func recoverInterruptedCalls() throws {
|
||||
for index in values.indices where values[index].status == .running {
|
||||
values[index].status = .error
|
||||
values[index].finishedAt = .now
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindDomain
|
||||
|
||||
@Model
|
||||
public final class PersistentExternalToolCall {
|
||||
@Attribute(.unique) public var id: UUID = UUID()
|
||||
public var runID: UUID = UUID()
|
||||
public var clientName = ""
|
||||
public var toolName = ""
|
||||
public var hostID: UUID?
|
||||
public var hostDisplayName: String?
|
||||
public var summary = ""
|
||||
public var risk = ExternalToolCall.Risk.readOnly.rawValue
|
||||
public var status = ExternalToolCall.Status.running.rawValue
|
||||
public var exitCode: Int?
|
||||
public var bytesRead: Int?
|
||||
public var bytesWritten: Int?
|
||||
public var startedAt = Date(timeIntervalSince1970: 0)
|
||||
public var finishedAt: Date?
|
||||
|
||||
public init(_ value: ExternalToolCall) { apply(value) }
|
||||
|
||||
public func apply(_ value: ExternalToolCall) {
|
||||
id = value.id
|
||||
runID = value.runID
|
||||
clientName = value.clientName
|
||||
toolName = value.toolName
|
||||
hostID = value.hostID
|
||||
hostDisplayName = value.hostDisplayName
|
||||
summary = value.summary
|
||||
risk = value.risk.rawValue
|
||||
status = value.status.rawValue
|
||||
exitCode = value.exitCode
|
||||
bytesRead = value.bytesRead
|
||||
bytesWritten = value.bytesWritten
|
||||
startedAt = value.startedAt
|
||||
finishedAt = value.finishedAt
|
||||
}
|
||||
|
||||
public var value: ExternalToolCall {
|
||||
ExternalToolCall(
|
||||
id: id, runID: runID, clientName: clientName, toolName: toolName,
|
||||
hostID: hostID, hostDisplayName: hostDisplayName, summary: summary,
|
||||
risk: ExternalToolCall.Risk(rawValue: risk) ?? .readOnly,
|
||||
status: ExternalToolCall.Status(rawValue: status) ?? .error,
|
||||
exitCode: exitCode, bytesRead: bytesRead, bytesWritten: bytesWritten,
|
||||
startedAt: startedAt, finishedAt: finishedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,67 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
@Model
|
||||
public final class PersistentExternalToolCall {
|
||||
@Attribute(.unique) public var id: UUID = UUID()
|
||||
public var runID: UUID = UUID()
|
||||
public var clientName = ""
|
||||
public var toolName = ""
|
||||
public var hostID: UUID?
|
||||
public var hostDisplayName: String?
|
||||
public var summary = ""
|
||||
public var risk = ExternalToolCall.Risk.readOnly.rawValue
|
||||
public var status = ExternalToolCall.Status.running.rawValue
|
||||
public var exitCode: Int?
|
||||
public var bytesRead: Int?
|
||||
public var bytesWritten: Int?
|
||||
public var startedAt = Date(timeIntervalSince1970: 0)
|
||||
public var finishedAt: Date?
|
||||
@MainActor
|
||||
public final class SwiftDataToolAuditRepository: ToolAuditRepository {
|
||||
private let context: ModelContext
|
||||
|
||||
public init(_ value: ExternalToolCall) { apply(value) }
|
||||
public init(context: ModelContext) { self.context = context }
|
||||
|
||||
public func apply(_ value: ExternalToolCall) {
|
||||
id = value.id
|
||||
runID = value.runID
|
||||
clientName = value.clientName
|
||||
toolName = value.toolName
|
||||
hostID = value.hostID
|
||||
hostDisplayName = value.hostDisplayName
|
||||
summary = value.summary
|
||||
risk = value.risk.rawValue
|
||||
status = value.status.rawValue
|
||||
exitCode = value.exitCode
|
||||
bytesRead = value.bytesRead
|
||||
bytesWritten = value.bytesWritten
|
||||
startedAt = value.startedAt
|
||||
finishedAt = value.finishedAt
|
||||
public func entries() throws -> [ExternalToolCall] {
|
||||
try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
).map(\.value)
|
||||
}
|
||||
|
||||
public var value: ExternalToolCall {
|
||||
ExternalToolCall(
|
||||
id: id, runID: runID, clientName: clientName, toolName: toolName,
|
||||
hostID: hostID, hostDisplayName: hostDisplayName, summary: summary,
|
||||
risk: ExternalToolCall.Risk(rawValue: risk) ?? .readOnly,
|
||||
status: ExternalToolCall.Status(rawValue: status) ?? .error,
|
||||
exitCode: exitCode, bytesRead: bytesRead, bytesWritten: bytesWritten,
|
||||
startedAt: startedAt, finishedAt: finishedAt
|
||||
public func clear() throws {
|
||||
try context.commit { try context.delete(model: PersistentExternalToolCall.self) }
|
||||
}
|
||||
|
||||
public func insert(_ value: ExternalToolCall, limit: Int) throws {
|
||||
try context.commit {
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchOffset = max(0, limit - 1)
|
||||
for old in try context.fetch(descriptor) { context.delete(old) }
|
||||
context.insert(PersistentExternalToolCall(value))
|
||||
}
|
||||
}
|
||||
|
||||
public func update(_ value: ExternalToolCall) throws {
|
||||
try context.commit {
|
||||
let id = value.id
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
try context.fetch(descriptor).first?.apply(value)
|
||||
}
|
||||
}
|
||||
|
||||
public func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try context.fetch(descriptor).first?.value
|
||||
}
|
||||
|
||||
public func recoverInterruptedCalls() throws {
|
||||
let running = ExternalToolCall.Status.running.rawValue
|
||||
try context.commit {
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.status == running }
|
||||
))
|
||||
for row in rows {
|
||||
row.status = ExternalToolCall.Status.error.rawValue
|
||||
row.finishedAt = .now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
developer_dir=$(xcode-select -p)
|
||||
if swift_executable=$(xcrun --find swift 2>/dev/null); then
|
||||
:
|
||||
else
|
||||
swift_executable="$developer_dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift"
|
||||
fi
|
||||
swift_host="$(dirname "$(dirname "$swift_executable")")/lib/swift/host"
|
||||
macos_sdk="$developer_dir/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
|
||||
|
||||
exec "$swift_executable" -sdk "$macos_sdk" -I "$swift_host" -L "$swift_host" \
|
||||
-lSwiftParser -lSwiftSyntax -Xlinker -rpath -Xlinker "$swift_host" \
|
||||
"$script_dir/swiftdata-repository-declarations.swift" "$1"
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import SwiftParser
|
||||
import SwiftSyntax
|
||||
|
||||
final class SwiftDataRepositoryDeclarationVisitor: SyntaxVisitor {
|
||||
private static let namePattern = try! NSRegularExpression(
|
||||
pattern: #"^SwiftData[A-Za-z0-9_]*Repository$"#
|
||||
)
|
||||
|
||||
private(set) var containsDeclaration = false
|
||||
|
||||
init() {
|
||||
super.init(viewMode: .sourceAccurate)
|
||||
}
|
||||
|
||||
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
record(node.name)
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
private func record(_ token: TokenSyntax) {
|
||||
var name = token.text
|
||||
if name.count >= 2, name.first == "`", name.last == "`" {
|
||||
name.removeFirst()
|
||||
name.removeLast()
|
||||
}
|
||||
let range = NSRange(name.startIndex..<name.endIndex, in: name)
|
||||
if Self.namePattern.firstMatch(in: name, range: range) != nil {
|
||||
containsDeclaration = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard CommandLine.arguments.count == 2 else {
|
||||
throw CocoaError(.fileReadInvalidFileName)
|
||||
}
|
||||
|
||||
let requestURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let request = try JSONDecoder().decode([String].self, from: Data(contentsOf: requestURL))
|
||||
let results = try request.map { source in
|
||||
let tree = Parser.parse(source: source)
|
||||
guard !tree.hasError else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
let visitor = SwiftDataRepositoryDeclarationVisitor()
|
||||
visitor.walk(tree)
|
||||
return visitor.containsDeclaration
|
||||
}
|
||||
let output = try JSONEncoder().encode(results)
|
||||
try FileHandle.standardOutput.write(contentsOf: output)
|
||||
try FileHandle.standardOutput.write(contentsOf: Data("\n".utf8))
|
||||
@@ -98,7 +98,7 @@ struct TermindApp: App {
|
||||
currentDeviceID: connectionHistoryDeviceID,
|
||||
currentDeviceName: Self.computerName)
|
||||
let externalToolCalls = ToolAuditModel(
|
||||
modelContext: container.mainContext,
|
||||
repository: SwiftDataToolAuditRepository(context: container.mainContext),
|
||||
hostName: { [weak hosts] id in hosts?.host(id)?.name }
|
||||
)
|
||||
let proAccess: ProAccessController =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
extension ModelContext {
|
||||
func commit(_ operation: () throws -> Void) throws {
|
||||
do {
|
||||
try operation()
|
||||
try save()
|
||||
} catch {
|
||||
rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindDomain
|
||||
import TermindPersistence
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@MainActor
|
||||
final class SwiftDataToolAuditRepository: ToolAuditRepository {
|
||||
private let context: ModelContext
|
||||
init(context: ModelContext) { self.context = context }
|
||||
|
||||
func entries() throws -> [ExternalToolCall] {
|
||||
try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
).map(\.value)
|
||||
}
|
||||
|
||||
func clear() throws {
|
||||
try context.commit { try context.delete(model: PersistentExternalToolCall.self) }
|
||||
}
|
||||
|
||||
func insert(_ value: ExternalToolCall, limit: Int) throws {
|
||||
try context.commit {
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchOffset = max(0, limit - 1)
|
||||
for old in try context.fetch(descriptor) { context.delete(old) }
|
||||
context.insert(PersistentExternalToolCall(value))
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ value: ExternalToolCall) throws {
|
||||
try context.commit {
|
||||
let id = value.id
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
try context.fetch(descriptor).first?.apply(value)
|
||||
}
|
||||
}
|
||||
|
||||
func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
|
||||
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try context.fetch(descriptor).first?.value
|
||||
}
|
||||
|
||||
func recoverInterruptedCalls() throws {
|
||||
let running = ExternalToolCall.Status.running.rawValue
|
||||
try context.commit {
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.status == running }
|
||||
))
|
||||
for row in rows {
|
||||
row.status = ExternalToolCall.Status.error.rawValue
|
||||
row.finishedAt = .now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ToolAuditModel {
|
||||
convenience init(
|
||||
modelContext: ModelContext,
|
||||
hostName: @escaping (Host.ID) -> String? = { _ in nil }
|
||||
) {
|
||||
self.init(
|
||||
repository: SwiftDataToolAuditRepository(context: modelContext),
|
||||
hostName: hostName
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,78 @@ struct ArchitectureBoundaryTests {
|
||||
#expect(!models.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func swiftDataRepositoriesHaveOneSharedOwner() throws {
|
||||
let forbiddenFiles = try [
|
||||
"", "../TFI", "../Frameworks/TermindDomain", "../Frameworks/TermindApplication",
|
||||
"../Frameworks/TermindInfrastructure", "../Frameworks/TermindRemoteInfrastructure",
|
||||
"../Frameworks/TermindPresentation", "../TermindMCPBridge", "../Benchmarks/PortForwarding",
|
||||
"../TFIWidgets", "../WorkspaceActivitySupport",
|
||||
].flatMap {
|
||||
try swiftFiles(in: $0)
|
||||
}
|
||||
let persistenceFiles = try swiftFiles(in: "../Frameworks/TermindPersistence")
|
||||
let files = forbiddenFiles + persistenceFiles
|
||||
let sources = try files.map { try String(contentsOf: $0, encoding: .utf8) }
|
||||
let declarations = try swiftDataRepositoryDeclarations(in: sources)
|
||||
|
||||
for (file, containsDeclaration) in zip(forbiddenFiles, declarations.prefix(forbiddenFiles.count)) {
|
||||
#expect(
|
||||
!containsDeclaration,
|
||||
"\(file.path) declares a SwiftData repository outside TermindPersistence"
|
||||
)
|
||||
}
|
||||
#expect(declarations.dropFirst(forbiddenFiles.count).contains(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func recognizesSwiftDataRepositoryDeclarations() throws {
|
||||
let sources = [
|
||||
"final class SwiftDataFooRepository {}",
|
||||
"@MainActor public final class SwiftDataFooRepository {}",
|
||||
"@MainActor\nfinal class SwiftDataFooRepository {}",
|
||||
"@MainActor @Observable final class SwiftDataFooRepository {}",
|
||||
"@available(macOS 15, *) final class SwiftDataFooRepository {}",
|
||||
"actor SwiftDataFooRepository {}",
|
||||
"struct SwiftDataFooRepository {}",
|
||||
"typealias SwiftDataFooRepository = String",
|
||||
"final class `SwiftDataFooRepository` {}",
|
||||
"// class SwiftDataDecoyRepository {}\nfinal class SwiftDataFooRepository {}",
|
||||
"let text = \"class SwiftDataDecoyRepository {}\"\nfinal class SwiftDataFooRepository {}",
|
||||
"final class /* shared persistence owner */ SwiftDataFooRepository {}",
|
||||
#"let text = "\({ () -> String in class SwiftDataFooRepository {}; return "" }())""#,
|
||||
]
|
||||
let declarations = try swiftDataRepositoryDeclarations(in: sources)
|
||||
for (source, containsDeclaration) in zip(sources, declarations) {
|
||||
#expect(containsDeclaration, "Expected a declaration in: \(source)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func ignoresSwiftDataRepositoryReferencesAndTextualNearMatches() throws {
|
||||
let sources = [
|
||||
"let repository = SwiftDataFooRepository()",
|
||||
"let repository: SwiftDataFooRepository",
|
||||
"final class NotSwiftDataFooRepository {}",
|
||||
"final class SwiftDataFooRepositoryFactory {}",
|
||||
"// final class SwiftDataFooRepository {}",
|
||||
"/* struct SwiftDataFooRepository {} */",
|
||||
"let text = \"actor SwiftDataFooRepository {}\"",
|
||||
"/// protocol SwiftDataFooRepository {}",
|
||||
"/* outer /* enum SwiftDataFooRepository {} */ still outer */",
|
||||
##"""
|
||||
let text = """
|
||||
class SwiftDataFooRepository {}
|
||||
"""
|
||||
"""##,
|
||||
##"let text = #"typealias SwiftDataFooRepository = String"#"##,
|
||||
]
|
||||
let declarations = try swiftDataRepositoryDeclarations(in: sources)
|
||||
for (source, containsDeclaration) in zip(sources, declarations) {
|
||||
#expect(!containsDeclaration, "Expected no declaration in: \(source)")
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceRoot: URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
@@ -297,6 +369,54 @@ struct ArchitectureBoundaryTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func swiftDataRepositoryDeclarations(in sources: [String]) throws -> [Bool] {
|
||||
let request = FileManager.default.temporaryDirectory.appending(
|
||||
path: "termind-swiftdata-repository-declarations-\(UUID().uuidString).json"
|
||||
)
|
||||
try JSONEncoder().encode(sources).write(to: request, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: request) }
|
||||
|
||||
let repositoryRoot = sourceRoot.deletingLastPathComponent().standardizedFileURL
|
||||
let executable = repositoryRoot.appending(path: "Scripts/swiftdata-repository-declarations.sh")
|
||||
let process = Process()
|
||||
let standardOutput = Pipe()
|
||||
let standardError = Pipe()
|
||||
process.executableURL = executable
|
||||
process.arguments = [request.path]
|
||||
process.standardOutput = standardOutput
|
||||
process.standardError = standardError
|
||||
|
||||
try process.run()
|
||||
let output = standardOutput.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorOutput = standardError.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
let errorMessage = String(decoding: errorOutput, as: UTF8.self)
|
||||
|
||||
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
|
||||
throw SwiftDataRepositoryDeclarationToolError(
|
||||
message: "Parser exited with status \(process.terminationStatus): \(errorMessage)"
|
||||
)
|
||||
}
|
||||
guard errorOutput.isEmpty else {
|
||||
throw SwiftDataRepositoryDeclarationToolError(
|
||||
message: "Parser wrote to stderr: \(errorMessage)"
|
||||
)
|
||||
}
|
||||
let declarations = try JSONDecoder().decode([Bool].self, from: output)
|
||||
guard declarations.count == sources.count else {
|
||||
throw SwiftDataRepositoryDeclarationToolError(
|
||||
message: "Parser returned \(declarations.count) results for \(sources.count) sources"
|
||||
)
|
||||
}
|
||||
return declarations
|
||||
}
|
||||
|
||||
private struct SwiftDataRepositoryDeclarationToolError: LocalizedError {
|
||||
let message: String
|
||||
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private func swiftFiles(in directory: String) throws -> [URL] {
|
||||
let root = sourceRoot.appending(path: directory, directoryHint: .isDirectory)
|
||||
let keys: [URLResourceKey] = [.isRegularFileKey]
|
||||
|
||||
Reference in new issue
Block a user