@proteinjs/reflection-build 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/runWatch.js +3 -30
  3. package/dist/src/runWatch.js.map +1 -1
  4. package/dist/src/watch.d.ts +1 -1
  5. package/dist/src/watch.js +39 -28
  6. package/dist/src/watch.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/runWatch.ts +3 -8
  9. package/src/watch.ts +44 -35
  10. package/test/examples/source-repository/a/node_modules/.package-lock.json +6 -6
  11. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/CHANGELOG.md +11 -0
  12. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +44 -1
  13. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +207 -142
  14. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
  15. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/package.json +2 -2
  16. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/PackageUtil.ts +176 -102
  17. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/PackageUtil.symlinkDependencies.test.ts +76 -0
  18. package/test/examples/source-repository/a/package-lock.json +8 -8
  19. package/test/examples/source-repository/a/package.json +2 -2
  20. package/test/examples/source-repository/b/node_modules/.package-lock.json +4 -4
  21. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/CHANGELOG.md +11 -0
  22. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +44 -1
  23. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +207 -142
  24. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
  25. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/package.json +2 -2
  26. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/PackageUtil.ts +176 -102
  27. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/PackageUtil.symlinkDependencies.test.ts +76 -0
  28. package/test/examples/source-repository/b/package-lock.json +6 -6
  29. package/test/examples/source-repository/b/package.json +2 -2
@@ -325,9 +325,20 @@ export class PackageUtil {
325
325
 
326
326
  /**
327
327
  * Symlink the dependencies of `localPackage` to other local packages in the workspace.
328
+ *
329
+ * This links the package's full TRANSITIVE closure of workspace dependencies,
330
+ * not just its directly-declared ones. A package's `package.json` only lists
331
+ * its direct deps, but those deps pull in workspace packages of their own
332
+ * (e.g. `flow-server` declares `@n3xah/space-server`, which itself depends on
333
+ * `@n3xah/space-common`). Node resolves a transitive dep like `space-common`
334
+ * out of the consumer's own `node_modules` first, so if we only symlinked
335
+ * direct deps, npm would satisfy `space-common` with a stale registry copy
336
+ * that lags the live workspace source — causing schema/version drift. By
337
+ * linking the whole closure, every workspace package a package can reach at
338
+ * runtime resolves to the live source tree.
339
+ *
328
340
  * @param localPackage package to symlink the dependencies of
329
341
  * @param localPackageMap `LocalPackageMap` of the workspace
330
- * @param logger optionally provide a logger to capture this method's logging
331
342
  */
332
343
  static async symlinkDependencies(localPackage: LocalPackage, localPackageMap: LocalPackageMap) {
333
344
  const packageDir = path.dirname(localPackage.filePath);
@@ -336,118 +347,181 @@ export class PackageUtil {
336
347
  await Fs.createFolder(nodeModulesPath);
337
348
  }
338
349
 
339
- const linkDependencies = async (dependencies: Record<string, string> | undefined) => {
340
- if (!dependencies) {
341
- return;
342
- }
350
+ const transitiveWorkspaceDependencies = await PackageUtil.getTransitiveWorkspaceDependencies(
351
+ localPackage,
352
+ localPackageMap
353
+ );
354
+ for (const dependencyPackageName of transitiveWorkspaceDependencies) {
355
+ await PackageUtil.symlinkPackage(dependencyPackageName, nodeModulesPath, packageDir, localPackageMap);
356
+ }
357
+ }
343
358
 
344
- for (const dependencyPackageName in dependencies) {
345
- const dependencyPath = localPackageMap[dependencyPackageName]?.filePath
346
- ? path.dirname(localPackageMap[dependencyPackageName].filePath)
347
- : null;
348
- if (!dependencyPath) {
359
+ /**
360
+ * Compute the transitive closure of workspace dependencies for `localPackage`.
361
+ *
362
+ * Reuses `getPackageDependencyGraph`, which already crawls `dependencies` and
363
+ * `devDependencies` (transitively, across the whole workspace) and adds an
364
+ * edge `consumer -> dependency` for each. So the workspace deps reachable from
365
+ * a package are exactly the nodes reachable by following `successors` from its
366
+ * node. We do a cycle-safe traversal (a `visited` set), since the dependency
367
+ * graph can contain cycles. The package itself is excluded from the result.
368
+ *
369
+ * Only names present in `localPackageMap` are returned — i.e. packages that
370
+ * actually live in the workspace and can be symlinked (the graph already
371
+ * filters to file:/relative/workspace deps in `addDependencies`, but we filter
372
+ * again here so the result is exactly the set of linkable packages).
373
+ *
374
+ * @returns workspace package names this package transitively depends on
375
+ */
376
+ private static async getTransitiveWorkspaceDependencies(
377
+ localPackage: LocalPackage,
378
+ localPackageMap: LocalPackageMap
379
+ ): Promise<string[]> {
380
+ const graph = await PackageUtil.getPackageDependencyGraph(localPackageMap);
381
+ const rootPackageName = localPackage.packageJson['name'];
382
+
383
+ const transitiveDependencies = new Set<string>();
384
+ const visited = new Set<string>([rootPackageName]);
385
+ const stack: string[] = [rootPackageName];
386
+ while (stack.length > 0) {
387
+ const current = stack.pop()!;
388
+ const successors = (graph.successors(current) as string[] | void) || [];
389
+ for (const dependencyPackageName of successors) {
390
+ if (visited.has(dependencyPackageName)) {
349
391
  continue;
350
392
  }
351
393
 
352
- const symlinkPath = path.join(nodeModulesPath, dependencyPackageName);
353
- const symlinkParent = path.dirname(symlinkPath);
354
- if (!(await Fs.exists(symlinkParent))) {
355
- await Fs.createFolder(symlinkParent);
394
+ visited.add(dependencyPackageName);
395
+ stack.push(dependencyPackageName);
396
+ if (localPackageMap[dependencyPackageName]) {
397
+ transitiveDependencies.add(dependencyPackageName);
398
+ }
399
+ }
400
+ }
401
+
402
+ return Array.from(transitiveDependencies);
403
+ }
404
+
405
+ /**
406
+ * Symlink a single workspace package into `nodeModulesPath`, and create
407
+ * `node_modules/.bin/<name>` shims for any `bin` it declares.
408
+ *
409
+ * This is the per-dependency linking logic used by `symlinkDependencies` for
410
+ * each package in the transitive closure. The caller is responsible for
411
+ * deciding WHICH packages to link; this method just links the one named.
412
+ *
413
+ * @param dependencyPackageName name of the workspace package to link
414
+ * @param nodeModulesPath absolute path to the consumer's `node_modules`
415
+ * @param packageDir absolute path to the consumer package's directory (cwd for `ln`)
416
+ * @param localPackageMap `LocalPackageMap` of the workspace
417
+ */
418
+ private static async symlinkPackage(
419
+ dependencyPackageName: string,
420
+ nodeModulesPath: string,
421
+ packageDir: string,
422
+ localPackageMap: LocalPackageMap
423
+ ) {
424
+ const dependencyPath = localPackageMap[dependencyPackageName]?.filePath
425
+ ? path.dirname(localPackageMap[dependencyPackageName].filePath)
426
+ : null;
427
+ if (!dependencyPath) {
428
+ return;
429
+ }
430
+
431
+ const symlinkPath = path.join(nodeModulesPath, dependencyPackageName);
432
+ const symlinkParent = path.dirname(symlinkPath);
433
+ if (!(await Fs.exists(symlinkParent))) {
434
+ await Fs.createFolder(symlinkParent);
435
+ }
436
+ // Clear out any existing entry at symlinkPath before creating the
437
+ // new symlink. Use `fs.lstat` rather than `Fs.exists` because
438
+ // `Fs.exists` is `fs.stat`-backed — which FOLLOWS symlinks and
439
+ // throws on a broken target, making broken symlinks invisible
440
+ // here. That's a real failure mode: if a prior run produced a
441
+ // symlink to a path that no longer exists (e.g. after the tree
442
+ // was moved, mounted elsewhere, or retargeted by tooling), the
443
+ // broken link survives the `Fs.exists` check, the delete is
444
+ // skipped, and `ln -s` then fails with "File exists".
445
+ try {
446
+ await fs.lstat(symlinkPath);
447
+ // Existing entry (symlink, file, or directory) — remove it.
448
+ // `deleteFolder` (fs-extra `remove`) handles all three.
449
+ await Fs.deleteFolder(symlinkPath);
450
+ } catch (e: any) {
451
+ if (e.code !== 'ENOENT') {
452
+ throw e;
453
+ }
454
+ // Nothing there — nothing to clean up.
455
+ }
456
+
457
+ // Use a RELATIVE link target so the workspace is portable across
458
+ // mount points (a developer's laptop, CI containers, a remote
459
+ // sandbox, etc.) without having to re-run `symlink-workspace` just
460
+ // because the absolute root path changed. `ln -s TARGET LINK`
461
+ // resolves TARGET relative to the directory containing the link —
462
+ // i.e. `symlinkParent` — so we compute the relative path from there.
463
+ const relativeDependencyPath = path.relative(symlinkParent, dependencyPath);
464
+ await cmd('ln', ['-s', relativeDependencyPath, symlinkPath], { cwd: packageDir });
465
+
466
+ // Create `.bin/<name>` shims for every bin the dependency declares.
467
+ //
468
+ // This is normally npm's job: `npm install` creates shims at
469
+ // `node_modules/.bin/<name>` pointing into the installed package so
470
+ // lifecycle scripts like `npm run watch` (where npm prepends
471
+ // `./node_modules/.bin` to PATH) can find them. `symlink-workspace`
472
+ // bypasses `npm install`, so without this loop the shims only exist
473
+ // if the user happened to run `npm install` at some point and they
474
+ // survive. They don't survive a broken-symlink sweep, a fresh
475
+ // checkout, or a move to a new host — so we create them ourselves.
476
+ //
477
+ // Also chmod +x the bin target: tsc output doesn't preserve the
478
+ // execute bit that `npm install` sets from the published tarball.
479
+ const depPackageJson = JSON.parse(await Fs.readFile(localPackageMap[dependencyPackageName].filePath));
480
+ const bin = depPackageJson.bin;
481
+ const binEntries: Array<{ name: string; relPath: string }> = [];
482
+ if (bin && typeof bin === 'object') {
483
+ for (const binName in bin) {
484
+ binEntries.push({ name: binName, relPath: bin[binName] });
485
+ }
486
+ } else if (bin && typeof bin === 'string') {
487
+ // Shorthand: `"bin": "./path"` — the exposed name is the
488
+ // package's bare name (scope stripped). Matches npm behavior.
489
+ const bareName = dependencyPackageName.includes('/')
490
+ ? dependencyPackageName.split('/').pop()!
491
+ : dependencyPackageName;
492
+ binEntries.push({ name: bareName, relPath: bin });
493
+ }
494
+
495
+ if (binEntries.length > 0) {
496
+ const dotBinDir = path.join(nodeModulesPath, '.bin');
497
+ if (!(await Fs.exists(dotBinDir))) {
498
+ await Fs.createFolder(dotBinDir);
499
+ }
500
+ for (const { name, relPath } of binEntries) {
501
+ const binFilePath = path.resolve(dependencyPath, relPath);
502
+ if (await Fs.exists(binFilePath)) {
503
+ await fs.chmod(binFilePath, 0o755);
356
504
  }
357
- // Clear out any existing entry at symlinkPath before creating the
358
- // new symlink. Use `fs.lstat` rather than `Fs.exists` because
359
- // `Fs.exists` is `fs.stat`-backed which FOLLOWS symlinks and
360
- // throws on a broken target, making broken symlinks invisible
361
- // here. That's a real failure mode: if a prior run produced a
362
- // symlink to a path that no longer exists (e.g. after the tree
363
- // was moved, mounted elsewhere, or retargeted by tooling), the
364
- // broken link survives the `Fs.exists` check, the delete is
365
- // skipped, and `ln -s` then fails with "File exists".
505
+ const shimPath = path.join(dotBinDir, name);
506
+ // Same lstat-based cleanup as for the dep symlink — broken
507
+ // shims from a prior run in a different environment would
508
+ // otherwise make `ln -s` fail with "File exists".
366
509
  try {
367
- await fs.lstat(symlinkPath);
368
- // Existing entry (symlink, file, or directory) — remove it.
369
- // `deleteFolder` (fs-extra `remove`) handles all three.
370
- await Fs.deleteFolder(symlinkPath);
510
+ await fs.lstat(shimPath);
511
+ await Fs.deleteFolder(shimPath);
371
512
  } catch (e: any) {
372
513
  if (e.code !== 'ENOENT') {
373
514
  throw e;
374
515
  }
375
- // Nothing there — nothing to clean up.
376
- }
377
-
378
- // Use a RELATIVE link target so the workspace is portable across
379
- // mount points (a developer's laptop, CI containers, a remote
380
- // sandbox, etc.) without having to re-run `symlink-workspace` just
381
- // because the absolute root path changed. `ln -s TARGET LINK`
382
- // resolves TARGET relative to the directory containing the link —
383
- // i.e. `symlinkParent` — so we compute the relative path from there.
384
- const relativeDependencyPath = path.relative(symlinkParent, dependencyPath);
385
- await cmd('ln', ['-s', relativeDependencyPath, symlinkPath], { cwd: packageDir });
386
-
387
- // Create `.bin/<name>` shims for every bin the dependency declares.
388
- //
389
- // This is normally npm's job: `npm install` creates shims at
390
- // `node_modules/.bin/<name>` pointing into the installed package so
391
- // lifecycle scripts like `npm run watch` (where npm prepends
392
- // `./node_modules/.bin` to PATH) can find them. `symlink-workspace`
393
- // bypasses `npm install`, so without this loop the shims only exist
394
- // if the user happened to run `npm install` at some point and they
395
- // survive. They don't survive a broken-symlink sweep, a fresh
396
- // checkout, or a move to a new host — so we create them ourselves.
397
- //
398
- // Also chmod +x the bin target: tsc output doesn't preserve the
399
- // execute bit that `npm install` sets from the published tarball.
400
- const depPackageJson = JSON.parse(await Fs.readFile(localPackageMap[dependencyPackageName].filePath));
401
- const bin = depPackageJson.bin;
402
- const binEntries: Array<{ name: string; relPath: string }> = [];
403
- if (bin && typeof bin === 'object') {
404
- for (const binName in bin) {
405
- binEntries.push({ name: binName, relPath: bin[binName] });
406
- }
407
- } else if (bin && typeof bin === 'string') {
408
- // Shorthand: `"bin": "./path"` — the exposed name is the
409
- // package's bare name (scope stripped). Matches npm behavior.
410
- const bareName = dependencyPackageName.includes('/')
411
- ? dependencyPackageName.split('/').pop()!
412
- : dependencyPackageName;
413
- binEntries.push({ name: bareName, relPath: bin });
414
- }
415
-
416
- if (binEntries.length > 0) {
417
- const dotBinDir = path.join(nodeModulesPath, '.bin');
418
- if (!(await Fs.exists(dotBinDir))) {
419
- await Fs.createFolder(dotBinDir);
420
- }
421
- for (const { name, relPath } of binEntries) {
422
- const binFilePath = path.resolve(dependencyPath, relPath);
423
- if (await Fs.exists(binFilePath)) {
424
- await fs.chmod(binFilePath, 0o755);
425
- }
426
- const shimPath = path.join(dotBinDir, name);
427
- // Same lstat-based cleanup as for the dep symlink — broken
428
- // shims from a prior run in a different environment would
429
- // otherwise make `ln -s` fail with "File exists".
430
- try {
431
- await fs.lstat(shimPath);
432
- await Fs.deleteFolder(shimPath);
433
- } catch (e: any) {
434
- if (e.code !== 'ENOENT') {
435
- throw e;
436
- }
437
- }
438
- // Relative target: from `node_modules/.bin` into the dep
439
- // directory. Goes through the dep's symlinked node_modules
440
- // entry (not into the source tree) so the shim continues to
441
- // resolve correctly after the workspace is relocated.
442
- const shimTargetAbsolute = path.join(nodeModulesPath, dependencyPackageName, relPath);
443
- const shimRelative = path.relative(dotBinDir, shimTargetAbsolute);
444
- await cmd('ln', ['-s', shimRelative, shimPath], { cwd: packageDir });
445
- }
446
516
  }
517
+ // Relative target: from `node_modules/.bin` into the dep
518
+ // directory. Goes through the dep's symlinked node_modules
519
+ // entry (not into the source tree) so the shim continues to
520
+ // resolve correctly after the workspace is relocated.
521
+ const shimTargetAbsolute = path.join(nodeModulesPath, dependencyPackageName, relPath);
522
+ const shimRelative = path.relative(dotBinDir, shimTargetAbsolute);
523
+ await cmd('ln', ['-s', shimRelative, shimPath], { cwd: packageDir });
447
524
  }
448
- };
449
-
450
- await linkDependencies(localPackage.packageJson.dependencies);
451
- await linkDependencies(localPackage.packageJson.devDependencies);
525
+ }
452
526
  }
453
527
  }
@@ -225,6 +225,82 @@ describe('PackageUtil.symlinkDependencies — relative link targets', () => {
225
225
  expect(content).toContain('console.log("only")');
226
226
  });
227
227
 
228
+ test('symlinks a TRANSITIVE workspace dependency the consumer reaches only through another dep', async () => {
229
+ // Regression test for the missing-transitive-link bug.
230
+ //
231
+ // A package.json only lists DIRECT deps. But Node resolves a
232
+ // transitive workspace dep (one pulled in THROUGH a direct dep) out of
233
+ // the consumer's own node_modules first — so if symlinkDependencies
234
+ // only linked direct deps, npm would satisfy the transitive one with a
235
+ // stale registry copy that lags the live workspace source, causing
236
+ // version/schema drift (this exact gap cost a content_reference
237
+ // last_activity_at schema mismatch between flow-server's transitive
238
+ // space-common and the live source).
239
+ //
240
+ // Topology: consumer -> mid -> leaf. consumer declares ONLY mid; its
241
+ // single path to leaf is transitive. leaf must still get symlinked into
242
+ // consumer's node_modules with a relative target.
243
+ const leafPkg = await writePackage('packages/leaf', '@scope/leaf');
244
+ const midPkg = await writePackage('packages/mid', '@scope/mid', {
245
+ '@scope/leaf': '^1.0.0',
246
+ });
247
+ const consumerPkg = await writePackage('packages/consumer', '@scope/consumer', {
248
+ '@scope/mid': '^1.0.0',
249
+ });
250
+ const packageMap: LocalPackageMap = {
251
+ '@scope/leaf': leafPkg,
252
+ '@scope/mid': midPkg,
253
+ '@scope/consumer': consumerPkg,
254
+ };
255
+
256
+ await PackageUtil.symlinkDependencies(consumerPkg, packageMap);
257
+
258
+ // Direct dep is linked (baseline).
259
+ const midLink = await fs.readlink(path.join(workspaceRoot, 'packages/consumer/node_modules/@scope/mid'));
260
+ expect(path.isAbsolute(midLink)).toBe(false);
261
+ expect(midLink).toBe(path.join('..', '..', '..', 'mid'));
262
+
263
+ // Transitive dep — the whole point of this test — is ALSO linked, with
264
+ // a relative target, and resolves to the live leaf source.
265
+ const leafSymlinkPath = path.join(workspaceRoot, 'packages/consumer/node_modules/@scope/leaf');
266
+ expect((await fs.lstat(leafSymlinkPath)).isSymbolicLink()).toBe(true);
267
+ const leafLink = await fs.readlink(leafSymlinkPath);
268
+ expect(path.isAbsolute(leafLink)).toBe(false);
269
+ expect(leafLink).toBe(path.join('..', '..', '..', 'leaf'));
270
+ const resolvedLeaf = await fs.stat(leafSymlinkPath);
271
+ expect(resolvedLeaf.isDirectory()).toBe(true);
272
+ });
273
+
274
+ test('is cycle-safe: links the full closure even when the dependency graph has a cycle', async () => {
275
+ // The dependency graph can contain cycles (a <-> b). The transitive
276
+ // closure traversal must terminate and still link every reachable
277
+ // workspace package.
278
+ //
279
+ // Topology: consumer -> a, a -> b, b -> a (cycle). consumer declares
280
+ // only a; both a and b must be linked.
281
+ const aPkg = await writePackage('packages/a', '@scope/a', {
282
+ '@scope/b': '^1.0.0',
283
+ });
284
+ const bPkg = await writePackage('packages/b', '@scope/b', {
285
+ '@scope/a': '^1.0.0',
286
+ });
287
+ const consumerPkg = await writePackage('packages/consumer', '@scope/consumer', {
288
+ '@scope/a': '^1.0.0',
289
+ });
290
+ const packageMap: LocalPackageMap = {
291
+ '@scope/a': aPkg,
292
+ '@scope/b': bPkg,
293
+ '@scope/consumer': consumerPkg,
294
+ };
295
+
296
+ await PackageUtil.symlinkDependencies(consumerPkg, packageMap);
297
+
298
+ const aLink = await fs.readlink(path.join(workspaceRoot, 'packages/consumer/node_modules/@scope/a'));
299
+ const bLink = await fs.readlink(path.join(workspaceRoot, 'packages/consumer/node_modules/@scope/b'));
300
+ expect(aLink).toBe(path.join('..', '..', '..', 'a'));
301
+ expect(bLink).toBe(path.join('..', '..', '..', 'b'));
302
+ });
303
+
228
304
  test('handles scoped and unscoped dependencies uniformly', async () => {
229
305
  const scopedDep = await writePackage('packages/scoped', '@scope/foo');
230
306
  const unscopedDep = await writePackage('packages/unscoped', 'bar');
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@proteinjs/reflection-build-test-a",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@proteinjs/reflection-build-test-a",
9
- "version": "0.0.20",
9
+ "version": "0.0.21",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@dagrejs/graphlib": "2.1.4",
13
13
  "@proteinjs/reflection": "^1.1.11",
14
14
  "@proteinjs/reflection-build-test-b": "file:../b",
15
15
  "@proteinjs/util": "^1.6.0",
16
- "@proteinjs/util-node": "^1.8.1",
16
+ "@proteinjs/util-node": "^1.9.0",
17
17
  "globby": "11.0.1",
18
18
  "gulp": "4.0.2",
19
19
  "gulp-shell": "0.8.0",
@@ -32,13 +32,13 @@
32
32
  },
33
33
  "../b": {
34
34
  "name": "@proteinjs/reflection-build-test-b",
35
- "version": "0.0.20",
35
+ "version": "0.0.21",
36
36
  "license": "ISC",
37
37
  "dependencies": {
38
38
  "@dagrejs/graphlib": "2.1.4",
39
39
  "@proteinjs/reflection": "^1.1.11",
40
40
  "@proteinjs/util": "^1.6.0",
41
- "@proteinjs/util-node": "^1.8.1",
41
+ "@proteinjs/util-node": "^1.9.0",
42
42
  "globby": "11.0.1",
43
43
  "gulp": "4.0.2",
44
44
  "gulp-shell": "0.8.0",
@@ -1129,9 +1129,9 @@
1129
1129
  }
1130
1130
  },
1131
1131
  "node_modules/@proteinjs/util-node": {
1132
- "version": "1.8.1",
1133
- "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.8.1.tgz",
1134
- "integrity": "sha512-F4BEBmroyTuRjQGYkJQhs2JoTg6VUIeEveMZbHzV5mG8jIyF2zbIF+qQCJ0cV2pdQg2PLhi61VlstTwJrEIKdQ==",
1132
+ "version": "1.9.0",
1133
+ "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.9.0.tgz",
1134
+ "integrity": "sha512-kn9zzmm7tUmlZiiJxGjkekhwsNprUzZM2r5rWZYgDD/BWdGCTA1QCfKEhmjCHLJutR1J+i4NmWcSfuYdyt+hjw==",
1135
1135
  "license": "ISC",
1136
1136
  "dependencies": {
1137
1137
  "@dagrejs/graphlib": "2.1.4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/reflection-build-test-a",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "private": true,
5
5
  "description": "Test package a",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "@proteinjs/reflection": "^1.1.11",
23
23
  "@proteinjs/reflection-build-test-b": "file:../b",
24
24
  "@proteinjs/util": "^1.6.0",
25
- "@proteinjs/util-node": "^1.8.1",
25
+ "@proteinjs/util-node": "^1.9.0",
26
26
  "globby": "11.0.1",
27
27
  "gulp": "4.0.2",
28
28
  "gulp-shell": "0.8.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/reflection-build-test-b",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1074,9 +1074,9 @@
1074
1074
  }
1075
1075
  },
1076
1076
  "node_modules/@proteinjs/util-node": {
1077
- "version": "1.8.1",
1078
- "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.8.1.tgz",
1079
- "integrity": "sha512-F4BEBmroyTuRjQGYkJQhs2JoTg6VUIeEveMZbHzV5mG8jIyF2zbIF+qQCJ0cV2pdQg2PLhi61VlstTwJrEIKdQ==",
1077
+ "version": "1.9.0",
1078
+ "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.9.0.tgz",
1079
+ "integrity": "sha512-kn9zzmm7tUmlZiiJxGjkekhwsNprUzZM2r5rWZYgDD/BWdGCTA1QCfKEhmjCHLJutR1J+i4NmWcSfuYdyt+hjw==",
1080
1080
  "license": "ISC",
1081
1081
  "dependencies": {
1082
1082
  "@dagrejs/graphlib": "2.1.4",
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.9.0](https://github.com/proteinjs/util/compare/@proteinjs/util-node@1.8.1...@proteinjs/util-node@1.9.0) (2026-06-24)
7
+
8
+
9
+ ### Features
10
+
11
+ * symlink-workspace links each package's transitive workspace-dep closure ([55a7c25](https://github.com/proteinjs/util/commit/55a7c2541f500bec4e12bb05454acadd6b4c4b66))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [1.8.1](https://github.com/proteinjs/util/compare/@proteinjs/util-node@1.8.0...@proteinjs/util-node@1.8.1) (2026-04-14)
7
18
 
8
19
 
@@ -101,9 +101,52 @@ export declare class PackageUtil {
101
101
  static getWorkspaceMetadata(workspacePath: string): Promise<WorkspaceMetadata>;
102
102
  /**
103
103
  * Symlink the dependencies of `localPackage` to other local packages in the workspace.
104
+ *
105
+ * This links the package's full TRANSITIVE closure of workspace dependencies,
106
+ * not just its directly-declared ones. A package's `package.json` only lists
107
+ * its direct deps, but those deps pull in workspace packages of their own
108
+ * (e.g. `flow-server` declares `@n3xah/space-server`, which itself depends on
109
+ * `@n3xah/space-common`). Node resolves a transitive dep like `space-common`
110
+ * out of the consumer's own `node_modules` first, so if we only symlinked
111
+ * direct deps, npm would satisfy `space-common` with a stale registry copy
112
+ * that lags the live workspace source — causing schema/version drift. By
113
+ * linking the whole closure, every workspace package a package can reach at
114
+ * runtime resolves to the live source tree.
115
+ *
104
116
  * @param localPackage package to symlink the dependencies of
105
117
  * @param localPackageMap `LocalPackageMap` of the workspace
106
- * @param logger optionally provide a logger to capture this method's logging
107
118
  */
108
119
  static symlinkDependencies(localPackage: LocalPackage, localPackageMap: LocalPackageMap): Promise<void>;
120
+ /**
121
+ * Compute the transitive closure of workspace dependencies for `localPackage`.
122
+ *
123
+ * Reuses `getPackageDependencyGraph`, which already crawls `dependencies` and
124
+ * `devDependencies` (transitively, across the whole workspace) and adds an
125
+ * edge `consumer -> dependency` for each. So the workspace deps reachable from
126
+ * a package are exactly the nodes reachable by following `successors` from its
127
+ * node. We do a cycle-safe traversal (a `visited` set), since the dependency
128
+ * graph can contain cycles. The package itself is excluded from the result.
129
+ *
130
+ * Only names present in `localPackageMap` are returned — i.e. packages that
131
+ * actually live in the workspace and can be symlinked (the graph already
132
+ * filters to file:/relative/workspace deps in `addDependencies`, but we filter
133
+ * again here so the result is exactly the set of linkable packages).
134
+ *
135
+ * @returns workspace package names this package transitively depends on
136
+ */
137
+ private static getTransitiveWorkspaceDependencies;
138
+ /**
139
+ * Symlink a single workspace package into `nodeModulesPath`, and create
140
+ * `node_modules/.bin/<name>` shims for any `bin` it declares.
141
+ *
142
+ * This is the per-dependency linking logic used by `symlinkDependencies` for
143
+ * each package in the transitive closure. The caller is responsible for
144
+ * deciding WHICH packages to link; this method just links the one named.
145
+ *
146
+ * @param dependencyPackageName name of the workspace package to link
147
+ * @param nodeModulesPath absolute path to the consumer's `node_modules`
148
+ * @param packageDir absolute path to the consumer package's directory (cwd for `ln`)
149
+ * @param localPackageMap `LocalPackageMap` of the workspace
150
+ */
151
+ private static symlinkPackage;
109
152
  }