Skip to content

Commit 149d7a4

Browse files
ceritiumclaude
andcommitted
Implement check command for CI/CD version validation
Add a new `check` command that verifies stacktodate.yml matches detected versions: - Reads stacktodate.yml and detects current project versions - Compares only manifest items (ignores extra detected technologies) - Reports matches, mismatches, and missing manifest entries - Supports text (human-readable) and JSON output formats - Exits with code 0 if all manifest versions match, 1 if differences found - Perfect for CI/CD pipelines to ensure declared tech stack is up to date Features: - Reuses existing detection and comparison logic - Comprehensive test suite with 6 test cases - Clear output showing exact differences - JSON output for machine parsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent 26c2339 commit 149d7a4

File tree

3 files changed

+483
-0
lines changed

3 files changed

+483
-0
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,48 @@ Options:
149149
- `--skip-autodetect`: Keep existing stack without detection
150150
- `--no-interactive`: Use first candidate without prompting
151151

152+
### Check technology versions
153+
154+
Verify that your `stacktodate.yml` matches the currently detected versions in your project. Perfect for CI/CD pipelines:
155+
156+
```bash
157+
stacktodate check
158+
```
159+
160+
This command:
161+
- Reads your `stacktodate.yml` file
162+
- Detects current versions in your project
163+
- Compares them and reports any differences
164+
- Exits with code 0 if all versions match, 1 if there are differences
165+
166+
Options:
167+
- `--config, -c`: Path to stacktodate.yml file (default: `stacktodate.yml`)
168+
- `--format, -f`: Output format: `text` (default) or `json` for CI/CD integration
169+
170+
**Output Example (text format):**
171+
```
172+
Technology Check Results
173+
========================
174+
175+
MATCH (3):
176+
ruby: 3.2.0 == 3.2.0 ✓
177+
nodejs: 18.0.0 == 18.0.0 ✓
178+
python: 3.11 == 3.11 ✓
179+
180+
MISMATCH (1):
181+
rails: 7.0.0 != 7.1.0 (config has 7.1.0)
182+
183+
Summary: 3 match, 1 mismatch, 0 missing
184+
Exit code: 1 (has differences)
185+
```
186+
187+
**CI/CD Integration (JSON format):**
188+
```bash
189+
stacktodate check --format json
190+
```
191+
192+
Returns structured JSON output suitable for parsing in CI/CD pipelines.
193+
152194
### Push to Stack To Date
153195

154196
Upload your detected tech stack to the Stack To Date platform for monitoring and lifecycle tracking:

cmd/check.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/spf13/cobra"
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
type CheckResult struct {
14+
Status string `json:"status"`
15+
Summary CheckSummary `json:"summary"`
16+
Results CheckResults `json:"results"`
17+
}
18+
19+
type CheckSummary struct {
20+
Matches int `json:"matches"`
21+
Mismatches int `json:"mismatches"`
22+
MissingConfig int `json:"missing_config"`
23+
}
24+
25+
type CheckResults struct {
26+
Matched []ComparisonEntry `json:"matched"`
27+
Mismatched []ComparisonEntry `json:"mismatched"`
28+
MissingConfig []ComparisonEntry `json:"missing_config"`
29+
}
30+
31+
type ComparisonEntry struct {
32+
Name string `json:"name"`
33+
Version string `json:"version,omitempty"`
34+
Detected string `json:"detected,omitempty"`
35+
Source string `json:"source,omitempty"`
36+
}
37+
38+
var (
39+
checkConfigFile string
40+
checkFormat string
41+
)
42+
43+
var checkCmd = &cobra.Command{
44+
Use: "check",
45+
Short: "Check if detected versions match stacktodate.yml",
46+
Long: `Verify that the versions in stacktodate.yml match the currently detected versions in your project. Useful for CI/CD pipelines.`,
47+
Run: func(cmd *cobra.Command, args []string) {
48+
// Determine config file
49+
configPath := checkConfigFile
50+
if configPath == "" {
51+
configPath = "stacktodate.yml"
52+
}
53+
54+
// Resolve absolute path
55+
absConfigPath, err := filepath.Abs(configPath)
56+
if err != nil {
57+
fmt.Fprintf(os.Stderr, "Error resolving config path: %v\n", err)
58+
os.Exit(2)
59+
}
60+
61+
// Read config file
62+
content, err := os.ReadFile(absConfigPath)
63+
if err != nil {
64+
fmt.Fprintf(os.Stderr, "Error reading config file %s: %v\n", configPath, err)
65+
os.Exit(2)
66+
}
67+
68+
// Parse YAML
69+
var config Config
70+
if err := yaml.Unmarshal(content, &config); err != nil {
71+
fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", configPath, err)
72+
os.Exit(2)
73+
}
74+
75+
// Change to config directory for detection
76+
originalDir, err := os.Getwd()
77+
if err != nil {
78+
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
79+
os.Exit(2)
80+
}
81+
82+
configDir := filepath.Dir(absConfigPath)
83+
if configDir == "" {
84+
configDir = "."
85+
}
86+
87+
if configDir != "." {
88+
if err := os.Chdir(configDir); err != nil {
89+
fmt.Fprintf(os.Stderr, "Error changing to directory: %v\n", err)
90+
os.Exit(2)
91+
}
92+
defer os.Chdir(originalDir)
93+
}
94+
95+
// Detect current versions
96+
detectedInfo := DetectProjectInfo()
97+
detectedStack := normalizeDetectedToStack(detectedInfo)
98+
99+
// Compare stacks
100+
result := compareStacks(config.Stack, detectedStack)
101+
102+
// Output results
103+
if checkFormat == "json" {
104+
outputJSON(result)
105+
} else {
106+
outputText(result)
107+
}
108+
109+
// Exit with appropriate code
110+
if result.Status != "match" {
111+
os.Exit(1)
112+
}
113+
},
114+
}
115+
116+
func normalizeDetectedToStack(info DetectedInfo) map[string]StackEntry {
117+
normalized := make(map[string]StackEntry)
118+
119+
if len(info.Ruby) > 0 {
120+
normalized["ruby"] = StackEntry{
121+
Version: info.Ruby[0].Value,
122+
Source: info.Ruby[0].Source,
123+
}
124+
}
125+
126+
if len(info.Rails) > 0 {
127+
normalized["rails"] = StackEntry{
128+
Version: info.Rails[0].Value,
129+
Source: info.Rails[0].Source,
130+
}
131+
}
132+
133+
if len(info.Node) > 0 {
134+
normalized["nodejs"] = StackEntry{
135+
Version: info.Node[0].Value,
136+
Source: info.Node[0].Source,
137+
}
138+
}
139+
140+
if len(info.Go) > 0 {
141+
normalized["go"] = StackEntry{
142+
Version: info.Go[0].Value,
143+
Source: info.Go[0].Source,
144+
}
145+
}
146+
147+
if len(info.Python) > 0 {
148+
normalized["python"] = StackEntry{
149+
Version: info.Python[0].Value,
150+
Source: info.Python[0].Source,
151+
}
152+
}
153+
154+
return normalized
155+
}
156+
157+
func compareStacks(configStack, detectedStack map[string]StackEntry) CheckResult {
158+
result := CheckResult{
159+
Results: CheckResults{
160+
Matched: []ComparisonEntry{},
161+
Mismatched: []ComparisonEntry{},
162+
MissingConfig: []ComparisonEntry{},
163+
},
164+
}
165+
166+
// Check all items in config
167+
for tech, configEntry := range configStack {
168+
if detectedEntry, exists := detectedStack[tech]; exists {
169+
if configEntry.Version == detectedEntry.Version {
170+
result.Results.Matched = append(result.Results.Matched, ComparisonEntry{
171+
Name: tech,
172+
Version: configEntry.Version,
173+
Detected: detectedEntry.Version,
174+
Source: detectedEntry.Source,
175+
})
176+
result.Summary.Matches++
177+
} else {
178+
result.Results.Mismatched = append(result.Results.Mismatched, ComparisonEntry{
179+
Name: tech,
180+
Version: configEntry.Version,
181+
Detected: detectedEntry.Version,
182+
Source: detectedEntry.Source,
183+
})
184+
result.Summary.Mismatches++
185+
}
186+
} else {
187+
result.Results.MissingConfig = append(result.Results.MissingConfig, ComparisonEntry{
188+
Name: tech,
189+
Version: configEntry.Version,
190+
Source: configEntry.Source,
191+
})
192+
result.Summary.MissingConfig++
193+
}
194+
}
195+
196+
// Determine overall status
197+
if result.Summary.Mismatches == 0 && result.Summary.MissingConfig == 0 {
198+
result.Status = "match"
199+
} else {
200+
result.Status = "mismatch"
201+
}
202+
203+
return result
204+
}
205+
206+
func outputText(result CheckResult) {
207+
fmt.Println("Technology Check Results")
208+
fmt.Println("========================")
209+
fmt.Println()
210+
211+
if len(result.Results.Matched) > 0 {
212+
fmt.Printf("MATCH (%d):\n", len(result.Results.Matched))
213+
for _, entry := range result.Results.Matched {
214+
fmt.Printf(" %-12s %s == %s ✓\n", entry.Name+":", entry.Version, entry.Detected)
215+
}
216+
fmt.Println()
217+
}
218+
219+
if len(result.Results.Mismatched) > 0 {
220+
fmt.Printf("MISMATCH (%d):\n", len(result.Results.Mismatched))
221+
for _, entry := range result.Results.Mismatched {
222+
fmt.Printf(" %-12s %s != %s (config has %s)\n", entry.Name+":", entry.Detected, entry.Version, entry.Version)
223+
}
224+
fmt.Println()
225+
}
226+
227+
if len(result.Results.MissingConfig) > 0 {
228+
fmt.Printf("MISSING FROM DETECTION (%d):\n", len(result.Results.MissingConfig))
229+
for _, entry := range result.Results.MissingConfig {
230+
fmt.Printf(" %-12s %s (in config but not detected)\n", entry.Name+":", entry.Version)
231+
}
232+
fmt.Println()
233+
}
234+
235+
fmt.Printf("Summary: %d match, %d mismatch, %d missing\n",
236+
result.Summary.Matches,
237+
result.Summary.Mismatches,
238+
result.Summary.MissingConfig)
239+
240+
if result.Status == "mismatch" {
241+
fmt.Println("Exit code: 1 (has differences)")
242+
} else {
243+
fmt.Println("Exit code: 0 (all match)")
244+
}
245+
}
246+
247+
func outputJSON(result CheckResult) {
248+
data, err := json.MarshalIndent(result, "", " ")
249+
if err != nil {
250+
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
251+
os.Exit(2)
252+
}
253+
fmt.Println(string(data))
254+
}
255+
256+
func init() {
257+
rootCmd.AddCommand(checkCmd)
258+
checkCmd.Flags().StringVarP(&checkConfigFile, "config", "c", "", "Path to stacktodate.yml config file (default: stacktodate.yml)")
259+
checkCmd.Flags().StringVarP(&checkFormat, "format", "f", "text", "Output format: text or json (default: text)")
260+
}

0 commit comments

Comments
 (0)