@proteinjs/reflection-build 2.0.1 → 2.0.3

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 (72) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +5 -5
  3. package/test/examples/source-repository/a/node_modules/.package-lock.json +13 -13
  4. package/test/examples/source-repository/a/node_modules/@proteinjs/util/CHANGELOG.md +8 -0
  5. package/test/examples/source-repository/a/node_modules/@proteinjs/util/LICENSE +21 -0
  6. package/test/examples/source-repository/a/node_modules/@proteinjs/util/package.json +3 -2
  7. package/test/examples/source-repository/a/node_modules/@proteinjs/util/tsconfig.json +1 -0
  8. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/CHANGELOG.md +63 -0
  9. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/LICENSE +21 -0
  10. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.d.ts +1 -0
  11. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.js +1 -0
  12. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.js.map +1 -1
  13. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js +7 -4
  14. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js.map +1 -1
  15. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/Fs.js +16 -12
  16. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/Fs.js.map +1 -1
  17. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +59 -13
  18. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +119 -80
  19. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
  20. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.d.ts +91 -0
  21. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js +608 -0
  22. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js.map +1 -0
  23. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/promisifiedFs.d.ts +0 -1
  24. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/index.ts +1 -0
  25. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/package.json +4 -3
  26. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/ArgsMap.ts +7 -4
  27. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/Fs.ts +12 -9
  28. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/PackageUtil.ts +92 -38
  29. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/WorkspaceDeclaration.ts +340 -0
  30. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/ArgsMap.test.ts +35 -0
  31. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/Fs.getFilePathsMatchingGlob.test.ts +70 -0
  32. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/PackageUtil.getLocalPackageMap.test.ts +107 -0
  33. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/PackageUtil.pathKeyedIdentity.test.ts +169 -0
  34. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/WorkspaceDeclaration.test.ts +174 -0
  35. package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/tsconfig.json +1 -0
  36. package/test/examples/source-repository/a/package-lock.json +16 -16
  37. package/test/examples/source-repository/a/package.json +3 -3
  38. package/test/examples/source-repository/b/node_modules/.package-lock.json +10 -10
  39. package/test/examples/source-repository/b/node_modules/@proteinjs/util/CHANGELOG.md +8 -0
  40. package/test/examples/source-repository/b/node_modules/@proteinjs/util/LICENSE +21 -0
  41. package/test/examples/source-repository/b/node_modules/@proteinjs/util/package.json +3 -2
  42. package/test/examples/source-repository/b/node_modules/@proteinjs/util/tsconfig.json +1 -0
  43. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/CHANGELOG.md +63 -0
  44. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/LICENSE +21 -0
  45. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.d.ts +1 -0
  46. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.js +1 -0
  47. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.js.map +1 -1
  48. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js +7 -4
  49. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js.map +1 -1
  50. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/Fs.js +16 -12
  51. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/Fs.js.map +1 -1
  52. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +59 -13
  53. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +119 -80
  54. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
  55. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.d.ts +91 -0
  56. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js +608 -0
  57. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js.map +1 -0
  58. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/promisifiedFs.d.ts +0 -1
  59. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/index.ts +1 -0
  60. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/package.json +4 -3
  61. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/ArgsMap.ts +7 -4
  62. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/Fs.ts +12 -9
  63. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/PackageUtil.ts +92 -38
  64. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/WorkspaceDeclaration.ts +340 -0
  65. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/ArgsMap.test.ts +35 -0
  66. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/Fs.getFilePathsMatchingGlob.test.ts +70 -0
  67. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/PackageUtil.getLocalPackageMap.test.ts +107 -0
  68. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/PackageUtil.pathKeyedIdentity.test.ts +169 -0
  69. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/WorkspaceDeclaration.test.ts +174 -0
  70. package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/tsconfig.json +1 -0
  71. package/test/examples/source-repository/b/package-lock.json +13 -13
  72. package/test/examples/source-repository/b/package.json +3 -3
@@ -0,0 +1,70 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { Fs } from '../src/Fs';
5
+
6
+ /**
7
+ * Regression tests for the `Fs` glob helpers: the ignore patterns must PRUNE excluded
8
+ * directories, not merely filter them out of the results.
9
+ *
10
+ * `Fs` used to fold the base directory into one absolute pattern (`<dir>/<glob>`) and hand
11
+ * fast-glob the ignore list, which fast-glob then matched against the ABSOLUTE entry path. A
12
+ * globstar does not cross a dot-segment, so whenever the base path itself contained a
13
+ * dot-directory (`~/.n3xa/workspaces/<name>` — the default local workspaces root — or a
14
+ * `.scratch` estate) the ignore list matched nothing: the walk entered every node_modules/dist
15
+ * and followed the workspace symlinks inside them. On a materialized metarepo (70+
16
+ * node_modules trees) that is millions of entries held in fast-glob's unique-index, and the
17
+ * dev-skill Glob of every package.json exhausted a 12G heap three times running (2026-09-02).
18
+ * Matching relative to `cwd` keeps the ignore list and the entry paths in the same frame, so
19
+ * the prune holds under any base path.
20
+ *
21
+ * The oracle: an unreadable directory inside node_modules, under a dot-directory base path.
22
+ * A walk that enters node_modules hits EACCES, which fast-glob treats as fatal, and the call
23
+ * rejects; a pruned walk never sees it and resolves.
24
+ */
25
+ describe('Fs glob helpers prune ignored directories under a dot-directory base path', () => {
26
+ const IGNORE = ['**/node_modules/**', '**/dist/**'];
27
+ let root: string;
28
+ let locked: string;
29
+
30
+ beforeEach(async () => {
31
+ root = await fs.mkdtemp(path.join(os.tmpdir(), '.fs-glob-prune-'));
32
+ locked = path.join(root, 'node_modules', 'dep', 'locked');
33
+ await fs.mkdir(locked, { recursive: true });
34
+ await fs.mkdir(path.join(root, 'packages', 'pkg', 'src'), { recursive: true });
35
+ await fs.mkdir(path.join(root, 'dist'), { recursive: true });
36
+ await fs.writeFile(path.join(root, 'package.json'), '{}');
37
+ await fs.writeFile(path.join(root, 'packages', 'pkg', 'package.json'), '{}');
38
+ await fs.writeFile(path.join(root, 'packages', 'pkg', 'src', 'b.ts'), '');
39
+ await fs.writeFile(path.join(root, 'node_modules', 'dep', 'package.json'), '{}');
40
+ await fs.writeFile(path.join(root, 'dist', 'out.js'), '');
41
+ await fs.chmod(locked, 0o000);
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await fs.chmod(locked, 0o755).catch(() => undefined);
46
+ await fs.rm(root, { recursive: true, force: true });
47
+ });
48
+
49
+ const relative = (paths: string[]) => paths.map((p) => path.relative(root, p)).sort();
50
+
51
+ // chmod 000 does not stop root; the oracle is meaningless there.
52
+ const asRoot = typeof process.getuid === 'function' && process.getuid() === 0;
53
+ const it = asRoot ? test.skip : test;
54
+
55
+ it('getFilePathsMatchingGlob never enters node_modules, returns absolute paths', async () => {
56
+ const out = await Fs.getFilePathsMatchingGlob(root, '**/package.json', IGNORE);
57
+ expect(out.every((p) => path.isAbsolute(p))).toBe(true);
58
+ expect(relative(out)).toEqual(['package.json', 'packages/pkg/package.json']);
59
+ });
60
+
61
+ it('getFilePathsMatchingGlob: a trailing separator on the directory changes nothing', async () => {
62
+ const out = await Fs.getFilePathsMatchingGlob(root + path.sep, '**/package.json', IGNORE);
63
+ expect(relative(out)).toEqual(['package.json', 'packages/pkg/package.json']);
64
+ });
65
+
66
+ it('getFilePaths walks the whole tree except the ignored directories', async () => {
67
+ const out = await Fs.getFilePaths(root, IGNORE);
68
+ expect(relative(out)).toEqual(['package.json', 'packages/pkg/package.json', 'packages/pkg/src/b.ts']);
69
+ });
70
+ });
@@ -0,0 +1,107 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import { PackageUtil } from '../src/PackageUtil';
5
+
6
+ /**
7
+ * Regression tests for PackageUtil.getLocalPackageMap — workspace package discovery.
8
+ *
9
+ * The important property: discovery is a BOUNDED walk. It must not descend into
10
+ * hidden (dot-prefixed) directories, `node_modules`, or `dist`, and must never
11
+ * follow symlinks. The prior glob-based implementation violated all three at
12
+ * once in one spot: micromatch's default `dot: false` makes a leading `**` in
13
+ * an ignore pattern refuse to cross dot-segments, so beneath a hidden directory
14
+ * (e.g. a `.scratch/` full of throwaway checkouts) the
15
+ * `['**\/node_modules/**', '**\/dist/**']` ignores matched nothing while the
16
+ * walker still descended and followed symlinks — an unbounded traversal that
17
+ * OOMed every workspace command regardless of heap size.
18
+ */
19
+ describe('PackageUtil.getLocalPackageMap — bounded discovery', () => {
20
+ let workspaceRoot: string;
21
+
22
+ beforeEach(async () => {
23
+ workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pkg-discovery-'));
24
+ });
25
+
26
+ afterEach(async () => {
27
+ if (workspaceRoot) {
28
+ await fs.rm(workspaceRoot, { recursive: true, force: true }).catch(() => {
29
+ /* ignore */
30
+ });
31
+ }
32
+ });
33
+
34
+ const writePackageJson = async (relDir: string, name: string) => {
35
+ const dir = path.join(workspaceRoot, relDir);
36
+ await fs.mkdir(dir, { recursive: true });
37
+ await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }, null, 2));
38
+ };
39
+
40
+ /**
41
+ * The incident signature, compressed: a hidden directory containing a
42
+ * checkout whose `node_modules` holds symlinks back to the checkout root.
43
+ * The old glob walker descended into `.scratch` (hidden dirs were not
44
+ * pruned), its node_modules ignore was dead under the dot-segment
45
+ * (micromatch `dot: false`), and it followed the symlinks — several cycle
46
+ * links give the expansion a branching factor, so the path space grows
47
+ * combinatorially (`loopN/node_modules/loopM/...`) and the walk allocates
48
+ * unboundedly, exactly the OOM every workspace command hit. Bounded
49
+ * discovery never enters `.scratch` at all, so this returns instantly.
50
+ */
51
+ it('does not descend into hidden directories (unbounded-traversal incident repro)', async () => {
52
+ await writePackageJson('.', 'root');
53
+ await writePackageJson('packages/a', '@test/a');
54
+ await writePackageJson('.scratch/checkout/packages/b', '@test/b');
55
+ const checkoutDir = path.join(workspaceRoot, '.scratch', 'checkout');
56
+ await fs.mkdir(path.join(checkoutDir, 'node_modules'), { recursive: true });
57
+ for (let i = 0; i < 4; i++) {
58
+ await fs.symlink(checkoutDir, path.join(checkoutDir, 'node_modules', `loop${i}`));
59
+ }
60
+
61
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
62
+
63
+ expect(Object.keys(packageMap).sort()).toEqual(['@test/a', 'root']);
64
+ }, 10000);
65
+
66
+ it('does not follow symlinked directories', async () => {
67
+ await writePackageJson('packages/a', '@test/a');
68
+ // A package OUTSIDE the workspace, reachable only via symlink — the old
69
+ // walker followed the link and admitted @test/outside as a member.
70
+ const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pkg-outside-'));
71
+ try {
72
+ await fs.mkdir(path.join(outsideDir, 'c'), { recursive: true });
73
+ await fs.writeFile(
74
+ path.join(outsideDir, 'c', 'package.json'),
75
+ JSON.stringify({ name: '@test/outside', version: '1.0.0' })
76
+ );
77
+ await fs.symlink(path.join(outsideDir, 'c'), path.join(workspaceRoot, 'packages', 'linked'));
78
+
79
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
80
+
81
+ expect(Object.keys(packageMap)).toEqual(['@test/a']);
82
+ } finally {
83
+ await fs.rm(outsideDir, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ it('does not descend into node_modules or dist', async () => {
88
+ await writePackageJson('packages/a', '@test/a');
89
+ await writePackageJson('packages/a/node_modules/dep', '@test/dep');
90
+ await writePackageJson('packages/a/dist', '@test/dist-artifact');
91
+
92
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
93
+
94
+ expect(Object.keys(packageMap)).toEqual(['@test/a']);
95
+ });
96
+
97
+ it('survives a symlink cycle in the open (non-hidden) tree', async () => {
98
+ await writePackageJson('packages/a', '@test/a');
99
+ // Symlink from inside a package back to the workspace root — a cycle the
100
+ // old walker expanded path-by-path until the kernel refused to resolve it.
101
+ await fs.symlink(workspaceRoot, path.join(workspaceRoot, 'packages', 'a', 'back-to-root'));
102
+
103
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
104
+
105
+ expect(Object.keys(packageMap)).toEqual(['@test/a']);
106
+ });
107
+ });
@@ -0,0 +1,169 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import { PackageUtil } from '../src/PackageUtil';
5
+
6
+ /**
7
+ * Regression tests for path-keyed package identity (metarepo issue #75).
8
+ *
9
+ * Every workspace root in the metarepo is named `root` — the metarepo root, the app root, and
10
+ * each nested lerna root (proteinjs, util, build, ...). Package IDENTITY must therefore be the
11
+ * package.json path, never the name: a name-keyed discovery map silently drops all but one of
12
+ * the same-named packages, and closure computation seeded from a shared name-keyed graph node
13
+ * conflates their dependencies.
14
+ *
15
+ * Live failure this reproduces: the app root (named `root`, declaring a registry dep on
16
+ * @proteinjs/build) collided with the metarepo root in the name-keyed packageMap and was
17
+ * additionally skipped by symlink-workspace's `root` skip — so its node_modules kept a STALE
18
+ * REGISTRY copy of @proteinjs/build (carrying an old @proteinjs/util-node) indefinitely, and
19
+ * the dev-server OOM persisted after the workspace fix had shipped.
20
+ *
21
+ * Fixture shape (mirrors the metarepo): two packages BOTH named `root` with DIFFERENT deps,
22
+ * plus regular workspace packages. Each root's closure and symlinks must derive from its OWN
23
+ * declarations.
24
+ */
25
+ describe('PackageUtil — path-keyed package identity (same-named packages)', () => {
26
+ let workspaceRoot: string;
27
+
28
+ beforeEach(async () => {
29
+ workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pkg-identity-'));
30
+ });
31
+
32
+ afterEach(async () => {
33
+ if (workspaceRoot) {
34
+ await fs.rm(workspaceRoot, { recursive: true, force: true }).catch(() => {
35
+ /* ignore */
36
+ });
37
+ }
38
+ });
39
+
40
+ /** Write a package.json and return its absolute file path. */
41
+ const writePackageJson = async (
42
+ relDir: string,
43
+ name: string,
44
+ deps: Record<string, string> = {},
45
+ devDeps: Record<string, string> = {}
46
+ ): Promise<string> => {
47
+ const dir = path.join(workspaceRoot, relDir);
48
+ await fs.mkdir(dir, { recursive: true });
49
+ const pkgPath = path.join(dir, 'package.json');
50
+ await fs.writeFile(
51
+ pkgPath,
52
+ JSON.stringify({ name, version: '1.0.0', dependencies: deps, devDependencies: devDeps }, null, 2)
53
+ );
54
+ return pkgPath;
55
+ };
56
+
57
+ /**
58
+ * The metarepo shape: metarepo root and app root are BOTH named `root`, with different
59
+ * workspace deps. `@test/build` itself depends on `@test/util-node` (the live transitive
60
+ * chain that carried the stale code).
61
+ */
62
+ const writeMetarepoFixture = async () => {
63
+ const metarepoRootPath = await writePackageJson('.', 'root', {}, { '@test/build': '^1.0.0' });
64
+ const appRootPath = await writePackageJson('packages/app', 'root', {}, { '@test/app-lib': '^1.0.0' });
65
+ const buildPath = await writePackageJson('packages/tools/build', '@test/build', {
66
+ '@test/util-node': '^1.0.0',
67
+ });
68
+ const utilNodePath = await writePackageJson('packages/tools/util-node', '@test/util-node');
69
+ const appLibPath = await writePackageJson('packages/app/packages/app-lib', '@test/app-lib');
70
+ return { metarepoRootPath, appRootPath, buildPath, utilNodePath, appLibPath };
71
+ };
72
+
73
+ test('discovery retains every same-named package, path-keyed', async () => {
74
+ const { metarepoRootPath, appRootPath } = await writeMetarepoFixture();
75
+
76
+ const packagePathMap = await PackageUtil.getLocalPackagePathMap(workspaceRoot);
77
+
78
+ // Both `root`-named packages are distinct entries under their own package.json paths.
79
+ expect(packagePathMap[metarepoRootPath]?.name).toBe('root');
80
+ expect(packagePathMap[appRootPath]?.name).toBe('root');
81
+ expect(Object.keys(packagePathMap)).toHaveLength(5);
82
+
83
+ // Workspace metadata carries the same collision-free map; the name-keyed index still
84
+ // resolves real (unique) dependency names.
85
+ const metadata = await PackageUtil.getWorkspaceMetadata(workspaceRoot);
86
+ expect(Object.keys(metadata.packagePathMap)).toHaveLength(5);
87
+ expect(metadata.packageMap['@test/build'].filePath).toBe(
88
+ path.join(workspaceRoot, 'packages/tools/build/package.json')
89
+ );
90
+ });
91
+
92
+ test('transitive closure derives from the given package instance, not a shared name-keyed node', async () => {
93
+ const { metarepoRootPath, appRootPath } = await writeMetarepoFixture();
94
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
95
+
96
+ // Construct the LocalPackage records from disk truth (discovery may or may not retain
97
+ // both same-named packages in a name-keyed map — closure must be correct regardless of
98
+ // which instance we hand it).
99
+ const metarepoRoot = {
100
+ name: 'root',
101
+ filePath: metarepoRootPath,
102
+ packageJson: JSON.parse(await fs.readFile(metarepoRootPath, 'utf-8')),
103
+ };
104
+ const appRoot = {
105
+ name: 'root',
106
+ filePath: appRootPath,
107
+ packageJson: JSON.parse(await fs.readFile(appRootPath, 'utf-8')),
108
+ };
109
+
110
+ const metarepoClosure = await PackageUtil.getTransitiveWorkspaceDependencies(metarepoRoot, packageMap);
111
+ const appClosure = await PackageUtil.getTransitiveWorkspaceDependencies(appRoot, packageMap);
112
+
113
+ // The metarepo root declares @test/build (which pulls @test/util-node) — and nothing else.
114
+ expect(metarepoClosure.sort()).toEqual(['@test/build', '@test/util-node']);
115
+ // The app root declares @test/app-lib — and nothing else. A name-keyed traversal seeded
116
+ // from the shared `root` node returns the OTHER root's deps here.
117
+ expect(appClosure.sort()).toEqual(['@test/app-lib']);
118
+ });
119
+
120
+ test('symlinkDependencies links each same-named consumer to ITS OWN workspace closure', async () => {
121
+ const { metarepoRootPath, appRootPath } = await writeMetarepoFixture();
122
+ const packageMap = await PackageUtil.getLocalPackageMap(workspaceRoot);
123
+ const metarepoRoot = {
124
+ name: 'root',
125
+ filePath: metarepoRootPath,
126
+ packageJson: JSON.parse(await fs.readFile(metarepoRootPath, 'utf-8')),
127
+ };
128
+ const appRoot = {
129
+ name: 'root',
130
+ filePath: appRootPath,
131
+ packageJson: JSON.parse(await fs.readFile(appRootPath, 'utf-8')),
132
+ };
133
+
134
+ await PackageUtil.symlinkDependencies(metarepoRoot, packageMap);
135
+ await PackageUtil.symlinkDependencies(appRoot, packageMap);
136
+
137
+ // Metarepo root gets its own closure: build + util-node, NOT app-lib.
138
+ const metarepoNodeModules = path.join(workspaceRoot, 'node_modules');
139
+ await expectSymlinkTo(
140
+ path.join(metarepoNodeModules, '@test/build'),
141
+ path.join(workspaceRoot, 'packages/tools/build')
142
+ );
143
+ await expectSymlinkTo(
144
+ path.join(metarepoNodeModules, '@test/util-node'),
145
+ path.join(workspaceRoot, 'packages/tools/util-node')
146
+ );
147
+ await expectAbsent(path.join(metarepoNodeModules, '@test/app-lib'));
148
+
149
+ // App root gets ITS closure: app-lib only, NOT the metarepo root's build chain.
150
+ const appNodeModules = path.join(workspaceRoot, 'packages/app/node_modules');
151
+ await expectSymlinkTo(
152
+ path.join(appNodeModules, '@test/app-lib'),
153
+ path.join(workspaceRoot, 'packages/app/packages/app-lib')
154
+ );
155
+ await expectAbsent(path.join(appNodeModules, '@test/build'));
156
+ });
157
+
158
+ const expectSymlinkTo = async (linkPath: string, expectedTargetDir: string) => {
159
+ const stat = await fs.lstat(linkPath).catch(() => undefined);
160
+ expect(stat?.isSymbolicLink() ?? false).toBe(true);
161
+ const resolved = await fs.realpath(linkPath);
162
+ expect(resolved).toBe(await fs.realpath(expectedTargetDir));
163
+ };
164
+
165
+ const expectAbsent = async (linkPath: string) => {
166
+ const stat = await fs.lstat(linkPath).catch(() => undefined);
167
+ expect(stat).toBeUndefined();
168
+ };
169
+ });
@@ -0,0 +1,174 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import { PackageUtil } from '../src/PackageUtil';
5
+ import { WorkspaceDeclaration, WorkspaceDeclarationError } from '../src/WorkspaceDeclaration';
6
+
7
+ /**
8
+ * Workspace membership comes from the DECLARATION, never from a crawl (n3xah/app Deploy to Test
9
+ * run 33747781291, 2026-09-03): the scanner crawled every package.json under the app and keyed
10
+ * them by name, so CI fixture trees whose package.json files reused the app's own package names
11
+ * (`@n3xa/app-common/-server/-ui` under scripts/ci/fixtures) shadowed the real packages — walk
12
+ * order put the fixtures last, they had no build script, and build-workspace reported "1 package
13
+ * in workspace" (ops/error-bridge), shipping an image with no packages/server/dist.
14
+ *
15
+ * The rules pinned here:
16
+ * - a directory holding lerna.json `packages` (or a root package.json `workspacePackages`) is a
17
+ * workspace ROOT; its members are exactly what it declares, and it OWNS its subtree — nothing
18
+ * undeclared beneath it is a member, however the tree is walked from above;
19
+ * - a declared literal path with no package.json is a hard error naming the declaration;
20
+ * - two leaf packages sharing a name is a hard error naming both paths (roots — the containers
21
+ * every metarepo names `root` — are identified by path and exempt);
22
+ * - an undeclared tree still crawls (bounded: no hidden dirs, node_modules, dist, symlinks), and
23
+ * hands every declared root it meets to that root's declaration.
24
+ */
25
+ describe('WorkspaceDeclaration — membership is declared, never crawled', () => {
26
+ let root: string;
27
+
28
+ beforeEach(async () => {
29
+ root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ws-decl-')));
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
34
+ });
35
+
36
+ const writeJson = async (relPath: string, json: unknown) => {
37
+ const filePath = path.join(root, relPath);
38
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
39
+ await fs.writeFile(filePath, JSON.stringify(json, null, 2));
40
+ return filePath;
41
+ };
42
+ const writePackage = (relDir: string, name: string, extra: Record<string, unknown> = {}) =>
43
+ writeJson(path.join(relDir, 'package.json'), { name, version: '1.0.0', ...extra });
44
+ const buildable = { scripts: { build: 'tsc' } };
45
+ const rel = (filePaths: string[]) => filePaths.map((p) => path.relative(root, p)).sort();
46
+
47
+ it('resolves a declared package to ITS OWN package.json — a same-named fixture beneath the root is not a member (the 33747781291 shadowing repro)', async () => {
48
+ await writeJson('lerna.json', { packages: ['packages/common', 'packages/server'] });
49
+ const rootPkg = await writePackage('.', 'root');
50
+ const common = await writePackage('packages/common', '@t/common', buildable);
51
+ const server = await writePackage('packages/server', '@t/server', buildable);
52
+ // The impostor: same name, sorts AFTER the real one (walk order made it "win"), no build script.
53
+ await writePackage('scripts/ci/fixtures/floor-sync/repo/packages/server', '@t/server');
54
+ await writePackage('scripts/ci/fixtures/floor-sync/repo', 'root');
55
+
56
+ const { packageMap, packagePathMap, sortedPackageNames } = await PackageUtil.getWorkspaceMetadata(root);
57
+
58
+ expect(packageMap['@t/server'].filePath).toBe(server);
59
+ expect(packageMap['@t/server'].packageJson.scripts?.build).toBe('tsc');
60
+ expect(rel(Object.keys(packagePathMap))).toEqual(rel([rootPkg, common, server]));
61
+ expect(sortedPackageNames.filter((n) => n !== 'root').sort()).toEqual(['@t/common', '@t/server']);
62
+ });
63
+
64
+ it('expands lerna globs (packages/*, packages/**) and the root package.json `workspacePackages` extra roots', async () => {
65
+ await writeJson('lerna.json', { packages: ['packages/*'] });
66
+ const rootPkg = await writeJson('package.json', {
67
+ name: 'root',
68
+ version: '1.0.0',
69
+ [WorkspaceDeclaration.EXTRA_PACKAGES_FIELD]: ['ops/*'],
70
+ });
71
+ const a = await writePackage('packages/a', '@t/a', buildable);
72
+ const b = await writePackage('packages/b', '@t/b', buildable);
73
+ const bridge = await writePackage('ops/error-bridge', '@t/error-bridge', buildable);
74
+ await fs.mkdir(path.join(root, 'ops/notes'), { recursive: true }); // a glob match without package.json: skipped
75
+ await writePackage('packages/a/test/fixtures/x', '@t/a-fixture'); // not matched by packages/* — not a member
76
+
77
+ const { packagePathMap } = await PackageUtil.getWorkspaceMetadata(root);
78
+ expect(rel(Object.keys(packagePathMap))).toEqual(rel([rootPkg, a, b, bridge]));
79
+
80
+ // packages/** reaches nested dirs but still never enters hidden dirs, node_modules, dist, or symlinks.
81
+ await writeJson('lerna.json', { packages: ['packages/**'] });
82
+ const deep = await writePackage('packages/group/c', '@t/c', buildable);
83
+ await writePackage('packages/.hidden/h', '@t/hidden');
84
+ await writePackage('packages/a/node_modules/dep', '@t/dep');
85
+ await writePackage('packages/a/dist', '@t/dist-artifact');
86
+ const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-outside-'));
87
+ try {
88
+ await fs.mkdir(path.join(outside, 'o'));
89
+ await fs.writeFile(path.join(outside, 'o', 'package.json'), JSON.stringify({ name: '@t/outside' }));
90
+ await fs.symlink(path.join(outside, 'o'), path.join(root, 'packages', 'linked'));
91
+ const nested = await writePackage('packages/a/test/fixtures/x', '@t/a-fixture'); // packages/** DOES reach it
92
+ const { packagePathMap: deepMap } = await PackageUtil.getWorkspaceMetadata(root);
93
+ expect(rel(Object.keys(deepMap))).toEqual(rel([rootPkg, a, b, bridge, deep, nested]));
94
+ } finally {
95
+ await fs.rm(outside, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ it('a declared literal path with no package.json is a hard error naming the declaration and the path', async () => {
100
+ await writeJson('lerna.json', { packages: ['packages/common', 'packages/ui'] });
101
+ await writePackage('.', 'root');
102
+ await writePackage('packages/common', '@t/common', buildable);
103
+ await fs.mkdir(path.join(root, 'packages/ui'), { recursive: true }); // declared, but empty
104
+
105
+ await expect(PackageUtil.getWorkspaceMetadata(root)).rejects.toThrow(WorkspaceDeclarationError);
106
+ await expect(PackageUtil.getWorkspaceMetadata(root)).rejects.toThrow(
107
+ /lerna\.json declares "packages\/ui" but .*packages\/ui\/package\.json does not exist/
108
+ );
109
+ });
110
+
111
+ it('an undeclared tree crawls, and every declared root it meets owns its own subtree (the metarepo shape)', async () => {
112
+ // Metarepo root: no lerna.json, no workspacePackages — a crawl root, named `root` like every root.
113
+ const meta = await writePackage('.', 'root');
114
+ // packages/app is a declared root; its fixtures are NOT members even though the crawl passes above them.
115
+ await writeJson('packages/app/lerna.json', { packages: ['packages/common'] });
116
+ const app = await writePackage('packages/app', 'root');
117
+ const appCommon = await writePackage('packages/app/packages/common', '@t/app-common', buildable);
118
+ await writePackage('packages/app/scripts/ci/fixtures/f/packages/common', '@t/app-common');
119
+ await writePackage('packages/app/packages/undeclared', '@t/undeclared', buildable);
120
+ // A plain undeclared package elsewhere in the crawl stays a member (today's behavior for loose trees).
121
+ const loose = await writePackage('packages/loose', '@t/loose', buildable);
122
+
123
+ const { packageMap, packagePathMap } = await PackageUtil.getWorkspaceMetadata(root);
124
+ expect(rel(Object.keys(packagePathMap))).toEqual(rel([meta, app, appCommon, loose]));
125
+ expect(packageMap['@t/app-common'].filePath).toBe(appCommon);
126
+ expect(packageMap['@t/undeclared']).toBeUndefined();
127
+ });
128
+
129
+ it('two leaf packages sharing a name is a hard error naming both paths and which one is declared', async () => {
130
+ await writePackage('.', 'root');
131
+ await writeJson('packages/app/lerna.json', { packages: ['packages/common'] });
132
+ await writePackage('packages/app', 'root');
133
+ const declared = await writePackage('packages/app/packages/common', '@t/app-common', buildable);
134
+ const crawled = await writePackage('tools/impostor', '@t/app-common');
135
+
136
+ let error: unknown;
137
+ try {
138
+ await WorkspaceDeclaration.discover(root);
139
+ } catch (e) {
140
+ error = e;
141
+ }
142
+ expect(error).toBeInstanceOf(WorkspaceDeclarationError);
143
+ const message = String((error as Error).message);
144
+ expect(message).toContain('@t/app-common');
145
+ expect(message).toContain(declared);
146
+ expect(message).toContain(crawled);
147
+ expect(message).toMatch(/declared by .*packages\/app\/lerna\.json/);
148
+ expect(message).toMatch(/crawled/);
149
+
150
+ // Declared vs declared collides the same way.
151
+ await fs.rm(path.join(root, 'tools'), { recursive: true });
152
+ await writeJson('packages/other/lerna.json', { packages: ['packages/*'] });
153
+ await writePackage('packages/other', 'root');
154
+ const otherCommon = await writePackage('packages/other/packages/common', '@t/app-common', buildable);
155
+ await expect(WorkspaceDeclaration.discover(root)).rejects.toThrow(otherCommon);
156
+ });
157
+
158
+ it('containers named `root` at every level stay legal — roots are identified by path, not name', async () => {
159
+ const meta = await writePackage('.', 'root');
160
+ await writeJson('packages/app/lerna.json', { packages: ['packages/*'] });
161
+ const app = await writePackage('packages/app', 'root');
162
+ const common = await writePackage('packages/app/packages/common', '@t/common', buildable);
163
+ await writeJson('packages/lib/lerna.json', {}); // lerna's default packages/* when the field is absent
164
+ const lib = await writePackage('packages/lib', 'root');
165
+ const libA = await writePackage('packages/lib/packages/a', '@t/lib-a', buildable);
166
+
167
+ const discovered = await WorkspaceDeclaration.discover(root);
168
+ expect(rel(discovered.map((d) => d.filePath))).toEqual(rel([meta, app, common, lib, libA]));
169
+ const byPath = new Map(discovered.map((d) => [d.filePath, d]));
170
+ expect(byPath.get(common)?.origin).toBe('declared');
171
+ expect(byPath.get(common)?.declaredBy).toBe(path.join(root, 'packages/app/lerna.json'));
172
+ expect(byPath.get(meta)?.origin).toBe('crawled');
173
+ });
174
+ });
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "noEmitOnError": true,
3
4
  "target": "es5",
4
5
  "module": "commonjs",
5
6
  "declaration": true,
@@ -10,9 +10,9 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@dagrejs/graphlib": "2.1.4",
13
- "@proteinjs/reflection": "^1.1.12",
14
- "@proteinjs/util": "^1.6.1",
15
- "@proteinjs/util-node": "^1.10.1",
13
+ "@proteinjs/reflection": "^1.2.0",
14
+ "@proteinjs/util": "^1.6.2",
15
+ "@proteinjs/util-node": "^1.11.0",
16
16
  "globby": "11.0.1",
17
17
  "gulp": "4.0.2",
18
18
  "gulp-shell": "0.8.0",
@@ -1078,9 +1078,9 @@
1078
1078
  }
1079
1079
  },
1080
1080
  "node_modules/@proteinjs/reflection": {
1081
- "version": "1.1.12",
1082
- "resolved": "https://registry.npmjs.org/@proteinjs/reflection/-/reflection-1.1.12.tgz",
1083
- "integrity": "sha512-w+sL7Nqppk4DXdFxrfwjEGHSPykCwB3GWaiQXcP6X+8Qtj32BI0wmTvAt6hXvtbXPhvIHPFnTfSs9qlW0FYf9Q==",
1081
+ "version": "1.2.0",
1082
+ "resolved": "https://registry.npmjs.org/@proteinjs/reflection/-/reflection-1.2.0.tgz",
1083
+ "integrity": "sha512-AsoipvjpRD0WA1VC4770D5NsN0BDdli9UipdzHHPxcA+BJYcxwPLPLA7h+AAXTNMNfpr/BbEFkxHC0oSCiTkNg==",
1084
1084
  "license": "ISC",
1085
1085
  "dependencies": {
1086
1086
  "@dagrejs/graphlib": "2.1.4",
@@ -1088,9 +1088,9 @@
1088
1088
  }
1089
1089
  },
1090
1090
  "node_modules/@proteinjs/util": {
1091
- "version": "1.6.1",
1092
- "resolved": "https://registry.npmjs.org/@proteinjs/util/-/util-1.6.1.tgz",
1093
- "integrity": "sha512-bFy6++l1u6RtMMeZEDYvMQWg14SAG2gCgUrcPy9M0Ctl0ATVMdQbU3h4IUQOlxwW32mWoni0qDTt9BkmHwAQjg==",
1091
+ "version": "1.6.2",
1092
+ "resolved": "https://registry.npmjs.org/@proteinjs/util/-/util-1.6.2.tgz",
1093
+ "integrity": "sha512-ZY7rMImFNnGV7x/Bqkkav1/gy9IvRR823JcnRJ4SoWXSlA29kuPdaHfnBay6eTQ7+kjiGGtJ+tMWM6NvZW9rUw==",
1094
1094
  "license": "ISC",
1095
1095
  "dependencies": {
1096
1096
  "@dagrejs/graphlib": "2.1.4",
@@ -1098,13 +1098,13 @@
1098
1098
  }
1099
1099
  },
1100
1100
  "node_modules/@proteinjs/util-node": {
1101
- "version": "1.10.1",
1102
- "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.10.1.tgz",
1103
- "integrity": "sha512-950BkiO4ccf9Bbt0CpvPj5pUalNH9dV1zqr7Cw1Dh3tg9xny6R02p1QrKvPU7anoZcnSp2Us6lBv0rlutYoSWw==",
1101
+ "version": "1.11.0",
1102
+ "resolved": "https://registry.npmjs.org/@proteinjs/util-node/-/util-node-1.11.0.tgz",
1103
+ "integrity": "sha512-+G6Q2w/F5NqmB/3wpNlhj6anpFs503xeKGiec4Hh0+WAsN1JkZCMmEQsHrrzaLGide0b6LyY3Kdo1eycFWRwGw==",
1104
1104
  "license": "ISC",
1105
1105
  "dependencies": {
1106
1106
  "@dagrejs/graphlib": "2.1.4",
1107
- "@proteinjs/util": "^1.6.1",
1107
+ "@proteinjs/util": "^1.6.2",
1108
1108
  "fs-extra": "11.1.1",
1109
1109
  "globby": "11.0.1",
1110
1110
  "typescript": "5.2.2"
@@ -19,9 +19,9 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@dagrejs/graphlib": "2.1.4",
22
- "@proteinjs/reflection": "^1.1.12",
23
- "@proteinjs/util": "^1.6.1",
24
- "@proteinjs/util-node": "^1.10.1",
22
+ "@proteinjs/reflection": "^1.2.0",
23
+ "@proteinjs/util": "^1.6.2",
24
+ "@proteinjs/util-node": "^1.11.0",
25
25
  "globby": "11.0.1",
26
26
  "gulp": "4.0.2",
27
27
  "gulp-shell": "0.8.0",