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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 761f1f1873cb333406eeab947ee2ae1edde559b69fa38497810c96bdf802f58e
4
- data.tar.gz: 6721ab8889e4129b9c886fa875b92579d359a9012852d69aaa711fa1e29c08eb
3
+ metadata.gz: 7f49550d59c10cbcb9b8ed9dae068082d5962abd833fcf2b6b039d07ce409b5e
4
+ data.tar.gz: 4e04f20a894021945d2366a00a4b4239404541d139d60ed99c93c49afff767e5
5
5
  SHA512:
6
- metadata.gz: 23c9ab030b80757ac305dc66d4f0a35c9df394e248fa22ce62e8164d96c9b1573c4771b49ea82770574c113a7c4fd56ccfd26e294bda7f4515ede443dca28bfa
7
- data.tar.gz: 5b670417365cbd83d7a706f3a1dc03edaadbcde2ca5cff920651446f66c790565d97a798e3a784fed847493cd62e1051cd27a0a4b23d11d67cd46014fb85314b
6
+ metadata.gz: 69c67662fc6d413a3af72e8873ad96345e8e363a87fac6657c1c10385239ab195201805c2e9d1784beae717316f15e9835ba81f1483e39f43b8028c971d03ab4
7
+ data.tar.gz: 0efecd32fde11e6c6b6be017f427554cd7d57c11ad633d766c8b61be499b660cf7f61928e25dbcfd53db52cfe1ed765016c0ab0a5aa0d71a13503b921ed4f029
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- beachio-hammer (0.1.0.pre5)
4
+ beachio-hammer (0.1.0.pre6)
5
5
  json (~> 2.7)
6
6
 
7
7
  GEM
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.pre5`**
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.pre5
82
+ RUN gem install beachio-hammer -v 0.1.0.pre6
72
83
  ```
73
84
 
74
- Build step in the agent:
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 |
@@ -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.first == "build" ? argv.shift : "build"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hammer
4
- VERSION = "0.1.0.pre5"
4
+ VERSION = "0.1.0.pre7"
5
5
  end
@@ -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.pre5
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