evo-cli 0.14.0__tar.gz → 0.14.2__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.
- {evo_cli-0.14.0 → evo_cli-0.14.2}/PKG-INFO +58 -1
- {evo_cli-0.14.0 → evo_cli-0.14.2}/README.md +57 -0
- evo_cli-0.14.2/evo_cli/VERSION +1 -0
- evo_cli-0.14.2/evo_cli/commands/harness/__init__.py +29 -0
- evo_cli-0.14.2/evo_cli/commands/harness/_dag.py +407 -0
- evo_cli-0.14.2/evo_cli/commands/harness/_git.py +210 -0
- evo_cli-0.14.2/evo_cli/commands/harness/_model.py +274 -0
- evo_cli-0.14.2/evo_cli/commands/harness/_mutate.py +95 -0
- evo_cli-0.14.0/evo_cli/commands/harness.py → evo_cli-0.14.2/evo_cli/commands/harness/_paths.py +62 -93
- evo_cli-0.14.2/evo_cli/commands/harness/_render.py +89 -0
- evo_cli-0.14.2/evo_cli/commands/harness/_server.py +165 -0
- evo_cli-0.14.2/evo_cli/commands/harness/check.py +86 -0
- evo_cli-0.14.2/evo_cli/commands/harness/edit.py +161 -0
- evo_cli-0.14.2/evo_cli/commands/harness/pull.py +74 -0
- evo_cli-0.14.2/evo_cli/commands/harness/serve.py +47 -0
- evo_cli-0.14.2/evo_cli/commands/harness/show.py +65 -0
- evo_cli-0.14.2/evo_cli/commands/harness/views.py +155 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/index-BpeLRwRd.js +242 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/index-CiUsumT3.css +1 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- evo_cli-0.14.2/evo_cli/commands/harness/web/index.html +14 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/PKG-INFO +58 -1
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/SOURCES.txt +30 -1
- {evo_cli-0.14.0 → evo_cli-0.14.2}/pyproject.toml +1 -1
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_harness.py +12 -8
- evo_cli-0.14.2/tests/test_harness_dag.py +193 -0
- evo_cli-0.14.0/evo_cli/VERSION +0 -1
- {evo_cli-0.14.0 → evo_cli-0.14.2}/Containerfile +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/HISTORY.md +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/LICENSE +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/MANIFEST.in +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/__init__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/__main__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/base.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/cli.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/console.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/setup.cfg +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/__init__.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_claude_code.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_cli.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_console.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_cred.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_download.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_gh.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_mcp.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_opencode.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_plantuml.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_sysmon.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_tts.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_update.py +0 -0
- {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.2
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -234,3 +234,60 @@ to disk and the command warns.
|
|
|
234
234
|
|
|
235
235
|
The same engine backs the `evo-tts` MCP server in
|
|
236
236
|
[agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
|
|
237
|
+
|
|
238
|
+
## `evo harness` - read a repo cluster
|
|
239
|
+
|
|
240
|
+
A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
|
|
241
|
+
declares the seams between them (who owns what, who consumes it, what verifies it), and `plans/`
|
|
242
|
+
holds exec-plans for changes that span several repos at once.
|
|
243
|
+
|
|
244
|
+
`evo harness` finds that repo by walking up from the current directory to a `harness.yaml`, falling
|
|
245
|
+
back to `~/.claude/harness/registry.json`, so every command works with no arguments from anywhere
|
|
246
|
+
inside the cluster. Pass `--harness PATH` to override.
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
evo harness serve # dashboard on http://127.0.0.1:8788
|
|
250
|
+
evo harness repos # repos in the manifest, with branch and dirty state
|
|
251
|
+
evo harness seams # contract seams; --graph for the owner -> consumer DAG
|
|
252
|
+
evo harness plans # progress across every exec-plan
|
|
253
|
+
evo harness show <plan>
|
|
254
|
+
evo harness check <plan> [--fetch] # what the plan claims vs what git says
|
|
255
|
+
evo harness graph <plan>:steps # the same DAG as an adjacency list
|
|
256
|
+
evo harness pull # fast-forward every repo
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### The dashboard
|
|
260
|
+
|
|
261
|
+
`serve` draws every dependency in the cluster as a DAG: seam ownership between repos, repo merge
|
|
262
|
+
order inside a plan, and step order built from `depends_on` / `depends_on_step` / `blocked_by` /
|
|
263
|
+
`blocks`. It detects cycles (Tarjan) and reports them, because a cycle means no merge order
|
|
264
|
+
satisfies every seam - which is exactly the thing a plan gets wrong silently.
|
|
265
|
+
|
|
266
|
+
Every graph has a **table view** beside it. A node-link diagram conveys nothing to a screen reader
|
|
267
|
+
and cannot be pasted into a document, so the adjacency list is a first-class view rather than a
|
|
268
|
+
fallback, and the terminal gets the same data from `evo harness graph`.
|
|
269
|
+
|
|
270
|
+
The dashboard is **read-only**. Plan YAML is folded block scalars and hand-written prose; re-emitting
|
|
271
|
+
it with a YAML dumper would destroy the formatting. Writes go through the CLI, which splices the one
|
|
272
|
+
line it needs and refuses to save if the reparsed file is not exactly the intended change:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
|
|
276
|
+
evo harness debt <plan> 0 fixed
|
|
277
|
+
evo harness question <plan> 1 answered
|
|
278
|
+
evo harness repo <plan> 2 merged
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
|
|
282
|
+
already brings.
|
|
283
|
+
|
|
284
|
+
### Building the dashboard bundle
|
|
285
|
+
|
|
286
|
+
Release wheels ship the built bundle, so users never need Node. Only changing the UI does:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
cd web && npm install && npm run build
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
That writes `evo_cli/commands/harness/web/`, which is committed. `npm run dev` serves the UI on
|
|
293
|
+
:5178 and proxies `/api` to a running `evo harness serve` for a live-reload loop.
|
|
@@ -206,3 +206,60 @@ to disk and the command warns.
|
|
|
206
206
|
|
|
207
207
|
The same engine backs the `evo-tts` MCP server in
|
|
208
208
|
[agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
|
|
209
|
+
|
|
210
|
+
## `evo harness` - read a repo cluster
|
|
211
|
+
|
|
212
|
+
A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
|
|
213
|
+
declares the seams between them (who owns what, who consumes it, what verifies it), and `plans/`
|
|
214
|
+
holds exec-plans for changes that span several repos at once.
|
|
215
|
+
|
|
216
|
+
`evo harness` finds that repo by walking up from the current directory to a `harness.yaml`, falling
|
|
217
|
+
back to `~/.claude/harness/registry.json`, so every command works with no arguments from anywhere
|
|
218
|
+
inside the cluster. Pass `--harness PATH` to override.
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
evo harness serve # dashboard on http://127.0.0.1:8788
|
|
222
|
+
evo harness repos # repos in the manifest, with branch and dirty state
|
|
223
|
+
evo harness seams # contract seams; --graph for the owner -> consumer DAG
|
|
224
|
+
evo harness plans # progress across every exec-plan
|
|
225
|
+
evo harness show <plan>
|
|
226
|
+
evo harness check <plan> [--fetch] # what the plan claims vs what git says
|
|
227
|
+
evo harness graph <plan>:steps # the same DAG as an adjacency list
|
|
228
|
+
evo harness pull # fast-forward every repo
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### The dashboard
|
|
232
|
+
|
|
233
|
+
`serve` draws every dependency in the cluster as a DAG: seam ownership between repos, repo merge
|
|
234
|
+
order inside a plan, and step order built from `depends_on` / `depends_on_step` / `blocked_by` /
|
|
235
|
+
`blocks`. It detects cycles (Tarjan) and reports them, because a cycle means no merge order
|
|
236
|
+
satisfies every seam - which is exactly the thing a plan gets wrong silently.
|
|
237
|
+
|
|
238
|
+
Every graph has a **table view** beside it. A node-link diagram conveys nothing to a screen reader
|
|
239
|
+
and cannot be pasted into a document, so the adjacency list is a first-class view rather than a
|
|
240
|
+
fallback, and the terminal gets the same data from `evo harness graph`.
|
|
241
|
+
|
|
242
|
+
The dashboard is **read-only**. Plan YAML is folded block scalars and hand-written prose; re-emitting
|
|
243
|
+
it with a YAML dumper would destroy the formatting. Writes go through the CLI, which splices the one
|
|
244
|
+
line it needs and refuses to save if the reparsed file is not exactly the intended change:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
|
|
248
|
+
evo harness debt <plan> 0 fixed
|
|
249
|
+
evo harness question <plan> 1 answered
|
|
250
|
+
evo harness repo <plan> 2 merged
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
|
|
254
|
+
already brings.
|
|
255
|
+
|
|
256
|
+
### Building the dashboard bundle
|
|
257
|
+
|
|
258
|
+
Release wheels ship the built bundle, so users never need Node. Only changing the UI does:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
cd web && npm install && npm run build
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
That writes `evo_cli/commands/harness/web/`, which is committed. `npm run dev` serves the UI on
|
|
265
|
+
:5178 and proxies `/api` to a running `evo harness serve` for a live-reload loop.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.14.2
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
from evo_cli.commands.harness.check import check
|
|
4
|
+
from evo_cli.commands.harness.edit import debt, question, repo, step
|
|
5
|
+
from evo_cli.commands.harness.pull import pull
|
|
6
|
+
from evo_cli.commands.harness.serve import serve
|
|
7
|
+
from evo_cli.commands.harness.show import show
|
|
8
|
+
from evo_cli.commands.harness.views import graph, plans, repos, seams
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group("harness")
|
|
12
|
+
def harness_group():
|
|
13
|
+
"""Read and track a repo cluster: its manifest, its contract seams, its exec-plans.
|
|
14
|
+
|
|
15
|
+
\b
|
|
16
|
+
evo harness serve dashboard with every dependency drawn as a DAG
|
|
17
|
+
evo harness repos repos in the manifest, with git state
|
|
18
|
+
evo harness seams contract seams: owner, consumers, verify command
|
|
19
|
+
evo harness plans progress across every exec-plan
|
|
20
|
+
evo harness show <plan> read one plan
|
|
21
|
+
evo harness graph <plan> print a DAG as an adjacency list
|
|
22
|
+
evo harness check <plan> check what the plan claims against real git
|
|
23
|
+
evo harness step <plan> 3 done mark a step
|
|
24
|
+
evo harness pull fast-forward every repo
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, pull):
|
|
29
|
+
harness_group.add_command(command)
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from evo_cli.commands.harness._model import Plan, cluster, load_seams, tone_of
|
|
6
|
+
|
|
7
|
+
CLUSTER_REPO = "__cluster__"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _strongly_connected(nodes: list[str], edges: list[tuple[str, str]]) -> list[list[str]]:
|
|
11
|
+
"""Tarjan. Returns components of size > 1 plus any self-loop, i.e. every real cycle."""
|
|
12
|
+
adjacency: dict[str, list[str]] = {n: [] for n in nodes}
|
|
13
|
+
for source, target in edges:
|
|
14
|
+
if source in adjacency and target in adjacency:
|
|
15
|
+
adjacency[source].append(target)
|
|
16
|
+
|
|
17
|
+
index_of: dict[str, int] = {}
|
|
18
|
+
low: dict[str, int] = {}
|
|
19
|
+
on_stack: dict[str, bool] = {}
|
|
20
|
+
stack: list[str] = []
|
|
21
|
+
counter = [0]
|
|
22
|
+
found: list[list[str]] = []
|
|
23
|
+
|
|
24
|
+
def visit(root: str) -> None:
|
|
25
|
+
work = [(root, 0)]
|
|
26
|
+
while work:
|
|
27
|
+
node, child = work[-1]
|
|
28
|
+
if child == 0:
|
|
29
|
+
index_of[node] = low[node] = counter[0]
|
|
30
|
+
counter[0] += 1
|
|
31
|
+
stack.append(node)
|
|
32
|
+
on_stack[node] = True
|
|
33
|
+
recursed = False
|
|
34
|
+
neighbours = adjacency[node]
|
|
35
|
+
while child < len(neighbours):
|
|
36
|
+
nxt = neighbours[child]
|
|
37
|
+
child += 1
|
|
38
|
+
if nxt not in index_of:
|
|
39
|
+
work[-1] = (node, child)
|
|
40
|
+
work.append((nxt, 0))
|
|
41
|
+
recursed = True
|
|
42
|
+
break
|
|
43
|
+
if on_stack.get(nxt):
|
|
44
|
+
low[node] = min(low[node], index_of[nxt])
|
|
45
|
+
if recursed:
|
|
46
|
+
continue
|
|
47
|
+
work[-1] = (node, child)
|
|
48
|
+
if low[node] == index_of[node]:
|
|
49
|
+
component = []
|
|
50
|
+
while True:
|
|
51
|
+
top = stack.pop()
|
|
52
|
+
on_stack[top] = False
|
|
53
|
+
component.append(top)
|
|
54
|
+
if top == node:
|
|
55
|
+
break
|
|
56
|
+
if len(component) > 1 or (node, node) in edges:
|
|
57
|
+
found.append(sorted(component))
|
|
58
|
+
work.pop()
|
|
59
|
+
if work:
|
|
60
|
+
parent = work[-1][0]
|
|
61
|
+
low[parent] = min(low[parent], low[node])
|
|
62
|
+
|
|
63
|
+
for node in nodes:
|
|
64
|
+
if node not in index_of:
|
|
65
|
+
visit(node)
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rank(nodes: list[str], edges: list[tuple[str, str]], cyclic: set[str]) -> dict[str, int]:
|
|
70
|
+
"""Longest-path layering over the acyclic part. Nodes inside a cycle share their entry rank."""
|
|
71
|
+
live = [(s, t) for s, t in edges if s not in cyclic or t not in cyclic]
|
|
72
|
+
incoming: dict[str, int] = {n: 0 for n in nodes}
|
|
73
|
+
outgoing: dict[str, list[str]] = {n: [] for n in nodes}
|
|
74
|
+
for source, target in live:
|
|
75
|
+
if source in outgoing and target in incoming:
|
|
76
|
+
outgoing[source].append(target)
|
|
77
|
+
incoming[target] += 1
|
|
78
|
+
rank = {n: 0 for n in nodes}
|
|
79
|
+
queue = [n for n in nodes if incoming[n] == 0]
|
|
80
|
+
seen = 0
|
|
81
|
+
while queue:
|
|
82
|
+
node = queue.pop(0)
|
|
83
|
+
seen += 1
|
|
84
|
+
for target in outgoing[node]:
|
|
85
|
+
rank[target] = max(rank[target], rank[node] + 1)
|
|
86
|
+
incoming[target] -= 1
|
|
87
|
+
if incoming[target] == 0:
|
|
88
|
+
queue.append(target)
|
|
89
|
+
return rank
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _finish(nodes: list[dict], edges: list[dict], warnings: list[dict]) -> dict:
|
|
93
|
+
ids = [n["id"] for n in nodes]
|
|
94
|
+
pairs = [(e["source"], e["target"]) for e in edges]
|
|
95
|
+
cycles = _strongly_connected(ids, pairs)
|
|
96
|
+
cyclic = {n for component in cycles for n in component}
|
|
97
|
+
ranks = _rank(ids, pairs, cyclic)
|
|
98
|
+
|
|
99
|
+
for node in nodes:
|
|
100
|
+
node["rank"] = ranks.get(node["id"], 0)
|
|
101
|
+
node["inCycle"] = node["id"] in cyclic
|
|
102
|
+
for edge in edges:
|
|
103
|
+
both = edge["source"] in cyclic and edge["target"] in cyclic
|
|
104
|
+
edge["inCycle"] = both and any(
|
|
105
|
+
edge["source"] in component and edge["target"] in component for component in cycles
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if cycles:
|
|
109
|
+
for component in cycles:
|
|
110
|
+
warnings.append(
|
|
111
|
+
{
|
|
112
|
+
"level": "warn",
|
|
113
|
+
"text": f"Cycle between {' -> '.join(component)} -> {component[0]}. Merge order is undefined here.",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
"nodes": nodes,
|
|
118
|
+
"edges": edges,
|
|
119
|
+
"cycles": cycles,
|
|
120
|
+
"acyclic": not cycles,
|
|
121
|
+
"warnings": warnings,
|
|
122
|
+
"depth": (max(ranks.values()) + 1) if ranks else 0,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def seam_graph(manifest_path: Path) -> dict:
|
|
127
|
+
info = cluster(manifest_path)
|
|
128
|
+
seams = load_seams(manifest_path)
|
|
129
|
+
known = {r["name"]: r for r in info["repos"]}
|
|
130
|
+
|
|
131
|
+
order: list[str] = []
|
|
132
|
+
for seam in seams:
|
|
133
|
+
for name in [seam["owner"], *seam["consumers"]]:
|
|
134
|
+
if name and name not in order:
|
|
135
|
+
order.append(name)
|
|
136
|
+
for name in known:
|
|
137
|
+
if name not in order:
|
|
138
|
+
order.append(name)
|
|
139
|
+
|
|
140
|
+
owned: dict[str, int] = {}
|
|
141
|
+
consumed: dict[str, int] = {}
|
|
142
|
+
for seam in seams:
|
|
143
|
+
owned[seam["owner"]] = owned.get(seam["owner"], 0) + 1
|
|
144
|
+
for name in seam["consumers"]:
|
|
145
|
+
consumed[name] = consumed.get(name, 0) + 1
|
|
146
|
+
|
|
147
|
+
nodes = []
|
|
148
|
+
for name in order:
|
|
149
|
+
repo = known.get(name)
|
|
150
|
+
nodes.append(
|
|
151
|
+
{
|
|
152
|
+
"id": name,
|
|
153
|
+
"label": name,
|
|
154
|
+
"kind": "repo" if repo else "external",
|
|
155
|
+
"tone": "idle" if repo and repo["present"] else ("bad" if repo else "warn"),
|
|
156
|
+
"meta": {
|
|
157
|
+
"role": repo["role"] if repo else "outside the manifest",
|
|
158
|
+
"path": repo["path"] if repo else "",
|
|
159
|
+
"present": bool(repo and repo["present"]),
|
|
160
|
+
"note": repo["note"] if repo else "",
|
|
161
|
+
"owns": owned.get(name, 0),
|
|
162
|
+
"consumes": consumed.get(name, 0),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
edges = []
|
|
168
|
+
for seam in seams:
|
|
169
|
+
for consumer in seam["consumers"]:
|
|
170
|
+
if consumer == seam["owner"]:
|
|
171
|
+
continue
|
|
172
|
+
edges.append(
|
|
173
|
+
{
|
|
174
|
+
"id": f"{seam['name']}:{seam['owner']}->{consumer}",
|
|
175
|
+
"source": seam["owner"],
|
|
176
|
+
"target": consumer,
|
|
177
|
+
"label": seam["name"],
|
|
178
|
+
"kind": seam["kind"],
|
|
179
|
+
# Blocking is a stronger contract, not a fault, so it reads as a solid line
|
|
180
|
+
# rather than a red one. Red stays reserved for something actually wrong.
|
|
181
|
+
"tone": "idle",
|
|
182
|
+
"dashed": not seam["blocking"],
|
|
183
|
+
"meta": {
|
|
184
|
+
"seam": seam["name"],
|
|
185
|
+
"blocking": seam["blocking"],
|
|
186
|
+
"verify": seam["verify"],
|
|
187
|
+
"source": seam["source"],
|
|
188
|
+
"mirrors": seam["mirrors"],
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
warnings = []
|
|
194
|
+
for seam in seams:
|
|
195
|
+
if seam["owner"] and seam["owner"] not in known:
|
|
196
|
+
warnings.append(
|
|
197
|
+
{
|
|
198
|
+
"level": "warn",
|
|
199
|
+
"text": f"Seam {seam['name']!r} is owned by {seam['owner']!r}, absent from harness.yaml.",
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
if not seam["verify"]:
|
|
203
|
+
warnings.append({"level": "warn", "text": f"Seam {seam['name']!r} declares no verify command."})
|
|
204
|
+
return _finish(nodes, edges, warnings)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def plan_repo_graph(plan: Plan) -> dict:
|
|
208
|
+
entries = plan.items("repos")
|
|
209
|
+
names = [str(e.get("repo") or f"repo-{i}") for i, e in enumerate(entries)]
|
|
210
|
+
|
|
211
|
+
nodes = []
|
|
212
|
+
for index, entry in enumerate(entries):
|
|
213
|
+
status = str(entry.get("status") or "")
|
|
214
|
+
nodes.append(
|
|
215
|
+
{
|
|
216
|
+
"id": names[index],
|
|
217
|
+
"label": names[index],
|
|
218
|
+
"kind": "repo",
|
|
219
|
+
"tone": tone_of("repos", status),
|
|
220
|
+
"meta": {
|
|
221
|
+
"index": index,
|
|
222
|
+
"status": status,
|
|
223
|
+
"order": entry.get("order"),
|
|
224
|
+
"branch": str(entry.get("branch") or ""),
|
|
225
|
+
"note": " ".join(str(entry.get("note") or "").split()),
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
edges = []
|
|
231
|
+
warnings = []
|
|
232
|
+
for index, entry in enumerate(entries):
|
|
233
|
+
depends = entry.get("depends_on") or []
|
|
234
|
+
if isinstance(depends, str):
|
|
235
|
+
depends = [depends]
|
|
236
|
+
for dependency in depends:
|
|
237
|
+
dependency = str(dependency)
|
|
238
|
+
if dependency not in names:
|
|
239
|
+
warnings.append(
|
|
240
|
+
{
|
|
241
|
+
"level": "warn",
|
|
242
|
+
"text": f"{names[index]} depends_on {dependency!r}, which this plan never lists.",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
edges.append(
|
|
247
|
+
{
|
|
248
|
+
"id": f"dep:{dependency}->{names[index]}",
|
|
249
|
+
"source": dependency,
|
|
250
|
+
"target": names[index],
|
|
251
|
+
"label": "merges before",
|
|
252
|
+
"kind": "depends_on",
|
|
253
|
+
"tone": "ok"
|
|
254
|
+
if tone_of("repos", str(entries[names.index(dependency)].get("status") or "")) == "ok"
|
|
255
|
+
else "idle",
|
|
256
|
+
"dashed": False,
|
|
257
|
+
"meta": {},
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
declared = {name: entries[i].get("order") for i, name in enumerate(names)}
|
|
262
|
+
for edge in edges:
|
|
263
|
+
before, after = declared.get(edge["source"]), declared.get(edge["target"])
|
|
264
|
+
if isinstance(before, int) and isinstance(after, int) and before >= after:
|
|
265
|
+
warnings.append(
|
|
266
|
+
{
|
|
267
|
+
"level": "error",
|
|
268
|
+
"text": f"{edge['target']} depends on {edge['source']} but declares order {after} <= {before}. "
|
|
269
|
+
"The declared order contradicts the dependency.",
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
return _finish(nodes, edges, warnings)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
FORWARD_KEYS = ("depends_on", "depends_on_step", "blocked_by")
|
|
276
|
+
BACKWARD_KEYS = ("blocks",)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def step_key(entry: dict, index: int) -> str:
|
|
280
|
+
"""Plans key their steps by `id` or by `order`, never both. Dependencies quote that key."""
|
|
281
|
+
for name in ("id", "order"):
|
|
282
|
+
if entry.get(name) is not None:
|
|
283
|
+
return str(entry[name])
|
|
284
|
+
return str(index)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _as_list(value) -> list:
|
|
288
|
+
if value is None:
|
|
289
|
+
return []
|
|
290
|
+
if isinstance(value, (list, tuple)):
|
|
291
|
+
return list(value)
|
|
292
|
+
return [value]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def plan_step_graph(plan: Plan) -> dict:
|
|
296
|
+
steps = plan.items("steps")
|
|
297
|
+
repo_entries = plan.items("repos")
|
|
298
|
+
repo_names = [str(e.get("repo") or "") for e in repo_entries]
|
|
299
|
+
repo_depends: dict[str, list[str]] = {}
|
|
300
|
+
for index, entry in enumerate(repo_entries):
|
|
301
|
+
depends = [str(d) for d in _as_list(entry.get("depends_on"))]
|
|
302
|
+
repo_depends[repo_names[index]] = [d for d in depends if d in repo_names]
|
|
303
|
+
|
|
304
|
+
keys = [step_key(entry, index) for index, entry in enumerate(steps)]
|
|
305
|
+
node_ids = [f"step-{key}" for key in keys]
|
|
306
|
+
by_key = dict(zip(keys, node_ids))
|
|
307
|
+
|
|
308
|
+
nodes = []
|
|
309
|
+
lanes: dict[str, list[str]] = {}
|
|
310
|
+
for index, entry in enumerate(steps):
|
|
311
|
+
repo = str(entry.get("repo") or "unassigned")
|
|
312
|
+
status = str(entry.get("status") or "")
|
|
313
|
+
lanes.setdefault(repo, []).append(node_ids[index])
|
|
314
|
+
nodes.append(
|
|
315
|
+
{
|
|
316
|
+
"id": node_ids[index],
|
|
317
|
+
"label": keys[index],
|
|
318
|
+
"kind": "step",
|
|
319
|
+
"tone": tone_of("steps", status),
|
|
320
|
+
"meta": {
|
|
321
|
+
"index": index,
|
|
322
|
+
"key": keys[index],
|
|
323
|
+
"repo": repo,
|
|
324
|
+
"status": status,
|
|
325
|
+
"blocking": bool(entry.get("blocking")),
|
|
326
|
+
"what": " ".join(str(entry.get("what") or entry.get("issue") or "").split()),
|
|
327
|
+
"verify": str(entry.get("verify") or ""),
|
|
328
|
+
"note": " ".join(str(entry.get("note") or "").split()),
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
edges = []
|
|
334
|
+
warnings = []
|
|
335
|
+
seen: set = set()
|
|
336
|
+
|
|
337
|
+
def add(source: str, target: str, label: str, kind: str) -> None:
|
|
338
|
+
if source == target or (source, target) in seen:
|
|
339
|
+
return
|
|
340
|
+
seen.add((source, target))
|
|
341
|
+
edges.append(
|
|
342
|
+
{
|
|
343
|
+
"id": f"{kind}:{source}->{target}",
|
|
344
|
+
"source": source,
|
|
345
|
+
"target": target,
|
|
346
|
+
"label": label,
|
|
347
|
+
"kind": kind,
|
|
348
|
+
"tone": "idle",
|
|
349
|
+
"dashed": kind != "declared",
|
|
350
|
+
"meta": {},
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def link(entry: dict, index: int, raw, forward: bool, key_name: str) -> None:
|
|
355
|
+
target_key = str(raw)
|
|
356
|
+
if target_key not in by_key:
|
|
357
|
+
warnings.append(
|
|
358
|
+
{
|
|
359
|
+
"level": "warn",
|
|
360
|
+
"text": f"Step {keys[index]} declares {key_name}: {target_key} - no step carries that id.",
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
here, there = node_ids[index], by_key[target_key]
|
|
365
|
+
if forward:
|
|
366
|
+
add(there, here, key_name, "declared")
|
|
367
|
+
else:
|
|
368
|
+
add(here, there, key_name, "declared")
|
|
369
|
+
|
|
370
|
+
declared = False
|
|
371
|
+
for index, entry in enumerate(steps):
|
|
372
|
+
for key_name in FORWARD_KEYS:
|
|
373
|
+
for raw in _as_list(entry.get(key_name)):
|
|
374
|
+
declared = True
|
|
375
|
+
link(entry, index, raw, True, key_name)
|
|
376
|
+
for key_name in BACKWARD_KEYS:
|
|
377
|
+
for raw in _as_list(entry.get(key_name)):
|
|
378
|
+
declared = True
|
|
379
|
+
link(entry, index, raw, False, key_name)
|
|
380
|
+
|
|
381
|
+
if not declared:
|
|
382
|
+
for lane in lanes.values():
|
|
383
|
+
for first, second in zip(lane, lane[1:]):
|
|
384
|
+
add(first, second, "same repo, in order", "sequence")
|
|
385
|
+
cluster_lane = lanes.get(CLUSTER_REPO, [])
|
|
386
|
+
for repo, lane in lanes.items():
|
|
387
|
+
if repo == CLUSTER_REPO or not lane:
|
|
388
|
+
continue
|
|
389
|
+
for dependency in repo_depends.get(repo, []):
|
|
390
|
+
upstream = lanes.get(dependency) or []
|
|
391
|
+
if upstream:
|
|
392
|
+
add(upstream[-1], lane[0], f"{dependency} merges first", "repo-order")
|
|
393
|
+
if cluster_lane:
|
|
394
|
+
add(lane[-1], cluster_lane[0], "cluster wrap-up", "repo-order")
|
|
395
|
+
if steps:
|
|
396
|
+
warnings.append(
|
|
397
|
+
{
|
|
398
|
+
"level": "info",
|
|
399
|
+
"text": "No step declares depends_on, so these edges are inferred from repo merge order and "
|
|
400
|
+
"step numbering. Add depends_on to a step to state the real order.",
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
return _finish(nodes, edges, warnings)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def plan_graphs(plan: Plan) -> dict:
|
|
407
|
+
return {"repos": plan_repo_graph(plan), "steps": plan_step_graph(plan)}
|