From 161e5f2ef5d7083ba6f139595c46b55155393dcd Mon Sep 17 00:00:00 2001 From: vstepko <36109882+vstepko@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:22:41 +0300 Subject: [PATCH] feat(terraform): resolve local module sources and directory topology (cherry picked from commit e00593d10ee0ac1d21079f9c2e1dcf82bc1e5624) --- README.md | 15 ++ graphify/build.py | 14 ++ graphify/cache.py | 2 +- graphify/cli.py | 8 + graphify/extract.py | 9 +- graphify/extractors/terraform.py | 138 ++++++++++++++++- graphify/watch.py | 2 + tests/test_terraform_modules.py | 257 +++++++++++++++++++++++++++++++ 8 files changed, 439 insertions(+), 6 deletions(-) create mode 100644 tests/test_terraform_modules.py diff --git a/README.md b/README.md index c9f11ef51..4db32086e 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,21 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Video / Audio | `.mp4 .mov .mp3 .wav` and more (requires `uv tool install graphifyy[video]`) | | YouTube / URLs | any video URL (requires `uv tool install graphifyy[video]`) | +Terraform module calls with a literal local `source` (`./...` or `../...`) link +to a directory module node through an `EXTRACTED` `module_source` edge. Each +directory node contains its scanned `.tf` files, so nested calls expose paths +such as environment → application → base → resource. Scan the common repository +root to include both callers and implementations. Paths resolve relative to the +calling module, and excluded or out-of-root files are never loaded implicitly. + +Remote sources and source expressions are not resolved; `.tfvars`, generic +`.hcl`, and `.tf.json` files do not define module-source targets. References to +`module.app.output` still target the module call rather than its implementation's +output. The graph represents source configuration, not evaluated Terraform +instances. Incremental Terraform changes reconcile the scanned `.tf` corpus, +reusing cached syntax for unchanged files. After upgrading an existing graph, +run `graphify update .` once to regenerate Terraform IDs and topology. + Code is extracted **locally with no API calls** (AST via tree-sitter). Everything else goes through your AI assistant's model API. Google Drive for desktop `.gdoc`, `.gsheet`, and `.gslides` files are shortcut diff --git a/graphify/build.py b/graphify/build.py index c553ab043..861a92855 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -2026,6 +2026,20 @@ def build_merge( continue # the new chunks re-emitted it — theirs wins carried.append(he) + # Remove replaced deleted-source records before entity dedup can choose their stale + # provenance over a freshly emitted node with the same ID. A Terraform + # directory anchor, for example, survives deletion of its first .tf file + # with a new source_file; pruning only AFTER build would delete the live + # anchor too if dedup selected the old record. + if prune_sources: + fresh_ids = {n.get("id") for chunk in new_chunks for n in chunk.get("nodes", [])} + existing_nodes = [ + n for n in existing_nodes + if not (n.get("id") in fresh_ids and _prune_match(n.get("source_file"))) + ] + # Other deleted records and edges stay until the normal prune below: + # it needs their connectivity to identify newly orphaned import stubs. + base = ( [{"nodes": existing_nodes, "edges": existing_edges, "hyperedges": carried_hyperedges}] if had_graph else [] diff --git a/graphify/cache.py b/graphify/cache.py index 63afc98ee..1d604b9aa 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -36,7 +36,7 @@ except Exception: _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 2 +_AST_CACHE_SCHEMA = 3 # Terraform directory-scoped IDs and module-source syntax facts. # Version dirs already swept this process — cleanup runs once per (base, version). _cleaned_ast_dirs: set[str] = set() diff --git a/graphify/cli.py b/graphify/cli.py index 04f7c5ff0..b93a0a8b1 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3741,6 +3741,13 @@ def dispatch_command(cmd: str) -> None: if detection.get("walk_errors"): _extraction_incomplete = True + if incremental_mode: + from graphify.extractors.terraform import refresh_terraform_paths + code_files = refresh_terraform_paths( + code_files, [Path(p) for p in files_by_type.get("code", [])], + [Path(p) for p in [*deleted_files, *excluded_files, *graph_stale_sources]], + ) + # AST extraction on code files. Empty code list (docs-only corpus) is # the issue #698 case — skip cleanly instead of crashing inside extract(). ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} @@ -3797,6 +3804,7 @@ def dispatch_command(cmd: str) -> None: for f in _flist } _ctx_live.discard(None) + _ctx_live.difference_update(_ctx_identity(p) for p in code_files) for _node in _ctx_graph.get("nodes", []): if not _node.get("id") or not _ctx_is_ast_tier(_node): continue diff --git a/graphify/extract.py b/graphify/extract.py index 936a151e7..4fd15d7cf 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -58,7 +58,7 @@ from graphify.extractors.robot import extract_robot # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 from graphify.extractors.sql import extract_sql # noqa: F401 -from graphify.extractors.terraform import extract_terraform # noqa: F401 +from graphify.extractors.terraform import extract_terraform, prepare_terraform, resolve_terraform_modules # noqa: F401 from graphify.extractors.verilog import extract_verilog # noqa: F401 from graphify.extractors.zig import extract_zig # noqa: F401 from graphify.security import sanitize_metadata @@ -4729,6 +4729,9 @@ _KOTLIN_IMPORT_TARGET_RESOLVER = LanguageResolver( # registry (framework lives in graphify.resolver_registry). A new language plugs in # by adding one register() call below — no edits to extract()'s body. Order # preserved from the prior inlined wiring: Swift (#1356) before Python (#1446). +register_language_resolver( + LanguageResolver("terraform_modules", frozenset({".tf"}), resolve_terraform_modules) +) register_language_resolver( LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls) ) @@ -6659,6 +6662,10 @@ def extract( file=sys.stderr, flush=True, ) + for path, result in zip(paths, per_file): + if path.suffix in (".tf", ".tfvars", ".hcl"): + prepare_terraform(result, path, root) + all_nodes: list[dict] = [] all_edges: list[dict] = [] all_raw_calls: list[dict] = [] diff --git a/graphify/extractors/terraform.py b/graphify/extractors/terraform.py index b5fb78f99..8b3fb4d8f 100644 --- a/graphify/extractors/terraform.py +++ b/graphify/extractors/terraform.py @@ -1,13 +1,121 @@ -"""Terraform extractor. Moved verbatim from graphify/extract.py.""" +"""Terraform block extraction and local module-source topology.""" from __future__ import annotations +import hashlib +import json +from collections.abc import Sequence from pathlib import Path from graphify.extractors.base import _make_id _TF_META_HEADS = frozenset({"count", "each", "self", "path", "terraform"}) + +def _scope_id(directory: str) -> str: + # Hash the exact path too: make_id folds case and punctuation, which must + # not merge e.g. dev/app with prod/app, or a-b with a_b. + digest = hashlib.sha256(directory.encode("utf-8")).hexdigest()[:16] + return _make_id("terraform", directory, digest) + + +def prepare_terraform(result: dict, path: Path, root: Path) -> None: + """Make cached directory-scoped IDs portable before the common merge passes.""" + directory = path.parent.resolve() + try: + relative = directory.relative_to(root).as_posix() + except ValueError: + return + old, new = _scope_id(directory.as_posix()), _scope_id(relative) + for node in result.get("nodes", []): + nid = node["id"] + if nid.startswith(old + "_"): + node["id"] = new + nid[len(old):] + node["_terraform_directory"] = relative + for edge in result.get("edges", []): + for key in ("source", "target"): + nid = edge[key] + if nid.startswith(old + "_"): + edge[key] = new + nid[len(old):] + # Resolve paths only here, with the scan boundary available. The per-file + # cache keeps syntax facts, never filesystem-dependent topology. + for node in result.get("nodes", []): + source = node.get("module_source", "") + if not source.startswith(("./", "../")): + continue + try: + target = (directory / source).resolve().relative_to(root) + except (ValueError, OSError, RuntimeError): + continue + node["_terraform_source_directory"] = target.as_posix() + + +def resolve_terraform_modules(per_file: list[dict], nodes: list[dict], edges: list[dict]) -> None: + """Connect module calls to scanned .tf directories without reading new files.""" + files: dict[str, list[dict]] = {} + anchors = {} + for node in nodes: + directory = node.get("_terraform_directory") + if directory is None: + continue + if node.get("type") == "terraform_file" and Path(node["source_file"]).suffix == ".tf": + files.setdefault(directory, []).append(node) + elif node.get("type") == "module" and node.get("language") == "terraform": + anchors[directory] = node["id"] + fresh_files = { + node["id"] for result in per_file for node in result.get("nodes", []) + if node.get("type") == "terraform_file" + } + known_ids = {n["id"] for n in nodes} + for directory, members in sorted(files.items()): + anchor = anchors.setdefault(directory, _make_id(_scope_id(directory), "directory")) + if anchor not in known_ids: + owner = min(members, key=lambda n: n["source_file"]) + nodes.append({ + "id": anchor, "label": f"Terraform module: {directory}", + "type": "module", "language": "terraform", "file_type": "code", + "source_file": owner["source_file"], "source_location": None, + "_terraform_directory": directory, + }) + known_ids.add(anchor) + for member in members: + if member["id"] in fresh_files: + edges.append({ + "source": anchor, "target": member["id"], "relation": "contains", + "confidence": "EXTRACTED", "weight": 1.0, + "source_file": member["source_file"], "source_location": None, + }) + fresh_calls = { + node["id"] for result in per_file for node in result.get("nodes", []) + if "module_source" in node + } + for node in nodes: + if node["id"] not in fresh_calls: + continue + target = anchors.get(node.get("_terraform_source_directory")) + if target is not None: + edges.append({ + "source": node["id"], "target": target, "relation": "module_source", + "confidence": "EXTRACTED", "weight": 1.0, + "source_file": node["source_file"], + "source_location": node["module_source_location"], + }) + + +def refresh_terraform_paths( + changed: list[Path], corpus: list[Path], invalidated: Sequence[Path] = (), +) -> list[Path]: + """Reconcile directory ownership and incoming links on incremental scans. + + Callers pass only the permitted, live corpus. Cached syntax is reused for + unchanged Terraform files; other languages keep their incremental behavior. + """ + if not any(p.suffix == ".tf" or p.name in (".gitignore", ".graphifyignore") + for p in [*changed, *invalidated]): + return changed + return list(dict.fromkeys([*changed, *(p for p in corpus if p.suffix == ".tf")])) + + def extract_terraform(path: Path) -> dict: """Extract Terraform/HCL blocks and the references between them via tree-sitter. @@ -37,14 +145,16 @@ def extract_terraform(path: Path) -> dict: except Exception as e: return {"nodes": [], "edges": [], "error": str(e)} - str_path = str(path) + str_path = str(path.parent.resolve() / path.name) file_nid = _make_id(str_path) - scope = path.parent.name or "tf" + scope = _scope_id(path.parent.resolve().as_posix()) nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code", - "source_file": str_path, "source_location": None}] + "source_file": str_path, "source_location": None, + "type": "terraform_file"}] edges: list[dict] = [] seen_ids: set[str] = {file_nid} + nodes_by_id = {file_nid: nodes[0]} seen_edges: set[tuple[str, str, str]] = set() def _read(n) -> str: @@ -59,6 +169,7 @@ def extract_terraform(path: Path) -> dict: seen_ids.add(nid) nodes.append({"id": nid, "label": label, "file_type": "code", "source_file": str_path, "source_location": f"L{line}"}) + nodes_by_id[nid] = nodes[-1] edges.append({"source": file_nid, "target": nid, "relation": "contains", "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", "weight": 1.0}) @@ -156,6 +267,25 @@ def extract_terraform(path: Path) -> dict: owner = _add_node(f"data.{labels[0]}.{labels[1]}", f"data.{labels[0]}.{labels[1]}", line) elif btype == "module" and labels: owner = _add_node(f"module.{labels[0]}", f"module.{labels[0]}", line) + if blk_body is not None and path.suffix == ".tf": + for attr in blk_body.named_children: + if attr.type != "attribute" or _read(attr.named_children[0]) != "source": + continue + # Accept a plain quoted literal only, not interpolation, + # heredocs, or constant expressions requiring evaluation. + value = attr.named_children[-1] + while value.type in ("expression", "literal_value") and len(value.named_children) == 1: + value = value.named_children[0] + if value.type != "string_lit" or value.has_error: + continue + try: + module_source = json.loads(_read(value)) + except (ValueError, TypeError): + continue + if not isinstance(module_source, str): + continue + nodes_by_id[owner]["module_source"] = module_source.replace("$${", "${").replace("%%{", "%{") + nodes_by_id[owner]["module_source_location"] = f"L{attr.start_point[0] + 1}" elif btype == "variable" and labels: owner = _add_node(f"var.{labels[0]}", f"var.{labels[0]}", line) elif btype == "output" and labels: diff --git a/graphify/watch.py b/graphify/watch.py index b2f977d60..183e48497 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1600,6 +1600,8 @@ def _rebuild_code( # File was deleted or renamed away inside the watched root. # Evict preserved nodes that still claim this source path. _add_deleted_source(deleted_in_root) + from graphify.extractors.terraform import refresh_terraform_paths + wanted = refresh_terraform_paths(wanted, code_files, changed_paths) if not wanted and not deleted_paths: print("[graphify watch] No tracked code files in change set - skipping rebuild.") return True diff --git a/tests/test_terraform_modules.py b/tests/test_terraform_modules.py new file mode 100644 index 000000000..8e996fbb1 --- /dev/null +++ b/tests/test_terraform_modules.py @@ -0,0 +1,257 @@ +"""Local Terraform module topology, including incremental graph reconciliation.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import networkx as nx +import pytest + +from graphify.build import build_from_json +from graphify.extract import extract, extract_terraform + + +def _write(root, name, body): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def _extract(root, paths=None): + return extract( + sorted(root.rglob("*.tf")) if paths is None else paths, + root=root, cache_root=root, parallel=False, + ) + + +def _node(result, label, source=None): + return next(n for n in result["nodes"] if n["label"] == label + and (source is None or n["source_file"] == source)) + + +def _module_edges(result): + return [e for e in result.get("links", result.get("edges", [])) + if e.get("relation") == "module_source"] + + +def _assert_integrity(result): + ids = {n["id"] for n in result["nodes"]} + for e in result.get("links", result.get("edges", [])): + assert e["source"] in ids, e + assert e["target"] in ids, e + + +def test_environment_application_base_topology_and_source_provenance(tmp_path): + _write(tmp_path, "envs/dev/main.tf", 'module "app" {\n source = "../../applications/app"\n}\n') + _write(tmp_path, "applications/app/main.tf", 'module "base" { source = "../../base" }\n') + _write(tmp_path, "applications/app/outputs.tf", 'output "id" { value = "app" }\n') + _write(tmp_path, "base/storage.tf", 'resource "aws_s3_bucket" "data" {}\n') + result = _extract(tmp_path) + _assert_integrity(result) + G = build_from_json(result, root=tmp_path, directed=True) + env = _node(result, "Terraform module: envs/dev") + resource = _node(result, "aws_s3_bucket.data") + assert nx.has_path(G, env["id"], resource["id"]) + app = _node(result, "Terraform module: applications/app") + outputs = _node(result, "outputs.tf") + assert G.has_edge(app["id"], outputs["id"]) + edge = next(e for e in _module_edges(result) if e["target"] == app["id"]) + assert edge["source_file"] == "envs/dev/main.tf" + assert edge["source_location"] == "L2" + assert edge["confidence"] == "EXTRACTED" + assert len(_module_edges(result)) == 2 + + +def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path): + for directory in ("dev/app", "prod/app", "a-b/app", "a_b/app"): + _write(tmp_path, f"{directory}/main.tf", 'variable "name" {}\n') + _write(tmp_path, f"{directory}/use.tf", 'output "name" { value = var.name }\n') + result = _extract(tmp_path) + _assert_integrity(result) + variables = [n for n in result["nodes"] if n["label"] == "var.name"] + assert len({n["id"] for n in variables}) == 4 + for variable in variables: + source = str(Path(variable["source_file"]).parent / "use.tf") + output = _node(result, "output.name", source) + assert any(e["source"] == output["id"] and e["target"] == variable["id"] + for e in result["edges"]) + + +def test_raw_extractor_scopes_ids_by_full_directory(tmp_path): + paths = [_write(tmp_path, f"{env}/app/main.tf", 'variable "x" {}') + for env in ("dev", "prod")] + ids = [_node(extract_terraform(p), "var.x")["id"] for p in paths] + assert len(set(ids)) == 2 + + +def test_multiple_calls_share_one_module_and_cycles_are_finite(tmp_path): + _write(tmp_path, "main.tf", 'module "a" { source = "./child" }\nmodule "b" { source = "./child" }\n') + _write(tmp_path, "child/main.tf", 'module "parent" { source = "../" }\n') + result = _extract(tmp_path) + _assert_integrity(result) + assert len([n for n in result["nodes"] if n.get("type") == "module"]) == 2 + child_id = _node(result, "Terraform module: child")["id"] + assert sum(e["target"] == child_id for e in _module_edges(result)) == 2 + assert len(_module_edges(result)) == 3 + + +@pytest.mark.parametrize("source", [ + '"hashicorp/consul/aws"', '"git::https://example.com/module.git//sub?ref=v1"', + '"https://example.com/module.zip"', '"../missing"', + '"../${var.name}"', 'var.source', '"../app" + "suffix"', +]) +def test_unresolved_sources_do_not_create_topology(tmp_path, source): + _write(tmp_path, "env/main.tf", f'module "app" {{ source = {source} }}\n') + _write(tmp_path, "app/main.tf", 'resource "aws_s3_bucket" "data" {}\n') + assert _module_edges(_extract(tmp_path)) == [] + + +def test_ignored_outside_and_non_tf_targets_are_not_followed(tmp_path): + root = tmp_path / "repo" + caller = _write(root, "main.tf", ''' +module "outside" { source = "../outside" } +module "excluded" { source = "./excluded" } +module "hcl" { source = "./hcl" } +module "vars" { source = "./vars" } +module "nested" { source = "./nested" } +''') + _write(tmp_path, "outside/main.tf", 'variable "x" {}') + _write(root, "excluded/main.tf", 'variable "x" {}') + hcl = _write(root, "hcl/main.hcl", 'variable "x" {}') + tfvars = _write(root, "vars/main.tfvars", 'x = 1') + nested = _write(root, "nested/sub/main.tf", 'variable "x" {}') + result = _extract(root, [caller, hcl, tfvars, nested]) + assert _module_edges(result) == [] + assert not any(n.get("_terraform_directory") in ("excluded", "hcl", "vars", "nested") + for n in result["nodes"] if n.get("type") == "module") + + +def test_source_literals_escapes_and_nested_source_attributes(tmp_path): + _write(tmp_path, "main.tf", r'''module "app" { + source = "./a\u0070p" + settings = { source = "./wrong" } +} +module "escaped" { source = "./$${literal}" } +''') + _write(tmp_path, "app/main.tf", 'variable "x" {}') + _write(tmp_path, "${literal}/main.tf", 'variable "x" {}') + result = _extract(tmp_path) + assert _node(result, "module.app")["module_source"] == "./app" + assert len(_module_edges(result)) == 2 + + +def test_symlink_targets_respect_scan_boundary(tmp_path): + root = tmp_path / "repo" + _write(root, "main.tf", 'module "inside" { source = "./alias" }\nmodule "outside" { source = "./escape" }') + _write(root, "app/main.tf", 'variable "x" {}') + _write(tmp_path, "outside/main.tf", 'variable "y" {}') + try: + (root / "alias").symlink_to(root / "app", target_is_directory=True) + (root / "escape").symlink_to(tmp_path / "outside", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + result = _extract(root, [root / "main.tf", root / "app/main.tf"]) + assert len(_module_edges(result)) == 1 + assert _module_edges(result)[0]["target"] == _node(result, "Terraform module: app")["id"] + + +def test_cache_recomputes_topology_and_ids_are_portable(tmp_path, monkeypatch): + roots = [tmp_path / name for name in ("one", "two")] + for root in roots: + _write(root, "main.tf", 'module "app" { source = "./app" }') + _write(root, "app/main.tf", 'variable "x" {}') + first = _extract(roots[0]) + from graphify.extract import _DISPATCH + with monkeypatch.context() as cached: + def unexpected_parse(path): + raise AssertionError(f"cache miss for unchanged {path}") + cached.setitem(_DISPATCH, ".tf", unexpected_parse) + assert first == _extract(roots[0]) + second = _extract(roots[1]) + assert first["nodes"] == second["nodes"] + assert first["edges"] == second["edges"] + monkeypatch.chdir(roots[0]) + relative = _extract(Path("."), [Path("app/main.tf"), Path("main.tf")]) + assert first["nodes"] == relative["nodes"] + assert first["edges"] == relative["edges"] + target = roots[0] / "app/main.tf" + target.unlink() + result = _extract(roots[0]) + assert _module_edges(result) == [] + assert not any(n["label"] == "Terraform module: app" for n in result["nodes"]) + + +@pytest.mark.parametrize("mode", ["watch", "cli"]) +@pytest.mark.parametrize("no_cluster", [True, False]) +def test_incremental_source_change_target_addition_and_deletion(tmp_path, monkeypatch, mode, no_cluster): + root = tmp_path / "repo" + caller = _write(root, "env/main.tf", 'module "app" { source = "../app" }') + a = _write(root, "app/a.tf", 'variable "x" {}') + b = _write(root, "app/b.tf", 'variable "y" {}') + _write(root, "other/main.tf", 'variable "z" {}') + project_path = str(Path(__file__).resolve().parents[1]) + monkeypatch.setenv("PYTHONPATH", project_path) + monkeypatch.chdir(root) + + def rebuild(changed=None): + if mode == "watch": + from graphify.watch import _rebuild_code + assert _rebuild_code(root, changed_paths=changed, acquire_lock=False, + no_cluster=no_cluster) + else: + args = [sys.executable, "-m", "graphify", "extract", str(root), "--code-only"] + if no_cluster: + args.append("--no-cluster") + p = subprocess.run(args, cwd=root, env=os.environ.copy(), capture_output=True, text=True) + assert p.returncode == 0, p.stdout + p.stderr + result = json.loads((root / "graphify-out/graph.json").read_text()) + _assert_integrity(result) + return result + + first = rebuild() + anchor = _node(first, "Terraform module: app")["id"] + assert _module_edges(first)[0]["target"] == anchor + a.unlink() # Removing the anchor's provenance file must preserve the module. + second = rebuild([a]) + assert _module_edges(second)[0]["target"] == anchor + assert _node(second, "Terraform module: app")["source_file"] == "app/b.tf" + b.unlink() # Removing the final file must also remove the incoming link. + assert _module_edges(rebuild([b])) == [] + c = _write(root, "app/c.tf", 'variable "new" {}') + assert _module_edges(rebuild([c]))[0]["target"] == anchor + caller.write_text('module "app" { source = "../other" }') + final = rebuild([caller]) + assert len(_module_edges(final)) == 1 + assert _module_edges(final)[0]["target"] == _node(final, "Terraform module: other")["id"] + + +@pytest.mark.parametrize("mode", ["watch", "cli"]) +def test_incremental_exclusion_removes_incoming_module_links(tmp_path, monkeypatch, mode): + root = tmp_path / "repo" + _write(root, "main.tf", 'module "app" { source = "./app" }') + _write(root, "app/main.tf", 'variable "x" {}') + monkeypatch.setenv("PYTHONPATH", str(Path(__file__).resolve().parents[1])) + monkeypatch.chdir(root) + + def rebuild(changed=None): + if mode == "watch": + from graphify.watch import _rebuild_code + assert _rebuild_code(root, changed_paths=changed, acquire_lock=False, no_cluster=True) + else: + p = subprocess.run( + [sys.executable, "-m", "graphify", "extract", str(root), "--code-only", "--no-cluster"], + capture_output=True, text=True, + ) + assert p.returncode == 0, p.stdout + p.stderr + return json.loads((root / "graphify-out/graph.json").read_text()) + + assert len(_module_edges(rebuild())) == 1 + ignore = _write(root, ".graphifyignore", "app/\n") + result = rebuild([ignore]) + _assert_integrity(result) + assert _module_edges(result) == [] + assert not any(n.get("_terraform_directory") == "app" for n in result["nodes"])