Skip to content

Commit 8d4706b

Browse files
authored
Merge branch 'main' into main
2 parents 8a3671a + ca173dd commit 8d4706b

13 files changed

Lines changed: 930 additions & 93 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7-
branches: [main]
87

98
concurrency:
109
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}

README.md

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
Inspired by [XKCD #2347](https://xkcd.com/2347/), StackTower renders dependency graphs as **physical towers** where blocks rest on what they depend on. Your application sits at the top, supported by libraries below—all the way down to that one critical package maintained by *some dude in Nebraska*.
44

5-
Traditional node-link diagrams are technically correct but don't *feel* like anything. Tower visualizations tap into intuition: width shows importance, depth reveals foundation, and the structure makes hidden dependencies visible at a glance.
65

76
📖 **[Read the full story at stacktower.io](https://www.stacktower.io)**
87

@@ -38,6 +37,9 @@ stacktower parse rust serde -o serde.json
3837

3938
# JavaScript (npm)
4039
stacktower parse javascript yup -o yup.json
40+
41+
# PHP (Packagist/Composer)
42+
stacktower parse php monolog/monolog -o monolog.json
4143
```
4244

4345
Add `--enrich` with a `GITHUB_TOKEN` to pull repository metadata (stars, maintainers, last commit) for richer visualizations.
@@ -63,14 +65,20 @@ The repository ships with pre-parsed graphs so you can experiment immediately:
6365
# Real packages with full metadata
6466
stacktower render examples/real/flask.json -t tower --style handdrawn --merge -o flask.svg
6567
stacktower render examples/real/serde.json -t tower --popups -o serde.svg
66-
stacktower render examples/real/express.json -t tower --ordering barycenter -o express.svg
68+
stacktower render examples/real/express.json -t tower --ordering barycentric -o express.svg
6769

6870
# Synthetic test cases
6971
stacktower render examples/test/diamond.json -t tower -o diamond.svg
7072
```
7173

7274
## Options Reference
7375

76+
### Global Options
77+
78+
| Flag | Description |
79+
|------|-------------|
80+
| `-v`, `--verbose` | Enable debug logging (search space info, timing details) |
81+
7482
### Parse Options
7583

7684
| Flag | Description |
@@ -99,6 +107,59 @@ stacktower render examples/test/diamond.json -t tower -o diamond.svg
99107
|------|-------------|
100108
| `--detailed` | Show node metadata in labels |
101109

110+
## JSON Format
111+
112+
The render layer accepts a simple JSON format, making it easy to visualize **any** directed graph—not just package dependencies. You can hand-craft graphs for component diagrams, callgraphs, or pipe output from other tools.
113+
114+
### Minimal Example
115+
116+
```json
117+
{
118+
"nodes": [
119+
{ "id": "app" },
120+
{ "id": "lib-a" },
121+
{ "id": "lib-b" }
122+
],
123+
"edges": [
124+
{ "from": "app", "to": "lib-a" },
125+
{ "from": "lib-a", "to": "lib-b" }
126+
]
127+
}
128+
```
129+
130+
### Required Fields
131+
132+
| Field | Type | Description |
133+
|-------|------|-------------|
134+
| `nodes[].id` | string | Unique node identifier (displayed as label) |
135+
| `edges[].from` | string | Source node ID |
136+
| `edges[].to` | string | Target node ID |
137+
138+
### Optional Fields
139+
140+
| Field | Type | Description |
141+
|-------|------|-------------|
142+
| `nodes[].row` | int | Pre-assigned layer (computed automatically if omitted) |
143+
| `nodes[].kind` | string | Internal use: `"subdivider"` or `"auxiliary"` |
144+
| `nodes[].meta` | object | Freeform metadata for display features |
145+
146+
### Recognized `meta` Keys
147+
148+
These keys are read by specific render flags. All are optional—missing keys simply disable the corresponding feature.
149+
150+
| Key | Type | Used By |
151+
|-----|------|---------|
152+
| `repo_url` | string | Clickable blocks, `--popups`, `--nebraska` |
153+
| `repo_stars` | int | `--popups` |
154+
| `repo_owner` | string | `--nebraska` |
155+
| `repo_maintainers` | []string | `--nebraska`, `--popups` |
156+
| `repo_last_commit` | string (date) | `--popups`, brittle detection |
157+
| `repo_last_release` | string (date) | `--popups` |
158+
| `repo_archived` | bool | `--popups`, brittle detection |
159+
| `summary` | string | `--popups` (fallback: `description`) |
160+
161+
The `--detailed` flag (node-link only) displays **all** meta keys in the node label.
162+
102163
## How It Works
103164

104165
1. **Parse** — Fetch package metadata from registries (PyPI, crates.io, npm)
@@ -121,11 +182,27 @@ The ordering step is where the magic happens. StackTower uses an optimal search
121182

122183
HTTP responses are cached in `~/.cache/stacktower/` with a 24-hour TTL. Use `--refresh` to bypass.
123184

185+
## Adding New Languages
186+
187+
To add support for a new package manager (e.g., Ruby/RubyGems):
188+
189+
1. **Create a registry client** in `pkg/integrations/rubygems/client.go` — parse the registry API, extract dependencies, use `integrations.BaseClient` for HTTP + caching
190+
191+
2. **Create a source parser** in `pkg/source/ruby/ruby.go` — implement the `source.PackageInfo` interface (`GetName`, `GetVersion`, `GetDependencies`, `ToMetadata`, `ToRepoInfo`)
192+
193+
3. **Wire into CLI** in `internal/cli/parse.go`:
194+
```go
195+
cmd.AddCommand(newParserCmd("ruby <gem>", "Parse Ruby dependencies",
196+
func() (source.Parser, error) { return ruby.NewParser(source.DefaultCacheTTL) }, &opts))
197+
```
198+
199+
The generic `source.Parse()` handles concurrent fetching, depth limits, and graph construction automatically.
200+
124201
## Learn More
125202

126203
- 📖 **[stacktower.io](https://www.stacktower.io)** — Interactive examples and the full story behind tower visualizations
127204
- 🐛 **[Issues](https://github.com/matzehuels/stacktower/issues)** — Bug reports and feature requests
128205

129206
## License
130207

131-
MIT
208+
Apache-2.0

internal/cli/parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/matzehuels/stacktower/pkg/source"
1313
"github.com/matzehuels/stacktower/pkg/source/javascript"
1414
"github.com/matzehuels/stacktower/pkg/source/metadata"
15+
"github.com/matzehuels/stacktower/pkg/source/php"
1516
"github.com/matzehuels/stacktower/pkg/source/python"
1617
"github.com/matzehuels/stacktower/pkg/source/rust"
1718
"github.com/matzehuels/stacktower/pkg/source/ruby"
@@ -50,6 +51,8 @@ func newParseCmd() *cobra.Command {
5051
func() (source.Parser, error) { return javascript.NewParser(source.DefaultCacheTTL) }, &opts))
5152
cmd.AddCommand(newParserCmd("ruby <gem>", "Parse Ruby gem dependencies from RubyGems",
5253
func() (source.Parser, error) { return ruby.NewParser(source.DefaultCacheTTL) }, &opts))
54+
cmd.AddCommand(newParserCmd("php <package>", "Parse PHP (Composer) package dependencies from Packagist",
55+
func() (source.Parser, error) { return php.NewParser(source.DefaultCacheTTL) }, &opts))
5356

5457
return cmd
5558
}

internal/cli/render.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,12 @@ func buildLayoutOpts(ctx context.Context, opts *renderOpts) ([]tower.Option, err
256256

257257
func withOptimalSearchProgress(ctx context.Context, timeoutSec int) ordering.Orderer {
258258
logger := loggerFromContext(ctx)
259-
o := &optimalSearchOrderer{prog: newProgress(logger), logger: logger, lastBest: -1}
259+
o := &optimalSearchOrderer{
260+
prog: newProgress(logger),
261+
logger: logger,
262+
lastBest: -1,
263+
start: time.Now(),
264+
}
260265

261266
o.OptimalSearch = ordering.OptimalSearch{
262267
Timeout: time.Duration(timeoutSec) * time.Second,
@@ -269,11 +274,34 @@ func withOptimalSearchProgress(ctx context.Context, timeoutSec int) ordering.Ord
269274
switch {
270275
case o.lastBest < 0:
271276
logger.Infof("Initial: %d crossings (explored: %d, pruned: %d)", bestScore, explored, pruned)
277+
o.lastLog = time.Now()
272278
case bestScore < o.lastBest:
273279
logger.Infof("Improved: %d crossings (↓%d)", bestScore, o.lastBest-bestScore)
280+
o.lastLog = time.Now()
281+
default:
282+
if time.Since(o.lastLog) >= 10*time.Second {
283+
elapsed := time.Since(o.start).Truncate(time.Second)
284+
logger.Infof("Searching... %v/%ds elapsed, %d crossings (pruned: %d)", elapsed, timeoutSec, bestScore, pruned)
285+
o.lastLog = time.Now()
286+
}
274287
}
275288
o.lastBest = bestScore
276289
},
290+
Debug: func(info ordering.DebugInfo) {
291+
logger.Debugf("Search space: %d rows, max depth reached: %d/%d", info.TotalRows, info.MaxDepth, info.TotalRows)
292+
293+
bottlenecks := 0
294+
for _, r := range info.Rows {
295+
if r.Candidates > 100 {
296+
logger.Debugf(" Row %d: %d nodes, %d candidates", r.Row, r.NodeCount, r.Candidates)
297+
bottlenecks++
298+
}
299+
}
300+
301+
if info.MaxDepth < info.TotalRows && bottlenecks > 0 {
302+
logger.Debugf("Search incomplete: %d rows have >100 candidates, causing combinatorial explosion", bottlenecks)
303+
}
304+
},
277305
}
278306
return o
279307
}
@@ -284,6 +312,7 @@ type optimalSearchOrderer struct {
284312
logger *log.Logger
285313
lastExplored, lastPruned int
286314
lastBest int
315+
start, lastLog time.Time
287316
}
288317

289318
func (o *optimalSearchOrderer) OrderRows(g *dag.DAG) map[int][]string {
@@ -294,6 +323,9 @@ func (o *optimalSearchOrderer) OrderRows(g *dag.DAG) map[int][]string {
294323
o.logger.Infof("Best: %d crossings (explored: %d, pruned: %d)",
295324
crossings, o.lastExplored, o.lastPruned)
296325
}
326+
if crossings > 0 {
327+
o.logger.Warn("Layout has edge crossings; try increasing the timeout (--ordering-timeout)")
328+
}
297329
return result
298330
}
299331

pkg/dag/perm/pqtree.go

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -358,77 +358,90 @@ func (t *PQTree) Enumerate(limit int) [][]int {
358358
}
359359

360360
var results [][]int
361-
t.enumerate(t.root, nil, limit, &results)
361+
t.enumerateLazy(t.root, nil, func(perm []int) bool {
362+
results = append(results, perm)
363+
return limit <= 0 || len(results) < limit
364+
})
362365
return results
363366
}
364367

365-
func (t *PQTree) enumerate(node *pqNode, prefix []int, limit int, results *[][]int) bool {
366-
if limit > 0 && len(*results) >= limit {
367-
return false
368-
}
369-
368+
// enumerateLazy generates permutations one at a time via callback.
369+
// Returns false if callback signaled stop, true otherwise.
370+
func (t *PQTree) enumerateLazy(node *pqNode, prefix []int, emit func([]int) bool) bool {
370371
if node.kind == leafNode {
371-
*results = append(*results, append(slices.Clone(prefix), node.value))
372-
return true
372+
return emit(append(slices.Clone(prefix), node.value))
373373
}
374374

375-
perms := t.childPermutations(node)
376-
for _, perm := range perms {
377-
if limit > 0 && len(*results) >= limit {
375+
return t.forEachChildPerm(node, func(children []*pqNode) bool {
376+
return t.enumerateChildrenLazy(children, prefix, emit)
377+
})
378+
}
379+
380+
// For Q-nodes: yields forward and reverse only.
381+
// For P-nodes: generates permutations one at a time without storing them all.
382+
func (t *PQTree) forEachChildPerm(node *pqNode, fn func([]*pqNode) bool) bool {
383+
if node.kind == qNode {
384+
if !fn(node.children) {
378385
return false
379386
}
380-
if !t.enumerateChildren(perm, prefix, limit, results) {
381-
return false
387+
if len(node.children) <= 1 {
388+
return true
382389
}
390+
rev := slices.Clone(node.children)
391+
slices.Reverse(rev)
392+
return fn(rev)
383393
}
384394

385-
return true
386-
}
395+
// P-node: Generate permutations lazily
396+
n := len(node.children)
397+
if n == 0 {
398+
return fn(nil)
399+
}
400+
if n == 1 {
401+
return fn(node.children)
402+
}
387403

388-
func (t *PQTree) childPermutations(node *pqNode) [][]*pqNode {
389-
if node.kind == qNode {
390-
fwd := slices.Clone(node.children)
391-
rev := slices.Clone(node.children)
392-
slices.Reverse(rev)
393-
return [][]*pqNode{fwd, rev}
404+
perm := slices.Clone(node.children)
405+
state := make([]int, n)
406+
407+
// Emit first permutation (identity)
408+
if !fn(slices.Clone(perm)) {
409+
return false
394410
}
395411

396-
indexPerms := Generate(len(node.children), -1)
397-
result := make([][]*pqNode, len(indexPerms))
398-
for i, perm := range indexPerms {
399-
ordered := make([]*pqNode, len(perm))
400-
for j, idx := range perm {
401-
ordered[j] = node.children[idx]
412+
// iteratively generate remaining permutations
413+
for i := 0; i < n; {
414+
if state[i] < i {
415+
if i&1 == 0 {
416+
perm[0], perm[i] = perm[i], perm[0]
417+
} else {
418+
perm[state[i]], perm[i] = perm[i], perm[state[i]]
419+
}
420+
if !fn(slices.Clone(perm)) {
421+
return false
422+
}
423+
state[i]++
424+
i = 0
425+
} else {
426+
state[i] = 0
427+
i++
402428
}
403-
result[i] = ordered
404429
}
405-
return result
430+
return true
406431
}
407432

408-
func (t *PQTree) enumerateChildren(children []*pqNode, prefix []int, limit int, results *[][]int) bool {
433+
func (t *PQTree) enumerateChildrenLazy(children []*pqNode, prefix []int, emit func([]int) bool) bool {
409434
if len(children) == 0 {
410-
*results = append(*results, slices.Clone(prefix))
411-
return true
435+
return emit(slices.Clone(prefix))
412436
}
413437

414438
first := children[0]
415439
rest := children[1:]
416440

417-
var firstPerms [][]int
418-
t.enumerate(first, nil, 0, &firstPerms)
419-
420-
for _, fp := range firstPerms {
421-
if limit > 0 && len(*results) >= limit {
422-
return false
423-
}
424-
425-
newPrefix := append(slices.Clone(prefix), fp...)
426-
if !t.enumerateChildren(rest, newPrefix, limit, results) {
427-
return false
428-
}
429-
}
430-
431-
return true
441+
return t.enumerateLazy(first, nil, func(firstPerm []int) bool {
442+
newPrefix := append(slices.Clone(prefix), firstPerm...)
443+
return t.enumerateChildrenLazy(rest, newPrefix, emit)
444+
})
432445
}
433446

434447
func (t *PQTree) ValidCount() int {

0 commit comments

Comments
 (0)