Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "che-apple-mail-mcp",
"version": "2.46.1",
"description": "Apple Mail MCP server plugin — archive-mail SOP commands, compose/confirmation rules, and the signed-binary wrapper. Versions and the release narrative live in plugin/.claude-plugin/plugin.json and CHANGELOG.md; this entry deliberately carries no binary_version (#335), and its version is pinned equal to plugin.json by ManifestVersionTests.",
"description": "Apple Mail MCP server plugin — archive-mail SOP commands, compose/confirmation rules, and the signed-binary wrapper. Current versions live in plugin/.claude-plugin/plugin.json; the release narrative lives in plugin/CHANGELOG.md (#396). This entry deliberately carries no binary_version (#335), and its version is pinned equal to plugin.json by ManifestVersionTests.",
"author": {
"name": "Che Cheng"
},
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ ship an unsigned binary). See README "Signing & Notarization" for one-time
setup and why signing is what keeps Full Disk Access working across upgrades
([#211](https://github.com/PsychQuant/che-apple-mail-mcp/issues/211)).

After the script finishes, bump `plugin/.claude-plugin/plugin.json`'s **`version`
and `binary_version`** in this repo — the installed-plugin cache is keyed by
After the script finishes, **write the shell release entry in
`plugin/CHANGELOG.md`** — it is the single source for plugin-shell narrative
(#396), and `ManifestVersionTests` pins its newest released header to
`plugin.json`'s `version`, so a bump without an entry fails the suite. Then bump
`plugin/.claude-plugin/plugin.json`'s **`version` and `binary_version`** in this repo — the installed-plugin cache is keyed by
`version`, so bumping only `binary_version` ships nothing (#335 verify). Mirror
the new `version` into `.claude-plugin/marketplace.json`'s plugin entry
(`ManifestVersionTests` pins the two equal; the root manifest deliberately
Expand Down
201 changes: 201 additions & 0 deletions Tests/CheAppleMailMCPTests/ManifestVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,205 @@ final class ManifestVersionTests: XCTestCase {
"the marketplace entry must NOT declare binary_version — plugin.json is the "
+ "single source for the binary pin (#335's design decision).")
}

func testDescriptionsCarryNoVersionNarrative() throws {
// #396: the 18.8KB description-as-changelog convention is dead — narrative
// lives in plugin/CHANGELOG.md. Round-2 hardening (#400 verify): every
// description-carrying surface must EXIST (a deleted key must not pass
// vacuously), stay short, and carry no semver-shaped token at all — the
// round-1 literal markers ("Shell v", "binary stays") only locked the
// last incident's exact strings, not the class.
let root = repoRoot()
var inspected = 0

func check(_ desc: String, at label: String) {
inspected += 1
XCTAssertFalse(desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
"\(label): description is empty")
XCTAssertLessThan(desc.count, 1000,
"\(label): description is \(desc.count) chars — narrative belongs in plugin/CHANGELOG.md (#396)")
// Token-bounded so an IP address is not mistaken for a version.
// The lookahead is (?!\.?[0-9A-Za-z]), NOT (?![0-9A-Za-z.]): the
// latter let a version at the end of a sentence ("ships v2.28.0.")
// escape the ban, because the trailing period satisfied it. Found
// by mutation-testing this guard.
XCTAssertNil(desc.range(of: #"(?<![0-9A-Za-z.])v?[0-9]+\.[0-9]+\.[0-9]+(?!\.?[0-9A-Za-z])"#,
options: .regularExpression),
"\(label): description contains a semver-shaped token — any version claim here "
+ "starts lying the release after it was written (#396)")
}

let pjData = try Data(contentsOf: root.appendingPathComponent("plugin/.claude-plugin/plugin.json"))
let pj = try XCTUnwrap(try JSONSerialization.jsonObject(with: pjData) as? [String: Any])
check(try XCTUnwrap(pj["description"] as? String, "plugin.json must declare a description"),
at: "plugin.json")

let mktData = try Data(contentsOf: root.appendingPathComponent(".claude-plugin/marketplace.json"))
let mkt = try XCTUnwrap(try JSONSerialization.jsonObject(with: mktData) as? [String: Any])
check(try XCTUnwrap(mkt["description"] as? String, "marketplace.json must declare a top-level description"),
at: "marketplace.json (top-level)")
// By NAME, not `plugins.first`: the moment this manifest lists a second
// plugin, position stops identifying anything and the guard silently
// moves to whichever entry happens to be first (#396 verify).
let pluginName = try XCTUnwrap(pj["name"] as? String)
let plugins = try XCTUnwrap(mkt["plugins"] as? [[String: Any]])
let entry = try XCTUnwrap(plugins.first { ($0["name"] as? String) == pluginName },
"marketplace.json lists no entry named '\(pluginName)'")
check(try XCTUnwrap(entry["description"] as? String, "marketplace entry must declare a description"),
at: "marketplace.json (entry)")

// Not `== 3`, which would be true however few surfaces existed: assert
// each named surface was reached.
XCTAssertEqual(inspected, 3,
"expected plugin.json + marketplace top-level + marketplace entry '\(pluginName)' "
+ "to be inspected; got \(inspected)")
}

func testPluginChangelogNewestMatchesPluginVersion() throws {
// #396 round 2: plugin/CHANGELOG.md is the anointed single shell-narrative
// source, but a single source with no owner rots (this repo's #311 lesson;
// at anointing time it was already two minor versions behind). Pin its
// newest released header to plugin.json's `version` — a shell release that
// forgets its changelog entry now fails the suite.
let probe = try ChangelogParserTests.run(
["newest"], changelog: repoRoot().appendingPathComponent("plugin/CHANGELOG.md").path)
guard probe.status == 0, !probe.out.isEmpty else {
XCTFail("no released ## [x.y.z] header found in plugin/CHANGELOG.md")
return
}
let pjData = try Data(contentsOf: repoRoot().appendingPathComponent("plugin/.claude-plugin/plugin.json"))
let pj = try XCTUnwrap(try JSONSerialization.jsonObject(with: pjData) as? [String: Any])
let shellVersion = try XCTUnwrap(pj["version"] as? String)
XCTAssertEqual(probe.out, shellVersion,
"plugin/CHANGELOG.md newest released header ('\(probe.out)') must match plugin.json "
+ "version ('\(shellVersion)') — the single narrative source needs an owner (#396); "
+ "write the release entry alongside the version bump.")

// Owning the header string alone is not owning the narrative: an empty
// `## [x.y.z]` section satisfies the equality above while saying
// nothing (#396 verify). Require prose under the newest header.
let changelog = try String(
contentsOf: repoRoot().appendingPathComponent("plugin/CHANGELOG.md"), encoding: .utf8)
let lines = changelog.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
guard let headerIndex = lines.firstIndex(where: { $0.hasPrefix("## [\(probe.out)]") }) else {
XCTFail("could not locate the '## [\(probe.out)]' section body")
return
}
var body: [String] = []
for line in lines[(headerIndex + 1)...] {
if line.hasPrefix("## ") { break }
body.append(line)
}
let substantive = body.filter {
let s = $0.trimmingCharacters(in: .whitespaces)
return !s.isEmpty && !s.hasPrefix("###")
}
XCTAssertFalse(substantive.isEmpty,
"plugin/CHANGELOG.md's newest section '[\(probe.out)]' has a header and no content — "
+ "the guard owns the version string, but the point is owning the narrative (#396).")
}

func testPluginChangelogIsOrderedAndComplete() throws {
// #396 verify round 3. The round-2 backfill shipped three defects that
// NOTHING in this suite could see, because every guard here checked the
// newest entry only:
// 1. `[2.19.7]` was inserted ABOVE `[2.20.0]` — the version ordering
// silently broke;
// 2. nine dates were an invented one-per-day descending sequence
// rather than looked-up values (2.29.0–2.33.0 all shipped on the
// SAME day, ~8 hours apart);
// 3. `2.11.0` / `2.8.0` / `2.7.0` / `2.5.1` had no entry AND sat
// outside both declared gaps, while the file carried a sentence
// certifying that no such version existed.
//
// Dates cannot be re-derived offline — they live in another repo's
// commit history. What CAN be enforced locally is the structure that
// makes a fabricated or misfiled entry visible: strictly descending
// versions, non-increasing dates, and no skipped minor. All three were
// violated by the round-2 file and all three are cheap to check.
let text = try String(
contentsOf: repoRoot().appendingPathComponent("plugin/CHANGELOG.md"), encoding: .utf8)

let re = try NSRegularExpression(
pattern: #"^## \[(\d+)\.(\d+)\.(\d+)\] - (\d{4}-\d{2}-\d{2})$"#,
options: [.anchorsMatchLines])
var entries: [(v: [Int], date: String, raw: String)] = []
let range = NSRange(text.startIndex..., in: text)
re.enumerateMatches(in: text, range: range) { match, _, _ in
guard let match,
let r1 = Range(match.range(at: 1), in: text),
let r2 = Range(match.range(at: 2), in: text),
let r3 = Range(match.range(at: 3), in: text),
let r4 = Range(match.range(at: 4), in: text) else { return }
let v = [Int(text[r1])!, Int(text[r2])!, Int(text[r3])!]
entries.append((v, String(text[r4]), "\(v[0]).\(v[1]).\(v[2])"))
}
XCTAssertGreaterThan(entries.count, 40,
"expected the full release history in plugin/CHANGELOG.md, found \(entries.count) entries")

// 1. strictly descending versions
for i in 0..<(entries.count - 1) {
let a = entries[i], b = entries[i + 1]
XCTAssertTrue(a.v.lexicographicallyPrecedes(b.v) == false && a.v != b.v,
"plugin/CHANGELOG.md: [\(a.raw)] appears before [\(b.raw)] — release headers "
+ "must strictly descend. An entry inserted at the wrong place reads as a "
+ "different release history than the one that happened (#396 round 3).")
}

// 2. non-increasing dates (an older release cannot post-date a newer one)
for i in 0..<(entries.count - 1) {
let a = entries[i], b = entries[i + 1]
XCTAssertTrue(a.date >= b.date,
"plugin/CHANGELOG.md: [\(a.raw)] is dated \(a.date) but the older [\(b.raw)] "
+ "is dated \(b.date) — dates must not increase going down the file.")
}

// 3. no skipped minor between the oldest and newest entry. The file
// claims completeness from its floor upward, so a hole is either a
// missing entry or an unpublished version that must be named here.
let knownAbsentMinors: Set<Int> = [] // none as of 2.46.1; add with a reason
let minors = Set(entries.map { $0.v[1] })
let lo = entries.map { $0.v[1] }.min()!, hi = entries.map { $0.v[1] }.max()!
for minor in lo...hi where !minors.contains(minor) && !knownAbsentMinors.contains(minor) {
XCTFail("plugin/CHANGELOG.md skips 2.\(minor).x with no entry and no declared "
+ "absence — either backfill it (version/date/binary pin are recoverable from "
+ "the aggregator's plugin.json history) or add it to knownAbsentMinors with a "
+ "reason (#396 round 3).")
}
}

func testBinaryPinNamesAShippedBinary() throws {
// #396 verify: `binary_version` is the field that decides which binary
// users actually download, and NOTHING owned it. This PR deletes the
// surfaces that used to cross-check it by eye (README's "shell vX +
// binary vY" pairs, and the description narrative), so without a
// mechanical check the redundancy is removed and nothing replaces it.
//
// The repo has already paid for this once — plugin/CHANGELOG [2.44.1]
// records v2.44.0 shipping an SOP documented against binary v2.26.0+
// while plugin.json still pinned 2.25.0 and marketplace.json 2.24.0.
// Users ran a binary without the fix; 24 self-sent messages were
// mislabelled. The failure was silent.
//
// A pin can never legitimately name a binary that was never released,
// so pin it to the ROOT changelog — the binary's own single source.
let pjData = try Data(contentsOf: repoRoot().appendingPathComponent("plugin/.claude-plugin/plugin.json"))
let pj = try XCTUnwrap(try JSONSerialization.jsonObject(with: pjData) as? [String: Any])
let binaryPin = try XCTUnwrap(pj["binary_version"] as? String,
"plugin.json must declare binary_version — the wrapper downloads whatever it names")

let rootChangelog = try String(
contentsOf: repoRoot().appendingPathComponent("CHANGELOG.md"), encoding: .utf8)
let released = rootChangelog
.split(separator: "\n", omittingEmptySubsequences: false)
.compactMap { line -> String? in
guard line.hasPrefix("## ["), let close = line.firstIndex(of: "]") else { return nil }
let v = String(line[line.index(line.startIndex, offsetBy: 4)..<close])
return v == "Unreleased" ? nil : v
}
XCTAssertTrue(released.contains(binaryPin),
"plugin.json pins binary_version '\(binaryPin)', which has no released section in the "
+ "root CHANGELOG.md — the wrapper would download a tag that was never shipped, or the "
+ "pin is a typo. Released binary versions: \(released.prefix(5).joined(separator: ", "))…")
}
}
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json

Large diffs are not rendered by default.

Loading