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.
@@ -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
- if line in path_set:
145
- counter[line] += 1
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
@@ -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
- if "spring-ldap-core" in text or "spring-security-ldap" in text:
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
@@ -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. Defaults are intentionally NOT
12
- changed when centralizing — ``explain`` and ``pr-impact`` keep their
13
- human-facing ``text`` default — to avoid breaking existing scripts. The strict
14
- guarantee is on ``-f json``, which every command supports.
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": ("text", "json"),
45
+ "pr-impact": ("json", "text"),
40
46
  "migrate-check": ("json", "text"),
41
47
  "spring-audit": ("json", "yaml", "github-comment"),
42
48
  "explain": ("text", "json"),
@@ -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
- ln.strip()
236
- for ln in lines[1:]
237
- if ln.strip() and not ln.startswith("__COMMIT__")
238
- ][:_MAX_FILES_PER_COMMIT]
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: