sourcecode 1.35.17__py3-none-any.whl → 1.35.19__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/cli.py +189 -2
- sourcecode/license.py +221 -4
- sourcecode/migrate_check.py +434 -0
- {sourcecode-1.35.17.dist-info → sourcecode-1.35.19.dist-info}/METADATA +1 -1
- {sourcecode-1.35.17.dist-info → sourcecode-1.35.19.dist-info}/RECORD +9 -8
- {sourcecode-1.35.17.dist-info → sourcecode-1.35.19.dist-info}/WHEEL +0 -0
- {sourcecode-1.35.17.dist-info → sourcecode-1.35.19.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.35.17.dist-info → sourcecode-1.35.19.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -167,6 +167,11 @@ Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
|
|
|
167
167
|
sourcecode --compact --git-context include git hotspots and uncommitted files
|
|
168
168
|
sourcecode --agent full structured JSON for AI agents
|
|
169
169
|
|
|
170
|
+
[bold]Auth commands:[/bold]
|
|
171
|
+
auth login [dim]# authenticate via browser (device code)[/dim]
|
|
172
|
+
auth status [dim]# show current plan and auth state[/dim]
|
|
173
|
+
auth logout [dim]# remove local credentials[/dim]
|
|
174
|
+
|
|
170
175
|
[bold]Cache commands:[/bold]
|
|
171
176
|
cache status [dim]# cache size, hit keys, last-warmed timestamp[/dim]
|
|
172
177
|
cache warm [dim]# pre-build cache ahead of an agent session[/dim]
|
|
@@ -221,8 +226,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
221
226
|
"repo-ir", "mcp", "endpoints", "impact",
|
|
222
227
|
# Enterprise workflow commands
|
|
223
228
|
"onboard", "modernize", "fix-bug", "review-pr",
|
|
224
|
-
# License
|
|
225
|
-
"activate",
|
|
229
|
+
# License / auth
|
|
230
|
+
"activate", "auth",
|
|
226
231
|
# Cache observability
|
|
227
232
|
"cache",
|
|
228
233
|
# RIS bootstrap
|
|
@@ -235,6 +240,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
235
240
|
"pr-impact",
|
|
236
241
|
# Class architectural summary
|
|
237
242
|
"explain",
|
|
243
|
+
# Spring Boot 2→3 migration readiness
|
|
244
|
+
"migrate-check",
|
|
238
245
|
}
|
|
239
246
|
)
|
|
240
247
|
|
|
@@ -512,6 +519,9 @@ app.add_typer(mcp_app, name="mcp")
|
|
|
512
519
|
cache_app = typer.Typer(help="Cache inspection and management.", rich_markup_mode="rich")
|
|
513
520
|
app.add_typer(cache_app, name="cache")
|
|
514
521
|
|
|
522
|
+
auth_app = typer.Typer(help="Authentication: login, status, logout.", rich_markup_mode="rich")
|
|
523
|
+
app.add_typer(auth_app, name="auth")
|
|
524
|
+
|
|
515
525
|
|
|
516
526
|
def _maybe_ask_consent() -> None:
|
|
517
527
|
"""Show first-run consent prompt once, on interactive TTYs only."""
|
|
@@ -3890,6 +3900,116 @@ def spring_audit_cmd(
|
|
|
3890
3900
|
typer.echo("✓ copied to clipboard", err=True)
|
|
3891
3901
|
|
|
3892
3902
|
|
|
3903
|
+
# ── Spring Boot Migration Check ───────────────────────────────────────────────
|
|
3904
|
+
|
|
3905
|
+
|
|
3906
|
+
@app.command("migrate-check")
|
|
3907
|
+
def migrate_check_cmd(
|
|
3908
|
+
path: Path = typer.Argument(
|
|
3909
|
+
Path("."),
|
|
3910
|
+
help="Repository path to scan (default: current directory)",
|
|
3911
|
+
),
|
|
3912
|
+
output_path: Optional[Path] = typer.Option(
|
|
3913
|
+
None, "--output", "-o",
|
|
3914
|
+
help="Write output to a file instead of stdout.",
|
|
3915
|
+
),
|
|
3916
|
+
format: str = typer.Option(
|
|
3917
|
+
"json",
|
|
3918
|
+
"--format",
|
|
3919
|
+
"-f",
|
|
3920
|
+
help="Output format: json (default) or text.",
|
|
3921
|
+
show_default=True,
|
|
3922
|
+
),
|
|
3923
|
+
copy: bool = typer.Option(
|
|
3924
|
+
False,
|
|
3925
|
+
"--copy",
|
|
3926
|
+
"-c",
|
|
3927
|
+
help="Copy output to system clipboard after a successful run.",
|
|
3928
|
+
),
|
|
3929
|
+
min_severity: str = typer.Option(
|
|
3930
|
+
"low",
|
|
3931
|
+
"--min-severity",
|
|
3932
|
+
help="Minimum severity to include: critical, high, medium, or low (default).",
|
|
3933
|
+
show_default=True,
|
|
3934
|
+
),
|
|
3935
|
+
) -> None:
|
|
3936
|
+
"""Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
|
|
3937
|
+
|
|
3938
|
+
\b
|
|
3939
|
+
Detects:
|
|
3940
|
+
MIG-001 javax.persistence import (CRITICAL — JPA will not compile)
|
|
3941
|
+
MIG-002 javax.servlet import (HIGH — Servlet API changed)
|
|
3942
|
+
MIG-003 javax.validation import (HIGH — Bean Validation changed)
|
|
3943
|
+
MIG-004 javax.transaction import (HIGH — TX API changed)
|
|
3944
|
+
MIG-005 extends WebSecurityConfigurerAdapter (HIGH — removed in Spring 6)
|
|
3945
|
+
MIG-006 javax.annotation import (MEDIUM)
|
|
3946
|
+
MIG-007 javax.inject import (MEDIUM)
|
|
3947
|
+
MIG-008 javax.ws.rs import (MEDIUM — JAX-RS changed)
|
|
3948
|
+
|
|
3949
|
+
\b
|
|
3950
|
+
Examples:
|
|
3951
|
+
sourcecode migrate-check .
|
|
3952
|
+
sourcecode migrate-check /path/to/repo --format text
|
|
3953
|
+
sourcecode migrate-check . --min-severity high
|
|
3954
|
+
sourcecode migrate-check . --output migration.json
|
|
3955
|
+
"""
|
|
3956
|
+
from sourcecode.repository_ir import find_java_files
|
|
3957
|
+
from sourcecode.migrate_check import run_migrate_check
|
|
3958
|
+
|
|
3959
|
+
target = path.resolve()
|
|
3960
|
+
if not target.exists() or not target.is_dir():
|
|
3961
|
+
_emit_error_json(
|
|
3962
|
+
INVALID_INPUT_CODE,
|
|
3963
|
+
f"'{target}' is not a valid directory.",
|
|
3964
|
+
path=str(target),
|
|
3965
|
+
hint="Pass an existing repository directory.",
|
|
3966
|
+
expected="A directory path.",
|
|
3967
|
+
)
|
|
3968
|
+
raise typer.Exit(code=1)
|
|
3969
|
+
|
|
3970
|
+
if format not in ("json", "text"):
|
|
3971
|
+
_emit_error_json(
|
|
3972
|
+
INVALID_INPUT_CODE,
|
|
3973
|
+
f"Invalid format '{format}'.",
|
|
3974
|
+
hint="format must be one of: json, text.",
|
|
3975
|
+
expected="json | text",
|
|
3976
|
+
)
|
|
3977
|
+
raise typer.Exit(code=1)
|
|
3978
|
+
|
|
3979
|
+
if min_severity not in ("critical", "high", "medium", "low"):
|
|
3980
|
+
_emit_error_json(
|
|
3981
|
+
INVALID_INPUT_CODE,
|
|
3982
|
+
f"Invalid min-severity '{min_severity}'.",
|
|
3983
|
+
hint="min-severity must be one of: critical, high, medium, low.",
|
|
3984
|
+
expected="critical | high | medium | low",
|
|
3985
|
+
)
|
|
3986
|
+
raise typer.Exit(code=1)
|
|
3987
|
+
|
|
3988
|
+
file_list = find_java_files(target)
|
|
3989
|
+
report = run_migrate_check(file_list, target, min_severity=min_severity)
|
|
3990
|
+
|
|
3991
|
+
if format == "text":
|
|
3992
|
+
output = report.to_text(min_severity=min_severity)
|
|
3993
|
+
else:
|
|
3994
|
+
output = _serialize_dict(report.to_dict(), "json")
|
|
3995
|
+
|
|
3996
|
+
if output_path is not None:
|
|
3997
|
+
output_path.write_text(output, encoding="utf-8")
|
|
3998
|
+
total = report.summary.get("total_findings", 0)
|
|
3999
|
+
typer.echo(
|
|
4000
|
+
f"Migration check written to {output_path} "
|
|
4001
|
+
f"(score: {report.readiness_score}/100, {total} findings)",
|
|
4002
|
+
err=True,
|
|
4003
|
+
)
|
|
4004
|
+
else:
|
|
4005
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
4006
|
+
sys.stdout.buffer.write(b"\n")
|
|
4007
|
+
sys.stdout.buffer.flush()
|
|
4008
|
+
if copy:
|
|
4009
|
+
if _copy_to_clipboard(output):
|
|
4010
|
+
typer.echo("✓ copied to clipboard", err=True)
|
|
4011
|
+
|
|
4012
|
+
|
|
3893
4013
|
# ── Spring Impact Chain ───────────────────────────────────────────────────────
|
|
3894
4014
|
|
|
3895
4015
|
|
|
@@ -4799,6 +4919,73 @@ def activate_cmd(
|
|
|
4799
4919
|
_activate(license_key)
|
|
4800
4920
|
|
|
4801
4921
|
|
|
4922
|
+
# ---------------------------------------------------------------------------
|
|
4923
|
+
# Auth commands (device-flow login / status / logout)
|
|
4924
|
+
# ---------------------------------------------------------------------------
|
|
4925
|
+
|
|
4926
|
+
@auth_app.command("login")
|
|
4927
|
+
def auth_login_cmd() -> None:
|
|
4928
|
+
"""Authenticate via browser (device code flow).
|
|
4929
|
+
|
|
4930
|
+
\b
|
|
4931
|
+
The CLI shows a URL. Open it in your browser, log in with your account,
|
|
4932
|
+
and the CLI completes authentication automatically.
|
|
4933
|
+
Credentials are stored in ~/.sourcecode/license.json (30-min cache; Supabase is source of truth).
|
|
4934
|
+
|
|
4935
|
+
\b
|
|
4936
|
+
Examples:
|
|
4937
|
+
sourcecode auth login
|
|
4938
|
+
"""
|
|
4939
|
+
from sourcecode.license import auth_login as _auth_login
|
|
4940
|
+
_auth_login()
|
|
4941
|
+
|
|
4942
|
+
|
|
4943
|
+
@auth_app.command("status")
|
|
4944
|
+
def auth_status_cmd() -> None:
|
|
4945
|
+
"""Show current authentication and plan status."""
|
|
4946
|
+
import json as _json
|
|
4947
|
+
try:
|
|
4948
|
+
from sourcecode.license import _license_data as _ld, is_pro as _ip
|
|
4949
|
+
except Exception:
|
|
4950
|
+
_ld = None
|
|
4951
|
+
_ip = False
|
|
4952
|
+
|
|
4953
|
+
if not _ld:
|
|
4954
|
+
out: dict = {"status": "unauthenticated", "pro": False}
|
|
4955
|
+
sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
|
|
4956
|
+
sys.stdout.flush()
|
|
4957
|
+
return
|
|
4958
|
+
|
|
4959
|
+
out = {
|
|
4960
|
+
"status": "authenticated",
|
|
4961
|
+
"auth_method": _ld.get("auth_method", "license_key"),
|
|
4962
|
+
"email": _ld.get("email", ""),
|
|
4963
|
+
"plan": _ld.get("plan", "unknown"),
|
|
4964
|
+
"plan_status": _ld.get("status", "unknown"),
|
|
4965
|
+
"pro": _ip,
|
|
4966
|
+
"validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
|
|
4967
|
+
}
|
|
4968
|
+
sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
|
|
4969
|
+
sys.stdout.flush()
|
|
4970
|
+
|
|
4971
|
+
|
|
4972
|
+
@auth_app.command("logout")
|
|
4973
|
+
def auth_logout_cmd() -> None:
|
|
4974
|
+
"""Remove local credentials (does not cancel your subscription)."""
|
|
4975
|
+
import json as _json
|
|
4976
|
+
_lf = Path.home() / ".sourcecode" / "license.json"
|
|
4977
|
+
if _lf.exists():
|
|
4978
|
+
try:
|
|
4979
|
+
_lf.unlink()
|
|
4980
|
+
out: dict = {"status": "logged_out", "message": "Local credentials removed."}
|
|
4981
|
+
except Exception as _exc:
|
|
4982
|
+
out = {"status": "error", "message": str(_exc)}
|
|
4983
|
+
else:
|
|
4984
|
+
out = {"status": "logged_out", "message": "No local credentials found."}
|
|
4985
|
+
sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
|
|
4986
|
+
sys.stdout.flush()
|
|
4987
|
+
|
|
4988
|
+
|
|
4802
4989
|
@app.command("version")
|
|
4803
4990
|
def version_cmd() -> None:
|
|
4804
4991
|
"""Show version and exit.
|
sourcecode/license.py
CHANGED
|
@@ -42,8 +42,17 @@ if _SUPABASE_URL != _DEFAULT_SUPABASE_URL:
|
|
|
42
42
|
_LICENSE_DIR: Path = Path.home() / ".sourcecode"
|
|
43
43
|
_LICENSE_FILE: Path = _LICENSE_DIR / "license.json"
|
|
44
44
|
_DELTA_RUNS_FILE: Path = _LICENSE_DIR / "delta_runs.json"
|
|
45
|
-
_CACHE_TTL_SECONDS: int =
|
|
45
|
+
_CACHE_TTL_SECONDS: int = 1800 # 30 minutes default; CI env overrides to 24h (see _get_cache_ttl)
|
|
46
|
+
_CACHE_TTL_CI_SECONDS: int = 86400 # 24 hours — CI containers must not re-validate mid-run
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_cache_ttl() -> int:
|
|
50
|
+
"""Return TTL in seconds. CI containers get 24h to avoid mid-run network calls."""
|
|
51
|
+
return _CACHE_TTL_CI_SECONDS if os.environ.get("SOURCECODE_CI") else _CACHE_TTL_SECONDS
|
|
46
52
|
_DELTA_FREE_LIMIT: int = 30
|
|
53
|
+
_DEVICE_POLL_INTERVAL_S: float = 2.5
|
|
54
|
+
_DEVICE_POLL_TIMEOUT_S: float = 300.0 # 5-minute window for user to complete browser auth
|
|
55
|
+
_AUTH_BASE_URL: str = "https://sourcecode.dev"
|
|
47
56
|
_LICENSE_KEY_RE = re.compile(r"^[A-Za-z0-9_\-]{1,200}$")
|
|
48
57
|
|
|
49
58
|
# ---------------------------------------------------------------------------
|
|
@@ -213,6 +222,78 @@ def _call_get_license(license_key: str) -> Optional[dict]:
|
|
|
213
222
|
return None # Network error — caller decides what to do
|
|
214
223
|
|
|
215
224
|
|
|
225
|
+
def _generate_device_code() -> str:
|
|
226
|
+
"""Generate a human-readable device code: XXXX-XXXX-XXXX."""
|
|
227
|
+
import uuid
|
|
228
|
+
raw = uuid.uuid4().hex.upper()
|
|
229
|
+
return f"{raw[:4]}-{raw[4:8]}-{raw[8:12]}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _call_device_check(device_code: str) -> Optional[dict]:
|
|
233
|
+
"""Poll /device-check edge function. Returns dict or None on network error.
|
|
234
|
+
|
|
235
|
+
Expected responses:
|
|
236
|
+
{"status": "pending"}
|
|
237
|
+
{"status": "complete", "device_token": "...", "email": "...", "plan": "pro", ...}
|
|
238
|
+
{"status": "error", "message": "..."}
|
|
239
|
+
"""
|
|
240
|
+
import urllib.error
|
|
241
|
+
import urllib.request
|
|
242
|
+
|
|
243
|
+
if not _SUPABASE_ANON_KEY:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
url = f"{_SUPABASE_URL}/functions/v1/device-check"
|
|
247
|
+
body = json.dumps({"device_code": device_code}).encode("utf-8")
|
|
248
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
249
|
+
req.add_header("apikey", _SUPABASE_ANON_KEY)
|
|
250
|
+
req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
|
|
251
|
+
req.add_header("Content-Type", "application/json")
|
|
252
|
+
req.add_header("Accept", "application/json")
|
|
253
|
+
try:
|
|
254
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
255
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
256
|
+
except urllib.error.HTTPError as exc:
|
|
257
|
+
try:
|
|
258
|
+
return json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
259
|
+
except Exception:
|
|
260
|
+
return {"status": "error", "message": f"HTTP {exc.code}"}
|
|
261
|
+
except Exception:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _call_get_user_plan(device_token: str) -> Optional[dict]:
|
|
266
|
+
"""Fetch current plan/status for an authenticated device token.
|
|
267
|
+
|
|
268
|
+
Expected response:
|
|
269
|
+
{"valid": true, "plan": "pro", "status": "active", "features": [...], "email": "..."}
|
|
270
|
+
{"valid": false, "error": "token_revoked"}
|
|
271
|
+
"""
|
|
272
|
+
import urllib.error
|
|
273
|
+
import urllib.request
|
|
274
|
+
|
|
275
|
+
if not _SUPABASE_ANON_KEY:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
url = f"{_SUPABASE_URL}/functions/v1/get-user-plan"
|
|
279
|
+
body = json.dumps({"device_token": device_token}).encode("utf-8")
|
|
280
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
281
|
+
req.add_header("apikey", _SUPABASE_ANON_KEY)
|
|
282
|
+
req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
|
|
283
|
+
req.add_header("Content-Type", "application/json")
|
|
284
|
+
req.add_header("Accept", "application/json")
|
|
285
|
+
try:
|
|
286
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
287
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
288
|
+
except urllib.error.HTTPError as exc:
|
|
289
|
+
try:
|
|
290
|
+
return json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
291
|
+
except Exception:
|
|
292
|
+
return {"valid": False, "error": f"HTTP {exc.code}"}
|
|
293
|
+
except Exception:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
216
297
|
def _maybe_revalidate() -> None:
|
|
217
298
|
"""Re-validate cached license if stale. Mutates globals; never raises."""
|
|
218
299
|
global _license_data, is_pro
|
|
@@ -220,18 +301,55 @@ def _maybe_revalidate() -> None:
|
|
|
220
301
|
if not _license_data:
|
|
221
302
|
return
|
|
222
303
|
|
|
223
|
-
validated_at_str =
|
|
304
|
+
validated_at_str = (
|
|
305
|
+
_license_data.get("validated_at")
|
|
306
|
+
or _license_data.get("activated_at")
|
|
307
|
+
or _license_data.get("authenticated_at")
|
|
308
|
+
)
|
|
224
309
|
if validated_at_str:
|
|
225
310
|
try:
|
|
226
311
|
validated_at = datetime.fromisoformat(validated_at_str)
|
|
227
312
|
if validated_at.tzinfo is None:
|
|
228
313
|
validated_at = validated_at.replace(tzinfo=timezone.utc)
|
|
229
314
|
age = (datetime.now(timezone.utc) - validated_at).total_seconds()
|
|
230
|
-
if age <
|
|
315
|
+
if age < _get_cache_ttl():
|
|
231
316
|
return
|
|
232
317
|
except Exception:
|
|
233
318
|
pass
|
|
234
319
|
|
|
320
|
+
auth_method = _license_data.get("auth_method")
|
|
321
|
+
|
|
322
|
+
if auth_method == "device_flow":
|
|
323
|
+
device_token = _license_data.get("device_token")
|
|
324
|
+
if not device_token:
|
|
325
|
+
return
|
|
326
|
+
result = _call_get_user_plan(device_token)
|
|
327
|
+
if result is None:
|
|
328
|
+
return # Network error — keep cached (offline-first)
|
|
329
|
+
if not result.get("valid", True):
|
|
330
|
+
_license_data = None
|
|
331
|
+
is_pro = False
|
|
332
|
+
try:
|
|
333
|
+
if _LICENSE_FILE.exists():
|
|
334
|
+
_LICENSE_FILE.unlink()
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
return
|
|
338
|
+
_license_data["plan"] = result.get("plan", "free")
|
|
339
|
+
_license_data["status"] = result.get("status", "active")
|
|
340
|
+
_license_data["features"] = result.get("features", [])
|
|
341
|
+
_license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
|
|
342
|
+
is_pro = (
|
|
343
|
+
_license_data.get("plan") == "pro"
|
|
344
|
+
and _license_data.get("status", "active") != "inactive"
|
|
345
|
+
)
|
|
346
|
+
try:
|
|
347
|
+
_write_license_file(_license_data)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# Key-based auth (existing flow / legacy)
|
|
235
353
|
key = _license_data.get("license_key")
|
|
236
354
|
if not key:
|
|
237
355
|
return
|
|
@@ -266,6 +384,7 @@ def _init() -> None:
|
|
|
266
384
|
is_pro = (
|
|
267
385
|
_license_data is not None
|
|
268
386
|
and _license_data.get("plan") == "pro"
|
|
387
|
+
and _license_data.get("status", "active") != "inactive"
|
|
269
388
|
)
|
|
270
389
|
|
|
271
390
|
|
|
@@ -354,7 +473,105 @@ def require_pro(feature_name: str) -> None:
|
|
|
354
473
|
|
|
355
474
|
|
|
356
475
|
# ---------------------------------------------------------------------------
|
|
357
|
-
#
|
|
476
|
+
# Device-flow authentication
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def _finish_device_auth(result: dict) -> None:
|
|
480
|
+
"""Persist device-flow credentials and emit success JSON. Exits on error."""
|
|
481
|
+
global _license_data, is_pro
|
|
482
|
+
|
|
483
|
+
device_token = result.get("device_token") or result.get("access_token") or ""
|
|
484
|
+
email = result.get("email", "")
|
|
485
|
+
plan = result.get("plan", "free")
|
|
486
|
+
plan_status = (
|
|
487
|
+
result.get("status_detail")
|
|
488
|
+
or result.get("user_status")
|
|
489
|
+
or result.get("status", "active")
|
|
490
|
+
)
|
|
491
|
+
features = result.get("features") or []
|
|
492
|
+
|
|
493
|
+
if not device_token:
|
|
494
|
+
sys.stderr.write("\n")
|
|
495
|
+
_fail("auth_error", "Authentication completed but no session token received. Contact support.")
|
|
496
|
+
|
|
497
|
+
_LICENSE_DIR.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
499
|
+
data: dict = {
|
|
500
|
+
"auth_method": "device_flow",
|
|
501
|
+
"device_token": device_token,
|
|
502
|
+
"email": email,
|
|
503
|
+
"plan": plan,
|
|
504
|
+
"status": plan_status,
|
|
505
|
+
"features": features,
|
|
506
|
+
"authenticated_at": now,
|
|
507
|
+
"validated_at": now,
|
|
508
|
+
}
|
|
509
|
+
_write_license_file(data)
|
|
510
|
+
_license_data = data
|
|
511
|
+
is_pro = plan == "pro" and plan_status != "inactive"
|
|
512
|
+
|
|
513
|
+
sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
|
|
514
|
+
sys.stderr.flush()
|
|
515
|
+
|
|
516
|
+
output: dict = {"status": "authenticated", "email": email, "plan": plan, "pro": is_pro}
|
|
517
|
+
if not is_pro:
|
|
518
|
+
output["upgrade_hint"] = "https://sourcecode.dev/pricing"
|
|
519
|
+
else:
|
|
520
|
+
output["features"] = features
|
|
521
|
+
sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
|
|
522
|
+
sys.stdout.flush()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def auth_login() -> None:
|
|
526
|
+
"""Device code authentication flow.
|
|
527
|
+
|
|
528
|
+
Shows a browser URL; polls the backend every 2.5 s until the user
|
|
529
|
+
completes authentication or the 5-minute window expires.
|
|
530
|
+
Writes credentials to ~/.sourcecode/license.json on success.
|
|
531
|
+
Exits 0 on success, 1 on any failure.
|
|
532
|
+
"""
|
|
533
|
+
import time
|
|
534
|
+
|
|
535
|
+
device_code = _generate_device_code()
|
|
536
|
+
activate_url = f"{_AUTH_BASE_URL}/activate?code={device_code}"
|
|
537
|
+
|
|
538
|
+
sys.stderr.write(f"\n Open this URL to authenticate:\n {activate_url}\n\n Waiting")
|
|
539
|
+
sys.stderr.flush()
|
|
540
|
+
|
|
541
|
+
deadline = time.monotonic() + _DEVICE_POLL_TIMEOUT_S
|
|
542
|
+
_tick = 0
|
|
543
|
+
|
|
544
|
+
while time.monotonic() < deadline:
|
|
545
|
+
time.sleep(_DEVICE_POLL_INTERVAL_S)
|
|
546
|
+
_tick += 1
|
|
547
|
+
if _tick % 4 == 0:
|
|
548
|
+
sys.stderr.write(".")
|
|
549
|
+
sys.stderr.flush()
|
|
550
|
+
|
|
551
|
+
result = _call_device_check(device_code)
|
|
552
|
+
if result is None:
|
|
553
|
+
continue # network blip — keep polling
|
|
554
|
+
|
|
555
|
+
status = result.get("status")
|
|
556
|
+
if status == "pending":
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
if status == "complete":
|
|
560
|
+
_finish_device_auth(result)
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
if status == "error" or result.get("error"):
|
|
564
|
+
sys.stderr.write("\n")
|
|
565
|
+
_fail("auth_error", result.get("message") or result.get("error") or "Authentication failed.")
|
|
566
|
+
|
|
567
|
+
# Unknown status — keep polling
|
|
568
|
+
|
|
569
|
+
sys.stderr.write("\n")
|
|
570
|
+
_fail("auth_timeout", "Authentication timed out after 5 minutes. Please try again.")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
# Activation (key-based — legacy / direct key entry)
|
|
358
575
|
# ---------------------------------------------------------------------------
|
|
359
576
|
|
|
360
577
|
def activate_license(license_key: str) -> None:
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""migrate_check.py — Spring Boot 2→3 (javax→jakarta) migration readiness checker.
|
|
2
|
+
|
|
3
|
+
Scans Java source files for import namespaces and class patterns that must be
|
|
4
|
+
updated when migrating from Spring Boot 2.x (javax.*) to Spring Boot 3.x (jakarta.*).
|
|
5
|
+
|
|
6
|
+
Entry point: run_migrate_check(file_paths, root) → MigrationReport
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Rule catalogue
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class _Rule:
|
|
24
|
+
id: str
|
|
25
|
+
severity: str
|
|
26
|
+
title: str
|
|
27
|
+
explanation: str
|
|
28
|
+
fix_hint: str
|
|
29
|
+
import_pattern: Optional[re.Pattern] = None # matches the import statement
|
|
30
|
+
extends_pattern: Optional[re.Pattern] = None # matches an extends clause
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_IMPORT_RULES: list[_Rule] = [
|
|
34
|
+
_Rule(
|
|
35
|
+
id="MIG-001",
|
|
36
|
+
severity="critical",
|
|
37
|
+
title="javax.persistence import — JPA namespace not migrated to jakarta",
|
|
38
|
+
explanation=(
|
|
39
|
+
"Spring Boot 3 uses Jakarta EE 9 which moved JPA to the jakarta.persistence "
|
|
40
|
+
"namespace. Files importing javax.persistence will not compile after migration."
|
|
41
|
+
),
|
|
42
|
+
fix_hint="Replace 'javax.persistence' with 'jakarta.persistence' across all affected files.",
|
|
43
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.persistence[^;]+);", re.MULTILINE),
|
|
44
|
+
),
|
|
45
|
+
_Rule(
|
|
46
|
+
id="MIG-002",
|
|
47
|
+
severity="high",
|
|
48
|
+
title="javax.servlet import — Servlet API not migrated to jakarta",
|
|
49
|
+
explanation=(
|
|
50
|
+
"Spring Boot 3 bundles Jakarta Servlet 6.0. Filters, HttpServletRequest, and "
|
|
51
|
+
"HttpServletResponse referencing javax.servlet will break after migration."
|
|
52
|
+
),
|
|
53
|
+
fix_hint="Replace 'javax.servlet' with 'jakarta.servlet' and update the servlet-api dependency.",
|
|
54
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.servlet[^;]+);", re.MULTILINE),
|
|
55
|
+
),
|
|
56
|
+
_Rule(
|
|
57
|
+
id="MIG-003",
|
|
58
|
+
severity="high",
|
|
59
|
+
title="javax.validation import — Bean Validation not migrated to jakarta",
|
|
60
|
+
explanation=(
|
|
61
|
+
"Spring Boot 3 uses Hibernate Validator 8.x which implements jakarta.validation. "
|
|
62
|
+
"Constraint annotations (@NotNull, @Valid, etc.) under javax.validation will not be "
|
|
63
|
+
"picked up by the validator after migration."
|
|
64
|
+
),
|
|
65
|
+
fix_hint="Replace 'javax.validation' with 'jakarta.validation'.",
|
|
66
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.validation[^;]+);", re.MULTILINE),
|
|
67
|
+
),
|
|
68
|
+
_Rule(
|
|
69
|
+
id="MIG-004",
|
|
70
|
+
severity="high",
|
|
71
|
+
title="javax.transaction import — TX API not migrated to jakarta",
|
|
72
|
+
explanation=(
|
|
73
|
+
"Spring Boot 3 depends on Jakarta Transactions (jakarta.transaction). "
|
|
74
|
+
"Direct javax.transaction imports (@Transactional from javax or UserTransaction) "
|
|
75
|
+
"will resolve to the wrong class after migration."
|
|
76
|
+
),
|
|
77
|
+
fix_hint="Replace 'javax.transaction' with 'jakarta.transaction'.",
|
|
78
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.transaction[^;]+);", re.MULTILINE),
|
|
79
|
+
),
|
|
80
|
+
_Rule(
|
|
81
|
+
id="MIG-006",
|
|
82
|
+
severity="medium",
|
|
83
|
+
title="javax.annotation import — CDI annotations not migrated to jakarta",
|
|
84
|
+
explanation=(
|
|
85
|
+
"jakarta.annotation replaces javax.annotation in Jakarta EE 9+. "
|
|
86
|
+
"@PostConstruct, @PreDestroy, @Resource are affected."
|
|
87
|
+
),
|
|
88
|
+
fix_hint="Replace 'javax.annotation' with 'jakarta.annotation'.",
|
|
89
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.annotation[^;]+);", re.MULTILINE),
|
|
90
|
+
),
|
|
91
|
+
_Rule(
|
|
92
|
+
id="MIG-007",
|
|
93
|
+
severity="medium",
|
|
94
|
+
title="javax.inject import — DI annotations not migrated to jakarta",
|
|
95
|
+
explanation=(
|
|
96
|
+
"jakarta.inject replaces javax.inject in Jakarta EE 9+. "
|
|
97
|
+
"@Inject and @Named from javax.inject are affected."
|
|
98
|
+
),
|
|
99
|
+
fix_hint="Replace 'javax.inject' with 'jakarta.inject'.",
|
|
100
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.inject[^;]+);", re.MULTILINE),
|
|
101
|
+
),
|
|
102
|
+
_Rule(
|
|
103
|
+
id="MIG-008",
|
|
104
|
+
severity="medium",
|
|
105
|
+
title="javax.ws.rs import — JAX-RS API not migrated to jakarta",
|
|
106
|
+
explanation=(
|
|
107
|
+
"jakarta.ws.rs replaces javax.ws.rs in Jakarta EE 9+. "
|
|
108
|
+
"JAX-RS resource classes, Response, and client code are affected."
|
|
109
|
+
),
|
|
110
|
+
fix_hint="Replace 'javax.ws.rs' with 'jakarta.ws.rs'.",
|
|
111
|
+
import_pattern=re.compile(r"^[ \t]*import\s+(javax\.ws\.rs[^;]+);", re.MULTILINE),
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
_EXTENDS_RULES: list[_Rule] = [
|
|
116
|
+
_Rule(
|
|
117
|
+
id="MIG-005",
|
|
118
|
+
severity="high",
|
|
119
|
+
title="extends WebSecurityConfigurerAdapter — removed in Spring Security 6",
|
|
120
|
+
explanation=(
|
|
121
|
+
"WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in "
|
|
122
|
+
"Spring Security 6 (Spring Boot 3). Classes extending it must be replaced with "
|
|
123
|
+
"SecurityFilterChain @Bean methods in a @Configuration class."
|
|
124
|
+
),
|
|
125
|
+
fix_hint=(
|
|
126
|
+
"Remove the class extension and expose a SecurityFilterChain @Bean instead. "
|
|
127
|
+
"See the Spring Security 6 migration guide."
|
|
128
|
+
),
|
|
129
|
+
extends_pattern=re.compile(r"\bextends\s+WebSecurityConfigurerAdapter\b"),
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
_ALL_RULES: list[_Rule] = _IMPORT_RULES + _EXTENDS_RULES
|
|
134
|
+
|
|
135
|
+
SEVERITY_ORDER: dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Finding
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class MigrationFinding:
|
|
144
|
+
id: str # deterministic: "{rule_id}-{file_hash[:12]}"
|
|
145
|
+
rule_id: str # "MIG-001" .. "MIG-008"
|
|
146
|
+
severity: str # "critical" | "high" | "medium" | "low"
|
|
147
|
+
title: str
|
|
148
|
+
source_file: str # relative path
|
|
149
|
+
first_line: int # 1-based line number of first match
|
|
150
|
+
imports_found: list[str] = field(default_factory=list) # matched import statements
|
|
151
|
+
explanation: str = ""
|
|
152
|
+
fix_hint: str = ""
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def make_id(rule_id: str, source_file: str) -> str:
|
|
156
|
+
h = hashlib.sha256(f"{rule_id}:{source_file}".encode()).hexdigest()[:12]
|
|
157
|
+
return f"{rule_id}-{h}"
|
|
158
|
+
|
|
159
|
+
def to_dict(self) -> dict:
|
|
160
|
+
d: dict = {
|
|
161
|
+
"id": self.id,
|
|
162
|
+
"rule_id": self.rule_id,
|
|
163
|
+
"severity": self.severity,
|
|
164
|
+
"title": self.title,
|
|
165
|
+
"source_file": self.source_file,
|
|
166
|
+
"first_line": self.first_line,
|
|
167
|
+
"explanation": self.explanation,
|
|
168
|
+
"fix_hint": self.fix_hint,
|
|
169
|
+
}
|
|
170
|
+
if self.imports_found:
|
|
171
|
+
d["imports_found"] = self.imports_found
|
|
172
|
+
return d
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Report
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class MigrationReport:
|
|
181
|
+
schema_version: str = "1.0"
|
|
182
|
+
generated_at: str = ""
|
|
183
|
+
repo_id: str = ""
|
|
184
|
+
git_head: str = ""
|
|
185
|
+
|
|
186
|
+
# Core metrics
|
|
187
|
+
readiness_score: int = 100 # 0–100; 100 = ready to migrate
|
|
188
|
+
blocking_count: int = 0 # critical + high finding count
|
|
189
|
+
estimated_effort_days: float = 0.0
|
|
190
|
+
spring_boot_2_detected: bool = False
|
|
191
|
+
|
|
192
|
+
findings: list[MigrationFinding] = field(default_factory=list)
|
|
193
|
+
limitations: list[str] = field(default_factory=list)
|
|
194
|
+
summary: dict = field(default_factory=dict)
|
|
195
|
+
metadata: dict = field(default_factory=dict)
|
|
196
|
+
|
|
197
|
+
def finalize(self) -> "MigrationReport":
|
|
198
|
+
if not self.generated_at:
|
|
199
|
+
self.generated_at = datetime.now(timezone.utc).isoformat()
|
|
200
|
+
|
|
201
|
+
by_severity: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
202
|
+
by_rule: dict[str, int] = {}
|
|
203
|
+
affected_files: set[str] = set()
|
|
204
|
+
|
|
205
|
+
for f in self.findings:
|
|
206
|
+
by_severity[f.severity] = by_severity.get(f.severity, 0) + 1
|
|
207
|
+
by_rule[f.rule_id] = by_rule.get(f.rule_id, 0) + 1
|
|
208
|
+
affected_files.add(f.source_file)
|
|
209
|
+
|
|
210
|
+
self.blocking_count = by_severity["critical"] + by_severity["high"]
|
|
211
|
+
|
|
212
|
+
# Score: deduct per affected-file/severity combination (not per finding, to avoid
|
|
213
|
+
# double-counting a file that imports 10 javax.persistence classes).
|
|
214
|
+
critical_files: set[str] = set()
|
|
215
|
+
high_files: set[str] = set()
|
|
216
|
+
medium_files: set[str] = set()
|
|
217
|
+
low_files: set[str] = set()
|
|
218
|
+
for f in self.findings:
|
|
219
|
+
if f.severity == "critical":
|
|
220
|
+
critical_files.add(f.source_file)
|
|
221
|
+
elif f.severity == "high":
|
|
222
|
+
high_files.add(f.source_file)
|
|
223
|
+
elif f.severity == "medium":
|
|
224
|
+
medium_files.add(f.source_file)
|
|
225
|
+
else:
|
|
226
|
+
low_files.add(f.source_file)
|
|
227
|
+
|
|
228
|
+
deduction = (
|
|
229
|
+
len(critical_files) * 15
|
|
230
|
+
+ len(high_files) * 8
|
|
231
|
+
+ len(medium_files) * 3
|
|
232
|
+
+ len(low_files) * 1
|
|
233
|
+
)
|
|
234
|
+
self.readiness_score = max(0, 100 - deduction)
|
|
235
|
+
|
|
236
|
+
# Effort: sum per distinct affected file weighted by severity
|
|
237
|
+
self.estimated_effort_days = round(
|
|
238
|
+
len(critical_files) * 0.5
|
|
239
|
+
+ len(high_files) * 0.25
|
|
240
|
+
+ len(medium_files) * 0.1
|
|
241
|
+
+ len(low_files) * 0.05,
|
|
242
|
+
1,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self.summary = {
|
|
246
|
+
"total_findings": len(self.findings),
|
|
247
|
+
"affected_files": len(affected_files),
|
|
248
|
+
"by_severity": by_severity,
|
|
249
|
+
"by_rule": by_rule,
|
|
250
|
+
}
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def to_dict(self) -> dict:
|
|
254
|
+
return {
|
|
255
|
+
"schema_version": self.schema_version,
|
|
256
|
+
"generated_at": self.generated_at,
|
|
257
|
+
"repo_id": self.repo_id,
|
|
258
|
+
"git_head": self.git_head,
|
|
259
|
+
"readiness_score": self.readiness_score,
|
|
260
|
+
"blocking_count": self.blocking_count,
|
|
261
|
+
"estimated_effort_days": self.estimated_effort_days,
|
|
262
|
+
"spring_boot_2_detected": self.spring_boot_2_detected,
|
|
263
|
+
"summary": self.summary,
|
|
264
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
265
|
+
"limitations": self.limitations,
|
|
266
|
+
"metadata": self.metadata,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def to_text(self, min_severity: str = "low") -> str:
|
|
270
|
+
min_order = SEVERITY_ORDER.get(min_severity, 3)
|
|
271
|
+
visible = [f for f in self.findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
|
|
272
|
+
|
|
273
|
+
lines: list[str] = [
|
|
274
|
+
f"Migration Readiness: {self.readiness_score}/100",
|
|
275
|
+
f"Blocking issues: {self.blocking_count} "
|
|
276
|
+
f"(critical: {self.summary.get('by_severity', {}).get('critical', 0)}, "
|
|
277
|
+
f"high: {self.summary.get('by_severity', {}).get('high', 0)})",
|
|
278
|
+
f"Affected files: {self.summary.get('affected_files', 0)}",
|
|
279
|
+
f"Estimated effort: {self.estimated_effort_days}d",
|
|
280
|
+
"",
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
if not visible:
|
|
284
|
+
lines.append("No findings at or above selected severity.")
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
for f in sorted(visible, key=lambda x: (SEVERITY_ORDER.get(x.severity, 3), x.source_file)):
|
|
288
|
+
lines.append(
|
|
289
|
+
f"{f.rule_id} [{f.severity.upper()}] {f.source_file}:{f.first_line}"
|
|
290
|
+
)
|
|
291
|
+
lines.append(f" {f.title}")
|
|
292
|
+
lines.append(f" Fix: {f.fix_hint}")
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
return "\n".join(lines)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Scanner
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def _scan_file(
|
|
303
|
+
source: str,
|
|
304
|
+
rel_path: str,
|
|
305
|
+
rules: list[_Rule],
|
|
306
|
+
) -> list[MigrationFinding]:
|
|
307
|
+
findings: list[MigrationFinding] = []
|
|
308
|
+
|
|
309
|
+
for rule in rules:
|
|
310
|
+
if rule.import_pattern is not None:
|
|
311
|
+
matches = list(rule.import_pattern.finditer(source))
|
|
312
|
+
if not matches:
|
|
313
|
+
continue
|
|
314
|
+
# Compute 1-based line number of first match
|
|
315
|
+
first_line = source[: matches[0].start()].count("\n") + 1
|
|
316
|
+
imports_found = [m.group(1) for m in matches]
|
|
317
|
+
findings.append(
|
|
318
|
+
MigrationFinding(
|
|
319
|
+
id=MigrationFinding.make_id(rule.id, rel_path),
|
|
320
|
+
rule_id=rule.id,
|
|
321
|
+
severity=rule.severity,
|
|
322
|
+
title=rule.title,
|
|
323
|
+
source_file=rel_path,
|
|
324
|
+
first_line=first_line,
|
|
325
|
+
imports_found=imports_found,
|
|
326
|
+
explanation=rule.explanation,
|
|
327
|
+
fix_hint=rule.fix_hint,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
elif rule.extends_pattern is not None:
|
|
332
|
+
m = rule.extends_pattern.search(source)
|
|
333
|
+
if m is None:
|
|
334
|
+
continue
|
|
335
|
+
first_line = source[: m.start()].count("\n") + 1
|
|
336
|
+
findings.append(
|
|
337
|
+
MigrationFinding(
|
|
338
|
+
id=MigrationFinding.make_id(rule.id, rel_path),
|
|
339
|
+
rule_id=rule.id,
|
|
340
|
+
severity=rule.severity,
|
|
341
|
+
title=rule.title,
|
|
342
|
+
source_file=rel_path,
|
|
343
|
+
first_line=first_line,
|
|
344
|
+
explanation=rule.explanation,
|
|
345
|
+
fix_hint=rule.fix_hint,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return findings
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Public entry point
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def run_migrate_check(
|
|
357
|
+
file_paths: list[str],
|
|
358
|
+
root: Path,
|
|
359
|
+
*,
|
|
360
|
+
min_severity: str = "low",
|
|
361
|
+
) -> MigrationReport:
|
|
362
|
+
"""Scan Java files for Spring Boot 2→3 migration blockers.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
file_paths: Relative Java file paths (from find_java_files).
|
|
366
|
+
root: Absolute repo root.
|
|
367
|
+
min_severity: Filter threshold — findings below this severity are excluded
|
|
368
|
+
from the report. Choices: critical | high | medium | low.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
MigrationReport with findings, readiness_score, and effort estimate.
|
|
372
|
+
"""
|
|
373
|
+
min_order = SEVERITY_ORDER.get(min_severity, 3)
|
|
374
|
+
all_findings: list[MigrationFinding] = []
|
|
375
|
+
limitations: list[str] = []
|
|
376
|
+
read_errors = 0
|
|
377
|
+
|
|
378
|
+
for rel_path in file_paths:
|
|
379
|
+
abs_path = root / rel_path
|
|
380
|
+
try:
|
|
381
|
+
source = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
382
|
+
except OSError:
|
|
383
|
+
read_errors += 1
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
file_findings = _scan_file(source, rel_path, _ALL_RULES)
|
|
387
|
+
# Apply min_severity filter
|
|
388
|
+
filtered = [f for f in file_findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
|
|
389
|
+
all_findings.extend(filtered)
|
|
390
|
+
|
|
391
|
+
if read_errors:
|
|
392
|
+
limitations.append(f"{read_errors} file(s) could not be read and were skipped.")
|
|
393
|
+
|
|
394
|
+
# Detect Spring Boot 2 pom.xml heuristic (best-effort, non-fatal)
|
|
395
|
+
spring_boot_2 = _detect_spring_boot_2(root)
|
|
396
|
+
|
|
397
|
+
report = MigrationReport(
|
|
398
|
+
spring_boot_2_detected=spring_boot_2,
|
|
399
|
+
findings=all_findings,
|
|
400
|
+
limitations=limitations,
|
|
401
|
+
metadata={
|
|
402
|
+
"java_files_scanned": len(file_paths),
|
|
403
|
+
"min_severity": min_severity,
|
|
404
|
+
"rules_applied": [r.id for r in _ALL_RULES],
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Populate git_head — non-fatal
|
|
409
|
+
try:
|
|
410
|
+
import subprocess as _sub
|
|
411
|
+
_r = _sub.run(
|
|
412
|
+
["git", "-C", str(root), "rev-parse", "--short", "HEAD"],
|
|
413
|
+
capture_output=True, text=True, timeout=3,
|
|
414
|
+
)
|
|
415
|
+
if _r.returncode == 0:
|
|
416
|
+
report.git_head = _r.stdout.strip()
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
return report.finalize()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _detect_spring_boot_2(root: Path) -> bool:
|
|
424
|
+
"""Return True if any pom.xml or build.gradle declares spring-boot 2.x."""
|
|
425
|
+
_SB2 = re.compile(r"spring.boot[^\"'\n]*[\"']?2\.\d+", re.IGNORECASE)
|
|
426
|
+
for name in ("pom.xml", "build.gradle", "build.gradle.kts"):
|
|
427
|
+
candidate = root / name
|
|
428
|
+
try:
|
|
429
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
430
|
+
if _SB2.search(text):
|
|
431
|
+
return True
|
|
432
|
+
except OSError:
|
|
433
|
+
pass
|
|
434
|
+
return False
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=hzHULzlZzht_1GYLCF6iKVI8a1MFSsEJYu30xrAvGZU,104
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
|
|
3
3
|
sourcecode/architecture_analyzer.py,sha256=qh749a7ykPtGmQI1MR9y6j8TtL_jBdVYFx9YRsLqOMw,44121
|
|
4
4
|
sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
|
|
@@ -7,7 +7,7 @@ sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
|
|
|
7
7
|
sourcecode/canonical_ir.py,sha256=uwpwCnJxMh_eiIVg4cOLv7-aZthvmDFcG4azCOycLkw,24281
|
|
8
8
|
sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
|
|
9
9
|
sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
|
|
10
|
-
sourcecode/cli.py,sha256=
|
|
10
|
+
sourcecode/cli.py,sha256=CALwHKAoBv1MmhLE7ZingoarOxXTk3VUCrxF4CDYB20,233442
|
|
11
11
|
sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
|
|
12
12
|
sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
|
|
13
13
|
sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
|
|
@@ -26,9 +26,10 @@ sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,2
|
|
|
26
26
|
sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
|
|
27
27
|
sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
|
|
28
28
|
sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
|
|
29
|
-
sourcecode/license.py,sha256=
|
|
29
|
+
sourcecode/license.py,sha256=3JCV2OeTVttKrOGBguU5uZC0c02Stig-KLB0mP2lNiY,22742
|
|
30
30
|
sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
|
|
31
31
|
sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
|
|
32
|
+
sourcecode/migrate_check.py,sha256=KQJRuQPrZSDYnIsekEjF-6j6b702Cu-pjojo-teO4wM,16338
|
|
32
33
|
sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
|
|
33
34
|
sourcecode/path_filters.py,sha256=ROFRQ8eSLBEMiixK9f45-RO7um4VEEcjoD5AA4I427I,3739
|
|
34
35
|
sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
|
|
@@ -93,8 +94,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
|
|
|
93
94
|
sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
|
|
94
95
|
sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
|
|
95
96
|
sourcecode/telemetry/transport.py,sha256=QSslxIwij8YkRWcVvxykODDrkiN_GAAEu3dUP7KIWeE,1651
|
|
96
|
-
sourcecode-1.35.
|
|
97
|
-
sourcecode-1.35.
|
|
98
|
-
sourcecode-1.35.
|
|
99
|
-
sourcecode-1.35.
|
|
100
|
-
sourcecode-1.35.
|
|
97
|
+
sourcecode-1.35.19.dist-info/METADATA,sha256=PoK3wFYqg5mUZAk98YbNEx2Gvd7c55DmNlYqORyKox8,21297
|
|
98
|
+
sourcecode-1.35.19.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
99
|
+
sourcecode-1.35.19.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
|
|
100
|
+
sourcecode-1.35.19.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
101
|
+
sourcecode-1.35.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|