Skip to content

Commit dc1d64c

Browse files
feat(validate): add validate command with structured JSON output
Adds a new `xcodegen validate` command that validates the project spec without generating a project. Output is always a JSON object with three keys: - valid (bool): whether the spec passed all checks - errors (array): each with stage and message fields - warnings (array): same shape This separates validation from generation and enables CI pipelines to get machine-readable validation results. Supports comma-separated --spec paths and all existing ProjectCommand flags (--no-env, --project-root). Exit code is 1 when the spec has errors, 0 when valid. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b978ed4 commit dc1d64c

File tree

2 files changed

+100
-0
lines changed

2 files changed

+100
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import Foundation
2+
import PathKit
3+
import ProjectSpec
4+
import SwiftCLI
5+
import XcodeGenKit
6+
import Version
7+
8+
class ValidateCommand: ProjectCommand {
9+
10+
init(version: Version) {
11+
super.init(version: version,
12+
name: "validate",
13+
shortDescription: "Validate the project spec without generating a project")
14+
}
15+
16+
// Fully override execute() so that parsing errors are also captured as JSON
17+
override func execute() throws {
18+
var specPaths: [Path] = []
19+
if let spec = spec {
20+
specPaths = spec.components(separatedBy: ",").map { Path($0).absolute() }
21+
} else {
22+
specPaths = [Path("project.yml").absolute()]
23+
}
24+
25+
var allErrors: [ValidationIssue] = []
26+
var allWarnings: [ValidationIssue] = []
27+
28+
for specPath in specPaths {
29+
guard specPath.exists else {
30+
allErrors.append(ValidationIssue(stage: "parsing",
31+
message: "No project spec found at \(specPath)"))
32+
continue
33+
}
34+
35+
let specLoader = SpecLoader(version: version)
36+
let variables: [String: String] = disableEnvExpansion ? [:] : ProcessInfo.processInfo.environment
37+
38+
let project: Project
39+
do {
40+
project = try specLoader.loadProject(path: specPath, projectRoot: projectRoot, variables: variables)
41+
} catch {
42+
allErrors.append(ValidationIssue(stage: "parsing", message: error.localizedDescription))
43+
continue
44+
}
45+
46+
do {
47+
try specLoader.validateProjectDictionaryWarnings()
48+
} catch let e as SpecValidationError {
49+
allWarnings += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) }
50+
} catch {
51+
allWarnings.append(ValidationIssue(stage: "validation", message: error.localizedDescription))
52+
}
53+
54+
do {
55+
try project.validateMinimumXcodeGenVersion(version)
56+
try project.validate()
57+
} catch let e as SpecValidationError {
58+
allErrors += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) }
59+
} catch {
60+
allErrors.append(ValidationIssue(stage: "validation", message: error.localizedDescription))
61+
}
62+
}
63+
64+
let result = ValidationResult(valid: allErrors.isEmpty, errors: allErrors, warnings: allWarnings)
65+
stdout.print(try result.jsonString())
66+
67+
if !result.valid {
68+
throw ValidationFailed()
69+
}
70+
}
71+
72+
// Not called — execute() is fully overridden above
73+
override func execute(specLoader: SpecLoader, projectSpecPath: Path, project: Project) throws {}
74+
}
75+
76+
// MARK: - JSON output types
77+
78+
private struct ValidationIssue: Encodable {
79+
let stage: String
80+
let message: String
81+
}
82+
83+
private struct ValidationResult: Encodable {
84+
let valid: Bool
85+
let errors: [ValidationIssue]
86+
let warnings: [ValidationIssue]
87+
88+
func jsonString() throws -> String {
89+
let encoder = JSONEncoder()
90+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
91+
let data = try encoder.encode(self)
92+
return String(data: data, encoding: .utf8)!
93+
}
94+
}
95+
96+
private struct ValidationFailed: ProcessError {
97+
var message: String? { nil }
98+
var exitStatus: Int32 { 1 }
99+
}

Sources/XcodeGenCLI/XcodeGenCLI.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class XcodeGenCLI {
1717
generateCommand,
1818
CacheCommand(version: version),
1919
DumpCommand(version: version),
20+
ValidateCommand(version: version),
2021
]
2122
)
2223
cli.parser.routeBehavior = .searchWithFallback(generateCommand)

0 commit comments

Comments
 (0)