diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d818561b..66f2bb3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.53 (2026-08-30) +- Feature: F# `.fs`/`.fsx` files are now extracted (optional `[fsharp]` extra, ionide tree-sitter-fsharp) — modules, namespaces, record/union/class types with union cases and members, let-bound functions/values, `open` imports, and calls including pipeline application (`x |> f`, `f <| x`); `.fs` joins the `dotnet` interop family so F#→C# references can rewire across the seam. - Fix: a batch of cross-language inheritance-edge corrections (thanks @Synvoya): JavaScript `class X extends Y` now emits an `inherits` edge (#1790); PHP interfaces, enums, and traits are captured as class-like nodes with their heritage (#1791); Scala `trait` declarations become class-like nodes (#1792) and qualified `extends`/`with` bases resolve to the tail type (#1794); a qualified Kotlin supertype resolves to its tail type instead of the package head (#1793); a C# interface extending an interface is classified as `inherits`, not `implements` (#1817); and a Go interface type-set constraint no longer emits a spurious `embeds` edge (#1818). - Feature: Robot Framework `.robot`/`.resource` files are now extracted (optional `[robot]` extra) — suites, test cases, user keywords, keyword-call edges, and resource/library imports, with case/space/underscore-insensitive keyword resolution (#3192, thanks @nshiveg). - Fix: chat-template control tokens are now defanged by form (`<|…|>`, `[INST]`/`[SYSTEM]`) rather than an enumerated few, closing a prompt-injection gap for attacker-chosen tokens (e.g. `<|eot_id|>`); legitimate content is untouched (#3183, thanks @abhay-codes07). diff --git a/graphify/analyze.py b/graphify/analyze.py index 844a7c0d2..840e9d1af 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -38,7 +38,7 @@ **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")}, **{e: "ruby" for e in (".rb", ".rake")}, **{e: "swift" for e in (".swift",)}, - **{e: "dotnet" for e in (".cs",)}, + **{e: "dotnet" for e in (".cs", ".fs", ".fsx")}, **{e: "php" for e in (".php",)}, **{e: "r" for e in (".r",)}, } diff --git a/graphify/build.py b/graphify/build.py index bb03fe1f5..2bd493512 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -78,7 +78,7 @@ def _is_ast_tier(item: dict) -> bool: ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".hpp": "c", ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", - ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".fs": "cs", ".fsx": "cs", ".swift": "swift", ".lua": "lua", } diff --git a/graphify/detect.py b/graphify/detect.py index 1adad00bb..87b64f9c8 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -41,7 +41,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.fs', '.fsx', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d71..24234e6d2 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -48,6 +48,7 @@ from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.commonlisp import extract_commonlisp # noqa: F401 from graphify.extractors.markdown import extract_markdown, _MD_LINK_INDEX_CACHE # noqa: F401 +from graphify.extractors.fsharp import extract_fsharp # noqa: F401 from graphify.extractors.ocaml import extract_ocaml # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 @@ -2324,6 +2325,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php", ".php5": "php", ".php7": "php", ".phps": "php", ".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet", + ".fs": "dotnet", ".fsx": "dotnet", ".lua": "lua", ".luau": "lua", ".zig": "zig", ".ex": "elixir", ".exs": "elixir", @@ -5422,6 +5424,8 @@ def add_existing_edge(edge: dict) -> None: ".svelte": extract_svelte, ".astro": extract_astro, ".dart": extract_dart, + ".fs": extract_fsharp, + ".fsx": extract_fsharp, ".ml": extract_ocaml, ".mli": extract_ocaml, ".lisp": extract_commonlisp, @@ -5482,6 +5486,8 @@ def add_existing_edge(edge: dict) -> None: ".hcl": "terraform", ".dm": "dm", ".dme": "dm", + ".fs": "fsharp", + ".fsx": "fsharp", ".ml": "ocaml", ".mli": "ocaml", ".lisp": "commonlisp", @@ -5597,6 +5603,88 @@ def _is_cpp_header(path: Path) -> bool: return any(marker in head for marker in _CPP_HEADER_MARKERS) + +def _looks_like_fsharp_source(path: Path) -> bool: + """Distinguish F# from the other users of the .fs extension (GLSL fragment + shaders, Forth). Evidence-tiered per the round-4 cross-examination: + + - comment-only lines are ignored entirely (`// type of light` in a shader + must not read as F#; `// uniform distribution` in real F# must not read + as GLSL — both happened); + - unmistakable GLSL line-starts are STRONG negative evidence; + - F# declaration line-starts are STRONG positive evidence and override + everything weaker (`float count / float total` is a real F# expression + line in a real corpus file — bare type keywords are only WEAK evidence); + - weak GLSL evidence (float/int/bool/vecN declaration-shaped line-starts) + rejects only when no strong F# evidence exists anywhere in the window; + - the window is 64 KB so a long license header cannot starve the F# pass. + """ + try: + with open(path, "rb") as fh: + head = fh.read(65536) # bounded read — not read_bytes()[:n], + # which slurps the whole file before slicing + except OSError: + return True # unreadable: let the extractor report the real error + # Windows-authored F# commonly leads with a UTF-8 BOM; without stripping it + # the first line's marker (usually `module`/`namespace`) never matches. + if head.startswith(b"\xef\xbb\xbf"): + head = head[3:] + + # STRONG = shapes the F# grammar cannot parse at a line start. Only the + # preprocessor directives qualify: `#version`/`#extension` are not F# + # compiler directives and tree-sitter-fsharp rejects those lines outright. + # Every word-shaped marker audited (uniform/varying/layout in rounds 9-10; + # gl_/in vecN/out vecN/void main closing the class here) parses as valid + # F# at a line start — `gl_ctx.MakeCurrent ctx`, verbose `let x = 1.0` + # + `in vec2 x x`, and `out`/`void` as plain identifiers — so keeping any + # of them strong drops real F# files whole. They live in the WEAK tier, + # which never overrides a strong F# declaration. + strong_glsl = (b"#version", b"#extension") + # No `(*` marker: Forth stack-effect comments start with it too, and any + # compilable F# file surfaces a real declaration line within the 64KB + # window regardless of leading block-comment headers (bot round-8 find). + strong_fsharp = (b"let ", b"module ", b"namespace ", b"open ", b"type ", + b"member ", b"#light", b"#load", b"#r ", b"[<") + weak_glsl = (b"float ", b"int ", b"bool ", b"vec2", b"vec3", b"vec4", + b"mat3", b"mat4", b"sampler2D", + b"uniform ", b"varying ", b"precision ", b"layout", + b"in vec", b"out vec", b"gl_", b"void main") + + saw_strong_glsl = saw_strong_fsharp = saw_weak_glsl = False + in_block_comment = False + for raw in head.splitlines(): + line = raw.lstrip() + if in_block_comment: + if b"*/" in line: + in_block_comment = False + continue + if line.startswith(b"//"): + continue + if line.startswith(b"/*"): + if b"*/" not in line: + in_block_comment = True + continue + if not line: + continue + if any(line.startswith(m) for m in strong_glsl): + saw_strong_glsl = True + elif any(line.startswith(m) for m in strong_fsharp): + saw_strong_fsharp = True + elif any(line.startswith(m) for m in weak_glsl): + saw_weak_glsl = True + + if saw_strong_glsl: + # A strong F# declaration can only be shader-glue coincidence when + # strong GLSL directives are present; GLSL wins (a .fs shader is far + # likelier than an F# file whose lines start with `uniform `). + return False + if saw_strong_fsharp: + return True + if saw_weak_glsl: + return False + return False + + def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" if path.name.lower().endswith(".blade.php"): @@ -5632,6 +5720,9 @@ def _get_extractor(path: Path) -> Any | None: # mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc. if suffix == ".m" and not _is_objc_source(path): return None + # `.fs` is F# OR a GLSL fragment shader (or Forth). Only route plausible F#. + if suffix == ".fs" and not _looks_like_fsharp_source(path): + return None # Extensionless files: resolve by shebang, mirroring detect.classify_file. # Without this, detect labels e.g. `#!/usr/bin/env bash` CLIs as code but # extraction returns no extractor and the file silently contributes nothing. @@ -6691,7 +6782,18 @@ def _learn(e: dict) -> None: # references edges left on shadow stubs, disambiguating same-named types by the # referencing file's `using` directives + enclosing namespace (mirrors Java #1318). _DOTNET_TYPE_EXTS = {".cs", ".razor", ".cshtml"} + # The imports repoint reads language-agnostic edge metadata (target_fqn / + # using_kind) and canonical namespace nodes, so it must also run for a + # pure-F# corpus — F# `open` edges carry the same contract (#3221 round 4; + # gated on .cs alone, every F#-only repo silently dropped its import edges + # in build's dangling-edge prune). The TYPE-reference resolver stays gated + # on C# sources: its index and metadata contract (metadata.namespace, + # scope_chain, ref_token) are C#-shaped, and F# nodes do not provide them + # yet — generalizing it is the follow-up that would also resolve + # cross-language constructor calls. + _DOTNET_IMPORT_EXTS = _DOTNET_TYPE_EXTS | {".fs", ".fsx"} cs_paths = [p for p in paths if p.suffix.lower() in _DOTNET_TYPE_EXTS] + dotnet_paths = [p for p in paths if p.suffix.lower() in _DOTNET_IMPORT_EXTS] if cs_paths: cs_results = [r for r, p in zip(per_file, paths) if p.suffix.lower() in _DOTNET_TYPE_EXTS] try: @@ -6699,11 +6801,13 @@ def _learn(e: dict) -> None: except Exception as exc: import logging logging.getLogger(__name__).warning("C# type-reference resolution failed, skipping: %s", exc) + if dotnet_paths: + dotnet_results = [r for r, p in zip(per_file, paths) if p.suffix.lower() in _DOTNET_IMPORT_EXTS] try: - _resolve_cross_file_csharp_imports(cs_results, cs_paths, all_nodes, all_edges) + _resolve_cross_file_csharp_imports(dotnet_results, dotnet_paths, all_nodes, all_edges) except Exception as exc: import logging - logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc) + logging.getLogger(__name__).warning(".NET cross-file import resolution failed, skipping: %s", exc) # Cross-file Bash source-backed call resolution: a call to a function defined # in a file this one `source`s is left unresolved by the per-file extractor diff --git a/graphify/extractors/fsharp.py b/graphify/extractors/fsharp.py new file mode 100644 index 000000000..c0ef437a7 --- /dev/null +++ b/graphify/extractors/fsharp.py @@ -0,0 +1,669 @@ +"""F# extractor (own module, optional tree-sitter-fsharp dependency). + +Handles implementation files (.fs) and scripts (.fsx) via ionide's +tree-sitter-fsharp ``language()`` grammar, which covers both. Signature files +(.fsi, ``language_signature()``) are deliberately not wired yet. + +F# is ML-family, so this module follows graphify/extractors/ocaml.py for the +resolution discipline: sourceless ref stubs for cross-file targets (#1402), a +local-definition table with ambiguity tracking, and two-pass call resolution +so forward references (``let rec ... and ...`` — every ``and``-joined head is +minted) resolve. + +.NET-family conventions (so F# joins the same corpus passes as C#): + +* **Namespaces are canonical** (``csharp_namespace:`` via + engine._csharp_namespace_id, ``type: "namespace"``): N files declaring one + namespace merge into one hub, and namespace segments never qualify local + call binding — the corpus shares them. +* **Ids chain from the container** (C#'s ``_make_id(parent_nid, name)`` + pattern) with a kind tag where kinds can collide: the companion-module idiom + (``type Config`` + ``module Config``) yields two nodes, sibling modules' + same-named ``run`` bindings stay distinct, and a member's id hangs off its + owning type. +* **Labels follow the family's shape conventions**: members ``.Name()``, + let-bound functions ``name()`` (both excluded from the unique-stub + type-rewire by `_is_type_like_definition`'s ``)``/leading-``.`` rules — + ``_node_label_key`` strips punctuation, so cross-file matching still works); + plain values stay bare. +* **`open` mirrors `using`**: an ``imports`` edge from the FILE node to + ``_make_id(full_fqn)`` with ``target_fqn`` metadata, EXTRACTED, no minted + node — not a last-segment stub that could rewire onto an unrelated class. +* **Heritage is emitted**: ``inherit Base()`` → INFERRED ``inherits`` and + ``interface I with`` → INFERRED ``implements`` edges to sourceless stubs, + so the supertype guard in the corpus rewire can protect F# base types. + +F#-specific handling, grounded in live AST probes of the grammar: + +* A generic ``type_name`` carries ``type_arguments`` siblings — the type's + own name is the ``long_identifier``/``identifier`` child only, never the + subtree's last identifier (that is the last type parameter, or a constraint + type such as ``IDisposable``). +* ``type X with`` (type_extension) AUGMENTS a possibly-foreign type: members + attach to a sourceless stub of X, and no sourced type node is minted — a + sourced one would let an extension file impersonate the BCL type it extends. +* Object expressions (``{ new IFoo with ... }``) are anonymous: their member + bodies' calls attribute to the enclosing binding, no member node is minted, + and an INFERRED ``references`` edge points at the interface stub. +* Active patterns (``let (|Even|Odd|) n``) and operator definitions + (``let (+.) a b``) mint nodes labelled with their delimited spelling; their + bodies attribute to them, not to the enclosing module. +* ``member val`` auto-properties put ``property_or_ident`` directly under + ``member_defn`` (no ``method_or_prop_defn`` wrapper) and are still emitted. +* Callees may be dotted (``dot_expression``); pipes (``|>``/``<|`` families) + carry callees on the operand side, with comment nodes filtered before + operand counting; enum members live under ``enum_type_cases``. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id, _read_text +from graphify.extractors.engine import _csharp_namespace_id +from graphify.security import sanitize_metadata + +# *_type_defn wrappers under type_definition, per the grammar. type_extension +# is handled by its own branch (it augments an existing type) and is therefore +# not in this set. +_TYPE_DEFN_KINDS = frozenset({ + "record_type_defn", "union_type_defn", "interface_type_defn", + "enum_type_defn", "type_abbrev_defn", + "type_declaration", "delegate_type_defn", "anon_type_defn", +}) + +# Pipe operators whose non-function operand is data, not a callee. +_PIPE_RIGHT = frozenset({"|>", "||>", "|||>"}) # callee on the right +_PIPE_LEFT = frozenset({"<|", "<||", "<|||"}) # callee on the left +# Composition: BOTH operands are callees (direction only swaps application +# order). Deliberately not generalized to custom operators — Kleisli (>=>) +# happens to compose functions but bind (>>=) has a data operand; there is no +# sound generic rule (round-4 panel, grammar-coverage arm). +_COMPOSE = frozenset({">>", "<<"}) + +_COMMENT_TYPES = frozenset({"line_comment", "block_comment", "xml_doc"}) + +# Node types that can carry a callee name in an application/pipe position. +_CALLEE_TYPES = frozenset({"long_identifier_or_op", "dot_expression"}) + + +def extract_fsharp(path: Path) -> dict: + """Extract modules, namespaces, types, union/enum cases, members, let-bound + functions/values, operators, active patterns, ``open`` imports, heritage + (inherits/implements), and calls (application + pipeline) from an F# file.""" + try: + import tree_sitter_fsharp as tsfsharp + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-fsharp not installed"} + + try: + source = path.read_bytes() + except OSError as e: + return {"nodes": [], "edges": [], "error": f"cannot read {path}: {e}"} + + try: + parser = Parser(Language(tsfsharp.language())) + root = parser.parse(source).root_node + except Exception as e: # pragma: no cover - grammar load failure + return {"nodes": [], "edges": [], "error": f"failed to load: {e}"} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + node_labels: dict[str, str] = {} + + local_defs: dict[str, str] = {} + ambiguous: set[str] = set() + # Names a qualified call `M.f` may resolve THROUGH to a local `f`: modules + # and types DEFINED here. Never namespace segments (corpus-shared). + local_containers: set[str] = set() + # container label -> {member name -> nid}: a QUALIFIED call `Q.f` may bind + # locally only when f is a member of Q ITSELF — `B.helper` must not bind to + # A's helper just because B is also a local container (bot round-6 find). + # A label claimed by two containers is ambiguous and resolves nothing. + container_members: dict[str, dict[str, str]] = {} + ambiguous_containers: set[str] = set() + # (caller_nid, callee_name, qualifier_root_or_None, full_path_text, line) + call_sites: list[tuple[str, str, str | None, str, int]] = [] + + def add_node(nid: str, label: str, line: int, **extra) -> None: + node_labels.setdefault(nid, label) + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + **extra, + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", weight: float = 1.0, + metadata: dict | None = None) -> None: + edge = { + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + } + if metadata: + edge["metadata"] = sanitize_metadata(metadata) + edges.append(edge) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def ref_stub(name: str) -> str: + """Sourceless stub for a cross-file target; the corpus rewire collapses + it onto the unique real definition (#1402).""" + nid = _make_id(name) + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": name, + "file_type": "code", + "source_file": "", + "source_location": "", + "origin_file": str_path, + }) + return nid + + def line_of(node) -> int: + return node.start_point[0] + 1 + + def register_container(label: str) -> None: + if label in container_members: + ambiguous_containers.add(label) + else: + container_members[label] = {} + local_containers.add(label) + + def register_member(container_nid: str, name: str, nid: str) -> None: + clabel = node_labels.get(container_nid, "") + if clabel and clabel not in ambiguous_containers: + container_members.setdefault(clabel, {})[name] = nid + + def register_def(name: str, nid: str) -> None: + if name in ambiguous: + return + if name in local_defs and local_defs[name] != nid: + ambiguous.add(name) + local_defs.pop(name, None) + return + local_defs[name] = nid + + def identifiers_of(node) -> list[str]: + """All `identifier` leaf texts under an identifier-ish node, in source + order. Works for long_identifier_or_op and dot_expression alike.""" + out: list[str] = [] + + def rec(n) -> None: + if n.type == "identifier": + out.append(_read_text(n, source)) + return + for c in n.children: + rec(c) + + rec(node) + return out + + def first_child(node, *types): + for c in node.children: + if c.type in types: + return c + return None + + def find_all(node, node_type: str) -> list: + """Matches of node_type, NOT descending into a match — intentional: + for destructuring, inner patterns nest beside identifier_pattern (under + paren_pattern/repeat_pattern), never inside one, so `let (a, (b, c))` + yields all three (pinned by test_nested_destructuring_let).""" + found: list = [] + + def rec(n) -> None: + if n.type == node_type: + found.append(n) + return + for c in n.children: + rec(c) + + rec(node) + return found + + def type_ref_parts(node) -> list[str]: + """Dotted name of a type REFERENCE (heritage clause, interface impl, + object expression). `Base<'T>` wraps in generic_type whose subtree also + holds the type parameters — read only the long_identifier child, the + same rule the round-3 type_name fix established for definitions.""" + if node is None: + return [] + if node.type in ("generic_type", "simple_type", "long_identifier_or_op"): + inner = first_child(node, "long_identifier", "identifier") + if inner is not None: + return identifiers_of(inner) + return identifiers_of(node) + + def type_name_parts(defn) -> tuple[list[str], int]: + """The type's OWN dotted name. A generic `type_name` carries + `type_arguments` (and `when` constraints) as siblings of the name — + taking the subtree's last identifier yields the last type parameter, + or a constraint type like IDisposable. Read only the name child.""" + tn = first_child(defn, "type_name") + if tn is None: + return [], line_of(defn) + name_node = first_child(tn, "long_identifier", "identifier") + if name_node is None: + return [], line_of(tn) + return identifiers_of(name_node), line_of(tn) + + def emit_cases(defn, type_nid: str) -> None: + """DU cases (union_type_cases) and enum members (enum_type_cases), + id-scoped under their owning type.""" + for wrapper in ("union_type_cases", "enum_type_cases"): + cases = first_child(defn, wrapper) + if cases is None: + continue + for case in cases.children: + if case.type not in ("union_type_case", "enum_type_case"): + continue + ident = first_child(case, "identifier") + if ident is None: + continue + cname = _read_text(ident, source) + cnid = _make_id(type_nid, cname) + add_node(cnid, cname, line_of(case)) + add_edge(type_nid, cnid, "contains", line_of(case)) + register_def(cname, cnid) + register_member(type_nid, cname, cnid) + + def emit_member(member_defn, type_nid: str) -> str | None: + """member this.Run() / static member Default / member val Name. + Id hangs off the OWNING TYPE's node id (C#'s convention); label uses + the dotnet `.Name()` shape so the rewire treats it as a method.""" + mp = first_child(member_defn, "method_or_prop_defn", "member_signature") + # `member val Name = ...` puts property_or_ident directly under + # member_defn, with no method_or_prop_defn wrapper. + poi = (first_child(mp, "property_or_ident", "identifier") if mp is not None + else first_child(member_defn, "property_or_ident")) + if poi is None: + return None + parts = identifiers_of(poi) + if not parts: + return None + mname = parts[-1] + line = line_of(member_defn) + mnid = _make_id(type_nid, "mem", mname) + add_node(mnid, f".{mname}()", line) + add_edge(type_nid, mnid, "contains", line) + register_def(mname, mnid) + register_member(type_nid, mname, mnid) + return mnid + + def emit_heritage(defn, type_nid: str) -> None: + """`inherit Base(...)` → inherits; `interface I with` → implements. + INFERRED edges to sourceless stubs: the target is defined elsewhere, + and the stub is what lets the corpus rewire (and its supertype guard) + bind it to the real definition.""" + for decl in defn.children: + if decl.type == "class_inherits_decl": + st = first_child(decl, "simple_type", "generic_type", + "long_identifier") + parts = type_ref_parts(st) + if parts: + add_edge(type_nid, ref_stub(parts[-1]), "inherits", + line_of(decl), confidence="INFERRED") + + def bound_value_names(head) -> list[tuple[str, int]]: + """Names bound by a value_declaration_left, with their lines. + + - `let x = ...` / `let f (a: A) : T = ...`: the FIRST + long_identifier_or_op under the direct identifier_pattern (never the + subtree's last identifier — that is a type annotation). + - `let (a, b) = ...`: one name per identifier_pattern inside the + paren_pattern. + """ + ip = first_child(head, "identifier_pattern") + if ip is not None: + lio = first_child(ip, "long_identifier_or_op") + if lio is not None: + parts = identifiers_of(lio) + if parts: + return [(parts[-1], line_of(ip))] + return [] + pp = first_child(head, "paren_pattern") + if pp is not None: + out: list[tuple[str, int]] = [] + for sub in find_all(pp, "identifier_pattern"): + lio = first_child(sub, "long_identifier_or_op") + if lio is not None: + parts = identifiers_of(lio) + if parts: + out.append((parts[-1], line_of(sub))) + return out + return [] + + def mint_binding_head(head, container_nid: str) -> str | None: + """Mint definition node(s) for one binding head; returns the nid to + attribute the following body's calls to. + + Ids chain from the container (same-named `run` in two sibling modules + stays two nodes). Functions get `name()` labels — engine languages do + the same (function_label_parens), and `_is_type_like_definition` + excludes `)`-labelled nodes from the unique-stub TYPE rewire, so a + Python `parse()` reference can't bind onto an F# `parse` function. + Plain values stay bare-labelled.""" + minted: str | None = None + if head.type == "function_declaration_left": + ident = first_child(head, "identifier") + if ident is not None: + name = _read_text(ident, source) + line = line_of(head) + nid = _make_id(container_nid, name) + add_node(nid, f"{name}()", line) + add_edge(container_nid, nid, + "defines" if container_nid == file_nid else "contains", line) + register_def(name, nid) + register_member(container_nid, name, nid) + return nid + # Active pattern `(|Even|Odd|)`: mint one node labelled with the + # full delimited spelling; each case name resolves to it. + ap = first_child(head, "active_pattern") + if ap is not None: + case_names = [_read_text(c, source) for c in ap.children + if c.type == "active_pattern_op_name"] + if case_names: + label = _read_text(ap, source) # keeps `|_|` in partials + line = line_of(head) + nid = _make_id(container_nid, "ap", *case_names) + add_node(nid, label, line) + add_edge(container_nid, nid, + "defines" if container_nid == file_nid else "contains", + line) + for cn in case_names: + register_def(cn, nid) + return nid + # Operator `(+.)`: label is the delimited spelling (ends in `)`, + # so it is excluded from the type-like rewire by construction). + op = first_child(head, "op_identifier") + if op is not None: + op_text = _read_text(op, source) + line = line_of(head) + nid = _make_id(container_nid, "op", op_text) + add_node(nid, op_text, line) + add_edge(container_nid, nid, + "defines" if container_nid == file_nid else "contains", line) + # no register_def: nothing resolves calls by operator spelling + # (non-pipe operator invocations are deliberately unrecorded) + return nid + return None + # value_declaration_left + for name, line in bound_value_names(head): + nid = _make_id(container_nid, name) + add_node(nid, name, line) + add_edge(container_nid, nid, + "defines" if container_nid == file_nid else "contains", line) + register_def(name, nid) + register_member(container_nid, name, nid) + minted = nid + return minted + + def record_call(callee_node, caller: str) -> None: + parts = identifiers_of(callee_node) + if not parts: + return + callee = parts[-1] + qualifier = parts[0] if len(parts) > 1 else None + call_sites.append((caller, callee, qualifier, + ".".join(parts), line_of(callee_node))) + + def walk(node, container_nid: str, enclosing_value: str) -> None: + t = node.type + + if t == "import_decl": # open X.Y — mirror C#'s `using` (#3221 r3): + # an EXTRACTED `imports` edge from the FILE node to the full-FQN + # id, no minted node. A last-segment stub would let `open + # System.Text` rewire onto any unrelated class named `Text`. + li = first_child(node, "long_identifier") + if li is not None: + parts = identifiers_of(li) + if parts: + fqn = ".".join(parts) + add_edge(file_nid, _make_id(fqn), "imports", line_of(node), + metadata={"using_kind": "namespace", + "target_fqn": fqn, + "scope_kind": "file"}) + return + + if t == "namespace": + name_node = first_child(node, "long_identifier", "identifier") + parts = identifiers_of(name_node) if name_node is not None else [] + if parts: + ns_label = ".".join(parts) + line = line_of(node) + ns_nid = _csharp_namespace_id(ns_label) + add_node(ns_nid, ns_label, line, type="namespace", + metadata={"kind": "csharp_namespace"}) + add_edge(file_nid, ns_nid, "contains", line) + for child in node.children: + walk(child, ns_nid, enclosing_value) + return + + if t in ("named_module", "module_defn"): + name_node = first_child(node, "long_identifier", "identifier") + parts = identifiers_of(name_node) if name_node is not None else [] + if parts: + mname = parts[-1] + line = line_of(node) + mnid = _make_id(container_nid, "m", mname) + add_node(mnid, mname, line) + add_edge(container_nid, mnid, + "defines" if container_nid == file_nid else "contains", line) + register_def(mname, mnid) + register_container(mname) + for child in node.children: + walk(child, mnid, enclosing_value) + return + + if t == "type_definition": + for defn in node.children: + if defn.type == "type_extension": + # `type X with ...` AUGMENTS an existing (often foreign) + # type. Minting a sourced X here would let this file + # impersonate the real definition in the unique-stub + # rewire (verified: a C# `class Foo : Widget` rewired its + # inherits edge onto an extension file). Members attach to + # a sourceless stub instead. + parts, line = type_name_parts(defn) + if not parts: + continue + owner = ref_stub(parts[-1]) + for child in defn.children: + if child.type == "type_extension_elements": + for el in child.children: + if el.type == "member_defn": + mnid = emit_member(el, owner) + for sub in el.children: + walk(sub, container_nid, mnid or enclosing_value) + else: + walk(el, container_nid, enclosing_value) + continue + if defn.type not in _TYPE_DEFN_KINDS: + continue + parts, line = type_name_parts(defn) + if not parts: + continue + tname = parts[-1] + tnid = _make_id(container_nid, "t", tname) + add_node(tnid, tname, line) + add_edge(container_nid, tnid, + "defines" if container_nid == file_nid else "contains", line) + register_def(tname, tnid) + register_container(tname) + emit_cases(defn, tnid) + emit_heritage(defn, tnid) + for child in defn.children: + if child.type == "type_extension_elements": + for el in child.children: + if el.type == "member_defn": + vd = first_child(el, "value_declaration") + if vd is not None: + # `static let build x = ...`: a binding in + # member clothing; mint it under the type + # (empty scope lets mint_binding_head run). + for sub in vd.children: + walk(sub, tnid, "") + continue + mnid = emit_member(el, tnid) + for sub in el.children: + walk(sub, tnid, mnid or tnid) + elif el.type == "interface_implementation": + st = first_child(el, "simple_type", "generic_type", + "long_identifier") + iparts = type_ref_parts(st) + if iparts: + add_edge(tnid, ref_stub(iparts[-1]), + "implements", line_of(el), + confidence="INFERRED") + for imember in el.children: + if imember.type == "member_defn": + mnid = emit_member(imember, tnid) + for sub in imember.children: + walk(sub, tnid, mnid or tnid) + else: + walk(el, tnid, enclosing_value) + elif child.type == "class_inherits_decl": + # heritage edge came from emit_heritage; the ctor + # arguments still carry calls (`inherit Base(mkArg ())`) + for sub in child.children: + if sub.type not in ("simple_type", "generic_type", + "long_identifier"): + walk(sub, tnid, enclosing_value) + elif child.type not in ("type_name", "union_type_cases", + "enum_type_cases"): + walk(child, tnid, enclosing_value) + return + + if t == "exception_definition": + li = first_child(node, "long_identifier", "identifier") + parts = identifiers_of(li) if li is not None else [] + if parts: + ename = parts[-1] + line = line_of(node) + enid = _make_id(container_nid, "e", ename) + add_node(enid, ename, line) + add_edge(container_nid, enid, + "defines" if container_nid == file_nid else "contains", line) + register_def(ename, enid) + return + + if t == "object_expression": + # `{ new IFoo with member ... }` is ANONYMOUS: minting its members + # as container members fabricates ownership, merges same-named + # implementations, and poisons local_defs (a later real `Go` + # binding turns ambiguous). Attribute member-body calls to the + # enclosing binding; reference the interface as a stub. + st = first_child(node, "long_identifier_or_op", "generic_type", + "simple_type", "long_identifier") + iparts = type_ref_parts(st) + if iparts: + add_edge(enclosing_value or container_nid, ref_stub(iparts[-1]), + "references", line_of(node), confidence="INFERRED") + for child in node.children: + if child.type == "member_defn": + for sub in child.children: + walk(sub, container_nid, enclosing_value) + else: + walk(child, container_nid, enclosing_value) + return + + if t == "member_defn": + vd = first_child(node, "value_declaration") + if vd is not None: + for sub in vd.children: + walk(sub, container_nid, "") + return + # A member outside type_extension_elements (type augmentation). + mnid = emit_member(node, container_nid) + for child in node.children: + walk(child, container_nid, mnid or enclosing_value) + return + + if t == "function_or_value_defn": + # `let rec f ... and g ...` packs EVERY and-joined head into this + # one node, heads and bodies interleaved in source order: each + # head (re)binds the attribution scope for the body that follows. + current_scope = enclosing_value + for child in node.children: + if child.type in ("function_declaration_left", + "value_declaration_left"): + if not enclosing_value: + minted = mint_binding_head(child, container_nid) + if minted: + current_scope = minted + continue # argument patterns carry no call sites + walk(child, container_nid, current_scope) + return + + if t == "application_expression": + fn = node.named_children[0] if node.named_children else None + if fn is not None and fn.type in _CALLEE_TYPES: + record_call(fn, enclosing_value or container_nid) + # Fall through: arguments may contain further applications. + + if t == "infix_expression": + op = first_child(node, "infix_op") + operands = [c for c in node.named_children + if c.type != "infix_op" and c.type not in _COMMENT_TYPES] + if op is not None and len(operands) == 2: + op_text = _read_text(op, source) + target = None + if op_text in _PIPE_RIGHT and operands[1].type in _CALLEE_TYPES: + target = operands[1] + elif op_text in _PIPE_LEFT and operands[0].type in _CALLEE_TYPES: + target = operands[0] + elif op_text in _COMPOSE: + for opnd in operands: + if opnd.type in _CALLEE_TYPES: + record_call(opnd, enclosing_value or container_nid) + if target is not None: + record_call(target, enclosing_value or container_nid) + # Fall through: both operands need walking (nested pipes, args). + + for child in node.children: + walk(child, container_nid, enclosing_value) + + walk(root, file_nid, "") + + for caller, callee, qualifier, full_path, line in call_sites: + if qualifier is not None: + members = (container_members.get(qualifier) + if qualifier not in ambiguous_containers else None) + if members is not None and callee in members: + add_edge(caller, members[callee], "calls", line) + elif qualifier in local_containers and callee in local_defs: + # qualifier is local but does not own this name (or is + # ambiguous): a full-path stub keeps it distinct — binding to + # the same-named member of a DIFFERENT container is the false + # EXTRACTED edge this branch exists to prevent. + add_edge(caller, ref_stub(full_path), "calls", line, + confidence="INFERRED") + elif callee in local_defs: + add_edge(caller, ref_stub(full_path), "calls", line, + confidence="INFERRED") + else: + add_edge(caller, ref_stub(callee), "calls", line, + confidence="INFERRED") + elif callee in local_defs: + add_edge(caller, local_defs[callee], "calls", line) + else: + add_edge(caller, ref_stub(callee), "calls", line, confidence="INFERRED") + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index 7fd891dc8..e3a3e363e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,11 +94,13 @@ ocaml = ["tree-sitter-ocaml"] # tree-sitter-commonlisp ships prebuilt abi3 wheels for every platform; optional # because Common Lisp is a niche corpus language. commonlisp = ["tree-sitter-commonlisp"] +# tree-sitter-fsharp (ionide) ships prebuilt abi3 wheels for every platform. +fsharp = ["tree-sitter-fsharp"] # The official Robot Framework parser (robot.api) - pure Python, wheels for # every platform, no C toolchain; optional because Robot Framework corpora are # QA-automation specific. robot = ["robotframework>=4.0"] -all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "robotframework>=4.0"] +all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "tree-sitter-fsharp", "robotframework>=4.0"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/fsharp_coverage_oracle.py b/tests/fsharp_coverage_oracle.py new file mode 100644 index 000000000..e227151f2 --- /dev/null +++ b/tests/fsharp_coverage_oracle.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Coverage oracle for the F# extractor. + +Run directly (not pytest-collected): [OK ] let function +[OK ] let value +[OK ] annotated let +[OK ] annotated single-arg let +[OK ] destructuring let +[OK ] let rec and +[OK ] nested let scoping +[OK ] active pattern total +[OK ] active pattern partial +[OK ] operator def +[OK ] record type +[OK ] union type + cases +[OK ] enum members +[OK ] generic type +[OK ] constrained generic +[OK ] type abbreviation +[OK ] struct type +[OK ] interface type +[OK ] delegate type +[OK ] mutually recursive types +[OK ] class + member +[OK ] static member +[OK ] member val +[OK ] abstract/override +[OK ] inherit +[OK ] interface impl +[OK ] type extension member +[OK ] object expression +[OK ] exception +[OK ] open +[OK ] nested module +[OK ] namespace + module +[OK ] pipe right +[OK ] pipe left +[OK ] pipe2 right +[OK ] composition >> +[OK ] composition << +[OK ] static let in class +[OK ] local module qualified call +[OK ] class let vs member collision +[OK ] generic heritage +[OK ] inherit args +[OK ] dotted call +[OK ] method chain +[OK ] match with calls +[OK ] when guard call +[OK ] lambda body call +[OK ] try/with call +[OK ] use binding +[OK ] do binding +[OK ] computation expression +[OK ] task CE +[OK ] seq expression +[OK ] string interpolation call +[OK ] backtick identifier +[OK ] attribute on function +[OK ] extension member on local +[OK ] module rec + +58/58 constructs covered +Exit 0 = all constructs covered. + +A curated inventory of F# constructs, each with a minimal EXPECTATION: labels +that must appear as sourced nodes and/or (caller, relation, callee) edges that +must exist. Constructs whose expectations fail are OMISSIONS — reported +mechanically so review arms assess consequences instead of hunting. + +This is the instrument the first three review rounds lacked: every +omission-class defect they found (generics, active patterns, member val, +heritage, object expressions) would have appeared here for free. +""" +from __future__ import annotations + +import pathlib, sys, tempfile + +from graphify.extractors.fsharp import extract_fsharp + +# (name, source, expected_sourced_labels, expected_edges[, forbidden_labels]) +# expected_edges: (src_label, relation, tgt_label); None matches any, but a +# fully-wildcard edge is rejected by run_one (it can never fail — round-4 +# mutation audit). forbidden_labels catches COMMISSION defects (a wrong extra +# sourced node), the class every headline round-1..3 bug belonged to. +CONSTRUCTS: list = [ + ("let function", "module M\nlet f x = g x\n", {"f()"}, [("f()", "calls", "g")]), + ("let value", "module M\nlet port = 8080\n", {"port"}, []), + ("annotated let", "module M\nlet f (a: A) : R = g a\n", set(), + [(None, "calls", "g")], {"R", "A"}), + ("annotated single-arg let", "module M\nlet run (m: string) : int = work m\n", + set(), [(None, "calls", "work")], {"int", "string"}), + ("destructuring let", "module M\nlet (a, b) = mk ()\n", {"a", "b"}, []), + ("let rec and", "module M\nlet rec f x = g x\nand g y = f y\n", {"f()", "g()"}, + [("f()", "calls", "g()"), ("g()", "calls", "f()")]), + ("nested let scoping", "module M\nlet outer x =\n let inner = 1\n use2 inner\n", + {"outer()"}, [("outer()", "calls", "use2")]), + ("active pattern total", "module M\nlet (|Even|Odd|) n = cls n\n", + {"(|Even|Odd|)"}, [("(|Even|Odd|)", "calls", "cls")]), + ("active pattern partial", "module M\nlet (|Int|_|) (s: string) = tryInt s\n", + {"(|Int|_|)"}, [("(|Int|_|)", "calls", "tryInt")]), + ("operator def", "module M\nlet (+.) a b = comb a b\n", {"(+.)"}, + [("(+.)", "calls", "comb")]), + ("record type", "module M\ntype R = { A: int }\n", {"R"}, []), + ("union type + cases", "module M\ntype U = | X | Y of int\n", {"U", "X", "Y"}, + [("U", "contains", "X")]), + ("enum members", "module M\ntype E = | A = 1 | B = 2\n", {"E", "A", "B"}, + [("E", "contains", "A")]), + ("generic type", "module M\ntype Box<'T>() = member this.Get() = 1\n", + {"Box", ".Get()"}, [("Box", "contains", ".Get()")]), + ("constrained generic", "module M\ntype C<'T when 'T :> System.IDisposable> = { V: 'T }\n", + {"C"}, [], {"T", "IDisposable"}), + ("type abbreviation", "module M\ntype Alias = System.String\n", {"Alias"}, []), + ("struct type", "module M\n[]\ntype P = { X: int }\n", {"P"}, []), + ("interface type", "module M\ntype IFoo =\n abstract member Go: unit -> int\n", + {"IFoo", ".Go()"}, [("IFoo", "contains", ".Go()")]), + ("delegate type", "module M\ntype D = delegate of int -> int\n", {"D"}, []), + ("mutually recursive types", "module M\ntype A = { B: B }\nand B = { A: A }\n", + {"A", "B"}, []), + ("class + member", "module M\ntype S(c: int) =\n member this.Run() = go c\n", + {"S", ".Run()"}, [(".Run()", "calls", "go")]), + ("static member", "module M\ntype S() =\n static member Make() = build ()\n", + {".Make()"}, [(".Make()", "calls", "build")]), + ("member val", "module M\ntype S() =\n member val Name = \"x\" with get, set\n", + {".Name()"}, []), + ("abstract/override", "module M\n[]\ntype B() =\n abstract member Go: unit -> int\n default this.Go() = 1\ntype D() =\n inherit B()\n override this.Go() = 2\n", + {"B", "D"}, [("D", "inherits", "B")]), + ("inherit", "module M\ntype Sub() =\n inherit Base()\n", {"Sub"}, + [("Sub", "inherits", "Base")]), + ("interface impl", "module M\ntype R() =\n interface System.IDisposable with\n member this.Dispose() = ()\n", + {"R", ".Dispose()"}, [("R", "implements", "IDisposable")]), + ("type extension member", "module M\ntype System.String with\n member this.Shout() = up this\n", + {".Shout()"}, [], {"String"}), + ("object expression", "module M\nlet mk () =\n { new System.IDisposable with\n member this.Dispose() = clean () }\n", + {"mk()"}, [("mk()", "calls", "clean"), ("mk()", "references", "IDisposable")], + {".Dispose()"}), + ("exception", "module M\nexception Bad of string\n", {"Bad"}, []), + # imports edges target _make_id(fqn) — no node is minted, so the target + # has no label; anchor on the source (the file node) instead. + ("open", "module M\nopen System.Text\n", set(), + [("c.fs", "imports", None)], {"Text"}), + ("nested module", "module M\nmodule Inner =\n let f x = x\n", {"Inner", "f()"}, + [("Inner", "contains", "f()")]), + ("namespace + module", "namespace N.S\nmodule Impl =\n let f x = x\n", + {"Impl", "f()"}, []), + ("pipe right", "module M\nlet h x = x |> f1\n", {"h()"}, [("h()", "calls", "f1")]), + ("pipe left", "module M\nlet h x = f1 <| x\n", {"h()"}, [("h()", "calls", "f1")]), + ("pipe2 right", "module M\nlet h a b = (a, b) ||> f2\n", {"h()"}, + [("h()", "calls", "f2")]), + ("composition >>", "module M\nlet h = f1 >> f2\n", {"h"}, + [("h", "calls", "f1"), ("h", "calls", "f2")]), + ("composition <<", "module M\nlet h = f2 << f1\n", {"h"}, + [("h", "calls", "f1"), ("h", "calls", "f2")]), + ("static let in class", "module M\ntype C() =\n static let build x = shape x\n member this.Go() = build 1\n", + {"build()", ".Go()"}, [("build()", "calls", "shape"), (".Go()", "calls", "build()")]), + ("local module qualified call", "module Root\nmodule Config =\n let create p = p\nlet boot () = Config.create 1\n", + {"create()", "boot()"}, [("boot()", "calls", "create()")]), + ("class let vs member collision", "module M\ntype T() =\n let run () = 1\n member this.Run() = run ()\n", + {"run()", ".Run()"}, [(".Run()", "calls", "run()")]), + ("generic heritage", "module M\ntype Child<'T>() =\n inherit Base<'T>()\n", + {"Child"}, [("Child", "inherits", "Base")], {"T", "Base"}), + ("inherit args", "module M\ntype Sub() =\n inherit Base(mkArg ())\n", + {"Sub"}, [("Sub", "calls", "mkArg")]), + # Bare-name stub target is the ESTABLISHED design (mirrors ocaml.py): it + # is what lets the corpus rewire collapse the call onto the real `init`. + ("dotted call", "module M\nlet h x = Grasp.Telemetry.init x\n", {"h()"}, + [("h()", "calls", "init")]), + ("method chain", "module M\nlet h (sb: B) = sb.Append(1).Append(2)\n", {"h()"}, + [("h()", "calls", None)]), + ("match with calls", "module M\nlet h x =\n match x with\n | Some v -> handle v\n | None -> fallback ()\n", + {"h()"}, [("h()", "calls", "handle"), ("h()", "calls", "fallback")]), + ("when guard call", "module M\nlet h x =\n match x with\n | v when isBig v -> v\n | v -> v\n", + {"h()"}, [("h()", "calls", "isBig")]), + ("lambda body call", "module M\nlet h xs = List.map (fun x -> conv x) xs\n", + {"h()"}, [("h()", "calls", "conv")]), + ("try/with call", "module M\nlet h x =\n try risky x\n with _ -> recover x\n", + {"h()"}, [("h()", "calls", "risky"), ("h()", "calls", "recover")]), + ("use binding", "module M\nlet h () =\n use r = acquire ()\n work r\n", + {"h()"}, [("h()", "calls", "acquire"), ("h()", "calls", "work")]), + ("do binding", "module M\ndo setup ()\n", set(), [(None, "calls", "setup")]), + ("computation expression", "module M\nlet h () =\n async {\n let! r = fetch ()\n return proc r\n }\n", + {"h()"}, [("h()", "calls", "fetch"), ("h()", "calls", "proc")]), + ("task CE", "module M\nlet h () =\n task {\n do! flush ()\n return 1\n }\n", + {"h()"}, [("h()", "calls", "flush")]), + ("seq expression", "module M\nlet h n = seq { for i in 1..n -> conv i }\n", + {"h()"}, [("h()", "calls", "conv")]), + ("string interpolation call", "module M\nlet h x = printfn $\"v={calc x}\"\n", + {"h()"}, [("h()", "calls", "calc")]), + ("backtick identifier", "module M\nlet ``my test name`` () = check ()\n", + set(), [(None, "calls", "check")]), + ("attribute on function", "module M\n[]\nlet main argv = run argv\n", + {"main()"}, [("main()", "calls", "run")]), + ("extension member on local", "module M\ntype T() = member this.A() = 1\ntype T with\n member this.B() = 2\n", + {"T", ".A()", ".B()"}, []), + ("module rec", "module rec M\nlet f x = g x\nlet g y = y\n", {"f()", "g()"}, + [("f()", "calls", "g()")]), +] + + +def run_one(name: str, src: str, want_labels: set, want_edges: list, + forbidden: set | None = None): + with tempfile.TemporaryDirectory() as td: + fp = pathlib.Path(td) / "c.fs" + fp.write_text(src, encoding="utf-8") + r = extract_fsharp(fp) + if "error" in r: + return [f"extractor error: {r['error']}"] + lab = {n["id"]: n["label"] for n in r["nodes"]} + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + edges = {(lab.get(e["source"]), e["relation"], lab.get(e["target"])) + for e in r["edges"]} + problems = [] + missing = want_labels - sourced + if missing: + problems.append(f"missing sourced labels: {sorted(missing)}") + hit = sourced & (forbidden or set()) + if hit: + problems.append(f"FORBIDDEN sourced labels present: {sorted(hit)}") + for (ws, wr, wt) in want_edges: + if ws is None and wt is None: + problems.append(f"vacuous expectation (all-wildcard edge): {wr}") + continue + ok = any((ws is None or s == ws) and rel == wr and (wt is None or t == wt) + for (s, rel, t) in edges) + if not ok: + problems.append(f"missing edge: ({ws}, {wr}, {wt})") + for e in r["edges"]: + if e["source"] == e["target"]: + problems.append(f"self-loop edge: {lab.get(e['source'])} {e['relation']}") + return problems + + +def main(): + failures = 0 + for entry in CONSTRUCTS: + name, src, wl, we = entry[0], entry[1], entry[2], entry[3] + fb = entry[4] if len(entry) > 4 else None + problems = run_one(name, src, wl, we, fb) + status = "OK " if not problems else "MISS" + if problems: + failures += 1 + print(f"[{status}] {name}") + for pr in problems: + print(f" {pr}") + print(f"\n{len(CONSTRUCTS) - failures}/{len(CONSTRUCTS)} constructs covered") + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/test_fsharp.py b/tests/test_fsharp.py new file mode 100644 index 000000000..ce464f3f0 --- /dev/null +++ b/tests/test_fsharp.py @@ -0,0 +1,542 @@ +"""Tests for the F# extractor (graphify/extractors/fsharp.py).""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_fsharp") + +from graphify.extract import extract_fsharp + + +def _write(tmp_path: Path, name: str, body: str) -> Path: + p = tmp_path / name + p.write_text(body, encoding="utf-8") + return p + + +def _labels(r) -> set[str]: + return {n["label"] for n in r["nodes"]} + + +def _rel_pairs(r, relation: str) -> set[tuple[str, str]]: + lab = {n["id"]: n["label"] for n in r["nodes"]} + return { + (lab.get(e["source"], e["source"]), lab.get(e["target"], e["target"])) + for e in r["edges"] + if e["relation"] == relation + } + + +IMPL = """\ +module Grasp.Sidecar.Demo + +open System.Text +open Grasp.Abstractions + +type Config = { Port: int; Host: string } +type Mode = | Fast | Careful of int + +exception BadFrame of string + +let defaultPort = 8080 + +let makeConfig host = + { Port = defaultPort; Host = host } + +let validate cfg = cfg + +let start (cfg: Config) = + let sb = StringBuilder() + sb.Append(cfg.Host) |> ignore + makeConfig cfg.Host |> validate + +type Server(cfg: Config) = + member this.Run() = start cfg + static member Default = Server(makeConfig "x") +""" + + +def test_defines_module_types_values_and_members(tmp_path): + r = extract_fsharp(_write(tmp_path, "demo.fs", IMPL)) + assert "error" not in r + labels = _labels(r) + # module (last segment), record + DU types, exception, let-bound defs + assert {"Demo", "Config", "Mode", "BadFrame", + "defaultPort", "makeConfig()", "validate()", "start()"} <= labels + # DU cases and class members + assert {"Fast", "Careful", "Server", ".Run()", ".Default()"} <= labels + + +def test_containment_shape(tmp_path): + r = extract_fsharp(_write(tmp_path, "demo.fs", IMPL)) + defines = _rel_pairs(r, "defines") + contains = _rel_pairs(r, "contains") + # file defines the top-level module; module contains its declarations + assert ("demo.fs", "Demo") in defines + assert ("Demo", "makeConfig()") in contains + assert ("Demo", "Config") in contains + # DU cases contained by their type; members contained by their class + assert ("Mode", "Fast") in contains + assert ("Mode", "Careful") in contains + assert ("Server", ".Run()") in contains + assert ("Server", ".Default()") in contains + + +def test_nested_let_does_not_mint_a_definition(tmp_path): + r = extract_fsharp(_write(tmp_path, "demo.fs", IMPL)) + # `let sb = ...` is local to `start` and must not become a node. + assert "sb" not in _labels(r) + + +def test_pipeline_calls_resolve_same_file(tmp_path): + r = extract_fsharp(_write(tmp_path, "demo.fs", IMPL)) + calls = _rel_pairs(r, "calls") + # `makeConfig cfg.Host |> validate` inside `start`: + # the application edge AND the pipeline edge, both attributed to `start`. + assert ("start()", "makeConfig()") in calls + assert ("start()", "validate()") in calls + + +def test_member_body_calls_attribute_to_member(tmp_path): + r = extract_fsharp(_write(tmp_path, "demo.fs", IMPL)) + calls = _rel_pairs(r, "calls") + # `member this.Run() = start cfg` — caller is Run, not the file. + assert (".Run()", "start()") in calls + + +def test_pipeline_callee_left_of_backpipe(tmp_path): + src = "module M\nlet f x = x\nlet g y =\n f <| y\n" + r = extract_fsharp(_write(tmp_path, "back.fs", src)) + assert ("g()", "f()") in _rel_pairs(r, "calls") + + + +def test_qualified_external_call_stays_distinct(tmp_path): + # `sb.Append(...)`: `sb` is not a local container, so the callee must be a + # stub and never bind to a hypothetical local `Append`. + src = ("module M\n" + "let Append x = x\n" + "let go (sb: System.Text.StringBuilder) =\n" + " sb.Append(\"y\") |> ignore\n") + r = extract_fsharp(_write(tmp_path, "qual.fs", src)) + lab = {n["id"]: n for n in r["nodes"]} + # The edge must EXIST: without this, the loop below is vacuously green when + # no call edge is emitted at all (caught by graphify's own review of this PR). + append_edges = [e for e in r["edges"] if e["relation"] == "calls" + and lab[e["target"]]["label"] in ("Append", "sb.Append")] + assert append_edges, "no call edge emitted for sb.Append at all" + for e in append_edges: + # must NOT resolve to the local definition (which has a source_file) + assert lab[e["target"]]["source_file"] == "", ( + "external qualified call bound to a local definition") + + +def test_fsx_script_parses(tmp_path): + src = "let hello name =\n printfn \"hi %s\" name\nhello \"world\"\n" + r = extract_fsharp(_write(tmp_path, "script.fsx", src)) + assert "error" not in r + assert "hello()" in _labels(r) + calls = _rel_pairs(r, "calls") + assert ("script.fsx", "hello()") in calls + assert ("hello()", "printfn") in calls + + +def test_same_named_members_of_different_types_stay_distinct(tmp_path): + # Two types in ONE file, each with a Dispose member: file-scoped member ids + # would merge them into a single node (caught by graphify's own review). + src = ("module M\n" + "type A() =\n" + " member this.Dispose() = 1\n" + "type B() =\n" + " member this.Dispose() = 2\n") + r = extract_fsharp(_write(tmp_path, "two.fs", src)) + disp = [n for n in r["nodes"] if n["label"] == ".Dispose()" and n.get("source_file")] + assert len(disp) == 2, f"expected 2 Dispose nodes, got {len(disp)}" + contains = _rel_pairs(r, "contains") + assert ("A", ".Dispose()") in contains and ("B", ".Dispose()") in contains + + +def test_annotated_let_names_the_binding_not_the_type(tmp_path): + # `let subscribe (a: A) (b: B) : IDisposable = ...` parses as a + # value_declaration_left whose LAST identifier is the return type. The + # minted definition must be `subscribe`; a sourced `IDisposable` node here + # would absorb every BCL `implements IDisposable` stub in the corpus + # rewire (found live on Grasp.Sidecar/SpanEmitter.fs L68). + src = ("module M\n" + "let subscribe (source: A) (events: B) : IDisposable =\n" + " ignore source\n" + "let port : int = 8080\n") + r = extract_fsharp(_write(tmp_path, "ann.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "subscribe" in sourced or "subscribe()" in sourced + assert "port" in sourced + assert "IDisposable" not in sourced + assert "int" not in sourced + + +def test_dotted_callee_records_call(tmp_path): + # `Grasp.Telemetry.init args` parses as application > dot_expression; the + # callee must be recorded as a full-path stub, not silently skipped. + src = "module M\nlet go args =\n Grasp.Telemetry.init args\n" + r = extract_fsharp(_write(tmp_path, "dot.fs", src)) + calls = _rel_pairs(r, "calls") + assert ("go()", "Grasp.Telemetry.init") in calls or ("go()", "init") in calls, calls + + +def test_let_rec_and_mints_every_binding(tmp_path): + src = "module M\nlet rec f x = g x\nand g y = f y\n" + r = extract_fsharp(_write(tmp_path, "rec.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert {"f()", "g()"} <= sourced + calls = _rel_pairs(r, "calls") + assert ("f()", "g()") in calls + assert ("g()", "f()") in calls + assert ("f()", "f()") not in calls, "false self-loop from mis-attributed and-binding" + + +def test_enum_members_are_emitted(tmp_path): + src = "module M\ntype Color =\n | Red = 0\n | Blue = 1\n" + r = extract_fsharp(_write(tmp_path, "enum.fs", src)) + contains = _rel_pairs(r, "contains") + assert ("Color", "Red") in contains and ("Color", "Blue") in contains + + +def test_destructuring_let_mints_each_name(tmp_path): + src = "module M\nlet (major, minor) = parseVersion v\n" + r = extract_fsharp(_write(tmp_path, "destr.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert {"major", "minor"} <= sourced + + +def test_comment_inside_pipeline_keeps_call_edge(tmp_path): + src = "module M\nlet f x = x\nlet h x =\n x // note\n |> f\n" + r = extract_fsharp(_write(tmp_path, "cpipe.fs", src)) + assert ("h()", "f()") in _rel_pairs(r, "calls") + + +def test_same_named_union_cases_stay_type_scoped(tmp_path): + src = ("module M\n" + "type ParseResult = | Ok of int | Bad\n" + "type SaveResult = | Ok of string | Failed\n") + r = extract_fsharp(_write(tmp_path, "du.fs", src)) + oks = [n for n in r["nodes"] if n["label"] == "Ok" and n.get("source_file")] + assert len(oks) == 2, f"expected 2 type-scoped Ok nodes, got {len(oks)}" + + +def test_single_case_du_has_no_self_loop(tmp_path): + src = "module M\ntype Email = Email of string\n" + r = extract_fsharp(_write(tmp_path, "email.fs", src)) + for e in r["edges"]: + assert e["source"] != e["target"], f"self-loop: {e}" + emails = [n for n in r["nodes"] if n["label"] == "Email" and n.get("source_file")] + assert len(emails) == 2 # the type AND its case, distinct + + +def test_namespace_is_canonical_and_marked(tmp_path): + src_a = "namespace Grasp.Core\ntype A() = member this.Go() = 1\n" + src_b = "namespace Grasp.Core\ntype B() = member this.Ho() = 2\n" + ra = extract_fsharp(_write(tmp_path, "a.fs", src_a)) + rb = extract_fsharp(_write(tmp_path, "b.fs", src_b)) + ns_a = [n for n in ra["nodes"] if n.get("type") == "namespace"] + ns_b = [n for n in rb["nodes"] if n.get("type") == "namespace"] + assert ns_a and ns_b + assert ns_a[0]["id"] == ns_b[0]["id"], "namespace id must be canonical across files" + assert ns_a[0]["id"].startswith("csharp_namespace:") + assert ns_a[0]["label"] == "Grasp.Core" + + +def test_namespace_segment_does_not_bind_local(tmp_path): + # Under `namespace Grasp.Sidecar`, the call `Sidecar.validate c` must stay + # a stub — the namespace is corpus-wide, not a local qualifier. + src = ("namespace Grasp.Sidecar\n" + "module Impl =\n" + " let validate c = c\n" + " let go c = Sidecar.validate c\n") + r = extract_fsharp(_write(tmp_path, "ns.fs", src)) + lab = {n["id"]: n for n in r["nodes"]} + vcalls = [e for e in r["edges"] if e["relation"] == "calls" + and lab[e["target"]]["label"] in ("validate", "validate()", + "Sidecar.validate")] + assert vcalls, "no call edge for Sidecar.validate at all" + for e in vcalls: + assert not lab[e["target"]].get("source_file"), ( + "namespace-rooted call falsely bound to local definition") + + +# ── Findings from the four-model panel (round 3) ───────────────────────────── + + +def test_generic_type_named_after_itself_not_its_parameter(tmp_path): + src = ("module M\n" + "type Box<'T>() =\n" + " static member Create(x: 'T) = x\n" + "type Cache<'T when 'T :> System.IDisposable> = { Item: 'T }\n") + r = extract_fsharp(_write(tmp_path, "gen.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert {"Box", "Cache"} <= sourced + assert "T" not in sourced + assert "IDisposable" not in sourced, "constraint type minted as sourced definition" + + +def test_type_extension_does_not_impersonate_foreign_type(tmp_path): + src = ("module M\n" + "type System.String with\n" + " member this.Shout() = 1\n") + r = extract_fsharp(_write(tmp_path, "ext.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "String" not in sourced, "extension minted a sourced foreign-type node" + # the member still exists, hung off the sourceless stub + assert ".Shout()" in sourced + lab = {n["id"]: n for n in r["nodes"]} + owners = [lab[e["source"]] for e in r["edges"] + if e["relation"] == "contains" and lab[e["target"]]["label"] == ".Shout()"] + assert owners and all(o["source_file"] == "" for o in owners) + + +def test_heritage_edges_emitted(tmp_path): + src = ("module M\n" + "type Derived() =\n" + " inherit Base()\n" + " interface System.IDisposable with\n" + " member this.Dispose() = ()\n") + r = extract_fsharp(_write(tmp_path, "her.fs", src)) + rels = _rel_pairs(r, "inherits") | _rel_pairs(r, "implements") + assert ("Derived", "Base") in rels + assert ("Derived", "IDisposable") in rels + # stubs, not sourced definitions + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "Base" not in sourced and "IDisposable" not in sourced + + +def test_object_expression_members_are_anonymous(tmp_path): + src = ("module M\n" + "let cleanup () = ()\n" + "let mk () =\n" + " { new System.IDisposable with\n" + " member this.Dispose() = cleanup () }\n" + "let Go x = x\n" + "let caller y = Go y\n") + r = extract_fsharp(_write(tmp_path, "obj.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert ".Dispose()" not in sourced, "object-expression member minted as owned member" + calls = _rel_pairs(r, "calls") + assert ("mk()", "cleanup()") in calls, calls + # the real Go binding must not be poisoned into ambiguity + assert ("caller()", "Go()") in calls, calls + + +def test_active_pattern_and_operator_are_minted_and_attributed(tmp_path): + src = ("module M\n" + "let classify n = n\n" + "let combine a b = a\n" + "let (|Even|Odd|) n = classify n\n" + "let (+.) a b = combine a b\n") + r = extract_fsharp(_write(tmp_path, "ops.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "(|Even|Odd|)" in sourced + assert "(+.)" in sourced + calls = _rel_pairs(r, "calls") + assert ("(|Even|Odd|)", "classify()") in calls + assert ("(+.)", "combine()") in calls + assert ("M", "classify()") not in calls, "body call attributed to module" + + +def test_member_val_auto_property_is_emitted(tmp_path): + src = ("module M\n" + "type T() =\n" + " member val Name = \"x\" with get, set\n" + " member this.Go() = 1\n") + r = extract_fsharp(_write(tmp_path, "mv.fs", src)) + contains = _rel_pairs(r, "contains") + assert ("T", ".Name()") in contains + assert ("T", ".Go()") in contains + + +def test_same_named_bindings_in_sibling_modules_stay_distinct(tmp_path): + src = ("module Root\n" + "module A =\n" + " let encode x = x\n" + " let run x = encode x\n" + "module B =\n" + " let decode y = y\n" + " let run y = decode y\n") + r = extract_fsharp(_write(tmp_path, "sib.fs", src)) + runs = [n for n in r["nodes"] if n["label"] == "run()" and n.get("source_file")] + assert len(runs) == 2, f"expected 2 run() nodes, got {len(runs)}" + + +def test_companion_type_and_module_stay_distinct(tmp_path): + src = ("module Root\n" + "type Config = { Port: int }\n" + "module Config =\n" + " let create p = p\n") + r = extract_fsharp(_write(tmp_path, "comp.fs", src)) + configs = [n for n in r["nodes"] if n["label"] == "Config" and n.get("source_file")] + assert len(configs) == 2, f"companion type/module merged: {len(configs)} node(s)" + + +def test_open_mirrors_csharp_using(tmp_path): + src = "module M\nopen System.Text\n" + r = extract_fsharp(_write(tmp_path, "op.fs", src)) + imports = [e for e in r["edges"] if e["relation"] == "imports"] + assert imports, "no imports edge for open" + e = imports[0] + assert e["confidence"] == "EXTRACTED" + assert e["metadata"]["target_fqn"] == "System.Text" + # no minted last-segment stub that could rewire onto an unrelated `Text` + assert not any(n["label"] == "Text" for n in r["nodes"]) + + +# ── Findings from the round-4 lensed panel ─────────────────────────────────── + + +def test_annotated_single_arg_let_does_not_mint_type(tmp_path): + # The R2 regression test was proven VACUOUS by mutation: its multi-arg + # source parses via function_declaration_left, never exercising + # bound_value_names. A SINGLE-arg annotated let goes down the value path. + src = "module M\nlet run (mode: string) : int = work mode\n" + r = extract_fsharp(_write(tmp_path, "sann.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "int" not in sourced + assert "run" in sourced or "run()" in sourced + + +def test_object_expression_references_interface(tmp_path): + # This edge was DEAD CODE (wrong child type matched) — two arms proved it + # fired on zero corpus files. Positive assertion pins it. + src = ("module M\n" + "let mk () =\n" + " { new System.IDisposable with\n" + " member this.Dispose() = () }\n") + r = extract_fsharp(_write(tmp_path, "oref.fs", src)) + assert ("mk()", "IDisposable") in _rel_pairs(r, "references") + + +def test_generic_heritage_edges_emitted(tmp_path): + src = ("module M\n" + "type Child<'T>() =\n" + " inherit Base<'T>()\n" + " interface System.Collections.Generic.IComparer<'T> with\n" + " member this.Compare(a, b) = 0\n") + r = extract_fsharp(_write(tmp_path, "gher.fs", src)) + assert ("Child", "Base") in _rel_pairs(r, "inherits") + assert ("Child", "IComparer") in _rel_pairs(r, "implements") + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "T" not in sourced, "type parameter leaked from generic heritage" + + +def test_static_let_minted_and_resolvable(tmp_path): + src = ("module M\n" + "type C() =\n" + " static let build x = shape x\n" + " member this.Go() = build 1\n") + r = extract_fsharp(_write(tmp_path, "slet.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "build()" in sourced + calls = _rel_pairs(r, "calls") + assert ("build()", "shape") in calls, "static-let body call misattributed" + assert (".Go()", "build()") in calls, "member call did not resolve to local static let" + + +def test_class_let_and_member_do_not_collide(tmp_path): + # _make_id case-folds: `let capacity` and `member .Capacity()` merged into + # one node on the real corpus (RingBuffer.fs lost its public member), and + # produced a false self-loop elsewhere. Member ids now carry a kind tag. + src = ("module M\n" + "type T() =\n" + " let run () = 1\n" + " member this.Run() = run ()\n") + r = extract_fsharp(_write(tmp_path, "coll.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "run()" in sourced and ".Run()" in sourced + for e in r["edges"]: + assert e["source"] != e["target"], f"self-loop: {e}" + + +def test_composition_records_both_callees(tmp_path): + src = ("module M\n" + "let f1 x = x\n" + "let f2 x = x\n" + "let pipeline = f1 >> f2\n" + "let rev = f2 << f1\n") + r = extract_fsharp(_write(tmp_path, "comp.fs", src)) + calls = _rel_pairs(r, "calls") + assert ("pipeline", "f1()") in calls and ("pipeline", "f2()") in calls + assert ("rev", "f1()") in calls and ("rev", "f2()") in calls + + +def test_inherit_argument_calls_recorded(tmp_path): + src = "module M\ntype Sub() =\n inherit Base(mkArg ())\n" + r = extract_fsharp(_write(tmp_path, "iarg.fs", src)) + calls = _rel_pairs(r, "calls") + assert ("Sub", "mkArg") in calls, calls + + +def test_local_module_qualified_call_binds_extracted(tmp_path): + # The POSITIVE half of local_containers — previously only negative tests + # existed, so deleting the feature survived every test (64 corpus edges + # silently demoted to stubs). + src = ("module Root\n" + "module Config =\n" + " let create p = p\n" + "let boot () = Config.create 1\n") + r = extract_fsharp(_write(tmp_path, "lq.fs", src)) + lab = {n["id"]: n for n in r["nodes"]} + hits = [e for e in r["edges"] if e["relation"] == "calls" + and lab[e["target"]]["label"] == "create()"] + assert hits, "qualified call through local module did not bind" + assert all(e["confidence"] == "EXTRACTED" for e in hits) + assert all(lab[e["target"]].get("source_file") for e in hits) + + +def test_abstract_members_emitted(tmp_path): + src = ("module M\n" + "type IFoo =\n" + " abstract member Go: unit -> int\n") + r = extract_fsharp(_write(tmp_path, "abs.fs", src)) + assert ("IFoo", ".Go()") in _rel_pairs(r, "contains") + + +def test_partial_active_pattern_label_keeps_wildcard(tmp_path): + src = "module M\nlet (|Int|_|) (s: string) = tryInt s\n" + r = extract_fsharp(_write(tmp_path, "pap.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert "(|Int|_|)" in sourced, sourced + + +def test_heritage_confidence_is_inferred(tmp_path): + src = "module M\ntype Sub() =\n inherit Base()\n" + r = extract_fsharp(_write(tmp_path, "hconf.fs", src)) + her = [e for e in r["edges"] if e["relation"] == "inherits"] + assert her and all(e["confidence"] == "INFERRED" for e in her) + + +def test_qualified_call_binds_only_to_owning_container(tmp_path): + # `B.helper` where module B defines no helper must NOT bind to A's helper + # just because B is also a local container (found by graphify's own bot, + # round 6 — missed by all four panel arms). + src = ("module Root\n" + "module A =\n" + " let helper x = x\n" + "module B =\n" + " let go y = A.helper y\n" + " let bad z = B.helper z\n") + r = extract_fsharp(_write(tmp_path, "own.fs", src)) + lab = {n["id"]: n for n in r["nodes"]} + calls = [(lab[e["source"]]["label"], lab[e["target"]], e["confidence"]) + for e in r["edges"] if e["relation"] == "calls"] + good = [(s, t, c) for s, t, c in calls if s == "go()"] + bad = [(s, t, c) for s, t, c in calls if s == "bad()"] + assert good and all(t["label"] == "helper()" and t.get("source_file") + and c == "EXTRACTED" for s, t, c in good) + assert bad and all(not t.get("source_file") for s, t, c in bad), ( + "B.helper falsely bound to A's sourced helper") + + +def test_nested_destructuring_let_mints_all_names(tmp_path): + src = "module M\nlet (a, (b, c)) = mk ()\n" + r = extract_fsharp(_write(tmp_path, "nest.fs", src)) + sourced = {n["label"] for n in r["nodes"] if n.get("source_file")} + assert {"a", "b", "c"} <= sourced diff --git a/tests/test_fsharp_registration.py b/tests/test_fsharp_registration.py new file mode 100644 index 000000000..614819e75 --- /dev/null +++ b/tests/test_fsharp_registration.py @@ -0,0 +1,314 @@ +"""Registry tests for F# — each asserts one wiring point, so that deleting any +single registration is caught by a named failure rather than by silence. + +The F# gap shipped in the first place because .fs matched *no* category and was +skipped without an error; these tests exist to make that class of regression +loud. +""" +from __future__ import annotations + +import pytest + +# NOTE: no module-level importorskip. The registry entries below are pure +# literals whose absence is a shippable bug regardless of whether the grammar +# is installed; only the two dispatch tests need the grammar and skip +# individually. + + +def test_detect_categorizes_fs_as_code(): + from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS + assert ".fs" in CODE_EXTENSIONS + assert ".fsx" in CODE_EXTENSIONS + assert ".fs" not in DOC_EXTENSIONS + + +def test_watch_covers_fs(): + from graphify.watch import _WATCHED_EXTENSIONS + assert ".fs" in _WATCHED_EXTENSIONS and ".fsx" in _WATCHED_EXTENSIONS + + +def test_dispatch_routes_fs_to_fsharp_extractor(tmp_path): + pytest.importorskip("tree_sitter_fsharp") + # Through the public extract() path, not a direct import: a missing + # dispatch entry silently yields zero nodes for the file. + from graphify.extract import extract + p = tmp_path / "m.fs" + p.write_text("module M\nlet f x = x\n", encoding="utf-8") + r = extract([p], root=tmp_path) + labels = {n["label"] for n in r["nodes"]} + assert "f()" in labels, "extract() did not route .fs to the F# extractor" + + +def test_dispatch_routes_fsx_to_fsharp_extractor(tmp_path): + pytest.importorskip("tree_sitter_fsharp") + # .fsx must be tested separately: the .fs entry alone keeps this green, + # and the .fsx entry's removal survived a mutation run until this existed. + from graphify.extract import extract + p = tmp_path / "s.fsx" + p.write_text("let hello name = name\n", encoding="utf-8") + r = extract([p], root=tmp_path) + labels = {n["label"] for n in r["nodes"]} + assert "hello()" in labels, "extract() did not route .fsx to the F# extractor" + + +def test_extra_hint_names_fsharp(): + from graphify.extract import _EXTRA_FOR_EXTENSION + assert _EXTRA_FOR_EXTENSION.get(".fs") == "fsharp" + assert _EXTRA_FOR_EXTENSION.get(".fsx") == "fsharp" + + +def test_fs_shares_dotnet_interop_family_with_cs(): + # The load-bearing entry: same family is what allows an F# reference stub + # to rewire onto a C# definition instead of dangling. + from graphify.extract import _LANG_FAMILY_BY_EXT + assert _LANG_FAMILY_BY_EXT.get(".fs") == _LANG_FAMILY_BY_EXT[".cs"] == "dotnet" + assert _LANG_FAMILY_BY_EXT.get(".fsx") == "dotnet" + + +def test_analyze_family_matches_cs(): + from graphify.analyze import _LANG_FAMILY + assert _LANG_FAMILY.get(".fs") == _LANG_FAMILY[".cs"] + + +def test_build_edge_family_matches_cs(): + from graphify.build import _EDGE_LANG_FAMILY + assert _EDGE_LANG_FAMILY.get(".fs") == _EDGE_LANG_FAMILY[".cs"] + assert _EDGE_LANG_FAMILY.get(".fsx") == _EDGE_LANG_FAMILY[".cs"] + + +def test_glsl_fragment_shader_is_not_dispatched_to_fsharp(tmp_path): + # .fs is also the standard GLSL fragment-shader extension. A shader must + # get NO extractor (no-AST-extractor warning path), not be ERROR-parsed + # into sourceless dotnet-family stubs. + from graphify.extract import _get_extractor + p = tmp_path / "frag.fs" + p.write_text("#version 330 core\nuniform vec4 color;\n" + "void main() { gl_FragColor = color; }\n", encoding="utf-8") + assert _get_extractor(p) is None + + q = tmp_path / "real.fs" + q.write_text("module M\nlet f x = x\n", encoding="utf-8") + assert _get_extractor(q) is not None + + +def test_glsl_guard_is_load_bearing_for_marker_collisions(tmp_path): + # A #version-directive shader whose body carries an F#-shaped line-start + # (`type ` — GLSL-invalid, but sniffing is lexical) must STILL be rejected: + # strong GLSL directives outrank strong F# evidence. Deleting the strong + # GLSL check flips this file to "F#", so this test kills that mutant. + # (Since round 11 the word-shaped markers are weak, so only the #version/ + # #extension directives can carry this kill.) + from graphify.extract import _get_extractor + p = tmp_path / "lighting.fs" + p.write_text("#version 330 core\n" + "type of_light = 1; // hybrid nonsense, lexically F#-shaped\n" + "in vec3 normal;\nout vec4 fragColor;\n" + "void main() { fragColor = vec4(normal, 1.0); }\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +def test_modern_shader_without_version_line_not_dispatched(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "phong.fs" + p.write_text("// phong lighting\nin vec3 normal;\nout vec4 fragColor;\n" + "void main() { float d = 0.0; }\n", encoding="utf-8") + assert _get_extractor(p) is None + + +# ── Round-4 sniff + resolution gates ───────────────────────────────────────── + + +def test_fsharp_with_glsl_words_in_comments_is_dispatched(tmp_path): + # Round-3's sniff DROPPED this file ("uniform " matched inside a comment). + from graphify.extract import _get_extractor + p = tmp_path / "stats.fs" + p.write_text("module Stats\n" + "// Draws a sample from a uniform distribution over [lo, hi).\n" + "let sampleUniform lo hi = lo + hi\n", encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_fsharp_with_float_expression_lines_is_dispatched(tmp_path): + # Cross-exam counterexample: real corpus lines start with `float ` — + # weak GLSL evidence must not override a strong F# declaration. + from graphify.extract import _get_extractor + p = tmp_path / "fmt.fs" + p.write_text("namespace Grasp.Tui\nmodule Fmt =\n" + " let pct count total =\n" + " float count / float total * 100.0\n", encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_marker_free_shader_rejected_on_weak_evidence(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "blur.fs" + p.write_text("// blur\nfloat weight = 0.5;\nfloat offset = 1.3;\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +def test_comment_only_marker_regression(tmp_path): + # Kills the mutant that re-adds b"//" as F# evidence: this shader's only + # F#-marker-shaped bytes are comments. + from graphify.extract import _get_extractor + p = tmp_path / "glow.fs" + p.write_text("// glow pass\n// type: additive\nfloat glow = 2.0;\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +def test_pure_fsharp_corpus_resolves_open_to_canonical_namespace(tmp_path): + # The imports repoint was gated on .cs presence: a pure-F# corpus dangled + # every open edge and build silently pruned them (verified by two arms). + pytest.importorskip("tree_sitter_fsharp") + from graphify.extract import extract + from graphify.extractors.engine import _csharp_namespace_id + lib = tmp_path / "Lib.fs" + lib.write_text("namespace Acme.Widgets\ntype Gadget() = member this.Go() = 1\n", + encoding="utf-8") + prog = tmp_path / "Program.fs" + prog.write_text("namespace Acme.App\nopen Acme.Widgets\n" + "module Main =\n let run () = 1\n", encoding="utf-8") + r = extract([lib, prog], root=tmp_path, max_workers=1) + canon = _csharp_namespace_id("Acme.Widgets") + hits = [e for e in r["edges"] + if e["relation"] == "imports" and e["target"] == canon] + assert hits, "open edge was not repointed to the canonical namespace node" + + +def test_missing_grammar_is_reported_not_raised(tmp_path, monkeypatch): + # Lives HERE (no module-level importorskip): it fakes the ImportError, so + # it must run precisely on machines where the grammar is absent. + from graphify.extractors.fsharp import extract_fsharp + import builtins + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "tree_sitter_fsharp": + raise ImportError("boom") + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", fake_import) + p = tmp_path / "x.fs" + p.write_text("module M\n", encoding="utf-8") + r = extract_fsharp(p) + assert r["nodes"] == [] and "not installed" in r["error"] + + +def test_bom_prefixed_fsharp_is_dispatched(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "bom.fs" + p.write_bytes(b"\xef\xbb\xbfmodule M\n") # BOM + single marker line + assert _get_extractor(p) is not None + + +def test_forth_with_paren_star_comment_not_dispatched(tmp_path): + # Forth stack-effect comments can start a line with `(*`; that byte pair + # must not count as F# evidence (bot round-8 find, probe-confirmed). + from graphify.extract import _get_extractor + p = tmp_path / "math.fs" + p.write_text("( Forth multiply )\n(* stack: a b -- a*b )\n: square dup * ;\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +def test_fsharp_with_block_comment_header_still_dispatched(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "hdr.fs" + p.write_text("(* Copyright 2026\n licensed as... *)\nmodule M\nlet f x = x\n", + encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_fsharp_calling_identifier_named_uniform_is_dispatched(tmp_path): + # `uniform` is a valid F# identifier; a call on a continuation line must + # not reject the file (bot round-9 find, probe-confirmed). + from graphify.extract import _get_extractor + p = tmp_path / "stats2.fs" + p.write_text("module Stats\nlet sample () =\n uniform 0.0 1.0\n", + encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_uniform_only_headerless_shader_still_rejected(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "u.fs" + p.write_text("// u\nuniform vec4 color;\nvoid main() { gl_FragColor = color; }\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +def test_fsharp_calling_identifier_named_layout_is_dispatched(tmp_path): + # Same word-vs-directive class as `uniform` (bot round-10): `layout` is a + # natural F# identifier (TUI code); demoted to weak evidence. + from graphify.extract import _get_extractor + p = tmp_path / "tui.fs" + p.write_text("module Tui\nlet render w =\n layout w |> draw\n", + encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_layout_qualifier_shader_still_rejected(tmp_path): + from graphify.extract import _get_extractor + p = tmp_path / "lq.fs" + p.write_text("// s\nlayout(location = 0) in vec3 pos;\nvoid main() { }\n", + encoding="utf-8") + assert _get_extractor(p) is None + + +# ── Round-11 marker-class closure: every word-shaped marker demoted ───────── +# The remaining formerly-strong markers were audited against the grammar +# itself: gl_-prefixed names, `in vecN` (verbose let...in), `out vecN`, and +# `void main` all parse as valid F# line-starts, so any of them as strong +# evidence rejects a real F# file that carries one on a continuation line. +# Each acceptance test below kills the mutant that re-promotes its marker. + + +def test_fsharp_with_gl_prefixed_interop_identifier_is_dispatched(tmp_path): + # OpenGL interop code mirrors C names: `gl_`-prefixed identifiers are + # ordinary F# identifiers (`let gl_ctx = ...` parses clean). + from graphify.extract import _get_extractor + p = tmp_path / "glapp.fs" + p.write_text("module GlApp\nlet render ctx =\n" + " gl_makeCurrent ctx\n gl_swapBuffers ()\n", + encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_fsharp_verbose_let_in_line_is_dispatched(tmp_path): + # Verbose syntax puts `in` at a line start; `vec2` is a natural + # constructor-function name in F# math code. + from graphify.extract import _get_extractor + p = tmp_path / "vec.fs" + p.write_text("module V\nlet v =\n let x = 1.0\n in vec2 x x\n", + encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_fsharp_out_identifier_application_is_dispatched(tmp_path): + # `out` is not an F# keyword; `out vec3 v` is a plain application. + from graphify.extract import _get_extractor + p = tmp_path / "emit.fs" + p.write_text("module P\nlet emit v =\n out vec3 v\n", encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_fsharp_void_identifier_application_is_dispatched(tmp_path): + # `void` is not a lexer-level F# keyword either (`let void = 1` parses); + # a line starting `void main` is a legal application. + from graphify.extract import _get_extractor + p = tmp_path / "void.fs" + p.write_text("module V\nlet void x = x\nlet main = 1\n" + "let run =\n void main\n", encoding="utf-8") + assert _get_extractor(p) is not None + + +def test_gl_prefixed_headerless_shader_still_rejected(tmp_path): + # A directive-free shader fragment must still fall to the weak tier: + # gl_/vec lines with no F# declaration anywhere reject. + from graphify.extract import _get_extractor + p = tmp_path / "gl.fs" + p.write_text("// pass-through\ngl_FragColor = vec4(1.0);\n", + encoding="utf-8") + assert _get_extractor(p) is None diff --git a/uv.lock b/uv.lock index 888412e05..76fc5af2d 100644 --- a/uv.lock +++ b/uv.lock @@ -1148,6 +1148,7 @@ all = [ { name = "tiktoken" }, { name = "tree-sitter-commonlisp" }, { name = "tree-sitter-dm" }, + { name = "tree-sitter-fsharp" }, { name = "tree-sitter-hcl" }, { name = "tree-sitter-ocaml" }, { name = "tree-sitter-pascal" }, @@ -1173,6 +1174,9 @@ dm = [ falkordb = [ { name = "falkordb" }, ] +fsharp = [ + { name = "tree-sitter-fsharp" }, +] gemini = [ { name = "openai" }, { name = "tiktoken" }, @@ -1320,6 +1324,8 @@ requires-dist = [ { name = "tree-sitter-dm", marker = "extra == 'dm'" }, { name = "tree-sitter-elixir", specifier = ">=0.3,<0.5" }, { name = "tree-sitter-fortran", specifier = ">=0.6,<0.8" }, + { name = "tree-sitter-fsharp", marker = "extra == 'all'" }, + { name = "tree-sitter-fsharp", marker = "extra == 'fsharp'" }, { name = "tree-sitter-go", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-groovy", specifier = ">=0.1,<0.3" }, { name = "tree-sitter-hcl", marker = "extra == 'all'" }, @@ -1353,7 +1359,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "robot", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "fsharp", "robot", "all"] [package.metadata.requires-dev] dev = [ @@ -4629,6 +4635,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/e3/bb2c89f65497b3c8d43fb71fd6f47fef098dc3e3b0bf16083f6f9e4fc92d/tree_sitter_fortran-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:45b0e226325e626101949d6aafcf0422fc210c3cf3ae9b9a2281b41f47d9cc20", size = 379749, upload-time = "2026-04-24T14:15:11.079Z" }, ] +[[package]] +name = "tree-sitter-fsharp" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/bf/ff2f4ab3cf4e574a491c2d30811f90fbdf6110044f955aab842bf4227155/tree_sitter_fsharp-0.3.11.tar.gz", hash = "sha256:cd3ab061850df53bd1b98a20832256252a3a80acabfd51dda36990c8eadeee15", size = 4154593, upload-time = "2026-07-30T13:05:45.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f7/6a4b052fe746a31a783cf4ae8a2fc09f722d616663bba2e9feb3079d01af/tree_sitter_fsharp-0.3.11-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a0de87dc58e1617c927857f3b9d225a6cecaa05fa2222a702745cf5fd4077763", size = 1308147, upload-time = "2026-07-30T13:05:36.247Z" }, + { url = "https://files.pythonhosted.org/packages/0f/72/0d92c8a4f822e04ef598fe56a9f9c7c5f4124fd8f852a83367bb24c22c47/tree_sitter_fsharp-0.3.11-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:87c3abca2f8b9ef7c591f01a30556b4d409db1b20374abe7c5ebf69e95615bb2", size = 1420693, upload-time = "2026-07-30T13:05:37.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/59/5fd60b4014a58418a9a486beaa059f7cf8316836446d80ef0fd63f05edc9/tree_sitter_fsharp-0.3.11-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:feb7e8c380a1f682310875559bfa1297d1643dfc5ec30c5dfd9d54310a4c7717", size = 1419754, upload-time = "2026-07-30T13:05:38.629Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b0/344f72a5e9b75f8f1e8b81b116303c268948782fdbc2b6af245d2573da43/tree_sitter_fsharp-0.3.11-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de053d7b182e64c5a961690be69e8c27961360be3539198a2ac64c6d71e8b48f", size = 1422265, upload-time = "2026-07-30T13:05:39.721Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9e/8471950cb7169954fdd62dea22ed2300844e3fc4bd90fe1896ef46fbb2db/tree_sitter_fsharp-0.3.11-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d31390e1d9b162c1bef091d441772824e109c4f95aab11a527533a2794ce5e7c", size = 1413629, upload-time = "2026-07-30T13:05:40.733Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/3151d41dc3a2098be7a18999a4540d1aeefdcab2cee5c98506be64bf8e03/tree_sitter_fsharp-0.3.11-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a700d8c765e1c850b5c7afb42966936d99b4607e4df896bcf8a15f3971858d30", size = 1415594, upload-time = "2026-07-30T13:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/e175931c551c9e3cafb1058a0195e909148f5abea527baf7b68e098f66c1/tree_sitter_fsharp-0.3.11-cp310-abi3-win_amd64.whl", hash = "sha256:a238e3eeb1da24ad8364f250973178ff5ea265aeadbd31a372c5b0d7bd28f946", size = 1308869, upload-time = "2026-07-30T13:05:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/09/18/c8b2b66f14f755e3ee8a60dade772d9319ea242b0a920ea8cab31ebfe172/tree_sitter_fsharp-0.3.11-cp310-abi3-win_arm64.whl", hash = "sha256:a88a15c392a644d4ea55e88eacc7d058bae5b6ebf7b2cfa723816a5f9d03ec3f", size = 1301250, upload-time = "2026-07-30T13:05:44.281Z" }, +] + [[package]] name = "tree-sitter-go" version = "0.25.0"