webbee 0.7.1__tar.gz → 0.7.3__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 (160) hide show
  1. {webbee-0.7.1 → webbee-0.7.3}/CHANGELOG.md +36 -0
  2. {webbee-0.7.1 → webbee-0.7.3}/PKG-INFO +3 -3
  3. {webbee-0.7.1 → webbee-0.7.3}/pyproject.toml +3 -3
  4. webbee-0.7.3/src/webbee/__init__.py +1 -0
  5. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/query.py +126 -9
  6. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/service.py +131 -31
  7. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tools.py +6 -3
  8. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_any_folder.py +21 -0
  9. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_cache_and_sync_worker.py +1 -1
  10. webbee-0.7.3/tests/test_intel_folder_1106.py +109 -0
  11. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_query.py +30 -1
  12. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools.py +9 -2
  13. webbee-0.7.1/src/webbee/__init__.py +0 -1
  14. {webbee-0.7.1 → webbee-0.7.3}/.github/workflows/publish.yml +0 -0
  15. {webbee-0.7.1 → webbee-0.7.3}/.gitignore +0 -0
  16. {webbee-0.7.1 → webbee-0.7.3}/LICENSE +0 -0
  17. {webbee-0.7.1 → webbee-0.7.3}/README.md +0 -0
  18. {webbee-0.7.1 → webbee-0.7.3}/install.sh +0 -0
  19. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/__main__.py +0 -0
  20. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/_launch.py +0 -0
  21. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/account.py +0 -0
  22. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/active_sessions.py +0 -0
  23. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/autoupdate.py +0 -0
  24. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/banner_art.py +0 -0
  25. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/boot.py +0 -0
  26. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/browser.py +0 -0
  27. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/checkpoints.py +0 -0
  28. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/cli.py +0 -0
  29. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard.py +0 -0
  30. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard_read.py +0 -0
  31. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard_session.py +0 -0
  32. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/coding_context.py +0 -0
  33. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/commands.py +0 -0
  34. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/config.py +0 -0
  35. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/consent.py +0 -0
  36. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/details.py +0 -0
  37. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/events.py +0 -0
  38. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/frames.py +0 -0
  39. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/home.py +0 -0
  40. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/home_view.py +0 -0
  41. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/http.py +0 -0
  42. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/instance_lock.py +0 -0
  43. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/__init__.py +0 -0
  44. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/graph.py +0 -0
  45. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/outline.py +0 -0
  46. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/store.py +0 -0
  47. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/tree.py +0 -0
  48. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/vectors.py +0 -0
  49. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/watch.py +0 -0
  50. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/media.py +0 -0
  51. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/modals.py +0 -0
  52. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/mode_store.py +0 -0
  53. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/native_files.py +0 -0
  54. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/native_read.py +0 -0
  55. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/newtab_mode.py +0 -0
  56. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/output_pane.py +0 -0
  57. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/pricing_constants.py +0 -0
  58. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/queue_panel.py +0 -0
  59. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/reflow.py +0 -0
  60. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/remote.py +0 -0
  61. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/render.py +0 -0
  62. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/repl.py +0 -0
  63. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/repo.py +0 -0
  64. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/selection.py +0 -0
  65. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/session.py +0 -0
  66. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/sessions.py +0 -0
  67. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/sizing.py +0 -0
  68. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/slots.py +0 -0
  69. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/steer.py +0 -0
  70. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/stream.py +0 -0
  71. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tab_store.py +0 -0
  72. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tabs.py +0 -0
  73. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/thread.py +0 -0
  74. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tier_store.py +0 -0
  75. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/todo_panel.py +0 -0
  76. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tokens.py +0 -0
  77. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tui.py +0 -0
  78. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/update.py +0 -0
  79. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/urlopen.py +0 -0
  80. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/wallet.py +0 -0
  81. {webbee-0.7.1 → webbee-0.7.3}/src/webbee/worktrees.py +0 -0
  82. {webbee-0.7.1 → webbee-0.7.3}/tests/__init__.py +0 -0
  83. {webbee-0.7.1 → webbee-0.7.3}/tests/conftest.py +0 -0
  84. {webbee-0.7.1 → webbee-0.7.3}/tests/test_account.py +0 -0
  85. {webbee-0.7.1 → webbee-0.7.3}/tests/test_active_sessions.py +0 -0
  86. {webbee-0.7.1 → webbee-0.7.3}/tests/test_audit_2026_07_25_findings.py +0 -0
  87. {webbee-0.7.1 → webbee-0.7.3}/tests/test_autoupdate.py +0 -0
  88. {webbee-0.7.1 → webbee-0.7.3}/tests/test_checkpoints.py +0 -0
  89. {webbee-0.7.1 → webbee-0.7.3}/tests/test_checkpoints_nested_1030.py +0 -0
  90. {webbee-0.7.1 → webbee-0.7.3}/tests/test_cli.py +0 -0
  91. {webbee-0.7.1 → webbee-0.7.3}/tests/test_clipboard.py +0 -0
  92. {webbee-0.7.1 → webbee-0.7.3}/tests/test_clipboard_read.py +0 -0
  93. {webbee-0.7.1 → webbee-0.7.3}/tests/test_code_map_fresh.py +0 -0
  94. {webbee-0.7.1 → webbee-0.7.3}/tests/test_coding_context.py +0 -0
  95. {webbee-0.7.1 → webbee-0.7.3}/tests/test_commands.py +0 -0
  96. {webbee-0.7.1 → webbee-0.7.3}/tests/test_config.py +0 -0
  97. {webbee-0.7.1 → webbee-0.7.3}/tests/test_cpc_contract_stable.py +0 -0
  98. {webbee-0.7.1 → webbee-0.7.3}/tests/test_cpc_outline.py +0 -0
  99. {webbee-0.7.1 → webbee-0.7.3}/tests/test_cross_loop_token.py +0 -0
  100. {webbee-0.7.1 → webbee-0.7.3}/tests/test_details.py +0 -0
  101. {webbee-0.7.1 → webbee-0.7.3}/tests/test_events.py +0 -0
  102. {webbee-0.7.1 → webbee-0.7.3}/tests/test_freeze_fix.py +0 -0
  103. {webbee-0.7.1 → webbee-0.7.3}/tests/test_home.py +0 -0
  104. {webbee-0.7.1 → webbee-0.7.3}/tests/test_home_view.py +0 -0
  105. {webbee-0.7.1 → webbee-0.7.3}/tests/test_home_view_mouse.py +0 -0
  106. {webbee-0.7.1 → webbee-0.7.3}/tests/test_inspect_page.py +0 -0
  107. {webbee-0.7.1 → webbee-0.7.3}/tests/test_instance_lock.py +0 -0
  108. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_checksum_tree.py +0 -0
  109. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_chunker.py +0 -0
  110. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_embed.py +0 -0
  111. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_graph.py +0 -0
  112. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_no_numpy.py +0 -0
  113. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_service.py +0 -0
  114. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_store.py +0 -0
  115. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_vectors.py +0 -0
  116. {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_watch.py +0 -0
  117. {webbee-0.7.1 → webbee-0.7.3}/tests/test_launch.py +0 -0
  118. {webbee-0.7.1 → webbee-0.7.3}/tests/test_marathon.py +0 -0
  119. {webbee-0.7.1 → webbee-0.7.3}/tests/test_modals_e2e.py +0 -0
  120. {webbee-0.7.1 → webbee-0.7.3}/tests/test_mode_and_remote_0_6_8.py +0 -0
  121. {webbee-0.7.1 → webbee-0.7.3}/tests/test_mode_store.py +0 -0
  122. {webbee-0.7.1 → webbee-0.7.3}/tests/test_native_files.py +0 -0
  123. {webbee-0.7.1 → webbee-0.7.3}/tests/test_newtab_mode.py +0 -0
  124. {webbee-0.7.1 → webbee-0.7.3}/tests/test_packaging.py +0 -0
  125. {webbee-0.7.1 → webbee-0.7.3}/tests/test_probe_cost.py +0 -0
  126. {webbee-0.7.1 → webbee-0.7.3}/tests/test_queue_manage_0337.py +0 -0
  127. {webbee-0.7.1 → webbee-0.7.3}/tests/test_reflow.py +0 -0
  128. {webbee-0.7.1 → webbee-0.7.3}/tests/test_remote_code_reads.py +0 -0
  129. {webbee-0.7.1 → webbee-0.7.3}/tests/test_render.py +0 -0
  130. {webbee-0.7.1 → webbee-0.7.3}/tests/test_repl.py +0 -0
  131. {webbee-0.7.1 → webbee-0.7.3}/tests/test_repo.py +0 -0
  132. {webbee-0.7.1 → webbee-0.7.3}/tests/test_resilient_http.py +0 -0
  133. {webbee-0.7.1 → webbee-0.7.3}/tests/test_review_fixes_1030.py +0 -0
  134. {webbee-0.7.1 → webbee-0.7.3}/tests/test_session.py +0 -0
  135. {webbee-0.7.1 → webbee-0.7.3}/tests/test_session_review_1030.py +0 -0
  136. {webbee-0.7.1 → webbee-0.7.3}/tests/test_sessions.py +0 -0
  137. {webbee-0.7.1 → webbee-0.7.3}/tests/test_sizing.py +0 -0
  138. {webbee-0.7.1 → webbee-0.7.3}/tests/test_slots.py +0 -0
  139. {webbee-0.7.1 → webbee-0.7.3}/tests/test_steer.py +0 -0
  140. {webbee-0.7.1 → webbee-0.7.3}/tests/test_stream.py +0 -0
  141. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tab_bar_stability.py +0 -0
  142. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tab_restore_0337.py +0 -0
  143. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tabs.py +0 -0
  144. {webbee-0.7.1 → webbee-0.7.3}/tests/test_terminal_ux_0336.py +0 -0
  145. {webbee-0.7.1 → webbee-0.7.3}/tests/test_thread.py +0 -0
  146. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tier_invalidation.py +0 -0
  147. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tier_store.py +0 -0
  148. {webbee-0.7.1 → webbee-0.7.3}/tests/test_todo_panel.py +0 -0
  149. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tokens.py +0 -0
  150. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools_eol_cancel_1030.py +0 -0
  151. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools_vision_websearch.py +0 -0
  152. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tui.py +0 -0
  153. {webbee-0.7.1 → webbee-0.7.3}/tests/test_tui_hardening.py +0 -0
  154. {webbee-0.7.1 → webbee-0.7.3}/tests/test_update.py +0 -0
  155. {webbee-0.7.1 → webbee-0.7.3}/tests/test_update_badge_0340.py +0 -0
  156. {webbee-0.7.1 → webbee-0.7.3}/tests/test_urlopen.py +0 -0
  157. {webbee-0.7.1 → webbee-0.7.3}/tests/test_version.py +0 -0
  158. {webbee-0.7.1 → webbee-0.7.3}/tests/test_version_badge_corner.py +0 -0
  159. {webbee-0.7.1 → webbee-0.7.3}/tests/test_wallet.py +0 -0
  160. {webbee-0.7.1 → webbee-0.7.3}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3
4
+
5
+ * **A folder of repositories is a folder** (second 06.11 report).
6
+ * Repositories nested deeper (`core/<repo>`, `modules/<repo>`) are found, up to four levels.
7
+ * Each repository syncs on its own, even when the folder's top holds loose scripts or a manifest. Before, the
8
+ whole folder was one 670-file project.
9
+ * Imports are resolved inside each repository first; a package another repository provides is linked to it.
10
+ * **The cloud graph answers from the first minute.**
11
+ * While the session's first sync runs, the vault answers with the keys it confirmed last time, and the answer
12
+ says so.
13
+ * "pending" ends with that first run. Before, every tool call queued a sync and the kernel always met one running.
14
+ * **Facts, not names.**
15
+ * The `read_file` header lists only the files whose references resolve to the file (it said "used by
16
+ check-paths-agree.py, +50 more").
17
+ * `graph_slice` callers are the real calls, with line and caller.
18
+ * `impact_of_change` says in one sentence what it found, including name-only matches as unproven.
19
+ * Python members resolve through typed locals, `self` attributes and return annotations.
20
+ * Needs imperal-mcp 0.9.3 and imperal-sdk 6.5.1.
21
+
22
+ ## 0.7.2
23
+
24
+ * **The code graph answers correctly** (06.11 report):
25
+ * `impact_of_change` lists the files that really use a symbol, with the line, the user and the depth. A
26
+ function nobody calls had answered 481 unrelated files.
27
+ * Files that only share the name are listed apart under `by_name`.
28
+ * An unknown symbol is reported as such, never as "0 users".
29
+ * **Every major language is linked**:
30
+ * Go, Rust, Java, Kotlin, C#, PHP, Ruby;
31
+ * TS/JS monorepos (workspace packages, tsconfig `extends`, default exports, CommonJS, `.mts/.cts`);
32
+ * Vue, Svelte and Astro components;
33
+ * members through typed fields, constructors and return types.
34
+ * **Projects of every ecosystem** are recognised (`Cargo.toml`, `go.mod`, `pom.xml`, `*.csproj`, `composer.json`,
35
+ `Gemfile` ...), locally and on servers.
36
+ * `.gitignore` rules apply in folders without git and on machines without a git binary (Windows).
37
+ * Needs imperal-mcp 0.9.2 and imperal-sdk 6.5.0.
38
+
3
39
  ## 0.7.1
4
40
 
5
41
  * **A missing code index says why**: when the index did not start (a failure while building it, or it is turned off),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -31,8 +31,8 @@ Requires-Python: >=3.7
31
31
  Requires-Dist: asyncssh>=2.14; python_version >= '3.10'
32
32
  Requires-Dist: httpx-sse>=0.4; python_version >= '3.10'
33
33
  Requires-Dist: httpx>=0.27; python_version >= '3.10'
34
- Requires-Dist: imperal-mcp>=0.9.1; python_version >= '3.10'
35
- Requires-Dist: imperal-sdk[universal-index]>=6.4.1; python_version >= '3.10'
34
+ Requires-Dist: imperal-mcp>=0.9.3; python_version >= '3.10'
35
+ Requires-Dist: imperal-sdk[universal-index]>=6.5.1; python_version >= '3.10'
36
36
  Requires-Dist: model2vec>=0.8.2; python_version >= '3.10' and (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')
37
37
  Requires-Dist: numpy>=2; python_version >= '3.10' and (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')
38
38
  Requires-Dist: prompt-toolkit>=3; python_version >= '3.10'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.7.1"
3
+ version = "0.7.3"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -13,8 +13,8 @@ dependencies = [
13
13
  # release on a Python 3.12 it sets up once with uv -- `uv` comes as a wheel on 3.8+,
14
14
  # 3.7 fetches it with uv's own installer.
15
15
  'uv>=0.4; python_version >= "3.8" and python_version < "3.10"',
16
- 'imperal-mcp>=0.9.1; python_version >= "3.10"', # raised to the newest at each release
17
- 'imperal-sdk[universal-index]>=6.4.1; python_version >= "3.10"',
16
+ 'imperal-mcp>=0.9.3; python_version >= "3.10"', # raised to the newest at each release
17
+ 'imperal-sdk[universal-index]>=6.5.1; python_version >= "3.10"',
18
18
  'asyncssh>=2.14; python_version >= "3.10"',
19
19
  'httpx>=0.27; python_version >= "3.10"',
20
20
  'httpx-sse>=0.4; python_version >= "3.10"',
@@ -0,0 +1 @@
1
+ __version__ = "0.7.3"
@@ -14,22 +14,42 @@ def _sym_item(s) -> dict:
14
14
 
15
15
  def repo_profile(svc) -> dict:
16
16
  p = svc.repo_profile()
17
- item = {"id": p["repo_key"], "title": f"repo ({p['file_count']} files)", "kind": "repo", **p}
17
+ repos = p.get("repos") or []
18
+ title = (f"folder ({p['file_count']} files, {len(repos)} repositories: {', '.join(repos[:12])}"
19
+ + (" ..." if len(repos) > 12 else "") + ")") if repos else f"repo ({p['file_count']} files)"
20
+ item = {"id": p["repo_key"], "title": title, "kind": "folder" if repos else "repo", **p}
18
21
  return _env([item])
19
22
 
20
23
 
21
24
  def graph_slice(svc, symbols, depth: int = 1) -> dict:
25
+ """Each definition a symbol names, with its RESOLVED callers (file, line, the calling definition) and callees
26
+ (what its body's references resolve to) -- the vault's rules (``imperal_sdk.code_links``). Before, the callers
27
+ were every definition of every file where the name appeared (report 06.11 #2)."""
22
28
  if svc is None or svc.graph is None:
23
29
  return {"ok": False, "content": "index not ready"}
24
30
  if isinstance(symbols, str):
25
31
  symbols = [symbols]
26
- g, items = svc.graph, []
32
+ lk = linker(svc)
33
+ links = lk.links()
34
+ items = []
27
35
  for name in (symbols or []):
28
- for s in g.symbol_table.get(name, []):
29
- it = _sym_item(s)
30
- it["callers"] = [c.name for c in g.callers_of(name)][:20]
31
- it["callees"] = g.callees_of(s)[:20]
36
+ for path, qual in lk.symbol_targets(str(name)):
37
+ if not qual:
38
+ continue
39
+ d = lk.defs(path).get(qual)
40
+ it = {"id": f"{path}::{qual}", "title": qual, "kind": getattr(d, "kind", ""),
41
+ "subtitle": f"{path}:{getattr(d, 'start_line', 0)}", "signature": getattr(d, "signature", ""),
42
+ "path": path, "start_line": getattr(d, "start_line", 0), "end_line": getattr(d, "end_line", 0)}
43
+ callers = [{"path": src, "user": user or "(module)", "line": line, "kind": kind}
44
+ for src, user, kind, line, tp, tq, _o in links
45
+ if tp == path and (tq == qual or tq.startswith(qual + ".")) and not (src == path and user == qual)]
46
+ it["callers"] = callers[:20]
47
+ it["callers_total"] = len(callers)
48
+ it["callees"] = sorted({f"{tp}::{tq}" if tq else tp for src, user, _k, _l, tp, tq, _o in links
49
+ if src == path and (user == qual or user.startswith(qual + "."))})[:20]
32
50
  items.append(it)
51
+ if not items:
52
+ return {**_env([]), "note": f"{', '.join(map(str, symbols or []))}: not a definition in this index"}
33
53
  return _env(items)
34
54
 
35
55
 
@@ -186,14 +206,111 @@ def contract_slice(svc, query: str = "", kind: str = "all", k: int = 50) -> dict
186
206
  return _env(items[:limit], has_more=len(items) > limit)
187
207
 
188
208
 
209
+ # The local answer of ``impact_of_change`` computes links the way the vault's graph does (imperal_sdk.code_links,
210
+ # 2026-11-06): before, a file "depended" on a symbol when the bare NAME appeared in it, and each depth step pulled in
211
+ # every definition of every hit file -- a function nobody calls answered 481 files (06.11 report).
212
+ # which files carry scope facts and which are resolution configs: the SDK's one answer (Python, TS/JS, Vue/Svelte/Astro,
213
+ # Go, Rust, Java, Kotlin, C#, PHP, Ruby; go.mod, Cargo.toml, tsconfig, package.json ...) -- never a list of our own
214
+ _FACTS: dict = {} # (root, path, content_hash) -> BlobFacts | None: a file parsed once per content
215
+ _FACTS_MAX = 60000
216
+ _READ_MAX = 2_000_000
217
+
218
+
219
+ def _read(root: str, rel: str):
220
+ import os
221
+ try:
222
+ full = os.path.join(root, rel)
223
+ if os.path.getsize(full) > _READ_MAX:
224
+ return None
225
+ with open(full, encoding="utf-8", errors="replace") as fh:
226
+ return fh.read()
227
+ except OSError:
228
+ return None
229
+
230
+
231
+ def _is_config(rel: str) -> bool:
232
+ from imperal_sdk.code_index import is_resolution_config
233
+ return is_resolution_config(rel)
234
+
235
+
236
+ def linker(svc):
237
+ """The repo's resolver (``imperal_sdk.code_links.Linker``) for the index's current graph -- built once per graph
238
+ (a rebuilt graph builds it again), each file parsed once per content."""
239
+ import os
240
+ g = svc.graph
241
+ if getattr(svc, "_linker_graph", None) is g and getattr(svc, "_linker", None) is not None:
242
+ return svc._linker
243
+ from imperal_sdk import code_index as ci
244
+ from imperal_sdk.code_links import Linker
245
+ root = str(getattr(svc, "root", "") or "")
246
+ if len(_FACTS) > _FACTS_MAX:
247
+ _FACTS.clear()
248
+ facts: dict = {}
249
+ for path, fi in list(svc.index.files.items()):
250
+ if not ci.has_scope_facts(path):
251
+ continue
252
+ key = (root, path, getattr(fi, "content_hash", ""))
253
+ if key not in _FACTS:
254
+ text = _read(root, path) if root else None
255
+ _FACTS[key] = ci.scope_facts(path, text) if text is not None else None
256
+ facts[path] = _FACTS[key]
257
+ configs = []
258
+ cands = {p for p in svc.index.files if _is_config(p)}
259
+ if root:
260
+ cands |= {n for n in os.listdir(root) if _is_config(n)} if os.path.isdir(root) else set()
261
+ for rel in sorted(cands):
262
+ text = _read(root, rel) if root else None
263
+ if text is None:
264
+ continue
265
+ try:
266
+ kind, data = ci.parse_config(rel, text)
267
+ except Exception:
268
+ continue
269
+ configs.append({"path": rel.replace("\\", "/"), "kind": kind, "data": data})
270
+ roots = []
271
+ if root:
272
+ try:
273
+ from webbee.intel.service import is_repository, sub_repositories
274
+ roots = [] if is_repository(root) else sub_repositories(root)
275
+ except Exception:
276
+ roots = []
277
+ lk = Linker(facts, configs, roots)
278
+ svc._linker, svc._linker_graph = lk, g
279
+ return lk
280
+
281
+
189
282
  def impact_of_change(svc, symbols) -> dict:
283
+ """Files that use ``symbols`` (resolved links: line, user, depth through the enclosing definition), code before
284
+ tests; ``by_name`` -- files that only share a name (a method on a local, a language without scope facts), marked
285
+ and bounded; ``resolved`` -- the definitions found (none: "not a definition in this index", never "0 users")."""
190
286
  if svc is None or svc.graph is None:
191
287
  return {"ok": False, "content": "index not ready"}
192
288
  if isinstance(symbols, str):
193
289
  symbols = [symbols]
194
- files = svc.graph.dependents_of(list(symbols or []), depth=3)
195
- items = [{"id": f, "title": f, "kind": "file"} for f in sorted(files)]
196
- return _env(items)
290
+ from imperal_sdk.code_index import has_scope_facts
291
+ from imperal_sdk.code_links import BY_NAME_SHOWN, impact, is_test_path
292
+ syms = [str(s) for s in (symbols or []) if str(s).strip()]
293
+ out = impact(linker(svc), syms, depth=2)
294
+ # languages without scope facts: their uses are known by name only -- listed with the other by-name files
295
+ names = {s.split("::")[-1].split(":")[-1].rsplit(".", 1)[-1] for s in syms}
296
+ known = {b["path"] for b in out["by_name"]} | {i["id"] for i in out["data"]["items"]}
297
+ extra = []
298
+ for path, fi in svc.index.files.items():
299
+ if has_scope_facts(path) or path in known:
300
+ continue
301
+ if names & set(getattr(fi, "refs", ()) or ()):
302
+ extra.append({"path": path, "line": 0, "name": "", "group": "tests" if is_test_path(path) else "code",
303
+ "note": "no scope facts for this language: by name"})
304
+ if extra:
305
+ out["by_name_count"] = int(out.get("by_name_count") or 0) + len(extra)
306
+ out["by_name"] = sorted(out["by_name"] + extra, key=lambda b: (b["group"] == "tests", b["path"]))[:BY_NAME_SHOWN]
307
+ from imperal_sdk.code_links import impact_summary
308
+ out["summary"] = impact_summary(syms, out["resolved"], len(out["data"]["items"]), int(out.get("by_name_count") or 0),
309
+ len(out.get("same_file") or []))
310
+ if not out["resolved"]:
311
+ out["note"] = f"{', '.join(syms)}: not a definition in this index"
312
+ out["link"] = "resolved locally (the vault's rules); by_name lists name-only matches apart"
313
+ return out
197
314
 
198
315
 
199
316
  def orient(svc, q) -> dict:
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import asyncio
8
8
  import hashlib
9
9
  import logging
10
+ import json
10
11
  import os
11
12
  import subprocess
12
13
  import sys
@@ -171,10 +172,22 @@ def _index_listing(root: str, focus: str | None = None, limit: int = UNIT_MAX_FI
171
172
  skipped = {"placeholder": 0, "over_cap": 0}
172
173
  excluded = {str(e).strip("/") for e in exclude if str(e).strip("/")}
173
174
  listed = _git_listed(root)
175
+ # no git answer (a folder that is no repository, the repositories inside it, a machine without git -- Windows,
176
+ # a minimal Linux): every .gitignore on the way applies, read here (06.11 report: none applied, ~19.6k dump and
177
+ # screenshot files were indexed). The same parser as the SFTP listing (imperal_sdk.code_index.gitignore_rules).
178
+ from imperal_sdk.code_index import gitignore_rules, gitignored
179
+ rules: list | None = None if listed is not None else []
174
180
  for dp, dns, fns in os.walk(root):
175
181
  here = os.path.relpath(dp, root).replace(os.sep, "/")
182
+ if rules is not None and ".gitignore" in fns:
183
+ try:
184
+ with open(os.path.join(dp, ".gitignore"), encoding="utf-8", errors="replace") as fh:
185
+ rules = rules + gitignore_rules(fh.read(), "" if here == "." else here)
186
+ except OSError:
187
+ pass
176
188
  dns[:] = sorted(d for d in dns if not skipped_dir(dp, d)
177
- and (d if here == "." else f"{here}/{d}") not in excluded)
189
+ and (d if here == "." else f"{here}/{d}") not in excluded
190
+ and not (rules and gitignored(rules, d if here == "." else f"{here}/{d}", True)))
178
191
  for fn in sorted(fns):
179
192
  rel = fn if here == "." else f"{here}/{fn}"
180
193
  if is_placeholder(rel):
@@ -182,6 +195,8 @@ def _index_listing(root: str, focus: str | None = None, limit: int = UNIT_MAX_FI
182
195
  continue
183
196
  if listed is not None and rel not in listed:
184
197
  continue
198
+ if rules and gitignored(rules, rel, False):
199
+ continue
185
200
  if kind_of(rel, root=root) is None:
186
201
  continue
187
202
  try:
@@ -740,9 +755,10 @@ class IntelService:
740
755
  if rels is not None: # a change: only the repositories it touched, with their own paths
741
756
  by_repo: dict = {}
742
757
  for rel in rels:
743
- head, _, tail = str(rel).replace(os.sep, "/").partition("/")
744
- if head in subs and tail:
745
- by_repo.setdefault(head, []).append(tail)
758
+ rel = str(rel).replace(os.sep, "/")
759
+ head = repo_of(rel, subs)
760
+ if head and rel != head:
761
+ by_repo.setdefault(head, []).append(rel[len(head) + 1:])
746
762
  todo = {name: by_repo[name] for name in by_repo}
747
763
  else:
748
764
  todo = {name: None for name in subs}
@@ -752,6 +768,8 @@ class IntelService:
752
768
  if out.get("repo_key"):
753
769
  repos[name] = str(out["repo_key"])
754
770
  out = {"repo_key": "", "repos": repos}
771
+ if repos and rels is None:
772
+ remember_keys(self.root, out)
755
773
  if repos:
756
774
  self.last_sync = out
757
775
  self.last_sync_ok_at = time.time()
@@ -801,6 +819,8 @@ class IntelService:
801
819
  return out
802
820
  measured = co_change_pairs(root) if rels is None and history else None
803
821
  out = asyncio.run(vault_sync(factory(), meta, listing, read, measured))
822
+ if out.get("repo_key") and rels is None and os.path.realpath(root) == os.path.realpath(self.root):
823
+ remember_keys(root, out)
804
824
  if sig and out.get("repo_key"):
805
825
  self._synced_sigs[root] = (sig, str(out["repo_key"]), time.time())
806
826
  elif rels is not None:
@@ -830,10 +850,16 @@ class IntelService:
830
850
  extract + embed, minutes) it answers at once ``{pending}`` -- a state, not a failure. ``wait_s``: tests only."""
831
851
  if self.vault_client_factory is None:
832
852
  return {"ok": False, "content": "this session cannot reach the cloud"}
833
- if not self.last_sync and self._sync_busy():
834
- # the repo's first sync (a whole repo: extract + embed) is still running: answered at once, as a state
835
- return {"pending": "the first code sync of this workspace is still running -- the local index answers "
836
- "until it ends"}
853
+ if self._sync_done == 0 and self._sync_busy():
854
+ # the session's FIRST sync is still running (a whole repo: extract + embed): answered at once, as a state
855
+ # -- with the keys the vault confirmed for this folder last time, so the cloud graph answers meanwhile
856
+ # (marked by its age). Only the first run: after it, each answer waits for its own sync (report 06.11 #2:
857
+ # every tool call queued a sync and every kernel ask met one running -- "pending" for a whole session)
858
+ known = remembered_keys(self.root)
859
+ return {"pending": "the first code sync of this workspace is still running -- "
860
+ + ("the cloud graph answers from the last confirmed sync"
861
+ if known else "the local index answers until it ends"),
862
+ **({"known": known} if known else {})}
837
863
  ticket = self._sync_request(None, history=False)
838
864
  with self._sync_cv:
839
865
  done = self._sync_cv.wait_for(lambda: self._sync_done >= ticket, timeout=wait_s)
@@ -990,42 +1016,116 @@ class IntelService:
990
1016
 
991
1017
  # ── code graph everywhere (plan 3 Tasks 7, 9): the local workspace in the vault's one graph ─────────
992
1018
 
993
- # what makes a folder a project for the code graph: version control or a manifest of an indexable language at its root
994
- # (the sdk's config names; Python and JS/TS are what the index reads)
995
- _MANIFESTS = tuple(indexer.CONFIG_NAMES) + ("setup.py", "requirements.txt", "tsconfig.json")
1019
+ # what makes a folder a project for the code graph: version control or any ecosystem's manifest at its root -- the
1020
+ # sdk's one rule (``indexer.is_project_root``: Cargo.toml, go.mod, pom.xml, *.csproj, composer.json, Gemfile, ...)
996
1021
 
997
1022
 
998
1023
  SUB_REPOS_MAX = 40 # a folder of projects: at most this many of its repositories are synced
1024
+ REPO_DEPTH = 4 # repositories are looked for this deep (~/Projects/<org>/<repo>, a folder's core/<repo>)
1025
+ _REPOS_TTL_S = 30.0 # a folder's repositories, remembered this long (is_repository asks on every tool call)
1026
+ _REPOS_SEEN: dict = {}
1027
+ # a home folder's system trees: never walked for repositories (macOS Library / Applications, Volumes ...)
1028
+ _TOP_NEVER = frozenset({"Library", "Applications", "System", "Volumes", "private", "cores", "dev"})
1029
+
1030
+
1031
+ def find_repositories(root: str, depth: int = REPO_DEPTH) -> list:
1032
+ """The repositories inside ``root`` (folders holding ``.git``: a directory, or a file in a worktree), as
1033
+ root-relative posix paths, at most ``depth`` levels down -- never under a hidden, skipped or linked folder, never
1034
+ inside another repository (report 06.11 #2: core/wpanel-sdk, modules/wpanel-tickets were one level too deep and
1035
+ the whole folder became one project). The ONE rule for Webbee and imperal-mcp."""
1036
+ key = (os.path.realpath(root), int(depth))
1037
+ hit = _REPOS_SEEN.get(key)
1038
+ if hit and time.monotonic() - hit[0] < _REPOS_TTL_S:
1039
+ return list(hit[1])
1040
+ out: list = []
1041
+ frontier = [""]
1042
+ for _ in range(max(1, int(depth))):
1043
+ nxt: list = []
1044
+ for rel in frontier:
1045
+ here = os.path.join(root, rel) if rel else root
1046
+ try:
1047
+ names = sorted(os.listdir(here))
1048
+ except OSError:
1049
+ continue
1050
+ for name in names:
1051
+ full = os.path.join(here, name)
1052
+ if (not rel and name in _TOP_NEVER) or os.path.islink(full) or not os.path.isdir(full) \
1053
+ or skipped_dir(here, name):
1054
+ continue
1055
+ child = f"{rel}/{name}" if rel else name
1056
+ (out if os.path.exists(os.path.join(full, ".git")) else nxt).append(child)
1057
+ frontier = nxt
1058
+ _REPOS_SEEN[key] = (time.monotonic(), tuple(out))
1059
+ return out
999
1060
 
1000
1061
 
1001
1062
  def sub_repositories(root: str) -> list:
1002
- """The repositories directly inside a folder that is none itself: sub-folders with ``.git`` (never a link, never a
1003
- hidden folder; a manifest alone does not make a sub-folder a project here), sorted, bounded."""
1004
- try:
1005
- names = sorted(os.listdir(root))
1006
- except OSError:
1007
- return []
1008
- out = []
1009
- for name in names:
1010
- full = os.path.join(root, name)
1011
- if name.startswith(".") or os.path.islink(full) or not os.path.isdir(full):
1012
- continue
1013
- try:
1014
- if ".git" in os.listdir(full):
1015
- out.append(name)
1016
- except OSError:
1017
- continue
1018
- return out[:SUB_REPOS_MAX]
1063
+ """The repositories inside a folder that is none itself (``find_repositories``), sorted, bounded."""
1064
+ return find_repositories(root)[:SUB_REPOS_MAX]
1019
1065
 
1020
1066
 
1021
1067
  def is_repository(root: str) -> bool:
1022
- """A repository the code graph may hold: ``.git`` (a dir, or a file in a worktree) or a project manifest at the
1023
- root. A home folder or a folder of projects is none -- its repositories are synced one by one (owner 2026-10-25)."""
1068
+ """A repository the code graph may hold as ONE: ``.git`` at the root; without it, a folder holding repositories
1069
+ is a folder of them (each synced on its own -- even with a manifest or loose scripts at the top, report 06.11
1070
+ #2); otherwise any ecosystem's manifest makes it a project without version control. A home folder or a folder
1071
+ of projects is none (owner 2026-10-25)."""
1024
1072
  try:
1025
1073
  names = set(os.listdir(root))
1026
1074
  except OSError:
1027
1075
  return False
1028
- return ".git" in names or any(m in names for m in _MANIFESTS)
1076
+ if ".git" in names:
1077
+ return True
1078
+ if find_repositories(root):
1079
+ return False
1080
+ return indexer.is_project_root(names)
1081
+
1082
+
1083
+ def repo_of(rel: str, repos) -> str:
1084
+ """The repository of a folder-relative path: the longest repository path it lies under, or ""."""
1085
+ rel = str(rel).replace(os.sep, "/").lstrip("./")
1086
+ best = ""
1087
+ for r in repos or ():
1088
+ if (rel == r or rel.startswith(r + "/")) and len(r) > len(best):
1089
+ best = r
1090
+ return best
1091
+
1092
+
1093
+ # the vault's keys of the repositories this machine last synced (a new session answers from the cloud graph while
1094
+ # its own first sync still runs -- report 06.11 #2: a whole session ran on the local index)
1095
+ _KEYS_FILE = os.path.join(os.path.expanduser("~"), ".local", "share", "webbee", "vault-keys.json")
1096
+
1097
+
1098
+ def remembered_keys(root: str) -> dict:
1099
+ """``{repo_key | repos, synced_at}`` the vault last confirmed for ``root`` on this machine, or {}."""
1100
+ try:
1101
+ with open(_KEYS_FILE, encoding="utf-8") as f:
1102
+ got = json.load(f).get(os.path.realpath(root)) or {}
1103
+ return got if isinstance(got, dict) and (got.get("repo_key") or got.get("repos")) else {}
1104
+ except (OSError, ValueError, AttributeError):
1105
+ return {}
1106
+
1107
+
1108
+ def remember_keys(root: str, out: dict) -> None:
1109
+ keys = {k: out[k] for k in ("repo_key", "repos") if out.get(k)}
1110
+ if not keys:
1111
+ return
1112
+ try:
1113
+ try:
1114
+ with open(_KEYS_FILE, encoding="utf-8") as f:
1115
+ data = json.load(f)
1116
+ data = data if isinstance(data, dict) else {}
1117
+ except (OSError, ValueError):
1118
+ data = {}
1119
+ data[os.path.realpath(root)] = {**keys, "synced_at": time.time()}
1120
+ if len(data) > 200: # bounded: the newest roots
1121
+ data = dict(sorted(data.items(), key=lambda kv: -float((kv[1] or {}).get("synced_at") or 0))[:200])
1122
+ os.makedirs(os.path.dirname(_KEYS_FILE), exist_ok=True)
1123
+ tmp = _KEYS_FILE + ".tmp"
1124
+ with open(tmp, "w", encoding="utf-8") as f:
1125
+ json.dump(data, f)
1126
+ os.replace(tmp, _KEYS_FILE)
1127
+ except OSError:
1128
+ pass
1029
1129
 
1030
1130
 
1031
1131
  _MACHINE_ID_FILE = os.path.join(os.path.expanduser("~"), ".local", "share", "webbee", "machine-id")
@@ -551,8 +551,11 @@ class LocalToolExecutor:
551
551
  parts.append("defines " + ", ".join(names[:2]) + extra)
552
552
  graph = getattr(self.indexer, "graph", None)
553
553
  if graph is not None and fi.symbols:
554
- deps = sorted(graph.dependents_of({s.name for s in fi.symbols},
555
- depth=1) - {rel})
554
+ # the files whose references RESOLVE to this file (the vault's rules) -- never a file that only
555
+ # shares a name with one of its definitions (report 06.11 #2: "used by check-paths-agree.py, +50")
556
+ from webbee.intel.query import linker
557
+ deps = sorted({src for src, _u, _k, _l, dst, _q, _o in linker(self.indexer).links()
558
+ if dst == rel and src != rel})
556
559
  if deps:
557
560
  extra = f" +{len(deps) - 3} more" if len(deps) > 3 else ""
558
561
  parts.append("↔ used by " + ", ".join(deps[:3]) + extra)
@@ -1370,7 +1373,7 @@ async def _no_repo_at(sftp, ci, root: str) -> str:
1370
1373
  names = {e.filename for e in await sftp.readdir(repo_root)}
1371
1374
  except Exception:
1372
1375
  names = set()
1373
- if not names & (set(getattr(ci, "_REPO_MARKERS", (".git",))) | set(getattr(ci, "_MANIFESTS", ()))):
1376
+ if not ci.is_project_root(names):
1374
1377
  return f"{root} is in no repository (no .git or project manifest at it or above)"
1375
1378
  return ""
1376
1379
 
@@ -95,3 +95,24 @@ def test_a_secret_written_later_is_never_read_and_the_profile_names_no_document(
95
95
  assert "credentials.json" not in svc.index.files and not any("credentials.json" in o for o in opened)
96
96
  prof = svc.repo_profile()
97
97
  assert not any("Agreement" in s for s in prof["top_symbols"]) and prof["other_kinds"]["document"] >= 2
98
+
99
+
100
+
101
+ def test_gitignore_applies_without_git(tmp_path, monkeypatch):
102
+ """06.11 report: in a folder without git (or on a machine without the git binary) no .gitignore applied."""
103
+ from webbee.intel import service
104
+ (tmp_path / ".gitignore").write_text("dumps/\n*.png\n")
105
+ (tmp_path / "dumps").mkdir()
106
+ (tmp_path / "dumps" / "big.sql").write_text("x")
107
+ (tmp_path / "shot.png").write_bytes(b"\x89PNG")
108
+ (tmp_path / "app.py").write_text("x = 1\n")
109
+ (tmp_path / "sub").mkdir()
110
+ (tmp_path / "sub" / ".gitignore").write_text("local.py\n")
111
+ (tmp_path / "sub" / "local.py").write_text("y = 1\n")
112
+ (tmp_path / "sub" / "keep.py").write_text("z = 1\n")
113
+ rels, _stats, _skipped = service._index_listing(str(tmp_path))
114
+ assert "app.py" in rels and "sub/keep.py" in rels
115
+ assert not {"dumps/big.sql", "shot.png", "sub/local.py"} & set(rels)
116
+ # the same with git present but failing (no binary): the rules still apply
117
+ monkeypatch.setattr(service, "_git_listed", lambda root: None)
118
+ assert "sub/local.py" not in service._index_listing(str(tmp_path))[0]
@@ -306,7 +306,7 @@ def test_sync_now_waits_for_the_real_sync_and_answers_with_its_confirmation(tmp_
306
306
  kernel's request waits for its own sync to end and answers with what the vault took -- never "busy"."""
307
307
  import threading
308
308
  svc, slow = _service_with_slow_sync(tmp_path, monkeypatch)
309
- svc.last_sync = {"repo_key": "k"} # not the first sync
309
+ svc._sync_taken = svc._sync_done = 1 # not the first sync: a run already ended
310
310
  svc.sync_vault(None)
311
311
  assert slow.started.wait(5)
312
312
  got = {}
@@ -0,0 +1,109 @@
1
+ """Report 06.11 #2: a folder whose repositories sit one level deeper (core/<repo>, modules/<repo>) with loose
2
+ scripts at its top was one 670-file project; the first sync answered "pending" for a whole session; the read
3
+ header and graph_slice linked files by a shared name."""
4
+ import os
5
+ import subprocess
6
+ import threading
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from webbee.intel import service as sv
12
+ from webbee.intel.service import IntelService, find_repositories, is_repository, repo_of, sub_repositories
13
+
14
+
15
+ def _repo(path):
16
+ os.makedirs(path, exist_ok=True)
17
+ os.makedirs(os.path.join(path, ".git"), exist_ok=True)
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _fresh(monkeypatch, tmp_path):
22
+ monkeypatch.setattr(sv, "_REPOS_SEEN", {})
23
+ monkeypatch.setattr(sv, "_KEYS_FILE", str(tmp_path / "keys" / "vault-keys.json"))
24
+
25
+
26
+ def test_repositories_one_level_deeper_are_found_and_make_a_folder(tmp_path):
27
+ for r in ("core/wpanel-sdk", "modules/wpanel-tickets", "gateway"):
28
+ _repo(tmp_path / r)
29
+ _repo(tmp_path / "core/wpanel-sdk/vendor/inner") # inside a repository: never its own unit
30
+ (tmp_path / "requirements.txt").write_text("x\n") # a manifest and loose scripts at the top
31
+ (tmp_path / "check-paths-agree.py").write_text("print(1)\n")
32
+ assert find_repositories(str(tmp_path)) == ["gateway", "core/wpanel-sdk", "modules/wpanel-tickets"]
33
+ assert not is_repository(str(tmp_path)) # a folder of repositories, not one project
34
+ assert sorted(sub_repositories(str(tmp_path))) == ["core/wpanel-sdk", "gateway", "modules/wpanel-tickets"]
35
+ assert repo_of("core/wpanel-sdk/src/a.py", sub_repositories(str(tmp_path))) == "core/wpanel-sdk"
36
+ assert repo_of("check-paths-agree.py", sub_repositories(str(tmp_path))) == ""
37
+
38
+
39
+ def test_a_manifest_without_repositories_inside_is_a_project(tmp_path):
40
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
41
+ assert is_repository(str(tmp_path))
42
+ _repo(tmp_path / "solo")
43
+ assert is_repository(str(tmp_path / "solo"))
44
+
45
+
46
+ class _Slow:
47
+ def __init__(self, out=None):
48
+ self.started, self.release, self.calls, self.out = threading.Event(), threading.Event(), [], out
49
+
50
+ def __call__(self, rels=None, history=True):
51
+ self.calls.append((rels, history))
52
+ self.started.set()
53
+ self.release.wait(10)
54
+ return self.out if self.out is not None else {}
55
+
56
+
57
+ def _svc(tmp_path, monkeypatch, out=None):
58
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
59
+ svc.vault_client_factory = lambda: object()
60
+ slow = _Slow(out)
61
+ monkeypatch.setattr(svc, "_sync_vault", slow)
62
+ return svc, slow
63
+
64
+
65
+ def test_the_first_sync_running_answers_with_the_keys_confirmed_last_time(tmp_path, monkeypatch):
66
+ sv.remember_keys(str(tmp_path), {"repo_key": "", "repos": {"gateway": "k1", "core/wpanel-sdk": "k2"}})
67
+ svc, slow = _svc(tmp_path, monkeypatch)
68
+ svc.sync_vault(None)
69
+ assert slow.started.wait(5)
70
+ got = svc.sync_now()
71
+ assert "pending" in got and got["known"]["repos"] == {"gateway": "k1", "core/wpanel-sdk": "k2"}
72
+ assert "cloud graph answers" in got["pending"]
73
+ slow.release.set()
74
+
75
+
76
+ def test_pending_ends_with_the_first_run_even_when_it_failed(tmp_path, monkeypatch):
77
+ """Every tool call queued a sync; the kernel's ask always met one running and 'pending' never ended."""
78
+ svc, slow = _svc(tmp_path, monkeypatch, out={"repo_key": "", "partial_reason": "no repository here"})
79
+ slow.release.set()
80
+ svc.sync_vault(None)
81
+ deadline = time.time() + 5
82
+ while svc._sync_done == 0:
83
+ assert time.time() < deadline
84
+ time.sleep(0.01)
85
+ slow.release.clear()
86
+ svc.sync_vault(None) # the next tool call's sync is running
87
+ assert slow.started.wait(5)
88
+ got = {}
89
+ th = threading.Thread(target=lambda: got.update(svc.sync_now()))
90
+ th.start()
91
+ time.sleep(0.2)
92
+ assert th.is_alive() and not got # waits for a real outcome, never "pending" again
93
+ slow.release.set()
94
+ th.join(10)
95
+ assert got.get("ok") is False and "no repository here" in got["content"]
96
+
97
+
98
+ def test_graph_slice_callers_are_resolved_users(tmp_path):
99
+ pytest.importorskip("tree_sitter_python")
100
+ (tmp_path / "lib.py").write_text("def session_for_sid(sid):\n return sid\n")
101
+ (tmp_path / "api.py").write_text("from lib import session_for_sid\n\n\ndef me(sid):\n"
102
+ " return session_for_sid(sid)\n")
103
+ (tmp_path / "other.py").write_text("def unrelated():\n return 'session_for_sid'\n")
104
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=False)
105
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
106
+ svc.build()
107
+ from webbee.intel.query import graph_slice
108
+ item = graph_slice(svc, ["session_for_sid"])["data"]["items"][0]
109
+ assert [(c["path"], c["user"], c["line"]) for c in item["callers"]] == [("api.py", "me", 5)]