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.
- sourcecode/__init__.py +1 -1
- sourcecode/cache.py +104 -0
- sourcecode/cache_model.py +295 -0
- sourcecode/chain_rules.py +49 -8
- sourcecode/cli.py +419 -205
- sourcecode/confidence_analyzer.py +10 -6
- sourcecode/context_cache.py +7 -27
- sourcecode/deployment_prefix.py +85 -0
- sourcecode/facts/__init__.py +71 -0
- sourcecode/facts/registry.json +158 -0
- sourcecode/license.py +1 -1
- sourcecode/mcp/registry.py +1 -1
- sourcecode/mcp/server.py +3 -3
- sourcecode/metrics_analyzer.py +8 -5
- sourcecode/posture.py +78 -6
- sourcecode/prepare_context.py +51 -26
- sourcecode/reference_facts.py +307 -0
- sourcecode/ris.py +27 -1
- sourcecode/serializer.py +36 -9
- sourcecode/spring_profiles.py +93 -13
- sourcecode/telemetry/__init__.py +4 -3
- sourcecode/telemetry/config.py +31 -23
- sourcecode/telemetry/consent.py +20 -16
- sourcecode/test_sources.py +178 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/METADATA +128 -14
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/RECORD +29 -24
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/licenses/LICENSE +0 -0
sourcecode/spring_profiles.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
) -> "
|
|
160
|
-
"""`
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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:
|
sourcecode/telemetry/consent.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"""First-run telemetry notice.
|
|
2
2
|
|
|
3
|
-
Telemetry is
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
22
|
+
ask — anonymous telemetry is OFF
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
Nothing has been collected, and nothing will be unless you
|
|
25
|
+
turn it on. If you would like to help:
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
flags used, MCP tool names invoked, approximate repo size,
|
|
24
|
-
execution duration, errors.
|
|
27
|
+
ask telemetry enable
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
+
)
|