From a8dbbe59cfa7bb87d124fb85297b475d8d3fdb91 Mon Sep 17 00:00:00 2001 From: Safi Date: Sun, 7 Jun 2026 12:20:36 +0100 Subject: [PATCH] fix: land PRs #1170 #1169 #1165 (hooks, sensitive filter, score_nodes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1170 — replace nohup with cross-platform Python detach in git hooks. Git for Windows MSYS has no nohup so post-commit/post-checkout hooks silently failed. Now uses subprocess.Popen with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP on Windows, start_new_session=True on POSIX. Quoting-safe (argv list). Fixes #1161. #1169 — fix _is_sensitive false positives on topic-mentioning filenames. token-economics-of-recall.md and password-policy-discussion.md were silently dropped as secrets. Generic keywords (token/secret/password) now only fire when the keyword ends the filename stem or the stem is ≤2 words. Specific patterns (.env/.pem/id_rsa etc.) remain unconditional. #1165 — fix multi-word endpoint resolution in _score_nodes. graphify path "AuthService" "UserRepo" never fired the exact-match bonus because per-token comparison never equalled the full label. Now joins normalized tokens and compares against the full label and its tokenized form. O(1) per node, affects query_graph and shortest_path uniformly. Co-Authored-By: Claude Sonnet 4.6 --- graphify/detect.py | 56 ++++++++++++--- graphify/hooks.py | 168 ++++++++++++++++++++++++++++--------------- graphify/serve.py | 32 ++++++++- tests/test_detect.py | 27 +++++++ tests/test_hooks.py | 95 ++++++++++++++++++++++++ tests/test_serve.py | 31 ++++++++ 6 files changed, 344 insertions(+), 65 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index cb6964c9..921661d2 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -98,23 +98,60 @@ _SENSITIVE_DIRS = frozenset({ ".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials", }) -# Files that may contain secrets - skip silently. +# Files that may contain secrets - skip silently. These patterns are specific +# (extensions, exact credential-store names) and always apply. +_SENSITIVE_PATTERNS = [ + re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE), + re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE), + re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'), + re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE), + re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE), +] + +# Generic keyword patterns - these only count when the keyword is LOAD-BEARING +# in the filename (see _generic_keyword_hit), because a keyword buried mid-phrase +# in a long descriptive slug names a topic, not a credential store: +# "token-economics-of-recall.md" is a note ABOUT tokens; "api_token.txt" IS one. # Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt # match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior # is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because # "i" after "token" is alpha and blocks the match). # `token` is kept separate because its longer suffix "izer"/"ize" is the only # common false-positive; other keywords have no such well-known derivatives. -_SENSITIVE_PATTERNS = [ - re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE), - re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE), +_GENERIC_KEYWORD_PATTERNS = [ re.compile(r'(? bool: + """True if a generic secret keyword appears load-bearing in the filename. + + Secret-store files name their contents, and in English compounds the + content noun is the head, which comes last: "github-personal-access-token", + "api_token", "oauth_token". A keyword that is neither at the end of the + stem nor in a short (<=2 word) name is a topic word in a descriptive slug + ("token-economics-of-recall.md", "password-policy-discussion.md") and must + not cause the file to be silently dropped from the graph (#436, #718). + """ + # Stem = name up to the first dot, ignoring leading dots so dotfiles like + # ".token" keep their keyword ("" stems would never match). + stem = name.lstrip('.').split('.')[0] + for pat in _GENERIC_KEYWORD_PATTERNS: + hit = False + for m in pat.finditer(stem): + hit = True + if m.end() == len(stem): # keyword ends the stem -> names the contents + return True + if hit and len([w for w in _WORD_SPLIT.split(stem) if w]) <= 2: + return True # short name like token_config.yaml / secret_handler.txt + return False + # Signals that a .md/.txt file is actually a converted academic paper _PAPER_SIGNALS = [ re.compile(r'\barxiv\b', re.IGNORECASE), @@ -143,7 +180,10 @@ def _is_sensitive(path: Path) -> bool: return True # Stage 2: filename pattern match name = path.name - return any(p.search(name) for p in _SENSITIVE_PATTERNS) + if any(p.search(name) for p in _SENSITIVE_PATTERNS): + return True + # Stage 3: generic keywords, only when load-bearing in the name + return _generic_keyword_hit(name) def _looks_like_paper(path: Path) -> bool: diff --git a/graphify/hooks.py b/graphify/hooks.py index 258c29d9..d704eea2 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -74,6 +74,110 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then fi """ +# The Python that the rebuild runs, shared by both hooks. Embedded verbatim into +# the launcher below and re-executed in the detached child. Must not contain the +# double-quote, $, backtick or backslash characters: it is carried inside a +# shell double-quoted `-c "..."` argument (see _detached_launch). +_REBUILD_BODY_COMMIT = """\ +import os, signal, sys +from pathlib import Path + +changed_raw = os.environ.get('GRAPHIFY_CHANGED', '') +changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()] + +if not changed: + sys.exit(0) + +print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...') + +try: + from graphify.watch import _rebuild_code, _apply_resource_limits + _apply_resource_limits() + _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600')) + if _timeout > 0 and hasattr(signal, 'SIGALRM'): + signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s'))) + signal.alarm(_timeout) + _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') + _rebuild_code(Path('.'), changed_paths=changed, force=_force) +except TimeoutError as exc: + print(f'[graphify hook] {exc}') + sys.exit(1) +except Exception as exc: + print(f'[graphify hook] Rebuild failed: {exc}') + sys.exit(1) +""" + +_REBUILD_BODY_CHECKOUT = """\ +from graphify.watch import _rebuild_code, _apply_resource_limits +from pathlib import Path +import os, signal, sys +try: + _apply_resource_limits() + _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600')) + if _timeout > 0 and hasattr(signal, 'SIGALRM'): + signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s'))) + signal.alarm(_timeout) + _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') + # post-checkout: branch switch can touch arbitrary files; full rebuild path + # (no changed_paths) is correct here. The flock inside _rebuild_code still + # prevents pile-ups when commit + checkout fire back-to-back. + _rebuild_code(Path('.'), force=_force) +except TimeoutError as exc: + print(f'[graphify] {exc}') + sys.exit(1) +except Exception as exc: + print(f'[graphify] Rebuild failed: {exc}') + sys.exit(1) +""" + +# Cross-platform detached-launch shim (#1161). The hooks used to background the +# rebuild with `nohup "$GRAPHIFY_PYTHON" -c "..." &`, but Git for Windows' bundled +# MSYS shell ships no nohup (nor setsid), so that line died with +# 'nohup: command not found' and the rebuild silently never ran — git commit/pull +# still returned 0, so the graph just went stale with no signal. graphify already +# requires Python, so we let Python do the detaching: a tiny outer process spawns +# the real rebuild fully detached and returns immediately, so the hook never +# blocks. POSIX uses start_new_session (the setsid equivalent); Windows uses +# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, breaking away from any job object +# when allowed. This payload is carried inside a shell double-quoted -c argument, +# so it deliberately uses only single-quoted Python strings (no ", $, ` or \\). +_LAUNCHER_TEMPLATE = """\ +import os, subprocess, sys +_src = ''' +__REBUILD_BODY__ +''' +_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log') +try: + os.makedirs(os.path.dirname(_log), exist_ok=True) + _out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace') +except OSError: + _out = subprocess.DEVNULL +_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True) +_cmd = [sys.executable, '-c', _src] +if os.name == 'nt': + _flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP + try: + subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB + except OSError: + subprocess.Popen(_cmd, creationflags=_flags, **_kw) +else: + subprocess.Popen(_cmd, start_new_session=True, **_kw) +""" + + +def _detached_launch(rebuild_body: str) -> str: + """Return a POSIX-sh line that runs ``rebuild_body`` as a detached background + Python process via ``$GRAPHIFY_PYTHON``. + + Replaces the old ``nohup ... &`` form, which failed on Git for Windows' + shell (no nohup/setsid) and let the rebuild silently never run (#1161). + The launcher writes the child's output to ``$GRAPHIFY_REBUILD_LOG`` and + returns the instant the child is spawned, so the git hook never blocks. + """ + launcher = _LAUNCHER_TEMPLATE.replace("__REBUILD_BODY__", rebuild_body) + return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n' + + _HOOK_SCRIPT = """\ # graphify-hook-start # Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed). @@ -107,41 +211,15 @@ fi """ + _PYTHON_DETECT + """ export GRAPHIFY_CHANGED="$CHANGED" -# Run rebuild detached so git commit returns immediately. -# Full repo rebuilds can take hours; blocking the post-commit hook stalls the shell. +# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds +# can take hours; blocking the post-commit hook stalls the shell. The Python +# launcher below detaches the child cross-platform, so it works on Git for +# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161). _GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log" mkdir -p "$(dirname "$_GRAPHIFY_LOG")" +export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG" echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)" -nohup "$GRAPHIFY_PYTHON" -c " -import os, signal, sys -from pathlib import Path - -changed_raw = os.environ.get('GRAPHIFY_CHANGED', '') -changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()] - -if not changed: - sys.exit(0) - -print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...') - -try: - from graphify.watch import _rebuild_code, _apply_resource_limits - _apply_resource_limits() - _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600')) - if _timeout > 0 and hasattr(signal, 'SIGALRM'): - signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s'))) - signal.alarm(_timeout) - _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') - _rebuild_code(Path('.'), changed_paths=changed, force=_force) -except TimeoutError as exc: - print(f'[graphify hook] {exc}') - sys.exit(1) -except Exception as exc: - print(f'[graphify hook] Rebuild failed: {exc}') - sys.exit(1) -" >> "$_GRAPHIFY_LOG" 2>&1 < /dev/null & -disown 2>/dev/null || true -# graphify-hook-end +""" + _detached_launch(_REBUILD_BODY_COMMIT) + """# graphify-hook-end """ @@ -179,31 +257,9 @@ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) """ + _PYTHON_DETECT + """ _GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log" mkdir -p "$(dirname "$_GRAPHIFY_LOG")" +export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG" echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)" -nohup "$GRAPHIFY_PYTHON" -c " -from graphify.watch import _rebuild_code, _apply_resource_limits -from pathlib import Path -import os, signal, sys -try: - _apply_resource_limits() - _timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600')) - if _timeout > 0 and hasattr(signal, 'SIGALRM'): - signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s'))) - signal.alarm(_timeout) - _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') - # post-checkout: branch switch can touch arbitrary files; full rebuild path - # (no changed_paths) is correct here. The flock inside _rebuild_code still - # prevents pile-ups when commit + checkout fire back-to-back. - _rebuild_code(Path('.'), force=_force) -except TimeoutError as exc: - print(f'[graphify] {exc}') - sys.exit(1) -except Exception as exc: - print(f'[graphify] Rebuild failed: {exc}') - sys.exit(1) -" >> "$_GRAPHIFY_LOG" 2>&1 < /dev/null & -disown 2>/dev/null || true -# graphify-checkout-hook-end +""" + _detached_launch(_REBUILD_BODY_CHECKOUT) + """# graphify-checkout-hook-end """ diff --git a/graphify/serve.py b/graphify/serve.py index 4c55c675..a349f511 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -136,11 +136,38 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: scored = [] norm_terms = [tok for t in terms for tok in _search_tokens(t)] idf = _compute_idf(G, norm_terms) + # Whole-query string for full-label matching (mirrors _find_node's `term`). + joined = " ".join(norm_terms) + # Weight the full-query bonus by the rarest constituent term so a specific + # multi-word label still outweighs common-token noise; floor at 1.0. + joined_w = max((idf.get(t, 1.0) for t in norm_terms), default=1.0) for nid, data in G.nodes(data=True): norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower() bare_label = norm_label.rstrip("()") + # Tokenized form of the label (punctuation stripped, same transform as the + # query). norm_label may still carry punctuation like ':' or '-', which a + # tokenized query can never equal; comparing token-joined forms on both + # sides makes "uoce: dehumidifier driver" match query "uoce dehumidifier + # driver". + label_tokens = " ".join(_search_tokens(data.get("label") or "")) source = (data.get("source_file") or "").lower() score = 0.0 + # Full-query tier: a multi-word query that equals (or prefixes) the whole + # label must dominate the per-token bag-of-words sums below, so `path`/ + # `query` resolve the same node `explain` does (via _find_node). Without + # this, no single token equals a multi-word label, the per-token exact + # tier never fires, and every node sharing the token set ties -> arbitrary + # node-id sort -> wrong/disconnected endpoint -> false "No path found". + if joined: + nid_lower = nid.lower() + if joined in (norm_label, bare_label, label_tokens, nid_lower): + score += _EXACT_MATCH_BONUS * 10 * joined_w + elif ( + norm_label.startswith(joined) + or bare_label.startswith(joined) + or label_tokens.startswith(joined) + ): + score += _PREFIX_MATCH_BONUS * 10 * joined_w for t in norm_terms: w = idf.get(t, 1.0) # Three-tier precedence: exact > prefix > substring (take the @@ -155,7 +182,10 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: score += _SOURCE_MATCH_BONUS * w if score > 0: scored.append((score, nid)) - return sorted(scored, reverse=True) + # Sort by score desc; break ties toward the shorter label so a concise exact + # match beats a longer superset that happens to share the same score. + scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1])) + return scored def _pick_seeds(scored: list[tuple[float, str]], max_k: int = 3, gap_ratio: float = 0.2) -> list[str]: diff --git a/tests/test_detect.py b/tests/test_detect.py index ee5ac1d6..395a9d4d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -638,6 +638,33 @@ def test_sensitive_token_config_yaml(): assert _is_sensitive(Path("token_config.yaml")) +# ── Generic keywords must be load-bearing: topic slugs are not secret stores ── +# A keyword buried mid-phrase in a >=3-word descriptive name is a note ABOUT +# the topic, not a credential file. It must not be silently dropped. + +def test_sensitive_does_not_flag_token_economics_note(): + assert not _is_sensitive(Path("token-economics-of-recall.md")) + +def test_sensitive_does_not_flag_password_policy_discussion(): + assert not _is_sensitive(Path("password-policy-discussion.md")) + +def test_sensitive_flags_keyword_at_end_of_long_name(): + # Keyword as the final word names the file's contents — still a secret store. + assert _is_sensitive(Path("github-personal-access-token.txt")) + +def test_sensitive_flags_my_private_key_txt(): + # Multi-word keyword at end of stem (end-of-stem check runs before word + # counting, so splitting private_key on "_" cannot un-flag it). + assert _is_sensitive(Path("my_private_key.txt")) + +def test_sensitive_flags_dotfile_token(): + # Leading dot stripped before stem extraction; ".token" keeps its keyword. + assert _is_sensitive(Path(".token")) + +def test_sensitive_flags_plural_tokens_txt(): + assert _is_sensitive(Path("tokens.txt")) + + # ── Issue #933: failed-chunk files must not be frozen in manifest ───────────── def test_save_manifest_skips_semantic_hash_for_files_without_cache(tmp_path): diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 098b5ee0..c4032718 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -216,3 +216,98 @@ def test_hook_check_no_additionalContext(tmp_path): assert result.returncode == 0 assert result.stdout == "" assert result.stderr == "" + + +# ── #1161: background rebuild must not rely on nohup (missing on Git for Windows) ── + +import ast # noqa: E402 +import re # noqa: E402 + +from graphify.hooks import ( # noqa: E402 + _HOOK_SCRIPT, + _CHECKOUT_SCRIPT, + _REBUILD_BODY_COMMIT, + _REBUILD_BODY_CHECKOUT, + _detached_launch, +) + +_HOOK_SCRIPTS = [("post-commit", _HOOK_SCRIPT), ("post-checkout", _CHECKOUT_SCRIPT)] + + +@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS) +def test_hooks_do_not_use_nohup(name, script): + """Git for Windows' bundled shell ships no `nohup`/`setsid`, so the old + `nohup ... &` launch died with 'nohup: command not found' and the rebuild + silently never ran (#1161). The generated hooks must not reference either.""" + assert "nohup" not in script, f"{name} still references nohup (#1161)" + assert "setsid" not in script, f"{name} still references setsid (#1161)" + assert "disown" not in script, f"{name} still uses disown (#1161)" + + +@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS) +def test_hooks_use_cross_platform_detach(name, script): + """The replacement detaches via Python: start_new_session on POSIX and + DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP on Windows (#1161).""" + assert "subprocess.Popen" in script + assert "start_new_session=True" in script, f"{name} missing POSIX detach" + assert "0x00000008" in script, f"{name} missing Windows DETACHED_PROCESS flag" + assert "0x00000200" in script, f"{name} missing CREATE_NEW_PROCESS_GROUP flag" + + +def _launcher_payload(script: str) -> str: + """Extract the `python -c ""` the hook hands to GRAPHIFY_PYTHON. + + The launcher is the only `-c` invocation whose body begins with + `import os, subprocess, sys` (the interpreter-detection probes in + _PYTHON_DETECT use `-c "import graphify"`).""" + m = re.search(r'-c "(import os, subprocess, sys.*?)"\n', script, re.DOTALL) + assert m, "launcher payload not found" + return m.group(1) + + +@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS) +def test_launcher_payload_is_shell_quote_safe(name, script): + """The launcher is carried inside a shell double-quoted `-c "..."` argument, + so it must contain no characters the shell would interpret there: an + unescaped double-quote, $, backtick or backslash would corrupt the hook.""" + payload = _launcher_payload(script) + for bad in ('"', "$", "`", "\\"): + assert bad not in payload, f"{name} launcher payload contains unsafe {bad!r}" + + +@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS) +def test_launcher_and_rebuild_body_are_valid_python(name, script): + """Both the launcher and the rebuild body it re-executes must parse, so a + quoting slip can't ship a hook that crashes the moment git fires it.""" + payload = _launcher_payload(script) + ast.parse(payload) # launcher itself + inner = re.search(r"_src = '''(.*?)'''", payload, re.DOTALL) + assert inner, f"{name}: embedded rebuild body not found" + ast.parse(inner.group(1)) # the detached child's source + + +def test_rebuild_bodies_are_shell_quote_safe(): + """The shared rebuild bodies are embedded verbatim into the launcher, so they + too must avoid characters unsafe inside a shell double-quoted argument.""" + for body in (_REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT): + for bad in ('"', "$", "`", "\\"): + assert bad not in body + assert "'''" not in body # would terminate the launcher's _src literal + + +def test_detached_launch_targets_graphify_python(): + """The launcher must run via the resolved $GRAPHIFY_PYTHON, not a bare + `python`, so it uses the same interpreter the detection block selected.""" + snippet = _detached_launch(_REBUILD_BODY_COMMIT) + assert snippet.startswith('"$GRAPHIFY_PYTHON" -c "') + assert "nohup" not in snippet + + +def test_installed_hooks_contain_no_nohup(tmp_path): + """End-to-end: the files written to .git/hooks must be nohup-free (#1161).""" + repo = _make_git_repo(tmp_path) + install(repo) + for name in ("post-commit", "post-checkout"): + text = (repo / ".git" / "hooks" / name).read_text(encoding="utf-8") + assert "nohup" not in text, f"installed {name} still references nohup" + assert "start_new_session=True" in text diff --git a/tests/test_serve.py b/tests/test_serve.py index 5ab6271a..c98dece2 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -87,6 +87,37 @@ def test_score_nodes_ignores_trailing_punctuation(): assert scored[0][1] == "n1" +def test_score_nodes_multiword_exact_label_outranks_superset(): + """A multi-word query equal to a whole label must resolve uniquely. + + Regression for the `graphify path` "No path found" bug: every node sharing + the query's token set scored identically (no single token equals a + multi-word label, so the per-token exact tier never fired), the tie broke by + arbitrary node-id sort, and a wrong/disconnected endpoint was chosen. The + full-query tier in _score_nodes must make the exact label win strictly. + """ + G = nx.Graph() + # Reproduce the real graph: norm_label keeps punctuation (strip_diacritics + + # lower, NOT tokenized), so the ':' survives. A tokenized query can never + # equal that, which is exactly why the first-cut fix was a no-op for + # punctuated labels. The exact node must still win via the label's tokenized + # form. + def _add(nid, label, src): + G.add_node(nid, label=label, norm_label=label.lower(), + source_file=src, community=0) + + _add("exact", "UOCE: Dehumidifier Driver", "uoce_dehumidifier.yaml") + _add("super", "UOCE: Dehumidifier Driver State Machine", "uoce_dehumidifier.yaml") + _add("decoy", "Dehumidifier Driver Helper", "uoce_dehumidifier.yaml") + + # CLI resolves endpoints as [t.lower() for t in label.split()]. + scored = _score_nodes(G, [t.lower() for t in "UOCE: Dehumidifier Driver".split()]) + + # Resolves uniquely to the exact label, strictly ahead of the superset. + assert scored[0][1] == "exact" + assert scored[0][0] > scored[1][0], "exact label must strictly outrank superset/token-bag matches" + + def test_find_node_ignores_trailing_punctuation(): G = _make_graph() assert _find_node(G, "extract?") == ["n1"]