sourcecode 1.35.16__tar.gz → 1.35.18__tar.gz
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-1.35.16 → sourcecode-1.35.18}/PKG-INFO +1 -1
- {sourcecode-1.35.16 → sourcecode-1.35.18}/pyproject.toml +1 -1
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/cli.py +131 -15
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/license.py +283 -5
- {sourcecode-1.35.16 → sourcecode-1.35.18}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/.gitignore +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/.ruff.toml +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/CHANGELOG.md +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/CONTRIBUTING.md +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/LICENSE +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/README.md +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/SECURITY.md +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/raw +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/classifier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/explain.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/fqn_utils.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/registry.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/pr_impact.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/repository_ir.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_impact.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/spring_tx_analyzer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-1.35.16 → sourcecode-1.35.18}/src/sourcecode/workspace.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sourcecode"
|
|
7
|
-
version = "1.35.
|
|
7
|
+
version = "1.35.18"
|
|
8
8
|
description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -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]
|
|
@@ -197,9 +202,13 @@ Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
|
|
|
197
202
|
[dim]modernize (full) dead zones, tangles, full coupling[/dim]
|
|
198
203
|
[dim]fix-bug (full) complete risk-ranked file list[/dim]
|
|
199
204
|
[dim]review-pr (expanded) CI-grade PR review[/dim]
|
|
200
|
-
[dim]prepare-context delta incremental context for CI/CD[/dim]
|
|
205
|
+
[dim]prepare-context delta incremental context for CI/CD (30 free runs/repo)[/dim]
|
|
201
206
|
[dim]prepare-context generate-tests test gap analysis[/dim]
|
|
202
|
-
[dim]--full removes all truncation limits[/dim]
|
|
207
|
+
[dim]--full removes all truncation limits (free up to 500 files)[/dim]
|
|
208
|
+
[dim]--rank-by git-churn file volatility ranking via git history[/dim]
|
|
209
|
+
[dim]rich exports (HTML/PDF/CI) structured reports for CI and stakeholders[/dim]
|
|
210
|
+
[dim]multi-repo analysis cross-repository blast radius[/dim]
|
|
211
|
+
[dim]team snapshots shared org-level cache[/dim]
|
|
203
212
|
|
|
204
213
|
[dim cyan]→ sourcecode activate <key>[/dim cyan]
|
|
205
214
|
"""
|
|
@@ -217,8 +226,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
217
226
|
"repo-ir", "mcp", "endpoints", "impact",
|
|
218
227
|
# Enterprise workflow commands
|
|
219
228
|
"onboard", "modernize", "fix-bug", "review-pr",
|
|
220
|
-
# License
|
|
221
|
-
"activate",
|
|
229
|
+
# License / auth
|
|
230
|
+
"activate", "auth",
|
|
222
231
|
# Cache observability
|
|
223
232
|
"cache",
|
|
224
233
|
# RIS bootstrap
|
|
@@ -508,6 +517,9 @@ app.add_typer(mcp_app, name="mcp")
|
|
|
508
517
|
cache_app = typer.Typer(help="Cache inspection and management.", rich_markup_mode="rich")
|
|
509
518
|
app.add_typer(cache_app, name="cache")
|
|
510
519
|
|
|
520
|
+
auth_app = typer.Typer(help="Authentication: login, status, logout.", rich_markup_mode="rich")
|
|
521
|
+
app.add_typer(auth_app, name="auth")
|
|
522
|
+
|
|
511
523
|
|
|
512
524
|
def _maybe_ask_consent() -> None:
|
|
513
525
|
"""Show first-run consent prompt once, on interactive TTYs only."""
|
|
@@ -573,7 +585,8 @@ GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
|
|
|
573
585
|
DOCS_DEPTH_CHOICES = ["module", "symbols", "full"]
|
|
574
586
|
|
|
575
587
|
# ── Module-level constants ─────────────────────────────────────────────────────
|
|
576
|
-
_FREE_TIER_NODE_CAP: int =
|
|
588
|
+
_FREE_TIER_NODE_CAP: int = 50 # semantic cap for graph nodes and semantic symbols in free tier
|
|
589
|
+
_FREE_FULL_FILE_THRESHOLD: int = 500 # Java source files; above this --full requires Pro
|
|
577
590
|
_JAVA_MIN_SCAN_DEPTH: int = 12 # Maven src/main/java/<pkg>/<module>/File depth floor
|
|
578
591
|
_JVM_STACKS: frozenset[str] = frozenset({"java", "kotlin", "scala", "groovy"})
|
|
579
592
|
_IMPACT_PRIORITY_THRESHOLDS: list[tuple[float, str]] = [
|
|
@@ -878,6 +891,11 @@ def main(
|
|
|
878
891
|
)
|
|
879
892
|
raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
|
|
880
893
|
|
|
894
|
+
# Pro gate for --rank-by git-churn: git history analysis is a Pro feature.
|
|
895
|
+
if rank_by == "git-churn":
|
|
896
|
+
from sourcecode.license import require_feature as _req_git_history
|
|
897
|
+
_req_git_history("git-history")
|
|
898
|
+
|
|
881
899
|
if symbol is not None and not symbol.strip():
|
|
882
900
|
_emit_error_json(
|
|
883
901
|
INVALID_INPUT_CODE,
|
|
@@ -911,10 +929,21 @@ def main(
|
|
|
911
929
|
)
|
|
912
930
|
raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
|
|
913
931
|
|
|
914
|
-
# Pro gate for --full:
|
|
932
|
+
# Pro gate for --full: free tier allowed up to _FREE_FULL_FILE_THRESHOLD Java files.
|
|
915
933
|
if full:
|
|
916
|
-
from sourcecode.license import
|
|
917
|
-
|
|
934
|
+
from sourcecode.license import is_pro as _full_is_pro
|
|
935
|
+
if not _full_is_pro:
|
|
936
|
+
from itertools import islice as _islice
|
|
937
|
+
_full_check_path = Path(_get_detected_path()).resolve()
|
|
938
|
+
_java_count = sum(
|
|
939
|
+
1 for _ in _islice(
|
|
940
|
+
(p for p in _full_check_path.rglob("*.java") if ".git" not in p.parts),
|
|
941
|
+
_FREE_FULL_FILE_THRESHOLD + 1,
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
if _java_count > _FREE_FULL_FILE_THRESHOLD:
|
|
945
|
+
from sourcecode.license import require_feature as _req_full
|
|
946
|
+
_req_full("--full")
|
|
918
947
|
|
|
919
948
|
# P0-2 FIX: --compact and --full are mutually exclusive.
|
|
920
949
|
# compact is designed to be a bounded summary; --full removes truncation limits,
|
|
@@ -2633,14 +2662,34 @@ def prepare_context_cmd(
|
|
|
2633
2662
|
)
|
|
2634
2663
|
raise typer.Exit(code=1)
|
|
2635
2664
|
|
|
2636
|
-
# Pro gate: generate-tests
|
|
2637
|
-
|
|
2638
|
-
if task in _PRO_TASKS:
|
|
2665
|
+
# Pro gate: generate-tests requires Pro. delta allows 30 free runs per repo.
|
|
2666
|
+
if task == "generate-tests":
|
|
2639
2667
|
from sourcecode.license import require_feature as _require_feature
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2668
|
+
_require_feature("generate-tests")
|
|
2669
|
+
elif task == "delta":
|
|
2670
|
+
from sourcecode.license import is_pro as _delta_is_pro
|
|
2671
|
+
if not _delta_is_pro:
|
|
2672
|
+
from sourcecode.license import check_delta_free_tier as _check_delta
|
|
2673
|
+
_delta_allowed, _delta_used, _delta_remaining = _check_delta(str(path.resolve()))
|
|
2674
|
+
if not _delta_allowed:
|
|
2675
|
+
from sourcecode.license import require_feature as _require_feature_delta
|
|
2676
|
+
_require_feature_delta(
|
|
2677
|
+
"delta",
|
|
2678
|
+
extra_fields={
|
|
2679
|
+
"free_tier_note": (
|
|
2680
|
+
f"Free quota of {30} delta runs per repository exhausted."
|
|
2681
|
+
),
|
|
2682
|
+
"free_tier_alternative": "sourcecode prepare-context review-pr --since <ref>",
|
|
2683
|
+
},
|
|
2684
|
+
)
|
|
2685
|
+
# Within quota: emit a header note so CI logs show remaining runs.
|
|
2686
|
+
elif _delta_remaining <= 5:
|
|
2687
|
+
import sys as _sys_delta
|
|
2688
|
+
_sys_delta.stderr.write(
|
|
2689
|
+
f"[sourcecode] delta free tier: {_delta_remaining} run(s) remaining"
|
|
2690
|
+
f" (used {_delta_used}/{30}). Upgrade to Pro for unlimited CI runs.\n"
|
|
2691
|
+
)
|
|
2692
|
+
_sys_delta.stderr.flush()
|
|
2644
2693
|
|
|
2645
2694
|
# Validate --format: only "json" and "github-comment" are valid for prepare-context.
|
|
2646
2695
|
# "yaml" is intentionally NOT supported here (use main command for yaml output).
|
|
@@ -4758,6 +4807,73 @@ def activate_cmd(
|
|
|
4758
4807
|
_activate(license_key)
|
|
4759
4808
|
|
|
4760
4809
|
|
|
4810
|
+
# ---------------------------------------------------------------------------
|
|
4811
|
+
# Auth commands (device-flow login / status / logout)
|
|
4812
|
+
# ---------------------------------------------------------------------------
|
|
4813
|
+
|
|
4814
|
+
@auth_app.command("login")
|
|
4815
|
+
def auth_login_cmd() -> None:
|
|
4816
|
+
"""Authenticate via browser (device code flow).
|
|
4817
|
+
|
|
4818
|
+
\b
|
|
4819
|
+
The CLI shows a URL. Open it in your browser, log in with your account,
|
|
4820
|
+
and the CLI completes authentication automatically.
|
|
4821
|
+
Credentials are stored in ~/.sourcecode/license.json (30-min cache; Supabase is source of truth).
|
|
4822
|
+
|
|
4823
|
+
\b
|
|
4824
|
+
Examples:
|
|
4825
|
+
sourcecode auth login
|
|
4826
|
+
"""
|
|
4827
|
+
from sourcecode.license import auth_login as _auth_login
|
|
4828
|
+
_auth_login()
|
|
4829
|
+
|
|
4830
|
+
|
|
4831
|
+
@auth_app.command("status")
|
|
4832
|
+
def auth_status_cmd() -> None:
|
|
4833
|
+
"""Show current authentication and plan status."""
|
|
4834
|
+
import json as _json
|
|
4835
|
+
try:
|
|
4836
|
+
from sourcecode.license import _license_data as _ld, is_pro as _ip
|
|
4837
|
+
except Exception:
|
|
4838
|
+
_ld = None
|
|
4839
|
+
_ip = False
|
|
4840
|
+
|
|
4841
|
+
if not _ld:
|
|
4842
|
+
out: dict = {"status": "unauthenticated", "pro": False}
|
|
4843
|
+
sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
|
|
4844
|
+
sys.stdout.flush()
|
|
4845
|
+
return
|
|
4846
|
+
|
|
4847
|
+
out = {
|
|
4848
|
+
"status": "authenticated",
|
|
4849
|
+
"auth_method": _ld.get("auth_method", "license_key"),
|
|
4850
|
+
"email": _ld.get("email", ""),
|
|
4851
|
+
"plan": _ld.get("plan", "unknown"),
|
|
4852
|
+
"plan_status": _ld.get("status", "unknown"),
|
|
4853
|
+
"pro": _ip,
|
|
4854
|
+
"validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
|
|
4855
|
+
}
|
|
4856
|
+
sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
|
|
4857
|
+
sys.stdout.flush()
|
|
4858
|
+
|
|
4859
|
+
|
|
4860
|
+
@auth_app.command("logout")
|
|
4861
|
+
def auth_logout_cmd() -> None:
|
|
4862
|
+
"""Remove local credentials (does not cancel your subscription)."""
|
|
4863
|
+
import json as _json
|
|
4864
|
+
_lf = Path.home() / ".sourcecode" / "license.json"
|
|
4865
|
+
if _lf.exists():
|
|
4866
|
+
try:
|
|
4867
|
+
_lf.unlink()
|
|
4868
|
+
out: dict = {"status": "logged_out", "message": "Local credentials removed."}
|
|
4869
|
+
except Exception as _exc:
|
|
4870
|
+
out = {"status": "error", "message": str(_exc)}
|
|
4871
|
+
else:
|
|
4872
|
+
out = {"status": "logged_out", "message": "No local credentials found."}
|
|
4873
|
+
sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
|
|
4874
|
+
sys.stdout.flush()
|
|
4875
|
+
|
|
4876
|
+
|
|
4761
4877
|
@app.command("version")
|
|
4762
4878
|
def version_cmd() -> None:
|
|
4763
4879
|
"""Show version and exit.
|
|
@@ -41,7 +41,18 @@ if _SUPABASE_URL != _DEFAULT_SUPABASE_URL:
|
|
|
41
41
|
|
|
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 = 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
|
|
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"
|
|
45
56
|
_LICENSE_KEY_RE = re.compile(r"^[A-Za-z0-9_\-]{1,200}$")
|
|
46
57
|
|
|
47
58
|
# ---------------------------------------------------------------------------
|
|
@@ -83,12 +94,37 @@ _FEATURE_INFO: dict[str, dict[str, str]] = {
|
|
|
83
94
|
"value": "Reduces test debt systematically across the entire codebase.",
|
|
84
95
|
},
|
|
85
96
|
"--full": {
|
|
86
|
-
"display": "--full flag",
|
|
97
|
+
"display": "--full flag (large repos)",
|
|
87
98
|
"description": (
|
|
88
99
|
"Removes truncation limits on transactional boundaries, DTO mappers, and large result sets."
|
|
100
|
+
" Free tier may use --full on repositories under 500 Java source files."
|
|
89
101
|
),
|
|
90
102
|
"value": "Essential for complete analysis of enterprise-scale codebases.",
|
|
91
103
|
},
|
|
104
|
+
"git-history": {
|
|
105
|
+
"display": "git history analysis",
|
|
106
|
+
"description": (
|
|
107
|
+
"Churn ranking, commit frequency per file, volatility signals over 90-day window."
|
|
108
|
+
),
|
|
109
|
+
"value": "Identifies which files change most — the highest-risk targets in any refactor.",
|
|
110
|
+
},
|
|
111
|
+
"multi-repo": {
|
|
112
|
+
"display": "multi-repo analysis",
|
|
113
|
+
"description": (
|
|
114
|
+
"Cross-repository dependency graphs, shared module impact, and org-level blast radius."
|
|
115
|
+
),
|
|
116
|
+
"value": "Required for microservices and monorepo architectures.",
|
|
117
|
+
},
|
|
118
|
+
"export-rich": {
|
|
119
|
+
"display": "rich exports (HTML/PDF/CI)",
|
|
120
|
+
"description": "Structured HTML reports, PDF exports, and CI-consumable risk summaries.",
|
|
121
|
+
"value": "Embed analysis into your CI pipeline or share with non-CLI stakeholders.",
|
|
122
|
+
},
|
|
123
|
+
"team-snapshots": {
|
|
124
|
+
"display": "team snapshot sharing",
|
|
125
|
+
"description": "Shared org-level snapshots and multi-user cache access.",
|
|
126
|
+
"value": "Eliminates cold-cache overhead across the entire engineering team.",
|
|
127
|
+
},
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
# ---------------------------------------------------------------------------
|
|
@@ -113,6 +149,40 @@ def _write_license_file(data: dict) -> None:
|
|
|
113
149
|
raise
|
|
114
150
|
|
|
115
151
|
|
|
152
|
+
def _read_delta_runs() -> dict:
|
|
153
|
+
try:
|
|
154
|
+
if _DELTA_RUNS_FILE.exists():
|
|
155
|
+
return json.loads(_DELTA_RUNS_FILE.read_text(encoding="utf-8"))
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_delta_free_tier(repo_path: str) -> "tuple[bool, int, int]":
|
|
162
|
+
"""Check and consume one delta free-tier run for repo_path.
|
|
163
|
+
|
|
164
|
+
Returns (allowed, runs_used, runs_remaining).
|
|
165
|
+
When allowed=True the run count is incremented atomically.
|
|
166
|
+
When allowed=False the quota is exhausted — caller should gate to Pro.
|
|
167
|
+
"""
|
|
168
|
+
import hashlib
|
|
169
|
+
key = hashlib.sha256(str(Path(repo_path).resolve()).encode()).hexdigest()[:16]
|
|
170
|
+
runs = _read_delta_runs()
|
|
171
|
+
used = int(runs.get(key, 0))
|
|
172
|
+
if used >= _DELTA_FREE_LIMIT:
|
|
173
|
+
return False, used, 0
|
|
174
|
+
new_used = used + 1
|
|
175
|
+
runs[key] = new_used
|
|
176
|
+
try:
|
|
177
|
+
_LICENSE_DIR.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
tmp = _DELTA_RUNS_FILE.with_suffix(".tmp")
|
|
179
|
+
tmp.write_text(json.dumps(runs, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
180
|
+
tmp.replace(_DELTA_RUNS_FILE)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return True, new_used, max(0, _DELTA_FREE_LIMIT - new_used)
|
|
184
|
+
|
|
185
|
+
|
|
116
186
|
def _load_license_file() -> Optional[dict]:
|
|
117
187
|
"""Read ~/.sourcecode/license.json. Returns parsed dict or None."""
|
|
118
188
|
try:
|
|
@@ -152,6 +222,78 @@ def _call_get_license(license_key: str) -> Optional[dict]:
|
|
|
152
222
|
return None # Network error — caller decides what to do
|
|
153
223
|
|
|
154
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
|
+
|
|
155
297
|
def _maybe_revalidate() -> None:
|
|
156
298
|
"""Re-validate cached license if stale. Mutates globals; never raises."""
|
|
157
299
|
global _license_data, is_pro
|
|
@@ -159,18 +301,55 @@ def _maybe_revalidate() -> None:
|
|
|
159
301
|
if not _license_data:
|
|
160
302
|
return
|
|
161
303
|
|
|
162
|
-
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
|
+
)
|
|
163
309
|
if validated_at_str:
|
|
164
310
|
try:
|
|
165
311
|
validated_at = datetime.fromisoformat(validated_at_str)
|
|
166
312
|
if validated_at.tzinfo is None:
|
|
167
313
|
validated_at = validated_at.replace(tzinfo=timezone.utc)
|
|
168
314
|
age = (datetime.now(timezone.utc) - validated_at).total_seconds()
|
|
169
|
-
if age <
|
|
315
|
+
if age < _get_cache_ttl():
|
|
170
316
|
return
|
|
171
317
|
except Exception:
|
|
172
318
|
pass
|
|
173
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)
|
|
174
353
|
key = _license_data.get("license_key")
|
|
175
354
|
if not key:
|
|
176
355
|
return
|
|
@@ -205,6 +384,7 @@ def _init() -> None:
|
|
|
205
384
|
is_pro = (
|
|
206
385
|
_license_data is not None
|
|
207
386
|
and _license_data.get("plan") == "pro"
|
|
387
|
+
and _license_data.get("status", "active") != "inactive"
|
|
208
388
|
)
|
|
209
389
|
|
|
210
390
|
|
|
@@ -293,7 +473,105 @@ def require_pro(feature_name: str) -> None:
|
|
|
293
473
|
|
|
294
474
|
|
|
295
475
|
# ---------------------------------------------------------------------------
|
|
296
|
-
#
|
|
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)
|
|
297
575
|
# ---------------------------------------------------------------------------
|
|
298
576
|
|
|
299
577
|
def activate_license(license_key: str) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|