sourcecode 3.2.0__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.

@@ -153,18 +153,58 @@ _TYPE_AFTER_ANN_RE = re.compile(
153
153
  r"\b(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)"
154
154
  )
155
155
 
156
+ # What may stand between `@Profile(...)` and the type declaration it annotates:
157
+ # further annotations and modifiers, nothing else. Anything beyond this means
158
+ # the annotation belongs to a member, not to the type.
159
+ _ANNOTATED_TYPE_RE = re.compile(
160
+ r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
161
+ r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
162
+ r"|public|protected|private|static|final|abstract|strictfp|sealed|non-sealed)*"
163
+ r"\s*(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)",
164
+ re.DOTALL,
165
+ )
156
166
 
157
- def conditional_beans_from_sources(
167
+ # The member an annotation sits on, when it is not a type: the declared name of
168
+ # the next method or field.
169
+ _MEMBER_AFTER_ANN_RE = re.compile(
170
+ r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
171
+ r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
172
+ r"|public|protected|private|static|final|abstract|synchronized|native|transient|volatile|default"
173
+ r"|<[^<>]*>|[\w.$]+(?:\s*<[^<>]*>)?(?:\s*\[\s*\])*)*?"
174
+ r"\s(?P<name>[A-Za-z_$][\w$]*)\s*[(=;]",
175
+ re.DOTALL,
176
+ )
177
+
178
+
179
+ def _enclosing_type_name(text: str, pos: int, fallback: str) -> str:
180
+ """Name of the type declaration that encloses ``pos``."""
181
+ last = None
182
+ for m in _TYPE_AFTER_ANN_RE.finditer(text, 0, pos):
183
+ last = m
184
+ return last.group("name") if last else fallback
185
+
186
+
187
+ def conditional_beans_from_sources_detailed(
158
188
  root: Path, file_paths: "Optional[list[str]]" = None
159
- ) -> "dict[str, list[str]]":
160
- """`{profile: [TypeName, …]}` for types carrying `@Profile`.
189
+ ) -> "list[ProfileConditionalBean]":
190
+ """`@Profile` symbols and the expression each one declares, from sources.
191
+
192
+ The single derivation of this fact for callers that hold no CIR;
193
+ :func:`profile_conditional_beans` is the same fact read from the IR, and
194
+ both must agree — a symbol this reports under a profile must be a symbol
195
+ `posture` resolves the same way.
161
196
 
162
- Which beans a profile switches is the fact worth reporting — a profile list
163
- alone does not tell a reader that one profile swaps the security filter
164
- chain. Used where no CIR is in scope; `profile_conditional_beans` is the
165
- exact, IR-based version.
197
+ **Attribution rule (the defect this exists to prevent):** a `@Profile` is
198
+ attributed to the declaration it actually annotates. Only annotations and
199
+ modifiers may stand between it and a type declaration; anything else means
200
+ the annotation sits on a member, which is then reported as ``Type#member``.
201
+ The previous scan searched for the *next* type declaration anywhere ahead
202
+ and fell back to the file name, so a `@Profile("m3")` on a `@Bean` method
203
+ inside `DevSecurityConfig` was reported as `DevSecurityConfig` being
204
+ conditional on `m3` — the exact opposite of its class-level
205
+ `@Profile("!m3")`, in the same document, contradicting `posture`.
166
206
  """
167
- out: dict[str, set[str]] = {}
207
+ beans: list[ProfileConditionalBean] = []
168
208
  try:
169
209
  paths = (
170
210
  [Path(root) / p for p in file_paths if p.endswith(".java")]
@@ -172,7 +212,7 @@ def conditional_beans_from_sources(
172
212
  else list(Path(root).rglob("*.java"))
173
213
  )
174
214
  except Exception:
175
- return {}
215
+ return []
176
216
  for path in paths[:20000]:
177
217
  try:
178
218
  text = path.read_text(encoding="utf-8", errors="ignore")
@@ -180,11 +220,51 @@ def conditional_beans_from_sources(
180
220
  continue
181
221
  if "@Profile" not in text:
182
222
  continue
223
+ try:
224
+ rel = str(path.relative_to(Path(root)))
225
+ except Exception:
226
+ rel = str(path)
183
227
  for match in _PROFILE_ANN_RE.finditer(text):
184
- type_match = _TYPE_AFTER_ANN_RE.search(text, match.end())
185
- name = type_match.group("name") if type_match else path.stem
186
- for token in _split_profile_expression(match.group("args")):
187
- out.setdefault(token.strip(), set()).add(name)
228
+ tail = text[match.end():match.end() + 4000]
229
+ type_match = _ANNOTATED_TYPE_RE.match(tail)
230
+ if type_match:
231
+ symbol = type_match.group("name")
232
+ else:
233
+ member_match = _MEMBER_AFTER_ANN_RE.match(tail)
234
+ if not member_match:
235
+ # Neither a type nor a readable member: report nothing
236
+ # rather than attribute the annotation to a guess.
237
+ continue
238
+ owner = _enclosing_type_name(text, match.start(), path.stem)
239
+ symbol = f"{owner}#{member_match.group('name')}"
240
+ args = match.group("args")
241
+ names = _split_profile_expression(args)
242
+ if not names:
243
+ continue
244
+ beans.append(ProfileConditionalBean(
245
+ symbol=symbol,
246
+ profiles=tuple(names),
247
+ source_file=rel,
248
+ expression=args.strip(),
249
+ ))
250
+ beans.sort(key=lambda b: (b.symbol, b.profiles))
251
+ return beans
252
+
253
+
254
+ def conditional_beans_from_sources(
255
+ root: Path, file_paths: "Optional[list[str]]" = None
256
+ ) -> "dict[str, list[str]]":
257
+ """`{profile: [symbol, …]}` — a projection of
258
+ :func:`conditional_beans_from_sources_detailed`.
259
+
260
+ Kept as the map shape published in the signals payload. It loses the
261
+ operators (`"a & !b"` appears under `a` and under `!b`), which is why the
262
+ detailed list carries `expression` and is what a reader should consume.
263
+ """
264
+ out: dict[str, set[str]] = {}
265
+ for bean in conditional_beans_from_sources_detailed(root, file_paths):
266
+ for token in bean.profiles:
267
+ out.setdefault(token.strip(), set()).add(bean.symbol)
188
268
  return {profile: sorted(names) for profile, names in sorted(out.items())}
189
269
 
190
270
 
@@ -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:
@@ -1,9 +1,13 @@
1
1
  """First-run telemetry notice.
2
2
 
3
- Telemetry is enabled by default (opt-out) and stays anonymous. The notice is
4
- shown exactly once, only on interactive TTYs, to inform the user that
5
- telemetry is on and how to turn it off. It does not ask a question — it
6
- informs and respects the user's right to disable.
3
+ Telemetry is **opt-in**: nothing has been collected when this notice appears, and
4
+ nothing will be until the user runs `ask telemetry enable`. The notice is shown
5
+ exactly once, only on interactive TTYs, and it is an invitation — it states what
6
+ *would* be collected if they said yes, and it never changes the setting.
7
+
8
+ The invitation is deliberately printed even though the answer is already "no":
9
+ a user who never learns the option exists cannot choose to help, and a silent
10
+ collector is the thing this release removed.
7
11
 
8
12
  Notice is written to stderr so it doesn't pollute stdout output.
9
13
  """
@@ -15,20 +19,20 @@ import sys
15
19
 
16
20
  _NOTICE = """\
17
21
  \033[2m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18
- sourcecode — anonymous telemetry is ON by default
22
+ ask — anonymous telemetry is OFF
19
23
 
20
- Anonymous usage metrics help improve sourcecode.
24
+ Nothing has been collected, and nothing will be unless you
25
+ turn it on. If you would like to help:
21
26
 
22
- Collected: tool version, Python version, OS, commands used,
23
- flags used, MCP tool names invoked, approximate repo size,
24
- execution duration, errors.
27
+ ask telemetry enable
25
28
 
26
- Never collected: source code, file paths, file names, secrets,
27
- tokens, environment variables, or any repository content.
29
+ If you do, this is collected: tool version, Python version,
30
+ OS, commands used, flags used, MCP tool names invoked,
31
+ approximate repo size, execution duration, errors.
28
32
 
29
- Disable at any time:
30
- ask telemetry disable
31
- export SOURCECODE_TELEMETRY=0 (or DO_NOT_TRACK=1)
33
+ Never collected, on or off: source code, file paths, file
34
+ names, secrets, tokens, environment variables, or any
35
+ repository content.
32
36
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
33
37
  """
34
38
 
@@ -47,10 +51,10 @@ def _is_interactive() -> bool:
47
51
 
48
52
 
49
53
  def show_first_run_notice() -> None:
50
- """Print the one-time telemetry notice on interactive terminals.
54
+ """Print the one-time telemetry invitation on interactive terminals.
51
55
 
52
56
  Never raises. Does nothing on non-interactive / CI environments.
53
- Telemetry stays enabled regardless — this only informs the user.
57
+ Telemetry stays off regardless — this only tells the user the option exists.
54
58
  """
55
59
  if not _is_interactive():
56
60
  return
@@ -0,0 +1,178 @@
1
+ """test_sources.py — one answer to "is this file a test, and does this repo have any".
2
+
3
+ Four surfaces answered this independently: `signals.has_tests` by substring
4
+ (`"/test" in path`), the `analysis_gaps` testing check by a second substring rule,
5
+ `metrics_analyzer.is_test_file` by ecosystem naming patterns, and the review-pr
6
+ coverage risk by a third. On a repository whose *production* code lives in a
7
+ package called `test` — `com.example.saint.test.PruebaCreaBD` — the substring
8
+ rules reported "2 test files" and `has_tests: true` for a repository containing
9
+ **no tests at all**, and the same document's `analysis_gaps` said otherwise.
10
+
11
+ The fact this module owns:
12
+
13
+ Which files are test sources, which roots hold them, and whether the
14
+ repository has any.
15
+
16
+ Two signals decide it, in this order:
17
+
18
+ 1. **A test source root.** `src/test/java`, `src/it/…`, `src/androidTest/…`,
19
+ `src/testFixtures/…`, or a repository-level `test/`, `tests/`, `spec/`,
20
+ `__tests__/` directory. A build tool that separates test sources says so with
21
+ a directory, and that statement outranks any name.
22
+ 2. **A naming convention**, per ecosystem — `FooTest.java`, `test_foo.py`,
23
+ `foo.spec.ts`, `foo_test.go`.
24
+
25
+ What is deliberately *not* a signal: a package or directory named `test` that
26
+ sits **inside a main source root**. `src/main/java/com/ex/test/Helper.java` is
27
+ production code, and calling it a test inflates coverage in the one direction a
28
+ reader must never be misled about.
29
+
30
+ VAI: only published, tool-neutral conventions appear here — Maven/Gradle source
31
+ layout and the standard test naming patterns of each ecosystem.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from dataclasses import dataclass, field
37
+ from pathlib import PurePosixPath
38
+
39
+ # Directory layouts that *declare* a test source set. Matched as path prefixes
40
+ # after normalization, so `module-a/src/test/java/...` counts too.
41
+ _TEST_ROOT_SEGMENTS: tuple[tuple[str, ...], ...] = (
42
+ ("src", "test"),
43
+ ("src", "it"),
44
+ ("src", "integration-test"),
45
+ ("src", "integrationTest"),
46
+ ("src", "androidTest"),
47
+ ("src", "testFixtures"),
48
+ )
49
+
50
+ # Repository-level test directories, for ecosystems with no src/main split.
51
+ _TEST_DIR_NAMES: frozenset[str] = frozenset(
52
+ {"test", "tests", "spec", "specs", "__tests__"}
53
+ )
54
+
55
+ # A directory named `test` under one of these is production code, not a test.
56
+ _MAIN_ROOT_SEGMENTS: tuple[tuple[str, ...], ...] = (
57
+ ("src", "main"),
58
+ )
59
+
60
+ # Naming conventions, per ecosystem. Anchored to avoid `testdata/`, `_tested.py`.
61
+ _TEST_NAME_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
62
+ re.compile(p) for p in (
63
+ r"(?:^|/)test_[^/]*\.py$",
64
+ r"[^/]*_test\.py$",
65
+ r"[^/]*_test\.go$",
66
+ r"[^/]*\.(spec|test)\.(js|jsx|ts|tsx|mjs|cjs)$",
67
+ r"[^/]*(Test|Tests|Spec|IT)\.(java|kt|scala)$",
68
+ r"[^/]*_test\.rb$",
69
+ r"[^/]*_spec\.rb$",
70
+ r"[^/]*_test\.dart$",
71
+ r"[^/]*(Test|test)\.(c|cpp|cc|h|hpp)$",
72
+ r"[^/]*Test\.php$",
73
+ )
74
+ )
75
+
76
+
77
+ def _norm(path: str) -> str:
78
+ return path.replace("\\", "/").lstrip("./")
79
+
80
+
81
+ def _parts(path: str) -> list[str]:
82
+ return [p for p in _norm(path).split("/") if p]
83
+
84
+
85
+ def _under_main_root(parts: list[str]) -> bool:
86
+ for i in range(len(parts) - 1):
87
+ if tuple(parts[i:i + 2]) in _MAIN_ROOT_SEGMENTS:
88
+ return True
89
+ return False
90
+
91
+
92
+ def declared_test_root(path: str) -> "str | None":
93
+ """The declared test source root containing ``path``, if any."""
94
+ parts = _parts(path)
95
+ for i in range(len(parts) - 1):
96
+ if tuple(parts[i:i + 2]) in _TEST_ROOT_SEGMENTS:
97
+ return "/".join(parts[:i + 2])
98
+ if _under_main_root(parts):
99
+ # A `test` package inside a main source root is production code.
100
+ return None
101
+ for i, part in enumerate(parts[:-1]):
102
+ if part in _TEST_DIR_NAMES:
103
+ return "/".join(parts[:i + 1])
104
+ return None
105
+
106
+
107
+ def is_test_path(path: str) -> bool:
108
+ """Whether ``path`` is a test source.
109
+
110
+ The single derivation of this fact. A file under a declared test source root
111
+ is a test whatever it is named; a file named by convention is a test wherever
112
+ it sits; a `test` package inside a main source root is neither.
113
+ """
114
+ if declared_test_root(path) is not None:
115
+ return True
116
+ normalized = _norm(path)
117
+ return any(p.search(normalized) for p in _TEST_NAME_PATTERNS)
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class SourceTestFacts:
122
+ """What a repository's file list says about its tests."""
123
+
124
+ test_files: tuple[str, ...] = ()
125
+ roots: tuple[str, ...] = ()
126
+ basis: str = ""
127
+ extensions: tuple[str, ...] = field(default=())
128
+
129
+ @property
130
+ def count(self) -> int:
131
+ return len(self.test_files)
132
+
133
+ @property
134
+ def has_tests(self) -> bool:
135
+ return bool(self.test_files)
136
+
137
+ def to_dict(self) -> dict:
138
+ return {
139
+ "test_file_count": self.count,
140
+ "test_source_roots": list(self.roots),
141
+ "has_tests": self.has_tests,
142
+ "basis": self.basis,
143
+ }
144
+
145
+
146
+ def analyze_test_sources(
147
+ file_paths: "list[str] | tuple[str, ...]",
148
+ *,
149
+ extensions: "tuple[str, ...] | None" = None,
150
+ ) -> SourceTestFacts:
151
+ """Test sources in ``file_paths``, optionally restricted to ``extensions``.
152
+
153
+ ``extensions`` scopes the question to one language (``(".java",)`` for the
154
+ backend coverage gap) without changing the rule that answers it.
155
+ """
156
+ paths = [
157
+ p for p in file_paths
158
+ if not extensions or _norm(p).endswith(extensions)
159
+ ]
160
+ tests = tuple(sorted(p for p in paths if is_test_path(p)))
161
+ roots = tuple(sorted({r for p in tests if (r := declared_test_root(p))}))
162
+ if tests:
163
+ basis = (
164
+ f"{len(tests)} file(s) under a declared test source root "
165
+ f"or matching a test naming convention"
166
+ + (f"; roots: {', '.join(roots)}" if roots else "")
167
+ )
168
+ else:
169
+ basis = (
170
+ "no declared test source root (src/test, src/it, tests/) and no file "
171
+ "matching a test naming convention"
172
+ )
173
+ return SourceTestFacts(
174
+ test_files=tests,
175
+ roots=roots,
176
+ basis=basis,
177
+ extensions=tuple(extensions or ()),
178
+ )