diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 748b5517e..e6022264e 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -11,10 +11,15 @@ ensure_tools() { "${ROOT_DIR}/Scripts/install_lint_tools.sh" } +check_codex_parser_hash() { + "${ROOT_DIR}/Scripts/regenerate-codex-parser-hash.sh" --check +} + cmd="${1:-lint}" case "$cmd" in lint) + check_codex_parser_hash ensure_tools "${BIN_DIR}/swiftformat" Sources Tests --lint "${BIN_DIR}/swiftlint" --strict diff --git a/Scripts/regenerate-codex-parser-hash.sh b/Scripts/regenerate-codex-parser-hash.sh new file mode 100755 index 000000000..91e4863e7 --- /dev/null +++ b/Scripts/regenerate-codex-parser-hash.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="${ROOT_DIR}/Sources/CodexBarCore/Vendored/CostUsage" +OUTPUT_FILE="${ROOT_DIR}/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift" +MODE="${1:-write}" + +case "$MODE" in + write | --write) + CHECK_ONLY=0 + ;; + check | --check) + CHECK_ONLY=1 + ;; + *) + echo "Usage: $0 [write|--write|check|--check]" >&2 + exit 2 + ;; +esac + +if command -v shasum >/dev/null 2>&1; then + HASH_CMD=(shasum -a 256) +elif command -v sha256sum >/dev/null 2>&1; then + HASH_CMD=(sha256sum) +else + echo "error: shasum or sha256sum is required" >&2 + exit 1 +fi + +FILE_LIST="$(mktemp)" +trap 'rm -f "$FILE_LIST"' EXIT + +find "$SOURCE_DIR" \ + -type f \ + -name '*.swift' \ + ! -name '*Claude*' \ + -print | + sed "s#^${ROOT_DIR}/##" | + LC_ALL=C sort >"$FILE_LIST" + +HASH="$( + while IFS= read -r file; do + printf '== %s ==\n' "$file" + cat "${ROOT_DIR}/${file}" + printf '\n' + done <"$FILE_LIST" | "${HASH_CMD[@]}" +)" +HASH="${HASH%% *}" +SHORT_HASH="${HASH:0:16}" + +render_generated() { + cat <"$EXPECTED_FILE" + if ! cmp -s "$EXPECTED_FILE" "$OUTPUT_FILE"; then + echo "error: ${OUTPUT_FILE#${ROOT_DIR}/} is stale. Run Scripts/regenerate-codex-parser-hash.sh and commit the result." >&2 + if [[ -f "$OUTPUT_FILE" ]]; then + diff -u "$OUTPUT_FILE" "$EXPECTED_FILE" >&2 || true + else + diff -u /dev/null "$EXPECTED_FILE" >&2 || true + fi + exit 1 + fi + echo "Codex parser hash is current (${SHORT_HASH})" + exit 0 +fi + +mkdir -p "$(dirname "$OUTPUT_FILE")" +render_generated >"$OUTPUT_FILE" + +echo "Updated ${OUTPUT_FILE#${ROOT_DIR}/} to ${SHORT_HASH}" diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift new file mode 100644 index 000000000..512965b5b --- /dev/null +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -0,0 +1,5 @@ +// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. + +enum CodexParserHash { + static let value = "e8e8a42de095aa72" +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 27082a80e..1d39db8aa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -25,25 +25,41 @@ enum CostUsageCacheIO { .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) } - static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache { + static func load( + provider: UsageProvider, + cacheRoot: URL? = nil, + producerKey: String? = nil) -> CostUsageCache + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - if let decoded = self.loadCache(at: url) { return decoded } + let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) + if let decoded = self.loadCache(at: url, expectedProducerKey: expectedProducerKey) { return decoded } return CostUsageCache() } - private static func loadCache(at url: URL) -> CostUsageCache? { + private static func loadCache(at url: URL, expectedProducerKey: String?) -> CostUsageCache? { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } + if let expectedProducerKey { + guard decoded.producerKey == expectedProducerKey else { return nil } + } return decoded } - static func save(provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil) { + static func save( + provider: UsageProvider, + cache: CostUsageCache, + cacheRoot: URL? = nil, + producerKey: String? = nil) + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var cache = cache + cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() do { @@ -57,10 +73,19 @@ enum CostUsageCacheIO { try? FileManager.default.removeItem(at: tmp) } } + + static func currentProducerKey( + provider: UsageProvider, + parserHash: String = CodexParserHash.value) -> String? + { + guard provider == .codex else { return nil } + return "\(provider.rawValue):cu:p\(parserHash)" + } } struct CostUsageCache: Codable { var version: Int = 1 + var producerKey: String? var lastScanUnixMs: Int64 = 0 var scanSinceKey: String? var scanUntilKey: String? diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 377ead4b6..f71c60e25 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -13,4 +13,125 @@ struct CostUsageCacheTests { #expect(codexURL.lastPathComponent == "codex-v8.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } + + @Test + func `cache load requires matching producer key`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.producerKey == "codex:cu:p1111111111111111") + #expect(loaded.lastScanUnixMs == 123) + #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) + + let stale = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p2222222222222222") + #expect(stale.lastScanUnixMs == 0) + #expect(stale.files.isEmpty) + #expect(stale.days.isEmpty) + } + + @Test + func `legacy cache without producer key is ignored`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let legacy = """ + { + "version": 1, + "lastScanUnixMs": 999, + "files": {}, + "days": { + "2026-05-18": { + "gpt-5": [1, 0, 0] + } + } + } + """ + try legacy.write(to: url, atomically: false, encoding: .utf8) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.days.isEmpty) + } + + @Test + func `non codex cache does not require producer key`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let legacy = """ + { + "version": 1, + "lastScanUnixMs": 999, + "files": {}, + "days": { + "2026-05-18": { + "claude-sonnet-4-5": [1, 0, 0] + } + } + } + """ + try legacy.write(to: url, atomically: false, encoding: .utf8) + + let loaded = CostUsageCacheIO.load(provider: .claude, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 999) + #expect(loaded.days["2026-05-18"]?["claude-sonnet-4-5"] == [1, 0, 0]) + } + + @Test + func `current producer key uses generated parser hash for codex only`() { + let codexKey = CostUsageCacheIO.currentProducerKey( + provider: .codex, + parserHash: "abc1234567890def") + let standaloneKey = CostUsageCacheIO.currentProducerKey( + provider: .claude, + parserHash: "abc1234567890def") + + #expect(codexKey == "codex:cu:pabc1234567890def") + #expect(standaloneKey == nil) + } + + @Test + func `generated parser hash is stable short lowercase hex`() { + let hash = CodexParserHash.value + + #expect(hash.range(of: #"^[0-9a-f]{16}$"#, options: .regularExpression) != nil) + #expect(CostUsageCacheIO.currentProducerKey(provider: .codex) == "codex:cu:p\(hash)") + } + + private func makeTemporaryCacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } }