Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [ ] New/changed exported symbols have godoc comments
- [ ] Tests added/updated for behavior changes (table-driven where applicable)
- [ ] No public-API breakage (or if there is, it's flagged below)
- [ ] If this PR closes a milestone ledger, each row's planned evidence text matches the criterion text (per M2 retro carry-forward)

## Breaking change?

Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ The mature-field discipline this approximates has the recorder of a defect struc
- **Quality over elapsed time on the thinking path.** Do not trade thinking quality for wall-clock speed. On the lookup path, parallelism is welcome.
- **Phrasing to follow when planning a task:** *"I will do X thinking/edit work in this context; I may delegate Y lookup if useful."* Both sides explicit. Do not forbid all subagent use (hurts lookup parallelism). Do not leave the line implicit (it won't hold).

### Git Safety Protocol

**Work mode — destructive git operations.**

- **Never run destructive git operations on `main` or any tracking branch without explicit per-occurrence confirmation in chat.** The destructive set: `git reset --hard <ref>`, `git push --force` and `--force-with-lease`, `git clean -fd`, `git branch -D`, `git checkout -- .`, `git restore .`, `git stash drop`, `git rebase -i` with drop/squash on already-pushed commits, `git rm -rf` against tracked files. None of these have a built-in "are you sure?" prompt; all of them can silently destroy work.
- **CC works on feature branches only.** Branch off `main` at the start of a milestone; commits land on the feature branch; the only way to update `main` is via a merged PR. CC never `git checkout main && commit`. CC never `git checkout main && reset`. If CC finds itself on `main` with uncommitted work, the protocol is: `stash` → branch off → `stash pop`.
- **Preservation is the default when state is unexpected.** If `git status` shows a state CC doesn't fully understand — local ahead of origin, unexpected files in the working tree, an unrecognised stash, a detached HEAD, anything that wasn't an explicit consequence of the last few CC operations — *stop and ask*. The non-destructive resolutions almost always exist (push to a backup branch, create a recovery branch from HEAD, ask the user what those commits represent). Reaching for a destructive resolution because "the divergence looks wrong" is the failure mode this protocol exists to prevent.
- **The reflog is a backstop, not a license.** The repo's local `.git/config` extends reflog longevity (90 days unreachable / 365 days reachable; see `scripts/setup-git.sh`). That's a recovery window for when something goes wrong despite the protocol — *not* permission to skip it. There is also a pre-push hook at `scripts/hooks/pre-push` that refuses non-fast-forward and deletion pushes to `main`; that's another backstop, not permission.
- **Phrasing to follow when planning a destructive operation:** if any operation in the destructive set above is in the plan, name it and wait — *"Next step: I'm about to run `git reset --hard origin/main` to undo my last three local commits. OK?"* — and pause until confirmed. Do not bury the destructive command inside a chain of operations; do not assume an earlier "go ahead" extends to a new destructive step.

## Common commands

The `Makefile` is the canonical menu. `make help` prints the full list with descriptions. Frequently used:
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ Contributions are welcome. This document describes the conventions you'll need t
## Development setup

```bash
# One-time local git setup (recommended after every fresh clone).
make setup-git

# Confirm your toolchain
make check-tools

# Build, lint, test
make check
```

`make setup-git` applies the local-only git config the project relies on for safety backstops: extends reflog longevity to 90 days for unreachable refs / 365 days for reachable, and points `core.hooksPath` at the version-controlled hooks under `scripts/hooks/` (currently a `pre-push` that refuses non-fast-forward and deletion pushes to `main`). The script is idempotent. See [`CLAUDE.md`'s "Git Safety Protocol" section](./CLAUDE.md) for the protocol these technical backstops support.

`make check-tools` will tell you which tools are installed and which need installing (`gofmt`, `goimports`, `golangci-lint`, `pkgsite`). All are optional except Go itself; missing tools degrade specific Make targets gracefully.

The Go floor for the module is declared in `go.mod`. Local development can use any later Go release; CI tests against the floor and the latest currently-supported major version.
Expand Down
19 changes: 17 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ COVERAGE_FILE := coverage.out
COVERAGE_HTML := coverage.html
COVERAGE_THRESHOLD := 90

# Lint cache: a fresh directory per `make lint` invocation so local lint
# behaviour matches CI's cold-cache behaviour. Cost: ~5–10s extra per run.
# Justification: M1 and M2 both shipped lint failures that passed locally
# with stale cache (see retrospectives 0001 and 0002). Falls back to
# /tmp/cascade-lint-cache when mktemp's -t flag isn't supported.
LINT_CACHE_DIR := $(shell mktemp -d -t cascade-lint-cache.XXXXXX 2>/dev/null || echo /tmp/cascade-lint-cache)

# List of binaries to build and install (matches subdirectories under cmd/)
BINARIES := cascade

Expand Down Expand Up @@ -165,6 +172,14 @@ check-tools:
@command -v git >/dev/null 2>&1 && echo "$(GREEN)✓ git found$(RESET)" || echo "$(RED)✗ git not found$(RESET)"
@test -f go.mod && echo "$(GREEN)✓ go.mod found$(RESET)" || echo "$(RED)✗ go.mod not found$(RESET)"

# One-time local git setup: extends reflog longevity, points
# core.hooksPath at scripts/hooks (so the version-controlled pre-push
# hook becomes active). Idempotent; safe to re-run. See CLAUDE.md
# §"Git Safety Protocol" for the protocol this backstops.
.PHONY: setup-git
setup-git:
@bash scripts/setup-git.sh

# Build directory creation
$(BIN_DIR):
@echo "$(BLUE)Creating bin directory...$(RESET)"
Expand Down Expand Up @@ -260,9 +275,9 @@ lint:
@echo "$(CYAN)• Running go vet...$(RESET)"
@go vet ./...
@echo "$(GREEN)✓ go vet passed$(RESET)"
@echo "$(CYAN)• Running golangci-lint...$(RESET)"
@echo "$(CYAN)• Running golangci-lint (cache: $(LINT_CACHE_DIR))...$(RESET)"
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run ./...; \
GOLANGCI_LINT_CACHE=$(LINT_CACHE_DIR) golangci-lint run ./...; \
echo "$(GREEN)✓ golangci-lint passed$(RESET)"; \
else \
echo "$(YELLOW)⊙ golangci-lint not installed, skipping$(RESET)"; \
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ Every step is small, typed, and tested. The only io is the two `os/exec` calls i
| | Milestone | Status |
|---|---|---|
| M1 | Repo scaffold + CI baseline | completed |
| M2 | `go list` adapter (shell-out + streaming JSON parser) | in progress |
| M3 | Dep graph + reverse-dep index + closure | not started |
| M2 | `go list` adapter (shell-out + streaming JSON parser) | completed |
| M3 | Dep graph + reverse-dep index + closure | in progress |
| M4 | Changed-files-to-packages mapping | not started |
| M5 | CLI + main wiring | not started |
| M6 | `v0.1.0` release | not started |
Expand Down
156 changes: 156 additions & 0 deletions depgraph/depgraph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package depgraph

import (
"sort"

"github.com/geomyidia/cascade/golist"
)

// Graph is a directed import graph constructed from a slice of
// golist.Package values. Edges run importer → imported, mirroring
// `go list -deps`'s view. The Graph also stores reverse edges
// internally so RevDepClosure runs in O(V + E) without rebuilding.
//
// A Graph is immutable after Build returns. Methods are safe for
// concurrent reads from multiple goroutines.
//
// API stability: pre-v1.0, the Graph type is opaque (no exported
// fields, no exported methods beyond those documented). Internal
// representation may change without notice.
type Graph struct {
// nodes is the set of import paths the graph knows about. A path
// is a node iff it appeared as a Package.ImportPath in Build's
// input. Paths that appear only as edge targets (e.g. imports of
// packages not in the input) are NOT nodes.
nodes map[string]struct{}

// forward[p] is the sorted, deduplicated slice of import paths that
// p directly imports (the union of Imports + TestImports + XTestImports
// from p's golist.Package).
forward map[string][]string

// reverse[p] is the sorted, deduplicated slice of import paths that
// directly import p (the inverse of forward).
reverse map[string][]string

// stats is computed once at Build time and returned by Stats.
stats Stats
}

// Build constructs a Graph from the given slice of packages.
//
// Edge semantics: for each Package P, the union of P.Imports +
// P.TestImports + P.XTestImports forms P's outgoing-edge set. This
// merging reflects affected-set intent — if a package's tests import
// X and X changes, the package needs re-testing.
//
// Build never fails. Empty input yields an empty Graph (zero nodes,
// zero edges). Duplicate ImportPath entries are treated as a single
// node, with the last entry's edge set winning.
//
// Build does not validate that every imported path appears as a node
// in pkgs. Edges to absent nodes are recorded in the forward map but
// don't add nodes — Has on an absent path returns false.
func Build(pkgs []golist.Package) *Graph {
g := &Graph{
nodes: make(map[string]struct{}, len(pkgs)),
forward: make(map[string][]string, len(pkgs)),
reverse: make(map[string][]string),
}

// First pass: register nodes and collect raw edges.
// A second per-path entry overrides the first (last-entry-wins).
for _, p := range pkgs {
if p.ImportPath == "" {
continue
}
g.nodes[p.ImportPath] = struct{}{}
g.forward[p.ImportPath] = mergeImports(p.Imports, p.TestImports, p.XTestImports)
}

// Second pass: build the reverse map from the (now stable) forward map.
for src, targets := range g.forward {
for _, dst := range targets {
g.reverse[dst] = append(g.reverse[dst], src)
}
}

// Sort the reverse map for deterministic, O(1)-access methods. The
// forward map is already sorted by mergeImports; the reverse map is
// dedup-by-construction (each src-imports-dst pair appends once),
// so no dedup pass is required.
sortValues(g.forward)
sortValues(g.reverse)

g.stats = computeStats(g)
return g
}

// mergeImports returns the sorted, deduplicated union of three import
// slices. Empty / nil inputs are tolerated. Used by Build to merge
// Package.Imports + TestImports + XTestImports into a single edge set.
func mergeImports(a, b, c []string) []string {
if len(a)+len(b)+len(c) == 0 {
return nil
}
seen := make(map[string]struct{}, len(a)+len(b)+len(c))
out := make([]string, 0, len(a)+len(b)+len(c))
for _, s := range a {
if _, dup := seen[s]; !dup {
seen[s] = struct{}{}
out = append(out, s)
}
}
for _, s := range b {
if _, dup := seen[s]; !dup {
seen[s] = struct{}{}
out = append(out, s)
}
}
for _, s := range c {
if _, dup := seen[s]; !dup {
seen[s] = struct{}{}
out = append(out, s)
}
}
sort.Strings(out)
return out
}

// sortValues sorts each value slice in m in place. Used by Build to
// ensure DirectImports / DirectImporters / RevDepClosure return
// deterministically-ordered output without per-call sorts.
//
// Slices of length 0 or 1 are already trivially sorted; the guard
// keeps Build allocation-free for sparse graphs.
func sortValues(m map[string][]string) {
for _, v := range m {
if len(v) > 1 {
sort.Strings(v)
}
}
}

// computeStats walks the graph's internal maps and produces a Stats
// snapshot. Called once at the end of Build; cached on the Graph and
// returned (by value) from Stats.
//
// MaxInDegree and MaxOutDegree are computed across known nodes only
// (i.e. import paths present as Package.ImportPath in Build's input).
// Edges to absent nodes (typically stdlib paths the caller didn't
// include in pkgs) contribute to Edges and to MaxOutDegree but do not
// themselves appear as nodes, so they don't get an in-degree count.
func computeStats(g *Graph) Stats {
s := Stats{Nodes: len(g.nodes)}
for path := range g.nodes {
out := len(g.forward[path])
s.Edges += out
if out > s.MaxOutDegree {
s.MaxOutDegree = out
}
if in := len(g.reverse[path]); in > s.MaxInDegree {
s.MaxInDegree = in
}
}
return s
}
Loading
Loading