fix(hooks): match the real file extension in the Read|Glob hook (#1463)

The Read|Glob PreToolUse hook (the "run graphify first" nudge, shared by the
Claude Code and CodeBuddy installers via _READ_SETTINGS_HOOK) decided whether to
nudge by substring-scanning the joined file_path/pattern/path for known
extensions. That had two opposite failures: '.js' is a substring of '.json' so
package.json / tsconfig.json spuriously fired, and .astro/.vue/.svelte weren't in
the set so Astro/Vue/Svelte projects never nudged on their primary source type.

The hook now compares each value's real trailing extension (segment after the
last '/', then after the last '.') against the set, and adds .astro/.vue/.svelte.
package.json -> tail .json (silent); **/*.astro -> tail .astro (fires); an
extension on a directory component (my.ts/file) correctly stays silent. The
graphify-out/ suppression and fail-open behavior are unchanged.

Ported from PR #1464 by @marketechniks onto current v8. Added three regression
tests on top of the PR's (multi-dot a.test.tsx / foo.min.js, a Windows backslash
path, and the directory-extension trap) to pin the trickiest parts of the new
segment-split logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
marketechniks
2026-06-25 20:33:00 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 1e3270a374
commit 9b583a0dd5
3 changed files with 50 additions and 3 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: the `Read|Glob` PreToolUse hook (the "run graphify first" nudge installed for Claude Code and CodeBuddy) now matches the file's real trailing extension instead of substring-scanning the path (#1463, thanks @marketechniks). The old check asked `any(ext in path)`, which had two opposite failures: `.json` files (`package.json`, `tsconfig.json`) spuriously fired because `.js` is a substring of `.json`, and `.astro`/`.vue`/`.svelte` never fired because they weren't in the set — so on Astro/Vue/Svelte projects, where those are the primary source type, reads and globs never surfaced the graph. The hook now compares the segment after the last `/` then after the last `.` against the extension set (with `.astro`/`.vue`/`.svelte` added), so `package.json` stays silent, `data.geojson` stays silent, `**/*.astro` fires, and an extension sitting on a directory component (`my.ts/file`) correctly doesn't. The `graphify-out/` suppression and fail-open behavior are unchanged.
- Fix: make it unambiguous in the skill that graphify needs no API key, so terminal-style hosts stop looping on a missing one (#1461). Hermes (and the other AGENTS.md hosts: Codex, Aider, OpenClaw, Droid, Trae, …) run the `graphify` CLI directly and don't dispatch subagents, but the Step 3 extraction guidance framed the no-key path only as "fall through to subagent dispatch" — so on `/graphify .` those agents would spin for minutes insisting they needed an API key before eventually proceeding. Step 3 now opens with an explicit, hoisted "graphify needs no API key — never ask the user for one, never block on one" statement (code is AST-only; a code-only corpus skips semantic extraction entirely), and the fallback now spells out a non-subagent path for terminal hosts instead of assuming subagent dispatch. Applied across every generated skill body, including the aider/devin monoliths, with a regression test that pins the wording in place.
- Feat: extract WPF/XAML structure from `.xaml` files (#1460, thanks @MikeKatsoulakis). No new parser dependency (stdlib XML, with the same DOCTYPE/ENTITY and size guards as the `.csproj` extractor). Captures the root element, named controls (`x:Name`/`Name`) and their control types, `{Binding ...}` references, and `x:Class`, and bridges the view to its `.xaml.cs` code-behind by resolving event-handler attributes to the matching methods on the partial class. Event resolution is gated on the .NET handler signature `(object sender, …EventArgs e)` and skips free-form attributes (`Content`, `Text`, `Tag`, …), so a property value that merely matches a method name (e.g. `Content="Save"` next to a business method `Save()`) can't fabricate a spurious event edge.
- Fix: `to_canvas` (Obsidian Canvas export) now lays out each community's node cards in the same `ceil(sqrt(n))`-column grid the group box is sized for. The box width assumed a roughly-square `sqrt(n)`-column layout, but the placement loop hardcoded 3 columns, so any community larger than ~9 members rendered as a cramped 3-wide strip in an over-wide, mostly-empty box. The column count is now computed once per community and reused for the box width, box height, and card placement, so the cards fill the box. Cosmetic, no data change (#1452, thanks @TPAteeq).
+9 -3
View File
@@ -405,6 +405,10 @@ _READ_SETTINGS_HOOK = {
# python3 (already a graphify dependency), the shell is POSIX, and every branch
# fails open, so a legitimate read always goes through. Reading the graph's own
# report under graphify-out/ is suppressed so it never starts a feedback loop.
# The extension test compares each value's real trailing extension (segment
# after the last '/' then after the last '.') against exts -- not a substring
# scan, which both missed framework files like .astro and false-matched .json
# against .js (the substring '.js' is inside '.json').
"matcher": "Read|Glob",
"hooks": [
{
@@ -414,9 +418,11 @@ _READ_SETTINGS_HOOK = {
"import json,sys;"
"d=json.load(sys.stdin);"
"t=d.get('tool_input',d);"
"s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');"
"exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');"
"sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); "
"exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');"
"vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];"
"j=' '.join(vals).lower().replace(chr(92),'/');"
"tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];"
"sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); "
"if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then "
r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \"<question>\"` (scoped subgraph), `graphify explain \"<concept>\"`, or `graphify path \"<A>\" \"<B>\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too — include it in every subagent prompt involving code exploration."}}'; """
"fi || true"
+40
View File
@@ -62,6 +62,46 @@ def test_glob_pattern_nudges(tmp_path):
assert "graphify query" in out
def test_nudges_on_framework_source(tmp_path):
""".astro/.vue/.svelte are real source types and must nudge (regression)."""
for path in ("src/components/Hero.astro", "src/App.vue", "src/Card.svelte"):
out = _run({"file_path": path}, tmp_path, graph=True).stdout
assert "graphify query" in out, f"{path} should nudge"
def test_astro_glob_nudges(tmp_path):
out = _run({"pattern": "**/*.astro"}, tmp_path, graph=True).stdout
assert "graphify query" in out
def test_silent_on_json_config(tmp_path):
"""Config files must stay silent: '.json' must not match the '.js' extension."""
for path in ("package.json", "tsconfig.json", "data.geojson"):
out = _run({"file_path": path}, tmp_path, graph=True).stdout
assert out.strip() == "", f"{path} should not nudge"
def test_nudges_on_multi_dot_source(tmp_path):
"""A real trailing extension must win on multi-dot names (the segment split):
a.test.tsx -> .tsx (nudge), foo.min.js -> .js (nudge)."""
for path in ("src/a.test.tsx", "lib/foo.min.js"):
out = _run({"file_path": path}, tmp_path, graph=True).stdout
assert "graphify query" in out, f"{path} should nudge"
def test_windows_path_nudges(tmp_path):
"""Backslash-separated paths split on the real final segment, then its ext."""
out = _run({"file_path": r"src\components\app.py"}, tmp_path, graph=True).stdout
assert "graphify query" in out
def test_silent_when_extension_is_on_a_directory_segment(tmp_path):
"""An extension that sits on a directory component, not the final segment,
must not fire: my.ts/file -> tail is 'file' (no dot), silent."""
out = _run({"file_path": "my.ts/file"}, tmp_path, graph=True).stdout
assert out.strip() == ""
def test_fails_open_on_malformed_stdin(tmp_path):
(tmp_path / "graphify-out").mkdir()
(tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")