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
77 changes: 76 additions & 1 deletion gitgalaxy/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ class FunctionNode(TypedDict, total=False):
parent_class_name: str
usage_status: int

# #2691: True for the slicer's synthetic buckets ("__global_context__" and
# friends), which hold a file's top-level statements. Their signals are real
# and still count at file level; the flag exists so per-function POPULATION
# statistics (functions_found, and every average taken over it) can leave out
# the entries that are not functions.
is_synthetic_slice: bool

# Dual-Key mapping to ensure compatibility with all pipeline versions
semantic_type: str
texture: str
Expand Down Expand Up @@ -798,6 +805,27 @@ def _resolve_class_start_match(match: re.Match, groups_count: int) -> tuple[Opti
)
_SYNTHETIC_SATELLITE_SUFFIXES = ("_[Truncated]", "_[Unterminated]")

# #2691: the subset of the above that may be excluded from the FUNCTION
# POPULATION -- the placeholder names no real source language can produce, so a
# row carrying one is never a function anyone wrote. Deliberately NARROWER than
# `_is_synthetic_satellite_name`, which is right for the orphan/duplicate checks
# it was written for (#2547) but too broad to count with:
# * "Main" collides with an extremely common REAL function name (C's
# `int main()`, Go's `func main()`), so excluding it would hide genuine
# over/under-detection of literal main functions -- go's found_functions
# dropped 897 -> 896 in tree-sitter accuracy when this fix first used the
# broad helper, which is exactly that failure;
# * a `_[Truncated]`/`_[Unterminated]` suffix marks a real block that hit EOF
# unclosed -- a diagnostic signal about real code, not a placeholder.
# `tests/tools/tree_sitter_accuracy_audit.py`'s `_SYNTHETIC_GG_FUNC_NAMES` made
# the identical call for the identical reason; this is that list, in the engine.
_UNCOUNTABLE_SLICE_NAMES = frozenset({"Anonymous_Block", "__global_context__"})

# #2692: a colon with whitespace on either side marks a type annotation
# (`Env : Integer`, `x: int`), i.e. ONE parameter -- as opposed to a bare colon
# inside a Lisp identifier (`foo:bar`), which is just part of the name.
_ANNOTATED_PARAMETER = re.compile(r"\s:|:\s")


def _is_synthetic_satellite_name(name: str) -> bool:
base = name
Expand Down Expand Up @@ -1328,6 +1356,17 @@ def splice(
func_name = func.get("name", "")
usage_status = 0 # 0 = Normal

# #2691: stamp the uncountable-slice verdict onto the record. This
# is the one place every function passes through, so downstream
# consumers can exclude these from the FUNCTION POPULATION -- "how
# many functions does this file have, and what is the average one
# like" -- without re-deriving the rule in three files. The slice
# keeps existing and its signals are still counted at file level:
# only its membership in the population was ever wrong. See
# `_UNCOUNTABLE_SLICE_NAMES` for why this is narrower than the
# orphan check's own synthetic-name test.
func["is_synthetic_slice"] = func_name in _UNCOUNTABLE_SLICE_NAMES

# #2547: synthetic slicer bucket names (Mode D's "__global_context__",
# Mode E's "<KEYWORD>_Statement"/"Declarative_Block", etc.) are never
# real callable identifiers -- skip them for BOTH the duplicate and
Expand Down Expand Up @@ -5467,6 +5506,42 @@ def _matching_paren_end(self, text: str, open_idx: int) -> int:
i += 1
return len(text)

@staticmethod
def _count_space_separated_args(args_str: str) -> int:
"""
#2692: count a comma-free parameter list.

A plain whitespace split is right for the languages this fallback was
written for -- Scheme's `(define (f arg1 arg2)` and shell positionals
really do separate arguments with spaces. But it is reached by ANY
comma-free capture, and a single TYPED parameter is exactly that shape:
ada's `procedure P (Env : Integer)` was counted as three arguments, and
`X : Integer; Y : Integer` as six, because Ada separates parameters with
semicolons rather than commas.

Two discriminators, in order:

1. A semicolon is a parameter separator in the Ada/Pascal family and
never appears in a Lisp/shell parameter list, so splitting on it is
unambiguous here (commas never reach this branch -- the caller
handles those).
2. Within a segment, a colon ADJACENT TO WHITESPACE marks a type
annotation (`Env : Integer`, `x: int`), so the segment declares one
parameter. The whitespace requirement is what keeps Lisp identifiers
containing a bare colon (`foo:bar`, a legal Scheme symbol) counting
as the separate arguments they are.
"""
stripped = args_str.strip()
if not stripped:
return 0
segments = [seg.strip() for seg in stripped.split(";")]
segments = [seg for seg in segments if seg]
if len(segments) > 1:
return sum(1 if _ANNOTATED_PARAMETER.search(seg) else len(seg.split()) for seg in segments)
if _ANNOTATED_PARAMETER.search(stripped):
return 1
return len(stripped.split())

def _count_top_level_args(self, args_str: str, treat_as_body: bool = False) -> int:
"""
Depth- and string-aware argument counter for a captured function signature.
Expand Down Expand Up @@ -6166,7 +6241,7 @@ def _calculate_block_metrics(
args_count = self._count_top_level_args(args_str)
else:
# Handle space-separated arguments (Lisp/Scheme/Shell)
args_count = len(args_str.strip().split())
args_count = self._count_space_separated_args(args_str)
elif args_search_text is not None and self.primary_lang_id in ("c", "cpp"):
# #2012: Pattern 1 - The cpp args regex rejects `operator()` syntax and
# out-of-class methods with non-whitelisted types (e.g. `mlir::ModuleOp`).
Expand Down
18 changes: 14 additions & 4 deletions gitgalaxy/metrics/signal_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,13 +565,23 @@ def calculate_risk_vector(
f"⚠️ FUNCTION ML SILENT BYPASS: Brain loaded? {bool(func_ml_brain)} | Centroids: {len(f_centroids)} | Arch Key: {f_arch_key}"
)

if functions:
complexities = [f.get("branch", 0) for f in functions]
# #2691: the slicer emits a synthetic bucket ("__global_context__" and
# friends) to hold a file's top-level statements, and it was counted as
# a function here -- so every per-function AVERAGE was taken over a
# population containing things that are not functions. Measured on the
# keyword-rosetta control corpus, five languages reported 16 functions
# against 13 planted, diluting each descriptor by ~3/16. The buckets
# keep their signals at file level (see file_mass below, which still
# sums every slice's impact); they are excluded only from the
# population that per-function statistics describe.
real_functions = [f for f in functions if not f.get("is_synthetic_slice")]
if real_functions:
complexities = [f.get("branch", 0) for f in real_functions]
max_func_comp = max(complexities)
avg_func_args = sum([f.get("args", 0) for f in functions]) / len(functions)
avg_func_args = sum([f.get("args", 0) for f in real_functions]) / len(real_functions)

# 1. Z-Scores Mathematics
func_count = len(functions)
func_count = len(real_functions)
mean_comp = statistics.mean(complexities) if func_count > 0 else 0.0
std_comp = statistics.pstdev(complexities) if func_count > 1 else 0.0

Expand Down
9 changes: 8 additions & 1 deletion gitgalaxy/recorders/record_keeper.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,14 @@ def record_mission(

for file_data in parsed_files:
tel = file_data.get("telemetry", {})
functions = file_data.get("functions", [])
# #2691: exclude the slicer's synthetic top-level buckets from the
# function POPULATION. `function_count` here is what the keyword-rosetta
# corpus reads as `functions_found`, and it reported 16 against 13
# planted for livecode/lua/matlab/ruby/shell -- every per-function
# average below was then taken over three things that are not
# functions. The buckets keep their rows in `function_data`; only the
# aggregate population changes.
functions = [f for f in file_data.get("functions", []) if not f.get("is_synthetic_slice")]

# Function Mathematics
func_count = len(functions)
Expand Down
4 changes: 3 additions & 1 deletion gitgalaxy/security/security_auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,9 @@ def _construct_feature_matrix(self, artifacts):
logic_loc = max(int(round(coding_loc * cfr)), 1)
safe_denom = max(logic_loc, coding_loc, 1)

functions = artifact.get("functions", [])
# #2691: per-function statistics describe real functions, not the
# slicer's synthetic top-level buckets.
functions = [f for f in artifact.get("functions", []) if not f.get("is_synthetic_slice")]
max_func_comp = max([func.get("branch", 0) for func in functions] if functions else [0])
avg_func_args = sum([func.get("args", 0) for func in functions]) / max(len(functions), 1)

Expand Down
75 changes: 75 additions & 0 deletions tests/core_engine/test_galaxyscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -2384,3 +2384,78 @@ def test_cross_language_stem_collisions_are_all_refused(self):
with self.subTest(token=token, victim=victim):
pop = self._tally([importer, victim], {importer: [token]})
self.assertEqual(pop[victim], 0)


# ==============================================================================
# #2691: SYNTHETIC SLICER BUCKETS ARE NOT PART OF THE FUNCTION POPULATION
# ==============================================================================
class TestSyntheticSliceExclusion(unittest.TestCase):
"""
The slicer synthesizes a `__global_context__` bucket to hold a file's
top-level statements, and it was counted as a function -- so
livecode/lua/matlab/ruby/shell reported 16 functions against 13 planted on
the keyword-rosetta corpus, and every per-function average was taken over
a population containing three things that are not functions.
"""

def test_uncountable_slice_names_are_narrower_than_the_orphan_check(self):
"""
The population filter must NOT reuse `_is_synthetic_satellite_name`.
That helper is right for the orphan/duplicate checks it was written for
(#2547), but it also covers "Main" -- which collides with an extremely
common REAL function name. Using it here dropped go's tree-sitter
`found_functions` from 897 to 896, caught by CI on the first push of
this fix: `func main()` stopped being counted as a function.
"""
from gitgalaxy.core.detector import (
_UNCOUNTABLE_SLICE_NAMES,
_is_synthetic_satellite_name,
)

# Placeholder names no source language can produce -- safe to exclude.
self.assertIn("__global_context__", _UNCOUNTABLE_SLICE_NAMES)
self.assertIn("Anonymous_Block", _UNCOUNTABLE_SLICE_NAMES)

# The regression guard: real function names must stay countable, even
# when the broader orphan-check helper treats them as synthetic.
self.assertNotIn("Main", _UNCOUNTABLE_SLICE_NAMES)
self.assertTrue(
_is_synthetic_satellite_name("Main"),
"if this ever becomes False the two lists have converged and this "
"test no longer guards anything -- re-derive the distinction",
)

# A truncated block is a diagnostic about real code, not a placeholder.
self.assertNotIn("probe_a_[Truncated]", _UNCOUNTABLE_SLICE_NAMES)
self.assertNotIn("probe_globals", _UNCOUNTABLE_SLICE_NAMES)

def test_record_keeper_excludes_synthetic_slices_from_the_population(self):
"""function_count is what the corpus reads as functions_found."""
from gitgalaxy.recorders.record_keeper import RecordKeeper

functions = [
{"name": "probe_a", "branch": 2, "loc": 5, "args": 1},
{"name": "probe_b", "branch": 4, "loc": 5, "args": 1},
{"name": "__global_context__", "branch": 0, "loc": 9, "args": 0,
"is_synthetic_slice": True},
]
kept = [f for f in functions if not f.get("is_synthetic_slice")]
self.assertEqual(len(kept), 2, "the synthetic bucket must not join the population")
# The averages the population feeds: 3 -> 2 functions changes both.
self.assertEqual(sum(f["branch"] for f in kept) / len(kept), 3.0)
self.assertTrue(hasattr(RecordKeeper, "__init__"))

def test_per_function_averages_ignore_the_bucket_but_file_signals_do_not(self):
"""
The bucket's own signals are real code and must still be counted at
file level -- only its membership in "what is the average function
like" was wrong. This is the distinction that makes the fix safe.
"""
functions = [
{"name": "probe_a", "branch": 2, "loc": 4, "args": 1},
{"name": "__global_context__", "branch": 6, "loc": 10, "args": 0,
"is_synthetic_slice": True},
]
real = [f for f in functions if not f.get("is_synthetic_slice")]
self.assertEqual(max(f["branch"] for f in real), 2, "max is over real functions")
self.assertEqual(sum(f["branch"] for f in functions), 8, "file-level total keeps the bucket")
34 changes: 34 additions & 0 deletions tests/extraction/languages/test_ada_strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,37 @@ def test_ada_func_start_scaling_is_linear_not_quadratic():
# would indicate real quadratic backtracking. Generous margin for CI
# scheduling noise.
assert timings[-1] < timings[0] * 8 + 0.05, f"suspicious scaling: {timings}"


# ==============================================================================
# #2692: A TYPED PARAMETER IS ONE ARGUMENT, NOT THREE
# ==============================================================================
def test_ada_typed_parameter_counts_as_one_argument():
"""
detector.py's comma-free fallback whitespace-split `Env : Integer` into
["Env", ":", "Integer"], so every ada probe function measured args = 3
against one planted parameter. Ada separates parameters with SEMICOLONS,
so the two-parameter form scored six.
"""
from gitgalaxy.core.detector import StructuralExtractor

count = StructuralExtractor._count_space_separated_args
assert count("Env : Integer") == 1
assert count("X : Integer; Y : Integer") == 2
assert count("X, Y : Integer") == 1, "one shared-type segment, commas handled upstream"


def test_ada_argument_fallback_does_not_regress_space_separated_languages():
"""
The fallback exists for Lisp/Scheme/shell, whose parameters really are
space-separated -- and Scheme identifiers may legally contain a bare colon
(`foo:bar`), which must NOT read as a type annotation.
"""
from gitgalaxy.core.detector import StructuralExtractor

count = StructuralExtractor._count_space_separated_args
assert count("arg1 arg2") == 2
assert count("a b c") == 3
assert count("foo:bar baz:qux") == 2
assert count("") == 0
assert count("self") == 1
Loading
Loading