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
49 changes: 48 additions & 1 deletion gitgalaxy/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `::`
Expand Down
37 changes: 37 additions & 0 deletions tests/extraction/languages/test_haskell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion tests/tools/tree_sitter_accuracy_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down
Loading