pebra 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. pebra/__init__.py +0 -0
  2. pebra/__main__.py +10 -0
  3. pebra/adapters/__init__.py +0 -0
  4. pebra/adapters/_ast_utils.py +265 -0
  5. pebra/adapters/_paths.py +41 -0
  6. pebra/adapters/architecture_map.py +91 -0
  7. pebra/adapters/ast_diff_adapter.py +256 -0
  8. pebra/adapters/ast_import_graph.py +228 -0
  9. pebra/adapters/bandit_adapter.py +100 -0
  10. pebra/adapters/calibration_store.py +31 -0
  11. pebra/adapters/candidate_application.py +161 -0
  12. pebra/adapters/candidate_binding.py +352 -0
  13. pebra/adapters/candidate_edits.py +63 -0
  14. pebra/adapters/candidate_gate.py +16 -0
  15. pebra/adapters/candidate_replay_cache.py +134 -0
  16. pebra/adapters/codegraph_adapter.py +1610 -0
  17. pebra/adapters/codegraph_candidate_refinement.py +943 -0
  18. pebra/adapters/codegraph_graph_reader.py +271 -0
  19. pebra/adapters/codegraph_materialized_diff.py +323 -0
  20. pebra/adapters/composite_evidence.py +89 -0
  21. pebra/adapters/continuity_witness.py +331 -0
  22. pebra/adapters/contract_surface.py +19 -0
  23. pebra/adapters/enforcement_capability.py +180 -0
  24. pebra/adapters/evidence_merge.py +125 -0
  25. pebra/adapters/gate_check_adapter.py +549 -0
  26. pebra/adapters/git_adapter.py +76 -0
  27. pebra/adapters/git_change_verifier.py +276 -0
  28. pebra/adapters/import_graph_cache.py +332 -0
  29. pebra/adapters/learning_store.py +39 -0
  30. pebra/adapters/patch_header_adapter.py +93 -0
  31. pebra/adapters/patch_materializer.py +125 -0
  32. pebra/adapters/paths.py +66 -0
  33. pebra/adapters/rca_adapter.py +256 -0
  34. pebra/adapters/repository_registry.py +20 -0
  35. pebra/adapters/request_evidence.py +58 -0
  36. pebra/adapters/sanction_store.py +27 -0
  37. pebra/adapters/snapshot_read_store.py +95 -0
  38. pebra/adapters/store/__init__.py +0 -0
  39. pebra/adapters/store/db.py +2033 -0
  40. pebra/adapters/structural_feature_adapter.py +151 -0
  41. pebra/adapters/yaml_config.py +169 -0
  42. pebra/app/__init__.py +0 -0
  43. pebra/app/accept_risk_controller.py +74 -0
  44. pebra/app/assess_controller.py +1328 -0
  45. pebra/app/candidate_apply_controller.py +124 -0
  46. pebra/app/finalize_outcome_controller.py +110 -0
  47. pebra/app/human_approval_controller.py +178 -0
  48. pebra/app/learning_controller.py +105 -0
  49. pebra/app/observatory_query_controller.py +92 -0
  50. pebra/app/promotion_controller.py +325 -0
  51. pebra/app/record_outcome_controller.py +50 -0
  52. pebra/app/verify_controller.py +165 -0
  53. pebra/cli/__init__.py +0 -0
  54. pebra/cli/accept_risk.py +113 -0
  55. pebra/cli/agent_init.py +187 -0
  56. pebra/cli/apply_candidate.py +51 -0
  57. pebra/cli/assess.py +157 -0
  58. pebra/cli/candidate_patch.py +37 -0
  59. pebra/cli/capabilities.py +58 -0
  60. pebra/cli/dashboard.py +83 -0
  61. pebra/cli/dependents.py +46 -0
  62. pebra/cli/finalize_outcome.py +86 -0
  63. pebra/cli/gate_check.py +49 -0
  64. pebra/cli/gate_hook.py +62 -0
  65. pebra/cli/graph_stats.py +40 -0
  66. pebra/cli/learn.py +66 -0
  67. pebra/cli/main.py +125 -0
  68. pebra/cli/promote.py +90 -0
  69. pebra/cli/record_outcome.py +64 -0
  70. pebra/cli/scorecard.py +88 -0
  71. pebra/cli/setup_graph.py +457 -0
  72. pebra/cli/tui.py +53 -0
  73. pebra/cli/verify.py +103 -0
  74. pebra/composition.py +398 -0
  75. pebra/core/__init__.py +0 -0
  76. pebra/core/apply_snapshot.py +512 -0
  77. pebra/core/assessment_builder.py +314 -0
  78. pebra/core/benefit_aggregation.py +38 -0
  79. pebra/core/benefit_model.py +139 -0
  80. pebra/core/calibrated_priors.py +29 -0
  81. pebra/core/candidate_aggregation.py +99 -0
  82. pebra/core/candidate_parser.py +37 -0
  83. pebra/core/candidate_refinement.py +162 -0
  84. pebra/core/change_classifier.py +275 -0
  85. pebra/core/confidence_gate.py +29 -0
  86. pebra/core/constants.py +195 -0
  87. pebra/core/dashboard_metrics.py +50 -0
  88. pebra/core/decision_engine.py +733 -0
  89. pebra/core/destructive_op_model.py +108 -0
  90. pebra/core/engine_argv.py +34 -0
  91. pebra/core/engine_paths.py +67 -0
  92. pebra/core/explanation_generator.py +116 -0
  93. pebra/core/exposure_model.py +28 -0
  94. pebra/core/file_risk_aggregation.py +45 -0
  95. pebra/core/graph_trust.py +32 -0
  96. pebra/core/graph_version.py +55 -0
  97. pebra/core/high_risk_controls.py +64 -0
  98. pebra/core/language_capability.py +121 -0
  99. pebra/core/learning_eval.py +138 -0
  100. pebra/core/model_guidance.py +187 -0
  101. pebra/core/models.py +553 -0
  102. pebra/core/modify_risk_model.py +206 -0
  103. pebra/core/outcome_labels.py +58 -0
  104. pebra/core/patch_paths.py +188 -0
  105. pebra/core/post_assessment_guardrails.py +304 -0
  106. pebra/core/prediction_capture.py +218 -0
  107. pebra/core/prediction_error.py +224 -0
  108. pebra/core/promotion_evaluator.py +488 -0
  109. pebra/core/query_validator.py +20 -0
  110. pebra/core/rca_engine_paths.py +57 -0
  111. pebra/core/request_validator.py +57 -0
  112. pebra/core/risk_fact_decay.py +56 -0
  113. pebra/core/score_math.py +155 -0
  114. pebra/core/score_normalizer.py +106 -0
  115. pebra/core/snapshot_reconciler.py +66 -0
  116. pebra/core/structural_features.py +104 -0
  117. pebra/core/variance_bounds.py +50 -0
  118. pebra/core/warm_prior.py +193 -0
  119. pebra/core/weight_resolver.py +51 -0
  120. pebra/dashboard/__init__.py +0 -0
  121. pebra/dashboard/api.py +232 -0
  122. pebra/dashboard/auth.py +36 -0
  123. pebra/dashboard/ports.py +56 -0
  124. pebra/dashboard/server.py +179 -0
  125. pebra/dashboard/static/app.js +579 -0
  126. pebra/dashboard/static/style.css +219 -0
  127. pebra/dashboard/static/vendor/uplot.LICENSE.txt +21 -0
  128. pebra/dashboard/static/vendor/uplot.iife.min.js +2 -0
  129. pebra/dashboard/static/vendor/uplot.min.css +1 -0
  130. pebra/dashboard/templates/index.html +43 -0
  131. pebra/learning_composition.py +21 -0
  132. pebra/mcp_server/__init__.py +0 -0
  133. pebra/mcp_server/__main__.py +17 -0
  134. pebra/mcp_server/server.py +288 -0
  135. pebra/observatory_context.py +51 -0
  136. pebra/ports/__init__.py +0 -0
  137. pebra/ports/architecture_knowledge_port.py +18 -0
  138. pebra/ports/blast_radius_port.py +11 -0
  139. pebra/ports/calibration_port.py +13 -0
  140. pebra/ports/candidate_application_port.py +33 -0
  141. pebra/ports/candidate_binding_port.py +15 -0
  142. pebra/ports/candidate_replay_port.py +15 -0
  143. pebra/ports/change_verifier_port.py +17 -0
  144. pebra/ports/config_port.py +57 -0
  145. pebra/ports/contract_surface_port.py +17 -0
  146. pebra/ports/evidence_port.py +13 -0
  147. pebra/ports/fanin_port.py +38 -0
  148. pebra/ports/file_fanin_port.py +18 -0
  149. pebra/ports/graph_risk_refinement_port.py +16 -0
  150. pebra/ports/language_capability_port.py +27 -0
  151. pebra/ports/learning_port.py +38 -0
  152. pebra/ports/materialized_diff_port.py +25 -0
  153. pebra/ports/observatory_read_port.py +34 -0
  154. pebra/ports/outcome_port.py +16 -0
  155. pebra/ports/repository_registry_port.py +18 -0
  156. pebra/ports/sanction_port.py +19 -0
  157. pebra/ports/snapshot_read_port.py +21 -0
  158. pebra/ports/store_port.py +114 -0
  159. pebra/ports/structural_feature_port.py +18 -0
  160. pebra/ports/symbol_diff_port.py +11 -0
  161. pebra/tui/__init__.py +6 -0
  162. pebra/tui/app.py +70 -0
  163. pebra/tui/data.py +99 -0
  164. pebra/tui/screens/__init__.py +1 -0
  165. pebra/tui/screens/detail.py +73 -0
  166. pebra/tui/screens/observatory.py +200 -0
  167. pebra/tui/theme.py +64 -0
  168. pebra/tui/theme.tcss +33 -0
  169. pebra/tui/widgets/__init__.py +1 -0
  170. pebra/tui/widgets/ledger_table.py +103 -0
  171. pebra/tui/widgets/score_sparklines.py +54 -0
  172. pebra/tui/widgets/status_header.py +31 -0
  173. pebra-0.1.0.dist-info/METADATA +242 -0
  174. pebra-0.1.0.dist-info/RECORD +179 -0
  175. pebra-0.1.0.dist-info/WHEEL +5 -0
  176. pebra-0.1.0.dist-info/entry_points.txt +3 -0
  177. pebra-0.1.0.dist-info/licenses/LICENSE +201 -0
  178. pebra-0.1.0.dist-info/licenses/pebra/dashboard/static/vendor/uplot.LICENSE.txt +21 -0
  179. pebra-0.1.0.dist-info/top_level.txt +1 -0
pebra/__init__.py ADDED
File without changes
pebra/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Enable ``python -m pebra``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pebra.cli.main import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
File without changes
@@ -0,0 +1,265 @@
1
+ """Shared AST import resolver for the architecture map (AD-22) and the blast-radius walker (AD-12).
2
+
3
+ Keeping one resolver means both graphs count edges identically — including relative imports, which a
4
+ naive resolver drops (systematically under-counting in-degree for package-relative codebases). Each
5
+ edge is classified by kind so the blast walker can attach edge-confidence weights.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ # Directories never scanned for the import graph (vcs, envs, caches, build artifacts).
16
+ SKIP_DIRS = {
17
+ ".pebra", ".venv", ".git", "__pycache__", ".nox", "node_modules", "dist", "build",
18
+ }
19
+
20
+ # Entrypoint filenames (single shared definition so both walkers agree). __init__.py counts: it runs
21
+ # at import time and re-exports, so changing it is entry-point-like.
22
+ _ENTRYPOINT_NAMES = {"__init__.py", "__main__.py", "main.py"}
23
+
24
+
25
+ def is_entrypoint(posix_path: str) -> bool:
26
+ base = posix_path.rsplit("/", 1)[-1]
27
+ return base in _ENTRYPOINT_NAMES or base.startswith("handle")
28
+
29
+ # Edge-confidence weights (Architecture §5 clean-room): how much to trust each import kind as a
30
+ # real dependency edge. Used by the blast walker; not calibrated probabilities.
31
+ EDGE_CONFIDENCE = {"static": 0.85, "relative": 0.75, "wildcard": 0.35, "dynamic": 0.15, "unknown": 0.10}
32
+
33
+
34
+ def python_files(root: Path) -> list[str]:
35
+ """Sorted posix rel-paths of repo Python files, excluding SKIP_DIRS. Shared by both walkers.
36
+
37
+ Uses os.walk with ``followlinks=False`` and prunes skipped dirs in-place — this avoids following
38
+ symlink loops (which rglob can) and skips entire excluded trees instead of filtering per file.
39
+
40
+ ``.pyi`` stubs are included so stub-only modules still resolve — but a stub is dropped when its
41
+ real ``.py`` sibling is present in the same directory (the module shadows its stub), so a module
42
+ is represented exactly once and its imports aren't double-counted.
43
+ """
44
+ out: list[str] = []
45
+ root_str = str(root)
46
+ for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
47
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
48
+ names = set(filenames)
49
+ for name in filenames:
50
+ if name.endswith(".py") or (name.endswith(".pyi") and name[:-1] not in names):
51
+ out.append(Path(dirpath, name).relative_to(root).as_posix())
52
+ return sorted(out)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ImportEdge:
57
+ kind: str # static | relative | wildcard | dynamic | external
58
+ target: str | None # resolved repo file (posix rel path) or None if outside the repo / unknown
59
+ name: str | None = None # the import target as written (e.g. "billing.legacy", ".sub.x"); for
60
+ # dynamic imports the string-literal argument, or None when it isn't a literal. Used by 3d model
61
+ # guidance to name WHAT couldn't be resolved; not part of edge identity.
62
+
63
+
64
+ def _top_level_names(fileset: set[str]) -> set[str]:
65
+ """Top-level importable names present in the repo: a package directory's name, or a root-level
66
+ module's stem. Used to tell an unresolved absolute import that points INTO the repo (a real
67
+ resolution failure, kind 'static') from one pointing at a stdlib/third-party package (kind
68
+ 'external' — expected to be unresolved and not a sign of an incomplete graph)."""
69
+ names: set[str] = set()
70
+ for f in fileset:
71
+ head, sep, _ = f.partition("/")
72
+ names.add(head if sep else head.rsplit(".", 1)[0]) # dir name, or root module stem
73
+ return names
74
+
75
+
76
+ def _abs_unresolved_kind(parts: list[str], top_level_names: set[str]) -> str:
77
+ """Classify an absolute import that did NOT resolve: 'static' (internal failure) if its top-level
78
+ name belongs to the repo, else 'external' (a library we never expected to resolve)."""
79
+ return "static" if parts and parts[0] in top_level_names else "external"
80
+
81
+
82
+ def _resolve(parts: list[str], fileset: set[str]) -> str | None:
83
+ # Resolution order: real module, then package, then their .pyi stubs. The .py forms are tried
84
+ # first so a real module always wins over a stub of the same name. PEP-420 namespace packages
85
+ # (a bare package dir with no __init__) have no single file target and stay unresolved — the
86
+ # conservative false-negative direction; their submodules still resolve at the leaf.
87
+ base = "/".join(parts)
88
+ for candidate in (f"{base}.py", f"{base}/__init__.py", f"{base}.pyi", f"{base}/__init__.pyi"):
89
+ if candidate in fileset:
90
+ return candidate
91
+ return None
92
+
93
+
94
+ def _relative_anchor(file_relpath: str, level: int) -> list[str] | None:
95
+ """Package parts the relative import is anchored at (level 1 = current package).
96
+
97
+ Returns ``None`` when the import reaches above the repo root (more leading dots than there are
98
+ enclosing packages) — that's unresolvable, distinct from the empty-anchor top-level case where a
99
+ level-1 relative import legitimately anchors at the repo root.
100
+ """
101
+ pkg = file_relpath.split("/")[:-1] # drop the filename
102
+ drop = level - 1
103
+ if drop > len(pkg):
104
+ return None
105
+ return pkg[: len(pkg) - drop]
106
+
107
+
108
+ def _is_dynamic_call(node: ast.AST) -> bool:
109
+ if not isinstance(node, ast.Call):
110
+ return False
111
+ func = node.func
112
+ if isinstance(func, ast.Attribute) and func.attr == "import_module":
113
+ return True
114
+ return isinstance(func, ast.Name) and func.id == "__import__"
115
+
116
+
117
+ def _dynamic_name(node: ast.Call) -> str | None:
118
+ """The string-literal module passed to importlib.import_module(...) / __import__(...), if any."""
119
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
120
+ return node.args[0].value
121
+ return None
122
+
123
+
124
+ def _from_name(level: int, module: str | None, leaf: str) -> str:
125
+ """Readable source spec for a `from ... import leaf` (e.g. 'pkg.core', '.sub.x', 'legacy.*')."""
126
+ return "." * level + ".".join(p for p in (module, leaf) if p)
127
+
128
+
129
+ # Specific decorator leaves that mark a framework/test entrypoint even when used bare (@command,
130
+ # @tool, @app.route). These names are distinctive enough that a bare match is safe.
131
+ _ENTRYPOINT_DECORATOR_LEAVES = frozenset({
132
+ "route", "command", "group", "callback", "tool", "resource", "prompt", "fixture",
133
+ })
134
+ # HTTP-method leaves are common method names, so they only count when QUALIFIED (@router.get, not a
135
+ # bare @get) — this avoids false-positive entrypoints in non-web code that happens to use such names.
136
+ _HTTP_VERB_LEAVES = frozenset({
137
+ "get", "post", "put", "delete", "patch", "head", "options", "websocket",
138
+ })
139
+
140
+
141
+ def _decorator_path(dec: ast.expr) -> list[str]:
142
+ """Dotted parts of a decorator expression: @app.route('/x') -> ['app', 'route']."""
143
+ node: ast.AST = dec.func if isinstance(dec, ast.Call) else dec
144
+ parts: list[str] = []
145
+ while isinstance(node, ast.Attribute):
146
+ parts.append(node.attr)
147
+ node = node.value
148
+ if isinstance(node, ast.Name):
149
+ parts.append(node.id)
150
+ parts.reverse()
151
+ return parts
152
+
153
+
154
+ def _is_entrypoint_decorator(dec: ast.expr) -> bool:
155
+ path = _decorator_path(dec)
156
+ if not path:
157
+ return False
158
+ leaf = path[-1]
159
+ if leaf in _ENTRYPOINT_DECORATOR_LEAVES:
160
+ return True
161
+ if leaf in _HTTP_VERB_LEAVES and len(path) >= 2: # @router.get yes, bare @get no
162
+ return True
163
+ return "pytest" in path
164
+
165
+
166
+ def _tree_is_entrypoint(tree: ast.AST) -> bool:
167
+ for node in ast.walk(tree):
168
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
169
+ if node.name.startswith("test_"): # pytest-style test entrypoint
170
+ return True
171
+ if any(_is_entrypoint_decorator(d) for d in node.decorator_list):
172
+ return True
173
+ elif isinstance(node, ast.ClassDef):
174
+ if any(_is_entrypoint_decorator(d) for d in node.decorator_list):
175
+ return True
176
+ return False
177
+
178
+
179
+ def source_is_entrypoint(source: str) -> bool:
180
+ """Does this source define a framework/test entrypoint (decorator- or test-name-based)?
181
+ Decorator/name detection only — filename-based entrypoints are handled by ``is_entrypoint``."""
182
+ try:
183
+ tree = ast.parse(source)
184
+ except (SyntaxError, ValueError):
185
+ return False
186
+ return _tree_is_entrypoint(tree)
187
+
188
+
189
+ def parse_facts(
190
+ file_relpath: str, source: str, fileset: set[str], top_level_names: set[str]
191
+ ) -> tuple[list[ImportEdge], bool, bool]:
192
+ """One parse per file -> (import edges, is_entrypoint, parse_error).
193
+
194
+ Entrypoint = filename-based OR a framework/test decorator. Used by the cache build so each file
195
+ is parsed exactly once. ``parse_error`` lets the blast evidence distinguish "clean zero edges"
196
+ from "the changed file could not be inspected."
197
+ """
198
+ try:
199
+ tree = ast.parse(source)
200
+ except (SyntaxError, ValueError):
201
+ return [], is_entrypoint(file_relpath), True # fall back to the filename signal
202
+ edges = _edges_from_tree(tree, file_relpath, fileset, top_level_names)
203
+ return edges, is_entrypoint(file_relpath) or _tree_is_entrypoint(tree), False
204
+
205
+
206
+ def iter_import_edges(
207
+ file_relpath: str, source: str, fileset: set[str], top_level_names: set[str] | None = None
208
+ ) -> list[ImportEdge]:
209
+ try:
210
+ tree = ast.parse(source)
211
+ except (SyntaxError, ValueError):
212
+ # unparseable file (syntax error, or null bytes -> ValueError on older Pythons): contribute
213
+ # no edges, like any other file we can't read. One bad file never fails the whole graph build.
214
+ return []
215
+
216
+ # repo top-level names, for classifying unresolved absolute imports as internal vs external.
217
+ # Computed once per build by the caller; derived here when called standalone (e.g. unit tests).
218
+ tl = top_level_names if top_level_names is not None else _top_level_names(fileset)
219
+ return _edges_from_tree(tree, file_relpath, fileset, tl)
220
+
221
+
222
+ def _edges_from_tree(
223
+ tree: ast.AST, file_relpath: str, fileset: set[str], tl: set[str]
224
+ ) -> list[ImportEdge]:
225
+ edges: list[ImportEdge] = []
226
+ for node in ast.walk(tree):
227
+ if isinstance(node, ast.Import):
228
+ for alias in node.names:
229
+ parts = alias.name.split(".")
230
+ target = _resolve(parts, fileset)
231
+ kind = "static" if target is not None else _abs_unresolved_kind(parts, tl)
232
+ edges.append(ImportEdge(kind, target, name=alias.name))
233
+ elif isinstance(node, ast.ImportFrom):
234
+ wildcard = any(a.name == "*" for a in node.names)
235
+ kind = "wildcard" if wildcard else ("relative" if node.level > 0 else "static")
236
+ if node.level > 0:
237
+ anchor = _relative_anchor(file_relpath, node.level)
238
+ if anchor is None:
239
+ # the relative import escapes the repo root: unresolvable, but still a real
240
+ # (relative) edge attempt — record it with no target rather than mis-resolving.
241
+ leaf = "*" if wildcard else (node.names[0].name if node.names else "")
242
+ edges.append(ImportEdge(kind, None, name=_from_name(node.level, node.module, leaf)))
243
+ continue
244
+ else:
245
+ anchor = []
246
+ base = anchor + (node.module.split(".") if node.module else [])
247
+ if wildcard:
248
+ name = _from_name(node.level, node.module, "*")
249
+ edges.append(ImportEdge(kind, _resolve(base, fileset) if base else None, name=name))
250
+ else:
251
+ # each imported name may itself be a submodule (`from pkg import core` -> pkg/core.py);
252
+ # try that first, then fall back to the module/package where the name is defined.
253
+ for alias in node.names:
254
+ target = _resolve(base + [alias.name], fileset)
255
+ if target is None and base:
256
+ target = _resolve(base, fileset)
257
+ name = _from_name(node.level, node.module, alias.name)
258
+ if target is None and node.level == 0:
259
+ # unresolved absolute from-import: internal failure vs external library
260
+ edges.append(ImportEdge(_abs_unresolved_kind(base, tl), None, name=name))
261
+ else:
262
+ edges.append(ImportEdge(kind, target, name=name))
263
+ elif _is_dynamic_call(node):
264
+ edges.append(ImportEdge("dynamic", None, name=_dynamic_name(node)))
265
+ return edges
@@ -0,0 +1,41 @@
1
+ """Repo-relative path safety for adapters that READ caller-supplied file paths (RCA, bandit).
2
+
3
+ ``expected_files`` come from the request/action and could (now, or via a future model/MCP surface)
4
+ contain absolute paths or ``..`` traversal. Any adapter that opens those paths must validate them
5
+ BEFORE reading/copying, so analysis can never touch files outside the repo. Invalid paths are dropped
6
+ (the caller degrades to projected / an evidence gap), never raised.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path, PureWindowsPath
12
+
13
+
14
+ def is_safe_relative(repo_root: str, rel: str) -> bool:
15
+ """The single canonical repo-relative path-safety predicate.
16
+
17
+ True iff ``rel`` is a safe repo-relative path under ``repo_root``: rejects the empty path, absolute
18
+ paths, a drive or ``:``/NTFS-ADS component, any ``..`` component, and paths that resolve (e.g. via a
19
+ symlink) outside ``repo_root``. Every adapter that reads/writes caller-supplied paths derives from
20
+ this one predicate so an escape-class fix lands in exactly one place.
21
+ """
22
+ if not rel or PureWindowsPath(rel).drive or ":" in rel:
23
+ return False
24
+ p = Path(rel)
25
+ if p.is_absolute() or ".." in p.parts:
26
+ return False
27
+ root = Path(repo_root).resolve()
28
+ try:
29
+ (root / p).resolve().relative_to(root)
30
+ except ValueError:
31
+ return False # resolves outside the repo (e.g. drive-relative or a symlink escape)
32
+ return True
33
+
34
+
35
+ def safe_relative_files(repo_root: str, files: list[str]) -> list[str]:
36
+ """Return the subset of ``files`` that are safe repo-relative paths under ``repo_root``.
37
+
38
+ The filtering form of ``is_safe_relative``. Order is preserved; the original path strings are
39
+ returned; invalid paths are dropped (the caller degrades to projected / an evidence gap).
40
+ """
41
+ return [rel for rel in files if is_safe_relative(repo_root, rel)]
@@ -0,0 +1,91 @@
1
+ """architecture_map (AD-22) — PEBRA's self-built repo structure map (ArchitectureKnowledgeProvider).
2
+
3
+ Derives structural signals (god-node/anchor, domains, bridge centrality) + a freshness verdict from
4
+ the shared, content-hash-cached import graph (``import_graph_cache``). Freshness is git-agnostic:
5
+ FRESH/REBUILT when the content-hashed graph is up to date, STALE when its (re)build failed, UNKNOWN
6
+ when there is no graph to build (empty/missing repo). current_head is kept only as provenance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fnmatch import fnmatch
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pebra.adapters.import_graph_cache import get_import_graph
16
+ from pebra.core.constants import STAGE_MAP, GraphFreshness
17
+ from pebra.core.models import ArchitectureEvidence
18
+ from pebra.ports.config_port import CriticalityGlob
19
+
20
+
21
+ def _domain(posix_path: str) -> str:
22
+ return posix_path.split("/", 1)[0] if "/" in posix_path else "."
23
+
24
+
25
+ class ArchitectureMapAdapter:
26
+ def __init__(
27
+ self,
28
+ criticality_globs: list[CriticalityGlob] | None = None,
29
+ graph_provider: object | None = None,
30
+ ) -> None:
31
+ self._globs = list(criticality_globs or [])
32
+ self._graph = graph_provider # optional build-once memo (Slice 5c); else direct get_import_graph
33
+
34
+ # --- public port method ---
35
+
36
+ def gather_architecture(
37
+ self, repo_root: str, affected_files: list[str], current_head: str | None
38
+ ) -> ArchitectureEvidence:
39
+ root = Path(repo_root)
40
+ if not root.is_dir():
41
+ return ArchitectureEvidence(domain_criticality_hint=self._hint(affected_files))
42
+ # One shared, content-hash-cached import graph (no separate scan). current_head is provenance
43
+ # only — freshness is decided by per-file content hashes, not HEAD.
44
+ payload, freshness = self._graph.get(root) if self._graph is not None else get_import_graph(root)
45
+ if freshness is GraphFreshness.UNKNOWN: # empty repo / nothing to map
46
+ return ArchitectureEvidence(domain_criticality_hint=self._hint(affected_files))
47
+ return self._evidence(payload, affected_files, freshness, current_head)
48
+
49
+ # --- evidence assembly ---
50
+
51
+ def _evidence(
52
+ self,
53
+ m: dict[str, Any],
54
+ affected_files: list[str],
55
+ freshness: GraphFreshness,
56
+ graph_commit: str | None,
57
+ ) -> ArchitectureEvidence:
58
+ anchors = set(m.get("anchors", []))
59
+ god_node_scores: dict[str, float] = m.get("god_node_scores", {})
60
+ out_degree: dict[str, int] = m.get("out_degree", {})
61
+ cycle_files = set(m.get("cycle_files", []))
62
+ entrypoints = set(m.get("entrypoints", [])) # decorator/filename entrypoints (3e)
63
+ matched_anchors = [f for f in affected_files if f in anchors]
64
+ # 3f: structural signals are repo-relative percentiles + an in-degree floor (computed in the
65
+ # cache). god_node_score = max fan-in percentile of the edited files; fan_out = their coupling.
66
+ god = max((god_node_scores.get(f, 0.0) for f in affected_files), default=0.0)
67
+ fan_out = max((out_degree.get(f, 0) for f in affected_files), default=0)
68
+ total_edges = m.get("total_edges", 0)
69
+ bridge = (m.get("cross_domain_edges", 0) / total_edges) if total_edges else 0.0
70
+ return ArchitectureEvidence(
71
+ graph_commit=graph_commit,
72
+ graph_freshness=freshness,
73
+ matched_anchors=matched_anchors,
74
+ matched_domains=sorted({_domain(f) for f in affected_files}),
75
+ architecture_anchor_score=(len(matched_anchors) / len(anchors)) if anchors else 0.0,
76
+ god_node_score=god,
77
+ bridge_centrality=bridge,
78
+ domain_entrypoint=any(f in entrypoints for f in affected_files),
79
+ fan_out=fan_out,
80
+ cycle_participation=any(f in cycle_files for f in affected_files),
81
+ domain_criticality_hint=self._hint(affected_files),
82
+ source_files=list(affected_files),
83
+ )
84
+
85
+ def _hint(self, affected_files: list[str]) -> str | None:
86
+ best: str | None = None
87
+ for g in self._globs:
88
+ if any(fnmatch(f, g.pattern) for f in affected_files):
89
+ if best is None or STAGE_MAP.get(g.stage, 0.0) > STAGE_MAP.get(best, 0.0):
90
+ best = g.stage
91
+ return best
@@ -0,0 +1,256 @@
1
+ """ast_diff_adapter (SymbolDiffProvider, AD-27) — owns Python symbol-diff I/O.
2
+
3
+ When seeded with a request ``symbol_diff`` evidence slice (a plain dict), it returns that directly.
4
+ Otherwise it computes Python AST-based symbol diff rows for verify/reclassification paths and falls
5
+ back conservatively when source is unavailable or unparseable. It deliberately does NOT hold the whole
6
+ AssessmentRequest — only its own evidence slice, like any adapter config.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import difflib
13
+ from dataclasses import replace
14
+ from typing import Any
15
+
16
+ from pebra.adapters.patch_header_adapter import parse_patch_headers
17
+ from pebra.core.models import CandidateAction, SymbolDiffEvidence
18
+
19
+ _ALLOWED = set(SymbolDiffEvidence.__dataclass_fields__)
20
+
21
+ # File-op dominance when a patch touches several files: DELETE (symbol loss) > MOVE/RENAME (path
22
+ # migration) > CREATE (no callers). Drives the single file_operation_kind axis.
23
+ _FILE_OP_SEVERITY = {"CREATE": 1, "RENAME": 2, "MOVE": 2, "DELETE": 3}
24
+
25
+ # Body-only source similarity below this ratio (signature unchanged, body wholly rewritten) flags a
26
+ # suspected identity replacement. Empirically calibrated: a total rewrite scores ~0.45 body-only while
27
+ # a legitimate variable-rename refactor scores ~0.68, so 0.5 separates them. Conservative by design —
28
+ # it may flag very aggressive refactors (extra CONTRACT review), never the reverse. Compares BODY only
29
+ # because the shared `def`/signature line inflates full-source similarity above any useful threshold.
30
+ # Known limitation: for very short bodies (~≤3 lines) shared tokens (return, arg names) keep the ratio
31
+ # above 0.5 even for a total semantic swap, so identity-replacement detection is only effective for
32
+ # functions with 4+ body lines. That is the accepted conservative (false-negative) direction.
33
+ _IDENTITY_REPLACEMENT_THRESHOLD: float = 0.5
34
+
35
+
36
+ def _functions(source: str) -> dict[str, ast.AST]:
37
+ """Map qualified function/method name -> def node. Raises SyntaxError on unparseable source."""
38
+ tree = ast.parse(source)
39
+ funcs: dict[str, ast.AST] = {}
40
+
41
+ class _V(ast.NodeVisitor):
42
+ def __init__(self) -> None:
43
+ self.stack: list[str] = []
44
+
45
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
46
+ self.stack.append(node.name)
47
+ self.generic_visit(node)
48
+ self.stack.pop()
49
+
50
+ def _add(self, node: ast.AST, name: str) -> None:
51
+ qual = ".".join([*self.stack, name])
52
+ funcs[qual] = node
53
+
54
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
55
+ self._add(node, node.name)
56
+ self.stack.append(node.name)
57
+ self.generic_visit(node)
58
+ self.stack.pop()
59
+
60
+ visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment]
61
+
62
+ _V().visit(tree)
63
+ return funcs
64
+
65
+
66
+ _CONTROL_FLOW = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.AsyncFor, ast.AsyncWith)
67
+
68
+
69
+ def _control_flow_count(node: ast.AST) -> int:
70
+ return sum(isinstance(n, _CONTROL_FLOW) for n in ast.walk(node))
71
+
72
+
73
+ def _visibility(qual: str) -> str:
74
+ leaf = qual.split(".")[-1]
75
+ return "private" if leaf.startswith("_") else "internal"
76
+
77
+
78
+ def _strip_docstring(body: list[ast.stmt]) -> list[ast.stmt]:
79
+ """Drop a leading docstring statement so it doesn't count as a semantic body change (AD-27:
80
+ ordinary docstrings are cosmetic)."""
81
+ if (
82
+ body
83
+ and isinstance(body[0], ast.Expr)
84
+ and isinstance(body[0].value, ast.Constant)
85
+ and isinstance(body[0].value.value, str)
86
+ ):
87
+ return body[1:]
88
+ return body
89
+
90
+
91
+ def _normalized_module_dump(src: str) -> str:
92
+ """ast.dump of the whole module with docstrings stripped (module + every def/class). Used to
93
+ detect module/class-level semantic changes (constants, imports, class bases, decorators) that the
94
+ per-function diff doesn't capture, while ignoring docstring/comment/whitespace-only edits."""
95
+ tree = ast.parse(src)
96
+ for node in ast.walk(tree):
97
+ if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
98
+ node.body = _strip_docstring(node.body)
99
+ return ast.dump(tree)
100
+
101
+
102
+ def _has_semantic_module_content(src: str | None) -> bool:
103
+ if src is None:
104
+ return False
105
+ tree = ast.parse(src)
106
+ tree.body = _strip_docstring(tree.body)
107
+ return bool(tree.body)
108
+
109
+
110
+ def parses(src: str | None) -> bool:
111
+ """True if the source parses (or is absent). Lets the verifier tell 'cosmetic, no semantic
112
+ change' (parses, no rows -> COSMETIC) from 'couldn't parse' (-> UNKNOWN)."""
113
+ if src is None:
114
+ return True
115
+ try:
116
+ ast.parse(src)
117
+ except SyntaxError:
118
+ return False
119
+ return True
120
+
121
+
122
+ def _body_source(src: str | None, statements: list[ast.stmt]) -> str:
123
+ """Concatenated source of a function's body statements (docstring already stripped by caller)."""
124
+ if not src:
125
+ return ""
126
+ parts = [ast.get_source_segment(src, stmt) for stmt in statements]
127
+ return "\n".join(p for p in parts if p)
128
+
129
+
130
+ def _row(symbol_id: str, qual: str, *, signature_changed: bool, body_changed: bool,
131
+ control_flow_changed: bool, identity_replacement_suspected: bool = False) -> dict[str, Any]:
132
+ return {
133
+ "symbol_id": symbol_id,
134
+ "visibility": _visibility(qual),
135
+ "signature_changed": signature_changed,
136
+ "return_shape_changed": False,
137
+ "body_changed": body_changed,
138
+ "control_flow_changed": control_flow_changed,
139
+ "identity_replacement_suspected": identity_replacement_suspected,
140
+ "external_side_effect_changed": False,
141
+ "db_write_changed": False,
142
+ "payment_api_changed": False,
143
+ "migration_changed": False,
144
+ "directive_comment_changed": False,
145
+ "test_only": False,
146
+ "callers_percentile": 0.0,
147
+ "transitive_reaches_consequence_symbol": False,
148
+ }
149
+
150
+
151
+ def compute_symbol_diff_rows(
152
+ before_src: str | None, after_src: str | None, file_path: str
153
+ ) -> list[dict[str, Any]]:
154
+ """Diff two versions of a Python file into per-symbol SymbolDiff rows (for change_classifier).
155
+
156
+ Conservative on failure: a syntax error in either version yields no rows (the verifier then
157
+ treats the file as unclassifiable rather than crashing). Visibility never escalates a plain body
158
+ edit to CONTRACT — that requires a real signature change.
159
+ """
160
+ try:
161
+ before = _functions(before_src) if before_src else {}
162
+ after = _functions(after_src) if after_src else {}
163
+ except SyntaxError:
164
+ return []
165
+
166
+ # NOTE: the Phase-1 body-similarity heuristic (identity_replacement_suspected) IS active and
167
+ # catches most "same name, wholly different function" cases. What remains deferred is robust
168
+ # symbol identity / rename tracking: symbols are still matched by qualified name, so a
169
+ # nested/closure function replaced by a different function of the same qualified name is matched
170
+ # as "modified" — a narrow residual false-negative. Full identity tracking is a later enrichment.
171
+ rows: list[dict[str, Any]] = []
172
+ for qual in sorted(set(before) | set(after)):
173
+ b = before.get(qual)
174
+ a = after.get(qual)
175
+ symbol_id = f"{file_path}::{qual}"
176
+ if b is not None and a is not None:
177
+ # signature = parameters AND return annotation (ast args node excludes `returns`)
178
+ _none = ast.Constant(value=None)
179
+ sig_changed = ast.dump(b.args) != ast.dump(a.args) or ( # type: ignore[attr-defined]
180
+ ast.dump(b.returns or _none) != ast.dump(a.returns or _none) # type: ignore[attr-defined]
181
+ )
182
+ # compare bodies with the leading docstring stripped — a docstring-only edit is cosmetic
183
+ b_stmts = _strip_docstring(b.body) # type: ignore[attr-defined]
184
+ a_stmts = _strip_docstring(a.body) # type: ignore[attr-defined]
185
+ body_changed = ast.dump(ast.Module(body=b_stmts, type_ignores=[])) != ast.dump(
186
+ ast.Module(body=a_stmts, type_ignores=[])
187
+ )
188
+ cf_changed = _control_flow_count(b) != _control_flow_count(a)
189
+ # M4: same name + same signature + body wholly rewritten = suspected identity replacement
190
+ identity_suspected = False
191
+ if not sig_changed and body_changed:
192
+ b_body, a_body = _body_source(before_src, b_stmts), _body_source(after_src, a_stmts)
193
+ if b_body and a_body:
194
+ ratio = difflib.SequenceMatcher(None, b_body, a_body).ratio()
195
+ identity_suspected = ratio < _IDENTITY_REPLACEMENT_THRESHOLD
196
+ if sig_changed or body_changed or cf_changed:
197
+ rows.append(_row(symbol_id, qual, signature_changed=sig_changed,
198
+ body_changed=body_changed, control_flow_changed=cf_changed,
199
+ identity_replacement_suspected=identity_suspected))
200
+ else:
201
+ # added or removed symbol: treat as a body change
202
+ rows.append(_row(symbol_id, qual, signature_changed=False, body_changed=True,
203
+ control_flow_changed=False))
204
+
205
+ # Module/class-level semantic fallback: if no function row captured the change but the normalized
206
+ # module AST (docstrings stripped) differs, the edit was outside function bodies — a module
207
+ # constant, import, class attribute/base, decorator, or one-sided module add/delete. Emit a
208
+ # module-scope BEHAVIORAL row so it is not mistaken for cosmetic. A docstring/comment/whitespace
209
+ # only edit leaves the normalized content empty/equal.
210
+ if not rows:
211
+ module_changed = False
212
+ if before_src and after_src:
213
+ module_changed = _normalized_module_dump(before_src) != _normalized_module_dump(after_src)
214
+ elif before_src or after_src:
215
+ module_changed = _has_semantic_module_content(before_src or after_src)
216
+ if module_changed:
217
+ rows.append(_row(f"{file_path}::__module__", "__module__", signature_changed=False,
218
+ body_changed=True, control_flow_changed=False))
219
+ return rows
220
+
221
+
222
+ class AstDiffAdapter:
223
+ def __init__(self, symbol_diff_evidence: dict[str, Any] | None = None) -> None:
224
+ self._evidence = symbol_diff_evidence
225
+
226
+ def symbol_diff(self, action: CandidateAction, repo_root: str) -> SymbolDiffEvidence:
227
+ if self._evidence:
228
+ base = SymbolDiffEvidence(
229
+ **{k: v for k, v in self._evidence.items() if k in _ALLOWED}
230
+ )
231
+ else:
232
+ base = SymbolDiffEvidence(
233
+ parsed_patch_available=False,
234
+ changed_symbols=list(action.affected_symbols),
235
+ max_change_kind="UNKNOWN",
236
+ fallback_reason="no symbol diff supplied; Phase-0 cold-start file/path-level risk",
237
+ )
238
+ return _detect_file_operation(base, action)
239
+
240
+
241
+ def _detect_file_operation(
242
+ base: SymbolDiffEvidence, action: CandidateAction
243
+ ) -> SymbolDiffEvidence:
244
+ """Set the FileOperationKind axis from patch headers. Independent of max_change_kind (symbol
245
+ semantics): a deleted file and a contract change are recorded separately. Supplied evidence that
246
+ already set a non-NONE op is preserved."""
247
+ if base.file_operation_kind != "NONE" or not action.proposed_patch:
248
+ return base
249
+ ops = parse_patch_headers(action.proposed_patch)
250
+ if not ops:
251
+ return base
252
+ dominant = max(ops, key=lambda o: _FILE_OP_SEVERITY.get(o.kind, 0))
253
+ paths = tuple(
254
+ p for o in ops if o.kind == dominant.kind and (p := (o.old_path or o.new_path)) is not None
255
+ )
256
+ return replace(base, file_operation_kind=dominant.kind, file_operation_paths=paths)