mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 17:26:48 +00:00
#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:
co-authored by
Claude Sonnet 4.6
parent
f146be3ea2
commit
a8dbbe59cf
+48
-8
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user