diff --git a/graphify/export.py b/graphify/export.py index 9aa11003..328b5256 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -444,8 +444,11 @@ def _obsidian_safe_stem(label: str) -> str: label.replace("\r\n", " ").replace("\r", " ").replace("\n", " "), ).strip() cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE) - # Obsidian treats a leading-dot filename as a hidden file (#2205). - if cleaned.startswith("."): + # Obsidian treats a leading-dot filename as a hidden file (#2205). Only + # prefix when something nameable remains after the dots: an all-dots label + # like "..." would otherwise become the meaningless stem "dot-" instead of + # falling through to the "unnamed" guard below (#1409). + if cleaned.startswith(".") and re.search(r"\w", cleaned.lstrip("."), flags=re.UNICODE): cleaned = "dot-" + cleaned.lstrip(".") # A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char # strip above but is empty once a downstream tool re-slugs on word chars diff --git a/tests/test_detect.py b/tests/test_detect.py index 15fb570e..63b13a00 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -2530,3 +2530,42 @@ def test_sensitive_bare_keyword_prose_still_dropped(): assert _is_sensitive(Path("secrets.md")) assert _is_sensitive(Path("token.rst")) assert not _is_sensitive(Path("token-lifecycle.md")) # multi-word slug indexed + + +# ── #2232 / #2184: committed dotenv templates (.env.example etc.) are graphable ── + +@pytest.mark.parametrize("path", [ + ".env.example", + ".env.sample", + ".env.template", + ".env.dist", + ".ENV.EXAMPLE", # case-insensitive, real on macOS/Windows + ".envrc.sample", # direnv template + ".env.production.example", # per-environment template +]) +def test_sensitive_filter_indexes_env_templates(path): + """Placeholder-only committed templates must not be treated as live secrets.""" + assert not _is_sensitive(Path(path)), f"{path} is a committed template, must be indexed (#2184)" + + +@pytest.mark.parametrize("path", [ + ".env", + ".env.local", + ".env.production", + ".envrc", + ".env.example.local", # template suffix not final -> a real local override + ".env.example.bak", # backup of a (possibly filled-in) env file +]) +def test_sensitive_filter_still_excludes_real_env_files(path): + """The template carve-out is suffix-anchored; live env files stay excluded.""" + assert _is_sensitive(Path(path)), f"{path} is a live env file, must stay excluded (#2184)" + + +@pytest.mark.parametrize("path", [ + "secrets/.env.example", + "deploy/credentials/.env.example", +]) +def test_sensitive_env_template_inside_secrets_dir_still_dropped(path): + """Stage 1 dir guard runs before the Stage 2 template exemption: anything + under a secrets/credentials dir stays excluded, template suffix or not.""" + assert _is_sensitive(Path(path)), f"{path} is under a secrets dir, must stay excluded (#2184)" diff --git a/tests/test_export.py b/tests/test_export.py index 57d66797..7b55780e 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -459,6 +459,16 @@ def test_to_obsidian_leading_dot_labels_are_not_hidden_filenames(): assert not any(s.startswith(".") for s in file_stems), file_stems +def test_obsidian_safe_stem_all_dots_label_falls_back_to_unnamed(): + """#2205 follow-up: the `dot-` prefix only applies when a word char survives + the dot strip. An all-dots label like "..." must hit the #1409 "unnamed" + fallback, not produce the meaningless stem "dot-".""" + from graphify.export import _obsidian_safe_stem + assert _obsidian_safe_stem(".env") == "dot-env" # #2205 fix unchanged + assert _obsidian_safe_stem("...") == "unnamed" # not "dot-" + assert _obsidian_safe_stem("Database") == "Database" # normal labels untouched + + # ── Existing-vault safety: graphify must not clobber user notes / .obsidian (#1506) ── def _two_node_graph():