aidp 0.13.0 → 0.14.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 +4 -4
- data/README.md +7 -0
- data/lib/aidp/cli/first_run_wizard.rb +28 -303
- data/lib/aidp/cli/issue_importer.rb +359 -0
- data/lib/aidp/cli.rb +151 -3
- data/lib/aidp/daemon/process_manager.rb +146 -0
- data/lib/aidp/daemon/runner.rb +232 -0
- data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
- data/lib/aidp/execute/future_work_backlog.rb +411 -0
- data/lib/aidp/execute/guard_policy.rb +246 -0
- data/lib/aidp/execute/instruction_queue.rb +131 -0
- data/lib/aidp/execute/interactive_repl.rb +335 -0
- data/lib/aidp/execute/repl_macros.rb +651 -0
- data/lib/aidp/execute/steps.rb +8 -0
- data/lib/aidp/execute/work_loop_runner.rb +322 -36
- data/lib/aidp/execute/work_loop_state.rb +162 -0
- data/lib/aidp/harness/config_schema.rb +88 -0
- data/lib/aidp/harness/configuration.rb +48 -1
- data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
- data/lib/aidp/init/doc_generator.rb +256 -0
- data/lib/aidp/init/project_analyzer.rb +343 -0
- data/lib/aidp/init/runner.rb +83 -0
- data/lib/aidp/init.rb +5 -0
- data/lib/aidp/logger.rb +279 -0
- data/lib/aidp/setup/wizard.rb +777 -0
- data/lib/aidp/tooling_detector.rb +115 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +282 -0
- data/lib/aidp/watch/plan_generator.rb +166 -0
- data/lib/aidp/watch/plan_processor.rb +83 -0
- data/lib/aidp/watch/repository_client.rb +243 -0
- data/lib/aidp/watch/runner.rb +93 -0
- data/lib/aidp/watch/state_store.rb +105 -0
- data/lib/aidp/watch.rb +9 -0
- data/lib/aidp.rb +14 -0
- data/templates/implementation/simple_task.md +36 -0
- metadata +26 -1
@@ -0,0 +1,343 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "find"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Aidp
|
7
|
+
module Init
|
8
|
+
# Performs lightweight static analysis of the repository to detect languages,
|
9
|
+
# frameworks, config files, and quality tooling. Designed to run quickly and
|
10
|
+
# deterministically without external services.
|
11
|
+
class ProjectAnalyzer
|
12
|
+
IGNORED_DIRECTORIES = %w[
|
13
|
+
.git
|
14
|
+
.svn
|
15
|
+
.hg
|
16
|
+
node_modules
|
17
|
+
vendor
|
18
|
+
tmp
|
19
|
+
log
|
20
|
+
build
|
21
|
+
dist
|
22
|
+
coverage
|
23
|
+
.yardoc
|
24
|
+
].freeze
|
25
|
+
|
26
|
+
LANGUAGE_EXTENSIONS = {
|
27
|
+
".rb" => "Ruby",
|
28
|
+
".rake" => "Ruby",
|
29
|
+
".gemspec" => "Ruby",
|
30
|
+
".js" => "JavaScript",
|
31
|
+
".jsx" => "JavaScript",
|
32
|
+
".ts" => "TypeScript",
|
33
|
+
".tsx" => "TypeScript",
|
34
|
+
".py" => "Python",
|
35
|
+
".go" => "Go",
|
36
|
+
".java" => "Java",
|
37
|
+
".kt" => "Kotlin",
|
38
|
+
".cs" => "C#",
|
39
|
+
".php" => "PHP",
|
40
|
+
".rs" => "Rust",
|
41
|
+
".swift" => "Swift",
|
42
|
+
".scala" => "Scala",
|
43
|
+
".c" => "C",
|
44
|
+
".cpp" => "C++",
|
45
|
+
".hpp" => "C++",
|
46
|
+
".m" => "Objective-C",
|
47
|
+
".mm" => "Objective-C++",
|
48
|
+
".hs" => "Haskell",
|
49
|
+
".erl" => "Erlang",
|
50
|
+
".ex" => "Elixir",
|
51
|
+
".exs" => "Elixir",
|
52
|
+
".clj" => "Clojure",
|
53
|
+
".coffee" => "CoffeeScript"
|
54
|
+
}.freeze
|
55
|
+
|
56
|
+
CONFIG_FILES = %w[
|
57
|
+
.editorconfig
|
58
|
+
.rubocop.yml
|
59
|
+
.rubocop_todo.yml
|
60
|
+
.standardrb
|
61
|
+
.eslintrc
|
62
|
+
.eslintrc.js
|
63
|
+
.eslintrc.cjs
|
64
|
+
.eslintrc.json
|
65
|
+
.prettierrc
|
66
|
+
.prettierrc.js
|
67
|
+
.prettierrc.cjs
|
68
|
+
.prettierrc.json
|
69
|
+
.stylelintrc
|
70
|
+
.flake8
|
71
|
+
pyproject.toml
|
72
|
+
tox.ini
|
73
|
+
setup.cfg
|
74
|
+
package.json
|
75
|
+
Gemfile
|
76
|
+
Gemfile.lock
|
77
|
+
mix.exs
|
78
|
+
go.mod
|
79
|
+
go.sum
|
80
|
+
composer.json
|
81
|
+
Cargo.toml
|
82
|
+
Cargo.lock
|
83
|
+
pom.xml
|
84
|
+
build.gradle
|
85
|
+
build.gradle.kts
|
86
|
+
].freeze
|
87
|
+
|
88
|
+
KEY_DIRECTORIES = %w[
|
89
|
+
app
|
90
|
+
src
|
91
|
+
lib
|
92
|
+
spec
|
93
|
+
test
|
94
|
+
tests
|
95
|
+
scripts
|
96
|
+
bin
|
97
|
+
config
|
98
|
+
docs
|
99
|
+
examples
|
100
|
+
packages
|
101
|
+
modules
|
102
|
+
].freeze
|
103
|
+
|
104
|
+
TOOLING_HINTS = {
|
105
|
+
rubocop: [".rubocop.yml", ".rubocop_todo.yml"],
|
106
|
+
standardrb: [".standardrb"],
|
107
|
+
eslint: [".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json"],
|
108
|
+
prettier: [".prettierrc", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.json"],
|
109
|
+
stylelint: [".stylelintrc"],
|
110
|
+
flake8: [".flake8"],
|
111
|
+
black: ["pyproject.toml"],
|
112
|
+
pytest: ["pytest.ini", "pyproject.toml"],
|
113
|
+
jest: ["package.json"],
|
114
|
+
rspec: ["spec", ".rspec"],
|
115
|
+
minitest: ["test"],
|
116
|
+
gofmt: ["go.mod"],
|
117
|
+
cargo_fmt: ["Cargo.toml"]
|
118
|
+
}.freeze
|
119
|
+
|
120
|
+
FRAMEWORK_HINTS = {
|
121
|
+
"Rails" => {files: ["config/application.rb"], contents: [/rails/i]},
|
122
|
+
"Hanami" => {files: ["config/app.rb"], contents: [/hanami/i]},
|
123
|
+
"Sinatra" => {files: ["config.ru"], contents: [/sinatra/i]},
|
124
|
+
"React" => {files: ["package.json"], contents: [/react/]},
|
125
|
+
"Next.js" => {files: ["package.json"], contents: [/next/]},
|
126
|
+
"Express" => {files: ["package.json"], contents: [/express/]},
|
127
|
+
"Angular" => {files: ["angular.json"]},
|
128
|
+
"Vue" => {files: ["package.json"], contents: [/vue/]},
|
129
|
+
"Django" => {files: ["manage.py"], contents: [/django/]},
|
130
|
+
"Flask" => {files: ["requirements.txt"], contents: [/flask/]},
|
131
|
+
"FastAPI" => {files: ["pyproject.toml", "requirements.txt"], contents: [/fastapi/]},
|
132
|
+
"Phoenix" => {files: ["mix.exs"], contents: [/phoenix/]},
|
133
|
+
"Spring" => {files: ["pom.xml", "build.gradle", "build.gradle.kts"], contents: [/spring/]},
|
134
|
+
"Laravel" => {files: ["composer.json"], contents: [/laravel/]},
|
135
|
+
"Go Gin" => {files: ["go.mod"], contents: [/gin-gonic/]},
|
136
|
+
"Go Fiber" => {files: ["go.mod"], contents: [/github.com\/gofiber/]}
|
137
|
+
}.freeze
|
138
|
+
|
139
|
+
TEST_FRAMEWORK_HINTS = {
|
140
|
+
"RSpec" => {directories: ["spec"], dependencies: [/rspec/]},
|
141
|
+
"Minitest" => {directories: ["test"], dependencies: [/minitest/]},
|
142
|
+
"Cucumber" => {directories: ["features"]},
|
143
|
+
"Jest" => {dependencies: [/jest/]},
|
144
|
+
"Mocha" => {dependencies: [/mocha/]},
|
145
|
+
"Jasmine" => {dependencies: [/jasmine/]},
|
146
|
+
"Pytest" => {directories: ["tests", "test"], dependencies: [/pytest/]},
|
147
|
+
"NUnit" => {files: ["*.csproj"], contents: [/nunit/]},
|
148
|
+
"JUnit" => {dependencies: [/junit/]},
|
149
|
+
"Go test" => {files: ["*_test.go"]}
|
150
|
+
}.freeze
|
151
|
+
|
152
|
+
attr_reader :project_dir
|
153
|
+
|
154
|
+
def initialize(project_dir = Dir.pwd)
|
155
|
+
@project_dir = project_dir
|
156
|
+
end
|
157
|
+
|
158
|
+
def analyze
|
159
|
+
{
|
160
|
+
languages: detect_languages,
|
161
|
+
frameworks: detect_frameworks,
|
162
|
+
key_directories: detect_key_directories,
|
163
|
+
config_files: detect_config_files,
|
164
|
+
test_frameworks: detect_test_frameworks,
|
165
|
+
tooling: detect_tooling,
|
166
|
+
repo_stats: collect_repo_stats
|
167
|
+
}
|
168
|
+
end
|
169
|
+
|
170
|
+
private
|
171
|
+
|
172
|
+
def detect_languages
|
173
|
+
counts = Hash.new(0)
|
174
|
+
|
175
|
+
traverse_files do |relative_path|
|
176
|
+
ext = File.extname(relative_path)
|
177
|
+
language = LANGUAGE_EXTENSIONS[ext]
|
178
|
+
next unless language
|
179
|
+
|
180
|
+
full_path = File.join(project_dir, relative_path)
|
181
|
+
counts[language] += File.size(full_path)
|
182
|
+
end
|
183
|
+
|
184
|
+
counts.sort_by { |_lang, weight| -weight }.to_h
|
185
|
+
end
|
186
|
+
|
187
|
+
def detect_frameworks
|
188
|
+
frameworks = Set.new
|
189
|
+
|
190
|
+
FRAMEWORK_HINTS.each do |framework, rules|
|
191
|
+
if rules[:files]&.any? { |file| project_glob?(file) }
|
192
|
+
if rules[:contents]
|
193
|
+
frameworks << framework if rules[:contents].any? { |pattern| search_files_for_pattern(rules[:files], pattern) }
|
194
|
+
else
|
195
|
+
frameworks << framework
|
196
|
+
end
|
197
|
+
elsif rules[:contents]&.any? { |pattern| search_project_for_pattern(pattern) }
|
198
|
+
frameworks << framework
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
frameworks.to_a.sort
|
203
|
+
end
|
204
|
+
|
205
|
+
def detect_key_directories
|
206
|
+
KEY_DIRECTORIES.select { |dir| Dir.exist?(File.join(project_dir, dir)) }
|
207
|
+
end
|
208
|
+
|
209
|
+
def detect_config_files
|
210
|
+
CONFIG_FILES.select { |file| project_glob?(file) }
|
211
|
+
end
|
212
|
+
|
213
|
+
def detect_test_frameworks
|
214
|
+
results = []
|
215
|
+
|
216
|
+
TEST_FRAMEWORK_HINTS.each do |framework, hints|
|
217
|
+
has_directory = hints[:directories]&.any? { |dir| Dir.exist?(File.join(project_dir, dir)) }
|
218
|
+
has_dependency = hints[:dependencies]&.any? { |pattern| search_project_for_pattern(pattern, limit_files: ["Gemfile", "Gemfile.lock", "package.json", "pyproject.toml", "requirements.txt", "go.mod", "mix.exs", "composer.json", "Cargo.toml"]) }
|
219
|
+
has_files = hints[:files]&.any? { |glob| project_glob?(glob) }
|
220
|
+
|
221
|
+
results << framework if has_directory || has_dependency || has_files
|
222
|
+
end
|
223
|
+
|
224
|
+
results.uniq.sort
|
225
|
+
end
|
226
|
+
|
227
|
+
def detect_tooling
|
228
|
+
tooling = Hash.new { |hash, key| hash[key] = [] }
|
229
|
+
|
230
|
+
TOOLING_HINTS.each do |tool, indicators|
|
231
|
+
hit = indicators.any? do |indicator|
|
232
|
+
if indicator.include?("*")
|
233
|
+
end
|
234
|
+
project_glob?(indicator)
|
235
|
+
end
|
236
|
+
tooling[tool] << "config" if hit
|
237
|
+
end
|
238
|
+
|
239
|
+
# Post-process for package.json to extract scripts referencing linters
|
240
|
+
package_json_path = File.join(project_dir, "package.json")
|
241
|
+
if File.exist?(package_json_path)
|
242
|
+
begin
|
243
|
+
json = JSON.parse(File.read(package_json_path))
|
244
|
+
scripts = json.fetch("scripts", {})
|
245
|
+
tooling[:eslint] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("eslint") }
|
246
|
+
tooling[:prettier] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("prettier") }
|
247
|
+
tooling[:jest] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("jest") }
|
248
|
+
rescue JSON::ParserError
|
249
|
+
# ignore malformed package.json
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
tooling.delete_if { |_tool, evidence| evidence.empty? }
|
254
|
+
end
|
255
|
+
|
256
|
+
def collect_repo_stats
|
257
|
+
{
|
258
|
+
total_files: counted_files.size,
|
259
|
+
total_directories: counted_directories.size,
|
260
|
+
docs_present: Dir.exist?(File.join(project_dir, "docs")),
|
261
|
+
has_ci_config: project_glob?(".github/workflows/*.yml") || project_glob?(".gitlab-ci.yml"),
|
262
|
+
has_containerization: project_glob?("Dockerfile") || project_glob?("docker-compose.yml")
|
263
|
+
}
|
264
|
+
end
|
265
|
+
|
266
|
+
def counted_files
|
267
|
+
@counted_files ||= begin
|
268
|
+
files = []
|
269
|
+
traverse_files { |path| files << path }
|
270
|
+
files
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def counted_directories
|
275
|
+
@counted_directories ||= begin
|
276
|
+
dirs = Set.new
|
277
|
+
traverse_files do |path|
|
278
|
+
dirs << File.dirname(path)
|
279
|
+
end
|
280
|
+
dirs.to_a
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
def traverse_files
|
285
|
+
Find.find(project_dir) do |path|
|
286
|
+
next if path == project_dir
|
287
|
+
|
288
|
+
relative = path.sub("#{project_dir}/", "")
|
289
|
+
if File.directory?(path)
|
290
|
+
dirname = File.basename(path)
|
291
|
+
if IGNORED_DIRECTORIES.include?(dirname)
|
292
|
+
Find.prune
|
293
|
+
else
|
294
|
+
next
|
295
|
+
end
|
296
|
+
else
|
297
|
+
yield relative
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def project_glob?(pattern)
|
303
|
+
Dir.glob(File.join(project_dir, pattern)).any?
|
304
|
+
end
|
305
|
+
|
306
|
+
def search_files_for_pattern(files, pattern)
|
307
|
+
files.any? do |file|
|
308
|
+
Dir.glob(File.join(project_dir, file)).any? do |path|
|
309
|
+
File.read(path).match?(pattern)
|
310
|
+
rescue Errno::ENOENT, Errno::EISDIR
|
311
|
+
false
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def search_project_for_pattern(pattern, limit_files: nil)
|
317
|
+
if limit_files
|
318
|
+
limit_files.any? do |file|
|
319
|
+
path = File.join(project_dir, file)
|
320
|
+
next false unless File.exist?(path)
|
321
|
+
|
322
|
+
File.read(path).match?(pattern)
|
323
|
+
rescue Errno::ENOENT
|
324
|
+
false
|
325
|
+
end
|
326
|
+
else
|
327
|
+
traverse_files do |relative_path|
|
328
|
+
path = File.join(project_dir, relative_path)
|
329
|
+
begin
|
330
|
+
return true if File.read(path).match?(pattern)
|
331
|
+
rescue Errno::ENOENT
|
332
|
+
next
|
333
|
+
rescue ArgumentError, Encoding::InvalidByteSequenceError => e
|
334
|
+
warn "[AIDP] Skipping file with invalid encoding: #{path} (#{e.class})"
|
335
|
+
next
|
336
|
+
end
|
337
|
+
end
|
338
|
+
false
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tty-prompt"
|
4
|
+
require_relative "../message_display"
|
5
|
+
require_relative "project_analyzer"
|
6
|
+
require_relative "doc_generator"
|
7
|
+
|
8
|
+
module Aidp
|
9
|
+
module Init
|
10
|
+
# High-level coordinator for `aidp init`. Handles analysis, optional user
|
11
|
+
# preferences, and documentation generation.
|
12
|
+
class Runner
|
13
|
+
include Aidp::MessageDisplay
|
14
|
+
|
15
|
+
def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil)
|
16
|
+
@project_dir = project_dir
|
17
|
+
@prompt = prompt
|
18
|
+
@analyzer = analyzer || ProjectAnalyzer.new(project_dir)
|
19
|
+
@doc_generator = doc_generator || DocGenerator.new(project_dir)
|
20
|
+
end
|
21
|
+
|
22
|
+
def run
|
23
|
+
display_message("🔍 Running aidp init project analysis...", type: :info)
|
24
|
+
analysis = @analyzer.analyze
|
25
|
+
display_summary(analysis)
|
26
|
+
|
27
|
+
preferences = gather_preferences
|
28
|
+
|
29
|
+
@doc_generator.generate(analysis: analysis, preferences: preferences)
|
30
|
+
|
31
|
+
display_message("\n📄 Generated documentation:", type: :info)
|
32
|
+
display_message(" - docs/LLM_STYLE_GUIDE.md", type: :success)
|
33
|
+
display_message(" - docs/PROJECT_ANALYSIS.md", type: :success)
|
34
|
+
display_message(" - docs/CODE_QUALITY_PLAN.md", type: :success)
|
35
|
+
display_message("\n✅ aidp init complete.", type: :success)
|
36
|
+
|
37
|
+
{
|
38
|
+
analysis: analysis,
|
39
|
+
preferences: preferences,
|
40
|
+
generated_files: [
|
41
|
+
"docs/LLM_STYLE_GUIDE.md",
|
42
|
+
"docs/PROJECT_ANALYSIS.md",
|
43
|
+
"docs/CODE_QUALITY_PLAN.md"
|
44
|
+
]
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def display_summary(analysis)
|
51
|
+
languages = analysis[:languages].keys
|
52
|
+
frameworks = analysis[:frameworks]
|
53
|
+
tests = analysis[:test_frameworks]
|
54
|
+
config_files = analysis[:config_files]
|
55
|
+
|
56
|
+
display_message("\n📊 Repository Snapshot", type: :highlight)
|
57
|
+
display_message(" Languages: #{languages.empty? ? "Unknown" : languages.join(", ")}", type: :info)
|
58
|
+
display_message(" Frameworks: #{frameworks.empty? ? "None detected" : frameworks.join(", ")}", type: :info)
|
59
|
+
display_message(" Test suites: #{tests.empty? ? "Not found" : tests.join(", ")}", type: :info)
|
60
|
+
display_message(" Config files: #{config_files.empty? ? "None detected" : config_files.join(", ")}", type: :info)
|
61
|
+
end
|
62
|
+
|
63
|
+
def gather_preferences
|
64
|
+
display_message("\n⚙️ Customise bootstrap plans (press Enter to accept defaults):", type: :info)
|
65
|
+
|
66
|
+
{
|
67
|
+
adopt_new_conventions: ask_yes_no("Adopt the newly generated conventions as canonical defaults?", default: true),
|
68
|
+
stricter_linters: ask_yes_no("Enforce stricter linting based on detected tools?", default: false),
|
69
|
+
migrate_styles: ask_yes_no("Plan migrations to align legacy files with the new style guide?", default: false)
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def ask_yes_no(question, default:)
|
74
|
+
@prompt.yes?(question) do |q|
|
75
|
+
q.default default ? "yes" : "no"
|
76
|
+
end
|
77
|
+
rescue NoMethodError
|
78
|
+
# Compatibility with simplified prompts in tests (e.g. TestPrompt)
|
79
|
+
default
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
data/lib/aidp/init.rb
ADDED
data/lib/aidp/logger.rb
ADDED
@@ -0,0 +1,279 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "json"
|
5
|
+
require "fileutils"
|
6
|
+
|
7
|
+
module Aidp
|
8
|
+
# Unified structured logger for all AIDP operations
|
9
|
+
# Supports:
|
10
|
+
# - Multiple log levels (info, error, debug)
|
11
|
+
# - Text and JSONL formats
|
12
|
+
# - Automatic rotation
|
13
|
+
# - Redaction of secrets
|
14
|
+
# - Consistent file layout in .aidp/logs/
|
15
|
+
#
|
16
|
+
# Usage:
|
17
|
+
# Aidp.setup_logger(project_dir, config)
|
18
|
+
# Aidp.logger.info("component", "message", key: "value")
|
19
|
+
class AidpLogger
|
20
|
+
LEVELS = {
|
21
|
+
debug: ::Logger::DEBUG,
|
22
|
+
info: ::Logger::INFO,
|
23
|
+
warn: ::Logger::WARN,
|
24
|
+
error: ::Logger::ERROR
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
LOG_DIR = ".aidp/logs"
|
28
|
+
INFO_LOG = "#{LOG_DIR}/aidp.log"
|
29
|
+
DEBUG_LOG = "#{LOG_DIR}/aidp_debug.log"
|
30
|
+
|
31
|
+
DEFAULT_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
32
|
+
DEFAULT_MAX_FILES = 5
|
33
|
+
|
34
|
+
attr_reader :level, :json_format
|
35
|
+
|
36
|
+
def initialize(project_dir = Dir.pwd, config = {})
|
37
|
+
@project_dir = project_dir
|
38
|
+
@config = config
|
39
|
+
@level = determine_log_level
|
40
|
+
@json_format = config[:json] || false
|
41
|
+
@max_size = config[:max_size_mb] ? config[:max_size_mb] * 1024 * 1024 : DEFAULT_MAX_SIZE
|
42
|
+
@max_files = config[:max_backups] || DEFAULT_MAX_FILES
|
43
|
+
|
44
|
+
ensure_log_directory
|
45
|
+
migrate_old_logs if should_migrate?
|
46
|
+
setup_loggers
|
47
|
+
end
|
48
|
+
|
49
|
+
# Log info level message
|
50
|
+
def info(component, message, **metadata)
|
51
|
+
log(:info, component, message, **metadata)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Log error level message
|
55
|
+
def error(component, message, **metadata)
|
56
|
+
log(:error, component, message, **metadata)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Log warn level message
|
60
|
+
def warn(component, message, **metadata)
|
61
|
+
log(:warn, component, message, **metadata)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Log debug level message
|
65
|
+
def debug(component, message, **metadata)
|
66
|
+
log(:debug, component, message, **metadata)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Log at specified level
|
70
|
+
def log(level, component, message, **metadata)
|
71
|
+
return unless should_log?(level)
|
72
|
+
|
73
|
+
# Redact sensitive data
|
74
|
+
safe_message = redact(message)
|
75
|
+
safe_metadata = redact_hash(metadata)
|
76
|
+
|
77
|
+
# Log to appropriate file(s)
|
78
|
+
if level == :debug
|
79
|
+
write_to_debug(level, component, safe_message, safe_metadata)
|
80
|
+
else
|
81
|
+
write_to_info(level, component, safe_message, safe_metadata)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Always log errors to both files
|
85
|
+
if level == :error
|
86
|
+
write_to_debug(level, component, safe_message, safe_metadata)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Close all loggers
|
91
|
+
def close
|
92
|
+
@info_logger&.close
|
93
|
+
@debug_logger&.close
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def determine_log_level
|
99
|
+
# Priority: ENV > config > default
|
100
|
+
level_str = ENV["AIDP_LOG_LEVEL"] || @config[:level] || "info"
|
101
|
+
level_sym = level_str.to_sym
|
102
|
+
LEVELS.key?(level_sym) ? level_sym : :info
|
103
|
+
end
|
104
|
+
|
105
|
+
def should_log?(level)
|
106
|
+
LEVELS[level] >= LEVELS[@level]
|
107
|
+
end
|
108
|
+
|
109
|
+
def ensure_log_directory
|
110
|
+
log_dir = File.join(@project_dir, LOG_DIR)
|
111
|
+
FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
|
112
|
+
end
|
113
|
+
|
114
|
+
def setup_loggers
|
115
|
+
info_path = File.join(@project_dir, INFO_LOG)
|
116
|
+
debug_path = File.join(@project_dir, DEBUG_LOG)
|
117
|
+
|
118
|
+
@info_logger = create_logger(info_path)
|
119
|
+
@debug_logger = create_logger(debug_path)
|
120
|
+
end
|
121
|
+
|
122
|
+
def create_logger(path)
|
123
|
+
logger = ::Logger.new(path, @max_files, @max_size)
|
124
|
+
logger.level = ::Logger::DEBUG # Control at write level instead
|
125
|
+
logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
|
126
|
+
logger
|
127
|
+
end
|
128
|
+
|
129
|
+
def write_to_info(level, component, message, metadata)
|
130
|
+
entry = format_entry(level, component, message, metadata)
|
131
|
+
@info_logger.send(logger_method(level), entry)
|
132
|
+
end
|
133
|
+
|
134
|
+
def write_to_debug(level, component, message, metadata)
|
135
|
+
entry = format_entry(level, component, message, metadata)
|
136
|
+
@debug_logger.send(logger_method(level), entry)
|
137
|
+
end
|
138
|
+
|
139
|
+
def logger_method(level)
|
140
|
+
case level
|
141
|
+
when :debug then :debug
|
142
|
+
when :info then :info
|
143
|
+
when :warn then :warn
|
144
|
+
when :error then :error
|
145
|
+
else :info
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def format_entry(level, component, message, metadata)
|
150
|
+
if @json_format
|
151
|
+
format_json(level, component, message, metadata)
|
152
|
+
else
|
153
|
+
format_text(level, component, message, metadata)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def format_text(level, component, message, metadata)
|
158
|
+
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
|
159
|
+
level_str = level.to_s.upcase
|
160
|
+
parts = ["#{timestamp} #{level_str} #{component} #{message}"]
|
161
|
+
|
162
|
+
unless metadata.empty?
|
163
|
+
metadata_str = metadata.map { |k, v| "#{k}=#{redact(v.to_s)}" }.join(" ")
|
164
|
+
parts << "(#{metadata_str})"
|
165
|
+
end
|
166
|
+
|
167
|
+
parts.join(" ")
|
168
|
+
end
|
169
|
+
|
170
|
+
def format_json(level, component, message, metadata)
|
171
|
+
entry = {
|
172
|
+
ts: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
|
173
|
+
level: level.to_s,
|
174
|
+
component: component,
|
175
|
+
msg: message
|
176
|
+
}.merge(metadata)
|
177
|
+
|
178
|
+
JSON.generate(entry)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Redaction patterns for common secrets
|
182
|
+
REDACTION_PATTERNS = [
|
183
|
+
# API keys and tokens (with capture groups)
|
184
|
+
[/\b(api[_-]?key|token|secret|password|passwd|pwd)[=:]\s*['"]?([^\s'")]+)['"]?/i, '\1=<REDACTED>'],
|
185
|
+
# Bearer tokens
|
186
|
+
[/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/, "<REDACTED>"],
|
187
|
+
# GitHub tokens
|
188
|
+
[/\bgh[ps]_[A-Za-z0-9_]{36,}/, "<REDACTED>"],
|
189
|
+
# AWS keys
|
190
|
+
[/\bAKIA[0-9A-Z]{16}/, "<REDACTED>"],
|
191
|
+
# Generic secrets in key=value format
|
192
|
+
[/\b(secret|credentials?|auth)[=:]\s*['"]?([^\s'")]{8,})['"]?/i, '\1=<REDACTED>']
|
193
|
+
].freeze
|
194
|
+
|
195
|
+
def redact(text)
|
196
|
+
return text unless text.is_a?(String)
|
197
|
+
|
198
|
+
redacted = text.dup
|
199
|
+
REDACTION_PATTERNS.each do |pattern, replacement|
|
200
|
+
redacted.gsub!(pattern, replacement)
|
201
|
+
end
|
202
|
+
redacted
|
203
|
+
end
|
204
|
+
|
205
|
+
def redact_hash(hash)
|
206
|
+
hash.transform_values { |v| v.is_a?(String) ? redact(v) : v }
|
207
|
+
end
|
208
|
+
|
209
|
+
# Migration from old debug_logs location
|
210
|
+
OLD_DEBUG_DIR = ".aidp/debug_logs"
|
211
|
+
OLD_DEBUG_LOG = "#{OLD_DEBUG_DIR}/aidp_debug.log"
|
212
|
+
|
213
|
+
def should_migrate?
|
214
|
+
old_path = File.join(@project_dir, OLD_DEBUG_LOG)
|
215
|
+
new_path = File.join(@project_dir, DEBUG_LOG)
|
216
|
+
|
217
|
+
# Migrate if old exists and new doesn't
|
218
|
+
File.exist?(old_path) && !File.exist?(new_path)
|
219
|
+
end
|
220
|
+
|
221
|
+
def migrate_old_logs
|
222
|
+
old_path = File.join(@project_dir, OLD_DEBUG_LOG)
|
223
|
+
new_path = File.join(@project_dir, DEBUG_LOG)
|
224
|
+
|
225
|
+
begin
|
226
|
+
FileUtils.mv(old_path, new_path)
|
227
|
+
log_migration_notice
|
228
|
+
rescue => e
|
229
|
+
# If migration fails, just continue (new logs will be created)
|
230
|
+
warn "Failed to migrate old logs: #{e.message}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def log_migration_notice
|
235
|
+
notice = format_text(
|
236
|
+
:info,
|
237
|
+
"migration",
|
238
|
+
"Logs migrated from .aidp/debug_logs/ to .aidp/logs/",
|
239
|
+
timestamp: Time.now.utc.iso8601
|
240
|
+
)
|
241
|
+
|
242
|
+
# Write directly to avoid recursion
|
243
|
+
info_path = File.join(@project_dir, INFO_LOG)
|
244
|
+
File.open(info_path, "a") do |f|
|
245
|
+
f.puts notice
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# Module-level logger accessor
|
251
|
+
class << self
|
252
|
+
# Set up global logger instance
|
253
|
+
def setup_logger(project_dir = Dir.pwd, config = {})
|
254
|
+
@logger = AidpLogger.new(project_dir, config)
|
255
|
+
end
|
256
|
+
|
257
|
+
# Get current logger instance (creates default if not set up)
|
258
|
+
def logger
|
259
|
+
@logger ||= AidpLogger.new
|
260
|
+
end
|
261
|
+
|
262
|
+
# Convenience logging methods
|
263
|
+
def log_info(component, message, **metadata)
|
264
|
+
logger.info(component, message, **metadata)
|
265
|
+
end
|
266
|
+
|
267
|
+
def log_error(component, message, **metadata)
|
268
|
+
logger.error(component, message, **metadata)
|
269
|
+
end
|
270
|
+
|
271
|
+
def log_warn(component, message, **metadata)
|
272
|
+
logger.warn(component, message, **metadata)
|
273
|
+
end
|
274
|
+
|
275
|
+
def log_debug(component, message, **metadata)
|
276
|
+
logger.debug(component, message, **metadata)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|