Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions gitgalaxy/core/network_risk_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@
# segment (`ejs`, `lodash`, `node:fs`) is a package. Anchored, fixed alternatives.
_JS_ALIAS_PREFIX = re.compile(r"^(?:@/|~/|#)")

# #3596: languages whose import may name a DECLARATION (a Kotlin top-level
# function or property) that lives in a file not named after it. Declared per
# language via the "imports_may_name_declaration" flag.
DECLARATION_IMPORT_LANGS = frozenset(
lang_id for lang_id, definition in LANGUAGE_DEFINITIONS.items() if definition.get("imports_may_name_declaration")
)

# #3597: a URI scheme a language resolves as a package (`dart:io`, `package:`);
# the scheme name alone, anchored and bounded.
_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.-]{0,31}:")

# #3037/#3038: the deterministic work budget for each hop-count path metric.
# Closeness and average path length share one search; betweenness and Louvain
# modularity (#3039) each run their own.
Expand All @@ -109,6 +120,13 @@
PATH_METRICS_WORK_BUDGET = 50_000_000


def _is_test_path(path: str) -> bool:
"""A test source file, by the same structural heuristic the test-coverage
map uses (a test directory, or a test_/_test/.spec./.test. name)."""
low = "/" + path.replace("\\", "/").lower()
return any(x in low for x in ("/test/", "/tests/", "test/", "test_", "_test", ".spec.", ".test.", "tests/"))


def _without_extension(path_str: str) -> str:
"""
Drops a trailing file extension from a slash-separated token, leaving
Expand Down Expand Up @@ -153,6 +171,8 @@ def __init__(self, parent_logger: Optional[logging.Logger] = None):
# the location-based stages of _resolve_target. Rebuilt with the
# resolution map by _build_resolution_map.
self._by_norm_path: dict[str, str] = {}
# #3596: declared name -> the files declaring it (DECLARATION_IMPORT_LANGS).
self._declared_in: dict[str, list[str]] = defaultdict(list)

def _build_resolution_map(self, files: list[dict[str, Any]]) -> dict[str, list[str]]:
"""
Expand All @@ -164,11 +184,19 @@ def _build_resolution_map(self, files: list[dict[str, Any]]) -> dict[str, list[s
"""
resolution_map: dict[str, list[str]] = defaultdict(list)
self._by_norm_path = {}
self._declared_in = defaultdict(list)
for f in files:
path = f.get("path", "")
if not path:
continue
self._by_norm_path.setdefault(path.replace("\\", "/"), path)
# #3596: where each declaration lives, for languages whose import
# names a declaration rather than a file (Kotlin top-level functions).
if str(f.get("lang_id", "")).lower() in DECLARATION_IMPORT_LANGS:
for unit in (f.get("functions") or []) + (f.get("classes") or []):
name = unit.get("name") if isinstance(unit, dict) else None
if name:
self._declared_in[name].append(path)
name = f.get("name", Path(path).name)
stem = Path(path).stem

Expand Down Expand Up @@ -303,6 +331,30 @@ def _resolve_target(
if _MODULE_NAME.fullmatch(module):
return self._resolve_module_tree(module, curr_path)

# #3597: Dart `package:name/path.dart` is <name>/lib/path.dart, found as a
# path tail; any other scheme (`dart:io`) is the SDK.
scheme = src_def.get("import_package_scheme")
if scheme and _URI_SCHEME.match(target_token):
if not target_token.startswith(scheme):
return None
name, _, rest = target_token[len(scheme) :].partition("/")
return self._resolve_path_tail(f"{name}/lib/{rest}", resolution_map) if name and rest else None

# #3598: `source "$DIR/lib/x.sh"` -- only the literal path after the last
# variable names the file; `~/...` is the user's home directory.
if src_def.get("import_path_may_start_with_variable") and (target_token.startswith("~") or "$" in target_token):
return self._resolve_variable_path(target_token, curr_path, resolution_map)

# #3595: a Scala wildcard (`io.circe.syntax._`) of a package object is
# that package's package.scala; of an object, the object's file (the
# name search below, on the token without its wildcard).
package_object = src_def.get("package_object_file")
if package_object and target_token.endswith(("._", ".*")):
target_token = target_token[:-2]
owned = self._resolve_path_tail(f"{target_token.replace('.', '/')}/{package_object}", resolution_map)
if owned is not None:
return owned

# #3553/#3552: a `./`/`../` token -- and any token of a language that
# searches the importing file's directory first -- names a location.
# Try it before the name search, which drops a name that repeats
Expand Down Expand Up @@ -331,11 +383,20 @@ def _resolve_target(
if resolved is None and src_def.get("imports_may_name_member"):
parts = target_token.split(".")
for cut in range(1, min(3, len(parts) - 2) + 1):
# The owner of a member or nested class is a CLASS: `java.util.X`
# failing never makes `java.util` (a lone util.kt) its file.
if not parts[-cut - 1][:1].isupper():
continue
resolved = self._resolve_by_name(
".".join(parts[:-cut]), resolution_map, curr_path, folded_maps, fold_lang, src_lang, file_facts
)
if resolved is not None:
break
# #3596: a Kotlin top-level function/property (`a.b.asName`) lives in a
# file not named after it: the one file that declares it under a
# directory mirroring its package.
if resolved is None and src_def.get("imports_may_name_declaration"):
resolved = self._resolve_declaration(target_token, curr_path, file_facts, src_lang)
return resolved

def _resolve_by_name(
Expand Down Expand Up @@ -603,6 +664,61 @@ def _resolve_path_mirror(
return same[0]
return None

def _resolve_path_tail(self, tail: str, resolution_map: dict[str, list[str]]) -> Optional[str]:
"""The one file whose path ends with the whole `tail`, or None."""
tail = tail.strip("/")
name = tail.rsplit("/", 1)[-1]
same = [c for c in dict.fromkeys(resolution_map.get(name, ())) if self._path_ends_with(c, tail)]
return same[0] if len(same) == 1 else None

def _resolve_variable_path(
self, target_token: str, curr_path: str, resolution_map: dict[str, list[str]]
) -> Optional[str]:
"""#3598: a shell path behind a variable. What follows the last expansion's
first `/` is literal: a bare name is tried beside the importing file (the
common `$DIR` = the script's own directory), and any tail must be a whole
path tail of one file. The stem alone never decides -- `$rvm_path/scripts/
completion` is not lib/completion.bash."""
if target_token.startswith("~"):
return None
tail = target_token[target_token.rfind("$") :]
slash = tail.find("/")
if slash == -1:
return None
tail = tail[slash + 1 :].strip("/\"' ")
if not tail:
return None
if "/" not in tail:
beside = self._by_norm_path.get(posixpath.join(posixpath.dirname(curr_path.replace("\\", "/")), tail))
if beside is not None:
return beside
return self._resolve_path_tail(tail, resolution_map)

def _resolve_declaration(
self,
target_token: str,
curr_path: str,
file_facts: Optional[dict[str, tuple[str, bool]]],
src_lang: Optional[str],
) -> Optional[str]:
"""#3596: `a.b.name` -> the one file of the importer's language that
declares `name` and sits under a directory ending in a/b."""
parts = target_token.split(".")
if len(parts) < 2:
return None
package_dir = "/".join(parts[:-1])
hits = [
c
for c in dict.fromkeys(self._declared_in.get(parts[-1], ()))
if self._path_ends_with(posixpath.dirname(c.replace("\\", "/")), package_dir)
and (not file_facts or (file_facts.get(c) or ("", False))[0] == src_lang)
]
# A test's helper of the same name is not what production code imports
# (a Kotlin test `fun joinToCode()` beside an unextracted main one).
if not _is_test_path(curr_path):
hits = [c for c in hits if not _is_test_path(c)]
return hits[0] if len(hits) == 1 else None

def _resolve_literal_file(self, target_token: str, resolution_map: dict[str, list[str]]) -> Optional[str]:
"""The one file whose name is exactly the token's last segment and whose
path ends with the token (`<chrono>` -> .../chrono, `<QtCore/QString>`
Expand Down
84 changes: 75 additions & 9 deletions gitgalaxy/galaxyscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,72 @@ def extract_raw_imports(import_regex: "re.Pattern[str]", content: str, lang_def:
continue
extracted_path = next((g for g in match.groups() if g), None)
if extracted_path:
# Handle comma-separated blocks and brackets (e.g., Rust/Scala: {A, B}, Python: a, b as c)
clean_group = extracted_path.replace("{", "").replace("}", "")
for item in clean_group.split(","):
# Strip 'as alias' and whitespace to isolate the pure module name
clean_module = re.split(r"\s+as\s+", item)[0].strip()
if clean_module:
tokens.add(clean_module)
# Comma-separated lists (Python `a, b as c`) and brace selectors (Scala
# `a.b.{C, D => E}`, Rust `a::{b, c::{d}}`, PHP `A\\{B, C}`), each
# item with its `as` / `=>` alias dropped.
if _SELECTOR_GROUP.search(extracted_path):
items = _expand_import_selectors(extracted_path)
else:
items = [
re.split(r"\s+as\s+", item)[0].strip()
for item in extracted_path.replace("{", "").replace("}", "").split(",")
]
for item in items:
if item:
tokens.add(item)
return tokens


# #3595: a brace selector group opens right after an import-path separator
# (`a.b.{`, `a::{`, `A\\{`). Only then is a brace a group; `${VAR}` and an HTML
# `{{ placeholder }}` are not, and keep the plain comma split.
_SELECTOR_GROUP = re.compile(r"(?:\.|::|\\)[ \t]*\{")


def _split_top_level(text: str) -> list[str]:
"""`text` split on the commas that are not inside braces."""
parts, depth, start = [], 0, 0
for i, ch in enumerate(text):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
elif ch == "," and depth == 0:
parts.append(text[start:i])
start = i + 1
parts.append(text[start:])
return parts


def _expand_import_selectors(text: str, depth: int = 0) -> list[str]:
"""#3595: `io.circe.{ Decoder, Json }` is `io.circe.Decoder` and
`io.circe.Json` -- the prefix before a brace applies to every selector in
it. Deleting the braces and splitting on commas (the old reading) gave
`io.circe. Decoder` and a bare `Json`, neither of which resolves. Nested
groups recurse (bounded); a Rust `self` selector names the prefix itself."""
out: list[str] = []
for part in _split_top_level(text):
part = part.strip()
brace = part.find("{")
# A selector group follows a path separator (`a.b.{`, `a::{`, `A\\{`) or
# opens the item; `${VAR}` (shell, PowerShell) is an expansion, not one.
if brace == 0 or (brace > 0 and part[:brace].rstrip()[-1:] not in (".", ":", "\\")):
brace = -1
if brace == -1 or depth > 8:
leaf = re.split(r"\s+as\s+|\s*=>\s*", part)[0].replace("{", "").replace("}", "").strip()
if leaf:
out.append(leaf)
continue
prefix = re.sub(r"\s+", "", part[:brace])
inner = part[brace + 1 : part.rfind("}") if part.rfind("}") > brace else len(part)]
for item in _expand_import_selectors(inner, depth + 1):
if item == "self":
out.append(prefix.rstrip(":.\\/"))
elif item:
out.append(prefix + item)
return out


def _process_file_worker(rel_path: str) -> dict[str, Any]:
"""Processes a single file path using the worker's cached hardware modules."""

Expand Down Expand Up @@ -2308,6 +2364,14 @@ def _resolve_dependency_graph(self):
# Languages whose `.`-led tokens are local by construction: Rust's
# `./name` module declarations (#3554) and Python's relative imports,
# including the `.name` form `relative_import_groups` records.
# A path that names the current crate/module (Rust `crate::`, `self::`,
# `super::`) is local by the language's own keyword -- never a package a
# typosquat could mimic (#3595 made `crate::a::{B, C}` full paths).
local_path_prefixes = {
lid: tuple(ldef.get("local_import_prefixes") or ())
for lid, ldef in self.config.get("LANGUAGE_DEFINITIONS", {}).items()
if isinstance(ldef, dict) and ldef.get("local_import_prefixes")
}
local_module_langs = {
lid
for lid, ldef in self.config.get("LANGUAGE_DEFINITIONS", {}).items()
Expand Down Expand Up @@ -2408,8 +2472,10 @@ def _resolve_dependency_graph(self):
# (`.name`, `..pkg.mod`) is never an external package -- the
# typosquat radar read cython's `from . import Options` as a
# package mimicking `Option`.
if not matched_internal and not (
raw_import.startswith(".") and str(meta.get("lang_id", "")).lower() in local_module_langs
if (
not matched_internal
and not (raw_import.startswith(".") and str(meta.get("lang_id", "")).lower() in local_module_langs)
and not raw_import.startswith(local_path_prefixes.get(str(meta.get("lang_id", "")).lower(), ()))
):
if clean_path not in external_imports_tally:
external_imports_tally[clean_path] = []
Expand Down
7 changes: 7 additions & 0 deletions gitgalaxy/standards/language_standards/languages/dart.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
# Rationale: (CORRECTION) Like Swift and Rust, Dart officially supports nested multi-line
# comments (/* /* */ */). Standard C parsing would prematurely terminate here causing geometry failure.
"lexical_family": "standard_block",
# #3597: `package:name/path.dart` is <the name package>/lib/path.dart. A package sits
# in a directory named after it by convention, so the resolver matches the path tail
# `name/lib/path.dart`; `dart:` is the SDK. Neither ever falls to a name search.
"import_package_scheme": "package:",
# A relative URI (`import 'src/client.dart'`) names a file beside the importer; the
# same name repeats across a monorepo's packages.
"imports_resolve_from_importer_dir": True,
"rules": {
# Epic #3264: Explicitly declare the structural invocation paradigm
"calls_out": CALLS_OUT_C_STYLE_NO_ANNOTATION, # #3359: `@Name(` is an annotation (C1)
Expand Down
7 changes: 7 additions & 0 deletions gitgalaxy/standards/language_standards/languages/kotlin.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
# Rationale: (CORRECTION) While Kotlin uses // and /* */, it officially allows nested
# block comments (/* /* */ */). Using standard C parsing would cause early termination here.
"lexical_family": "standard_block",
# #3596: a Kotlin import names a declaration by PACKAGE and NAME, and the file need
# not be named after it. `a.b.Util.X` (an object member, a nested class) is Util's
# file -- the same member retry as Java -- and `a.b.asName` (a top-level function or
# property) is the file under a .../a/b/ directory that declares it
# (network_risk_sensor.py).
"imports_may_name_member": True,
"imports_may_name_declaration": True,
"rules": {
# Epic #3264: Explicitly declare the structural invocation paradigm
"calls_out": CALLS_OUT_C_STYLE_NO_ANNOTATION, # #3359: `@Name(` is an annotation (C1)
Expand Down
3 changes: 3 additions & 0 deletions gitgalaxy/standards/language_standards/languages/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
# it as `./name`, a LOCAL token, so the supply-chain firewall and the typosquat radar
# never read a module file name as an external crate.
"local_module_capture_group": 2,
# `crate::` / `self::` / `super::` paths name this crate by keyword: never an external
# package, so the typosquat radar never tallies them (#3595).
"local_import_prefixes": ("crate::", "self::", "super::"),
"rules": {
# Epic #3264: Explicitly declare the structural invocation paradigm
"calls_out": CALLS_OUT_C_STYLE,
Expand Down
8 changes: 8 additions & 0 deletions gitgalaxy/standards/language_standards/languages/scala.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
# Rationale: Scala explicitly supports nested multi-line comments (/* /* */ */),
# requiring depth-aware stripping to prevent premature termination.
"lexical_family": "recursive_block",
# #3595: a Scala import names a declaration by PACKAGE and NAME -- one file declares
# many classes (circe's Error.scala holds DecodingFailure), an object member is its
# object's file, and a wildcard of a package object (`io.circe.syntax._`) is that
# package's package.scala. See network_risk_sensor.py.
"imports_may_name_member": True,
"imports_may_name_declaration": True,
"import_path_mirrors_module_path": True,
"package_object_file": "package.scala",
"rules": {
# Epic #3264: Explicitly declare the structural invocation paradigm
"calls_out": CALLS_OUT_C_STYLE_NO_ANNOTATION, # #3359: `@Name(` is an annotation (C1)
Expand Down
4 changes: 4 additions & 0 deletions gitgalaxy/standards/language_standards/languages/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
# UPGRADED: Maps to Family 3 (Pure Hash)
# Rationale: Relies strictly on '#' for line-level Commented / Non-Executable Text; no native block delimiters.
"lexical_family": "line_exclusive",
# #3598: `source "$DIR/lib/x.sh"` -- the variable is configuration. Only the literal
# path after the last expansion names the file, and only as a whole path tail
# (network_risk_sensor.py); `~/...` is the user's home, never the repository.
"import_path_may_start_with_variable": True,
"rules": {
# Epic #3264: Explicitly declare the structural invocation paradigm
"calls_out": CALLS_OUT_UNSUPPORTED,
Expand Down
11 changes: 11 additions & 0 deletions tests/core_engine/test_extract_raw_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ def test_a_rust_mod_declaration_is_recorded_as_a_local_module():
"./lexical",
"serde::de",
}


def test_brace_selectors_distribute_their_prefix():
# #3595: `io.circe.{ Decoder, Json }` was `io.circe. Decoder` and a bare `Json`.
src = "import io.circe.{ Decoder, Json => J, _ }\nimport cats.syntax.show._\n"
assert _tokens("scala", src) == {"io.circe.Decoder", "io.circe.Json", "io.circe._", "cats.syntax.show._"}
assert _tokens("rust", "use std::{fs, io::{self, Read}};\n") == {"std::fs", "std::io", "std::io::Read"}


def test_a_shell_expansion_is_not_a_selector_group():
assert _tokens("shell", 'source "${BASH_IT}/lib/log.bash"\n') == {"$BASH_IT/lib/log.bash"}
Loading
Loading