@drafthq/draft 3.7.2 → 5.0.1
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/.claude-plugin/marketplace.json +6 -3
- package/.claude-plugin/plugin.json +4 -2
- package/.cursor-plugin/plugin.json +4 -2
- package/LICENSE +1 -1
- package/README.md +7 -7
- package/bin/README.md +14 -13
- package/cli/src/cli.js +2 -2
- package/cli/src/installer.js +4 -2
- package/cli/src/lib/cursor-registry.js +2 -2
- package/cli/src/lib/graph.js +2 -1
- package/core/shared/condensation.md +8 -7
- package/core/shared/draft-context-loading.md +1 -1
- package/core/shared/graph-query.md +31 -23
- package/core/templates/architecture.md +1 -1
- package/integrations/agents/AGENTS.md +43 -34
- package/integrations/copilot/.github/copilot-instructions.md +43 -34
- package/package.json +7 -3
- package/scripts/fetch-memory-engine.sh +44 -11
- package/scripts/lib.sh +1 -1
- package/scripts/tools/_graph_queries.sh +28 -12
- package/scripts/tools/_lib.sh +38 -29
- package/scripts/tools/check-repo-size.sh +5 -1
- package/scripts/tools/cycle-detect.sh +11 -7
- package/scripts/tools/graph-callers.sh +1 -1
- package/scripts/tools/graph-impact.sh +63 -35
- package/scripts/tools/graph-init.sh +5 -10
- package/scripts/tools/graph-preflight.sh +6 -3
- package/scripts/tools/graph-query.sh +8 -7
- package/scripts/tools/graph-snapshot.sh +13 -24
- package/scripts/tools/graph-traces.sh +2 -2
- package/scripts/tools/hotspot-rank.sh +10 -1
- package/scripts/tools/mermaid-from-graph.sh +1 -1
- package/scripts/tools/okf-emit-catalog.sh +3 -0
- package/scripts/tools/okf-render-views.sh +6 -4
- package/scripts/tools/okf-validate.sh +5 -0
- package/scripts/tools/resolve-tools.sh +5 -3
- package/scripts/tools/scan-markers.sh +9 -1
- package/scripts/tools/verify-graph-binary.sh +14 -2
- package/skills/draft/intent-mapping.md +1 -1
- package/skills/implement/SKILL.md +1 -1
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
# here, not a hunt across N scripts (the Phase 0 :Function bug was duplicated
|
|
8
8
|
# across two files precisely because the Cypher was inlined).
|
|
9
9
|
#
|
|
10
|
-
# Dialect notes (engine v0.
|
|
11
|
-
# SAFE :
|
|
12
|
-
# `
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# `
|
|
16
|
-
#
|
|
17
|
-
# parser wants a literal on the right
|
|
18
|
-
#
|
|
19
|
-
#
|
|
10
|
+
# Dialect notes (engine v0.9.0, verified live against this engine):
|
|
11
|
+
# SAFE : `=`, `<>`/`!=`, `<`, `>`, `<=`, `>=` against a literal; `STARTS WITH`,
|
|
12
|
+
# `NOT x STARTS WITH`, `AND`, `OR`; explicit and variable-length
|
|
13
|
+
# patterns (`[:R*1..3]`, fixed depth `[:R*2..2]`); relationship-type
|
|
14
|
+
# alternation `[:A|B]`; `coalesce()`; `DISTINCT`; `count(x)`,
|
|
15
|
+
# `count(DISTINCT x)`; `WITH`-grouping aggregation.
|
|
16
|
+
# UNSAFE : comparing one property against another (`a.x < b.x`, `a.x = b.x` —
|
|
17
|
+
# the parser wants a literal on the right: "expected value at pos N"),
|
|
18
|
+
# `NOT EXISTS(...)`, `NOT (pattern)`, path variables (`p=(...)`,
|
|
19
|
+
# `length(p)`), and multi-pattern joins (`MATCH (a)…, (b)…` parse but
|
|
20
|
+
# ignore RETURN and LIMIT).
|
|
21
|
+
# GOTCHA : LIMIT applies before DISTINCT, so `RETURN DISTINCT … LIMIT n` can
|
|
22
|
+
# return fewer than n rows while more exist — judge truncation on raw
|
|
23
|
+
# rows. Every builder below stays inside the SAFE set.
|
|
20
24
|
#
|
|
21
25
|
# Label-agnostic on name matches: code units are :Method ⪢ :Function in OO repos;
|
|
22
26
|
# pinning :Function silently returns [] (the graph-tooling-v2 Phase 0 bug). CALLS
|
|
@@ -63,7 +67,17 @@ gq_q_inherits_sym() { printf "MATCH (c)-[:INHERITS]->(p) WHERE c.name='%s'
|
|
|
63
67
|
gq_q_derived_sym() { printf "MATCH (c)-[:INHERITS]->(p) WHERE p.name='%s' RETURN c.qualified_name AS child, p.qualified_name AS parent LIMIT 200" "$1"; }
|
|
64
68
|
gq_q_raises() { printf "MATCH (f {name:'%s'})-[:RAISES|THROWS]->(e) RETURN e.name AS error, e.qualified_name AS qualified LIMIT 200" "$1"; }
|
|
65
69
|
gq_q_raisers() { printf "MATCH (f)-[:RAISES|THROWS]->(e {name:'%s'}) RETURN f.qualified_name AS raiser, f.file_path AS file LIMIT 200" "$1"; }
|
|
66
|
-
|
|
70
|
+
# $1 = comma-separated list of pre-escaped, single-quoted qualified names.
|
|
71
|
+
gq_q_node_props() { printf "MATCH (f) WHERE f.qualified_name IN [%s] RETURN f.qualified_name AS q, f.complexity AS c, f.cognitive AS cog, f.is_entry_point AS ep LIMIT 1000" "$1"; }
|
|
72
|
+
# Dependents at exactly $2 CALLS hops (one query per depth: path variables are
|
|
73
|
+
# unsupported, so the hop count comes from the fixed depth). Raw rows, no
|
|
74
|
+
# DISTINCT — LIMIT applies before DISTINCT, so only a raw row count at the limit
|
|
75
|
+
# reveals truncation. The file form skips callers inside the target file itself.
|
|
76
|
+
GQ_DEP_LIMIT=5000
|
|
77
|
+
gq_q_dependents_file() { printf "MATCH (a)-[:CALLS*%s..%s]->(b) WHERE b.file_path = '%s' AND a.file_path <> '%s' RETURN a.qualified_name AS q, a.name AS name, a.file_path AS file, a.is_test AS test LIMIT %s" "$2" "$2" "$1" "$1" "$GQ_DEP_LIMIT"; }
|
|
78
|
+
gq_q_dependents_symbol() { printf "MATCH (a)-[:CALLS*%s..%s]->(b {name:'%s'}) WHERE a.name <> '%s' RETURN a.qualified_name AS q, a.name AS name, a.file_path AS file, a.is_test AS test LIMIT %s" "$2" "$2" "$1" "$1" "$GQ_DEP_LIMIT"; }
|
|
79
|
+
gq_q_importers() { printf "MATCH (a)-[:IMPORTS]->(b) WHERE b.file_path = '%s' AND a.file_path <> '%s' RETURN a.file_path AS file LIMIT %s" "$1" "$1" "$GQ_DEP_LIMIT"; }
|
|
80
|
+
gq_q_file_exists() { printf "MATCH (f) WHERE f.file_path = '%s' RETURN f.file_path AS file LIMIT 1" "$1"; }
|
|
67
81
|
gq_q_risk() { printf "MATCH (f) WHERE f.unguarded_recursion=true OR f.alloc_in_loop=true OR f.recursion_in_loop=true OR f.linear_scan_in_loop=true RETURN f.qualified_name AS symbol, f.file_path AS file, f.complexity AS complexity, f.unguarded_recursion AS unguarded_recursion, f.alloc_in_loop AS alloc_in_loop, f.recursion_in_loop AS recursion_in_loop, f.linear_scan_in_loop AS linear_scan_in_loop LIMIT 200"; }
|
|
68
82
|
|
|
69
83
|
# ── Runner + classifier ──
|
|
@@ -83,7 +97,9 @@ gq_run() {
|
|
|
83
97
|
payload="$(jq -n --arg p "$project" --arg q "$query" '{project:$p, query:$q}')" || return 3
|
|
84
98
|
res="$(memory_cli query_graph "$payload" 2>/dev/null || true)"
|
|
85
99
|
[[ -n "$res" ]] || return 3
|
|
86
|
-
|
|
100
|
+
# Shapeless JSON (`{}`) is not an empty result — it is a failed query.
|
|
101
|
+
# Callers must not treat it as a measured true-negative.
|
|
102
|
+
printf '%s' "$res" | jq -e 'has("rows") and (.rows | type == "array")' >/dev/null 2>&1 || return 3
|
|
87
103
|
printf '%s' "$res"
|
|
88
104
|
}
|
|
89
105
|
|
package/scripts/tools/_lib.sh
CHANGED
|
@@ -235,7 +235,7 @@ find_memory_bin() {
|
|
|
235
235
|
# its own unavailable-JSON shape (the shapes differ per tool).
|
|
236
236
|
graph_bootstrap() {
|
|
237
237
|
local repo="$1" self_repo
|
|
238
|
-
REPO_ABS="$(cd "$repo" 2>/dev/null && pwd)" || return 1
|
|
238
|
+
REPO_ABS="$(cd "$repo" 2>/dev/null && pwd -P)" || return 1
|
|
239
239
|
self_repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
240
240
|
find_memory_bin "$REPO_ABS" "$self_repo" || return 1
|
|
241
241
|
command -v jq >/dev/null 2>&1 || return 1
|
|
@@ -245,6 +245,8 @@ graph_bootstrap() {
|
|
|
245
245
|
|
|
246
246
|
# Run a codebase-memory-mcp CLI tool. Echoes the JSON result (stdout); the engine's
|
|
247
247
|
# `level=...` log lines go to stderr and are discarded unless DRAFT_MEMORY_DEBUG is set.
|
|
248
|
+
# Args travel on stdin: the engine deprecated positional raw JSON (0.9.0 warns it
|
|
249
|
+
# "will be removed in a future release"), and that warning lands in the discarded stderr.
|
|
248
250
|
# Usage: memory_cli <tool> [json-args]
|
|
249
251
|
memory_cli() {
|
|
250
252
|
local tool="$1"
|
|
@@ -253,22 +255,12 @@ memory_cli() {
|
|
|
253
255
|
return 1
|
|
254
256
|
fi
|
|
255
257
|
if [[ -n "${DRAFT_MEMORY_DEBUG:-}" ]]; then
|
|
256
|
-
"$MEMORY_BIN" cli "$tool" "$args"
|
|
258
|
+
"$MEMORY_BIN" cli "$tool" <<< "$args"
|
|
257
259
|
else
|
|
258
|
-
"$MEMORY_BIN" cli "$tool" "$args" 2>/dev/null
|
|
260
|
+
"$MEMORY_BIN" cli "$tool" <<< "$args" 2>/dev/null
|
|
259
261
|
fi
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
# Resolve the engine's project name for a repository absolute path via list_projects.
|
|
263
|
-
# Echoes the project name, or nothing if the repo has not been indexed yet.
|
|
264
|
-
memory_project_for_repo() {
|
|
265
|
-
local repo_abs="$1"
|
|
266
|
-
command -v jq >/dev/null 2>&1 || return 1
|
|
267
|
-
memory_cli list_projects '{}' 2>/dev/null \
|
|
268
|
-
| jq -r --arg p "$repo_abs" '.projects[]? | select(.root_path == $p) | .name' 2>/dev/null \
|
|
269
|
-
| head -1
|
|
270
|
-
}
|
|
271
|
-
|
|
272
264
|
# Total physical RAM in MB (portable). Echoes a positive integer, or nothing.
|
|
273
265
|
_total_ram_mb() {
|
|
274
266
|
if [[ -r /proc/meminfo ]]; then
|
|
@@ -306,52 +298,69 @@ _can_cgroup_bound() {
|
|
|
306
298
|
|
|
307
299
|
# Index a repository under a memory bound. The codebase-memory-mcp engine
|
|
308
300
|
# self-budgets ~50% of *physical* RAM and is not cgroup-aware, so a first index
|
|
309
|
-
# of a huge repo can exhaust the host (the original 30 GB hang).
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
301
|
+
# of a huge repo can exhaust the host (the original 30 GB hang). The engine's
|
|
302
|
+
# own budget (CBM_MEM_BUDGET_MB) is set to DRAFT_INDEX_MEM_PCT (default 25) of
|
|
303
|
+
# total RAM unless the user chose one; on Linux the process is also confined to
|
|
304
|
+
# a transient cgroup scope of that size. CBM_WORKERS caps the engine's parallel
|
|
305
|
+
# working set so the throttle has less transient pressure to absorb. Where cgroup
|
|
306
|
+
# v2 + systemd-run are unavailable (e.g. macOS) the budget and worker cap are the
|
|
307
|
+
# bound. Never falls back
|
|
314
308
|
# from a started scope to an unbounded run — a bounded OOM fails the index
|
|
315
309
|
# cleanly (host stays alive) rather than re-triggering the hang.
|
|
316
310
|
# Echoes the engine's JSON result on stdout (same contract as memory_cli).
|
|
311
|
+
# Usage: memory_index_bounded <repo-abs> [project-name]
|
|
317
312
|
memory_index_bounded() {
|
|
318
|
-
local repo_abs="$1"
|
|
313
|
+
local repo_abs="$1" name="${2:-}"
|
|
319
314
|
# Payload built with jq (never string concatenation) so a repo path
|
|
320
315
|
# containing a `"` or `\` can never corrupt the JSON sent to the engine.
|
|
321
316
|
command -v jq >/dev/null 2>&1 || return 1
|
|
322
317
|
local json
|
|
323
|
-
json="$(jq -n --arg r "$repo_abs"
|
|
318
|
+
json="$(jq -n --arg r "$repo_abs" --arg n "$name" \
|
|
319
|
+
'{repo_path:$r} + (if $n == "" then {} else {name:$n} end)')" || return 1
|
|
324
320
|
export CBM_WORKERS="${CBM_WORKERS:-4}"
|
|
325
321
|
local total pct
|
|
326
322
|
total="$(_total_ram_mb)"
|
|
327
323
|
pct="${DRAFT_INDEX_MEM_PCT:-25}"
|
|
324
|
+
[[ "${total:-0}" -gt 0 ]] && export CBM_MEM_BUDGET_MB="${CBM_MEM_BUDGET_MB:-$(( total * pct / 100 ))}"
|
|
328
325
|
if [[ "${total:-0}" -gt 0 ]] && _can_cgroup_bound; then
|
|
329
326
|
local high_arg max_arg
|
|
330
327
|
read -r high_arg max_arg <<< "$(_mem_bound_args "$total" "$pct")"
|
|
331
328
|
if [[ -n "${DRAFT_MEMORY_DEBUG:-}" ]]; then
|
|
332
329
|
systemd-run --user --scope -q -p "$high_arg" -p "$max_arg" \
|
|
333
|
-
-- "$MEMORY_BIN" cli index_repository "$json"
|
|
330
|
+
-- "$MEMORY_BIN" cli index_repository <<< "$json"
|
|
334
331
|
else
|
|
335
332
|
systemd-run --user --scope -q -p "$high_arg" -p "$max_arg" \
|
|
336
|
-
-- "$MEMORY_BIN" cli index_repository "$json" 2>/dev/null
|
|
333
|
+
-- "$MEMORY_BIN" cli index_repository <<< "$json" 2>/dev/null
|
|
337
334
|
fi
|
|
338
335
|
else
|
|
339
336
|
memory_cli index_repository "$json"
|
|
340
337
|
fi
|
|
341
338
|
}
|
|
342
339
|
|
|
343
|
-
#
|
|
344
|
-
#
|
|
340
|
+
# Bring a repository's engine index up to date; echo its project name.
|
|
341
|
+
# Always re-indexes: the engine indexes incrementally (content-based, git-aware),
|
|
342
|
+
# so an unchanged repo costs ~0.1 s. Indexing only when the project was absent
|
|
343
|
+
# left every live query answering from the first index ever taken — a symbol
|
|
344
|
+
# added since stayed invisible while the result still said status:"ok".
|
|
345
|
+
#
|
|
346
|
+
# The project is named explicitly: the engine derives names by flattening '/' to
|
|
347
|
+
# '-', so /x/a-b/c and /x/a/b-c shared one DB and each index overwrote the other.
|
|
348
|
+
# A repo the engine already knows keeps its name (no forced full re-index); a new
|
|
349
|
+
# one gets <basename>-<sha8 of its path>, which no other path can derive.
|
|
350
|
+
# Returns 1 if the engine is unavailable.
|
|
345
351
|
memory_ensure_index() {
|
|
346
352
|
local repo_abs="$1"
|
|
347
353
|
[[ -n "${MEMORY_BIN:-}" ]] || return 1
|
|
348
354
|
command -v jq >/dev/null 2>&1 || return 1
|
|
349
|
-
local proj
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
355
|
+
local proj name
|
|
356
|
+
name="$(memory_cli list_projects '{}' \
|
|
357
|
+
| jq -r --arg p "$repo_abs" 'first(.projects[]? | select(.root_path == $p) | .name) // empty' 2>/dev/null || true)"
|
|
358
|
+
if [[ -z "$name" ]]; then
|
|
359
|
+
name="$(printf '%s' "$repo_abs" | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-8)"
|
|
360
|
+
name="$(basename "$repo_abs")-$name"
|
|
354
361
|
fi
|
|
362
|
+
proj="$(memory_index_bounded "$repo_abs" "$name" \
|
|
363
|
+
| jq -r '.project // empty' 2>/dev/null || true)"
|
|
355
364
|
[[ -n "$proj" ]] || return 1
|
|
356
365
|
printf '%s' "$proj"
|
|
357
366
|
}
|
|
@@ -109,7 +109,11 @@ else
|
|
|
109
109
|
# `head` lets head close the pipe early, which under `pipefail` surfaces
|
|
110
110
|
# sort's SIGPIPE as a hard failure — non-deterministically, depending on
|
|
111
111
|
# whether the output fit in the pipe buffer.
|
|
112
|
-
|
|
112
|
+
# git ls-tree -l: "mode type sha size<TAB>path" — path may contain spaces.
|
|
113
|
+
largest="$(awk -F '\t' '$1 ~ / [0-9]+$/ {
|
|
114
|
+
n = split($1, a, " ")
|
|
115
|
+
printf " %8.2f MB %s\n", a[n] / 1048576, $2
|
|
116
|
+
}' <<< "$listing" | sort -rn)"
|
|
113
117
|
head -n "$TOP" <<< "$largest"
|
|
114
118
|
fi
|
|
115
119
|
fi
|
|
@@ -64,20 +64,24 @@ graph_bootstrap "$REPO" || unavailable
|
|
|
64
64
|
# derived from a query that never ran, which is exactly the true-negative
|
|
65
65
|
# confusion Guardrail 4 exists to prevent, and the one failure mode a caller
|
|
66
66
|
# cannot detect. Both queries must land for the sample to mean anything, so
|
|
67
|
-
# either failure routes to `unavailable`. gq_run
|
|
68
|
-
# JSON
|
|
67
|
+
# either failure routes to `unavailable`. gq_run requires a `.rows` array, so
|
|
68
|
+
# shapeless JSON (`{}`) is unavailable, not an empty sample.
|
|
69
69
|
R2="$(gq_run "$PROJECT" "$(gq_q_cycles2)")" || unavailable
|
|
70
70
|
R3="$(gq_run "$PROJECT" "$(gq_q_cycles3)")" || unavailable
|
|
71
71
|
|
|
72
|
-
#
|
|
73
|
-
# the engine rejects `a.x < b.x`, which is what the query used to rely on.
|
|
74
|
-
# A
|
|
72
|
+
# Degenerate rows and duplicate rotations are filtered here rather than in
|
|
73
|
+
# Cypher: the engine rejects `a.x < b.x`, which is what the query used to rely on.
|
|
74
|
+
# A cycle comes back once per rotation (a 2-cycle as A,B and B,A — hence the
|
|
75
|
+
# doubled LIMIT upstream), and a self-loop also matches both patterns with a
|
|
76
|
+
# repeated node, e.g. (x, x, x). Drop rows that repeat a node, rotate each to
|
|
77
|
+
# start at its smallest member, and keep one of each.
|
|
75
78
|
jq -n --argjson r2 "$R2" --argjson r3 "$R3" '
|
|
79
|
+
def cycles: map(select((unique | length) == length)
|
|
80
|
+
| (indices(min)[0]) as $i | .[$i:] + .[:$i]) | unique;
|
|
76
81
|
( ((($r2.rows) // []) | length) >= 200
|
|
77
82
|
or ((($r3.rows) // []) | length) >= 100 ) as $trunc
|
|
78
|
-
| ( ($r2.rows // []) | map(select(.[0] != .[1])) | unique_by(sort) ) as $two
|
|
79
83
|
| {
|
|
80
|
-
cycles: ($
|
|
84
|
+
cycles: ((($r2.rows // []) | cycles) + (($r3.rows // []) | cycles)),
|
|
81
85
|
truncated: $trunc,
|
|
82
86
|
source: "memory-graph"
|
|
83
87
|
}'
|
|
@@ -88,7 +88,7 @@ if [[ "$TRANSITIVE" -eq 1 ]]; then
|
|
|
88
88
|
PAYLOAD="$(jq -n --arg p "$PROJECT" --arg f "$SYMBOL" --argjson d "$DEPTH" \
|
|
89
89
|
'{project:$p, function_name:$f, depth:$d, direction:"both"}')"
|
|
90
90
|
RES="$(memory_cli trace_path "$PAYLOAD" 2>/dev/null || true)"
|
|
91
|
-
echo "$RES" | jq -e . >/dev/null 2>&1 || unavailable
|
|
91
|
+
echo "$RES" | jq -e 'has("callers") and (.callers | type == "array")' >/dev/null 2>&1 || unavailable
|
|
92
92
|
N="$(echo "$RES" | jq -r '(.callers // []) | length' 2>/dev/null || echo 0)"
|
|
93
93
|
if [[ "$N" -gt 0 ]]; then STATUS="ok"; else
|
|
94
94
|
STATUS="$(gq_symbol_status "$PROJECT" "$SYM_ESC" '{"rows":[]}')"
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# graph-impact.sh — blast radius for a file or symbol, from the knowledge graph.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# function (transitive upstream dependents).
|
|
4
|
+
# Everything that depends on the target, up to --depth CALLS hops: callers of the
|
|
5
|
+
# symbol, or callers of anything defined in the file plus the files that import
|
|
6
|
+
# it. Callers inside the target file are not downstream and are skipped.
|
|
8
7
|
#
|
|
9
8
|
# Usage:
|
|
10
9
|
# scripts/tools/graph-impact.sh --repo DIR (--file PATH | --symbol NAME) [--depth N]
|
|
11
10
|
#
|
|
12
|
-
# Output: JSON {target, kind, impacted:[{name,file,qualified,hop}],
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# Output: JSON {target, kind, impacted:[{name,file,qualified,hop}], downstream_files,
|
|
12
|
+
# affected_modules, max_depth, by_category:{code,test}, status, truncated, source}.
|
|
13
|
+
# `impacted` lists each dependent once at its nearest hop, capped at 200; the
|
|
14
|
+
# aggregates always cover the full set. `truncated` is true when the list was
|
|
15
|
+
# capped or the engine row limit was hit. A module is a file's top-level path
|
|
16
|
+
# segment ("." for root files), as in classify-files.sh.
|
|
17
|
+
# status = ok | no-edges (target known, nothing depends on it) | no-match
|
|
18
|
+
# (target unknown to the graph)
|
|
15
19
|
# source = "memory-graph" | "unavailable"
|
|
16
20
|
#
|
|
17
21
|
# Exit codes: 0 OK, 1 invocation error, 2 graph engine unavailable.
|
|
18
22
|
set -euo pipefail
|
|
19
23
|
|
|
20
|
-
# shellcheck source=
|
|
21
|
-
source "$(dirname "${BASH_SOURCE[0]}")/
|
|
24
|
+
# shellcheck source=_graph_queries.sh
|
|
25
|
+
source "$(dirname "${BASH_SOURCE[0]}")/_graph_queries.sh"
|
|
22
26
|
|
|
23
27
|
REPO="."
|
|
24
28
|
FILE=""
|
|
@@ -34,12 +38,13 @@ Usage:
|
|
|
34
38
|
|
|
35
39
|
Flags:
|
|
36
40
|
--repo DIR Repository root (default: cwd).
|
|
37
|
-
--file PATH
|
|
38
|
-
--symbol NAME
|
|
39
|
-
--depth N Caller traversal depth
|
|
41
|
+
--file PATH Dependents of a file (repo-relative, ./-prefixed, or absolute).
|
|
42
|
+
--symbol NAME Dependents (transitive callers) of a function.
|
|
43
|
+
--depth N Caller traversal depth (default: 3).
|
|
40
44
|
--help Show this help.
|
|
41
45
|
|
|
42
|
-
Output: JSON {target, kind, impacted,
|
|
46
|
+
Output: JSON {target, kind, impacted, downstream_files, affected_modules,
|
|
47
|
+
max_depth, by_category, status, truncated, source}. Exit 2 when engine unavailable.
|
|
43
48
|
EOF
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -70,27 +75,50 @@ if [[ -n "$SYMBOL" ]]; then TARGET="$SYMBOL"; KIND="symbol"; else TARGET="$FILE"
|
|
|
70
75
|
graph_bootstrap "$REPO" || unavailable "$TARGET" "$KIND"
|
|
71
76
|
|
|
72
77
|
if [[ -n "$SYMBOL" ]]; then
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# --symbol can never corrupt the JSON; an engine failure is unavailable,
|
|
76
|
-
# never a fabricated empty-impact success.
|
|
77
|
-
PAYLOAD="$(jq -n --arg p "$PROJECT" --arg f "$SYMBOL" --argjson d "$DEPTH" \
|
|
78
|
-
'{project:$p, function_name:$f, depth:$d, direction:"both"}')"
|
|
79
|
-
RES="$(memory_cli trace_path "$PAYLOAD" 2>/dev/null || true)"
|
|
80
|
-
echo "$RES" | jq -e . >/dev/null 2>&1 || unavailable "$TARGET" "$KIND"
|
|
81
|
-
echo "$RES" | jq --arg t "$TARGET" '
|
|
82
|
-
{target:$t, kind:"symbol",
|
|
83
|
-
impacted: [ (.callers // [])[] | {name:.name, file:(.file_path // ""), qualified:(.qualified_name // ""), hop:(.hop // 1)} ],
|
|
84
|
-
source:"memory-graph"}'
|
|
78
|
+
T_ESC="$(gq_escape "$SYMBOL")"
|
|
79
|
+
DEPENDENTS=gq_q_dependents_symbol; EXISTS=gq_q_exists
|
|
85
80
|
else
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
echo "$RES" | jq --arg t "$TARGET" '
|
|
91
|
-
{target:$t, kind:"file",
|
|
92
|
-
impacted: [ (.impacted_symbols // [])[]
|
|
93
|
-
| select((.file // "") | endswith($t) or (. == $t))
|
|
94
|
-
| {name:.name, file:(.file // ""), hop:1} ],
|
|
95
|
-
source:"memory-graph"}'
|
|
81
|
+
# The graph keys files by repo-relative path.
|
|
82
|
+
REL="${FILE#"$REPO_ABS"/}"; REL="${REL#./}"
|
|
83
|
+
T_ESC="$(gq_escape "$REL")"
|
|
84
|
+
DEPENDENTS=gq_q_dependents_file; EXISTS=gq_q_file_exists
|
|
96
85
|
fi
|
|
86
|
+
|
|
87
|
+
# Accumulate {q,name,file,test,hop} JSONL in a temp file: the row sets of a hot
|
|
88
|
+
# target can exceed argv limits.
|
|
89
|
+
ROWS="$(mktemp)"
|
|
90
|
+
trap 'rm -f "$ROWS"' EXIT
|
|
91
|
+
TRUNC=false
|
|
92
|
+
for ((k = 1; k <= DEPTH; k++)); do
|
|
93
|
+
R="$(gq_run "$PROJECT" "$("$DEPENDENTS" "$T_ESC" "$k")")" || unavailable "$TARGET" "$KIND"
|
|
94
|
+
[[ "$(gq_rows_len "$R")" -lt "$GQ_DEP_LIMIT" ]] || TRUNC=true
|
|
95
|
+
jq -c --argjson k "$k" '.rows[] | {q:(.[0] // ""), name:(.[1] // ""), file:(.[2] // ""),
|
|
96
|
+
test:((.[3] | tostring) == "true"), hop:$k}' <<< "$R" >> "$ROWS"
|
|
97
|
+
done
|
|
98
|
+
if [[ -n "$FILE" ]]; then
|
|
99
|
+
R="$(gq_run "$PROJECT" "$(gq_q_importers "$T_ESC")")" || unavailable "$TARGET" "$KIND"
|
|
100
|
+
[[ "$(gq_rows_len "$R")" -lt "$GQ_DEP_LIMIT" ]] || TRUNC=true
|
|
101
|
+
jq -c '.rows[] | {q:"", name:.[0], file:.[0], test:false, hop:1}' <<< "$R" >> "$ROWS"
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# Nothing depends on it: a true negative only if the graph knows the target.
|
|
105
|
+
STATUS=ok
|
|
106
|
+
if [[ ! -s "$ROWS" ]]; then
|
|
107
|
+
EX="$(gq_run "$PROJECT" "$("$EXISTS" "$T_ESC")")" || unavailable "$TARGET" "$KIND"
|
|
108
|
+
if [[ "$(gq_rows_len "$EX")" -gt 0 ]]; then STATUS=no-edges; else STATUS=no-match; fi
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
jq -s --arg t "$TARGET" --arg kind "$KIND" --arg status "$STATUS" --argjson trunc "$TRUNC" '
|
|
112
|
+
(group_by([.q, .file]) | map(min_by(.hop))) as $u
|
|
113
|
+
| ($u | map(.file) | map(select(. != "")) | unique) as $files
|
|
114
|
+
| ($u | map(select(.test) | .file) | unique) as $tests
|
|
115
|
+
| {target:$t, kind:$kind,
|
|
116
|
+
impacted: ($u | sort_by(.hop, .file, .name) | .[:200]
|
|
117
|
+
| map({name, file, qualified:.q, hop})),
|
|
118
|
+
downstream_files: $files,
|
|
119
|
+
affected_modules: ($files | map(if test("/") then split("/")[0] else "." end) | unique),
|
|
120
|
+
max_depth: ($u | map(.hop) | max // 0),
|
|
121
|
+
by_category: {code: ($files - $tests | length), test: ($tests | length)},
|
|
122
|
+
status: $status,
|
|
123
|
+
truncated: ($trunc or ($u | length) > 200),
|
|
124
|
+
source: "memory-graph"}' "$ROWS"
|
|
@@ -62,7 +62,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
62
62
|
done
|
|
63
63
|
|
|
64
64
|
[[ -d "$SCOPE" ]] || { echo "ERROR: --scope '$SCOPE' is not a directory" >&2; exit 1; }
|
|
65
|
-
SCOPE_ABS="$(cd "$SCOPE" && pwd)"
|
|
65
|
+
SCOPE_ABS="$(cd "$SCOPE" && pwd -P)"
|
|
66
66
|
SELF_REPO="$(cd "$TOOLS_DIR/../.." && pwd)"
|
|
67
67
|
|
|
68
68
|
# --- Resolve ROOT (bounded by the git toplevel; never escapes the repo) ---
|
|
@@ -127,22 +127,17 @@ write_root_link() {
|
|
|
127
127
|
local status="$1"
|
|
128
128
|
local mod_graph="$SCOPE_ABS/draft/graph"
|
|
129
129
|
mkdir -p "$mod_graph"
|
|
130
|
-
local rel
|
|
130
|
+
local rel root_commit ts
|
|
131
131
|
rel="$(root_link_relpath)"
|
|
132
|
-
if [[ -f "$schema" ]]; then
|
|
133
|
-
root_project="$(grep -m1 '^project:' "$schema" 2>/dev/null | sed 's/^project:[[:space:]]*//; s/^"//; s/"$//' || true)"
|
|
134
|
-
[[ -n "$root_project" ]] || root_project="unknown"
|
|
135
|
-
fi
|
|
136
132
|
root_commit="$(git -C "$ROOT_ABS" rev-parse --verify --quiet HEAD 2>/dev/null || echo none)"
|
|
137
133
|
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
138
|
-
# Every interpolated value goes through json_escape: a repo path
|
|
139
|
-
#
|
|
140
|
-
#
|
|
134
|
+
# Every interpolated value goes through json_escape: a repo path carrying a
|
|
135
|
+
# quote or backslash would otherwise emit a root-link.json that no consumer
|
|
136
|
+
# can parse.
|
|
141
137
|
cat > "$mod_graph/root-link.json" <<EOF
|
|
142
138
|
{
|
|
143
139
|
"root_graph": "$(json_escape "$rel")",
|
|
144
140
|
"root_abs": "$(json_escape "$ROOT_ABS/draft/graph")",
|
|
145
|
-
"root_project": "$(json_escape "${root_project:-unknown}")",
|
|
146
141
|
"root_commit": "$(json_escape "$root_commit")",
|
|
147
142
|
"status": "$(json_escape "$status")",
|
|
148
143
|
"linked_at": "$ts",
|
|
@@ -47,7 +47,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
47
47
|
done
|
|
48
48
|
REPO="${REPO:-.}"
|
|
49
49
|
[[ -d "$REPO" ]] || { echo "ERROR: '$REPO' is not a directory" >&2; exit 2; }
|
|
50
|
-
REPO_ABS="$(cd "$REPO" && pwd)"
|
|
50
|
+
REPO_ABS="$(cd "$REPO" && pwd -P)"
|
|
51
51
|
|
|
52
52
|
# --- formatting (color only on a tty, and never in --json) ---
|
|
53
53
|
if [[ -t 1 && "$JSON_MODE" -eq 0 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; D=$'\e[0m'; else B=""; G=""; Y=""; R=""; D=""; fi
|
|
@@ -168,6 +168,10 @@ if find_memory_bin "$REPO_ABS" "$SELF_REPO"; then
|
|
|
168
168
|
ENGINE_FOUND=true
|
|
169
169
|
VER="$("$ENGINE" --version 2>/dev/null | head -1 || echo '?')"
|
|
170
170
|
ok "Engine: $ENGINE ($VER)"
|
|
171
|
+
PINNED="$(sed -n 's/^DEFAULT_VERSION="v\{0,1\}\([^"]*\)".*/\1/p' "$SELF_REPO/scripts/fetch-memory-engine.sh" 2>/dev/null | head -1)"
|
|
172
|
+
if [[ -n "$PINNED" && "${VER##* }" != "$PINNED" ]]; then
|
|
173
|
+
warn "Engine version ${VER##* } differs from the pinned $PINNED the graph tools are verified against — scripts/fetch-memory-engine.sh --force"
|
|
174
|
+
fi
|
|
171
175
|
# Numeric-only: the value is emitted bare into the --json report, so a
|
|
172
176
|
# non-numeric field (engine output format drift) would produce invalid JSON.
|
|
173
177
|
LIMIT="$("$ENGINE" config list 2>/dev/null | awk '/auto_index_limit/{print $3}' || true)"
|
|
@@ -255,8 +259,7 @@ cat <<EOF
|
|
|
255
259
|
|
|
256
260
|
Next step (when ready, from the git root):
|
|
257
261
|
scripts/tools/graph-init.sh --scope . --json & # or: /draft:init --graph-only
|
|
258
|
-
|
|
259
|
-
${ENGINE:-codebase-memory-mcp} cli index_status '{"project":"<name>"}'
|
|
262
|
+
scripts/tools/graph-query.sh --repo . --tool index_status
|
|
260
263
|
EOF
|
|
261
264
|
hr
|
|
262
265
|
exit "$VEXIT"
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
# a `query` field in --json is scanned for write
|
|
13
13
|
# verbs exactly like --cypher.
|
|
14
14
|
#
|
|
15
|
-
# Dialect limits (engine v0.
|
|
16
|
-
# SAFE :
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
15
|
+
# Dialect limits (engine v0.9.0 — see _graph_queries.sh for the full list):
|
|
16
|
+
# SAFE : comparisons against a literal, `STARTS WITH`, `AND`/`OR`,
|
|
17
|
+
# variable-length `[:R*1..3]`, rel-type alternation `[:A|B]`,
|
|
18
|
+
# `coalesce()`, `DISTINCT`, `count(...)`, WITH-aggregation.
|
|
19
|
+
# UNSAFE : property-to-property comparison, NOT EXISTS(...), NOT (pattern),
|
|
20
|
+
# path variables, multi-pattern joins. LIMIT applies before DISTINCT.
|
|
20
21
|
# Passthrough returns the engine's raw error, not a silent empty result.
|
|
21
22
|
#
|
|
22
23
|
# Usage:
|
|
@@ -52,8 +53,8 @@ Flags:
|
|
|
52
53
|
`query` field is write-verb checked like --cypher.
|
|
53
54
|
--help Show this help.
|
|
54
55
|
|
|
55
|
-
Dialect: avoid
|
|
56
|
-
multi-pattern joins.
|
|
56
|
+
Dialect: avoid property-to-property comparison, NOT EXISTS, NOT(pattern), path
|
|
57
|
+
variables, multi-pattern joins. LIMIT applies before DISTINCT.
|
|
57
58
|
|
|
58
59
|
Output: raw engine JSON on success; {"source":"unavailable"} (exit 2) when the
|
|
59
60
|
engine is unavailable; exit 1 on invocation error or a rejected write verb.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# truth; the engine is the structural index over it.
|
|
12
12
|
#
|
|
13
13
|
# Writes one file under <repo>/draft/graph/:
|
|
14
|
-
# schema.yaml engine
|
|
14
|
+
# schema.yaml engine metadata + index counts. Its presence is the
|
|
15
15
|
# GATE that tells skills the graph engine is wired for this repo
|
|
16
16
|
# (see core/shared/graph-query.md Pre-Check). It carries no graph
|
|
17
17
|
# data — every structural query goes to the live engine.
|
|
@@ -61,26 +61,18 @@ done
|
|
|
61
61
|
|
|
62
62
|
[[ -d "$REPO" ]] || { echo "ERROR: --repo '$REPO' is not a directory" >&2; exit 1; }
|
|
63
63
|
|
|
64
|
-
REPO_ABS="$(cd "$REPO" && pwd)"
|
|
64
|
+
REPO_ABS="$(cd "$REPO" && pwd -P)"
|
|
65
65
|
SELF_REPO="$(cd "$TOOLS_DIR/../.." && pwd)"
|
|
66
66
|
OUT="${OUT_DIR:-$REPO_ABS/draft/graph}"
|
|
67
67
|
|
|
68
68
|
find_memory_bin "$REPO_ABS" "$SELF_REPO" || { echo "graph engine unavailable — nothing written" >&2; exit 2; }
|
|
69
69
|
command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 2; }
|
|
70
70
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
71
|
+
# Refresh the engine index (incremental; indexes from scratch when absent). This is
|
|
72
|
+
# the valuable side-effect — live queries resolve against a current index. A failed
|
|
73
|
+
# refresh writes nothing: a fresh `generated_at` over a frozen index would lie.
|
|
73
74
|
PROJECT="$(memory_ensure_index "$REPO_ABS" || true)"
|
|
74
|
-
[[ -n "$PROJECT" ]] || { echo "
|
|
75
|
-
|
|
76
|
-
# ...then ALWAYS re-index. memory_ensure_index calls index_repository only when the
|
|
77
|
-
# project is ABSENT — correct for the graph-*.sh query wrappers, which must stay
|
|
78
|
-
# cheap — so on an already-indexed repo this tool used to write a gate marker with a
|
|
79
|
-
# fresh `generated_at` over a frozen index: a deleted symbol stayed resolvable, a new
|
|
80
|
-
# one never appeared, and nothing in the output said so. Refreshing is this tool's
|
|
81
|
-
# entire job. The engine indexes incrementally, so the repeat call is cheap.
|
|
82
|
-
REFRESHED="$(memory_index_bounded "$REPO_ABS" 2>/dev/null | jq -r '.project // empty' 2>/dev/null || true)"
|
|
83
|
-
[[ -n "$REFRESHED" ]] && PROJECT="$REFRESHED"
|
|
75
|
+
[[ -n "$PROJECT" ]] || { echo "index refresh failed — nothing written" >&2; exit 2; }
|
|
84
76
|
|
|
85
77
|
mkdir -p "$OUT"
|
|
86
78
|
|
|
@@ -116,32 +108,29 @@ VER="$("$MEMORY_BIN" --version 2>/dev/null | awk '{print $NF}' || echo unknown)"
|
|
|
116
108
|
|
|
117
109
|
# Incremental-refresh provenance (graph-tooling-v2 Phase 5): the engine indexes
|
|
118
110
|
# incrementally (content-based, git-aware), so re-indexing only touches changed
|
|
119
|
-
# files. detect_changes reports that working-tree delta —
|
|
120
|
-
#
|
|
111
|
+
# files. detect_changes reports that working-tree delta — echoed so a refresh
|
|
112
|
+
# shows what moved, but kept out of the committed marker (it differs per machine
|
|
113
|
+
# and per run, like the path-derived project name and a timestamp would).
|
|
114
|
+
# Best-effort: never aborts the write.
|
|
121
115
|
CHANGES_JSON="$(memory_cli detect_changes "$(jq -n --arg p "$PROJECT" '{project:$p}')" 2>/dev/null || echo '{}')"
|
|
122
116
|
echo "$CHANGES_JSON" | jq -e . >/dev/null 2>&1 || CHANGES_JSON='{}'
|
|
123
117
|
CHANGED_FILES="$(echo "$CHANGES_JSON" | jq -r '.changed_count // (.changed_files | length?) // 0' 2>/dev/null || echo 0)"
|
|
124
118
|
IMPACTED="$(echo "$CHANGES_JSON" | jq -r '(.impacted_symbols | length?) // 0' 2>/dev/null || echo 0)"
|
|
125
119
|
|
|
126
|
-
# YAML double-quoted
|
|
127
|
-
#
|
|
128
|
-
PROJECT_Y="${PROJECT//\\/\\\\}"; PROJECT_Y="${PROJECT_Y//\"/\\\"}"
|
|
120
|
+
# YAML double-quoted scalar: escape backslashes then quotes so an unusual
|
|
121
|
+
# engine version string can never corrupt the marker.
|
|
129
122
|
VER_Y="${VER//\\/\\\\}"; VER_Y="${VER_Y//\"/\\\"}"
|
|
130
123
|
|
|
131
124
|
cat > "$OUT/schema.yaml" <<EOF
|
|
132
125
|
# Draft graph gate marker — written by scripts/tools/graph-snapshot.sh
|
|
133
126
|
# Draft is engine-only: this file carries NO graph data. Its presence signals that
|
|
134
127
|
# the local codebase-memory-mcp engine is wired for this repo. Query the engine
|
|
135
|
-
# live via the graph-*.sh wrappers (
|
|
128
|
+
# live via the graph-*.sh wrappers (graph-query.sh --tool covers the rest).
|
|
136
129
|
# Counts below are point-of-index provenance; the live engine is authoritative.
|
|
137
130
|
engine: codebase-memory-mcp
|
|
138
131
|
engine_version: "$VER_Y"
|
|
139
|
-
project: "$PROJECT_Y"
|
|
140
|
-
generated_at: "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
141
132
|
indexed_nodes: $NODES
|
|
142
133
|
indexed_edges: $EDGES
|
|
143
|
-
changed_files: $CHANGED_FILES
|
|
144
|
-
impacted_symbols: $IMPACTED
|
|
145
134
|
access: engine-live
|
|
146
135
|
EOF
|
|
147
136
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# graph-tooling-v2 Phase 6. Wraps the engine's ingest_traces to close the
|
|
5
5
|
# static/dynamic gap — dynamic dispatch the static graph misses (e.g. closures,
|
|
6
6
|
# reflection, virtual calls). This is a WRITE path and is gated behind
|
|
7
|
-
# --experimental. NOTE: in engine v0.
|
|
7
|
+
# --experimental. NOTE: in engine v0.9.0 ingest_traces is accepted but runtime
|
|
8
8
|
# edge creation is "not yet implemented" — the engine returns its status verbatim.
|
|
9
9
|
#
|
|
10
10
|
# Usage:
|
|
@@ -39,7 +39,7 @@ Flags:
|
|
|
39
39
|
--experimental Required acknowledgement — this is a write/experimental path.
|
|
40
40
|
--help Show this help.
|
|
41
41
|
|
|
42
|
-
NOTE: engine v0.
|
|
42
|
+
NOTE: engine v0.9.0 accepts traces but runtime edge creation is not yet
|
|
43
43
|
implemented; the engine's status is returned verbatim.
|
|
44
44
|
|
|
45
45
|
Output: raw engine JSON; {"source":"unavailable"} (exit 2) when unavailable.
|
|
@@ -79,7 +79,16 @@ echo "$ARCH_JSON" | jq -e . >/dev/null 2>&1 || unavailable
|
|
|
79
79
|
# and still reported source:"memory-graph", so a fan-in-only ranking was
|
|
80
80
|
# indistinguishable from a fully measured one. Record the outcome instead, and
|
|
81
81
|
# omit the fields rather than emitting measurements that were never taken.
|
|
82
|
-
|
|
82
|
+
# Ask for the hotspots by name: a capped scan of every node missed hotspots past
|
|
83
|
+
# the window on large repos and scored them 0 under enrichment "ok".
|
|
84
|
+
NAMES=""
|
|
85
|
+
while IFS= read -r q; do
|
|
86
|
+
NAMES+="${NAMES:+,}'$(gq_escape "$q")'"
|
|
87
|
+
done < <(jq -r '(.hotspots // [])[].qualified_name // empty' <<< "$ARCH_JSON")
|
|
88
|
+
if [[ -z "$NAMES" ]]; then
|
|
89
|
+
PROPS_JSON='{"rows":[]}'
|
|
90
|
+
ENRICHMENT="ok"
|
|
91
|
+
elif PROPS_JSON="$(gq_run "$PROJECT" "$(gq_q_node_props "$NAMES")")"; then
|
|
83
92
|
ENRICHMENT="ok"
|
|
84
93
|
else
|
|
85
94
|
PROPS_JSON='{"rows":[]}'
|
|
@@ -108,7 +108,7 @@ render_co_change() {
|
|
|
108
108
|
render_proto_map() {
|
|
109
109
|
local res; res="$(memory_cli get_architecture \
|
|
110
110
|
"$(jq -n --arg p "$PROJECT" '{project:$p, aspects:["routes"]}')" || true)"
|
|
111
|
-
[[ -n "$res" ]] && echo "$res" | jq -e . >/dev/null 2>&1 || return 2
|
|
111
|
+
[[ -n "$res" ]] && echo "$res" | jq -e 'has("routes") and (.routes | type == "array")' >/dev/null 2>&1 || return 2
|
|
112
112
|
local edges; edges="$(echo "$res" | jq -r '(.routes // [])[] | " \"" + ((.method // "")|tostring) + " " + ((.path // "")|tostring) + "\" --> \"" + ((.handler // "?")|tostring) + "\""' 2>/dev/null || true)"
|
|
113
113
|
if [[ -z "$edges" ]]; then return 1; fi
|
|
114
114
|
printf '```mermaid\nflowchart LR\n%s\n```\n' "$edges"
|
|
@@ -82,6 +82,9 @@ sniff_desc() {
|
|
|
82
82
|
|
|
83
83
|
write_page() {
|
|
84
84
|
local cid="$1" ctype="$2" resource="$3" fan_in="$4"
|
|
85
|
+
case "$cid" in
|
|
86
|
+
''|/*|*..*) echo "ERROR: concept_id escapes bundle: $cid" >&2; return 1 ;;
|
|
87
|
+
esac
|
|
85
88
|
local out="$BUNDLE/$cid"
|
|
86
89
|
mkdir -p "$(dirname "$out")"
|
|
87
90
|
if [[ -f "$out" && $FORCE -eq 0 ]]; then
|