• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 22285099215

22 Feb 2026 08:52PM UTC coverage: 75.854% (-17.1%) from 92.936%
22285099215

Pull #23121

github

web-flow
Merge c7299df9c into ba8359840
Pull Request #23121: fix issue with optional fields in dependency validator

28 of 29 new or added lines in 2 files covered. (96.55%)

11174 existing lines in 400 files now uncovered.

53694 of 70786 relevant lines covered (75.85%)

1.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.86
/src/python/pants/backend/javascript/nodejs_project.py
1
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
4✔
4

5
import itertools
4✔
6
import os.path
4✔
7
from collections.abc import Iterable
4✔
8
from dataclasses import dataclass, replace
4✔
9
from pathlib import PurePath
4✔
10

11
from pants.backend.javascript import package_json
4✔
12
from pants.backend.javascript.package_json import (
4✔
13
    AllPackageJson,
14
    PackageJson,
15
    PnpmWorkspaceGlobs,
16
    PnpmWorkspaces,
17
)
18
from pants.backend.javascript.package_manager import PackageManager
4✔
19
from pants.backend.javascript.subsystems import nodejs
4✔
20
from pants.backend.javascript.subsystems.nodejs import NodeJS, UserChosenNodeJSResolveAliases
4✔
21
from pants.core.util_rules import stripped_source_files
4✔
22
from pants.core.util_rules.stripped_source_files import StrippedFileNameRequest, strip_file_name
4✔
23
from pants.engine.collection import Collection
4✔
24
from pants.engine.internals.native_engine import MergeDigests
4✔
25
from pants.engine.rules import Rule, collect_rules, rule
4✔
26
from pants.engine.unions import UnionRule
4✔
27
from pants.util.ordered_set import FrozenOrderedSet
4✔
28
from pants.util.strutil import bullet_list, softwrap
4✔
29

30

31
@dataclass(frozen=True)
4✔
32
class _TentativeProject:
4✔
33
    root_dir: str
4✔
34
    workspaces: FrozenOrderedSet[PackageJson]
4✔
35
    default_resolve_name: str
4✔
36

37
    def is_parent(self, project: _TentativeProject) -> bool:
4✔
38
        return self.root_dir != project.root_dir and any(
4✔
39
            project.root_dir == workspace.root_dir for workspace in self.workspaces
40
        )
41

42
    def including_workspaces_from(self, child: _TentativeProject) -> _TentativeProject:
4✔
43
        return replace(self, workspaces=self.workspaces | child.workspaces)
3✔
44

45
    def root_workspace(self) -> PackageJson | None:
4✔
46
        for ws in self.workspaces:
4✔
47
            if ws.root_dir == self.root_dir:
4✔
48
                return ws
4✔
49
        return None
×
50

51

52
@dataclass(frozen=True)
4✔
53
class NodeJSProject:
4✔
54
    root_dir: str
4✔
55
    workspaces: FrozenOrderedSet[PackageJson]
4✔
56
    default_resolve_name: str
4✔
57
    package_manager: PackageManager
4✔
58
    pnpm_workspace: PnpmWorkspaceGlobs | None = None
4✔
59

60
    @property
4✔
61
    def lockfile_name(self) -> str:
4✔
62
        return self.package_manager.lockfile_name
4✔
63

64
    @property
4✔
65
    def generate_lockfile_args(self) -> tuple[str, ...]:
4✔
UNCOV
66
        return self.package_manager.generate_lockfile_args
×
67

68
    @property
4✔
69
    def immutable_install_args(self) -> tuple[str, ...]:
4✔
70
        return self.package_manager.immutable_install_args
3✔
71

72
    @property
4✔
73
    def workspace_specifier_arg(self) -> str:
4✔
74
        return self.package_manager.workspace_specifier_arg
1✔
75

76
    @property
4✔
77
    def args_separator(self) -> tuple[str, ...]:
4✔
UNCOV
78
        return self.package_manager.run_arg_separator
×
79

80
    def extra_env(self) -> dict[str, str]:
4✔
81
        return dict(self.package_manager.extra_env)
3✔
82

83
    @property
4✔
84
    def pack_archive_format(self) -> str:
4✔
85
        return self.package_manager.pack_archive_format
1✔
86

87
    def extra_caches(self) -> dict[str, str]:
4✔
88
        return dict(self.package_manager.extra_caches)
3✔
89

90
    def get_project_digest(self) -> MergeDigests:
4✔
91
        return MergeDigests(
3✔
92
            itertools.chain(
93
                (ws.digest for ws in self.workspaces),
94
                [self.pnpm_workspace.digest] if self.pnpm_workspace else [],
95
            )
96
        )
97

98
    @property
4✔
99
    def single_workspace(self) -> bool:
4✔
100
        return len(self.workspaces) == 1 and next(iter(self.workspaces)).root_dir == self.root_dir
3✔
101

102
    @classmethod
4✔
103
    def from_tentative(
4✔
104
        cls,
105
        project: _TentativeProject,
106
        nodejs: NodeJS,
107
        pnpm_workspaces: PnpmWorkspaces,
108
    ) -> NodeJSProject:
109
        root_ws = project.root_workspace()
4✔
110
        package_manager: str | None = None
4✔
111
        if root_ws:
4✔
112
            package_manager = root_ws.package_manager or nodejs.default_package_manager
4✔
113
        if not package_manager:
4✔
114
            raise ValueError(
×
115
                softwrap(
116
                    f"""
117
                    Could not determine package manager for project {project.default_resolve_name}.
118

119
                    Either configure a default [{NodeJS.name}].package_manager, or set the root
120
                    `package.json#packageManager` property.
121
                    """
122
                )
123
            )
124

125
        for workspace in project.workspaces:
4✔
126
            if workspace.package_manager:
4✔
127
                if not package_manager == workspace.package_manager:
3✔
128
                    ws_ref = f"{workspace.name}@{workspace.version}"
1✔
129
                    raise ValueError(
1✔
130
                        softwrap(
131
                            f"""
132
                            Workspace {ws_ref}'s package manager
133
                            {workspace.package_manager} is not compatible with
134
                            project {project.default_resolve_name}'s package manager {package_manager}.
135

136
                            Move or duplicate the `package.json#packageManager` entry from the
137
                            workspace {ws_ref} to the root package to resolve this error.
138
                            """
139
                        )
140
                    )
141

142
        return NodeJSProject(
4✔
143
            root_dir=project.root_dir,
144
            workspaces=project.workspaces,
145
            default_resolve_name=project.default_resolve_name or "nodejs-default",
146
            package_manager=PackageManager.from_string(package_manager),
147
            pnpm_workspace=pnpm_workspaces.for_root(project.root_dir),
148
        )
149

150

151
class AllNodeJSProjects(Collection[NodeJSProject]):
4✔
152
    def project_for_directory(self, directory: str) -> NodeJSProject:
4✔
153
        for project in self:
4✔
154
            if directory in (workspace.root_dir for workspace in project.workspaces):
4✔
155
                return project
4✔
156
        raise ValueError(
×
157
            f"{directory} is not a package directory that is part of a project. This is likely a bug."
158
        )
159

160

161
@dataclass(frozen=True)
4✔
162
class ProjectPaths:
4✔
163
    root: str
4✔
164
    project_globs: list[str]
4✔
165

166
    def full_globs(self) -> Iterable[str]:
4✔
167
        return (os.path.join(self.root, project) for project in self.project_globs)
4✔
168

169
    def matches_glob(self, pkg_json: PackageJson) -> bool:
4✔
170
        path = PurePath(pkg_json.root_dir)
4✔
171

172
        def safe_match(glob: str) -> bool:
4✔
173
            if glob == "":
4✔
174
                return pkg_json.root_dir == ""
2✔
175
            return path.match(glob)
3✔
176

177
        return any(safe_match(glob) for glob in self.full_globs())
4✔
178

179

180
async def _get_default_resolve_name(path: str) -> str:
4✔
181
    stripped = await strip_file_name(StrippedFileNameRequest(path))
4✔
182
    return stripped.value.replace(os.path.sep, ".")
4✔
183

184

185
@rule
4✔
186
async def find_node_js_projects(
4✔
187
    package_workspaces: AllPackageJson,
188
    pnpm_workspaces: PnpmWorkspaces,
189
    nodejs: NodeJS,
190
    resolve_names: UserChosenNodeJSResolveAliases,
191
) -> AllNodeJSProjects:
192
    project_paths = []
4✔
193
    for pkg in package_workspaces:
4✔
194
        package_manager = pkg.package_manager or nodejs.default_package_manager
4✔
195
        if (
4✔
196
            package_manager and PackageManager.from_string(package_manager).name == "pnpm"
197
        ):  # case for pnpm
198
            if pkg in pnpm_workspaces:
2✔
199
                project_paths.append(
1✔
200
                    ProjectPaths(pkg.root_dir, ["", *pnpm_workspaces[pkg].packages])
201
                )
202
            else:
203
                project_paths.append(ProjectPaths(pkg.root_dir, [""]))
2✔
204
        else:  # case for npm, yarn
205
            project_paths.append(ProjectPaths(pkg.root_dir, ["", *pkg.workspaces]))
4✔
206

207
    node_js_projects = {
4✔
208
        _TentativeProject(
209
            paths.root,
210
            FrozenOrderedSet(pkg for pkg in package_workspaces if paths.matches_glob(pkg)),
211
            await _get_default_resolve_name(paths.root),
212
        )
213
        for paths in project_paths
214
    }
215
    merged_projects = _merge_workspaces(node_js_projects)
4✔
216
    all_projects = AllNodeJSProjects(
4✔
217
        NodeJSProject.from_tentative(p, nodejs, pnpm_workspaces) for p in merged_projects
218
    )
219
    _ensure_resolve_names_are_unique(all_projects, resolve_names)
4✔
220

221
    return all_projects
4✔
222

223

224
_AMBIGUOUS_RESOLVE_SOLUTIONS = [
4✔
225
    f"Configure [{NodeJS.options_scope}].resolves to grant the package.json directories different names.",
226
    "Make one package a workspace of the other.",
227
    "Re-configure your source root(s).",
228
]
229

230

231
def _ensure_resolve_names_are_unique(
4✔
232
    all_projects: AllNodeJSProjects, resolve_names: UserChosenNodeJSResolveAliases
233
) -> None:
234
    seen: dict[str, NodeJSProject] = {}
4✔
235
    for project in all_projects:
4✔
236
        resolve_name = resolve_names.get(project.root_dir, project.default_resolve_name)
4✔
237
        seen_project = seen.get(resolve_name)
4✔
238
        if seen_project:
4✔
239
            raise ValueError(
×
240
                softwrap(
241
                    f"""
242
                    Projects with root directories '{project.root_dir}' and '{seen_project.root_dir}'
243
                    have the same resolve name {resolve_name}. This will cause ambiguities.
244

245
                    To disambiguate, either:\n\n
246
                    {bullet_list(_AMBIGUOUS_RESOLVE_SOLUTIONS)}
247
                    """
248
                )
249
            )
250
        seen[resolve_name] = project
4✔
251

252

253
def _project_to_parents(
4✔
254
    projects: set[_TentativeProject],
255
) -> dict[_TentativeProject, list[_TentativeProject]]:
256
    return {
4✔
257
        project: [
258
            candidate_parent for candidate_parent in projects if candidate_parent.is_parent(project)
259
        ]
260
        for project in sorted(projects, key=lambda p: p.root_dir, reverse=False)
261
    }
262

263

264
def _merge_workspaces(node_js_projects: set[_TentativeProject]) -> Iterable[_TentativeProject]:
4✔
265
    project_to_parents = _project_to_parents(node_js_projects)
4✔
266
    while any(parents for parents in project_to_parents.values()):
4✔
267
        _ensure_one_parent(project_to_parents)
3✔
268
        node_js_projects = set()
3✔
269
        for project, parents in project_to_parents.items():
3✔
270
            node_js_projects -= {project, *parents}
3✔
271
            node_js_projects.add(
3✔
272
                parents[0].including_workspaces_from(project) if parents else project
273
            )
274
        project_to_parents = _project_to_parents(node_js_projects)
3✔
275
    return node_js_projects
4✔
276

277

278
def _ensure_one_parent(
4✔
279
    project_to_parents: dict[_TentativeProject, list[_TentativeProject]],
280
) -> None:
281
    for project, parents in project_to_parents.items():
3✔
282
        if len(parents) > 1:
3✔
283
            raise ValueError(
1✔
284
                softwrap(
285
                    f"""
286
                    Nodejs projects {", ".join(parent.root_dir for parent in parents)}
287
                    are specifying {project.root_dir} to be part of their workspaces.
288

289
                    A package can only be part of one project.
290
                    """
291
                )
292
            )
293

294

295
def rules() -> Iterable[Rule | UnionRule]:
4✔
296
    return [
4✔
297
        *nodejs.rules(),
298
        *package_json.rules(),
299
        *stripped_source_files.rules(),
300
        *collect_rules(),
301
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc