From e4f00922ced017057620d210d17ccc8c2d09cde9 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 23 Sep 2026 21:21:28 +0800 Subject: [PATCH] Move tool audit persistence into shared layers --- FrameworkTests/ToolAuditRepositoryTests.swift | 110 ++++++++++++++++ .../Repositories/ToolAuditRepository.swift | 18 +-- .../PersistentExternalToolCall.swift | 51 ++++++++ .../SwiftDataToolAuditRepository.swift | 96 ++++++++------ Scripts/swiftdata-repository-declarations.sh | 16 +++ .../swiftdata-repository-declarations.swift | 76 +++++++++++ TFM/App/TermindApp.swift | 2 +- .../FeatureModels/ToolAuditModel.swift | 1 + .../Database/ModelContext+Commit.swift | 14 -- .../SwiftDataToolAuditRepository.swift | 81 ------------ TFMTests/ArchitectureBoundaryTests.swift | 120 ++++++++++++++++++ 11 files changed, 440 insertions(+), 145 deletions(-) create mode 100644 FrameworkTests/ToolAuditRepositoryTests.swift rename {TFM/Application => Frameworks/TermindApplication}/Repositories/ToolAuditRepository.swift (63%) create mode 100644 Frameworks/TermindPersistence/PersistentExternalToolCall.swift create mode 100755 Scripts/swiftdata-repository-declarations.sh create mode 100644 Scripts/swiftdata-repository-declarations.swift delete mode 100644 TFM/Infrastructure/Database/ModelContext+Commit.swift delete mode 100644 TFM/Infrastructure/Database/SwiftDataToolAuditRepository.swift diff --git a/FrameworkTests/ToolAuditRepositoryTests.swift b/FrameworkTests/ToolAuditRepositoryTests.swift new file mode 100644 index 00000000..9b695486 --- /dev/null +++ b/FrameworkTests/ToolAuditRepositoryTests.swift @@ -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 + ) + } +} diff --git a/TFM/Application/Repositories/ToolAuditRepository.swift b/Frameworks/TermindApplication/Repositories/ToolAuditRepository.swift similarity index 63% rename from TFM/Application/Repositories/ToolAuditRepository.swift rename to Frameworks/TermindApplication/Repositories/ToolAuditRepository.swift index bc0b6978..08c0390b 100644 --- a/TFM/Application/Repositories/ToolAuditRepository.swift +++ b/Frameworks/TermindApplication/Repositories/ToolAuditRepository.swift @@ -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 diff --git a/Frameworks/TermindPersistence/PersistentExternalToolCall.swift b/Frameworks/TermindPersistence/PersistentExternalToolCall.swift new file mode 100644 index 00000000..2d9d601d --- /dev/null +++ b/Frameworks/TermindPersistence/PersistentExternalToolCall.swift @@ -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 + ) + } +} diff --git a/Frameworks/TermindPersistence/SwiftDataToolAuditRepository.swift b/Frameworks/TermindPersistence/SwiftDataToolAuditRepository.swift index 2d9d601d..21ff0c9e 100644 --- a/Frameworks/TermindPersistence/SwiftDataToolAuditRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataToolAuditRepository.swift @@ -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( + 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( + 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( + 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( + 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( + predicate: #Predicate { $0.status == running } + )) + for row in rows { + row.status = ExternalToolCall.Status.error.rawValue + row.finishedAt = .now + } + } } } diff --git a/Scripts/swiftdata-repository-declarations.sh b/Scripts/swiftdata-repository-declarations.sh new file mode 100755 index 00000000..e3b1a809 --- /dev/null +++ b/Scripts/swiftdata-repository-declarations.sh @@ -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" diff --git a/Scripts/swiftdata-repository-declarations.swift b/Scripts/swiftdata-repository-declarations.swift new file mode 100644 index 00000000..46a4f77d --- /dev/null +++ b/Scripts/swiftdata-repository-declarations.swift @@ -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.. Void) throws { - do { - try operation() - try save() - } catch { - rollback() - throw error - } - } -} diff --git a/TFM/Infrastructure/Database/SwiftDataToolAuditRepository.swift b/TFM/Infrastructure/Database/SwiftDataToolAuditRepository.swift deleted file mode 100644 index 820b0b92..00000000 --- a/TFM/Infrastructure/Database/SwiftDataToolAuditRepository.swift +++ /dev/null @@ -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( - 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( - 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( - 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( - 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( - 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 - ) - } -} diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index c75bb9ba..6f189f12 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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]