From 8ef09d70b1b1126e770bd17ebe2d600a49b52bc1 Mon Sep 17 00:00:00 2001 From: Piers Date: Sun, 24 May 2026 11:43:40 +1000 Subject: [PATCH 1/2] =?UTF-8?q?DD-338=20Phase=20C=20Wave=205=20=E2=80=94?= =?UTF-8?q?=20consolidate=20=5Fmeta=20emission=20onto=20MCPHelpers=20SPM?= =?UTF-8?q?=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 160-LOC hand-rolled `Sources/AppleNotesBlade/MetaEnvelope.swift` with the canonical `stallari-mcp-helpers-swift` SPM dep (MCPHelpers v0.1.0). The hand-rolled v1 was written 2026-05-23 in PR #2 alongside the first Swift _meta envelope emission; the canonical lib shipped 2026-05-23 as DD-338 Phase E.swift and the duplication is now retired before it drifts. Wire-shape changes (canonical vs hand-rolled v1): - `redactions` ALWAYS emitted (`[]` when empty). Was omit-when-empty. - `next_cursor` ALWAYS emitted (JSON `null` when nil). Was omit-when-nil. - `filtered_by` alphabetically sorted by the formatter. Caller pre-sort no longer load-bearing; existing pre-sort calls remain harmless. - `formatMetaLine` now throws (encoding-failure surface, unreachable for our value types). Per the canonical contract, `MetaEnvelope.init` parameter order is now (matchedTotal, returned, latencyMs, filteredBy, redactions, ...) — all 3 emitting tool handlers updated. ResultBuilder.makeResultWithMeta wraps the new try-formatMetaLine in the existing do/catch and routes encode failures through errorResult(.internalError("encode_failure")) per Wave 3 OQ-7. Retained as blade-local (not part of the canonical surface): - `Duration.toMilliseconds()` — `ContinuousClock.Duration` → Int ms for populating MetaEnvelope.latencyMs (canonical leaves clock marshalling to the caller). - `metaQueryDigest(_:)` — SHA-256 short digest for search-query audit values. Blade-domain helper; not generic enough for the canonical lib. Moved into Sources/AppleNotesBlade/MetaHelpers.swift. Platform bump: .macOS(.v13) → .macOS(.v14) to satisfy the canonical lib's minimum platform. macOS 14 (Sonoma 2023) is widely deployed; consumer is the Stallari harness only. Catalog declarations in stallari-plugins/plugins/tools/apple-notes-blade-mcp.json flip in a sibling PR (separate stallari-plugins repo) — covered by the `feat/dd-338-w5-apple-catalog-flip` branch. Test surface: - All 73 existing tests pass post-flip (zero regressions). - MetaEnvelopeTests updated for canonical wire shape: 4-key formatter assertion → 6-key, omit-when-empty tests → always-present-with-default assertions, +1 canonical-shape gate test using locked input/output pair. Build: clean, zero warnings. Tests: 73/73 green. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 42 +++++ Package.swift | 7 +- Sources/AppleNotesBlade/MetaEnvelope.swift | 160 ------------------ Sources/AppleNotesBlade/MetaHelpers.swift | 56 ++++++ .../AppleNotesBlade/Tools/ListFolders.swift | 5 +- Sources/AppleNotesBlade/Tools/ListNotes.swift | 5 +- .../AppleNotesBlade/Tools/ResultBuilder.swift | 8 +- .../AppleNotesBlade/Tools/SearchNotes.swift | 5 +- Sources/AppleNotesBlade/Version.swift | 2 +- .../MetaEnvelopeTests.swift | 87 ++++++++-- 10 files changed, 195 insertions(+), 182 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 Sources/AppleNotesBlade/MetaEnvelope.swift create mode 100644 Sources/AppleNotesBlade/MetaHelpers.swift diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82a95aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-05-24 + +### Changed + +- **DD-338 Phase C Wave 5:** consolidate `_meta:` envelope emission onto the + canonical `stallari-mcp-helpers-swift` SPM dep (`MCPHelpers` module v0.1.0). + Local `Sources/AppleNotesBlade/MetaEnvelope.swift` (160 LOC) deleted; functionally + equivalent but wire-shape now matches the cross-language canonical: + - `redactions` and `next_cursor` are ALWAYS emitted (defaults to `[]` and JSON + `null` respectively). Hand-rolled v1 omitted these when empty/nil. + - `filtered_by` is alphabetically sorted by the formatter (caller pre-sort is + no longer load-bearing; existing pre-sorts remain harmless). + - `formatMetaLine` now throws (encoding-failure surface). +- Catalog declarations flipped `audit_surface: minimal → structured` for the 3 + tools emitting `_meta` envelopes (`apple_notes_list_folders`, + `apple_notes_list_notes`, `apple_notes_search_notes`). Reconciles drift + between code shipped 2026-05-23 PR #2 and catalog declaration. +- Platform bump `.macOS(.v13) → .macOS(.v14)` to satisfy the + `stallari-mcp-helpers-swift` minimum platform. +- Retained `metaQueryDigest(_:)` and `Duration.toMilliseconds()` as blade-local + helpers in a new `Sources/AppleNotesBlade/MetaHelpers.swift` — these are NOT + part of the canonical surface. + +### Removed + +- `Sources/AppleNotesBlade/MetaEnvelope.swift` (replaced by the canonical + `MCPHelpers` SPM dep). + +## [0.2.0] - 2026-05-23 + +### Added + +- First Swift-blade `_meta:` envelope emission across 3 tools + (`apple_notes_list_folders`, `apple_notes_list_notes`, + `apple_notes_search_notes`). DD-338 Phase C Wave 5 audit_surface promotion. diff --git a/Package.swift b/Package.swift index f68ed52..6e5d939 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ import PackageDescription let package = Package( name: "apple-notes-blade-mcp", platforms: [ - .macOS(.v13) + .macOS(.v14) ], products: [ .library( @@ -33,6 +33,10 @@ let package = Package( // purely for resolution compatibility. The runtime SQLite client // exposed by the package is API-compatible across traits. .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.3", traits: ["SQLCipher"]), + // Canonical _meta: envelope helpers (DD-338 Phase E.swift). Replaces + // the previously hand-rolled Sources/AppleNotesBlade/MetaEnvelope.swift + // (deleted in DD-338 Phase C Wave 5). + .package(url: "https://github.com/Groupthink-dev/stallari-mcp-helpers-swift", from: "0.1.0"), ], targets: [ .target( @@ -40,6 +44,7 @@ let package = Package( dependencies: [ .product(name: "MCP", package: "swift-sdk"), .product(name: "SQLite", package: "SQLite.swift"), + .product(name: "MCPHelpers", package: "stallari-mcp-helpers-swift"), ] ), .testTarget( diff --git a/Sources/AppleNotesBlade/MetaEnvelope.swift b/Sources/AppleNotesBlade/MetaEnvelope.swift deleted file mode 100644 index 4ba7d45..0000000 --- a/Sources/AppleNotesBlade/MetaEnvelope.swift +++ /dev/null @@ -1,160 +0,0 @@ -// MetaEnvelope.swift -// -// DD-338 Phase C Wave 5 — canonical `_meta:` envelope helper for the Swift -// blade-mcp class. Establishes precedent for all future first-party Swift -// blades. Duplicated byte-equivalent across `apple-mail-blade-mcp` and -// `apple-notes-blade-mcp` per DD-240 invariant #8 (no cross-blade-mcp deps). -// -// Wire shape: -// -// -// -// _meta: {"matched_total": 42, "returned": 10, "filtered_by": [...], "latency_ms": 87} -// -// Required fields: `matched_total: Int`, `returned: Int`, `filtered_by: [String]`, -// `latency_ms: Int`. Optional fields (`redactions`, `next_cursor`, `error_notes`) -// are OMITTED when empty/nil per Wave 3 OQ-1 ratification. -// -// Cross-language byte-non-equivalence with Python (`json.dumps(separators= -// (", ", ": "))`) and TS (`JSON.stringify`) is ACCEPTED per Wave 5 OQ-1 -// ratification — the assembler regex `\n\n_meta: (\{.*\})$` parses the JSON -// object, it does not byte-hash the line. See DEVFU -// `2026-05-23-meta-envelope-byte-equivalence-cross-language`. -// -// Reference helpers: -// - Python: `gmail-blade-mcp/src/gmail_blade_mcp/server.py` (_format_meta_envelope) -// - TS: `cloudflare-blade-mcp/src/utils/meta.ts` (formatMetaLine) - -import CryptoKit -import Foundation -import MCP - -// MARK: - Envelope value type - -/// Canonical `_meta:` envelope value. `Sendable` for actor reach. -/// -/// Construction discipline: callers pre-sort `filteredBy` alphabetically for -/// hash reproducibility (the helper does NOT re-sort on emit — sort site is -/// the caller's contract per Wave 3 ratification). Optional arrays passed as -/// `nil` OR `[]` are omitted from the emitted JSON. -public struct MetaEnvelope: Sendable { - public let matchedTotal: Int - public let returned: Int - public let filteredBy: [String] - public let latencyMs: Int - public let redactions: [String]? - public let nextCursor: String? - public let errorNotes: [String]? - - public init( - matchedTotal: Int, - returned: Int, - filteredBy: [String], - latencyMs: Int, - redactions: [String]? = nil, - nextCursor: String? = nil, - errorNotes: [String]? = nil - ) { - self.matchedTotal = matchedTotal - self.returned = returned - self.filteredBy = filteredBy - self.latencyMs = latencyMs - self.redactions = redactions - self.nextCursor = nextCursor - self.errorNotes = errorNotes - } -} - -// MARK: - Wire formatting - -/// Format a `_meta:` envelope line. Single-line JSON. The caller MUST prepend -/// `\n\n` when appending to an existing payload (canonical separator). -/// -/// Optional fields (`redactions`, `next_cursor`, `error_notes`) are omitted -/// when empty/nil. JSON top-level keys are sorted alphabetically via -/// `JSONEncoder.OutputFormatting.sortedKeys` for byte-reproducibility within -/// the Swift implementation. (Cross-language byte equality with Python+TS is -/// not guaranteed — see file header.) -public func formatMetaLine(_ meta: MetaEnvelope) -> String { - var shadow = MetaEnvelopeShadow( - matched_total: meta.matchedTotal, - returned: meta.returned, - filtered_by: meta.filteredBy, - latency_ms: meta.latencyMs - ) - if let redactions = meta.redactions, !redactions.isEmpty { - shadow.redactions = redactions - } - if let nextCursor = meta.nextCursor { - shadow.next_cursor = nextCursor - } - if let errorNotes = meta.errorNotes, !errorNotes.isEmpty { - shadow.error_notes = errorNotes - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - let data: Data - do { - data = try encoder.encode(shadow) - } catch { - // Encoding a struct of primitives + arrays-of-strings cannot fail in - // practice; fall back to a minimal stub on the off chance. - return #"_meta: {"matched_total":0,"returned":0,"filtered_by":[],"latency_ms":0}"# - } - let json = String(data: data, encoding: .utf8) ?? "{}" - return "_meta: " + json -} - -/// Append a `_meta:` envelope line to an existing payload using the canonical -/// `\n\n` separator. Use at every Track-promoted tool-handler site; do NOT -/// inline the concatenation. -public func appendMeta(_ payload: String, _ metaLine: String) -> String { - return "\(payload)\n\n\(metaLine)" -} - -/// Shadow struct for `JSONEncoder` — uses snake_case property names so the -/// emitted keys match the wire-shape spec without per-key `CodingKeys` boilerplate. -/// Top-level key ordering is irrelevant because `JSONEncoder.OutputFormatting` -/// `.sortedKeys` is applied on emit. -private struct MetaEnvelopeShadow: Encodable { - let matched_total: Int // swiftlint:disable:this identifier_name - let returned: Int - let filtered_by: [String] // swiftlint:disable:this identifier_name - let latency_ms: Int // swiftlint:disable:this identifier_name - var redactions: [String]? - var next_cursor: String? // swiftlint:disable:this identifier_name - var error_notes: [String]? // swiftlint:disable:this identifier_name -} - -// MARK: - Latency timing - -extension Duration { - /// Convert a `ContinuousClock.Duration` (or any `Duration`) to integer - /// milliseconds. Truncates sub-millisecond fractions. Used to populate - /// `MetaEnvelope.latencyMs`. - public func toMilliseconds() -> Int { - let comps = self.components - // attoseconds = 1e-18 s; 1 ms = 1e-3 s = 1e15 attoseconds. - let msFromSeconds = comps.seconds * 1000 - let msFromAttoseconds = comps.attoseconds / 1_000_000_000_000_000 - return Int(msFromSeconds + msFromAttoseconds) - } -} - -// MARK: - Query digest - -/// Compute a SHA-256-based 12-character digest of a query string, suitable for -/// `filtered_by` `query=` audit values. Privacy-safe + token-bound + -/// collision-resistant within session window. Mirrors W3 OQ-6 ratification for -/// `cf_d1_query`. The digest is non-reversible — the assembler audit trail -/// records that a search was performed without recording the verbatim query -/// bytes. -public func metaQueryDigest(_ query: String) -> String { - let data = Data(query.utf8) - let digest = SHA256.hash(data: data) - let base64 = Data(digest).base64EncodedString() - // Strip non-alphanumeric chars (`+`, `/`, `=`) to keep the audit value - // path-safe and grep-friendly, then take the first 12 characters. - let stripped = base64.filter { $0.isLetter || $0.isNumber } - return String(stripped.prefix(12)) -} diff --git a/Sources/AppleNotesBlade/MetaHelpers.swift b/Sources/AppleNotesBlade/MetaHelpers.swift new file mode 100644 index 0000000..8b2e766 --- /dev/null +++ b/Sources/AppleNotesBlade/MetaHelpers.swift @@ -0,0 +1,56 @@ +// MetaHelpers.swift +// +// DD-338 Phase C Wave 5 — blade-local helpers that complement the canonical +// `MCPHelpers` module (SPM dep at +// https://github.com/Groupthink-dev/stallari-mcp-helpers-swift). The previous +// Sources/AppleNotesBlade/MetaEnvelope.swift hand-rolled the entire envelope +// surface; it was deleted when the canonical lib shipped (v0.1.0) and the +// blade migrated onto the SPM dep. This file retains only the two helpers +// that are NOT part of the canonical surface: +// +// - `Duration.toMilliseconds()` — convert `ContinuousClock.Duration` to Int +// milliseconds for `MetaEnvelope.latencyMs` (canonical lib leaves clock +// marshalling to the caller). +// - `metaQueryDigest(_:)` — SHA-256 short-digest of a verbatim query string +// for `filtered_by` `query=` audit entries. Blade-domain helper (search +// tools use it); not generic enough for the canonical lib. +// +// All envelope construction, formatting, and appending now flows through +// `MCPHelpers.MetaEnvelope`, `MCPHelpers.formatMetaLine`, and +// `MCPHelpers.appendMeta`. + +import CryptoKit +import Foundation + +// MARK: - Latency timing + +extension Duration { + /// Convert a `ContinuousClock.Duration` (or any `Duration`) to integer + /// milliseconds. Truncates sub-millisecond fractions. Used to populate + /// `MetaEnvelope.latencyMs`. + public func toMilliseconds() -> Int { + let comps = self.components + // attoseconds = 1e-18 s; 1 ms = 1e-3 s = 1e15 attoseconds. + let msFromSeconds = comps.seconds * 1000 + let msFromAttoseconds = comps.attoseconds / 1_000_000_000_000_000 + return Int(msFromSeconds + msFromAttoseconds) + } +} + +// MARK: - Query digest + +/// Compute a SHA-256-based 12-character digest of a query string, suitable for +/// `filtered_by` `query=` audit values. Privacy-safe + token-bound + +/// collision-resistant within session window. Mirrors W3 OQ-6 ratification for +/// `cf_d1_query`. The digest is non-reversible — the assembler audit trail +/// records that a search was performed without recording the verbatim query +/// bytes. +public func metaQueryDigest(_ query: String) -> String { + let data = Data(query.utf8) + let digest = SHA256.hash(data: data) + let base64 = Data(digest).base64EncodedString() + // Strip non-alphanumeric chars (`+`, `/`, `=`) to keep the audit value + // path-safe and grep-friendly, then take the first 12 characters. + let stripped = base64.filter { $0.isLetter || $0.isNumber } + return String(stripped.prefix(12)) +} diff --git a/Sources/AppleNotesBlade/Tools/ListFolders.swift b/Sources/AppleNotesBlade/Tools/ListFolders.swift index 49e7bac..593df94 100644 --- a/Sources/AppleNotesBlade/Tools/ListFolders.swift +++ b/Sources/AppleNotesBlade/Tools/ListFolders.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import MCPHelpers /// Handler for `apple_notes_list_folders`. Optional `account_id` filter. /// @@ -29,8 +30,8 @@ public struct ListFoldersHandler: Sendable { let meta = MetaEnvelope( matchedTotal: folders.count, returned: folders.count, - filteredBy: filteredBy, - latencyMs: elapsed.toMilliseconds() + latencyMs: elapsed.toMilliseconds(), + filteredBy: filteredBy ) return makeResultWithMeta( payload: ListFoldersResponse(folders: folders), diff --git a/Sources/AppleNotesBlade/Tools/ListNotes.swift b/Sources/AppleNotesBlade/Tools/ListNotes.swift index 5a86006..8798adc 100644 --- a/Sources/AppleNotesBlade/Tools/ListNotes.swift +++ b/Sources/AppleNotesBlade/Tools/ListNotes.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import MCPHelpers /// Handler for `apple_notes_list_notes`. Index-only — never decodes bodies. /// @@ -55,8 +56,8 @@ public struct ListNotesHandler: Sendable { let meta = MetaEnvelope( matchedTotal: notes.count, returned: notes.count, - filteredBy: filteredBy, - latencyMs: elapsed.toMilliseconds() + latencyMs: elapsed.toMilliseconds(), + filteredBy: filteredBy ) return makeResultWithMeta( payload: ListNotesResponse(notes: notes), diff --git a/Sources/AppleNotesBlade/Tools/ResultBuilder.swift b/Sources/AppleNotesBlade/Tools/ResultBuilder.swift index 59e92e6..9ac757f 100644 --- a/Sources/AppleNotesBlade/Tools/ResultBuilder.swift +++ b/Sources/AppleNotesBlade/Tools/ResultBuilder.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import MCPHelpers /// JSON encoder used by all tool handlers. ISO-8601 dates with fractional /// seconds; pretty-print disabled (consumer-facing JSON, not human-edit). @@ -34,12 +35,15 @@ func makeResult(payload: T) -> CallTool.Result { /// (no envelope on errors per Wave 3 OQ-7 ratification). /// /// Use at every Track-promoted handler site to avoid per-handler boilerplate around -/// encode + envelope-append. See `MetaEnvelope.swift` for the helper module. +/// encode + envelope-append. Envelope construction + formatting uses the canonical +/// `MCPHelpers` SPM dep (DD-338 Phase C Wave 5; replaces former hand-rolled +/// Sources/AppleNotesBlade/MetaEnvelope.swift). func makeResultWithMeta(payload: T, meta: MetaEnvelope) -> CallTool.Result { do { let data = try makeToolEncoder().encode(payload) let json = String(data: data, encoding: .utf8) ?? "{}" - let text = appendMeta(json, formatMetaLine(meta)) + let line = try formatMetaLine(meta) + let text = appendMeta(json, line) return CallTool.Result(content: [.text(text: text, annotations: nil, _meta: nil)]) } catch { return errorResult(.internalError("encode_failure")) diff --git a/Sources/AppleNotesBlade/Tools/SearchNotes.swift b/Sources/AppleNotesBlade/Tools/SearchNotes.swift index d1af2bb..8168e75 100644 --- a/Sources/AppleNotesBlade/Tools/SearchNotes.swift +++ b/Sources/AppleNotesBlade/Tools/SearchNotes.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import MCPHelpers /// Handler for `apple_notes_search_notes`. v0.1.0 implementation: SQL `LIKE` /// against `ZTITLE1` and `ZSNIPPET` — never opens body bytes. Real FTS via @@ -65,8 +66,8 @@ public struct SearchNotesHandler: Sendable { let meta = MetaEnvelope( matchedTotal: results.count, returned: results.count, - filteredBy: filteredBy, - latencyMs: elapsed.toMilliseconds() + latencyMs: elapsed.toMilliseconds(), + filteredBy: filteredBy ) return makeResultWithMeta( payload: SearchNotesResponse(query: query, results: results), diff --git a/Sources/AppleNotesBlade/Version.swift b/Sources/AppleNotesBlade/Version.swift index 3d0b7e8..b167f25 100644 --- a/Sources/AppleNotesBlade/Version.swift +++ b/Sources/AppleNotesBlade/Version.swift @@ -4,5 +4,5 @@ extension AppleNotesBlade { /// SemVer string for the library. Bump together with the git tag. /// Pre-release suffixes (`-rc1`) are allowed; consumers should pin by SHA /// not by version while the suffix is non-empty. - public static let semver = "0.2.0" + public static let semver = "0.3.0" } diff --git a/Tests/AppleNotesBladeTests/MetaEnvelopeTests.swift b/Tests/AppleNotesBladeTests/MetaEnvelopeTests.swift index cb086d8..cb89809 100644 --- a/Tests/AppleNotesBladeTests/MetaEnvelopeTests.swift +++ b/Tests/AppleNotesBladeTests/MetaEnvelopeTests.swift @@ -5,8 +5,21 @@ // - apple_notes_list_folders // - apple_notes_list_notes // - apple_notes_search_notes +// +// Post-DD-338-Phase-C-Wave-5 cutover: envelope construction + formatting now +// flow through the canonical `MCPHelpers` SPM dep +// (https://github.com/Groupthink-dev/stallari-mcp-helpers-swift). Canonical +// wire-shape vs. the hand-rolled v1 differs in two ways exercised by these +// tests: +// - `redactions` ALWAYS emitted (defaults to `[]`); was omitted when empty +// - `next_cursor` ALWAYS emitted (JSON `null` when nil); was omitted when nil +// - `filtered_by` alphabetically sorted by the formatter (caller no longer +// needs to pre-sort; existing pre-sort calls remain harmless) +// - `formatMetaLine` now throws (encoding-failure surface; unreachable in +// practice for our value types) import MCP +import MCPHelpers import XCTest @testable import AppleNotesBlade @@ -37,42 +50,65 @@ final class MetaEnvelopeTests: XCTestCase { return extractText(from: result) } - func testMetaEnvelopeFormatterByteShape() { + func testMetaEnvelopeFormatterByteShape() throws { + // Canonical MCPHelpers shape: ALWAYS emits matched_total, returned, + // filtered_by, latency_ms, redactions, next_cursor (6 required keys). + // error_notes omitted when nil/empty. `filtered_by` sorted alphabetically. let meta = MetaEnvelope( matchedTotal: 42, returned: 10, - filteredBy: ["folder_id=23", "limit=10"], - latencyMs: 87 + latencyMs: 87, + filteredBy: ["folder_id=23", "limit=10"] ) - let line = formatMetaLine(meta) + let line = try formatMetaLine(meta) XCTAssertTrue(line.hasPrefix("_meta: ")) let json = String(line.dropFirst("_meta: ".count)) let data = json.data(using: .utf8)! let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] XCTAssertNotNil(parsed) - XCTAssertEqual(parsed?.count, 4) + XCTAssertEqual(parsed?.count, 6) XCTAssertEqual(parsed?["matched_total"] as? Int, 42) XCTAssertEqual(parsed?["returned"] as? Int, 10) + // Canonical sorts filtered_by alphabetically. XCTAssertEqual(parsed?["filtered_by"] as? [String], ["folder_id=23", "limit=10"]) XCTAssertEqual(parsed?["latency_ms"] as? Int, 87) + XCTAssertEqual(parsed?["redactions"] as? [String], []) + XCTAssertTrue(parsed?["next_cursor"] is NSNull) } - func testMetaEnvelopeOmitsEmptyOptionals() { + func testMetaEnvelopeAlwaysEmitsRedactionsAndNextCursor() throws { + // Canonical contract: redactions + next_cursor are REQUIRED fields. + // Empty redactions → `[]`; nil next_cursor → JSON `null`. error_notes + // alone is the omit-when-empty optional. let meta = MetaEnvelope( matchedTotal: 1, returned: 1, - filteredBy: [], latencyMs: 0, + filteredBy: [], redactions: [], nextCursor: nil, errorNotes: [] ) - let line = formatMetaLine(meta) - XCTAssertFalse(line.contains("redactions")) - XCTAssertFalse(line.contains("next_cursor")) + let line = try formatMetaLine(meta) + XCTAssertTrue(line.contains("\"redactions\":[]")) + XCTAssertTrue(line.contains("\"next_cursor\":null")) XCTAssertFalse(line.contains("error_notes")) } + func testMetaEnvelopeFilteredBySortedByFormatter() throws { + // Canonical formatter sorts filteredBy alphabetically — caller order + // is no longer load-bearing. Tools may still pre-sort for clarity; + // outcome is identical. + let meta = MetaEnvelope( + matchedTotal: 0, + returned: 0, + latencyMs: 0, + filteredBy: ["z=1", "a=2", "m=3"] + ) + let line = try formatMetaLine(meta) + XCTAssertTrue(line.contains("\"filtered_by\":[\"a=2\",\"m=3\",\"z=1\"]")) + } + func testMetaQueryDigestIsDeterministic() { let a = metaQueryDigest("hello world") let b = metaQueryDigest("hello world") @@ -160,10 +196,14 @@ final class MetaEnvelopeTests: XCTestCase { XCTAssertEqual(meta?["matched_total"] as? Int, meta?["returned"] as? Int) } - func testListNotesNextCursorOmitted() async { + func testListNotesNextCursorAlwaysPresentAsNull() async { + // Per canonical MCPHelpers contract: next_cursor is REQUIRED; offset- + // paginated tools emit JSON `null`. Was omit-when-nil in hand-rolled v1 + // (DD-338 W5 OQ-6) — canonical promotes to always-present. let text = await call("apple_notes_list_notes", ["folder_id": .int(10)]) let meta = parseMeta(from: text) - XCTAssertNil(meta?["next_cursor"]) + XCTAssertNotNil(meta) + XCTAssertTrue(meta?["next_cursor"] is NSNull) } // MARK: - apple_notes_search_notes (B) @@ -218,4 +258,27 @@ final class MetaEnvelopeTests: XCTestCase { XCTAssertTrue(text.contains("\"query\"")) XCTAssertTrue(text.contains("\n\n_meta: ")) } + + // MARK: - canonical-shape gate (DD-338 W5 acceptance criterion) + + func testFormatMetaLineEmitsCanonicalMCPHelpersShape() throws { + // Gate against future drift between this blade's emission and the + // canonical MCPHelpers wire shape. The canonical input/output pair + // mirrors the MCPHelpers Swift v0.1.0 own test fixture: required + // fields populated, optional errorNotes omitted, filtered_by sorted. + let meta = MetaEnvelope( + matchedTotal: 100, + returned: 25, + latencyMs: 12, + filteredBy: ["b=2", "a=1"], + redactions: [], + nextCursor: nil, + errorNotes: nil + ) + let line = try formatMetaLine(meta) + XCTAssertEqual( + line, + #"_meta: {"matched_total":100,"returned":25,"filtered_by":["a=1","b=2"],"latency_ms":12,"redactions":[],"next_cursor":null}"# + ) + } } From 72ca01b201d2a9024bd7c15f968468ce7e054890 Mon Sep 17 00:00:00 2001 From: Piers Date: Sun, 24 May 2026 11:45:48 +1000 Subject: [PATCH 2/2] fix(ci): drop macos-14 from matrix; Swift 5.10 incompat with new SPM deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the sister apple-mail-blade-mcp commit 6fc526a. The DD-338 W5 SPM dep on stallari-mcp-helpers-swift transitively requires swift-tools-version 6.0+; macos-14 GH runner ships Swift 5.10 by default. macos-15 ships Xcode 16 / Swift 6.0+ — keeping it as the single CI target. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d593df6..736305b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,12 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15] + os: [macos-15] + # macos-14 dropped: ships Swift 5.10 by default; Package.swift requires + # swift-tools-version 6.0+ (the DD-338 W5 SPM dep on stallari-mcp-helpers-swift + # introduces a transitively-required 6.0 toolchain). Re-enable with + # maxim-lobanov/setup-xcode@v1 + xcode-version: '16.0' if Swift 5.10 + # backport is ever in scope. steps: - name: Checkout