codegraph-ir 0.1.0__tar.gz → 0.2.0__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 (136) hide show
  1. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/.github/workflows/release.yml +3 -1
  2. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/Code-IR.md +6 -1
  3. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/PKG-INFO +3 -2
  4. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/README.md +1 -1
  5. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/languages.md +1 -0
  6. codegraph_ir-0.2.0/docs/plan-0.2-0.4.md +109 -0
  7. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/roadmap.md +2 -0
  8. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/status.md +6 -1
  9. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/pyproject.toml +2 -1
  10. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/schemas/component_spec.schema.json +6 -0
  11. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/__init__.py +1 -1
  12. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/symbols.py +27 -0
  13. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/cli.py +139 -11
  14. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/json_export.py +22 -0
  15. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/hooks.py +11 -6
  16. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/ir/component_spec.py +2 -0
  17. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/base.py +59 -0
  18. codegraph_ir-0.2.0/src/cgir/languages/go.py +516 -0
  19. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/python.py +48 -10
  20. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/registry.py +4 -1
  21. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/typescript.py +89 -9
  22. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/pipeline.py +10 -2
  23. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/diff.py +75 -2
  24. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/impact.py +33 -0
  25. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/pack.py +3 -0
  26. codegraph_ir-0.2.0/src/cgir/report/pins.py +116 -0
  27. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/slicing/slicer.py +2 -0
  28. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/sources/tree_sitter_source.py +1 -0
  29. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/verify.py +7 -0
  30. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/watch.py +3 -2
  31. codegraph_ir-0.2.0/tests/integration/test_init.py +73 -0
  32. codegraph_ir-0.2.0/tests/unit/test_go_adapter.py +189 -0
  33. codegraph_ir-0.2.0/tests/unit/test_impact_run.py +64 -0
  34. codegraph_ir-0.2.0/tests/unit/test_pins.py +207 -0
  35. codegraph_ir-0.2.0/tests/unit/test_shapes.py +126 -0
  36. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/.github/workflows/ci.yml +0 -0
  37. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/.gitignore +0 -0
  38. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/.pre-commit-config.yaml +0 -0
  39. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/.python-version +0 -0
  40. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/CLAUDE.md +0 -0
  41. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/LICENSE +0 -0
  42. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/RELEASING.md +0 -0
  43. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/action.yml +0 -0
  44. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/README.md +0 -0
  45. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/architecture-rules.md +0 -0
  46. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/architecture.md +0 -0
  47. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/development.md +0 -0
  48. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/experiment-log.md +0 -0
  49. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/feature-research.md +0 -0
  50. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/gate-noise.md +0 -0
  51. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/github-action.md +0 -0
  52. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/docs/strategy.md +0 -0
  53. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/examples/agent-pr-contract-drift.md +0 -0
  54. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/__init__.py +0 -0
  55. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/call_graph.py +0 -0
  56. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/cfg.py +0 -0
  57. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/effects.py +0 -0
  58. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/entrypoints.py +0 -0
  59. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/param_flow.py +0 -0
  60. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/pdg.py +0 -0
  61. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/purity.py +0 -0
  62. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/analyses/reaching_defs.py +0 -0
  63. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/api/__init__.py +0 -0
  64. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/api/mcp_server.py +0 -0
  65. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/api/server.py +0 -0
  66. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/config.py +0 -0
  67. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/__init__.py +0 -0
  68. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/graphml.py +0 -0
  69. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/html_viz.py +0 -0
  70. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/mermaid.py +0 -0
  71. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/export/neo4j.py +0 -0
  72. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/ir/__init__.py +0 -0
  73. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/ir/edges.py +0 -0
  74. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/ir/graph.py +0 -0
  75. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/ir/nodes.py +0 -0
  76. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/__init__.py +0 -0
  77. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/languages/cache.py +0 -0
  78. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/manifest.py +0 -0
  79. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/py.typed +0 -0
  80. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/regenerate/__init__.py +0 -0
  81. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/regenerate/prompt_pack.py +0 -0
  82. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/regenerate/regenerator.py +0 -0
  83. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/__init__.py +0 -0
  84. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/flow.py +0 -0
  85. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/lint.py +0 -0
  86. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/report/stats.py +0 -0
  87. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/slicing/__init__.py +0 -0
  88. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/sources/__init__.py +0 -0
  89. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/sources/base.py +0 -0
  90. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/sources/codeql_source.py +0 -0
  91. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/sources/joern_source.py +0 -0
  92. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/trace/__init__.py +0 -0
  93. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/src/cgir/trace/trace_map.py +0 -0
  94. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/__init__.py +0 -0
  95. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/conftest.py +0 -0
  96. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/fixtures/python_sample/orchestrator.py +0 -0
  97. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/fixtures/python_sample/pricing.py +0 -0
  98. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/fixtures/ts_sample/api/service.ts +0 -0
  99. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/fixtures/ts_sample/util.ts +0 -0
  100. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/integration/__init__.py +0 -0
  101. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/integration/test_api.py +0 -0
  102. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/integration/test_cli_scan.py +0 -0
  103. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/integration/test_hook.py +0 -0
  104. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/__init__.py +0 -0
  105. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_action.py +0 -0
  106. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_call_graph.py +0 -0
  107. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_cfg.py +0 -0
  108. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_component_spec.py +0 -0
  109. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_diff.py +0 -0
  110. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_effects.py +0 -0
  111. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_entrypoints.py +0 -0
  112. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_graphml.py +0 -0
  113. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_html_viz.py +0 -0
  114. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_impact.py +0 -0
  115. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_ir_graph.py +0 -0
  116. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_language_adapter.py +0 -0
  117. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_lint.py +0 -0
  118. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_manifest.py +0 -0
  119. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_mcp_tools.py +0 -0
  120. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_mermaid.py +0 -0
  121. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_pack.py +0 -0
  122. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_param_flow.py +0 -0
  123. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_pdg.py +0 -0
  124. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_purity.py +0 -0
  125. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_python_di.py +0 -0
  126. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_reaching_defs.py +0 -0
  127. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_regenerator.py +0 -0
  128. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_slicer.py +0 -0
  129. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_source_cache.py +0 -0
  130. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_stats.py +0 -0
  131. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_symbols.py +0 -0
  132. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_trace_map.py +0 -0
  133. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_tree_sitter_source.py +0 -0
  134. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_typescript_adapter.py +0 -0
  135. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_verify.py +0 -0
  136. {codegraph_ir-0.1.0 → codegraph_ir-0.2.0}/tests/unit/test_watch.py +0 -0
@@ -7,7 +7,9 @@ name: release
7
7
 
8
8
  on:
9
9
  push:
10
- tags: ["v*"]
10
+ # Version tags only — the floating `v0` Action-convenience tag must not
11
+ # trigger a release (its stripped form never matches __version__).
12
+ tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
11
13
 
12
14
  jobs:
13
15
  build:
@@ -55,10 +55,15 @@ H --> I[LLM regenerate / test / trace]
55
55
  "inputs":{"type":"array","items":{"type":"string"}},"outputs":{"type":"array","items":{"type":"string"}},
56
56
  "effects":{"type":"array","items":{"type":"string"}},"calls":{"type":"array","items":{"type":"string"}},
57
57
  "reads":{"type":"array","items":{"type":"string"}},"writes":{"type":"array","items":{"type":"string"}},
58
- "purity":{"type":"number"},"algorithm":{"type":"array","items":{"type":"string"}},
58
+ "purity":{"type":"number"},"pins":{"type":"array","items":{"type":"string"}},
59
+ "algorithm":{"type":"array","items":{"type":"string"}},
59
60
  "trace":{"type":"array","items":{"type":"string"}}}}
60
61
  ```
61
62
 
63
+ `pins` are developer-declared invariants extracted from `cgir:` comment
64
+ pragmas (`pure`, `no-<tag>`, `stable-signature`, `frozen`): state pins hold on
65
+ every scan; change pins hold across scan pairs and are always enforced.
66
+
62
67
  **Example Python → ComponentSpec**
63
68
  ```python
64
69
  def add_tax(price: float, rate: float) -> float: return price * (1 + rate)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-ir
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The deterministic contract layer for AI-modified codebases — effects, purity, contracts, and blast radius, with zero LLM calls.
5
5
  Project-URL: Homepage, https://github.com/asonkiya/llm-semantic-compilers
6
6
  Project-URL: Repository, https://github.com/asonkiya/llm-semantic-compilers
@@ -43,6 +43,7 @@ Requires-Python: >=3.11
43
43
  Requires-Dist: jsonschema>=4.21
44
44
  Requires-Dist: networkx>=3.2
45
45
  Requires-Dist: pydantic>=2.6
46
+ Requires-Dist: tree-sitter-go<0.25,>=0.23
46
47
  Requires-Dist: tree-sitter-python<0.24,>=0.23
47
48
  Requires-Dist: tree-sitter-typescript<0.24,>=0.23
48
49
  Requires-Dist: tree-sitter<0.25,>=0.23
@@ -89,7 +90,7 @@ For library/agent use in a project: `uv pip install codegraph-ir`
89
90
  ## The local loop
90
91
 
91
92
  ```bash
92
- cgir scan . # build the .cgir index (Python + TypeScript)
93
+ cgir scan . # build the .cgir index (Python, TypeScript, Go)
93
94
  cgir watch . # keep it live: re-scan + show contract drift on save
94
95
  cgir pack app.service.charge --repo . # minimal context bundle for one component
95
96
  cgir impact app.service.charge # blast radius: affected callers, entrypoints, tests
@@ -23,7 +23,7 @@ For library/agent use in a project: `uv pip install codegraph-ir`
23
23
  ## The local loop
24
24
 
25
25
  ```bash
26
- cgir scan . # build the .cgir index (Python + TypeScript)
26
+ cgir scan . # build the .cgir index (Python, TypeScript, Go)
27
27
  cgir watch . # keep it live: re-scan + show contract drift on save
28
28
  cgir pack app.service.charge --repo . # minimal context bundle for one component
29
29
  cgir impact app.service.charge # blast radius: affected callers, entrypoints, tests
@@ -27,6 +27,7 @@ full test suite green.
27
27
  | 2 | CFG statement classification + field extraction (normalized `StatementDesc`) | ✅ done |
28
28
  | 3 | ingest structural dispatch + attr extraction (normalized `Declaration`s) | ✅ done |
29
29
  | 4 | `TypeScriptAdapter` + per-file language dispatch | ✅ done |
30
+ | 5 | `GoAdapter` (package=directory merge, struct-field DI, panic≙raise) | ✅ done |
30
31
 
31
32
  **Two languages ship (Python, TypeScript); a mixed repo scans both.**
32
33
  Zero grammar node-type strings remain outside `cgir/languages/`. The CFG builder is pure topology over statement
@@ -0,0 +1,109 @@
1
+ # Plan: 0.2 → 0.4 (pins, init, impact --run; data-shape contracts; Go)
2
+
3
+ **Status: all three workstreams landed 2026-07-10** (single sitting; see git
4
+ log). Shipping as one 0.2.0 release rather than three.
5
+
6
+ Three workstreams, landed in this order — each one's machinery feeds the next.
7
+ Depth before width: pins + shapes strengthen the gate for the two languages
8
+ people can use today; Go then inherits all of it.
9
+
10
+ ## Workstream A — "Make it personal" (→ 0.2.0)
11
+
12
+ ### A1. Declarable contracts (pins)
13
+
14
+ Comment pragmas pinning *intent*, enforced everywhere the pipeline runs:
15
+
16
+ ```python
17
+ # cgir: pure
18
+ def score(events): ... # trailing form also supported
19
+ ```
20
+
21
+ Module-level pins at top of file apply to every component in it.
22
+
23
+ Pins split into two classes with different checkers:
24
+
25
+ | pin | class | meaning | checked by |
26
+ |---|---|---|---|
27
+ | `pure` | state (single scan) | kind pure_function, no impure effects incl. transitive | lint, watch |
28
+ | `no-<tag>` | state | tag not in transitive effects (closure over spec.calls) | lint, watch |
29
+ | `stable-signature` | change (scan pair) | signature/outputs may not change | diff, hook, verify, CI |
30
+ | `frozen` | change | no contract field may change; removal is a violation | diff, hook, verify, CI |
31
+
32
+ Change pins are always evaluated — the pin is the opt-in, no --fail-on needed.
33
+ State pins catch violations in newly added code too (drift rules can't).
34
+
35
+ Design: new `report/pins.py` with `state_violations(specs)` and
36
+ `change_violations(old, new)` — pure over specs; transitive effects computed
37
+ by closure over `spec.calls` (no schema change beyond the `pins` field).
38
+ Wire into lint, the diff CLI, hook, verify; pack renders `Pinned: ...` in the
39
+ target header so rewriting agents see the invariant.
40
+
41
+ Plumbing: adapter pragma extraction (generic row-based helper over tree-sitter
42
+ `comment` nodes; `#` / `//` prefixes) → FunctionDecl.pins → node attrs →
43
+ `pins: list[str]` on ComponentSpec (schema in both places + Code-IR.md).
44
+
45
+ Honest limit: a pin is only as strong as effect detection (lexical escapes per
46
+ status.md). Confidence tiers are the future systematic fix.
47
+
48
+ ### A2. `cgir init`
49
+
50
+ One-command onboarding: scan → print "what your repo is" (histogram,
51
+ entrypoints, untested-effectful) → write starter `cgir.toml` (never clobbers
52
+ without --force) → `--hook` installs the seatbelt → append `.cgir/` to
53
+ .gitignore → print MCP + Action next-steps.
54
+
55
+ ### A3. `cgir impact --run`
56
+
57
+ Execute the impact test list: qualname + trace path → pytest nodeid
58
+ (`path::Class::test_x`), subprocess, propagate exit code. Filter to
59
+ pytest-collectable names (covered_by can include fixture helpers). v1 runs
60
+ pytest only; TS prints the command (runner detection is v1.1).
61
+
62
+ ## Workstream C — Data-shape contracts (→ 0.3.0)
63
+
64
+ "The rewrite dropped a field from the returned object" becomes drift — the
65
+ residual failure class in both benchmarks.
66
+
67
+ The DI work already built the foundation: `ClassDecl.fields` (name → type)
68
+ exists in both adapters, and Python's annotated-class-body extraction already
69
+ covers TypedDict/dataclass/pydantic. Additions:
70
+
71
+ - TS: `interface_declaration` / object type-alias members → ClassDecl.fields.
72
+ - Shape lives on the *type* (Class node), not a new spec field. `compute_diff`
73
+ gains a `types` section (added/removed/changed fields per type qualname),
74
+ with `referenced_by` computed via `referenced_type_names` over specs.
75
+ - New violation rule `shape-change`: fires when a drifted type is referenced
76
+ in any component's outputs/inputs.
77
+ - ScanResult carries `types`; diff CLI reads them from repo_graph.json; hook
78
+ and verify inherit.
79
+
80
+ v1 exclusions (stated): inheritance (base-class fields), un-annotated
81
+ dict-literal returns (literal-key fingerprints v1.1), Optional/default
82
+ subtleties beyond the annotation text.
83
+
84
+ ## Workstream B — Go adapter (→ 0.4.0)
85
+
86
+ Lands after C so GoAdapter implements fields/shape extraction as part of its
87
+ contract rather than retrofitting.
88
+
89
+ - package = directory; `type X struct/interface` → Class (struct members →
90
+ fields — Go composition maps directly onto the DI machinery); method
91
+ receivers attach methods to the receiver's Class; `raise` ≙ `panic(` only
92
+ (Go errors are values).
93
+ - Effects: net (net/http), fs (os.*), db (database/sql receivers ×
94
+ Query/Exec), io (fmt.Print*/log.*), nondeterm (time.Now/rand/uuid).
95
+ - Same-package cross-file calls (no import in Go): symbol tables for go
96
+ modules sharing a directory are merged (keyed on the module language attr).
97
+ - v1 known limits (documented): cross-package import resolution needs go.mod
98
+ module-prefix stripping (follow-up); struct-in-one-file/methods-in-another
99
+ weakens field DI; `select`/`defer` CFG ordering approximated; `go f()` is a
100
+ call site (future concurrency-effect hook).
101
+ - Validation ritual: fixture tests mirroring the TS suite, scan a real OSS Go
102
+ repo, byte-identical self-diff on Python/TS indexes.
103
+
104
+ ## Cross-cutting
105
+
106
+ Every spec-vocabulary change updates Code-IR.md §Data model + both schema
107
+ locations + a schema test. Red-green throughout. Each workstream ships as a
108
+ minor release (0.2.0, 0.3.0, 0.4.0) — release mechanics are push-button
109
+ (RELEASING.md).
@@ -1,5 +1,7 @@
1
1
  # Roadmap
2
2
 
3
+ **Active plan: [`plan-0.2-0.4.md`](./plan-0.2-0.4.md)** — pins/init/impact-run (0.2.0), data-shape contracts (0.3.0), Go adapter (0.4.0).
4
+
3
5
  Forward-looking sequencing. The grouping mirrors `Code-IR.md` §Architecture: **P0** is "you can produce a `ComponentSpec`," **P1** is "you can trust it," **P2** is "you can scale it." Within each tier the order below reflects current dependencies, not strict chronology — feel free to interleave when dependencies allow.
4
6
 
5
7
  ## Done
@@ -66,6 +66,11 @@ cgir scan tests/fixtures/python_sample --out /tmp/cgir-out
66
66
  | Python DI field types (`__init__` annotations, class-level annotations, direct construction) → `self.<field>.<method>()` resolution + `self.`/`this.` receiver in packs | done | `src/cgir/languages/python.py:_class_fields`, `cli._call_receivers` |
67
67
  | Per-component `language` on specs (multi-language repos) | done | `src/cgir/slicing/slicer.py:_node_language` |
68
68
  | Packaging: distribution `codegraph-ir` (import + CLI stay `cgir`), py.typed, tag-triggered PyPI release via trusted publishing | done | `pyproject.toml`, `.github/workflows/release.yml`, `RELEASING.md` |
69
+ | Declarable contracts (`cgir:` pins — pure/no-tag/stable-signature/frozen) enforced by lint/diff/hook/verify; rendered in pack | done | `src/cgir/report/pins.py`, `languages/base.py:PinIndex` |
70
+ | `cgir init` (scan summary, starter cgir.toml, .gitignore, `--hook`) | done | `src/cgir/cli.py:init` |
71
+ | `cgir impact --run` (execute the blast-radius tests via pytest node-ids) | done | `report/impact.py:runnable_selectors` |
72
+ | Data-shape contracts (`types` diff section + opt-in `shape-change` rule; TS interfaces/type-aliases, Py TypedDict/dataclass) | done | `report/diff.py:_type_shape_changes`, `json_export.read_types` |
73
+ | Go adapter (package=dir symbol merge, struct-field DI, panic≙raise, effects tables) | done | `src/cgir/languages/go.py` |
69
74
  | Entrypoint recognition (HTTP/CLI/task decorators) | done | `src/cgir/analyses/entrypoints.py` |
70
75
  | Context packer (`cgir pack`, budget-aware, type closure + docstrings + raises) | done | `src/cgir/report/pack.py` |
71
76
  | Docstring / raises / module-variable extraction | done | `tree_sitter_source._docstring_text`, `_raised_names`, `_add_module_variables` |
@@ -85,7 +90,7 @@ cgir scan tests/fixtures/python_sample --out /tmp/cgir-out
85
90
 
86
91
  ## Test coverage
87
92
 
88
- `pytest -q` runs 391 tests, all green (the table below lists the load-bearing files; newer suites — `test_impact.py`, `test_watch.py`, `test_hook.py`, `test_python_di.py`, `test_typescript_adapter.py`, `test_diff.py` — are described by their docstrings):
93
+ `pytest -q` runs 444 tests, all green (the table below lists the load-bearing files; newer suites — `test_impact.py`, `test_watch.py`, `test_hook.py`, `test_python_di.py`, `test_typescript_adapter.py`, `test_diff.py` — are described by their docstrings):
89
94
 
90
95
  | File | Covers |
91
96
  |---|---|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  # Distribution name (the import package and CLI command are both `cgir`;
7
7
  # the PyPI project name `cgir` was already taken, so we ship as codegraph-ir).
8
8
  name = "codegraph-ir"
9
- version = "0.1.0"
9
+ version = "0.2.0"
10
10
  description = "The deterministic contract layer for AI-modified codebases — effects, purity, contracts, and blast radius, with zero LLM calls."
11
11
  readme = "README.md"
12
12
  license = { file = "LICENSE" }
@@ -43,6 +43,7 @@ dependencies = [
43
43
  "tree-sitter>=0.23,<0.25",
44
44
  "tree-sitter-python>=0.23,<0.24",
45
45
  "tree-sitter-typescript>=0.23,<0.24",
46
+ "tree-sitter-go>=0.23,<0.25",
46
47
  "jsonschema>=4.21",
47
48
  ]
48
49
 
@@ -107,6 +107,12 @@
107
107
  "items": {
108
108
  "type": "string"
109
109
  }
110
+ },
111
+ "pins": {
112
+ "type": "array",
113
+ "items": {
114
+ "type": "string"
115
+ }
110
116
  }
111
117
  },
112
118
  "additionalProperties": false
@@ -3,4 +3,4 @@
3
3
  See ``Code-IR.md`` at the repo root for the product specification.
4
4
  """
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.0"
@@ -41,6 +41,8 @@ def build_symbol_tables(graph: RepoGraph) -> dict[str, SymbolTable]:
41
41
  table.bindings[child.name] = child.id
42
42
  tables[module.id] = table
43
43
 
44
+ _merge_go_packages(graph, tables)
45
+
44
46
  for module in graph.nodes(NodeKind.Module):
45
47
  table = tables[module.id]
46
48
  for child in graph.children(module.id, NodeKind.Import):
@@ -52,6 +54,31 @@ def build_symbol_tables(graph: RepoGraph) -> dict[str, SymbolTable]:
52
54
  return tables
53
55
 
54
56
 
57
+ def _merge_go_packages(graph: RepoGraph, tables: dict[str, SymbolTable]) -> None:
58
+ """Go's package scope: files in one directory share top-level names.
59
+
60
+ Modules are per-file, but Go code calls sibling-file functions with no
61
+ import. Union the local bindings of go modules that share a directory
62
+ (existing bindings win, so a file's own names shadow siblings').
63
+ """
64
+ by_dir: dict[str, list[Node]] = {}
65
+ for module in graph.nodes(NodeKind.Module):
66
+ if module.attrs.get("language") != "go" or not module.path:
67
+ continue
68
+ directory = module.path.rsplit("/", 1)[0] if "/" in module.path else ""
69
+ by_dir.setdefault(directory, []).append(module)
70
+ for modules in by_dir.values():
71
+ if len(modules) < 2:
72
+ continue
73
+ union: dict[str, str | None] = {}
74
+ for module in modules:
75
+ union.update(tables[module.id].bindings)
76
+ for module in modules:
77
+ merged = dict(union)
78
+ merged.update(tables[module.id].bindings) # own names shadow siblings
79
+ tables[module.id].bindings = merged
80
+
81
+
55
82
  def _resolve_target(qualname_index: dict[str, str], target: str) -> str | None:
56
83
  """Exact qualname match, else a *unique* suffix match.
57
84
 
@@ -188,6 +188,75 @@ def stats(
188
188
  typer.echo(render_text(result), nl=False)
189
189
 
190
190
 
191
+ _STARTER_CONFIG = """\
192
+ # CGIR architecture rules — see docs/architecture-rules.md.
193
+ # Pins (`# cgir: pure`, `no-net`, `stable-signature`, `frozen`) need no config;
194
+ # they are enforced by `cgir lint`, the pre-commit hook, and `cgir diff`.
195
+ #
196
+ # [[rule]]
197
+ # name = "core stays pure"
198
+ # in = "app.core.*"
199
+ # forbid-effect = ["net", "db"]
200
+ """
201
+
202
+ _NEXT_STEPS = """\
203
+ Next steps:
204
+ cgir watch {repo} # live index + contract drift on save
205
+ cgir hook install # pre-commit seatbelt (or: cgir init --hook)
206
+ cgir mcp --index {index} # serve the index to your agent over MCP
207
+ CI gate: see docs/github-action.md
208
+ """
209
+
210
+
211
+ @app.command()
212
+ def init(
213
+ repo: Annotated[Path, typer.Argument(exists=True, file_okay=False)] = Path("."),
214
+ hook: Annotated[
215
+ bool, typer.Option("--hook", help="Also install the pre-commit seatbelt.")
216
+ ] = False,
217
+ force: Annotated[
218
+ bool, typer.Option("--force", help="Overwrite an existing cgir.toml.")
219
+ ] = False,
220
+ ) -> None:
221
+ """One-command onboarding: scan, starter config, .gitignore, next steps."""
222
+ result = scan_repo(repo, repo / ".cgir")
223
+ typer.echo(f"Indexed {len(result.specs)} components:")
224
+ _print_kind_histogram(result.specs)
225
+ entrypoints = [s for s in result.specs if s.entrypoint]
226
+ if entrypoints:
227
+ typer.echo(f" entrypoints: {len(entrypoints)}")
228
+ untested = [s for s in result.specs if s.effects and not s.covered_by]
229
+ if untested:
230
+ typer.echo(f" effectful without linked tests: {len(untested)}")
231
+
232
+ config = repo / "cgir.toml"
233
+ if config.exists() and not force:
234
+ typer.echo(f"kept existing {config.name}")
235
+ else:
236
+ config.write_text(_STARTER_CONFIG)
237
+ typer.echo(f"wrote {config.name} (report-only starter)")
238
+
239
+ gitignore = repo / ".gitignore"
240
+ existing = gitignore.read_text() if gitignore.exists() else ""
241
+ if ".cgir/" not in existing:
242
+ gitignore.write_text(
243
+ existing + ("" if existing.endswith("\n") or not existing else "\n") + ".cgir/\n"
244
+ )
245
+ typer.echo("added .cgir/ to .gitignore")
246
+
247
+ if hook:
248
+ from cgir.hooks import install as install_hook
249
+
250
+ try:
251
+ path = install_hook(repo)
252
+ typer.echo(f"installed contract seatbelt at {path}")
253
+ except FileExistsError:
254
+ typer.echo("pre-commit hook already exists — skipped (cgir hook install --force)")
255
+
256
+ typer.echo("")
257
+ typer.echo(_NEXT_STEPS.format(repo=repo, index=repo / ".cgir"), nl=False)
258
+
259
+
191
260
  @app.command()
192
261
  def watch(
193
262
  repo: Annotated[Path, typer.Argument(exists=True, file_okay=False)] = Path("."),
@@ -243,12 +312,20 @@ def impact(
243
312
  ),
244
313
  ] = None,
245
314
  repo: Annotated[
246
- Path | None, typer.Option("--repo", help="Repo root (with --candidate).")
315
+ Path | None,
316
+ typer.Option("--repo", help="Repo root (with --candidate or --run)."),
247
317
  ] = None,
318
+ run: Annotated[
319
+ bool,
320
+ typer.Option(
321
+ "--run", help="Execute the selected tests (pytest) and exit with their status."
322
+ ),
323
+ ] = False,
248
324
  ) -> None:
249
325
  """Blast radius of changing a component: affected callers, entrypoints at risk, tests to run.
250
326
 
251
327
  Worst-case by default; narrowed by --changed or by a --candidate's actual contract delta.
328
+ --run executes exactly the tests the radius names.
252
329
  """
253
330
  specs = _load_specs(index_dir)
254
331
  try:
@@ -273,6 +350,35 @@ def impact(
273
350
  )
274
351
  except KeyError as exc:
275
352
  raise typer.BadParameter(f"Unknown component: {component_id}") from exc
353
+ if run:
354
+ _run_impact_tests(specs, list(data["tests"]), repo or Path.cwd())
355
+
356
+
357
+ def _run_impact_tests(specs: list[ComponentSpec], test_ids: list[str], repo: Path) -> None:
358
+ """Execute the impact-selected tests with pytest; exit with their status."""
359
+ import subprocess
360
+ import sys
361
+
362
+ from cgir.report.impact import runnable_selectors
363
+
364
+ selectors, skipped = runnable_selectors(specs, test_ids)
365
+ for test_id in skipped:
366
+ typer.echo(f" (skipped, not pytest-runnable: {test_id})")
367
+ if not selectors:
368
+ typer.echo("no runnable tests selected.")
369
+ return
370
+ typer.echo(f"running {len(selectors)} test(s):")
371
+ proc = subprocess.run(
372
+ [sys.executable, "-m", "pytest", "-q", *selectors],
373
+ cwd=repo,
374
+ capture_output=True,
375
+ text=True,
376
+ )
377
+ typer.echo(proc.stdout, nl=False)
378
+ if proc.stderr:
379
+ typer.echo(proc.stderr, nl=False)
380
+ if proc.returncode != 0:
381
+ raise typer.Exit(code=proc.returncode)
276
382
 
277
383
 
278
384
  @app.command()
@@ -458,26 +564,36 @@ def lint(
458
564
  config: Annotated[Path, typer.Option("--config", help="Rules file.")] = Path("cgir.toml"),
459
565
  as_json: Annotated[bool, typer.Option("--json")] = False,
460
566
  ) -> None:
461
- """Check the index against semantic architecture rules (effects, kind, calls)."""
567
+ """Check the index against semantic architecture rules (effects, kind, calls).
568
+
569
+ Pin invariants (`# cgir: pure`, `no-<tag>`) are always checked — no rules
570
+ file needed for those.
571
+ """
462
572
  from cgir.report.lint import lint as run_lint
463
573
  from cgir.report.lint import load_rules, render_lint
574
+ from cgir.report.pins import state_violations
464
575
 
465
- if not config.exists():
466
- raise typer.BadParameter(f"No rules file at {config}")
467
- violations = run_lint(_load_specs(index_dir), load_rules(config))
576
+ specs = _load_specs(index_dir)
577
+ violations = run_lint(specs, load_rules(config)) if config.exists() else []
578
+ pin_lines = state_violations(specs)
468
579
  if as_json:
469
580
  typer.echo(
470
581
  json.dumps(
471
- [
472
- {"rule": v.rule, "component": v.component, "detail": v.detail}
473
- for v in violations
582
+ [{"rule": v.rule, "component": v.component, "detail": v.detail} for v in violations]
583
+ + [
584
+ {"rule": "pin", "component": line.split(":")[0], "detail": line}
585
+ for line in pin_lines
474
586
  ],
475
587
  indent=2,
476
588
  )
477
589
  )
478
590
  else:
479
591
  typer.echo(render_lint(violations), nl=False)
480
- if violations:
592
+ if pin_lines:
593
+ typer.echo(f"pin violations ({len(pin_lines)}):")
594
+ for line in pin_lines:
595
+ typer.echo(f" ! {line}")
596
+ if violations or pin_lines:
481
597
  raise typer.Exit(code=1)
482
598
 
483
599
 
@@ -562,10 +678,22 @@ def diff(
562
678
  ),
563
679
  ] = None,
564
680
  ) -> None:
565
- """Compare two scan indexes: added/removed components and contract drift."""
681
+ """Compare two scan indexes: added/removed components and contract drift.
682
+
683
+ Pin invariants are always enforced: change pins (stable-signature, frozen)
684
+ across the pair, state pins (pure, no-<tag>) on the new index.
685
+ """
686
+ from cgir.export.json_export import read_types
687
+ from cgir.report.pins import change_violations, state_violations
688
+
566
689
  warning = compatibility_warning(read_manifest(old_index), read_manifest(new_index))
567
- result = compute_diff(_load_specs(old_index), _load_specs(new_index))
690
+ old_specs, new_specs = _load_specs(old_index), _load_specs(new_index)
691
+ result = compute_diff(
692
+ old_specs, new_specs, old_types=read_types(old_index), new_types=read_types(new_index)
693
+ )
568
694
  found = violations(result, list(fail_on or []))
695
+ found += change_violations(old_specs, new_specs)
696
+ found += state_violations(new_specs)
569
697
  if as_json:
570
698
  payload = dict(result)
571
699
  if warning:
@@ -19,6 +19,28 @@ def read_specs(index_dir: Path) -> list[ComponentSpec]:
19
19
  ]
20
20
 
21
21
 
22
+ def read_types(index_dir: Path) -> dict[str, dict[str, str]]:
23
+ """Type shapes (qualname -> field name -> type text) from an index's graph.
24
+
25
+ Sourced from Class nodes' ``fields`` attr — TypedDict/dataclass/pydantic
26
+ bodies and TS interfaces/type-aliases. Empty-field types are skipped.
27
+ """
28
+ graph_path = index_dir / "repo_graph.json"
29
+ if not graph_path.exists():
30
+ return {}
31
+ data = json.loads(graph_path.read_text())
32
+ out: dict[str, dict[str, str]] = {}
33
+ for node in data.get("nodes", []):
34
+ if node.get("kind") != "Class":
35
+ continue
36
+ attrs = node.get("attrs") or {}
37
+ fields = attrs.get("fields")
38
+ qual = attrs.get("qualname")
39
+ if isinstance(fields, dict) and fields and isinstance(qual, str):
40
+ out[qual] = {str(k): str(v) for k, v in fields.items()}
41
+ return out
42
+
43
+
22
44
  def write_index(out_dir: Path, graph: RepoGraph, specs: list[ComponentSpec]) -> None:
23
45
  out_dir.mkdir(parents=True, exist_ok=True)
24
46
  (out_dir / "repo_graph.json").write_text(
@@ -32,6 +32,7 @@ from cgir.languages import ADAPTERS
32
32
  from cgir.pipeline import scan_repo
33
33
  from cgir.report.diff import compute_diff, render_diff, violations
34
34
  from cgir.report.impact import compute_typed_impact
35
+ from cgir.report.pins import change_violations, state_violations
35
36
 
36
37
  # The low-noise default from the real-history noise measurement: fail only
37
38
  # when something starts — or silently stops — reaching the outside world.
@@ -78,15 +79,16 @@ def _staged_supported(repo: Path) -> bool:
78
79
  return any(Path(n).suffix in _SUPPORTED_EXTS for n in names)
79
80
 
80
81
 
81
- def _specs_of_tree(repo: Path, tree: str) -> list[ComponentSpec]:
82
- """Scan the content of a git tree-ish out-of-tree."""
82
+ def _scan_tree(repo: Path, tree: str) -> tuple[list[ComponentSpec], dict[str, dict[str, str]]]:
83
+ """Scan the content of a git tree-ish out-of-tree: (specs, type shapes)."""
83
84
  with tempfile.TemporaryDirectory() as td:
84
85
  src = Path(td) / "src"
85
86
  src.mkdir()
86
87
  archive = subprocess.check_output(["git", "-C", str(repo), "archive", tree])
87
88
  subprocess.run(["tar", "-x", "-C", str(src)], input=archive, check=True)
88
89
  with tempfile.TemporaryDirectory() as od:
89
- return scan_repo(src, out=Path(od)).specs
90
+ result = scan_repo(src, out=Path(od))
91
+ return result.specs, result.types
90
92
 
91
93
 
92
94
  def run_check(repo: Path, fail_on: list[str] | None = None) -> HookResult:
@@ -103,13 +105,16 @@ def run_check(repo: Path, fail_on: list[str] | None = None) -> HookResult:
103
105
  if head_tree == staged_tree:
104
106
  return HookResult(checked=False)
105
107
 
106
- base_specs = _specs_of_tree(repo, head_tree) if head_tree else []
107
- head_specs = _specs_of_tree(repo, staged_tree)
108
+ base_specs, base_types = _scan_tree(repo, head_tree) if head_tree else ([], {})
109
+ head_specs, head_types = _scan_tree(repo, staged_tree)
108
110
  except Exception as exc: # never block a commit because of our own bug
109
111
  return HookResult(checked=False, error=f"{type(exc).__name__}: {exc}")
110
112
 
111
- diff = compute_diff(base_specs, head_specs)
113
+ diff = compute_diff(base_specs, head_specs, old_types=base_types, new_types=head_types)
112
114
  found = violations(diff, rules)
115
+ # Pin invariants are always enforced, independent of --fail-on rules.
116
+ found += change_violations(base_specs, head_specs)
117
+ found += state_violations(head_specs)
113
118
  changed = [c["id"] for c in diff["changed"]]
114
119
 
115
120
  tests: set[str] = set()
@@ -41,6 +41,7 @@ COMPONENT_SPEC_SCHEMA: dict[str, Any] = {
41
41
  "reads": {"type": "array", "items": {"type": "string"}},
42
42
  "writes": {"type": "array", "items": {"type": "string"}},
43
43
  "purity": {"type": "number", "minimum": 0, "maximum": 1},
44
+ "pins": {"type": "array", "items": {"type": "string"}},
44
45
  "algorithm": {"type": "array", "items": {"type": "string"}},
45
46
  "trace": {"type": "array", "items": {"type": "string"}},
46
47
  },
@@ -75,6 +76,7 @@ class ComponentSpec:
75
76
  reads: list[str] = field(default_factory=list)
76
77
  writes: list[str] = field(default_factory=list)
77
78
  purity: float | None = None
79
+ pins: list[str] = field(default_factory=list)
78
80
  algorithm: list[str] = field(default_factory=list)
79
81
 
80
82
  def to_dict(self) -> dict[str, Any]: