Files
ng-eventually/.tasks/done/T01.l-pnpm-everywhere-and-two-loose-ranges.md
T
Sylvain Duchesne ab5980c257 chore: T01.l est close - pnpm partout, la portee du pair durcie, pnpm link verifie
Le critere d-acceptation a ete execute, pas raisonne : lien reel contre le
checkout, resolution confirmee par readlink, defaire par `pnpm remove`. Le
polyfill lui-meme n-est jamais duplique par le lien ; le SDK en dessous l-est
des que l-application importe `@ng-org/web` elle aussi - 2 copies mesurees.
2026-09-24 12:14:42 +02:00

143 lines
15 KiB
Markdown

---
id: T01.l
title: pnpm everywhere, and two dependency ranges that are looser than they look
parent: T01
depends_on: [T01.j]
created: 2026-09-21
claimed_by: worker-T01.l
claimed_at: 2026-09-24
blocked: false
---
## Description
Four items. The first two are dependency hygiene found by T01.h; the last two finish the move to a single package manager, whose POINT is that `pnpm link` works for local development against a checked-out dependency.
### 1. `ng-e2e-helpers`' peer range admits a package nobody wants
`packages/ng-e2e-helpers/package.json` declares a peer range `>=0.1.2-alpha.13` on `@ng-org/*`. Those packages also publish a bare `0.1.2`, dating from 2025, which semver ranks ABOVE every `0.1.2-alpha.N` - so that range would happily resolve to a year-old release. Exact pins exclude it TODAY, which is precisely what makes it a trap: it is inert until someone loosens a pin or installs fresh against a different resolver.
Fix the range so it cannot admit the bare `0.1.2`. VERIFY the fix by checking what the range actually resolves to, not by reading it - a range that looks right and resolves wrong is the whole failure mode here. Check whether the same shape exists on any other declared range in either package while you are there.
### 2. `playwright` is behind
`1.61.1` -> `1.63.0`. Bump it, reinstall, and RUN the e2e suite - a Playwright bump is exactly the kind of change that is invisible to a typecheck and visible to a browser. Report the counts verbatim. If the broker is unreachable, report the suite as UNRUN; never substitute a typecheck for the measurement. `.project/concepts/e2e-harness/caveat_a-dropped-pipe-kills-a-run.md` describes a Chromium control-pipe failure this suite already lives with - read it before concluding that a new red run is caused by the bump.
### 3. Two places still describe `bun install`'s semantics
`packages/polyfill/e2e/orm-live-read.ts:159` and `.project/concepts/e2e-harness/knowledge_what-each-suite-judges.md:14` both explain that `@ng-org/orm` is an optional peer that "`bun install` does not fetch". That is no longer the repo's install path, and pnpm does not treat optional peers the same way. ESTABLISH what pnpm actually does here - install fresh and look - then correct both texts to say the true thing. If it turns out pnpm DOES fetch it, that changes what the ORM live-read runner needs to say about itself, so say so rather than editing the sentence to match a guess.
### 4. Make bun unnecessary for installing and typechecking
Root `package.json:13` and `packages/ng-e2e-helpers/package.json:23` run `bunx tsc`. Nothing installs with bun anywhere in this repo - VERIFIED, there is no `bun install` in any script and no CI exists - and `packageManager: pnpm@10.26.0` is declared. But `bunx` still requires a contributor to have bun on their machine to typecheck. Move those to pnpm's equivalent.
**Explicitly NOT in scope: `bun test`.** It is the test RUNNER, not an installer, and it does not block `pnpm link`. Changing it is a separate decision with its own cost, and nothing here needs it. If you find evidence that it DOES block something, report that rather than acting on it.
### The acceptance criterion, which is the reason for all of this
`pnpm link` must work for local development against a checked-out dependency. VERIFY IT - actually perform a link against a local checkout and confirm the consuming package resolves to it, then undo. Do not reason about whether it should work. And check the thing that would silently ruin it: that linking does not produce TWO copies of a state-carrying package in one tree. The polyfill holds a dozen module-level state holders, so two copies in one application means two registries and two identities - that hazard is named in its own published contract.
### Verification
**Hard gate:** `bun test` -> 0 fail; `bunx tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0 (or its pnpm equivalent once item 4 lands); `bunx tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> exit 0; the example's own typecheck -> exit 0; `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR; plus the e2e counts from item 2.
Anti-cheat: no `any`, no `@ts-ignore`, no deleted or skipped test, no weakened assertion, and no LOOSENING a range to make an install succeed - item 1 is about tightening, and a fix that widens something else to compensate is not a fix.
## Progress
## Result
Status: done
Commits: `a30f1c4` (item 1), `6dad05b` (item 4), `d4d7a4e` (item 2), `82d41b2` (item 3).
### 1. The peer range - done, VERIFIED BY RESOLUTION
`packages/ng-e2e-helpers/package.json`: `"@ng-org/web": ">=0.1.2-alpha.13"` -> `">=0.1.2-alpha.13 <0.1.2"`.
Measured, not read. Registry query `pnpm view '@ng-org/web@<range>' version`:
| range | versions admitted | `semver.maxSatisfying` | pnpm resolves to |
|---|---|---|---|
| `>=0.1.2-alpha.13` (before) | **4** - alpha.13, alpha.14, alpha.15, **`0.1.2`** | **`0.1.2`** | `0.1.2-alpha.15` |
| `>=0.1.2-alpha.13 <0.1.2` (after) | **3** - alpha.13, alpha.14, alpha.15 | `0.1.2-alpha.15` | `0.1.2-alpha.15` |
The trap was real and it was masked by a second mechanism, which is worth recording because the task's premise was only half right. `0.1.2` was published 2025-10-19; every alpha is 2026, alpha.15 on 2026-09-23. `semver.maxSatisfying` on the old range returns `0.1.2` - the year-old release. But pnpm did NOT pick it: the `latest` dist-tag is `0.1.2-alpha.15`, and pnpm prefers `latest` when it satisfies the range. So the old range resolved correctly ONLY because upstream's `latest` happens to point at an alpha. The day upstream moves `latest` to `0.1.2`, the old range follows it and the new one cannot.
The tightening changed nothing in `pnpm-lock.yaml` - the peer range of a workspace importer does not appear there, confirming T01.h's reading that the declaration was latent.
**The same shape found elsewhere, NOT fixed - it is a published-surface decision, reported instead.** `packages/polyfill/package.json:11-16` declares all four `@ng-org/*` peers as `"*"`. Measured: `*` admits **exactly one** of `@ng-org/web`'s 16 published versions - the bare `0.1.2` - and **excludes every alpha**, because node-semver's `*` is `>=0.0.0` and does not match prereleases. That is the same inversion, worse. Two facts bound its severity: pnpm emits **no** unmet-peer warning for it (a scratch consumer declaring alpha.14 + alpha.21 installed clean and pnpm bound the peers anyway), and neither package is published (`pnpm view` -> E404 for both). So it is inert today under pnpm. Tightening four optional peer ranges on a published surface is a compatibility decision, not this task's named scope.
Two other loose declarations, reported not touched: `ng-e2e-helpers` peer `playwright: ">=1.40.0"` admits any future major (ordinary upward looseness, not the prerelease inversion - playwright's prereleases carry a different version tuple so the range excludes them); root `@types/bun: "latest"` is an unpinned floating dist-tag.
### 2. playwright 1.61.1 -> 1.63.0 - done, e2e RUN
Bumped in root `package.json` and `packages/ng-e2e-helpers/package.json`, lockfile updated. `chromium-1243` (what 1.63.0 wants) was already in `~/.cache/ms-playwright`; `playwright install chromium` was a no-op and `chromium.executablePath()` exists.
**Polyfill e2e on 1.63.0, verbatim:** `== Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.3 min, slowest cold sync 30s ==`. Baseline `46/46` matched on the first run - no broker-login flake, no dropped pipe, `caveat_a-dropped-pipe-kills-a-run` not needed.
Two side effects of the bump worth knowing: 1.63.0 raises `engines.node` from `>=18` to `>=20` (this host runs v22.16.0), and it no longer declares `fsevents` as an optionalDependency, which is why that entry left the lockfile.
### 3. What pnpm actually does with the optional `@ng-org/orm` peer - MEASURED, and it changed what the texts had to say
Two facts, both from installs, not reading:
- **For a consumer, pnpm does not fetch it.** A scratch package depending only on `@ng-eventually/polyfill` (from `pnpm pack`) installed **zero** `@ng-org` packages, with no warning. `autoInstallPeers` is on by default in pnpm 10 and skips OPTIONAL peers. So the substance of the old claim survives the change of package manager: the tool name was wrong, the semantics were not.
- **For THIS checkout, pnpm DOES fetch it** - and that is the part both texts got wrong. `packages/polyfill/package.json:33` lists `@ng-org/orm: 0.1.2-alpha.21` in **devDependencies**. After `rm -rf node_modules && pnpm install --frozen-lockfile`, `packages/polyfill/node_modules/@ng-org/orm` is present and `bun build e2e/orm-live-entry.ts` succeeds (`Bundled 57 modules in 22ms`).
So the runner's advice - "install it in packages/polyfill before running this measurement" - had become a false lead: a plain install already has it, and a failed bundle means something else. Both texts now say the optional peer governs what a **consumer** receives, not what this repository installs, and the runner points at `pnpm install` instead.
### 4. bun unnecessary to install or typecheck - done, VERIFIED with bun off the PATH
`bunx tsc` -> `pnpm exec tsc` in root `package.json:13` and `packages/ng-e2e-helpers/package.json:23`. `pnpm exec` finds `tsc` from the helpers package too (pnpm walks up to the workspace root's `node_modules/.bin`), so no local `typescript` devDependency was needed.
Proof rather than argument: with `~/.bun/bin` removed from `PATH` and `command -v bun` empty, both `pnpm run typecheck` exit 0.
`bun test` untouched, as instructed. **Evidence that bun is still required for one thing, reported not acted on:** `packages/polyfill/e2e/orm-live-read.ts:128` shells out to `bun build` to bundle the page, and `packages/ng-e2e-helpers/package.json` `mint-wallet` runs `bun run`. Neither installs nor typechecks, and neither blocks `pnpm link`, so both are out of this task's scope - but "bun is not needed" is true only of installing and typechecking, not of running the e2e bundler.
### The acceptance criterion - PERFORMED, and it passes with one measured caveat
`pnpm link` works. A scratch application ran `pnpm link <repo>/packages/polyfill`; `node_modules/@ng-eventually/polyfill` is a symlink whose `readlink` is the checkout, `/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill`, and `pnpm remove @ng-eventually/polyfill` undoes it cleanly (specifier gone, symlink gone, checkout unharmed).
**The duplication check, which is the thing that could have ruined it.** pnpm warns at link time, by name:
> `The package @ng-eventually/polyfill, which you have just pnpm linked, has the following peerDependencies specified in its package.json: @ng-org/web@*, @ng-org/orm@*, @ng-org/shex-orm@*, @ng-org/alien-deepsignals@* - The linked in dependency will not resolve the peer dependencies from the target node_modules.`
Measured by resolving each specifier from both importers and comparing `realpath`:
| variant | polyfill copies | `@ng-org/web` copies | `@ng-org/orm` copies |
|---|---|---|---|
| `pnpm link`, app also imports the SDK itself | **1** (the checkout) | **2** | **2** |
| `pnpm link`, app declares nothing but the polyfill | **1** | 1 | 1 |
| `file:` protocol, app also imports the SDK | 1 | 1 | 1 |
**The state-carrying package named in `contract_polyfill-surface.md:254` is never duplicated by the link** - exactly one polyfill, the checkout. That hazard is not tripped. But the SDK *underneath* it is: when the application also imports `@ng-org/web` directly (as `e2e/orm-live-entry.ts:20` does), the link leaves the app on its own store copy and the linked polyfill on the repo's - same version, two physical copies, so two `init()`s and two sessions. `file:` avoids it but repacks the polyfill into the app's store, which forfeits live editing, so it is not a substitute for a link during development.
Actionable form for a contributor: **link the polyfill and let it be the only thing that names `@ng-org/*`**, or accept a second SDK copy. This is not fixed here - it follows from the `"*"` optional peers, so it is the same open decision as item 1's second finding.
### Hard gate, verbatim, on the committed state after `rm -rf node_modules && pnpm install --frozen-lockfile`
- `bun test` -> `449 pass / 0 fail / 1253 expect() calls`, 40 files. Baseline `449 pass / 0 fail` matched **before** any change as well.
- `pnpm exec tsc --noEmit -p packages/polyfill/tsconfig.json` -> `EXIT=0`
- `pnpm exec tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> `EXIT=0`
- example's own typecheck (`cd examples/notebook && pnpm exec tsc --noEmit -p tsconfig.json`) -> `EXIT=0`
- `python3 ~/.claude/skills/concept/lint.py` -> **0 ERROR** (6 pre-existing WARNING groups, none on the leaf edited here)
- polyfill e2e -> `46 passed, 0 failed, 46 total`
- fresh install: 113 packages, **one** copy of each `@ng-org/*`, **one** copy of `playwright`
### Surprises
- **VERIFIED:** the item-1 premise was half right. The old range *admits* `0.1.2` (`maxSatisfying` = `0.1.2`) but pnpm did not *resolve* to it, because it prefers the `latest` dist-tag and upstream's `latest` is `0.1.2-alpha.15`. The trap was masked by upstream's tagging, not by the exact pins alone - so it was one dist-tag move from firing, and only resolution testing showed that.
- **VERIFIED:** `@ng-org/orm` is the one package of the four with **no** bare `0.1.2` (alphas up to alpha.22 only). `web`, `shex-orm` and `alien-deepsignals` all have it, matching T01.h.
- **VERIFIED:** `pnpm unlink @ng-eventually/polyfill` answers `Nothing to unlink` for a link created by `pnpm link <path>`. The undo that works is `pnpm remove @ng-eventually/polyfill`.
- **VERIFIED:** `@ng-org/alien-deepsignals` reaches a consumer that never names it - it is a hard dependency of `@ng-org/orm`, exact-pinned at `0.1.2-alpha.13`, not an auto-installed peer.
- **VERIFIED:** immediately after the in-place playwright install, `node_modules/.pnpm` still held an orphaned `playwright@1.61.1` (0 lockfile references, 0 symlinks pointing at it). A fresh install cleared it. Cosmetic, but it is the same orphan shape T01.i reported for `@ng-org/web@0.1.2-alpha.13`.
### Found broken, left alone
- `packages/polyfill/package.json`'s four `"*"` optional peers admit only the bare `0.1.2` and exclude every published alpha (see item 1). Inert under pnpm today; it is a published-surface decision.
- `examples/notebook/package.json` has **no** `typecheck` script, so "the example's own typecheck" has no script to run - it has to be typed out by hand. Item 4 named only two scripts, so none was added.
- Concept lint's 6 pre-existing WARNING groups: 3 oversized leaves, 2 open `_debt.md`, 1 unlisted `usage_` declaration, 5 banned aliases in the concepts' own doctrine.
- `bun build` in the ORM live-read runner and `bun run` in `mint-wallet` still require bun (item 4's scope was installing and typechecking).
Not touched: `docs/briefs/2026-08-10-consumer-harness-identity-switching.md`, T01.m's and T01.k's files, `ng-helpers`. `bun test` left as the runner.