anatomize 0.2.0__tar.gz → 0.2.1__tar.gz

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 (93) hide show
  1. {anatomize-0.2.0 → anatomize-0.2.1}/.github/workflows/ci.yml +3 -3
  2. {anatomize-0.2.0 → anatomize-0.2.1}/.gitignore +4 -2
  3. {anatomize-0.2.0 → anatomize-0.2.1}/CHANGELOG.md +15 -0
  4. {anatomize-0.2.0 → anatomize-0.2.1}/PKG-INFO +20 -10
  5. {anatomize-0.2.0 → anatomize-0.2.1}/README.md +19 -9
  6. {anatomize-0.2.0 → anatomize-0.2.1}/docs/GUIDE.md +16 -4
  7. {anatomize-0.2.0 → anatomize-0.2.1}/docs/REPOMIX_VS_ANATOMIZE.md +1 -1
  8. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/__init__.py +1 -1
  9. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/cli.py +49 -7
  10. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/config.py +10 -1
  11. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/exclude.py +26 -3
  12. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/formats/json_fmt.py +1 -1
  13. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/formats/markdown_fmt.py +1 -1
  14. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/formats/yaml_fmt.py +1 -1
  15. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/generators/main.py +6 -1
  16. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/deps.py +17 -1
  17. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/discovery.py +51 -2
  18. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/formats.py +52 -37
  19. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/ignore.py +14 -7
  20. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/jsonl.py +1 -3
  21. anatomize-0.2.1/src/anatomize/pack/overview.py +41 -0
  22. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/runner.py +241 -19
  23. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/version.py +1 -1
  24. anatomize-0.2.1/tests/e2e/test_pack_explain_selection.py +113 -0
  25. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_pack_hybrid_cli.py +29 -14
  26. anatomize-0.2.1/tests/unit/test_pack_binary_sniff.py +47 -0
  27. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_discovery.py +29 -0
  28. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_overview.py +2 -6
  29. anatomize-0.2.1/tests/unit/test_pack_prefix_style.py +44 -0
  30. anatomize-0.2.0/src/anatomize/pack/overview.py +0 -134
  31. {anatomize-0.2.0 → anatomize-0.2.1}/.gitattributes +0 -0
  32. {anatomize-0.2.0 → anatomize-0.2.1}/.github/workflows/publish-pypi.yml +0 -0
  33. {anatomize-0.2.0 → anatomize-0.2.1}/.github/workflows/publish-testpypi.yml +0 -0
  34. {anatomize-0.2.0 → anatomize-0.2.1}/CONTRIBUTING.md +0 -0
  35. {anatomize-0.2.0 → anatomize-0.2.1}/LICENSE +0 -0
  36. {anatomize-0.2.0 → anatomize-0.2.1}/SKILL.md +0 -0
  37. {anatomize-0.2.0 → anatomize-0.2.1}/pyproject.toml +0 -0
  38. {anatomize-0.2.0 → anatomize-0.2.1}/scripts/bench_pack.py +0 -0
  39. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/__init__.py +0 -0
  40. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/discovery.py +0 -0
  41. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/extractor.py +0 -0
  42. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/parser.py +0 -0
  43. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/policy.py +0 -0
  44. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/core/types.py +0 -0
  45. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/formats/__init__.py +0 -0
  46. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/formats/payloads.py +0 -0
  47. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/generators/__init__.py +0 -0
  48. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/__init__.py +0 -0
  49. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/compress.py +0 -0
  50. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/limit.py +0 -0
  51. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/match.py +0 -0
  52. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/mode.py +0 -0
  53. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/pyright_lsp.py +0 -0
  54. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/representations.py +0 -0
  55. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/slicing.py +0 -0
  56. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/summaries.py +0 -0
  57. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/tokens.py +0 -0
  58. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/tree.py +0 -0
  59. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/pack/uses.py +0 -0
  60. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/py.typed +0 -0
  61. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/schemas/__init__.py +0 -0
  62. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/schemas/hierarchy.schema.json +0 -0
  63. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/schemas/module.schema.json +0 -0
  64. {anatomize-0.2.0 → anatomize-0.2.1}/src/anatomize/validation.py +0 -0
  65. {anatomize-0.2.0 → anatomize-0.2.1}/tests/README.md +0 -0
  66. {anatomize-0.2.0 → anatomize-0.2.1}/tests/__init__.py +0 -0
  67. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_cli.py +0 -0
  68. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_init.py +0 -0
  69. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_pack_cli.py +0 -0
  70. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_pack_pyright_uses.py +0 -0
  71. {anatomize-0.2.0 → anatomize-0.2.1}/tests/e2e/test_pack_removed_flags.py +0 -0
  72. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/project_src/src/excluded/skip.py +0 -0
  73. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/project_src/src/ns_pkg/sub/mod.py +0 -0
  74. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/project_src/src/pkg/__init__.py +0 -0
  75. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/project_src/src/pkg/mod.py +0 -0
  76. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/project_src/src/top_level.py +0 -0
  77. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/sample_package/__init__.py +0 -0
  78. {anatomize-0.2.0 → anatomize-0.2.1}/tests/fixtures/sample_package/models.py +0 -0
  79. {anatomize-0.2.0 → anatomize-0.2.1}/tests/integration/__init__.py +0 -0
  80. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/__init__.py +0 -0
  81. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_config.py +0 -0
  82. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_discovery.py +0 -0
  83. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_exclude.py +0 -0
  84. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_extractor.py +0 -0
  85. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_formats.py +0 -0
  86. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_generator.py +0 -0
  87. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_deps.py +0 -0
  88. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_hybrid.py +0 -0
  89. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_limit.py +0 -0
  90. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pack_output_safety.py +0 -0
  91. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_parser.py +0 -0
  92. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_pyright_lsp_uri.py +0 -0
  93. {anatomize-0.2.0 → anatomize-0.2.1}/tests/unit/test_uses_symbol_positions.py +0 -0
@@ -48,8 +48,8 @@ jobs:
48
48
  . .venv-smoke/bin/activate
49
49
  python -m pip install -U pip
50
50
  python -m pip install dist/*.whl
51
- anatomize generate tests/fixtures/project_src/src --output .skeleton-smoke -f json
52
- anatomize validate .skeleton-smoke --source tests/fixtures/project_src/src
51
+ anatomize generate tests/fixtures/project_src/src --output .anatomy-smoke -f json
52
+ anatomize validate .anatomy-smoke --source tests/fixtures/project_src/src
53
53
 
54
54
  pyright-e2e:
55
55
  runs-on: ubuntu-latest
@@ -69,7 +69,7 @@ jobs:
69
69
  - name: Install Pyright
70
70
  run: |
71
71
  npm i -g pyright
72
- pyright --version
72
+ command -v pyright
73
73
  command -v pyright-langserver
74
74
 
75
75
  - name: Install
@@ -16,8 +16,10 @@ dist/
16
16
  build/
17
17
  *.egg-info/
18
18
 
19
- .skeleton/
20
- .skeleton-*/
19
+ .anatomy/
20
+ .anatomy-*/
21
+ .runtime/
22
+ .runtime-*/
21
23
 
22
24
  .DS_Store
23
25
  Thumbs.db
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.2.1 - 2026-02-01
6
+
7
+ ### Added
8
+ - `anatomize pack --prefix standard|minimal` to control pack prefix verbosity (token overhead vs guidance).
9
+ - `anatomize pack --explain-selection` to write a deterministic selection report for debugging include/ignore/slice behavior.
10
+
11
+ ### Changed
12
+ - Hybrid mode now supports markdown/plain output (JSONL is optional, and required only for `--fit-to-max-output`).
13
+ - Pack ignore rules now retain rule provenance (default vs standard ignore files vs CLI) for selection reporting.
14
+ - Binary sniffing reads only a small prefix for performance (instead of reading entire files).
15
+ - Dependency-closure failures now include an import chain for faster debugging.
16
+
17
+ ### Infrastructure
18
+ - CI Pyright job avoids invoking commands that require an LSP transport without `--stdio`.
19
+
5
20
  ## 0.2.0 - 2026-02-01
6
21
 
7
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anatomize
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Deterministic, token-efficient codebase packs and skeleton maps for AI review (Python)
5
5
  Project-URL: Homepage, https://github.com/BradSegal/anatomize
6
6
  Project-URL: Documentation, https://github.com/BradSegal/anatomize#readme
@@ -114,6 +114,13 @@ anatomize pack . --output codebase.md
114
114
  # Full bundle
115
115
  anatomize pack . --format markdown --output codebase.md
116
116
 
117
+ # Minimal prefix (lower token overhead)
118
+ anatomize pack . --prefix minimal --output codebase.md
119
+
120
+ # Explain selection (why files were included/excluded)
121
+ # (writes `codebase.md.selection.json` by default)
122
+ anatomize pack . --explain-selection --output codebase.md
123
+
117
124
  # Filter by globs
118
125
  anatomize pack . --include "src/**" --ignore "**/__pycache__/**" --output src-only.md
119
126
 
@@ -147,13 +154,13 @@ anatomize pack . --token-count-tree --output codebase.md
147
154
  # JSONL (stream-friendly)
148
155
  anatomize pack . --format jsonl --output codebase.jsonl
149
156
 
150
- # Hybrid mode (skeleton-style summaries + selective fill)
151
- # - defaults to JSONL when --mode hybrid is set
157
+ # Hybrid mode (summaries + selective fill; token-efficient)
158
+ # - defaults to markdown when --format and the output extension are not specified
152
159
  # - Python files default to summary; non-Python defaults to metadata-only
153
- anatomize pack . --mode hybrid --output hybrid.jsonl
160
+ anatomize pack . --mode hybrid --output hybrid.md
154
161
 
155
- # Hybrid: include full content for a slice and fit within a hard token budget
156
- anatomize pack . --mode hybrid --max-output 50_000t --fit-to-max-output \
162
+ # Hybrid: include full content for a slice and fit within a hard token budget (JSONL only)
163
+ anatomize pack . --mode hybrid --format jsonl --max-output 50_000t --fit-to-max-output \
157
164
  --content "src/pkg/**" --output hybrid.slice.jsonl
158
165
  ```
159
166
 
@@ -219,8 +226,9 @@ symlinks: forbid # forbid|files|dirs|all
219
226
  workers: 0 # 0 = auto
220
227
 
221
228
  pack:
222
- format: markdown # markdown|plain|json|xml|jsonl
223
- mode: bundle # bundle|hybrid
229
+ format: markdown # markdown|plain|json|xml|jsonl (hybrid supports markdown|plain|jsonl)
230
+ mode: bundle # bundle|hybrid (hybrid is token-efficient summaries + selective fill)
231
+ prefix: standard # standard|minimal
224
232
  output: anatomize-pack.md # if the extension is known, it must match `format`
225
233
  include: []
226
234
  ignore: []
@@ -281,8 +289,10 @@ When `anatomize generate` runs from `.anatomize.yaml`, it writes one skeleton di
281
289
  Each pack artifact starts with a lightweight, deterministic overview (and, if enabled, a structure tree) before file blocks/records.
282
290
 
283
291
  Token reporting:
284
- - **Artifact tokens**: exact tokens of the written output file(s).
285
- - **Content tokens**: tokens of file contents only (useful for budgeting and “what’s expensive”).
292
+ - **Artifact tokens**: exact tokens of the written output file(s) (returned by the Python API).
293
+ - **Content tokens**: tokens of file contents only (returned by the Python API; useful for budgeting).
294
+
295
+ Pack artifacts intentionally do **not** embed token counts (agents don’t need them; they waste tokens).
286
296
 
287
297
  ---
288
298
 
@@ -75,6 +75,13 @@ anatomize pack . --output codebase.md
75
75
  # Full bundle
76
76
  anatomize pack . --format markdown --output codebase.md
77
77
 
78
+ # Minimal prefix (lower token overhead)
79
+ anatomize pack . --prefix minimal --output codebase.md
80
+
81
+ # Explain selection (why files were included/excluded)
82
+ # (writes `codebase.md.selection.json` by default)
83
+ anatomize pack . --explain-selection --output codebase.md
84
+
78
85
  # Filter by globs
79
86
  anatomize pack . --include "src/**" --ignore "**/__pycache__/**" --output src-only.md
80
87
 
@@ -108,13 +115,13 @@ anatomize pack . --token-count-tree --output codebase.md
108
115
  # JSONL (stream-friendly)
109
116
  anatomize pack . --format jsonl --output codebase.jsonl
110
117
 
111
- # Hybrid mode (skeleton-style summaries + selective fill)
112
- # - defaults to JSONL when --mode hybrid is set
118
+ # Hybrid mode (summaries + selective fill; token-efficient)
119
+ # - defaults to markdown when --format and the output extension are not specified
113
120
  # - Python files default to summary; non-Python defaults to metadata-only
114
- anatomize pack . --mode hybrid --output hybrid.jsonl
121
+ anatomize pack . --mode hybrid --output hybrid.md
115
122
 
116
- # Hybrid: include full content for a slice and fit within a hard token budget
117
- anatomize pack . --mode hybrid --max-output 50_000t --fit-to-max-output \
123
+ # Hybrid: include full content for a slice and fit within a hard token budget (JSONL only)
124
+ anatomize pack . --mode hybrid --format jsonl --max-output 50_000t --fit-to-max-output \
118
125
  --content "src/pkg/**" --output hybrid.slice.jsonl
119
126
  ```
120
127
 
@@ -180,8 +187,9 @@ symlinks: forbid # forbid|files|dirs|all
180
187
  workers: 0 # 0 = auto
181
188
 
182
189
  pack:
183
- format: markdown # markdown|plain|json|xml|jsonl
184
- mode: bundle # bundle|hybrid
190
+ format: markdown # markdown|plain|json|xml|jsonl (hybrid supports markdown|plain|jsonl)
191
+ mode: bundle # bundle|hybrid (hybrid is token-efficient summaries + selective fill)
192
+ prefix: standard # standard|minimal
185
193
  output: anatomize-pack.md # if the extension is known, it must match `format`
186
194
  include: []
187
195
  ignore: []
@@ -242,8 +250,10 @@ When `anatomize generate` runs from `.anatomize.yaml`, it writes one skeleton di
242
250
  Each pack artifact starts with a lightweight, deterministic overview (and, if enabled, a structure tree) before file blocks/records.
243
251
 
244
252
  Token reporting:
245
- - **Artifact tokens**: exact tokens of the written output file(s).
246
- - **Content tokens**: tokens of file contents only (useful for budgeting and “what’s expensive”).
253
+ - **Artifact tokens**: exact tokens of the written output file(s) (returned by the Python API).
254
+ - **Content tokens**: tokens of file contents only (returned by the Python API; useful for budgeting).
255
+
256
+ Pack artifacts intentionally do **not** embed token counts (agents don’t need them; they waste tokens).
247
257
 
248
258
  ---
249
259
 
@@ -163,7 +163,7 @@ Hybrid mode is intended for token efficiency and navigability:
163
163
  - Python files default to `summary` (signatures/imports/line numbers).
164
164
  - Non-Python files default to `meta` (metadata only) unless explicitly summarized or included as content.
165
165
  - If `--format` is omitted, it is inferred from `--output` when the extension is known (`.md|.txt|.json|.xml|.jsonl`).
166
- - Hybrid output is **JSONL**. If `--format` is not provided, it defaults to JSONL when `--mode hybrid` is used. Non-`.jsonl` output paths are rejected.
166
+ - Hybrid supports **markdown**, **plain**, and **jsonl** output. If `--format` is not provided, it defaults to markdown when `--mode hybrid` is used and the output extension is unknown.
167
167
 
168
168
  ### Output formats
169
169
  - `--format markdown` (best for human review)
@@ -171,6 +171,11 @@ Hybrid mode is intended for token efficiency and navigability:
171
171
  - `--format json` / `--format xml` (best for machine processing)
172
172
  - `--format jsonl` (stream-friendly, line-delimited JSON)
173
173
 
174
+ ### Prefix style (token overhead vs guidance)
175
+ Control the amount of non-code guidance emitted at the top of markdown/plain pack artifacts:
176
+ - `--prefix standard` (default): includes a short “How to use” section.
177
+ - `--prefix minimal`: omits guidance and keeps only a compact, deterministic summary.
178
+
174
179
  ### Content encoding (robustness)
175
180
  File contents can break Markdown structure (e.g. embedded ``` fences). Control how content is emitted with:
176
181
  - `--content-encoding fence-safe` (default): picks a fence length that cannot occur in the content.
@@ -178,6 +183,11 @@ File contents can break Markdown structure (e.g. embedded ``` fences). Control h
178
183
 
179
184
  Note: Markdown output intentionally disallows `--content-encoding verbatim`.
180
185
 
186
+ ### Selection report (debugging file inclusion)
187
+ If you need to understand why paths were included or excluded, use:
188
+ - `--explain-selection` to write a deterministic selection report.
189
+ - `--explain-selection-output PATH` to choose where it is written.
190
+
181
191
  ### Hybrid representations (fill-in controls)
182
192
  In hybrid mode, each file record has an explicit representation:
183
193
  - `meta`: path/language/is_binary/size/content_tokens
@@ -197,7 +207,7 @@ Supported non-Python summaries:
197
207
  - Markdown: headings outline
198
208
 
199
209
  ### Fit-to-budget (explicit)
200
- Hybrid mode can deterministically fit within a hard cap:
210
+ Hybrid mode can deterministically fit within a hard cap (JSONL only):
201
211
  - `--max-output 50_000t --fit-to-max-output`
202
212
 
203
213
  If enabled, `pack` may downgrade representations (e.g. `summary` → `meta`) to satisfy `--max-output` and emits an auditable `selection_trace`.
@@ -205,8 +215,10 @@ If it cannot satisfy the cap, it fails.
205
215
 
206
216
  ### Token metrics
207
217
  `pack` reports:
208
- - **Artifact tokens**: exact tokens of the written output file(s).
209
- - **Content tokens**: tokens for file contents only (useful for budgeting per-file).
218
+ - **Artifact tokens**: exact tokens of the written output file(s) (returned by the Python API).
219
+ - **Content tokens**: tokens for file contents only (returned by the Python API; useful for budgeting per-file).
220
+
221
+ Pack artifacts intentionally do **not** embed token counts (agents don’t need them; they waste tokens).
210
222
 
211
223
  Encoding is controlled by:
212
224
  - `--token-encoding` (default: `cl100k_base`)
@@ -2,7 +2,7 @@
2
2
 
3
3
  ### Versions
4
4
  - `repomix`: 1.11.1
5
- - `anatomize`: 0.1.0
5
+ - `anatomize`: 0.2.1
6
6
  - `tiktoken`: 0.12.0
7
7
 
8
8
  ### Method
@@ -9,7 +9,7 @@ Example
9
9
  >>> from anatomize.formats import write_skeleton
10
10
  >>> gen = SkeletonGenerator(sources=["./src"])
11
11
  >>> skeleton = gen.generate(level="modules")
12
- >>> write_skeleton(skeleton, ".skeleton", formats=[OutputFormat.YAML])
12
+ >>> write_skeleton(skeleton, ".anatomy", formats=[OutputFormat.YAML])
13
13
  """
14
14
 
15
15
  from anatomize.core.types import (
@@ -23,7 +23,7 @@ from anatomize.core.policy import SymlinkPolicy
23
23
  from anatomize.core.types import ResolutionLevel
24
24
  from anatomize.formats import OutputFormat, write_skeleton
25
25
  from anatomize.generators.main import SkeletonGenerator
26
- from anatomize.pack.formats import ContentEncoding, PackFormat
26
+ from anatomize.pack.formats import ContentEncoding, PackFormat, PrefixStyle, default_output_path
27
27
  from anatomize.pack.limit import OutputLimit, parse_output_limit
28
28
  from anatomize.pack.mode import PackMode
29
29
  from anatomize.pack.slicing import SliceBackend
@@ -259,7 +259,7 @@ def generate(
259
259
  Examples
260
260
  --------
261
261
  anatomize generate ./src
262
- anatomize generate ./src --output .skeleton --level signatures
262
+ anatomize generate ./src --output .anatomy --level signatures
263
263
  anatomize generate ./src -f yaml -f json -f markdown
264
264
  """
265
265
  try:
@@ -530,8 +530,8 @@ def validate(
530
530
 
531
531
  Examples
532
532
  --------
533
- anatomize validate .skeleton --source ./src
534
- anatomize validate .skeleton -s ./src -s ./tests --fix
533
+ anatomize validate .anatomy --source ./src
534
+ anatomize validate .anatomy -s ./src -s ./tests --fix
535
535
  """
536
536
  try:
537
537
  from anatomize.validation import validate_skeleton_dir
@@ -644,6 +644,24 @@ def pack(
644
644
  str | None,
645
645
  typer.Option("--mode", help="Pack mode: bundle or hybrid (hybrid requires jsonl)."),
646
646
  ] = None,
647
+ prefix: Annotated[
648
+ str | None,
649
+ typer.Option("--prefix", help="Pack prefix style: standard or minimal."),
650
+ ] = None,
651
+ explain_selection: Annotated[
652
+ bool,
653
+ typer.Option(
654
+ "--explain-selection",
655
+ help="Write a deterministic selection report (why files were included/excluded).",
656
+ ),
657
+ ] = False,
658
+ explain_selection_output: Annotated[
659
+ Path | None,
660
+ typer.Option(
661
+ "--explain-selection-output",
662
+ help="Selection report output path (default derived from --output).",
663
+ ),
664
+ ] = None,
647
665
  include: Annotated[
648
666
  list[str] | None,
649
667
  typer.Option("--include", help="Include pattern(s). Repeatable allowlist; when absent, includes all."),
@@ -844,15 +862,25 @@ def pack(
844
862
  )
845
863
  else:
846
864
  if resolved_mode is PackMode.HYBRID:
847
- if inferred_from_output is not None and inferred_from_output is not PackFormat.JSONL:
848
- raise ValueError("--mode hybrid requires JSONL output (.jsonl)")
849
- resolved_fmt = PackFormat.JSONL
865
+ if inferred_from_output is not None:
866
+ if inferred_from_output not in (PackFormat.JSONL, PackFormat.MARKDOWN, PackFormat.PLAIN):
867
+ raise ValueError("--mode hybrid supports only markdown, plain, or jsonl output")
868
+ resolved_fmt = inferred_from_output
869
+ else:
870
+ resolved_fmt = PackFormat.MARKDOWN
850
871
  else:
851
872
  resolved_fmt = inferred_from_output if inferred_from_output is not None else pack_cfg.format
852
873
 
853
874
  resolved_deps = bool(entry) if deps is None else deps
854
875
  resolved_backend = pack_cfg.slice_backend if slice_backend is None else SliceBackend(slice_backend)
855
876
 
877
+ resolved_prefix = pack_cfg.prefix if prefix is None else PrefixStyle(prefix)
878
+ if resolved_mode is PackMode.HYBRID and resolved_fmt not in (
879
+ PackFormat.JSONL,
880
+ PackFormat.MARKDOWN,
881
+ PackFormat.PLAIN,
882
+ ):
883
+ raise ValueError("--mode hybrid supports only markdown, plain, or jsonl output")
856
884
  resolved_include = pack_cfg.include if include is None else include
857
885
  resolved_ignore = pack_cfg.ignore if ignore is None else ignore
858
886
  resolved_ignore_files = (
@@ -902,11 +930,23 @@ def pack(
902
930
  [Path(p) for p in pack_cfg.python_roots] if python_root is None else python_root
903
931
  )
904
932
 
933
+ selection_report_path: Path | None = None
934
+ if explain_selection:
935
+ base = resolved_output if resolved_output is not None else default_output_path(resolved_fmt)
936
+ base = base.resolve()
937
+ selection_report_path = (
938
+ explain_selection_output.resolve()
939
+ if explain_selection_output is not None
940
+ else base.with_name(base.name + ".selection.json")
941
+ )
942
+
905
943
  res = run_pack(
906
944
  root=root,
907
945
  output=resolved_output,
908
946
  fmt=resolved_fmt,
909
947
  mode=resolved_mode,
948
+ prefix_style=resolved_prefix,
949
+ selection_report_output=selection_report_path,
910
950
  include=resolved_include,
911
951
  ignore=resolved_ignore,
912
952
  ignore_files=resolved_ignore_files,
@@ -941,6 +981,8 @@ def pack(
941
981
 
942
982
  for a in res.artifacts:
943
983
  typer.echo(f"Wrote: {a.path}")
984
+ if selection_report_path is not None:
985
+ typer.echo(f"Selection report: {selection_report_path}")
944
986
  total_artifact_tokens = sum(a.tokens for a in res.artifacts)
945
987
  typer.echo(f"Artifact tokens: {total_artifact_tokens:,} ({resolved_token_encoding})")
946
988
  typer.echo(f"Content tokens: {res.content_tokens:,} ({resolved_token_encoding})")
@@ -14,7 +14,7 @@ from pydantic import BaseModel, Field
14
14
  from anatomize.core.policy import SymlinkPolicy
15
15
  from anatomize.core.types import ResolutionLevel
16
16
  from anatomize.formats import OutputFormat
17
- from anatomize.pack.formats import ContentEncoding, PackFormat, infer_pack_format_from_output_path
17
+ from anatomize.pack.formats import ContentEncoding, PackFormat, PrefixStyle, infer_pack_format_from_output_path
18
18
  from anatomize.pack.mode import PackMode
19
19
  from anatomize.pack.slicing import SliceBackend
20
20
  from anatomize.pack.summaries import SummaryConfig
@@ -26,6 +26,7 @@ class PackConfig(BaseModel):
26
26
  format: PackFormat = PackFormat.MARKDOWN
27
27
  mode: PackMode = PackMode.BUNDLE
28
28
  output: str | None = None
29
+ prefix: PrefixStyle = PrefixStyle.STANDARD
29
30
  include: list[str] = Field(default_factory=list)
30
31
  ignore: list[str] = Field(default_factory=list)
31
32
  ignore_files: list[str] = Field(default_factory=list)
@@ -54,6 +55,13 @@ class PackConfig(BaseModel):
54
55
  model_config = {"extra": "forbid"}
55
56
 
56
57
  def model_post_init(self, __context: Any) -> None:
58
+ if self.mode is PackMode.HYBRID and self.format not in (
59
+ PackFormat.MARKDOWN,
60
+ PackFormat.PLAIN,
61
+ PackFormat.JSONL,
62
+ ):
63
+ raise ValueError("pack.mode=hybrid supports only markdown, plain, or jsonl format")
64
+
57
65
  if self.output is None:
58
66
  return
59
67
  inferred = infer_pack_format_from_output_path(Path(self.output))
@@ -180,6 +188,7 @@ class AnatomizeConfig(BaseModel):
180
188
  "format": self.pack.format.value,
181
189
  "mode": self.pack.mode.value,
182
190
  "output": self.pack.output,
191
+ "prefix": self.pack.prefix.value,
183
192
  "include": self.pack.include,
184
193
  "ignore": self.pack.ignore,
185
194
  "ignore_files": self.pack.ignore_files,
@@ -16,10 +16,12 @@ Supported:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ from collections.abc import Sequence
19
20
  from dataclasses import dataclass
20
21
  from fnmatch import fnmatchcase
21
22
  from functools import cache
22
23
  from pathlib import PurePosixPath
24
+ from typing import TypeAlias
23
25
 
24
26
 
25
27
  @dataclass(frozen=True)
@@ -35,12 +37,23 @@ class ExcludeRule:
35
37
  anchored: bool
36
38
  directory_only: bool
37
39
  has_slash: bool
40
+ source: str
41
+
42
+
43
+ IgnorePattern: TypeAlias = str | tuple[str, str]
38
44
 
39
45
 
40
46
  class Excluder:
41
- def __init__(self, patterns: list[str]) -> None:
47
+ def __init__(self, patterns: Sequence[IgnorePattern]) -> None:
42
48
  self._rules: list[ExcludeRule] = []
43
- for raw in patterns:
49
+ for item in patterns:
50
+ source = "unspecified"
51
+ raw: str
52
+ if isinstance(item, tuple):
53
+ raw, source = item
54
+ else:
55
+ raw = item
56
+
44
57
  parsed = parse_ignore_line(raw, allow_negation=True)
45
58
  if parsed is None:
46
59
  continue
@@ -67,20 +80,30 @@ class Excluder:
67
80
  anchored=anchored,
68
81
  directory_only=directory_only,
69
82
  has_slash=has_slash,
83
+ source=source,
70
84
  )
71
85
  )
72
86
 
73
87
  def is_excluded(self, rel_posix: str, *, is_dir: bool) -> bool:
74
88
  """Return True if the given relative path should be excluded."""
89
+ return self.explain(rel_posix, is_dir=is_dir)[0]
90
+
91
+ def rules(self) -> list[ExcludeRule]:
92
+ return list(self._rules)
93
+
94
+ def explain(self, rel_posix: str, *, is_dir: bool) -> tuple[bool, ExcludeRule | None]:
95
+ """Return (excluded, last_matching_rule_or_None)."""
75
96
  rel_posix = rel_posix.strip("/")
76
97
  path = PurePosixPath(rel_posix) if rel_posix else PurePosixPath(".")
77
98
 
78
99
  excluded = False
100
+ matched: ExcludeRule | None = None
79
101
  for rule in self._rules:
80
102
  if not self._matches(path, rule, is_dir=is_dir):
81
103
  continue
82
104
  excluded = not rule.negated
83
- return excluded
105
+ matched = rule
106
+ return excluded, matched
84
107
 
85
108
  def filter_dirnames(self, parent_rel_posix: str, dirnames: list[str]) -> None:
86
109
  """Prune excluded directories in-place for os.walk dirnames."""
@@ -26,7 +26,7 @@ class JsonFormatter:
26
26
  Example
27
27
  -------
28
28
  >>> formatter = JsonFormatter()
29
- >>> formatter.write(skeleton, Path(".skeleton"))
29
+ >>> formatter.write(skeleton, Path(".anatomy"))
30
30
  """
31
31
 
32
32
  def __init__(self, indent: int = 2) -> None:
@@ -23,7 +23,7 @@ class MarkdownFormatter:
23
23
  Example
24
24
  -------
25
25
  >>> formatter = MarkdownFormatter()
26
- >>> formatter.write(skeleton, Path(".skeleton"))
26
+ >>> formatter.write(skeleton, Path(".anatomy"))
27
27
  """
28
28
 
29
29
  def write(self, skeleton: Skeleton, output_dir: Path) -> None:
@@ -27,7 +27,7 @@ class YamlFormatter:
27
27
  Example
28
28
  -------
29
29
  >>> formatter = YamlFormatter()
30
- >>> formatter.write(skeleton, Path(".skeleton"))
30
+ >>> formatter.write(skeleton, Path(".anatomy"))
31
31
  """
32
32
 
33
33
  def __init__(self) -> None:
@@ -69,9 +69,14 @@ class SkeletonGenerator:
69
69
  "*.pyc",
70
70
  ".git",
71
71
  ".venv",
72
- ".skeleton",
72
+ ".anatomy",
73
+ ".runtime",
73
74
  ".mypy_cache",
74
75
  ".pytest_cache",
76
+ ".ruff_cache",
77
+ "dist",
78
+ "build",
79
+ "*.egg-info",
75
80
  ]
76
81
  self._symlinks = symlinks
77
82
  self._workers = workers
@@ -90,10 +90,12 @@ def dependency_closure(entry_files: list[Path], *, index: PythonModuleIndex) ->
90
90
  raise ValueError("At least one --entry is required for dependency closure")
91
91
 
92
92
  seen: set[Path] = set()
93
+ parent: dict[Path, Path | None] = {}
93
94
  queue: list[PythonModule] = []
94
95
  for f in entry_files:
95
96
  mod = index.module_for_path(f)
96
97
  queue.append(mod)
98
+ parent.setdefault(mod.path, None)
97
99
 
98
100
  while queue:
99
101
  current = queue.pop()
@@ -104,16 +106,30 @@ def dependency_closure(entry_files: list[Path], *, index: PythonModuleIndex) ->
104
106
  for init in index.package_inits_for(current.module):
105
107
  if init.path not in seen:
106
108
  queue.append(init)
109
+ parent.setdefault(init.path, current.path)
107
110
 
108
111
  for imported in _extract_imported_modules(current, index=index):
109
112
  target = index.resolve_module(imported)
110
113
  if target is None:
111
114
  top = imported.split(".", 1)[0]
112
115
  if index.is_local_toplevel(top):
113
- raise ValueError(f"Unresolved local import '{imported}' from {current.path}")
116
+ chain: list[Path] = [current.path]
117
+ p = current.path
118
+ while True:
119
+ prev = parent.get(p)
120
+ if prev is None:
121
+ break
122
+ p = prev
123
+ chain.append(p)
124
+ chain = list(reversed(chain))
125
+ chain_str = "\n".join(f" - {c}" for c in chain)
126
+ raise ValueError(
127
+ f"Unresolved local import '{imported}' from {current.path}\nImport chain:\n{chain_str}"
128
+ )
114
129
  continue
115
130
  if target.path not in seen:
116
131
  queue.append(target)
132
+ parent.setdefault(target.path, current.path)
117
133
 
118
134
  return sorted(seen)
119
135
 
@@ -20,6 +20,16 @@ class DiscoveredPath:
20
20
  is_binary: bool
21
21
 
22
22
 
23
+ @dataclass(frozen=True)
24
+ class DiscoveryTraceItem:
25
+ path: str
26
+ is_dir: bool
27
+ decision: str # "included" or "excluded"
28
+ reason: str | None # "ignore" | "include" | None
29
+ matched_pattern: str | None
30
+ matched_source: str | None
31
+
32
+
23
33
  def discover_paths(
24
34
  root: Path,
25
35
  *,
@@ -27,6 +37,7 @@ def discover_paths(
27
37
  include_patterns: list[str] | None,
28
38
  symlinks: SymlinkPolicy,
29
39
  max_file_bytes: int,
40
+ trace: list[DiscoveryTraceItem] | None = None,
30
41
  ) -> list[DiscoveredPath]:
31
42
  root = root.resolve()
32
43
  if not root.exists() or not root.is_dir():
@@ -55,7 +66,22 @@ def discover_paths(
55
66
  except OSError as e:
56
67
  raise ValueError(f"Failed to stat path: {entry}") from e
57
68
 
58
- if excluder.is_excluded(rel_posix, is_dir=is_dir):
69
+ excluded, matched = excluder.explain(rel_posix, is_dir=is_dir)
70
+ if excluded:
71
+ if trace is not None:
72
+ matched_pattern = None
73
+ if matched is not None:
74
+ matched_pattern = matched.pattern + ("/" if matched.directory_only else "")
75
+ trace.append(
76
+ DiscoveryTraceItem(
77
+ path=rel_posix,
78
+ is_dir=is_dir,
79
+ decision="excluded",
80
+ reason="ignore",
81
+ matched_pattern=matched_pattern,
82
+ matched_source=matched.source if matched is not None else None,
83
+ )
84
+ )
59
85
  continue
60
86
 
61
87
  if include_patterns and not include_matcher.matches_any(rel_posix, is_dir=is_dir):
@@ -64,6 +90,17 @@ def discover_paths(
64
90
  # our simple include matcher cannot prove that cheaply, so we traverse all
65
91
  # non-excluded directories and filter files at the leaf.
66
92
  if not is_dir:
93
+ if trace is not None:
94
+ trace.append(
95
+ DiscoveryTraceItem(
96
+ path=rel_posix,
97
+ is_dir=False,
98
+ decision="excluded",
99
+ reason="include",
100
+ matched_pattern=None,
101
+ matched_source=None,
102
+ )
103
+ )
67
104
  continue
68
105
 
69
106
  if is_dir:
@@ -95,6 +132,17 @@ def discover_paths(
95
132
  is_binary=is_binary,
96
133
  )
97
134
  )
135
+ if trace is not None:
136
+ trace.append(
137
+ DiscoveryTraceItem(
138
+ path=rel_posix,
139
+ is_dir=False,
140
+ decision="included",
141
+ reason=None,
142
+ matched_pattern=None,
143
+ matched_source=None,
144
+ )
145
+ )
98
146
 
99
147
  results.append(
100
148
  DiscoveredPath(
@@ -115,7 +163,8 @@ def discover_paths(
115
163
 
116
164
  def _is_binary_file(path: Path, *, sniff_bytes: int = 8192) -> bool:
117
165
  try:
118
- data = path.read_bytes()[:sniff_bytes]
166
+ with path.open("rb") as f:
167
+ data = f.read(sniff_bytes)
119
168
  except OSError:
120
169
  return True
121
170
  if b"\x00" in data: