graphify-mesh 0.0.22__tar.gz

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 (185) hide show
  1. graphify_mesh-0.0.22/.github/smoke/end-to-end.sh +338 -0
  2. graphify_mesh-0.0.22/.github/workflows/ci.yml +50 -0
  3. graphify_mesh-0.0.22/.github/workflows/pages.yml +99 -0
  4. graphify_mesh-0.0.22/.github/workflows/release.yml +247 -0
  5. graphify_mesh-0.0.22/.github/workflows/stage-build.yml +94 -0
  6. graphify_mesh-0.0.22/.github/workflows/stage-security.yml +141 -0
  7. graphify_mesh-0.0.22/.github/workflows/stage-smoke.yml +49 -0
  8. graphify_mesh-0.0.22/.github/workflows/stage-tests.yml +117 -0
  9. graphify_mesh-0.0.22/.gitignore +35 -0
  10. graphify_mesh-0.0.22/.gitleaksignore +1 -0
  11. graphify_mesh-0.0.22/.graphifyignore +34 -0
  12. graphify_mesh-0.0.22/.trufflehog-exclude.txt +20 -0
  13. graphify_mesh-0.0.22/CHANGELOG.md +441 -0
  14. graphify_mesh-0.0.22/Dockerfile.test +57 -0
  15. graphify_mesh-0.0.22/LICENSE +21 -0
  16. graphify_mesh-0.0.22/PKG-INFO +219 -0
  17. graphify_mesh-0.0.22/README.md +150 -0
  18. graphify_mesh-0.0.22/docs/_config.yml +1 -0
  19. graphify_mesh-0.0.22/docs/architecture.md +275 -0
  20. graphify_mesh-0.0.22/docs/assets/css/style.scss +442 -0
  21. graphify_mesh-0.0.22/docs/configuration.md +434 -0
  22. graphify_mesh-0.0.22/docs/index.md +55 -0
  23. graphify_mesh-0.0.22/docs/keeping-sync-up-to-date.md +374 -0
  24. graphify_mesh-0.0.22/docs/mcp-server.md +302 -0
  25. graphify_mesh-0.0.22/docs/setup.md +269 -0
  26. graphify_mesh-0.0.22/examples/hooks/graphify-mesh-cwd.py +75 -0
  27. graphify_mesh-0.0.22/examples/manual-relations.example.json +34 -0
  28. graphify_mesh-0.0.22/examples/manual-relations.schema.json +43 -0
  29. graphify_mesh-0.0.22/examples/registry.example.json +31 -0
  30. graphify_mesh-0.0.22/examples/systemd/graphify-mesh-sync.env.example +37 -0
  31. graphify_mesh-0.0.22/examples/systemd/graphify-mesh-sync.service +94 -0
  32. graphify_mesh-0.0.22/examples/systemd/graphify-mesh-sync.timer +21 -0
  33. graphify_mesh-0.0.22/examples/systemd/reap-graphify-serve.service +31 -0
  34. graphify_mesh-0.0.22/examples/systemd/reap-graphify-serve.timer +27 -0
  35. graphify_mesh-0.0.22/pyproject.toml +170 -0
  36. graphify_mesh-0.0.22/src/graphify_mesh/__init__.py +20 -0
  37. graphify_mesh-0.0.22/src/graphify_mesh/_version.py +24 -0
  38. graphify_mesh-0.0.22/src/graphify_mesh/server/__init__.py +61 -0
  39. graphify_mesh-0.0.22/src/graphify_mesh/server/anchors.py +223 -0
  40. graphify_mesh-0.0.22/src/graphify_mesh/server/citation.py +17 -0
  41. graphify_mesh-0.0.22/src/graphify_mesh/server/config.py +195 -0
  42. graphify_mesh-0.0.22/src/graphify_mesh/server/context_pack.py +212 -0
  43. graphify_mesh-0.0.22/src/graphify_mesh/server/embed_query.py +206 -0
  44. graphify_mesh-0.0.22/src/graphify_mesh/server/frames.py +93 -0
  45. graphify_mesh-0.0.22/src/graphify_mesh/server/http_app.py +367 -0
  46. graphify_mesh-0.0.22/src/graphify_mesh/server/lexical_read.py +208 -0
  47. graphify_mesh-0.0.22/src/graphify_mesh/server/project_map.py +92 -0
  48. graphify_mesh-0.0.22/src/graphify_mesh/server/ranking.py +209 -0
  49. graphify_mesh-0.0.22/src/graphify_mesh/server/retrieval.py +343 -0
  50. graphify_mesh-0.0.22/src/graphify_mesh/server/rwlock.py +61 -0
  51. graphify_mesh-0.0.22/src/graphify_mesh/server/scope.py +207 -0
  52. graphify_mesh-0.0.22/src/graphify_mesh/server/sdk_app.py +108 -0
  53. graphify_mesh-0.0.22/src/graphify_mesh/server/server.py +813 -0
  54. graphify_mesh-0.0.22/src/graphify_mesh/server/similar.py +284 -0
  55. graphify_mesh-0.0.22/src/graphify_mesh/server/stdio_guard.py +287 -0
  56. graphify_mesh-0.0.22/src/graphify_mesh/server/store.py +733 -0
  57. graphify_mesh-0.0.22/src/graphify_mesh/server/traverse.py +305 -0
  58. graphify_mesh-0.0.22/src/graphify_mesh/sync/__init__.py +26 -0
  59. graphify_mesh-0.0.22/src/graphify_mesh/sync/backend.py +80 -0
  60. graphify_mesh-0.0.22/src/graphify_mesh/sync/cli.py +249 -0
  61. graphify_mesh-0.0.22/src/graphify_mesh/sync/clustering.py +182 -0
  62. graphify_mesh-0.0.22/src/graphify_mesh/sync/config.py +815 -0
  63. graphify_mesh-0.0.22/src/graphify_mesh/sync/discovery.py +668 -0
  64. graphify_mesh-0.0.22/src/graphify_mesh/sync/embed_similarity.py +516 -0
  65. graphify_mesh-0.0.22/src/graphify_mesh/sync/embedding.py +1372 -0
  66. graphify_mesh-0.0.22/src/graphify_mesh/sync/graphify_cli.py +941 -0
  67. graphify_mesh-0.0.22/src/graphify_mesh/sync/lexical_index.py +293 -0
  68. graphify_mesh-0.0.22/src/graphify_mesh/sync/locking.py +30 -0
  69. graphify_mesh-0.0.22/src/graphify_mesh/sync/naming.py +563 -0
  70. graphify_mesh-0.0.22/src/graphify_mesh/sync/naming_state.py +143 -0
  71. graphify_mesh-0.0.22/src/graphify_mesh/sync/overlay.py +146 -0
  72. graphify_mesh-0.0.22/src/graphify_mesh/sync/overlay_api.py +312 -0
  73. graphify_mesh-0.0.22/src/graphify_mesh/sync/overlay_depends.py +324 -0
  74. graphify_mesh-0.0.22/src/graphify_mesh/sync/overlay_refs.py +143 -0
  75. graphify_mesh-0.0.22/src/graphify_mesh/sync/overlay_similar.py +357 -0
  76. graphify_mesh-0.0.22/src/graphify_mesh/sync/perms.py +116 -0
  77. graphify_mesh-0.0.22/src/graphify_mesh/sync/pipeline.py +1891 -0
  78. graphify_mesh-0.0.22/src/graphify_mesh/sync/progress.py +41 -0
  79. graphify_mesh-0.0.22/src/graphify_mesh/sync/publish.py +374 -0
  80. graphify_mesh-0.0.22/src/graphify_mesh/sync/reaper.py +201 -0
  81. graphify_mesh-0.0.22/src/graphify_mesh/sync/reaper_cli.py +62 -0
  82. graphify_mesh-0.0.22/src/graphify_mesh/sync/registry.py +184 -0
  83. graphify_mesh-0.0.22/src/graphify_mesh/sync/repo_tags.py +299 -0
  84. graphify_mesh-0.0.22/src/graphify_mesh/sync/rss.py +68 -0
  85. graphify_mesh-0.0.22/src/graphify_mesh/sync/source_cache.py +149 -0
  86. graphify_mesh-0.0.22/src/graphify_mesh/sync/state.py +268 -0
  87. graphify_mesh-0.0.22/src/graphify_mesh/sync/sync_project.py +793 -0
  88. graphify_mesh-0.0.22/src/graphify_mesh/sync/tls.py +119 -0
  89. graphify_mesh-0.0.22/src/graphify_mesh/sync/validate.py +277 -0
  90. graphify_mesh-0.0.22/src/graphify_mesh/sync/vectors.py +234 -0
  91. graphify_mesh-0.0.22/tests/compat/test_graphify_contract.py +227 -0
  92. graphify_mesh-0.0.22/tests/fixtures/fake_graphify/graphify +358 -0
  93. graphify_mesh-0.0.22/tests/fixtures/graphs/repo_a.json +14 -0
  94. graphify_mesh-0.0.22/tests/fixtures/graphs/repo_b.json +12 -0
  95. graphify_mesh-0.0.22/tests/fixtures/graphs/repo_forbidden_edge.json +12 -0
  96. graphify_mesh-0.0.22/tests/fixtures/similarity/README.md +80 -0
  97. graphify_mesh-0.0.22/tests/fixtures/similarity/build_corpus.py +526 -0
  98. graphify_mesh-0.0.22/tests/fixtures/similarity/corpus.json +975 -0
  99. graphify_mesh-0.0.22/tests/fixtures/similarity/expected.json +991 -0
  100. graphify_mesh-0.0.22/tests/fixtures/similarity/r1.php-mixed.npy +0 -0
  101. graphify_mesh-0.0.22/tests/fixtures/similarity/r2.php-service.npy +0 -0
  102. graphify_mesh-0.0.22/tests/fixtures/similarity/r3.tsx-kit.npy +0 -0
  103. graphify_mesh-0.0.22/tests/fixtures/similarity/r4.php-app.npy +0 -0
  104. graphify_mesh-0.0.22/tests/fixtures/similarity/r5.php-mixed.npy +0 -0
  105. graphify_mesh-0.0.22/tests/fixtures/similarity/r6.ts-app.npy +0 -0
  106. graphify_mesh-0.0.22/tests/fixtures/similarity/r7.tsx-kit.npy +0 -0
  107. graphify_mesh-0.0.22/tests/hardening/_env_helper.py +30 -0
  108. graphify_mesh-0.0.22/tests/hardening/test_gates.py +279 -0
  109. graphify_mesh-0.0.22/tests/hardening/test_reaper.py +269 -0
  110. graphify_mesh-0.0.22/tests/hardening/test_reaper_cli.py +69 -0
  111. graphify_mesh-0.0.22/tests/hooks/test_cwd_hook.py +160 -0
  112. graphify_mesh-0.0.22/tests/server/conftest.py +358 -0
  113. graphify_mesh-0.0.22/tests/server/test_anchors.py +343 -0
  114. graphify_mesh-0.0.22/tests/server/test_cache_concurrency.py +57 -0
  115. graphify_mesh-0.0.22/tests/server/test_cli.py +57 -0
  116. graphify_mesh-0.0.22/tests/server/test_config_transport.py +150 -0
  117. graphify_mesh-0.0.22/tests/server/test_context_pack.py +86 -0
  118. graphify_mesh-0.0.22/tests/server/test_cross_project_exclude.py +90 -0
  119. graphify_mesh-0.0.22/tests/server/test_disabled_repos.py +299 -0
  120. graphify_mesh-0.0.22/tests/server/test_embed_query.py +156 -0
  121. graphify_mesh-0.0.22/tests/server/test_hardening.py +248 -0
  122. graphify_mesh-0.0.22/tests/server/test_http_app.py +377 -0
  123. graphify_mesh-0.0.22/tests/server/test_http_concurrency.py +189 -0
  124. graphify_mesh-0.0.22/tests/server/test_lexical_read.py +269 -0
  125. graphify_mesh-0.0.22/tests/server/test_list_repos.py +184 -0
  126. graphify_mesh-0.0.22/tests/server/test_project_map.py +85 -0
  127. graphify_mesh-0.0.22/tests/server/test_ranking_fusion.py +223 -0
  128. graphify_mesh-0.0.22/tests/server/test_retrieval_vectors.py +90 -0
  129. graphify_mesh-0.0.22/tests/server/test_rwlock.py +111 -0
  130. graphify_mesh-0.0.22/tests/server/test_scope.py +245 -0
  131. graphify_mesh-0.0.22/tests/server/test_scope_before_ranking.py +63 -0
  132. graphify_mesh-0.0.22/tests/server/test_sdk_app.py +84 -0
  133. graphify_mesh-0.0.22/tests/server/test_sdk_surface.py +84 -0
  134. graphify_mesh-0.0.22/tests/server/test_server.py +300 -0
  135. graphify_mesh-0.0.22/tests/server/test_similar.py +104 -0
  136. graphify_mesh-0.0.22/tests/server/test_stdio_e2e.py +455 -0
  137. graphify_mesh-0.0.22/tests/server/test_stdio_transport.py +428 -0
  138. graphify_mesh-0.0.22/tests/server/test_store.py +717 -0
  139. graphify_mesh-0.0.22/tests/server/test_store_concurrency.py +167 -0
  140. graphify_mesh-0.0.22/tests/server/test_transport_parity.py +93 -0
  141. graphify_mesh-0.0.22/tests/server/test_traverse.py +376 -0
  142. graphify_mesh-0.0.22/tests/server/test_traverse_bounds.py +105 -0
  143. graphify_mesh-0.0.22/tests/sync/conftest.py +192 -0
  144. graphify_mesh-0.0.22/tests/sync/test_ast_only.py +91 -0
  145. graphify_mesh-0.0.22/tests/sync/test_atomic_restore_and_caps.py +147 -0
  146. graphify_mesh-0.0.22/tests/sync/test_backend.py +72 -0
  147. graphify_mesh-0.0.22/tests/sync/test_bootstrap_and_dirty.py +112 -0
  148. graphify_mesh-0.0.22/tests/sync/test_child_home_isolation.py +534 -0
  149. graphify_mesh-0.0.22/tests/sync/test_cli_timeout.py +240 -0
  150. graphify_mesh-0.0.22/tests/sync/test_clustering.py +169 -0
  151. graphify_mesh-0.0.22/tests/sync/test_config_urls.py +114 -0
  152. graphify_mesh-0.0.22/tests/sync/test_discovery.py +140 -0
  153. graphify_mesh-0.0.22/tests/sync/test_discovery_depth.py +602 -0
  154. graphify_mesh-0.0.22/tests/sync/test_discovery_scan_integrity.py +285 -0
  155. graphify_mesh-0.0.22/tests/sync/test_embed_similarity.py +1280 -0
  156. graphify_mesh-0.0.22/tests/sync/test_embedding.py +1508 -0
  157. graphify_mesh-0.0.22/tests/sync/test_extract_concurrency.py +116 -0
  158. graphify_mesh-0.0.22/tests/sync/test_extract_concurrency_settings.py +131 -0
  159. graphify_mesh-0.0.22/tests/sync/test_extract_loop_fixes.py +191 -0
  160. graphify_mesh-0.0.22/tests/sync/test_failure_reason.py +36 -0
  161. graphify_mesh-0.0.22/tests/sync/test_infra_outage.py +1079 -0
  162. graphify_mesh-0.0.22/tests/sync/test_labeling_settings.py +61 -0
  163. graphify_mesh-0.0.22/tests/sync/test_lexical_index.py +153 -0
  164. graphify_mesh-0.0.22/tests/sync/test_locking.py +111 -0
  165. graphify_mesh-0.0.22/tests/sync/test_naming.py +990 -0
  166. graphify_mesh-0.0.22/tests/sync/test_naming_state.py +61 -0
  167. graphify_mesh-0.0.22/tests/sync/test_overlay.py +632 -0
  168. graphify_mesh-0.0.22/tests/sync/test_perms.py +228 -0
  169. graphify_mesh-0.0.22/tests/sync/test_php_builder_and_shrink_check.py +1321 -0
  170. graphify_mesh-0.0.22/tests/sync/test_pipeline_publish_order.py +564 -0
  171. graphify_mesh-0.0.22/tests/sync/test_publish_prune.py +409 -0
  172. graphify_mesh-0.0.22/tests/sync/test_registry.py +58 -0
  173. graphify_mesh-0.0.22/tests/sync/test_repo_tags.py +149 -0
  174. graphify_mesh-0.0.22/tests/sync/test_repo_tags_version_parity.py +213 -0
  175. graphify_mesh-0.0.22/tests/sync/test_rss.py +72 -0
  176. graphify_mesh-0.0.22/tests/sync/test_scan_settings.py +249 -0
  177. graphify_mesh-0.0.22/tests/sync/test_shrink_grant.py +280 -0
  178. graphify_mesh-0.0.22/tests/sync/test_shrink_grant_binding.py +192 -0
  179. graphify_mesh-0.0.22/tests/sync/test_shrink_tolerance.py +114 -0
  180. graphify_mesh-0.0.22/tests/sync/test_staging_sweep.py +107 -0
  181. graphify_mesh-0.0.22/tests/sync/test_state_recovery.py +42 -0
  182. graphify_mesh-0.0.22/tests/sync/test_sync_engine.py +420 -0
  183. graphify_mesh-0.0.22/tests/sync/test_tls.py +81 -0
  184. graphify_mesh-0.0.22/tests/sync/test_validate.py +144 -0
  185. graphify_mesh-0.0.22/tests/sync/test_vectors.py +166 -0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # End-to-end smoke for graphify-mesh: build a two-repo fixture mesh, run the
4
+ # real sync pipeline against the real `graphify` binary, then boot the HTTP MCP
5
+ # server against what it published and talk MCP to it.
6
+ #
7
+ # Nothing here is faked. The unit suite points GRAPHIFY_BIN at
8
+ # tests/fixtures/fake_graphify/graphify, so until this script existed no CI job
9
+ # had ever executed `graphify merge-graphs` for real, nor opened the HTTP
10
+ # transport's socket. Those are exactly the two seams where a working unit suite
11
+ # still ships a broken release.
12
+ #
13
+ # Usage: bash .github/smoke/end-to-end.sh <workdir>
14
+ # The workdir is an argument rather than a mktemp so a developer can run this by
15
+ # hand exactly the way CI runs it and then poke at the tree it left behind.
16
+
17
+ set -euo pipefail
18
+
19
+ WORK="${1:?usage: end-to-end.sh <workdir>}"
20
+ mkdir -p "$WORK"
21
+ WORK="$(cd "$WORK" && pwd)"
22
+
23
+ # Mirror the production topology the unit fixtures use (tests/sync/conftest.py):
24
+ # the mesh root lives *inside* the scan root, as a sibling of the scanned
25
+ # projects. Discovery's approved-root containment check is sensitive to this
26
+ # layout, so a smoke that flattened it would exercise a shape nobody deploys.
27
+ SCAN_ROOT="$WORK/www"
28
+ MESH_ROOT="$SCAN_ROOT/graph-mesh"
29
+ REGISTRY="$MESH_ROOT/bin/registry.json"
30
+
31
+ fail() { echo "::error::$*" >&2; exit 1; }
32
+ step() { echo; echo "=== $* ==="; }
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Fixture
36
+ # ---------------------------------------------------------------------------
37
+ # Each fake project needs real parseable source, not just a canned graph.json:
38
+ # on a repo it has no prior state for, the pipeline picks the `update` action
39
+ # and shells out to `graphify update <root>`, which re-derives the graph from
40
+ # the AST. Source that produced no nodes would trip the shrink guard and the
41
+ # run would refuse the repo instead of merging it.
42
+ make_repo() {
43
+ local name="$1" prefix="$2"
44
+ local root="$SCAN_ROOT/$name"
45
+ local collection="$MESH_ROOT/graphify/smoke/$name"
46
+
47
+ mkdir -p "$root/src" "$collection"
48
+ cat >"$root/src/${prefix}_core.py" <<EOF
49
+ """Fixture module for the ${name} smoke repo."""
50
+
51
+
52
+ class ${prefix^}Service:
53
+ def handle(self, payload):
54
+ return ${prefix}_normalize(payload)
55
+
56
+ def describe(self):
57
+ return "${prefix}"
58
+
59
+
60
+ def ${prefix}_normalize(payload):
61
+ return {k: v for k, v in sorted(payload.items())}
62
+
63
+
64
+ def ${prefix}_entrypoint():
65
+ return ${prefix^}Service().handle({})
66
+ EOF
67
+ cat >"$root/src/${prefix}_support.py" <<EOF
68
+ """Second fixture module so the repo has more than one source file."""
69
+
70
+ from ${prefix}_core import ${prefix^}Service
71
+
72
+
73
+ class ${prefix^}Cache:
74
+ def __init__(self):
75
+ self._store = {}
76
+
77
+ def get(self, key):
78
+ return self._store.get(key)
79
+
80
+
81
+ def ${prefix}_build_service():
82
+ return ${prefix^}Service()
83
+ EOF
84
+
85
+ # The graphify-out symlink is what discovery walks the scan root looking for,
86
+ # and it points at the registry-declared collection path — the project dir
87
+ # never owns its own output directory.
88
+ ln -sfn "$collection" "$root/graphify-out"
89
+
90
+ # Seed a graph so the repo is an `update` (AST-only) and not a `bootstrap`.
91
+ # Bootstrap runs `graphify extract --backend ollama`, and neither the CI
92
+ # runner nor the test container has an Ollama to reach.
93
+ cp "$SEED_GRAPH" "$collection/graph.json"
94
+ }
95
+
96
+ step "Building the fixture mesh under $WORK"
97
+ SEED_GRAPH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/tests/fixtures/graphs/repo_a.json"
98
+ [ -f "$SEED_GRAPH" ] || fail "seed graph fixture not found at $SEED_GRAPH"
99
+
100
+ mkdir -p "$MESH_ROOT/bin"
101
+ make_repo repo-a alpha
102
+ make_repo repo-b beta
103
+
104
+ cat >"$REGISTRY" <<EOF
105
+ {
106
+ "repos": [
107
+ {
108
+ "repo_id": "smoke.repo-a",
109
+ "root": "$SCAN_ROOT/repo-a",
110
+ "collection_path": "$MESH_ROOT/graphify/smoke/repo-a",
111
+ "enabled": true
112
+ },
113
+ {
114
+ "repo_id": "smoke.repo-b",
115
+ "root": "$SCAN_ROOT/repo-b",
116
+ "collection_path": "$MESH_ROOT/graphify/smoke/repo-b",
117
+ "enabled": true
118
+ }
119
+ ],
120
+ "disabled": [],
121
+ "external_roots": []
122
+ }
123
+ EOF
124
+
125
+ # The registry is read by a process that also runs child binaries, so the sync
126
+ # entrypoint audits its permissions and complains about a group/world-writable
127
+ # config. A default umask on a CI runner is fine, but be explicit.
128
+ chmod 600 "$REGISTRY"
129
+
130
+ GLOBAL_DIR="$MESH_ROOT/graphify/global"
131
+ CURRENT="$GLOBAL_DIR/current"
132
+
133
+ # --skip-labeling / --skip-embedding: both stages talk to Ollama, which exists
134
+ # on neither the GitHub runner nor the test container. Everything this smoke
135
+ # asserts on — discovery, per-repo sync, merge, repo-tag remap, overlay,
136
+ # lexical index, validate, publish — runs regardless.
137
+ SYNC_ARGS=(
138
+ --once
139
+ --mesh-root "$MESH_ROOT"
140
+ --scan-root "$SCAN_ROOT"
141
+ --scan-depth 2
142
+ --registry "$REGISTRY"
143
+ --skip-labeling
144
+ --skip-embedding
145
+ )
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Phase 1 — a dry run must write nothing into the mesh root
149
+ # ---------------------------------------------------------------------------
150
+ # Dry-run redirects the publish step (sync/pipeline.py) and the naming
151
+ # workspace (sync/config.py) into an ephemeral staging root. A dry run that
152
+ # leaves a generation behind is a real regression: it means somebody scheduled
153
+ # a "safe" preview that mutated the tree every agent reads from.
154
+ step "Phase 1: dry run"
155
+ graphify-mesh-sync "${SYNC_ARGS[@]}" --dry-run
156
+
157
+ if [ -e "$CURRENT" ]; then
158
+ fail "dry run published a generation: $CURRENT exists"
159
+ fi
160
+ if [ -d "$GLOBAL_DIR/generations" ] && [ -n "$(ls -A "$GLOBAL_DIR/generations")" ]; then
161
+ fail "dry run wrote into $GLOBAL_DIR/generations"
162
+ fi
163
+ echo "OK: no generation published by the dry run"
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Phase 2 — a real run publishes a complete, two-repo generation
167
+ # ---------------------------------------------------------------------------
168
+ step "Phase 2: real run"
169
+ graphify-mesh-sync "${SYNC_ARGS[@]}"
170
+
171
+ [ -L "$CURRENT" ] || fail "no current symlink at $CURRENT"
172
+ GEN="$(readlink -f "$CURRENT")"
173
+ [ -d "$GEN" ] || fail "current symlink does not resolve to a directory: $CURRENT"
174
+ echo "OK: current -> $GEN"
175
+
176
+ # Assert on the published artifacts rather than on the pipeline's own exit
177
+ # code: publish flips the symlink last, so a half-written generation is
178
+ # precisely the failure mode a green exit code would hide.
179
+ python3 - "$GEN" <<'PY'
180
+ import json, sys
181
+ from pathlib import Path
182
+
183
+ gen = Path(sys.argv[1])
184
+ docs = {}
185
+ for name in ("global-graph.json", "cross-project-overlay.json", "lexical-index.json"):
186
+ path = gen / name
187
+ if not path.is_file():
188
+ sys.exit(f"::error::missing published file: {path}")
189
+ try:
190
+ docs[name] = json.loads(path.read_text(encoding="utf-8"))
191
+ except json.JSONDecodeError as err:
192
+ sys.exit(f"::error::{path} is not valid JSON: {err}")
193
+ print(f"OK: {name} exists and parses")
194
+
195
+ # A merge that silently drops one input still produces a valid graph, so the
196
+ # only assertion worth making is per-repo attribution: every fixture repo must
197
+ # own at least one node in the merged structural graph, tagged with the
198
+ # repo_id the registry declared (not graphify's own auto tag, which the remap
199
+ # stage rewrites).
200
+ nodes = docs["global-graph.json"].get("nodes") or []
201
+ seen = {}
202
+ for node in nodes:
203
+ repo = node.get("repo_id") or node.get("repo")
204
+ if repo:
205
+ seen[repo] = seen.get(repo, 0) + 1
206
+ print(f"node count={len(nodes)} repo attribution={seen}")
207
+ missing = [r for r in ("smoke.repo-a", "smoke.repo-b") if seen.get(r, 0) == 0]
208
+ if missing:
209
+ sys.exit(f"::error::merged graph has no nodes for: {', '.join(missing)}")
210
+ print("OK: both fixture repos are represented in global-graph.json")
211
+ PY
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Phase 3 — the HTTP transport serves MCP, and refuses without the token
215
+ # ---------------------------------------------------------------------------
216
+ step "Phase 3: HTTP MCP server"
217
+
218
+ # Never a literal in the file, never echoed: this token is the only thing
219
+ # between the daemon and every other process on the machine.
220
+ GRAPHIFY_MESH_HTTP_TOKEN="$(openssl rand -hex 32)"
221
+ export GRAPHIFY_MESH_HTTP_TOKEN
222
+ HTTP_HOST=127.0.0.1
223
+ HTTP_PORT=19744
224
+ HTTP_PATH=/mcp
225
+ URL="http://${HTTP_HOST}:${HTTP_PORT}${HTTP_PATH}"
226
+
227
+ SERVER_PID=""
228
+ cleanup() {
229
+ if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
230
+ kill "$SERVER_PID" 2>/dev/null || true
231
+ wait "$SERVER_PID" 2>/dev/null || true
232
+ fi
233
+ }
234
+ # A failing assertion below must not leave a listening daemon behind for the
235
+ # rest of the job (or, locally, for the rest of the day).
236
+ trap cleanup EXIT
237
+
238
+ # The server has no --mesh-root/--registry flags; both come from the
239
+ # environment (src/graphify_mesh/server/config.py), and mesh_root otherwise
240
+ # defaults to the current working directory.
241
+ GRAPHIFY_MESH_ROOT="$MESH_ROOT" GRAPHIFY_MESH_REGISTRY="$REGISTRY" \
242
+ graphify-mesh-server \
243
+ --transport http --host "$HTTP_HOST" --port "$HTTP_PORT" --path "$HTTP_PATH" \
244
+ >"$WORK/server.log" 2>&1 &
245
+ SERVER_PID=$!
246
+
247
+ # Wait for the port instead of sleeping a fixed interval, and surface the
248
+ # server's own log if it died during startup (a config error exits immediately).
249
+ for _ in $(seq 1 60); do
250
+ if python3 -c "
251
+ import socket,sys
252
+ s=socket.socket()
253
+ s.settimeout(0.5)
254
+ sys.exit(0 if s.connect_ex(('$HTTP_HOST', $HTTP_PORT))==0 else 1)
255
+ "; then
256
+ break
257
+ fi
258
+ kill -0 "$SERVER_PID" 2>/dev/null || { cat "$WORK/server.log" >&2; fail "server exited during startup"; }
259
+ sleep 0.5
260
+ done
261
+
262
+ # The MCP conversation and the negative check share one script because both
263
+ # need the same headers and the same streamable-HTTP framing. Python's urllib
264
+ # rather than curl: it is already a hard dependency of everything else here,
265
+ # and it can assert on the parsed body instead of on grep.
266
+ python3 - "$URL" <<'PY'
267
+ import json, os, sys, urllib.error, urllib.request
268
+
269
+ url = sys.argv[1]
270
+ token = os.environ["GRAPHIFY_MESH_HTTP_TOKEN"]
271
+ EXPECTED = {"search", "cross_project", "find_similar", "project_map", "neighbors", "context_pack", "list_repos"}
272
+
273
+
274
+ def post(payload, bearer):
275
+ body = json.dumps(payload).encode()
276
+ headers = {
277
+ "Content-Type": "application/json",
278
+ # The streamable-HTTP transport requires the client to declare it can
279
+ # take either framing, even though this server is configured
280
+ # json_response=True and never opens an SSE stream.
281
+ "Accept": "application/json, text/event-stream",
282
+ }
283
+ if bearer is not None:
284
+ headers["Authorization"] = f"Bearer {bearer}"
285
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
286
+ with urllib.request.urlopen(req, timeout=30) as resp:
287
+ return resp.status, resp.read().decode()
288
+
289
+
290
+ def rpc(payload, bearer=token):
291
+ status, text = post(payload, bearer)
292
+ if status != 200:
293
+ sys.exit(f"::error::{payload.get('method')} returned HTTP {status}: {text[:400]}")
294
+ # json_response=True still permits an SSE-shaped body; unwrap it if present
295
+ # so this assertion does not depend on which framing the SDK picked.
296
+ if text.startswith("event:") or text.startswith("data:"):
297
+ text = "\n".join(
298
+ line[len("data:"):].strip()
299
+ for line in text.splitlines()
300
+ if line.startswith("data:")
301
+ )
302
+ return json.loads(text)
303
+
304
+
305
+ init = rpc({
306
+ "jsonrpc": "2.0",
307
+ "id": 1,
308
+ "method": "initialize",
309
+ "params": {
310
+ "protocolVersion": "2025-06-18",
311
+ "capabilities": {},
312
+ "clientInfo": {"name": "graphify-mesh-smoke", "version": "0"},
313
+ },
314
+ })
315
+ if "error" in init:
316
+ sys.exit(f"::error::initialize failed: {init['error']}")
317
+ print("OK: initialize ->", init["result"]["serverInfo"])
318
+
319
+ listed = rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
320
+ if "error" in listed:
321
+ sys.exit(f"::error::tools/list failed: {listed['error']}")
322
+ names = {t["name"] for t in listed["result"]["tools"]}
323
+ print("OK: tools/list ->", sorted(names))
324
+ if names != EXPECTED:
325
+ sys.exit(f"::error::tool set mismatch: missing={EXPECTED - names} unexpected={names - EXPECTED}")
326
+
327
+ # The negative half. A smoke that only ever sends the right token proves the
328
+ # happy path and nothing about the thing the token exists for.
329
+ for label, bearer in (("no token", None), ("wrong token", "x" * 64)):
330
+ try:
331
+ status, text = post({"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}}, bearer)
332
+ except urllib.error.HTTPError as err:
333
+ print(f"OK: {label} refused with HTTP {err.code}")
334
+ continue
335
+ sys.exit(f"::error::{label} was accepted with HTTP {status}: {text[:200]}")
336
+ PY
337
+
338
+ step "All three phases passed"
@@ -0,0 +1,50 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ # The nightly run asks a question no push-triggered run can ask: did the
8
+ # world change while this repository stood still? pip-audit answers it for
9
+ # the dependency tree, since a CVE gets published against code that has not
10
+ # been touched, and the floating-latest lane of graphify-compat answers it
11
+ # for upstream graphifyy, whose next release can break the two coupling
12
+ # points this package imports. Both stay green on the last push and go red
13
+ # on their own schedule.
14
+ schedule:
15
+ - cron: '0 3 * * *'
16
+ # Manual re-run for the same two checks, without waiting for 03:00.
17
+ workflow_dispatch:
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ # A newer push to the same branch or pull request makes the run in flight
23
+ # pointless, so cancel it.
24
+ concurrency:
25
+ group: ci-${{ github.ref }}
26
+ cancel-in-progress: true
27
+
28
+ # One pipeline, four stages, in this order: security, tests, build, smoke. Each
29
+ # stage is a reusable workflow whose jobs run in parallel; the stages themselves
30
+ # are sequential, so a leaked secret stops the run before anything is built and
31
+ # a failing test stops it before anything is published. release.yml calls these
32
+ # same four stage workflows in the same order and adds the release stage on top,
33
+ # so a tag cannot take a shortcut past a check a pull request has to pass.
34
+ jobs:
35
+ security:
36
+ uses: ./.github/workflows/stage-security.yml
37
+
38
+ tests:
39
+ needs: security
40
+ uses: ./.github/workflows/stage-tests.yml
41
+
42
+ # No expected_version on this path: outside a release there is no tag to
43
+ # compare the built wheel against, so the build runs without the version gate.
44
+ build:
45
+ needs: tests
46
+ uses: ./.github/workflows/stage-build.yml
47
+
48
+ smoke:
49
+ needs: build
50
+ uses: ./.github/workflows/stage-smoke.yml
@@ -0,0 +1,99 @@
1
+ name: Pages
2
+
3
+ # The site is two sources joined at deploy time: the Jekyll landing page from
4
+ # docs/ on main, and the PEP 503 index that release.yml stores on the gh-pages
5
+ # branch. A branch-based Pages source can serve only one of them, which is why
6
+ # /simple/ returned 404 while the landing page worked.
7
+ on:
8
+ push:
9
+ branches:
10
+ - main
11
+ paths:
12
+ - "docs/**"
13
+ - ".github/workflows/pages.yml"
14
+ workflow_dispatch:
15
+ # workflow_run uses the default branch ref, unlike workflow_call, which would
16
+ # inherit the release tag and be rejected by the protected Pages environment.
17
+ workflow_run:
18
+ workflows: [Release]
19
+ types: [completed]
20
+
21
+ # Default deny; each job below requests only what it needs.
22
+ permissions: {}
23
+
24
+ # One deployment at a time, and never cancel one in flight: a cancelled deploy
25
+ # can leave the site on a half-finished release. Queued runs still collapse.
26
+ concurrency:
27
+ group: pages
28
+ cancel-in-progress: false
29
+
30
+ jobs:
31
+ build:
32
+ if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
33
+ runs-on: ubuntu-latest
34
+ permissions:
35
+ contents: read
36
+ steps:
37
+ # Always main: a release run starts from a tag ref, but the site shows the
38
+ # docs as they are on main.
39
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
+ with:
41
+ ref: main
42
+ persist-credentials: false
43
+
44
+ - name: Check whether gh-pages exists
45
+ # Same check as release.yml: actions/checkout fails noisily on a ref
46
+ # that does not exist, which is the state before the first release.
47
+ id: gh-pages-check
48
+ run: |
49
+ if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
50
+ echo "exists=true" >> "$GITHUB_OUTPUT"
51
+ else
52
+ echo "exists=false" >> "$GITHUB_OUTPUT"
53
+ fi
54
+
55
+ - name: Check out gh-pages
56
+ if: steps.gh-pages-check.outputs.exists == 'true'
57
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
58
+ with:
59
+ ref: gh-pages
60
+ path: index-branch
61
+ persist-credentials: false
62
+
63
+ - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
64
+
65
+ - name: Build the landing page
66
+ uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
67
+ with:
68
+ source: ./docs
69
+ destination: ./_site
70
+
71
+ - name: Add the simple index
72
+ if: steps.gh-pages-check.outputs.exists == 'true'
73
+ # jekyll-build-pages runs in a container and leaves _site owned by
74
+ # root, so the runner user needs sudo to write into it.
75
+ run: |
76
+ if [ -d index-branch/simple ]; then
77
+ sudo mkdir -p _site/simple
78
+ sudo cp -a index-branch/simple/. _site/simple/
79
+ else
80
+ echo "gh-pages has no simple/ tree; deploying the landing page only"
81
+ fi
82
+
83
+ - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
84
+ with:
85
+ path: ./_site
86
+
87
+ deploy:
88
+ needs: build
89
+ runs-on: ubuntu-latest
90
+ permissions:
91
+ pages: write
92
+ id-token: write
93
+ contents: read
94
+ environment:
95
+ name: github-pages
96
+ url: ${{ steps.deployment.outputs.page_url }}
97
+ steps:
98
+ - id: deployment
99
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1