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.
- pebra/__init__.py +0 -0
- pebra/__main__.py +10 -0
- pebra/adapters/__init__.py +0 -0
- pebra/adapters/_ast_utils.py +265 -0
- pebra/adapters/_paths.py +41 -0
- pebra/adapters/architecture_map.py +91 -0
- pebra/adapters/ast_diff_adapter.py +256 -0
- pebra/adapters/ast_import_graph.py +228 -0
- pebra/adapters/bandit_adapter.py +100 -0
- pebra/adapters/calibration_store.py +31 -0
- pebra/adapters/candidate_application.py +161 -0
- pebra/adapters/candidate_binding.py +352 -0
- pebra/adapters/candidate_edits.py +63 -0
- pebra/adapters/candidate_gate.py +16 -0
- pebra/adapters/candidate_replay_cache.py +134 -0
- pebra/adapters/codegraph_adapter.py +1610 -0
- pebra/adapters/codegraph_candidate_refinement.py +943 -0
- pebra/adapters/codegraph_graph_reader.py +271 -0
- pebra/adapters/codegraph_materialized_diff.py +323 -0
- pebra/adapters/composite_evidence.py +89 -0
- pebra/adapters/continuity_witness.py +331 -0
- pebra/adapters/contract_surface.py +19 -0
- pebra/adapters/enforcement_capability.py +180 -0
- pebra/adapters/evidence_merge.py +125 -0
- pebra/adapters/gate_check_adapter.py +549 -0
- pebra/adapters/git_adapter.py +76 -0
- pebra/adapters/git_change_verifier.py +276 -0
- pebra/adapters/import_graph_cache.py +332 -0
- pebra/adapters/learning_store.py +39 -0
- pebra/adapters/patch_header_adapter.py +93 -0
- pebra/adapters/patch_materializer.py +125 -0
- pebra/adapters/paths.py +66 -0
- pebra/adapters/rca_adapter.py +256 -0
- pebra/adapters/repository_registry.py +20 -0
- pebra/adapters/request_evidence.py +58 -0
- pebra/adapters/sanction_store.py +27 -0
- pebra/adapters/snapshot_read_store.py +95 -0
- pebra/adapters/store/__init__.py +0 -0
- pebra/adapters/store/db.py +2033 -0
- pebra/adapters/structural_feature_adapter.py +151 -0
- pebra/adapters/yaml_config.py +169 -0
- pebra/app/__init__.py +0 -0
- pebra/app/accept_risk_controller.py +74 -0
- pebra/app/assess_controller.py +1328 -0
- pebra/app/candidate_apply_controller.py +124 -0
- pebra/app/finalize_outcome_controller.py +110 -0
- pebra/app/human_approval_controller.py +178 -0
- pebra/app/learning_controller.py +105 -0
- pebra/app/observatory_query_controller.py +92 -0
- pebra/app/promotion_controller.py +325 -0
- pebra/app/record_outcome_controller.py +50 -0
- pebra/app/verify_controller.py +165 -0
- pebra/cli/__init__.py +0 -0
- pebra/cli/accept_risk.py +113 -0
- pebra/cli/agent_init.py +187 -0
- pebra/cli/apply_candidate.py +51 -0
- pebra/cli/assess.py +157 -0
- pebra/cli/candidate_patch.py +37 -0
- pebra/cli/capabilities.py +58 -0
- pebra/cli/dashboard.py +83 -0
- pebra/cli/dependents.py +46 -0
- pebra/cli/finalize_outcome.py +86 -0
- pebra/cli/gate_check.py +49 -0
- pebra/cli/gate_hook.py +62 -0
- pebra/cli/graph_stats.py +40 -0
- pebra/cli/learn.py +66 -0
- pebra/cli/main.py +125 -0
- pebra/cli/promote.py +90 -0
- pebra/cli/record_outcome.py +64 -0
- pebra/cli/scorecard.py +88 -0
- pebra/cli/setup_graph.py +457 -0
- pebra/cli/tui.py +53 -0
- pebra/cli/verify.py +103 -0
- pebra/composition.py +398 -0
- pebra/core/__init__.py +0 -0
- pebra/core/apply_snapshot.py +512 -0
- pebra/core/assessment_builder.py +314 -0
- pebra/core/benefit_aggregation.py +38 -0
- pebra/core/benefit_model.py +139 -0
- pebra/core/calibrated_priors.py +29 -0
- pebra/core/candidate_aggregation.py +99 -0
- pebra/core/candidate_parser.py +37 -0
- pebra/core/candidate_refinement.py +162 -0
- pebra/core/change_classifier.py +275 -0
- pebra/core/confidence_gate.py +29 -0
- pebra/core/constants.py +195 -0
- pebra/core/dashboard_metrics.py +50 -0
- pebra/core/decision_engine.py +733 -0
- pebra/core/destructive_op_model.py +108 -0
- pebra/core/engine_argv.py +34 -0
- pebra/core/engine_paths.py +67 -0
- pebra/core/explanation_generator.py +116 -0
- pebra/core/exposure_model.py +28 -0
- pebra/core/file_risk_aggregation.py +45 -0
- pebra/core/graph_trust.py +32 -0
- pebra/core/graph_version.py +55 -0
- pebra/core/high_risk_controls.py +64 -0
- pebra/core/language_capability.py +121 -0
- pebra/core/learning_eval.py +138 -0
- pebra/core/model_guidance.py +187 -0
- pebra/core/models.py +553 -0
- pebra/core/modify_risk_model.py +206 -0
- pebra/core/outcome_labels.py +58 -0
- pebra/core/patch_paths.py +188 -0
- pebra/core/post_assessment_guardrails.py +304 -0
- pebra/core/prediction_capture.py +218 -0
- pebra/core/prediction_error.py +224 -0
- pebra/core/promotion_evaluator.py +488 -0
- pebra/core/query_validator.py +20 -0
- pebra/core/rca_engine_paths.py +57 -0
- pebra/core/request_validator.py +57 -0
- pebra/core/risk_fact_decay.py +56 -0
- pebra/core/score_math.py +155 -0
- pebra/core/score_normalizer.py +106 -0
- pebra/core/snapshot_reconciler.py +66 -0
- pebra/core/structural_features.py +104 -0
- pebra/core/variance_bounds.py +50 -0
- pebra/core/warm_prior.py +193 -0
- pebra/core/weight_resolver.py +51 -0
- pebra/dashboard/__init__.py +0 -0
- pebra/dashboard/api.py +232 -0
- pebra/dashboard/auth.py +36 -0
- pebra/dashboard/ports.py +56 -0
- pebra/dashboard/server.py +179 -0
- pebra/dashboard/static/app.js +579 -0
- pebra/dashboard/static/style.css +219 -0
- pebra/dashboard/static/vendor/uplot.LICENSE.txt +21 -0
- pebra/dashboard/static/vendor/uplot.iife.min.js +2 -0
- pebra/dashboard/static/vendor/uplot.min.css +1 -0
- pebra/dashboard/templates/index.html +43 -0
- pebra/learning_composition.py +21 -0
- pebra/mcp_server/__init__.py +0 -0
- pebra/mcp_server/__main__.py +17 -0
- pebra/mcp_server/server.py +288 -0
- pebra/observatory_context.py +51 -0
- pebra/ports/__init__.py +0 -0
- pebra/ports/architecture_knowledge_port.py +18 -0
- pebra/ports/blast_radius_port.py +11 -0
- pebra/ports/calibration_port.py +13 -0
- pebra/ports/candidate_application_port.py +33 -0
- pebra/ports/candidate_binding_port.py +15 -0
- pebra/ports/candidate_replay_port.py +15 -0
- pebra/ports/change_verifier_port.py +17 -0
- pebra/ports/config_port.py +57 -0
- pebra/ports/contract_surface_port.py +17 -0
- pebra/ports/evidence_port.py +13 -0
- pebra/ports/fanin_port.py +38 -0
- pebra/ports/file_fanin_port.py +18 -0
- pebra/ports/graph_risk_refinement_port.py +16 -0
- pebra/ports/language_capability_port.py +27 -0
- pebra/ports/learning_port.py +38 -0
- pebra/ports/materialized_diff_port.py +25 -0
- pebra/ports/observatory_read_port.py +34 -0
- pebra/ports/outcome_port.py +16 -0
- pebra/ports/repository_registry_port.py +18 -0
- pebra/ports/sanction_port.py +19 -0
- pebra/ports/snapshot_read_port.py +21 -0
- pebra/ports/store_port.py +114 -0
- pebra/ports/structural_feature_port.py +18 -0
- pebra/ports/symbol_diff_port.py +11 -0
- pebra/tui/__init__.py +6 -0
- pebra/tui/app.py +70 -0
- pebra/tui/data.py +99 -0
- pebra/tui/screens/__init__.py +1 -0
- pebra/tui/screens/detail.py +73 -0
- pebra/tui/screens/observatory.py +200 -0
- pebra/tui/theme.py +64 -0
- pebra/tui/theme.tcss +33 -0
- pebra/tui/widgets/__init__.py +1 -0
- pebra/tui/widgets/ledger_table.py +103 -0
- pebra/tui/widgets/score_sparklines.py +54 -0
- pebra/tui/widgets/status_header.py +31 -0
- pebra-0.1.0.dist-info/METADATA +242 -0
- pebra-0.1.0.dist-info/RECORD +179 -0
- pebra-0.1.0.dist-info/WHEEL +5 -0
- pebra-0.1.0.dist-info/entry_points.txt +3 -0
- pebra-0.1.0.dist-info/licenses/LICENSE +201 -0
- pebra-0.1.0.dist-info/licenses/pebra/dashboard/static/vendor/uplot.LICENSE.txt +21 -0
- pebra-0.1.0.dist-info/top_level.txt +1 -0
pebra/__init__.py
ADDED
|
File without changes
|
pebra/__main__.py
ADDED
|
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
|
pebra/adapters/_paths.py
ADDED
|
@@ -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)
|