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/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 (on by default; opt-out).", rich_markup_mode="rich")
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 on by default (opt-out). We inform rather than ask, then
861
- mark the notice as shown so it appears only once.
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
- if _git_sha and _git_root_str:
1703
- _core_key = f"{_git_sha}-{_core_h}"
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
- # No git history (untracked/no-commit repo) — stable synthetic key
1706
- # scoped per repo path via cache_dir(); invalidated by --no-cache or cache clear.
1707
- _core_key = f"nogit-{_core_h}"
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 = _git_sha if _git_sha else "nogit"
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, git_head, symptom) so warm calls complete in <1s.
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
- _sha_r2 = _pctx_sub.run(
3307
- ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
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
- _sym_h = _pctx_hash.sha256((symptom or "").encode()).hexdigest()[:8]
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, has_been_asked, is_enabled
3845
+ from sourcecode.telemetry.config import config_file_path, is_enabled, stored_choice
3747
3846
  enabled = is_enabled()
3748
- asked = has_been_asked()
3847
+ choice = stored_choice()
3749
3848
  status = "enabled" if enabled else "disabled"
3750
- typer.echo(f"Telemetry: {status} (on by default; opt-out)")
3751
- if not asked:
3752
- typer.echo(" (first-run notice not yet shown — will show on next run)")
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
- typer.echo(" Disable: ask telemetry disable")
3755
- typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0 (or DO_NOT_TRACK=1)")
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 on by default; this opt-out is remembered.")
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 L1/L2 caches and rebuild the RIS
9304
- (Repository Intelligence Snapshot). Useful after a merge/pull in CI.
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(
@@ -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
- committed HEAD, plus — only when the tree is dirty — a hash of the porcelain
194
- status and the diff against HEAD. Two identical tree states yield the same
195
- signature (correct reuse); any tracked edit changes it (correct invalidation).
196
- Returns ``""`` when the path is not a git repo (caller disables caching).
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
- head = _git_head(repo_root)
199
- if not head:
200
- return ""
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. Respects the user's opt-out; never raises or blocks."""
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-out as every other event
37
- (`ask telemetry disable`, SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1); when
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:
@@ -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 git HEAD matches stored RIS.
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
- _r = _sp.run(
1103
- ["git", "-C", str(self.root), "rev-parse", "--short", "HEAD"],
1104
- capture_output=True, text=True, timeout=2,
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 = (
@@ -1,12 +1,13 @@
1
- """sourcecode telemetry — anonymous usage metrics, on by default (opt-out).
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 enabled by default and stays anonymous. It can be disabled at any
9
- time via `sourcecode telemetry disable`, SOURCECODE_TELEMETRY=0, or DO_NOT_TRACK=1.
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.
@@ -1,11 +1,17 @@
1
1
  """Persistent telemetry configuration.
2
2
 
3
- Telemetry is enabled by default (opt-out). It stays anonymous and never
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
- Disable: `sourcecode telemetry disable`, SOURCECODE_TELEMETRY=0, or DO_NOT_TRACK=1
8
- Env override: SOURCECODE_TELEMETRY=0 (disable) or =1 (enable)
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 unless telemetry has been explicitly disabled.
55
+ """True only when telemetry has been explicitly turned on.
51
56
 
52
- Telemetry is enabled by default (opt-out). Precedence, highest first:
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: True — except in CI, where it defaults to False
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 = _load().get("telemetry", {}).get("enabled")
75
+ stored = stored_choice()
67
76
  if stored is not None:
68
- return bool(stored)
69
- # No explicit choice yet: on by default, off under CI.
70
- return not _in_ci()
77
+ return stored
78
+ return False
71
79
 
72
80
 
73
81
  def has_been_asked() -> bool: