ace-docs 0.31.0

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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/docs/config.yml +169 -0
  3. data/.ace-defaults/docs/multi-subject-example.md +130 -0
  4. data/.ace-defaults/docs/single-subject-example.md +150 -0
  5. data/.ace-defaults/nav/protocols/guide-sources/ace-docs.yml +10 -0
  6. data/.ace-defaults/nav/protocols/prompt-sources/ace-docs.yml +34 -0
  7. data/.ace-defaults/nav/protocols/tmpl-sources/ace-docs.yml +10 -0
  8. data/.ace-defaults/nav/protocols/wfi-sources/ace-docs.yml +19 -0
  9. data/CHANGELOG.md +1082 -0
  10. data/LICENSE +21 -0
  11. data/README.md +40 -0
  12. data/Rakefile +14 -0
  13. data/exe/ace-docs +14 -0
  14. data/handbook/guides/documentation/ruby.md +16 -0
  15. data/handbook/guides/documentation/rust.md +35 -0
  16. data/handbook/guides/documentation/typescript.md +18 -0
  17. data/handbook/guides/documentation.g.md +437 -0
  18. data/handbook/guides/documents-embedded-sync.g.md +473 -0
  19. data/handbook/guides/documents-embedding.g.md +276 -0
  20. data/handbook/guides/markdown-style.g.md +290 -0
  21. data/handbook/prompts/ace-change-analyzer.system.md +113 -0
  22. data/handbook/prompts/ace-change-analyzer.user.md +95 -0
  23. data/handbook/prompts/document-analysis.md +74 -0
  24. data/handbook/prompts/document-analysis.system.md +129 -0
  25. data/handbook/prompts/markdown-style.system.md +113 -0
  26. data/handbook/skills/as-docs-create-adr/SKILL.md +35 -0
  27. data/handbook/skills/as-docs-create-api/SKILL.md +35 -0
  28. data/handbook/skills/as-docs-create-user/SKILL.md +35 -0
  29. data/handbook/skills/as-docs-maintain-adrs/SKILL.md +35 -0
  30. data/handbook/skills/as-docs-squash-changelog/SKILL.md +42 -0
  31. data/handbook/skills/as-docs-update/SKILL.md +36 -0
  32. data/handbook/skills/as-docs-update-blueprint/SKILL.md +28 -0
  33. data/handbook/skills/as-docs-update-roadmap/SKILL.md +24 -0
  34. data/handbook/skills/as-docs-update-tools/SKILL.md +36 -0
  35. data/handbook/skills/as-docs-update-usage/SKILL.md +26 -0
  36. data/handbook/templates/code-docs/javascript-jsdoc.template.md +102 -0
  37. data/handbook/templates/code-docs/ruby-yard.template.md +85 -0
  38. data/handbook/templates/project-docs/README.template.md +73 -0
  39. data/handbook/templates/project-docs/architecture.template.md +300 -0
  40. data/handbook/templates/project-docs/blueprint.template.md +165 -0
  41. data/handbook/templates/project-docs/context/ownership.yml +160 -0
  42. data/handbook/templates/project-docs/decisions/adr.template.md +60 -0
  43. data/handbook/templates/project-docs/prd.template.md +144 -0
  44. data/handbook/templates/project-docs/roadmap/roadmap.template.md +47 -0
  45. data/handbook/templates/project-docs/vision.template.md +233 -0
  46. data/handbook/templates/user-docs/user-guide.template.md +107 -0
  47. data/handbook/workflow-instructions/docs/create-adr.wf.md +334 -0
  48. data/handbook/workflow-instructions/docs/create-api.wf.md +448 -0
  49. data/handbook/workflow-instructions/docs/create-cookbook.wf.md +434 -0
  50. data/handbook/workflow-instructions/docs/create-user.wf.md +399 -0
  51. data/handbook/workflow-instructions/docs/maintain-adrs.wf.md +589 -0
  52. data/handbook/workflow-instructions/docs/squash-changelog.wf.md +246 -0
  53. data/handbook/workflow-instructions/docs/update-blueprint.wf.md +361 -0
  54. data/handbook/workflow-instructions/docs/update-context.wf.md +336 -0
  55. data/handbook/workflow-instructions/docs/update-roadmap.wf.md +421 -0
  56. data/handbook/workflow-instructions/docs/update-tools.wf.md +307 -0
  57. data/handbook/workflow-instructions/docs/update-usage.wf.md +710 -0
  58. data/handbook/workflow-instructions/docs/update.wf.md +418 -0
  59. data/lib/ace/docs/atoms/diff_filterer.rb +131 -0
  60. data/lib/ace/docs/atoms/frontmatter_free_matcher.rb +20 -0
  61. data/lib/ace/docs/atoms/git_date_resolver.rb +16 -0
  62. data/lib/ace/docs/atoms/readme_metadata_inferrer.rb +60 -0
  63. data/lib/ace/docs/atoms/terminology_extractor.rb +308 -0
  64. data/lib/ace/docs/atoms/time_range_calculator.rb +96 -0
  65. data/lib/ace/docs/atoms/timestamp_parser.rb +106 -0
  66. data/lib/ace/docs/atoms/type_inferrer.rb +70 -0
  67. data/lib/ace/docs/cli/commands/analyze.rb +351 -0
  68. data/lib/ace/docs/cli/commands/analyze_consistency.rb +185 -0
  69. data/lib/ace/docs/cli/commands/discover.rb +75 -0
  70. data/lib/ace/docs/cli/commands/scope_options.rb +71 -0
  71. data/lib/ace/docs/cli/commands/status.rb +241 -0
  72. data/lib/ace/docs/cli/commands/update.rb +198 -0
  73. data/lib/ace/docs/cli/commands/validate.rb +225 -0
  74. data/lib/ace/docs/cli.rb +60 -0
  75. data/lib/ace/docs/models/analysis_report.rb +120 -0
  76. data/lib/ace/docs/models/consistency_report.rb +259 -0
  77. data/lib/ace/docs/models/document.rb +354 -0
  78. data/lib/ace/docs/molecules/change_detector.rb +389 -0
  79. data/lib/ace/docs/molecules/document_loader.rb +133 -0
  80. data/lib/ace/docs/molecules/frontmatter_manager.rb +85 -0
  81. data/lib/ace/docs/molecules/git_date_resolver.rb +30 -0
  82. data/lib/ace/docs/organisms/cross_document_analyzer.rb +274 -0
  83. data/lib/ace/docs/organisms/document_registry.rb +318 -0
  84. data/lib/ace/docs/organisms/validator.rb +164 -0
  85. data/lib/ace/docs/prompts/compact_diff_prompt.rb +119 -0
  86. data/lib/ace/docs/prompts/consistency_prompt.rb +286 -0
  87. data/lib/ace/docs/prompts/document_analysis_prompt.rb +389 -0
  88. data/lib/ace/docs/version.rb +7 -0
  89. data/lib/ace/docs.rb +82 -0
  90. data/lib/test.rb +4 -0
  91. metadata +347 -0
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require "terminal-table"
6
+ require "colorize"
7
+ require_relative "../../organisms/document_registry"
8
+ require_relative "scope_options"
9
+
10
+ module Ace
11
+ module Docs
12
+ module CLI
13
+ module Commands
14
+ # ace-support-cli Command class for the status command
15
+ #
16
+ # This command shows document freshness and update status.
17
+ class Status < Ace::Support::Cli::Command
18
+ include Ace::Support::Cli::Base
19
+ include ScopeOptions
20
+
21
+ desc <<~DESC.strip
22
+ Show status of all managed documents
23
+
24
+ Display status information for all documents tracked by ace-docs,
25
+ including freshness, update status, and document metadata.
26
+
27
+ Configuration:
28
+ Global config: ~/.ace/docs/config.yml
29
+ Project config: .ace/docs/config.yml
30
+ Example: ace-docs/.ace-defaults/docs/config.yml
31
+
32
+ Output:
33
+ Table format with columns: path, type, status, last-updated
34
+ Exit codes: 0 (success), 1 (error)
35
+ DESC
36
+
37
+ example [
38
+ " # All tracked documents",
39
+ "--type handbook # Filter by document type",
40
+ "--needs-update # Show only documents needing update",
41
+ "--freshness stale # Filter by freshness status",
42
+ "--freshness current # Filter by freshness status",
43
+ "--package ace-docs # Scope to one package",
44
+ "--glob 'ace-docs/**/*.md' # Scope by glob"
45
+ ]
46
+
47
+ option :type, type: :string, desc: "Filter by document type"
48
+ option :needs_update, type: :boolean, desc: "Show only documents needing update"
49
+ option :freshness, type: :string, desc: "Filter by freshness status (current/stale/outdated)"
50
+ option :package, type: :array, desc: "Scope to package(s), e.g. --package ace-docs"
51
+ option :glob, type: :array, desc: "Scope by glob(s), e.g. --glob 'ace-docs/**/*.md'"
52
+
53
+ # Standard options
54
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
55
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
56
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
57
+
58
+ def call(**options)
59
+ execute_status(options)
60
+ end
61
+
62
+ private
63
+
64
+ def execute_status(options)
65
+ registry = create_registry(options)
66
+ documents = filter_documents(registry, options)
67
+
68
+ if documents.empty?
69
+ warn "No managed documents found."
70
+ return
71
+ end
72
+
73
+ display_status(documents)
74
+ display_summary(documents, registry)
75
+ rescue => e
76
+ warn "Error showing status: #{e.message}"
77
+ warn e.backtrace.join("\n") if debug?(options)
78
+ raise Ace::Support::Cli::Error.new(e.message)
79
+ end
80
+
81
+ def create_registry(options)
82
+ project_root = options[:project_root]
83
+ scope_globs = normalized_scope_globs(options, project_root: project_root)
84
+
85
+ Ace::Docs::Organisms::DocumentRegistry.new(
86
+ project_root: project_root,
87
+ scope_globs: scope_globs
88
+ )
89
+ end
90
+
91
+ def filter_documents(registry, options)
92
+ documents = registry.all
93
+
94
+ # Filter by type if specified
95
+ if options[:type]
96
+ documents = documents.select { |doc| doc.doc_type == options[:type] }
97
+ end
98
+
99
+ # Filter by needs-update if specified
100
+ if options[:needs_update]
101
+ documents = documents.select(&:needs_update?)
102
+ end
103
+
104
+ # Filter by freshness if specified
105
+ if options[:freshness]
106
+ status = options[:freshness].to_sym
107
+ documents = documents.select { |doc| doc.freshness_status == status }
108
+ end
109
+
110
+ documents.sort_by(&:path)
111
+ end
112
+
113
+ def display_status(documents)
114
+ # Group documents by directory
115
+ grouped = documents.group_by { |doc| File.dirname(doc.relative_path || doc.path) }
116
+
117
+ puts "\nManaged Documents (#{documents.size} found)\n"
118
+
119
+ grouped.each do |group_name, group_docs|
120
+ puts "\n#{format_group_name(group_name)}:".cyan
121
+ display_group_table(group_docs)
122
+ end
123
+ end
124
+
125
+ def display_group_table(documents)
126
+ rows = documents.map do |doc|
127
+ [
128
+ status_icon(doc),
129
+ doc.display_name,
130
+ doc.doc_type || "-",
131
+ format_date(doc.last_updated),
132
+ freshness_indicator(doc)
133
+ ]
134
+ end
135
+
136
+ table = Terminal::Table.new do |t|
137
+ t.headings = ["", "Document", "Type", "Last Updated", "Status"]
138
+ t.rows = rows
139
+ t.style = {border_top: false, border_bottom: false}
140
+ end
141
+
142
+ puts table
143
+ end
144
+
145
+ def display_summary(documents, registry)
146
+ stats = registry.stats
147
+ needs_update = documents.count(&:needs_update?)
148
+
149
+ puts "\nSummary:"
150
+ puts " Total: #{documents.size} documents"
151
+ puts " Needing update: #{needs_update}".yellow if needs_update > 0
152
+
153
+ # Show by type
154
+ if stats[:by_type].any?
155
+ puts " By type:"
156
+ stats[:by_type].each do |type, count|
157
+ puts " #{type}: #{count}"
158
+ end
159
+ end
160
+
161
+ # Show by freshness
162
+ freshness_counts = documents.group_by(&:freshness_status).transform_values(&:size)
163
+ if freshness_counts.any?
164
+ puts " By freshness:"
165
+ freshness_counts.each do |status, count|
166
+ color = case status
167
+ when :current then :green
168
+ when :stale then :yellow
169
+ when :outdated then :red
170
+ else :white
171
+ end
172
+ puts " #{status}: #{count}".colorize(color)
173
+ end
174
+ end
175
+ end
176
+
177
+ def status_icon(doc)
178
+ case doc.freshness_status
179
+ when :current
180
+ "✓".green
181
+ when :stale
182
+ "⚠".yellow
183
+ when :outdated
184
+ "✗".red
185
+ else
186
+ "?".light_black
187
+ end
188
+ end
189
+
190
+ def format_date(date)
191
+ return "-" unless date
192
+
193
+ # Normalize Time to Date for age calculation
194
+ date_for_calc = date.is_a?(Time) ? date.to_date : date
195
+ days_ago = (Date.today - date_for_calc).to_i
196
+
197
+ # Display with time component when available (ISO 8601 for Time objects)
198
+ date_str = if date.respond_to?(:hour)
199
+ date.utc.strftime("%Y-%m-%dT%H:%M:%SZ") # ISO 8601 UTC
200
+ else
201
+ date.strftime("%Y-%m-%d") # Date-only
202
+ end
203
+
204
+ if days_ago == 0
205
+ "#{date_str} (today)".green
206
+ elsif days_ago == 1
207
+ "#{date_str} (1d ago)".green
208
+ elsif days_ago <= 7
209
+ "#{date_str} (#{days_ago}d ago)".yellow
210
+ else
211
+ "#{date_str} (#{days_ago}d ago)".red
212
+ end
213
+ end
214
+
215
+ def freshness_indicator(doc)
216
+ if doc.needs_update?
217
+ "needs update".red
218
+ elsif doc.freshness_status == :current
219
+ "current"
220
+ elsif doc.freshness_status == :stale
221
+ "getting stale".yellow
222
+ else
223
+ doc.freshness_status.to_s
224
+ end
225
+ end
226
+
227
+ def format_group_name(name)
228
+ # Format group names - keep original directory names intact
229
+ case name
230
+ when nil, ""
231
+ "Root"
232
+ else
233
+ # Just return the directory name as-is (don't capitalize)
234
+ name.to_s
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../../organisms/document_registry"
6
+ require_relative "../../molecules/frontmatter_manager"
7
+ require_relative "../../atoms/frontmatter_free_matcher"
8
+ require_relative "scope_options"
9
+
10
+ module Ace
11
+ module Docs
12
+ module CLI
13
+ module Commands
14
+ # ace-support-cli Command class for the update command
15
+ #
16
+ # This command handles updating document frontmatter.
17
+ class Update < Ace::Support::Cli::Command
18
+ include Ace::Support::Cli::Base
19
+ include ScopeOptions
20
+
21
+ # Exit codes
22
+ EXIT_SUCCESS = 0
23
+ EXIT_ERROR = 1
24
+
25
+ # Custom error classes
26
+ class UpdateError < StandardError; end
27
+ class FileNotFoundError < UpdateError; end
28
+ class MissingArgumentError < UpdateError; end
29
+
30
+ desc <<~DESC.strip
31
+ Update document frontmatter
32
+
33
+ Update frontmatter fields in a single document or all documents matching a preset.
34
+ Common updates include last-updated timestamps and status changes.
35
+
36
+ SYNTAX:
37
+ ace-docs update FILE [OPTIONS]
38
+ ace-docs update --preset PRESET [OPTIONS]
39
+
40
+ Configuration:
41
+ Global config: ~/.ace/docs/config.yml
42
+ Project config: .ace/docs/config.yml
43
+
44
+ Output:
45
+ Updated fields written to file frontmatter
46
+ Exit codes: 0 (success), 1 (error)
47
+ DESC
48
+
49
+ example [
50
+ "README.md --set last-updated=today",
51
+ "docs/guide.md --set status=complete --set last-reviewed=2025-01-04",
52
+ "--set last-updated=today --preset handbook",
53
+ "file.md --set last-updated=2025-01-04",
54
+ "--package ace-docs --set last-checked=today",
55
+ "--glob 'ace-docs/docs/**/*.md' --set last-updated=today"
56
+ ]
57
+
58
+ argument :file, required: false, desc: "File to update (or use --preset)"
59
+
60
+ option :set, type: :hash, desc: "Fields to update (e.g., --set last-updated=today)"
61
+ option :preset, type: :string, desc: "Update all documents matching preset"
62
+ option :package, type: :array, desc: "Scope to package(s), e.g. --package ace-docs"
63
+ option :glob, type: :array, desc: "Scope by glob(s), e.g. --glob 'ace-docs/**/*.md'"
64
+
65
+ # Standard options
66
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
67
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
68
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
69
+
70
+ def call(file: nil, **options)
71
+ # Handle --help/-h passed as file argument
72
+ if file == "--help" || file == "-h"
73
+ # ace-support-cli will handle help automatically, so we just ignore
74
+ return EXIT_SUCCESS
75
+ end
76
+
77
+ execute_update(file, options)
78
+ end
79
+
80
+ private
81
+
82
+ def execute_update(file, options)
83
+ documents = select_documents(file, options)
84
+
85
+ if documents.empty?
86
+ puts "No documents to update."
87
+ return EXIT_SUCCESS
88
+ end
89
+
90
+ updated_count = update_documents(documents, options)
91
+ puts "Updated frontmatter for #{updated_count} document(s)"
92
+
93
+ EXIT_SUCCESS
94
+ rescue => e
95
+ warn "Error updating documents: #{e.message}"
96
+ warn e.backtrace.join("\n") if debug?(options)
97
+ EXIT_ERROR
98
+ end
99
+
100
+ def select_documents(file, options)
101
+ scope_globs = normalized_scope_globs(options, project_root: options[:project_root])
102
+ registry = Ace::Docs::Organisms::DocumentRegistry.new(
103
+ project_root: options[:project_root],
104
+ scope_globs: scope_globs
105
+ )
106
+ scoped_docs = registry.all
107
+
108
+ if options[:preset]
109
+ scoped_docs.select { |d| d.context_preset == options[:preset] }
110
+ elsif file
111
+ # Try to find in registry first (existing doc with frontmatter)
112
+ doc = registry.find_by_path(file)
113
+
114
+ # If not in registry, check if file exists without frontmatter
115
+ if !doc && File.exist?(file)
116
+ unless path_in_scope?(file, scope_globs, project_root: options[:project_root] || Dir.pwd)
117
+ raise FileNotFoundError, "File outside requested scope: #{file}"
118
+ end
119
+
120
+ # Create minimal Document object for file without frontmatter
121
+ require_relative "../../models/document"
122
+ doc = Ace::Docs::Models::Document.new(
123
+ path: File.expand_path(file),
124
+ frontmatter: {}, # Empty frontmatter - will be initialized
125
+ content: File.read(file)
126
+ )
127
+ end
128
+
129
+ raise FileNotFoundError, "File not found: #{file}" unless doc
130
+ [doc]
131
+ elsif scope_options_present?(options)
132
+ scoped_docs
133
+ else
134
+ raise MissingArgumentError, "Please specify a file, --preset, --package, or --glob"
135
+ end
136
+ end
137
+
138
+ def update_documents(documents, options)
139
+ updated_count = 0
140
+ updates = options[:set] || {}
141
+ project_root = options[:project_root] || Dir.pwd
142
+
143
+ documents.each do |doc|
144
+ if frontmatter_free_document?(doc.path, project_root: project_root)
145
+ puts "Skipped: #{doc.path} (frontmatter-free document, metadata is inferred)"
146
+ next
147
+ end
148
+
149
+ # Initialize required fields if frontmatter is empty
150
+ working_updates = doc.frontmatter.empty? ? initialize_required_fields(doc, updates) : updates
151
+
152
+ if Ace::Docs::Molecules::FrontmatterManager.update_document(doc, working_updates)
153
+ updated_count += 1
154
+ puts "Updated: #{doc.display_name}"
155
+ end
156
+ end
157
+
158
+ updated_count
159
+ end
160
+
161
+ def initialize_required_fields(doc, updates)
162
+ required_updates = updates.dup
163
+
164
+ # Infer doc-type from file path/extension if not provided
165
+ required_updates["doc-type"] ||= infer_doc_type(doc.path)
166
+
167
+ # Require purpose to be provided
168
+ unless required_updates["purpose"]
169
+ raise MissingArgumentError, "Purpose required for new frontmatter. Use: --set purpose:'Document description'"
170
+ end
171
+
172
+ required_updates
173
+ end
174
+
175
+ def infer_doc_type(path)
176
+ case path
177
+ when /README\.md$/i then "readme"
178
+ when /\.wf\.md$/ then "workflow"
179
+ when /\.g\.md$/ then "guide"
180
+ when /\.template\.md$/ then "template"
181
+ when /docs\/.*\.md$/ then "context"
182
+ else "reference"
183
+ end
184
+ end
185
+
186
+ def frontmatter_free_document?(path, project_root:)
187
+ patterns = Ace::Docs.config["frontmatter_free"] || []
188
+ Ace::Docs::Atoms::FrontmatterFreeMatcher.match?(
189
+ path,
190
+ patterns: patterns,
191
+ project_root: project_root
192
+ )
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require "colorize"
6
+ require "open3"
7
+ require_relative "../../organisms/document_registry"
8
+ require_relative "../../organisms/validator"
9
+ require_relative "scope_options"
10
+
11
+ module Ace
12
+ module Docs
13
+ module CLI
14
+ module Commands
15
+ # ace-support-cli Command class for the validate command
16
+ #
17
+ # This command handles document validation.
18
+ class Validate < Ace::Support::Cli::Command
19
+ include Ace::Support::Cli::Base
20
+ include ScopeOptions
21
+
22
+ # Exit codes
23
+ EXIT_SUCCESS = 0
24
+ EXIT_ERROR = 1
25
+
26
+ desc <<~DESC.strip
27
+ Validate documents against rules
28
+
29
+ Validate documents against configured rules. Syntax validation uses linters,
30
+ semantic validation uses LLM analysis.
31
+
32
+ Configuration:
33
+ Validation rules configured via ace-lint
34
+ Global config: ~/.ace/docs/config.yml
35
+ Project config: .ace/docs/config.yml
36
+
37
+ Output:
38
+ Validation report printed to stdout
39
+ Exit codes: 0 (pass), 1 (fail), 2 (error)
40
+ DESC
41
+
42
+ example [
43
+ " # Validate all documents",
44
+ "README.md # Validate specific file",
45
+ "docs/**/*.md # Validate by pattern",
46
+ "--syntax # Run syntax validation only",
47
+ "--semantic # Run semantic validation only",
48
+ "--all # Run all validation types",
49
+ "--package ace-docs # Scope validation to one package",
50
+ "--glob 'ace-docs/**/*.md' # Scope validation by glob"
51
+ ]
52
+
53
+ argument :pattern, required: false, desc: "File or pattern to validate"
54
+
55
+ option :syntax, type: :boolean, desc: "Run syntax validation using linters"
56
+ option :semantic, type: :boolean, desc: "Run semantic validation using LLM"
57
+ option :all, type: :boolean, desc: "Run all validation types"
58
+ option :package, type: :array, desc: "Scope to package(s), e.g. --package ace-docs"
59
+ option :glob, type: :array, desc: "Scope by glob(s), e.g. --glob 'ace-docs/**/*.md'"
60
+
61
+ # Standard options
62
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
63
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
64
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
65
+
66
+ def call(pattern: nil, **options)
67
+ # Handle --help/-h passed as pattern argument
68
+ if pattern == "--help" || pattern == "-h"
69
+ # ace-support-cli will handle help automatically, so we just ignore
70
+ return EXIT_SUCCESS
71
+ end
72
+
73
+ execute_validate(pattern, options)
74
+ end
75
+
76
+ private
77
+
78
+ def execute_validate(pattern, options)
79
+ scope_globs = normalized_scope_globs(options, project_root: options[:project_root])
80
+ registry = Ace::Docs::Organisms::DocumentRegistry.new(
81
+ project_root: options[:project_root],
82
+ scope_globs: scope_globs
83
+ )
84
+ documents = select_documents(registry, pattern)
85
+
86
+ if documents.empty?
87
+ warn "No documents to validate."
88
+ return EXIT_SUCCESS
89
+ end
90
+
91
+ validator = Ace::Docs::Organisms::Validator.new(registry)
92
+ has_errors = false
93
+
94
+ documents.each do |doc|
95
+ puts "Validating: #{doc.display_name}"
96
+ results = validate_document(validator, doc, options)
97
+
98
+ if results[:valid]
99
+ puts " ✓ Valid"
100
+ else
101
+ puts " ✗ Invalid".red
102
+ has_errors = true
103
+ display_errors(results[:errors])
104
+ end
105
+
106
+ display_warnings(results[:warnings]) if results[:warnings]&.any?
107
+ end
108
+
109
+ has_errors ? EXIT_ERROR : EXIT_SUCCESS
110
+ rescue => e
111
+ warn "Error validating documents: #{e.message}"
112
+ warn e.backtrace.join("\n") if debug?(options)
113
+ EXIT_ERROR
114
+ end
115
+
116
+ def select_documents(registry, pattern)
117
+ if pattern
118
+ if File.exist?(pattern)
119
+ doc = registry.find_by_path(pattern)
120
+ doc ? [doc] : []
121
+ else
122
+ # Treat as glob pattern
123
+ registry.all.select do |doc|
124
+ rel = doc.relative_path || doc.path
125
+ File.fnmatch?(pattern, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
126
+ File.fnmatch?(pattern, doc.path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
127
+ end
128
+ end
129
+ else
130
+ registry.all
131
+ end
132
+ end
133
+
134
+ def validate_document(validator, doc, options)
135
+ # Determine validation types
136
+ run_syntax = options[:syntax] || options[:all]
137
+ run_semantic = options[:semantic] || options[:all]
138
+ run_all = !options[:syntax] && !options[:semantic]
139
+
140
+ # If syntax validation is requested and ace-lint is configured, use it
141
+ if (run_syntax || run_all) && Ace::Docs.config["validation_enabled"]
142
+ validate_with_ace_lint(doc)
143
+ else
144
+ # Fall back to internal validation
145
+ validator.validate_document(
146
+ doc,
147
+ syntax: run_syntax || run_all,
148
+ semantic: run_semantic || run_all
149
+ )
150
+ end
151
+ end
152
+
153
+ def validate_with_ace_lint(doc)
154
+ ace_lint_path = Ace::Docs.config["ace_lint_path"] || "ace-lint"
155
+
156
+ # Check if ace-lint is available (using argv-style to avoid shell injection)
157
+ unless system("which", ace_lint_path, out: File::NULL, err: File::NULL)
158
+ # Fall back to internal validation
159
+ return {
160
+ valid: false,
161
+ errors: ["ace-lint not found. Install with: gem install ace-lint"],
162
+ warnings: []
163
+ }
164
+ end
165
+
166
+ # Run ace-lint on the document
167
+ stdout, stderr, status = Open3.capture3(ace_lint_path, doc.path)
168
+
169
+ if status.success?
170
+ {valid: true, errors: [], warnings: parse_lint_warnings(stdout)}
171
+ else
172
+ {valid: false, errors: parse_lint_errors(stdout, stderr), warnings: []}
173
+ end
174
+ rescue => e
175
+ {
176
+ valid: false,
177
+ errors: ["Lint validation failed: #{e.message}"],
178
+ warnings: []
179
+ }
180
+ end
181
+
182
+ def parse_lint_errors(stdout, stderr)
183
+ errors = []
184
+
185
+ # Parse stdout for errors
186
+ stdout.lines.each do |line|
187
+ if line.include?("error") || line.include?("ERROR")
188
+ errors << line.strip
189
+ end
190
+ end
191
+
192
+ # Add stderr if present
193
+ errors << stderr.strip unless stderr.strip.empty?
194
+
195
+ errors.empty? ? ["Validation failed"] : errors
196
+ end
197
+
198
+ def parse_lint_warnings(stdout)
199
+ warnings = []
200
+
201
+ stdout.lines.each do |line|
202
+ if line.include?("warning") || line.include?("WARNING")
203
+ warnings << line.strip
204
+ end
205
+ end
206
+
207
+ warnings
208
+ end
209
+
210
+ def display_errors(errors)
211
+ errors.each do |error|
212
+ puts " - #{error}".red
213
+ end
214
+ end
215
+
216
+ def display_warnings(warnings)
217
+ warnings.each do |warning|
218
+ puts " ⚠ #{warning}"
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end