diff --git a/CHANGELOG.md b/CHANGELOG.md index 076d602c8..66540308a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feat: MATLAB `.m` files now have a dedicated, offline tree-sitter extractor, completing the follow-up to #1702. Content sniffing still routes Objective-C `.m` files to their existing extractor, while MATLAB scripts, functions, nested/local functions, `classdef` classes, inheritance, properties, events, enumerations, `+package` folders, `@Class` folders, private functions, imports, typed receivers, function handles, and conservative cross-file calls are represented structurally. Call resolution preserves MATLAB's call-vs-index ambiguity instead of inventing edges, respects lexical/package/private scope, and cannot merge or resolve into same-named Objective-C/native symbols. + ## 0.9.15 (2026-07-13) - Fix: detection now honors nested `.gitignore`/`.graphifyignore` files below the scan root, not just those at the scan root and above (#1847, thanks @Mohak-Agrawal). git applies a `.gitignore` to everything under its own directory, but graphify only loaded ignore files from the VCS-root-down-to-scan-root chain — so a `vendor/sub/.gitignore` deeper in the tree was never read and its exclusions leaked into the graph. Each directory's own ignore files are now read during the walk and anchored to that directory, preserving last-match-wins precedence (nearer files win, including over `.git/info/exclude`) and the parent-exclusion rule. diff --git a/README.md b/README.md index 0a883e75b..41e26f415 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,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` (`.m` is content-sniffed between MATLAB and Objective-C; `.mm` is Objective-C++; `.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) | | 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/analyze.py b/graphify/analyze.py index 7f3eb72ff..94b4f23ac 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -27,7 +27,7 @@ **{e: "go" for e in (".go",)}, **{e: "rust" for e in (".rs",)}, **{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")}, - **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")}, + **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".m", ".mm")}, **{e: "ruby" for e in (".rb", ".rake")}, **{e: "swift" for e in (".swift",)}, **{e: "dotnet" for e in (".cs",)}, @@ -36,12 +36,17 @@ } -def _cross_language(src_a: str, src_b: str) -> bool: +def _cross_language( + src_a: str, + src_b: str, + family_a: str | None = None, + family_b: str | None = None, +) -> bool: """Return True if two source files belong to different language families.""" ext_a = Path(src_a).suffix.lower() ext_b = Path(src_b).suffix.lower() - fam_a = _LANG_FAMILY.get(ext_a) - fam_b = _LANG_FAMILY.get(ext_b) + fam_a = family_a if family_a and family_a != "native" else _LANG_FAMILY.get(ext_a) + fam_b = family_b if family_b and family_b != "native" else _LANG_FAMILY.get(ext_b) if fam_a is None or fam_b is None: return False return fam_a != fam_b @@ -222,7 +227,15 @@ def _surprise_score( _suppress_structural = ( conf == "INFERRED" and relation in ("calls", "uses") - and (_cross_language(u_source, v_source) or {cat_u, cat_v} == {"code", "doc"}) + and ( + _cross_language( + u_source, + v_source, + G.nodes[u].get("language_family"), + G.nodes[v].get("language_family"), + ) + or {cat_u, cat_v} == {"code", "doc"} + ) ) if _suppress_structural: conf_bonus = 0 diff --git a/graphify/build.py b/graphify/build.py index 428fa1cd4..cedb4b403 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -716,8 +716,22 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if _edge_rel in ("calls", "imports", "imports_from", "references"): src_ext = Path(G.nodes[src].get("source_file") or "").suffix.lower() tgt_ext = Path(G.nodes[tgt].get("source_file") or "").suffix.lower() - src_fam = _EDGE_LANG_FAMILY.get(src_ext) - tgt_fam = _EDGE_LANG_FAMILY.get(tgt_ext) + # ``.m`` is shared by Objective-C and MATLAB. Extractors stamp an + # explicit family for ambiguous suffixes; legacy data falls back to + # the extension map for backwards compatibility. + src_explicit = G.nodes[src].get("language_family") + tgt_explicit = G.nodes[tgt].get("language_family") + # ``native`` is the extraction resolver's broad interop family. The + # build guard intentionally keeps its existing C-vs-Swift boundary; + # only MATLAB needs to override the ambiguous `.m` suffix here. + src_fam = ( + src_explicit if src_explicit and src_explicit != "native" + else _EDGE_LANG_FAMILY.get(src_ext) + ) + tgt_fam = ( + tgt_explicit if tgt_explicit and tgt_explicit != "native" + else _EDGE_LANG_FAMILY.get(tgt_ext) + ) if _edge_rel == "calls": # Unchanged #1547/#1556 behavior: only INFERRED calls, and drop as # soon as either family differs (an unknown ext counts as different). diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py index 181e7493b..20748640f 100644 --- a/graphify/callflow_html.py +++ b/graphify/callflow_html.py @@ -515,10 +515,13 @@ def node_kind(node: dict) -> str: if any(word in label for word in ("component", "props", "hook", "store")) or hook_like or source_file.endswith((".tsx", ".jsx", ".vue", ".svelte")): return "ui" raw = raw_label + if raw.lower().endswith(( + ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", + ".rb", ".php", ".cs", ".swift", ".m", ".mm", ".vue", ".svelte", + )): + return "module" if raw[:1].isupper() and not raw.endswith("()"): return "klass" - if raw.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs", ".swift", ".vue", ".svelte")): - return "module" return "function" diff --git a/graphify/cli.py b/graphify/cli.py index 0bb124777..4c1b1c7b9 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -40,7 +40,7 @@ _HOOK_SOURCE_EXTS = ( '.py', '.js', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', '.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt', - '.swift', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx', + '.swift', '.m', '.mm', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx', ) _GEMINI_NUDGE_TEXT = ( 'graphify: knowledge graph at graphify-out/. For focused questions, run ' diff --git a/graphify/extract.py b/graphify/extract.py index a4ac6cbbd..fd30251e4 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -138,6 +138,7 @@ from graphify.extractors.objc import _objc_local_var_types, extract_objc # noqa: E402,F401 from graphify.extractors.julia import extract_julia # noqa: E402,F401 +from graphify.extractors.matlab import extract_matlab, resolve_matlab_calls # noqa: E402,F401 _RECURSION_LIMIT = 10_000 @@ -167,6 +168,33 @@ def _safe_extract(extractor: Callable, path: Path) -> dict: return {"nodes": [], "edges": [], "error": f"{type(e).__name__}: {e}"} +def _stamp_result_language(result: dict, path: Path) -> None: + """Attach explicit provenance for the ambiguous ``.m`` suffix. + + This runs for both fresh and cached results, so old cache entries cannot + classify MATLAB as Objective-C merely because the languages share a suffix. + """ + language = result.get("language") + family = result.get("language_family") + if not language or not family: + suffix = path.suffix.lower() + if suffix == ".mm" or (suffix == ".m" and _is_objc_source(path)): + language, family = "objective-c", "native" + elif suffix == ".m": + language, family = "matlab", "matlab" + if not language or not family: + return + result["language"] = language + result["language_family"] = family + for item in ( + list(result.get("nodes", [])) + + list(result.get("edges", [])) + + list(result.get("raw_calls", [])) + ): + item.setdefault("language", language) + item.setdefault("language_family", family) + + def _file_node_id(rel_path: Path) -> str: """File-level node ID matching the skill.md spec: ``{parent_dir}_{stem}`` — one parent directory level, no extension. ``rel_path`` MUST be relative to @@ -1792,13 +1820,38 @@ def _lang_is_case_insensitive(source_file: object) -> bool: } -def _lang_family(source_file: object) -> str | None: - """Interop family of the file's language, or None when unknown/not code.""" +_LANG_FAMILY_BY_NAME: dict[str, str] = { + "matlab": "matlab", + "objective-c": "native", + "objc": "native", +} + + +def _lang_family( + source_file: object, + language: object = None, + language_family: object = None, +) -> str | None: + """Interop family, preferring extractor provenance over file suffix.""" + if language_family: + return str(language_family) + if language: + named = _LANG_FAMILY_BY_NAME.get(str(language).lower()) + if named is not None: + return named if not source_file: return None return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower()) +def _is_matlab_node(node: dict) -> bool: + """Return whether a graph node came from the MATLAB extractor.""" + return ( + str(node.get("language", "")).lower() == "matlab" + or str(node.get("language_family", "")).lower() == "matlab" + ) + + def _node_label_key(node: dict, fold: bool = False) -> str: label = str(node.get("label", "")).strip() key = re.sub(r"[^a-zA-Z0-9]+", "", label) @@ -1861,7 +1914,11 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: for endpoint in ("source", "target"): nid = edge.get(endpoint) if nid in stub_ids: - fam = _lang_family(edge.get("source_file")) + fam = _lang_family( + edge.get("source_file"), + edge.get("language"), + edge.get("language_family"), + ) if fam is not None: stub_families.setdefault(str(nid), set()).add(fam) # A stub referenced as a supertype must resolve to a class/type, @@ -1874,20 +1931,41 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: stub_id = str(stub.get("id", "")) if not stub_id: continue - candidates = real_by_label.get(_node_label_key(stub), []) + fams = stub_families.get(stub_id, set()) + + def _family_candidates(items: list[dict]) -> list[dict]: + # Keep legacy permissive behavior for unstamped extractors. MATLAB + # stamps every stub, so its definitions cannot absorb ObjC symbols. + if not fams or not stub.get("language_family"): + return items + return [ + item for item in items + if _lang_family( + item.get("source_file"), + item.get("language"), + item.get("language_family"), + ) in fams + ] + + candidates = _family_candidates(real_by_label.get(_node_label_key(stub), [])) if len(candidates) != 1: # No unique exact type match — fall back to a case-insensitive match, but # only against case-insensitive-language definitions (so a case-sensitive # `PATH` can never absorb a `Path` reference). - candidates = real_by_label_ci.get(_node_label_key(stub, fold=True), []) + candidates = _family_candidates( + real_by_label_ci.get(_node_label_key(stub, fold=True), []) + ) if len(candidates) != 1: # #1781: no unique type — try a unique top-level FUNCTION definition, # gated by (a) the stub not being used as a supertype and (b) a # language-family match with the stub's referrers. fcands = func_by_label.get(_node_label_key(stub), []) if len(fcands) == 1 and stub_id not in supertype_stub_ids: - fams = stub_families.get(stub_id, set()) - cand_fam = _lang_family(fcands[0].get("source_file")) + cand_fam = _lang_family( + fcands[0].get("source_file"), + fcands[0].get("language"), + fcands[0].get("language_family"), + ) if not fams or cand_fam is None or cand_fam in fams: candidates = fcands if len(candidates) != 1: @@ -2056,7 +2134,12 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and not _is_matlab_node(n) + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, from `method` edges. @@ -2242,7 +2325,12 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and not _is_matlab_node(n) + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} @@ -2348,7 +2436,12 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and not _is_matlab_node(n) + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type @@ -2469,7 +2562,12 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and not _is_matlab_node(n) + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type. @@ -2570,6 +2668,7 @@ def key(label: str) -> str: node.get("source_file") and node.get("id") in contained and _is_type_like_definition(node) + and not _is_matlab_node(node) ): type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"]) @@ -2673,7 +2772,12 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and not _is_matlab_node(n) + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} @@ -2779,6 +2883,9 @@ def _key(label: str) -> str: _resolve_objc_member_calls, ) ) +register_language_resolver( + LanguageResolver("matlab_calls", frozenset({".m"}), resolve_matlab_calls) +) # C# receiver-typed member-call resolution (#1609): `field/param/local.Method()` # bound to the receiver's declared type instead of a bare same-named match. register_language_resolver( @@ -3968,18 +4075,42 @@ def _is_objc_header(path: Path) -> bool: ) +_OBJC_SOURCE_DIRECTIVE_RE = re.compile( + rb"^[ \t]*(?:\#[ \t]*import\b|@(?:interface|protocol|implementation|import|class)\b)", + re.MULTILINE, +) +_OBJC_SOURCE_SYNTAX_RE = re.compile( + rb'@"|@(?:selector|autoreleasepool|try|catch|finally|throw|synchronized|encode)\b' + rb"|^[ \t]*[-+][ \t]*\([^\r\n)]*\)[ \t]*[A-Za-z_]", + re.MULTILINE, +) +_OBJC_MESSAGE_FUNCTION_RE = re.compile( + rb"\b[A-Za-z_]\w*(?:[ \t]+[A-Za-z_]\w*)?[ \t*]+" + rb"[A-Za-z_]\w*[ \t]*\([^;{}]*\)[ \t]*\{[^{}]*" + rb"\[[ \t]*[A-Za-z_]\w*[ \t]+[A-Za-z_]", + re.DOTALL, +) + + def _is_objc_source(path: Path) -> bool: """Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702). `.m` is shared by Objective-C implementation files and MATLAB (also Octave). - The suffix map routes `.m` to extract_objc unconditionally, which force-parses - MATLAB through the Objective-C tree-sitter grammar and emits garbage nodes/edges - (worse than skipping). A genuine ObjC `.m` always carries an ObjC directive - (@implementation/@interface/@import/#import); MATLAB has none of them. Reuses - the same marker set as the `.h` sniff. `.mm` is unambiguously Objective-C++ and - is not sniffed. + Content sniffing selects between the dedicated MATLAB and Objective-C + extractors. It recognizes line-anchored ObjC directives, ObjC-only `@` + syntax/method declarations, and conservative C-function message sends while + ignoring MATLAB comments, strings, and function handles that merely contain + those words. `.mm` is unambiguously Objective-C++ and is not sniffed. """ - return _is_objc_header(path) + try: + head = path.read_bytes()[:256 * 1024] + except OSError: + return False + return bool( + _OBJC_SOURCE_DIRECTIVE_RE.search(head) + or _OBJC_SOURCE_SYNTAX_RE.search(head) + or _OBJC_MESSAGE_FUNCTION_RE.search(head) + ) def _is_cpp_header(path: Path) -> bool: @@ -4025,13 +4156,10 @@ def _get_extractor(path: Path) -> Any | None: # grammar has no class_specifier). Reroute to extract_cpp (#1547). if _is_cpp_header(path): return extract_cpp - # `.m` is Objective-C OR MATLAB. extract_objc unconditionally would force-parse - # MATLAB through the ObjC grammar into garbage (#1702). Route to extract_objc - # only when the file actually looks like Objective-C; otherwise leave it without - # an extractor (surfaced by the no-AST-extractor warning, #1689) rather than - # mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc. + # `.m` is Objective-C OR MATLAB. Objective-C keeps the directive-based route; + # every other `.m` uses the dedicated MATLAB parser. `.mm` stays ObjC++. if suffix == ".m" and not _is_objc_source(path): - return None + return extract_matlab # 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. @@ -4334,6 +4462,7 @@ def extract( for i in range(total): if per_file[i] is None: per_file[i] = {"nodes": [], "edges": []} + _stamp_result_language(per_file[i], paths[i]) # #1666: surface any source file an extractor accepted but that produced zero # nodes (not even a file node). Such a file is silently absent from the graph, @@ -4483,11 +4612,22 @@ def extract( for n in all_nodes: if n.get("id") in id_remap: n["id"] = id_remap[n["id"]] + if n.get("parent_function_nid") in id_remap: + n["parent_function_nid"] = id_remap[n["parent_function_nid"]] for e in all_edges: if e.get("source") in id_remap: e["source"] = id_remap[e["source"]] if e.get("target") in id_remap: e["target"] = id_remap[e["target"]] + # Script-level calls use the file node as caller; keep that caller in + # sync with the same portable file-id remap applied to edge endpoints. + for rc in all_raw_calls: + if rc.get("caller_nid") in id_remap: + rc["caller_nid"] = id_remap[rc["caller_nid"]] + for result in per_file: + for item in result.get("matlab_imports", []): + if item.get("caller_nid") in id_remap: + item["caller_nid"] = id_remap[item["caller_nid"]] if prefix_remap: sym_remap: dict[str, str] = {} for n in all_nodes: @@ -4520,6 +4660,8 @@ def extract( for n in all_nodes: if n.get("id") in sym_remap: n["id"] = sym_remap[n["id"]] + if n.get("parent_function_nid") in sym_remap: + n["parent_function_nid"] = sym_remap[n["parent_function_nid"]] for e in all_edges: if e.get("source") in sym_remap: e["source"] = sym_remap[e["source"]] @@ -4532,6 +4674,11 @@ def extract( cn = rc.get("caller_nid") if cn in sym_remap: rc["caller_nid"] = sym_remap[cn] + for result in per_file: + for item in result.get("matlab_imports", []): + caller_nid = item.get("caller_nid") + if caller_nid in sym_remap: + item["caller_nid"] = sym_remap[caller_nid] _merge_swift_extensions(per_file, all_nodes, all_edges) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) @@ -4647,8 +4794,14 @@ def extract( # (test/non-test classification + path proximity). Kept separate from the # file-node-id map because tie-breaking compares the actual file paths. nid_to_source_file: dict[str, str] = {} + nid_to_language_family: dict[str, str] = {} for n in all_nodes: sf = n.get("source_file") + explicit_family = _lang_family( + sf, n.get("language"), n.get("language_family") + ) + if explicit_family is not None: + nid_to_language_family[n["id"]] = explicit_family if not sf: continue nid_to_source_file[n["id"]] = str(sf) @@ -4678,6 +4831,8 @@ def extract( # of these files with no import evidence is gated below (#1659). _JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") for rc in all_raw_calls: + if rc.get("defer_to_language_resolver"): + continue callee = rc.get("callee", "") if not callee: continue @@ -4710,11 +4865,15 @@ def extract( # non-code nodes) are kept, preserving the previous permissive behavior; # real interop pairs (Kotlin↔Java, C↔C++↔ObjC, JS↔TS) share a family and # still resolve. - caller_family = _lang_family(rc.get("source_file")) + caller_family = _lang_family( + rc.get("source_file"), + rc.get("language"), + rc.get("language_family"), + ) if caller_family is not None: candidates = [ c for c in candidates - if (candidate_family := _lang_family(nid_to_source_file.get(c))) is None + if (candidate_family := nid_to_language_family.get(c)) is None or candidate_family == caller_family ] if not candidates: diff --git a/graphify/extractors/MIGRATION.md b/graphify/extractors/MIGRATION.md index 75e1525e2..8ba399549 100644 --- a/graphify/extractors/MIGRATION.md +++ b/graphify/extractors/MIGRATION.md @@ -17,6 +17,7 @@ written so an AI agent can execute it in a single session. | go | yes | | powershell (ps1 + psd1 manifest) | yes | | fortran | yes | +| matlab | yes (dedicated extractor; `.m` content-sniffed against Objective-C) | | sql | yes | | dm (dm/dmm/dmi/dmf) | yes | | bash | yes | diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..63c6d47ec 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -21,6 +21,7 @@ from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia from graphify.extractors.markdown import extract_markdown +from graphify.extractors.matlab import extract_matlab from graphify.extractors.objc import extract_objc from graphify.extractors.pascal import extract_pascal from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form @@ -50,6 +51,7 @@ "julia": extract_julia, "lazarus_form": extract_lazarus_form, "markdown": extract_markdown, + "matlab": extract_matlab, "objc": extract_objc, "pascal": extract_pascal, "powershell": extract_powershell, diff --git a/graphify/extractors/matlab.py b/graphify/extractors/matlab.py new file mode 100644 index 000000000..011efe0fb --- /dev/null +++ b/graphify/extractors/matlab.py @@ -0,0 +1,1101 @@ +"""MATLAB structural extraction and conservative cross-file resolution. + +MATLAB and Objective-C share ``.m``. Routing is handled in ``extract.py``; +this module assumes that a file has already been classified as MATLAB. The +grammar intentionally parses both calls and indexing (``A(1)``/``A{1}``) as a +``function_call``, so this extractor emits a call only when lexical or corpus +evidence identifies the target as callable. Unknown ambiguous expressions are +left unresolved instead of manufacturing graph edges. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _file_stem, _make_id, _read_text + +_LANGUAGE = "matlab" +_FAMILY = "matlab" + + +def _child(node, type_name: str): + return next((c for c in node.children if c.type == type_name), None) + + +def _field(node, name: str): + try: + return node.child_by_field_name(name) + except Exception: + return None + + +def _identifiers(node, source: bytes) -> list[str]: + if node is None: + return [] + out: list[str] = [] + stack = [node] + while stack: + current = stack.pop() + if current.type == "identifier": + value = _read_text(current, source).strip() + if value: + out.append(value) + continue + stack.extend(reversed(current.children)) + return out + + +def _package_name(path: Path) -> str: + return ".".join(part[1:] for part in path.parent.parts if part.startswith("+") and len(part) > 1) + + +def _class_folder(path: Path) -> str | None: + for part in reversed(path.parent.parts): + if part.startswith("@") and len(part) > 1: + return part[1:] + return None + + +def _in_private_folder(path: Path) -> bool: + return any(part.lower() == "private" for part in path.parent.parts) + + +def _qualified(package: str, name: str) -> str: + return f"{package}.{name}" if package else name + + +def _function_name(node, source: bytes) -> str | None: + name_node = _field(node, "name") + if name_node is None: + name_node = _child(node, "identifier") + if name_node is None: + return None + name = _read_text(name_node, source).strip() + raw = _read_text(node, source) + prefix = raw[: max(0, name_node.start_byte - node.start_byte)] + if re.search(r"\bget\.\s*$", prefix): + return f"get.{name}" + if re.search(r"\bset\.\s*$", prefix): + return f"set.{name}" + return name or None + + +def _function_arguments(node, source: bytes) -> list[str]: + args = _child(node, "function_arguments") + if args is None: + return [] + # Preserve ignored (`~`) arguments so arity stays faithful, while callers + # can still exclude the placeholder from lexical variable bindings. + return [ + "~" if c.type == "ignored_argument" else _read_text(c, source).strip() + for c in args.children + if c.type in ("identifier", "ignored_argument") + and (c.type == "ignored_argument" or _read_text(c, source).strip()) + ] + + +def _function_outputs(node, source: bytes) -> list[str]: + output = _child(node, "function_output") + return _identifiers(output, source) + + +def _call_arity(call_node) -> int: + args = _child(call_node, "arguments") + if args is None: + return 0 + return sum(1 for c in args.children if c.is_named) + + +def _call_name(call_node, source: bytes) -> str: + name_node = _field(call_node, "name") + if name_node is None: + name_node = next((c for c in call_node.children if c.type == "identifier"), None) + return _read_text(name_node, source).strip() if name_node is not None else "" + + +def extract_matlab(path: Path) -> dict: + """Extract MATLAB scripts, functions, classes, members, imports and calls.""" + try: + import tree_sitter_matlab as tsmatlab + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-matlab not installed"} + + try: + source = path.read_bytes() + parser = Parser(Language(tsmatlab.language())) + root = parser.parse(source).root_node + except Exception as exc: # parser initialization/read failure + return {"nodes": [], "edges": [], "error": str(exc)} + + str_path = str(path) + stem = _file_stem(path) + package = _package_name(path) + class_folder = _class_folder(path) + is_private = _in_private_folder(path) + nodes: list[dict] = [] + edges: list[dict] = [] + raw_calls: list[dict] = [] + seen_nodes: set[str] = set() + seen_edges: set[tuple[str, str, str, str]] = set() + functions: list[dict[str, Any]] = [] + class_nodes: dict[str, str] = {} + scope_variables: dict[str, set[str]] = {} + scope_handles: dict[str, dict[str, str]] = {} + scope_types: dict[str, dict[str, str]] = {} + matlab_imports: list[dict[str, str]] = [] + seen_imports: set[tuple[str, str]] = set() + + def add_node( + nid: str, + label: str, + line: int | None, + *, + source_file: str | None = str_path, + node_type: str | None = None, + **metadata: Any, + ) -> None: + if nid in seen_nodes: + return + seen_nodes.add(nid) + node: dict[str, Any] = { + "id": nid, + "label": label, + "file_type": "code", + "source_file": source_file or "", + "source_location": f"L{line}" if line else "", + "language": _LANGUAGE, + "language_family": _FAMILY, + } + if node_type: + node["type"] = node_type + node.update({k: v for k, v in metadata.items() if v not in (None, "", [], {})}) + nodes.append(node) + + def add_edge( + src: str, + tgt: str, + relation: str, + line: int | None, + *, + context: str | None = None, + confidence: str = "EXTRACTED", + score: float = 1.0, + ) -> None: + key = (src, tgt, relation, context or "") + if key in seen_edges: + return + seen_edges.add(key) + edge: dict[str, Any] = { + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "confidence_score": score, + "source_file": str_path, + "source_location": f"L{line}" if line else "", + "weight": 1.0, + "language": _LANGUAGE, + "language_family": _FAMILY, + } + if context: + edge["context"] = context + edges.append(edge) + + def ensure_stub(name: str, line: int, *, node_type: str | None = None) -> str: + raw = name.strip() + nid = _make_id(raw) + add_node( + nid, + raw.split(".")[-1], + None, + source_file=None, + node_type=node_type, + qualified_name=raw, + origin_file=str_path, + ) + return nid + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1, node_type="file", package=package) + + package_nid: str | None = None + if package: + package_nid = _make_id("matlab_package", package) + add_node(package_nid, package, 1, node_type="module", qualified_name=package) + add_edge(package_nid, file_nid, "contains", 1, context="package_file") + + def emit_import(node, caller_nid: str = file_nid) -> bool: + if node.type != "command": + return False + cmd = _child(node, "command_name") + if cmd is None or _read_text(cmd, source).strip() != "import": + return False + args = [ + _read_text(c, source).strip() + for c in node.children + if c.type == "command_argument" + ] + line = node.start_point[0] + 1 + for raw in args: + imported = raw.rstrip(".*") + if not imported: + continue + module_nid = _make_id("matlab_package", imported) + add_node(module_nid, imported, line, node_type="module", qualified_name=imported) + add_edge(caller_nid, module_nid, "imports", line, context="import") + import_key = (caller_nid, imported) + if import_key not in seen_imports: + seen_imports.add(import_key) + matlab_imports.append({ + "path": str_path, + "caller_nid": caller_nid, + "name": imported, + "raw": raw, + }) + return True + + def class_property_type(property_node) -> str | None: + name_node = _field(property_node, "name") + passed_name = False + for child in property_node.children: + if child is name_node: + passed_name = True + continue + if not passed_name or not child.is_named: + continue + if child.type in ("dimensions", "validation_functions", "default_value"): + continue + raw = _read_text(child, source).strip() + if raw and re.match(r"^[A-Za-z]\w*(?:\.\w+)*$", raw): + return raw + return None + + def section_access(section) -> str | None: + header = _read_text(section, source).splitlines()[0] + match = re.search(r"\bAccess\s*=\s*([A-Za-z]\w*)", header, re.IGNORECASE) + return match.group(1).lower() if match else None + + def create_function( + node, + container_nid: str, + *, + owner_class: str | None = None, + parent_function: str | None = None, + is_top_level: bool = False, + is_primary: bool = False, + declaration_only: bool = False, + is_static: bool = False, + visibility: str | None = None, + ) -> str | None: + name = _function_name(node, source) + if not name: + return None + args = _function_arguments(node, source) + outputs = _function_outputs(node, source) + line = node.start_point[0] + 1 + if parent_function: + nid = _make_id(parent_function, name) + relation = "contains" + label = f"{name}()" + kind = "nested_function" + elif owner_class: + nid = _make_id(container_nid, name) + relation = "method" + label = f".{name}()" + kind = "method" + else: + nid = _make_id(stem, name) + relation = "contains" + label = f"{name}()" + kind = "function" + qualified_name = _qualified(package, f"{owner_class}.{name}" if owner_class else name) + add_node( + nid, + label, + line, + node_type=kind, + symbol_name=name, + qualified_name=qualified_name, + owner_class_name=owner_class, + parent_function_nid=parent_function, + parameter_names=args, + output_names=outputs, + arity=len(args), + is_primary=is_primary, + is_exported=bool((is_primary and name == path.stem) or owner_class), + file_name_matches=bool(not is_primary or name == path.stem), + declaration_only=declaration_only, + is_static=is_static, + visibility=visibility or ("private" if is_private else "public"), + package=package, + ) + add_edge(container_nid, nid, relation, line) + for existing in nodes: + if existing.get("id") == nid: + existing["_callable"] = True + break + scope_variables[nid] = {arg for arg in args if arg != "~"} | set(outputs) + scope_handles[nid] = {} + scope_types[nid] = {} + for argument_block in (c for c in node.children if c.type == "arguments_statement"): + for declaration in argument_block.children: + if declaration.type != "property": + continue + argument_name_node = _field(declaration, "name") + argument_type = class_property_type(declaration) + if argument_name_node is not None and argument_type: + scope_types[nid][_read_text(argument_name_node, source).strip()] = argument_type + functions.append({ + "nid": nid, + "node": node, + "name": name, + "owner_class": owner_class, + "parent_function": parent_function, + "is_top_level": is_top_level, + "is_primary": is_primary, + "is_static": is_static, + }) + return nid + + def collect_bindings( + node, + variables: set[str], + handles: dict[str, str], + types: dict[str, str], + ) -> None: + if node.type in ("function_definition", "class_definition"): + return + if node.type == "assignment": + left = _field(node, "left") + right = _field(node, "right") + if left is not None: + if left.type in ("identifier", "multioutput_variable"): + variables.update(_identifiers(left, source)) + if left is not None and left.type == "identifier" and right is not None and right.type == "handle_operator": + target_ids = _identifiers(right, source) + if target_ids: + handles[_read_text(left, source).strip()] = ".".join(target_ids) + if left is not None and left.type == "identifier" and right is not None and right.type == "function_call": + constructor = _call_name(right, source) + if constructor[:1].isupper() and constructor not in variables: + types[_read_text(left, source).strip()] = constructor + for child in node.children: + collect_bindings(child, variables, handles, types) + + def walk_function_children(node, fnid: str, owner_class: str | None) -> None: + body = _child(node, "block") + if body is None: + return + for child in body.children: + if child.type == "function_definition": + nested = create_function( + child, + fnid, + parent_function=fnid, + owner_class=owner_class, + ) + if nested: + walk_function_children(child, nested, owner_class) + + # First pass: imports, classes and function definitions. + top_functions = [c for c in root.children if c.type == "function_definition"] + first_top_start = top_functions[0].start_byte if top_functions else None + first_executable = next( + (c for c in root.children if c.type not in ("comment",)), + None, + ) + is_function_file = bool(first_executable is not None and first_executable.type == "function_definition") + + for child in root.children: + if emit_import(child): + continue + if child.type == "class_definition": + name_node = _field(child, "name") + if name_node is None: + continue + class_name = _read_text(name_node, source).strip() + line = child.start_point[0] + 1 + # Namespace MATLAB type ids so a sibling Objective-C/C++ `Foo.h` / + # `Foo.mm` declaration-definition pair can still collapse without a + # same-stem MATLAB `Foo.m` joining that native collision group. + class_nid = _make_id(stem, "matlab", class_name) + class_nodes[class_name] = class_nid + add_node( + class_nid, + class_name, + line, + node_type="class", + symbol_name=class_name, + qualified_name=_qualified(package, class_name), + package=package, + ) + add_edge(file_nid, class_nid, "contains", line) + if package_nid: + add_edge(package_nid, class_nid, "contains", line) + superclasses = _child(child, "superclasses") + if superclasses is not None: + for base_node in superclasses.children: + if base_node.type != "property_name": + continue + base = _read_text(base_node, source).strip() + if base: + add_edge(class_nid, ensure_stub(base, line, node_type="class"), "inherits", line) + for section in child.children: + if section.type == "properties": + property_access = section_access(section) + for prop in section.children: + if prop.type != "property": + continue + pn = _field(prop, "name") + if pn is None: + continue + pname = _read_text(pn, source).strip() + ptype = class_property_type(prop) + pnid = _make_id(class_nid, "property", pname) + add_node( + pnid, + pname, + prop.start_point[0] + 1, + node_type="property", + symbol_name=pname, + owner_class_name=class_name, + declared_type=ptype, + visibility=property_access or ("private" if is_private else "public"), + package=package, + ) + add_edge(class_nid, pnid, "defines", prop.start_point[0] + 1, context="property") + if ptype: + add_edge( + pnid, + ensure_stub(ptype, prop.start_point[0] + 1), + "references", + prop.start_point[0] + 1, + context="field", + ) + elif section.type == "events": + for event in section.children: + if event.type != "identifier": + continue + ename = _read_text(event, source).strip() + enid = _make_id(class_nid, "event", ename) + add_node(enid, ename, event.start_point[0] + 1, node_type="event", owner_class_name=class_name) + add_edge(class_nid, enid, "defines", event.start_point[0] + 1, context="event") + elif section.type == "enumeration": + for enum in section.children: + if enum.type != "enum": + continue + ids = _identifiers(enum, source) + if not ids: + continue + ename = ids[0] + enid = _make_id(class_nid, "enum", ename) + add_node( + enid, + ename, + enum.start_point[0] + 1, + node_type="enum_case", + owner_class_name=class_name, + ) + add_edge(class_nid, enid, "defines", enum.start_point[0] + 1, context="enum_case") + elif section.type == "methods": + header = _read_text(section, source).splitlines()[0] if _read_text(section, source) else "" + static_section = bool(re.search(r"\bStatic\b", header, re.IGNORECASE)) + method_access = section_access(section) + for method in section.children: + if method.type not in ("function_definition", "function_signature"): + continue + mid = create_function( + method, + class_nid, + owner_class=class_name, + declaration_only=method.type == "function_signature", + is_static=static_section, + visibility=method_access, + ) + if mid and method.type == "function_definition": + walk_function_children(method, mid, class_name) + continue + if child.type == "function_definition": + if class_folder: + class_nid = class_nodes.get(class_folder) or _make_id("matlab", class_folder) + if class_folder not in class_nodes: + add_node( + class_nid, + class_folder, + None, + source_file=None, + node_type="class", + symbol_name=class_folder, + qualified_name=_qualified(package, class_folder), + # Every method file under one @Class folder refers to the + # same old-style class. A folder-stable origin prevents + # collision disambiguation from splitting that class into + # one absolute-path-derived node per method file. + origin_file=str(path.parent), + ) + fnid = create_function(child, class_nid, owner_class=class_folder) + else: + is_primary = is_function_file and child.start_byte == first_top_start + fnid = create_function( + child, + file_nid, + is_top_level=True, + is_primary=is_primary, + ) + if fnid and package_nid and is_primary: + add_edge(package_nid, fnid, "contains", child.start_point[0] + 1) + if fnid: + walk_function_children(child, fnid, class_folder) + + # Script scope lexical bindings. A function file has no executable script + # scope before its first top-level function. + script_variables: set[str] = set() + script_handles: dict[str, str] = {} + script_types: dict[str, str] = {} + for child in root.children: + if child.type not in ("function_definition", "class_definition"): + collect_bindings(child, script_variables, script_handles, script_types) + + for fn in functions: + body = _child(fn["node"], "block") + if body is not None: + collect_bindings( + body, + scope_variables[fn["nid"]], + scope_handles[fn["nid"]], + scope_types[fn["nid"]], + ) + + function_by_nid = {str(fn["nid"]): fn for fn in functions} + + def caller_ancestors(caller: str) -> list[str]: + ancestors: list[str] = [] + current = caller + seen: set[str] = set() + while current and current not in seen: + seen.add(current) + ancestors.append(current) + info = function_by_nid.get(current) + current = str(info.get("parent_function") or "") if info else "" + return ancestors + + def local_candidates(caller: str, name: str) -> list[str]: + matches = [fn for fn in functions if fn.get("name") == name] + if not matches: + return [] + + ancestors = caller_ancestors(caller) + ancestor_rank = {nid: rank for rank, nid in enumerate(ancestors)} + visible_nested = [ + fn for fn in matches + if fn.get("parent_function") in ancestor_rank + ] + if visible_nested: + nearest = min(ancestor_rank[str(fn["parent_function"])] for fn in visible_nested) + return [ + str(fn["nid"]) for fn in visible_nested + if ancestor_rank[str(fn["parent_function"])] == nearest + ] + + caller_info = function_by_nid.get(caller, {}) + caller_owner = caller_info.get("owner_class") + if caller_owner: + owned = [ + fn for fn in matches + if not fn.get("parent_function") and fn.get("owner_class") == caller_owner + ] + if owned: + return [str(fn["nid"]) for fn in owned] + + # Local/top-level functions are file-scoped. Class methods and nested + # functions never participate in this bare fallback. + return [ + str(fn["nid"]) for fn in matches + if fn.get("is_top_level") + and not fn.get("owner_class") + and not fn.get("parent_function") + ] + + def emit_raw( + caller: str, + callee: str, + node, + *, + qualifier: str | None = None, + receiver_type: str | None = None, + owner_class: str | None = None, + indirect: bool = False, + receiver_is_self: bool = False, + relation: str | None = None, + ) -> None: + raw_calls.append({ + "caller_nid": caller, + "callee": callee, + "qualifier": qualifier, + "receiver": qualifier, + "receiver_type": receiver_type, + "is_member_call": bool(qualifier), + "receiver_is_self": receiver_is_self, + "owner_class": owner_class, + "indirect": indirect, + "requested_relation": relation, + "argument_count": _call_arity(node) if node.type == "function_call" else None, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "language": _LANGUAGE, + "language_family": _FAMILY, + "defer_to_language_resolver": True, + }) + + def split_qualified_target(target: str) -> tuple[str, str | None]: + parts = [part for part in target.split(".") if part] + if not parts: + return "", None + return parts[-1], ".".join(parts[:-1]) or None + + def walk_calls( + node, + caller: str, + variables: set[str], + handles: dict[str, str], + types: dict[str, str], + owner_class: str | None, + self_names: set[str], + ) -> None: + if node.type in ("function_definition", "class_definition"): + return + if node.type == "command": + if emit_import(node, caller): + return + command_node = _child(node, "command_name") + command_name = ( + _read_text(command_node, source).strip() + if command_node is not None else "" + ) + if command_name and command_name not in variables: + local = local_candidates(caller, command_name) + if len(local) == 1 and local[0] != caller: + add_edge( + caller, + local[0], + "calls", + node.start_point[0] + 1, + context="command", + ) + else: + emit_raw(caller, command_name, node, owner_class=owner_class) + return + if node.type == "field_expression": + named_children = [child for child in node.children if child.is_named] + field_node = next( + (child for child in reversed(named_children) if child.type == "function_call"), + None, + ) + if field_node is not None: + call_index = named_children.index(field_node) + qualifier_nodes = named_children[:call_index] + callee = _call_name(field_node, source) + qualifier = ".".join( + _read_text(part, source).strip() + for part in qualifier_nodes + if _read_text(part, source).strip() + ) + if callee: + emit_raw( + caller, + callee, + field_node, + qualifier=qualifier, + receiver_type=types.get(qualifier), + owner_class=owner_class, + receiver_is_self=qualifier in self_names, + ) + # Walk arguments, but not the field call again. + args = _child(field_node, "arguments") + if args is not None: + for arg in args.children: + walk_calls(arg, caller, variables, handles, types, owner_class, self_names) + for qualifier_node in qualifier_nodes: + walk_calls( + qualifier_node, + caller, + variables, + handles, + types, + owner_class, + self_names, + ) + return + if node.type == "function_call": + callee = _call_name(node, source) + if callee: + if callee == "feval": + args = _child(node, "arguments") + first_arg = next((c for c in (args.children if args is not None else []) if c.is_named), None) + target = "" + if first_arg is not None and first_arg.type == "identifier": + handle_name = _read_text(first_arg, source).strip() + target = handles.get(handle_name, "") + elif first_arg is not None and first_arg.type == "handle_operator": + target_ids = _identifiers(first_arg, source) + target = ".".join(target_ids) + target_name, target_qualifier = split_qualified_target(target) + if target_name: + local = [] if target_qualifier else local_candidates(caller, target_name) + if len(local) == 1: + add_edge( + caller, + local[0], + "indirect_call", + node.start_point[0] + 1, + context="feval", + confidence="INFERRED", + score=0.8, + ) + else: + emit_raw( + caller, + target_name, + node, + qualifier=target_qualifier, + receiver_type=types.get(target_qualifier or ""), + owner_class=owner_class, + indirect=True, + ) + elif callee in handles: + target_name, target_qualifier = split_qualified_target(handles[callee]) + local = [] if target_qualifier else local_candidates(caller, target_name) + if len(local) == 1: + add_edge( + caller, + local[0], + "indirect_call", + node.start_point[0] + 1, + context="function_handle", + confidence="INFERRED", + score=0.8, + ) + else: + emit_raw( + caller, + target_name, + node, + qualifier=target_qualifier, + receiver_type=types.get(target_qualifier or ""), + owner_class=owner_class, + indirect=True, + ) + elif callee not in variables: + local = local_candidates(caller, callee) + if len(local) == 1 and local[0] != caller: + add_edge(caller, local[0], "calls", node.start_point[0] + 1, context="call") + else: + emit_raw(caller, callee, node, owner_class=owner_class) + args = _child(node, "arguments") + if args is not None: + for arg in args.children: + walk_calls(arg, caller, variables, handles, types, owner_class, self_names) + return + if node.type == "handle_operator": + ids = _identifiers(node, source) + if ids: + target_name = ids[-1] + target_qualifier = ".".join(ids[:-1]) or None + if target_name not in variables: + emit_raw( + caller, + target_name, + node, + qualifier=target_qualifier, + receiver_type=types.get(target_qualifier or ""), + owner_class=owner_class, + relation="references", + ) + return + for child in node.children: + walk_calls(child, caller, variables, handles, types, owner_class, self_names) + + # Calls in script statements belong to the file node. + for child in root.children: + if child.type not in ("function_definition", "class_definition"): + walk_calls( + child, + file_nid, + script_variables, + script_handles, + script_types, + None, + set(), + ) + + def enclosing_self_names(fn: dict[str, Any]) -> set[str]: + current = fn + seen: set[str] = set() + while current: + nid = str(current.get("nid") or "") + if not nid or nid in seen: + break + seen.add(nid) + owner = str(current.get("owner_class") or "") + if ( + owner + and not current.get("parent_function") + and not current.get("is_static") + and current.get("name") != owner + ): + args = _function_arguments(current["node"], source) + if args and args[0] != "~": + return {args[0]} + return set() + parent = str(current.get("parent_function") or "") + current = function_by_nid.get(parent, {}) if parent else {} + return set() + + for fn in functions: + body = _child(fn["node"], "block") + if body is None: + continue + walk_calls( + body, + fn["nid"], + scope_variables[fn["nid"]], + scope_handles[fn["nid"]], + scope_types[fn["nid"]], + fn["owner_class"], + enclosing_self_names(fn), + ) + + return { + "nodes": nodes, + "edges": edges, + "raw_calls": raw_calls, + "matlab_imports": matlab_imports, + "language": _LANGUAGE, + "language_family": _FAMILY, + } + + +def _matlab_private_visible(definition_file: str, caller_file: str) -> bool: + dpath = Path(definition_file) + if dpath.parent.name.lower() != "private": + return True + try: + # MATLAB private functions are visible only to functions in the folder + # immediately above `private` (including an @Class folder), not to all + # descendants of that folder. + return Path(caller_file).resolve().parent == dpath.parent.parent.resolve() + except OSError: + return False + + +def resolve_matlab_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve MATLAB calls with package, class, lexical and visibility evidence.""" + matlab_results = [r for r in per_file if r.get("language") == _LANGUAGE] + if not matlab_results: + return + + node_by_id = {str(n.get("id")): n for n in all_nodes if n.get("id")} + definitions = [ + n for n in all_nodes + if n.get("language") == _LANGUAGE and n.get("_callable") and n.get("symbol_name") + ] + classes = [ + n for n in all_nodes + if n.get("language") == _LANGUAGE + and n.get("type") == "class" + and (n.get("source_file") or n.get("origin_file")) + ] + by_name: dict[str, list[dict]] = {} + by_qualified: dict[str, list[dict]] = {} + for definition in definitions: + by_name.setdefault(str(definition["symbol_name"]), []).append(definition) + qname = definition.get("qualified_name") + if qname: + by_qualified.setdefault(str(qname), []).append(definition) + classes_by_name: dict[str, list[dict]] = {} + for cls in classes: + classes_by_name.setdefault(str(cls.get("symbol_name") or cls.get("label")), []).append(cls) + if cls.get("qualified_name"): + classes_by_name.setdefault(str(cls["qualified_name"]), []).append(cls) + + owner_by_method: dict[str, str] = { + str(e["target"]): str(e["source"]) + for e in all_edges + if e.get("relation") == "method" + } + existing = {(e.get("source"), e.get("target"), e.get("relation")) for e in all_edges} + + imports_by_file: dict[str, set[str]] = {} + imports_by_caller: dict[str, set[str]] = {} + for result in matlab_results: + for item in result.get("matlab_imports", []): + imported = str(item.get("name", "")) + scope = str(item.get("caller_nid", "")) + if node_by_id.get(scope, {}).get("type") == "file": + imports_by_file.setdefault(str(item.get("path", "")), set()).add(imported) + elif scope: + imports_by_caller.setdefault(scope, set()).add(imported) + + def lexical_ancestors(caller: str) -> list[str]: + ancestors: list[str] = [] + current = caller + seen: set[str] = set() + while current and current not in seen: + seen.add(current) + ancestors.append(current) + current = str(node_by_id.get(current, {}).get("parent_function_nid") or "") + return ancestors + + def bare_visible(candidate: dict, caller: str) -> bool: + parent = str(candidate.get("parent_function_nid") or "") + if parent: + return parent in lexical_ancestors(caller) + owner = str(candidate.get("owner_class_name") or "") + if owner: + caller_owner = str(node_by_id.get(caller, {}).get("owner_class_name") or "") + return bool(caller_owner and caller_owner == owner) + return True + + def visible(candidates: list[dict], caller_file: str) -> list[dict]: + return [ + candidate for candidate in candidates + if _matlab_private_visible(str(candidate.get("source_file", "")), caller_file) + ] + + for result in matlab_results: + for rc in result.get("raw_calls", []): + if not rc.get("defer_to_language_resolver"): + continue + caller = str(rc.get("caller_nid", "")) + callee = str(rc.get("callee", "")) + caller_file = str(rc.get("source_file", "")) + if not caller or not callee: + continue + qualifier = str(rc.get("qualifier") or "") + candidates: list[dict] = [] + confidence = "INFERRED" + score = 0.8 + + if rc.get("receiver_is_self") and rc.get("owner_class"): + owner = str(rc["owner_class"]) + candidates = [d for d in by_name.get(callee, []) if d.get("owner_class_name") == owner] + confidence, score = "EXTRACTED", 1.0 + elif rc.get("receiver_type"): + receiver_type = str(rc["receiver_type"]).split(".")[-1] + candidates = [ + d for d in by_name.get(callee, []) + if d.get("owner_class_name") == receiver_type + ] + elif qualifier: + # Class-qualified static/member call. + class_hits = classes_by_name.get(qualifier, []) + if len({str(c.get("id")) for c in class_hits}) == 1: + owner_name = str(class_hits[0].get("symbol_name") or class_hits[0].get("label")) + candidates = [d for d in by_name.get(callee, []) if d.get("owner_class_name") == owner_name] + if not candidates: + # Package-qualified free function (pkg.func()). + candidates = by_qualified.get(f"{qualifier}.{callee}", []) + if candidates: + confidence, score = "EXTRACTED", 1.0 + else: + same_file = [ + d for d in by_name.get(callee, []) + if str(d.get("source_file")) == caller_file + and bare_visible(d, caller) + ] + if same_file: + ancestors = lexical_ancestors(caller) + nested = [ + d for d in same_file + if str(d.get("parent_function_nid") or "") in ancestors + ] + if nested: + ranks = { + str(d["id"]): ancestors.index(str(d["parent_function_nid"])) + for d in nested + } + nearest = min(ranks.values()) + nested = [d for d in nested if ranks[str(d["id"])] == nearest] + candidates = nested or same_file + confidence, score = "EXTRACTED", 1.0 + else: + caller_node = node_by_id.get(caller, {}) + caller_package = str(caller_node.get("package") or "") + free_functions = [ + d for d in by_name.get(callee, []) + if not d.get("owner_class_name") + and not d.get("parent_function_nid") + ] + package_hits = [ + d for d in free_functions + if d.get("is_exported") and str(d.get("package") or "") == caller_package + ] + if package_hits: + candidates = package_hits + else: + imported = set(imports_by_file.get(caller_file, set())) + for scope in lexical_ancestors(caller): + imported.update(imports_by_caller.get(scope, set())) + imported_hits = [ + d for d in free_functions + if d.get("is_exported") and any( + str(d.get("qualified_name", "")).startswith(name + ".") + or str(d.get("qualified_name", "")) == name + for name in imported + ) + ] + candidates = imported_hits or [d for d in free_functions if d.get("is_exported")] + candidates = visible(candidates, caller_file) + + # A capitalized bare name may be a class constructor. This is the + # only safe interpretation when there is exactly one class and no + # function candidate; unknown A(1) remains unresolved. + relation = str( + rc.get("requested_relation") + or ("indirect_call" if rc.get("indirect") else "calls") + ) + if ( + not candidates + and not qualifier + and not rc.get("requested_relation") + and callee[:1].isupper() + ): + class_hits = classes_by_name.get(callee, []) + unique_classes = {str(c.get("id")): c for c in class_hits} + if len(unique_classes) == 1: + target = next(iter(unique_classes.values())) + relation = "instantiates" + candidates = [target] + confidence, score = "EXTRACTED", 1.0 + + unique = {str(c.get("id")): c for c in candidates if c.get("id")} + if len(unique) != 1: + continue + target = next(iter(unique.values())) + target_id = str(target["id"]) + if target_id == caller or (caller, target_id, relation) in existing: + continue + existing.add((caller, target_id, relation)) + all_edges.append({ + "source": caller, + "target": target_id, + "relation": relation, + "context": ( + "function_handle_reference" if relation == "references" + else "function_handle" if rc.get("indirect") + else "call" + ), + "confidence": confidence, + "confidence_score": score, + "source_file": caller_file, + "source_location": rc.get("source_location"), + "weight": 1.0, + "language": _LANGUAGE, + "language_family": _FAMILY, + }) diff --git a/graphify/extractors/objc.py b/graphify/extractors/objc.py index 9f978a50f..fae32f691 100644 --- a/graphify/extractors/objc.py +++ b/graphify/extractors/objc.py @@ -424,7 +424,11 @@ def walk_calls(n) -> None: walk_calls(body_node) result = {"nodes": nodes, "edges": edges, "raw_calls": raw_calls, - "input_tokens": 0, "output_tokens": 0} + "input_tokens": 0, "output_tokens": 0, + "language": "objective-c", "language_family": "native"} + for item in nodes + edges + raw_calls: + item.setdefault("language", "objective-c") + item.setdefault("language_family", "native") if objc_type_table: result["objc_type_table"] = {"path": str_path, "table": objc_type_table} return result diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index d46b4cbcc..b5d81fbca 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -1949,6 +1949,15 @@ def _merge_decl_def_classes( for nid, group in by_id.items(): if len(group) < 2: continue + # MATLAB and Objective-C share `.m`, and same-stem sibling files can + # therefore collide with a native header despite being unrelated. Only + # native declaration/definition pairs participate in this merge. + if any( + node.get("language") == "matlab" + or node.get("language_family") == "matlab" + for node in group + ): + continue # The distinct source files of this collision must form a clean sibling # header/impl set with exactly one header. Each file must parse as a # header/impl file (others -> bail), share one directory + base stem. diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 8db8f73f2..f6b69638d 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -771,7 +771,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md index c444a852d..2c15b4371 100644 --- a/graphify/skill-devin.md +++ b/graphify/skill-devin.md @@ -910,7 +910,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/agents/references/update.md b/graphify/skills/agents/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/agents/references/update.md +++ b/graphify/skills/agents/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/amp/references/update.md b/graphify/skills/amp/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/amp/references/update.md +++ b/graphify/skills/amp/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/claude/references/update.md b/graphify/skills/claude/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/claude/references/update.md +++ b/graphify/skills/claude/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/claw/references/update.md b/graphify/skills/claw/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/claw/references/update.md +++ b/graphify/skills/claw/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/codex/references/update.md b/graphify/skills/codex/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/codex/references/update.md +++ b/graphify/skills/codex/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/copilot/references/update.md b/graphify/skills/copilot/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/copilot/references/update.md +++ b/graphify/skills/copilot/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/droid/references/update.md b/graphify/skills/droid/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/droid/references/update.md +++ b/graphify/skills/droid/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/kilo/references/update.md b/graphify/skills/kilo/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/kilo/references/update.md +++ b/graphify/skills/kilo/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/kiro/references/update.md b/graphify/skills/kiro/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/kiro/references/update.md +++ b/graphify/skills/kiro/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/opencode/references/update.md b/graphify/skills/opencode/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/opencode/references/update.md +++ b/graphify/skills/opencode/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/pi/references/update.md b/graphify/skills/pi/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/pi/references/update.md +++ b/graphify/skills/pi/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/trae/references/update.md b/graphify/skills/trae/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/trae/references/update.md +++ b/graphify/skills/trae/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/vscode/references/update.md b/graphify/skills/vscode/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/vscode/references/update.md +++ b/graphify/skills/vscode/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/windows/references/update.md b/graphify/skills/windows/references/update.md index fa2612180..5a3de64bd 100644 --- a/graphify/skills/windows/references/update.md +++ b/graphify/skills/windows/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/pyproject.toml b/pyproject.toml index 1b2b818d1..ce9779af2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "tree-sitter-fortran>=0.6,<0.8", "tree-sitter-bash>=0.23,<0.27", "tree-sitter-json>=0.23,<0.26", + "tree-sitter-matlab>=1.3,<2", ] [project.urls] diff --git a/tests/test_build.py b/tests/test_build.py index 872a2e12b..28a40804b 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -907,6 +907,51 @@ def test_cross_language_imports_references_are_dropped(): assert G.has_edge("src_time_ts", "src_util_ts"), "same-family (TS->TS) import must survive" +def test_explicit_language_family_disambiguates_matlab_from_objc_m_files(): + extraction = { + "nodes": [ + { + "id": "matlab_fn", "label": "solve()", "file_type": "code", + "source_file": "solve.m", "language": "matlab", "language_family": "matlab", + }, + { + "id": "objc_method", "label": ".solve()", "file_type": "code", + "source_file": "Controller.m", "language": "objective-c", "language_family": "native", + }, + { + "id": "c_helper", "label": "helper()", "file_type": "code", + "source_file": "helper.c", + }, + { + "id": "swift_helper", "label": "swiftHelper()", "file_type": "code", + "source_file": "App.swift", + }, + ], + "edges": [ + { + "source": "matlab_fn", "target": "objc_method", "relation": "calls", + "confidence": "INFERRED", "source_file": "solve.m", "weight": 1.0, + "language": "matlab", "language_family": "matlab", + }, + { + "source": "objc_method", "target": "c_helper", "relation": "calls", + "confidence": "INFERRED", "source_file": "Controller.m", "weight": 1.0, + "language": "objective-c", "language_family": "native", + }, + { + "source": "swift_helper", "target": "c_helper", "relation": "calls", + "confidence": "INFERRED", "source_file": "App.swift", "weight": 1.0, + }, + ], + "input_tokens": 0, + "output_tokens": 0, + } + graph = build_from_json(extraction) + assert not graph.has_edge("matlab_fn", "objc_method") + assert graph.has_edge("objc_method", "c_helper") + assert not graph.has_edge("swift_helper", "c_helper") + + def test_cross_family_reference_to_unknown_ext_is_kept(): """The #1749 guard only drops when BOTH endpoints are known code languages, so a reference from a config/manifest (unknown ext) to a code file is kept.""" diff --git a/tests/test_callflow_html.py b/tests/test_callflow_html.py index 9605c9ba1..d90ad3ee1 100644 --- a/tests/test_callflow_html.py +++ b/tests/test_callflow_html.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -from graphify.callflow_html import derive_sections_from_communities, write_callflow_html +from graphify.callflow_html import derive_sections_from_communities, node_kind, write_callflow_html def _make_graphify_out(tmp_path: Path) -> Path: @@ -49,6 +49,10 @@ def _make_graphify_out(tmp_path: Path) -> Path: return out +def test_uppercase_matlab_filename_is_a_module_not_a_class(): + assert node_kind({"label": "Model.m", "source_file": "Model.m"}) == "module" + + def test_write_callflow_html_creates_file_and_uses_report(tmp_path): out = _make_graphify_out(tmp_path) diff --git a/tests/test_extract.py b/tests/test_extract.py index dfb28d6d1..a5468dc34 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2011,10 +2011,9 @@ def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsy def test_get_extractor_routes_matlab_m_away_from_objc(tmp_path): - # #1702: .m is shared by Objective-C and MATLAB. A real ObjC .m still routes to - # extract_objc, but a MATLAB .m must NOT be force-parsed by the ObjC grammar - # (which produces garbage) — it gets no extractor instead. - from graphify.extract import _get_extractor, extract_objc + # #1702: .m is shared by Objective-C and MATLAB. Content sniffing sends each + # file to its own grammar rather than force-parsing MATLAB as Objective-C. + from graphify.extract import _get_extractor, extract_matlab, extract_objc objc = tmp_path / "Foo.m" objc.write_text('#import "Foo.h"\n@implementation Foo\n- (void)bar {}\n@end\n') @@ -2026,19 +2025,19 @@ def test_get_extractor_routes_matlab_m_away_from_objc(tmp_path): mm.write_text("#import \n@implementation X\n@end\n") assert _get_extractor(objc) is extract_objc # real ObjC .m -> objc - assert _get_extractor(matlab_fn) is None # MATLAB function -> no garbage - assert _get_extractor(matlab_cls) is None # MATLAB classdef -> no garbage + assert _get_extractor(matlab_fn) is extract_matlab # MATLAB function -> MATLAB + assert _get_extractor(matlab_cls) is extract_matlab # MATLAB classdef -> MATLAB assert _get_extractor(mm) is extract_objc # .mm is unambiguously ObjC++ -def test_matlab_m_not_extracted_as_garbage(tmp_path, capsys): - # End to end: a MATLAB .m produces no (garbage) nodes and is surfaced by the - # no-AST-extractor warning (#1702 + #1689), rather than mis-parsed as ObjC. +def test_matlab_m_uses_real_extractor_not_objc_garbage(tmp_path, capsys): + # End to end: MATLAB produces structural nodes through its own grammar. m = tmp_path / "controller.m" m.write_text("function u = controller(x)\n u = -x;\nend\n") result = extract([m], cache_root=tmp_path) - assert result["nodes"] == [] # no garbage ObjC nodes - assert "no AST extractor" in capsys.readouterr().err # surfaced, not silent + assert any(n.get("label") == "controller()" for n in result["nodes"]) + assert all(n.get("language") == "matlab" for n in result["nodes"]) + assert "no AST extractor" not in capsys.readouterr().err def test_rewire_binds_cross_module_function_reference_to_definition(): diff --git a/tests/test_matlab_support.py b/tests/test_matlab_support.py new file mode 100644 index 000000000..47813d2d5 --- /dev/null +++ b/tests/test_matlab_support.py @@ -0,0 +1,431 @@ +from pathlib import Path + +from graphify.extract import _get_extractor, extract, extract_matlab, extract_objc + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _labels(result: dict) -> dict[str, str]: + return {str(node["id"]): str(node.get("label", "")) for node in result["nodes"]} + + +def _relations(result: dict, relation: str) -> list[tuple[str, str]]: + labels = _labels(result) + return [ + (labels.get(str(edge["source"]), ""), labels.get(str(edge["target"]), "")) + for edge in result["edges"] + if edge.get("relation") == relation + ] + + +def test_m_extension_routes_matlab_and_objc_by_content(tmp_path: Path) -> None: + matlab = _write(tmp_path / "solver.m", "function y = solver(x)\ny = x + 1;\nend\n") + objc = _write(tmp_path / "Widget.m", "#import \n@implementation Widget\n@end\n") + + assert _get_extractor(matlab) is extract_matlab + assert _get_extractor(objc) is extract_objc + + result = extract([matlab], cache_root=tmp_path / ".cache", parallel=False) + assert result["nodes"] + assert all(node.get("language") == "matlab" for node in result["nodes"]) + assert all(node.get("language_family") == "matlab" for node in result["nodes"]) + + +def test_matlab_cross_file_call_but_indexing_is_not_a_call(tmp_path: Path) -> None: + helper = _write(tmp_path / "helper.m", "function y = helper(x)\ny = x + 1;\nend\n") + main = _write(tmp_path / "main.m", "A = [1, 2];\nx = A(1);\ny = helper(1);\n") + + result = extract([helper, main], cache_root=tmp_path / ".cache", parallel=False) + calls = _relations(result, "calls") + assert ("main.m", "helper()") in calls + assert all(target != "A()" for _, target in calls) + + +def test_matlab_class_members_inheritance_and_self_calls(tmp_path: Path) -> None: + model = _write( + tmp_path / "Model.m", + """classdef Model < Base +properties (Access = private) + value double +end +events + Changed +end +methods + function y = run(obj, x) + y = obj.compute(x); + end + function y = compute(obj, x) + y = x + obj.value; + end +end +end +""", + ) + result = extract([model], cache_root=tmp_path / ".cache", parallel=False) + labels = set(_labels(result).values()) + assert {"Model", "value", "Changed", ".run()", ".compute()"} <= labels + value_node = next(node for node in result["nodes"] if node.get("label") == "value") + assert value_node.get("visibility") == "private" + assert ("Model", "Base") in _relations(result, "inherits") + assert (".run()", ".compute()") in _relations(result, "calls") + + +def test_matlab_packages_function_handles_and_unknown_call_index_ambiguity(tmp_path: Path) -> None: + helper = _write( + tmp_path / "+util" / "helper.m", + "function y = helper(x)\ny = x + 1;\nend\n", + ) + callback = _write( + tmp_path / "callback.m", + "function y = callback(x)\ny = x * 2;\nend\n", + ) + main = _write( + tmp_path / "main.m", + "f = @callback;\na = f(1);\nb = util.helper(2);\ng = @util.helper;\nd = g(4);\nc = Unknown(3);\n", + ) + result = extract([helper, callback, main], cache_root=tmp_path / ".cache", parallel=False) + assert ("main.m", "helper()") in _relations(result, "calls") + assert ("main.m", "callback()") in _relations(result, "indirect_call") + assert ("main.m", "helper()") in _relations(result, "indirect_call") + assert all(target != "Unknown" for _, target in _relations(result, "calls")) + assert all(target != "Unknown" for _, target in _relations(result, "instantiates")) + + +def test_ignored_parameter_still_counts_toward_arity(tmp_path: Path) -> None: + source = _write( + tmp_path / "select.m", + "function y = select(x, ~, z)\ny = x + z;\nend\n", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + function = next(node for node in result["nodes"] if node.get("label") == "select()") + assert function.get("arity") == 3 + assert function.get("parameter_names") == ["x", "~", "z"] + + +def test_matlab_private_function_does_not_resolve_outside_parent_tree(tmp_path: Path) -> None: + private_fn = _write( + tmp_path / "pkg" / "private" / "secret.m", + "function y = secret(x)\ny = x;\nend\n", + ) + outside = _write(tmp_path / "other" / "main.m", "y = secret(1);\n") + result = extract([private_fn, outside], cache_root=tmp_path / ".cache", parallel=False) + assert ("main.m", "secret()") not in _relations(result, "calls") + + +def test_matlab_constructed_local_receiver_resolves_class_method(tmp_path: Path) -> None: + service = _write( + tmp_path / "Service.m", + """classdef Service +methods + function run(obj) + end +end +end +""", + ) + main = _write(tmp_path / "main.m", "svc = Service();\nsvc.run();\n") + result = extract([service, main], cache_root=tmp_path / ".cache", parallel=False) + assert ("main.m", ".run()") in _relations(result, "calls") + + +def test_matlab_routing_ignores_objc_text_but_recognizes_message_only_objc(tmp_path: Path) -> None: + handle = _write(tmp_path / "handle.m", "f = @interface;\ny = f(1);\n") + comment = _write(tmp_path / "comment.m", "% #import is documentation, not ObjC\ny = 1;\n") + objc = _write(tmp_path / "MessageOnly.m", "void invoke(id target) { [target run]; }\n") + custom_objc = _write( + tmp_path / "CustomResult.m", + "Result *make(id observer) { [observer refresh]; return nil; }\n", + ) + + assert _get_extractor(handle) is extract_matlab + assert _get_extractor(comment) is extract_matlab + assert _get_extractor(objc) is extract_objc + assert _get_extractor(custom_objc) is extract_objc + + +def test_function_handle_reference_is_not_a_call_but_feval_is(tmp_path: Path) -> None: + callback = _write( + tmp_path / "callback.m", + "function y = callback(x)\ny = x;\nend\n", + ) + holder = _write(tmp_path / "holder.m", "f = @callback;\n") + referenced = extract([callback, holder], cache_root=tmp_path / ".cache-ref", parallel=False) + assert ("holder.m", "callback()") in _relations(referenced, "references") + assert ("holder.m", "callback()") not in _relations(referenced, "indirect_call") + + invoked = _write(tmp_path / "invoked.m", "y = feval(@callback, 1);\n") + called = extract([callback, invoked], cache_root=tmp_path / ".cache-call", parallel=False) + assert ("invoked.m", "callback()") in _relations(called, "indirect_call") + + +def test_nested_function_does_not_leak_to_top_level_sibling(tmp_path: Path) -> None: + source = _write( + tmp_path / "scopes.m", + """function y = a(x) +y = hidden(x); +function z = hidden(v) +z = v; +end +end +function y = b(x) +y = hidden(x); +end +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + calls = _relations(result, "calls") + assert ("a()", "hidden()") in calls + assert ("b()", "hidden()") not in calls + + +def test_private_function_is_visible_only_to_immediate_parent_folder(tmp_path: Path) -> None: + private_fn = _write( + tmp_path / "pkg" / "private" / "secret.m", + "function y = secret(x)\ny = x;\nend\n", + ) + sibling = _write(tmp_path / "pkg" / "inside.m", "y = secret(1);\n") + descendant = _write(tmp_path / "pkg" / "sub" / "nested.m", "y = secret(1);\n") + result = extract( + [private_fn, sibling, descendant], + cache_root=tmp_path / ".cache", + parallel=False, + ) + calls = _relations(result, "calls") + assert ("inside.m", "secret()") in calls + assert ("nested.m", "secret()") not in calls + + +def test_bare_call_does_not_bind_to_unrelated_class_method(tmp_path: Path) -> None: + model = _write( + tmp_path / "Model.m", + """classdef Model +methods +function run(obj) +end +end +end +""", + ) + script = _write(tmp_path / "main.m", "run();\n") + result = extract([model, script], cache_root=tmp_path / ".cache", parallel=False) + assert ("main.m", ".run()") not in _relations(result, "calls") + + +def test_static_method_first_argument_is_not_treated_as_self(tmp_path: Path) -> None: + model = _write( + tmp_path / "StaticModel.m", + """classdef StaticModel +methods (Static) +function run(x) +x.helper(); +end +end +methods +function helper(obj) +end +end +end +""", + ) + result = extract([model], cache_root=tmp_path / ".cache", parallel=False) + assert (".run()", ".helper()") not in _relations(result, "calls") + + +def test_uppercase_indexed_variable_is_not_inferred_as_constructor(tmp_path: Path) -> None: + foo = _write( + tmp_path / "Foo.m", + """classdef Foo +methods +function run(obj) +end +end +end +""", + ) + script = _write( + tmp_path / "capital_index.m", + "Foo = [1, 2];\nx = Foo(1);\nx.run();\n", + ) + result = extract([foo, script], cache_root=tmp_path / ".cache", parallel=False) + assert ("capital_index.m", ".run()") not in _relations(result, "calls") + + +def test_bound_instance_method_handle_resolves_reference_and_invocation(tmp_path: Path) -> None: + service = _write( + tmp_path / "Service.m", + """classdef Service +methods +function run(obj) +end +end +end +""", + ) + script = _write( + tmp_path / "main.m", + "svc = Service();\nf = @svc.run;\nf();\n", + ) + result = extract([service, script], cache_root=tmp_path / ".cache", parallel=False) + assert ("main.m", ".run()") in _relations(result, "references") + assert ("main.m", ".run()") in _relations(result, "indirect_call") + + +def test_nested_package_qualifier_resolves_exact_function(tmp_path: Path) -> None: + ab = _write( + tmp_path / "+a" / "+b" / "helper.m", + "function y = helper(x)\ny = x;\nend\n", + ) + cd = _write( + tmp_path / "+c" / "+d" / "helper.m", + "function y = helper(x)\ny = x;\nend\n", + ) + main = _write(tmp_path / "main.m", "y = a.b.helper(1);\n") + result = extract([ab, cd, main], cache_root=tmp_path / ".cache", parallel=False) + nodes = {str(node["id"]): node for node in result["nodes"]} + targets = [ + nodes[str(edge["target"])] + for edge in result["edges"] + if edge.get("relation") == "calls" + and nodes.get(str(edge["source"]), {}).get("label") == "main.m" + ] + assert len(targets) == 1 + assert targets[0].get("qualified_name") == "a.b.helper" + + +def test_function_scoped_import_disambiguates_package_call(tmp_path: Path) -> None: + a = _write( + tmp_path / "+a" / "helper.m", + "function y = helper(x)\ny = x;\nend\n", + ) + b = _write( + tmp_path / "+b" / "helper.m", + "function y = helper(x)\ny = x;\nend\n", + ) + main = _write( + tmp_path / "main.m", + "function y = main(x)\nimport a.helper\ny = helper(x);\nend\n", + ) + result = extract([a, b, main], cache_root=tmp_path / ".cache", parallel=False) + nodes = {str(node["id"]): node for node in result["nodes"]} + targets = [ + nodes[str(edge["target"])] + for edge in result["edges"] + if edge.get("relation") == "calls" + and nodes.get(str(edge["source"]), {}).get("label") == "main()" + ] + assert len(targets) == 1 + assert targets[0].get("qualified_name") == "a.helper" + + +def test_project_function_can_shadow_builtin_and_command_form_resolves(tmp_path: Path) -> None: + mean_fn = _write( + tmp_path / "mean.m", + "function y = mean(x)\ny = x;\nend\n", + ) + foo_fn = _write( + tmp_path / "foo.m", + "function foo(value)\ndisp(value);\nend\n", + ) + main = _write(tmp_path / "main.m", "x = mean(1);\nfoo bar\n") + result = extract([mean_fn, foo_fn, main], cache_root=tmp_path / ".cache", parallel=False) + calls = _relations(result, "calls") + assert ("main.m", "mean()") in calls + assert ("main.m", "foo()") in calls + + +def test_old_style_class_folder_uses_one_portable_class_identity(tmp_path: Path) -> None: + run = _write( + tmp_path / "@Widget" / "run.m", + "function run(obj)\nend\n", + ) + stop = _write( + tmp_path / "@Widget" / "stop.m", + "function stop(obj)\nend\n", + ) + result = extract([run, stop], cache_root=tmp_path / ".cache", parallel=False) + class_ids = { + str(node["id"]) + for node in result["nodes"] + if node.get("type") == "class" and node.get("label") == "Widget" + } + assert class_ids == {"matlab_widget"} + assert {("Widget", ".run()"), ("Widget", ".stop()")} <= set( + _relations(result, "method") + ) + + +def test_matlab_class_is_not_merged_into_same_stem_objc_header(tmp_path: Path) -> None: + header = _write( + tmp_path / "Foo.h", + "@interface Foo\n- (void)nativeOnly;\n@end\n", + ) + matlab = _write( + tmp_path / "Foo.m", + """classdef Foo +methods +function matlabOnly(obj) +end +end +end +""", + ) + result = extract([header, matlab], cache_root=tmp_path / ".cache", parallel=False) + foo_nodes = [node for node in result["nodes"] if node.get("label") == "Foo"] + assert {node.get("language") for node in foo_nodes} == {"objective-c", "matlab"} + assert ".matlabOnly()" in set(_labels(result).values()) + assert "-nativeOnly" in set(_labels(result).values()) + + +def test_matlab_class_does_not_make_objc_receiver_type_ambiguous(tmp_path: Path) -> None: + objc = _write( + tmp_path / "Native.m", + """@interface Widget ++ (instancetype)new; +@end +@interface Maker +- (id)make; +@end +@implementation Maker +- (id)make { return [Widget new]; } +@end +""", + ) + matlab = _write(tmp_path / "models" / "Other.m", "classdef Widget\nend\n") + result = extract([objc, matlab], cache_root=tmp_path / ".cache", parallel=False) + assert ("-make", "+new") in _relations(result, "calls") + + +def test_matlab_sibling_does_not_block_native_header_implementation_merge(tmp_path: Path) -> None: + header = _write( + tmp_path / "Foo.h", + "@interface Foo\n- (void)work;\n@end\n", + ) + implementation = _write( + tmp_path / "Foo.mm", + "@implementation Foo\n- (void)work {}\n@end\n", + ) + matlab = _write(tmp_path / "Foo.m", "classdef Foo\nend\n") + caller = _write( + tmp_path / "Caller.m", + """#import "Foo.h" +@interface Caller +- (void)call; +@end +@implementation Caller +- (void)call { Foo *foo; [foo work]; } +@end +""", + ) + result = extract( + [header, implementation, matlab, caller], + cache_root=tmp_path / ".cache", + parallel=False, + ) + assert ("-call", "-work") in _relations(result, "calls") + foo_nodes = [node for node in result["nodes"] if node.get("label") == "Foo"] + assert {node.get("language") for node in foo_nodes} == {"objective-c", "matlab"} diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index 9797267a9..5da0743d5 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -497,6 +497,7 @@ def test_monoliths_change_only_sanctioned_lines(): rendered = gen.render(platforms[key])[0].content assert gen.ENUM_VALUES in rendered assert UNIFIED_DESCRIPTION in rendered + assert "'.swift','.m','.mm','.kt'" in rendered def test_monoliths_carry_the_1392_runbook_fixes(): diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md index 8db8f73f2..f6b69638d 100644 --- a/tools/skillgen/expected/graphify__skill-aider.md +++ b/tools/skillgen/expected/graphify__skill-aider.md @@ -771,7 +771,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md index c444a852d..2c15b4371 100644 --- a/tools/skillgen/expected/graphify__skill-devin.md +++ b/tools/skillgen/expected/graphify__skill-devin.md @@ -910,7 +910,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__agents__references__update.md b/tools/skillgen/expected/graphify__skills__agents__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__update.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__amp__references__update.md b/tools/skillgen/expected/graphify__skills__amp__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__update.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__claude__references__update.md b/tools/skillgen/expected/graphify__skills__claude__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__claw__references__update.md b/tools/skillgen/expected/graphify__skills__claw__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__codex__references__update.md b/tools/skillgen/expected/graphify__skills__codex__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__update.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__update.md b/tools/skillgen/expected/graphify__skills__copilot__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__update.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__droid__references__update.md b/tools/skillgen/expected/graphify__skills__droid__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__update.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__update.md b/tools/skillgen/expected/graphify__skills__kilo__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__update.md b/tools/skillgen/expected/graphify__skills__kiro__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__update.md b/tools/skillgen/expected/graphify__skills__opencode__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__pi__references__update.md b/tools/skillgen/expected/graphify__skills__pi__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__update.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__trae__references__update.md b/tools/skillgen/expected/graphify__skills__trae__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__update.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__update.md b/tools/skillgen/expected/graphify__skills__vscode__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__windows__references__update.md b/tools/skillgen/expected/graphify__skills__windows__references__update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__update.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md index 8db8f73f2..f6b69638d 100644 --- a/tools/skillgen/fragments/core/aider.md +++ b/tools/skillgen/fragments/core/aider.md @@ -771,7 +771,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md index c444a852d..2c15b4371 100644 --- a/tools/skillgen/fragments/core/devin.md +++ b/tools/skillgen/fragments/core/devin.md @@ -910,7 +910,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/references/shared/update.md b/tools/skillgen/fragments/references/shared/update.md index fa2612180..5a3de64bd 100644 --- a/tools/skillgen/fragments/references/shared/update.md +++ b/tools/skillgen/fragments/references/shared/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.m','.mm','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 93e403f27..fd0db9c45 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -918,6 +918,21 @@ def _is_semantic_cache_scope_fix_line(line: str) -> bool: ) or stripped.startswith("saved = save_semantic_cache(") +def _is_matlab_update_extension_line(line: str) -> bool: + """Whether an update snippet adds MATLAB/ObjC `.m`/`.mm` as code files. + + The monolithic Aider/Devin skills pin their bodies to pristine v8. Both the + old line and the line with exactly these two inserted suffixes are sanctioned + so MATLAB-only edits take the deterministic AST update path. + """ + stripped = line.strip() + without_matlab = stripped.replace("'.m',", "").replace("'.mm',", "") + return without_matlab in { + "code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'}", + "code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'}", + } + + # Every line that may differ between a rendered monolith and its pristine v8 # baseline. Each predicate documents one sanctioned change-class; a blank line is # allowed because the multi-line fix blocks insert spacing. Anything else failing @@ -936,6 +951,7 @@ def _is_semantic_cache_scope_fix_line(line: str) -> bool: _is_obsidian_usage_comment_line, _is_uv_from_interpreter_fix_line, _is_semantic_cache_scope_fix_line, + _is_matlab_update_extension_line, ) @@ -954,7 +970,7 @@ def monolith_roundtrip(platform: Platform) -> list[str]: unification, the unified frontmatter description, the chunk-cleanup rewrite (#1172), the four #1392 runbook fixes (directed propagation, content-only semantic scope, stale-cache unlink, and the zero-node/shrink-guard ordering), - and semantic-cache source scoping (#1757). + semantic-cache source scoping (#1757), and MATLAB code-only update routing. The comparison is a multiset diff, not a positional zip: a line whose text is unchanged but merely *moved* (the report-write line shifted below ``to_json`` diff --git a/uv.lock b/uv.lock index 088ebbbdc..18c638b31 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.15" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1113,6 +1113,7 @@ dependencies = [ { name = "tree-sitter-julia" }, { name = "tree-sitter-kotlin" }, { name = "tree-sitter-lua" }, + { name = "tree-sitter-matlab" }, { name = "tree-sitter-objc" }, { name = "tree-sitter-php" }, { name = "tree-sitter-powershell" }, @@ -1311,6 +1312,7 @@ requires-dist = [ { name = "tree-sitter-julia", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" }, { name = "tree-sitter-lua", specifier = ">=0.2,<0.6" }, + { name = "tree-sitter-matlab", specifier = ">=1.3,<2" }, { name = "tree-sitter-objc", specifier = ">=3.0,<4.0" }, { name = "tree-sitter-pascal", marker = "extra == 'all'" }, { name = "tree-sitter-pascal", marker = "extra == 'pascal'" }, @@ -4719,6 +4721,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/97/3104ecfa3c34320411bcad9b4f2823956487b6e222edcc83689819badc9d/tree_sitter_lua-0.5.0-cp310-abi3-win_arm64.whl", hash = "sha256:8488f3bea40779896f5771bcfcdc26900eb21e94f6658eb68a848fc37dd39221", size = 23506, upload-time = "2026-02-26T17:07:32.775Z" }, ] +[[package]] +name = "tree-sitter-matlab" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/8f/741f97fa6a33bea1e860610ddfe285cc54719b22874e579e00c82a3320ee/tree_sitter_matlab-1.3.0.tar.gz", hash = "sha256:a3c470dc7acfa996473496fa72917bff3c073121793c07af3a58123efe165bfb", size = 172095, upload-time = "2026-02-04T07:46:00.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/30/b4925490c25451da3c0f94f6a3ed1425bcfe6411bb8261b4af74c4b1898f/tree_sitter_matlab-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dd343dfa820c40b4eb52f62a7bfce9192510f11f932427d84ecf089228a6517c", size = 86529, upload-time = "2026-02-04T07:45:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/8b9bcb5b1452541f0b8a2181ddb8e3181bf42631155de9ed632d0f33930a/tree_sitter_matlab-1.3.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b8515503f2966ee9030654cc14ddf9f66b3c9fe73d5830248a9a0e672d16664", size = 116266, upload-time = "2026-02-04T07:45:38.186Z" }, + { url = "https://files.pythonhosted.org/packages/06/08/ad8e452c892a73e58249cbde708cd41e53e62a57ee51e2fdb61e56362191/tree_sitter_matlab-1.3.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f84ecacbdc83b149d2b954d5fbd7101db4abf19f0cdd4c50d08022ed60c0b9b7", size = 123087, upload-time = "2026-02-04T07:45:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3c/db0b7a8f07125aec0172a7ed9688da7001fcd93a364478653275590acc1d/tree_sitter_matlab-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:96913579ed39d4d8d6c8c56a2c320a588a50befbc8d3d441e9fe02a308c0b59b", size = 120656, upload-time = "2026-02-04T07:45:40.96Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c8/cb7479ef3eeb23975820668d37dfb9262f837bd40dbd9ec650d37af142c1/tree_sitter_matlab-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:68cc60df281b15c5d852f15bfddc3e8d120e1c9eb77dc46fcddc75e55ffe41e5", size = 114911, upload-time = "2026-02-04T07:45:42.163Z" }, + { url = "https://files.pythonhosted.org/packages/93/70/12731db61d859211176dac6da4df993d7b69c998ad6f0e2839d8638b376e/tree_sitter_matlab-1.3.0-cp310-abi3-win32.whl", hash = "sha256:3e7044be254569d832268974cde313c5c094399325e5b597811276d4acf3a766", size = 81607, upload-time = "2026-02-04T07:45:43.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/50/c09e2a9ff9ac3e3be0e1c2786a4ab179eb4aca06784c36f357e67ac7f563/tree_sitter_matlab-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:7ca52f75fe09b68efc567c2e69e6a329c31e999946a25528942dffdc73686224", size = 82635, upload-time = "2026-02-04T07:45:45.435Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/94778e6f58ba16de8807819316b0851ef0a999fa1c177a73756379598095/tree_sitter_matlab-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9064fb4d9494f9e8c183e56840aa34504056e856e8623939c7021764fc1de422", size = 80961, upload-time = "2026-02-04T07:45:47.109Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f5/da0ddb58a7537be3adfee2aa88dce597b4f8021e046f50767364cb000d65/tree_sitter_matlab-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9e17969162fd8bc130a1088b7502ad1c7f19e72cfd8d8091c4f0ef6100923ea", size = 86549, upload-time = "2026-02-04T07:45:48.688Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e2/aef2727d8a0334a53439073c9e259bfcb96b771146613ecb5ed85ff911e6/tree_sitter_matlab-1.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:96b54b44ff720e5da4f61fdf307bbf875fb6673659e39038f65cc5466e35fe28", size = 119090, upload-time = "2026-02-04T07:45:50.456Z" }, + { url = "https://files.pythonhosted.org/packages/17/b4/f0d7e1b7a391d6c15d8dae4c64b70acc44d84b5dbc329e29525f6cebedce/tree_sitter_matlab-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dd3ed28eb3531b0001d8d007142f18cd9fb9abc643e395b6938c2b4d59bf2f5", size = 125759, upload-time = "2026-02-04T07:45:51.69Z" }, + { url = "https://files.pythonhosted.org/packages/f1/98/0b7ea7cc546a5d4f98ae9c06cd72555ffa88575dc0faf10da5a324bbbb3e/tree_sitter_matlab-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3780544ffa1e952c66bcaa852af0090a3e83578a710a1bc0fd70bed468317b33", size = 123409, upload-time = "2026-02-04T07:45:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/49/02/1ccc465c00c04994dd12ca82c6cac45380c45798892fa68a357f254b8ba7/tree_sitter_matlab-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7af56374457af40849552fa6f8804f409ddeb00e9df83f2a6f41215b769397a0", size = 117745, upload-time = "2026-02-04T07:45:54.881Z" }, + { url = "https://files.pythonhosted.org/packages/20/70/677c17d14baa6070ea27d24b3f65763c8f17c0d41a0491ea9b21c1ad20b5/tree_sitter_matlab-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:b415bd72381a3545a6381b5fd84a3cc0014205c917fba25a0d4263fbe07b091e", size = 84279, upload-time = "2026-02-04T07:45:56.502Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/c8502111da9c2cdab0da37e51bb9ce63abbba4314754b262ae4ea2eb7e9c/tree_sitter_matlab-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:101e6150555a788baef87b4d04e8790bd8aad9ba7dd6048c58d1d0f5d6b1e234", size = 84855, upload-time = "2026-02-04T07:45:57.457Z" }, + { url = "https://files.pythonhosted.org/packages/20/6d/57fe704e91a893d310e589c2363bcfc1342edce5868d72b4bc060f04eeb2/tree_sitter_matlab-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d9db720b63888eb250de13029cdea324042f92f5f2c2934fd5f0a2eaa5f03b90", size = 83493, upload-time = "2026-02-04T07:45:58.929Z" }, +] + [[package]] name = "tree-sitter-objc" version = "3.0.2"