diff --git a/gitgalaxy/core/detector.py b/gitgalaxy/core/detector.py index e68c8e16e..80c836a1a 100644 --- a/gitgalaxy/core/detector.py +++ b/gitgalaxy/core/detector.py @@ -5788,7 +5788,19 @@ def _slice_by_indentation( # `safe_code` has strings/comments masked, which is perfect since we don't # want to match an arrow inside a default-value string literal or comment signature_text = safe_code[start_idx:sig_end] - if "->" not in signature_text and "⊸" not in signature_text: + # #2934 contract (docs/func_start_rule_contract.md, #2856): "no + # arrow" is too broad a test for "point-free value binding". A + # zero-arg IO action (`entry :: IO ()`, `main :: IO a`) is + # arrowless yet opens an executable block under its own name -- + # the canonical Haskell entry point -- so it must NOT be dropped + # like a pure CAF (`defaultKaTeXURL :: Text`). Skip only an + # arrowless signature whose return-type head is NOT `IO`; every + # true value binding still falls through, IO actions are kept. + if ( + "->" not in signature_text + and "⊸" not in signature_text + and not self._haskell_arrowless_signature_is_action(signature_text) + ): continue # Extract the raw payload using the ORIGINAL code to retain the exact executable payload @@ -7409,6 +7421,41 @@ def _count_shell_positional_max(self, matches: list[str]) -> int: saw_variadic = True return max_index if max_index else (1 if saw_variadic else 0) + def _haskell_arrowless_signature_is_action(self, signature_text: str) -> bool: + """ + #2934: an arrowless Haskell type signature is a point-free VALUE + binding (a CAF like `defaultKaTeXURL :: Text`) in the common case, + which #1312 correctly drops. It is NOT one when its type is a zero- + arg IO action (`entry :: IO ()`, `main :: IO a`): that action opens + an executable block under its own name (docs/func_start_rule_contract + .md, #2856) exactly as a function does, and the rest of the engine + already treats `IO ()` as a genuine zero-arrow callable (see + `_count_haskell_type_arrows` and haskell.py's `args` rule). Return + True only when the signature's OUTERMOST return-type head is `IO`, so + pure value types (`Text`, `Int`, `IORef Int`) still read as values -- + `IORef` must never be mistaken for `IO` (whole-token match, never a + prefix). Broader action monads (`ReaderT ... IO ()`, or `m ()` under + a `MonadIO` constraint) are deliberately left to a follow-up per the + issue; only a bare `IO` head is retained here. + """ + parts = signature_text.split("::", 1) + if len(parts) < 2: + return False + type_str = parts[1].strip() + # A leading `forall a b.` quantifier's `.` terminates the binder list + # (not a qualified-name dot); strip the whole clause before the head. + type_str = re.sub(r"^forall\b[^.]*\.\s*", "", type_str) + # Skip a leading typeclass-constraint clause, mirroring the LAST-top- + # level-`=>` rule `_count_haskell_type_arrows` uses for the same job. + last_constraint = type_str.rfind("=>") + if last_constraint != -1: + type_str = type_str[last_constraint + 2 :].strip() + head_match = re.match(r"\(*\s*([A-Za-z_][A-Za-z0-9_']*(?:\.[A-Za-z_][A-Za-z0-9_']*)*)", type_str) + if not head_match: + return False + head = head_match.group(1) + return head == "IO" or head.endswith(".IO") + def _count_haskell_type_arrows(self, args_str: str) -> int: """ Counts a Haskell function's curried arity from its flattened `::` diff --git a/tests/extraction/languages/test_haskell.py b/tests/extraction/languages/test_haskell.py index 558f893c1..1c6f6fccd 100644 --- a/tests/extraction/languages/test_haskell.py +++ b/tests/extraction/languages/test_haskell.py @@ -37,6 +37,10 @@ "valid": [ ("TargetFunc :: Int -> Int", "TargetFunc"), ("TargetFunc :: Maybe String", "TargetFunc"), + # #2934: an arrowless `IO ()` entry-point signature matches func_start + # at the regex level already (like `Maybe String` above) -- the drop + # was purely in the slicer guard, exercised in the #2934 block below. + ("entryPoint :: IO ()", "entryPoint"), # Valid operators? ("(+++) :: Int -> Int", "(+++)"), ("target_func :: a -> b", "target_func"), @@ -379,6 +383,39 @@ def _extract_function_names(payload: str) -> list[str]: return [f["name"] for f in functions] +# ============================================================================== +# ARROWLESS IO-ACTION ENTRY POINTS (#2934) +# ============================================================================== +# The #1312 guard used "no arrow in the signature" as its whole test for a +# point-free VALUE binding, which wrongly dropped the canonical arrowless +# entry-point shape `entry :: IO ()` (a zero-arg IO action DOES open an +# executable block under its own name -- docs/func_start_rule_contract.md, +# #2856). #2934 narrows the guard so an arrowless signature whose return-type +# head is `IO` is retained, while pure value types stay rejected. + + +def test_haskell_arrowless_io_action_entry_point_accepted(): + """#2934: `entry :: IO ()` / `main :: IO a` are zero-arg IO actions, not CAFs -- extracted.""" + assert _extract_function_names('entry :: IO ()\nentry = putStrLn "hi"\n') == ["entry"] # noqa: S101 + assert _extract_function_names("main :: IO a\nmain = undefined\n") == ["main"] # noqa: S101 + + +def test_haskell_arrowless_io_action_under_forall_and_constraint_accepted(): + """#2934: the return-type head is still `IO` after a `forall` quantifier or a `=>` constraint.""" + assert _extract_function_names("run :: forall a. IO a\nrun = undefined\n") == ["run"] # noqa: S101 + assert _extract_function_names("act :: Monad m => IO ()\nact = undefined\n") == ["act"] # noqa: S101 + + +def test_haskell_arrowless_ioref_value_binding_still_rejected(): + """#2934 guard: `IORef` must not be misread as `IO` -- `counter :: IORef Int` stays a value.""" + assert _extract_function_names("counter :: IORef Int\ncounter = undefined\n") == [] # noqa: S101 + + +def test_haskell_arrowless_non_io_action_monad_still_rejected(): + """#2934: only a bare `IO` head is retained; `ReaderT ... IO ()` (head ReaderT) is a follow-up.""" + assert _extract_function_names("runApp :: ReaderT Env IO ()\nrunApp = undefined\n") == [] # noqa: S101 + + def test_haskell_func_start_instance_method_equations_accepted(): """#1442: typeclass instance method equations (no restated `::`) must be found, and every pattern-matched clause of the same method must collapse into ONE node diff --git a/tests/tools/tree_sitter_accuracy_audit.py b/tests/tools/tree_sitter_accuracy_audit.py index 509f328ac..eedd440ea 100755 --- a/tests/tools/tree_sitter_accuracy_audit.py +++ b/tests/tools/tree_sitter_accuracy_audit.py @@ -1000,6 +1000,22 @@ def _count_haskell_signature_arrows(type_node: Optional[Any]) -> int: return 1 + _count_haskell_signature_arrows(type_node.child_by_field_name("result")) +def _haskell_signature_type_is_io_action(type_node: Optional[Any]) -> bool: + """#2934: mirrors detector.py's `_haskell_arrowless_signature_is_action` on the ground-truth + side. An arrowless signature whose (already-unwrapped) type is a bare `IO ...` application + (`IO ()`, `IO a`) is a zero-arg IO action -- a real entry point that opens an executable block + under its own name -- not a point-free value binding, so it must be kept just like an arrow + chain. tree-sitter renders `IO ()` as `apply(constructor: name "IO", argument: ...)`; walk the + constructor spine down to the head constructor (so `ReaderT Env IO ()` yields head "ReaderT", + correctly NOT retained -- broader action monads are a follow-up per the issue) and require it + to be exactly `IO`. `IORef Int` yields head "IORef" and stays a value binding. + """ + node = type_node + while node is not None and node.type == "apply": + node = node.child_by_field_name("constructor") + return node is not None and node.type == "name" and node.text == b"IO" + + def _get_node_name(node: Any) -> Optional[str]: if node.type == "bind": # #1566: only a real function -- see func_node_types' haskell entry for the full @@ -1013,7 +1029,12 @@ def _get_node_name(node: Any) -> Optional[str]: if sig is None: return None sig_type = _unwrap_haskell_signature_type(sig.child_by_field_name("type")) - if sig_type is None or sig_type.type != "function": + if sig_type is None: + return None + # #2934: an arrow chain is a function; a bare `IO ...` action is an arrowless entry point + # that GitGalaxy now also extracts -- keep both, so the ground truth doesn't book the + # newly-retained `entry :: IO ()` units as extra_functions false positives. + if sig_type.type != "function" and not _haskell_signature_type_is_io_action(sig_type): return None return name_node.text.decode("utf8")