From 0c6c69d8acff9e0f2bfb2b9072ade2b6746e96cf Mon Sep 17 00:00:00 2001
From: Adam Shaw <arshaw@users.noreply.github.com>
Date: Wed, 4 Dec 2024 20:56:26 -0500
Subject: [PATCH] write scoped workspace-root deps

---
 .../pnpm-make-dedicated-lockfile/lib.js       | 91 ++++++++++++++++++-
 scripts/src/meta/update.ts                    |  2 +-
 scripts/src/meta/utils.ts                     |  4 +
 3 files changed, 95 insertions(+), 2 deletions(-)

diff --git a/premium/packages/pnpm-make-dedicated-lockfile/lib.js b/premium/packages/pnpm-make-dedicated-lockfile/lib.js
index e1646861e..e50fba33f 100644
--- a/premium/packages/pnpm-make-dedicated-lockfile/lib.js
+++ b/premium/packages/pnpm-make-dedicated-lockfile/lib.js
@@ -1,9 +1,10 @@
 // derived from https://github.com/pnpm/pnpm/blob/main/packages/make-dedicated-lockfile/src/index.ts
 
-import { join as joinPaths, sep as pathSep, isAbsolute } from 'path'
+import { join as joinPaths, sep as pathSep, isAbsolute, relative as getRelative } from 'path'
 import { getLockfileImporterId, readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile-file'
 import { pruneSharedLockfile } from '@pnpm/prune-lockfile'
 import { DEPENDENCIES_FIELDS } from '@pnpm/types'
+import { readFile, lstat } from 'fs/promises'
 
 export function readLockfile(rootDir, ignoreIncompatible = true) {
   return readWantedLockfile(rootDir, { ignoreIncompatible }) // needs options or fails!
@@ -59,6 +60,66 @@ export async function makeDedicatedLockfile(rootDir, scopedDir, verbose) {
     }
   }
 
+  // transfer over workspace-root dependencies,
+  // using scopedDir's manifest as a whitelist
+  if (
+    !lockfile.importers[getRelative(rootDir, scopedDir)] && // NOT already a workspace
+    await hasManifest(scopedDir)
+  ) {
+    const scopedManifest = await readManifest(scopedDir)
+    const scopedRootSnapshot = { specifiers: {} }
+    const rootImporter = lockfile.importers['.']
+
+    for (const depField of DEPENDENCIES_FIELDS) {
+      const depSpecifiers = scopedManifest[depField]
+
+      if (depSpecifiers) {
+        for (const depName in depSpecifiers) {
+          const depSpecifier = depSpecifiers[depName]
+          let depLocator = rootImporter[depField]?.[depName]
+
+          if (!depLocator) {
+            throw new Error(
+              `Scope '${scopedDir}' cannot depend on ${depName} without root also depending on it`
+            )
+          }
+
+          const depLocatorLink =
+            depLocator.startsWith(linkPrefix) &&
+            depLocator.slice(linkPrefix.length)
+
+          if (depLocatorLink) {
+            const scopedDepLocatorLink =
+              depLocatorLink.startsWith(`${baseImporterId}/`) &&
+              depLocatorLink.slice(baseImporterId.length + 1)
+
+            if (!scopedDepLocatorLink) {
+              throw new Error(
+                `Scope '${scopedDir}' cannot depend on ${depLocatorLink} if it is outside`
+              )
+            }
+
+            depLocator = linkPrefix + scopedDepLocatorLink
+          } else {
+            /*
+            TODO: for registry deps, throw error if specified version conflicts.
+            if rootImporter.specifiers[depName] !== depSpecifier
+            */
+          }
+
+          ;(
+            scopedRootSnapshot[depField] ||
+            (scopedRootSnapshot[depField] = {})
+          )[depName] = depLocator
+
+          scopedRootSnapshot.specifiers[depName] = depSpecifier
+        }
+      }
+    }
+
+    transformedImporters['.'] = scopedRootSnapshot
+  }
+
   lockfile.importers = transformedImporters
 
   const dedicatedLockfile = pruneSharedLockfile(lockfile)
@@ -83,6 +144,9 @@ const linkPrefix = 'link:'
 function filterLinkedDepsOutOfScope(importerId, snapshot, scopedDir, pkgsOutOfScope) {
   const transformedSnapshot = {}
 
+  /*
+  snapshotVal is the hash of dependends/devDependencies/etc
+  */
   for (const [snapshotKey, snapshotVal] of Object.entries(snapshot)) {
     if (!DEPENDENCIES_FIELDS.includes(snapshotKey)) {
       // not a dependency-related field. copy as-is
@@ -161,3 +225,28 @@ async function relinkDeps(rootDir, importerId, snapshot, readManifest) {
     specifiers: transformedSpecifiers,
   }
 }
+
+// UTILS... TODO: make DRY
+// -------------------------------------------------------------------------------------------------
+
+function hasManifest(dir) {
+  return fileExists(joinPaths(dir, 'package.json'))
+}
+
+async function readManifest(dir) {
+  const manifestPath = joinPaths(dir, 'package.json')
+  return await readJson(manifestPath)
+}
+
+function fileExists(path) {
+  return lstat(path).then(
+    () => true,
+    () => false,
+  )
+}
+
+async function readJson(path) {
+  const srcJson = await readFile(path, 'utf8')
+  const srcMeta = JSON.parse(srcJson)
+  return srcMeta
+}
diff --git a/scripts/src/meta/update.ts b/scripts/src/meta/update.ts
index 607be389e..be7316ecf 100644
--- a/scripts/src/meta/update.ts
+++ b/scripts/src/meta/update.ts
@@ -4,7 +4,7 @@ import * as yaml from 'js-yaml'
 import { makeDedicatedLockfile, readLockfile, relinkLockfile, writeLockfile } from 'pnpm-make-dedicated-lockfile'
 import { addFile, assumeUnchanged } from '@fullcalendar-scripts/standard/utils/git'
 import { boolPromise } from '@fullcalendar-scripts/standard/utils/lang'
-import { querySubrepoPkgs, readManifest, writeManifest } from './utils.js'
+import { querySubrepoPkgs, readManifest } from './utils.js'
 import { lockFilename, workspaceFilename, turboFilename, miscSubpaths } from './config.js'
 
 const verbose = true
diff --git a/scripts/src/meta/utils.ts b/scripts/src/meta/utils.ts
index 52dd09d09..e2ae2f46e 100644
--- a/scripts/src/meta/utils.ts
+++ b/scripts/src/meta/utils.ts
@@ -69,6 +69,10 @@ export async function getSubrepos(monorepoDir: string, singleName?: string) {
 // -------------------------------------------------------------------------------------------------
 // TODO: DRY with writeDistPkgJsons maybe?
 
+export async function hasManifest(dir: string): Promise<boolean> {
+  return fileExists(joinPaths(dir, 'package.json'))
+}
+
 export async function readManifest(dir: string): Promise<any> {
   const manifestPath = joinPaths(dir, 'package.json')
   return await readJson(manifestPath)