From 78f518f162eb2e24b7a8b26bb9e27f35895b41e4 Mon Sep 17 00:00:00 2001 From: Stepan <261916613+stepan-orlov@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:03:59 +0200 Subject: [PATCH] feat: Monkey C (Garmin Connect IQ) extractor + member-call resolver Monkey C has no tree-sitter grammar, so `.mc` files were unclassified and silently skipped (a Connect IQ app produced an empty graph). Adds graphify/extractors/monkeyc.py, a scanner-based extractor in the spirit of the Pascal regex fallback: comments/string/char literals are blanked with offsets preserved, then one brace-depth scan attributes every module / class / function to its scope and every call to its function. Emitted: module/class/function nodes (`method` from a class, `contains` from a module or the file), `inherits` (same-file base direct; any other base a sourceless stub with its `using ... as` alias expanded, e.g. `Toybox.WatchUi.View`, for the unique-stub rewire), `imports_from` (a bare `import Utils;` rewired onto the app's own module node), same-file `calls` (`$.` global qualifier accepted), `method(:sym)` / `me.method(:sym)` / `new Lang.Method(self, :sym)` as `indirect_call`, and `raw_calls` tagged `lang="monkeyc"` carrying the receiver and its type where known. `new Toybox.*(...)` is never a raw call. New `monkeyc_member_calls` language resolver: `Module.fn()` / `Type.fn()` qualified calls (EXTRACTED; type known but function not -> type-level `references`), receiver-typed calls via `var x as T` / typed parameters / `x = new T()` (INFERRED) and inherited bare calls along the caller's `inherits` chain, each guarded by exactly-one-definition; `Toybox.*` receivers are the SDK and never bind to app code; purely additive. - register `.mc` in _DISPATCH, CODE_EXTENSIONS, _LANG_FAMILY_BY_EXT, build._EDGE_LANG_FAMILY, cli._HOOK_SOURCE_EXTS, LANGUAGE_EXTRACTORS - fixtures: tests/fixtures/sample.mc, tests/fixtures/monkeyc_cross_file/, tests/fixtures/monkeyc_ambiguous/ - tests: 15 new tests in tests/test_languages.py (single-file, cross-file extract() pipeline, ambiguity guard) - README language table row + CHANGELOG entry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + README.md | 1 + graphify/build.py | 1 + graphify/cli.py | 1 + graphify/detect.py | 2 +- graphify/extract.py | 9 + graphify/extractors/__init__.py | 2 + graphify/extractors/monkeyc.py | 649 +++++++++++++++++++++ tests/fixtures/monkeyc_ambiguous/A.mc | 4 + tests/fixtures/monkeyc_ambiguous/B.mc | 4 + tests/fixtures/monkeyc_ambiguous/Caller.mc | 6 + tests/fixtures/monkeyc_cross_file/App.mc | 20 + tests/fixtures/monkeyc_cross_file/Base.mc | 25 + tests/fixtures/sample.mc | 111 ++++ tests/test_languages.py | 217 +++++++ 15 files changed, 1052 insertions(+), 1 deletion(-) create mode 100644 graphify/extractors/monkeyc.py create mode 100644 tests/fixtures/monkeyc_ambiguous/A.mc create mode 100644 tests/fixtures/monkeyc_ambiguous/B.mc create mode 100644 tests/fixtures/monkeyc_ambiguous/Caller.mc create mode 100644 tests/fixtures/monkeyc_cross_file/App.mc create mode 100644 tests/fixtures/monkeyc_cross_file/Base.mc create mode 100644 tests/fixtures/sample.mc diff --git a/CHANGELOG.md b/CHANGELOG.md index c029b472a5..6b0e928534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.45 (unreleased) +- Feature: Monkey C (Garmin Connect IQ, `.mc`) is now extracted. There is no tree-sitter grammar for Monkey C, so `graphify/extractors/monkeyc.py` is a scanner-based extractor in the spirit of the Pascal regex fallback: modules, classes, functions/methods, `extends` (a same-file base resolves directly; any other base becomes a sourceless stub with its `using ... as` alias expanded, e.g. `Toybox.WatchUi.View`, so the unique-stub rewire collapses it onto the real class), `using`/`import` (a bare `import Utils;` is rewired onto the app's own module node), same-file `calls`, and `method(:sym)` / `new Lang.Method(self, :sym)` callbacks as `indirect_call`. A new `monkeyc_member_calls` language resolver binds `Module.fn()` / `Type.fn()` qualified calls (EXTRACTED), receiver-typed calls via `var x as T` / typed parameters / `x = new T()` (INFERRED) and inherited bare calls along the caller's `inherits` chain, each guarded by exactly-one-definition; `Toybox.*` receivers are the SDK and never bind to app code. - Fix: `graphify install ` now advances the `.graphify_version` stamp only for the platform it actually (re)writes, instead of stamping every installed platform as current; a platform whose skill content was left untouched keeps its old stamp so its staleness warning stays truthful (#2694, thanks @ousamabenyounes). This completes #2694 (the CLAUDE_CONFIG_DIR half shipped in 0.9.44). - Fix: an incremental rebuild no longer collapses the whole graph when the `.graphify_root` marker records a subfolder while stored `source_file` paths are relative to the repo root; the marker is validated against the stored paths before it is trusted as their anchor, so a mismatched marker can't make every unchanged source look deleted (#2603, thanks @catpotd). A genuinely deleted source is still evicted, and incremental ids stay identical to a cold build. - Fix: a Go file that declares both an exported and an unexported symbol differing only by case (e.g. `Run` and `run`, which are distinct in Go's case-sensitive visibility rules) no longer collapses them onto one node id and drops one; the exported symbol keeps its stable id and the unexported one is disambiguated, so an intra-file call to the unexported symbol resolves locally instead of phantoming to another package (#2779, thanks @catpotd). Only the Go extractor's id assignment is affected; the shared id normalization is unchanged, so no other language's ids move. diff --git a/README.md b/README.md index e3a2cf867f..400fc14398 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg |------|-----------| | 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 .ml .mli .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]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.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) | +| Monkey C (Garmin Connect IQ) | `.mc` (scanner-based — no tree-sitter grammar exists; modules, classes, functions/methods, `extends`, `using`/`import`, calls, `method(:sym)` callbacks; `Module.fn()` / typed-receiver / inherited calls resolved corpus-wide) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) | | 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/build.py b/graphify/build.py index 7f195fe2f4..fe98b67a53 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -69,6 +69,7 @@ def _is_ast_tier(item: dict) -> bool: ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".mc": "monkeyc", } diff --git a/graphify/cli.py b/graphify/cli.py index 95adad4b9c..483069bf99 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -72,6 +72,7 @@ '.py', '.js', '.cjs', '.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', + '.mc', ) _GEMINI_NUDGE_TEXT = ( 'graphify: knowledge graph at graphify-out/. For focused questions, run ' diff --git a/graphify/detect.py b/graphify/detect.py index f76a4259f6..a8292de58c 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -42,7 +42,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.mc'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 396aa26598..c10a092efa 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -46,6 +46,7 @@ from graphify.extractors.go import _GO_PREDECLARED_FUNCS, extract_go # noqa: F401 from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 +from graphify.extractors.monkeyc import extract_monkeyc, resolve_monkeyc_member_calls # noqa: F401 from graphify.extractors.ocaml import extract_ocaml # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 @@ -2127,6 +2128,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".ex": "elixir", ".exs": "elixir", ".jl": "julia", ".dart": "dart", + ".mc": "monkeyc", ".sh": "shell", ".bash": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", } @@ -3833,6 +3835,12 @@ def _resolve_kotlin_qualified_calls( register_language_resolver( LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) ) +# Monkey C (Garmin Connect IQ): receiver-typed / `Type.fn()`-qualified member +# calls and inherited bare calls, resolved corpus-wide against the caller's +# class `inherits` chain (see graphify.extractors.monkeyc). +register_language_resolver( + LanguageResolver("monkeyc_member_calls", frozenset({".mc"}), resolve_monkeyc_member_calls) +) # Pascal/Delphi cross-file inherited-method-call resolution: a call from a # manual descendant class to a method it inherits from an ancestor declared # in a DIFFERENT file (the common generated-base/manual-descendant split, @@ -4922,6 +4930,7 @@ def add_existing_edge(edge: dict) -> None: ".svelte": extract_svelte, ".astro": extract_astro, ".dart": extract_dart, + ".mc": extract_monkeyc, ".ml": extract_ocaml, ".mli": extract_ocaml, ".v": extract_verilog, diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094e..43a3753d4d 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.monkeyc import extract_monkeyc 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, + "monkeyc": extract_monkeyc, "objc": extract_objc, "pascal": extract_pascal, "powershell": extract_powershell, diff --git a/graphify/extractors/monkeyc.py b/graphify/extractors/monkeyc.py new file mode 100644 index 0000000000..71282ac2f3 --- /dev/null +++ b/graphify/extractors/monkeyc.py @@ -0,0 +1,649 @@ +"""Monkey C (Garmin Connect IQ) extractor. + +Monkey C has no tree-sitter grammar, so this is a scanner/regex extractor in +the spirit of the Pascal regex fallback (``_extract_pascal_regex``): comments, +string and char literals are blanked out with offsets preserved (so every line +number stays exact), then one token pass tracks brace depth to attribute every +``module`` / ``class`` / ``function`` to its enclosing scope and every call +inside a function body to that function. + +Emitted per file: + +- the file node; ``module`` and ``class`` nodes (``contains`` from their + owner); functions — a ``method`` edge from a class (label ``.name()``), + ``contains`` from a module or the file (label ``name()``); +- ``inherits`` for ``class X extends Y``: a same-file base resolves directly; + any other base gets a SOURCELESS stub node whose label is the alias-expanded + name (``extends Ui.View`` under ``using Toybox.WatchUi as Ui`` becomes + ``Toybox.WatchUi.View``), so the corpus-level unique-stub rewire in + ``extract()`` collapses it onto the real class when the base lives in + another file, and an SDK base stays as one shared external node; +- ``imports_from`` for ``using`` / ``import`` (context ``import``). A bare + import (``import Utils;`` — a module of the same app) also gets a sourceless + stub for the same rewire; a dotted ``Toybox.*`` import stays dangling and is + dropped at build time, like a Python stdlib import; +- ``calls`` for targets resolvable inside the file (own class, enclosing + module, file scope; ``me.`` / ``self.`` stripped), ``indirect_call`` for + ``method(:name)`` / ``new Lang.Method(self, :name)`` callbacks; +- everything else goes to ``raw_calls``: bare calls for the shared cross-file + pass, member calls tagged ``lang="monkeyc"`` with a ``receiver_type`` — an + explicit ``Type.fn()`` qualifier (``receiver_kind="static"``) or a receiver + typed via ``var x as T`` / a parameter type / ``x = new T()`` + (``receiver_kind="typed"``) — for :func:`resolve_monkeyc_member_calls`. +""" +from __future__ import annotations + +import bisect +import re +from pathlib import Path + +from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id + +# Comments and literals, blanked before scanning. Strings never span lines in +# Monkey C; the char-literal branch keeps `'{'` from unbalancing the brace scan. +_MC_BLANK_RE = re.compile( + r"//[^\n]*" + r"|/\*.*?\*/" + r"|\"(?:\\.|[^\"\\\n])*\"" + r"|'(?:\\.|[^'\\\n])'", + re.DOTALL, +) + +# One pass, left to right. Declarations consume their opening brace so the +# scope they open is pushed with the right owner; every other brace is an +# anonymous block (function body statements, dictionary literals, `enum { }`, +# `switch { }`, ...) that only has to stay balanced. +_MC_TOKEN_RE = re.compile( + r""" + (?P\b(?:using|import)\s+(?P[A-Za-z_][\w.]*) + (?:\s+as\s+(?P[A-Za-z_]\w*))?\s*;) + | (?P\bclass\s+(?P[A-Za-z_]\w*) + (?:\s+extends\s+(?P[A-Za-z_][\w.]*))?\s*\{) + | (?P\bmodule\s+(?P[A-Za-z_]\w*)\s*\{) + | (?P\bfunction\s+(?P[A-Za-z_]\w*)\s* + \((?P(?:[^()]|\((?:[^()]|\([^()]*\))*\))*)\)\s*(?:as\b[^{;]*)?\{) + | (?P\{) + | (?P\}) + """, + re.VERBOSE, +) + +# Inside a function body: `new T(`, `method(:sym)`, and `recv.callee(` / +# `callee(`. `new` is listed first so `new Foo(` is never read as a call to +# `Foo`; the negative lookbehind keeps `a.b().c(` from yielding a spurious +# receiver-less `c(` and skips symbols such as `:name(`; `$.` (the global +# scope qualifier) is accepted and dropped. +_MC_CALL_RE = re.compile( + r""" + (?P\bnew\s+(?P[A-Za-z_][\w.]*)\s*\() + | (?P\bmethod\s*\(\s*:(?P[A-Za-z_]\w*)\s*\)) + | (?P(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.)? + (?P[A-Za-z_]\w*)\s*\() + """, + re.VERBOSE, +) + +# `new Lang.Method(self, :onTick)` / `new Method(me, :onTick)` — the callback +# form that predates the `method(:sym)` shorthand. +_MC_METHOD_OBJ_RE = re.compile(r"\s*(?P[A-Za-z_]\w*)\s*,\s*:(?P[A-Za-z_]\w*)") +# `me.method(:sym)` / `obj.method(:sym)`: the receiver form of the callback +# shorthand, reached through the `call` branch (callee `method`). +_MC_METHOD_SYM_RE = re.compile(r"\s*:(?P[A-Za-z_]\w*)\s*\)") + +# Type table sources: `var x as T`, `x = new T(`, and `name as T` parameters. +_MC_VAR_TYPED_RE = re.compile(r"\bvar\s+(?P[A-Za-z_]\w*)\s+as\s+(?P[A-Za-z_][\w.]*)") +_MC_VAR_NAME_RE = re.compile(r"\bvar\s+(?P[A-Za-z_]\w*)") +_MC_NEW_ASSIGN_RE = re.compile( + r"(?[A-Za-z_]\w*)\s*=\s*new\s+(?P[A-Za-z_][\w.]*)\s*\(" +) +_MC_PARAM_TYPED_RE = re.compile(r"(?P[A-Za-z_]\w*)\s+as\s+(?P[A-Za-z_][\w.]*)") + +# Statement keywords that are followed by `(` and would otherwise look like a +# call. `method` is Object.method(:sym), handled by its own branch; a +# `method(x)` with a non-symbol argument is not a call graph edge either. +_MC_CALL_KEYWORDS = frozenset({ + "if", "else", "for", "while", "do", "switch", "case", "default", "catch", + "try", "finally", "return", "throw", "new", "function", "and", "or", "not", + "instanceof", "has", "as", "break", "continue", "var", "const", "enum", + "class", "module", "using", "import", "static", "hidden", "private", + "public", "protected", "self", "me", "null", "true", "false", "method", +}) + +_MC_SELF = frozenset({"me", "self"}) +_MC_SDK_ROOT = "Toybox" + + +def _expand_alias(name: str, aliases: dict[str, str]) -> str: + """``Ui.View`` -> ``Toybox.WatchUi.View`` under ``using Toybox.WatchUi as Ui``.""" + head, sep, rest = name.partition(".") + full = aliases.get(head) + if full is None: + return name + return f"{full}.{rest}" if sep else full + + +def extract_monkeyc(path: Path) -> dict: + """Extract modules, classes, functions, imports and calls from a ``.mc`` file.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except Exception as e: # noqa: BLE001 - mirrors the other extractors + return {"nodes": [], "edges": [], "error": str(e)} + + # Blank comments/literals in place: same length, newlines kept. + def _blank(m: re.Match) -> str: + return "".join("\n" if ch == "\n" else " " for ch in m.group(0)) + + src = _MC_BLANK_RE.sub(_blank, text) + line_starts = [0] + [m.end() for m in re.finditer(r"\n", src)] + + def _line(offset: int) -> int: + return bisect.bisect_right(line_starts, offset) + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + raw_calls: list[dict] = [] + seen_ids: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() + + def add_node(nid: str, label: str, line: int, **extra) -> None: + if nid in seen_ids: + return + seen_ids.add(nid) + node = {"id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}"} + node.update(extra) + nodes.append(node) + + def add_stub(nid: str, label: str) -> None: + # Sourceless stub for a symbol defined outside this file (a base class or + # an imported module). No source_file, so _disambiguate_colliding_node_ids + # never bakes this file's path into the id and the unique-stub rewire can + # collapse it onto the real definition (see powershell/objc for the same + # pattern, #1402). + if nid in seen_ids: + return + seen_ids.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": "", "source_location": ""}) + + def add_edge(src: str, tgt: str, relation: str, line: int, *, + confidence: str = "EXTRACTED", context: str | None = None) -> None: + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) + edge = {"source": src, "target": tgt, "relation": relation, + "confidence": confidence, "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0} + if context: + edge["context"] = context + edges.append(edge) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + aliases: dict[str, str] = {} + # owner nid -> {function name -> function nid}; the file's own functions + # are keyed by file_nid. + funcs_by_owner: dict[str, dict[str, str]] = {} + # type name -> nid for classes/modules declared in this file + local_types: dict[str, str] = {} + class_bases: list[tuple[str, str, int]] = [] # (class nid, base as written, line) + bare_imports: list[tuple[str, int]] = [] # (module name, line) + func_scopes: list[dict] = [] # closed function scopes + class_spans: list[dict] = [] # closed class/module scopes + + # Scope stack. kind: file | module | class | function | block. + stack: list[dict] = [{"kind": "file", "nid": file_nid, "name": path.name, "start": 0}] + + def _owner() -> dict: + for scope in reversed(stack): + if scope["kind"] in ("file", "module", "class"): + return scope + return stack[0] + + def _enclosing(kind: str) -> dict | None: + for scope in reversed(stack): + if scope["kind"] == kind: + return scope + return None + + for m in _MC_TOKEN_RE.finditer(src): + kind = m.lastgroup + line = _line(m.start()) + if kind == "import": + name = m.group("import_name") + alias = m.group("import_alias") or name.rsplit(".", 1)[-1] + aliases[alias] = name + if "." in name: + add_edge(file_nid, _make_id(name), "imports_from", line, context="import") + else: + # A bare import names a module of this app — possibly one + # declared further down this very file, so it is resolved in + # the post-pass below. + bare_imports.append((name, line)) + elif kind == "klass": + owner = _owner() + prefix = stem if owner["kind"] == "file" else owner["nid"] + name = m.group("class_name") + nid = _make_id(prefix, name) + add_node(nid, name, line, _callable=True, _callable_class=True) + add_edge(owner["nid"], nid, "contains", line) + local_types.setdefault(name, nid) + base = m.group("class_base") + if base: + class_bases.append((nid, base, line)) + stack.append({"kind": "class", "nid": nid, "name": name, "start": m.end()}) + elif kind == "module": + owner = _owner() + prefix = stem if owner["kind"] == "file" else owner["nid"] + name = m.group("module_name") + nid = _make_id(prefix, name) + add_node(nid, name, line) + add_edge(owner["nid"], nid, "contains", line) + local_types.setdefault(name, nid) + stack.append({"kind": "module", "nid": nid, "name": name, "start": m.end()}) + elif kind == "func": + owner = _owner() + name = m.group("func_name") + if owner["kind"] == "class": + nid = _make_id(owner["nid"], name) + add_node(nid, f".{name}()", line, _callable=True) + add_edge(owner["nid"], nid, "method", line) + else: + prefix = stem if owner["kind"] == "file" else owner["nid"] + nid = _make_id(prefix, name) + add_node(nid, f"{name}()", line, _callable=True) + add_edge(owner["nid"], nid, "contains", line) + funcs_by_owner.setdefault(owner["nid"], {}).setdefault(name, nid) + stack.append({"kind": "function", "nid": nid, "name": name, + "start": m.end(), "owner": owner, + "params": m.group("func_params") or "", "line": line}) + elif kind == "open": + stack.append({"kind": "block", "start": m.end()}) + elif kind == "close": + if len(stack) <= 1: + continue # unbalanced `}` — never pop the file scope + scope = stack.pop() + scope["end"] = m.start() + if scope["kind"] == "function": + func_scopes.append(scope) + elif scope["kind"] in ("class", "module"): + class_spans.append(scope) + + # A scope still open at EOF (unbalanced source) closes at the end. + while len(stack) > 1: + scope = stack.pop() + scope["end"] = len(src) + if scope["kind"] == "function": + func_scopes.append(scope) + elif scope["kind"] in ("class", "module"): + class_spans.append(scope) + + # ── bare imports (post-pass: the module may be declared later in the file) ── + for name, line in bare_imports: + if name in local_types: + target = local_types[name] + else: + # A module of this app defined in another file: a sourceless stub + # lets the corpus-level unique-stub rewire collapse the edge onto + # the real module node. + target = _make_id(name) + add_stub(target, name) + add_edge(file_nid, target, "imports_from", line, context="import") + + # ── inherits (post-pass: the base may be declared later in the file) ── + for cls_nid, base, line in class_bases: + if "." not in base and base in local_types: + base_nid = local_types[base] + else: + label = _expand_alias(base, aliases) + base_nid = _make_id(label) + add_stub(base_nid, label) + if base_nid != cls_nid: + add_edge(cls_nid, base_nid, "inherits", line) + + # ── type tables for receiver typing ── + # Every typed `var`, `x = new T(` and typed parameter is attributed to the + # innermost function (a local) or, outside any function, to the enclosing + # class/module (a field). A name bound to two different types is dropped. + func_scopes.sort(key=lambda s: s["start"]) + func_starts = [s["start"] for s in func_scopes] + + def _func_at(offset: int) -> dict | None: + i = bisect.bisect_right(func_starts, offset) - 1 + if i >= 0 and offset < func_scopes[i]["end"]: + return func_scopes[i] + return None + + class_spans.sort(key=lambda s: s["start"]) + + def _type_scope_at(offset: int) -> dict | None: + best = None + for scope in class_spans: + if scope["start"] <= offset < scope["end"]: + best = scope # spans are nested; the last (innermost) start wins + return best + + locals_by_func: dict[str, dict[str, str | None]] = {} + fields_by_type: dict[str, dict[str, str | None]] = {} + declared_locals: dict[str, set[str]] = {} + + def _bind(table: dict[str, str | None], name: str, type_name: str) -> None: + prev = table.get(name, ...) + if prev is ... or prev == type_name: + table[name] = type_name + else: + table[name] = None # ambiguous + + for fs in func_scopes: + for pm in _MC_PARAM_TYPED_RE.finditer(fs["params"]): + _bind(locals_by_func.setdefault(fs["nid"], {}), pm.group("name"), pm.group("type")) + for vm in _MC_VAR_NAME_RE.finditer(src): + fs = _func_at(vm.start()) + if fs is not None: + declared_locals.setdefault(fs["nid"], set()).add(vm.group("name")) + for vm in _MC_VAR_TYPED_RE.finditer(src): + fs = _func_at(vm.start()) + if fs is not None: + _bind(locals_by_func.setdefault(fs["nid"], {}), vm.group("name"), vm.group("type")) + else: + ts = _type_scope_at(vm.start()) + owner_nid = ts["nid"] if ts is not None else file_nid + _bind(fields_by_type.setdefault(owner_nid, {}), vm.group("name"), vm.group("type")) + for am in _MC_NEW_ASSIGN_RE.finditer(src): + name, type_name = am.group("name"), am.group("type") + fs = _func_at(am.start()) + if fs is not None and name in declared_locals.get(fs["nid"], ()): + _bind(locals_by_func.setdefault(fs["nid"], {}), name, type_name) + continue + ts = _type_scope_at(am.start()) + owner_nid = ts["nid"] if ts is not None else file_nid + _bind(fields_by_type.setdefault(owner_nid, {}), name, type_name) + + # ── calls ── + seen_call_pairs: set[tuple[str, str]] = set() + + def _module_of(scope: dict) -> str | None: + # The innermost module that contains a scope, for a bare call from a + # class method to a function of the enclosing module. Spans are sorted + # by start, so a nested module comes after its parent: keep the last hit. + best = None + for outer in class_spans: + if outer["kind"] == "module" and outer["start"] <= scope["start"] < outer["end"]: + best = outer["nid"] + return best + + def _receiver_type(fs: dict, recv: str) -> tuple[str | None, str | None]: + """(type name, kind) for a member-call receiver, or (None, None).""" + head = recv.split(".", 1)[0] + if "." not in recv: + typed = locals_by_func.get(fs["nid"], {}).get(recv) + if typed is None: + owner = fs["owner"] + typed = fields_by_type.get(owner["nid"], {}).get(recv) + if typed is None and owner["kind"] == "class": + mod = _module_of(fs) + if mod: + typed = fields_by_type.get(mod, {}).get(recv) + if typed is None: + typed = fields_by_type.get(file_nid, {}).get(recv) + if typed: + return _expand_alias(typed, aliases), "typed" + if head[:1].isupper(): + return _expand_alias(recv, aliases), "static" + return None, None + + def _emit_call(caller: str, target: str, relation: str, line: int, *, + confidence: str = "EXTRACTED", context: str = "call") -> None: + if target == caller: + return + pair = (caller, target) + if pair in seen_call_pairs: + return + seen_call_pairs.add(pair) + add_edge(caller, target, relation, line, confidence=confidence, context=context) + + def _resolve_bare(fs: dict, name: str) -> str | None: + owner = fs["owner"] + hit = funcs_by_owner.get(owner["nid"], {}).get(name) + if hit: + return hit + if owner["kind"] == "class": + mod = _module_of(fs) + if mod: + hit = funcs_by_owner.get(mod, {}).get(name) + if hit: + return hit + if owner["kind"] != "file": + hit = funcs_by_owner.get(file_nid, {}).get(name) + if hit: + return hit + return None + + def _raw(fs: dict, callee: str, line: int, **extra) -> None: + rc = {"caller_nid": fs["nid"], "callee": callee, "is_member_call": False, + "lang": "monkeyc", "source_file": str_path, "source_location": f"L{line}"} + rc.update(extra) + raw_calls.append(rc) + + def _callback(fs: dict, sym: str, line: int) -> None: + target = _resolve_bare(fs, sym) + if target: + _emit_call(fs["nid"], target, "indirect_call", line, + confidence="INFERRED", context="callback") + else: + _raw(fs, sym, line, indirect=True, context="callback", + self_scope=fs["owner"]["kind"] == "class") + + for fs in func_scopes: + body = src[fs["start"]:fs["end"]] + base = fs["start"] + for cm in _MC_CALL_RE.finditer(body): + line = _line(base + cm.start()) + branch = cm.lastgroup + if branch == "new": + type_name = cm.group("new_type") + if type_name.rsplit(".", 1)[-1] == "Method": + om = _MC_METHOD_OBJ_RE.match(body, cm.end()) + if om is not None: + if om.group("obj") in _MC_SELF: + _callback(fs, om.group("sym"), line) + else: + _raw(fs, om.group("sym"), line, indirect=True, context="callback") + continue + if "." not in type_name and type_name in local_types: + _emit_call(fs["nid"], local_types[type_name], "calls", line) + elif _expand_alias(type_name, aliases).startswith(_MC_SDK_ROOT + "."): + continue # SDK constructor: never binds to a same-named app class + else: + # `new Foo(...)` is a call to Foo's constructor: the shared + # cross-file pass resolves the bare type name (as the Java + # extractor does for object_creation_expression, #1373). + _raw(fs, type_name.rsplit(".", 1)[-1], line) + elif branch == "mref": + _callback(fs, cm.group("mref_name"), line) + else: + callee = cm.group("callee") + recv = cm.group("recv") + if recv: + head, _, rest = recv.partition(".") + if head in _MC_SELF: + recv = rest or None + if callee == "method": + # `me.method(:sym)` / `obj.method(:sym)`: same callback as + # the bare `method(:sym)` branch, on a receiver. + sm = _MC_METHOD_SYM_RE.match(body, cm.end()) + if sm is not None: + if recv is None: + _callback(fs, sm.group("sym"), line) + else: + _raw(fs, sm.group("sym"), line, indirect=True, context="callback") + continue + if callee in _MC_CALL_KEYWORDS: + continue + if not recv: + target = _resolve_bare(fs, callee) + if target: + _emit_call(fs["nid"], target, "calls", line) + else: + _raw(fs, callee, line, self_scope=fs["owner"]["kind"] == "class") + continue + if "." not in recv and recv in local_types: + target = funcs_by_owner.get(local_types[recv], {}).get(callee) + if target: + _emit_call(fs["nid"], target, "calls", line) + continue + type_name, kind = _receiver_type(fs, recv) + _raw(fs, callee, line, is_member_call=True, receiver=recv, + receiver_type=type_name, receiver_kind=kind) + + clean_edges = [e for e in edges if e["source"] in seen_ids and + (e["target"] in seen_ids or e["relation"] == "imports_from")] + return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} + + +def resolve_monkeyc_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve cross-file Monkey C member calls and inherited bare calls. + + The shared cross-file call pass skips every ``is_member_call`` (a bare method + name collides across the corpus and inflates god nodes, #543/#1219). Monkey C + code is dominated by such calls: an app is one namespace of modules and + classes, so ``Store.get()``, ``BleSession.enroll()`` and + ``_transport.write()`` are the architecture. The extractor records the + receiver and, when it can, its type; this pass binds the call ONLY when that + type name resolves to exactly one Monkey C definition (the god-node guard) and + the callee is a function of that type or of a base along its ``inherits`` + chain (a module's functions hang off ``contains`` edges, a class's off + ``method`` edges). An explicit ``Type.fn()`` qualifier is EXTRACTED; a + receiver typed by local inference is INFERRED. A qualified call whose type is + known but whose function is not (a field, an SDK method on a local + subclass) becomes a type-level ``references`` edge. + + A bare call the extractor could not resolve inside its own file but which + was made from a class body (``self_scope``) is looked up along the caller's + class ``inherits`` chain — the ``initialize()`` / ``onShow()`` -style call to + a method a base class in another file provides; when the shared pass already + bound that pair by corpus-unique name, nothing is added. ``Toybox.*`` + receivers are the SDK and never resolve to app code. + + Purely additive; runs after id disambiguation so node ids are final. + """ + raw = [ + rc + for result in per_file + for rc in (result.get("raw_calls") or []) + if rc.get("lang") == "monkeyc" and rc.get("callee") and rc.get("caller_nid") + and (rc.get("receiver_type") or rc.get("self_scope")) + ] + if not raw: + return + + node_by_id: dict[str, dict] = {n["id"]: n for n in all_nodes if n.get("id")} + # Real (sourced) Monkey C class/module definitions, by exact label. Labels of + # functions carry `()`, so `endswith(")")` keeps them out; a stub has no + # source_file and is skipped so the guard counts definitions only. + types_by_label: dict[str, list[str]] = {} + for n in all_nodes: + sf = str(n.get("source_file") or "") + if not sf.endswith(".mc") or n.get("file_type") != "code": + continue + label = str(n.get("label", "")) + if not label or label.endswith(")") or label.startswith(".") or "." in label: + continue + if label == Path(sf).name: + continue # the file node + types_by_label.setdefault(label, []).append(n["id"]) + + children: dict[str, dict[str, str]] = {} # type nid -> {function name -> nid} + parent_of: dict[str, str] = {} # function nid -> owning type nid + bases: dict[str, list[str]] = {} + for e in all_edges: + rel = e.get("relation") + src, tgt = e.get("source"), e.get("target") + if rel in ("method", "contains"): + tnode = node_by_id.get(tgt) + if tnode is None: + continue + label = str(tnode.get("label", "")) + if label.endswith("()"): + name = label[:-2].lstrip(".") + children.setdefault(src, {}).setdefault(name, tgt) + parent_of.setdefault(tgt, src) + elif rel == "inherits": + bases.setdefault(src, []).append(tgt) + + def _lookup(type_nid: str, name: str) -> str | None: + seen: set[str] = set() + frontier = [type_nid] + depth = 0 + while frontier and depth < 16: + nxt: list[str] = [] + for t in frontier: + if t in seen: + continue + seen.add(t) + hit = children.get(t, {}).get(name) + if hit: + return hit + nxt.extend(bases.get(t, [])) + frontier = nxt + depth += 1 + return None + + existing = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in raw: + caller = rc["caller_nid"] + callee = rc["callee"] + indirect = bool(rc.get("indirect")) + relation = "indirect_call" if indirect else "calls" + target: str | None = None + rtype = rc.get("receiver_type") + if rtype: + if rtype == _MC_SDK_ROOT or rtype.startswith(_MC_SDK_ROOT + "."): + continue + head = rtype.rsplit(".", 1)[-1] + if head in _LANGUAGE_BUILTIN_GLOBALS: + continue + candidates = types_by_label.get(rtype) or types_by_label.get(head, []) + if len(candidates) != 1: + continue + type_nid = candidates[0] + target = _lookup(type_nid, callee) + if target is None: + if rc.get("receiver_kind") != "static": + continue + target = type_nid + relation = "references" + confidence = "EXTRACTED" if rc.get("receiver_kind") == "static" else "INFERRED" + else: + owner = parent_of.get(caller) + if not owner: + continue + target = _lookup(owner, callee) + confidence = "EXTRACTED" + if indirect: + confidence = "INFERRED" # a callback names the method; it is not invoked here + # The shared pass runs first and may already have bound the same pair + # (an inherited bare call resolved by corpus-unique name); additive only. + if not target or target == caller or (caller, target) in existing: + continue + existing.add((caller, target)) + edge = { + "source": caller, + "target": target, + "relation": relation, + "confidence": confidence, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + "context": rc.get("context") or ("callback" if indirect else "call"), + } + all_edges.append(edge) diff --git a/tests/fixtures/monkeyc_ambiguous/A.mc b/tests/fixtures/monkeyc_ambiguous/A.mc new file mode 100644 index 0000000000..c8bc56b68c --- /dev/null +++ b/tests/fixtures/monkeyc_ambiguous/A.mc @@ -0,0 +1,4 @@ +// One of two same-named classes: the resolver must not guess between them. +class Twin { + function ping() as Void {} +} diff --git a/tests/fixtures/monkeyc_ambiguous/B.mc b/tests/fixtures/monkeyc_ambiguous/B.mc new file mode 100644 index 0000000000..e32d69e283 --- /dev/null +++ b/tests/fixtures/monkeyc_ambiguous/B.mc @@ -0,0 +1,4 @@ +// The other Twin. +class Twin { + function ping() as Void {} +} diff --git a/tests/fixtures/monkeyc_ambiguous/Caller.mc b/tests/fixtures/monkeyc_ambiguous/Caller.mc new file mode 100644 index 0000000000..54fa6e6fdf --- /dev/null +++ b/tests/fixtures/monkeyc_ambiguous/Caller.mc @@ -0,0 +1,6 @@ +// `Twin.ping()` is ambiguous across A.mc / B.mc -> no edge (god-node guard). +class Caller { + function go() as Void { + Twin.ping(); + } +} diff --git a/tests/fixtures/monkeyc_cross_file/App.mc b/tests/fixtures/monkeyc_cross_file/App.mc new file mode 100644 index 0000000000..439215de78 --- /dev/null +++ b/tests/fixtures/monkeyc_cross_file/App.mc @@ -0,0 +1,20 @@ +// Derived view: extends a class from Base.mc, calls its module and inherits its method. +using Toybox.WatchUi as Ui; +import Toybox.Lang; +import Store; + +class MainView extends BaseView { + private var _other as BaseView; + + function initialize() { + BaseView.initialize(); // superclass call across files + _other = new BaseView(); // constructor call across files + } + + function onShow() as Void { + var n = Store.get(:count); // Module.fn() across files + refresh(); // inherited from BaseView (other file) + _other.refresh(); // typed receiver across files + Store.reset(); // Store has no reset(): type-level `references` edge + } +} diff --git a/tests/fixtures/monkeyc_cross_file/Base.mc b/tests/fixtures/monkeyc_cross_file/Base.mc new file mode 100644 index 0000000000..287f6fc24a --- /dev/null +++ b/tests/fixtures/monkeyc_cross_file/Base.mc @@ -0,0 +1,25 @@ +// Base module + base view, extended and called from App.mc. +using Toybox.WatchUi as Ui; +import Toybox.Lang; + +module Store { + var _cache as Dictionary = {}; + + function get(key as Symbol) as Number or Null { + return _cache.get(key); + } + + function put(key as Symbol, value as Number) as Void { + _cache.put(key, value); + } +} + +class BaseView extends Ui.View { + function initialize() { + View.initialize(); + } + + function refresh() as Void { + Store.put(:refreshed, 1); + } +} diff --git a/tests/fixtures/sample.mc b/tests/fixtures/sample.mc new file mode 100644 index 0000000000..3514f2a995 --- /dev/null +++ b/tests/fixtures/sample.mc @@ -0,0 +1,111 @@ +// Sample Monkey C (Garmin Connect IQ) source for the extractor tests. +/* A block comment with a stray brace { and a fake declaration: + function nope() { } + class Nope { } +*/ +using Toybox.WatchUi as Ui; +using Toybox.Graphics; +import Toybox.Lang; +import Toybox.Timer; +import Toybox.System; +import Helpers; + +const GREETING = "hello { world // not a comment"; + +enum { STATE_IDLE, STATE_BUSY } + +module Helpers { + var _counter as Number = 0; + + function bump(step as Number) as Number { + _counter += step; + return _counter; + } + + function label(prefix as String) as String { + // bare call inside the module -> Helpers.bump + return prefix + " " + bump(1).toString(); + } + + class Ticker { + hidden var _timer as Timer.Timer or Null; + private var _ticks as Number = 0; + + function initialize() { + _timer = new Timer.Timer(); + } + + function start() as Void { + // callback registered by symbol -> .onTick() + _timer.start(method(:onTick), 1000, true); + // the older Method-object form -> .onIdle() + var idle = new Lang.Method(self, :onIdle); + if (idle == null) { throw new Lang.InvalidValueException("no callback"); } + } + + function onIdle() as Void { + _ticks = 0; + } + + function onTick() as Void { + _ticks = _ticks + 1; + me.report(); // me. -> own method + Helpers.bump(2); // Module.fn() -> raw member call, static receiver + } + + function report() as Void { + System.println(label("ticks")); // bare -> enclosing module Helpers.label + var again = me.method(:onIdle); // receiver form of the callback shorthand + } + } +} + +class SampleView extends Ui.View { + private var _ticker as Helpers.Ticker; + private var _delegate; + + function initialize() { + View.initialize(); + _ticker = new Helpers.Ticker(); + _delegate = new SampleDelegate(); // local class -> calls edge + } + + function onShow() as Void { + _ticker.start(); // typed field -> raw member call, typed receiver + $.Helpers.bump(3); // global-scope qualifier is dropped -> Helpers.bump() + var s = new SampleDelegate(); + s.onBack(); // local typed via `new` -> raw member call + requestUpdate(); // inherited SDK method -> raw bare call + } + + (:debug) private function _dump(dc as Graphics.Dc) as Void { + dc.drawText(0, 0, Graphics.FONT_SMALL, GREETING, Graphics.TEXT_JUSTIFY_LEFT); + var d = { :title => "x", :count => 3 }; + if (d.hasKey(:title)) { System.println('{'); } + switch (_delegate) { + case null: { break; } + default: { break; } + } + } +} + +class SampleDelegate extends Ui.BehaviorDelegate { + function initialize() { + BehaviorDelegate.initialize(); + } + + function onBack() as Boolean { + Ui.popView(Ui.SLIDE_DOWN); + return true; + } +} + +class DerivedView extends SampleView { + function initialize() { + SampleView.initialize(); // superclass call, local static receiver + } + + function onHide() as Void { + onShow(); // inherited from SampleView -> raw bare call (self_scope) + } +} diff --git a/tests/test_languages.py b/tests/test_languages.py index 37466ecfc9..fa2601cd2d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3052,3 +3052,220 @@ def test_decldef_merge_does_not_merge_same_name_same_dir_distinct_files(): r = _corpus("cpp_samedir/Alpha.h", "cpp_samedir/Beta.h") dups = _nodes_with_label(r, "Dup") assert len(dups) == 2, f"same-dir distinct Dups must stay distinct, got {[n['id'] for n in dups]}" + + +# ── Monkey C (Garmin Connect IQ) ───────────────────────────────────────────── +from graphify.extract import extract_monkeyc + + +def _mc(): + return extract_monkeyc(FIXTURES / "sample.mc") + + +def _mc_labelled(r, relation, context=None): + labels = {n["id"]: n["label"] for n in r["nodes"]} + return { + (labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"])) + for e in r["edges"] + if e["relation"] == relation and (context is None or e.get("context") == context) + } + + +def test_monkeyc_no_error(): + r = _mc() + assert "error" not in r + assert r["nodes"] and r["edges"] + + +def test_monkeyc_finds_modules_and_classes(): + labels = set(_labels(_mc())) + assert {"Helpers", "Ticker", "SampleView", "SampleDelegate", "DerivedView"} <= labels + # declarations inside comments/strings never become nodes + assert "Nope" not in labels + assert "nope()" not in labels + + +def test_monkeyc_finds_functions_with_scope_labels(): + labels = set(_labels(_mc())) + # module functions are `name()`, class methods `.name()` + assert {"bump()", "label()"} <= labels + assert {".initialize()", ".start()", ".onTick()", ".report()", ".onShow()", ".onBack()"} <= labels + # annotated + private method still found + assert "._dump()" in labels + + +def test_monkeyc_contains_and_method_edges(): + r = _mc() + contains = _mc_labelled(r, "contains") + methods = _mc_labelled(r, "method") + assert ("sample.mc", "Helpers") in contains + assert ("Helpers", "bump()") in contains + assert ("Helpers", "Ticker") in contains # class nested in a module + assert ("sample.mc", "SampleView") in contains + assert ("Ticker", ".onTick()") in methods + assert ("SampleView", "._dump()") in methods # braces in string/char/dict did not desync scopes + assert ("DerivedView", ".onHide()") in methods + + +def test_monkeyc_inherits_local_and_sdk_stub(): + r = _mc() + inherits = _mc_labelled(r, "inherits") + # a base declared in the same file resolves to the real node + assert ("DerivedView", "SampleView") in inherits + # an SDK base becomes a sourceless stub with the alias-expanded name + assert ("SampleView", "Toybox.WatchUi.View") in inherits + assert ("SampleDelegate", "Toybox.WatchUi.BehaviorDelegate") in inherits + stub = _node_by_label(r, "Toybox.WatchUi.View") + assert stub["source_file"] == "" and stub["file_type"] == "code" + + +def test_monkeyc_imports(): + r = _mc() + imports = _edges_with_relation(r, "imports_from") + assert len(imports) >= 6 + assert all(e.get("context") == "import" for e in imports) + # `import Helpers;` targets the module declared (later) in this very file + helpers = _node_by_label(r, "Helpers") + assert helpers["source_file"].endswith("sample.mc") + assert any(e["target"] == helpers["id"] for e in imports) + + +def test_monkeyc_intra_file_calls(): + r = _mc() + calls = _mc_labelled(r, "calls") + assert (".onTick()", ".report()") in calls # me.report() + assert ("label()", "bump()") in calls # bare call inside the module + assert (".report()", "label()") in calls # class method -> enclosing module function + assert (".initialize()", "SampleDelegate") in calls # new LocalClass() + assert (".initialize()", ".initialize()") in calls # DerivedView -> SampleView.initialize() + assert (".onTick()", "bump()") in calls # Helpers.bump() — module declared in this file + assert (".onShow()", "bump()") in calls # $.Helpers.bump() — `$.` global qualifier dropped + call_edges = _edges_with_relation(r, "calls") + assert all(e.get("context") == "call" for e in call_edges) + assert all(e["confidence"] == "EXTRACTED" for e in call_edges) + + +def test_monkeyc_callback_symbol_is_indirect_call(): + r = _mc() + indirect = _edges_with_relation(r, "indirect_call") + pairs = _mc_labelled(r, "indirect_call") + assert (".start()", ".onTick()") in pairs # method(:onTick) + assert (".start()", ".onIdle()") in pairs # new Lang.Method(self, :onIdle) + assert (".report()", ".onIdle()") in pairs # me.method(:onIdle) + assert all(e["confidence"] == "INFERRED" and e.get("context") == "callback" for e in indirect) + + +def test_monkeyc_raw_calls_carry_receiver_types(): + r = _mc() + raw = r["raw_calls"] + assert raw and all(rc["lang"] == "monkeyc" for rc in raw) + by_callee = {} + for rc in raw: + by_callee.setdefault(rc["callee"], []).append(rc) + # `Ui.popView(...)` — explicit qualifier through a `using ... as Ui` alias + pop = by_callee["popView"] + assert pop[0]["is_member_call"] is True + assert pop[0]["receiver_type"] == "Toybox.WatchUi" and pop[0]["receiver_kind"] == "static" + # `_ticker.start()` — field typed by `var _ticker as Helpers.Ticker` + start = [rc for rc in by_callee["start"] if rc.get("receiver") == "_ticker"] + assert start and start[0]["receiver_type"] == "Helpers.Ticker" and start[0]["receiver_kind"] == "typed" + # `_timer.start(...)` — field typed by `var _timer as Timer.Timer or Null`, alias-expanded + timer = [rc for rc in by_callee["start"] if rc.get("receiver") == "_timer"] + assert timer and timer[0]["receiver_type"] == "Toybox.Timer.Timer" and timer[0]["receiver_kind"] == "typed" + # `s.onBack()` — local typed by `var s = new SampleDelegate()` + on_back = by_callee["onBack"] + assert on_back[0]["receiver_type"] == "SampleDelegate" and on_back[0]["receiver_kind"] == "typed" + # `requestUpdate()` — bare, unresolved in-file, made from a class body + req = by_callee["requestUpdate"] + assert req[0]["is_member_call"] is False and req[0]["self_scope"] is True + # `View.initialize()` — SDK class brought in by `import`: a static member raw call + view_init = [rc for rc in by_callee["initialize"] if rc.get("receiver") == "View"] + assert view_init and view_init[0]["is_member_call"] is True + assert view_init[0]["receiver_type"] == "View" and view_init[0]["receiver_kind"] == "static" + # `new Timer.Timer()` / `new Lang.InvalidValueException()` — SDK constructors are never raw calls + assert "Timer" not in by_callee and "InvalidValueException" not in by_callee + # `if (`, `switch (` and friends are not calls + assert not {"if", "switch", "return", "new", "method", "throw"} & set(by_callee) + + +def test_monkeyc_no_dangling_edges(): + r = _mc() + ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in ids, e + if e["relation"] != "imports_from": + assert e["target"] in ids, e + + +def test_monkeyc_line_numbers_survive_blanking(): + r = _mc() + ticker = _node_by_label(r, "Ticker") + lines = (FIXTURES / "sample.mc").read_text().splitlines() + line_no = int(ticker["source_location"][1:]) + assert "class Ticker" in lines[line_no - 1] + + +def test_monkeyc_dispatch_registered(): + from graphify.extract import _DISPATCH + assert _DISPATCH[".mc"] is extract_monkeyc + + +def test_monkeyc_detect_extension_registered(): + from graphify.detect import CODE_EXTENSIONS, classify_file, FileType + assert ".mc" in CODE_EXTENSIONS + assert classify_file(FIXTURES / "sample.mc") == FileType.CODE + + +def test_monkeyc_cross_file_resolution(): + """Two-file corpus: `extends`, `Module.fn()`, `new Class()`, an inherited + bare call and a typed receiver all resolve across files, and a bare + `import Store;` is rewired onto the real module node.""" + r = _corpus("monkeyc_cross_file/Base.mc", "monkeyc_cross_file/App.mc") + labels = {n["id"]: n["label"] for n in r["nodes"]} + src_of = {n["id"]: n.get("source_file", "") for n in r["nodes"]} + + def pairs(relation): + return { + (labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"]), e["confidence"]) + for e in r["edges"] if e["relation"] == relation + } + + # inherits rewired from the sourceless stub onto the real BaseView (in Base.mc) + inherits = [e for e in r["edges"] if e["relation"] == "inherits" and labels.get(e["source"]) == "MainView"] + assert len(inherits) == 1 + assert labels[inherits[0]["target"]] == "BaseView" + assert src_of[inherits[0]["target"]].endswith("Base.mc") + # only one BaseView node survives (no leftover stub) + assert len([n for n in r["nodes"] if n["label"] == "BaseView"]) == 1 + + calls = pairs("calls") + assert (".onShow()", "get()", "EXTRACTED") in calls # Store.get() — explicit qualifier + assert (".initialize()", ".initialize()", "EXTRACTED") in calls # BaseView.initialize() + # `refresh()` inherited from BaseView: bound by the shared pass (unique name, INFERRED) + # or, when the name is ambiguous, by the inherits chain (EXTRACTED) — either way one edge. + assert any(s == ".onShow()" and t == ".refresh()" for s, t, _ in calls) + assert any(s == ".initialize()" and t == "BaseView" for s, t, _ in calls) # new BaseView() + # `Store.reset()` — Store is known but has no reset(): type-level references, EXTRACTED + assert (".onShow()", "Store", "EXTRACTED") in pairs("references") + + # `import Store;` -> the real Store module node in Base.mc + store = [n for n in r["nodes"] if n["label"] == "Store"] + assert len(store) == 1 and store[0]["source_file"].endswith("Base.mc") + imports = [e for e in r["edges"] if e["relation"] == "imports_from" and e["target"] == store[0]["id"]] + assert imports, "import Store; should be rewired onto the real module node" + + # SDK stubs stay external and unique + view_stubs = [n for n in r["nodes"] if n["label"] == "Toybox.WatchUi.View"] + assert view_stubs and all(n["source_file"] == "" for n in view_stubs) + + +def test_monkeyc_ambiguous_type_yields_no_edge(): + """Two same-named classes in different files: `Twin.ping()` must not bind to either + (the exactly-one-definition guard), and no phantom Twin node is fabricated.""" + r = _corpus("monkeyc_ambiguous/A.mc", "monkeyc_ambiguous/B.mc", "monkeyc_ambiguous/Caller.mc") + labels = {n["id"]: n["label"] for n in r["nodes"]} + twins = [n for n in r["nodes"] if n["label"] == "Twin" and n.get("source_file")] + assert len(twins) == 2 + from_go = [(labels.get(e["target"], e["target"]), e["relation"]) + for e in r["edges"] if labels.get(e["source"]) == ".go()"] + assert not any(t in (".ping()", "Twin") for t, _ in from_go), from_go