From 271165b28c41c589cc420822eaeb81baf8d55ee7 Mon Sep 17 00:00:00 2001 From: Jipeng Zhang Date: Mon, 20 Jul 2026 19:37:44 +0700 Subject: [PATCH] Add generated route contract tooling --- CHANGELOG.md | 11 ++ README.md | 22 ++- README.zh-CN.md | 24 ++- RouteContracts.json | 85 ++++++---- Scripts/generate_route_catalog.swift | 238 +++++++++++++++++++++++++++ Scripts/update_route_contracts.swift | 193 ++++++++++++++++++++++ URLRouter.xcodeproj/project.pbxproj | 28 ++++ docs/architecture.md | 6 +- docs/architecture.zh-CN.md | 5 +- docs/production-governance.md | 34 +++- docs/production-governance.zh-CN.md | 29 +++- docs/route-catalog.html | 71 ++++++++ 12 files changed, 706 insertions(+), 40 deletions(-) create mode 100644 Scripts/generate_route_catalog.swift create mode 100644 Scripts/update_route_contracts.swift create mode 100644 docs/route-catalog.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b112e..24db26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes are documented here. This project follows [Semantic Versioni ## [Unreleased] +## [2.5.0] - 2026-07-20 + +### Added + +- Added App-root route-contract generation and validation scripts that discover local Feature Packages, update the single `RouteContracts.json`, and generate a searchable local route catalog. +- Added a URLRouterDemo build phase that verifies route contracts and refreshes the route catalog on every build. + +### Changed + +- Documented the App-root ownership model for route contracts and the Xcode/CI integration workflow in English and Chinese. + ## [2.4.7] - 2026-07-19 ### Changed diff --git a/README.md b/README.md index 00aeade..9b2befe 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Add `URLRouter` to the App target and to any Feature Package that declares a ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.4.7") + .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.5.0") ] ``` @@ -172,9 +172,29 @@ lifecycle, telemetry, and coordinated concurrent routes. ```bash swift test +swift Scripts/update_route_contracts.swift swift Scripts/validate_route_contract.swift RouteContracts.json ``` +`update_route_contracts.swift` scans every Swift Package in the App root that +declares a `RouteModule` and creates or updates the single App-owned +`RouteContracts.json`. Feature Packages do not keep their own copies. The +generator fails when it cannot reliably infer a route, rather than publishing a +guessed contract. + +When URLRouter lives outside the App repository, point the script at the App +root (all relative paths below are resolved from it): + +```bash +swift /path/to/URLRouter/Scripts/update_route_contracts.swift \ + --app-root /path/to/MyApp \ + --output RouteContracts.json +swift /path/to/URLRouter/Scripts/generate_route_catalog.swift \ + --app-root /path/to/MyApp \ + --contracts RouteContracts.json \ + --output docs/route-catalog.html +``` + The core library and `RouterHost` support all four listed Apple platforms. On macOS, SwiftUI presents a `fullScreenCover` route as a sheet because macOS does not provide `fullScreenCover`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c5fac8..9b44ab6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ Package。 ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.4.7") + .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.5.0") ] ``` @@ -168,9 +168,31 @@ URL builder、Universal Link、Tab 和带版本的路由协议。 ```bash swift test +swift Scripts/update_route_contracts.swift swift Scripts/validate_route_contract.swift RouteContracts.json +swift Scripts/generate_route_catalog.swift ``` +`update_route_contracts.swift` 会扫描当前 App 根目录内所有声明 `RouteModule` 的 Swift +Package,并生成或更新根目录唯一的 `RouteContracts.json`。随后,最后一条命令会扫描 +同一批 Feature,并在 +`docs/route-catalog.html` 生成可搜索的本地路由目录:URL 模板、路径/查询参数、目标页面、 +Feature package 和展示方式都会列出。 + +当脚本放在 URLRouter 包内、但要扫描另一个 App 时,指定 App 根目录即可(契约和输出路径 +均相对于该根目录): + +```bash +swift /path/to/URLRouter/Scripts/generate_route_catalog.swift \ + --app-root /path/to/MyApp \ + --contracts RouteContracts.json \ + --output docs/route-catalog.html +``` + +同一个 App 根目录只能有一份 `RouteContracts.json`;Feature Package 不应各自维护副本。 +生成器会识别标准的 `RouteModule` 解析器、`ModuleRoute` 与 URL builder 写法;无法可靠推导 +路径或参数时会失败,而不会生成猜测的契约。 + 核心库和 `RouterHost` 支持上述四个平台。macOS 的 SwiftUI 没有 `fullScreenCover`,因此该展示方式会自动以 sheet 呈现。 diff --git a/RouteContracts.json b/RouteContracts.json index 3bee683..47f634b 100644 --- a/RouteContracts.json +++ b/RouteContracts.json @@ -1,41 +1,68 @@ { - "schemaVersion": 1, - "supportedVersions": ["1"], - "routes": [ + "routes" : [ { - "moduleID": "content", - "routeID": "article", - "pathTemplate": "/articles/:id", - "presentations": ["push"], - "requiredQueryItems": ["presentation", "version"] + "moduleID" : "navigation", + "pathTemplate" : "/", + "presentations" : [ + "tab" + ], + "requiredQueryItems" : [ + "presentation", + "version" + ], + "routeID" : "home" }, { - "moduleID": "navigation", - "routeID": "home", - "pathTemplate": "/", - "presentations": ["tab"], - "requiredQueryItems": ["presentation", "version"] + "moduleID" : "navigation", + "pathTemplate" : "/favorites", + "presentations" : [ + "tab" + ], + "requiredQueryItems" : [ + "presentation", + "version" + ], + "routeID" : "favorites" }, { - "moduleID": "navigation", - "routeID": "favorites", - "pathTemplate": "/favorites", - "presentations": ["tab"], - "requiredQueryItems": ["presentation", "version"] + "moduleID" : "navigation", + "pathTemplate" : "/settings", + "presentations" : [ + "sheet" + ], + "requiredQueryItems" : [ + "presentation", + "version" + ], + "routeID" : "settings" }, { - "moduleID": "navigation", - "routeID": "settings", - "pathTemplate": "/settings", - "presentations": ["sheet"], - "requiredQueryItems": ["presentation", "version"] + "moduleID" : "navigation", + "pathTemplate" : "/sign-in", + "presentations" : [ + "fullScreenCover" + ], + "requiredQueryItems" : [ + "presentation", + "version" + ], + "routeID" : "signIn" }, { - "moduleID": "navigation", - "routeID": "signIn", - "pathTemplate": "/sign-in", - "presentations": ["fullScreenCover"], - "requiredQueryItems": ["presentation", "version"] + "moduleID" : "content", + "pathTemplate" : "/articles/:id", + "presentations" : [ + "push" + ], + "requiredQueryItems" : [ + "presentation", + "version" + ], + "routeID" : "article" } + ], + "schemaVersion" : 1, + "supportedVersions" : [ + "1" ] -} +} \ No newline at end of file diff --git a/Scripts/generate_route_catalog.swift b/Scripts/generate_route_catalog.swift new file mode 100644 index 0000000..17c42af --- /dev/null +++ b/Scripts/generate_route_catalog.swift @@ -0,0 +1,238 @@ +import Foundation + +struct RouteContractManifest: Decodable { + let schemaVersion: Int + let supportedVersions: [String] + let routes: [RouteContract] +} + +struct RouteContract: Decodable { + let moduleID: String + let routeID: String + let pathTemplate: String + let presentations: [String] + let requiredQueryItems: [String] +} + +struct FeaturePackage { + let name: String + let path: String + let moduleIDs: Set + let destinations: [String: String] +} + +struct CatalogRoute { + let contract: RouteContract + let feature: FeaturePackage? + let destination: String +} + +let fileManager = FileManager.default + +struct Configuration { + let appRoot: URL + let contractsURL: URL + let outputURL: URL +} + +func absoluteURL(_ path: String, relativeTo base: URL) -> URL { + URL(fileURLWithPath: path, relativeTo: base).standardizedFileURL +} + +func configuration(arguments: [String]) throws -> Configuration { + let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + var appRoot = workingDirectory + var contractsPath = "RouteContracts.json" + var outputPath = "docs/route-catalog.html" + var index = 0 + + while index < arguments.count { + let option = arguments[index] + if option == "--help" || option == "-h" { + print("Usage: swift generate_route_catalog.swift [--app-root ] [--contracts ] [--output ]") + exit(0) + } + guard ["--app-root", "--contracts", "--output"].contains(option), index + 1 < arguments.count else { + throw NSError(domain: "RouteCatalog", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown or incomplete option: \(option). Use --help for usage."]) + } + index += 1 + switch option { + case "--app-root": appRoot = absoluteURL(arguments[index], relativeTo: workingDirectory) + case "--contracts": contractsPath = arguments[index] + case "--output": outputPath = arguments[index] + default: break + } + index += 1 + } + + return Configuration( + appRoot: appRoot, + contractsURL: absoluteURL(contractsPath, relativeTo: appRoot), + outputURL: absoluteURL(outputPath, relativeTo: appRoot) + ) +} + +func contents(of path: String) throws -> String { + try String(contentsOfFile: path, encoding: .utf8) +} + +func matches(_ pattern: String, in text: String) -> [[String]] { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(text.startIndex..., in: text) + return expression.matches(in: text, range: range).map { match in + (0.. String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") +} + +func swiftFiles(in directory: URL) -> [URL] { + guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey]) else { + return [] + } + return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } +} + +func destinationMap(in source: String) -> [String: String] { + var result: [String: String] = [:] + // Handles `guard route.routeID == "detail" ... return AnyView(DetailView(...))`. + for match in matches(#"route\.routeID\s*==\s*\"([^\"]+)\"[\s\S]{0,400}?AnyView\(\s*([A-Za-z_][A-Za-z0-9_]*)"#, in: source) where match.count >= 3 { + result[match[1]] = match[2] + } + // Handles `case "settings": AnyView(SettingsView())` in a destination switch. + for match in matches(#"case\s+\"([^\"]+)\"\s*:\s*(?:return\s+)?AnyView\(\s*([A-Za-z_][A-Za-z0-9_]*)"#, in: source) where match.count >= 3 { + result[match[1]] = match[2] + } + return result +} + +func featurePackages(at root: URL) -> [FeaturePackage] { + let ignoredDirectories: Set = [".build", ".git", "DerivedData", "Pods", "Carthage", "SourcePackages"] + guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey]) else { + return [] + } + + var packageURLs: [URL] = [] + for case let url as URL in enumerator { + if ignoredDirectories.contains(url.lastPathComponent) { + enumerator.skipDescendants() + continue + } + if url.lastPathComponent == "Package.swift" { + packageURLs.append(url.deletingLastPathComponent()) + } + } + + return packageURLs.compactMap { packageURL -> FeaturePackage? in + let packageManifest = packageURL.appendingPathComponent("Package.swift") + guard let packageText = try? contents(of: packageManifest.path) else { return nil } + let name = matches(#"name:\s*\"([^\"]+)\""#, in: packageText).first?[1] ?? packageURL.lastPathComponent + // A package may contain other local packages. Only inspect its own Sources directory. + let sourceDirectory = packageURL.appendingPathComponent("Sources", isDirectory: true) + let source = swiftFiles(in: sourceDirectory).compactMap { try? contents(of: $0.path) }.joined(separator: "\n") + let literalIDs = matches(#"RouteModule\(\s*id:\s*\"([^\"]+)\""#, in: source).compactMap { $0.count > 1 ? $0[1] : nil } + let declaredIDs = matches(#"(?:public\s+)?static\s+let\s+id\s*=\s*\"([^\"]+)\""#, in: source).compactMap { $0.count > 1 ? $0[1] : nil } + let moduleIDs = Set(literalIDs + declaredIDs) + // Only packages that declare a RouteModule are Feature packages for this catalog. + guard !moduleIDs.isEmpty else { return nil } + return FeaturePackage(name: name, path: packageURL.path, moduleIDs: moduleIDs, destinations: destinationMap(in: source)) + } +} + +func sampleURL(for route: RouteContract, versions: [String]) -> String { + let path = route.pathTemplate.replacingOccurrences(of: #":([A-Za-z][A-Za-z0-9_]*)"#, with: "{$1}", options: .regularExpression) + let queryItems = route.requiredQueryItems.map { item -> String in + switch item { + case "presentation": return "presentation=" + (route.presentations.first ?? "{presentation}") + case "version": return "version=" + (versions.first ?? "{version}") + default: return "\(item)={\(item)}" + } + } + return "https://example.com\(path)" + (queryItems.isEmpty ? "" : "?" + queryItems.joined(separator: "&")) +} + +func parameters(for route: RouteContract) -> String { + let pathParameters = matches(#":([A-Za-z][A-Za-z0-9_]*)"#, in: route.pathTemplate).compactMap { $0.count > 1 ? "\($0[1])(路径)" : nil } + let queryParameters = route.requiredQueryItems.map { "\($0)(查询,必填)" } + return (pathParameters + queryParameters).joined(separator: "
") +} + +do { + let configuration = try configuration(arguments: Array(CommandLine.arguments.dropFirst())) + let manifestData = try Data(contentsOf: configuration.contractsURL) + let manifest = try JSONDecoder().decode(RouteContractManifest.self, from: manifestData) + guard manifest.schemaVersion == 1 else { throw NSError(domain: "RouteCatalog", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unsupported route contract schema version."]) } + + let features = featurePackages(at: configuration.appRoot) + let catalog = manifest.routes.map { route -> CatalogRoute in + let feature = features.first { $0.moduleIDs.contains(route.moduleID) } + let destination = feature?.destinations[route.routeID] ?? "由 App 容器处理(Feature 未提供 destination View)" + return CatalogRoute(contract: route, feature: feature, destination: destination) + } + + let rows = catalog.map { item in + let route = item.contract + return """ + + \(html(sampleURL(for: route, versions: manifest.supportedVersions))) + \(parameters(for: route)) + \(html(item.destination)) + \(html(item.feature?.name ?? "未找到对应 Feature package")) + \(html(route.moduleID))/\(html(route.routeID)) + \(html(route.presentations.joined(separator: "、"))) + + """ + }.joined(separator: "\n") + + let page = """ + + + + + + URLRouter 路由目录 + + + +

URLRouter 路由目录

+

\(html(configuration.contractsURL.lastPathComponent)) 与 App 内的 Feature Package 自动生成 · 共 \(catalog.count) 条路由

+ + + + \(rows) +
URL 模板参数目标页面Feature package路由 ID展示方式
+ + + + """ + + try fileManager.createDirectory(at: configuration.outputURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try page.write(to: configuration.outputURL, atomically: true, encoding: .utf8) + print("Generated \(configuration.outputURL.path) (\(catalog.count) routes across \(features.count) Feature packages).") +} catch { + fputs("Route catalog generation failed: \(error.localizedDescription)\n", stderr) + exit(1) +} diff --git a/Scripts/update_route_contracts.swift b/Scripts/update_route_contracts.swift new file mode 100644 index 0000000..dad82ca --- /dev/null +++ b/Scripts/update_route_contracts.swift @@ -0,0 +1,193 @@ +import Foundation + +struct RouteContractManifest: Codable, Equatable { + let schemaVersion: Int + let supportedVersions: [String] + let routes: [RouteContract] +} + +struct RouteContract: Codable, Equatable { + let moduleID: String + let routeID: String + let pathTemplate: String + let presentations: [String] + let requiredQueryItems: [String] +} + +struct FeatureSource { + let moduleID: String + let source: String +} + +let fileManager = FileManager.default + +func matches(_ pattern: String, in text: String) -> [[String]] { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(text.startIndex..., in: text) + return expression.matches(in: text, range: range).map { match in + (0.. [URL] { + guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey]) else { return [] } + let ignored: Set = [".build", ".git", "DerivedData", "Pods", "Carthage", "SourcePackages"] + var result: [URL] = [] + for case let url as URL in enumerator { + if ignored.contains(url.lastPathComponent) { enumerator.skipDescendants(); continue } + if url.pathExtension == "swift" { result.append(url) } + } + return result +} + +func absoluteURL(_ path: String, relativeTo base: URL) -> URL { + URL(fileURLWithPath: path, relativeTo: base).standardizedFileURL +} + +func usage() { + print("Usage: swift update_route_contracts.swift [--app-root ] [--output ] [--check]") +} + +func configuration() throws -> (root: URL, output: URL, check: Bool) { + let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + var root = workingDirectory + var output = "RouteContracts.json" + var check = false + let arguments = Array(CommandLine.arguments.dropFirst()) + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--help", "-h": usage(); exit(0) + case "--check": check = true + case "--app-root", "--output": + guard index + 1 < arguments.count else { throw NSError(domain: "RouteContracts", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(arguments[index])."]) } + index += 1 + if arguments[index - 1] == "--app-root" { root = absoluteURL(arguments[index], relativeTo: workingDirectory) } + else { output = arguments[index] } + default: throw NSError(domain: "RouteContracts", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown option: \(arguments[index])."]) + } + index += 1 + } + return (root, absoluteURL(output, relativeTo: root), check) +} + +func featureSources(at root: URL) -> [FeatureSource] { + guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey]) else { return [] } + let ignored: Set = [".build", ".git", "DerivedData", "Pods", "Carthage", "SourcePackages"] + var packages: [URL] = [] + for case let url as URL in enumerator { + if ignored.contains(url.lastPathComponent) { enumerator.skipDescendants(); continue } + if url.lastPathComponent == "Package.swift" { packages.append(url.deletingLastPathComponent()) } + } + return packages.flatMap { packageURL -> [FeatureSource] in + let sources = sourceFiles(in: packageURL.appendingPathComponent("Sources")) + .compactMap { try? String(contentsOf: $0, encoding: .utf8) }.joined(separator: "\n") + let literalIDs = matches(#"RouteModule\(\s*id:\s*\"([^\"]+)\""#, in: sources).compactMap { $0.count > 1 ? $0[1] : nil } + let declaredIDs = matches(#"(?:public\s+)?static\s+let\s+id\s*=\s*\"([^\"]+)\""#, in: sources).compactMap { $0.count > 1 ? $0[1] : nil } + return Array(Set(literalIDs + declaredIDs)).map { FeatureSource(moduleID: $0, source: sources) } + } +} + +func routeNames(in source: String, moduleID: String) -> [(name: String?, routeID: String)] { + let pattern = #"(?:(?:public\s+)?static\s+let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*)?ModuleRoute\(\s*moduleID:\s*(?:id|\""# + NSRegularExpression.escapedPattern(for: moduleID) + #"\")\s*,\s*routeID:\s*\"([^\"]+)\""# + return matches(pattern, in: source).compactMap { match in + guard match.count >= 3 else { return nil } + return (match[1].isEmpty ? nil : match[1], match[2]) + } +} + +func paths(in source: String, routeNames: [(name: String?, routeID: String)]) -> [String: String] { + var result: [String: String] = [:] + let idsByName = Dictionary(uniqueKeysWithValues: routeNames.compactMap { item in + item.name.map { ($0, item.routeID) } + }) + // Standard switch resolver: `case ["settings"]: return settings`. + for match in matches(#"case\s*\[([^\]]*)\]\s*:\s*return\s+([A-Za-z_][A-Za-z0-9_]*)"#, in: source) where match.count >= 3 { + guard let routeID = idsByName[match[2]] else { continue } + let components = matches(#"\"([^\"]+)\""#, in: match[1]).compactMap { $0.count > 1 ? $0[1] : nil } + result[routeID] = components.isEmpty ? "/" : "/" + components.joined(separator: "/") + } + // Standard guard resolver. Parameters are inferred from `parameters: ["id": link.pathComponents[1]]`. + for route in routeNames where result[route.routeID] == nil { + guard let occurrence = source.range(of: "routeID: \"\(route.routeID)\"") else { continue } + let start = source.index(occurrence.lowerBound, offsetBy: -min(800, source.distance(from: source.startIndex, to: occurrence.lowerBound))) + let end = source.index(occurrence.upperBound, offsetBy: min(400, source.distance(from: occurrence.upperBound, to: source.endIndex))) + let context = String(source[start.. 1, let count = Int(countMatch[1]) else { continue } + var components = Array(repeating: "{unknown}", count: count) + for literal in matches(#"link\.pathComponents\[(\d+)\]\s*==\s*\"([^\"]+)\""#, in: context) where literal.count >= 3 { + if let index = Int(literal[1]), index < count { components[index] = literal[2] } + } + for parameter in matches(#"\"([^\"]+)\"\s*:\s*link\.pathComponents\[(\d+)\]"#, in: context) where parameter.count >= 3 { + if let index = Int(parameter[2]), index < count { components[index] = ":\(parameter[1])" } + } + guard !components.contains("{unknown}") else { continue } + result[route.routeID] = "/" + components.joined(separator: "/") + } + return result +} + +func urlMetadata(in source: String) -> [String: (presentations: [String], queryItems: [String], versions: [String])] { + var result: [String: (presentations: [String], queryItems: [String], versions: [String])] = [:] + for match in matches(#"https?://[^\"]+"#, in: source) where !match[0].isEmpty { + let value = match[0].replacingOccurrences(of: #"\\\(([A-Za-z_][A-Za-z0-9_]*)\)"#, with: ":$1", options: .regularExpression) + guard let queryStart = value.firstIndex(of: "?") else { continue } + let beforeQuery = String(value[.. 1 ? $0[1] : nil } + let versions = pairs.filter { $0.first == "version" }.compactMap { $0.count > 1 ? $0[1] : nil } + guard !presentations.isEmpty else { continue } + result[path] = (presentations, queryItems, versions) + } + return result +} + +do { + let config = try configuration() + let allAppSource = sourceFiles(in: config.root).compactMap { try? String(contentsOf: $0, encoding: .utf8) }.joined(separator: "\n") + var routes: [RouteContract] = [] + var versions = Set() + var failures: [String] = [] + + for feature in featureSources(at: config.root) { + let names = routeNames(in: feature.source, moduleID: feature.moduleID) + let routePaths = paths(in: feature.source, routeNames: names) + let metadata = urlMetadata(in: feature.source) + for route in names { + guard let path = routePaths[route.routeID] else { failures.append("Could not infer path for \(feature.moduleID)/\(route.routeID)."); continue } + var details = metadata[path] + // Tab routes commonly have no URL builder; infer them from an App-shell TabView tag. + if details == nil, let name = route.name, + allAppSource.contains(".tag(Optional(") && allAppSource.contains(".\(name))") { + details = (["tab"], ["presentation", "version"], ["1"]) + } + guard let details else { failures.append("Could not infer presentation/query parameters for \(feature.moduleID)/\(route.routeID)."); continue } + versions.formUnion(details.versions) + routes.append(RouteContract(moduleID: feature.moduleID, routeID: route.routeID, pathTemplate: path, presentations: Array(Set(details.presentations)).sorted(), requiredQueryItems: Array(Set(details.queryItems)).sorted())) + } + } + guard failures.isEmpty else { throw NSError(domain: "RouteContracts", code: 1, userInfo: [NSLocalizedDescriptionKey: failures.joined(separator: "\n")]) } + let manifest = RouteContractManifest(schemaVersion: 1, supportedVersions: versions.isEmpty ? ["1"] : versions.sorted(), routes: routes) + let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(manifest) + if config.check { + let current = try Data(contentsOf: config.output) + guard current == data else { throw NSError(domain: "RouteContracts", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(config.output.path) is out of date. Run update_route_contracts.swift before building."]) } + print("Route contracts are up to date (\(routes.count) routes).") + } else { + try fileManager.createDirectory(at: config.output.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: config.output, options: .atomic) + print("Updated \(config.output.path) (\(routes.count) routes).") + } +} catch { + fputs("Route contract update failed: \(error.localizedDescription)\n", stderr) + exit(1) +} diff --git a/URLRouter.xcodeproj/project.pbxproj b/URLRouter.xcodeproj/project.pbxproj index 3de7b6e..73393a9 100644 --- a/URLRouter.xcodeproj/project.pbxproj +++ b/URLRouter.xcodeproj/project.pbxproj @@ -190,6 +190,7 @@ isa = PBXNativeTarget; buildConfigurationList = D3A000000000000000000061 /* Build configuration list for PBXNativeTarget "URLRouterDemo" */; buildPhases = ( + D3A000000000000000000034 /* Verify Route Contracts */, D3A000000000000000000032 /* Sources */, D3A000000000000000000030 /* Frameworks */, D3A000000000000000000033 /* Resources */, @@ -281,6 +282,31 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + D3A000000000000000000034 /* Verify Route Contracts */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)", + "$(SRCROOT)/Scripts/update_route_contracts.swift", + "$(SRCROOT)/RouteContracts.json", + ); + name = "Verify Route Contracts"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "unset SDKROOT\nswift \"${SRCROOT}/Scripts/update_route_contracts.swift\" --app-root \"${SRCROOT}\" --check\nswift \"${SRCROOT}/Scripts/generate_route_catalog.swift\" --app-root \"${SRCROOT}\" --contracts RouteContracts.json --output docs/route-catalog.html\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 6ED81C8E244C409800C385B2 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -555,6 +581,7 @@ CODE_SIGN_ENTITLEMENTS = URLRouterDemo/URLRouterDemo.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 68266EE2HL; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = URLRouterDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -576,6 +603,7 @@ CODE_SIGN_ENTITLEMENTS = URLRouterDemo/URLRouterDemo.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 68266EE2HL; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = URLRouterDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/docs/architecture.md b/docs/architecture.md index f6e4dca..9368f70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,7 +114,7 @@ from being “handled” without visibly switching tabs. For every public route change, update these items in one pull request: 1. The Feature's parser and URL builder. -2. `RouteContracts.json`. +2. Generate the App-root `RouteContracts.json` with `update_route_contracts.swift`. 3. Tests and caller documentation. 4. Migration notes if an old URL remains in emails, websites, notifications, or released apps. @@ -123,5 +123,9 @@ The repository CI catches structural and breaking catalog changes; it cannot decide your product migration policy. Treat a removed or reinterpreted URL as a breaking change and plan it accordingly. +`RouteContracts.json` belongs once, at the App root. It aggregates every +Feature Package's public routes; do not distribute separate catalogs through +Features. This lets the App validate and publish one coherent URL surface. + For rollout, remote policy, concurrency, and observability guidance, continue to [Production governance](production-governance.md). diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index dff6a03..2fecebc 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -104,12 +104,15 @@ route ID 与 tab tag 完全一致。这样路由不会出现“已处理但界 每次修改公开路由,都在同一个 PR 中更新: 1. Feature 的解析器和 URL builder。 -2. `RouteContracts.json`。 +2. 用 `update_route_contracts.swift` 生成 App 根目录的 `RouteContracts.json`。 3. 测试和调用方文档。 4. 旧 URL 已存在于邮件、网页、推送或已发布 App 时的迁移说明。 仓库 CI 会阻止目录结构错误和破坏性契约变化,但它不会替你决定产品迁移策略。 删除或改变已有 URL 的含义,应按破坏性变更处理并提前规划。 +`RouteContracts.json` 只属于 App 根目录的一份,它汇总所有 Feature Package 的公开 +路由;不要让每个 Feature 各自保存副本。这样 App 才能校验并发布一个一致的 URL 表面。 + 关于灰度、远程策略、并发与可观测性,请继续阅读 [生产治理](production-governance.zh-CN.md)。 diff --git a/docs/production-governance.md b/docs/production-governance.md index 8b36a25..b006944 100644 --- a/docs/production-governance.md +++ b/docs/production-governance.md @@ -146,9 +146,10 @@ top failure codes, and queue-full/expiry counts. ## Protect published URLs with contract CI -`RouteContracts.json` is a source-controlled catalog of public routes. In the -same pull request that changes a public route, update the Feature parser, URL -builder, catalog, tests, and any migration note. +`RouteContracts.json` is the one source-controlled catalog of public routes in +the App root, not a file copied into every Feature Package. In the same pull +request that changes a public route, update the Feature parser and URL builder, +regenerate the catalog, update tests, and add any migration note. CI validates the catalog and compares it with the PR base commit. It rejects an accidental removal or incompatible change to a path, presentation, required @@ -156,14 +157,39 @@ parameter, or supported contract version. Treat an intentional break as a major-version change with a migration plan. ```bash +swift Scripts/update_route_contracts.swift --check swift Scripts/validate_route_contract.swift RouteContracts.json ``` +Run the update command without `--check` to write the current catalog. Add the +following Xcode **Run Script** build phase before compiling sources to make +every local build reject an out-of-date catalog. Replace the URLRouter path +with the location used by your App: + +```bash +unset SDKROOT +swift "${SRCROOT}/Vendor/URLRouter/Scripts/update_route_contracts.swift" \ + --app-root "${SRCROOT}" \ + --check +swift "${SRCROOT}/Vendor/URLRouter/Scripts/generate_route_catalog.swift" \ + --app-root "${SRCROOT}" \ + --contracts RouteContracts.json \ + --output docs/route-catalog.html +``` + +Use `--check` in a build phase so the build does not modify tracked source +files. Because the generator recursively reads local Feature Packages, either +list every scanned source directory as a build-phase input or set +`ENABLE_USER_SCRIPT_SANDBOXING = NO` for this trusted script (the demo uses the +latter). Run the updating form deliberately before committing; use the same +check command in CI. The second command refreshes the local, searchable route +catalog after every successful contract check. + ## A practical rollout order 1. Ship one versioned URL and its Feature resolver. 2. Add Universal Links and tests. -3. Add `RouteContracts.json` before other teams depend on the URL. +3. Generate `RouteContracts.json` at the App root before other teams depend on the URL. 4. Add observability when support needs diagnosis. 5. Add the Provider when operations needs safe remote restrictions. 6. Add the coordinator when route sources actually compete. diff --git a/docs/production-governance.zh-CN.md b/docs/production-governance.zh-CN.md index 6804627..8440ddf 100644 --- a/docs/production-governance.zh-CN.md +++ b/docs/production-governance.zh-CN.md @@ -137,21 +137,44 @@ final class AppRouteObserver: ModuleRouteObserving { ## 用契约 CI 保护已发布 URL -`RouteContracts.json` 是受版本控制的公开路由目录。修改公开路由时,同一个 PR 还要 -修改 Feature 解析器、URL builder、目录、测试,以及必要的迁移说明。 +`RouteContracts.json` 是 App 根目录唯一、受版本控制的公开路由目录,而不是每个 +Feature Package 都复制一份。修改公开路由时,同一个 PR 还要修改 Feature 解析器和 +URL builder、重新生成目录、更新测试,以及必要的迁移说明。 CI 会校验目录并与 PR 基线比较,拒绝意外删除或不兼容修改路径、展示方式、必填参数 和支持的协议版本。确实要破坏兼容性时,按主版本变更处理并提供迁移方案。 ```bash +swift Scripts/update_route_contracts.swift --check swift Scripts/validate_route_contract.swift RouteContracts.json ``` +不带 `--check` 执行更新脚本会写入最新目录。要让每次本地构建都检查契约,请在 Xcode 的 +**Compile Sources 之前**添加一个 **Run Script** Build Phase;将 URLRouter 路径替换为 +App 实际使用的位置: + +```bash +unset SDKROOT +swift "${SRCROOT}/Vendor/URLRouter/Scripts/update_route_contracts.swift" \ + --app-root "${SRCROOT}" \ + --check +swift "${SRCROOT}/Vendor/URLRouter/Scripts/generate_route_catalog.swift" \ + --app-root "${SRCROOT}" \ + --contracts RouteContracts.json \ + --output docs/route-catalog.html +``` + +Build Phase 使用 `--check`,避免编译过程中改写受版本控制的文件。生成器会递归读取本地 +Feature Package,因此请将每个被扫描的源码目录列为 Build Phase 输入,或者对此受信任脚本 +设置 `ENABLE_USER_SCRIPT_SANDBOXING = NO`(Demo 使用后一种方式)。开发者在提交前主动 +运行不带该参数的更新命令;CI 同样运行检查命令。第二条命令会在契约检查通过后刷新 +本地可搜索的路由网页。 + ## 一个实际的接入顺序 1. 发布一条带版本 URL 和对应 Feature 解析器。 2. 加入 Universal Link 和测试。 -3. 其他团队依赖 URL 前加入 `RouteContracts.json`。 +3. 其他团队依赖 URL 前,在 App 根目录生成 `RouteContracts.json`。 4. 客服需要排障时加入可观测性。 5. 运营需要安全的远程限制时加入 Provider。 6. 多个路由来源真的会竞争时加入 coordinator。 diff --git a/docs/route-catalog.html b/docs/route-catalog.html new file mode 100644 index 0000000..b94fc6a --- /dev/null +++ b/docs/route-catalog.html @@ -0,0 +1,71 @@ + + + + + + URLRouter 路由目录 + + + +

URLRouter 路由目录

+

RouteContracts.json 与 App 内的 Feature Package 自动生成 · 共 5 条路由

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
URL 模板参数目标页面Feature package路由 ID展示方式
https://example.com/?presentation=tab&version=1presentation(查询,必填)
version(查询,必填)
由 App 容器处理(Feature 未提供 destination View)NavigationFeaturenavigation/hometab
https://example.com/favorites?presentation=tab&version=1presentation(查询,必填)
version(查询,必填)
由 App 容器处理(Feature 未提供 destination View)NavigationFeaturenavigation/favoritestab
https://example.com/settings?presentation=sheet&version=1presentation(查询,必填)
version(查询,必填)
SettingsViewNavigationFeaturenavigation/settingssheet
https://example.com/sign-in?presentation=fullScreenCover&version=1presentation(查询,必填)
version(查询,必填)
SignInViewNavigationFeaturenavigation/signInfullScreenCover
https://example.com/articles/{id}?presentation=push&version=1id(路径)
presentation(查询,必填)
version(查询,必填)
ArticleViewContentFeaturecontent/articlepush
+ + + \ No newline at end of file