Approvals on PRs from forks never got the `reviewed: coderabbit` label or a
Discord announcement. A pull_request_review run triggered by a forked PR is
granted a read-only token and no secrets, so the label step failed with HTTP
403 and the Discord step had no webhook.
Split the automation in two: the pull_request_review workflow now only records
the approval as an artifact (no token, no secrets), and a new workflow_run
companion runs from the default branch in base-repo context — where it has the
write-scoped token and secrets — to add the label and post to Discord.
The privileged half never checks out or executes PR content: it reads inert
data files, validates the PR number is an integer, maps the reviewer to a fixed
label, requests only actions:read + issues:write, surfaces a failed (vs absent)
artifact download instead of passing as a silent no-op, and scopes the Discord
webhook secret to the announce step.
Also documents that agents must open PRs using .github/pull_request_template.md,
since gh pr create does not apply it automatically.
* chore: add git hook rejecting bare `@mentions` in commit messages
* fix(husky): align bare-mention boundary with GitHub linkification rules
* fix(husky): keep mention boundaries intact when stripping code spans
* fix(husky): ignore the verbose-commit diff below the scissors line
* refactor(husky): split bare-mention hook into small single-purpose functions
* fix(husky): exempt tilde-fenced code blocks from mention scanning
* fix(husky): trim trailing punctuation from the reported scoped handle
* refactor(husky): detect bare mentions by scanning, not regex
Replace the single packed detection regex with a plain character scan and
small predicate functions: for each `@`, exempt it when it sits inside
backticks (odd backtick count before it), when it is not followed by an
ASCII letter/digit, or when it is preceded by one (email-like). This is
easier to read and reason about than the regex.
Side effect: the `~~~` tilde-fence special case is gone, so a mention inside
a `~~~` block is now flagged. Backtick code spans and fences are still
exempt.
* docs(husky): note readHandle precondition that the char after @ is alphanumeric
* refactor(husky): spell out abbreviated identifiers in the mention hook
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: reject bare #NNN issue refs in commit messages
Add a commit-msg hook (.husky/reject-bare-issue-refs.mjs) that rejects bare
'#NNN' references, which GitHub silently links to issues/PRs of this repo and
which AIs frequently misuse. Require qualified 'owner/repo#NNN' syntax or
absolute URLs instead, and document the rule in AGENTS.md/CLAUDE.md.
* docs: instruct agents to install git hooks before committing
---------
Co-authored-by: Claude <noreply@anthropic.com>
* perf(fs,package-manager): striped CAS lock + skip pre-flight stat on fresh-target imports
Two install-phase syscall trims:
- `cas_write_lock` swaps the per-path `DashMap<PathBuf, Arc<Mutex<()>>>`
for 256 static `Mutex<()>` stripes keyed by hashed path. Every CAFS
write previously paid one `PathBuf::to_path_buf` allocation, a
`DashMap` shard write lock, plus an `Arc<Mutex<()>>` slot allocation
even though contention was vanishingly rare. Striping keeps the
writer/verifier coordination the per-path mutex provided while
removing those per-call costs. With 256 stripes and ~10 rayon
workers the false-sharing probability per pair is ~4%, and the
guarded body (one `O_CREAT|O_EXCL` open + `write_all` of a tar
entry) is microseconds long.
- `import_indexed_dir::populate_dir` now calls a new
`import_into_fresh_target` instead of `link_file`. `populate_dir`
only ever runs against a directory it just `mkdir`'d, so the
`fs::metadata` pre-flight `link_file` performs to protect the
Copy-method overwrite contract is wasted — every call is `NotFound`
in practice and the EEXIST surface from the import syscall is the
only collision signal we need. Saves ~170k `stat` syscalls per
clean install on the alotta-files fixture. `link_file` still
exists with the original semantics for any caller that genuinely
doesn't know whether the target is fresh.
On the 3343-package alotta-files fixture against the verdaccio mock,
clean-install wall time goes from ~28s to ~19-22s on the local 10-core
machine — roughly closing the gap to pnpm (~20s) for that scenario.
Refs #11857, #11851.
* perf(store-dir): trim per-CAS-file allocations on the hot write path
Two micro-optimisations in `cas_file_path`, the helper every CAFS
write goes through:
- `cas_file_path` no longer `format!`s the sha-512 digest into a
fresh `String`. Sha-512 is always 64 bytes / 128 hex chars, so
render the hex into a stack buffer and slice it into the
`file_path_by_hex_str` call instead. One heap allocation per file
shaved off — ~170k on the alotta-files clean install.
- The repeated `self.v11().join("files")` rebuild used to walk two
`PathBuf::join`s per call. Memoise the result behind a `OnceLock`
on `StoreDir` (`cached_files_dir`) so `file_path_by_head_tail`
borrows it without re-joining. Race-free initialisation across
rayon workers, one allocation per process instead of one per file.
Refs #11857.
* docs(pacquet): address CodeRabbit nits
- Refresh `import_indexed_dir` doc comments so they name
`import_into_fresh_target()` (the actual materialization helper
after the fresh-target split) instead of `link_file()`.
- Add a const assertion that `NUM_CAS_LOCK_STRIPES` stays a power of
two, since `cas_write_lock` uses `& (NUM_CAS_LOCK_STRIPES - 1)` as
the stripe selector.
* docs: forbid past-implementation history in comments
- Extend AGENTS.md Comments rules: comments must describe the
current contract, not what the code replaced. Phrasings like
"used to", "previously", "the original X", or parentheticals
naming a removed type belong in `git log`.
- Apply the rule to `cas_write_lock`'s doc, which previously
framed itself in terms of the removed
`DashMap<PathBuf, Arc<Mutex<()>>>` shape.
Update the scope caveat in AGENTS.md to state that pacquet's parity
surface now covers `install`, `add`, `update`, and `remove`, rather
than `install` alone.
The lockfile verification cache currently only records the lockfile that exists at the **start** of an install. So a flow like:
```
pnpm install <pkg>
rm -rf node_modules
pnpm install
```
re-runs the per-package registry round-trip against the newly written lockfile, even though the local resolver already enforced the policy when picking those versions. The fresh lockfile is now recorded immediately after each install-time write, so the second install takes the cache fast path.
## Implementation
### Recording the post-resolution lockfile
- New helper `recordLockfileVerified` (in `installing/deps-installer/src/install/`). Gated on `cacheDir` + non-empty `resolutionVerifiers` — same gate the pre-resolution verifier uses.
- Two thin combiners over the lockfile writers: `writeWantedLockfileAndRecordVerified` and `writeLockfilesAndRecordVerified`. The install paths use these so the record always runs alongside the write.
### Hash stability: writer returns the canonical lockfile
The cache stores `hashObject(LockfileObject)` and the next install computes the same hash off the file it loads from disk. For the hashes to match, both ends must compute over structurally identical objects. They don't, naïvely: the in-memory write object can carry `undefined` optional fields (e.g. `settings.dedupePeers = undefined` from `opts.dedupePeers || undefined` in install code) that YAML drops on serialize — `object-hash` treats undefined vs missing as distinct values.
- `writeWantedLockfile` / `writeLockfiles` (in `@pnpm/lockfile.fs`) now return the canonical post-write `LockfileObject`: `convertToLockfileObject(stripUndefinedDeep(lockfileFile))`. The strip walks the existing object graph in memory rather than going through a `yaml.load` round-trip, so non-cache callers (deploy, deps-restorer, make-dedicated-lockfile, agent server) pay near-zero cost.
- Install hooks hash the writer's returned value, not the raw in-memory input. Guaranteed by construction to match what the next reader produces.
### `useGitBranchLockfile` correctness
The pre-resolution verification gate and the new post-write recorder were both keying cache records on a hard-coded `pnpm-lock.yaml`. Under `useGitBranchLockfile` the actual file is `pnpm-lock.<branch>.yaml`, so the stat shortcut hit `ENOENT` and the cache effectively never engaged for git-branch users. Both sites now resolve the real filename via `getWantedLockfileName`. The wrappers compute it once and pass it to the writer via a new optional `lockfileName` opt so `useGitBranchLockfile` installs don't fork `getCurrentBranch` twice per write.
### Bug fix unrelated to the cache, found during review
`writeLockfiles`' differs branch was deciding whether to remove or keep `node_modules/.pnpm/lock.yaml` based on `isEmptyLockfile(wantedLockfile)`. Filtered-current callers (deps-restorer) pass an empty current against a non-empty wanted, so this could leave a stale current lockfile on disk. Fixed to key off the current.
### Comments policy
`AGENTS.md` (and `pacquet/AGENTS.md`) now spell out the comment defaults: write self-documenting code, do not restate at call sites what the callee's JSDoc / doc comment already says, comments are reserved for the non-obvious *why*. The pruning pass in this PR brings the changed code in line.
## API surface
- `@pnpm/lockfile.fs` (minor):
- `writeWantedLockfile`: return widened from `Promise<void>` to `Promise<LockfileObject>`. New optional `lockfileName` opt.
- `writeCurrentLockfile`: return widened to `Promise<LockfileObject | undefined>` (undefined when the empty-lockfile branch unlinks).
- `writeLockfiles`: return widened from `Promise<void>` to `Promise<{ wantedLockfile, currentLockfile }>`. New optional `wantedLockfileName` opt. New exported `WriteLockfilesResult` type.
- New export: `getWantedLockfileName`.
- `@pnpm/installing.deps-installer` (patch): internal-only wrappers; no external API change.
* docs: split AGENTS.md into shared + pacquet-specific
Before: root AGENTS.md and pacquet/AGENTS.md each maintained their own
copy of the GitHub PR workflow, agent-footer rule, "never ignore test
failures," Conventional Commits list, and code-reuse philosophy.
Drift waiting to happen.
After:
- Root AGENTS.md owns the shared conventions (PR workflow, agent
footer, conventional commits, code reuse, never-ignore-tests,
PR-conflict script) and marks TS-only sections explicitly
(setup/build, testing, linting, changesets, Standard Style, Jest
gotchas).
- pacquet/AGENTS.md opens with "Read ../AGENTS.md first" and keeps
only pacquet-specific rules (cardinal rule, branded types, just
recipes, insta snapshots, miette diagnostics, Rust style notes,
the `bench:` commit type, things-not-to-do that are Rust-flavored).
- Root adds a one-line entry for `pacquet/` in the repo structure
list so first-time readers find the cross-link.
CLAUDE.md and pacquet/{CLAUDE,GEMINI}.md are unchanged — they're
symlinks to AGENTS.md and follow automatically.
* docs(agents): require parity between pnpm and pacquet
Add a "Keep pnpm and pacquet in sync" section to root AGENTS.md spelling
out the bidirectional obligation: any user-visible change (CLI surface,
lockfile/manifest format, error codes, defaults, env-var handling, log
emissions, store layout) must land in both stacks in the same PR, or
the originating PR must spawn a tracking issue. Pure refactors / perf
wins / TS-only test cleanups don't need mirroring.
Cross-link from pacquet/AGENTS.md's "cardinal rule" so a pacquet-side
reader knows the obligation goes both ways and where the pnpm-side
version lives.
* docs(agents): restore Rust-specific dependency-level guidance
The root "Keep the dependency on the right level" bullet uses npm
vocabulary ("package," "shared package"). For a Rust reader that
required mentally translating "package" → "crate" and made the
workspace-vs-crate distinction less obvious. Restore the pacquet
phrasing alongside the existing pacquet-specific notes.
* docs(agents): hand off cross-stack porting via the same PR
Drop the "open a tracking issue" fallback — it lets one side drift
behind while the issue sits in the backlog. Instead, the PR author
opens the PR with their side and flags in the description what still
needs porting; someone else pushes the matching commits to the same
PR before it lands. Both sides land together or not at all.
* docs(agents): drop external-repo framing from the cardinal rule
pacquet now lives in the same repo as pnpm, so the cardinal rule no
longer needs the "fetch pnpm/pnpm main, compare ls-remote SHAs, watch
your local clone for drift" mechanics. The reference TypeScript code
is just a few directories over (`pnpm/`, `pkg-manager/`, `resolving/`,
`lockfile/`, `store/`, etc.), and pnpm is the source of truth by
position in the repo, not by branch tracking.
Updates:
- Root `AGENTS.md`: rephrase the cross-link line to drop the "follow
pnpm's main" framing.
- `pacquet/AGENTS.md` cardinal rule: redirect "find the equivalent
code" from `https://github.com/pnpm/pnpm` to the in-repo
TypeScript workspaces, drop the "confirm you're on the freshest
main" paragraph, and reword the source-of-truth wording.
- Permalink citation rule: generalize from "upstream pnpm" to "any
GitHub repository, including this one" — citation SHAs now usually
point at this repo's history.
* docs(agents): note pacquet's current scope is install-only
Without this caveat the parity rule reads as if every command needs
porting today. pacquet only implements `install` right now; resolution
and other commands (`update`, `add`, `remove`, `publish`, `exec`,
`run`, `dlx`, `audit`, etc.) live only in TypeScript, so changes there
don't need a pacquet-side port. The caveat also flags that the parity
rule's scope will widen as pacquet ports more commands.
Add a "Working with GitHub PRs, Issues, and Comments" section to AGENTS.md covering three rules:
Keep PR title and description current when pushing new changes.
Reply to resolved review comments with the resolution + commit hash and close the conversation.
Sign all agent-authored comments, issues, and PRs with a footer that names the agent and model.
* fix: respect frozen-lockfile flag when migrating config dependencies
* fix: throw FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE when installing config deps with --frozen-lockfile
* fix: correct changeset package name and clean up minor issues
- Fix changeset referencing non-existent @pnpm/config.deps-installer
(should be @pnpm/installing.env-installer)
- Fix merge artifact in AGENTS.md
- Revert unnecessary Promise.all refactoring in migrateConfigDeps.ts
- Remove extra blank line in test file
* fix: move frozenLockfile check to call site and add missing tests
Move the frozenLockfile check from migrateConfigDepsToLockfile() to
normalizeForInstall() to minimize the number of check points.
Add unit tests for all frozenLockfile code paths:
- installConfigDeps: migration fails with frozenLockfile
- resolveAndInstallConfigDeps: old-format migration, new-format
resolution, and up-to-date lockfile success
- resolveConfigDeps: fails with frozenLockfile
* refactor: consolidate duplicate frozenLockfile checks in resolveAndInstallConfigDeps
Merge two identical frozenLockfile throw statements into a single check
covering both lockfileChanged and depsToResolve conditions.
* Delete respect-frozen-lockfile.md
* refactor: order fields
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Add a "Resolving Conflicts in GitHub PRs" section to AGENTS.md with
step-by-step instructions for force-fetching refs, rebasing, resolving
lockfile conflicts, and verifying mergeability.
Add shell/resolve-pr-conflicts.sh that automates the workflow: fetches
metadata, force-updates the base ref, rebases, auto-resolves lockfile
conflicts via pnpm install, force-pushes, and verifies the result.
Closes#11035
## Summary
### Root cause fix: don't apply cached side-effects for unapproved packages
When importing packages from the store, side-effects cache was applied for any package not explicitly denied (`allowBuild !== false`). This meant unapproved packages (`allowBuild === undefined`) got cached build artifacts, setting `isBuilt: true` and bypassing the `allowBuild` check in `buildModules`.
**Fix:** Only apply side-effects cache when `allowBuild` returns `true` (explicitly approved). Changed in three locations:
- `installing/deps-restorer/src/index.ts` (isolated linker)
- `installing/deps-restorer/src/linkHoistedModules.ts` (hoisted linker)
- `installing/deps-installer/src/install/link.ts` (non-headless install)
### Revocation detection
When a package's build approval is revoked between installs (was `true` in `.modules.yaml`, now undefined), detect it in `mutateModules` and add to `ignoredBuilds` so `strictDepBuilds` fails.
### Status messages in `_rebuild`
Users now see what happened to each package during rebuild:
- `pkg@version: built successfully`
- `pkg@version: skipped (no build scripts)`
- `pkg@version: skipped (not allowed)`
- `pkg@version: reused from store cache`
And during install:
- `pkg@version: reused from store (side effects cache)`
### `buildSelectedPkgs` fixes
- Preserve `storeDir`, `virtualStoreDir`, `virtualStoreDirMaxLength` from existing `.modules.yaml` instead of overwriting with config-derived values (which caused "reinstall from scratch" prompt)
- Write `allowBuilds` to `.modules.yaml` so GVS doesn't detect a mismatch on next install
- Merge `ignoredBuilds` with existing entries for packages not being rebuilt