@webship.co/workspace-graph 1.0.0-alpha1 → 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.
@@ -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
+ }