pycode-kg 0.23.1__tar.gz → 0.24.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 (63) hide show
  1. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/PKG-INFO +9 -9
  2. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/README.md +4 -4
  3. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/pyproject.toml +36 -13
  4. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/__init__.py +1 -1
  5. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/analysis/bridge.py +0 -1
  6. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/analysis/centrality.py +0 -1
  7. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/analysis/framework_detector.py +0 -1
  8. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/architecture.py +2 -2
  9. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_hooks.py +52 -11
  10. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_init.py +4 -0
  11. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_quilt.py +0 -1
  12. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_snapshot.py +4 -0
  13. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/module/extractor.py +1 -0
  14. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/pycodekg.py +103 -1
  15. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/pycodekg_thorough_analysis.py +356 -79
  16. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/report.py +53 -2
  17. pycode_kg-0.24.1/src/pycode_kg/resolution.py +211 -0
  18. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/scene3d.py +5 -51
  19. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/snapshots.py +16 -1
  20. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/viz3d.py +45 -47
  21. pycode_kg-0.23.1/src/pycode_kg/resolution.py +0 -95
  22. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/LICENSE +0 -0
  23. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/.DS_Store +0 -0
  24. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/__main__.py +0 -0
  25. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/analysis/__init__.py +0 -0
  26. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/app.py +0 -0
  27. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
  28. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/__init__.py +0 -0
  29. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_analyze.py +0 -0
  30. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_architecture.py +0 -0
  31. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_bridges.py +0 -0
  32. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_build.py +0 -0
  33. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_build_full.py +0 -0
  34. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_centrality.py +0 -0
  35. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_explain.py +0 -0
  36. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
  37. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_mcp.py +0 -0
  38. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_model.py +0 -0
  39. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_query.py +0 -0
  40. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/cmd_viz.py +0 -0
  41. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/main.py +0 -0
  42. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/cli/options.py +0 -0
  43. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/config.py +0 -0
  44. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/explain.py +0 -0
  45. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/graph.py +0 -0
  46. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/graph_html.py +0 -0
  47. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/index.py +0 -0
  48. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/kg.py +0 -0
  49. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/layout3d.py +0 -0
  50. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/mcp_server.py +0 -0
  51. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/module/__init__.py +0 -0
  52. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/module/base.py +0 -0
  53. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/module/types.py +0 -0
  54. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/ranking/__init__.py +0 -0
  55. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/ranking/cli_rank.py +0 -0
  56. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/ranking/coderank.py +0 -0
  57. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/render.py +0 -0
  58. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
  59. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/store.py +0 -0
  60. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/theme.py +0 -0
  61. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/utils.py +0 -0
  62. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/visitor.py +0 -0
  63. {pycode_kg-0.23.1 → pycode_kg-0.24.1}/src/pycode_kg/viz3d_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycode-kg
3
- Version: 0.23.1
3
+ Version: 0.24.1
4
4
  Summary: A tool to build a searchable knowledge graph from Python repositories
5
5
  License-Expression: Elastic-2.0
6
6
  License-File: LICENSE
@@ -21,8 +21,8 @@ Provides-Extra: viz3d
21
21
  Requires-Dist: PyQt5 (>=5.15.0) ; extra == "all"
22
22
  Requires-Dist: PyQt5 (>=5.15.0) ; extra == "viz3d"
23
23
  Requires-Dist: click (>=8.1.0,<9)
24
- Requires-Dist: kgmodule-utils[semantic,viz3d] (>=0.13.1)
25
- Requires-Dist: kgmodule-utils[viz3d-render] (>=0.12.1) ; extra == "viz3d"
24
+ Requires-Dist: kgmodule-utils[semantic,viz3d] (>=0.18.1)
25
+ Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.18.1) ; extra == "viz3d"
26
26
  Requires-Dist: markdown (>=3.6) ; extra == "all"
27
27
  Requires-Dist: markdown (>=3.6) ; extra == "viz3d"
28
28
  Requires-Dist: mcp (>=1.0.0,<2)
@@ -38,8 +38,8 @@ Requires-Dist: pyvis (>=0.3.2) ; extra == "viz"
38
38
  Requires-Dist: pyvista (>=0.44.0) ; extra == "all"
39
39
  Requires-Dist: pyvistaqt (>=0.11.0) ; extra == "all"
40
40
  Requires-Dist: pyvistaqt (>=0.11.0) ; extra == "viz3d"
41
- Requires-Dist: quiltwright (>=0.4.0) ; extra == "all"
42
- Requires-Dist: quiltwright (>=0.4.0) ; extra == "viz3d"
41
+ Requires-Dist: quiltwright (>=0.8.0) ; extra == "all"
42
+ Requires-Dist: quiltwright (>=0.8.0) ; extra == "viz3d"
43
43
  Requires-Dist: rich (>=14.3.3,<15)
44
44
  Requires-Dist: sentence-transformers (>=5.4.1)
45
45
  Requires-Dist: sqlite-vec (==0.1.9)
@@ -60,7 +60,7 @@ Description-Content-Type: text/markdown
60
60
 
61
61
  [![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13-blue.svg)](https://www.python.org/)
62
62
  [![License: Elastic-2.0](https://img.shields.io/badge/License-Elastic%202.0-blue.svg)](https://www.elastic.co/licensing/elastic-license)
63
- [![Version](https://img.shields.io/badge/version-0.23.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
63
+ [![Version](https://img.shields.io/badge/version-0.24.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
64
64
  [![CI](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
65
65
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
66
66
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
@@ -255,7 +255,7 @@ src/pycode_kg/
255
255
  └── viz3d_timeline.py # Metric history timeline
256
256
  ```
257
257
 
258
- The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.23.1.md](docs/analysis_v0.23.1.md), produced (of course) by `pycodekg analyze` against this very repo.
258
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.24.1.md](docs/analysis_v0.24.1.md), produced (of course) by `pycodekg analyze` against this very repo.
259
259
 
260
260
  ---
261
261
 
@@ -282,13 +282,13 @@ If you use PyCodeKG in your research or project, please cite it:
282
282
 
283
283
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
284
284
 
285
- > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.23.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
285
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.24.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
286
286
 
287
287
  ```bibtex
288
288
  @software{suchanek_pycode_kg,
289
289
  author = {Suchanek, Eric G.},
290
290
  title = {{PyCodeKG}: A Knowledge Graph for Python Codebases},
291
- version = {0.23.1},
291
+ version = {0.24.1},
292
292
  year = {2026},
293
293
  publisher = {Flux-Frontiers},
294
294
  url = {https://github.com/Flux-Frontiers/pycode_kg},
@@ -5,7 +5,7 @@
5
5
 
6
6
  [![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13-blue.svg)](https://www.python.org/)
7
7
  [![License: Elastic-2.0](https://img.shields.io/badge/License-Elastic%202.0-blue.svg)](https://www.elastic.co/licensing/elastic-license)
8
- [![Version](https://img.shields.io/badge/version-0.23.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
8
+ [![Version](https://img.shields.io/badge/version-0.24.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
9
9
  [![CI](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
10
10
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
11
11
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
@@ -200,7 +200,7 @@ src/pycode_kg/
200
200
  └── viz3d_timeline.py # Metric history timeline
201
201
  ```
202
202
 
203
- The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.23.1.md](docs/analysis_v0.23.1.md), produced (of course) by `pycodekg analyze` against this very repo.
203
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.24.1.md](docs/analysis_v0.24.1.md), produced (of course) by `pycodekg analyze` against this very repo.
204
204
 
205
205
  ---
206
206
 
@@ -227,13 +227,13 @@ If you use PyCodeKG in your research or project, please cite it:
227
227
 
228
228
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
229
229
 
230
- > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.23.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
230
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.24.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
231
231
 
232
232
  ```bibtex
233
233
  @software{suchanek_pycode_kg,
234
234
  author = {Suchanek, Eric G.},
235
235
  title = {{PyCodeKG}: A Knowledge Graph for Python Codebases},
236
- version = {0.23.1},
236
+ version = {0.24.1},
237
237
  year = {2026},
238
238
  publisher = {Flux-Frontiers},
239
239
  url = {https://github.com/Flux-Frontiers/pycode_kg},
@@ -1,7 +1,6 @@
1
1
  # pyproject.toml — PyCodeKG package configuration (PEP 621)
2
2
  #
3
3
  # Author: Eric G. Suchanek, PhD
4
- # Last Revision: 2026-08-15
5
4
  #
6
5
  # Build system : Poetry 2.x with PEP 621 [project] table
7
6
  #
@@ -71,7 +70,7 @@ torch = [
71
70
  # ---------------------------------------------------------------------------
72
71
  [project]
73
72
  name = "pycode-kg"
74
- version = "0.23.1"
73
+ version = "0.24.1"
75
74
  description = "A tool to build a searchable knowledge graph from Python repositories"
76
75
  readme = "README.md"
77
76
  license = "Elastic-2.0"
@@ -137,12 +136,26 @@ dependencies = [
137
136
  # Its only dependency is numpy, which `[semantic]` already pulls, so it adds
138
137
  # nothing to the install — it just declares what layout3d actually imports.
139
138
  # Unlike `[viz]`, there is no pyvis to leak into a bare install.
140
- # Floor is 0.13.1 rather than 0.12.1 because that is the release which
141
- # stops SnapshotManager writing absolute paths into snapshot JSON. This
142
- # package ships `pycodekg snapshot save`, and those snapshots get committed
143
- # to the user's repo — against an older kgmodule-utils the command writes
144
- # their home directory and username into version control.
145
- "kgmodule-utils[semantic,viz3d]>=0.13.1",
139
+ # Floor is 0.13.x rather than 0.12.1 because that is where SnapshotManager
140
+ # stops writing absolute paths into snapshot JSON. This package ships
141
+ # `pycodekg snapshot save`, and those snapshots get committed to the
142
+ # user's repo — against an older kgmodule-utils the command writes their
143
+ # home directory and username into version control.
144
+ #
145
+ # 0.13.2 specifically, never 0.13.1: that release carried the path fix but
146
+ # also made `repo_root` a read-only property, which breaks every subclass
147
+ # that assigns it. 0.13.2 keeps the fix and restores the plain attribute.
148
+ #
149
+ # 0.15.0 because the `viz3d` extra below needs kg_utils.viz3d.qt, and this
150
+ # repo's own rule is one floor per package — a lower floor here would leave
151
+ # a reader working out which constraint actually binds. Nothing in the core
152
+ # install needs 0.15.0 functionally; it is a superset of 0.14.0.
153
+ #
154
+ # 0.18.1 because resolve_symbols() only reads the receiver_class metadata
155
+ # this repo's visitor writes as of 0.24.1 -- against 0.18.0 the stubs are
156
+ # tagged correctly but the tag is silently ignored, so resolution stays on
157
+ # the old untyped trailing-name match with no error to say so.
158
+ "kgmodule-utils[semantic,viz3d]>=0.18.1",
146
159
  ]
147
160
 
148
161
  [project.optional-dependencies]
@@ -159,12 +172,22 @@ viz3d = [
159
172
  # and smooth_paths live there. The main dependency above stays on plain
160
173
  # [viz3d] (numpy only) so the nine repos that take pycode-kg do not acquire
161
174
  # VTK for a layout import; rendering stays opt-in, here.
162
- "kgmodule-utils[viz3d-render]>=0.12.1",
175
+ # Same floor as the core declaration above — one package, one floor, so a
176
+ # reader does not have to work out which constraint actually binds.
177
+ # [viz3d-qt] (new in 0.15.0) adds kg_utils.viz3d.qt — the Qt machinery a
178
+ # viewer needs to cast: the render lifecycle and cast_scene_to_looking_glass,
179
+ # which this module's "Cast to LG" button now drives instead of open-coding
180
+ # the four-step build/render/write/cast itself. It subsumes [viz3d-render]
181
+ # (it declares pyvista too), but both are named so the dependency reads as
182
+ # what it is rather than as a side effect.
183
+ "kgmodule-utils[viz3d-render,viz3d-qt]>=0.18.1",
163
184
  "pyvistaqt>=0.11.0",
164
185
  "trame-vtk>=2.0.0",
165
186
  # Looking Glass quilts. 0.4.0 supplies depth_report() and widened
166
- # requires-python to <3.14, which retires the old marker gate.
167
- "quiltwright>=0.4.0",
187
+ # requires-python to <3.14, which retires the old marker gate. 0.6.0 adds
188
+ # QuiltSpec.scaled(), which replaces the tile-grid rounding this repo used
189
+ # to open-code, and save_and_cast_quilt() underneath the SDK cast helper.
190
+ "quiltwright>=0.8.0",
168
191
  ]
169
192
 
170
193
  # Cross-KG sibling packages (doc-kg, pycode-kg, agent-kg, kg-rag) are NOT
@@ -189,7 +212,7 @@ all = [
189
212
  "pyvis>=0.3.2",
190
213
  "pyvista>=0.44.0",
191
214
  "pyvistaqt>=0.11.0",
192
- "quiltwright>=0.4.0",
215
+ "quiltwright>=0.8.0",
193
216
  "streamlit>=1.56.0",
194
217
  "trame-vtk>=2.0.0",
195
218
  ]
@@ -231,7 +254,7 @@ ty = ">=0.0.41"
231
254
  optional = true
232
255
 
233
256
  [tool.poetry.group.kg.dependencies]
234
- doc-kg = ">=0.21.2"
257
+ doc-kg = ">=0.22.0"
235
258
 
236
259
  [project.urls]
237
260
  Homepage = "https://github.com/Flux-Frontiers/pycode_kg"
@@ -36,7 +36,7 @@ Author: Eric G. Suchanek, PhD
36
36
  License: Elastic 2.0
37
37
  """
38
38
 
39
- __version__ = "0.23.1"
39
+ __version__ = "0.24.1"
40
40
  __author__ = "Eric G. Suchanek, PhD"
41
41
 
42
42
  # Low-level primitives (locked v0 contract)
@@ -4,7 +4,6 @@ Measures module interaction complexity: how many unique modules each module call
4
4
  For well-modularized codebases, identifies orchestrator and hub modules.
5
5
 
6
6
  Author: Eric G. Suchanek, PhD
7
- Last Revision: 2026-08-15 00:46:18
8
7
  License: Elastic 2.0
9
8
  """
10
9
 
@@ -12,7 +12,6 @@ Public API:
12
12
  - :func:`aggregate_module_scores` — roll node scores up to module level.
13
13
 
14
14
  Author: Eric G. Suchanek, PhD
15
- Last Revision: 2026-08-15 00:46:18
16
15
 
17
16
  License: Elastic 2.0
18
17
  """
@@ -3,7 +3,6 @@ Framework Detector for PyCodeKG.
3
3
  Identifies repo-defining abstractions using centrality and cross-module signals.
4
4
 
5
5
  Author: Eric G. Suchanek, PhD
6
- Last Revision: 2026-08-15 00:46:18
7
6
  License: Elastic 2.0
8
7
  """
9
8
 
@@ -610,11 +610,11 @@ class ArchitectureAnalyzer:
610
610
  readme = self.repo_root / "README.md"
611
611
  if readme.exists():
612
612
  try:
613
- with open(readme) as f:
613
+ with open(readme, encoding="utf-8") as f:
614
614
  for line in f:
615
615
  if line.startswith("#"):
616
616
  return line.lstrip("#").strip()
617
- except OSError:
617
+ except (OSError, UnicodeDecodeError):
618
618
  pass
619
619
  return "PyCodeKG Architecture"
620
620
 
@@ -6,7 +6,6 @@ CLI command for installing PyCodeKG git hooks:
6
6
  install-hooks — install the pre-commit snapshot hook into .git/hooks/
7
7
 
8
8
  Author: Eric G. Suchanek, PhD
9
- Last Revision: 2026-08-14 22:51:58
10
9
 
11
10
  Author: Eric G. Suchanek, PhD
12
11
 
@@ -29,10 +28,33 @@ from pycode_kg.cli.main import cli
29
28
 
30
29
  _PRE_COMMIT_HOOK = """\
31
30
  #!/usr/bin/env bash
32
- # PyCodeKG pre-commit hook — runs quality checks first, then rebuilds the local
33
- # index and captures a metrics snapshot.
31
+ # PyCodeKG pre-commit hook — runs quality checks. The index rebuild and metrics
32
+ # snapshot are opt-in and OFF by default; see "Why snapshots are off" below.
34
33
  # Installed by: pycodekg install-hooks
35
- # Skip with: PYCODEKG_SKIP_SNAPSHOT=1 git commit ...
34
+ #
35
+ # PYCODEKG_SNAPSHOT=1 git commit ... opt in to a per-commit snapshot
36
+ # PYCODEKG_SKIP_SNAPSHOT=1 git commit ... force snapshots off (wins)
37
+ #
38
+ # Note that PYCODEKG_SKIP_SNAPSHOT no longer skips the quality checks. It used
39
+ # to short-circuit the whole hook, so a variable named "skip snapshot" also
40
+ # silently skipped ruff, ty and pytest. It now gates only what it names.
41
+ #
42
+ # Why snapshots are off by default (2026-08-18)
43
+ # ---------------------------------------------
44
+ # A per-commit snapshot records `git write-tree` and is then itself staged into
45
+ # that same commit. Staging changes the index, so the recorded hash can never
46
+ # equal the tree it claims to describe — and manifest.json carries a
47
+ # `last_update` timestamp, so the `git add` is never a no-op. The drift is
48
+ # guaranteed by construction, not caused by formatting, and reordering does not
49
+ # fix it: the `git add` lands after the hash either way.
50
+ #
51
+ # An audit of 605 snapshots across 29 fleet manifests found 63 (10.4%) keyed to
52
+ # a tree any commit actually has. `snapshot diff` between adjacent entries has
53
+ # therefore been comparing states that never existed.
54
+ #
55
+ # The fix is to snapshot at release, keyed on the tag rather than on an
56
+ # ephemeral pre-commit tree. See kgrag_priv/docs/SNAPSHOT_STRATEGY.md. Until
57
+ # that lands, this hook runs quality checks only.
36
58
  #
37
59
  # Order matters, and it is deliberately checks-then-index:
38
60
  #
@@ -46,8 +68,6 @@ _PRE_COMMIT_HOOK = """\
46
68
  # commit that ruff/ty/pytest is about to reject.
47
69
  set -euo pipefail
48
70
 
49
- [ "${PYCODEKG_SKIP_SNAPSHOT:-0}" = "1" ] && exit 0
50
-
51
71
  REPO_ROOT="$(git rev-parse --show-toplevel)"
52
72
 
53
73
  cd "$REPO_ROOT"
@@ -63,12 +83,27 @@ elif command -v pre-commit &>/dev/null; then
63
83
  pre-commit run || exit 1
64
84
  fi
65
85
 
66
- # Capture the tree hash now that the checks have passed and nothing further
67
- # will modify the working tree — this keys the snapshot to the content that is
68
- # actually about to be committed.
86
+ # ---------------------------------------------------------------------------
87
+ # Opt-in index rebuild + snapshot. Everything below is skipped unless
88
+ # PYCODEKG_SNAPSHOT=1 is set, and is skipped regardless if
89
+ # PYCODEKG_SKIP_SNAPSHOT=1.
90
+ # ---------------------------------------------------------------------------
91
+ [ "${PYCODEKG_SNAPSHOT:-0}" = "1" ] || exit 0
92
+ [ "${PYCODEKG_SKIP_SNAPSHOT:-0}" = "1" ] && exit 0
93
+
94
+ # Captured after the checks so nothing further modifies the working tree. Note
95
+ # the caveat above: this still cannot match the committed tree, because the
96
+ # `git add` below changes the index after this point.
69
97
  TREE_HASH=$(git write-tree)
70
98
  BRANCH=$(git rev-parse --abbrev-ref HEAD)
71
99
 
100
+ # Snapshots are default-branch history. On any other branch, stop here —
101
+ # feature-branch commits (and the PRs/CI built from them) stay free of
102
+ # generated snapshot files, and skip the 60-90s rebuild along the way.
103
+ DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)
104
+ DEFAULT_BRANCH="${DEFAULT_BRANCH#origin/}"
105
+ [ "$BRANCH" != "${DEFAULT_BRANCH:-main}" ] && exit 0
106
+
72
107
  # Rebuild the local index to keep it in sync with staged content.
73
108
  "$REPO_ROOT/.venv/bin/pycodekg" build --repo "$REPO_ROOT" || exit 1
74
109
 
@@ -104,11 +139,14 @@ exit 0
104
139
  def install_hooks(repo: str, force: bool) -> None:
105
140
  """Install the PyCodeKG pre-commit git hook.
106
141
 
107
- After installation, before each commit:
142
+ After installation, before each commit on the default branch:
108
143
  1. Rebuilds the local PyCodeKG index (full wipe)
109
144
  2. Captures a metrics snapshot, keyed by tree hash
110
145
  3. Stages the snapshot directory
111
146
 
147
+ On any other branch the quality checks still run but the rebuild and
148
+ snapshot are skipped, so feature-branch commits carry no generated files.
149
+
112
150
  This keeps the index in sync and ensures snapshots reflect the state of
113
151
  the knowledge graph at commit time. The hook only ever touches its own
114
152
  repository — sibling KG repos install their own hooks.
@@ -137,5 +175,8 @@ def install_hooks(repo: str, force: bool) -> None:
137
175
  hook_path.chmod(mode)
138
176
 
139
177
  click.echo(f"OK Installed pre-commit hook: {hook_path}")
140
- click.echo(" Snapshots will be captured automatically before each commit.")
178
+ click.echo(" Quality checks run on every commit.")
179
+ click.echo(" Snapshots are OFF by default - see kgrag_priv/docs/SNAPSHOT_STRATEGY.md.")
180
+ click.echo(" Opt in with: PYCODEKG_SNAPSHOT=1 git commit ...")
181
+ click.echo(" Force off: PYCODEKG_SKIP_SNAPSHOT=1 git commit ...")
141
182
  click.echo(" Run 'pycodekg build' first if you haven't built the graph yet.")
@@ -253,6 +253,8 @@ def init(
253
253
 
254
254
  docstring_cov = analysis.get("docstring_coverage", {})
255
255
  coverage = docstring_cov.get("coverage_pct", 0.0) / 100.0 if docstring_cov else 0.0
256
+ coverage_documented = docstring_cov.get("with_doc", 0)
257
+ coverage_total = docstring_cov.get("total", 0)
256
258
  critical_issues = len(analysis.get("issues", []))
257
259
 
258
260
  fn_metrics = analysis.get("function_metrics", {})
@@ -276,6 +278,8 @@ def init(
276
278
  branch=branch,
277
279
  graph_stats_dict=stats,
278
280
  coverage=coverage,
281
+ coverage_documented=coverage_documented,
282
+ coverage_total=coverage_total,
279
283
  critical_issues=critical_issues,
280
284
  complexity_median=complexity_median,
281
285
  hotspots=hotspots,
@@ -12,7 +12,6 @@ in :mod:`quiltwright`. What this command owns is the part quiltwright cannot
12
12
  know: which graph to grow and how to frame it.
13
13
 
14
14
  Author: Eric G. Suchanek, PhD
15
- Last Revision: 2026-08-14
16
15
 
17
16
  License: Elastic 2.0
18
17
  """
@@ -111,6 +111,8 @@ def save_snapshot(
111
111
  # Extract metrics from analysis results
112
112
  docstring_cov = analysis.get("docstring_coverage", {})
113
113
  coverage = docstring_cov.get("coverage_pct", 0.0) / 100.0 if docstring_cov else 0.0
114
+ coverage_documented = docstring_cov.get("with_doc", 0)
115
+ coverage_total = docstring_cov.get("total", 0)
114
116
  issue_strings = analysis.get("issues", [])
115
117
  critical_issues = len(issue_strings)
116
118
 
@@ -140,6 +142,8 @@ def save_snapshot(
140
142
  branch=branch,
141
143
  graph_stats_dict=stats,
142
144
  coverage=coverage,
145
+ coverage_documented=coverage_documented,
146
+ coverage_total=coverage_total,
143
147
  critical_issues=critical_issues,
144
148
  complexity_median=complexity_median,
145
149
  hotspots=hotspots,
@@ -118,6 +118,7 @@ class PyCodeKGExtractor(KGExtractor):
118
118
  lineno=n.lineno,
119
119
  end_lineno=n.end_lineno,
120
120
  docstring=n.docstring or "",
121
+ metadata={"receiver_class": n.receiver_class} if n.receiver_class else {},
121
122
  )
122
123
 
123
124
  for e in edges:
@@ -23,7 +23,6 @@ No persistence, no embeddings, no LLMs—just pure AST extraction. Integration w
23
23
  vector databases and semantic search happens downstream.
24
24
 
25
25
  Author: Eric G. Suchanek, PhD
26
- Last Revision: 2026-08-15 00:46:18
27
26
 
28
27
  License: Elastic 2.0
29
28
  """
@@ -62,6 +61,11 @@ class Node:
62
61
  :param lineno: Starting line number
63
62
  :param end_lineno: Ending line number (if available)
64
63
  :param docstring: Extracted docstring (may be None)
64
+ :param receiver_class: For a dotted call stub (``kind == "symbol"``), the
65
+ class name resolved from the receiver's parameter or local variable
66
+ annotation, if any — e.g. ``"Plotter"`` for a call site
67
+ ``plotter.render()`` where ``plotter: pv.Plotter``. ``None`` when the
68
+ receiver has no known annotation, or the node isn't a call stub.
65
69
  """
66
70
 
67
71
  id: str
@@ -72,6 +76,7 @@ class Node:
72
76
  lineno: int | None
73
77
  end_lineno: int | None
74
78
  docstring: str | None
79
+ receiver_class: str | None = None
75
80
 
76
81
 
77
82
  @dataclass(frozen=True)
@@ -212,6 +217,91 @@ def _owner_id(
212
217
  return module_locals[module].get(fn.name)
213
218
 
214
219
 
220
+ def _own_scope_nodes(node: ast.AST) -> Iterable[ast.AST]:
221
+ """Yield descendants of *node*, not descending into nested function/class scopes.
222
+
223
+ A nested ``def``/``class``/``lambda`` introduces its own name binding for
224
+ any local it declares, so an ``AnnAssign`` inside one does not describe a
225
+ name in *node*'s own scope.
226
+
227
+ :param node: Root AST node (typically a function body).
228
+ :return: Iterator over descendants in *node*'s own scope.
229
+ """
230
+ for child in ast.iter_child_nodes(node):
231
+ if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda | ast.ClassDef):
232
+ continue
233
+ yield child
234
+ yield from _own_scope_nodes(child)
235
+
236
+
237
+ def _annotation_class_name(expr: ast.AST) -> str | None:
238
+ """Extract a class name from a type annotation, unwrapping ``Optional`` shapes.
239
+
240
+ ``X | None`` (PEP 604) and ``Optional[X]``/``typing.Optional[X]`` are the
241
+ common "this local starts as ``None``, gets assigned later" shape --
242
+ exactly the pattern a loop-accumulated match object or lazily-built
243
+ instance tends to carry. Neither is a class name on its own, so both are
244
+ unwrapped to the non-``None`` member before falling back to
245
+ :func:`expr_to_name`, which does not know about either.
246
+
247
+ :param expr: The annotation expression.
248
+ :return: Dotted class name, or ``None`` if it can't be determined.
249
+ """
250
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.BitOr):
251
+ for side in (expr.left, expr.right):
252
+ if isinstance(side, ast.Constant) and side.value is None:
253
+ continue
254
+ resolved = _annotation_class_name(side)
255
+ if resolved:
256
+ return resolved
257
+ return None
258
+
259
+ if isinstance(expr, ast.Subscript):
260
+ base = expr_to_name(expr.value)
261
+ if base and base.rsplit(".", 1)[-1] == "Optional":
262
+ return _annotation_class_name(expr.slice)
263
+ return expr_to_name(expr)
264
+
265
+ if isinstance(expr, ast.Constant) and expr.value is None:
266
+ return None
267
+
268
+ return expr_to_name(expr)
269
+
270
+
271
+ def _receiver_annotation(
272
+ fn: ast.FunctionDef | ast.AsyncFunctionDef, receiver_name: str
273
+ ) -> str | None:
274
+ """Resolve a call receiver's class name from its parameter or local annotation.
275
+
276
+ Looks at *fn*'s own parameters, then any ``AnnAssign`` in *fn*'s own body
277
+ (see :func:`_own_scope_nodes`), for a name matching *receiver_name*. The
278
+ first annotation found wins -- no reassignment or narrowing is tracked,
279
+ matching the conservative stance the rest of this module takes toward
280
+ ambiguity.
281
+
282
+ :param fn: Enclosing function or async function definition.
283
+ :param receiver_name: Bare name of the call's receiver, e.g. ``"plotter"``.
284
+ :return: The annotation's last dotted segment (e.g. ``"Plotter"`` for a
285
+ ``pv.Plotter`` annotation, or ``"Match"`` for ``re.Match | None``), or
286
+ ``None`` if *receiver_name* has no known annotation.
287
+ """
288
+ for a in (*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs):
289
+ if a.arg == receiver_name and a.annotation is not None:
290
+ cls = _annotation_class_name(a.annotation)
291
+ return cls.rsplit(".", 1)[-1] if cls else None
292
+
293
+ for stmt in _own_scope_nodes(fn):
294
+ if (
295
+ isinstance(stmt, ast.AnnAssign)
296
+ and isinstance(stmt.target, ast.Name)
297
+ and stmt.target.id == receiver_name
298
+ ):
299
+ cls = _annotation_class_name(stmt.annotation)
300
+ return cls.rsplit(".", 1)[-1] if cls else None
301
+
302
+ return None
303
+
304
+
215
305
  # ============================================================================
216
306
  # Core extraction logic
217
307
  # ============================================================================
@@ -467,6 +557,8 @@ def extract_repo(
467
557
  if not callee:
468
558
  continue
469
559
 
560
+ receiver_class: str | None = None
561
+
470
562
  # resolution rules (LOCKED)
471
563
  if callee in module_locals[module]:
472
564
  dst_id = module_locals[module][callee]
@@ -481,6 +573,15 @@ def extract_repo(
481
573
  dst_id = module_class_methods[module].get(meth) or f"sym:{callee}"
482
574
  else:
483
575
  dst_id = f"sym:{callee}"
576
+ # A plain local variable/parameter receiver, not self/cls and
577
+ # not a module-level import alias -- the one case an
578
+ # annotation can disambiguate. self./cls. never reach here.
579
+ if (
580
+ isinstance(n.func, ast.Attribute)
581
+ and isinstance(n.func.value, ast.Name)
582
+ and n.func.value.id not in ("self", "cls")
583
+ ):
584
+ receiver_class = _receiver_annotation(fn, n.func.value.id)
484
585
 
485
586
  if dst_id.startswith("sym:"):
486
587
  nodes.setdefault(
@@ -494,6 +595,7 @@ def extract_repo(
494
595
  None,
495
596
  None,
496
597
  None,
598
+ receiver_class,
497
599
  ),
498
600
  )
499
601