Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)},
}
Expand Down
2 changes: 1 addition & 1 deletion graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
108 changes: 106 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 28 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 30 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 34 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 35 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 35 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 35 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 37 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 39 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 41 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_get_extractor()

fans out to 7 callees (efferent coupling); 46 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Return the correct extractor function for a file, or None if unsupported."""
if path.name.lower().endswith(".blade.php"):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -6691,19 +6782,32 @@ 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:
_resolve_csharp_type_references(cs_results, cs_paths, all_nodes, all_edges)
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
Expand Down
Loading