sourcecode 3.2.1__py3-none-any.whl → 3.2.2__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 +201 -29
- sourcecode/context_cache.py +7 -27
- sourcecode/license.py +1 -1
- 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.2.2.dist-info}/METADATA +64 -40
- {sourcecode-3.2.1.dist-info → sourcecode-3.2.2.dist-info}/RECORD +17 -16
- {sourcecode-3.2.1.dist-info → sourcecode-3.2.2.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.1.dist-info → sourcecode-3.2.2.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.1.dist-info → sourcecode-3.2.2.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
|
|
|
@@ -8172,7 +8277,7 @@ def config_cmd() -> None:
|
|
|
8172
8277
|
from sourcecode.telemetry.config import config_file_path, is_enabled
|
|
8173
8278
|
typer.echo(f"ask {__version__}")
|
|
8174
8279
|
typer.echo(f"Config: {config_file_path()}")
|
|
8175
|
-
typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'}")
|
|
8280
|
+
typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'} (off by default; opt-in)")
|
|
8176
8281
|
typer.echo("")
|
|
8177
8282
|
typer.echo("Manage telemetry:")
|
|
8178
8283
|
typer.echo(" ask telemetry enable")
|
|
@@ -9300,17 +9405,32 @@ def cache_warm_cmd(
|
|
|
9300
9405
|
) -> None:
|
|
9301
9406
|
"""Pre-populate the cache by running a fresh analysis.
|
|
9302
9407
|
|
|
9303
|
-
Runs a full analysis to populate
|
|
9304
|
-
|
|
9408
|
+
Runs a full analysis to populate the snapshot cache, rebuild the RIS and build
|
|
9409
|
+
the shared Canonical IR. Useful after a merge/pull in CI.
|
|
9410
|
+
|
|
9411
|
+
\b
|
|
9412
|
+
It is not a general warm, and it says so when it finishes: it warms the
|
|
9413
|
+
compact view (add --agent for the agent view), and analysis flags that change
|
|
9414
|
+
what is analysed — --env-map, --depth N, --exclude — rescan anyway. Run
|
|
9415
|
+
`ask cache model` for what a warm gives each command.
|
|
9416
|
+
|
|
9417
|
+
\b
|
|
9418
|
+
In CI without a persisted cache directory, every pipeline pays the cold cost
|
|
9419
|
+
this command reports. Cache ~/.sourcecode/cache and ~/.sourcecode between jobs,
|
|
9420
|
+
or budget the cold run explicitly.
|
|
9305
9421
|
"""
|
|
9306
9422
|
import shutil as _shutil
|
|
9307
9423
|
import subprocess as _sub
|
|
9424
|
+
import time as _warm_time
|
|
9308
9425
|
# Warm exactly the path given. Resolving up to the enclosing git root warmed
|
|
9309
9426
|
# (and reported on) the whole monorepo when asked for one module — every other
|
|
9310
9427
|
# command scopes to the argument, so `cache warm ./service-a` populated a cache
|
|
9311
9428
|
# for a different target than `ask ./service-a` reads.
|
|
9312
9429
|
target = Path(path).resolve()
|
|
9313
9430
|
_git_root = _resolve_repo_root(Path(path))
|
|
9431
|
+
# Cost is reported for the whole warm, the CIR build included — that is what a
|
|
9432
|
+
# pipeline pays, and quoting only the analysis half would understate it (C4-5).
|
|
9433
|
+
_warm_t0 = _warm_time.monotonic()
|
|
9314
9434
|
typer.echo(f"Warming cache for {target} …", err=True)
|
|
9315
9435
|
if _git_root != target:
|
|
9316
9436
|
typer.echo(
|
|
@@ -9345,6 +9465,58 @@ def cache_warm_cmd(
|
|
|
9345
9465
|
err=True,
|
|
9346
9466
|
)
|
|
9347
9467
|
|
|
9468
|
+
# A warm that reports only what it built lets the reader assume it built
|
|
9469
|
+
# everything — which is how "el warm no es un warm general" became a field
|
|
9470
|
+
# finding (C4-3) rather than a documented limit. State both halves, and state
|
|
9471
|
+
# the cost, because in CI it is paid per pipeline (C4-5).
|
|
9472
|
+
_warm_elapsed = _warm_time.monotonic() - _warm_t0
|
|
9473
|
+
_warmed_view = "compact + agent views" if (compact and agent) else (
|
|
9474
|
+
"agent view" if agent else "compact view"
|
|
9475
|
+
)
|
|
9476
|
+
typer.echo(
|
|
9477
|
+
f"Warmed in {_warm_elapsed:.0f}s: {_warmed_view}, RIS, shared CIR, parse cache.",
|
|
9478
|
+
err=True,
|
|
9479
|
+
)
|
|
9480
|
+
typer.echo(
|
|
9481
|
+
"NOT warmed: prepare-context task answers (refactor / fix-bug / generate-tests / "
|
|
9482
|
+
"delta / review-pr), and any run whose analysis flags differ (--env-map, --depth N, "
|
|
9483
|
+
"--exclude) — those rescan. Measured: `endpoints` and `migrate-check` gain nothing "
|
|
9484
|
+
"from a warm. `ask cache model` says what a warm gives each command.",
|
|
9485
|
+
err=True,
|
|
9486
|
+
)
|
|
9487
|
+
typer.echo(
|
|
9488
|
+
f"In CI without a persisted ~/.sourcecode, every pipeline pays this {_warm_elapsed:.0f}s again.",
|
|
9489
|
+
err=True,
|
|
9490
|
+
)
|
|
9491
|
+
|
|
9492
|
+
|
|
9493
|
+
@cache_app.command("model")
|
|
9494
|
+
def cache_model_cmd(
|
|
9495
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
9496
|
+
markdown: bool = typer.Option(False, "--markdown", help="Output the tables published in the user guide."),
|
|
9497
|
+
) -> None:
|
|
9498
|
+
"""What each cache layer stores, what invalidates it, and what a warm helps.
|
|
9499
|
+
|
|
9500
|
+
\b
|
|
9501
|
+
Answers, per command, the question a warm raises: will this be fast next time?
|
|
9502
|
+
the answer — a warm stores what this command returns
|
|
9503
|
+
the shared work — a warm removes the Java parse / the shared IR; the command
|
|
9504
|
+
still computes its own answer
|
|
9505
|
+
nothing — a warm does not touch it
|
|
9506
|
+
|
|
9507
|
+
One rule covers invalidation: every layer keys on the exact tree state, so any
|
|
9508
|
+
change to the analysed files invalidates it, committed or not.
|
|
9509
|
+
"""
|
|
9510
|
+
from sourcecode import cache_model as _cmodel
|
|
9511
|
+
|
|
9512
|
+
if json_output:
|
|
9513
|
+
import json as _j
|
|
9514
|
+
typer.echo(_j.dumps(_cmodel.as_dict(), indent=2, ensure_ascii=False))
|
|
9515
|
+
elif markdown:
|
|
9516
|
+
typer.echo(_cmodel.render_markdown())
|
|
9517
|
+
else:
|
|
9518
|
+
typer.echo(_cmodel.render_text())
|
|
9519
|
+
|
|
9348
9520
|
|
|
9349
9521
|
@cache_app.command("freshness")
|
|
9350
9522
|
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
|
@@ -396,7 +396,7 @@ _init()
|
|
|
396
396
|
# ---------------------------------------------------------------------------
|
|
397
397
|
|
|
398
398
|
def _emit_telemetry(event: str, **kw: object) -> None:
|
|
399
|
-
"""Best-effort telemetry emit.
|
|
399
|
+
"""Best-effort telemetry emit. Sends nothing unless the user opted in; never raises or blocks."""
|
|
400
400
|
try:
|
|
401
401
|
from sourcecode import telemetry as _tel
|
|
402
402
|
_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 = (
|
sourcecode/telemetry/__init__.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
"""sourcecode telemetry — anonymous usage metrics,
|
|
1
|
+
"""sourcecode telemetry — anonymous usage metrics, off until you turn them on (opt-in).
|
|
2
2
|
|
|
3
3
|
Public API:
|
|
4
4
|
is_enabled() → bool
|
|
5
5
|
record(event, **kw) → None (fire-and-forget)
|
|
6
6
|
session_id() → str (ephemeral 8-char hex, new each process)
|
|
7
7
|
|
|
8
|
-
Telemetry is
|
|
9
|
-
|
|
8
|
+
Telemetry is opt-in and stays anonymous: nothing is collected until `ask telemetry
|
|
9
|
+
enable` (or SOURCECODE_TELEMETRY=1). It was opt-out until 3.3.0 — see config.py for
|
|
10
|
+
why the default moved.
|
|
10
11
|
|
|
11
12
|
Nothing sensitive (code, paths, secrets, output) is ever collected.
|
|
12
13
|
See docs/privacy.md for full details.
|
sourcecode/telemetry/config.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""Persistent telemetry configuration.
|
|
2
2
|
|
|
3
|
-
Telemetry is
|
|
4
|
-
collects source code, paths, secrets or repository content.
|
|
3
|
+
Telemetry is **off until the user turns it on** (opt-in). It stays anonymous and
|
|
4
|
+
never collects source code, paths, secrets or repository content.
|
|
5
|
+
|
|
6
|
+
It was opt-out until 3.3.0. Field evaluation #3 was the first audit of regulated
|
|
7
|
+
third-party code — public-sector health data — and there the default is not a
|
|
8
|
+
preference but a procurement blocker: the reviewer had to disable telemetry
|
|
9
|
+
*before the first run*, which the product gave them no way to do. Nothing is
|
|
10
|
+
collected before an explicit choice, so there is nothing to disable in time.
|
|
5
11
|
|
|
6
12
|
Config file: ~/.config/sourcecode/config.json
|
|
7
|
-
|
|
8
|
-
Env override: SOURCECODE_TELEMETRY=
|
|
13
|
+
Enable: `ask telemetry enable` or SOURCECODE_TELEMETRY=1
|
|
14
|
+
Env override: SOURCECODE_TELEMETRY=1 (enable) or =0 (disable); DO_NOT_TRACK=1 disables
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
@@ -18,18 +24,6 @@ from typing import Any
|
|
|
18
24
|
_ENV_VAR = "SOURCECODE_TELEMETRY"
|
|
19
25
|
_CONFIG_FILE = Path.home() / ".config" / "sourcecode" / "config.json"
|
|
20
26
|
|
|
21
|
-
# CI markers — when no explicit choice has been made, telemetry defaults OFF
|
|
22
|
-
# in CI (no human to see the first-run notice), ON otherwise.
|
|
23
|
-
_CI_VARS = (
|
|
24
|
-
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "CIRCLECI",
|
|
25
|
-
"TRAVIS", "JENKINS_URL", "BUILDKITE", "GITLAB_CI", "TF_BUILD",
|
|
26
|
-
"TEAMCITY_VERSION", "DRONE", "SEMAPHORE",
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _in_ci() -> bool:
|
|
31
|
-
return any(os.environ.get(v) for v in _CI_VARS)
|
|
32
|
-
|
|
33
27
|
|
|
34
28
|
def _load() -> dict[str, Any]:
|
|
35
29
|
try:
|
|
@@ -46,14 +40,29 @@ def _save(data: dict[str, Any]) -> None:
|
|
|
46
40
|
pass # config write failure is non-fatal
|
|
47
41
|
|
|
48
42
|
|
|
43
|
+
def stored_choice() -> "bool | None":
|
|
44
|
+
"""The explicit choice on record, or None when the user has made none.
|
|
45
|
+
|
|
46
|
+
`None` is a state, not a synonym for `False`: "off because nobody has been
|
|
47
|
+
asked yet" and "off because you turned it off" are different answers, and
|
|
48
|
+
`telemetry status` prints them differently.
|
|
49
|
+
"""
|
|
50
|
+
stored = _load().get("telemetry", {}).get("enabled")
|
|
51
|
+
return None if stored is None else bool(stored)
|
|
52
|
+
|
|
53
|
+
|
|
49
54
|
def is_enabled() -> bool:
|
|
50
|
-
"""True
|
|
55
|
+
"""True only when telemetry has been explicitly turned on.
|
|
51
56
|
|
|
52
|
-
Telemetry is
|
|
57
|
+
Telemetry is **opt-in**: no choice on record means off, everywhere, CI or
|
|
58
|
+
not. Precedence, highest first:
|
|
53
59
|
1. SOURCECODE_TELEMETRY env var (0 = off, 1 = on)
|
|
54
60
|
2. DO_NOT_TRACK env var (any value other than ""/"0" turns it off)
|
|
55
61
|
3. config file 'enabled' flag, if the user has made an explicit choice
|
|
56
|
-
4. default:
|
|
62
|
+
4. default: False
|
|
63
|
+
|
|
64
|
+
There is no CI special case any more, because there is nothing left for it
|
|
65
|
+
to protect against — the default it used to override is now off.
|
|
57
66
|
"""
|
|
58
67
|
env = os.environ.get(_ENV_VAR, "").strip()
|
|
59
68
|
if env == "0":
|
|
@@ -63,11 +72,10 @@ def is_enabled() -> bool:
|
|
|
63
72
|
dnt = os.environ.get("DO_NOT_TRACK", "").strip()
|
|
64
73
|
if dnt not in ("", "0"):
|
|
65
74
|
return False
|
|
66
|
-
stored =
|
|
75
|
+
stored = stored_choice()
|
|
67
76
|
if stored is not None:
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
return not _in_ci()
|
|
77
|
+
return stored
|
|
78
|
+
return False
|
|
71
79
|
|
|
72
80
|
|
|
73
81
|
def has_been_asked() -> bool:
|