@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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +4 -2
  3. package/.cursor-plugin/plugin.json +4 -2
  4. package/LICENSE +1 -1
  5. package/README.md +7 -7
  6. package/bin/README.md +14 -13
  7. package/cli/src/cli.js +2 -2
  8. package/cli/src/installer.js +4 -2
  9. package/cli/src/lib/cursor-registry.js +2 -2
  10. package/cli/src/lib/graph.js +2 -1
  11. package/core/shared/condensation.md +8 -7
  12. package/core/shared/draft-context-loading.md +1 -1
  13. package/core/shared/graph-query.md +31 -23
  14. package/core/templates/architecture.md +1 -1
  15. package/integrations/agents/AGENTS.md +43 -34
  16. package/integrations/copilot/.github/copilot-instructions.md +43 -34
  17. package/package.json +7 -3
  18. package/scripts/fetch-memory-engine.sh +44 -11
  19. package/scripts/lib.sh +1 -1
  20. package/scripts/tools/_graph_queries.sh +28 -12
  21. package/scripts/tools/_lib.sh +38 -29
  22. package/scripts/tools/check-repo-size.sh +5 -1
  23. package/scripts/tools/cycle-detect.sh +11 -7
  24. package/scripts/tools/graph-callers.sh +1 -1
  25. package/scripts/tools/graph-impact.sh +63 -35
  26. package/scripts/tools/graph-init.sh +5 -10
  27. package/scripts/tools/graph-preflight.sh +6 -3
  28. package/scripts/tools/graph-query.sh +8 -7
  29. package/scripts/tools/graph-snapshot.sh +13 -24
  30. package/scripts/tools/graph-traces.sh +2 -2
  31. package/scripts/tools/hotspot-rank.sh +10 -1
  32. package/scripts/tools/mermaid-from-graph.sh +1 -1
  33. package/scripts/tools/okf-emit-catalog.sh +3 -0
  34. package/scripts/tools/okf-render-views.sh +6 -4
  35. package/scripts/tools/okf-validate.sh +5 -0
  36. package/scripts/tools/resolve-tools.sh +5 -3
  37. package/scripts/tools/scan-markers.sh +9 -1
  38. package/scripts/tools/verify-graph-binary.sh +14 -2
  39. package/skills/draft/intent-mapping.md +1 -1
  40. 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.8.x, verified live against this engine):
11
- # SAFE : fixed-length patterns, single/multi-hop explicit patterns, `=`, `<`,
12
- # `STARTS WITH`, `NOT x STARTS WITH`, `AND`, `OR`, relationship-type
13
- # alternation `[:A|B]`, simple `count(x)`.
14
- # UNSAFE : coalesce(), `<>` / `!=` / `<=` / `>=`, `NOT EXISTS(...)`,
15
- # `NOT (pattern)`, `WITH`-grouping aggregation, multi-pattern joins,
16
- # and comparing one property against another (`a.x < b.x` — the
17
- # parser wants a literal on the right and fails with "expected value
18
- # at pos N"). `<` against a literal is fine.
19
- # Every builder below stays inside the SAFE set.
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
- gq_q_node_props() { printf "MATCH (f) RETURN f.qualified_name AS q, f.complexity AS c, f.cognitive AS cog, f.is_entry_point AS ep LIMIT 10000"; }
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
- printf '%s' "$res" | jq -e . >/dev/null 2>&1 || return 3
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
 
@@ -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). On Linux we
310
- # confine it to a transient cgroup scope sized to DRAFT_INDEX_MEM_PCT (default
311
- # 25) of total RAM; CBM_WORKERS caps the engine's parallel working set so the
312
- # throttle has less transient pressure to absorb. Where cgroup v2 + systemd-run
313
- # are unavailable (e.g. macOS) the worker cap is the only bound. Never falls back
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" '{repo_path:$r}')" || return 1
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
- # Ensure a repository is indexed in the engine; echo its project name.
344
- # Indexes on demand when absent. Returns 1 if the engine is unavailable.
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
- proj="$(memory_project_for_repo "$repo_abs" 2>/dev/null || true)"
351
- if [[ -z "$proj" ]]; then
352
- proj="$(memory_index_bounded "$repo_abs" \
353
- | jq -r '.project // empty' 2>/dev/null || true)"
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
- largest="$(awk '$4 ~ /^[0-9]+$/ { printf " %8.2f MB %s\n", $4 / 1048576, $5 }' <<< "$listing" | sort -rn)"
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 only ever echoes validated
68
- # JSON, so nothing downstream needs a second shape guard.
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
- # Self-loops and duplicate orderings are filtered here rather than in Cypher:
73
- # the engine rejects `a.x < b.x`, which is what the query used to rely on.
74
- # A 2-cycle comes back twice (A,B and B,A), hence the doubled LIMIT upstream.
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: ($two + ($r3.rows // [])),
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
- # Replaces `graph --query --file <path> --mode impact`. Backed by the
5
- # codebase-memory-mcp engine: combines detect_changes (git-diff impacted
6
- # symbols, when querying the working tree) with trace_path callers for a named
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}], source}.
13
- # `file` is always a path (empty when the engine carries none); the qualified
14
- # name has its own field rather than being emitted as if it were a path.
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=_lib.sh
21
- source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
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 Size impact of a changed file (uses git working-tree diff).
38
- --symbol NAME Transitive callers of a function (default depth 3).
39
- --depth N Caller traversal depth for --symbol (default: 3).
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, source}. Exit 2 when engine unavailable.
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
- # direction:"both" is the reliable form (the "callers" value returns empty in this engine);
74
- # we read the .callers array from it. Payload built with jq so a quote in
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
- # File impact: detect_changes maps the working-tree diff to impacted symbols.
87
- PAYLOAD="$(jq -n --arg p "$PROJECT" '{project:$p}')"
88
- RES="$(memory_cli detect_changes "$PAYLOAD" 2>/dev/null || true)"
89
- echo "$RES" | jq -e . >/dev/null 2>&1 || unavailable "$TARGET" "$KIND"
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 root_project="unknown" root_commit ts schema="$ROOT_ABS/draft/graph/schema.yaml"
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 or engine
139
- # project name carrying a quote or backslash would otherwise emit a
140
- # root-link.json that no consumer can parse.
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
- ${ENGINE:-codebase-memory-mcp} cli list_projects '{}'
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.8.x — see _graph_queries.sh for the full list):
16
- # SAFE : fixed-length patterns, `=`, `<`, `STARTS WITH`, `NOT x STARTS WITH`,
17
- # `AND`, `OR`, rel-type alternation `[:A|B]`, `count(x)`.
18
- # UNSAFE : coalesce(), `<>`/`!=`/`<=`/`>=`, NOT EXISTS(...), NOT (pattern),
19
- # WITH-grouping aggregation, multi-pattern joins.
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 coalesce(), <>, NOT EXISTS, NOT(pattern), WITH-aggregation,
56
- multi-pattern joins. Use =, <, STARTS WITH, AND/OR, [:A|B] alternation.
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 + project metadata + index counts. Its presence is the
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
- # Index on demand; this is the valuable side-effect it ensures the engine holds
72
- # a current index of the repo so live queries resolve.
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 "could not index repo — nothing written" >&2; exit 2; }
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 — recorded as provenance
120
- # and echoed so a refresh shows what moved. Best-effort: never aborts the write.
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 scalars: escape backslashes then quotes so an unusual
127
- # project name or engine version string can never corrupt the marker.
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 (or \`codebase-memory-mcp cli <tool>\`).
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.8.x ingest_traces is accepted but runtime
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.8.x accepts traces but runtime edge creation is not yet
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
- if PROPS_JSON="$(gq_run "$PROJECT" "$(gq_q_node_props)")"; then
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