diff --git a/README.md b/README.md index bd1dff423..2a48d7379 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` | | `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` | | `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` | +| `cfml` | CFML / ColdFusion `.cfc`/`.cfm`/`.cfs` AST extraction | `uv tool install "graphifyy[cfml]"` | | `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` | | `all` | Everything above | `uv tool install "graphifyy[all]"` | @@ -327,7 +328,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml .cfc .cfm .cfs` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.cfc`/`.cfm`/`.cfs` require `uv tool install graphifyy[cfml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | diff --git a/graphify/detect.py b/graphify/detect.py index d6eaf3318..a97e90a9a 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.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'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.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', '.cfc', '.cfm', '.cfs'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.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 a6fc74027..3151ac192 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -32,6 +32,7 @@ from graphify.extractors.apex import extract_apex # noqa: F401 from graphify.extractors.bash import extract_bash # noqa: F401 from graphify.extractors.blade import extract_blade # noqa: F401 +from graphify.extractors.cfml import extract_cfml # noqa: F401 from graphify.extractors.csharp import ( _resolve_cross_file_csharp_imports, _resolve_csharp_type_references, @@ -1743,6 +1744,7 @@ def _canonicalize_csharp_namespace_nodes(all_nodes: list[dict], all_edges: list[ ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", # PHP fns/classes ".sql", # SQL identifiers ".nim", ".nims", ".nimble", # Nim (style-insensitive) + ".cfc", ".cfm", ".cfs", # CFML tags/functions }) @@ -1783,6 +1785,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".php5": "php", ".php7": "php", ".phps": "php", ".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet", ".lua": "lua", ".luau": "lua", + ".cfc": "cfml", ".cfm": "cfml", ".cfs": "cfml", ".zig": "zig", ".ex": "elixir", ".exs": "elixir", ".jl": "julia", @@ -3739,6 +3742,9 @@ def add_existing_edge(edge: dict) -> None: ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".cfc": extract_cfml, + ".cfm": extract_cfml, + ".cfs": extract_cfml, } diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..4c1b4ccfe 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -13,6 +13,7 @@ from graphify.extractors.apex import extract_apex from graphify.extractors.bash import extract_bash from graphify.extractors.blade import extract_blade +from graphify.extractors.cfml import extract_cfml from graphify.extractors.dart import extract_dart from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm from graphify.extractors.elixir import extract_elixir @@ -37,6 +38,7 @@ "apex": extract_apex, "bash": extract_bash, "blade": extract_blade, + "cfml": extract_cfml, "dart": extract_dart, "delphi_form": extract_delphi_form, "dm": extract_dm, diff --git a/graphify/extractors/cfml.py b/graphify/extractors/cfml.py new file mode 100644 index 000000000..56b1ce20b --- /dev/null +++ b/graphify/extractors/cfml.py @@ -0,0 +1,350 @@ +"""CFML (ColdFusion) extractor for .cfc/.cfm/.cfs files, using tree-sitter-cfml.""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id, _read_text +from graphify.extractors.resolution import _cfml_resolve_component + +# Common CFML built-in functions, filtered out of cross-file call resolution so +# they don't accumulate into god-nodes (mirrors _LANGUAGE_BUILTIN_GLOBALS, #726). +_CFML_BUILTINS = frozenset({ + "writeoutput", "writedump", "writelog", "structnew", "structkeyexists", + "structdelete", "structcount", "structcopy", "structinsert", "structget", + "structeach", "structsort", "structappend", "structisempty", "structkeylist", + "structkeyarray", "structkeytranslate", + "arraynew", "arraylen", "arrayappend", "arrayinsertat", "arraydeleteat", + "arrayslice", "arraysort", "arrayfilter", "arraymap", "arrayeach", + "arraytolist", "arraycontains", "arrayfind", + "listappend", "listtoarray", "listlen", "listgetat", "listfind", + "listcontains", "listfirst", "listlast", "listrest", "listsort", + "querynew", "queryexecute", "queryaddrow", "querysetcell", + "isdefined", "isnull", "isarray", "isstruct", "isquery", "isjson", + "issimplevalue", "isnumeric", "isboolean", "isdate", "isvalid", + "iscustomfunction", "isobject", "variablesexists", + "len", "trim", "ltrim", "rtrim", "ucase", "lcase", "left", "right", + "mid", "replace", "replacenocase", "find", "findnocase", "refind", + "refindnocase", "rereplace", "rereplacenocase", "reversestring", + "dateformat", "timeformat", "now", "createdate", "createdatetime", + "createtime", "createodbcdate", "createodbcdatetime", "dateadd", + "datediff", "datecompare", "day", "month", "year", "hour", "minute", + "second", "dayofweek", + "serializejson", "deserializejson", "encodeforhtml", "encodeforhtmlattribute", + "encodeforjavascript", "encodeforurl", "encodeforxml", "htmleditformat", + "htmlcodeformat", "urlencodedformat", "duplicate", "tostring", "tobinary", + "tobase64", "tonumeric", + "val", "int", "abs", "round", "min", "max", "randrange", "rand", + "createuuid", "createguid", "throw", "rethrow", "abort", "location", + "getfunctionlist", "getmetadata", "gettemplatepath", "getbasetagdata", + "expandpath", "fileexists", "directoryexists", "fileread", "filewrite", + "fileopen", "fileclose", "directorylist", "directorycreate", + "javacast", "precisionevaluate", +}) + + +def _tag_name(node, source: bytes) -> str: + """Recover a CF tag's lowercase keyword ('function', 'set', 'property', ...). + + The paired forms (cf_tag/cf_start_tag/cf_end_tag) expose it as a real + cf_tag_name child, but the generic self-closing form (cf_selfclose_tag -- + shared by cfproperty/cfargument/cfinclude/cfimport/... alike) has its + keyword consumed by the external scanner without emitting any node for it, + so it's recovered here from the node's own raw text instead. + """ + for child in node.children: + if child.type == "cf_tag_name": + return _read_text(child, source).lower() + m = re.match(r" str | None: + """Static string value of a tag attribute, or None if absent/dynamic (#hash#).""" + for attr in _iter_tag_attributes(tag_node): + name_node = next((c for c in attr.children if c.type == "cf_attribute_name"), None) + if name_node is None or _read_text(name_node, source).lower() != name: + continue + for value_holder in attr.children: + if value_holder.type in ("quoted_cf_attribute_value", "cf_attribute_value"): + value_node = next( + (c for c in value_holder.children if c.type == "attribute_value"), None + ) + return _read_text(value_node, source) if value_node is not None else None + return None + return None + + +def _script_attr(node, name: str, source: bytes) -> str | None: + """Static string value of a cfscript component_attribute (extends=..., name=...).""" + for child in node.children: + if child.type != "component_attribute": + continue + ident = next((c for c in child.children if c.type == "identifier"), None) + if ident is None or _read_text(ident, source).lower() != name: + continue + string_node = next((c for c in child.children if c.type == "string"), None) + return _string_fragment(string_node, source) if string_node is not None else None + return None + + +def _string_fragment(string_node, source: bytes) -> str | None: + frag = next((c for c in string_node.children if c.type == "string_fragment"), None) + return _read_text(frag, source) if frag is not None else None + + +def extract_cfml(path: Path) -> dict: + """Extract components, functions, properties, includes, and calls from CFML. + + Handles .cfc/.cfm (both tag- and script-syntax) and .cfs (pure CFScript), + using the tree-sitter-cfml grammars: ``cfml`` for tag-based markup, + ``cfscript`` for script-style component bodies (the cfml grammar treats a + top-level ``component { ... }``/``interface { ... }`` block as opaque, + unparsed content, so script-style .cfc files are re-parsed with the + cfscript grammar instead). A ```` block embedded in an otherwise + tag-based file is likewise re-parsed with the cfscript grammar and merged + in, with line numbers offset to the enclosing file. + + Produces nodes for: + - the file itself (doubling as the component/interface definition) + - cffunction tags / cfscript function declarations + - cfproperty tags / cfscript property declarations + + Produces edges for: + - file --inherits--> base component (extends=, resolved by dotted path) + - file --implements--> interface (implements=, comma-separated) + - file --contains--> function / property + - file --imports--> cfinclude template (resolved relative to this file) / + cfimport taglib (stub -- not a resolvable single file) + - function --instantiates--> component (new Foo()/createObject("component", "Foo")) + - function --calls--> other function (same-file; cross-file via raw_calls) + + Member calls (obj.method()) are not resolved -- CFML has no static typing + to infer a receiver's component from, so guessing would produce wrong + cross-file edges; they're silently skipped like every other bespoke + extractor without receiver-type inference. + """ + try: + import tree_sitter_cfml as ts_cfml + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-cfml not installed"} + + try: + source = path.read_bytes() + except OSError: + return {"nodes": [], "edges": [], "error": f"cannot read {path}"} + + cfml_parser = Parser(Language(ts_cfml.language_cfml())) + script_parser = Parser(Language(ts_cfml.language_cfscript())) + + try: + if path.suffix.lower() == ".cfs": + root = script_parser.parse(source).root_node + else: + root = cfml_parser.parse(source).root_node + if root.child_count == 1 and root.children[0].type == "component_file": + # Top-level `component { ... }` / `interface { ... }` script + # syntax: the cfml (tag) grammar treats this as an opaque blob. + root = script_parser.parse(source).root_node + except Exception as exc: + return {"nodes": [], "edges": [], "error": str(exc)} + + str_path = str(path) + stem = _file_stem(path) + file_nid = _make_id(str_path) + nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code", + "source_file": str_path, "source_location": None}] + edges: list[dict] = [] + raw_calls: list[dict] = [] + seen_ids: set[str] = {file_nid} + seen_edges: set[tuple[str, str, str]] = set() + seen_call_pairs: set[tuple[str, str]] = set() + local_functions: dict[str, str] = {} # lowercase name -> fn_nid, same-file calls + call_sites: list = [] # (node, source, caller_nid, line), resolved after decls + + def add_node(nid: str, label: str, line: int) -> None: + 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}"}) + + def add_edge(src: str, tgt: str, relation: str, line: int, context: str | None = None) -> None: + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) + edge: dict = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0} + if context: + edge["context"] = context + edges.append(edge) + + def link_component(src_nid: str, dotted_name: str, relation: str, line: int, + resolve: bool = True) -> None: + tgt_nid = (_cfml_resolve_component(path, dotted_name) if resolve else None) + if tgt_nid is None: + tgt_nid = _make_id(dotted_name) + if tgt_nid != src_nid: + add_node(tgt_nid, dotted_name, line) + if tgt_nid != src_nid: + add_edge(src_nid, tgt_nid, relation, line) + + def link_file(src_nid: str, raw_path: str, relation: str, line: int) -> None: + # cfinclude target: a plain relative file path, not a dotted component + # name -- resolved relative to this file's own directory. + candidate = (path.parent / raw_path) + tgt_nid = _make_id(str(candidate.resolve())) if candidate.is_file() else _make_id(raw_path) + if tgt_nid != src_nid: + add_node(tgt_nid, raw_path, line) + add_edge(src_nid, tgt_nid, relation, line) + + def add_instantiation(caller_nid: str, dotted_name: str, line: int) -> None: + tgt_nid = _cfml_resolve_component(path, dotted_name) or _make_id(dotted_name) + if tgt_nid == caller_nid: + return + add_node(tgt_nid, dotted_name, line) + pair = (caller_nid, tgt_nid) + if pair in seen_call_pairs: + return + seen_call_pairs.add(pair) + add_edge(caller_nid, tgt_nid, "instantiates", line, context="call") + + def handle_call_like(node, source: bytes, caller_nid: str, line: int) -> None: + if node.type == "new_expression": + target_node = next( + (c for c in node.children if c.is_named and c.type != "arguments"), None + ) + if target_node is not None: + dotted = _read_text(target_node, source) + if dotted: + add_instantiation(caller_nid, dotted, line) + return + + if not node.children: + return + callee_node = node.children[0] + if callee_node.type == "member_expression": + return # no receiver-type inference for CFML -- skip rather than guess + if callee_node.type != "identifier": + return + callee = _read_text(callee_node, source) + if not callee: + return + if callee.lower() == "createobject": + args_node = next((c for c in node.children if c.type == "arguments"), None) + str_args = [c for c in args_node.children if c.type == "string"] if args_node else [] + if len(str_args) >= 2: + kind = _string_fragment(str_args[0], source) + dotted = _string_fragment(str_args[1], source) + if kind and kind.lower() == "component" and dotted: + add_instantiation(caller_nid, dotted, line) + return + if callee.lower() in _CFML_BUILTINS: + return + local_target = local_functions.get(callee.lower()) + if local_target: + if local_target == caller_nid: + return + pair = (caller_nid, local_target) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + add_edge(caller_nid, local_target, "calls", line, context="call") + return + raw_calls.append({"source_file": str_path, "source_location": f"L{line}", + "caller_nid": caller_nid, "callee": callee}) + + def walk(node, source: bytes, caller_nid: str, line_offset: int) -> None: + # `source` is the byte buffer node's own offsets are relative to -- the + # full file for the primary tree, or the extracted snippet + # for a nested reparse (see the cf_script_tag branch below). Using the + # wrong buffer silently reads garbage text at the right line number. + t = node.type + line = node.start_point[0] + line_offset + 1 + + if t == "cf_component_open_tag" or t == "component": + get_attr = _tag_attr if t == "cf_component_open_tag" else _script_attr + extends = get_attr(node, "extends", source) + implements = get_attr(node, "implements", source) + if extends: + link_component(file_nid, extends, "inherits", line) + if implements: + for iface in re.split(r"\s*,\s*", implements.strip()): + if iface: + link_component(file_nid, iface, "implements", line) + + elif t == "cf_function_tag": + name = _tag_attr(node, "name", source) + if name: + fn_nid = _make_id(stem, name) + add_node(fn_nid, f"{name}()", line) + add_edge(file_nid, fn_nid, "contains", line) + local_functions[name.lower()] = fn_nid + caller_nid = fn_nid + elif t == "function_declaration": + name_node = next((c for c in node.children if c.type == "identifier"), None) + if name_node is not None: + name = _read_text(name_node, source) + fn_nid = _make_id(stem, name) + add_node(fn_nid, f"{name}()", line) + add_edge(file_nid, fn_nid, "contains", line) + local_functions[name.lower()] = fn_nid + caller_nid = fn_nid + + elif t == "cf_selfclose_tag": + kw = _tag_name(node, source) + if kw == "property": + name = _tag_attr(node, "name", source) + if name: + prop_nid = _make_id(stem, name) + add_node(prop_nid, name, line) + add_edge(file_nid, prop_nid, "contains", line) + elif kw == "include": + template = _tag_attr(node, "template", source) + if template: + link_file(file_nid, template, "imports", line) + elif kw == "import": + taglib = _tag_attr(node, "taglib", source) + if taglib: + link_component(file_nid, taglib, "imports", line, resolve=False) + elif t == "property_declaration": + name = _script_attr(node, "name", source) + if name: + prop_nid = _make_id(stem, name) + add_node(prop_nid, name, line) + add_edge(file_nid, prop_nid, "contains", line) + + elif t == "cf_script_tag": + content = next((c for c in node.children if c.type == "cf_script_content"), None) + if content is not None and content.end_byte > content.start_byte: + snippet = source[content.start_byte:content.end_byte] + sub_root = script_parser.parse(snippet).root_node + walk(sub_root, snippet, caller_nid, line_offset + content.start_point[0]) + return # cf_script_content has no children of its own to recurse into + + elif t in ("call_expression", "new_expression"): + # Deferred to a second pass (below) so a call to a function + # declared LATER in the same file still resolves via + # local_functions instead of falling through to raw_calls. + call_sites.append((node, source, caller_nid, line)) + + for child in node.children: + walk(child, source, caller_nid, line_offset) + + walk(root, source, file_nid, 0) + for node, node_source, caller_nid, line in call_sites: + handle_call_like(node, node_source, caller_nid, line) + + return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls} diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 528c7064b..f959e516b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2285,3 +2285,60 @@ def _pascal_resolve_class(from_path: Path, class_name: str) -> str | None: if file_stem: return _make_id(file_stem, class_name) return None + + +_cfml_component_cache: dict[str, dict[str, str]] = {} # root_key -> {dotted_lower: node_id} + + +def _cfml_project_root(from_path: Path) -> Path: + """Return the highest ancestor directory that looks like a CFML webroot. + + Walks up the directory tree and tracks the topmost directory that has at + least 2 .cfc files as direct children (mirrors _pascal_project_root's + minimum-2 threshold, avoiding overshoot from a single stray .cfc). Falls + back to from_path.parent if nothing better is found. + """ + best = from_path.parent + current = from_path.parent + for _ in range(12): + if len(current.parts) <= 1: + break # never use a filesystem root + if sum(1 for _ in current.glob("*.cfc")) >= 2: + best = current + parent = current.parent + if parent == current: + break + current = parent + return best + + +def _cfml_resolve_component(from_path: Path, dotted_name: str) -> str | None: + """Resolve a dotted CFML component path (``models.User``) to the node ID + of its defining .cfc file. + + CFML convention: a dotted path is the file's path relative to the webroot + with dots standing in for path separators (``models.User`` -> + ``models/User.cfc``). Matching is case-insensitive (CFML component paths + are not case-sensitive on any supported engine). Falls back to matching + on the bare last segment when the full dotted path isn't found, so a + component referenced by an unconventional/partial path can still resolve. + + Returns None when no matching file is found on disk (external library, + mapped alias graphify can't see, or unconventional path) -- caller should + create a stub node. + """ + root = _cfml_project_root(from_path) + root_key = str(root) + if root_key not in _cfml_component_cache: + comp_map: dict[str, str] = {} + for f in root.rglob("*.cfc"): + rel = f.relative_to(root).with_suffix("") + comp_map[".".join(rel.parts).lower()] = _make_id(str(f)) + comp_map.setdefault(f.stem.lower(), _make_id(str(f))) + _cfml_component_cache[root_key] = comp_map + + key = dotted_name.strip(".").lower() + comp_map = _cfml_component_cache[root_key] + if key in comp_map: + return comp_map[key] + return comp_map.get(key.rsplit(".", 1)[-1]) diff --git a/pyproject.toml b/pyproject.toml index 6e6c54fb9..6482112f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,12 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp", "starlette>=1.3.1", "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"] +# extract_cfml() uses tree-sitter-cfml (.cfc/.cfm/.cfs ColdFusion/CFML) for AST +# extraction. Ships prebuilt wheels (e.g. manylinux), so this stays optional +# for the same reason pascal/sql do -- not everyone touches CFML -- rather +# than needing a C toolchain workaround. +cfml = ["tree-sitter-cfml>=0.26.30,<0.27"] +all = ["mcp", "starlette>=1.3.1", "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-cfml>=0.26.30,<0.27"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/fixtures/sample.cfc b/tests/fixtures/sample.cfc new file mode 100644 index 000000000..6a1b84dd7 --- /dev/null +++ b/tests/fixtures/sample.cfc @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + private function queryFetch() { + var q = new Query(); + q.setSQL("SELECT * FROM widgets"); + return q.execute(); + } + + + diff --git a/tests/fixtures/sample.cfm b/tests/fixtures/sample.cfm new file mode 100644 index 000000000..684b9d469 --- /dev/null +++ b/tests/fixtures/sample.cfm @@ -0,0 +1,4 @@ + + + +#obj.getName()# diff --git a/tests/fixtures/sample_script.cfc b/tests/fixtures/sample_script.cfc new file mode 100644 index 000000000..4130ff45b --- /dev/null +++ b/tests/fixtures/sample_script.cfc @@ -0,0 +1,21 @@ +component extends="base.BaseComponent" implements="IWidget" { + + property name="id" type="numeric"; + + function init(numeric id) { + variables.id = id; + return this; + } + + function load() { + var result = queryFetch(); + var helper = createObject("component", "utils.Helper"); + return helper.process(result); + } + + private function queryFetch() { + var q = new Query(); + return q.execute(); + } + +} diff --git a/tests/test_languages.py b/tests/test_languages.py index f610fbc40..000ff349a 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -2945,3 +2945,136 @@ def test_decldef_merge_does_not_merge_same_name_same_dir_distinct_files(): r = _corpus("cpp_samedir/Alpha.h", "cpp_samedir/Beta.h") dups = _nodes_with_label(r, "Dup") assert len(dups) == 2, f"same-dir distinct Dups must stay distinct, got {[n['id'] for n in dups]}" + + +# ---------------CFML / ColdFusion (.cfc tag & script syntax, .cfm)------------- + +import importlib.util as _ilu2 +_needs_cfml = pytest.mark.skipif( + _ilu2.find_spec("tree_sitter_cfml") is None, + reason="tree-sitter-cfml not installed (optional [cfml] extra)", +) + +from graphify.extract import extract_cfml + + +@_needs_cfml +def test_cfml_tag_no_error(): + r = extract_cfml(FIXTURES / "sample.cfc") + assert "error" not in r + + +@_needs_cfml +def test_cfml_tag_finds_inherits_and_implements(): + r = extract_cfml(FIXTURES / "sample.cfc") + assert "inherits" in _relations(r) + assert "implements" in _relations(r) + + +@_needs_cfml +def test_cfml_tag_finds_functions_and_property(): + r = extract_cfml(FIXTURES / "sample.cfc") + labels = _labels(r) + assert any("init" in l for l in labels) + assert any("load" in l for l in labels) + assert "id" in labels + + +@_needs_cfml +def test_cfml_tag_finds_nested_cfscript_function(): + """The block's queryFetch() is parsed with the cfscript grammar + (the cfml/tag grammar leaves content opaque) and merged in.""" + r = extract_cfml(FIXTURES / "sample.cfc") + labels = _labels(r) + assert any("queryFetch" in l for l in labels) + + +@_needs_cfml +def test_cfml_tag_finds_instantiation(): + r = extract_cfml(FIXTURES / "sample.cfc") + assert "instantiates" in _relations(r) + labels = _labels(r) + assert any("utils.Helper" in l for l in labels) + + +@_needs_cfml +def test_cfml_tag_resolves_same_file_call(): + """load() calls queryFetch(), defined later in the same file (inside the + nested block) -- must resolve to a direct `calls` edge, not be + left for cross-file raw_calls resolution.""" + r = extract_cfml(FIXTURES / "sample.cfc") + assert ("load()", "queryFetch()") in _calls(r) + assert r.get("raw_calls") == [] + + +@_needs_cfml +def test_cfml_tag_no_dangling_edges(): + r = extract_cfml(FIXTURES / "sample.cfc") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids + + +@_needs_cfml +def test_cfml_script_no_error(): + r = extract_cfml(FIXTURES / "sample_script.cfc") + assert "error" not in r + + +@_needs_cfml +def test_cfml_script_finds_inherits_and_implements(): + """Script-syntax `component extends="..." implements="..." { }` -- the cfml + grammar treats this as opaque, so it must be re-parsed with cfscript.""" + r = extract_cfml(FIXTURES / "sample_script.cfc") + assert "inherits" in _relations(r) + assert "implements" in _relations(r) + + +@_needs_cfml +def test_cfml_script_resolves_same_file_call(): + r = extract_cfml(FIXTURES / "sample_script.cfc") + assert ("load()", "queryFetch()") in _calls(r) + assert r.get("raw_calls") == [] + + +@_needs_cfml +def test_cfml_script_no_dangling_edges(): + r = extract_cfml(FIXTURES / "sample_script.cfc") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids + + +@_needs_cfml +def test_cfml_template_finds_includes_and_instantiation(): + r = extract_cfml(FIXTURES / "sample.cfm") + assert "imports" in _relations(r) + assert "instantiates" in _relations(r) + labels = _labels(r) + assert any("header.cfm" in l for l in labels) + assert any("models.User" in l for l in labels) + + +@_needs_cfml +def test_cfml_template_no_dangling_edges(): + r = extract_cfml(FIXTURES / "sample.cfm") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids + + +def test_cfml_missing_grammar_returns_error_not_crash(monkeypatch): + """Without tree-sitter-cfml installed, extract_cfml degrades to an error + dict instead of raising -- exercised unconditionally (monkeypatches the + import), unlike the tests above which need the real optional dependency.""" + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "tree_sitter_cfml": + raise ImportError("simulated missing optional dependency") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + r = extract_cfml(FIXTURES / "sample.cfc") + assert r == {"nodes": [], "edges": [], "error": "tree-sitter-cfml not installed"} diff --git a/uv.lock b/uv.lock index 088ebbbdc..86a1e2053 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.11" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1145,6 +1145,7 @@ all = [ { name = "python-docx" }, { name = "starlette" }, { name = "tiktoken" }, + { name = "tree-sitter-cfml" }, { name = "tree-sitter-dm" }, { name = "tree-sitter-hcl" }, { name = "tree-sitter-pascal" }, @@ -1158,6 +1159,9 @@ anthropic = [ bedrock = [ { name = "boto3" }, ] +cfml = [ + { name = "tree-sitter-cfml" }, +] chinese = [ { name = "jieba" }, ] @@ -1296,6 +1300,8 @@ requires-dist = [ { name = "tree-sitter-bash", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-c", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-c-sharp", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-cfml", marker = "extra == 'all'", specifier = ">=0.26.30,<0.27" }, + { name = "tree-sitter-cfml", marker = "extra == 'cfml'", specifier = ">=0.26.30,<0.27" }, { name = "tree-sitter-cpp", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-dm", marker = "extra == 'all'" }, { name = "tree-sitter-dm", marker = "extra == 'dm'" }, @@ -1331,7 +1337,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", "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", "cfml", "all"] [package.metadata.requires-dev] dev = [ @@ -4525,6 +4531,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/fb/114ff43fdd256d0befed32f77c1dadee9517867181c70794571f718ed05c/tree_sitter_c_sharp-0.23.5-cp310-abi3-win_arm64.whl", hash = "sha256:2de4ebf95ddc2e92cd3105c8a8e0e7ec646bc82f52bfaf2f3acec0fa2401ec09", size = 337260, upload-time = "2026-04-14T16:11:20.849Z" }, ] +[[package]] +name = "tree-sitter-cfml" +version = "0.26.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/61/9f3a95e26fd50012863a061dc1dc968f843d53e26f920ad1470b13059496/tree_sitter_cfml-0.26.30.tar.gz", hash = "sha256:c510f5839406bbf39873ce8fe83ce96091825be21ad2d8b97e472b60d597431d", size = 1462493, upload-time = "2026-07-10T00:57:15.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/5f/e3abdb3efe8e85c7eb44e775ccc48ed14ccbbeba4fb71775ca3f2413f704/tree_sitter_cfml-0.26.30-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:571968884fd00e9340e6b438ef8a29d536e95052e3e1c7ab183707f7b44df2ad", size = 601089, upload-time = "2026-07-10T00:57:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bd/0377e01f69b6c329c8d584ccdc511498756ee2b5a40d21fe475467403356/tree_sitter_cfml-0.26.30-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3693c3967f2abd0bb329b949412c1b31c03b481c3966b4c0c84dde7bd9278be2", size = 681317, upload-time = "2026-07-10T00:57:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/423f4d5ac62cf0d587f4f85141fa757f8d8bac3eedb1bc81e13d9e530bea/tree_sitter_cfml-0.26.30-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cea1522f1282a9c28314ec5e2eac495c7c63ecb684be7db691acd82ddfe9c721", size = 678457, upload-time = "2026-07-10T00:57:10.989Z" }, + { url = "https://files.pythonhosted.org/packages/77/36/bd64e791721da3f328f9da48ecfa14fec7c6be5322ea8f19637ff337aa14/tree_sitter_cfml-0.26.30-cp310-abi3-win32.whl", hash = "sha256:b693eb01de46183e834edb83ecf26afca16bcf4232efb8600c3fa2655414ecee", size = 557455, upload-time = "2026-07-10T00:57:12.671Z" }, + { url = "https://files.pythonhosted.org/packages/1f/de/ab65511efd042358fc1aed4f9a6c964dae92f07c79da4b62ed402909f30c/tree_sitter_cfml-0.26.30-cp310-abi3-win_amd64.whl", hash = "sha256:6fa9069b4ef0f380c82425c2501cc4b75ecdf6b0e687278dcea314aa3290e5ba", size = 559149, upload-time = "2026-07-10T00:57:14.323Z" }, +] + [[package]] name = "tree-sitter-cpp" version = "0.23.4"