sourcecode 3.2.1__py3-none-any.whl → 3.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/cache.py +104 -0
- sourcecode/cache_model.py +295 -0
- sourcecode/cli.py +234 -65
- sourcecode/context_cache.py +7 -27
- sourcecode/license.py +69 -7
- sourcecode/mcp/server.py +2 -2
- sourcecode/prepare_context.py +7 -7
- sourcecode/ris.py +27 -1
- sourcecode/telemetry/__init__.py +4 -3
- sourcecode/telemetry/config.py +31 -23
- sourcecode/telemetry/consent.py +20 -16
- {sourcecode-3.2.1.dist-info → sourcecode-3.3.0.dist-info}/METADATA +64 -40
- {sourcecode-3.2.1.dist-info → sourcecode-3.3.0.dist-info}/RECORD +17 -16
- {sourcecode-3.2.1.dist-info → sourcecode-3.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.1.dist-info → sourcecode-3.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.1.dist-info → sourcecode-3.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/cli.py
CHANGED
|
@@ -149,6 +149,88 @@ def _check_pipeline_coherence(sm: "SourceMap") -> list[str]: # type: ignore[nam
|
|
|
149
149
|
|
|
150
150
|
return issues
|
|
151
151
|
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Command tiers — what we promise about a command, published where it is read
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
#: The support commitment behind every command — one authority for it, read by
|
|
157
|
+
#: `--help`, by the README command table and by the user guide.
|
|
158
|
+
#:
|
|
159
|
+
#: A tier is a **stability** promise, never a value ranking. `posture` is the most
|
|
160
|
+
#: differentiated capability in the product *and* it is experimental: its shape can
|
|
161
|
+
#: still change under a minor, so nobody should gate a pipeline on it yet. Those are
|
|
162
|
+
#: two facts, and collapsing them onto one axis is how a surface starts lying — the
|
|
163
|
+
#: first panel of `--help` still says "start here" for the commands worth learning
|
|
164
|
+
#: first, and this table says what each command's output is worth relying on.
|
|
165
|
+
#:
|
|
166
|
+
#: This is not the pricing tier: Free/Pro is a separate axis (docs/PRODUCT_TIERS.md)
|
|
167
|
+
#: and gates repository size, never capability.
|
|
168
|
+
#:
|
|
169
|
+
#: Assigning the tiers is a later milestone's work (commands moving between them,
|
|
170
|
+
#: `parked` disappearing from the default help). Naming them is what ships here, so
|
|
171
|
+
#: the vocabulary exists before it is used against a command.
|
|
172
|
+
COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
|
|
173
|
+
("core", "contract stable within a major — safe to gate CI on", (
|
|
174
|
+
"endpoints", "spring-audit", "migrate-check",
|
|
175
|
+
"impact", "impact-chain", "pr-impact", "verify",
|
|
176
|
+
)),
|
|
177
|
+
("supported", "maintained; fields are added, never removed without a major", (
|
|
178
|
+
"verify-edit", "review-pr", "plan", "compare", "delta", "contract-diff",
|
|
179
|
+
"fix-bug", "rename-class", "prepare-context", "onboard", "explain",
|
|
180
|
+
"export", "repo-ir", "validation", "modernize", "chunk-file",
|
|
181
|
+
"cold-start", "baseline", "activate", "config", "schema", "version",
|
|
182
|
+
"cache", "auth", "mcp", "telemetry",
|
|
183
|
+
)),
|
|
184
|
+
("experimental", "shape may change in a minor — do not gate CI on it", (
|
|
185
|
+
"posture", "archetype", "retrieve",
|
|
186
|
+
)),
|
|
187
|
+
("parked", "kept working, no longer developed", ()),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
#: Tiers whose membership is short enough to name in `--help`. `supported` is the
|
|
191
|
+
#: remainder by construction, and printing twenty-six names would bury the two lists
|
|
192
|
+
#: a reader acts on.
|
|
193
|
+
_TIERS_NAMED_IN_HELP = ("core", "experimental", "parked")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def command_tier(name: str) -> "str | None":
|
|
197
|
+
"""The tier of a registered command, or None when it is in no tier.
|
|
198
|
+
|
|
199
|
+
None is a defect, not a state: the battery fails on it. It is returned rather
|
|
200
|
+
than defaulted so an unassigned command can never be silently published as
|
|
201
|
+
`supported`.
|
|
202
|
+
"""
|
|
203
|
+
for tier, _promise, members in COMMAND_TIERS:
|
|
204
|
+
if name in members:
|
|
205
|
+
return tier
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _tier_help_block() -> str:
|
|
210
|
+
"""The tier table as `--help` prints it — generated from `COMMAND_TIERS`, because
|
|
211
|
+
a hand-written catalogue beside a generated one drifts (it is what left
|
|
212
|
+
`endpoints` unlisted while it shipped)."""
|
|
213
|
+
import textwrap
|
|
214
|
+
|
|
215
|
+
lines = ["[bold]Command tiers[/bold] [dim](a stability promise, not a value ranking):[/dim]"]
|
|
216
|
+
indent = " " * 4
|
|
217
|
+
for tier, promise, members in COMMAND_TIERS:
|
|
218
|
+
if tier not in _TIERS_NAMED_IN_HELP:
|
|
219
|
+
listed = "every other command in the panels below"
|
|
220
|
+
elif members:
|
|
221
|
+
listed = " · ".join(members)
|
|
222
|
+
else:
|
|
223
|
+
listed = "none today"
|
|
224
|
+
lines.append(f" [bold]{tier:<14}[/bold]{promise}")
|
|
225
|
+
# Pre-wrapped so the renderer never breaks a command name at its hyphen.
|
|
226
|
+
wrapped = textwrap.fill(
|
|
227
|
+
listed, width=70, initial_indent=indent, subsequent_indent=indent,
|
|
228
|
+
break_on_hyphens=False, break_long_words=False,
|
|
229
|
+
)
|
|
230
|
+
lines.append(f"[dim]{wrapped}[/dim]")
|
|
231
|
+
return "\n".join(lines)
|
|
232
|
+
|
|
233
|
+
|
|
152
234
|
def _build_help_text() -> str:
|
|
153
235
|
"""Build --help text dynamically based on current license state."""
|
|
154
236
|
try:
|
|
@@ -193,6 +275,8 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
|
|
|
193
275
|
|
|
194
276
|
[dim]Every command is grouped in the panels below; full reference in docs/USER_GUIDE.md[/dim]
|
|
195
277
|
|
|
278
|
+
{_tier_help_block()}
|
|
279
|
+
|
|
196
280
|
[bold]Auth commands:[/bold]
|
|
197
281
|
auth status [dim]# show current plan and auth state[/dim]
|
|
198
282
|
auth logout [dim]# remove local credentials[/dim]
|
|
@@ -829,7 +913,7 @@ try:
|
|
|
829
913
|
except Exception:
|
|
830
914
|
pass
|
|
831
915
|
|
|
832
|
-
telemetry_app = typer.Typer(help="Manage anonymous telemetry (
|
|
916
|
+
telemetry_app = typer.Typer(help="Manage anonymous telemetry (off by default; opt-in).", rich_markup_mode="rich")
|
|
833
917
|
app.add_typer(telemetry_app, name="telemetry")
|
|
834
918
|
|
|
835
919
|
mcp_app = typer.Typer(help="MCP integration: setup, status, serve, remove.", rich_markup_mode="rich")
|
|
@@ -857,8 +941,9 @@ app.add_typer(retrieve_app, name="retrieve")
|
|
|
857
941
|
def _maybe_show_telemetry_notice() -> None:
|
|
858
942
|
"""Show first-run telemetry notice once, on interactive TTYs only.
|
|
859
943
|
|
|
860
|
-
Telemetry is
|
|
861
|
-
|
|
944
|
+
Telemetry is off by default (opt-in). The notice is an invitation, not a
|
|
945
|
+
disclosure: nothing has been collected when it appears. Marked as shown so it
|
|
946
|
+
appears only once.
|
|
862
947
|
"""
|
|
863
948
|
try:
|
|
864
949
|
from sourcecode.telemetry.config import has_been_asked, mark_asked
|
|
@@ -1699,12 +1784,21 @@ def main(
|
|
|
1699
1784
|
f"ex={_excl_key},depth={effective_depth}"
|
|
1700
1785
|
)
|
|
1701
1786
|
_core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
|
|
1702
|
-
|
|
1703
|
-
|
|
1787
|
+
# Freshness comes from ONE authority (cache.worktree_signature): the exact
|
|
1788
|
+
# tree state an analysis would read, not the committed HEAD. Keyed on HEAD,
|
|
1789
|
+
# this cache answered for a tree it had not read — an uncommitted pom.xml
|
|
1790
|
+
# gaining a dependency was served the pre-edit analysis with is_stale:false.
|
|
1791
|
+
# Clean tree → the sha, so the ordinary repeat run still hits.
|
|
1792
|
+
_tree_sig = _cache_mod.worktree_signature(
|
|
1793
|
+
Path(_git_root_str) if _git_root_str else target,
|
|
1794
|
+
scope=target,
|
|
1795
|
+
)
|
|
1796
|
+
if _tree_sig:
|
|
1797
|
+
_core_key = f"{_tree_sig}-{_core_h}"
|
|
1704
1798
|
else:
|
|
1705
|
-
#
|
|
1706
|
-
#
|
|
1707
|
-
_core_key =
|
|
1799
|
+
# The tree cannot be described (no git, unreadable) — a stable synthetic
|
|
1800
|
+
# key would answer forever from the first run, so skip the cache instead.
|
|
1801
|
+
_core_key = ""
|
|
1708
1802
|
|
|
1709
1803
|
# ── View flags: output presentation only (no re-analysis needed) ──
|
|
1710
1804
|
_view_flags_str = (
|
|
@@ -1719,7 +1813,7 @@ def main(
|
|
|
1719
1813
|
|
|
1720
1814
|
# ── Lookup ──────────────────────────────────────────────────────
|
|
1721
1815
|
# Step 1: try L1 to obtain the core_hash needed for L2 key
|
|
1722
|
-
_l1_result = _cache_mod.read_core(target, _core_key)
|
|
1816
|
+
_l1_result = _cache_mod.read_core(target, _core_key) if _core_key else None
|
|
1723
1817
|
|
|
1724
1818
|
# Additive overlays (--env-map / --git-context) miss L1 because they sit in
|
|
1725
1819
|
# the core key, yet neither changes the semantic core — env walks config
|
|
@@ -1735,8 +1829,8 @@ def main(
|
|
|
1735
1829
|
# we inject exactly the overlays we flipped to land the hit.
|
|
1736
1830
|
_l1_needs_env_inject = False
|
|
1737
1831
|
_l1_needs_git_inject = False
|
|
1738
|
-
if _l1_result is None and (env_map or git_context):
|
|
1739
|
-
_sha_prefix =
|
|
1832
|
+
if _l1_result is None and _core_key and (env_map or git_context):
|
|
1833
|
+
_sha_prefix = _tree_sig
|
|
1740
1834
|
_flippable = []
|
|
1741
1835
|
if git_context:
|
|
1742
1836
|
_flippable.append("gc") # inject git is cheap + additive
|
|
@@ -3292,10 +3386,11 @@ def prepare_context_cmd(
|
|
|
3292
3386
|
from dataclasses import asdict
|
|
3293
3387
|
import time as _time
|
|
3294
3388
|
|
|
3295
|
-
# Task-level cache: keyed on (task,
|
|
3389
|
+
# Task-level cache: keyed on (task, tree state, symptom) so warm calls complete in <1s.
|
|
3390
|
+
# The tree state comes from the same authority as every other layer, so an
|
|
3391
|
+
# uncommitted edit invalidates here exactly as it does for the root command.
|
|
3296
3392
|
# Skip for diff-dependent tasks (delta, review-pr), fast mode, and llm_prompt
|
|
3297
3393
|
# (those embed per-call content that must not be served from cache).
|
|
3298
|
-
import subprocess as _pctx_sub
|
|
3299
3394
|
import hashlib as _pctx_hash
|
|
3300
3395
|
from sourcecode import cache as _pctx_cache
|
|
3301
3396
|
_pctx_git_sha = ""
|
|
@@ -3303,15 +3398,19 @@ def prepare_context_cmd(
|
|
|
3303
3398
|
_pctx_cacheable = task not in ("delta", "review-pr") and not fast and not llm_prompt
|
|
3304
3399
|
if _pctx_cacheable:
|
|
3305
3400
|
try:
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
capture_output=True, text=True, timeout=3,
|
|
3401
|
+
_pctx_git_sha = _pctx_cache.worktree_signature(
|
|
3402
|
+
_resolve_repo_root(target), scope=target
|
|
3309
3403
|
)
|
|
3310
|
-
_pctx_git_sha = _sha_r2.stdout.strip()
|
|
3311
3404
|
except Exception:
|
|
3312
3405
|
pass
|
|
3313
3406
|
if _pctx_git_sha:
|
|
3314
|
-
|
|
3407
|
+
# Every option that changes the answer belongs in the key. --all and
|
|
3408
|
+
# --include-config did not, and went unnoticed only because the key
|
|
3409
|
+
# required a git sha: in a non-git tree nothing was cached at all, so
|
|
3410
|
+
# the collision could not surface.
|
|
3411
|
+
_sym_h = _pctx_hash.sha256(
|
|
3412
|
+
f"sym={symptom or ''};all={all_gaps};cfg={include_config}".encode()
|
|
3413
|
+
).hexdigest()[:8]
|
|
3315
3414
|
_pctx_cache_key = f"pctx-{task}-{_pctx_git_sha}-{_sym_h}-{format or 'json'}"
|
|
3316
3415
|
_cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
|
|
3317
3416
|
if _cached_pctx is not None:
|
|
@@ -3743,16 +3842,22 @@ def prepare_context_cmd(
|
|
|
3743
3842
|
@telemetry_app.command("status")
|
|
3744
3843
|
def telemetry_status() -> None:
|
|
3745
3844
|
"""Show current telemetry setting."""
|
|
3746
|
-
from sourcecode.telemetry.config import config_file_path,
|
|
3845
|
+
from sourcecode.telemetry.config import config_file_path, is_enabled, stored_choice
|
|
3747
3846
|
enabled = is_enabled()
|
|
3748
|
-
|
|
3847
|
+
choice = stored_choice()
|
|
3749
3848
|
status = "enabled" if enabled else "disabled"
|
|
3750
|
-
typer.echo(f"Telemetry: {status} (
|
|
3751
|
-
|
|
3752
|
-
|
|
3849
|
+
typer.echo(f"Telemetry: {status} (off by default; opt-in)")
|
|
3850
|
+
# "off because you said so" and "off because nobody asked you" are different
|
|
3851
|
+
# answers, and a buyer auditing this needs to be told which one they have.
|
|
3852
|
+
if choice is None:
|
|
3853
|
+
typer.echo(" No choice recorded — nothing has been collected or sent.")
|
|
3854
|
+
else:
|
|
3855
|
+
typer.echo(f" Your recorded choice: {'enabled' if choice else 'disabled'}.")
|
|
3753
3856
|
typer.echo(f" Config: {config_file_path()}")
|
|
3754
|
-
|
|
3755
|
-
|
|
3857
|
+
if enabled:
|
|
3858
|
+
typer.echo(" Disable: ask telemetry disable (or SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1)")
|
|
3859
|
+
else:
|
|
3860
|
+
typer.echo(" Enable: ask telemetry enable (or SOURCECODE_TELEMETRY=1)")
|
|
3756
3861
|
|
|
3757
3862
|
|
|
3758
3863
|
@telemetry_app.command("enable")
|
|
@@ -3774,7 +3879,7 @@ def telemetry_disable() -> None:
|
|
|
3774
3879
|
from sourcecode.telemetry.config import set_enabled
|
|
3775
3880
|
set_enabled(False)
|
|
3776
3881
|
typer.echo("Telemetry disabled. No data will be collected or sent.")
|
|
3777
|
-
typer.echo("Telemetry is
|
|
3882
|
+
typer.echo("Telemetry is off by default; this choice is recorded so the notice stops asking.")
|
|
3778
3883
|
typer.echo("Re-enable at any time: ask telemetry enable")
|
|
3779
3884
|
|
|
3780
3885
|
|
|
@@ -8000,47 +8105,44 @@ def auth_status_cmd() -> None:
|
|
|
8000
8105
|
"""Show current authentication and plan status."""
|
|
8001
8106
|
import json as _json
|
|
8002
8107
|
try:
|
|
8003
|
-
from sourcecode.license import
|
|
8004
|
-
|
|
8005
|
-
_license_data as _ld,
|
|
8006
|
-
is_pro as _ip,
|
|
8007
|
-
)
|
|
8108
|
+
from sourcecode.license import _license_data as _ld, entitlement as _entitlement
|
|
8109
|
+
_ent = _entitlement()
|
|
8008
8110
|
except Exception:
|
|
8009
8111
|
_ld = None
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
# While the early-adoption unlock is on, every Pro command runs. Reporting
|
|
8014
|
-
# `pro: false` here contradicted the --help header and the commands' own
|
|
8015
|
-
# behaviour: the status has to describe the gating that is actually in force,
|
|
8016
|
-
# not the credential that happens to be absent.
|
|
8017
|
-
_gating = {
|
|
8018
|
-
"pro_gating": "disabled" if _unlock else "enabled",
|
|
8019
|
-
"pro_effective": bool(_ip or _unlock),
|
|
8020
|
-
}
|
|
8021
|
-
if _unlock:
|
|
8022
|
-
_gating["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
|
|
8023
|
-
|
|
8024
|
-
if not _ld:
|
|
8025
|
-
out: dict = {
|
|
8026
|
-
"status": "unauthenticated",
|
|
8027
|
-
"pro": bool(_unlock),
|
|
8028
|
-
**_gating,
|
|
8112
|
+
_ent = {
|
|
8113
|
+
"entitlement": "free", "source": "free_tier", "authenticated": False,
|
|
8114
|
+
"paywall_active": True, "when_it_changes": "", "free_repo_java_file_limit": None,
|
|
8029
8115
|
}
|
|
8030
|
-
sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
|
|
8031
|
-
sys.stdout.flush()
|
|
8032
|
-
return
|
|
8033
8116
|
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
"pro":
|
|
8041
|
-
"
|
|
8042
|
-
|
|
8117
|
+
# P-2: one authority (`license.entitlement`) answers "what runs here, and why".
|
|
8118
|
+
# The legacy keys below are *derived* from it rather than computed a second
|
|
8119
|
+
# time — that is what stops `{"status": "unauthenticated", "pro": true}` from
|
|
8120
|
+
# reappearing. They are deprecated and will be removed in a future major.
|
|
8121
|
+
_pro = _ent["entitlement"] == "pro"
|
|
8122
|
+
_legacy = {
|
|
8123
|
+
"pro": _pro,
|
|
8124
|
+
"pro_effective": _pro,
|
|
8125
|
+
"pro_gating": "enabled" if _ent["paywall_active"] else "disabled",
|
|
8126
|
+
}
|
|
8127
|
+
if _ent["source"] == "early_adoption_unlock":
|
|
8128
|
+
_legacy["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
|
|
8129
|
+
|
|
8130
|
+
out: dict = {
|
|
8131
|
+
"status": "authenticated" if _ent["authenticated"] else "unauthenticated",
|
|
8132
|
+
**_ent,
|
|
8133
|
+
**_legacy,
|
|
8134
|
+
"deprecated_fields": ["pro", "pro_effective", "pro_gating", "pro_reason"],
|
|
8043
8135
|
}
|
|
8136
|
+
if _ld:
|
|
8137
|
+
out.update({
|
|
8138
|
+
"auth_method": _ld.get("auth_method", "license_key"),
|
|
8139
|
+
"email": _ld.get("email", ""),
|
|
8140
|
+
"plan": _ld.get("plan", "unknown"),
|
|
8141
|
+
# The authority reads a missing status as active, so reporting
|
|
8142
|
+
# "unknown" here would contradict the entitlement in the same object.
|
|
8143
|
+
"plan_status": _ld.get("status", "active"),
|
|
8144
|
+
"validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
|
|
8145
|
+
})
|
|
8044
8146
|
sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
|
|
8045
8147
|
sys.stdout.flush()
|
|
8046
8148
|
|
|
@@ -8172,7 +8274,7 @@ def config_cmd() -> None:
|
|
|
8172
8274
|
from sourcecode.telemetry.config import config_file_path, is_enabled
|
|
8173
8275
|
typer.echo(f"ask {__version__}")
|
|
8174
8276
|
typer.echo(f"Config: {config_file_path()}")
|
|
8175
|
-
typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'}")
|
|
8277
|
+
typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'} (off by default; opt-in)")
|
|
8176
8278
|
typer.echo("")
|
|
8177
8279
|
typer.echo("Manage telemetry:")
|
|
8178
8280
|
typer.echo(" ask telemetry enable")
|
|
@@ -9300,17 +9402,32 @@ def cache_warm_cmd(
|
|
|
9300
9402
|
) -> None:
|
|
9301
9403
|
"""Pre-populate the cache by running a fresh analysis.
|
|
9302
9404
|
|
|
9303
|
-
Runs a full analysis to populate
|
|
9304
|
-
|
|
9405
|
+
Runs a full analysis to populate the snapshot cache, rebuild the RIS and build
|
|
9406
|
+
the shared Canonical IR. Useful after a merge/pull in CI.
|
|
9407
|
+
|
|
9408
|
+
\b
|
|
9409
|
+
It is not a general warm, and it says so when it finishes: it warms the
|
|
9410
|
+
compact view (add --agent for the agent view), and analysis flags that change
|
|
9411
|
+
what is analysed — --env-map, --depth N, --exclude — rescan anyway. Run
|
|
9412
|
+
`ask cache model` for what a warm gives each command.
|
|
9413
|
+
|
|
9414
|
+
\b
|
|
9415
|
+
In CI without a persisted cache directory, every pipeline pays the cold cost
|
|
9416
|
+
this command reports. Cache ~/.sourcecode/cache and ~/.sourcecode between jobs,
|
|
9417
|
+
or budget the cold run explicitly.
|
|
9305
9418
|
"""
|
|
9306
9419
|
import shutil as _shutil
|
|
9307
9420
|
import subprocess as _sub
|
|
9421
|
+
import time as _warm_time
|
|
9308
9422
|
# Warm exactly the path given. Resolving up to the enclosing git root warmed
|
|
9309
9423
|
# (and reported on) the whole monorepo when asked for one module — every other
|
|
9310
9424
|
# command scopes to the argument, so `cache warm ./service-a` populated a cache
|
|
9311
9425
|
# for a different target than `ask ./service-a` reads.
|
|
9312
9426
|
target = Path(path).resolve()
|
|
9313
9427
|
_git_root = _resolve_repo_root(Path(path))
|
|
9428
|
+
# Cost is reported for the whole warm, the CIR build included — that is what a
|
|
9429
|
+
# pipeline pays, and quoting only the analysis half would understate it (C4-5).
|
|
9430
|
+
_warm_t0 = _warm_time.monotonic()
|
|
9314
9431
|
typer.echo(f"Warming cache for {target} …", err=True)
|
|
9315
9432
|
if _git_root != target:
|
|
9316
9433
|
typer.echo(
|
|
@@ -9345,6 +9462,58 @@ def cache_warm_cmd(
|
|
|
9345
9462
|
err=True,
|
|
9346
9463
|
)
|
|
9347
9464
|
|
|
9465
|
+
# A warm that reports only what it built lets the reader assume it built
|
|
9466
|
+
# everything — which is how "el warm no es un warm general" became a field
|
|
9467
|
+
# finding (C4-3) rather than a documented limit. State both halves, and state
|
|
9468
|
+
# the cost, because in CI it is paid per pipeline (C4-5).
|
|
9469
|
+
_warm_elapsed = _warm_time.monotonic() - _warm_t0
|
|
9470
|
+
_warmed_view = "compact + agent views" if (compact and agent) else (
|
|
9471
|
+
"agent view" if agent else "compact view"
|
|
9472
|
+
)
|
|
9473
|
+
typer.echo(
|
|
9474
|
+
f"Warmed in {_warm_elapsed:.0f}s: {_warmed_view}, RIS, shared CIR, parse cache.",
|
|
9475
|
+
err=True,
|
|
9476
|
+
)
|
|
9477
|
+
typer.echo(
|
|
9478
|
+
"NOT warmed: prepare-context task answers (refactor / fix-bug / generate-tests / "
|
|
9479
|
+
"delta / review-pr), and any run whose analysis flags differ (--env-map, --depth N, "
|
|
9480
|
+
"--exclude) — those rescan. Measured: `endpoints` and `migrate-check` gain nothing "
|
|
9481
|
+
"from a warm. `ask cache model` says what a warm gives each command.",
|
|
9482
|
+
err=True,
|
|
9483
|
+
)
|
|
9484
|
+
typer.echo(
|
|
9485
|
+
f"In CI without a persisted ~/.sourcecode, every pipeline pays this {_warm_elapsed:.0f}s again.",
|
|
9486
|
+
err=True,
|
|
9487
|
+
)
|
|
9488
|
+
|
|
9489
|
+
|
|
9490
|
+
@cache_app.command("model")
|
|
9491
|
+
def cache_model_cmd(
|
|
9492
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
9493
|
+
markdown: bool = typer.Option(False, "--markdown", help="Output the tables published in the user guide."),
|
|
9494
|
+
) -> None:
|
|
9495
|
+
"""What each cache layer stores, what invalidates it, and what a warm helps.
|
|
9496
|
+
|
|
9497
|
+
\b
|
|
9498
|
+
Answers, per command, the question a warm raises: will this be fast next time?
|
|
9499
|
+
the answer — a warm stores what this command returns
|
|
9500
|
+
the shared work — a warm removes the Java parse / the shared IR; the command
|
|
9501
|
+
still computes its own answer
|
|
9502
|
+
nothing — a warm does not touch it
|
|
9503
|
+
|
|
9504
|
+
One rule covers invalidation: every layer keys on the exact tree state, so any
|
|
9505
|
+
change to the analysed files invalidates it, committed or not.
|
|
9506
|
+
"""
|
|
9507
|
+
from sourcecode import cache_model as _cmodel
|
|
9508
|
+
|
|
9509
|
+
if json_output:
|
|
9510
|
+
import json as _j
|
|
9511
|
+
typer.echo(_j.dumps(_cmodel.as_dict(), indent=2, ensure_ascii=False))
|
|
9512
|
+
elif markdown:
|
|
9513
|
+
typer.echo(_cmodel.render_markdown())
|
|
9514
|
+
else:
|
|
9515
|
+
typer.echo(_cmodel.render_text())
|
|
9516
|
+
|
|
9348
9517
|
|
|
9349
9518
|
@cache_app.command("freshness")
|
|
9350
9519
|
def cache_freshness_cmd(
|
sourcecode/context_cache.py
CHANGED
|
@@ -190,34 +190,14 @@ def _git_head(repo_root: Path) -> str:
|
|
|
190
190
|
def _worktree_signature(repo_root: Path) -> str:
|
|
191
191
|
"""Deterministic fingerprint of the *exact* tree state.
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
Thin alias of the one authority, :func:`sourcecode.cache.worktree_signature`.
|
|
194
|
+
It used to be a second implementation living here, which is how the two caches
|
|
195
|
+
ended up invalidating on different facts: this one on the tree, the snapshot
|
|
196
|
+
cache on the committed HEAD alone.
|
|
197
197
|
"""
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
try:
|
|
202
|
-
status = subprocess.run(
|
|
203
|
-
["git", "-C", str(repo_root), "status", "--porcelain"],
|
|
204
|
-
capture_output=True, text=True, timeout=5,
|
|
205
|
-
)
|
|
206
|
-
porcelain = status.stdout if status.returncode == 0 else ""
|
|
207
|
-
except Exception:
|
|
208
|
-
porcelain = ""
|
|
209
|
-
if not porcelain.strip():
|
|
210
|
-
return head # clean tree — HEAD fully describes it
|
|
211
|
-
try:
|
|
212
|
-
diff = subprocess.run(
|
|
213
|
-
["git", "-C", str(repo_root), "diff", "HEAD"],
|
|
214
|
-
capture_output=True, text=True, timeout=10,
|
|
215
|
-
)
|
|
216
|
-
diff_txt = diff.stdout if diff.returncode == 0 else ""
|
|
217
|
-
except Exception:
|
|
218
|
-
diff_txt = ""
|
|
219
|
-
dirty = hashlib.sha256((porcelain + "\x00" + diff_txt).encode("utf-8", "replace")).hexdigest()[:16]
|
|
220
|
-
return f"{head}+dirty:{dirty}"
|
|
198
|
+
from sourcecode.cache import worktree_signature
|
|
199
|
+
|
|
200
|
+
return worktree_signature(repo_root)
|
|
221
201
|
|
|
222
202
|
|
|
223
203
|
def _options_fingerprint(options: Optional[dict[str, Any]]) -> str:
|
sourcecode/license.py
CHANGED
|
@@ -376,16 +376,78 @@ def _maybe_revalidate() -> None:
|
|
|
376
376
|
pass
|
|
377
377
|
|
|
378
378
|
|
|
379
|
-
def
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
379
|
+
def entitlement() -> dict:
|
|
380
|
+
"""The one answer to "what runs here today, and why" (P-2).
|
|
381
|
+
|
|
382
|
+
`auth status` used to publish four fields about this — `status`, `pro`,
|
|
383
|
+
`pro_gating`, `pro_effective`, plus a `pro_reason` sentence — and they read as
|
|
384
|
+
a contradiction: `{"status": "unauthenticated", "pro": true}`. Each field was
|
|
385
|
+
true on its own axis; together they told a buyer nothing about what they have,
|
|
386
|
+
why they have it, or what changes when it ends. That is the ADR-0008 pattern
|
|
387
|
+
exactly, on the commercial surface instead of an analysis one.
|
|
388
|
+
|
|
389
|
+
Every published entitlement field is derived from this dict and nowhere else.
|
|
390
|
+
|
|
391
|
+
Keys:
|
|
392
|
+
entitlement "pro" | "free" — what actually runs right now
|
|
393
|
+
source why: "license_key" | "early_adoption_unlock" | "free_tier"
|
|
394
|
+
authenticated whether a credential is on this machine (an independent
|
|
395
|
+
fact: today one can be entitled to Pro without one)
|
|
396
|
+
paywall_active whether the paywall is enforcing anything at all
|
|
397
|
+
when_it_changes what a user loses when `source` stops applying, in the
|
|
398
|
+
terms the gate actually uses — never a price
|
|
399
|
+
free_repo_java_file_limit
|
|
400
|
+
the number the size gate compares against
|
|
401
|
+
"""
|
|
402
|
+
authenticated = _license_data is not None
|
|
403
|
+
licensed = bool(
|
|
383
404
|
_license_data is not None
|
|
384
405
|
and _license_data.get("plan") == "pro"
|
|
385
406
|
and _license_data.get("status", "active") != "inactive"
|
|
386
407
|
)
|
|
387
|
-
if
|
|
388
|
-
|
|
408
|
+
if licensed:
|
|
409
|
+
source = "license_key"
|
|
410
|
+
elif _PRO_UNLOCK_ALL:
|
|
411
|
+
source = "early_adoption_unlock"
|
|
412
|
+
else:
|
|
413
|
+
source = "free_tier"
|
|
414
|
+
|
|
415
|
+
if source == "license_key":
|
|
416
|
+
when = "Your licence entitles you to Pro; nothing changes while it stays active."
|
|
417
|
+
elif source == "early_adoption_unlock":
|
|
418
|
+
when = (
|
|
419
|
+
"Pro runs without a licence during early adoption. When that ends, every command "
|
|
420
|
+
f"still runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} Java "
|
|
421
|
+
"source files; above that, the heavy-analysis commands ask for Pro. Capability is "
|
|
422
|
+
"never what is gated — size, automation and team are."
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
when = (
|
|
426
|
+
f"Every command runs at full output on repositories up to {_FREE_REPO_JAVA_FILE_LIMIT} "
|
|
427
|
+
"Java source files. Above that, the heavy-analysis commands require Pro."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"entitlement": "pro" if (licensed or _PRO_UNLOCK_ALL) else "free",
|
|
432
|
+
"source": source,
|
|
433
|
+
"authenticated": authenticated,
|
|
434
|
+
"paywall_active": not _PRO_UNLOCK_ALL,
|
|
435
|
+
"when_it_changes": when,
|
|
436
|
+
"free_repo_java_file_limit": _FREE_REPO_JAVA_FILE_LIMIT,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _init() -> None:
|
|
441
|
+
"""Load the credential and derive the entitlement from the one authority.
|
|
442
|
+
|
|
443
|
+
`is_pro` is what every gate reads (`can_use`, `require_feature`), and it is
|
|
444
|
+
computed here from `entitlement()` rather than beside it — a second copy of
|
|
445
|
+
this rule is how `auth status` came to publish a state the commands
|
|
446
|
+
contradicted (P-2).
|
|
447
|
+
"""
|
|
448
|
+
global _license_data, is_pro
|
|
449
|
+
_license_data = _load_license_file()
|
|
450
|
+
is_pro = entitlement()["entitlement"] == "pro"
|
|
389
451
|
|
|
390
452
|
|
|
391
453
|
_init()
|
|
@@ -396,7 +458,7 @@ _init()
|
|
|
396
458
|
# ---------------------------------------------------------------------------
|
|
397
459
|
|
|
398
460
|
def _emit_telemetry(event: str, **kw: object) -> None:
|
|
399
|
-
"""Best-effort telemetry emit.
|
|
461
|
+
"""Best-effort telemetry emit. Sends nothing unless the user opted in; never raises or blocks."""
|
|
400
462
|
try:
|
|
401
463
|
from sourcecode import telemetry as _tel
|
|
402
464
|
_tel.record(event, **kw) # type: ignore[arg-type]
|
sourcecode/mcp/server.py
CHANGED
|
@@ -33,8 +33,8 @@ def _record_tool_invocation(name: Any, success: bool, started: float) -> None:
|
|
|
33
33
|
|
|
34
34
|
Aggregate only: which of our own tools ran, whether it succeeded, and a
|
|
35
35
|
duration bucket. Never the arguments — those carry repository paths — and
|
|
36
|
-
never any result content. Honours the same opt-
|
|
37
|
-
(`ask telemetry
|
|
36
|
+
never any result content. Honours the same opt-in as every other event
|
|
37
|
+
(off until `ask telemetry enable` or SOURCECODE_TELEMETRY=1); when
|
|
38
38
|
telemetry is off, `record` returns before building anything.
|
|
39
39
|
"""
|
|
40
40
|
try:
|
sourcecode/prepare_context.py
CHANGED
|
@@ -1090,20 +1090,20 @@ class TaskContextBuilder:
|
|
|
1090
1090
|
def _try_ris_fast_path(self, task_name: str, spec: "TaskSpec") -> Optional[TaskOutput]:
|
|
1091
1091
|
"""Return TaskOutput from a warm RIS without running the full scan.
|
|
1092
1092
|
|
|
1093
|
-
Only activated for onboard/explain when
|
|
1093
|
+
Only activated for onboard/explain when the stored RIS describes the tree
|
|
1094
|
+
on disk — the same freshness authority every cache layer keys on, not the
|
|
1095
|
+
committed HEAD (which served this fast path through uncommitted edits).
|
|
1094
1096
|
Falls through (returns None) on any error or cache miss.
|
|
1095
1097
|
"""
|
|
1096
1098
|
try:
|
|
1097
|
-
import subprocess as _sp
|
|
1098
1099
|
from sourcecode.ris import load_ris as _lris
|
|
1100
|
+
from sourcecode.ris import _tree_signature as _ris_sig
|
|
1099
1101
|
_ris = _lris(self.root)
|
|
1100
1102
|
if _ris is None or not _ris.compact_summary:
|
|
1101
1103
|
return None
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
)
|
|
1106
|
-
if _r.returncode != 0 or _r.stdout.strip() != _ris.git_head:
|
|
1104
|
+
_stored_sig = (_ris.metadata or {}).get("tree_signature") or ""
|
|
1105
|
+
_current_sig = _ris_sig(self.root)
|
|
1106
|
+
if not _stored_sig or not _current_sig or _stored_sig != _current_sig:
|
|
1107
1107
|
return None
|
|
1108
1108
|
|
|
1109
1109
|
compact = _ris.compact_summary
|
sourcecode/ris.py
CHANGED
|
@@ -258,6 +258,7 @@ def _build_from_core(
|
|
|
258
258
|
1.0 if agent_data else (0.5 if compact_data else 0.0)
|
|
259
259
|
),
|
|
260
260
|
"partial": not bool(agent_data),
|
|
261
|
+
"tree_signature": _tree_signature(repo_root),
|
|
261
262
|
},
|
|
262
263
|
)
|
|
263
264
|
|
|
@@ -295,6 +296,7 @@ def maybe_update_ris(repo_root: Path, core_dict: dict, git_head: str) -> None:
|
|
|
295
296
|
"snapshot_source": "existing_snapshot_system",
|
|
296
297
|
"confidence": float(1.0 if agent_data else (0.5 if compact_data else 0.0)),
|
|
297
298
|
"partial": not bool(agent_data),
|
|
299
|
+
"tree_signature": _tree_signature(repo_root),
|
|
298
300
|
},
|
|
299
301
|
)
|
|
300
302
|
save_ris(repo_root, updated)
|
|
@@ -433,6 +435,21 @@ def _current_git_head(repo_root: Path) -> str:
|
|
|
433
435
|
return ""
|
|
434
436
|
|
|
435
437
|
|
|
438
|
+
def _tree_signature(repo_root: Path) -> str:
|
|
439
|
+
"""Tree state this snapshot describes — the one freshness authority.
|
|
440
|
+
|
|
441
|
+
Stored in ``metadata["tree_signature"]`` at write time so staleness compares
|
|
442
|
+
the tree the RIS was built from against the tree on disk now. HEAD alone
|
|
443
|
+
called a snapshot fresh while uncommitted edits made it describe nothing.
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
from sourcecode.cache import worktree_signature
|
|
447
|
+
|
|
448
|
+
return worktree_signature(repo_root)
|
|
449
|
+
except Exception:
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
|
|
436
453
|
def _has_uncommitted_changes(repo_root: Path) -> bool:
|
|
437
454
|
"""Return True if working tree has staged or unstaged changes to tracked files.
|
|
438
455
|
|
|
@@ -465,8 +482,17 @@ def get_cold_start_context(repo_root: Path) -> dict:
|
|
|
465
482
|
return {"status": "no_ris"}
|
|
466
483
|
|
|
467
484
|
current_head = _current_git_head(repo_root)
|
|
468
|
-
stale = bool(current_head and ris.git_head and current_head != ris.git_head)
|
|
469
485
|
uncommitted = _has_uncommitted_changes(repo_root)
|
|
486
|
+
# Staleness is about the tree the snapshot describes, not the commit it was
|
|
487
|
+
# taken at: with HEAD alone a snapshot stayed `stale: false` through every
|
|
488
|
+
# uncommitted edit. Snapshots written before the signature existed fall back
|
|
489
|
+
# to the pair of facts that were available then.
|
|
490
|
+
_stored_sig = (ris.metadata or {}).get("tree_signature") or ""
|
|
491
|
+
_current_sig = _tree_signature(repo_root)
|
|
492
|
+
if _stored_sig and _current_sig:
|
|
493
|
+
stale = _stored_sig != _current_sig
|
|
494
|
+
else:
|
|
495
|
+
stale = bool(current_head and ris.git_head and current_head != ris.git_head) or uncommitted
|
|
470
496
|
|
|
471
497
|
endpoints = ris.api_surface.get("endpoints", [])
|
|
472
498
|
_is_java = (
|