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.
- checksums.yaml +7 -0
- data/.ace-defaults/docs/config.yml +169 -0
- data/.ace-defaults/docs/multi-subject-example.md +130 -0
- data/.ace-defaults/docs/single-subject-example.md +150 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-docs.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-docs.yml +34 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-docs.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-docs.yml +19 -0
- data/CHANGELOG.md +1082 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/exe/ace-docs +14 -0
- data/handbook/guides/documentation/ruby.md +16 -0
- data/handbook/guides/documentation/rust.md +35 -0
- data/handbook/guides/documentation/typescript.md +18 -0
- data/handbook/guides/documentation.g.md +437 -0
- data/handbook/guides/documents-embedded-sync.g.md +473 -0
- data/handbook/guides/documents-embedding.g.md +276 -0
- data/handbook/guides/markdown-style.g.md +290 -0
- data/handbook/prompts/ace-change-analyzer.system.md +113 -0
- data/handbook/prompts/ace-change-analyzer.user.md +95 -0
- data/handbook/prompts/document-analysis.md +74 -0
- data/handbook/prompts/document-analysis.system.md +129 -0
- data/handbook/prompts/markdown-style.system.md +113 -0
- data/handbook/skills/as-docs-create-adr/SKILL.md +35 -0
- data/handbook/skills/as-docs-create-api/SKILL.md +35 -0
- data/handbook/skills/as-docs-create-user/SKILL.md +35 -0
- data/handbook/skills/as-docs-maintain-adrs/SKILL.md +35 -0
- data/handbook/skills/as-docs-squash-changelog/SKILL.md +42 -0
- data/handbook/skills/as-docs-update/SKILL.md +36 -0
- data/handbook/skills/as-docs-update-blueprint/SKILL.md +28 -0
- data/handbook/skills/as-docs-update-roadmap/SKILL.md +24 -0
- data/handbook/skills/as-docs-update-tools/SKILL.md +36 -0
- data/handbook/skills/as-docs-update-usage/SKILL.md +26 -0
- data/handbook/templates/code-docs/javascript-jsdoc.template.md +102 -0
- data/handbook/templates/code-docs/ruby-yard.template.md +85 -0
- data/handbook/templates/project-docs/README.template.md +73 -0
- data/handbook/templates/project-docs/architecture.template.md +300 -0
- data/handbook/templates/project-docs/blueprint.template.md +165 -0
- data/handbook/templates/project-docs/context/ownership.yml +160 -0
- data/handbook/templates/project-docs/decisions/adr.template.md +60 -0
- data/handbook/templates/project-docs/prd.template.md +144 -0
- data/handbook/templates/project-docs/roadmap/roadmap.template.md +47 -0
- data/handbook/templates/project-docs/vision.template.md +233 -0
- data/handbook/templates/user-docs/user-guide.template.md +107 -0
- data/handbook/workflow-instructions/docs/create-adr.wf.md +334 -0
- data/handbook/workflow-instructions/docs/create-api.wf.md +448 -0
- data/handbook/workflow-instructions/docs/create-cookbook.wf.md +434 -0
- data/handbook/workflow-instructions/docs/create-user.wf.md +399 -0
- data/handbook/workflow-instructions/docs/maintain-adrs.wf.md +589 -0
- data/handbook/workflow-instructions/docs/squash-changelog.wf.md +246 -0
- data/handbook/workflow-instructions/docs/update-blueprint.wf.md +361 -0
- data/handbook/workflow-instructions/docs/update-context.wf.md +336 -0
- data/handbook/workflow-instructions/docs/update-roadmap.wf.md +421 -0
- data/handbook/workflow-instructions/docs/update-tools.wf.md +307 -0
- data/handbook/workflow-instructions/docs/update-usage.wf.md +710 -0
- data/handbook/workflow-instructions/docs/update.wf.md +418 -0
- data/lib/ace/docs/atoms/diff_filterer.rb +131 -0
- data/lib/ace/docs/atoms/frontmatter_free_matcher.rb +20 -0
- data/lib/ace/docs/atoms/git_date_resolver.rb +16 -0
- data/lib/ace/docs/atoms/readme_metadata_inferrer.rb +60 -0
- data/lib/ace/docs/atoms/terminology_extractor.rb +308 -0
- data/lib/ace/docs/atoms/time_range_calculator.rb +96 -0
- data/lib/ace/docs/atoms/timestamp_parser.rb +106 -0
- data/lib/ace/docs/atoms/type_inferrer.rb +70 -0
- data/lib/ace/docs/cli/commands/analyze.rb +351 -0
- data/lib/ace/docs/cli/commands/analyze_consistency.rb +185 -0
- data/lib/ace/docs/cli/commands/discover.rb +75 -0
- data/lib/ace/docs/cli/commands/scope_options.rb +71 -0
- data/lib/ace/docs/cli/commands/status.rb +241 -0
- data/lib/ace/docs/cli/commands/update.rb +198 -0
- data/lib/ace/docs/cli/commands/validate.rb +225 -0
- data/lib/ace/docs/cli.rb +60 -0
- data/lib/ace/docs/models/analysis_report.rb +120 -0
- data/lib/ace/docs/models/consistency_report.rb +259 -0
- data/lib/ace/docs/models/document.rb +354 -0
- data/lib/ace/docs/molecules/change_detector.rb +389 -0
- data/lib/ace/docs/molecules/document_loader.rb +133 -0
- data/lib/ace/docs/molecules/frontmatter_manager.rb +85 -0
- data/lib/ace/docs/molecules/git_date_resolver.rb +30 -0
- data/lib/ace/docs/organisms/cross_document_analyzer.rb +274 -0
- data/lib/ace/docs/organisms/document_registry.rb +318 -0
- data/lib/ace/docs/organisms/validator.rb +164 -0
- data/lib/ace/docs/prompts/compact_diff_prompt.rb +119 -0
- data/lib/ace/docs/prompts/consistency_prompt.rb +286 -0
- data/lib/ace/docs/prompts/document_analysis_prompt.rb +389 -0
- data/lib/ace/docs/version.rb +7 -0
- data/lib/ace/docs.rb +82 -0
- data/lib/test.rb +4 -0
- 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
|