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

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

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

70.0
/src/python/pants/backend/javascript/package_json.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
12✔
4

5
import itertools
12✔
6
import json
12✔
7
import logging
12✔
8
import os.path
12✔
9
from abc import ABC
12✔
10
from collections.abc import Iterable, Mapping
12✔
11
from dataclasses import dataclass, field
12✔
12
from typing import Any, ClassVar, Literal
12✔
13

14
import yaml
12✔
15

16
from pants.backend.project_info import dependencies
12✔
17
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
12✔
18
from pants.base.specs import AncestorGlobSpec, RawSpecs
12✔
19
from pants.build_graph.address import Address
12✔
20
from pants.build_graph.build_file_aliases import BuildFileAliases
12✔
21
from pants.core.goals.package import OutputPathField
12✔
22
from pants.core.target_types import (
12✔
23
    TargetGeneratorSourcesHelperSourcesField,
24
    TargetGeneratorSourcesHelperTarget,
25
)
26
from pants.core.util_rules import stripped_source_files
12✔
27
from pants.engine import fs
12✔
28
from pants.engine.collection import Collection, DeduplicatedCollection
12✔
29
from pants.engine.env_vars import EXTRA_ENV_VARS_USAGE_HELP
12✔
30
from pants.engine.fs import CreateDigest, FileContent, GlobExpansionConjunction, PathGlobs
12✔
31
from pants.engine.internals import graph
12✔
32
from pants.engine.internals.graph import (
12✔
33
    ResolveAllTargetGeneratorRequests,
34
    resolve_all_generator_target_requests,
35
    resolve_targets,
36
)
37
from pants.engine.internals.native_engine import Digest, Snapshot
12✔
38
from pants.engine.internals.selectors import concurrently
12✔
39
from pants.engine.intrinsics import create_digest, digest_to_snapshot, get_digest_contents
12✔
40
from pants.engine.rules import Rule, collect_rules, implicitly, rule
12✔
41
from pants.engine.target import (
12✔
42
    COMMON_TARGET_FIELDS,
43
    AllTargets,
44
    Dependencies,
45
    DependenciesRequest,
46
    DescriptionField,
47
    GeneratedTargets,
48
    GenerateTargetsRequest,
49
    InvalidFieldException,
50
    ScalarField,
51
    SequenceField,
52
    SingleSourceField,
53
    SourcesField,
54
    StringField,
55
    StringSequenceField,
56
    Tags,
57
    Target,
58
    TargetGenerator,
59
    Targets,
60
)
61
from pants.engine.unions import UnionMembership, UnionRule
12✔
62
from pants.option.bootstrap_options import UnmatchedBuildFileGlobs
12✔
63
from pants.util.frozendict import FrozenDict
12✔
64
from pants.util.strutil import help_text, softwrap
12✔
65

66
_logger = logging.getLogger(__name__)
12✔
67

68

69
class NodePackageDependenciesField(Dependencies):
12✔
70
    pass
12✔
71

72

73
class PackageJsonSourceField(SingleSourceField):
12✔
74
    default = "package.json"
12✔
75
    required = False
12✔
76

77

78
class NodeScript(ABC):
12✔
79
    entry_point: str
12✔
80
    alias: ClassVar[str]
12✔
81

82

83
@dataclass(frozen=True)
12✔
84
class NodeRunScript(NodeScript):
12✔
85
    entry_point: str
12✔
86
    extra_env_vars: tuple[str, ...] = ()
12✔
87

88
    alias: ClassVar[str] = "node_run_script"
12✔
89

90
    def __str__(self) -> str:
12✔
91
        return f'{self.alias}(entry_point="{self.entry_point}", ...)'
×
92

93
    @classmethod
12✔
94
    def create(
12✔
95
        cls,
96
        entry_point: str,
97
        extra_env_vars: Iterable[str] = (),
98
    ) -> NodeRunScript:
99
        """A script that can be run directly via the run goal, mapped from the `scripts` section of
100
        a package.json file.
101

102
        This allows running any script defined in package.json directly through pants run.
103
        """
104
        return cls(
×
105
            entry_point=entry_point,
106
            extra_env_vars=tuple(extra_env_vars),
107
        )
108

109

110
@dataclass(frozen=True)
12✔
111
class NodeBuildScript(NodeScript):
12✔
112
    entry_point: str
12✔
113
    output_directories: tuple[str, ...] = ()
12✔
114
    output_files: tuple[str, ...] = ()
12✔
115
    extra_caches: tuple[str, ...] = ()
12✔
116
    extra_env_vars: tuple[str, ...] = ()
12✔
117
    description: str | None = None
12✔
118
    tags: tuple[str, ...] = ()
12✔
119

120
    alias: ClassVar[str] = "node_build_script"
12✔
121

122
    @classmethod
12✔
123
    def create(
12✔
124
        cls,
125
        entry_point: str,
126
        output_directories: Iterable[str] = (),
127
        output_files: Iterable[str] = (),
128
        extra_caches: Iterable[str] = (),
129
        extra_env_vars: Iterable[str] = (),
130
        description: str | None = None,
131
        tags: Iterable[str] = (),
132
    ) -> NodeBuildScript:
133
        """A build script, mapped from the `scripts` section of a package.json file.
134

135
        Either the `output_directories` or the `output_files` argument has to be set to capture the
136
        output artifacts of the build.
137
        """
138

139
        return cls(
×
140
            entry_point=entry_point,
141
            output_directories=tuple(output_directories),
142
            output_files=tuple(output_files),
143
            extra_caches=tuple(extra_caches),
144
            extra_env_vars=tuple(extra_env_vars),
145
            description=description,
146
            tags=tuple(tags),
147
        )
148

149

150
@dataclass(frozen=True)
12✔
151
class NodeTestScript(NodeScript):
12✔
152
    entry_point: str = "test"
12✔
153
    report_args: tuple[str, ...] = ()
12✔
154
    report_output_files: tuple[str, ...] = ()
12✔
155
    report_output_directories: tuple[str, ...] = ()
12✔
156
    coverage_args: tuple[str, ...] = ()
12✔
157
    coverage_output_files: tuple[str, ...] = ()
12✔
158
    coverage_output_directories: tuple[str, ...] = ()
12✔
159
    coverage_entry_point: str | None = None
12✔
160
    extra_caches: tuple[str, ...] = ()
12✔
161

162
    alias: ClassVar[str] = "node_test_script"
12✔
163

164
    def __str__(self) -> str:
12✔
165
        return f'{self.alias}(entry_point="{self.entry_point}", ...)'
×
166

167
    @classmethod
12✔
168
    def create(
12✔
169
        cls,
170
        entry_point: str = "test",
171
        report_args: Iterable[str] = (),
172
        report_output_files: Iterable[str] = (),
173
        report_output_directories: Iterable[str] = (),
174
        coverage_args: Iterable[str] = (),
175
        coverage_output_files: Iterable[str] = (),
176
        coverage_output_directories: Iterable[str] = (),
177
        coverage_entry_point: str | None = None,
178
    ) -> NodeTestScript:
179
        """The test script for this package, mapped from the `scripts` section of a package.json
180
        file. The pointed to script should accept a variadic number of ([ARG]...) path arguments.
181

182
        This entry point is the "test" script, by default.
183
        """
184
        return cls(
×
185
            entry_point=entry_point,
186
            report_args=tuple(report_args),
187
            report_output_files=tuple(report_output_files),
188
            report_output_directories=tuple(report_output_directories),
189
            coverage_args=tuple(coverage_args),
190
            coverage_output_files=tuple(coverage_output_files),
191
            coverage_output_directories=tuple(coverage_output_directories),
192
            coverage_entry_point=coverage_entry_point,
193
        )
194

195
    def supports_coverage(self) -> bool:
12✔
196
        return bool(self.coverage_entry_point) or bool(self.coverage_args)
×
197

198
    def coverage_globs(self, working_directory: str) -> PathGlobs:
12✔
199
        return self.coverage_globs_for(
×
200
            working_directory,
201
            self.coverage_output_files,
202
            self.coverage_output_directories,
203
            GlobMatchErrorBehavior.ignore,
204
        )
205

206
    @classmethod
12✔
207
    def coverage_globs_for(
12✔
208
        cls,
209
        working_directory: str,
210
        files: tuple[str, ...],
211
        directories: tuple[str, ...],
212
        error_behaviour: GlobMatchErrorBehavior,
213
        conjunction: GlobExpansionConjunction = GlobExpansionConjunction.any_match,
214
        description_of_origin: str | None = None,
215
    ) -> PathGlobs:
216
        dir_globs = (os.path.join(directory, "*") for directory in directories)
×
217
        return PathGlobs(
×
218
            (os.path.join(working_directory, glob) for glob in itertools.chain(files, dir_globs)),
219
            conjunction=conjunction,
220
            glob_match_error_behavior=error_behaviour,
221
            description_of_origin=description_of_origin,
222
        )
223

224

225
class NodePackageScriptsField(SequenceField[NodeScript]):
12✔
226
    alias = "scripts"
12✔
227
    expected_element_type = NodeScript
12✔
228

229
    help = help_text(
12✔
230
        """
231
        Custom node package manager scripts that should be known
232
        and ran as part of relevant goals.
233

234
        Maps the package.json#scripts section to a cacheable pants invocation.
235
        """
236
    )
237
    expected_type_description = (
12✔
238
        '[node_build_script(entry_point="build", output_directories=["./dist/"], ...])'
239
    )
240
    default = ()
12✔
241

242
    @classmethod
12✔
243
    def compute_value(
12✔
244
        cls, raw_value: Iterable[Any] | None, address: Address
245
    ) -> tuple[NodeScript, ...] | None:
246
        values = super().compute_value(raw_value, address)
×
247
        test_scripts = [value for value in values or () if isinstance(value, NodeTestScript)]
×
248
        if len(test_scripts) > 1:
×
249
            entry_points = ", ".join(str(script) for script in test_scripts)
×
250
            raise InvalidFieldException(
×
251
                softwrap(
252
                    f"""
253
                    You can only specify one `{NodeTestScript.alias}` per `{PackageJsonTarget.alias}`,
254
                    but the {cls.alias} contains {entry_points}.
255
                    """
256
                )
257
            )
258
        return values
×
259

260
    def build_scripts(self) -> Iterable[NodeBuildScript]:
12✔
261
        for script in self.value or ():
×
262
            if isinstance(script, NodeBuildScript):
×
263
                yield script
×
264

265
    def get_test_script(self) -> NodeTestScript:
12✔
266
        for script in self.value or ():
×
267
            if isinstance(script, NodeTestScript):
×
268
                return script
×
269
        return NodeTestScript()
×
270

271

272
class NodePackageTestScriptField(ScalarField[NodeTestScript]):
12✔
273
    alias = "_node_test_script"
12✔
274
    expected_type = NodeTestScript
12✔
275
    expected_type_description = (
12✔
276
        'node_test_script(entry_point="test", coverage_args="--coverage=true")'
277
    )
278
    default = NodeTestScript()
12✔
279
    value: NodeTestScript
12✔
280

281

282
class NodePackageVersionField(StringField):
12✔
283
    alias = "version"
12✔
284
    help = help_text(
12✔
285
        """
286
        Version of the Node package, as specified in the package.json.
287

288
        This field should not be overridden; use the value from target generation.
289
        """
290
    )
291
    required = False
12✔
292
    value: str | None
12✔
293

294

295
class NodeThirdPartyPackageVersionField(NodePackageVersionField):
12✔
296
    alias = "version"
12✔
297
    help = help_text(
12✔
298
        """
299
        Version of the Node package, as specified in the package.json.
300

301
        This field should not be overridden; use the value from target generation.
302
        """
303
    )
304
    required = True
12✔
305
    value: str
12✔
306

307

308
class NodePackageNameField(StringField):
12✔
309
    alias = "package"
12✔
310
    help = help_text(
12✔
311
        """
312
        Name of the Node package, as specified in the package.json.
313

314
        This field should not be overridden; use the value from target generation.
315
        """
316
    )
317
    required = True
12✔
318
    value: str
12✔
319

320

321
class NodeThirdPartyPackageNameField(NodePackageNameField):
12✔
322
    pass
12✔
323

324

325
class NodeThirdPartyPackageDependenciesField(Dependencies):
12✔
326
    pass
12✔
327

328

329
class NodeThirdPartyPackageTarget(Target):
12✔
330
    alias = "node_third_party_package"
12✔
331

332
    help = "A third party node package."
12✔
333

334
    core_fields = (
12✔
335
        *COMMON_TARGET_FIELDS,
336
        NodeThirdPartyPackageNameField,
337
        NodeThirdPartyPackageVersionField,
338
        NodeThirdPartyPackageDependenciesField,
339
    )
340

341

342
class NodePackageExtraEnvVarsField(StringSequenceField):
12✔
343
    alias = "extra_env_vars"
12✔
344
    help = help_text(
12✔
345
        f"""
346
        Environment variables to set when running package manager operations.
347

348
        {EXTRA_ENV_VARS_USAGE_HELP}
349
        """
350
    )
351
    required = False
12✔
352

353

354
class NodePackageTarget(Target):
12✔
355
    alias = "node_package"
12✔
356

357
    help = "A first party node package."
12✔
358

359
    core_fields = (
12✔
360
        *COMMON_TARGET_FIELDS,
361
        PackageJsonSourceField,
362
        NodePackageNameField,
363
        NodePackageVersionField,
364
        NodePackageDependenciesField,
365
        NodePackageTestScriptField,
366
        NodePackageExtraEnvVarsField,
367
    )
368

369

370
class NPMDistributionTarget(Target):
12✔
371
    alias = "npm_distribution"
12✔
372

373
    help = help_text(
12✔
374
        """
375
        A publishable npm registry distribution, typically a gzipped tarball
376
        of the sources and any resources, but omitting the lockfile.
377

378
        Generated using the projects package manager `pack` implementation.
379
        """
380
    )
381

382
    core_fields = (
12✔
383
        *COMMON_TARGET_FIELDS,
384
        PackageJsonSourceField,
385
        OutputPathField,
386
    )
387

388

389
class PackageJsonTarget(TargetGenerator):
12✔
390
    alias = "package_json"
12✔
391
    core_fields = (
12✔
392
        *COMMON_TARGET_FIELDS,
393
        PackageJsonSourceField,
394
        NodePackageScriptsField,
395
        NodePackageExtraEnvVarsField,
396
    )
397
    help = help_text(
12✔
398
        f"""
399
        A package.json file describing a nodejs package. (https://nodejs.org/api/packages.html#introduction)
400

401
        Generates a `{NodePackageTarget.alias}` target for the package.
402

403
        Generates `{NodeThirdPartyPackageTarget.alias}` targets for each specified
404
        3rd party dependency (e.g. in the package.json#devDependencies field).
405
        """
406
    )
407

408
    copied_fields = COMMON_TARGET_FIELDS
12✔
409
    moved_fields = (NodePackageDependenciesField,)
12✔
410

411

412
class NodeBuildScriptEntryPointField(StringField):
12✔
413
    alias = "entry_point"
12✔
414
    required = True
12✔
415
    value: str
12✔
416

417
    help = help_text(
12✔
418
        """
419
        The name of the script from the package.json#scripts section to execute for the build.
420

421
        This script should produce the output files/directories specified in the build script configuration.
422
        """
423
    )
424

425

426
class NodeBuildScriptSourcesField(SourcesField):
12✔
427
    alias = "_sources"
12✔
428
    required = False
12✔
429
    default = None
12✔
430

431
    help = "Marker field for node_build_scripts used in export-codegen."
12✔
432

433

434
class NodeBuildScriptOutputFilesField(StringSequenceField):
12✔
435
    alias = "output_files"
12✔
436
    required = False
12✔
437
    default = ()
12✔
438
    help = help_text(
12✔
439
        """
440
        Specify the build script's output files to capture, relative to the package.json.
441

442
        For directories, use `output_directories`. At least one of `output_files` and
443
        `output_directories` must be specified.
444

445
        Relative paths (including `..`) may be used, as long as the path does not ascend further
446
        than the package.json parent directory.
447
        """
448
    )
449

450

451
class NodeBuildScriptOutputDirectoriesField(StringSequenceField):
12✔
452
    alias = "output_directories"
12✔
453
    required = False
12✔
454
    default = ()
12✔
455
    help = help_text(
12✔
456
        """
457
        Specify full directories (including recursive descendants) of output to capture from the
458
        build script, relative to the package.json.
459

460
        For individual files, use `output_files`. At least one of `output_files` and
461
        `output_directories` must be specified.
462

463
        Relative paths (including `..`) may be used, as long as the path does not ascend further
464
        than the package.json parent directory.
465
        """
466
    )
467

468

469
class NodeBuildScriptExtraEnvVarsField(StringSequenceField):
12✔
470
    alias = "extra_env_vars"
12✔
471
    required = False
12✔
472
    default = ()
12✔
473
    help = help_text(
12✔
474
        f"""
475
        Additional environment variables to include in environment when running a build script process.
476

477
        {EXTRA_ENV_VARS_USAGE_HELP}
478
        """
479
    )
480

481

482
class NodeBuildScriptExtraCaches(StringSequenceField):
12✔
483
    alias = "extra_caches"
12✔
484
    required = False
12✔
485
    default = ()
12✔
486
    help = help_text(
487
        f"""
488
        Specify directories that pants should treat as caches for the build script.
489

490
        These directories will not be available as sources, but are available to
491
        subsequent executions of the build script.
492

493
        Example usage:
494
        # BUILD
495
        {PackageJsonTarget.alias}(
496
            scripts={NodeBuildScript.alias}(
497
                entry_point="build",
498
                output_directories=["dist"],
499
                extra_caches=[".parcel-cache"],
500
            )
501
        )
502

503
        # package.json
504
        {{
505
            ...
506
            "scripts": {{
507
                "build": "parcel build --dist-dir=dist --cache-dir=.parcel-cache"
508
                ...
509
            }}
510
            ...
511
        }}
512
        """
513
    )
514

515

516
class NodeBuildScriptTarget(Target):
12✔
517
    core_fields = (
12✔
518
        *COMMON_TARGET_FIELDS,
519
        NodeBuildScriptEntryPointField,
520
        NodeBuildScriptOutputDirectoriesField,
521
        NodeBuildScriptOutputFilesField,
522
        NodeBuildScriptSourcesField,
523
        NodeBuildScriptExtraCaches,
524
        NodeBuildScriptExtraEnvVarsField,
525
        NodePackageDependenciesField,
526
        OutputPathField,
527
    )
528

529
    alias = "_node_build_script"
12✔
530

531
    help = help_text(
12✔
532
        """
533
        A package.json script that is invoked by the configured package manager
534
        to produce `resource` targets or a packaged artifact.
535
        """
536
    )
537

538

539
class NodeRunScriptEntryPointField(StringField):
12✔
540
    alias = "entry_point"
12✔
541
    required = True
12✔
542
    value: str
12✔
543

544

545
class NodeRunScriptExtraEnvVarsField(StringSequenceField):
12✔
546
    alias = "extra_env_vars"
12✔
547
    required = False
12✔
548
    default = ()
12✔
549
    help = help_text(
12✔
550
        f"""
551
        Additional environment variables to include in environment when running a script process.
552

553
        {EXTRA_ENV_VARS_USAGE_HELP}
554
        """
555
    )
556

557

558
class NodeRunScriptTarget(Target):
12✔
559
    core_fields = (
12✔
560
        *COMMON_TARGET_FIELDS,
561
        NodeRunScriptEntryPointField,
562
        NodeRunScriptExtraEnvVarsField,
563
        NodePackageDependenciesField,
564
    )
565

566
    alias = "_node_run_script"
12✔
567

568
    help = help_text(
12✔
569
        """
570
        A package.json script that can be invoked directly via the run goal.
571
        """
572
    )
573

574

575
@dataclass(frozen=True)
12✔
576
class PackageJsonImports:
12✔
577
    """https://nodejs.org/api/packages.html#subpath-imports."""
578

579
    imports: FrozenDict[str, tuple[str, ...]]
12✔
580
    root_dir: str
12✔
581

582
    @classmethod
12✔
583
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonImports:
12✔
584
        return cls(
×
585
            imports=cls._import_from_package_json(pkg_json),
586
            root_dir=pkg_json.root_dir,
587
        )
588

589
    @staticmethod
12✔
590
    def _import_from_package_json(
12✔
591
        pkg_json: PackageJson,
592
    ) -> FrozenDict[str, tuple[str, ...]]:
593
        imports: Mapping[str, Any] | None = pkg_json.content.get("imports")
×
594

595
        def get_subpaths(value: str | Mapping[str, Any]) -> Iterable[str]:
×
596
            if isinstance(value, str):
×
597
                yield value
×
598
            elif isinstance(value, Mapping):
×
599
                for v in value.values():
×
600
                    yield from get_subpaths(v)
×
601

602
        if not imports:
×
603
            return FrozenDict()
×
604
        return FrozenDict(
×
605
            {key: tuple(sorted(get_subpaths(subpath))) for key, subpath in imports.items()}
606
        )
607

608

609
@dataclass(frozen=True)
12✔
610
class PackageJsonEntryPoints:
12✔
611
    """See https://nodejs.org/api/packages.html#package-entry-points and
612
    https://docs.npmjs.com/cli/v9/configuring-npm/package-json#browser."""
613

614
    exports: FrozenDict[str, str]
12✔
615
    bin: FrozenDict[str, str]
12✔
616
    root_dir: str
12✔
617

618
    @property
12✔
619
    def globs(self) -> Iterable[str]:
12✔
620
        for export in self.exports.values():
×
621
            yield export.replace("*", "**/*")
×
622
        yield from self.bin.values()
×
623

624
    def globs_from_root(self) -> Iterable[str]:
12✔
625
        for path in self.globs:
×
626
            yield os.path.normpath(os.path.join(self.root_dir, path))
×
627

628
    @classmethod
12✔
629
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonEntryPoints:
12✔
630
        return cls(
×
631
            exports=cls._exports_form_package_json(pkg_json),
632
            bin=cls._binaries_from_package_json(pkg_json),
633
            root_dir=pkg_json.root_dir,
634
        )
635

636
    @staticmethod
12✔
637
    def _exports_form_package_json(pkg_json: PackageJson) -> FrozenDict[str, str]:
12✔
638
        content = pkg_json.content
×
639
        exports: str | Mapping[str, str] | None = content.get("exports")
×
640
        main: str | None = content.get("main")
×
641
        browser: str | None = content.get("browser")
×
642
        source: str | None = content.get("source")
×
643
        if exports:
×
644
            if isinstance(exports, str):
×
645
                return FrozenDict({".": exports})
×
646
            else:
647
                return FrozenDict(exports)
×
648
        elif browser:
×
649
            return FrozenDict({".": browser})
×
650
        elif main:
×
651
            return FrozenDict({".": main})
×
652
        elif source:
×
653
            return FrozenDict({".": source})
×
654
        return FrozenDict()
×
655

656
    @staticmethod
12✔
657
    def _binaries_from_package_json(pkg_json: PackageJson) -> FrozenDict[str, str]:
12✔
658
        binaries: str | Mapping[str, str] | None = pkg_json.content.get("bin")
×
659
        if binaries:
×
660
            if isinstance(binaries, str):
×
661
                return FrozenDict({pkg_json.name: binaries})
×
662
            else:
663
                return FrozenDict(binaries)
×
664
        return FrozenDict()
×
665

666

667
@dataclass(frozen=True)
12✔
668
class PackageJsonScripts:
12✔
669
    scripts: FrozenDict[str, str]
12✔
670

671
    @classmethod
12✔
672
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonScripts:
12✔
673
        return cls(FrozenDict.deep_freeze(pkg_json.content.get("scripts", {})))
×
674

675

676
@dataclass(frozen=True)
12✔
677
class PackageJson:
12✔
678
    content: FrozenDict[str, Any]
12✔
679
    name: str
12✔
680
    version: str | None
12✔
681
    snapshot: Snapshot
12✔
682
    workspaces: tuple[str, ...] = ()
12✔
683
    module: Literal["commonjs", "module"] | None = None
12✔
684
    dependencies: FrozenDict[str, str] = field(default_factory=FrozenDict)
12✔
685
    package_manager: str | None = None
12✔
686

687
    def __post_init__(self) -> None:
12✔
688
        if self.module not in (None, "commonjs", "module"):
1✔
689
            raise ValueError(
×
690
                f'package.json "type" can only be one of "commonjs", "module", but was "{self.module}".'
691
            )
692

693
    @property
12✔
694
    def digest(self) -> Digest:
12✔
695
        return self.snapshot.digest
×
696

697
    @property
12✔
698
    def file(self) -> str:
12✔
699
        return self.snapshot.files[0]
×
700

701
    @property
12✔
702
    def root_dir(self) -> str:
12✔
703
        return os.path.dirname(self.file)
×
704

705

706
class FirstPartyNodePackageTargets(Targets):
12✔
707
    pass
12✔
708

709

710
class AllPackageJson(Collection[PackageJson]):
12✔
711
    pass
12✔
712

713

714
class PackageJsonForGlobs(Collection[PackageJson]):
12✔
715
    pass
12✔
716

717

718
@rule
12✔
719
async def all_first_party_node_package_targets(targets: AllTargets) -> FirstPartyNodePackageTargets:
12✔
720
    return FirstPartyNodePackageTargets(
×
721
        tgt for tgt in targets if tgt.has_fields((PackageJsonSourceField, NodePackageNameField))
722
    )
723

724

725
@dataclass(frozen=True)
12✔
726
class OwningNodePackageRequest:
12✔
727
    address: Address
12✔
728

729

730
@dataclass(frozen=True)
12✔
731
class OwningNodePackage:
12✔
732
    target: Target | None = None
12✔
733
    third_party: tuple[Target, ...] = ()
12✔
734

735
    @classmethod
12✔
736
    def no_owner(cls) -> OwningNodePackage:
12✔
UNCOV
737
        return cls()
1✔
738

739
    def ensure_owner(self) -> Target:
12✔
UNCOV
740
        if self != OwningNodePackage.no_owner():
1✔
UNCOV
741
            assert self.target
1✔
UNCOV
742
            return self.target
1✔
743
        raise ValueError("No owner could be determined.")
×
744

745

746
@rule
12✔
747
async def find_owning_package(request: OwningNodePackageRequest) -> OwningNodePackage:
12✔
748
    candidate_targets = await resolve_targets(
×
749
        **implicitly(
750
            RawSpecs(
751
                ancestor_globs=(AncestorGlobSpec(request.address.spec_path),),
752
                description_of_origin=f"the `{OwningNodePackage.__name__}` rule",
753
            )
754
        )
755
    )
756
    package_json_tgts = sorted(
×
757
        (
758
            tgt
759
            for tgt in candidate_targets
760
            if tgt.has_field(PackageJsonSourceField) and tgt.has_field(NodePackageNameField)
761
        ),
762
        key=lambda tgt: tgt.address.spec_path,
763
        reverse=True,
764
    )
765
    tgt = package_json_tgts[0] if package_json_tgts else None
×
766
    if tgt:
×
767
        deps = await resolve_targets(**implicitly(DependenciesRequest(tgt[Dependencies])))
×
768
        return OwningNodePackage(
×
769
            tgt, tuple(dep for dep in deps if dep.has_field(NodeThirdPartyPackageNameField))
770
        )
771
    return OwningNodePackage()
×
772

773

774
@rule
12✔
775
async def parse_package_json(content: FileContent) -> PackageJson:
12✔
776
    parsed_package_json = FrozenDict.deep_freeze(json.loads(content.content))
×
777
    package_name = parsed_package_json.get("name")
×
778
    if not package_name:
×
779
        raise ValueError("No package name found in package.json")
×
780

781
    return PackageJson(
×
782
        content=parsed_package_json,
783
        name=package_name,
784
        version=parsed_package_json.get("version"),
785
        snapshot=await digest_to_snapshot(**implicitly(PathGlobs([content.path]))),
786
        module=parsed_package_json.get("type"),
787
        workspaces=tuple(parsed_package_json.get("workspaces", ())),
788
        dependencies=FrozenDict.deep_freeze(
789
            {
790
                **parsed_package_json.get("dependencies", {}),
791
                **parsed_package_json.get("devDependencies", {}),
792
                **parsed_package_json.get("peerDependencies", {}),
793
            }
794
        ),
795
        package_manager=parsed_package_json.get("packageManager"),
796
    )
797

798

799
@rule
12✔
800
async def read_package_jsons(globs: PathGlobs) -> PackageJsonForGlobs:
12✔
801
    digest_contents = await get_digest_contents(**implicitly(globs))
×
802
    return PackageJsonForGlobs(
×
803
        await concurrently(parse_package_json(digest_content) for digest_content in digest_contents)
804
    )
805

806

807
@rule
12✔
808
async def all_package_json() -> AllPackageJson:
12✔
809
    # Avoids using `AllTargets` due to a circular rule dependency.
810
    # `generate_node_package_targets` requires knowledge of all
811
    # first party package names.
812
    description_of_origin = "The `AllPackageJson` rule"
×
813
    requests = await resolve_all_generator_target_requests(
×
814
        ResolveAllTargetGeneratorRequests(
815
            description_of_origin=description_of_origin, of_type=PackageJsonTarget
816
        )
817
    )
818
    globs = [
×
819
        glob
820
        for req in requests.requests
821
        for glob in req.generator[PackageJsonSourceField]
822
        .path_globs(UnmatchedBuildFileGlobs.error())
823
        .globs
824
    ]
825
    return AllPackageJson(
×
826
        await read_package_jsons(
827
            PathGlobs(
828
                globs, GlobMatchErrorBehavior.error, description_of_origin=description_of_origin
829
            )
830
        )
831
    )
832

833

834
@dataclass(frozen=True)
12✔
835
class PnpmWorkspaceGlobs:
12✔
836
    packages: tuple[str, ...]
12✔
837
    digest: Digest
12✔
838

839

840
class PnpmWorkspaces(FrozenDict[PackageJson, PnpmWorkspaceGlobs]):
12✔
841
    def for_root(self, root_dir: str) -> PnpmWorkspaceGlobs | None:
12✔
842
        for pkg, workspaces in self.items():
×
843
            if pkg.root_dir == root_dir:
×
844
                return workspaces
×
845
        return None
×
846

847

848
@rule
12✔
849
async def pnpm_workspace_files(pkgs: AllPackageJson) -> PnpmWorkspaces:
12✔
850
    digest_contents = await get_digest_contents(
×
851
        **implicitly(PathGlobs(os.path.join(pkg.root_dir, "pnpm-workspace.yaml") for pkg in pkgs))
852
    )
853

854
    async def parse_package_globs(content: FileContent) -> PnpmWorkspaceGlobs:
×
855
        parsed = yaml.safe_load(content.content) or {"packages": ("**",)}
×
856
        return PnpmWorkspaceGlobs(
×
857
            tuple(parsed.get("packages", ("**",)) or ("**",)),
858
            await create_digest(CreateDigest([content])),
859
        )
860

861
    globs_per_root = {
×
862
        os.path.dirname(digest_content.path): await parse_package_globs(digest_content)
863
        for digest_content in digest_contents
864
    }
865

866
    return PnpmWorkspaces(
×
867
        {pkg: globs_per_root[pkg.root_dir] for pkg in pkgs if pkg.root_dir in globs_per_root}
868
    )
869

870

871
class AllPackageJsonNames(DeduplicatedCollection[str]):
12✔
872
    """Used to not invalidate all generated node package targets when any package.json contents are
873
    changed."""
874

875

876
@rule
12✔
877
async def all_package_json_names(all_pkg_jsons: AllPackageJson) -> AllPackageJsonNames:
12✔
878
    return AllPackageJsonNames(pkg.name for pkg in all_pkg_jsons)
×
879

880

881
@rule
12✔
882
async def package_json_for_source(source_field: PackageJsonSourceField) -> PackageJson:
12✔
883
    [pkg_json] = await read_package_jsons(source_field.path_globs(UnmatchedBuildFileGlobs.error()))
×
884
    return pkg_json
×
885

886

887
@rule
12✔
888
async def script_entrypoints_for_source(
12✔
889
    source_field: PackageJsonSourceField,
890
) -> PackageJsonEntryPoints:
891
    return PackageJsonEntryPoints.from_package_json(await package_json_for_source(source_field))
×
892

893

894
@rule
12✔
895
async def subpath_imports_for_source(
12✔
896
    source_field: PackageJsonSourceField,
897
) -> PackageJsonImports:
898
    return PackageJsonImports.from_package_json(await package_json_for_source(source_field))
×
899

900

901
class GenerateNodePackageTargets(GenerateTargetsRequest):
12✔
902
    generate_from = PackageJsonTarget
12✔
903

904

905
def _script_missing_error(entry_point: str, scripts: Iterable[str], address: Address) -> ValueError:
12✔
906
    return ValueError(
×
907
        softwrap(
908
            f"""
909
            {entry_point} was not found in package.json#scripts section
910
            of the `{PackageJsonTarget.alias}` target with address {address}.
911

912
            Available scripts are: {", ".join(scripts)}.
913
            """
914
        )
915
    )
916

917

918
@rule
12✔
919
async def generate_node_package_targets(
12✔
920
    request: GenerateNodePackageTargets,
921
    union_membership: UnionMembership,
922
    first_party_names: AllPackageJsonNames,
923
) -> GeneratedTargets:
924
    file = request.generator[PackageJsonSourceField].file_path
×
925
    file_tgt = TargetGeneratorSourcesHelperTarget(
×
926
        {TargetGeneratorSourcesHelperSourcesField.alias: os.path.basename(file)},
927
        request.generator.address.create_generated(file),
928
        union_membership,
929
    )
930

931
    pkg_json = await package_json_for_source(request.generator[PackageJsonSourceField])
×
932

933
    third_party_tgts = [
×
934
        NodeThirdPartyPackageTarget(
935
            {
936
                **{
937
                    key: value
938
                    for key, value in request.template.items()
939
                    if key != PackageJsonSourceField.alias
940
                },
941
                NodeThirdPartyPackageNameField.alias: name,
942
                NodeThirdPartyPackageVersionField.alias: version,
943
                NodeThirdPartyPackageDependenciesField.alias: [file_tgt.address.spec],
944
            },
945
            request.generator.address.create_generated(name.replace("@", "__")),
946
            union_membership,
947
        )
948
        for name, version in pkg_json.dependencies.items()
949
        if name not in first_party_names
950
    ]
951

952
    package_target = NodePackageTarget(
×
953
        {
954
            **request.template,
955
            NodePackageNameField.alias: pkg_json.name,
956
            NodePackageVersionField.alias: pkg_json.version,
957
            NodePackageExtraEnvVarsField.alias: request.generator[
958
                NodePackageExtraEnvVarsField
959
            ].value,
960
            NodePackageDependenciesField.alias: [
961
                file_tgt.address.spec,
962
                *(tgt.address.spec for tgt in third_party_tgts),
963
                *request.template.get("dependencies", []),
964
            ],
965
            NodePackageTestScriptField.alias: request.generator[
966
                NodePackageScriptsField
967
            ].get_test_script(),
968
        },
969
        request.generator.address.create_generated(pkg_json.name.replace("@", "__")),
970
        union_membership,
971
    )
972
    scripts = PackageJsonScripts.from_package_json(pkg_json).scripts
×
973
    build_script_tgts = []
×
974
    for build_script in request.generator[NodePackageScriptsField].build_scripts():
×
975
        if build_script.entry_point in scripts:
×
976
            build_script_tgts.append(
×
977
                NodeBuildScriptTarget(
978
                    {
979
                        **request.template,
980
                        NodeBuildScriptEntryPointField.alias: build_script.entry_point,
981
                        NodeBuildScriptOutputDirectoriesField.alias: build_script.output_directories,
982
                        NodeBuildScriptOutputFilesField.alias: build_script.output_files,
983
                        NodeBuildScriptExtraEnvVarsField.alias: build_script.extra_env_vars,
984
                        NodeBuildScriptExtraCaches.alias: build_script.extra_caches,
985
                        NodePackageDependenciesField.alias: [
986
                            file_tgt.address.spec,
987
                            *(tgt.address.spec for tgt in third_party_tgts),
988
                            *request.template.get("dependencies", []),
989
                            package_target.address.spec,
990
                        ],
991
                        DescriptionField.alias: build_script.description,
992
                        Tags.alias: build_script.tags,
993
                    },
994
                    request.generator.address.create_generated(build_script.entry_point),
995
                    union_membership,
996
                )
997
            )
998
        else:
999
            raise _script_missing_error(
×
1000
                build_script.entry_point, scripts, request.generator.address
1001
            )
1002

1003
    run_script_tgts = []
×
1004
    for script in request.generator[NodePackageScriptsField].value or ():
×
1005
        if isinstance(script, NodeRunScript):
×
1006
            if script.entry_point in scripts:
×
1007
                run_script_tgts.append(
×
1008
                    NodeRunScriptTarget(
1009
                        {
1010
                            **request.template,
1011
                            NodeRunScriptEntryPointField.alias: script.entry_point,
1012
                            NodeRunScriptExtraEnvVarsField.alias: script.extra_env_vars,
1013
                            NodePackageDependenciesField.alias: [
1014
                                file_tgt.address.spec,
1015
                                *(tgt.address.spec for tgt in third_party_tgts),
1016
                                *request.template.get("dependencies", []),
1017
                                package_target.address.spec,
1018
                            ],
1019
                        },
1020
                        request.generator.address.create_generated(script.entry_point),
1021
                        union_membership,
1022
                    )
1023
                )
1024
            else:
1025
                raise _script_missing_error(script.entry_point, scripts, request.generator.address)
×
1026

1027
    coverage_script = package_target[NodePackageTestScriptField].value.coverage_entry_point
×
1028
    if coverage_script and coverage_script not in scripts:
×
1029
        raise _script_missing_error(coverage_script, scripts, request.generator.address)
×
1030

1031
    return GeneratedTargets(
×
1032
        request.generator,
1033
        [
1034
            package_target,
1035
            file_tgt,
1036
            *third_party_tgts,
1037
            *build_script_tgts,
1038
            *run_script_tgts,
1039
        ],
1040
    )
1041

1042

1043
def target_types() -> Iterable[type[Target]]:
12✔
1044
    return [
4✔
1045
        PackageJsonTarget,
1046
        NodePackageTarget,
1047
        NodeThirdPartyPackageTarget,
1048
        NPMDistributionTarget,
1049
        NodeBuildScriptTarget,
1050
    ]
1051

1052

1053
def rules() -> Iterable[Rule | UnionRule]:
12✔
1054
    return [
12✔
1055
        *graph.rules(),
1056
        *dependencies.rules(),
1057
        *stripped_source_files.rules(),
1058
        *fs.rules(),
1059
        *collect_rules(),
1060
        UnionRule(GenerateTargetsRequest, GenerateNodePackageTargets),
1061
    ]
1062

1063

1064
def build_file_aliases() -> BuildFileAliases:
12✔
1065
    return BuildFileAliases(
6✔
1066
        objects={
1067
            NodeBuildScript.alias: NodeBuildScript.create,
1068
            NodeTestScript.alias: NodeTestScript.create,
1069
            NodeRunScript.alias: NodeRunScript.create,
1070
        }
1071
    )
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

© 2025 Coveralls, Inc