sourcecode 1.31.31__py3-none-any.whl → 1.31.32__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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.31.31"
3
+ __version__ = "1.31.32"
sourcecode/cache.py CHANGED
@@ -469,6 +469,7 @@ def _cas_load_blob(cache_d: Path, blob_hash: str) -> Optional[str]:
469
469
  try:
470
470
  return gzip.decompress(path.read_bytes()).decode("utf-8")
471
471
  except Exception:
472
+ _safe_unlink(path) # evict corrupted blob so it doesn't block future reads
472
473
  return None
473
474
 
474
475
 
sourcecode/cli.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import threading
5
6
  import time
6
7
  from pathlib import Path
7
8
  from typing import Any, Optional, cast
@@ -140,7 +141,7 @@ def _check_pipeline_coherence(sm: "SourceMap") -> list[str]: # type: ignore[nam
140
141
  return issues
141
142
 
142
143
  _HELP = """\
143
- Compressed AI-ready context for Java/Spring enterprise codebases.
144
+ Deterministic codebase context for AI coding agents.
144
145
 
145
146
  [bold]Primary usage:[/bold]
146
147
  sourcecode --compact high-signal summary (~600-800 tokens)
@@ -176,9 +177,20 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
176
177
  }
177
178
  )
178
179
 
179
- # Mutable container holding the path extracted by _preprocess_argv().
180
- # Default "." means "current directory" when no path is given.
181
- _detected_path: list[str] = ["."]
180
+ # Thread-local storage for the path extracted by _preprocess_argv().
181
+ # Using threading.local() prevents concurrent MCP tool calls from clobbering
182
+ # each other's target path (the old module-level list was a shared mutable global).
183
+ _tls = threading.local()
184
+
185
+
186
+ def _get_detected_path() -> str:
187
+ """Return the thread-local detected path, defaulting to '.'."""
188
+ return _tls.__dict__.get("detected_path", ".")
189
+
190
+
191
+ def _set_detected_path(value: str) -> None:
192
+ """Set the thread-local detected path."""
193
+ _tls.detected_path = value
182
194
 
183
195
 
184
196
  # Options that take a value token — their next arg must not be treated as a path.
@@ -214,6 +226,7 @@ def _preprocess_args(args: list[str]) -> list[str]:
214
226
  """
215
227
  result = list(args)
216
228
  skip_next = False
229
+ _path_index: int = -1
217
230
  for i, arg in enumerate(result):
218
231
  if skip_next:
219
232
  skip_next = False
@@ -227,9 +240,11 @@ def _preprocess_args(args: list[str]) -> list[str]:
227
240
  if arg in _SUBCOMMANDS:
228
241
  return result # known subcommand — leave for Click to dispatch
229
242
  # First genuine positional: treat as repository path
230
- _detected_path[0] = arg
231
- result.pop(i)
232
- return result
243
+ _set_detected_path(arg)
244
+ _path_index = i
245
+ break
246
+ if _path_index >= 0:
247
+ result.pop(_path_index)
233
248
  return result
234
249
 
235
250
 
@@ -334,7 +349,7 @@ def _get_command_with_preprocessing(typer_instance: Any) -> Any:
334
349
  def _cmd_main(args: Optional[list[str]] = None, **kwargs: Any) -> Any:
335
350
  if args is not None:
336
351
  # CliRunner / programmatic call: preprocess the explicit args list.
337
- _detected_path[0] = "."
352
+ _set_detected_path(".")
338
353
  args = _preprocess_args(list(args))
339
354
  # args=None → Click reads sys.argv; _preprocess_argv() in main_entry handled it.
340
355
  return _orig_cmd_main(args=args, **kwargs)
@@ -363,7 +378,7 @@ app.add_typer(mcp_app, name="mcp")
363
378
  def _maybe_ask_consent() -> None:
364
379
  """Show first-run consent prompt once, on interactive TTYs only."""
365
380
  try:
366
- from sourcecode.telemetry.config import has_been_asked, mark_asked, set_enabled
381
+ from sourcecode.telemetry.config import has_been_asked, set_enabled
367
382
  from sourcecode.telemetry.consent import ask_for_consent
368
383
  if not has_been_asked():
369
384
  enabled = ask_for_consent()
@@ -833,7 +848,7 @@ def main(
833
848
  # Path was extracted from argv by _preprocess_argv() before Click ran.
834
849
  # FIX-P2-8: preserve original user input in error messages (Windows Git Bash
835
850
  # rewrites "/nonexistent" → "C:\Program Files\Git\nonexistent" via Path.resolve()).
836
- _raw_path_input = _detected_path[0]
851
+ _raw_path_input = _get_detected_path()
837
852
  target = Path(_raw_path_input).resolve()
838
853
  if not target.exists():
839
854
  _emit_error_json(
@@ -852,6 +867,7 @@ def main(
852
867
 
853
868
  # Normalize mode aliases
854
869
  _CONTRACT_MODES = frozenset({"contract", "minimal", "standard"})
870
+ _user_mode_explicit = mode not in ("contract",) # track if user passed a non-default value
855
871
  if mode == "minimal":
856
872
  mode = "contract" # minimal is a documented alias for contract
857
873
  elif mode not in _CONTRACT_MODES and mode != "raw":
@@ -872,6 +888,20 @@ def main(
872
888
  or docs or semantics or full_metrics or architecture
873
889
  )
874
890
  if mode in ("contract", "standard") and _legacy_flags_active:
891
+ if _user_mode_explicit:
892
+ _overriding_flags = [
893
+ f for f, v in [
894
+ ("--compact", compact), ("--tree", tree),
895
+ ("--trace-pipeline", trace_pipeline), ("--docs", docs),
896
+ ("--semantics", semantics), ("--full-metrics", full_metrics),
897
+ ("--architecture", architecture),
898
+ ] if v
899
+ ]
900
+ typer.echo(
901
+ f"[warning] --mode {mode} was overridden to raw because legacy flags "
902
+ f"({', '.join(_overriding_flags)}) require raw output mode.",
903
+ err=True,
904
+ )
875
905
  mode = "raw"
876
906
 
877
907
  # Map mode to contract_view depth
@@ -995,7 +1025,7 @@ def main(
995
1025
  f"cn={code_notes},mode={mode},"
996
1026
  f"ex={_excl_key},depth={effective_depth}"
997
1027
  )
998
- _core_h = _hashlib.md5(_core_flags_str.encode()).hexdigest()[:8]
1028
+ _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
999
1029
  _core_key = f"{_git_sha}-{_core_h}"
1000
1030
 
1001
1031
  # ── View flags: output presentation only (no re-analysis needed) ──
@@ -1007,7 +1037,7 @@ def main(
1007
1037
  f"mn={max_nodes},ge={graph_edges},mi={max_importers},"
1008
1038
  f"eg={emit_graph}"
1009
1039
  )
1010
- _view_h = _hashlib.md5(_view_flags_str.encode()).hexdigest()[:8]
1040
+ _view_h = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1011
1041
 
1012
1042
  # ── Lookup ──────────────────────────────────────────────────────
1013
1043
  # Step 1: try L1 to obtain the core_hash needed for L2 key
@@ -1961,7 +1991,7 @@ def main(
1961
1991
  if _written_core_hash:
1962
1992
  if not _view_key:
1963
1993
  # _view_key not set (L1 was also a miss); compute it now
1964
- _wvh = _hashlib.md5(_view_flags_str.encode()).hexdigest()[:8]
1994
+ _wvh = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1965
1995
  _view_key = f"{_written_core_hash}-{_wvh}"
1966
1996
  _cache_mod.write_view(
1967
1997
  target,
@@ -478,8 +478,13 @@ class EnvAnalyzer:
478
478
  example_files_found: list,
479
479
  limitations: list,
480
480
  profiles_scanned: list,
481
+ depth: int = 0,
482
+ max_depth: int = 12,
481
483
  ) -> int:
482
484
  """Walk the directory tree accumulating env var findings. Returns spring_candidates count."""
485
+ if depth >= max_depth:
486
+ return 0
487
+
483
488
  try:
484
489
  entries = sorted(current.iterdir())
485
490
  except PermissionError:
@@ -496,7 +501,7 @@ class EnvAnalyzer:
496
501
  continue
497
502
  total_spring_candidates += self._walk(
498
503
  root, entry, findings, example_entries, example_files_found,
499
- limitations, profiles_scanned,
504
+ limitations, profiles_scanned, depth + 1, max_depth,
500
505
  )
501
506
  elif entry.is_file():
502
507
  rel = entry.relative_to(root).as_posix()
@@ -326,6 +326,9 @@ def _parse_uncommitted(output: str) -> "UncommittedChanges":
326
326
  continue
327
327
  x, y = line[0], line[1]
328
328
  filepath = line[3:].strip()
329
+ # Renamed files: git porcelain v1 produces "old -> new"; keep only the new path.
330
+ if x == "R" and " -> " in filepath:
331
+ filepath = filepath.split(" -> ", 1)[1]
329
332
  if x == "?" and y == "?":
330
333
  untracked.append(filepath)
331
334
  else:
sourcecode/license.py CHANGED
@@ -5,10 +5,12 @@ Flow:
5
5
  2. is_pro set globally (True when plan == "pro")
6
6
  3. Pro commands call require_pro(feature_name) at entry — exits 1 if not Pro
7
7
  4. `sourcecode activate <key>` calls activate_license(key) — validates via
8
- Supabase REST, writes ~/.sourcecode/license.json, exits 0 on success
8
+ Edge Function, writes ~/.sourcecode/license.json, exits 0 on success
9
+ 5. Cached license is re-validated every 24 h (online); network errors keep
10
+ cached state (offline-first). Server-side invalidity clears cache.
9
11
 
10
- Supabase credentials:
11
- SOURCECODE_SUPABASE_URL — project REST endpoint
12
+ Supabase credentials (baked in; override via env vars for testing):
13
+ SOURCECODE_SUPABASE_URL — project Edge Function base URL
12
14
  SOURCECODE_SUPABASE_ANON_KEY — public anon key (not a secret)
13
15
  """
14
16
  from __future__ import annotations
@@ -21,19 +23,20 @@ from pathlib import Path
21
23
  from typing import Optional
22
24
 
23
25
  # ---------------------------------------------------------------------------
24
- # Supabase endpoint config — override via env vars
26
+ # Supabase endpoint config — hardcoded for production; override via env for dev
25
27
  # ---------------------------------------------------------------------------
26
28
  _SUPABASE_URL: str = os.environ.get(
27
29
  "SOURCECODE_SUPABASE_URL",
28
- "https://YOUR_PROJECT.supabase.co",
30
+ "https://qkndlmyekvujjdgthtmz.supabase.co",
29
31
  )
30
32
  _SUPABASE_ANON_KEY: str = os.environ.get(
31
33
  "SOURCECODE_SUPABASE_ANON_KEY",
32
- "",
34
+ "", # Set SOURCECODE_SUPABASE_ANON_KEY to your project anon key
33
35
  )
34
36
 
35
37
  _LICENSE_DIR: Path = Path.home() / ".sourcecode"
36
38
  _LICENSE_FILE: Path = _LICENSE_DIR / "license.json"
39
+ _CACHE_TTL_SECONDS: int = 86400 # 24 hours
37
40
 
38
41
  # ---------------------------------------------------------------------------
39
42
  # Global license state — loaded once at import time
@@ -53,6 +56,84 @@ def _load_license_file() -> Optional[dict]:
53
56
  return None
54
57
 
55
58
 
59
+ def _call_get_license(license_key: str) -> Optional[dict]:
60
+ """POST to /get-license edge function. Returns parsed dict or None on network error."""
61
+ import urllib.error
62
+ import urllib.request
63
+
64
+ if not _SUPABASE_ANON_KEY:
65
+ return None
66
+
67
+ url = f"{_SUPABASE_URL}/functions/v1/get-license"
68
+ body = json.dumps({"license_key": license_key}).encode("utf-8")
69
+ req = urllib.request.Request(url, data=body, method="POST")
70
+ req.add_header("apikey", _SUPABASE_ANON_KEY)
71
+ req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
72
+ req.add_header("Content-Type", "application/json")
73
+ req.add_header("Accept", "application/json")
74
+
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=8) as resp:
77
+ return json.loads(resp.read().decode("utf-8"))
78
+ except urllib.error.HTTPError as exc:
79
+ try:
80
+ return json.loads(exc.read().decode("utf-8", errors="replace"))
81
+ except Exception:
82
+ return {"valid": False, "error": f"HTTP {exc.code}"}
83
+ except Exception:
84
+ return None # Network error — caller decides what to do
85
+
86
+
87
+ def _maybe_revalidate() -> None:
88
+ """Re-validate cached license if stale. Mutates globals; never raises."""
89
+ global _license_data, is_pro
90
+
91
+ if not _license_data:
92
+ return
93
+
94
+ validated_at_str = _license_data.get("validated_at") or _license_data.get("activated_at")
95
+ if validated_at_str:
96
+ try:
97
+ validated_at = datetime.fromisoformat(validated_at_str)
98
+ if validated_at.tzinfo is None:
99
+ validated_at = validated_at.replace(tzinfo=timezone.utc)
100
+ age = (datetime.now(timezone.utc) - validated_at).total_seconds()
101
+ if age < _CACHE_TTL_SECONDS:
102
+ return
103
+ except Exception:
104
+ pass
105
+
106
+ key = _license_data.get("license_key")
107
+ if not key:
108
+ return
109
+
110
+ result = _call_get_license(key)
111
+ if result is None:
112
+ return # Network error — keep cached data (offline-first)
113
+
114
+ if not result.get("valid"):
115
+ _license_data = None
116
+ is_pro = False
117
+ try:
118
+ if _LICENSE_FILE.exists():
119
+ _LICENSE_FILE.unlink()
120
+ except Exception:
121
+ pass
122
+ return
123
+
124
+ _license_data["plan"] = result.get("plan", "pro")
125
+ _license_data["features"] = result.get("features", [])
126
+ _license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
127
+ is_pro = _license_data.get("plan") == "pro"
128
+ try:
129
+ _LICENSE_FILE.write_text(
130
+ json.dumps(_license_data, indent=2, ensure_ascii=False),
131
+ encoding="utf-8",
132
+ )
133
+ except Exception:
134
+ pass
135
+
136
+
56
137
  def _init() -> None:
57
138
  global _license_data, is_pro
58
139
  _license_data = _load_license_file()
@@ -72,17 +153,20 @@ _init()
72
153
  def require_pro(feature_name: str) -> None:
73
154
  """Exit with structured JSON error when not Pro.
74
155
 
75
- Call at the very top of every Pro-gated command, before any work.
156
+ Re-validates stale cached license before gating (once per 24 h, online).
76
157
 
77
158
  Example:
78
159
  from sourcecode.license import require_pro
79
160
  require_pro("impact")
80
161
  """
162
+ if is_pro:
163
+ _maybe_revalidate()
164
+
81
165
  if not is_pro:
82
166
  payload = {
83
167
  "error": "pro_required",
84
168
  "feature": feature_name,
85
- "message": "Run sourcecode activate <license_key>",
169
+ "message": "Run: sourcecode activate <license_key>",
86
170
  }
87
171
  sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
88
172
  sys.stdout.flush()
@@ -94,63 +178,42 @@ def require_pro(feature_name: str) -> None:
94
178
  # ---------------------------------------------------------------------------
95
179
 
96
180
  def activate_license(license_key: str) -> None:
97
- """Validate license_key via Supabase, write ~/.sourcecode/license.json.
181
+ """Validate license_key via Edge Function, write ~/.sourcecode/license.json.
98
182
 
99
183
  Outputs JSON to stdout; exits 0 on success, 1 on any failure.
100
184
  Never raises — all error paths emit JSON and call sys.exit(1).
101
185
  """
102
- import urllib.error
103
- import urllib.request
104
-
105
- # Bail early when Supabase isn't configured yet
106
- if not _SUPABASE_ANON_KEY or _SUPABASE_URL == "https://YOUR_PROJECT.supabase.co":
107
- _fail("configuration_error", "SOURCECODE_SUPABASE_URL / SOURCECODE_SUPABASE_ANON_KEY not configured.")
108
-
109
- url = (
110
- f"{_SUPABASE_URL}/rest/v1/users"
111
- f"?license_key=eq.{license_key}"
112
- f"&select=license_key,plan,email"
113
- )
114
- req = urllib.request.Request(url)
115
- req.add_header("apikey", _SUPABASE_ANON_KEY)
116
- req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
117
- req.add_header("Accept", "application/json")
186
+ if not _SUPABASE_ANON_KEY:
187
+ _fail("configuration_error", "SOURCECODE_SUPABASE_ANON_KEY not set. Contact support.")
118
188
 
119
- try:
120
- with urllib.request.urlopen(req, timeout=10) as resp:
121
- body = resp.read().decode("utf-8")
122
- except urllib.error.HTTPError as exc:
123
- _fail("network_error", f"Supabase returned HTTP {exc.code}")
124
- except Exception as exc:
125
- _fail("network_error", str(exc))
189
+ result = _call_get_license(license_key)
126
190
 
127
- try:
128
- rows = json.loads(body)
129
- except Exception:
130
- _fail("network_error", "Invalid JSON response from Supabase")
191
+ if result is None:
192
+ _fail("network_error", "Could not reach license server. Check your internet connection.")
131
193
 
132
- if not rows:
133
- _fail("invalid_license", "License key not found")
194
+ if not result.get("valid"):
195
+ _fail("invalid_license", result.get("error", "License key is not valid or subscription is inactive."))
134
196
 
135
- user = rows[0]
136
- if user.get("plan") != "pro":
137
- _fail("not_pro", "This license is not Pro")
197
+ if result.get("plan") != "pro":
198
+ _fail("not_pro", "This license is not a Pro license.")
138
199
 
139
- # Write license file
140
200
  _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
201
+ now = datetime.now(timezone.utc).isoformat()
141
202
  data = {
142
203
  "license_key": license_key,
143
- "plan": "pro",
144
- "email": user.get("email", ""),
145
- "activated_at": datetime.now(timezone.utc).isoformat(),
204
+ "plan": result["plan"],
205
+ "features": result.get("features", []),
206
+ "email": result.get("email", ""),
207
+ "activated_at": now,
208
+ "validated_at": now,
146
209
  }
147
210
  _LICENSE_FILE.write_text(
148
211
  json.dumps(data, indent=2, ensure_ascii=False),
149
212
  encoding="utf-8",
150
213
  )
151
214
 
152
- result = {"status": "activated", "plan": "pro"}
153
- sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
215
+ output = {"status": "activated", "plan": "pro", "features": data["features"]}
216
+ sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
154
217
  sys.stdout.flush()
155
218
 
156
219
 
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import os
5
6
  from pathlib import Path
6
7
 
7
8
  _MCP_SERVERS_KEY = "mcpServers"
@@ -13,13 +14,14 @@ _ENTRY_VALUE: dict[str, object] = {
13
14
 
14
15
 
15
16
  def read_config(path: Path) -> dict:
16
- """Parse JSON config from path. Returns empty dict if missing or empty."""
17
+ """Parse JSON config from path. Returns empty dict if missing, empty, or unreadable."""
17
18
  if not path.exists():
18
19
  return {}
19
- raw = path.read_text(encoding="utf-8").strip()
20
- if not raw:
20
+ try:
21
+ text = path.read_text(encoding="utf-8")
22
+ return json.loads(text) if text.strip() else {}
23
+ except (OSError, json.JSONDecodeError):
21
24
  return {}
22
- return json.loads(raw) # type: ignore[no-any-return]
23
25
 
24
26
 
25
27
  def is_installed(config: dict) -> bool:
@@ -49,9 +51,15 @@ def remove_entry(config: dict) -> dict:
49
51
 
50
52
 
51
53
  def write_config(path: Path, config: dict) -> None:
52
- """Atomically write config as formatted JSON."""
54
+ """Atomically write config as formatted JSON using a temp file + os.replace."""
53
55
  path.parent.mkdir(parents=True, exist_ok=True)
54
- path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
56
+ tmp = path.with_suffix(".tmp")
57
+ try:
58
+ tmp.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
59
+ os.replace(tmp, path)
60
+ finally:
61
+ if tmp.exists():
62
+ tmp.unlink(missing_ok=True)
55
63
 
56
64
 
57
65
  def validate(path: Path) -> bool:
sourcecode/mcp/runner.py CHANGED
@@ -20,9 +20,9 @@ def run_command(args: list[str]) -> Any:
20
20
  Returns parsed JSON dict when output is valid JSON, else the raw string.
21
21
  Raises RuntimeError on non-zero exit or empty output.
22
22
  """
23
- from sourcecode.cli import _detected_path, _preprocess_args, app
23
+ from sourcecode.cli import _set_detected_path, _preprocess_args, app
24
24
 
25
- _detected_path[0] = "."
25
+ _set_detected_path(".")
26
26
  processed = _preprocess_args(list(args))
27
27
  result = _runner.invoke(app, processed)
28
28
 
sourcecode/mcp/server.py CHANGED
@@ -454,18 +454,21 @@ def generate_tests_context(repo_path: str = ".", include_all: bool = False) -> d
454
454
  timeout_s = timeout_ms / 1000.0
455
455
 
456
456
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
457
- future = executor.submit(_execute, args)
458
- done, _not_done = concurrent.futures.wait([future], timeout=timeout_s)
459
- if _not_done:
460
- executor.shutdown(wait=False)
461
- return _ok({
462
- "truncated": True,
463
- "truncated_reason": f"timeout_{timeout_ms // 1000}s" if timeout_ms >= 1000 else f"timeout_{timeout_ms}ms",
464
- "files_analyzed": 0,
465
- "results": [],
466
- })
467
- executor.shutdown(wait=False)
468
- return future.result()
457
+ try:
458
+ future = executor.submit(_execute, args)
459
+ done, _not_done = concurrent.futures.wait([future], timeout=timeout_s)
460
+ if _not_done:
461
+ executor.shutdown(wait=False)
462
+ return _ok({
463
+ "truncated": True,
464
+ "truncated_reason": f"timeout_{timeout_ms // 1000}s" if timeout_ms >= 1000 else f"timeout_{timeout_ms}ms",
465
+ "files_analyzed": 0,
466
+ "results": [],
467
+ })
468
+ result = future.result()
469
+ finally:
470
+ executor.shutdown(wait=True)
471
+ return result
469
472
 
470
473
  except Exception as exc:
471
474
  return _err(
@@ -532,8 +535,8 @@ def modernize_context(repo_path: str = ".", format: str = "json") -> dict:
532
535
  try:
533
536
  if not isinstance(repo_path, str):
534
537
  return _err("repo_path must be a string", "INVALID_ARGUMENT")
535
- if not isinstance(format, str) or format not in ("json", "yaml"):
536
- return _err("format must be 'json' or 'yaml'", "INVALID_ARGUMENT")
538
+ if not isinstance(format, str) or format != "json":
539
+ return _err("format must be 'json' yaml is not supported for modernize output", "INVALID_ARGUMENT")
537
540
  repo_path = _normalize_repo_path(repo_path)
538
541
  _path_err = _check_repo_path(repo_path)
539
542
  if _path_err is not None:
sourcecode/mcp_nudge.py CHANGED
@@ -3,7 +3,12 @@
3
3
  Fires when:
4
4
  1. At least one known MCP client (Claude Desktop, Cursor) is installed
5
5
  2. sourcecode is NOT yet registered in that client's config
6
- 3. The nudge hasn't been shown this session (~/.sourcecode/nudge_shown flag)
6
+ 3. The nudge hasn't been shown yet (~/.sourcecode/nudge_shown flag absent)
7
+
8
+ The sentinel flag (~/.sourcecode/nudge_shown) persists globally on the
9
+ filesystem — it is NOT session-scoped. Once written it suppresses all future
10
+ nudges across all terminal sessions and process invocations until it is
11
+ deleted (which `sourcecode mcp init` does on successful installation).
7
12
 
8
13
  Cleared by: a successful `sourcecode mcp init` (deletes the flag so the
9
14
  post-init detection finds is_installed=True and never nudges again).
sourcecode/redactor.py CHANGED
@@ -23,6 +23,10 @@ _SECRET_PATTERNS: list[re.Pattern[str]] = [
23
23
  re.compile(r"sk-[A-Za-z0-9]{48}"), # OpenAI legacy key
24
24
  re.compile(r"AKIA[0-9A-Z]{16}"), # AWS Access Key ID
25
25
  re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"), # Bearer tokens
26
+ re.compile(r"sk-ant-[A-Za-z0-9\-_]{32,}"), # Anthropic API key
27
+ re.compile(r"hf_[A-Za-z0-9]{32,}"), # Hugging Face token
28
+ re.compile(r"sk_live_[A-Za-z0-9]{24,}"), # Stripe live secret key
29
+ re.compile(r"pk_live_[A-Za-z0-9]{24,}"), # Stripe live publishable key
26
30
  ]
27
31
 
28
32
  # Patrones de nombres de fichero que deben excluirse (SEC-02)
@@ -3246,7 +3246,7 @@ def compute_blast_radius(
3246
3246
  if _hub_class_guard and direct_callers:
3247
3247
  _n_direct = len(direct_callers)
3248
3248
  _k = min(_HUB_SAMPLE_SIZE, _n_direct)
3249
- _sample_seeds = random.sample(direct_callers, _k)
3249
+ _sample_seeds = sorted(direct_callers, key=lambda x: str(x))[:_k]
3250
3250
  _sample_visited: set[str] = set(matched_fqns) | set(direct_callers)
3251
3251
  _sample_queue: list[tuple[str, int]] = [(c, 1) for c in _sample_seeds]
3252
3252
  _sample_indirect: list[str] = []
sourcecode/serializer.py CHANGED
@@ -190,7 +190,7 @@ def _dependency_import_index(root: Path, file_paths: list[str]) -> set[str]:
190
190
  r"require\(['\"]([^'\"]+)['\"]\)|from\s+['\"]([^'\"]+)['\"])",
191
191
  re.MULTILINE,
192
192
  )
193
- for path in file_paths[:2000]:
193
+ for path in file_paths[:500]:
194
194
  if Path(path).suffix.lower() not in {".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"}:
195
195
  continue
196
196
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.31.31
3
+ Version: 1.31.32
4
4
  Summary: Deterministic codebase context for AI coding agents
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -225,7 +225,7 @@ Description-Content-Type: text/markdown
225
225
 
226
226
  **AI-ready change intelligence for Java/Spring enterprise monoliths.**
227
227
 
228
- ![Version](https://img.shields.io/badge/version-1.31.31-blue)
228
+ ![Version](https://img.shields.io/badge/version-1.31.32-blue)
229
229
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
230
230
 
231
231
  ---
@@ -263,7 +263,7 @@ pipx install sourcecode
263
263
 
264
264
  ```bash
265
265
  sourcecode version
266
- # sourcecode 1.31.31
266
+ # sourcecode 1.31.32
267
267
  ```
268
268
 
269
269
  ---
@@ -1,12 +1,12 @@
1
- sourcecode/__init__.py,sha256=1Q8tXMLCUF_dRIEoNcIN_LLVJVN4jXymK6NlDJ1O1sA,104
1
+ sourcecode/__init__.py,sha256=FAgcPCebMe_1K921d8TjlfM7T_GMcbLwHvBZvp_uat8,104
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/architecture_analyzer.py,sha256=Ry3aYT9dc7XuLmWLT5IZ93RkCf_P14Qtew0nGPvUl_8,42184
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=_btmeOJIe3t-NicF94D5ZAesa2YIJ0_QNExGnbHxGFE,50578
6
- sourcecode/cache.py,sha256=TiYa3ECjBKtvlfCk7GvQ9v6gZkAITpH3ow9PubA7sUo,22946
6
+ sourcecode/cache.py,sha256=pBrPdpPrOgpXHHQO670U3aUfVf5N3A3obsTKgiZtN4I,23030
7
7
  sourcecode/canonical_ir.py,sha256=_HM3AUmKSdna9u4dCoU6rpgSA6HdF8gzOKZykIUCNGY,23277
8
8
  sourcecode/classifier.py,sha256=yWeq6agTjkFa3zuNa-gdVIHtjoBoPoVlJnX-b7tdVJs,7851
9
- sourcecode/cli.py,sha256=757JuVOVVPLsWvqui4jQCaoDew0vSIg4l3ookLFdMqU,160341
9
+ sourcecode/cli.py,sha256=7tkUpZEnXjU_swX1wadkMnfYS0A0p0NvSRZEBIOLLi0,161521
10
10
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
11
11
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
12
12
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -17,13 +17,13 @@ sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo
17
17
  sourcecode/dependency_analyzer.py,sha256=Po7GKJnClCkXty0np1B4F1zo_bPeKAtgbehazhXuaBM,56493
18
18
  sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24411
19
19
  sourcecode/entrypoint_classifier.py,sha256=MTa7yqbeuJ9XPbGCPuvtR9IqY-SN3hoXXyVtb3iXDhs,4316
20
- sourcecode/env_analyzer.py,sha256=GxCidahAAIptTdDFIlVB6URd4HBnBlIX_SqUov3MBRQ,22076
20
+ sourcecode/env_analyzer.py,sha256=9_q_U_Q1tjiY5FSaeEImSDgToP2uHGrjBAFF7ihJn2I,22204
21
21
  sourcecode/file_classifier.py,sha256=QrYm7MlG29HQdAR1WOfpnIIBysAz62c5coz9eQ76meo,12892
22
22
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
23
- sourcecode/git_analyzer.py,sha256=0Gyj-vMpIIN4nfriKXVRouNYBeJ59s6pQDX2Xu9Pq-U,13177
23
+ sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
24
24
  sourcecode/graph_analyzer.py,sha256=iUK-7pSV-cvGqqD2hENdYmhnm0wcXFEyK-xnu5ul8OU,62515
25
- sourcecode/license.py,sha256=VuGBBnUvlDvGm_PA-mCL8WuxQnD4Fa23SG6xxVzFpfk,5461
26
- sourcecode/mcp_nudge.py,sha256=rJzr_dirC6L3VMsUBmzsSSJb-j-nphZy-qq7i2JPjEQ,2625
25
+ sourcecode/license.py,sha256=oiAxBXHf1mz42rrDKQCPX28W2yaAHvWqaVZuijJGSF8,7902
26
+ sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
27
27
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
28
28
  sourcecode/output_budget.py,sha256=43307mJEyUPU3MI-QEQoVxrcAvNyUzdzF_SAPgisBQE,6603
29
29
  sourcecode/path_filters.py,sha256=ROFRQ8eSLBEMiixK9f45-RO7um4VEEcjoD5AA4I427I,3739
@@ -31,15 +31,15 @@ sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrf
31
31
  sourcecode/prepare_context.py,sha256=RM7ka0rduJy8kwGHzLU9if6q7D9ST7tGjOf5LnsdTuw,201451
32
32
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
33
33
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
34
- sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
34
+ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
35
35
  sourcecode/relevance_scorer.py,sha256=MYF4FFkveAQps9SmTeTlh6ODiBz2F--_hWNeHMLtUHQ,8405
36
36
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
37
- sourcecode/repository_ir.py,sha256=y6d_ldrgolNwAiniWe54tR5HUE-UsxfYe5zDS_GNmrY,152741
37
+ sourcecode/repository_ir.py,sha256=rga0I9L0K-3kSMTF8R8slBH4Fkwwrh-ut4Y3h6GxLvY,152757
38
38
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
39
39
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
40
40
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
41
41
  sourcecode/semantic_analyzer.py,sha256=TDuC3wzZR2DPm1mgrAg1YSLk2QzJoueS3TZAmyGGpCU,89417
42
- sourcecode/serializer.py,sha256=7TzN2GLtIP3PIVatoB98_7DQdoAkUNvvNVU7Bz7r_K8,123313
42
+ sourcecode/serializer.py,sha256=3JBvsMDj5pt1RXpi1zJpk5DGWUKbeb2Jl622-kmYWD4,123312
43
43
  sourcecode/summarizer.py,sha256=BMHJA0Do4rBnabc1_BxHoETTNb5ew0VqCX_eY3_PdCg,20706
44
44
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
45
45
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -65,10 +65,10 @@ sourcecode/detectors/systems.py,sha256=nYaKbGDFu0EOXFcd_1doWFT3tTUdkbxc2DjHUF5Tc
65
65
  sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG65hZ4,1693
66
66
  sourcecode/detectors/tooling.py,sha256=8CKbtxwQoABP-WyBRNmdAmHDOvAH57AR1cF4UKuWEdQ,2074
67
67
  sourcecode/mcp/__init__.py,sha256=XU4HfRGbdid8wdUA0x_4f7uKZD1z3mv_XUY_WU_T9Mw,179
68
- sourcecode/mcp/runner.py,sha256=7PnFjKYbgxFeDnqVeSntXHxZX7ZtK3-krDkEuVjI24M,1386
69
- sourcecode/mcp/server.py,sha256=YHhKKwhB0u-DsslX_zptJpplz3i3a_I4svrJg8IKgb4,23894
68
+ sourcecode/mcp/runner.py,sha256=YSw2DXEICau6mCBr3Gfia3D_tKxMbRvIIXEh4cHC1SY,1390
69
+ sourcecode/mcp/server.py,sha256=zbc4G5j2VAlVnluAksoSk6zV_wwgDevaH5Q4oVsl2kA,24018
70
70
  sourcecode/mcp/onboarding/__init__.py,sha256=sj2PWqEBmMc4zBNkomg89WtL0M6S7A9yb7_wAuSWNP4,66
71
- sourcecode/mcp/onboarding/applier.py,sha256=yfSMT0NKdZsjavtLkC8yQ7OtkfepOl5IXGByqg6bdEY,1894
71
+ sourcecode/mcp/onboarding/applier.py,sha256=B9CneieWTpaDSDIyW3S5nrlRlBpvfqUcgi93-mm_ApQ,2135
72
72
  sourcecode/mcp/onboarding/backup.py,sha256=ihqGOR8QTX8HASRSEDyfFyXr5bkXrygPHamv4p9KTmk,1452
73
73
  sourcecode/mcp/onboarding/detector.py,sha256=kDc0U6kXMuq_GivqwKrgJzIVLVeoLr3RQl63ksW10I8,3327
74
74
  sourcecode/mcp/onboarding/planner.py,sha256=Fopg5f72FDiPfldF7NOxYjcBA_w8hi_jBJpSz39lPb8,1332
@@ -78,8 +78,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
78
78
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
79
79
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
80
80
  sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
81
- sourcecode-1.31.31.dist-info/METADATA,sha256=25SDVwl1Kx6PdAtuhy9QVT9WTSIFDVX_yXyMuLAzRsY,31103
82
- sourcecode-1.31.31.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
83
- sourcecode-1.31.31.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
84
- sourcecode-1.31.31.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
85
- sourcecode-1.31.31.dist-info/RECORD,,
81
+ sourcecode-1.31.32.dist-info/METADATA,sha256=69Ai2xiH7kn4Zmsm-KhGAfTTXtUbmyyy9EgLSSQ9GU4,31103
82
+ sourcecode-1.31.32.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
83
+ sourcecode-1.31.32.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
84
+ sourcecode-1.31.32.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
85
+ sourcecode-1.31.32.dist-info/RECORD,,