codegraph-ir 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 (136) hide show
  1. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/PKG-INFO +1 -1
  2. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/pyproject.toml +1 -1
  3. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/__init__.py +1 -1
  4. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/cfg.py +15 -1
  5. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/base.py +8 -0
  6. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/go.py +4 -1
  7. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/python.py +29 -1
  8. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/typescript.py +13 -1
  9. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_pins.py +24 -0
  10. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_slicer.py +66 -0
  11. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/.github/workflows/ci.yml +0 -0
  12. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/.github/workflows/release.yml +0 -0
  13. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/.gitignore +0 -0
  14. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/.pre-commit-config.yaml +0 -0
  15. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/.python-version +0 -0
  16. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/CLAUDE.md +0 -0
  17. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/Code-IR.md +0 -0
  18. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/LICENSE +0 -0
  19. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/README.md +0 -0
  20. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/RELEASING.md +0 -0
  21. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/action.yml +0 -0
  22. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/README.md +0 -0
  23. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/architecture-rules.md +0 -0
  24. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/architecture.md +0 -0
  25. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/development.md +0 -0
  26. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/experiment-log.md +0 -0
  27. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/feature-research.md +0 -0
  28. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/gate-noise.md +0 -0
  29. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/github-action.md +0 -0
  30. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/languages.md +0 -0
  31. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/plan-0.2-0.4.md +0 -0
  32. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/roadmap.md +0 -0
  33. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/status.md +0 -0
  34. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/docs/strategy.md +0 -0
  35. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/examples/agent-pr-contract-drift.md +0 -0
  36. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/schemas/component_spec.schema.json +0 -0
  37. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/__init__.py +0 -0
  38. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/call_graph.py +0 -0
  39. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/effects.py +0 -0
  40. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/entrypoints.py +0 -0
  41. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/param_flow.py +0 -0
  42. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/pdg.py +0 -0
  43. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/purity.py +0 -0
  44. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/reaching_defs.py +0 -0
  45. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/analyses/symbols.py +0 -0
  46. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/api/__init__.py +0 -0
  47. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/api/mcp_server.py +0 -0
  48. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/api/server.py +0 -0
  49. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/cli.py +0 -0
  50. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/config.py +0 -0
  51. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/__init__.py +0 -0
  52. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/graphml.py +0 -0
  53. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/html_viz.py +0 -0
  54. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/json_export.py +0 -0
  55. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/mermaid.py +0 -0
  56. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/export/neo4j.py +0 -0
  57. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/hooks.py +0 -0
  58. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/ir/__init__.py +0 -0
  59. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/ir/component_spec.py +0 -0
  60. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/ir/edges.py +0 -0
  61. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/ir/graph.py +0 -0
  62. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/ir/nodes.py +0 -0
  63. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/__init__.py +0 -0
  64. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/cache.py +0 -0
  65. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/languages/registry.py +0 -0
  66. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/manifest.py +0 -0
  67. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/pipeline.py +0 -0
  68. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/py.typed +0 -0
  69. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/regenerate/__init__.py +0 -0
  70. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/regenerate/prompt_pack.py +0 -0
  71. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/regenerate/regenerator.py +0 -0
  72. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/__init__.py +0 -0
  73. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/diff.py +0 -0
  74. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/flow.py +0 -0
  75. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/impact.py +0 -0
  76. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/lint.py +0 -0
  77. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/pack.py +0 -0
  78. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/pins.py +0 -0
  79. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/report/stats.py +0 -0
  80. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/slicing/__init__.py +0 -0
  81. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/slicing/slicer.py +0 -0
  82. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/sources/__init__.py +0 -0
  83. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/sources/base.py +0 -0
  84. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/sources/codeql_source.py +0 -0
  85. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/sources/joern_source.py +0 -0
  86. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/sources/tree_sitter_source.py +0 -0
  87. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/trace/__init__.py +0 -0
  88. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/trace/trace_map.py +0 -0
  89. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/verify.py +0 -0
  90. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/src/cgir/watch.py +0 -0
  91. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/__init__.py +0 -0
  92. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/conftest.py +0 -0
  93. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/fixtures/python_sample/orchestrator.py +0 -0
  94. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/fixtures/python_sample/pricing.py +0 -0
  95. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/fixtures/ts_sample/api/service.ts +0 -0
  96. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/fixtures/ts_sample/util.ts +0 -0
  97. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/integration/__init__.py +0 -0
  98. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/integration/test_api.py +0 -0
  99. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/integration/test_cli_scan.py +0 -0
  100. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/integration/test_hook.py +0 -0
  101. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/integration/test_init.py +0 -0
  102. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/__init__.py +0 -0
  103. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_action.py +0 -0
  104. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_call_graph.py +0 -0
  105. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_cfg.py +0 -0
  106. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_component_spec.py +0 -0
  107. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_diff.py +0 -0
  108. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_effects.py +0 -0
  109. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_entrypoints.py +0 -0
  110. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_go_adapter.py +0 -0
  111. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_graphml.py +0 -0
  112. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_html_viz.py +0 -0
  113. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_impact.py +0 -0
  114. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_impact_run.py +0 -0
  115. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_ir_graph.py +0 -0
  116. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_language_adapter.py +0 -0
  117. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_lint.py +0 -0
  118. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_manifest.py +0 -0
  119. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_mcp_tools.py +0 -0
  120. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_mermaid.py +0 -0
  121. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_pack.py +0 -0
  122. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_param_flow.py +0 -0
  123. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_pdg.py +0 -0
  124. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_purity.py +0 -0
  125. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_python_di.py +0 -0
  126. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_reaching_defs.py +0 -0
  127. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_regenerator.py +0 -0
  128. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_shapes.py +0 -0
  129. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_source_cache.py +0 -0
  130. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_stats.py +0 -0
  131. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_symbols.py +0 -0
  132. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_trace_map.py +0 -0
  133. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_tree_sitter_source.py +0 -0
  134. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_typescript_adapter.py +0 -0
  135. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_verify.py +0 -0
  136. {codegraph_ir-0.2.0 → codegraph_ir-0.2.1}/tests/unit/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-ir
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -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.2.0"
9
+ version = "0.2.1"
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" }
@@ -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.2.0"
6
+ __version__ = "0.2.1"
@@ -79,7 +79,13 @@ def build(graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = N
79
79
  body = file_adapter.function_body(func_ts)
80
80
  if body is None:
81
81
  continue
82
- builder = _CFGBuilder(graph=graph, owner=func, source=source, adapter=file_adapter)
82
+ builder = _CFGBuilder(
83
+ graph=graph,
84
+ owner=func,
85
+ source=source,
86
+ adapter=file_adapter,
87
+ global_names=file_adapter.global_declared_names(func_ts, source),
88
+ )
83
89
  builder.build_block(body, predecessors=[func.id], controller=None)
84
90
 
85
91
 
@@ -89,6 +95,9 @@ class _CFGBuilder:
89
95
  owner: Node
90
96
  source: bytes
91
97
  adapter: LanguageAdapter
98
+ # names declared global/nonlocal in this function: writes to them mutate
99
+ # outer state and are recorded as `mutates`, not local `writes`.
100
+ global_names: set[str] = field(default_factory=set)
92
101
  _counter: int = field(default=0, init=False)
93
102
 
94
103
  def build_block(
@@ -102,6 +111,11 @@ class _CFGBuilder:
102
111
 
103
112
  def build_stmt(self, ts_node: TSNode, preds: list[str], controller: str | None) -> list[str]:
104
113
  desc = self.adapter.describe_statement(ts_node, self.source)
114
+ if isinstance(desc, AssignDesc) and self.global_names:
115
+ outer = [w for w in desc.writes if w in self.global_names]
116
+ if outer:
117
+ desc.writes = [w for w in desc.writes if w not in self.global_names]
118
+ desc.mutates = list(desc.mutates) + outer
105
119
  if isinstance(desc, BranchDesc):
106
120
  return self._build_branch(ts_node, desc, preds, controller)
107
121
  if isinstance(desc, LoopDesc):
@@ -264,6 +264,14 @@ class LanguageAdapter(ABC):
264
264
  def describe_statement(self, node: TSNode, source: bytes) -> StatementDesc:
265
265
  """Classify one statement and extract its parts (see descriptors)."""
266
266
 
267
+ def global_declared_names(self, func_node: TSNode, source: bytes) -> set[str]:
268
+ """Names this function declares as outer-scope (`global`/`nonlocal`).
269
+
270
+ Assignments to these names mutate state *outside* the function, so
271
+ the CFG builder records them as ``mutates`` rather than local
272
+ ``writes``. Default: none (TS/Go have no such declaration form)."""
273
+ return set()
274
+
267
275
  # --- phase 3: ingest extraction ------------------------------------------
268
276
 
269
277
  @abstractmethod
@@ -295,8 +295,11 @@ class GoAdapter(LanguageAdapter):
295
295
  first_decl = next(
296
296
  (c for c in root.named_children if c.type not in {"comment", "package_clause"}), None
297
297
  )
298
+ pinnable = {"function_declaration", "method_declaration", "type_declaration"}
298
299
  module_pins = pin_index.module_pins(
299
- first_decl.start_point[0] if first_decl is not None else None
300
+ first_decl.start_point[0]
301
+ if first_decl is not None and first_decl.type in pinnable
302
+ else None
300
303
  )
301
304
  if module_pins:
302
305
  for decl in decls:
@@ -205,6 +205,28 @@ class PythonAdapter(LanguageAdapter):
205
205
  def function_body(self, func_node: TSNode) -> TSNode | None:
206
206
  return func_node.child_by_field_name("body")
207
207
 
208
+ def global_declared_names(self, func_node: TSNode, source: bytes) -> set[str]:
209
+ """Names bound by `global`/`nonlocal` in *this* function's own body.
210
+
211
+ Nested function bodies are skipped — their declarations apply to
212
+ their own CFG walk, not this one."""
213
+ names: set[str] = set()
214
+ body = func_node.child_by_field_name("body")
215
+ if body is None:
216
+ return names
217
+ stack: list[TSNode] = [body]
218
+ while stack:
219
+ node = stack.pop()
220
+ if node.type in {"function_definition", "class_definition"}:
221
+ continue # a nested scope's declarations are not ours
222
+ if node.type in {"global_statement", "nonlocal_statement"}:
223
+ for child in node.named_children:
224
+ if child.type == "identifier":
225
+ names.add(_text(child, source))
226
+ continue
227
+ stack.extend(node.named_children)
228
+ return names
229
+
208
230
  def block_statements(self, block: TSNode) -> list[TSNode]:
209
231
  return [c for c in block.named_children if c.type != "comment"]
210
232
 
@@ -335,8 +357,14 @@ class PythonAdapter(LanguageAdapter):
335
357
  for child in root.children:
336
358
  decls.extend(self._top_level_decl(child, source, module_name, pin_index))
337
359
  first_decl = next((c for c in root.children if c.type != "comment"), None)
360
+ # A header block only belongs to the first statement when that
361
+ # statement is *pinnable* (a definition) — touching an import keeps
362
+ # the pins module-level.
363
+ pinnable = {"function_definition", "class_definition", "decorated_definition"}
338
364
  module_pins = pin_index.module_pins(
339
- first_decl.start_point[0] if first_decl is not None else None
365
+ first_decl.start_point[0]
366
+ if first_decl is not None and first_decl.type in pinnable
367
+ else None
340
368
  )
341
369
  if module_pins:
342
370
  for decl in decls:
@@ -391,8 +391,20 @@ class TypeScriptAdapter(LanguageAdapter):
391
391
  for child in root.children:
392
392
  decls.extend(self._top_level(child, source, rel_path, pin_index))
393
393
  first_decl = next((c for c in root.children if c.type != "comment"), None)
394
+ # Only a pinnable definition claims an adjacent header block; an
395
+ # import keeps the pins module-level.
396
+ pinnable = {
397
+ "function_declaration",
398
+ "class_declaration",
399
+ "abstract_class_declaration",
400
+ "export_statement",
401
+ "lexical_declaration",
402
+ "variable_declaration",
403
+ }
394
404
  module_pins = pin_index.module_pins(
395
- first_decl.start_point[0] if first_decl is not None else None
405
+ first_decl.start_point[0]
406
+ if first_decl is not None and first_decl.type in pinnable
407
+ else None
396
408
  )
397
409
  if module_pins:
398
410
  for decl in decls:
@@ -205,3 +205,27 @@ def test_pack_renders_pins() -> None:
205
205
  specs = [_spec("m.f", pins=["pure", "no-net"])]
206
206
  out = render_pack(build_pack(specs, "m.f"))
207
207
  assert "Pinned: no-net, pure" in out or "Pinned: pure, no-net" in out
208
+
209
+
210
+ def test_module_pin_adjacent_to_imports_still_applies(tmp_path: Path) -> None:
211
+ """Real-world layout: the header pin sits directly above the imports.
212
+
213
+ Adjacency only disqualifies a header block when it touches a *pinnable
214
+ definition* — touching an import (or any plain statement) keeps it
215
+ module-level. Found dogfooding on camera-tracking.
216
+ """
217
+ (tmp_path / "m.py").write_text(
218
+ "# cgir: no-net\nfrom typing import Iterable\n\n\ndef f(x):\n return x\n"
219
+ )
220
+ [spec] = _scan(tmp_path)
221
+ assert "no-net" in spec.pins
222
+
223
+
224
+ def test_module_pin_adjacent_to_def_still_belongs_to_def(tmp_path: Path) -> None:
225
+ # No imports: header touching the first def is that def's pin, not module-wide.
226
+ (tmp_path / "m.py").write_text(
227
+ "# cgir: pure\ndef f(x):\n return x\n\n\ndef g(x):\n return x\n"
228
+ )
229
+ specs = {s.id: s for s in _scan(tmp_path)}
230
+ assert specs["m.f"].pins == ["pure"]
231
+ assert specs["m.g"].pins == []
@@ -264,3 +264,69 @@ def test_subscript_write_to_local_is_not_state_transformation(tmp_path: Path) ->
264
264
  """,
265
265
  )
266
266
  assert specs["m.index"].kind == ComponentKind.pure_function
267
+
268
+
269
+ def test_global_rebinding_is_state_transformer(tmp_path: Path) -> None:
270
+ """`global x; x = ...` mutates module state — found dogfooding: a lazy-init
271
+ service accessor with 34 callers scanned as pure_function."""
272
+ specs = _specs_for(
273
+ tmp_path,
274
+ """
275
+ _client = None
276
+
277
+
278
+ def get_client():
279
+ global _client
280
+ if _client is None:
281
+ _client = object()
282
+ return _client
283
+ """,
284
+ )
285
+ assert specs["m.get_client"].kind == ComponentKind.state_transformer
286
+
287
+
288
+ def test_nonlocal_names_extracted_by_adapter(tmp_path: Path) -> None:
289
+ # nested functions aren't sliced as components (yet), so pin the adapter
290
+ # hook directly: nonlocal declarations are collected like global ones.
291
+ from cgir.languages.python import PythonAdapter
292
+
293
+ a = PythonAdapter()
294
+ src = b"def bump():\n nonlocal count\n count = count + 1\n"
295
+ fn = a.locate_function(a.parse(src), "bump", 0)
296
+ assert fn is not None
297
+ assert a.global_declared_names(fn, src) == {"count"}
298
+
299
+
300
+ def test_local_rebinding_of_same_name_stays_pure(tmp_path: Path) -> None:
301
+ # a plain local assignment (no global decl) is not a mutation
302
+ specs = _specs_for(
303
+ tmp_path,
304
+ """
305
+ _client = None
306
+
307
+
308
+ def pure_shadow():
309
+ _client = 5
310
+ return _client
311
+ """,
312
+ )
313
+ assert specs["m.pure_shadow"].kind == ComponentKind.pure_function
314
+
315
+
316
+ def test_nested_global_decl_does_not_taint_outer(tmp_path: Path) -> None:
317
+ # the nested function's `global x` must not make the OUTER local write a mutation
318
+ specs = _specs_for(
319
+ tmp_path,
320
+ """
321
+ x = 0
322
+
323
+
324
+ def outer():
325
+ x = 1
326
+ def inner():
327
+ global x
328
+ x = 2
329
+ return x
330
+ """,
331
+ )
332
+ assert specs["m.outer"].kind == ComponentKind.pure_function
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes