sourcecode 2.7.0__py3-none-any.whl → 3.0.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cache.py +44 -3
- sourcecode/cli.py +439 -33
- sourcecode/context_cache.py +9 -0
- sourcecode/contract_pipeline.py +14 -3
- sourcecode/detectors/java.py +7 -1
- sourcecode/envelope.py +193 -0
- sourcecode/format_contract.py +11 -5
- sourcecode/git_analyzer.py +74 -12
- sourcecode/posture.py +458 -0
- sourcecode/prepare_context.py +126 -35
- sourcecode/repository_ir.py +70 -1
- sourcecode/schemas/envelope-v1.schema.json +74 -0
- sourcecode/semantic_integration_engine.py +11 -0
- sourcecode/spring_impact.py +44 -10
- sourcecode/spring_profiles.py +11 -1
- sourcecode/spring_security_audit.py +5 -3
- sourcecode/verify_repo.py +252 -0
- sourcecode/verify_rules.py +135 -12
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/METADATA +3 -1
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/RECORD +24 -20
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/contract_pipeline.py
CHANGED
|
@@ -129,7 +129,14 @@ def _resolve_relative(base_dir: str, src: str, path_set: set[str]) -> list[str]:
|
|
|
129
129
|
# ---------------------------------------------------------------------------
|
|
130
130
|
|
|
131
131
|
def _get_git_churn(root: Path, file_paths: list[str]) -> dict[str, int]:
|
|
132
|
-
"""Return commit count per file in last 90 days.
|
|
132
|
+
"""Return commit count per file in last 90 days.
|
|
133
|
+
|
|
134
|
+
`git log` paths are repo-root-relative while `file_paths` are relative to the
|
|
135
|
+
requested scope, so in a monorepo subdirectory the two namespaces never
|
|
136
|
+
intersect and every file scores zero churn. Translate before matching.
|
|
137
|
+
"""
|
|
138
|
+
from sourcecode.git_analyzer import git_scope_prefix, to_scope_relative
|
|
139
|
+
|
|
133
140
|
churn: dict[str, int] = {}
|
|
134
141
|
try:
|
|
135
142
|
result = subprocess.run(
|
|
@@ -137,12 +144,16 @@ def _get_git_churn(root: Path, file_paths: list[str]) -> dict[str, int]:
|
|
|
137
144
|
cwd=root, capture_output=True, text=True,
|
|
138
145
|
encoding="utf-8", errors="replace", timeout=15,
|
|
139
146
|
)
|
|
147
|
+
prefix = git_scope_prefix(root)
|
|
140
148
|
path_set = set(file_paths)
|
|
141
149
|
counter: Counter[str] = Counter()
|
|
142
150
|
for line in (result.stdout or "").splitlines():
|
|
143
151
|
line = line.strip().replace("\\", "/")
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
scoped = to_scope_relative(line, prefix)
|
|
153
|
+
if scoped is None:
|
|
154
|
+
continue
|
|
155
|
+
if scoped in path_set:
|
|
156
|
+
counter[scoped] += 1
|
|
146
157
|
churn = dict(counter)
|
|
147
158
|
except Exception:
|
|
148
159
|
pass
|
sourcecode/detectors/java.py
CHANGED
|
@@ -487,7 +487,13 @@ class JavaDetector(AbstractDetector):
|
|
|
487
487
|
frameworks.append(FrameworkDetection(name="Spring Security", source=source))
|
|
488
488
|
if "spring-boot-starter-data-jpa" in text or "spring-data-jpa" in text:
|
|
489
489
|
frameworks.append(FrameworkDetection(name="Spring Data JPA", source=source))
|
|
490
|
-
|
|
490
|
+
# The Boot starter names neither of the module coordinates, so a Boot app
|
|
491
|
+
# wiring LDAP the standard way declared no LDAP framework at all.
|
|
492
|
+
if (
|
|
493
|
+
"spring-ldap-core" in text
|
|
494
|
+
or "spring-security-ldap" in text
|
|
495
|
+
or "spring-boot-starter-data-ldap" in text
|
|
496
|
+
):
|
|
491
497
|
frameworks.append(FrameworkDetection(name="Spring LDAP", source=source))
|
|
492
498
|
if "spring-aspects" in text or "spring-aop" in text:
|
|
493
499
|
frameworks.append(FrameworkDetection(name="Spring AOP", source=source))
|
sourcecode/envelope.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""One response envelope for every machine-consumable command.
|
|
2
|
+
|
|
3
|
+
A consumer that reads two commands' output had no way to answer basic questions
|
|
4
|
+
about either one: which version produced it, against which scope, from which
|
|
5
|
+
analyzer, and whether two files in front of it came from the same run. Some
|
|
6
|
+
commands carried a `schema_version`, most carried nothing, and no command
|
|
7
|
+
carried run identity at all.
|
|
8
|
+
|
|
9
|
+
`_meta` answers those questions in the same shape everywhere:
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"schema_version": "1.0", # the command's payload contract
|
|
13
|
+
"envelope_version": "1", # the shape of this block
|
|
14
|
+
"tool_version": "2.7.0",
|
|
15
|
+
"command": "retrieve endpoint-inventory",
|
|
16
|
+
"scope": {"path": "/repo/api", "repo_root": "/repo", "prefix": "api/"},
|
|
17
|
+
"run_id": "9f2c…" # (tool, analyzer, tree state, scope)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
`run_id` is deterministic: the same tool analysing the same tree at the same
|
|
21
|
+
scope produces the same id, and any change to the analyzer, the commit, the
|
|
22
|
+
working tree's cleanliness or the scope produces a different one. It carries no
|
|
23
|
+
timestamp, so two consecutive runs on an unchanged tree are byte-identical —
|
|
24
|
+
the property a CI verdict and a signed report both need.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import subprocess
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
#: Shape of the `_meta` block itself. Bump when a field's meaning changes.
|
|
35
|
+
ENVELOPE_VERSION = "1"
|
|
36
|
+
|
|
37
|
+
_META_KEY = "_meta"
|
|
38
|
+
|
|
39
|
+
#: Published schemas, by name. Shipped with the package so a consumer can pin
|
|
40
|
+
#: against the exact contract the installed version emits.
|
|
41
|
+
SCHEMA_DIR = Path(__file__).resolve().parent / "schemas"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def available_schemas() -> "list[str]":
|
|
45
|
+
"""Names of the published schemas, sorted."""
|
|
46
|
+
if not SCHEMA_DIR.is_dir():
|
|
47
|
+
return []
|
|
48
|
+
return sorted(p.name[: -len(".schema.json")] for p in SCHEMA_DIR.glob("*.schema.json"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_schema(name: str) -> "dict[str, Any]":
|
|
52
|
+
"""Read a published schema by name (e.g. "envelope-v1"). Raises FileNotFoundError."""
|
|
53
|
+
import json
|
|
54
|
+
|
|
55
|
+
path = SCHEMA_DIR / f"{name}.schema.json"
|
|
56
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _git(args: list[str], cwd: Path) -> str:
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "-C", str(cwd), *args],
|
|
63
|
+
capture_output=True, text=True, timeout=5,
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
return ""
|
|
67
|
+
return (result.stdout or "").strip() if result.returncode == 0 else ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tree_state(target: Path) -> "tuple[str, str, str]":
|
|
71
|
+
"""(repo_root, scope_prefix, tree_id) for *target*, empty strings when not a repo.
|
|
72
|
+
|
|
73
|
+
`tree_id` is the commit plus a dirty marker: two runs on the same commit with
|
|
74
|
+
the same working tree agree, and an edited working tree never reuses the
|
|
75
|
+
clean commit's identity.
|
|
76
|
+
"""
|
|
77
|
+
root = _git(["rev-parse", "--show-toplevel"], target)
|
|
78
|
+
if not root:
|
|
79
|
+
return "", "", ""
|
|
80
|
+
prefix = _git(["rev-parse", "--show-prefix"], target)
|
|
81
|
+
head = _git(["rev-parse", "HEAD"], target) or "no-commit"
|
|
82
|
+
dirty = _git(["status", "--porcelain"], target)
|
|
83
|
+
tree_id = head if not dirty else f"{head}+dirty:{hashlib.sha256(dirty.encode()).hexdigest()[:12]}"
|
|
84
|
+
return root, prefix, tree_id
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _config_state(target: Path, repo_root: str) -> str:
|
|
88
|
+
"""Digest of the repo configuration that changes what the analyzer reports.
|
|
89
|
+
|
|
90
|
+
`sourcecode.config.json` teaches the scanner a project's own authorization
|
|
91
|
+
annotations, so two runs on the same commit with different configuration
|
|
92
|
+
produce different findings. A run identity that ignored it would claim the
|
|
93
|
+
two runs were the same run.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from sourcecode.security_config import CONFIG_FILENAME
|
|
97
|
+
except Exception:
|
|
98
|
+
return ""
|
|
99
|
+
for base in (Path(repo_root) if repo_root else None, target):
|
|
100
|
+
if base is None:
|
|
101
|
+
continue
|
|
102
|
+
candidate = base / CONFIG_FILENAME
|
|
103
|
+
try:
|
|
104
|
+
if candidate.is_file():
|
|
105
|
+
return hashlib.sha256(candidate.read_bytes()).hexdigest()[:12]
|
|
106
|
+
except OSError:
|
|
107
|
+
continue
|
|
108
|
+
return "none"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_meta(
|
|
112
|
+
command: str,
|
|
113
|
+
target: "Optional[Path]" = None,
|
|
114
|
+
*,
|
|
115
|
+
schema_version: "Optional[str]" = None,
|
|
116
|
+
extra: "Optional[dict[str, Any]]" = None,
|
|
117
|
+
) -> "dict[str, Any]":
|
|
118
|
+
"""Assemble the `_meta` block. Never raises — a degraded envelope beats none."""
|
|
119
|
+
from sourcecode import __version__
|
|
120
|
+
|
|
121
|
+
meta: dict[str, Any] = {
|
|
122
|
+
"envelope_version": ENVELOPE_VERSION,
|
|
123
|
+
"tool_version": __version__,
|
|
124
|
+
"command": command,
|
|
125
|
+
}
|
|
126
|
+
if schema_version:
|
|
127
|
+
meta["schema_version"] = schema_version
|
|
128
|
+
|
|
129
|
+
analyzer = ""
|
|
130
|
+
try:
|
|
131
|
+
from sourcecode.cache import analyzer_fingerprint
|
|
132
|
+
|
|
133
|
+
analyzer = analyzer_fingerprint()
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
repo_root = prefix = tree_id = config_id = ""
|
|
138
|
+
if target is not None:
|
|
139
|
+
try:
|
|
140
|
+
resolved = Path(target).resolve()
|
|
141
|
+
scope: dict[str, Any] = {"path": str(resolved)}
|
|
142
|
+
if resolved.is_dir():
|
|
143
|
+
repo_root, prefix, tree_id = _tree_state(resolved)
|
|
144
|
+
config_id = _config_state(resolved, repo_root)
|
|
145
|
+
if repo_root:
|
|
146
|
+
scope["repo_root"] = repo_root
|
|
147
|
+
# "" at the repository root; "api/" inside a module. The namespace
|
|
148
|
+
# every path in the payload is relative to.
|
|
149
|
+
scope["prefix"] = prefix
|
|
150
|
+
meta["scope"] = scope
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
# The manifest run_id is computed over, published so a consumer can see WHY
|
|
155
|
+
# two runs differ instead of only that they do — and so a reproduction can be
|
|
156
|
+
# attempted against the same inputs.
|
|
157
|
+
if analyzer or tree_id or config_id:
|
|
158
|
+
inputs: dict[str, Any] = {}
|
|
159
|
+
if analyzer:
|
|
160
|
+
inputs["analyzer"] = analyzer
|
|
161
|
+
if tree_id:
|
|
162
|
+
inputs["tree"] = tree_id
|
|
163
|
+
if config_id:
|
|
164
|
+
inputs["config"] = config_id
|
|
165
|
+
meta["inputs"] = inputs
|
|
166
|
+
|
|
167
|
+
meta["run_id"] = hashlib.sha256(
|
|
168
|
+
"\x00".join([
|
|
169
|
+
meta["tool_version"], analyzer, command,
|
|
170
|
+
schema_version or "", repo_root, prefix, tree_id, config_id,
|
|
171
|
+
]).encode()
|
|
172
|
+
).hexdigest()[:16]
|
|
173
|
+
|
|
174
|
+
if extra:
|
|
175
|
+
meta.update(extra)
|
|
176
|
+
return meta
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def attach_meta(payload: "dict[str, Any]", meta: "dict[str, Any]") -> "dict[str, Any]":
|
|
180
|
+
"""Merge *meta* into ``payload["_meta"]`` without overwriting what is there.
|
|
181
|
+
|
|
182
|
+
Commands that already publish provenance (cache source, changed-only state,
|
|
183
|
+
token economy) keep their fields: this adds identity, it does not replace
|
|
184
|
+
what a command knows about its own run.
|
|
185
|
+
"""
|
|
186
|
+
existing = payload.get(_META_KEY)
|
|
187
|
+
if isinstance(existing, dict):
|
|
188
|
+
merged = dict(meta)
|
|
189
|
+
merged.update(existing)
|
|
190
|
+
payload[_META_KEY] = merged
|
|
191
|
+
else:
|
|
192
|
+
payload[_META_KEY] = dict(meta)
|
|
193
|
+
return payload
|
sourcecode/format_contract.py
CHANGED
|
@@ -8,10 +8,16 @@ Every CLI command that emits machine-consumable output validates its
|
|
|
8
8
|
* invalid-format errors share an identical envelope shape and exit code.
|
|
9
9
|
|
|
10
10
|
The registry value is an *ordered* tuple; element ``0`` is the command's
|
|
11
|
-
default and matches its Typer option default.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
default and matches its Typer option default. The strict guarantee is on
|
|
12
|
+
``-f json``, which every command supports.
|
|
13
|
+
|
|
14
|
+
``explain`` keeps a human-facing ``text`` default: it emits prose for a reader,
|
|
15
|
+
and infers ``json`` from an ``--output`` file extension. ``pr-impact`` does not.
|
|
16
|
+
It is a gating command — it carries ``--fail-on`` and a meaningful exit code, so
|
|
17
|
+
its consumer is a pipeline, and a pipeline cannot parse prose. It defaulted to
|
|
18
|
+
``text`` while every other machine-consumable command defaulted to ``json``,
|
|
19
|
+
which made scripted use a trap. Scripts that relied on the old default must now
|
|
20
|
+
pass ``--format text`` explicitly.
|
|
15
21
|
|
|
16
22
|
Exit-code policy: an invalid ``--format`` is an argument-validation error and
|
|
17
23
|
exits with code ``2`` for every command (matching the documented
|
|
@@ -36,7 +42,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
|
|
|
36
42
|
"baseline-diff": ("json", "yaml"),
|
|
37
43
|
"baseline-trend": ("json", "yaml"),
|
|
38
44
|
"impact-chain": ("json", "yaml"),
|
|
39
|
-
"pr-impact": ("
|
|
45
|
+
"pr-impact": ("json", "text"),
|
|
40
46
|
"migrate-check": ("json", "text"),
|
|
41
47
|
"spring-audit": ("json", "yaml", "github-comment"),
|
|
42
48
|
"explain": ("text", "json"),
|
sourcecode/git_analyzer.py
CHANGED
|
@@ -76,6 +76,49 @@ def _run_git(args: list[str], cwd: Path, timeout: int = 15) -> tuple[str, int]:
|
|
|
76
76
|
return result.stdout or "", result.returncode
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def git_scope_prefix(path: Path) -> str:
|
|
80
|
+
"""Position of *path* inside its git repository, "" when it IS the repo root.
|
|
81
|
+
|
|
82
|
+
Git reports every path relative to the repository root; the rest of the engine
|
|
83
|
+
addresses files relative to the requested scope. In a monorepo subdirectory
|
|
84
|
+
those two namespaces never intersect, so churn, hotspots and changed-file sets
|
|
85
|
+
silently come back empty and root-relative paths leak into scoped output.
|
|
86
|
+
Every git path must cross this seam before the engine sees it.
|
|
87
|
+
|
|
88
|
+
Returns a prefix with a trailing slash (e.g. "api/"), as `git rev-parse
|
|
89
|
+
--show-prefix` does.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
stdout, rc = _run_git(["rev-parse", "--show-prefix"], path, timeout=5)
|
|
93
|
+
except Exception:
|
|
94
|
+
return ""
|
|
95
|
+
return stdout.strip() if rc == 0 else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def to_scope_relative(git_path: str, prefix: str) -> "str | None":
|
|
99
|
+
"""Translate a repo-root-relative git path into the scope namespace.
|
|
100
|
+
|
|
101
|
+
Returns None when the file lives outside the requested scope — it belongs to a
|
|
102
|
+
sibling module and must not appear in this scope's results.
|
|
103
|
+
"""
|
|
104
|
+
if not prefix:
|
|
105
|
+
return git_path
|
|
106
|
+
if git_path.startswith(prefix):
|
|
107
|
+
return git_path[len(prefix):] or None
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _scope_paths(paths: list[str], prefix: str) -> list[str]:
|
|
112
|
+
if not prefix:
|
|
113
|
+
return paths
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
for p in paths:
|
|
116
|
+
rel = to_scope_relative(p, prefix)
|
|
117
|
+
if rel:
|
|
118
|
+
out.append(rel)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
79
122
|
class GitAnalyzer:
|
|
80
123
|
"""Extrae contexto temporal del repositorio git."""
|
|
81
124
|
|
|
@@ -112,6 +155,10 @@ class GitAnalyzer:
|
|
|
112
155
|
except subprocess.TimeoutExpired:
|
|
113
156
|
return GitContext(requested=True, limitations=["git_timeout"])
|
|
114
157
|
|
|
158
|
+
# Everything git reports below is repo-root-relative; the caller addresses
|
|
159
|
+
# files relative to `path`. Translate at this seam, once.
|
|
160
|
+
prefix = git_scope_prefix(path)
|
|
161
|
+
|
|
115
162
|
try:
|
|
116
163
|
stdout, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], path, timeout=5)
|
|
117
164
|
branch = stdout.strip() or None
|
|
@@ -129,7 +176,7 @@ class GitAnalyzer:
|
|
|
129
176
|
path,
|
|
130
177
|
timeout=15,
|
|
131
178
|
)
|
|
132
|
-
recent_commits = _parse_commits(stdout)
|
|
179
|
+
recent_commits = _parse_commits(stdout, prefix)
|
|
133
180
|
except subprocess.TimeoutExpired:
|
|
134
181
|
limitations.append("commits_timeout")
|
|
135
182
|
except Exception as exc:
|
|
@@ -147,7 +194,7 @@ class GitAnalyzer:
|
|
|
147
194
|
path,
|
|
148
195
|
timeout=30,
|
|
149
196
|
)
|
|
150
|
-
change_hotspots = _parse_hotspots(stdout)
|
|
197
|
+
change_hotspots = _parse_hotspots(stdout, prefix)
|
|
151
198
|
except subprocess.TimeoutExpired:
|
|
152
199
|
limitations.append("hotspots_timeout")
|
|
153
200
|
hotspots_status = "failed"
|
|
@@ -172,7 +219,7 @@ class GitAnalyzer:
|
|
|
172
219
|
path,
|
|
173
220
|
timeout=30,
|
|
174
221
|
)
|
|
175
|
-
change_hotspots = _parse_hotspots(stdout)
|
|
222
|
+
change_hotspots = _parse_hotspots(stdout, prefix)
|
|
176
223
|
if change_hotspots:
|
|
177
224
|
hotspot_method = "absolute_log_fallback"
|
|
178
225
|
limitations.append(
|
|
@@ -184,7 +231,7 @@ class GitAnalyzer:
|
|
|
184
231
|
|
|
185
232
|
try:
|
|
186
233
|
stdout, _ = _run_git(["status", "--porcelain"], path, timeout=10)
|
|
187
|
-
uncommitted = _parse_uncommitted(stdout)
|
|
234
|
+
uncommitted = _parse_uncommitted(stdout, prefix)
|
|
188
235
|
except subprocess.TimeoutExpired:
|
|
189
236
|
limitations.append("status_timeout")
|
|
190
237
|
except Exception as exc:
|
|
@@ -217,7 +264,7 @@ class GitAnalyzer:
|
|
|
217
264
|
)
|
|
218
265
|
|
|
219
266
|
|
|
220
|
-
def _parse_commits(output: str) -> list:
|
|
267
|
+
def _parse_commits(output: str, prefix: str = "") -> list:
|
|
221
268
|
from sourcecode.schema import CommitRecord
|
|
222
269
|
|
|
223
270
|
commits = []
|
|
@@ -231,11 +278,14 @@ def _parse_commits(output: str) -> list:
|
|
|
231
278
|
if len(parts) < 4:
|
|
232
279
|
continue
|
|
233
280
|
hash_val, message, author, date_str = parts
|
|
234
|
-
files =
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
files = _scope_paths(
|
|
282
|
+
[
|
|
283
|
+
ln.strip()
|
|
284
|
+
for ln in lines[1:]
|
|
285
|
+
if ln.strip() and not ln.startswith("__COMMIT__")
|
|
286
|
+
],
|
|
287
|
+
prefix,
|
|
288
|
+
)[:_MAX_FILES_PER_COMMIT]
|
|
239
289
|
commits.append(
|
|
240
290
|
CommitRecord(
|
|
241
291
|
hash=hash_val[:8],
|
|
@@ -272,7 +322,7 @@ def _is_hotspot_admin(path: str) -> bool:
|
|
|
272
322
|
return False
|
|
273
323
|
|
|
274
324
|
|
|
275
|
-
def _parse_hotspots(output: str | None) -> list:
|
|
325
|
+
def _parse_hotspots(output: str | None, prefix: str = "") -> list:
|
|
276
326
|
from sourcecode.schema import ChangeHotspot
|
|
277
327
|
|
|
278
328
|
if not output:
|
|
@@ -301,6 +351,12 @@ def _parse_hotspots(output: str | None) -> list:
|
|
|
301
351
|
# Skip git artifact lines that are not file paths: flags (-o, --), separators, etc.
|
|
302
352
|
if line.startswith("-") or not ("/" in line or "." in line):
|
|
303
353
|
continue
|
|
354
|
+
# Cross the namespace seam before anything counts or is emitted: files in
|
|
355
|
+
# sibling modules are not this scope's hotspots.
|
|
356
|
+
scoped = to_scope_relative(line, prefix)
|
|
357
|
+
if scoped is None:
|
|
358
|
+
continue
|
|
359
|
+
line = scoped
|
|
304
360
|
if _is_hotspot_admin(line):
|
|
305
361
|
continue
|
|
306
362
|
file_counts[line] += 1
|
|
@@ -317,7 +373,7 @@ def _parse_hotspots(output: str | None) -> list:
|
|
|
317
373
|
]
|
|
318
374
|
|
|
319
375
|
|
|
320
|
-
def _parse_uncommitted(output: str) -> "UncommittedChanges":
|
|
376
|
+
def _parse_uncommitted(output: str, prefix: str = "") -> "UncommittedChanges":
|
|
321
377
|
from sourcecode.schema import UncommittedChanges
|
|
322
378
|
|
|
323
379
|
staged, unstaged, untracked = [], [], []
|
|
@@ -329,6 +385,12 @@ def _parse_uncommitted(output: str) -> "UncommittedChanges":
|
|
|
329
385
|
# Renamed files: git porcelain v1 produces "old -> new"; keep only the new path.
|
|
330
386
|
if x == "R" and " -> " in filepath:
|
|
331
387
|
filepath = filepath.split(" -> ", 1)[1]
|
|
388
|
+
# `git status --porcelain` is always repo-root-relative, even when run from
|
|
389
|
+
# a subdirectory: translate, and drop files outside the requested scope.
|
|
390
|
+
_scoped = to_scope_relative(filepath, prefix)
|
|
391
|
+
if _scoped is None:
|
|
392
|
+
continue
|
|
393
|
+
filepath = _scoped
|
|
332
394
|
if x == "?" and y == "?":
|
|
333
395
|
untracked.append(filepath)
|
|
334
396
|
else:
|