feature-loop-harness-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +53 -0
  2. package/bin/flh.js +391 -0
  3. package/package.json +29 -0
  4. package/templates/default/.codex/config.toml +2 -0
  5. package/templates/default/.codex/hooks/user-prompt-submit.sh +5 -0
  6. package/templates/default/.codex/hooks.json +16 -0
  7. package/templates/default/.flh/docs/FEATURE_IMPLEMENTATION_PIPELINE.md +454 -0
  8. package/templates/default/.flh/docs/PROJECT_WORKFLOW.md +270 -0
  9. package/templates/default/.flh/docs/REVIEW_PATCH_PIPELINE.md +166 -0
  10. package/templates/default/.flh/hooks/user_prompt_submit.py +1440 -0
  11. package/templates/default/.flh/runtime/STATE.md +84 -0
  12. package/templates/default/.flh/scripts/pre_commit.py +674 -0
  13. package/templates/default/.flh/workflow/docs-spec.yml +134 -0
  14. package/templates/default/.flh/workflow/flow.yml +82 -0
  15. package/templates/default/.flh/workflow/request-patterns.yml +265 -0
  16. package/templates/default/.flh/workflow/state-actions.yml +117 -0
  17. package/templates/default/.flh/workflow/transition-guards.yml +57 -0
  18. package/templates/default/.husky/pre-commit +3 -0
  19. package/templates/default/AGENTS.md +44 -0
  20. package/templates/default/HARNESS_MANUAL.md +1105 -0
  21. package/templates/default/README.md +251 -0
  22. package/templates/default/docs/API.md +41 -0
  23. package/templates/default/docs/ARCHITECTURE.md +86 -0
  24. package/templates/default/docs/DB_SCHEMA.md +149 -0
  25. package/templates/default/docs/DESIGN.md +52 -0
  26. package/templates/default/docs/MVP.md +47 -0
  27. package/templates/default/docs/QUALITY_SCORE.md +54 -0
  28. package/templates/default/docs/docs-map.md +64 -0
  29. package/templates/default/docs/features/active/.gitkeep +1 -0
  30. package/templates/default/docs/features/backlog/.gitkeep +1 -0
  31. package/templates/default/docs/features/blocked/.gitkeep +1 -0
  32. package/templates/default/docs/features/done/.gitkeep +1 -0
  33. package/templates/default/docs/features/feature-index.md +21 -0
  34. package/templates/default/docs/features/postponed/.gitkeep +1 -0
  35. package/templates/default/docs/features/ready/.gitkeep +1 -0
  36. package/templates/default/docs/features/review/.gitkeep +1 -0
  37. package/templates/default/docs/source-layout.yml +33 -0
  38. package/templates/default/gitignore.template +9 -0
  39. package/templates/default/tests/hooks/test_pre_commit.py +659 -0
  40. package/templates/default/tests/hooks/test_user_prompt_submit.py +750 -0
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env python3
2
+ """Source-code pre-commit guard for the Feature Loop Harness."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[2]
16
+ STATE_PATH = ROOT / ".flh/runtime/STATE.md"
17
+ SOURCE_LAYOUT_PATH = ROOT / "docs/source-layout.yml"
18
+
19
+ SUPPORTED_PACKAGE_MANAGERS = {"npm", "pnpm", "yarn", "bun"}
20
+ IMPLEMENTATION_BRANCH_PREFIXES = ("feat/", "fix/", "refactor/")
21
+ SOURCE_CANDIDATE_PREFIXES = ("app/", "apps/", "packages/")
22
+
23
+ DOC_HARNESS_PATTERNS = (
24
+ "docs/**",
25
+ ".flh/**",
26
+ ".codex/**",
27
+ "AGENTS.md",
28
+ "README.md",
29
+ "tests/hooks/**",
30
+ ".husky/**",
31
+ "package.json",
32
+ "package-lock.json",
33
+ )
34
+
35
+ ROOT_SCAFFOLD_PATTERNS = (
36
+ "package.json",
37
+ "package-lock.json",
38
+ "pnpm-lock.yaml",
39
+ "pnpm-workspace.yaml",
40
+ "yarn.lock",
41
+ "tsconfig.base.json",
42
+ "eslint.config.*",
43
+ "prettier.config.*",
44
+ ".prettierrc*",
45
+ ".prettierignore",
46
+ ".gitignore",
47
+ )
48
+
49
+ SOURCE_SCAFFOLD_ALLOW_PATTERNS = (
50
+ "package.json",
51
+ "package-lock.json",
52
+ "tsconfig*.json",
53
+ "vite.config.*",
54
+ "vitest.config.*",
55
+ "eslint.config.*",
56
+ "src/index.*",
57
+ "src/main.*",
58
+ "src/app.*",
59
+ ".gitkeep",
60
+ )
61
+
62
+ SOURCE_SCAFFOLD_BLOCK_PATTERNS = (
63
+ "src/features/**",
64
+ "src/routes/**",
65
+ "src/pages/**",
66
+ "src/components/**",
67
+ "*.test.*",
68
+ "*.spec.*",
69
+ "tests/**",
70
+ )
71
+
72
+ DB_BASELINE_ALLOW_PATTERNS = (
73
+ "prisma/schema.prisma",
74
+ "prisma/migrations/**",
75
+ "package.json",
76
+ )
77
+
78
+
79
+ class PreCommitBlock(Exception):
80
+ """Raised when the commit should be blocked."""
81
+
82
+
83
+ def run_git(args: list[str]) -> str:
84
+ result = subprocess.run(
85
+ ["git", *args],
86
+ cwd=ROOT,
87
+ text=True,
88
+ stdout=subprocess.PIPE,
89
+ stderr=subprocess.PIPE,
90
+ check=False,
91
+ )
92
+ if result.returncode != 0:
93
+ raise PreCommitBlock(result.stderr.strip() or "git 명령 실행에 실패했습니다.")
94
+ return result.stdout.strip()
95
+
96
+
97
+ def parse_scalar(value: str) -> Any:
98
+ value = value.strip()
99
+ if value in {"", "null", "None", "~"}:
100
+ return None
101
+ if value == "[]":
102
+ return []
103
+ if value == "{}":
104
+ return {}
105
+ if value in {"true", "True"}:
106
+ return True
107
+ if value in {"false", "False"}:
108
+ return False
109
+ if (value.startswith('"') and value.endswith('"')) or (
110
+ value.startswith("'") and value.endswith("'")
111
+ ):
112
+ return value[1:-1]
113
+ return value
114
+
115
+
116
+ def parse_yaml_subset(text: str) -> dict[str, Any]:
117
+ root: dict[str, Any] = {}
118
+ stack: list[tuple[int, Any]] = [(-1, root)]
119
+ lines = text.splitlines()
120
+
121
+ for index, raw_line in enumerate(lines):
122
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
123
+ continue
124
+
125
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
126
+ stripped = raw_line.strip()
127
+
128
+ while stack and indent <= stack[-1][0]:
129
+ stack.pop()
130
+ parent = stack[-1][1]
131
+
132
+ if stripped.startswith("- "):
133
+ if not isinstance(parent, list):
134
+ raise ValueError(f"Invalid list item at line {index + 1}: {raw_line}")
135
+ parent.append(parse_scalar(stripped[2:]))
136
+ continue
137
+
138
+ if ":" not in stripped:
139
+ raise ValueError(f"Invalid YAML line {index + 1}: {raw_line}")
140
+
141
+ key, value = stripped.split(":", 1)
142
+ key = key.strip()
143
+ value = value.strip()
144
+
145
+ if value:
146
+ if not isinstance(parent, dict):
147
+ raise ValueError(f"Invalid mapping at line {index + 1}: {raw_line}")
148
+ parent[key] = parse_scalar(value)
149
+ continue
150
+
151
+ next_value: Any = {}
152
+ for lookahead in lines[index + 1 :]:
153
+ if not lookahead.strip() or lookahead.lstrip().startswith("#"):
154
+ continue
155
+ next_value = [] if lookahead.strip().startswith("- ") else {}
156
+ break
157
+
158
+ if not isinstance(parent, dict):
159
+ raise ValueError(f"Invalid nested mapping at line {index + 1}: {raw_line}")
160
+ parent[key] = next_value
161
+ stack.append((indent, next_value))
162
+
163
+ return root
164
+
165
+
166
+ def parse_frontmatter(text: str) -> dict[str, Any]:
167
+ if not text.startswith("---"):
168
+ return {}
169
+ parts = text.split("---", 2)
170
+ if len(parts) < 3:
171
+ return {}
172
+ return parse_yaml_subset(parts[1])
173
+
174
+
175
+ def load_yaml(path: Path) -> dict[str, Any]:
176
+ if not path.exists():
177
+ return {}
178
+ return parse_yaml_subset(path.read_text(encoding="utf-8"))
179
+
180
+
181
+ def load_state() -> dict[str, Any]:
182
+ if not STATE_PATH.exists():
183
+ return {}
184
+ return parse_frontmatter(STATE_PATH.read_text(encoding="utf-8"))
185
+
186
+
187
+ def load_committed_state() -> dict[str, Any]:
188
+ result = subprocess.run(
189
+ ["git", "show", "HEAD:.flh/runtime/STATE.md"],
190
+ cwd=ROOT,
191
+ text=True,
192
+ stdout=subprocess.PIPE,
193
+ stderr=subprocess.PIPE,
194
+ check=False,
195
+ )
196
+ if result.returncode != 0:
197
+ return {}
198
+ return parse_frontmatter(result.stdout)
199
+
200
+
201
+ def normalize_path(path: str) -> str:
202
+ return path.strip().strip("/")
203
+
204
+
205
+ def normalize_package_manager(value: Any) -> str:
206
+ text = str(value or "").strip()
207
+ if not text:
208
+ return ""
209
+ return text.split("@", 1)[0]
210
+
211
+
212
+ def is_todo_value(value: Any) -> bool:
213
+ return "{{TODO" in str(value) or "TODO" in str(value)
214
+
215
+
216
+ def path_matches(path: str, pattern: str) -> bool:
217
+ path = normalize_path(path)
218
+ pattern = normalize_path(pattern)
219
+ return fnmatch.fnmatch(path, pattern)
220
+
221
+
222
+ def path_is_inside(path: str, root: str) -> bool:
223
+ path = normalize_path(path)
224
+ root = normalize_path(root)
225
+ return path == root or path.startswith(f"{root}/")
226
+
227
+
228
+ def source_roots(source_layout: dict[str, Any]) -> list[dict[str, Any]]:
229
+ roots = source_layout.get("source_roots")
230
+ if not isinstance(roots, dict):
231
+ return []
232
+
233
+ result: list[dict[str, Any]] = []
234
+ for key, config in roots.items():
235
+ if not isinstance(config, dict):
236
+ continue
237
+ path = config.get("path")
238
+ if not path or is_todo_value(path):
239
+ continue
240
+ result.append(
241
+ {
242
+ "key": str(key),
243
+ "path": normalize_path(str(path)),
244
+ "package": config.get("package") is True,
245
+ }
246
+ )
247
+ return result
248
+
249
+
250
+ def is_doc_harness_file(path: str) -> bool:
251
+ return any(path_matches(path, pattern) for pattern in DOC_HARNESS_PATTERNS)
252
+
253
+
254
+ def is_source_candidate(path: str, roots: list[dict[str, Any]]) -> bool:
255
+ normalized = normalize_path(path)
256
+ if normalized.startswith(SOURCE_CANDIDATE_PREFIXES):
257
+ return True
258
+ return any(path_is_inside(normalized, str(root["path"])) for root in roots)
259
+
260
+
261
+ def unknown_files(staged_files: list[str], roots: list[dict[str, Any]]) -> list[str]:
262
+ return [
263
+ path
264
+ for path in staged_files
265
+ if not is_doc_harness_file(path) and not is_source_candidate(path, roots)
266
+ ]
267
+
268
+
269
+ def match_source_root(path: str, roots: list[dict[str, Any]]) -> dict[str, Any] | None:
270
+ matches = [root for root in roots if path_is_inside(path, str(root["path"]))]
271
+ if not matches:
272
+ return None
273
+ return max(matches, key=lambda root: len(str(root["path"])))
274
+
275
+
276
+ def relative_to_source_root(path: str, source_root: dict[str, Any]) -> str:
277
+ root_path = normalize_path(str(source_root["path"]))
278
+ normalized = normalize_path(path)
279
+ if normalized == root_path:
280
+ return ""
281
+ return normalized[len(root_path) + 1 :]
282
+
283
+
284
+ def branch_kind(branch: str) -> str:
285
+ if branch in {"main", "master"}:
286
+ return "main"
287
+ if branch.startswith(IMPLEMENTATION_BRANCH_PREFIXES):
288
+ return "implementation"
289
+ return "other"
290
+
291
+
292
+ def feature_directory_exists(kind: str) -> bool:
293
+ base = ROOT / f"docs/features/{kind}"
294
+ if not base.exists():
295
+ return False
296
+ return any(path.is_dir() for path in base.iterdir())
297
+
298
+
299
+ def source_layout_completed(source_layout: dict[str, Any]) -> bool:
300
+ return source_layout.get("status") == "completed"
301
+
302
+
303
+ def package_manager_from_layout(source_layout: dict[str, Any]) -> str:
304
+ project = source_layout.get("project")
305
+ if not isinstance(project, dict):
306
+ return ""
307
+ return normalize_package_manager(project.get("package_manager"))
308
+
309
+
310
+ def package_json(path: Path) -> dict[str, Any]:
311
+ if not path.exists():
312
+ return {}
313
+ try:
314
+ return json.loads(path.read_text(encoding="utf-8"))
315
+ except json.JSONDecodeError as exc:
316
+ raise PreCommitBlock(f"{path.relative_to(ROOT)} 파일의 JSON 문법이 올바르지 않습니다: {exc}")
317
+
318
+
319
+ def package_manager_conflicts(package_json_path: Path, expected_pm: str) -> str | None:
320
+ data = package_json(package_json_path)
321
+ actual = normalize_package_manager(data.get("packageManager"))
322
+ if actual and actual != expected_pm:
323
+ return actual
324
+ return None
325
+
326
+
327
+ def script_command(pm: str, package_path: str, script_name: str) -> list[str]:
328
+ if pm == "npm":
329
+ return ["npm", "--prefix", package_path, "run", script_name]
330
+ if pm == "pnpm":
331
+ return ["pnpm", "-C", package_path, "run", script_name]
332
+ if pm == "yarn":
333
+ return ["yarn", "--cwd", package_path, "run", script_name]
334
+ if pm == "bun":
335
+ return ["bun", "--cwd", package_path, "run", script_name]
336
+ raise PreCommitBlock(f"지원하지 않는 package manager입니다: {pm}")
337
+
338
+
339
+ def lint_staged_command(pm: str) -> list[str]:
340
+ if pm == "npm":
341
+ return ["npx", "lint-staged"]
342
+ if pm == "pnpm":
343
+ return ["pnpm", "exec", "lint-staged"]
344
+ if pm == "yarn":
345
+ return ["yarn", "lint-staged"]
346
+ if pm == "bun":
347
+ return ["bunx", "lint-staged"]
348
+ raise PreCommitBlock(f"지원하지 않는 package manager입니다: {pm}")
349
+
350
+
351
+ def run_command(command: list[str]) -> None:
352
+ print(f"🔎 실행 명령: {' '.join(command)}")
353
+ result = subprocess.run(command, cwd=ROOT, check=False)
354
+ if result.returncode != 0:
355
+ raise PreCommitBlock(f"명령이 실패했습니다: {' '.join(command)}")
356
+
357
+
358
+ def root_scaffold_extra_files(source_layout: dict[str, Any]) -> set[str]:
359
+ project = source_layout.get("project")
360
+ if not isinstance(project, dict):
361
+ return set()
362
+ values = project.get("scaffold_extra_root_files")
363
+ if not isinstance(values, list):
364
+ return set()
365
+ return {normalize_path(str(value)) for value in values}
366
+
367
+
368
+ def is_root_scaffold_file(path: str, source_layout: dict[str, Any]) -> bool:
369
+ normalized = normalize_path(path)
370
+ if "/" in normalized:
371
+ return False
372
+ if any(path_matches(normalized, pattern) for pattern in ROOT_SCAFFOLD_PATTERNS):
373
+ return True
374
+ return normalized in root_scaffold_extra_files(source_layout)
375
+
376
+
377
+ def source_scaffold_allowed(path: str, source_root: dict[str, Any]) -> bool:
378
+ relative = relative_to_source_root(path, source_root)
379
+ if any(path_matches(relative, pattern) for pattern in SOURCE_SCAFFOLD_BLOCK_PATTERNS):
380
+ return False
381
+ return any(path_matches(relative, pattern) for pattern in SOURCE_SCAFFOLD_ALLOW_PATTERNS)
382
+
383
+
384
+ def has_source_scaffold_approval(state: dict[str, Any]) -> bool:
385
+ approvals = state.get("approvals")
386
+ if not isinstance(approvals, dict):
387
+ return False
388
+ source_scaffold = approvals.get("source_scaffold")
389
+ if not isinstance(source_scaffold, dict):
390
+ return False
391
+ return source_scaffold.get("created") is True
392
+
393
+
394
+ def has_database_baseline_approval(state: dict[str, Any]) -> bool:
395
+ approvals = state.get("approvals")
396
+ if not isinstance(approvals, dict):
397
+ return False
398
+ database_baseline = approvals.get("database_baseline")
399
+ if not isinstance(database_baseline, dict):
400
+ return False
401
+ if database_baseline.get("required") is False:
402
+ return database_baseline.get("skipped") is True
403
+ if database_baseline.get("required") is True:
404
+ return database_baseline.get("verified") is True
405
+ return False
406
+
407
+
408
+ def scaffold_exception_allowed(
409
+ staged_files: list[str],
410
+ source_files: list[tuple[str, dict[str, Any]]],
411
+ source_layout: dict[str, Any],
412
+ current_state: dict[str, Any],
413
+ committed_state: dict[str, Any],
414
+ ) -> tuple[bool, str]:
415
+ if current_state.get("current_state") != "FEATURE_IMPLEMENTATION":
416
+ return False, "현재 상태가 FEATURE_IMPLEMENTATION이 아닙니다."
417
+ if has_source_scaffold_approval(committed_state):
418
+ return False, "이전 커밋에 이미 source scaffold approval이 기록되어 있습니다."
419
+ if not has_source_scaffold_approval(current_state):
420
+ return False, "현재 STATE.md에 source scaffold approval 기록이 없습니다."
421
+ if ".flh/runtime/STATE.md" not in staged_files:
422
+ return False, "source scaffold approval을 기록한 .flh/runtime/STATE.md가 staged되지 않았습니다."
423
+
424
+ source_file_set = {path for path, _ in source_files}
425
+ for path, root in source_files:
426
+ if not source_scaffold_allowed(path, root):
427
+ return False, f"scaffold 허용 범위를 벗어난 source file입니다: {path}"
428
+
429
+ for path in staged_files:
430
+ if path in source_file_set:
431
+ continue
432
+ if path == ".flh/runtime/STATE.md":
433
+ continue
434
+ if is_root_scaffold_file(path, source_layout):
435
+ continue
436
+ return False, f"scaffold 예외에 허용되지 않는 파일입니다: {path}"
437
+
438
+ return True, "scaffold baseline exception 조건을 만족했습니다."
439
+
440
+
441
+ def db_baseline_allowed(path: str, source_root: dict[str, Any]) -> bool:
442
+ relative = relative_to_source_root(path, source_root)
443
+ return any(path_matches(relative, pattern) for pattern in DB_BASELINE_ALLOW_PATTERNS)
444
+
445
+
446
+ def database_baseline_exception_allowed(
447
+ staged_files: list[str],
448
+ source_files: list[tuple[str, dict[str, Any]]],
449
+ source_layout: dict[str, Any],
450
+ current_state: dict[str, Any],
451
+ committed_state: dict[str, Any],
452
+ ) -> tuple[bool, str]:
453
+ if current_state.get("current_state") != "FEATURE_IMPLEMENTATION":
454
+ return False, "현재 상태가 FEATURE_IMPLEMENTATION이 아닙니다."
455
+ if not has_source_scaffold_approval(current_state):
456
+ return False, "source scaffold approval이 아직 기록되어 있지 않습니다."
457
+ if has_database_baseline_approval(committed_state):
458
+ return False, "이전 커밋에 이미 database baseline approval이 기록되어 있습니다."
459
+ if not has_database_baseline_approval(current_state):
460
+ return False, "현재 STATE.md에 database baseline approval 기록이 없습니다."
461
+ current_database_baseline = current_state.get("approvals", {}).get("database_baseline", {})
462
+ if isinstance(current_database_baseline, dict) and current_database_baseline.get("required") is False:
463
+ return False, "DB 미사용 skip approval은 source file 변경을 포함할 수 없습니다."
464
+ if ".flh/runtime/STATE.md" not in staged_files:
465
+ return False, "database baseline approval을 기록한 .flh/runtime/STATE.md가 staged되지 않았습니다."
466
+
467
+ source_file_set = {path for path, _ in source_files}
468
+ for path, root in source_files:
469
+ if not db_baseline_allowed(path, root):
470
+ return False, f"database baseline 예외에 허용되지 않는 source file입니다: {path}"
471
+
472
+ for path in staged_files:
473
+ if path in source_file_set:
474
+ continue
475
+ if path == ".flh/runtime/STATE.md":
476
+ continue
477
+ if is_root_scaffold_file(path, source_layout):
478
+ continue
479
+ return False, f"database baseline 예외에 허용되지 않는 파일입니다: {path}"
480
+
481
+ return True, "database baseline exception 조건을 만족했습니다."
482
+
483
+
484
+ def block(message: str) -> None:
485
+ raise PreCommitBlock(message)
486
+
487
+
488
+ def print_block(message: str, details: list[str] | None = None) -> None:
489
+ print("\n🚫 커밋 차단\n")
490
+ print(message)
491
+ if details:
492
+ print()
493
+ for detail in details:
494
+ print(f"- {detail}")
495
+
496
+
497
+ def main() -> int:
498
+ try:
499
+ branch = run_git(["branch", "--show-current"]) or "(detached)"
500
+ staged_files = [
501
+ line
502
+ for line in run_git(["diff", "--cached", "--name-only"]).splitlines()
503
+ if line.strip()
504
+ ]
505
+
506
+ print(f"🔎 현재 브랜치: {branch}")
507
+ if not staged_files:
508
+ print("✅ staged file이 없어 pre-commit 검사를 건너뜁니다.")
509
+ return 0
510
+
511
+ source_layout = load_yaml(SOURCE_LAYOUT_PATH)
512
+ candidate_roots = source_roots(source_layout)
513
+ source_candidates = [
514
+ path for path in staged_files if is_source_candidate(path, candidate_roots)
515
+ ]
516
+ unknown = unknown_files(staged_files, candidate_roots)
517
+
518
+ if not source_candidates:
519
+ if unknown:
520
+ print("⚠️ unknown file이 staged되어 있습니다.")
521
+ print("source file 변경이 함께 없으므로 commit을 차단하지 않습니다.")
522
+ for path in unknown:
523
+ print(f"- {path}")
524
+ print("⏭️ source file 변경이 없어 source package checks와 lint-staged를 건너뜁니다.")
525
+ return 0
526
+
527
+ if unknown:
528
+ block(
529
+ "source file과 unknown file이 함께 staged되어 있습니다.\n\n"
530
+ "unknown file은 source/layout 또는 docs/harness 정책에 속하지 않아 함께 커밋할 수 없습니다.\n\n"
531
+ "unknown file:\n"
532
+ + "\n".join(f"- {path}" for path in unknown)
533
+ )
534
+
535
+ if not source_layout_completed(source_layout):
536
+ block(
537
+ "source file 변경이 감지됐지만 docs/source-layout.yml이 completed 상태가 아닙니다.\n\n"
538
+ "해결 방법:\n"
539
+ "docs/source-layout.yml을 완성하고 status를 completed로 변경한 뒤 다시 커밋하세요."
540
+ )
541
+
542
+ roots = source_roots(source_layout)
543
+ source_files: list[tuple[str, dict[str, Any]]] = []
544
+ for path in staged_files:
545
+ root = match_source_root(path, roots)
546
+ if root is not None:
547
+ source_files.append((path, root))
548
+
549
+ if not source_files:
550
+ block(
551
+ "source 후보 파일은 감지됐지만 completed source-layout 기준 source root에 속하지 않습니다.\n\n"
552
+ "해결 방법:\n"
553
+ "docs/source-layout.yml의 source_roots.*.path를 확인하세요."
554
+ )
555
+
556
+ kind = branch_kind(branch)
557
+ if kind == "main":
558
+ state = load_state()
559
+ committed_state = load_committed_state()
560
+ allowed, reason = scaffold_exception_allowed(
561
+ staged_files, source_files, source_layout, state, committed_state
562
+ )
563
+ if allowed:
564
+ print(f"✅ {reason}")
565
+ print("⏭️ scaffold baseline commit이므로 package checks와 lint-staged를 건너뜁니다.")
566
+ return 0
567
+ db_allowed, db_reason = database_baseline_exception_allowed(
568
+ staged_files, source_files, source_layout, state, committed_state
569
+ )
570
+ if db_allowed:
571
+ print(f"✅ {db_reason}")
572
+ print("⏭️ database baseline commit이므로 package checks와 lint-staged를 건너뜁니다.")
573
+ return 0
574
+ block(
575
+ "main/master 브랜치에서는 실제 source file을 직접 커밋할 수 없습니다.\n\n"
576
+ f"scaffold 예외 미적용 사유: {reason}\n"
577
+ f"database baseline 예외 미적용 사유: {db_reason}\n\n"
578
+ "해결 방법:\n"
579
+ "feat/*, fix/*, refactor/* 브랜치에서 작업하거나, baseline 예외 조건을 확인하세요."
580
+ )
581
+
582
+ if kind == "other":
583
+ block(
584
+ f"현재 브랜치에서 source file 변경을 커밋할 수 없습니다: {branch}\n\n"
585
+ "허용 브랜치:\n"
586
+ "- feat/*\n"
587
+ "- fix/*\n"
588
+ "- refactor/*\n\n"
589
+ "문서/하네스 파일만 변경한 커밋은 브랜치 prefix와 관계없이 허용됩니다."
590
+ )
591
+
592
+ if not feature_directory_exists("active") and not feature_directory_exists("review"):
593
+ block(
594
+ "source file 변경이 있지만 active 또는 review 기능 디렉토리가 없습니다.\n\n"
595
+ "해결 방법:\n"
596
+ "docs/features/active/ 또는 docs/features/review/에 대상 기능 디렉토리가 있는지 확인하세요."
597
+ )
598
+
599
+ affected: dict[str, dict[str, Any]] = {}
600
+ for _path, root in source_files:
601
+ if root.get("package") is True:
602
+ affected[str(root["path"])] = root
603
+
604
+ pm = package_manager_from_layout(source_layout)
605
+ if pm not in SUPPORTED_PACKAGE_MANAGERS:
606
+ block(
607
+ f"지원하지 않는 package manager입니다: {pm or '(empty)'}\n\n"
608
+ "docs/source-layout.yml의 project.package_manager 값을 확인하세요."
609
+ )
610
+
611
+ if shutil.which(pm) is None:
612
+ block(
613
+ f"package manager 실행 파일을 찾을 수 없습니다: {pm}\n\n"
614
+ "해결 방법:\n"
615
+ f"{pm}을 설치하거나 docs/source-layout.yml의 project.package_manager 값을 수정하세요."
616
+ )
617
+
618
+ root_pm_conflict = package_manager_conflicts(ROOT / "package.json", pm)
619
+ if root_pm_conflict:
620
+ block(
621
+ "package manager 설정이 서로 다릅니다.\n\n"
622
+ f"docs/source-layout.yml: {pm}\n"
623
+ f"package.json: {root_pm_conflict}"
624
+ )
625
+
626
+ if affected:
627
+ print("🔎 affected package:")
628
+ for package_path in sorted(affected):
629
+ print(f"- {package_path}")
630
+ else:
631
+ print("⏭️ package: true source root 변경이 없어 package script 검사를 건너뜁니다.")
632
+
633
+ for package_path in sorted(affected):
634
+ package_dir = ROOT / package_path
635
+ package_json_path = package_dir / "package.json"
636
+ if not package_json_path.exists():
637
+ block(
638
+ f"{package_path}는 package로 표시되어 있지만 package.json이 없습니다.\n\n"
639
+ "해결 방법:\n"
640
+ f"{package_path}/package.json을 생성하거나, package가 아닌 디렉토리라면 "
641
+ "docs/source-layout.yml에서 package 값을 false로 변경하세요."
642
+ )
643
+
644
+ package_pm_conflict = package_manager_conflicts(package_json_path, pm)
645
+ if package_pm_conflict:
646
+ block(
647
+ "package manager 설정이 서로 다릅니다.\n\n"
648
+ f"docs/source-layout.yml: {pm}\n"
649
+ f"{package_path}/package.json: {package_pm_conflict}"
650
+ )
651
+
652
+ data = package_json(package_json_path)
653
+ scripts = data.get("scripts") if isinstance(data, dict) else {}
654
+ scripts = scripts if isinstance(scripts, dict) else {}
655
+
656
+ print(f"\n🔎 {package_path} 검사 시작")
657
+ for script_name in ("lint", "typecheck", "test"):
658
+ if script_name not in scripts:
659
+ print(f"⏭️ {script_name} script 없음 - 스킵")
660
+ continue
661
+ run_command(script_command(pm, package_path, script_name))
662
+ print(f"✅ {script_name} 통과")
663
+
664
+ run_command(lint_staged_command(pm))
665
+ print("\n✅ pre-commit 검사 완료")
666
+ return 0
667
+
668
+ except PreCommitBlock as exc:
669
+ print_block(str(exc))
670
+ return 1
671
+
672
+
673
+ if __name__ == "__main__":
674
+ sys.exit(main())