fix: land PRs #1170 #1169 #1165 (hooks, sensitive filter, score_nodes)

#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 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-07 12:20:36 +01:00
co-authored by Claude Sonnet 4.6
parent f146be3ea2
commit a8dbbe59cf
6 changed files with 344 additions and 65 deletions
+48 -8
View File
@@ -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'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', 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),
]
# Word separators for the load-bearing check (underscore intentionally included;
# multi-word keywords like private_key are handled by the end-of-stem check,
# which runs before word counting).
_WORD_SPLIT = re.compile(r'[-_\s]+')
def _generic_keyword_hit(name: str) -> 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:
+112 -56
View File
@@ -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
"""
+31 -1
View File
@@ -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]:
+27
View File
@@ -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):
+95
View File
@@ -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 "<payload>"` 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
+31
View File
@@ -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"]