beachio-hammer 0.1.0.pre5 → 0.1.0.pre7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/PUBLISH.md +39 -4
- data/lib/beachio-hammer.rb +1 -0
- data/lib/hammer/check_command.rb +210 -0
- data/lib/hammer/cli.rb +13 -1
- data/lib/hammer/content/config.rb +18 -0
- data/lib/hammer/output_formatter.rb +51 -0
- data/lib/hammer/version.rb +1 -1
- data/spec/check_command_spec.rb +276 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f49550d59c10cbcb9b8ed9dae068082d5962abd833fcf2b6b039d07ce409b5e
|
|
4
|
+
data.tar.gz: 4e04f20a894021945d2366a00a4b4239404541d139d60ed99c93c49afff767e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69c67662fc6d413a3af72e8873ad96345e8e363a87fac6657c1c10385239ab195201805c2e9d1784beae717316f15e9835ba81f1483e39f43b8028c971d03ab4
|
|
7
|
+
data.tar.gz: 0efecd32fde11e6c6b6be017f427554cd7d57c11ad633d766c8b61be499b660cf7f61928e25dbcfd53db52cfe1ed765016c0ab0a5aa0d71a13503b921ed4f029
|
data/Gemfile.lock
CHANGED
data/PUBLISH.md
CHANGED
|
@@ -38,7 +38,18 @@ gem build beachio-hammer.gemspec
|
|
|
38
38
|
gem push beachio-hammer-*.gem
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Prerelease pin for executor team: **`beachio-hammer` `0.1.0.
|
|
41
|
+
Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre7`**
|
|
42
|
+
|
|
43
|
+
### 0.1.0.pre7 changes
|
|
44
|
+
|
|
45
|
+
- Fix gem packaging: include `lib/hammer/check_command.rb` (pre6 omitted untracked file)
|
|
46
|
+
|
|
47
|
+
### 0.1.0.pre6 changes (`hammer check`)
|
|
48
|
+
|
|
49
|
+
- Add `hammer check` for content validation (Swift CLI parity)
|
|
50
|
+
- Flags: `--project`, `--strict`, `--format json|human`
|
|
51
|
+
- Rule IDs: `CONFIG_MISSING`, `CONFIG_INVALID`, `COLLECTION_DIR_MISSING`, `TEMPLATE_MISSING`, `PARSE_ERROR`, `REQUIRED_FIELD_MISSING`, `SLUG_MISSING`, `PROJECT_NOT_FOUND`
|
|
52
|
+
- Agent QC: `hammer check --strict --format json`
|
|
42
53
|
|
|
43
54
|
### 0.1.0.pre5 changes (collection loops on static pages)
|
|
44
55
|
|
|
@@ -68,16 +79,17 @@ Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre5`**
|
|
|
68
79
|
Pin the published gem version in the executor Dockerfile:
|
|
69
80
|
|
|
70
81
|
```dockerfile
|
|
71
|
-
RUN gem install beachio-hammer -v 0.1.0.
|
|
82
|
+
RUN gem install beachio-hammer -v 0.1.0.pre6
|
|
72
83
|
```
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
Validate and build in the agent:
|
|
75
86
|
|
|
76
87
|
```bash
|
|
88
|
+
hammer check --project "$WORKSPACE" --strict --format json
|
|
77
89
|
hammer build --project "$WORKSPACE" --mode export --format json --clean
|
|
78
90
|
```
|
|
79
91
|
|
|
80
|
-
### CLI contract
|
|
92
|
+
### CLI contract — `hammer build`
|
|
81
93
|
|
|
82
94
|
| Flag | Default | Notes |
|
|
83
95
|
|------|---------|-------|
|
|
@@ -100,6 +112,29 @@ hammer build --project "$WORKSPACE" --mode export --format json --clean
|
|
|
100
112
|
}
|
|
101
113
|
```
|
|
102
114
|
|
|
115
|
+
### CLI contract — `hammer check`
|
|
116
|
+
|
|
117
|
+
| Flag | Default | Notes |
|
|
118
|
+
|------|---------|-------|
|
|
119
|
+
| `--project` | cwd (walk-up for markers) | Project root |
|
|
120
|
+
| `--strict` | off | Treat warnings as errors |
|
|
121
|
+
| `--format` | `human` | `json` for agent parsing |
|
|
122
|
+
|
|
123
|
+
**Exit codes:** `0` pass (or warnings without `--strict`), `1` check failures, `2` project/config error, `3` internal.
|
|
124
|
+
|
|
125
|
+
**JSON stdout** (`--format json`):
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"status": "pass",
|
|
130
|
+
"errors": [{ "ruleId", "message", "file", "suggestion" }],
|
|
131
|
+
"warnings": [{ "ruleId", "message", "file", "suggestion" }],
|
|
132
|
+
"stats": { "filesChecked", "durationMs" }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Rule IDs:** `CONFIG_MISSING` (warn), `CONFIG_INVALID`, `COLLECTION_DIR_MISSING`, `TEMPLATE_MISSING`, `PARSE_ERROR`, `REQUIRED_FIELD_MISSING`, `SLUG_MISSING` (warn), `PROJECT_NOT_FOUND` (JSON `status: "error"`, exit 2).
|
|
137
|
+
|
|
103
138
|
### Parity fixtures
|
|
104
139
|
|
|
105
140
|
| Fixture | Path | Files |
|
data/lib/beachio-hammer.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "hammer/content/page_generator"
|
|
|
23
23
|
require_relative "hammer/content/builder"
|
|
24
24
|
require_relative "hammer/builder"
|
|
25
25
|
require_relative "hammer/build_command"
|
|
26
|
+
require_relative "hammer/check_command"
|
|
26
27
|
require_relative "hammer/cli"
|
|
27
28
|
|
|
28
29
|
module Hammer
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
module CheckCommand
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(argv)
|
|
8
|
+
options = parse_options(argv)
|
|
9
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
|
|
11
|
+
project_dir, project_exit = resolve_project(options)
|
|
12
|
+
return project_exit if project_exit
|
|
13
|
+
|
|
14
|
+
errors = []
|
|
15
|
+
warnings = []
|
|
16
|
+
files_checked = 0
|
|
17
|
+
|
|
18
|
+
config_path = File.join(project_dir, "content.config.json")
|
|
19
|
+
|
|
20
|
+
unless File.file?(config_path)
|
|
21
|
+
warnings << issue(
|
|
22
|
+
rule_id: "CONFIG_MISSING",
|
|
23
|
+
message: "No content.config.json found",
|
|
24
|
+
file: project_dir,
|
|
25
|
+
suggestion: "Create a content.config.json to enable content validation"
|
|
26
|
+
)
|
|
27
|
+
duration_ms = elapsed_ms(start)
|
|
28
|
+
status = options[:strict] && !warnings.empty? ? "fail" : "pass"
|
|
29
|
+
output_result(status, errors, warnings, files_checked, duration_ms, options[:format])
|
|
30
|
+
return options[:strict] && !warnings.empty? ? 1 : 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
config = load_config(config_path, errors, warnings, files_checked, start, options[:format])
|
|
34
|
+
return 1 unless config
|
|
35
|
+
|
|
36
|
+
parser = Content::Parser.new(source_directory: project_dir)
|
|
37
|
+
|
|
38
|
+
config.collections.each do |collection_name, collection_config|
|
|
39
|
+
content_dir = File.join(project_dir, collection_config.path)
|
|
40
|
+
|
|
41
|
+
unless File.directory?(content_dir)
|
|
42
|
+
errors << issue(
|
|
43
|
+
rule_id: "COLLECTION_DIR_MISSING",
|
|
44
|
+
message: "Content directory not found for collection '#{collection_name}'",
|
|
45
|
+
file: collection_config.path,
|
|
46
|
+
suggestion: "Create the directory at #{collection_config.path}"
|
|
47
|
+
)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if collection_config.template
|
|
52
|
+
template_path = File.join(project_dir, collection_config.template)
|
|
53
|
+
unless File.file?(template_path)
|
|
54
|
+
errors << issue(
|
|
55
|
+
rule_id: "TEMPLATE_MISSING",
|
|
56
|
+
message: "Template not found for collection '#{collection_name}'",
|
|
57
|
+
file: collection_config.template,
|
|
58
|
+
suggestion: "Create the template file at #{collection_config.template}"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
file_extension = collection_config.format || "md"
|
|
64
|
+
discover_content_files(content_dir, file_extension).each do |file_path|
|
|
65
|
+
files_checked += 1
|
|
66
|
+
relative_path = file_path.delete_prefix("#{project_dir}/")
|
|
67
|
+
|
|
68
|
+
document = parse_content_file(parser, file_path, file_extension, relative_path, errors)
|
|
69
|
+
next unless document
|
|
70
|
+
|
|
71
|
+
collection_config.validate_required_fields(document.metadata).each do |field|
|
|
72
|
+
errors << issue(
|
|
73
|
+
rule_id: "REQUIRED_FIELD_MISSING",
|
|
74
|
+
message: "Missing required field '#{field}' in collection '#{collection_name}'",
|
|
75
|
+
file: relative_path,
|
|
76
|
+
suggestion: "Add '#{field}' to the front matter"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
next unless collection_config.slug
|
|
81
|
+
|
|
82
|
+
slug_config = collection_config.slug
|
|
83
|
+
slug_field = slug_config["field"] || slug_config[:field]
|
|
84
|
+
slug_fallback = slug_config["fallback"] || slug_config[:fallback]
|
|
85
|
+
slug_value = document.metadata[slug_field] || document.metadata[slug_fallback]
|
|
86
|
+
next unless slug_value.nil? || (slug_value.is_a?(String) && slug_value.empty?)
|
|
87
|
+
|
|
88
|
+
warnings << issue(
|
|
89
|
+
rule_id: "SLUG_MISSING",
|
|
90
|
+
message: "No slug or fallback value for collection '#{collection_name}'",
|
|
91
|
+
file: relative_path,
|
|
92
|
+
suggestion: "Add '#{slug_field}' or '#{slug_fallback}' to front matter"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
duration_ms = elapsed_ms(start)
|
|
98
|
+
has_failed = !errors.empty? || (options[:strict] && !warnings.empty?)
|
|
99
|
+
status = has_failed ? "fail" : "pass"
|
|
100
|
+
output_result(status, errors, warnings, files_checked, duration_ms, options[:format])
|
|
101
|
+
has_failed ? 1 : 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_options(argv)
|
|
105
|
+
options = {
|
|
106
|
+
project: nil,
|
|
107
|
+
strict: false,
|
|
108
|
+
format: :human
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
i = 0
|
|
112
|
+
while i < argv.length
|
|
113
|
+
arg = argv[i]
|
|
114
|
+
case arg
|
|
115
|
+
when "--project"
|
|
116
|
+
options[:project] = argv[i += 1]
|
|
117
|
+
when "--strict"
|
|
118
|
+
options[:strict] = true
|
|
119
|
+
when "--format"
|
|
120
|
+
fmt = argv[i += 1].to_s.downcase
|
|
121
|
+
options[:format] = fmt == "json" ? :json : :human
|
|
122
|
+
else
|
|
123
|
+
warn "Unknown option: #{arg}"
|
|
124
|
+
raise ConfigError, "Unknown option: #{arg}"
|
|
125
|
+
end
|
|
126
|
+
i += 1
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
options
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_project(options)
|
|
133
|
+
[ProjectResolver.resolve(explicit_path: options[:project]), nil]
|
|
134
|
+
rescue ConfigError => e
|
|
135
|
+
if options[:format] == :json
|
|
136
|
+
OutputFormatter.format_check_result(
|
|
137
|
+
status: "error",
|
|
138
|
+
errors: [issue(
|
|
139
|
+
rule_id: "PROJECT_NOT_FOUND",
|
|
140
|
+
message: e.message,
|
|
141
|
+
file: "",
|
|
142
|
+
suggestion: "Specify a project path with --project"
|
|
143
|
+
)],
|
|
144
|
+
warnings: [],
|
|
145
|
+
files_checked: 0,
|
|
146
|
+
duration_ms: 0,
|
|
147
|
+
format: :json
|
|
148
|
+
)
|
|
149
|
+
else
|
|
150
|
+
warn "Error: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
[nil, 2]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load_config(config_path, errors, warnings, files_checked, start, format)
|
|
156
|
+
Content::Config.load(config_path)
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
errors << issue(
|
|
159
|
+
rule_id: "CONFIG_INVALID",
|
|
160
|
+
message: "content.config.json is invalid: #{e.message}",
|
|
161
|
+
file: "content.config.json",
|
|
162
|
+
suggestion: "Check the JSON syntax and structure"
|
|
163
|
+
)
|
|
164
|
+
duration_ms = elapsed_ms(start)
|
|
165
|
+
output_result("fail", errors, warnings, files_checked, duration_ms, format)
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def discover_content_files(content_dir, file_extension)
|
|
170
|
+
Dir.glob(File.join(content_dir, "**", "*")).select do |path|
|
|
171
|
+
File.file?(path) && File.extname(path).delete_prefix(".") == file_extension
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_content_file(parser, file_path, file_extension, relative_path, errors)
|
|
176
|
+
if file_extension == "md"
|
|
177
|
+
parser.parse_markdown_file(file_path)
|
|
178
|
+
else
|
|
179
|
+
parser.parse_yaml_file(file_path)
|
|
180
|
+
end
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
errors << issue(
|
|
183
|
+
rule_id: "PARSE_ERROR",
|
|
184
|
+
message: "Failed to parse content file: #{e.message}",
|
|
185
|
+
file: relative_path,
|
|
186
|
+
suggestion: "Check the file format and front matter syntax"
|
|
187
|
+
)
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def issue(rule_id:, message:, file:, suggestion:)
|
|
192
|
+
{ rule_id: rule_id, message: message, file: file, suggestion: suggestion }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def output_result(status, errors, warnings, files_checked, duration_ms, format)
|
|
196
|
+
OutputFormatter.format_check_result(
|
|
197
|
+
status: status,
|
|
198
|
+
errors: errors,
|
|
199
|
+
warnings: warnings,
|
|
200
|
+
files_checked: files_checked,
|
|
201
|
+
duration_ms: duration_ms,
|
|
202
|
+
format: format
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def elapsed_ms(start)
|
|
207
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).to_i
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/hammer/cli.rb
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Hammer
|
|
4
4
|
module CLI
|
|
5
|
+
KNOWN_COMMANDS = %w[build check].freeze
|
|
6
|
+
|
|
5
7
|
module_function
|
|
6
8
|
|
|
7
9
|
def run(argv)
|
|
8
|
-
command = argv
|
|
10
|
+
command = resolve_command(argv)
|
|
9
11
|
case command
|
|
10
12
|
when "build"
|
|
11
13
|
BuildCommand.run(argv)
|
|
14
|
+
when "check"
|
|
15
|
+
CheckCommand.run(argv)
|
|
12
16
|
else
|
|
13
17
|
warn "Unknown command: #{command}"
|
|
14
18
|
2
|
|
@@ -23,5 +27,13 @@ module Hammer
|
|
|
23
27
|
warn "Build failed: #{e.message}"
|
|
24
28
|
1
|
|
25
29
|
end
|
|
30
|
+
|
|
31
|
+
def resolve_command(argv)
|
|
32
|
+
return "build" if argv.empty?
|
|
33
|
+
return argv.shift if KNOWN_COMMANDS.include?(argv.first)
|
|
34
|
+
return "build" if argv.first.start_with?("-")
|
|
35
|
+
|
|
36
|
+
argv.first
|
|
37
|
+
end
|
|
26
38
|
end
|
|
27
39
|
end
|
|
@@ -22,6 +22,24 @@ module Hammer
|
|
|
22
22
|
output&.dig("filename") || output_path
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def validate_required_fields(metadata)
|
|
26
|
+
return [] unless fields
|
|
27
|
+
|
|
28
|
+
missing = []
|
|
29
|
+
fields.each do |field_name, field_config|
|
|
30
|
+
next unless field_config.required == true
|
|
31
|
+
|
|
32
|
+
unless metadata.key?(field_name)
|
|
33
|
+
missing << field_name
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
value = metadata[field_name]
|
|
38
|
+
missing << field_name if value.is_a?(String) && value.empty?
|
|
39
|
+
end
|
|
40
|
+
missing
|
|
41
|
+
end
|
|
42
|
+
|
|
25
43
|
def self.from_hash(hash)
|
|
26
44
|
fields = (hash["fields"] || {}).transform_values { |f| FieldConfig.from_hash(f) }
|
|
27
45
|
new(
|
|
@@ -29,5 +29,56 @@ module Hammer
|
|
|
29
29
|
puts "Build finished in #{duration.round(2)}s — #{file_count} files (#{build_size})"
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
def format_check_result(status:, errors:, warnings:, files_checked:, duration_ms:, format:)
|
|
34
|
+
if format == :json
|
|
35
|
+
payload = {
|
|
36
|
+
"status" => status,
|
|
37
|
+
"errors" => errors.map { |e| check_issue_hash(e) },
|
|
38
|
+
"warnings" => warnings.map { |w| check_issue_hash(w) },
|
|
39
|
+
"stats" => {
|
|
40
|
+
"filesChecked" => files_checked,
|
|
41
|
+
"durationMs" => duration_ms
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
puts JSON.pretty_generate(payload)
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
puts ""
|
|
49
|
+
if status == "pass"
|
|
50
|
+
puts "\u2714 #{files_checked} files validated"
|
|
51
|
+
else
|
|
52
|
+
puts "\u2716 #{errors.length} error#{errors.length == 1 ? '' : 's'}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
errors.each do |error|
|
|
56
|
+
puts ""
|
|
57
|
+
puts "[#{error[:rule_id]}]"
|
|
58
|
+
puts error[:message]
|
|
59
|
+
puts " #{error[:file]}"
|
|
60
|
+
puts " Suggestion: #{error[:suggestion]}" if error[:suggestion]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless warnings.empty?
|
|
64
|
+
puts ""
|
|
65
|
+
puts "\u26A0 #{warnings.length} warning#{warnings.length == 1 ? '' : 's'}"
|
|
66
|
+
warnings.each do |warning|
|
|
67
|
+
puts " [#{warning[:rule_id]}] #{warning[:message]}"
|
|
68
|
+
puts " #{warning[:file]}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
puts ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_issue_hash(issue)
|
|
75
|
+
hash = {
|
|
76
|
+
"ruleId" => issue[:rule_id],
|
|
77
|
+
"message" => issue[:message],
|
|
78
|
+
"file" => issue[:file]
|
|
79
|
+
}
|
|
80
|
+
hash["suggestion"] = issue[:suggestion] if issue[:suggestion]
|
|
81
|
+
hash
|
|
82
|
+
end
|
|
32
83
|
end
|
|
33
84
|
end
|
data/lib/hammer/version.rb
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
CONTENT_FIXTURE = File.expand_path("../../Test Projects/content-generation-test", __dir__)
|
|
10
|
+
|
|
11
|
+
RSpec.describe Hammer::CheckCommand do
|
|
12
|
+
|
|
13
|
+
def capture_check(argv, project: CONTENT_FIXTURE)
|
|
14
|
+
out = StringIO.new
|
|
15
|
+
original = $stdout
|
|
16
|
+
$stdout = out
|
|
17
|
+
exit_code = Hammer::CheckCommand.run(["--project", project, *argv])
|
|
18
|
+
[out.string, exit_code]
|
|
19
|
+
ensure
|
|
20
|
+
$stdout = original
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe "content-generation-test fixture" do
|
|
24
|
+
it "passes validation" do
|
|
25
|
+
stdout, exit_code = capture_check(["--format", "json"])
|
|
26
|
+
result = JSON.parse(stdout)
|
|
27
|
+
|
|
28
|
+
expect(exit_code).to eq(0)
|
|
29
|
+
expect(result["status"]).to eq("pass")
|
|
30
|
+
expect(result["errors"]).to be_empty
|
|
31
|
+
expect(result["stats"]["filesChecked"]).to be > 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "passes with --strict when no warnings" do
|
|
35
|
+
_stdout, exit_code = capture_check(["--strict", "--format", "json"])
|
|
36
|
+
expect(exit_code).to eq(0)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "CONFIG_MISSING" do
|
|
41
|
+
it "passes with warning when not strict" do
|
|
42
|
+
Dir.mktmpdir do |dir|
|
|
43
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
44
|
+
result = JSON.parse(stdout)
|
|
45
|
+
|
|
46
|
+
expect(exit_code).to eq(0)
|
|
47
|
+
expect(result["status"]).to eq("pass")
|
|
48
|
+
expect(result["warnings"].map { |w| w["ruleId"] }).to include("CONFIG_MISSING")
|
|
49
|
+
expect(result["stats"]["filesChecked"]).to eq(0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "fails with --strict" do
|
|
54
|
+
Dir.mktmpdir do |dir|
|
|
55
|
+
stdout, exit_code = capture_check(["--strict", "--format", "json"], project: dir)
|
|
56
|
+
result = JSON.parse(stdout)
|
|
57
|
+
|
|
58
|
+
expect(exit_code).to eq(1)
|
|
59
|
+
expect(result["status"]).to eq("fail")
|
|
60
|
+
expect(result["warnings"].map { |w| w["ruleId"] }).to include("CONFIG_MISSING")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe "CONFIG_INVALID" do
|
|
66
|
+
it "fails and does not scan files" do
|
|
67
|
+
Dir.mktmpdir do |dir|
|
|
68
|
+
File.write(File.join(dir, "content.config.json"), "{ invalid json")
|
|
69
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
70
|
+
result = JSON.parse(stdout)
|
|
71
|
+
|
|
72
|
+
expect(exit_code).to eq(1)
|
|
73
|
+
expect(result["status"]).to eq("fail")
|
|
74
|
+
expect(result["errors"].map { |e| e["ruleId"] }).to include("CONFIG_INVALID")
|
|
75
|
+
expect(result["stats"]["filesChecked"]).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "COLLECTION_DIR_MISSING" do
|
|
81
|
+
it "reports missing collection directory" do
|
|
82
|
+
Dir.mktmpdir do |dir|
|
|
83
|
+
File.write(
|
|
84
|
+
File.join(dir, "content.config.json"),
|
|
85
|
+
JSON.generate(
|
|
86
|
+
"collections" => {
|
|
87
|
+
"posts" => { "path" => "content/missing" }
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
92
|
+
result = JSON.parse(stdout)
|
|
93
|
+
|
|
94
|
+
expect(exit_code).to eq(1)
|
|
95
|
+
expect(result["errors"].map { |e| e["ruleId"] }).to include("COLLECTION_DIR_MISSING")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe "TEMPLATE_MISSING" do
|
|
101
|
+
it "reports missing template file" do
|
|
102
|
+
Dir.mktmpdir do |dir|
|
|
103
|
+
FileUtils.mkdir_p(File.join(dir, "content", "posts"))
|
|
104
|
+
File.write(
|
|
105
|
+
File.join(dir, "content.config.json"),
|
|
106
|
+
JSON.generate(
|
|
107
|
+
"collections" => {
|
|
108
|
+
"posts" => {
|
|
109
|
+
"path" => "content/posts",
|
|
110
|
+
"template" => "templates/missing.html"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
116
|
+
result = JSON.parse(stdout)
|
|
117
|
+
|
|
118
|
+
expect(exit_code).to eq(1)
|
|
119
|
+
expect(result["errors"].map { |e| e["ruleId"] }).to include("TEMPLATE_MISSING")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe "REQUIRED_FIELD_MISSING" do
|
|
125
|
+
it "reports missing required front matter fields" do
|
|
126
|
+
Dir.mktmpdir do |dir|
|
|
127
|
+
FileUtils.mkdir_p(File.join(dir, "content", "posts"))
|
|
128
|
+
File.write(
|
|
129
|
+
File.join(dir, "content.config.json"),
|
|
130
|
+
JSON.generate(
|
|
131
|
+
"collections" => {
|
|
132
|
+
"posts" => {
|
|
133
|
+
"path" => "content/posts",
|
|
134
|
+
"fields" => {
|
|
135
|
+
"title" => { "type" => "string", "required" => true }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
File.write(File.join(dir, "content", "posts", "no-title.md"), "---\ndate: 2024-01-01\n---\n\nBody")
|
|
142
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
143
|
+
result = JSON.parse(stdout)
|
|
144
|
+
|
|
145
|
+
expect(exit_code).to eq(1)
|
|
146
|
+
expect(result["errors"].map { |e| e["ruleId"] }).to include("REQUIRED_FIELD_MISSING")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "SLUG_MISSING" do
|
|
152
|
+
it "warns when slug and fallback are absent" do
|
|
153
|
+
Dir.mktmpdir do |dir|
|
|
154
|
+
FileUtils.mkdir_p(File.join(dir, "content", "posts"))
|
|
155
|
+
File.write(
|
|
156
|
+
File.join(dir, "content.config.json"),
|
|
157
|
+
JSON.generate(
|
|
158
|
+
"collections" => {
|
|
159
|
+
"posts" => {
|
|
160
|
+
"path" => "content/posts",
|
|
161
|
+
"slug" => { "field" => "slug", "fallback" => "title" }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
File.write(File.join(dir, "content", "posts", "post.md"), "---\ndate: 2024-01-01\n---\n\nBody")
|
|
167
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
168
|
+
result = JSON.parse(stdout)
|
|
169
|
+
|
|
170
|
+
expect(exit_code).to eq(0)
|
|
171
|
+
expect(result["warnings"].map { |w| w["ruleId"] }).to include("SLUG_MISSING")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "fails with --strict" do
|
|
176
|
+
Dir.mktmpdir do |dir|
|
|
177
|
+
FileUtils.mkdir_p(File.join(dir, "content", "posts"))
|
|
178
|
+
File.write(
|
|
179
|
+
File.join(dir, "content.config.json"),
|
|
180
|
+
JSON.generate(
|
|
181
|
+
"collections" => {
|
|
182
|
+
"posts" => {
|
|
183
|
+
"path" => "content/posts",
|
|
184
|
+
"slug" => { "field" => "slug", "fallback" => "title" }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
File.write(File.join(dir, "content", "posts", "post.md"), "---\ndate: 2024-01-01\n---\n\nBody")
|
|
190
|
+
_stdout, exit_code = capture_check(["--strict", "--format", "json"], project: dir)
|
|
191
|
+
expect(exit_code).to eq(1)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "PARSE_ERROR" do
|
|
197
|
+
it "reports unparseable content files" do
|
|
198
|
+
Dir.mktmpdir do |dir|
|
|
199
|
+
FileUtils.mkdir_p(File.join(dir, "content", "posts"))
|
|
200
|
+
File.write(
|
|
201
|
+
File.join(dir, "content.config.json"),
|
|
202
|
+
JSON.generate("collections" => { "posts" => { "path" => "content/posts" } })
|
|
203
|
+
)
|
|
204
|
+
allow(Hammer::Content::Parser).to receive(:new).and_wrap_original do |method, **kwargs|
|
|
205
|
+
parser = method.call(**kwargs)
|
|
206
|
+
allow(parser).to receive(:parse_markdown_file).and_raise(StandardError, "bad front matter")
|
|
207
|
+
parser
|
|
208
|
+
end
|
|
209
|
+
File.write(File.join(dir, "content", "posts", "bad.md"), "---\ntitle: ok\n---\n\nBody")
|
|
210
|
+
stdout, exit_code = capture_check(["--format", "json"], project: dir)
|
|
211
|
+
result = JSON.parse(stdout)
|
|
212
|
+
|
|
213
|
+
expect(exit_code).to eq(1)
|
|
214
|
+
expect(result["errors"].map { |e| e["ruleId"] }).to include("PARSE_ERROR")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
describe "PROJECT_NOT_FOUND" do
|
|
220
|
+
it "returns exit 2 with JSON error status" do
|
|
221
|
+
stdout, exit_code = capture_check(["--format", "json"], project: "/nonexistent/path")
|
|
222
|
+
result = JSON.parse(stdout)
|
|
223
|
+
|
|
224
|
+
expect(exit_code).to eq(2)
|
|
225
|
+
expect(result["status"]).to eq("error")
|
|
226
|
+
expect(result["errors"].first["ruleId"]).to eq("PROJECT_NOT_FOUND")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
describe "CollectionConfig#validate_required_fields" do
|
|
231
|
+
it "flags missing keys and empty strings" do
|
|
232
|
+
config = Hammer::Content::CollectionConfig.new(
|
|
233
|
+
fields: {
|
|
234
|
+
"title" => Hammer::Content::FieldConfig.new(required: true),
|
|
235
|
+
"count" => Hammer::Content::FieldConfig.new(required: true)
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
missing = config.validate_required_fields("title" => "", "count" => 0)
|
|
240
|
+
expect(missing).to contain_exactly("title")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
RSpec.describe Hammer::CLI do
|
|
246
|
+
def capture_cli(argv)
|
|
247
|
+
out = StringIO.new
|
|
248
|
+
original = $stdout
|
|
249
|
+
$stdout = out
|
|
250
|
+
exit_code = Hammer::CLI.run(argv)
|
|
251
|
+
[out.string, exit_code]
|
|
252
|
+
ensure
|
|
253
|
+
$stdout = original
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "dispatches hammer check" do
|
|
257
|
+
stdout, exit_code = capture_cli(["check", "--project", CONTENT_FIXTURE, "--format", "json"])
|
|
258
|
+
result = JSON.parse(stdout)
|
|
259
|
+
|
|
260
|
+
expect(exit_code).to eq(0)
|
|
261
|
+
expect(result["status"]).to eq("pass")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "defaults to build for flag-only invocation" do
|
|
265
|
+
stdout, exit_code = capture_cli(["--project", CONTENT_FIXTURE, "--format", "json", "--quiet"])
|
|
266
|
+
result = JSON.parse(stdout)
|
|
267
|
+
|
|
268
|
+
expect(exit_code).to eq(0)
|
|
269
|
+
expect(result["status"]).to eq("success")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "returns exit 2 for unknown commands" do
|
|
273
|
+
_stdout, exit_code = capture_cli(["unknown"])
|
|
274
|
+
expect(exit_code).to eq(2)
|
|
275
|
+
end
|
|
276
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: beachio-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.pre7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Beach.io
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- lib/beachio-hammer.rb
|
|
55
55
|
- lib/hammer/build_command.rb
|
|
56
56
|
- lib/hammer/builder.rb
|
|
57
|
+
- lib/hammer/check_command.rb
|
|
57
58
|
- lib/hammer/cli.rb
|
|
58
59
|
- lib/hammer/content/builder.rb
|
|
59
60
|
- lib/hammer/content/config.rb
|
|
@@ -78,6 +79,7 @@ files:
|
|
|
78
79
|
- scripts/test-content.rb
|
|
79
80
|
- scripts/tree-diff.rb
|
|
80
81
|
- spec/blog_listing_spec.rb
|
|
82
|
+
- spec/check_command_spec.rb
|
|
81
83
|
- spec/encoding_spec.rb
|
|
82
84
|
- spec/parity/classic_spec.rb
|
|
83
85
|
- spec/parity/content_spec.rb
|