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.
- {webbee-0.7.1 → webbee-0.7.3}/CHANGELOG.md +36 -0
- {webbee-0.7.1 → webbee-0.7.3}/PKG-INFO +3 -3
- {webbee-0.7.1 → webbee-0.7.3}/pyproject.toml +3 -3
- webbee-0.7.3/src/webbee/__init__.py +1 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/query.py +126 -9
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/service.py +131 -31
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tools.py +6 -3
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_any_folder.py +21 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_cache_and_sync_worker.py +1 -1
- webbee-0.7.3/tests/test_intel_folder_1106.py +109 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_query.py +30 -1
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools.py +9 -2
- webbee-0.7.1/src/webbee/__init__.py +0 -1
- {webbee-0.7.1 → webbee-0.7.3}/.github/workflows/publish.yml +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/.gitignore +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/LICENSE +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/README.md +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/install.sh +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/__main__.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/_launch.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/account.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/active_sessions.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/autoupdate.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/banner_art.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/boot.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/browser.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/checkpoints.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/cli.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/coding_context.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/commands.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/config.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/consent.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/details.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/events.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/frames.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/home.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/home_view.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/http.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/instance_lock.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/graph.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/outline.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/tree.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/intel/watch.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/media.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/modals.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/mode_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/native_files.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/native_read.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/output_pane.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/pricing_constants.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/queue_panel.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/reflow.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/remote.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/render.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/repl.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/repo.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/selection.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/session.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/sessions.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/sizing.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/slots.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/steer.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/stream.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tab_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tabs.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/thread.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tier_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/todo_panel.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tokens.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/tui.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/update.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/urlopen.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/wallet.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/src/webbee/worktrees.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/__init__.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/conftest.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_account.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_active_sessions.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_autoupdate.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_checkpoints.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_checkpoints_nested_1030.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_cli.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_clipboard.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_clipboard_read.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_code_map_fresh.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_coding_context.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_commands.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_config.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_cpc_outline.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_cross_loop_token.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_details.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_events.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_freeze_fix.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_home.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_home_view.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_home_view_mouse.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_inspect_page.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_instance_lock.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_checksum_tree.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_chunker.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_embed.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_graph.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_service.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_vectors.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_intel_watch.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_launch.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_marathon.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_modals_e2e.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_mode_and_remote_0_6_8.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_mode_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_native_files.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_newtab_mode.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_packaging.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_probe_cost.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_reflow.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_remote_code_reads.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_render.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_repl.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_repo.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_resilient_http.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_review_fixes_1030.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_session.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_session_review_1030.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_sessions.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_sizing.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_slots.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_steer.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_stream.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tab_bar_stability.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tabs.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_thread.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tier_invalidation.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tier_store.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_todo_panel.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tokens.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools_eol_cancel_1030.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tools_vision_websearch.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tui.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_tui_hardening.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_update.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_urlopen.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_version.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.7.1 → webbee-0.7.3}/tests/test_wallet.py +0 -0
- {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.
|
|
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.
|
|
35
|
-
Requires-Dist: imperal-sdk[universal-index]>=6.
|
|
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.
|
|
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.
|
|
17
|
-
'imperal-sdk[universal-index]>=6.
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
lk = linker(svc)
|
|
33
|
+
links = lk.links()
|
|
34
|
+
items = []
|
|
27
35
|
for name in (symbols or []):
|
|
28
|
-
for
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
834
|
-
# the
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
994
|
-
#
|
|
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
|
|
1003
|
-
|
|
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``
|
|
1023
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
|
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.
|
|
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)]
|