beachio-hammer 0.1.0.pre6 → 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: f3b4a0b499b230ddf29d32eec63d87dc4e6ceac09bbfdcbc11b076532dad79a6
4
- data.tar.gz: 6eb175a810aae7ad23c4ffef79f770cb9201083f3ed4466b9c3ed5fefb975c4c
3
+ metadata.gz: 7f49550d59c10cbcb9b8ed9dae068082d5962abd833fcf2b6b039d07ce409b5e
4
+ data.tar.gz: 4e04f20a894021945d2366a00a4b4239404541d139d60ed99c93c49afff767e5
5
5
  SHA512:
6
- metadata.gz: 1b6f05d60825d07e45d3c1055b76b4e9e1155c664ae7de008602c1072fbddec96d19818aad8496f7955eb1f3b4c7c9363cc6ab87ab8990e77dded5a4bd9e00c7
7
- data.tar.gz: 036de2226fd8f9708baf2ff5a46c9fe58f23b7d54e61331ca55aa87f55fb2b0cf05c351869d6fc7a48091104068aebf18a0fa8b3e984115cfb175a5900d67d5b
6
+ metadata.gz: 69c67662fc6d413a3af72e8873ad96345e8e363a87fac6657c1c10385239ab195201805c2e9d1784beae717316f15e9835ba81f1483e39f43b8028c971d03ab4
7
+ data.tar.gz: 0efecd32fde11e6c6b6be017f427554cd7d57c11ad633d766c8b61be499b660cf7f61928e25dbcfd53db52cfe1ed765016c0ab0a5aa0d71a13503b921ed4f029
data/PUBLISH.md CHANGED
@@ -38,7 +38,11 @@ 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.pre6`**
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)
42
46
 
43
47
  ### 0.1.0.pre6 changes (`hammer check`)
44
48
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hammer
4
- VERSION = "0.1.0.pre6"
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.pre6
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