ff-lntools 0.1.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.
Files changed (59) hide show
  1. ff_lntools-0.1.0.dist-info/METADATA +94 -0
  2. ff_lntools-0.1.0.dist-info/RECORD +59 -0
  3. ff_lntools-0.1.0.dist-info/WHEEL +5 -0
  4. ff_lntools-0.1.0.dist-info/entry_points.txt +2 -0
  5. ff_lntools-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. ff_lntools-0.1.0.dist-info/top_level.txt +1 -0
  7. lntools/__init__.py +3 -0
  8. lntools/__main__.py +5 -0
  9. lntools/for-agent-layerinfo.md +33 -0
  10. lntools/l0/__init__.py +22 -0
  11. lntools/l0/edge/__init__.py +3 -0
  12. lntools/l0/edge/edge.py +20 -0
  13. lntools/l0/for-agent-layerinfo-l0.md +40 -0
  14. lntools/l0/initializer/__init__.py +3 -0
  15. lntools/l0/initializer/initializer.py +63 -0
  16. lntools/l0/module_ref/__init__.py +3 -0
  17. lntools/l0/module_ref/module_ref.py +26 -0
  18. lntools/l0/project_layout/__init__.py +3 -0
  19. lntools/l0/project_layout/project_layout.py +51 -0
  20. lntools/l0/raw_import/__init__.py +3 -0
  21. lntools/l0/raw_import/raw_import.py +11 -0
  22. lntools/l0/signature_extractor/__init__.py +3 -0
  23. lntools/l0/signature_extractor/signature_extractor.py +78 -0
  24. lntools/l0/violation/__init__.py +3 -0
  25. lntools/l0/violation/violation.py +22 -0
  26. lntools/l1/__init__.py +15 -0
  27. lntools/l1/for-agent-layerinfo-l1.md +25 -0
  28. lntools/l1/graph/__init__.py +3 -0
  29. lntools/l1/graph/graph.py +92 -0
  30. lntools/l1/layer_init_writer/__init__.py +3 -0
  31. lntools/l1/layer_init_writer/layer_init_writer.py +39 -0
  32. lntools/l1/scanner/__init__.py +3 -0
  33. lntools/l1/scanner/scanner.py +189 -0
  34. lntools/l2/__init__.py +15 -0
  35. lntools/l2/blaster/__init__.py +3 -0
  36. lntools/l2/blaster/blaster.py +23 -0
  37. lntools/l2/checker/__init__.py +3 -0
  38. lntools/l2/checker/checker.py +41 -0
  39. lntools/l2/doc_generator/__init__.py +3 -0
  40. lntools/l2/doc_generator/doc_generator.py +202 -0
  41. lntools/l2/for-agent-layerinfo-l2.md +31 -0
  42. lntools/l3/__init__.py +14 -0
  43. lntools/l3/for-agent-layerinfo-l3.md +15 -0
  44. lntools/l3/hook_runner/__init__.py +3 -0
  45. lntools/l3/hook_runner/hook_runner.py +74 -0
  46. lntools/l3/mover/__init__.py +3 -0
  47. lntools/l3/mover/mover.py +164 -0
  48. lntools/l4/__init__.py +14 -0
  49. lntools/l4/cli/__init__.py +3 -0
  50. lntools/l4/cli/cli.py +132 -0
  51. lntools/l4/for-agent-layerinfo-l4.md +11 -0
  52. lntools/protocol/CLAUDE-template.md +18 -0
  53. lntools/protocol/SKILL-template.md +30 -0
  54. lntools/protocol/for-agent-codingprotocol-ln-structure.md +367 -0
  55. lntools/protocol/for-agent-codingprotocol-python.md +94 -0
  56. lntools/protocol/for-agent-layerinfo-ln-template.md +109 -0
  57. lntools/protocol/for-agent-layerinfo-template.md +47 -0
  58. lntools/protocol/for-agent-moduleinfo-template.md +125 -0
  59. lntools/protocol/settings-hooks-template.json +20 -0
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: ff-lntools
3
+ Version: 0.1.0
4
+ Summary: ln-structure checker, blast radius, doc generator and Claude Code hooks
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # ff-lntools
14
+
15
+ ln-structure 프로젝트용 정적 검사, 영향 범위, 문서 생성 도구와 Claude Code 훅.
16
+ 표준 라이브러리만 사용. Python 3.10+.
17
+
18
+ ln-structure 규칙: `src/lntools/protocol/for-agent-codingprotocol-ln-structure.md`
19
+
20
+ ## 설치
21
+
22
+ ```
23
+ pip install git+https://github.com/gatesplan/ff-lntools
24
+ ```
25
+
26
+ 프로젝트가 쓰는 Python 환경마다 설치한다. 훅이 `python -m lntools` 로 호출하기 때문이다.
27
+
28
+ ## 프로젝트 세팅
29
+
30
+ ```
31
+ lnt init --skill # 1회. ~/.claude/skills/init-protocol 스킬 설치
32
+ cd my-project
33
+ lnt init # .claude/ 에 프로토콜 문서 복사, CLAUDE.md 생성, 훅 병합
34
+ ```
35
+
36
+ Claude Code 에서는 `/init-protocol` 이 같은 일을 한다. 코딩 프로토콜 문서의 원본은 이 저장소의
37
+ `src/lntools/protocol/` 이다.
38
+
39
+ ## 명령
40
+
41
+ ```
42
+ lnt init [--skill] # 프로젝트에 코딩 프로토콜 설치
43
+ lnt check [--file PATH] # C1 방향, C2 표면, C3 층 일치, C4 순환. 위반 시 exit 1
44
+ lnt blast MODULE [-d N] # MODULE 변경 시 영향받는 상위 모듈. 인터페이스 상속 경유 포함
45
+ lnt map # 모듈 목록, 층, 의존
46
+ lnt doc # for-agent-layerinfo.md, for-agent-layerinfo-lN.md 생성
47
+ lnt doc --check # 생성 결과와 파일 비교. 다르면 exit 1
48
+ lnt doc --stamp MODULE # for-agent-moduleinfo.md 의 sources hash 갱신
49
+ lnt move MODULE lK # 모듈을 lK 로 이동. 패키지/tests 의 import 재작성, tests 미러 이동,
50
+ # 두 층의 __init__ 을 지연 re-export 로 재생성, 문서 재생성
51
+ lnt hook post-edit # Claude Code PostToolUse 훅 진입 (stdin JSON)
52
+ lnt hook session-start # Claude Code SessionStart 훅 진입
53
+ ```
54
+
55
+ `--root` 로 프로젝트 안의 아무 경로를 주면 `src/<pkg>/lN` 을 찾아 올라간다. 기본은 현재 디렉토리.
56
+
57
+ ## 검사 규칙
58
+
59
+ | 코드 | 내용 |
60
+ |---|---|
61
+ | C1 | import 대상 층이 자기 층보다 낮아야 한다. 같은 층도 위반. `TYPE_CHECKING` 안의 import 도 포함 |
62
+ | C2 | `pkg.lN.module` 표면까지만 import. 모듈 안의 파일이나 중첩 모듈 내부로 진입 금지 |
63
+ | C3 | 선언 층 == 계산 층 (의존 최고 층 + 1, 의존 없으면 0, 외부 패키지 의존이 있으면 최소 1) |
64
+ | C4 | 모듈 간 runtime import 순환 금지. 모듈 내부 파일 간 순환은 규칙 밖 |
65
+
66
+ ## Claude Code 훅
67
+
68
+ `.claude/settings.json`:
69
+
70
+ ```json
71
+ {
72
+ "hooks": {
73
+ "SessionStart": [{
74
+ "matcher": "startup|resume|clear|compact",
75
+ "hooks": [{"type": "command", "command": "python -m lntools hook session-start"}]
76
+ }],
77
+ "PostToolUse": [{
78
+ "matcher": "Edit|Write|MultiEdit",
79
+ "hooks": [{"type": "command", "command": "python -m lntools hook post-edit", "timeout": 30}]
80
+ }]
81
+ }
82
+ }
83
+ ```
84
+
85
+ - session-start: `src/<pkg>/for-agent-layerinfo.md` 를 세션 컨텍스트에 주입
86
+ - post-edit: 편집한 파일이 `src/**/*.py` 이면 그 모듈을 검사.
87
+ 위반은 exit 2 + stderr 로 에이전트에게 오류로 전달되고, 위반이 없으면 영향 범위와
88
+ 문서 stale 여부가 additionalContext 로 전달된다
89
+
90
+ ## 문서
91
+
92
+ - `for-agent-layerinfo.md` (패키지 루트, 중첩 모듈 루트): 층별 모듈 목록. 마커 사이는 생성, 한 줄 설명과 마커 밖은 보존
93
+ - `for-agent-layerinfo-lN.md` (각 층): 공개 클래스 메서드 시그니처. 마커 사이는 생성
94
+ - `for-agent-moduleinfo.md` (각 모듈): 사람이 쓴다. `sources` 헤더의 hash 로 stale 판정만 한다
@@ -0,0 +1,59 @@
1
+ ff_lntools-0.1.0.dist-info/licenses/LICENSE,sha256=a866MbOAIcwhnga_SLR_8hE05-WvYEX9qe0LtzvRRz4,1066
2
+ lntools/__init__.py,sha256=wTPp70LSqWgaaxvgMwXikmDCca5IHyznb9tuPGs0cW8,45
3
+ lntools/__main__.py,sha256=FF98yEbkB4qjdp92eaJvYyidRIwsHbGKoykm6aHVzZk,55
4
+ lntools/for-agent-layerinfo.md,sha256=f3KGiuifgpG9hQSYHcW15uYbZlhFNs69ATPo5o7dbAw,1586
5
+ lntools/l0/__init__.py,sha256=Be6sqCdKBx6GOnuhBl-8WFMP-7canS_fCNac921110Q,604
6
+ lntools/l0/for-agent-layerinfo-l0.md,sha256=k03FwR9Wa90HlQXYH8ay5hPiMsKNNAHxYYN_BZEXdcc,1438
7
+ lntools/l0/edge/__init__.py,sha256=oiCQq3R7F3bnqhmhLmGlFej0tgnVNDjlM8aIYfuJOtY,43
8
+ lntools/l0/edge/edge.py,sha256=UB7l6iY4fRsRKFUbN1s2_79_ImjcToKOSUlg2_24JHw,899
9
+ lntools/l0/initializer/__init__.py,sha256=SESnsQJRm6OftZw5NZMMBXGM25rg2tlGcYlz2jzvs9I,98
10
+ lntools/l0/initializer/initializer.py,sha256=7ff8XAlVHft_HHYNrv9X7js2MTojn_Au3R8cM7GIqws,2783
11
+ lntools/l0/module_ref/__init__.py,sha256=vNBbuQGlrdzqAf8wkXDQS-J6fFk9YfHNZuaExBHoRSg,59
12
+ lntools/l0/module_ref/module_ref.py,sha256=80UOnYZ_7uMV_M7n-E_jtfS0SbTjtoF4ptqHVNQV0_I,1087
13
+ lntools/l0/project_layout/__init__.py,sha256=JWo1s5Ek6p9Mlr2CXI9vBDOB1XNc42Y3HuLZVynvBI0,93
14
+ lntools/l0/project_layout/project_layout.py,sha256=OnB9DhRBdX1y_12out3XKOo_tRLhtS2kCwzhYMu8f9Q,1671
15
+ lntools/l0/raw_import/__init__.py,sha256=_YRv7q5MjAK1CIQrh-AjSEJDwbDLlAiTfN0keDTXtBM,59
16
+ lntools/l0/raw_import/raw_import.py,sha256=Q23ByMoqgwgkm9eJhtuB1OSqG-6t8Xp80UPJXeMKwJc,513
17
+ lntools/l0/signature_extractor/__init__.py,sha256=LUdwTprOBBpCT_gvsF4yiEucU8p6DnQPqhs2YCOgncE,86
18
+ lntools/l0/signature_extractor/signature_extractor.py,sha256=kEmQnvCM4mKHvg4QKcab96OqaNJZd0xutPb2vCu5Bew,3547
19
+ lntools/l0/violation/__init__.py,sha256=G5CCgjVc6ZAhtXLK7y2Rvejw8pcPpgTT-MxFzTxmIKI,58
20
+ lntools/l0/violation/violation.py,sha256=QktjTT4bK-Be0xEawegt_j8WvzfynsWe1FL3VngIAyw,609
21
+ lntools/l1/__init__.py,sha256=1uzHtjv2arjz5hDmJ7gV9GGcVXqMt6XXdXgZI2VkyV0,340
22
+ lntools/l1/for-agent-layerinfo-l1.md,sha256=o5-U6Ns5mhDTVLuL2NJAgH4ZbJQmiIsldd-OUCaUpi8,1017
23
+ lntools/l1/graph/__init__.py,sha256=IiYvwf3SFD6a8CM-EuVPf-ljAAQp-iFyjIx3bMxvuAs,46
24
+ lntools/l1/graph/graph.py,sha256=ZQw2dpd2Q1BQZ04VUuloeGMC31X1bzyvyyhik5AUZZ0,3844
25
+ lntools/l1/layer_init_writer/__init__.py,sha256=6e_pTnEi-Kd1L2HwBlAp1vlwXo4fbZUYopQ24TKVSwM,78
26
+ lntools/l1/layer_init_writer/layer_init_writer.py,sha256=1rCvEtRPqKbowPVhAwlwzOaaEwxZehQWWf-XxjsQgEc,1214
27
+ lntools/l1/scanner/__init__.py,sha256=JMVLGeXHlkuCciQ-Mi32Zp1C-WvOzVQgue--6anpOz4,52
28
+ lntools/l1/scanner/scanner.py,sha256=viqZQCnKzU2OSutLGMKqHWPadaMLnPjK82cd4KS68pw,9107
29
+ lntools/l2/__init__.py,sha256=8Xp6tLsvyH3k-_2B3wtIrVBSjGW_r_Ucf_X1d6bKVeg,337
30
+ lntools/l2/for-agent-layerinfo-l2.md,sha256=mWUHkWXnNN4Zu6m9KeGZZkimy_erfsuzgmiv9gkQDWE,1409
31
+ lntools/l2/blaster/__init__.py,sha256=wt8eYYTMTjJ1Ip1IBSD_MeAdjL3-7DpgR5b7GwDuzX8,52
32
+ lntools/l2/blaster/blaster.py,sha256=hk32__9AcGEf6FLytWb1iRIBms4258uCaks0rlc8KHg,812
33
+ lntools/l2/checker/__init__.py,sha256=gLr_3IVhANcZIuhdEm4LEKH34fpAJLoM6YNseBw8mCg,52
34
+ lntools/l2/checker/checker.py,sha256=KdCXxKI-umzqGW-weQbCyVuRlENjZBO26bCTAQUXMYM,1955
35
+ lntools/l2/doc_generator/__init__.py,sha256=rdXXqge7PIBm5aDrPv7tdcZS85wZ2DXMvItbFu5Vfn4,68
36
+ lntools/l2/doc_generator/doc_generator.py,sha256=VLVqU1yIanHDyJ5_8mCYaqsl0f3B4DXcXQrIotOqqI4,8899
37
+ lntools/l3/__init__.py,sha256=lfxGqNpG2WTaFKor5BCuCY-__Zxlk3_W2rB-AcasuMQ,303
38
+ lntools/l3/for-agent-layerinfo-l3.md,sha256=wIIrIzbapV0h2foZrSaBVStzrUuXOQ_OqebN6R56QI4,409
39
+ lntools/l3/hook_runner/__init__.py,sha256=d7czPFX_eITQeKwYTS3XwMiba8CwvElBelKzY-Vdkl0,62
40
+ lntools/l3/hook_runner/hook_runner.py,sha256=clfYcSMupGmpnvR-NPe_sD8VELYcGtrtiVOp6YtMy6g,2839
41
+ lntools/l3/mover/__init__.py,sha256=fWMZu5zVpQR_o4bPy2_DZmjokOXJguZK7miXZU-4NhI,46
42
+ lntools/l3/mover/mover.py,sha256=j37O5xXRl_mIW7SHCnfRKasn5NKewyyoJDqZPXPSeq0,8035
43
+ lntools/l4/__init__.py,sha256=fKrHbvAcQmjfJTkOVx6CwBv9YUwvhRqP5fnUjcO8dCY,285
44
+ lntools/l4/for-agent-layerinfo-l4.md,sha256=S299PDuKfdxdSe6Y_enCOWagEgSEHAHbh2XeZoGyvCw,209
45
+ lntools/l4/cli/__init__.py,sha256=5QEV2-vr3YIUyICon_fYBCK1ohgJQbMGRWx3Ca74uFk,54
46
+ lntools/l4/cli/cli.py,sha256=tmfm-0nGChOEnOGIDju1YGIuPURgsTqzp8b6QkyxV8I,5479
47
+ lntools/protocol/CLAUDE-template.md,sha256=i2c9RO9lfD6ih_ncPXilHlXdI2jYputhpWC-39euo5I,537
48
+ lntools/protocol/SKILL-template.md,sha256=rm0UxGTpZhGT-czGCuLPu4hcKaNEU8SnCno9h6M26lg,1053
49
+ lntools/protocol/for-agent-codingprotocol-ln-structure.md,sha256=JCMIHF-7-W1lob-nxlso5sJ1Cqe-E_b4Ir4NPq4pUdg,11152
50
+ lntools/protocol/for-agent-codingprotocol-python.md,sha256=14u_vIK0JV6hDIEs100Wqu9KpurlQKVjh5FxGFn8LCk,2378
51
+ lntools/protocol/for-agent-layerinfo-ln-template.md,sha256=Oteao1f_KaXrX0cJO7zK6Oz7Hi0PMOWaGDT3e9m03cI,3284
52
+ lntools/protocol/for-agent-layerinfo-template.md,sha256=ukn8rzubQ4AC1mwBMCbfmbd5B-dKPtcCWn_3sb6sgII,1296
53
+ lntools/protocol/for-agent-moduleinfo-template.md,sha256=hPhNOHeGMtI2q5YewwFf5ANyCuZsaRbL-0Op5pBUjZ0,2814
54
+ lntools/protocol/settings-hooks-template.json,sha256=rbZrBQ-xmAqpAgOH8mLlYppJKae1xnoM4WMoHHcLdks,434
55
+ ff_lntools-0.1.0.dist-info/METADATA,sha256=JS34FGxN9Nr_Wh1rvApoI9nWLB1JmHqOilJOQ6t4BWI,3961
56
+ ff_lntools-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
57
+ ff_lntools-0.1.0.dist-info/entry_points.txt,sha256=Z5E1LExTtwFPTIAbGOOJPX4carO2wItrcKzdJrqV90M,44
58
+ ff_lntools-0.1.0.dist-info/top_level.txt,sha256=gSjFhkTIgi4WVkojB2fhnSpncploMRdDg7AxiIDsJ60,8
59
+ ff_lntools-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lnt = lntools.l4.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gatesplan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ lntools
lntools/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .l4.cli import main
2
+
3
+ __all__ = ["main"]
lntools/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .l4.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ # for-agent-layerinfo.md
2
+
3
+ <!-- lnt:generated:start -->
4
+ ## l0
5
+ - edge: 모듈 간 의존 간선. kind runtime/type_only/inherits, 표면 초과 경로 extra
6
+ - initializer: lnt init. 프로토콜 문서 복사, CLAUDE.md, 훅 병합, 스킬 설치
7
+ - module_ref: ln 모듈 하나. 이름, 스코프, 선언 층, 파일 목록, 외부 의존 여부
8
+ - project_layout: 프로젝트 루트와 src/<pkg> 탐색. LAYER_RE
9
+ - raw_import: 해석 전 import 문 하나. target, names, kind, external
10
+ - signature_extractor: 모듈 __init__ 공개 이름과 클래스 메서드 시그니처를 ast 로 추출
11
+ - violation: 규칙 위반 C1~C4 레코드와 포맷
12
+
13
+ ## l1
14
+ - graph: 모듈 그래프. 계산 층, 역의존, 인터페이스 경유 blast, 순환 탐지
15
+ - layer_init_writer: 층 __init__.py 를 PEP 562 지연 re-export 형태로 생성
16
+ - scanner: src/<pkg> 를 훑어 모듈과 간선 생성. 상대/절대/층 단위 import 해석, TYPE_CHECKING 구분
17
+
18
+ ## l2
19
+ - blaster: blast 결과 텍스트 보고서
20
+ - checker: C1 방향, C2 표면, C3 층 일치, C4 순환 검사
21
+ - doc_generator: layerinfo/layerinfo-ln 생성과 검사, moduleinfo sources hash stamp/stale
22
+
23
+ ## l3
24
+ - hook_runner: Claude Code 훅 진입. stdin JSON 해석, exit 2 / additionalContext 채널 선택
25
+ - mover: lnt move. 모듈 층 이동, import 재작성, tests 미러, 층 __init__ 와 문서 재생성
26
+
27
+ ## l4
28
+ - cli: lnt 명령줄. check, blast, map, doc, hook
29
+ <!-- lnt:generated:end -->
30
+
31
+ ## Notes
32
+
33
+ 의존은 항상 아래로만. l4 cli 가 진입점이며 모든 하위를 조립한다.
lntools/l0/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ import importlib
2
+
3
+ # 공개 이름 -> 모듈 폴더명. 지연 로드
4
+ _EXPORTS = {
5
+ "Edge": "edge",
6
+ "Initializer": "initializer",
7
+ "LAYER_RE": "project_layout",
8
+ "PROTOCOL_FILES": "initializer",
9
+ "ModuleRef": "module_ref",
10
+ "ProjectLayout": "project_layout",
11
+ "RawImport": "raw_import",
12
+ "SignatureExtractor": "signature_extractor",
13
+ "Violation": "violation",
14
+ }
15
+ __all__ = list(_EXPORTS)
16
+
17
+
18
+ def __getattr__(name: str):
19
+ if name in _EXPORTS:
20
+ mod = importlib.import_module(f".{_EXPORTS[name]}", __name__)
21
+ return getattr(mod, name)
22
+ raise AttributeError(name)
@@ -0,0 +1,3 @@
1
+ from .edge import Edge
2
+
3
+ __all__ = ["Edge"]
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ # 모듈 간 의존 간선. import 문 하나가 간선 하나
6
+ @dataclass
7
+ class Edge:
8
+ src: str # 출발 모듈 이름
9
+ dst: str | None # 도착 모듈 이름. 층 단위 import에서 이름을 못 풀면 None
10
+ dst_layer: int # 도착 층 번호
11
+ kind: str # runtime | type_only | inherits
12
+ file: Path # import 문이 있는 파일
13
+ line: int
14
+ target: str # import 문에 쓰인 점 경로 (패키지명 제외)
15
+ extra: str = "" # 도착 모듈 표면을 지나 들어간 나머지 경로. 비어있지 않으면 표면 위반
16
+
17
+ @property
18
+ def counts_for_layer(self) -> bool:
19
+ # 층 계산에 포함되는 간선. 상속 간선은 이미 runtime 간선이 따로 있으므로 제외
20
+ return self.kind in ("runtime", "type_only")
@@ -0,0 +1,40 @@
1
+ # l0
2
+
3
+ <!-- lnt:generated:start -->
4
+ ## edge
5
+ Edge.counts_for_layer() -> bool # edge.py
6
+
7
+ ## initializer
8
+ Initializer.__init__(project_root: Path, home: Path | None=None) # initializer.py
9
+ Initializer.run() -> list[str] # initializer.py
10
+ Initializer.merge_hooks() -> str # initializer.py
11
+ Initializer.install_skill() -> Path # initializer.py
12
+ PROTOCOL_FILES # 정의를 찾지 못함
13
+
14
+ ## module_ref
15
+ ModuleRef.basename() -> str # module_ref.py
16
+ ModuleRef.layer_name() -> str # module_ref.py
17
+ ModuleRef.contains(other: str) -> bool # module_ref.py
18
+
19
+ ## project_layout
20
+ ProjectLayout.__init__(project_root: Path, package_root: Path) # project_layout.py
21
+ ProjectLayout.package_name() -> str # project_layout.py
22
+ ProjectLayout.src_dir() -> Path # project_layout.py
23
+ ProjectLayout.has_layers(d: Path) -> bool # project_layout.py
24
+ ProjectLayout.find(start: Path) -> 'ProjectLayout | None' # project_layout.py
25
+ ProjectLayout.relative(path: Path) -> str # project_layout.py
26
+ LAYER_RE # 정의를 찾지 못함
27
+
28
+ ## raw_import
29
+ RawImport # raw_import.py (공개 메서드 없음)
30
+
31
+ ## signature_extractor
32
+ SignatureExtractor.exports_of(module_dir: Path) -> list[str] # signature_extractor.py
33
+ SignatureExtractor.extract(module_dir: Path, exported: list[str]) -> list[str] # signature_extractor.py
34
+
35
+ ## violation
36
+ Violation.format(base: Path | None=None) -> str # violation.py
37
+ <!-- lnt:generated:end -->
38
+
39
+ ## Notes
40
+
@@ -0,0 +1,3 @@
1
+ from .initializer import Initializer, PROTOCOL_FILES
2
+
3
+ __all__ = ["Initializer", "PROTOCOL_FILES"]
@@ -0,0 +1,63 @@
1
+ import json
2
+ from importlib import resources
3
+ from pathlib import Path
4
+
5
+ PROTOCOL_FILES = [
6
+ "for-agent-codingprotocol-ln-structure.md",
7
+ "for-agent-codingprotocol-python.md",
8
+ "for-agent-layerinfo-template.md",
9
+ "for-agent-layerinfo-ln-template.md",
10
+ "for-agent-moduleinfo-template.md",
11
+ ]
12
+
13
+
14
+ # 프로젝트에 코딩 프로토콜을 설치한다. 문서 복사, CLAUDE.md, 훅 병합, 스킬 설치
15
+ class Initializer:
16
+ def __init__(self, project_root: Path, home: Path | None = None):
17
+ self.project_root = project_root
18
+ self.home = home if home is not None else Path.home()
19
+ self.claude_dir = project_root / ".claude"
20
+
21
+ @staticmethod
22
+ def _protocol(name: str) -> str:
23
+ return (resources.files("lntools") / "protocol" / name).read_text(encoding="utf-8")
24
+
25
+ # 반환: 한 일의 설명 줄 목록
26
+ def run(self) -> list[str]:
27
+ done: list[str] = []
28
+ self.claude_dir.mkdir(parents=True, exist_ok=True)
29
+ for name in PROTOCOL_FILES:
30
+ (self.claude_dir / name).write_text(self._protocol(name), encoding="utf-8")
31
+ done.append(f".claude/{name}")
32
+ claude_md = self.project_root / "CLAUDE.md"
33
+ if claude_md.is_file():
34
+ done.append("CLAUDE.md 이미 존재 (건너뜀)")
35
+ else:
36
+ claude_md.write_text(self._protocol("CLAUDE-template.md"), encoding="utf-8")
37
+ done.append("CLAUDE.md 생성")
38
+ done.append(self.merge_hooks())
39
+ return done
40
+
41
+ # settings.json 의 hooks 에 템플릿 훅을 합친다. 같은 command 가 이미 있으면 건너뛴다
42
+ def merge_hooks(self) -> str:
43
+ tpl = json.loads(self._protocol("settings-hooks-template.json"))
44
+ dst = self.claude_dir / "settings.json"
45
+ cur = json.loads(dst.read_text(encoding="utf-8")) if dst.is_file() else {}
46
+ hooks = cur.setdefault("hooks", {})
47
+ added = 0
48
+ for event, groups in tpl["hooks"].items():
49
+ existing = hooks.setdefault(event, [])
50
+ have = {h.get("command") for g in existing for h in g.get("hooks", [])}
51
+ for g in groups:
52
+ if not any(h.get("command") in have for h in g["hooks"]):
53
+ existing.append(g)
54
+ added += 1
55
+ dst.write_text(json.dumps(cur, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
56
+ return f".claude/settings.json 훅 {added}개 추가" if added else ".claude/settings.json 훅 이미 있음"
57
+
58
+ # ~/.claude/skills/init-protocol/SKILL.md 설치
59
+ def install_skill(self) -> Path:
60
+ p = self.home / ".claude" / "skills" / "init-protocol" / "SKILL.md"
61
+ p.parent.mkdir(parents=True, exist_ok=True)
62
+ p.write_text(self._protocol("SKILL-template.md"), encoding="utf-8")
63
+ return p
@@ -0,0 +1,3 @@
1
+ from .module_ref import ModuleRef
2
+
3
+ __all__ = ["ModuleRef"]
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+
5
+ # ln-structure 모듈 하나. 이름은 패키지 루트 기준 점 경로 (예: l1.order, l3.portfolio.l1.store)
6
+ @dataclass
7
+ class ModuleRef:
8
+ name: str
9
+ scope: str # 소속 스코프. 루트는 "", 중첩 모듈 내부는 감싸는 모듈 이름
10
+ layer: int # 선언 층 (디렉토리 번호)
11
+ path: Path # 모듈 디렉토리
12
+ files: list[Path] = field(default_factory=list) # 모듈에 속한 모든 .py (중첩 내부 포함)
13
+ has_external: bool = False # 표준 라이브러리 외 패키지 import 여부
14
+ is_nested: bool = False # 내부에 lN 디렉토리를 가지는지
15
+
16
+ @property
17
+ def basename(self) -> str:
18
+ return self.name.rsplit(".", 1)[-1]
19
+
20
+ @property
21
+ def layer_name(self) -> str:
22
+ # 소속 층의 이름. 예: l1, l3.portfolio.l1
23
+ return self.name.rsplit(".", 1)[0]
24
+
25
+ def contains(self, other: str) -> bool:
26
+ return other.startswith(self.name + ".")
@@ -0,0 +1,3 @@
1
+ from .project_layout import ProjectLayout, LAYER_RE
2
+
3
+ __all__ = ["ProjectLayout", "LAYER_RE"]
@@ -0,0 +1,51 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ LAYER_RE = re.compile(r"^l(\d+)$")
5
+
6
+
7
+ # 프로젝트 루트와 패키지 루트(src/<pkg>) 위치
8
+ class ProjectLayout:
9
+ def __init__(self, project_root: Path, package_root: Path):
10
+ self.project_root = project_root.resolve()
11
+ self.package_root = package_root.resolve()
12
+
13
+ @property
14
+ def package_name(self) -> str:
15
+ return self.package_root.name
16
+
17
+ @property
18
+ def src_dir(self) -> Path:
19
+ return self.package_root.parent
20
+
21
+ @staticmethod
22
+ def has_layers(d: Path) -> bool:
23
+ if not d.is_dir():
24
+ return False
25
+ return any(c.is_dir() and LAYER_RE.match(c.name) for c in d.iterdir())
26
+
27
+ # start(파일 또는 디렉토리)에서 위로 올라가며 src/<pkg>/lN 을 가진 프로젝트를 찾는다
28
+ @classmethod
29
+ def find(cls, start: Path) -> "ProjectLayout | None":
30
+ start = start.resolve()
31
+ if start.is_file():
32
+ start = start.parent
33
+ for p in [start, *start.parents]:
34
+ src = p / "src"
35
+ if not src.is_dir():
36
+ continue
37
+ candidates = [c for c in sorted(src.iterdir()) if cls.has_layers(c)]
38
+ if not candidates:
39
+ continue
40
+ # 시작 위치를 포함하는 패키지 우선
41
+ for c in candidates:
42
+ if start == c or c in start.parents:
43
+ return cls(p, c)
44
+ return cls(p, candidates[0])
45
+ return None
46
+
47
+ def relative(self, path: Path) -> str:
48
+ try:
49
+ return path.resolve().relative_to(self.project_root).as_posix()
50
+ except ValueError:
51
+ return path.as_posix()
@@ -0,0 +1,3 @@
1
+ from .raw_import import RawImport
2
+
3
+ __all__ = ["RawImport"]
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ # 파일 하나에서 뽑은 import 문 하나. 아직 모듈로 해석되기 전 상태
5
+ @dataclass
6
+ class RawImport:
7
+ target: str | None # 패키지명을 포함한 절대 점 경로. None: 패키지 밖이거나 해석 불가
8
+ names: list[str] # from X import a, b 의 a, b. import X 면 빈 목록
9
+ kind: str # runtime | type_only | inherits
10
+ line: int
11
+ external: bool = False # 표준 라이브러리가 아닌 외부 패키지
@@ -0,0 +1,3 @@
1
+ from .signature_extractor import SignatureExtractor
2
+
3
+ __all__ = ["SignatureExtractor"]
@@ -0,0 +1,78 @@
1
+ import ast
2
+ from pathlib import Path
3
+
4
+
5
+ # 모듈 디렉토리에서 공개 이름과 그 시그니처를 ast로 뽑는다
6
+ class SignatureExtractor:
7
+ # 모듈 __init__.py의 공개 이름. __all__ 우선, 없으면 from .x import Y 의 Y
8
+ def exports_of(self, module_dir: Path) -> list[str]:
9
+ init = module_dir / "__init__.py"
10
+ if not init.is_file():
11
+ return []
12
+ tree = ast.parse(init.read_text(encoding="utf-8"), filename=str(init))
13
+ names: list[str] = []
14
+ for node in tree.body:
15
+ if isinstance(node, ast.Assign):
16
+ for t in node.targets:
17
+ if isinstance(t, ast.Name) and t.id == "__all__":
18
+ return [e.value for e in getattr(node.value, "elts", []) if isinstance(e, ast.Constant) and isinstance(e.value, str)]
19
+ if isinstance(node, ast.ImportFrom) and node.level >= 1:
20
+ names.extend(a.asname or a.name for a in node.names)
21
+ return names
22
+
23
+ # 공개 이름 각각의 시그니처 줄. 클래스는 Class.method(...) -> ret, 함수는 name(...) -> ret
24
+ # 각 줄 끝에 정의가 있는 파일을 모듈 디렉토리 기준으로 붙인다
25
+ def extract(self, module_dir: Path, exported: list[str]) -> list[str]:
26
+ defs = self._collect_defs(module_dir)
27
+ lines: list[str] = []
28
+ for name in exported:
29
+ found = defs.get(name)
30
+ if found is None:
31
+ lines.append(f"{name} # 정의를 찾지 못함")
32
+ continue
33
+ node, rel = found
34
+ if isinstance(node, ast.ClassDef):
35
+ lines.extend(self._class_lines(node, rel))
36
+ else:
37
+ lines.append(self._func_line(node, prefix="", drop_self=False, rel=rel))
38
+ return lines
39
+
40
+ def _collect_defs(self, module_dir: Path) -> dict[str, tuple[ast.AST, str]]:
41
+ defs: dict[str, tuple[ast.AST, str]] = {}
42
+ for f in sorted(module_dir.rglob("*.py")):
43
+ if f.name == "__init__.py":
44
+ continue
45
+ try:
46
+ tree = ast.parse(f.read_text(encoding="utf-8"), filename=str(f))
47
+ except SyntaxError:
48
+ continue
49
+ rel = f.relative_to(module_dir).as_posix()
50
+ for node in tree.body:
51
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
52
+ defs.setdefault(node.name, (node, rel))
53
+ return defs
54
+
55
+ def _class_lines(self, cls: ast.ClassDef, rel: str) -> list[str]:
56
+ out: list[str] = []
57
+ for node in cls.body:
58
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
59
+ continue
60
+ if node.name.startswith("_") and node.name != "__init__":
61
+ continue
62
+ out.append(self._func_line(node, prefix=cls.name + ".", drop_self=True, rel=rel))
63
+ if not out:
64
+ out.append(f"{cls.name} # {rel} (공개 메서드 없음)")
65
+ return out
66
+
67
+ def _func_line(self, fn: ast.AST, prefix: str, drop_self: bool, rel: str = "") -> str:
68
+ args = ast.unparse(fn.args)
69
+ if drop_self:
70
+ if args == "self" or args == "cls":
71
+ args = ""
72
+ elif args.startswith("self, "):
73
+ args = args[6:]
74
+ elif args.startswith("cls, "):
75
+ args = args[5:]
76
+ ret = f" -> {ast.unparse(fn.returns)}" if fn.returns is not None else ""
77
+ where = f" # {rel}" if rel else ""
78
+ return f"{prefix}{fn.name}({args}){ret}{where}"
@@ -0,0 +1,3 @@
1
+ from .violation import Violation
2
+
3
+ __all__ = ["Violation"]
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ # 규칙 위반 하나. code는 C1(방향) C2(표면) C3(층 일치) C4(순환)
6
+ @dataclass
7
+ class Violation:
8
+ code: str
9
+ module: str
10
+ file: Path
11
+ line: int
12
+ message: str
13
+
14
+ def format(self, base: Path | None = None) -> str:
15
+ p = self.file
16
+ if base is not None:
17
+ try:
18
+ p = p.relative_to(base)
19
+ except ValueError:
20
+ pass
21
+ loc = f"{p.as_posix()}:{self.line}" if self.line > 0 else p.as_posix()
22
+ return f"[{self.code}] {self.module} ({loc}) {self.message}"
lntools/l1/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ import importlib
2
+
3
+ _EXPORTS = {
4
+ "Graph": "graph",
5
+ "LayerInitWriter": "layer_init_writer",
6
+ "Scanner": "scanner",
7
+ }
8
+ __all__ = list(_EXPORTS)
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name in _EXPORTS:
13
+ mod = importlib.import_module(f".{_EXPORTS[name]}", __name__)
14
+ return getattr(mod, name)
15
+ raise AttributeError(name)
@@ -0,0 +1,25 @@
1
+ # l1
2
+
3
+ <!-- lnt:generated:start -->
4
+ ## graph
5
+ Graph.__init__(modules: dict[str, ModuleRef], edges: list[Edge]) # graph.py
6
+ Graph.modules_of(file: Path) -> list[ModuleRef] # graph.py
7
+ Graph.dependencies(name: str) -> list[Edge] # graph.py
8
+ Graph.dependents(name: str, kinds: tuple[str, ...]=('runtime', 'type_only')) -> list[str] # graph.py
9
+ Graph.interfaces_of(name: str) -> list[str] # graph.py
10
+ Graph.computed_layer(name: str) -> int # graph.py
11
+ Graph.blast(name: str, depth: int=1) -> list[tuple[str, str]] # graph.py
12
+ Graph.cycles() -> list[list[str]] # graph.py
13
+
14
+ ## layer_init_writer
15
+ LayerInitWriter.__init__() # layer_init_writer.py
16
+ LayerInitWriter.render(modules: list[ModuleRef]) -> str # layer_init_writer.py
17
+ LayerInitWriter.write(layer_dir: Path, modules: list[ModuleRef]) -> Path # layer_init_writer.py
18
+
19
+ ## scanner
20
+ Scanner.__init__(layout: ProjectLayout) # scanner.py
21
+ Scanner.scan() -> tuple[dict[str, ModuleRef], list[Edge]] # scanner.py
22
+ <!-- lnt:generated:end -->
23
+
24
+ ## Notes
25
+