From 587977747ce3c2ca945fe6e570bcca32988d45b7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:30:09 +0000 Subject: [PATCH 1/5] feat(cli): syspi update moves the named (or all) usages to the highest version their constraints allow; outdated reports what it would move Co-Authored-By: jason.han --- README.md | 5 +- changes/unreleased/update.added.md | 1 + cmd/syspi/main.go | 61 ++++++++++++++ docs/packages.md | 10 +++ internal/install/install.go | 127 ++++++++++++++++++++++++++--- internal/install/update.go | 77 +++++++++++++++++ 6 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 changes/unreleased/update.added.md create mode 100644 internal/install/update.go diff --git a/README.md b/README.md index 429828e..a806001 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ what package managers install. - **Lock** — `.project.lock.json` beside `.project.json`: one entry per package of the transitive closure with `resource`, `kind`, `vcs`, `source`, `tag`, `revision`, `sha256` and the usages it declares; deterministic bytes, so a fresh clone reproduces it exactly. A lock - entry wins over a constraint. + entry wins over a constraint everywhere but in `update`, the one command that moves a lock + entry (`outdated` shows what it would move). - **Constraints** — `versionConstraint` in `.project.json`: exact (`1.2.0`), caret (`^1.2.0`), tilde (`~1.2.0`) and comparison ranges (`>=1.2.0 <2.0.0`). `@` in the resource with no `versionConstraint` is an exact constraint. @@ -55,6 +56,8 @@ are in [docs/packages.md](docs/packages.md). syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json and .meta.json syspi add [@] [] add a usage, resolve, install, lock syspi remove ... drop a usage, re-lock, remove what nothing else needs from lib/ +syspi update [...] move the named (or all) usages to the highest version allowed, install, re-lock +syspi outdated [-json] [...] report what update would move; write nothing syspi install | sync resolve .project.json + lock, install what is missing or changed syspi lock write .project.lock.json without installing syspi bundle [-o out.kpar] [-tag T] deterministic .kpar; checks tag == version in a tagged checkout diff --git a/changes/unreleased/update.added.md b/changes/unreleased/update.added.md new file mode 100644 index 0000000..c9187e4 --- /dev/null +++ b/changes/unreleased/update.added.md @@ -0,0 +1 @@ +- `syspi update […]` re-resolves the named (or all) usages to the highest version their constraints allow, ignoring the lock — the one command that moves a lock entry — installs, rewrites the lock and removes the copies the moved entries left under `lib/`; `syspi outdated [-json] […]` reports what `update` would move (`name old -> new`) and writes nothing. diff --git a/cmd/syspi/main.go b/cmd/syspi/main.go index 33ee88f..fe72e16 100644 --- a/cmd/syspi/main.go +++ b/cmd/syspi/main.go @@ -6,6 +6,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -34,6 +35,8 @@ Commands: init write .project.json and .meta.json for a new package add add a usage to .project.json, then resolve, install and lock remove drop a usage from .project.json, re-lock, remove what nothing else needs from lib/ + update move the named (or all) usages to the highest version their constraints allow, install, re-lock + outdated report what update would move; write nothing install resolve .project.json and the lock, install what is missing or changed sync same as install lock resolve and write .project.lock.json without installing @@ -68,6 +71,10 @@ func run(args []string, stdout, stderr io.Writer) int { err = cmdAdd(ctx, args[1:], stdout, stderr) case "remove": err = cmdRemove(ctx, args[1:], stdout, stderr) + case "update": + err = cmdUpdate(ctx, args[1:], stdout, stderr) + case "outdated": + err = cmdOutdated(ctx, args[1:], stdout, stderr) case "install", "sync": err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false) case "lock": @@ -268,6 +275,60 @@ func cmdRemove(ctx context.Context, args []string, stdout, stderr io.Writer) err return refreshMeta(c.dir) } +func cmdUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("update", stderr, "[flags] [...]") + c.bind(fs) + if err := parse(fs, args); err != nil { + return err + } + res, err := install.Update(ctx, c.options(stderr, false), fs.Args()) + if err != nil { + return err + } + for i := range res.Changes { + fmt.Fprintln(stdout, res.Changes[i].Describe()) + } + if len(res.Changes) == 0 { + fmt.Fprintln(stdout, "every package is at the highest version its constraint allows; nothing moved") + } + for _, p := range res.Pruned { + fmt.Fprintf(stdout, "removed %s\n", p) + } + report(stdout, res) + return nil +} + +func cmdOutdated(ctx context.Context, args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("outdated", stderr, "[flags] [...]") + c.bind(fs) + asJSON := fs.Bool("json", false, "print the changes as a JSON array") + if err := parse(fs, args); err != nil { + return err + } + res, err := install.Outdated(ctx, c.options(stderr, false), fs.Args()) + if err != nil { + return err + } + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if res.Changes == nil { + res.Changes = []install.Change{} + } + return enc.Encode(res.Changes) + } + for i := range res.Changes { + fmt.Fprintln(stdout, res.Changes[i].Describe()) + } + if len(res.Changes) == 0 && !c.quiet { + fmt.Fprintln(stdout, "every package is at the highest version its constraint allows") + } + return nil +} + func cmdInstall(ctx context.Context, name string, args []string, stdout, stderr io.Writer, lockOnly bool) error { var c commonFlags fs := newFlagSet(name, stderr, "[flags]") diff --git a/docs/packages.md b/docs/packages.md index 39c146b..a9320af 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -131,6 +131,16 @@ published into `lib/` only after every usage resolved and verified; a failing ru and the lock as they were. A lock entry wins over the constraint in `.project.json` as long as it satisfies it; `install` then verifies the installed copy's digest and fetches only what is missing or changed. +`update` is the one command that moves a lock entry. `syspi update […]` re-resolves the +named usages of `.project.json` — every repository of the closure when none is named — to the +highest tagged version that satisfies their constraints, ignoring the lock; whatever moved is +installed, the lock rewritten, and the copies the moved entries left under `lib/` removed as +`remove` removes them. Every other command (`add`, `install`, `sync`, `lock`, `remove`) keeps a +lock entry that satisfies its constraint. `syspi outdated [-json] […]` resolves the same +way and reports each entry that would move (`name old -> new`, plus entries the move would add or +drop), writing nothing. Archives, local directories and standard libraries have no versions to +choose from: their usage names what they are, and `update` leaves them. + ## Install layout ``` diff --git a/internal/install/install.go b/internal/install/install.go index 9b901e0..e98b5df 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -14,6 +14,7 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "github.com/Open-MBEE/SysPI/internal/digest" @@ -41,6 +42,14 @@ type Options struct { // Prune removes from lib/ the installed copies the previous lock placed // there that the new lock no longer claims (see Remove). Prune bool + // Update names, by repository key, the usages re-resolved to the highest + // version their constraint allows, ignoring the lock; UpdateAll does so + // for every repository of the closure (see Update and Outdated). + Update []string + UpdateAll bool + // DryRun resolves as LockOnly does but writes nothing: the Result + // carries the lock the run would have written. + DryRun bool } // Result reports what a run did. @@ -54,6 +63,19 @@ type Result struct { RemovedUsages []string // Pruned lists the lib/ paths (relative to Root) a pruning run removed. Pruned []string + // Changes lists, in resource order, every lock entry the run moved, + // added or dropped against the previous lock. + Changes []Change +} + +// Change is one lock entry that differs between the previous and the new lock. +type Change struct { + Resource string `json:"resource"` + Name string `json:"name,omitempty"` + // From and To are the versions before and after; one is empty for an + // entry only one of the locks has. + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` } type request struct { @@ -112,6 +134,9 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, err } opts.Root = root + if opts.DryRun { + opts.LockOnly = true + } proj, err := project.ReadProject(root) if err != nil { return nil, err @@ -144,6 +169,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, err } } + r.res.Changes = changes(r.old, r.fresh) + if opts.DryRun { + r.res.Lock = r.fresh + return r.res, nil + } // Everything resolved and verified: publish the staged packages, then // the lock, restoring every replaced package if any step fails. backups, err := r.publish() @@ -572,6 +602,64 @@ func treePath(libDir string, p *project.Project) string { // installedTreeMatches reports whether dir holds a tree with the locked // digest, ignoring a .meta.json this tool generated. +// updating reports whether the lock is ignored for this repository: the +// run updates everything, or named this usage's repository. +func (r *resolver) updating(o *origin.Origin) bool { + if r.opts.UpdateAll { + return true + } + for _, k := range r.opts.Update { + if k == o.Key() { + return true + } + } + return false +} + +// latest opens the repository and selects the highest tagged version that +// satisfies the request's constraint. +func (r *resolver) latest(o *origin.Origin, req request) (vcs.Source, vcs.Version, error) { + src, err := vcs.Open(o.VCS, o.Source) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + vs, err := src.ListVersions(r.ctx) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + sel, err := vcs.Select(vs, req.constraint) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + return src, sel, nil +} + +// changes diffs two locks by resource: every entry moved, added or dropped. +func changes(old, fresh *lock.Lock) []Change { + var out []Change + seen := map[string]bool{} + for i := range fresh.Packages { + e := &fresh.Packages[i] + seen[e.Resource] = true + c := Change{Resource: e.Resource, Name: e.Name, To: e.Version} + if o := old.Find(e.Resource); o != nil { + if o.Version == e.Version { + continue + } + c.From = o.Version + } + out = append(out, c) + } + for i := range old.Packages { + e := &old.Packages[i] + if !seen[e.Resource] { + out = append(out, Change{Resource: e.Resource, Name: e.Name, From: e.Version}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Resource < out[j].Resource }) + return out +} + func installedTreeMatches(dir string, old *lock.Entry) bool { if _, err := os.Stat(filepath.Join(dir, project.ProjectFile)); err != nil { return false @@ -587,6 +675,21 @@ func installedTreeMatches(dir string, old *lock.Entry) bool { func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error) { id := o.Identity() old := r.lockedRepository(o, req.constraint) + var src vcs.Source + var sel vcs.Version + if old != nil && r.updating(o) { + if r.opts.Offline { + return nil, fmt.Errorf("%s: usage %q: offline; looking for a newer version of %s needs the repository", req.requester, req.resource, id) + } + var err error + if src, sel, err = r.latest(o, req); err != nil { + return nil, err + } + if sel.Version.String() != old.Version { + r.logf("%s: %s %s -> %s", id, old.Name, old.Version, sel.Version) + old = nil + } + } if old != nil && !r.opts.LockOnly { stem := (&project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}).InstallName() dst := filepath.Join(r.libDir, stem) @@ -608,21 +711,19 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error if r.opts.Offline { return nil, fmt.Errorf("%s: usage %q: offline and %s is not installed under %s", req.requester, req.resource, id, project.LibDir) } - src, err := vcs.Open(o.VCS, o.Source) - if err != nil { - return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) - } - var sel vcs.Version - if old != nil { - v, _ := version.Parse(old.Version) - sel = vcs.Version{Tag: old.Tag, Version: v} - } else { - vs, err := src.ListVersions(r.ctx) - if err != nil { + var err error + switch { + case src != nil: + // Opened and selected above: the lock is being moved. + case old != nil: + if src, err = vcs.Open(o.VCS, o.Source); err != nil { return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) } - if sel, err = vcs.Select(vs, req.constraint); err != nil { - return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + v, _ := version.Parse(old.Version) + sel = vcs.Version{Tag: old.Tag, Version: v} + default: + if src, sel, err = r.latest(o, req); err != nil { + return nil, err } } revision, err := src.Revision(r.ctx, sel) diff --git a/internal/install/update.go b/internal/install/update.go new file mode 100644 index 0000000..ff5da0e --- /dev/null +++ b/internal/install/update.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "context" + "fmt" + + "github.com/Open-MBEE/SysPI/internal/origin" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// Update re-resolves the named usages of .project.json — every one when +// resources is empty — to the highest version satisfying their constraints, +// ignoring the lock, then installs and re-locks as Run does and prunes the +// copies the moved entries leave behind. It is the one way a lock entry +// moves. Result.Changes lists each entry moved, added or dropped. +func Update(ctx context.Context, opts Options, resources []string) (*Result, error) { + if err := selectUpdates(&opts, resources); err != nil { + return nil, err + } + opts.Prune = true + return Run(ctx, opts) +} + +// Outdated resolves as Update would and reports what it would change, +// writing nothing: Result.Changes lists the entries that would move. +func Outdated(ctx context.Context, opts Options, resources []string) (*Result, error) { + if err := selectUpdates(&opts, resources); err != nil { + return nil, err + } + opts.DryRun = true + return Run(ctx, opts) +} + +// selectUpdates turns the named resources into the repository keys the +// resolver ignores the lock for; each must be a usage of .project.json. +func selectUpdates(opts *Options, resources []string) error { + if len(resources) == 0 { + opts.UpdateAll = true + return nil + } + proj, err := project.ReadProject(opts.Root) + if err != nil { + return err + } + for _, res := range resources { + o, err := origin.Parse(res) + if err != nil { + return err + } + if usageIndex(proj, o.Identity()) < 0 { + return fmt.Errorf("%s: no usage of %s", project.ProjectFile, o.Identity()) + } + if o.Kind != origin.VCS { + return fmt.Errorf("%s: %s is not a repository; it has no versions to choose from, and its usage names what it is", project.ProjectFile, o.Identity()) + } + opts.Update = append(opts.Update, o.Key()) + } + return nil +} + +// Describe is the one-line report of a change: "name old -> new", "name new (new)" +// or "name old (dropped)". +func (c *Change) Describe() string { + name := c.Name + if name == "" { + name = c.Resource + } + switch { + case c.From == "": + return fmt.Sprintf("%s %s (new)", name, c.To) + case c.To == "": + return fmt.Sprintf("%s %s (dropped)", name, c.From) + } + return fmt.Sprintf("%s %s -> %s", name, c.From, c.To) +} From 6be6a47e28f00a4a22b87eaf6693cc4875cc04ee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:32:52 +0000 Subject: [PATCH 2/5] test(install): update and outdated on the git, hg and svn fixtures Co-Authored-By: jason.han --- internal/install/install.go | 11 +-- internal/install/update_test.go | 141 ++++++++++++++++++++++++++++++++ tests/e2e/update_test.go | 108 ++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 internal/install/update_test.go create mode 100644 tests/e2e/update_test.go diff --git a/internal/install/install.go b/internal/install/install.go index e98b5df..8087b64 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -634,15 +634,17 @@ func (r *resolver) latest(o *origin.Origin, req request) (vcs.Source, vcs.Versio return src, sel, nil } -// changes diffs two locks by resource: every entry moved, added or dropped. +// changes diffs two locks by repository key (the old lock's resources are +// already keyed): every entry moved, added or dropped. func changes(old, fresh *lock.Lock) []Change { var out []Change seen := map[string]bool{} for i := range fresh.Packages { e := &fresh.Packages[i] - seen[e.Resource] = true + k := lockKey(e.Resource) + seen[k] = true c := Change{Resource: e.Resource, Name: e.Name, To: e.Version} - if o := old.Find(e.Resource); o != nil { + if o := old.Find(k); o != nil { if o.Version == e.Version { continue } @@ -652,7 +654,7 @@ func changes(old, fresh *lock.Lock) []Change { } for i := range old.Packages { e := &old.Packages[i] - if !seen[e.Resource] { + if !seen[lockKey(e.Resource)] { out = append(out, Change{Resource: e.Resource, Name: e.Name, From: e.Version}) } } @@ -686,7 +688,6 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error return nil, err } if sel.Version.String() != old.Version { - r.logf("%s: %s %s -> %s", id, old.Name, old.Version, sel.Version) old = nil } } diff --git a/internal/install/update_test.go b/internal/install/update_test.go new file mode 100644 index 0000000..717c7f4 --- /dev/null +++ b/internal/install/update_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +func describeAll(cs []Change) string { + var s []string + for i := range cs { + s = append(s, cs[i].Describe()) + } + return strings.Join(s, "; ") +} + +func lockedVersions(t *testing.T, dir string) string { + t.Helper() + l, _, err := lock.Read(dir, project.LockFile) + if err != nil { + t.Fatal(err) + } + var s []string + for i := range l.Packages { + s = append(s, l.Packages[i].Name+" "+l.Packages[i].Version) + } + sort.Strings(s) + return strings.Join(s, ",") +} + +func TestUpdateAndOutdated(t *testing.T) { + widgets, base := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + bres := "git+file://" + filepath.ToSlash(base) + dir := consumer(t, project.Usage{Resource: wres + "@1.0.0"}, project.Usage{Resource: bres + "@2.0.0"}) + run(t, dir, Options{}) + if got := lockedVersions(t, dir); got != "base 2.0.0,widgets 1.0.0" { + t.Fatalf("locked: %s", got) + } + // Loosen the constraints: the lock still wins for every command but update. + p := &project.Project{Name: "app", Publisher: "test", Version: "0.1.0", Usage: []project.Usage{ + {Resource: wres, VersionConstraint: ">=1.0.0 <1.2.0"}, + {Resource: bres, VersionConstraint: "^2.0.0"}, + }} + if err := p.Write(dir); err != nil { + t.Fatal(err) + } + res, _ := run(t, dir, Options{}) + if got := lockedVersions(t, dir); got != "base 2.0.0,widgets 1.0.0" || len(res.Changes) != 0 { + t.Fatalf("install moved the lock: %s %v", got, res.Changes) + } + lockBefore, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) + + // Naming widgets alone cannot move it: widgets 1.1.0 needs base ^2.1.0 + // and the lock entry for base, not named, wins — the conflict is reported. + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{wres}); err == nil || !strings.Contains(err.Error(), "already resolved to 2.0.0") { + t.Fatalf("outdated widgets alone: %v", err) + } + // outdated: what would move, nothing written. + for _, named := range [][]string{nil, {wres, bres}} { + res, err := Outdated(context.Background(), Options{Root: dir}, named) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "base 2.0.0 -> 2.1.0; widgets 1.0.0 -> 1.1.0" { + t.Fatalf("outdated %v: %s", named, got) + } + if lockNow, _ := os.ReadFile(filepath.Join(dir, project.LockFile)); string(lockNow) != string(lockBefore) { + t.Fatal("outdated wrote the lock") + } + if got := libNames(t, dir); got != "acme-widgets_1.0.0,base_2.0.0" { + t.Fatalf("outdated touched lib/: %s", got) + } + } + res, err := Outdated(context.Background(), Options{Root: dir}, []string{bres}) + if err != nil || describeAll(res.Changes) != "base 2.0.0 -> 2.1.0" { + t.Fatalf("outdated base: %v %s", err, describeAll(res.Changes)) + } + + // update of one usage moves that entry, installs it and prunes the old copy. + res, err = Update(context.Background(), Options{Root: dir}, []string{bres}) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "base 2.0.0 -> 2.1.0" || !res.LockWritten || strings.Join(res.Pruned, ",") != "lib/base_2.0.0" { + t.Fatalf("update base: %s written=%v pruned=%v", got, res.LockWritten, res.Pruned) + } + if got := lockedVersions(t, dir); got != "base 2.1.0,widgets 1.0.0" { + t.Fatalf("locked: %s", got) + } + if got := libNames(t, dir); got != "acme-widgets_1.0.0,base_2.1.0" { + t.Fatalf("lib/: %s", got) + } + // update of everything. + res, err = Update(context.Background(), Options{Root: dir}, nil) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "widgets 1.0.0 -> 1.1.0" || strings.Join(res.Pruned, ",") != "lib/acme-widgets_1.0.0" { + t.Fatalf("update all: %s pruned=%v", got, res.Pruned) + } + if got := libNames(t, dir); got != "acme-widgets_1.1.0,base_2.1.0" { + t.Fatalf("lib/: %s", got) + } + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 { + t.Fatalf("still outdated: %v %s", err, describeAll(res.Changes)) + } + // Nothing to move: update changes nothing and reinstalls nothing. + res, err = Update(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 || len(res.Installed) != 0 || res.LockWritten { + t.Fatalf("update at the top: %v %+v", err, res) + } + + // Refusals. + if _, err := Update(context.Background(), Options{Root: dir, Offline: true}, nil); err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("offline update: %v", err) + } + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{"git+file:///no/such/repo"}); err == nil || !strings.Contains(err.Error(), "no usage of") { + t.Fatalf("unknown usage: %v", err) + } + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + p.Usage = append(p.Usage, project.Usage{Resource: std}) + p.Write(dir) + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{std}); err == nil || !strings.Contains(err.Error(), "is not a repository") { + t.Fatalf("standard library named: %v", err) + } + // A new usage shows as new, a dropped one as dropped, in resource order. + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || describeAll(res.Changes) != "Systems-Library.kpar (new)" { + t.Fatalf("new usage: %v %q", err, describeAll(res.Changes)) + } +} diff --git a/tests/e2e/update_test.go b/tests/e2e/update_test.go new file mode 100644 index 0000000..b65e5d9 --- /dev/null +++ b/tests/e2e/update_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// looseProject rewrites the consuming .project.json so its one usage allows +// 1.0.0 up to (not including) 1.2.0, where the lock holds 1.0.0. +func looseProject(t *testing.T, app, res string) { + t.Helper() + write(t, app, ".project.json", "{\n \"name\": \"app\",\n \"version\": \"0.1.0\",\n \"usage\": [\n {\n \"resource\": \""+res+"\",\n \"versionConstraint\": \">=1.0.0 <1.2.0\"\n }\n ]\n}\n") +} + +func TestUpdateAndOutdated(t *testing.T) { + bin := syspiBinary(t) + root := t.TempDir() + gitRes := "git+file://" + filepath.ToSlash(gitFixture(t, root)) + + app := filepath.Join(root, "app") + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", gitRes+"@1.0.0") + looseProject(t, app, gitRes) + // Every other command keeps the lock entry. + sh(t, app, bin, "install") + sh(t, app, bin, "lock") + lockBefore, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + if !strings.Contains(string(lockBefore), `"version": "1.0.0"`) { + t.Fatalf("install moved the lock:\n%s", lockBefore) + } + + out := sh(t, app, bin, "outdated") + if out != "widgets 1.0.0 -> 1.1.0\n" { + t.Fatalf("outdated:\n%s", out) + } + var cs []map[string]string + if err := json.Unmarshal([]byte(sh(t, app, bin, "outdated", "-json")), &cs); err != nil { + t.Fatal(err) + } + if len(cs) != 1 || cs[0]["name"] != "widgets" || cs[0]["from"] != "1.0.0" || cs[0]["to"] != "1.1.0" || !strings.HasPrefix(cs[0]["resource"], "pkg:generic/widgets?vcs_url=") { + t.Fatalf("outdated -json: %v", cs) + } + if lockNow, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); string(lockNow) != string(lockBefore) { + t.Fatal("outdated wrote the lock") + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-widgets_1.1.0")); err == nil { + t.Fatal("outdated installed something") + } + if out, err := shErr(app, nil, bin, "outdated", "-offline"); err == nil || !strings.Contains(out, "offline") { + t.Fatalf("outdated -offline: %v\n%s", err, out) + } + + out = sh(t, app, bin, "update") + for _, want := range []string{"widgets 1.0.0 -> 1.1.0\n", "removed lib/acme-widgets_1.0.0\n", "1 package(s) locked: 1 installed", ".project.lock.json updated"} { + if !strings.Contains(out, want) { + t.Errorf("update lacks %q:\n%s", want, out) + } + } + entries, _ := os.ReadDir(filepath.Join(app, "lib")) + if len(entries) != 1 || entries[0].Name() != "acme-widgets_1.1.0" { + t.Fatalf("lib/ after update: %v", entries) + } + if lockNow, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); !strings.Contains(string(lockNow), `"version": "1.1.0"`) || !strings.Contains(string(lockNow), `"tag": "v1.1.0"`) { + t.Fatalf("lock after update:\n%s", lockNow) + } + if out := sh(t, app, bin, "outdated"); out != "every package is at the highest version its constraint allows\n" { + t.Fatalf("outdated after update:\n%s", out) + } + if out := sh(t, app, bin, "update", gitRes); !strings.HasPrefix(out, "every package is at the highest version its constraint allows; nothing moved\n") { + t.Fatalf("update at the top:\n%s", out) + } + if out, err := shErr(app, nil, bin, "update", "git+file:///no/such/repo"); err == nil || !strings.Contains(out, "no usage of") { + t.Fatalf("update of an unknown usage: %v\n%s", err, out) + } +} + +func TestUpdateAcrossVCS(t *testing.T) { + requireOrSkip(t, "hg", "svn", "svnadmin") + bin := syspiBinary(t) + root := t.TempDir() + for name, res := range map[string]string{ + "git": "git+file://" + filepath.ToSlash(gitFixture(t, root)), + "hg": "hg+file://" + filepath.ToSlash(hgFixture(t, root)), + "svn": "svn+file://" + strings.TrimPrefix(svnFixture(t, root), "file://"), + } { + app := filepath.Join(root, "app-"+name) + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", res+"@1.0.0") + looseProject(t, app, res) + if out := sh(t, app, bin, "outdated"); out != "widgets 1.0.0 -> 1.1.0\n" { + t.Errorf("%s outdated:\n%s", name, out) + } + out := sh(t, app, bin, "update", res) + if !strings.Contains(out, "widgets 1.0.0 -> 1.1.0\n") || !strings.Contains(out, "removed lib/acme-widgets_1.0.0\n") { + t.Errorf("%s update:\n%s", name, out) + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-widgets_1.1.0", "Widgets.sysml")); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} From 87728622012442a6ffcf9f1c97d5d30720a5b3f9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:42:49 +0000 Subject: [PATCH 3/5] fix(install): a lock-only run claims each lib/ destination too, so outdated and lock see the collision install refuses Two packages that would install under one name with different contents were refused only when installing; the dry run of outdated and lock itself passed them. Co-Authored-By: jason.han --- internal/install/install.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/internal/install/install.go b/internal/install/install.go index 8087b64..8c8f253 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -379,13 +379,13 @@ func (r *resolver) archivePath(p *project.Project) string { // installArchive stages a verified archive for lib/ unless an identical // copy is already there, and queues its usages. func (r *resolver) installArchive(e *lock.Entry, a *project.Archive, src string) error { - if r.opts.LockOnly { - return r.enqueueUsages(e, a.Project) - } dst := r.archivePath(a.Project) if err := r.claim(dst, e.Resource, a.SHA256); err != nil { return err } + if r.opts.LockOnly { + return r.enqueueUsages(e, a.Project) + } if cur, err := digest.File(dst); (err == nil && cur == a.SHA256) || r.alreadyStaged(dst) { r.res.Reused = append(r.res.Reused, e.Resource) return r.enqueueUsages(e, a.Project) @@ -484,10 +484,10 @@ func (r *resolver) reuseLocked(old *lock.Entry) (*lock.Entry, bool, error) { dst := filepath.Join(r.libDir, stem+project.ArchiveExt) cur, err := digest.File(dst) if r.opts.LockOnly || (err == nil && cur == old.SHA256) { + if err := r.claim(dst, old.Resource, old.SHA256); err != nil { + return nil, false, err + } if !r.opts.LockOnly { - if err := r.claim(dst, old.Resource, old.SHA256); err != nil { - return nil, false, err - } r.res.Reused = append(r.res.Reused, old.Resource) } e := *old @@ -689,6 +689,9 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error } if sel.Version.String() != old.Version { old = nil + } else { + // Already at the top: the locked tag, not another spelling of the same version. + sel = vcs.Version{Tag: old.Tag, Version: sel.Version} } } if old != nil && !r.opts.LockOnly { @@ -705,6 +708,9 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error } } if old != nil && r.opts.LockOnly { + if err := r.claim(treePath(r.libDir, &project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}), id, old.SHA256); err != nil { + return nil, err + } e := *old e.Resource, e.Source, e.Usage = id, o.Source, nil return &e, r.requeueLocked(&e, old) @@ -790,6 +796,10 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error return nil, fmt.Errorf("%s: usage %q: tag %s of %s exports tree SHA-256 %s, the lock recorded %s", req.requester, req.resource, sel.Tag, o.Source, sum, old.SHA256) } e := &lock.Entry{Resource: id, Kind: lock.KindVCS, Publisher: p.Publisher, Name: p.Name, Version: p.Version, VCS: o.VCS, Source: o.Source, Tag: sel.Tag, Revision: revision, SHA256: sum, MetaGenerated: !hasMeta} + dst := treePath(r.libDir, p) + if err := r.claim(dst, id, sum); err != nil { + return nil, err + } if r.opts.LockOnly { return e, r.enqueueUsages(e, p) } @@ -803,10 +813,6 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error } r.logf("%s %s: tag %s has no %s; generated one for the installed copy", p.Name, p.Version, sel.Tag, project.MetaFile) } - dst := treePath(r.libDir, p) - if err := r.claim(dst, id, sum); err != nil { - return nil, err - } if r.alreadyStaged(dst) { r.res.Reused = append(r.res.Reused, id) return e, r.enqueueUsages(e, p) From ab29be1ba96ac575fa09f83368d4e1bcd4cd9e01 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:42:49 +0000 Subject: [PATCH 4/5] fix(install): an entry already at the highest version keeps its locked tag When update finds the locked version is the highest allowed but another tag names the same version, the locked tag is used rather than the other spelling, so the revision check cannot fail against an unchanged entry. Co-Authored-By: jason.han --- internal/install/update_test.go | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/install/update_test.go b/internal/install/update_test.go index 717c7f4..c027b4f 100644 --- a/internal/install/update_test.go +++ b/internal/install/update_test.go @@ -139,3 +139,41 @@ func TestUpdateAndOutdated(t *testing.T) { t.Fatalf("new usage: %v %q", err, describeAll(res.Changes)) } } + +// A dry run sees the destination collision install would refuse, and an +// entry already at the top keeps its locked tag when another tag of the +// same version appears. +func TestOutdatedSeesCollisionsAndKeepsTheLockedTag(t *testing.T) { + widgets, base := fixture(t) + bres := "git+file://" + filepath.ToSlash(base) + twin := t.TempDir() + git(t, twin, "init", "-q", "-b", "main") + write(t, twin, ".project.json", `{"name":"base","version":"2.1.0"}`+"\n") + write(t, twin, "Other.kerml", "package Other;\n") + git(t, twin, "add", "-A") + git(t, twin, "commit", "-q", "-m", "2.1.0") + git(t, twin, "tag", "2.1.0") + dir := consumer(t, project.Usage{Resource: bres + "@2.1.0"}, project.Usage{Resource: "git+file://" + filepath.ToSlash(twin) + "@2.1.0"}) + for _, f := range []func() error{ + func() error { _, err := Outdated(context.Background(), Options{Root: dir}, nil); return err }, + func() error { _, err := Run(context.Background(), Options{Root: dir, LockOnly: true}); return err }, + } { + if err := f(); err == nil || !strings.Contains(err.Error(), "both install as lib/base_2.1.0 with different contents") { + t.Fatalf("collision not seen: %v", err) + } + } + + wres := "git+file://" + filepath.ToSlash(widgets) + dir = consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}) + run(t, dir, Options{}) + // Another tag naming the same version, on the commit without .project.json. + git(t, base, "tag", "v2.1.0", "3.0.0") + res, err := Update(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 || len(res.Installed) != 0 { + t.Fatalf("update with a twin tag: %v %+v", err, res) + } + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 { + t.Fatalf("outdated with a twin tag: %v %s", err, describeAll(res.Changes)) + } +} From 90fde9c10bc7bc119c08a0862b4bd25fed6468c4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:50:59 +0000 Subject: [PATCH 5/5] fix(install): the lock diff of update keys repositories with its own helper changes() used the resolver's lockKey; repositoryKey in update.go does the same so the branch does not depend on that helper's presence. Co-Authored-By: jason.han --- internal/install/install.go | 4 ++-- internal/install/update.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/install/install.go b/internal/install/install.go index 8c8f253..89c5a2e 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -641,7 +641,7 @@ func changes(old, fresh *lock.Lock) []Change { seen := map[string]bool{} for i := range fresh.Packages { e := &fresh.Packages[i] - k := lockKey(e.Resource) + k := repositoryKey(e.Resource) seen[k] = true c := Change{Resource: e.Resource, Name: e.Name, To: e.Version} if o := old.Find(k); o != nil { @@ -654,7 +654,7 @@ func changes(old, fresh *lock.Lock) []Change { } for i := range old.Packages { e := &old.Packages[i] - if !seen[lockKey(e.Resource)] { + if !seen[repositoryKey(e.Resource)] { out = append(out, Change{Resource: e.Resource, Name: e.Name, From: e.Version}) } } diff --git a/internal/install/update.go b/internal/install/update.go index ff5da0e..b61c2fc 100644 --- a/internal/install/update.go +++ b/internal/install/update.go @@ -60,6 +60,16 @@ func selectUpdates(opts *Options, resources []string) error { return nil } +// repositoryKey is what two lock entries for one repository share however +// the .git suffix was spelled: origin.Key for a repository resource, the +// resource itself otherwise. +func repositoryKey(resource string) string { + if o, err := origin.Parse(resource); err == nil && o.Kind == origin.VCS { + return o.Key() + } + return resource +} + // Describe is the one-line report of a change: "name old -> new", "name new (new)" // or "name old (dropped)". func (c *Change) Describe() string {