diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515..802b3cb41 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.63 (2026-09-16) +- Fix: `detect()` no longer treats `graphify-out/memory/` as unconditionally immune to every ignore rule. A plain `.gitignore` entry on the tool's own output dir still cannot drop a saved query note (the same protection the `converted/` sidecar directory already had), but a deliberate `.graphifyignore`/`--exclude` rule targeting a memory note or subpath is now honored, giving users an actual way to keep a specific note out of the graph (#3637, thanks @ayushcodes10). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). diff --git a/graphify/detect.py b/graphify/detect.py index eec8aaf1d..747ae66f5 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -1817,6 +1817,11 @@ def _ignored_for_scan(path: Path) -> bool: explicit_cache=explicit_ignore_cache, ) + def _explicitly_ignored_for_scan(path: Path) -> bool: + # Only .graphifyignore/--exclude rules, never a plain .gitignore entry + # (see the memory_dir handling below, #3637). + return _is_ignored(path, root, explicit_ignore_patterns, _cache=explicit_ignore_cache) + # Always include graphify-out/memory/ - query results filed back into the graph memory_dir = root / GRAPHIFY_OUT / "memory" scan_paths = [root] @@ -1856,6 +1861,21 @@ def _on_walk_error(err: OSError) -> None: if parent_real == real or parent_real.startswith(real + os.sep): dirnames.clear() continue + if in_memory_tree: + # Memory notes stay visible regardless of noise-dir rules or + # the graphify-out/ blanket .gitignore convention the docs + # recommend -- only a deliberate .graphifyignore/--exclude + # rule prunes here, never a plain .gitignore entry, so users + # get an actual way to keep specific notes out of the graph + # without losing the "memory is always visible" default (#3637). + kept_memory_dirs: list[str] = [] + for d in dirnames: + child = dp / d + if _explicitly_ignored_for_scan(child): + ignored.append(str(child) + os.sep) + continue + kept_memory_dirs.append(d) + dirnames[:] = kept_memory_dirs if not in_memory_tree: # dp == root was already loaded by _load_graphifyignore (root is # the last entry in its ancestor chain); every other directory @@ -1935,7 +1955,13 @@ def _on_walk_error(err: OSError) -> None: # Skip files inside our own converted/ dir (avoid re-processing sidecars) if str(p).startswith(str(converted_dir)): continue - if not in_memory and _ignored_for_scan(p): + if _ignored_for_scan(p): + ignored.append(str(p)) + continue + elif _explicitly_ignored_for_scan(p): + # #3637: same reasoning as the directory level prune above -- + # only a deliberate .graphifyignore/--exclude rule excludes a + # memory note, never the graphify-out/ blanket .gitignore entry. ignored.append(str(p)) continue if not _resolves_under_root(p, root): diff --git a/tests/test_detect.py b/tests/test_detect.py index 22099029a..431eb8c80 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -924,6 +924,71 @@ def fake_convert(path, out_dir, *, xlsx_to_markdown=None, root=None): assert result["files"]["document"][0].endswith("notes_converted.md") +def test_detect_memory_note_survives_a_gitignored_output_dir(tmp_path): + """#3637 baseline: memory notes still default to always visible even + when graphify-out/ is gitignored per the documented convention, exactly + like the converted/ sidecar case above -- a plain .gitignore entry on + the tool's own output dir must not silently drop query notes filed back + into the graph.""" + (tmp_path / ".gitignore").write_text("graphify-out/\n", encoding="utf-8") + memory_dir = tmp_path / "graphify-out" / "memory" + memory_dir.mkdir(parents=True) + (memory_dir / "note.md").write_text("# Query Memory\n\nSome saved answer.", encoding="utf-8") + + result = detect(tmp_path) + file_list = result["files"]["document"] + assert any("note.md" in f for f in file_list) + + +def test_detect_graphifyignore_excludes_specific_memory_note(tmp_path): + """#3637: a deliberate .graphifyignore rule targeting a memory note (or + subpath) is honored, giving users an actual way to keep a specific note + out of the graph -- the hard include only protects the default, it must + not make the directory unconditionally immune to explicit exclusion.""" + (tmp_path / ".graphifyignore").write_text("graphify-out/memory/scratch.md\n", encoding="utf-8") + memory_dir = tmp_path / "graphify-out" / "memory" + memory_dir.mkdir(parents=True) + (memory_dir / "scratch.md").write_text("# Scratch\n\nNot for the graph.", encoding="utf-8") + (memory_dir / "keep.md").write_text("# Keep\n\nA real saved answer.", encoding="utf-8") + + result = detect(tmp_path) + file_list = result["files"]["document"] + assert any("keep.md" in f for f in file_list) + assert not any("scratch.md" in f for f in file_list) + + +def test_detect_graphifyignore_excludes_memory_subdirectory(tmp_path): + """#3637: an explicit .graphifyignore rule can prune a whole subdirectory + of the memory dir, not just single files.""" + (tmp_path / ".graphifyignore").write_text("graphify-out/memory/drafts/\n", encoding="utf-8") + memory_dir = tmp_path / "graphify-out" / "memory" + drafts = memory_dir / "drafts" + drafts.mkdir(parents=True) + (drafts / "wip.md").write_text("# WIP\n\nUnfinished.", encoding="utf-8") + (memory_dir / "keep.md").write_text("# Keep\n\nA real saved answer.", encoding="utf-8") + + result = detect(tmp_path) + file_list = result["files"]["document"] + assert any("keep.md" in f for f in file_list) + assert not any("wip.md" in f for f in file_list) + + +def test_detect_memory_note_plain_gitignore_entry_is_not_authoritative(tmp_path): + """#3637: a plain .gitignore rule that happens to match a memory note + (as opposed to a deliberate .graphifyignore/--exclude rule) must NOT + exclude it -- only .graphifyignore/--exclude are treated as a deliberate + opt out inside the memory dir; a generic VCS .gitignore entry is not + strong enough evidence of intent to drop a query note by itself.""" + (tmp_path / ".gitignore").write_text("*.md\n", encoding="utf-8") + memory_dir = tmp_path / "graphify-out" / "memory" + memory_dir.mkdir(parents=True) + (memory_dir / "note.md").write_text("# Query Memory\n\nSome saved answer.", encoding="utf-8") + + result = detect(tmp_path) + file_list = result["files"]["document"] + assert any("note.md" in f for f in file_list) + + def test_detect_includes_video_key(tmp_path): """detect() result always includes a 'video' key even with no video files.""" (tmp_path / "main.py").write_text("x = 1")