ace-idea 0.18.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/idea/config.yml +21 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-idea.yml +19 -0
- data/CHANGELOG.md +387 -0
- data/README.md +42 -0
- data/Rakefile +13 -0
- data/docs/demo/ace-idea-getting-started.gif +0 -0
- data/docs/demo/ace-idea-getting-started.tape.yml +44 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +102 -0
- data/docs/handbook.md +39 -0
- data/docs/usage.md +320 -0
- data/exe/ace-idea +22 -0
- data/handbook/skills/as-idea-capture/SKILL.md +25 -0
- data/handbook/skills/as-idea-capture-features/SKILL.md +26 -0
- data/handbook/skills/as-idea-review/SKILL.md +26 -0
- data/handbook/workflow-instructions/idea/capture-features.wf.md +243 -0
- data/handbook/workflow-instructions/idea/capture.wf.md +270 -0
- data/handbook/workflow-instructions/idea/prioritize.wf.md +223 -0
- data/handbook/workflow-instructions/idea/review.wf.md +93 -0
- data/lib/ace/idea/atoms/idea_file_pattern.rb +40 -0
- data/lib/ace/idea/atoms/idea_frontmatter_defaults.rb +39 -0
- data/lib/ace/idea/atoms/idea_id_formatter.rb +37 -0
- data/lib/ace/idea/atoms/idea_validation_rules.rb +89 -0
- data/lib/ace/idea/atoms/slug_sanitizer_adapter.rb +6 -0
- data/lib/ace/idea/cli/commands/create.rb +98 -0
- data/lib/ace/idea/cli/commands/doctor.rb +206 -0
- data/lib/ace/idea/cli/commands/list.rb +62 -0
- data/lib/ace/idea/cli/commands/show.rb +55 -0
- data/lib/ace/idea/cli/commands/status.rb +61 -0
- data/lib/ace/idea/cli/commands/update.rb +118 -0
- data/lib/ace/idea/cli.rb +75 -0
- data/lib/ace/idea/models/idea.rb +39 -0
- data/lib/ace/idea/molecules/idea_clipboard_reader.rb +117 -0
- data/lib/ace/idea/molecules/idea_config_loader.rb +93 -0
- data/lib/ace/idea/molecules/idea_creator.rb +248 -0
- data/lib/ace/idea/molecules/idea_display_formatter.rb +165 -0
- data/lib/ace/idea/molecules/idea_doctor_fixer.rb +504 -0
- data/lib/ace/idea/molecules/idea_doctor_reporter.rb +264 -0
- data/lib/ace/idea/molecules/idea_frontmatter_validator.rb +137 -0
- data/lib/ace/idea/molecules/idea_llm_enhancer.rb +177 -0
- data/lib/ace/idea/molecules/idea_loader.rb +124 -0
- data/lib/ace/idea/molecules/idea_mover.rb +78 -0
- data/lib/ace/idea/molecules/idea_resolver.rb +57 -0
- data/lib/ace/idea/molecules/idea_scanner.rb +56 -0
- data/lib/ace/idea/molecules/idea_structure_validator.rb +157 -0
- data/lib/ace/idea/organisms/idea_doctor.rb +207 -0
- data/lib/ace/idea/organisms/idea_manager.rb +251 -0
- data/lib/ace/idea/version.rb +7 -0
- data/lib/ace/idea.rb +37 -0
- metadata +166 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "ace/support/items"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Idea
|
|
8
|
+
module Molecules
|
|
9
|
+
# Moves idea folders to different locations within the ideas root directory.
|
|
10
|
+
# Delegates to SpecialFolderDetector for folder name normalization.
|
|
11
|
+
class IdeaMover
|
|
12
|
+
# @param root_dir [String] Root directory for ideas
|
|
13
|
+
def initialize(root_dir)
|
|
14
|
+
@root_dir = root_dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Move an idea folder to a target location
|
|
18
|
+
# @param idea [Idea] Idea to move
|
|
19
|
+
# @param to [String] Target folder name (short or full, e.g., "maybe", "_archive")
|
|
20
|
+
# @param date [Time, nil] Date used to compute archive partition (default: Time.now)
|
|
21
|
+
# @return [String] New path of the idea directory
|
|
22
|
+
def move(idea, to:, date: nil)
|
|
23
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(to)
|
|
24
|
+
|
|
25
|
+
target_parent = if normalized == "_archive"
|
|
26
|
+
partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(date || Time.now)
|
|
27
|
+
File.expand_path(File.join(@root_dir, normalized, partition))
|
|
28
|
+
else
|
|
29
|
+
File.expand_path(File.join(@root_dir, normalized))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
root_real = File.expand_path(@root_dir)
|
|
33
|
+
unless target_parent.start_with?(root_real + File::SEPARATOR)
|
|
34
|
+
raise ArgumentError, "Path traversal detected in --to option"
|
|
35
|
+
end
|
|
36
|
+
FileUtils.mkdir_p(target_parent)
|
|
37
|
+
|
|
38
|
+
folder_name = File.basename(idea.path)
|
|
39
|
+
new_path = File.join(target_parent, folder_name)
|
|
40
|
+
|
|
41
|
+
# Same-location no-op check
|
|
42
|
+
return idea.path if File.expand_path(idea.path) == File.expand_path(new_path)
|
|
43
|
+
|
|
44
|
+
atomic_move(idea.path, new_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Move an idea to root (remove from special folder)
|
|
48
|
+
# @param idea [Idea] Idea to move
|
|
49
|
+
# @return [String] New path of the idea directory
|
|
50
|
+
def move_to_root(idea)
|
|
51
|
+
folder_name = File.basename(idea.path)
|
|
52
|
+
new_path = File.join(@root_dir, folder_name)
|
|
53
|
+
|
|
54
|
+
# Same-location no-op check
|
|
55
|
+
return idea.path if File.expand_path(idea.path) == File.expand_path(new_path)
|
|
56
|
+
|
|
57
|
+
atomic_move(idea.path, new_path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Move src to dest, raising ArgumentError if dest already exists.
|
|
63
|
+
def atomic_move(src, dest)
|
|
64
|
+
raise ArgumentError, "Destination already exists: #{dest}" if File.exist?(dest)
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
File.rename(src, dest)
|
|
68
|
+
rescue Errno::EXDEV
|
|
69
|
+
# Cross-device: fall back to copy+remove
|
|
70
|
+
FileUtils.cp_r(src, dest)
|
|
71
|
+
FileUtils.rm_rf(src)
|
|
72
|
+
end
|
|
73
|
+
dest
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
require_relative "idea_scanner"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Idea
|
|
8
|
+
module Molecules
|
|
9
|
+
# Wraps ShortcutResolver for raw idea IDs (no .t. type marker).
|
|
10
|
+
# Resolves 3-char suffix shortcuts, full 6-char IDs.
|
|
11
|
+
# Explicitly detects and warns on ambiguity collisions.
|
|
12
|
+
class IdeaResolver
|
|
13
|
+
# @param root_dir [String] Root directory containing ideas
|
|
14
|
+
def initialize(root_dir)
|
|
15
|
+
@root_dir = root_dir
|
|
16
|
+
@scanner = IdeaScanner.new(root_dir)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Resolve a reference to a scan result
|
|
20
|
+
# @param ref [String] Full ID (6 chars) or suffix shortcut (3 chars)
|
|
21
|
+
# @param warn_on_ambiguity [Boolean] Whether to print warning on ambiguity
|
|
22
|
+
# @return [ScanResult, nil] Resolved result or nil
|
|
23
|
+
def resolve(ref, warn_on_ambiguity: true)
|
|
24
|
+
scan_results = @scanner.scan
|
|
25
|
+
resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
|
|
26
|
+
|
|
27
|
+
on_ambiguity = if warn_on_ambiguity
|
|
28
|
+
->(matches) {
|
|
29
|
+
ids = matches.map(&:id).join(", ")
|
|
30
|
+
warn "Warning: Ambiguous shortcut '#{ref}' matches #{matches.size} ideas: #{ids}. Using most recent."
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
resolver.resolve(ref, on_ambiguity: on_ambiguity)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve with explicit ambiguity detection
|
|
38
|
+
# @param ref [String] Reference to resolve
|
|
39
|
+
# @return [Hash] Result with :result, :ambiguous, :matches keys
|
|
40
|
+
def resolve_with_info(ref)
|
|
41
|
+
scan_results = @scanner.scan
|
|
42
|
+
resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
|
|
43
|
+
|
|
44
|
+
matches = resolver.all_matches(ref)
|
|
45
|
+
|
|
46
|
+
if matches.empty?
|
|
47
|
+
{result: nil, ambiguous: false, matches: []}
|
|
48
|
+
elsif matches.size == 1
|
|
49
|
+
{result: matches.first, ambiguous: false, matches: matches}
|
|
50
|
+
else
|
|
51
|
+
{result: matches.last, ambiguous: true, matches: matches}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
require_relative "../atoms/idea_file_pattern"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Idea
|
|
8
|
+
module Molecules
|
|
9
|
+
# Wraps DirectoryScanner for .idea.s.md files.
|
|
10
|
+
# Returns scan results with raw b36ts IDs (no type markers).
|
|
11
|
+
class IdeaScanner
|
|
12
|
+
attr_reader :last_scan_total, :last_folder_counts
|
|
13
|
+
|
|
14
|
+
# @param root_dir [String] Root directory containing ideas (e.g., ".ace-ideas")
|
|
15
|
+
def initialize(root_dir)
|
|
16
|
+
@root_dir = root_dir
|
|
17
|
+
@scanner = Ace::Support::Items::Molecules::DirectoryScanner.new(
|
|
18
|
+
root_dir,
|
|
19
|
+
file_pattern: Atoms::IdeaFilePattern::FILE_GLOB
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Scan for all ideas
|
|
24
|
+
# @return [Array<ScanResult>] Sorted scan results
|
|
25
|
+
def scan
|
|
26
|
+
@scanner.scan
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Scan and filter by special folder or virtual filter
|
|
30
|
+
# @param folder [String, nil] Folder name, virtual filter ("next", "all"), or nil for all
|
|
31
|
+
# @return [Array<ScanResult>] Filtered scan results
|
|
32
|
+
def scan_in_folder(folder)
|
|
33
|
+
results = scan
|
|
34
|
+
@last_scan_total = results.size
|
|
35
|
+
@last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
|
|
36
|
+
return results if folder.nil?
|
|
37
|
+
|
|
38
|
+
virtual = Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(folder)
|
|
39
|
+
case virtual
|
|
40
|
+
when :all then results
|
|
41
|
+
when :next then results.select { |r| r.special_folder.nil? }
|
|
42
|
+
else
|
|
43
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(folder)
|
|
44
|
+
results.select { |r| r.special_folder == normalized }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if root directory exists
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def root_exists?
|
|
51
|
+
Dir.exist?(@root_dir)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/idea_file_pattern"
|
|
4
|
+
require_relative "../atoms/idea_id_formatter"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Idea
|
|
8
|
+
module Molecules
|
|
9
|
+
# Validates the directory structure of an ideas root directory.
|
|
10
|
+
# Checks folder naming, file naming, and structural conventions.
|
|
11
|
+
class IdeaStructureValidator
|
|
12
|
+
# @param root_dir [String] Root directory for ideas (e.g., ".ace-ideas")
|
|
13
|
+
def initialize(root_dir)
|
|
14
|
+
@root_dir = root_dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate the entire ideas directory structure
|
|
18
|
+
# @return [Array<Hash>] List of issues found
|
|
19
|
+
def validate(root_dir = @root_dir)
|
|
20
|
+
issues = []
|
|
21
|
+
|
|
22
|
+
unless Dir.exist?(root_dir)
|
|
23
|
+
issues << {type: :error, message: "Ideas root directory does not exist", location: root_dir}
|
|
24
|
+
return issues
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
check_folder_naming(root_dir, issues)
|
|
28
|
+
check_spec_files(root_dir, issues)
|
|
29
|
+
check_stale_backups(root_dir, issues)
|
|
30
|
+
check_empty_directories(root_dir, issues)
|
|
31
|
+
|
|
32
|
+
issues
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Check that idea folders follow {6-char-id}-{slug} naming
|
|
38
|
+
def check_folder_naming(root_dir, issues)
|
|
39
|
+
idea_dirs(root_dir).each do |dir|
|
|
40
|
+
folder_name = File.basename(dir)
|
|
41
|
+
|
|
42
|
+
# Skip special folders
|
|
43
|
+
next if folder_name.start_with?("_")
|
|
44
|
+
|
|
45
|
+
unless folder_name.match?(/^[0-9a-z]{6}-.+$/)
|
|
46
|
+
issues << {
|
|
47
|
+
type: :error,
|
|
48
|
+
message: "Folder name does not match '{id}-{slug}' convention: '#{folder_name}'",
|
|
49
|
+
location: dir
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check that each idea folder has exactly one .idea.s.md spec file
|
|
56
|
+
def check_spec_files(root_dir, issues)
|
|
57
|
+
idea_dirs(root_dir).each do |dir|
|
|
58
|
+
folder_name = File.basename(dir)
|
|
59
|
+
next if folder_name.start_with?("_")
|
|
60
|
+
|
|
61
|
+
spec_files = Dir.glob(File.join(dir, Atoms::IdeaFilePattern::FILE_GLOB))
|
|
62
|
+
|
|
63
|
+
if spec_files.empty?
|
|
64
|
+
issues << {
|
|
65
|
+
type: :warning,
|
|
66
|
+
message: "No .idea.s.md spec file in idea folder",
|
|
67
|
+
location: dir
|
|
68
|
+
}
|
|
69
|
+
elsif spec_files.size > 1
|
|
70
|
+
issues << {
|
|
71
|
+
type: :warning,
|
|
72
|
+
message: "Multiple .idea.s.md spec files in folder (#{spec_files.size} found)",
|
|
73
|
+
location: dir
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check for stale backup/tmp files
|
|
80
|
+
def check_stale_backups(root_dir, issues)
|
|
81
|
+
backup_patterns = [
|
|
82
|
+
File.join(root_dir, "**", "*.backup.*"),
|
|
83
|
+
File.join(root_dir, "**", "*.tmp"),
|
|
84
|
+
File.join(root_dir, "**", "*~")
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
backup_patterns.each do |pattern|
|
|
88
|
+
Dir.glob(pattern).each do |file|
|
|
89
|
+
next if file.include?("/.git/")
|
|
90
|
+
|
|
91
|
+
issues << {
|
|
92
|
+
type: :warning,
|
|
93
|
+
message: "Stale backup file (safe to delete)",
|
|
94
|
+
location: file
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check for empty directories
|
|
101
|
+
def check_empty_directories(root_dir, issues)
|
|
102
|
+
Dir.glob(File.join(root_dir, "**", "*")).each do |path|
|
|
103
|
+
next unless File.directory?(path)
|
|
104
|
+
next if path.include?("/.git/")
|
|
105
|
+
|
|
106
|
+
files = Dir.glob(File.join(path, "**", "*")).select { |f| File.file?(f) }
|
|
107
|
+
if files.empty?
|
|
108
|
+
issues << {
|
|
109
|
+
type: :warning,
|
|
110
|
+
message: "Empty directory (safe to delete)",
|
|
111
|
+
location: path
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Find all immediate subdirectories that look like idea folders
|
|
118
|
+
# (excludes special folders which are containers, includes their children)
|
|
119
|
+
# Also excludes category folders (folders containing only subdirectories)
|
|
120
|
+
def idea_dirs(root_dir)
|
|
121
|
+
dirs = []
|
|
122
|
+
|
|
123
|
+
# Direct children of root
|
|
124
|
+
Dir.glob(File.join(root_dir, "*")).each do |path|
|
|
125
|
+
next unless File.directory?(path)
|
|
126
|
+
|
|
127
|
+
folder_name = File.basename(path)
|
|
128
|
+
if folder_name.start_with?("_")
|
|
129
|
+
# Recurse into special folders
|
|
130
|
+
Dir.glob(File.join(path, "*")).each do |subpath|
|
|
131
|
+
dirs << subpath if File.directory?(subpath) && !category_folder?(subpath)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
dirs << path unless category_folder?(path)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
dirs
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if a folder is a category folder (only contains subdirectories, no files)
|
|
142
|
+
# These are organizational folders and not individual ideas
|
|
143
|
+
def category_folder?(dir_path)
|
|
144
|
+
return false unless Dir.exist?(dir_path)
|
|
145
|
+
|
|
146
|
+
# Check if folder has any files
|
|
147
|
+
files = Dir.glob(File.join(dir_path, "*")).select { |f| File.file?(f) }
|
|
148
|
+
return false if files.any?
|
|
149
|
+
|
|
150
|
+
# Check if folder has any subdirectories
|
|
151
|
+
subdirs = Dir.glob(File.join(dir_path, "*")).select { |f| File.directory?(f) }
|
|
152
|
+
subdirs.any?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/idea_scanner"
|
|
4
|
+
require_relative "../molecules/idea_loader"
|
|
5
|
+
require_relative "../molecules/idea_frontmatter_validator"
|
|
6
|
+
require_relative "../molecules/idea_structure_validator"
|
|
7
|
+
require_relative "../atoms/idea_validation_rules"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Idea
|
|
11
|
+
module Organisms
|
|
12
|
+
# Orchestrates comprehensive health checks for the ideas system.
|
|
13
|
+
# Runs structure validation, frontmatter validation, and scope/status
|
|
14
|
+
# consistency checks across all ideas in a root directory.
|
|
15
|
+
class IdeaDoctor
|
|
16
|
+
attr_reader :root_path, :options
|
|
17
|
+
|
|
18
|
+
# @param root_path [String] Path to ideas root directory
|
|
19
|
+
# @param options [Hash] Diagnosis options (:check, :verbose, etc.)
|
|
20
|
+
def initialize(root_path, options = {})
|
|
21
|
+
@root_path = root_path
|
|
22
|
+
@options = options
|
|
23
|
+
@issues = []
|
|
24
|
+
@stats = {
|
|
25
|
+
ideas_scanned: 0,
|
|
26
|
+
folders_checked: 0,
|
|
27
|
+
errors: 0,
|
|
28
|
+
warnings: 0,
|
|
29
|
+
info: 0
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run comprehensive health check
|
|
34
|
+
# @return [Hash] Diagnosis results
|
|
35
|
+
def run_diagnosis
|
|
36
|
+
unless @root_path && Dir.exist?(@root_path)
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
health_score: 0,
|
|
40
|
+
issues: [{type: :error, message: "Ideas root directory not found: #{@root_path}"}],
|
|
41
|
+
stats: @stats,
|
|
42
|
+
duration: 0,
|
|
43
|
+
root_path: @root_path
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@start_time = Time.now
|
|
48
|
+
|
|
49
|
+
if options[:check]
|
|
50
|
+
run_specific_check(options[:check])
|
|
51
|
+
else
|
|
52
|
+
run_full_check
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
health_score = calculate_health_score
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
valid: @stats[:errors] == 0,
|
|
59
|
+
health_score: health_score,
|
|
60
|
+
issues: @issues,
|
|
61
|
+
stats: @stats,
|
|
62
|
+
duration: Time.now - @start_time,
|
|
63
|
+
root_path: @root_path
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if an issue can be auto-fixed
|
|
68
|
+
# @param issue [Hash] Issue to check
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def auto_fixable?(issue)
|
|
71
|
+
return false unless issue[:type] == :error || issue[:type] == :warning
|
|
72
|
+
|
|
73
|
+
Molecules::IdeaDoctorFixer::FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def run_full_check
|
|
79
|
+
# 1. Structure checks (folder naming, file naming, backups, empty dirs)
|
|
80
|
+
run_structure_check
|
|
81
|
+
|
|
82
|
+
# 2. Frontmatter checks (per-file validation)
|
|
83
|
+
run_frontmatter_check
|
|
84
|
+
|
|
85
|
+
# 3. Scope/status consistency (cross-cutting)
|
|
86
|
+
run_scope_check
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_specific_check(check_type)
|
|
90
|
+
case check_type.to_s
|
|
91
|
+
when "structure"
|
|
92
|
+
run_structure_check
|
|
93
|
+
when "frontmatter"
|
|
94
|
+
run_frontmatter_check
|
|
95
|
+
when "scope"
|
|
96
|
+
run_scope_check
|
|
97
|
+
else
|
|
98
|
+
add_issue(:error, "Unknown check type: #{check_type}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run_structure_check
|
|
103
|
+
validator = Molecules::IdeaStructureValidator.new(@root_path)
|
|
104
|
+
issues = validator.validate
|
|
105
|
+
|
|
106
|
+
issues.each do |issue|
|
|
107
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Count folders checked
|
|
111
|
+
@stats[:folders_checked] = count_idea_folders
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run_frontmatter_check
|
|
115
|
+
scanner = Molecules::IdeaScanner.new(@root_path)
|
|
116
|
+
return unless scanner.root_exists?
|
|
117
|
+
|
|
118
|
+
scan_results = scanner.scan
|
|
119
|
+
@stats[:ideas_scanned] = scan_results.size
|
|
120
|
+
|
|
121
|
+
scan_results.each do |scan_result|
|
|
122
|
+
spec_file = scan_result.file_path
|
|
123
|
+
next unless spec_file && File.exist?(spec_file)
|
|
124
|
+
|
|
125
|
+
issues = Molecules::IdeaFrontmatterValidator.validate(
|
|
126
|
+
spec_file,
|
|
127
|
+
special_folder: scan_result.special_folder
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Filter out scope issues here (handled separately in run_scope_check)
|
|
131
|
+
issues.reject! { |i| scope_issue?(i[:message]) }
|
|
132
|
+
|
|
133
|
+
issues.each do |issue|
|
|
134
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def run_scope_check
|
|
140
|
+
scanner = Molecules::IdeaScanner.new(@root_path)
|
|
141
|
+
return unless scanner.root_exists?
|
|
142
|
+
|
|
143
|
+
scan_results = scanner.scan
|
|
144
|
+
|
|
145
|
+
scan_results.each do |scan_result|
|
|
146
|
+
spec_file = scan_result.file_path
|
|
147
|
+
next unless spec_file && File.exist?(spec_file)
|
|
148
|
+
|
|
149
|
+
# Load frontmatter for scope checking
|
|
150
|
+
content = File.read(spec_file)
|
|
151
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
152
|
+
next unless frontmatter.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
status = frontmatter["status"]
|
|
155
|
+
special_folder = scan_result.special_folder
|
|
156
|
+
|
|
157
|
+
scope_issues = Atoms::IdeaValidationRules.scope_consistent?(status, special_folder)
|
|
158
|
+
scope_issues.each do |issue|
|
|
159
|
+
add_issue(issue[:type], issue[:message], spec_file)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def scope_issue?(message)
|
|
165
|
+
message.include?("not in _archive") ||
|
|
166
|
+
message.include?("in _archive/ but status") ||
|
|
167
|
+
message.include?("in _maybe/ with terminal")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def count_idea_folders
|
|
171
|
+
return 0 unless Dir.exist?(@root_path)
|
|
172
|
+
|
|
173
|
+
count = 0
|
|
174
|
+
Dir.glob(File.join(@root_path, "*")).each do |path|
|
|
175
|
+
next unless File.directory?(path)
|
|
176
|
+
|
|
177
|
+
count += if File.basename(path).start_with?("_")
|
|
178
|
+
Dir.glob(File.join(path, "*")).count { |p| File.directory?(p) }
|
|
179
|
+
else
|
|
180
|
+
1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
count
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def add_issue(type, message, location = nil)
|
|
187
|
+
issue = {type: type, message: message}
|
|
188
|
+
issue[:location] = location if location
|
|
189
|
+
@issues << issue
|
|
190
|
+
|
|
191
|
+
case type
|
|
192
|
+
when :error then @stats[:errors] += 1
|
|
193
|
+
when :warning then @stats[:warnings] += 1
|
|
194
|
+
when :info then @stats[:info] += 1
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def calculate_health_score
|
|
199
|
+
score = 100
|
|
200
|
+
score -= @stats[:errors] * 10
|
|
201
|
+
score -= @stats[:warnings] * 2
|
|
202
|
+
[[score, 0].max, 100].min
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|