@drafthq/draft 4.0.0 → 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.
@@ -6025,7 +6025,7 @@ After a phase passes review, refresh `metadata.json.impact` so future tracks can
6025
6025
  "$DRAFT_TOOLS/graph-impact.sh" --repo . --file <path>
6026
6026
  ```
6027
6027
 
6028
- Aggregate across all files: `downstream_files` = total unique downstream files (deduped), `downstream_modules` = union of `affected_modules`, `max_depth` = max across queries, `by_category` = sum of each query's `by_category`. If the graph is absent, leave these fields as zeros / empty arrays — the snapshot still records the directly-touched files.
6028
+ Aggregate across all files: `downstream_files` = count of the union of each query's `downstream_files` array, `downstream_modules` = union of `affected_modules`, `max_depth` = max across queries, `by_category` = sum of each query's `by_category`. If the graph is absent, leave these fields as zeros / empty arrays — the snapshot still records the directly-touched files.
6029
6029
 
6030
6030
  3. **Write metadata.json** with the populated `impact` block and `computed_at` set to the current timestamp.
6031
6031
 
@@ -17030,7 +17030,7 @@ If `draft/graph/schema.yaml` exists, the project has automated graph analysis da
17030
17030
 
17031
17031
  | File | Purpose | Content |
17032
17032
  |------|---------|---------|
17033
- | `draft/graph/schema.yaml` | Gate marker (engine + project metadata + point-of-index counts); presence gates graph use | YAML, ~15 lines |
17033
+ | `draft/graph/schema.yaml` | Gate marker (engine metadata + point-of-index counts); presence gates graph use | YAML, ~10 lines |
17034
17034
 
17035
17035
  Note: `.ai-context.md` embeds a condensed graph summary (`GRAPH:MODULES`, `GRAPH:HOTSPOTS`, `GRAPH:CYCLES`) for first-pass structural ground truth. Deep structural data is queried live from the engine (see Live structural queries below).
17036
17036
 
@@ -17558,7 +17558,7 @@ Any skill that mutates `architecture.md` should execute this subroutine afterwar
17558
17558
  | Input | Path | Description |
17559
17559
  |-------|------|-------------|
17560
17560
  | architecture.md | `draft/architecture.md` | Comprehensive human-readable engineering reference (source of truth) |
17561
- | schema.yaml | `draft/graph/schema.yaml` | Graph metrics for tier computation (optional — skip if absent) |
17561
+ | Architecture view | `scripts/tools/graph-arch.sh --repo .` | Live graph metrics for tier computation (optional — skip when `draft/graph/schema.yaml` is absent) |
17562
17562
 
17563
17563
  ## Outputs
17564
17564
 
@@ -17571,11 +17571,12 @@ Any skill that mutates `architecture.md` should execute this subroutine afterwar
17571
17571
 
17572
17572
  ## Target Size
17573
17573
 
17574
- Compute tier from `draft/graph/schema.yaml` after graph build:
17574
+ Compute tier from the live architecture view (the same metrics as `draft init` Step 1.4.5 — `schema.yaml` carries only counts, not these):
17575
17575
 
17576
- M = stats.modules
17577
- F = stats.go_functions + stats.py_functions
17578
- P = stats.proto_rpcs
17576
+ ARCH=$("$DRAFT_TOOLS/graph-arch.sh" --repo .) (DRAFT_TOOLS: resolver below)
17577
+ M = $ARCH | jq '.packages | length' (modules)
17578
+ F = $ARCH | jq '[.node_labels[] | select(.label=="Function" or .label=="Method") | .count] | add // 0' (functions + methods)
17579
+ P = $ARCH | jq '.routes | length' (routes / RPCs)
17579
17580
 
17580
17581
  | Tier | Label | Condition | Budget |
17581
17582
  |------|--------|----------------------------------------|---------------|
@@ -17585,7 +17586,7 @@ Compute tier from `draft/graph/schema.yaml` after graph build:
17585
17586
  | 4 | large | M≤100 AND F≤5000 AND P≤500 | 400–600 lines |
17586
17587
  | 5 | XL | M>100 OR F>5000 OR P>500 | 600–900 lines |
17587
17588
 
17588
- If `schema.yaml` does not exist: default to tier 2 (180–280 lines).
17589
+ If `schema.yaml` does not exist (no graph) or `graph-arch.sh` reports `source: unavailable`: default to tier 2 (180–280 lines).
17589
17590
 
17590
17591
  - Below tier minimum: incomplete condensation — ensure all sections are represented
17591
17592
  - Above tier maximum: insufficient compression — apply prioritization rules below
@@ -17705,7 +17706,7 @@ Before writing `draft/.ai-context.md`, verify:
17705
17706
  - [ ] No references to `architecture.md` (file must be self-contained)
17706
17707
  - [ ] All invariants from architecture.md are preserved
17707
17708
  - [ ] Extension cookbooks are complete (an agent can follow them without other files)
17708
- - [ ] Output is within tier budget bounds (compute from schema.yaml or default tier 2)
17709
+ - [ ] Output is within tier budget bounds (compute from `graph-arch.sh` or default tier 2)
17709
17710
  - [ ] GRAPH:HOTSPOTS present (or note "No hotspot data available" if graph absent)
17710
17711
  - [ ] GRAPH:CYCLES present ("None ✓" or cycle list; or note if graph absent)
17711
17712
  - [ ] GRAPH:MODULE-HOTSPOTS present for tier ≥ 3 (or note if no hotspot data)
@@ -18216,7 +18217,7 @@ DRAFT_TOOLS="${DRAFT_PLUGIN_ROOT:-$(cat ~/.cache/draft/plugin-root 2>/dev/null)}
18216
18217
  | `bash "$DRAFT_TOOLS/graph-query.sh" (--cypher STR \| --tool NAME --json '{...}')` | generic read-only passthrough | `{source:"unavailable"}`, exit 2 |
18217
18218
  | `bash "$DRAFT_TOOLS/graph-traces.sh" ingest --file F --experimental` | runtime traces (experimental write) | `{source:"unavailable"}`, exit 2 |
18218
18219
 
18219
- For lower-level modes, call the engine directly: `codebase-memory-mcp cli <tool> '<json>'` (see the tool list in [bin/README.md](../../bin/README.md)).
18220
+ For an engine tool without a dedicated wrapper, use the read-only passthrough `graph-query.sh --repo . --tool <name> --json '{...}'` (project injected, write tools refused, `source:"unavailable"` on failure). Never call the engine binary directly — that skips engine resolution, the index refresh before each query, and the fail-loud contract.
18220
18221
 
18221
18222
  ### Capability wrappers & dialect limits (graph-tooling-v2)
18222
18223
 
@@ -18224,8 +18225,9 @@ All Cypher lives in `scripts/tools/_graph_queries.sh` (the single source of quer
18224
18225
  truth). Wrappers are thin arg-parse → builder → fail-loud JSON. Three contracts
18225
18226
  matter when consuming them:
18226
18227
 
18227
- **Fail-loud status.** Symbol-scoped wrappers (`graph-callers`, `graph-snippet`,
18228
- `graph-tests --symbol`, `graph-hierarchy --symbol/--derived`, `graph-errors`)
18228
+ **Fail-loud status.** Symbol-scoped wrappers (`graph-callers`, `graph-impact`,
18229
+ `graph-snippet`, `graph-tests --symbol`, `graph-hierarchy --symbol/--derived`,
18230
+ `graph-errors`)
18229
18231
  emit a `status` field that distinguishes the three real outcomes — never read a
18230
18232
  bare `[]` as a confirmed true negative:
18231
18233
 
@@ -18238,7 +18240,7 @@ bare `[]` as a confirmed true negative:
18238
18240
 
18239
18241
  **Shapeless JSON is unavailable.** `gq_run` requires `has("rows") and (.rows|type=="array")`. A bare `{}` (or any object without a `.rows` array) is not a measured empty result — wrappers emit `source:"unavailable"` and a non-zero exit. Do not read `{}` as "no callers / no cycles / no edges". `graph-impact`, `graph-callers`, and `mermaid-from-graph` also require their tool-shaped object; a failed snapshot refresh does not rewrite `schema.yaml`.
18240
18242
 
18241
- **Verified engine param shapes** (engine v0.8.x — the runtime source of truth is
18243
+ **Verified engine param shapes** (engine v0.9.0 — the runtime source of truth is
18242
18244
  `get_graph_schema`; do not hardcode a property set):
18243
18245
 
18244
18246
  ```bash
@@ -18251,13 +18253,18 @@ get_graph_schema '{"project":P}' # → {node_labels:[{label,count,properties}
18251
18253
 
18252
18254
  **Cypher dialect — keep queries inside the SAFE set:**
18253
18255
 
18254
- - ✅ SAFE: fixed-length patterns, single/multi-hop explicit patterns, `=`, `<`,
18255
- `STARTS WITH`, `NOT x STARTS WITH`, `AND`, `OR`, relationship-type alternation
18256
- `[:A|B]`, simple `count(x)`.
18257
- - ❌ UNSAFE (rejected or silently empty): `coalesce()`, `<>` / `!=` / `<=` / `>=`,
18258
- `NOT EXISTS(...)`, `NOT (pattern)`, `WITH`-grouping aggregation, multi-pattern
18259
- joins. `graph-query.sh --cypher` returns the engine's raw error, not a silent
18256
+ - ✅ SAFE: `=`, `<>`/`!=`, `<`, `>`, `<=`, `>=` against a literal; `STARTS WITH`,
18257
+ `NOT x STARTS WITH`, `AND`, `OR`; explicit and variable-length patterns
18258
+ (`[:R*1..3]`, fixed depth `[:R*2..2]`); relationship-type alternation `[:A|B]`;
18259
+ `coalesce()`; `DISTINCT`; `count(x)`, `count(DISTINCT x)`; `WITH`-grouping
18260
+ aggregation.
18261
+ - UNSAFE: comparing one property against another (`a.x < b.x`), `NOT EXISTS(...)`,
18262
+ `NOT (pattern)`, path variables (`p=(...)`, `length(p)`) — all rejected; and
18263
+ multi-pattern joins (`MATCH (a)…, (b)…`), which parse but ignore `RETURN` and
18264
+ `LIMIT`. `graph-query.sh --cypher` returns the engine's raw error, not a silent
18260
18265
  empty — but the builders never emit these forms.
18266
+ - ⚠️ `LIMIT` applies before `DISTINCT`: `RETURN DISTINCT … LIMIT n` can return
18267
+ fewer than n rows while more exist. Judge truncation on raw rows.
18261
18268
 
18262
18269
  **Caveats consumers must respect:**
18263
18270
 
@@ -18292,15 +18299,15 @@ The only committed file is the gate marker:
18292
18299
 
18293
18300
  | File | Role |
18294
18301
  |------|------|
18295
- | `draft/graph/schema.yaml` | Engine + project metadata and point-of-index counts (provenance, not authoritative). Carries **no graph data**. Its presence is the **gate** (see Pre-Check) — it signals the engine is wired for this repo. Written by `scripts/tools/graph-snapshot.sh`. |
18302
+ | `draft/graph/schema.yaml` | Engine metadata and point-of-index counts (provenance, not authoritative). Carries **no graph data**. Its presence is the **gate** (see Pre-Check) — it signals the engine is wired for this repo. Written by `scripts/tools/graph-snapshot.sh`. |
18296
18303
 
18297
- All structural data is obtained live by shelling out to the engine — either through the query-tool wrappers under `scripts/tools/` or directly via `codebase-memory-mcp cli <tool> '<json>'`. The shell tools auto-index the repo into the engine's own cache on demand, so no committed files are required.
18304
+ All structural data is obtained live through the query-tool wrappers under `scripts/tools/`; `graph-query.sh --tool` covers every read-only engine tool that has no dedicated wrapper. The wrappers refresh the repo's index in the engine's own cache before each query, so no committed files are required.
18298
18305
 
18299
18306
  ### How skills query (engine is the interface; jq is optional)
18300
18307
 
18301
- - **The engine is the query.** `codebase-memory-mcp cli <tool> '<json>'` (and the wrappers that call it) is how you ask it takes JSON args and returns JSON. There is no other query surface.
18308
+ - **The engine is the query; the wrappers are the interface.** Every wrapper drives the engine's CLI and returns JSON. There is no other query surface.
18302
18309
  - **Prefer the wrappers — they resolve the engine for you.** `graph-arch.sh` (architecture view: packages/routes/layers/hotspots), `graph-callers.sh`, `hotspot-rank.sh`, `graph-impact.sh`, `cycle-detect.sh`, `mermaid-from-graph.sh` return already-shaped JSON. The engine binary is usually **not on `$PATH`** (it lives under `~/.cache/draft/bin/`); the wrappers locate it via `_lib.sh:find_memory_bin`, so a skill using a wrapper needs no resolution step.
18303
- - **Raw `codebase-memory-mcp cli` requires resolving the binary first** (it is not on `$PATH`): `CM="${DRAFT_MEMORY_BIN:-$HOME/.cache/draft/bin/codebase-memory-mcp}"; "$CM" cli <tool> '<json>'`. Reach for this only for tools without a wrapper (`search_graph`, `search_code`, `trace_path`).
18310
+ - **Tools without a dedicated wrapper go through `graph-query.sh`:** `"$DRAFT_TOOLS/graph-query.sh" --repo . --tool search_code --json '{"pattern":"..."}'` (likewise `trace_path`, `get_graph_schema`, `index_status`). `search_graph` has its own wrapper, `graph-search.sh`.
18304
18311
  - **`jq` is not a query tool — it only trims output.** Reach for it solely to slice a *large* response (chiefly the `get_architecture` blob) down to the field you need, for token economy. The agent can read raw JSON directly; jq is an optimization, not a requirement. Don't pipe wrapper output through jq unless you genuinely need a sub-field.
18305
18312
 
18306
18313
  The engine uses a **unified, language-agnostic** node model — `Function`, `Method`, `Class`, `Module`, `File`, `Folder`, `Route`, `Section`, `Variable` (language is inferred from file extension) — and edges `CALLS`, `DEFINES`, `CONTAINS_FILE`, `IMPORTS`, `HTTP_CALLS`, `FILE_CHANGES_WITH`, `SEMANTICALLY_RELATED`, `SIMILAR_TO`. Each node carries `file_path` + `start_line`/`end_line` and rich `properties` (complexity, signature, parent_class), and the engine exposes full-text (`search_code`) and semantic search — none of which a committed snapshot reproduced.
@@ -18320,11 +18327,11 @@ Output: `{symbol, callers[{name, file}], source}`. Use when enumerating call sit
18320
18327
  ### Impact — blast radius of a file or symbol
18321
18328
 
18322
18329
  ```bash
18323
- "$DRAFT_TOOLS/graph-impact.sh" --repo . --file <path> # changed-file impact (working-tree diff)
18324
- "$DRAFT_TOOLS/graph-impact.sh" --repo . --symbol <name> # transitive callers of a function
18330
+ "$DRAFT_TOOLS/graph-impact.sh" --repo . --file <path> # dependents of a file: its symbols' callers + its importers
18331
+ "$DRAFT_TOOLS/graph-impact.sh" --repo . --symbol <name> # dependents (transitive callers) of a function
18325
18332
  ```
18326
18333
 
18327
- Output: `{target, kind, impacted[{name, file, hop}], source}`. Use when sizing risk before modifying a file or symbol, especially high-fan-in hotspots.
18334
+ Output: `{target, kind, impacted[{name, file, qualified, hop}], downstream_files, affected_modules, max_depth, by_category{code,test}, status, truncated, source}`. `impacted` lists each dependent once at its nearest hop (default depth 3), capped at 200 with `truncated:true`; the aggregates always cover the full set. `status` is `ok`, `no-edges` (target known, nothing depends on it), or `no-match` (target unknown to the graph — check the path). Use when sizing risk before modifying a file or symbol, especially high-fan-in hotspots.
18328
18335
 
18329
18336
  ### Hotspots — fan-in ranking
18330
18337
 
@@ -18435,7 +18442,7 @@ Unlocks any edge type or node property without a purpose-built wrapper. Write ve
18435
18442
  "$DRAFT_TOOLS/graph-snapshot.sh" --repo .
18436
18443
  ```
18437
18444
 
18438
- Indexes the repo into the engine and writes the `draft/graph/schema.yaml` gate marker (now including the `detect_changes` delta: `changed_files`/`impacted_symbols`). It writes **no** graph data. Run during `draft init` and `draft graph`, or whenever the index should be refreshed.
18445
+ Indexes the repo into the engine and writes the `draft/graph/schema.yaml` gate marker, and prints the `detect_changes` delta (`changed_files`/`impacted_symbols`) without committing it. It writes **no** graph data. Run during `draft init` and `draft graph`, or whenever the index should be refreshed.
18439
18446
 
18440
18447
  ## Finding the Engine (Resolution + Usage Report)
18441
18448
 
@@ -18476,7 +18483,7 @@ The engine indexes C/C++, Go, Python, TypeScript/JS, and more (tree-sitter, 159
18476
18483
  | No engine resolvable (or `DRAFT_MEMORY_DISABLE=1`) | Skip graph indexing in init; all skills proceed without graph data; tools emit `source: unavailable` |
18477
18484
  | Engine present but a query fails, or returns shapeless `{}` | Treat as unavailable — never as a true-negative empty result; skills proceed without graph data |
18478
18485
  | `draft/graph/schema.yaml` exists | Engine is wired — use live query tools as needed during the run |
18479
- | Engine index out of date | The engine indexes incrementally (content-based, git-aware) on each query, so it self-freshens. Re-run `graph-snapshot.sh` (or init) to force a reindex and refresh the marker. |
18486
+ | Engine index out of date | Every wrapper re-indexes incrementally (content-based, git-aware) before it queries — ~0.1 s on an unchanged repo — so live answers track the working tree, including the run's own edits. `graph-snapshot.sh` (or init) also refreshes the `schema.yaml` marker. |
18480
18487
 
18481
18488
  </core-file>
18482
18489
 
@@ -20433,7 +20440,7 @@ graph:
20433
20440
  cpp: "{high}"
20434
20441
  go: "{approximate | high}"
20435
20442
  stats:
20436
- modules: "{N from schema.yaml}"
20443
+ modules: "{N from engine: get_architecture .packages}"
20437
20444
  edges: "{total_edges from engine: get_architecture .edges}"
20438
20445
  hotspots: "{N}"
20439
20446
  notes: "{explicit fidelity summary from engine: get_architecture .languages/.packages}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drafthq/draft",
3
- "version": "4.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Context-Driven Development for AI coding agents — install Draft into Claude Code, Cursor, Codex, or opencode.",
5
5
  "bin": {
6
6
  "draft": "cli/bin/draft.js"
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "test": "bash tests/test-cli.sh",
28
- "version": "bash scripts/sync-version.sh && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json .cursor-plugin/plugin.json",
28
+ "version": "bash scripts/sync-version.sh --stage",
29
29
  "prepublishOnly": "bash scripts/build-integrations.sh"
30
30
  },
31
31
  "repository": {
@@ -3,8 +3,9 @@
3
3
  #
4
4
  # The engine is the codebase-memory-mcp single static binary. This script fetches
5
5
  # the release archive for the host OS/arch from GitHub Releases, verifies its
6
- # SHA-256 against the published checksums.txt, extracts it, and installs the
7
- # binary to the Draft-managed location (~/.cache/draft/bin/codebase-memory-mcp),
6
+ # SHA-256 (against hashes pinned below for the default version, else against the
7
+ # release's checksums.txt), extracts it, and installs the binary to the
8
+ # Draft-managed location (~/.cache/draft/bin/codebase-memory-mcp),
8
9
  # which scripts/tools/_lib.sh:find_memory_bin resolves.
9
10
  #
10
11
  # Pinned by default for reproducibility; override with CMM_VERSION (a tag, e.g.
@@ -44,9 +45,15 @@ while [[ $# -gt 0 ]]; do
44
45
  done
45
46
 
46
47
  BIN_PATH="$DEST/codebase-memory-mcp"
48
+ # Keep an existing install only at the requested version, so a pin bump upgrades
49
+ # it. "latest" cannot be compared offline; any existing binary satisfies it.
47
50
  if [[ -x "$BIN_PATH" && $FORCE -eq 0 ]]; then
48
- echo "codebase-memory-mcp already installed at $BIN_PATH ($("$BIN_PATH" --version 2>/dev/null || echo unknown))"
49
- exit 0
51
+ have="$("$BIN_PATH" --version 2>/dev/null | awk '{print $NF}' || true)"
52
+ if [[ "$VERSION" == "latest" || "$have" == "${VERSION#v}" ]]; then
53
+ echo "codebase-memory-mcp already installed at $BIN_PATH (${have:-unknown})"
54
+ exit 0
55
+ fi
56
+ echo "Installed engine is ${have:-unknown}; replacing it with ${VERSION}."
50
57
  fi
51
58
 
52
59
  # --- Detect OS / arch (mirrors the engine's own install.sh naming) ---
@@ -89,6 +96,28 @@ fi
89
96
  # A mismatch is always fatal. An *absent* checksum is fatal only under
90
97
  # DRAFT_STRICT_VERIFY=1 — otherwise it warns, so that a release without a
91
98
  # checksums.txt does not brick the install for everyone.
99
+ #
100
+ # checksums.txt ships in the same release as the archive, so it proves only that
101
+ # the download is intact: a replaced release asset passes it. The pinned
102
+ # version's archives are checked against SHA-256 values recorded here instead
103
+ # (copied from its checksums.txt when the pin was bumped) — update them with
104
+ # DEFAULT_VERSION.
105
+ pinned_sha256() {
106
+ [[ "$VERSION" == "$DEFAULT_VERSION" ]] || return 0
107
+ case "$1" in
108
+ codebase-memory-mcp-darwin-amd64.tar.gz) echo 6af3d02a27f589901fa763d3971089337bc8c9838bbed5d0cf543ca9f1a9e543 ;;
109
+ codebase-memory-mcp-darwin-arm64.tar.gz) echo faa02f0404230c451a9812230394481948f80183801fa5bf67044b41c2f25ed4 ;;
110
+ codebase-memory-mcp-linux-amd64-portable.tar.gz) echo 8459d5c9d1457f2c82de3de307ffc7641ecbba2dde893427be1e62eca8ef9b25 ;;
111
+ codebase-memory-mcp-linux-arm64-portable.tar.gz) echo b0a43fdaf534073c16707d72726b73b149d4c1212034b281ee8b7b2dac755107 ;;
112
+ esac
113
+ }
114
+ sha256_of() {
115
+ if command -v sha256sum >/dev/null 2>&1; then
116
+ sha256sum "$1" | awk '{print $1}'
117
+ else
118
+ shasum -a 256 "$1" | awk '{print $1}'
119
+ fi
120
+ }
92
121
  STRICT="${DRAFT_STRICT_VERIFY:-0}"
93
122
  unverified() {
94
123
  if [[ "$STRICT" == "1" ]]; then
@@ -98,14 +127,18 @@ unverified() {
98
127
  echo " warning: $1 — skipping verification (set DRAFT_STRICT_VERIFY=1 to make this fatal)" >&2
99
128
  }
100
129
 
101
- if curl -fsSL --proto '=https' --proto-redir '=https' --max-time 60 -o "$TMP/checksums.txt" "$BASE/checksums.txt" 2>/dev/null; then
130
+ pinned="$(pinned_sha256 "$ARCHIVE")"
131
+ if [[ -n "$pinned" ]]; then
132
+ actual="$(sha256_of "$TMP/$ARCHIVE")"
133
+ if [[ "$pinned" != "$actual" ]]; then
134
+ echo "error: $ARCHIVE does not match the SHA-256 pinned for $VERSION (expected $pinned, got $actual)" >&2
135
+ exit 2
136
+ fi
137
+ echo " checksum OK (pinned $pinned)"
138
+ elif curl -fsSL --proto '=https' --proto-redir '=https' --max-time 60 -o "$TMP/checksums.txt" "$BASE/checksums.txt" 2>/dev/null; then
102
139
  expected="$(grep " $ARCHIVE\$" "$TMP/checksums.txt" 2>/dev/null | awk '{print $1}' | head -1 || true)"
103
140
  if [[ -n "$expected" ]]; then
104
- if command -v sha256sum >/dev/null 2>&1; then
105
- actual="$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}')"
106
- else
107
- actual="$(shasum -a 256 "$TMP/$ARCHIVE" | awk '{print $1}')"
108
- fi
141
+ actual="$(sha256_of "$TMP/$ARCHIVE")"
109
142
  if [[ "$expected" != "$actual" ]]; then
110
143
  echo "error: checksum mismatch for $ARCHIVE (expected $expected, got $actual)" >&2
111
144
  exit 2
@@ -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 ──
@@ -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
  }
@@ -69,15 +69,19 @@ graph_bootstrap "$REPO" || unavailable
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
  }'