@webship.co/workspace-graph 0.0.0-stage → 1.0.0-beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/commands/cmd-tools-graphify.sh +27 -0
- package/commands/cmd-tools-graphs.sh +18 -0
- package/commands/cmd-tools-obsidian.sh +31 -0
- package/config/workspace.graphs.settings.yml +25 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +309 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib/graphs.d.ts +1 -0
- package/dist/src/lib/graphs.js +141 -0
- package/dist/src/lib/graphs.js.map +1 -0
- package/dist/src/lib/obsidian.d.ts +1 -0
- package/dist/src/lib/obsidian.js +414 -0
- package/dist/src/lib/obsidian.js.map +1 -0
- package/docs/AGENTS.md +29 -0
- package/docs/CLAUDE.md +7 -0
- package/docs/README.md +11 -0
- package/features/graph.feature +13 -0
- package/package.json +80 -4
- package/scripts/args/arg-graphify.sh +36 -0
- package/scripts/args/arg-graphs.sh +12 -0
- package/scripts/args/arg-obsidian.sh +17 -0
- package/scripts/functions/fun-graphify.sh +587 -0
- package/scripts/functions/fun-graphs.sh +205 -0
- package/scripts/functions/fun-obsidian.sh +330 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
argparse "$@" <<ARGEOF || exit 1
|
|
4
|
+
parser.add_argument('GRAPH_NAME',
|
|
5
|
+
nargs='?',
|
|
6
|
+
default="_all_",
|
|
7
|
+
help='Which graph, as <workspace>/<project> or just <project>. Default: all of them.')
|
|
8
|
+
parser.add_argument('-a', '--graphs-action',
|
|
9
|
+
default="list",
|
|
10
|
+
choices=['list', 'collect', 'remove', 'mcp-register', 'mcp-unregister', 'serve'],
|
|
11
|
+
help='list the graphs, collect strays left inside projects, remove one, register it with the Claude Code CLI over MCP, unregister it, or serve it in the foreground [default %(default)s].')
|
|
12
|
+
ARGEOF
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
argparse "$@" <<EOF || exit 1
|
|
4
|
+
parser.add_argument('PROJECT_NAME',
|
|
5
|
+
help='The project whose graph becomes a vault.')
|
|
6
|
+
parser.add_argument('-r', '--remove',
|
|
7
|
+
action='store_true',
|
|
8
|
+
default=False,
|
|
9
|
+
help='Delete the vault. The graph it was made from is left alone.')
|
|
10
|
+
parser.add_argument('-s', '--status',
|
|
11
|
+
action='store_true',
|
|
12
|
+
default=False,
|
|
13
|
+
help='Say what is there without building anything.')
|
|
14
|
+
parser.add_argument('-m', '--max-canvas-nodes',
|
|
15
|
+
default="300",
|
|
16
|
+
help='How many entities the canvas may draw, most-connected first [default %(default)s]. Every entity gets a note either way; this only bounds the canvas, which stops being readable long before it stops being drawable.')
|
|
17
|
+
EOF
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Graphify (https://github.com/Graphify-Labs/graphify) parses a codebase with
|
|
4
|
+
# tree-sitter into a knowledge graph you query instead of grepping. Local and
|
|
5
|
+
# deterministic for code; no vector store.
|
|
6
|
+
#
|
|
7
|
+
# What it actually covers on a Drupal project (graphify 0.9.28):
|
|
8
|
+
#
|
|
9
|
+
# Covered PHP classes under src/ — methods, `use` imports, extends,
|
|
10
|
+
# implements, trait use, and constructor-injected services (typed
|
|
11
|
+
# and promoted parameters) — plus JS, and SQL when the sql extra is
|
|
12
|
+
# installed (see GRAPHIFY_PACKAGE below).
|
|
13
|
+
# NOT Markdown, and every other document. graphify classifies .md as a
|
|
14
|
+
# doc, not code, and docs are only read by the semantic pass — so
|
|
15
|
+
# --code-only skips them and a plain run graphs none of a project's
|
|
16
|
+
# .md files. `--deep` plus an LLM backend is the only way in, and
|
|
17
|
+
# that sends file contents to the backend.
|
|
18
|
+
# NOT Hooks in *.module / *.theme / *.install / *.profile / *.engine:
|
|
19
|
+
# those extensions are not in graphify's dispatch table, so the
|
|
20
|
+
# files are skipped ("not classified").
|
|
21
|
+
# NOT Twig templates. The string "twig" does not appear in graphify.
|
|
22
|
+
# NOT The Drupal wiring YAML — *.info.yml, *.services.yml,
|
|
23
|
+
# *.routing.yml, *.libraries.yml, *.component.yml, recipe.yml.
|
|
24
|
+
# graphify treats YAML as a document, so --code-only drops it, and
|
|
25
|
+
# the semantic pass links those nodes to each other rather than to
|
|
26
|
+
# the PHP classes.
|
|
27
|
+
# NOT PHP 8 attributes (#[Block], #[FieldType], …), so plugin-id → class
|
|
28
|
+
# never appears. Interfaces, traits and enums get no node of their
|
|
29
|
+
# own. *.inc is mis-parsed as Pascal and yields nothing.
|
|
30
|
+
#
|
|
31
|
+
# So: good for "what does this service depend on", useless for "which route
|
|
32
|
+
# hits this controller". Say so rather than implying a full Drupal map.
|
|
33
|
+
#
|
|
34
|
+
# The graph lands in <project>/graphify-out/: graph.json (queried),
|
|
35
|
+
# GRAPH_REPORT.md and graph.html (both written by the clustering step).
|
|
36
|
+
|
|
37
|
+
# Pin: graphify's node-id scheme changed inside 0.9.x, and its CLI silently
|
|
38
|
+
# ignores unknown flags, so an unpinned upgrade degrades without erroring.
|
|
39
|
+
GRAPHIFY_VERSION="0.9.28";
|
|
40
|
+
|
|
41
|
+
# Third-party and generated trees. Without these, a built Drupal site graphs its vendored JS
|
|
42
|
+
# instead of its own code: measured 20,618 nodes of which 17,302 (84%) came from
|
|
43
|
+
# web/libraries/ace alone, in 139s. With them: 1,236 nodes in 11s, and the hubs are the
|
|
44
|
+
# project's own classes.
|
|
45
|
+
# Third-party trees only. Nothing of the project's own is excluded: a manifest is not
|
|
46
|
+
# noise, it is content — on a built Drupal site composer.json IS the architecture,
|
|
47
|
+
# since the site is defined by what it requires rather than by custom PHP. It does
|
|
48
|
+
# make the graph JSON-heavy (graphify emits a node per key, so composer.json alone is
|
|
49
|
+
# ~286 nodes), which shows up in god-nodes as `require` and `scripts`. That is a
|
|
50
|
+
# ranking artefact, not a reason to drop the data — read past it, or ask the graph a
|
|
51
|
+
# question rather than reading the hub list.
|
|
52
|
+
# Excludes in three layers, because "third-party" is not one thing on a Drupal site.
|
|
53
|
+
#
|
|
54
|
+
# ALWAYS: nothing here is code anyone reads — 84% of one unfiltered site graph was vendored JS.
|
|
55
|
+
# CORE: web/core is ~21,000 PHP files. Useful to have, ruinous as a default.
|
|
56
|
+
# CONTRIB: the modules a Drupal site actually behaves like. Excluding these was wrong: on a built
|
|
57
|
+
# site the interesting code IS contrib, a custom module's classes extend and inject
|
|
58
|
+
# contrib ones, and a graph that stops at the custom boundary answers "no such service"
|
|
59
|
+
# for half the questions asked of it.
|
|
60
|
+
GRAPHIFY_ALWAYS_EXCLUDES="vendor web/libraries web/sites/default/files docroot/libraries docroot/sites/default/files node_modules graphify-out";
|
|
61
|
+
GRAPHIFY_CORE_EXCLUDES="web/core docroot/core";
|
|
62
|
+
GRAPHIFY_CONTRIB_EXCLUDES="web/modules/contrib web/themes/contrib web/profiles/contrib docroot/modules/contrib docroot/themes/contrib docroot/profiles/contrib drush/contrib";
|
|
63
|
+
|
|
64
|
+
# The scope the graph was built at, resolved from --scope / --all.
|
|
65
|
+
# custom — custom code only (what this used to do)
|
|
66
|
+
# contrib — custom + contrib modules, themes and profiles. THE DEFAULT.
|
|
67
|
+
# all — everything, core and vendor included
|
|
68
|
+
function graphify_scope_excludes() {
|
|
69
|
+
local scope="$1";
|
|
70
|
+
case "${scope}" in
|
|
71
|
+
all) printf '%s' "" ;;
|
|
72
|
+
custom) printf '%s %s %s' "${GRAPHIFY_ALWAYS_EXCLUDES}" "${GRAPHIFY_CORE_EXCLUDES}" "${GRAPHIFY_CONTRIB_EXCLUDES}" ;;
|
|
73
|
+
*) printf '%s %s' "${GRAPHIFY_ALWAYS_EXCLUDES}" "${GRAPHIFY_CORE_EXCLUDES}" ;;
|
|
74
|
+
esac
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Where a project's graph lives: the graphs workspace, one directory per project —
|
|
78
|
+
# ~/workspace/graphs/<workspace>/<project>/.
|
|
79
|
+
#
|
|
80
|
+
# graphify only ever writes `<path>/graphify-out`, and that is the wrong place for it here: it is
|
|
81
|
+
# tens of MB, Leiden clustering is not bit-stable so every rebuild diffs in full, and half the
|
|
82
|
+
# projects worth mapping are git repositories we ship — `products/website_starter` must not gain a
|
|
83
|
+
# graphify-out/ because someone mapped it. So the build writes there and the result is MOVED into
|
|
84
|
+
# the store; the project is left exactly as it was found, and every read passes --graph.
|
|
85
|
+
function graphify_store_dir() {
|
|
86
|
+
printf '%s/graphs/%s/%s' "${WORKSPACE_ROOT}" "${doc_name}" "${PROJECT_NAME}";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function graphify_move_to_store() {
|
|
90
|
+
local project_path="$1" store="$2" built="${project_path}/graphify-out";
|
|
91
|
+
|
|
92
|
+
[ -d "${built}" ] || { echo "graphify wrote no graphify-out — nothing to store."; return 1 ; }
|
|
93
|
+
|
|
94
|
+
mkdir -p "$(dirname "${store}")" ;
|
|
95
|
+
rm -rf "${store}" ;
|
|
96
|
+
mv "${built}" "${store}" || return 1 ;
|
|
97
|
+
# The YAML mirror is an input to the build, not a result: it does not belong in the store and it
|
|
98
|
+
# certainly does not belong in the project afterwards.
|
|
99
|
+
rm -rf "${project_path}/graphify-yaml" ;
|
|
100
|
+
return 0 ;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Keep a derived path out of git WITHOUT touching the project's own .gitignore.
|
|
104
|
+
function graphify_git_exclude() {
|
|
105
|
+
local project_path="$1" pattern="$2" file="${project_path}/.git/info/exclude";
|
|
106
|
+
[ -d "${project_path}/.git" ] || return 0 ;
|
|
107
|
+
mkdir -p "${project_path}/.git/info" ;
|
|
108
|
+
grep -qxF "${pattern}" "${file}" 2>/dev/null || printf '%s\n' "${pattern}" >> "${file}" ;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# YAML, mapped by mirroring it as JSON.
|
|
112
|
+
#
|
|
113
|
+
# graphify has no YAML parser in its dispatch table, so on a Drupal tree it sees NONE of what
|
|
114
|
+
# actually defines behaviour: recipe.yml, config/*.yml, *.info.yml, *.services.yml, *.routing.yml,
|
|
115
|
+
# *.permissions.yml, *.libraries.yml. On a recipe that is the whole project — `products/website_starter`
|
|
116
|
+
# is 0 PHP files and 699 YAML ones, so a code-only graph of it is empty and every question about
|
|
117
|
+
# it answers "nothing found", which reads as "no such thing" rather than "not mapped".
|
|
118
|
+
#
|
|
119
|
+
# It DOES parse JSON (composer.json alone contributes ~286 nodes), so each YAML file is mirrored
|
|
120
|
+
# as `<file>.json` under graphify-yaml/ and that tree is included in the extract. The name has no
|
|
121
|
+
# leading dot on purpose: graphify skips hidden directories, so a `.graphify-yaml/` mirror was
|
|
122
|
+
# written and then parsed by nothing — the website_starter recipe's graph came back as 80 nodes of
|
|
123
|
+
# composer.json while its 699 YAML files went unseen. The mirrors are
|
|
124
|
+
# derived, never edited: the directory is rewritten on every run and kept out of git through
|
|
125
|
+
# .git/info/exclude, which is local and leaves the project's own .gitignore alone.
|
|
126
|
+
# $2 is the scope's exclude list. graphify anchors --exclude at the SCAN ROOT, so
|
|
127
|
+
# `--exclude web/core` cannot match `graphify-yaml/web/core/...`: without pruning the same paths
|
|
128
|
+
# here, a contrib-scope run printed "Core, vendor and libraries are excluded" while mirroring 3,256
|
|
129
|
+
# core YAML files into the graph. Measured on dev/amd-website_starter-1-0-x-site-template-manager.
|
|
130
|
+
function graphify_yaml_sidecars() {
|
|
131
|
+
# Three statements, not one: in a single `local a=$1 b=${a}/x`, bash evaluates the right-hand
|
|
132
|
+
# sides before `a` is visible, so `out` became "/graphify-yaml" — an absolute path at the
|
|
133
|
+
# FILESYSTEM ROOT. On the host that failed with PermissionError; inside the dashboard container,
|
|
134
|
+
# which runs as root, it silently succeeded and mirrored the whole tree to /graphify-yaml, which
|
|
135
|
+
# is why an earlier run reported "mirrored 699 YAML file(s)" and the graph gained nothing.
|
|
136
|
+
local project_path="$1";
|
|
137
|
+
local excludes="$2";
|
|
138
|
+
local out="${project_path}/graphify-yaml";
|
|
139
|
+
|
|
140
|
+
if ! python3 -c 'import yaml' 2>/dev/null ; then
|
|
141
|
+
echo "python3 has no yaml module, so the YAML files cannot be mirrored — the graph will cover code only.";
|
|
142
|
+
return 0 ;
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
rm -rf "${out}" ;
|
|
146
|
+
python3 - "${project_path}" "${out}" ${excludes} <<'PYEOF'
|
|
147
|
+
import json, os, sys, pathlib
|
|
148
|
+
import yaml
|
|
149
|
+
|
|
150
|
+
root = pathlib.Path(sys.argv[1])
|
|
151
|
+
out = pathlib.Path(sys.argv[2])
|
|
152
|
+
# The scope's own excludes, as paths relative to the project root, plus the ones that are never
|
|
153
|
+
# worth mirroring whatever the scope is.
|
|
154
|
+
EXCLUDE_PATHS = {e.strip('/') for e in sys.argv[3:] if e.strip('/')}
|
|
155
|
+
SKIP = {'vendor', 'node_modules', 'graphify-out', 'graphify-yaml', '.git', 'files'}
|
|
156
|
+
written = failed = 0
|
|
157
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
158
|
+
rel = pathlib.Path(dirpath).relative_to(root)
|
|
159
|
+
dirnames[:] = [d for d in dirnames
|
|
160
|
+
if d not in SKIP
|
|
161
|
+
and str((rel / d)) not in EXCLUDE_PATHS]
|
|
162
|
+
for name in filenames:
|
|
163
|
+
if not name.endswith(('.yml', '.yaml')):
|
|
164
|
+
continue
|
|
165
|
+
src = pathlib.Path(dirpath) / name
|
|
166
|
+
try:
|
|
167
|
+
# A Drupal config file can carry PHP-ish tags and multiple documents; take what parses
|
|
168
|
+
# and skip what does not, rather than failing the whole run for one file.
|
|
169
|
+
docs = [d for d in yaml.safe_load_all(src.read_text(errors='replace')) if d is not None]
|
|
170
|
+
except Exception:
|
|
171
|
+
failed += 1
|
|
172
|
+
continue
|
|
173
|
+
data = docs[0] if len(docs) == 1 else docs
|
|
174
|
+
target = out / src.relative_to(root).parent / (name + '.json')
|
|
175
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
try:
|
|
177
|
+
target.write_text(json.dumps(data, indent=1, default=str))
|
|
178
|
+
written += 1
|
|
179
|
+
except Exception:
|
|
180
|
+
failed += 1
|
|
181
|
+
print(f" mirrored {written} YAML file(s) as JSON for the graph" + (f", {failed} skipped (unparseable)" if failed else ""))
|
|
182
|
+
PYEOF
|
|
183
|
+
|
|
184
|
+
graphify_git_exclude "${project_path}" "/graphify-yaml" ;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# The PyPI package is `graphifyy` (double y); the command is `graphify`. The
|
|
188
|
+
# [sql] extra is not optional for us: without tree_sitter_sql graphify parses no
|
|
189
|
+
# .sql file at all and says so only in a warning —
|
|
190
|
+
# "2 .sql file(s) contributed nothing to the graph because a dependency is
|
|
191
|
+
# missing: tree_sitter_sql not installed"
|
|
192
|
+
# — so the graph silently loses a language the coverage note promises.
|
|
193
|
+
GRAPHIFY_PACKAGE="graphifyy[sql]==${GRAPHIFY_VERSION}";
|
|
194
|
+
|
|
195
|
+
# The MCP server is a separate dependency, and its absence is only visible at run time: the
|
|
196
|
+
# `graphify-mcp` entry point exists either way and dies with
|
|
197
|
+
# ModuleNotFoundError: No module named 'mcp'
|
|
198
|
+
# which the Claude Code CLI reports as "Connection closed", not as a missing package. So it is
|
|
199
|
+
# installed alongside rather than left to be discovered.
|
|
200
|
+
# Pinned below 2: PyPI's current mcp is 2.0.0, graphify 0.9.28 does `from mcp.types import AnyUrl`,
|
|
201
|
+
# and 2.x moved it — so an unpinned install produces a graphify-mcp that dies at import and a CLI
|
|
202
|
+
# that reports only "Connection closed". graphifyy's own [mcp] extra is unpinned, so the bound has
|
|
203
|
+
# to come from here.
|
|
204
|
+
GRAPHIFY_MCP_EXTRA="mcp<2";
|
|
205
|
+
|
|
206
|
+
# Make sure the graphify CLI is on PATH; install it with uv or pipx if not.
|
|
207
|
+
function ensure_graphify() {
|
|
208
|
+
if command -v graphify > /dev/null 2>&1 ; then
|
|
209
|
+
return 0 ;
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
echo "graphify is not installed — installing ${GRAPHIFY_PACKAGE}.";
|
|
213
|
+
if command -v uv > /dev/null 2>&1 ; then
|
|
214
|
+
uv tool install "${GRAPHIFY_PACKAGE}" --with "${GRAPHIFY_MCP_EXTRA}" ;
|
|
215
|
+
elif command -v pipx > /dev/null 2>&1 ; then
|
|
216
|
+
pipx install "${GRAPHIFY_PACKAGE}" ;
|
|
217
|
+
pipx inject graphifyy "${GRAPHIFY_MCP_EXTRA}" ;
|
|
218
|
+
else
|
|
219
|
+
# No pip fallback: Debian and Ubuntu mark the system python externally managed (PEP 668), so
|
|
220
|
+
# `pip install --user` is refused, and `python3 -m venv` needs a python3-venv package that is
|
|
221
|
+
# not installed by default. uv needs neither and installs into the home directory.
|
|
222
|
+
echo "Neither uv nor pipx is available — installing uv into ~/.local/bin." ;
|
|
223
|
+
if ! curl -LsSf https://astral.sh/uv/install.sh | sh ; then
|
|
224
|
+
echo "Could not install uv. Install it or pipx by hand, then run this again:" ;
|
|
225
|
+
echo " curl -LsSf https://astral.sh/uv/install.sh | sh" ;
|
|
226
|
+
return 1 ;
|
|
227
|
+
fi
|
|
228
|
+
export PATH="${HOME}/.local/bin:${PATH}" ;
|
|
229
|
+
uv tool install "${GRAPHIFY_PACKAGE}" --with "${GRAPHIFY_MCP_EXTRA}" ;
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
# uv puts its tools in ~/.local/bin, which is not on PATH in a non-login shell — and the
|
|
233
|
+
# dashboard runs these through one.
|
|
234
|
+
case ":${PATH}:" in
|
|
235
|
+
*":${HOME}/.local/bin:"*) ;;
|
|
236
|
+
*) export PATH="${HOME}/.local/bin:${PATH}" ;;
|
|
237
|
+
esac
|
|
238
|
+
|
|
239
|
+
if ! command -v graphify > /dev/null 2>&1 ; then
|
|
240
|
+
echo "graphify still is not on PATH — try: uv tool update-shell" ;
|
|
241
|
+
return 1 ;
|
|
242
|
+
fi
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Pick an LLM backend for the semantic pass (--deep). `graphify extract` exits 1
|
|
246
|
+
# when it has documents to read and no backend, so check before running rather
|
|
247
|
+
# than failing halfway.
|
|
248
|
+
function graphify_backend_args() {
|
|
249
|
+
graphify_backend="";
|
|
250
|
+
|
|
251
|
+
# An env var wins, so a one-off run can point somewhere else.
|
|
252
|
+
if [ -n "${ANTHROPIC_API_KEY}" ] || [ -n "${OPENAI_API_KEY}" ] || [ -n "${GEMINI_API_KEY}" ] || [ -n "${DEEPSEEK_API_KEY}" ] ; then
|
|
253
|
+
return 0 ;
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# The local Claude Code CLI first, when settings.yml says it is active: it carries its own
|
|
257
|
+
# session, so there is no key to hold and nothing goes to a third-party API. This used to be
|
|
258
|
+
# the last resort, which meant a stray key in settings.yml quietly took precedence over the
|
|
259
|
+
# login this machine already has.
|
|
260
|
+
if [ "${ai_providers_claude_code_cli_active}" == 'true' ] && command -v claude > /dev/null 2>&1 ; then
|
|
261
|
+
graphify_backend="--backend claude-cli";
|
|
262
|
+
graphify_backend_provider="claude-cli";
|
|
263
|
+
return 0 ;
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
# Otherwise use the key the workspace already holds. bootstrap.sh has flattened
|
|
267
|
+
# settings.yml, so ai_providers.anthropic.api_key is $ai_providers_anthropic_api_key.
|
|
268
|
+
# Placeholders are not keys — sk-change-this would fail deep in the run with an
|
|
269
|
+
# authentication error rather than here with a sentence.
|
|
270
|
+
local key;
|
|
271
|
+
for provider in anthropic openai gemini ; do
|
|
272
|
+
key=$(eval "echo \${ai_providers_${provider}_api_key}") ;
|
|
273
|
+
case "${key}" in ''|CHANGE_ME|sk-change-this) continue ;; esac
|
|
274
|
+
case "${provider}" in
|
|
275
|
+
anthropic) export ANTHROPIC_API_KEY="${key}" ;;
|
|
276
|
+
openai) export OPENAI_API_KEY="${key}" ;;
|
|
277
|
+
gemini) export GEMINI_API_KEY="${key}" ;;
|
|
278
|
+
esac
|
|
279
|
+
graphify_backend_provider="${provider}";
|
|
280
|
+
return 0 ;
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
# The CLI again, for when it is not marked active but nothing else is configured either:
|
|
284
|
+
# a working backend beats refusing to run.
|
|
285
|
+
if command -v claude > /dev/null 2>&1 ; then
|
|
286
|
+
graphify_backend="--backend claude-cli";
|
|
287
|
+
graphify_backend_provider="claude-cli";
|
|
288
|
+
return 0 ;
|
|
289
|
+
fi
|
|
290
|
+
return 1 ;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# Build (or refresh) the knowledge graph of a project in this workspace.
|
|
295
|
+
# Tell Claude Code, in the project itself, that a graph exists and how to use it.
|
|
296
|
+
#
|
|
297
|
+
# A `claude` session started in a project reads that project's CLAUDE.md, so this is
|
|
298
|
+
# the one place a note reaches the CLI without anyone remembering to mention it. The
|
|
299
|
+
# block is bounded by markers and only ever rewritten between them, because a project
|
|
300
|
+
# may have its own CLAUDE.md that matters more than ours.
|
|
301
|
+
#
|
|
302
|
+
# It states the coverage honestly. A graph that is trusted for what it does not
|
|
303
|
+
# contain is worse than no graph: asking it "where is this route handled" returns
|
|
304
|
+
# nothing, and nothing is easily read as "there is no such route".
|
|
305
|
+
# A note in the project's own CLAUDE.md, so a session started there knows the graph exists and
|
|
306
|
+
# where it is. Written ONLY where it cannot dirty something we ship: a built site has no git, and
|
|
307
|
+
# on a git repository the note is written only if CLAUDE.md is absent or untracked — then the file
|
|
308
|
+
# is excluded locally, so it never becomes a commit. On a tracked CLAUDE.md the command prints the
|
|
309
|
+
# query line instead of editing the file.
|
|
310
|
+
function graphify_write_project_note() {
|
|
311
|
+
local project_path="$1" store="$2";
|
|
312
|
+
local file="${project_path}/CLAUDE.md";
|
|
313
|
+
|
|
314
|
+
# The graph has already been moved out by the time this runs, so the counts come from the store.
|
|
315
|
+
# Reading ${project_path}/graphify-out here is what made the note say "? nodes, ? edges".
|
|
316
|
+
store="${store:-$(graphify_store_dir)}";
|
|
317
|
+
|
|
318
|
+
if [ -d "${project_path}/.git" ] ; then
|
|
319
|
+
if git -C "${project_path}" ls-files --error-unmatch CLAUDE.md > /dev/null 2>&1 ; then
|
|
320
|
+
echo " ${project_path}/CLAUDE.md is tracked, so it is left alone. Query the graph with:";
|
|
321
|
+
echo " bash cmd-tools-graphify.sh ${PROJECT_NAME} --query \"…\"";
|
|
322
|
+
return 0 ;
|
|
323
|
+
fi
|
|
324
|
+
graphify_git_exclude "${project_path}" "/CLAUDE.md" ;
|
|
325
|
+
fi
|
|
326
|
+
local begin="<!-- graphify:begin -->";
|
|
327
|
+
local end="<!-- graphify:end -->";
|
|
328
|
+
local json="${store}/graph.json";
|
|
329
|
+
local nodes="?" links="?";
|
|
330
|
+
|
|
331
|
+
if [ -f "${json}" ] && command -v python3 > /dev/null 2>&1 ; then
|
|
332
|
+
read -r nodes links <<< "$(python3 -c "
|
|
333
|
+
import json,sys
|
|
334
|
+
try:
|
|
335
|
+
d=json.load(open('${json}'))
|
|
336
|
+
print(len(d.get('nodes',[])), len(d.get('links',[])))
|
|
337
|
+
except Exception:
|
|
338
|
+
print('?','?')
|
|
339
|
+
" 2>/dev/null)";
|
|
340
|
+
fi
|
|
341
|
+
|
|
342
|
+
local block;
|
|
343
|
+
block=$(cat <<NOTE
|
|
344
|
+
${begin}
|
|
345
|
+
## Knowledge graph
|
|
346
|
+
|
|
347
|
+
This project has a graphify knowledge graph — ${nodes} nodes, ${links} edges — built by
|
|
348
|
+
\`cmd-tools-graphify.sh\` from the workspace folder. It lives OUTSIDE this project, in
|
|
349
|
+
\`${store}/\`, so nothing here is generated or gitignored. **Query it before grepping the tree**,
|
|
350
|
+
then open the files it points at:
|
|
351
|
+
|
|
352
|
+
\`\`\`bash
|
|
353
|
+
G=${store}/graph.json
|
|
354
|
+
graphify query "what does this service depend on?" --graph \$G # BFS from the matching nodes
|
|
355
|
+
graphify explain "SomeClass" --graph \$G # a node and its neighbours
|
|
356
|
+
graphify path "ClassA" "ClassB" --graph \$G # how two things connect
|
|
357
|
+
graphify god-nodes --top 15 --graph \$G # the most connected nodes
|
|
358
|
+
\`\`\`
|
|
359
|
+
|
|
360
|
+
**Know what it does not contain, so a silent miss is not read as an answer.** It maps PHP classes
|
|
361
|
+
under \`src/\` (methods, \`use\`, extends, implements, traits, constructor-injected services), JS, and
|
|
362
|
+
SQL. It does **not** map hooks in \`.module\`/\`.theme\`/\`.install\`/\`.profile\`, any Twig, any
|
|
363
|
+
\`*.yml\` — so routing, services and plugin wiring are absent — Markdown, or PHP 8 attributes, which
|
|
364
|
+
is why plugin-id to class never appears. A question about routes or config will come back empty
|
|
365
|
+
whether or not the answer exists: fall back to reading the files.
|
|
366
|
+
|
|
367
|
+
### Updating it
|
|
368
|
+
|
|
369
|
+
The graph is a snapshot; it does not follow edits. After changing code:
|
|
370
|
+
|
|
371
|
+
\`\`\`bash
|
|
372
|
+
cd ~/workspace/<workspace> && bash cmd-tools-graphify.sh <project> # re-extract and re-cluster
|
|
373
|
+
\`\`\`
|
|
374
|
+
|
|
375
|
+
\`graphify add <url>\` fetches a URL into \`./raw\` and folds it into the graph, for pulling an
|
|
376
|
+
upstream issue or doc in as context. The graph is kept in the graphs workspace rather than here
|
|
377
|
+
because it is tens of MB and Leiden clustering is not bit-stable, so it re-diffs entirely on every
|
|
378
|
+
rebuild.
|
|
379
|
+
${end}
|
|
380
|
+
NOTE
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if [ -f "${file}" ] && grep -q "${begin}" "${file}" ; then
|
|
384
|
+
# The block goes through the environment, not stdin: the replacement text is
|
|
385
|
+
# multi-line and contains the markers themselves, so quoting it into a command
|
|
386
|
+
# line or a second heredoc is how this breaks.
|
|
387
|
+
GRAPHIFY_NOTE="${block}" python3 - "${file}" "${begin}" "${end}" <<'PYEOF'
|
|
388
|
+
import os, re, sys
|
|
389
|
+
path, begin, end = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
390
|
+
block = os.environ['GRAPHIFY_NOTE'].rstrip('\n')
|
|
391
|
+
text = open(path).read()
|
|
392
|
+
# re.sub would treat backslashes in the replacement as escapes; pass a function.
|
|
393
|
+
text = re.sub(re.escape(begin) + r'.*?' + re.escape(end), lambda _m: block, text, flags=re.S)
|
|
394
|
+
open(path, 'w').write(text)
|
|
395
|
+
PYEOF
|
|
396
|
+
echo " Refreshed the graph note in ${file}";
|
|
397
|
+
else
|
|
398
|
+
{ [ -f "${file}" ] && printf '\n'; printf '%s\n' "${block}"; } >> "${file}" ;
|
|
399
|
+
echo " Added a graph note to ${file} — a claude session in this project will read it";
|
|
400
|
+
fi
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Put something into the graph that is not in the tree.
|
|
404
|
+
#
|
|
405
|
+
# `graphify add <url>` fetches a page into the project's ./raw and folds it into
|
|
406
|
+
# graph.json, which makes the graph a place to KEEP things — an upstream issue, a
|
|
407
|
+
# spec, a changelog — so a later session can query them alongside the code instead of
|
|
408
|
+
# being handed the same context again.
|
|
409
|
+
function graphify_store_url() {
|
|
410
|
+
local project_path="$1" url="$2" store;
|
|
411
|
+
store="$(graphify_store_dir)";
|
|
412
|
+
|
|
413
|
+
case "${url}" in
|
|
414
|
+
http://*|https://*) ;;
|
|
415
|
+
*) echo "--store needs an http(s) URL." ; return 1 ;;
|
|
416
|
+
esac
|
|
417
|
+
# The graph is in the store, not in the project: gating on ${project_path}/graphify-out made this
|
|
418
|
+
# unreachable the moment the graph moved out.
|
|
419
|
+
if [ ! -f "${store}/graph.json" ]; then
|
|
420
|
+
echo "No graph yet — build one first: bash cmd-tools-graphify.sh <project>" ;
|
|
421
|
+
return 1 ;
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
# Folding a fetched document in is a semantic step, so it needs the same backend
|
|
425
|
+
# --deep does; say so here rather than failing inside graphify.
|
|
426
|
+
if ! graphify_backend_args ; then
|
|
427
|
+
echo "Storing a URL reads the page, which needs an LLM backend: set" ;
|
|
428
|
+
echo "ai_providers.<provider>.api_key in core/config/settings.yml, export" ;
|
|
429
|
+
echo "ANTHROPIC_API_KEY, or install the claude CLI." ;
|
|
430
|
+
return 1 ;
|
|
431
|
+
fi
|
|
432
|
+
|
|
433
|
+
echo "Fetching ${url} into ${project_path}/raw and adding it to the graph…" ;
|
|
434
|
+
echo " (its contents go to ${graphify_backend_provider})" ;
|
|
435
|
+
# graphify writes to <path>/graphify-out and nowhere else, so the store is put back for the run
|
|
436
|
+
# and moved out again afterwards — the same shape the build uses.
|
|
437
|
+
cp -a "${store}" "${project_path}/graphify-out" || return 1 ;
|
|
438
|
+
if ! ( cd "${project_path}" && graphify add "${url}" ${graphify_backend} ) ; then
|
|
439
|
+
rm -rf "${project_path}/graphify-out" ;
|
|
440
|
+
return 1 ;
|
|
441
|
+
fi
|
|
442
|
+
graphify_move_to_store "${project_path}" "${store}" || return 1 ;
|
|
443
|
+
graphify_write_project_note "${project_path}" "${store}" ;
|
|
444
|
+
echo "Stored. Ask about it with: bash cmd-tools-graphify.sh ${PROJECT_NAME} --query \"…\"" ;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function graphify_project() {
|
|
448
|
+
ensure_graphify || return 1 ;
|
|
449
|
+
|
|
450
|
+
local project_path="${WORKSPACE_ROOT}/${doc_name}/${PROJECT_NAME}";
|
|
451
|
+
local extract_args="";
|
|
452
|
+
local cluster_args="--no-label";
|
|
453
|
+
local exclude="";
|
|
454
|
+
|
|
455
|
+
if [ ! -d "${project_path}" ]; then
|
|
456
|
+
echo "No such project: ${project_path}";
|
|
457
|
+
return 1 ;
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
echo "*---------------------------------------------------------------------*";
|
|
461
|
+
echo " Graphify ${doc_name}/${PROJECT_NAME}";
|
|
462
|
+
echo "*---------------------------------------------------------------------*";
|
|
463
|
+
|
|
464
|
+
if [ "${DEEP}" == 'yes' ] ; then
|
|
465
|
+
if graphify_backend_args ; then
|
|
466
|
+
echo "" ;
|
|
467
|
+
echo " --deep: reading the docs, Markdown and other non-code files through" ;
|
|
468
|
+
echo " ${graphify_backend_provider}. Their CONTENTS are sent to that service — this is a" ;
|
|
469
|
+
echo " client project, so be sure that is acceptable before continuing." ;
|
|
470
|
+
echo "" ;
|
|
471
|
+
fi
|
|
472
|
+
if ! graphify_backend_args ; then
|
|
473
|
+
echo "--deep needs an LLM backend: set ANTHROPIC_API_KEY (or OPENAI_API_KEY," ;
|
|
474
|
+
echo "GEMINI_API_KEY, DEEPSEEK_API_KEY), or install the claude CLI." ;
|
|
475
|
+
return 1 ;
|
|
476
|
+
fi
|
|
477
|
+
extract_args="${graphify_backend}";
|
|
478
|
+
cluster_args="${graphify_backend}";
|
|
479
|
+
echo "Deep pass: AST + semantic extraction over docs, PDFs and images.";
|
|
480
|
+
echo "That backend reads file contents. It does NOT connect Drupal YAML to";
|
|
481
|
+
echo "PHP classes — it builds a parallel document graph. Code-only is the";
|
|
482
|
+
echo "useful default.";
|
|
483
|
+
else
|
|
484
|
+
extract_args="--code-only";
|
|
485
|
+
echo "Code-only pass: local AST extraction, no API key, nothing leaves the machine.";
|
|
486
|
+
fi
|
|
487
|
+
|
|
488
|
+
if [ "${FORCE}" == 'yes' ] ; then
|
|
489
|
+
extract_args="${extract_args} --force";
|
|
490
|
+
fi
|
|
491
|
+
|
|
492
|
+
# graphify persists --exclude into the project's graphify-out, so later runs keep the scope.
|
|
493
|
+
local scope="${SCOPE}";
|
|
494
|
+
if [ "${ALL}" == 'yes' ] ; then
|
|
495
|
+
scope="all";
|
|
496
|
+
fi
|
|
497
|
+
case "${scope}" in
|
|
498
|
+
all) echo "Scope: everything — core and vendor included. Slow, and the graph is dominated by third-party code." ;;
|
|
499
|
+
custom) echo "Scope: custom code only — contrib and core are excluded." ;;
|
|
500
|
+
contrib) echo "Scope: custom + contrib (modules, themes, profiles). Core, vendor and libraries are excluded." ;;
|
|
501
|
+
*) echo "Unknown scope '${scope}' — use custom, contrib or all." ; return 1 ;;
|
|
502
|
+
esac
|
|
503
|
+
for exclude in $(graphify_scope_excludes "${scope}") ; do
|
|
504
|
+
extract_args="${extract_args} --exclude ${exclude}";
|
|
505
|
+
done
|
|
506
|
+
# argparse.sh emits an nargs='+' option as a bash ARRAY, so plain ${EXCLUDE} is only its FIRST
|
|
507
|
+
# element: `--exclude a b` used to drop b. [0] still reads the sentinel on a scalar default.
|
|
508
|
+
if [ ! "${EXCLUDE[0]}" == '_none_' ] ; then
|
|
509
|
+
for exclude in ${EXCLUDE[*]} ; do
|
|
510
|
+
extract_args="${extract_args} --exclude ${exclude}";
|
|
511
|
+
done
|
|
512
|
+
fi
|
|
513
|
+
|
|
514
|
+
# graphify keeps its AST cache and its persisted --exclude list inside graphify-out, so the
|
|
515
|
+
# previous build is put back before extracting — otherwise every run is a full re-parse.
|
|
516
|
+
local store;
|
|
517
|
+
store="$(graphify_store_dir)";
|
|
518
|
+
if [ -d "${store}" ] && [ ! -d "${project_path}/graphify-out" ] ; then
|
|
519
|
+
cp -a "${store}" "${project_path}/graphify-out" ;
|
|
520
|
+
fi
|
|
521
|
+
|
|
522
|
+
if [ "${NO_YAML}" == 'yes' ] ; then
|
|
523
|
+
rm -rf "${project_path}/graphify-yaml" ;
|
|
524
|
+
extract_args="${extract_args} --exclude graphify-yaml";
|
|
525
|
+
echo "Skipping the YAML mirror — on a recipe or a config-heavy module that leaves the graph empty.";
|
|
526
|
+
else
|
|
527
|
+
echo "Mirroring YAML as JSON so config, recipes, services and routing reach the graph…";
|
|
528
|
+
graphify_yaml_sidecars "${project_path}" "$(graphify_scope_excludes "${scope}")" ;
|
|
529
|
+
fi
|
|
530
|
+
|
|
531
|
+
graphify extract "${project_path}" ${extract_args} || return 1 ;
|
|
532
|
+
|
|
533
|
+
# `extract` writes graph.json only. Clustering is what produces the report
|
|
534
|
+
# and the browsable graph, and it runs fine with no API key (--no-label keeps
|
|
535
|
+
# deterministic community names and does not persist placeholders, so a later
|
|
536
|
+
# --deep run can still name them). Never pass --no-viz: it deletes graph.html.
|
|
537
|
+
echo "";
|
|
538
|
+
echo "Clustering and writing the report…";
|
|
539
|
+
graphify cluster-only "${project_path}" ${cluster_args} || return 1 ;
|
|
540
|
+
|
|
541
|
+
# Out of the project, into the graphs workspace.
|
|
542
|
+
graphify_move_to_store "${project_path}" "${store}" || return 1 ;
|
|
543
|
+
|
|
544
|
+
# A note only where it cannot dirty a repository we ship.
|
|
545
|
+
graphify_write_project_note "${project_path}" "${store}" ;
|
|
546
|
+
|
|
547
|
+
echo "";
|
|
548
|
+
echo "*---------------------------------------------------------------------*";
|
|
549
|
+
echo " Graph written to ${store}/";
|
|
550
|
+
echo " graph.html — open it in a browser (needs internet: it loads";
|
|
551
|
+
echo " vis-network from unpkg.com)";
|
|
552
|
+
echo " GRAPH_REPORT.md — key concepts and suggested questions";
|
|
553
|
+
echo " graph.json — queried by graphify query|path|explain";
|
|
554
|
+
echo "";
|
|
555
|
+
echo " Covered: PHP classes under src/ (extends, implements, traits,";
|
|
556
|
+
echo " injected services), JS and SQL. NOT covered: hooks in";
|
|
557
|
+
echo " .module/.theme/.install, Twig, *.yml wiring, and Markdown (a";
|
|
558
|
+
echo " document — only --deep reads those) — see the notes in";
|
|
559
|
+
echo " core/scripts/functions/fun-graphify.sh.";
|
|
560
|
+
echo "";
|
|
561
|
+
echo " Ask it something — the graph is outside the project, so pass --graph:";
|
|
562
|
+
echo " graphify query \"what does this service depend on?\" --graph ${store}/graph.json";
|
|
563
|
+
echo " graphify god-nodes --top 15 --graph ${store}/graph.json";
|
|
564
|
+
echo " or from the workspace folder, which fills that in for you:";
|
|
565
|
+
echo " bash cmd-tools-graphify.sh ${PROJECT_NAME} --query \"…\"";
|
|
566
|
+
echo "*---------------------------------------------------------------------*";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# Ask the project's graph a question, without re-reading the codebase.
|
|
570
|
+
function graphify_query_project() {
|
|
571
|
+
ensure_graphify || return 1 ;
|
|
572
|
+
|
|
573
|
+
local store graph_file;
|
|
574
|
+
store="$(graphify_store_dir)";
|
|
575
|
+
graph_file="${store}/graph.json";
|
|
576
|
+
|
|
577
|
+
if [ ! -f "${graph_file}" ]; then
|
|
578
|
+
echo "No graph yet for ${doc_name}/${PROJECT_NAME} — build one first:";
|
|
579
|
+
echo " bash cmd-tools-graphify.sh ${PROJECT_NAME}";
|
|
580
|
+
return 1 ;
|
|
581
|
+
fi
|
|
582
|
+
|
|
583
|
+
# graphify query exits 0 even when nothing matches, so read the output rather
|
|
584
|
+
# than trusting the exit code. --graph because the graph lives in the graphs
|
|
585
|
+
# workspace, not in the project.
|
|
586
|
+
graphify query "${QUERY}" --graph "${graph_file}" ;
|
|
587
|
+
}
|