moai-adk 0.3.0__py3-none-any.whl

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.

Potentially problematic release.


This version of moai-adk might be problematic. Click here for more details.

Files changed (87) hide show
  1. moai_adk/__init__.py +8 -0
  2. moai_adk/__main__.py +86 -0
  3. moai_adk/cli/__init__.py +2 -0
  4. moai_adk/cli/commands/__init__.py +16 -0
  5. moai_adk/cli/commands/backup.py +56 -0
  6. moai_adk/cli/commands/doctor.py +184 -0
  7. moai_adk/cli/commands/init.py +284 -0
  8. moai_adk/cli/commands/restore.py +77 -0
  9. moai_adk/cli/commands/status.py +79 -0
  10. moai_adk/cli/commands/update.py +133 -0
  11. moai_adk/cli/main.py +12 -0
  12. moai_adk/cli/prompts/__init__.py +5 -0
  13. moai_adk/cli/prompts/init_prompts.py +159 -0
  14. moai_adk/core/__init__.py +2 -0
  15. moai_adk/core/git/__init__.py +24 -0
  16. moai_adk/core/git/branch.py +26 -0
  17. moai_adk/core/git/branch_manager.py +137 -0
  18. moai_adk/core/git/checkpoint.py +140 -0
  19. moai_adk/core/git/commit.py +68 -0
  20. moai_adk/core/git/event_detector.py +81 -0
  21. moai_adk/core/git/manager.py +127 -0
  22. moai_adk/core/project/__init__.py +2 -0
  23. moai_adk/core/project/backup_utils.py +84 -0
  24. moai_adk/core/project/checker.py +302 -0
  25. moai_adk/core/project/detector.py +105 -0
  26. moai_adk/core/project/initializer.py +174 -0
  27. moai_adk/core/project/phase_executor.py +297 -0
  28. moai_adk/core/project/validator.py +118 -0
  29. moai_adk/core/quality/__init__.py +6 -0
  30. moai_adk/core/quality/trust_checker.py +441 -0
  31. moai_adk/core/quality/validators/__init__.py +6 -0
  32. moai_adk/core/quality/validators/base_validator.py +19 -0
  33. moai_adk/core/template/__init__.py +8 -0
  34. moai_adk/core/template/backup.py +95 -0
  35. moai_adk/core/template/config.py +95 -0
  36. moai_adk/core/template/languages.py +44 -0
  37. moai_adk/core/template/merger.py +117 -0
  38. moai_adk/core/template/processor.py +310 -0
  39. moai_adk/templates/.claude/agents/alfred/cc-manager.md +474 -0
  40. moai_adk/templates/.claude/agents/alfred/code-builder.md +534 -0
  41. moai_adk/templates/.claude/agents/alfred/debug-helper.md +302 -0
  42. moai_adk/templates/.claude/agents/alfred/doc-syncer.md +175 -0
  43. moai_adk/templates/.claude/agents/alfred/git-manager.md +200 -0
  44. moai_adk/templates/.claude/agents/alfred/project-manager.md +152 -0
  45. moai_adk/templates/.claude/agents/alfred/spec-builder.md +256 -0
  46. moai_adk/templates/.claude/agents/alfred/tag-agent.md +247 -0
  47. moai_adk/templates/.claude/agents/alfred/trust-checker.md +332 -0
  48. moai_adk/templates/.claude/commands/alfred/0-project.md +523 -0
  49. moai_adk/templates/.claude/commands/alfred/1-spec.md +531 -0
  50. moai_adk/templates/.claude/commands/alfred/2-build.md +413 -0
  51. moai_adk/templates/.claude/commands/alfred/3-sync.md +552 -0
  52. moai_adk/templates/.claude/hooks/alfred/README.md +238 -0
  53. moai_adk/templates/.claude/hooks/alfred/alfred_hooks.py +165 -0
  54. moai_adk/templates/.claude/hooks/alfred/core/__init__.py +79 -0
  55. moai_adk/templates/.claude/hooks/alfred/core/checkpoint.py +271 -0
  56. moai_adk/templates/.claude/hooks/alfred/core/context.py +110 -0
  57. moai_adk/templates/.claude/hooks/alfred/core/project.py +284 -0
  58. moai_adk/templates/.claude/hooks/alfred/core/tags.py +244 -0
  59. moai_adk/templates/.claude/hooks/alfred/handlers/__init__.py +23 -0
  60. moai_adk/templates/.claude/hooks/alfred/handlers/compact.py +51 -0
  61. moai_adk/templates/.claude/hooks/alfred/handlers/notification.py +25 -0
  62. moai_adk/templates/.claude/hooks/alfred/handlers/session.py +80 -0
  63. moai_adk/templates/.claude/hooks/alfred/handlers/tool.py +71 -0
  64. moai_adk/templates/.claude/hooks/alfred/handlers/user.py +41 -0
  65. moai_adk/templates/.claude/output-styles/alfred/agentic-coding.md +635 -0
  66. moai_adk/templates/.claude/output-styles/alfred/moai-adk-learning.md +691 -0
  67. moai_adk/templates/.claude/output-styles/alfred/study-with-alfred.md +469 -0
  68. moai_adk/templates/.claude/settings.json +135 -0
  69. moai_adk/templates/.github/PULL_REQUEST_TEMPLATE.md +68 -0
  70. moai_adk/templates/.github/workflows/moai-gitflow.yml +255 -0
  71. moai_adk/templates/.gitignore +41 -0
  72. moai_adk/templates/.moai/config.json +89 -0
  73. moai_adk/templates/.moai/memory/development-guide.md +367 -0
  74. moai_adk/templates/.moai/memory/spec-metadata.md +277 -0
  75. moai_adk/templates/.moai/project/product.md +121 -0
  76. moai_adk/templates/.moai/project/structure.md +150 -0
  77. moai_adk/templates/.moai/project/tech.md +221 -0
  78. moai_adk/templates/CLAUDE.md +733 -0
  79. moai_adk/templates/__init__.py +2 -0
  80. moai_adk/utils/__init__.py +8 -0
  81. moai_adk/utils/banner.py +42 -0
  82. moai_adk/utils/logger.py +152 -0
  83. moai_adk-0.3.0.dist-info/METADATA +20 -0
  84. moai_adk-0.3.0.dist-info/RECORD +87 -0
  85. moai_adk-0.3.0.dist-info/WHEEL +4 -0
  86. moai_adk-0.3.0.dist-info/entry_points.txt +2 -0
  87. moai_adk-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,441 @@
1
+ # @CODE:TRUST-001 | SPEC: SPEC-TRUST-001.md | TEST: tests/unit/core/quality/test_trust_checker.py
2
+ """
3
+ TRUST 원칙 통합 검증 시스템
4
+
5
+ TRUST 5원칙:
6
+ - T: Test First (테스트 커버리지 ≥85%)
7
+ - R: Readable (파일 ≤300 LOC, 함수 ≤50 LOC, 매개변수 ≤5개)
8
+ - U: Unified (타입 안전성)
9
+ - S: Secured (보안 취약점 스캔)
10
+ - T: Trackable (TAG 체인 무결성)
11
+ """
12
+
13
+ import ast
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from moai_adk.core.quality.validators.base_validator import ValidationResult
19
+
20
+ # ========================================
21
+ # 상수 정의 (의도를 드러내는 이름)
22
+ # ========================================
23
+ MIN_TEST_COVERAGE_PERCENT = 85
24
+ MAX_FILE_LINES_OF_CODE = 300
25
+ MAX_FUNCTION_LINES_OF_CODE = 50
26
+ MAX_FUNCTION_PARAMETERS = 5
27
+ MAX_CYCLOMATIC_COMPLEXITY = 10
28
+
29
+ # 파일 인코딩
30
+ DEFAULT_FILE_ENCODING = "utf-8"
31
+
32
+ # TAG 접두사
33
+ TAG_PREFIX_SPEC = "@SPEC:"
34
+ TAG_PREFIX_CODE = "@CODE:"
35
+ TAG_PREFIX_TEST = "@TEST:"
36
+
37
+
38
+ class TrustChecker:
39
+ """TRUST 원칙 통합 검증기"""
40
+
41
+ def __init__(self):
42
+ """TrustChecker 초기화"""
43
+ self.results: dict[str, ValidationResult] = {}
44
+
45
+ # ========================================
46
+ # T: Test First - Coverage Validation
47
+ # ========================================
48
+
49
+ def validate_coverage(self, project_path: Path, coverage_data: dict[str, Any]) -> ValidationResult:
50
+ """
51
+ 테스트 커버리지 검증 (≥85%)
52
+
53
+ Args:
54
+ project_path: 프로젝트 경로
55
+ coverage_data: 커버리지 데이터 (total_coverage, low_coverage_files)
56
+
57
+ Returns:
58
+ ValidationResult: 검증 결과
59
+ """
60
+ total_coverage = coverage_data.get("total_coverage", 0)
61
+
62
+ if total_coverage >= MIN_TEST_COVERAGE_PERCENT:
63
+ return ValidationResult(
64
+ passed=True, message=f"Test coverage: {total_coverage}% (Target: {MIN_TEST_COVERAGE_PERCENT}%)"
65
+ )
66
+
67
+ # 실패 시 상세 정보 생성
68
+ low_files = coverage_data.get("low_coverage_files", [])
69
+ details = f"Current coverage: {total_coverage}% (Target: {MIN_TEST_COVERAGE_PERCENT}%)\n"
70
+ details += "Low coverage files:\n"
71
+ for file_info in low_files:
72
+ details += f" - {file_info['file']}: {file_info['coverage']}%\n"
73
+ details += "\nRecommended: Add more test cases to increase coverage."
74
+
75
+ return ValidationResult(
76
+ passed=False,
77
+ message=f"Test coverage: {total_coverage}% (Target: {MIN_TEST_COVERAGE_PERCENT}%)",
78
+ details=details,
79
+ )
80
+
81
+ # ========================================
82
+ # R: Readable - Code Constraints
83
+ # ========================================
84
+
85
+ def validate_file_size(self, src_path: Path) -> ValidationResult:
86
+ """
87
+ 파일 크기 검증 (≤300 LOC)
88
+
89
+ Args:
90
+ src_path: 소스 코드 디렉토리 경로
91
+
92
+ Returns:
93
+ ValidationResult: 검증 결과
94
+ """
95
+ # 입력 검증 (보안 강화)
96
+ if not src_path.exists():
97
+ return ValidationResult(passed=False, message=f"Source path does not exist: {src_path}", details="")
98
+
99
+ if not src_path.is_dir():
100
+ return ValidationResult(passed=False, message=f"Source path is not a directory: {src_path}", details="")
101
+
102
+ violations = []
103
+
104
+ for py_file in src_path.rglob("*.py"):
105
+ # 가드절 적용 (가독성 향상)
106
+ if py_file.name.startswith("test_"):
107
+ continue
108
+
109
+ try:
110
+ lines = py_file.read_text(encoding="utf-8").splitlines()
111
+ loc = len(lines)
112
+
113
+ if loc > MAX_FILE_LINES_OF_CODE:
114
+ violations.append(f"{py_file.name}: {loc} LOC (Limit: {MAX_FILE_LINES_OF_CODE})")
115
+ except (UnicodeDecodeError, PermissionError):
116
+ # 보안: 파일 접근 오류 처리
117
+ continue
118
+
119
+ if not violations:
120
+ return ValidationResult(passed=True, message="All files within 300 LOC")
121
+
122
+ details = "Files exceeding 300 LOC:\n" + "\n".join(f" - {v}" for v in violations)
123
+ details += "\n\nRecommended: Refactor large files into smaller modules."
124
+
125
+ return ValidationResult(passed=False, message=f"{len(violations)} files exceed 300 LOC", details=details)
126
+
127
+ def validate_function_size(self, src_path: Path) -> ValidationResult:
128
+ """
129
+ 함수 크기 검증 (≤50 LOC)
130
+
131
+ Args:
132
+ src_path: 소스 코드 디렉토리 경로
133
+
134
+ Returns:
135
+ ValidationResult: 검증 결과
136
+ """
137
+ violations = []
138
+
139
+ for py_file in src_path.rglob("*.py"):
140
+ if py_file.name.startswith("test_"):
141
+ continue
142
+
143
+ try:
144
+ content = py_file.read_text()
145
+ tree = ast.parse(content)
146
+ lines = content.splitlines()
147
+
148
+ for node in ast.walk(tree):
149
+ if isinstance(node, ast.FunctionDef):
150
+ # AST 라인 번호는 1-based
151
+ start_line = node.lineno
152
+ end_line = node.end_lineno if node.end_lineno else start_line # type: ignore
153
+
154
+ # 실제 함수 라인 수 계산 (데코레이터 제외)
155
+ func_lines = lines[start_line - 1:end_line]
156
+ func_loc = len(func_lines)
157
+
158
+ if func_loc > MAX_FUNCTION_LINES_OF_CODE:
159
+ violations.append(
160
+ f"{py_file.name}::{node.name}(): {func_loc} LOC (Limit: {MAX_FUNCTION_LINES_OF_CODE})"
161
+ )
162
+ except SyntaxError:
163
+ continue
164
+
165
+ if not violations:
166
+ return ValidationResult(passed=True, message="All functions within 50 LOC")
167
+
168
+ details = "Functions exceeding 50 LOC:\n" + "\n".join(f" - {v}" for v in violations)
169
+ details += "\n\nRecommended: Extract complex functions into smaller ones."
170
+
171
+ return ValidationResult(passed=False, message=f"{len(violations)} functions exceed 50 LOC", details=details)
172
+
173
+ def validate_param_count(self, src_path: Path) -> ValidationResult:
174
+ """
175
+ 매개변수 개수 검증 (≤5개)
176
+
177
+ Args:
178
+ src_path: 소스 코드 디렉토리 경로
179
+
180
+ Returns:
181
+ ValidationResult: 검증 결과
182
+ """
183
+ violations = []
184
+
185
+ for py_file in src_path.rglob("*.py"):
186
+ if py_file.name.startswith("test_"):
187
+ continue
188
+
189
+ try:
190
+ tree = ast.parse(py_file.read_text())
191
+ for node in ast.walk(tree):
192
+ if isinstance(node, ast.FunctionDef):
193
+ param_count = len(node.args.args)
194
+ if param_count > MAX_FUNCTION_PARAMETERS:
195
+ violations.append(
196
+ f"{py_file.name}::{node.name}(): {param_count} parameters "
197
+ f"(Limit: {MAX_FUNCTION_PARAMETERS})"
198
+ )
199
+ except SyntaxError:
200
+ continue
201
+
202
+ if not violations:
203
+ return ValidationResult(passed=True, message="All functions within 5 parameters")
204
+
205
+ details = "Functions exceeding 5 parameters:\n" + "\n".join(f" - {v}" for v in violations)
206
+ details += "\n\nRecommended: Use data classes or parameter objects."
207
+
208
+ return ValidationResult(
209
+ passed=False, message=f"{len(violations)} functions exceed 5 parameters", details=details
210
+ )
211
+
212
+ def validate_complexity(self, src_path: Path) -> ValidationResult:
213
+ """
214
+ 순환 복잡도 검증 (≤10)
215
+
216
+ Args:
217
+ src_path: 소스 코드 디렉토리 경로
218
+
219
+ Returns:
220
+ ValidationResult: 검증 결과
221
+ """
222
+ violations = []
223
+
224
+ for py_file in src_path.rglob("*.py"):
225
+ if py_file.name.startswith("test_"):
226
+ continue
227
+
228
+ try:
229
+ tree = ast.parse(py_file.read_text())
230
+ for node in ast.walk(tree):
231
+ if isinstance(node, ast.FunctionDef):
232
+ complexity = self._calculate_complexity(node)
233
+ if complexity > MAX_CYCLOMATIC_COMPLEXITY:
234
+ violations.append(
235
+ f"{py_file.name}::{node.name}(): complexity {complexity} "
236
+ f"(Limit: {MAX_CYCLOMATIC_COMPLEXITY})"
237
+ )
238
+ except SyntaxError:
239
+ continue
240
+
241
+ if not violations:
242
+ return ValidationResult(passed=True, message="All functions within complexity 10")
243
+
244
+ details = "Functions exceeding complexity 10:\n" + "\n".join(f" - {v}" for v in violations)
245
+ details += "\n\nRecommended: Simplify complex logic using guard clauses."
246
+
247
+ return ValidationResult(
248
+ passed=False, message=f"{len(violations)} functions exceed complexity 10", details=details
249
+ )
250
+
251
+ def _calculate_complexity(self, node: ast.FunctionDef) -> int:
252
+ """
253
+ 순환 복잡도 계산 (McCabe complexity)
254
+
255
+ Args:
256
+ node: 함수 AST 노드
257
+
258
+ Returns:
259
+ int: 순환 복잡도
260
+ """
261
+ complexity = 1
262
+ for child in ast.walk(node):
263
+ # 분기문마다 +1
264
+ if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler, ast.With)):
265
+ complexity += 1
266
+ # and/or 연산자마다 +1
267
+ elif isinstance(child, ast.BoolOp):
268
+ complexity += len(child.values) - 1
269
+ # elif는 이미 ast.If로 카운트되므로 별도 처리 불필요
270
+ return complexity
271
+
272
+ # ========================================
273
+ # T: Trackable - TAG Chain Validation
274
+ # ========================================
275
+
276
+ def validate_tag_chain(self, project_path: Path) -> ValidationResult:
277
+ """
278
+ TAG 체인 완전성 검증
279
+
280
+ Args:
281
+ project_path: 프로젝트 경로
282
+
283
+ Returns:
284
+ ValidationResult: 검증 결과
285
+ """
286
+ specs_dir = project_path / ".moai" / "specs"
287
+ src_dir = project_path / "src"
288
+
289
+ # TAG 스캔
290
+ spec_tags = self._scan_tags(specs_dir, "@SPEC:")
291
+ code_tags = self._scan_tags(src_dir, "@CODE:")
292
+
293
+ # 체인 검증
294
+ broken_chains = []
295
+ for code_tag in code_tags:
296
+ tag_id = code_tag.split(":")[-1]
297
+ if not any(tag_id in spec_tag for spec_tag in spec_tags):
298
+ broken_chains.append(f"@CODE:{tag_id} (no @SPEC:{tag_id})")
299
+
300
+ if not broken_chains:
301
+ return ValidationResult(passed=True, message="TAG chain complete")
302
+
303
+ details = "broken tag chains:\n" + "\n".join(f" - {chain.lower()}" for chain in broken_chains)
304
+ details += "\n\nrecommended: add missing spec documents or fix tag references."
305
+
306
+ return ValidationResult(passed=False, message=f"{len(broken_chains)} broken TAG chains", details=details)
307
+
308
+ def detect_orphan_tags(self, project_path: Path) -> list[str]:
309
+ """
310
+ 고아 TAG 탐지
311
+
312
+ Args:
313
+ project_path: 프로젝트 경로
314
+
315
+ Returns:
316
+ list[str]: 고아 TAG 목록
317
+ """
318
+ specs_dir = project_path / ".moai" / "specs"
319
+ src_dir = project_path / "src"
320
+
321
+ spec_tags = self._scan_tags(specs_dir, "@SPEC:")
322
+ code_tags = self._scan_tags(src_dir, "@CODE:")
323
+
324
+ orphans = []
325
+ for code_tag in code_tags:
326
+ tag_id = code_tag.split(":")[-1]
327
+ if not any(tag_id in spec_tag for spec_tag in spec_tags):
328
+ orphans.append(code_tag)
329
+
330
+ return orphans
331
+
332
+ def _scan_tags(self, directory: Path, tag_prefix: str) -> list[str]:
333
+ """
334
+ 디렉토리에서 TAG 스캔
335
+
336
+ Args:
337
+ directory: 스캔할 디렉토리
338
+ tag_prefix: TAG 접두사 (예: "@SPEC:", "@CODE:")
339
+
340
+ Returns:
341
+ list[str]: 발견된 TAG 목록
342
+ """
343
+ if not directory.exists():
344
+ return []
345
+
346
+ tags = []
347
+ for file in directory.rglob("*"):
348
+ if file.is_file():
349
+ try:
350
+ content = file.read_text()
351
+ for line in content.splitlines():
352
+ if tag_prefix in line:
353
+ tags.append(line.strip())
354
+ except (UnicodeDecodeError, PermissionError):
355
+ continue
356
+
357
+ return tags
358
+
359
+ # ========================================
360
+ # Report Generation
361
+ # ========================================
362
+
363
+ def generate_report(self, results: dict[str, Any], format: str = "markdown") -> str:
364
+ """
365
+ 검증 결과 보고서 생성
366
+
367
+ Args:
368
+ results: 검증 결과 딕셔너리
369
+ format: 보고서 형식 ("markdown" 또는 "json")
370
+
371
+ Returns:
372
+ str: 보고서 문자열
373
+ """
374
+ if format == "json":
375
+ return json.dumps(results, indent=2)
376
+
377
+ # Markdown 형식
378
+ report = "# TRUST Validation Report\n\n"
379
+
380
+ for category, result in results.items():
381
+ status = "✅ PASS" if result.get("passed", False) else "❌ FAIL"
382
+ value = result.get('value', 'N/A')
383
+ # 숫자인 경우 % 기호 추가
384
+ if isinstance(value, (int, float)):
385
+ value_str = f"{value}%"
386
+ else:
387
+ value_str = str(value)
388
+
389
+ report += f"## {category.upper()}\n"
390
+ report += f"**Status**: {status}\n"
391
+ report += f"**Value**: {value_str}\n\n"
392
+
393
+ return report
394
+
395
+ # ========================================
396
+ # Tool Selection
397
+ # ========================================
398
+
399
+ def select_tools(self, project_path: Path) -> dict[str, str]:
400
+ """
401
+ 언어별 도구 자동 선택
402
+
403
+ Args:
404
+ project_path: 프로젝트 경로
405
+
406
+ Returns:
407
+ dict[str, str]: 선택된 도구 딕셔너리
408
+ """
409
+ config_path = project_path / ".moai" / "config.json"
410
+ if not config_path.exists():
411
+ return {
412
+ "test_framework": "pytest",
413
+ "coverage_tool": "coverage.py",
414
+ "linter": "ruff",
415
+ "type_checker": "mypy",
416
+ }
417
+
418
+ config = json.loads(config_path.read_text())
419
+ language = config.get("project", {}).get("language", "python")
420
+
421
+ if language == "python":
422
+ return {
423
+ "test_framework": "pytest",
424
+ "coverage_tool": "coverage.py",
425
+ "linter": "ruff",
426
+ "type_checker": "mypy",
427
+ }
428
+ elif language == "typescript":
429
+ return {
430
+ "test_framework": "vitest",
431
+ "linter": "biome",
432
+ "type_checker": "tsc",
433
+ }
434
+
435
+ # 기본값 (Python)
436
+ return {
437
+ "test_framework": "pytest",
438
+ "coverage_tool": "coverage.py",
439
+ "linter": "ruff",
440
+ "type_checker": "mypy",
441
+ }
@@ -0,0 +1,6 @@
1
+ # @CODE:TRUST-001 | SPEC: SPEC-TRUST-001.md
2
+ """TRUST 검증기 패키지"""
3
+
4
+ from moai_adk.core.quality.validators.base_validator import ValidationResult
5
+
6
+ __all__ = ["ValidationResult"]
@@ -0,0 +1,19 @@
1
+ # @CODE:TRUST-001:VALIDATOR | SPEC: SPEC-TRUST-001.md
2
+ """Base validator class and validation result"""
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ValidationResult:
10
+ """검증 결과 데이터 클래스"""
11
+
12
+ passed: bool
13
+ message: str
14
+ details: str = ""
15
+ metadata: dict[str, Any] | None = None
16
+
17
+ def __post_init__(self):
18
+ if self.metadata is None:
19
+ self.metadata = {}
@@ -0,0 +1,8 @@
1
+ # @CODE:TEMPLATE-001 | SPEC: SPEC-INIT-003.md | Chain: TEMPLATE-001
2
+ """Template management module."""
3
+
4
+ from moai_adk.core.template.backup import TemplateBackup
5
+ from moai_adk.core.template.merger import TemplateMerger
6
+ from moai_adk.core.template.processor import TemplateProcessor
7
+
8
+ __all__ = ["TemplateProcessor", "TemplateBackup", "TemplateMerger"]
@@ -0,0 +1,95 @@
1
+ # @CODE:TEMPLATE-001 | SPEC: SPEC-INIT-003.md | Chain: TEMPLATE-001
2
+ """Template backup manager (SPEC-INIT-003 v0.3.0).
3
+
4
+ Creates and manages backups to protect user data during template updates.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+
14
+ class TemplateBackup:
15
+ """Create and manage template backups."""
16
+
17
+ # Paths excluded from backups (protect user data)
18
+ BACKUP_EXCLUDE_DIRS = [
19
+ "specs", # User SPEC documents
20
+ "reports", # User reports
21
+ ]
22
+
23
+ def __init__(self, target_path: Path) -> None:
24
+ """Initialize the backup manager.
25
+
26
+ Args:
27
+ target_path: Project path (absolute).
28
+ """
29
+ self.target_path = target_path.resolve()
30
+
31
+ def has_existing_files(self) -> bool:
32
+ """Check whether backup-worthy files already exist.
33
+
34
+ Returns:
35
+ True when any tracked file exists.
36
+ """
37
+ return any(
38
+ (self.target_path / item).exists()
39
+ for item in [".moai", ".claude", "CLAUDE.md"]
40
+ )
41
+
42
+ def create_backup(self) -> Path:
43
+ """Create a timestamped backup.
44
+
45
+ Returns:
46
+ Backup path (for example, .moai-backups/20250110-143025/).
47
+ """
48
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
49
+ backup_path = self.target_path / ".moai-backups" / timestamp
50
+ backup_path.mkdir(parents=True, exist_ok=True)
51
+
52
+ # Copy backup targets
53
+ for item in [".moai", ".claude", "CLAUDE.md"]:
54
+ src = self.target_path / item
55
+ if not src.exists():
56
+ continue
57
+
58
+ dst = backup_path / item
59
+
60
+ if item == ".moai":
61
+ # Copy while skipping protected paths
62
+ self._copy_exclude_protected(src, dst)
63
+ elif src.is_dir():
64
+ shutil.copytree(src, dst, dirs_exist_ok=True)
65
+ else:
66
+ shutil.copy2(src, dst)
67
+
68
+ return backup_path
69
+
70
+ def _copy_exclude_protected(self, src: Path, dst: Path) -> None:
71
+ """Copy backup content while excluding protected paths.
72
+
73
+ Args:
74
+ src: Source directory.
75
+ dst: Destination directory.
76
+ """
77
+ dst.mkdir(parents=True, exist_ok=True)
78
+
79
+ for item in src.rglob("*"):
80
+ rel_path = item.relative_to(src)
81
+ rel_path_str = str(rel_path)
82
+
83
+ # Skip excluded paths
84
+ if any(
85
+ rel_path_str.startswith(exclude_dir)
86
+ for exclude_dir in self.BACKUP_EXCLUDE_DIRS
87
+ ):
88
+ continue
89
+
90
+ dst_item = dst / rel_path
91
+ if item.is_file():
92
+ dst_item.parent.mkdir(parents=True, exist_ok=True)
93
+ shutil.copy2(item, dst_item)
94
+ elif item.is_dir():
95
+ dst_item.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,95 @@
1
+ # @CODE:PY314-001 | SPEC: SPEC-PY314-001.md | TEST: tests/unit/test_config_manager.py
2
+ """Configuration Manager
3
+
4
+ Manage .moai/config.json:
5
+ - Read and write configuration files
6
+ - Support deep merges
7
+ - Preserve UTF-8 content
8
+ - Create directories automatically
9
+ """
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class ConfigManager:
17
+ """Read and write .moai/config.json."""
18
+
19
+ DEFAULT_CONFIG = {
20
+ "mode": "personal",
21
+ "locale": "ko",
22
+ "moai": {
23
+ "version": "0.3.0"
24
+ }
25
+ }
26
+
27
+ def __init__(self, config_path: Path) -> None:
28
+ """Initialize the ConfigManager.
29
+
30
+ Args:
31
+ config_path: Path to config.json.
32
+ """
33
+ self.config_path = config_path
34
+
35
+ def load(self) -> dict[str, Any]:
36
+ """Load the configuration file.
37
+
38
+ Returns default values when the file is missing.
39
+
40
+ Returns:
41
+ Configuration dictionary.
42
+ """
43
+ if not self.config_path.exists():
44
+ return self.DEFAULT_CONFIG.copy()
45
+
46
+ with open(self.config_path, encoding="utf-8") as f:
47
+ data: dict[str, Any] = json.load(f)
48
+ return data
49
+
50
+ def save(self, config: dict[str, Any]) -> None:
51
+ """Persist the configuration file.
52
+
53
+ Creates directories when missing and preserves UTF-8 content.
54
+
55
+ Args:
56
+ config: Configuration dictionary to save.
57
+ """
58
+ # Ensure the directory exists
59
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
60
+
61
+ # Write while preserving UTF-8 characters
62
+ with open(self.config_path, "w", encoding="utf-8") as f:
63
+ json.dump(config, f, ensure_ascii=False, indent=2)
64
+
65
+ def update(self, updates: dict[str, Any]) -> None:
66
+ """Update the configuration using a deep merge.
67
+
68
+ Args:
69
+ updates: Dictionary of updates to apply.
70
+ """
71
+ current = self.load()
72
+ merged = self._deep_merge(current, updates)
73
+ self.save(merged)
74
+
75
+ def _deep_merge(self, base: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
76
+ """Recursively deep-merge dictionaries.
77
+
78
+ Args:
79
+ base: Base dictionary.
80
+ updates: Dictionary with updates.
81
+
82
+ Returns:
83
+ Merged dictionary.
84
+ """
85
+ result = base.copy()
86
+
87
+ for key, value in updates.items():
88
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
89
+ # When both sides are dicts, merge recursively
90
+ result[key] = self._deep_merge(result[key], value)
91
+ else:
92
+ # Otherwise, overwrite the value
93
+ result[key] = value
94
+
95
+ return result