package manager: load two majors of one package side by side #324

Closed
opened 2026-10-12 08:24:56 +00:00 by orion · 8 comments
Collaborator

Context

Slice 2 of 3 of #28. After the resolver and lock can hold two majors, the workspace and the checker still identify a package by its name (workspace.packageNamed, use specifier lookup), so the two majors cannot be loaded side by side.

Design

docs/design/PROJECT.md section 6a: "two majors would need a package identity of name and major and per-package resolution of use specifiers".

Scope

  • compiler/src/project/workspace.trb: load every locked package, identity (name, major); the Package list may hold two entries of one name.
  • Specifier resolution (compiler/src/semantics/scope.trb, graph.trb): use ... from "acme/json" resolves to the major that the importing package's own dependencies line requires. Source files stay unchanged (no version in the specifier); a package has one requirement per name, so the choice is unambiguous. Packages looked up by name for the toolchain (std/*, prelude) keep their single version.
  • Diagnostics: "not a dependency of" and "no module" messages name the major when two exist.
  • The checker keeps the modules and types of the two majors distinct (a acme/json 1 Value is not an acme/json 2 Value).

Acceptance criteria

  • A fixture project with two path or locked packages x and y, x depending on acme/json ^1 and y on acme/json ^2, checks without errors; each resolves its own use "acme/json".
  • Passing a major-1 value where major 2 is expected is a type error that names both.
  • A package that is not a dependency of the importer still gets the existing "not a dependency" error.
  • Single-major graphs behave as before (existing workspace, scope and checker tests unchanged).

Seed impact

None expected (no syntax, native or driver flag; the compiler looks up std/* by name only, which keep one version). If the work finds a string lookup of a package name that must become major-aware in the seed path, state it in the PR under the two-commit rule.

Dependencies

Needs slice 1 (#323). Size: one PR (feat(project): load two majors of one package side by side).

## Context Slice 2 of 3 of #28. After the resolver and lock can hold two majors, the workspace and the checker still identify a package by its name (`workspace.packageNamed`, `use` specifier lookup), so the two majors cannot be loaded side by side. ## Design [docs/design/PROJECT.md section 6a](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/PROJECT.md#6a-requirements-and-resolution): "two majors would need a package identity of name and major and per-package resolution of `use` specifiers". ## Scope - `compiler/src/project/workspace.trb`: load every locked package, identity `(name, major)`; the `Package` list may hold two entries of one name. - Specifier resolution (`compiler/src/semantics/scope.trb`, `graph.trb`): `use ... from "acme/json"` resolves to the major that the importing package's own `dependencies` line requires. Source files stay unchanged (no version in the specifier); a package has one requirement per name, so the choice is unambiguous. Packages looked up by name for the toolchain (`std/*`, prelude) keep their single version. - Diagnostics: "not a dependency of" and "no module" messages name the major when two exist. - The checker keeps the modules and types of the two majors distinct (a `acme/json` 1 `Value` is not an `acme/json` 2 `Value`). ## Acceptance criteria - [ ] A fixture project with two path or locked packages `x` and `y`, `x` depending on `acme/json ^1` and `y` on `acme/json ^2`, checks without errors; each resolves its own `use "acme/json"`. - [ ] Passing a major-1 value where major 2 is expected is a type error that names both. - [ ] A package that is not a dependency of the importer still gets the existing "not a dependency" error. - [ ] Single-major graphs behave as before (existing workspace, scope and checker tests unchanged). ## Seed impact None expected (no syntax, native or driver flag; the compiler looks up `std/*` by name only, which keep one version). If the work finds a string lookup of a package name that must become major-aware in the seed path, state it in the PR under the two-commit rule. ## Dependencies Needs slice 1 (#323). Size: one PR (`feat(project): load two majors of one package side by side`).
Author
Collaborator

[loop:torbscript] refine: slice 2 of 3 of #28, specified; next: waiting (dependency #323)

[loop:torbscript] refine: slice 2 of 3 of #28, specified; next: waiting (dependency #323)
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261013 until 2026-10-13T09:07Z

[loop:torbscript] claim refine by session:loop-torbscript-20261013 until 2026-10-13T09:07Z
Author
Collaborator

[loop:torbscript] decided by the loop: refinement of the scope after reading main (e1d13b07 landed #323). No owner question: this is the internal identity of a package in the toolchain, the language and the published contract do not change, and single-major graphs behave as before. The points below add to the issue text and win where they differ from it.

Where the code stands (checked on main)

  • Workspace.packageNamed(name) returns the first package of that name; withLockedPackages (compiler/src/project/workspace.trb) skips a locked entry when packageNamed(locked.name) is already there, so the second major of a name is dropped today.
  • resolveImport (compiler/src/semantics/graph.trb) is the one choke point for use specifiers (scope, checker, language server and the reachability walk all call it), plus packageIndexNamed in programPackagesOf/noteImports/the reached-package walk, which also look a package up by name only.
  • Symbols and types are per module already, so once the two majors are two separate modules the checker keeps their types apart without further work. What is missing is only how they are printed: describeType writes the bare symbol name, so a mismatch reads "expected Value, found Value".

Decisions

  1. Identity is (name, version) of the Package, no new field. An installed registry package already carries its version in manifest.version (from its own lock's settings). Two entries of one name can only come from the registry: a source line (path:, archive:, git:) names one location per package. So the fixture uses two locked registry packages, not path packages (the acceptance criterion below says so).
  2. withLockedPackages keys the "already there" check by name and exact locked version, so both majors are added. The lock-side checks of the same file (lock.locked(name) for "a dependency of a locked package the lock lacks", and the two source checks) use the importer's requirement (lock.lockedWithin) instead of the first entry.
  3. Per-importer lookup is a method on Workspace (design rule: methods, no free wrapper), for example dependencyOf(importer: Package, name: String): Package?: the candidates are the packages of that name; with one candidate it is that one (all path:, archive:, git: and single-major cases, so nothing changes there); with several, the importer's own requirement line for the name (manifest.runtimeRequirements then developmentRequirements, for members and for installed packages alike, since a package's lock settings carry the runtime lines) picks the candidates whose version it allows. Tie rule: if a requirement spanning majors (>=1 <3) allows both locked majors, the highest wins, which is what the resolver prefers (PROJECT.md 6a). If the importer has no requirement for it, the first candidate (the lookup was by name before). packageNamed stays for the toolchain lookups (std/*, preludes, std/project, std/sandbox, std/encoding), which keep one version.
  4. resolveImport, noteImports, programPackagesOf and the reached-package walk go through the new method with the importing package, so the closed world of a program contains the majors its packages actually reach (both, when both are reached), and isAvailable stays "the importer depends on the name".
  5. Diagnostics name the major only when the workspace holds two of the name, in the form #323 already uses (acme/json 2.x): NotADependency, NoModuleInPackage and the "has no module" text (the Import cases carry the label). Single-major messages stay byte-identical.
  6. Mismatch printing: where a type mismatch message would print two different nominal types with the same text, both are printed qualified with their package and major (acme/json 1.x Value, acme/json 2.x Value); everything else stays as it is. Implementation is the implementer's call (a check in the one place that builds the "expected X, found Y" text, using the symbol's module to reach the package), no change of describeType's other 8 callers.
  7. No miscompile in the meantime. ir/instantiate.trb (SymbolPath package.name) and the mangling in ir/lower still identify a package by name until #325, so two majors would collide silently in build/run. This PR adds a guard at the entry of lowering: a program that reaches two packages of one name is refused with acme/json is in the program in two majors, which build and run cannot keep apart yet (#325); check is unaffected. #325 removes the guard. The guard is the second commit if the first gets large.
  8. Seed: none. Only compiler sources written in existing TorbScript change (no syntax, native, driver flag or string lookup of a non-std name: the std/* lookups stay by name). The PR states "seed impact: none".

Acceptance criteria (replaces the list in the issue)

  • Workspace test (next to lockedTree in compiler/tests/packages.test.trb): a project with members/locked packages x (acme/json:^1) and y (acme/json:^2), a lock with acme/json 1.x and 2.x, both unpacked in the cache: findWorkspace has no problems and holds two acme/json packages (1.x and 2.x); a lock missing either major still names torb update.
  • Checker test on that fixture: x and y each use "acme/json" and resolve to their own major (different module ids, different files); torb check is clean.
  • A value of the major-1 Value passed where the major-2 Value is expected is a type error whose text names both (acme/json 1.x Value, acme/json 2.x Value); two same-named types of one major still print bare.
  • A requirement spanning majors (>=1 <3) with both locked resolves to the higher one (workspace test).
  • A package that is not a dependency of the importer still gets "not a dependency" (naming the major when two exist); a missing module in a double package names the major.
  • build/run of the fixture refuses with the guard message (test), never produces a binary.
  • Single-major graphs: existing workspace, scope, checker, language-server and lock tests unchanged; no message of a single-major graph changes.
  • docs: PROJECT.md 6a "does not build yet" paragraph updated to say what #324 did and what #325 still has to; skills/ regenerated if the docs change requires it.

Size: one PR, two Conventional Commits: feat(project): load two majors of one package side by side (workspace, graph, scope diagnostics, tests) and feat(checker): name both majors in a type mismatch and refuse to lower them (items 6 and 7, docs). Touches compiler sources and docs/design/PROJECT.md, not seed/. Implementer: opus/high (checker).

[loop:torbscript] decided by the loop: refinement of the scope after reading `main` (e1d13b07 landed #323). No owner question: this is the internal identity of a package in the toolchain, the language and the published contract do not change, and single-major graphs behave as before. The points below add to the issue text and win where they differ from it. **Where the code stands (checked on `main`)** - `Workspace.packageNamed(name)` returns the first package of that name; `withLockedPackages` (`compiler/src/project/workspace.trb`) skips a locked entry when `packageNamed(locked.name)` is already there, so the second major of a name is dropped today. - `resolveImport` (`compiler/src/semantics/graph.trb`) is the one choke point for `use` specifiers (scope, checker, language server and the reachability walk all call it), plus `packageIndexNamed` in `programPackagesOf`/`noteImports`/the reached-package walk, which also look a package up by name only. - Symbols and types are per module already, so once the two majors are two separate modules the checker keeps their types apart without further work. What is missing is only how they are *printed*: `describeType` writes the bare symbol name, so a mismatch reads "expected `Value`, found `Value`". **Decisions** 1. **Identity is `(name, version)` of the `Package`, no new field.** An installed registry package already carries its version in `manifest.version` (from its own lock's `settings`). Two entries of one name can only come from the registry: a `source` line (`path:`, `archive:`, `git:`) names one location per package. So the fixture uses two locked registry packages, not path packages (the acceptance criterion below says so). 2. **`withLockedPackages` keys the "already there" check by name and exact locked version**, so both majors are added. The lock-side checks of the same file (`lock.locked(name)` for "a dependency of a locked package the lock lacks", and the two `source` checks) use the importer's requirement (`lock.lockedWithin`) instead of the first entry. 3. **Per-importer lookup is a method on `Workspace`** (design rule: methods, no free wrapper), for example `dependencyOf(importer: Package, name: String): Package?`: the candidates are the packages of that name; with one candidate it is that one (all `path:`, `archive:`, `git:` and single-major cases, so nothing changes there); with several, the importer's own requirement line for the name (`manifest.runtimeRequirements` then `developmentRequirements`, for members and for installed packages alike, since a package's lock `settings` carry the `runtime` lines) picks the candidates whose version it allows. **Tie rule:** if a requirement spanning majors (`>=1 <3`) allows both locked majors, the highest wins, which is what the resolver prefers (PROJECT.md 6a). If the importer has no requirement for it, the first candidate (the lookup was by name before). `packageNamed` stays for the toolchain lookups (`std/*`, preludes, `std/project`, `std/sandbox`, `std/encoding`), which keep one version. 4. **`resolveImport`, `noteImports`, `programPackagesOf` and the reached-package walk go through the new method with the importing package**, so the closed world of a program contains the majors its packages actually reach (both, when both are reached), and `isAvailable` stays "the importer depends on the name". 5. **Diagnostics name the major only when the workspace holds two of the name**, in the form #323 already uses (`acme/json 2.x`): `NotADependency`, `NoModuleInPackage` and the "has no module" text (the `Import` cases carry the label). Single-major messages stay byte-identical. 6. **Mismatch printing:** where a type mismatch message would print two different nominal types with the same text, both are printed qualified with their package and major (`acme/json 1.x Value`, `acme/json 2.x Value`); everything else stays as it is. Implementation is the implementer's call (a check in the one place that builds the "expected X, found Y" text, using the symbol's module to reach the package), no change of `describeType`'s other 8 callers. 7. **No miscompile in the meantime.** `ir/instantiate.trb` (`SymbolPath package.name`) and the mangling in `ir/lower` still identify a package by name until #325, so two majors would collide silently in `build`/`run`. This PR adds a guard at the entry of lowering: a program that reaches two packages of one name is refused with `acme/json is in the program in two majors, which build and run cannot keep apart yet (#325)`; `check` is unaffected. #325 removes the guard. The guard is the second commit if the first gets large. 8. **Seed: none.** Only compiler sources written in existing TorbScript change (no syntax, native, driver flag or string lookup of a non-`std` name: the `std/*` lookups stay by name). The PR states "seed impact: none". **Acceptance criteria (replaces the list in the issue)** - [ ] Workspace test (next to `lockedTree` in `compiler/tests/packages.test.trb`): a project with members/locked packages `x` (`acme/json:^1`) and `y` (`acme/json:^2`), a lock with `acme/json` 1.x and 2.x, both unpacked in the cache: `findWorkspace` has no problems and holds two `acme/json` packages (1.x and 2.x); a lock missing either major still names `torb update`. - [ ] Checker test on that fixture: `x` and `y` each `use "acme/json"` and resolve to their own major (different module ids, different files); `torb check` is clean. - [ ] A value of the major-1 `Value` passed where the major-2 `Value` is expected is a type error whose text names both (`acme/json 1.x Value`, `acme/json 2.x Value`); two same-named types of one major still print bare. - [ ] A requirement spanning majors (`>=1 <3`) with both locked resolves to the higher one (workspace test). - [ ] A package that is not a dependency of the importer still gets "not a dependency" (naming the major when two exist); a missing module in a double package names the major. - [ ] `build`/`run` of the fixture refuses with the guard message (test), never produces a binary. - [ ] Single-major graphs: existing workspace, scope, checker, language-server and lock tests unchanged; no message of a single-major graph changes. - [ ] docs: PROJECT.md 6a "does not build yet" paragraph updated to say what #324 did and what #325 still has to; `skills/` regenerated if the docs change requires it. **Size:** one PR, two Conventional Commits: `feat(project): load two majors of one package side by side` (workspace, graph, scope diagnostics, tests) and `feat(checker): name both majors in a type mismatch and refuse to lower them` (items 6 and 7, docs). Touches compiler sources and `docs/design/PROJECT.md`, not `seed/`. Implementer: opus/high (checker).
Author
Collaborator

[loop:torbscript] refine: read main after #323, checked the identity and use lookup sites, specified the per-importer lookup, the diagnostics and the mismatch printing, added a guard against a silent miscompile until #325, replaced the acceptance criteria (see the decided-by-the-loop comment above); no owner question, one PR in two commits, no seed impact. Label ready. Worker tokens: ~120k, next: implement

[loop:torbscript] refine: read `main` after #323, checked the identity and `use` lookup sites, specified the per-importer lookup, the diagnostics and the mismatch printing, added a guard against a silent miscompile until #325, replaced the acceptance criteria (see the decided-by-the-loop comment above); no owner question, one PR in two commits, no seed impact. Label `ready`. Worker tokens: ~120k, next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261013 until 2026-10-13T13:10Z

[loop:torbscript] claim implement by session:loop-torbscript-20261013 until 2026-10-13T13:10Z
Author
Collaborator

[loop:torbscript] release implement: the implement worker session was killed at ~07:14Z (gateway restart, no push, no branch, nothing to recover); respawning a fresh worker

[loop:torbscript] release implement: the implement worker session was killed at ~07:14Z (gateway restart, no push, no branch, nothing to recover); respawning a fresh worker
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261013 until 2026-10-13T15:50Z

[loop:torbscript] claim implement by session:loop-torbscript-20261013 until 2026-10-13T15:50Z
Author
Collaborator

[loop:torbscript] implement: PR #360 (two commits, Closes #324), forge CI green (gates, bootstrap, tier A/B, same C on every host, playground). Two majors are two packages with a per-importer lookup (Workspace.dependencyOf), the checker names both majors in a mismatch, and lowering refuses with the #325 message so no binary is produced. Docs: PROJECT.md 6a and RELEASE.md; skills/ needed no regeneration (docs skill --check green). Seed impact: none. Tokens: about 400k in this worker session, including two CI fix rounds (call-form parse errors, formatter). next: review

[loop:torbscript] implement: PR https://git.torb.dev/torbscript/language/pulls/360 (two commits, `Closes #324`), forge CI green (gates, bootstrap, tier A/B, same C on every host, playground). Two majors are two packages with a per-importer lookup (`Workspace.dependencyOf`), the checker names both majors in a mismatch, and lowering refuses with the #325 message so no binary is produced. Docs: PROJECT.md 6a and RELEASE.md; `skills/` needed no regeneration (`docs skill --check` green). Seed impact: none. Tokens: about 400k in this worker session, including two CI fix rounds (call-form parse errors, formatter). next: review
argus closed this issue 2026-10-13 10:33:03 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#324
No description provided.