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

pantsbuild / pants / 18427419357

11 Oct 2025 09:11AM UTC coverage: 80.25%. First build
18427419357

Pull #22461

github

web-flow
Merge 1d59c7492 into 4846d0735
Pull Request #22461: Add typescript typechecking support

410 of 531 new or added lines in 10 files covered. (77.21%)

77631 of 96737 relevant lines covered (80.25%)

3.35 hits per line

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

36.05
/src/python/pants/backend/typescript/goals/check.py
1
# Copyright 2025 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import logging
1✔
7
import os
1✔
8
from dataclasses import dataclass, replace
1✔
9
from hashlib import sha256
1✔
10
from pathlib import Path
1✔
11
from typing import TYPE_CHECKING
1✔
12

13
from pants.backend.javascript.nodejs_project import AllNodeJSProjects, find_node_js_projects
1✔
14
from pants.backend.javascript.package_json import (
1✔
15
    AllPackageJson,
16
    OwningNodePackageRequest,
17
    PackageJson,
18
    all_package_json,
19
    find_owning_package,
20
)
21
from pants.backend.javascript.resolve import RequestNodeResolve, resolve_for_package
1✔
22
from pants.backend.javascript.subsystems.nodejs_tool import NodeJSToolRequest, prepare_tool_process
1✔
23
from pants.backend.javascript.target_types import JSRuntimeSourceField
1✔
24
from pants.backend.typescript.subsystem import TypeScriptSubsystem
1✔
25
from pants.backend.typescript.tsconfig import AllTSConfigs, TSConfig, construct_effective_ts_configs
1✔
26
from pants.base.build_root import BuildRoot
1✔
27
from pants.build_graph.address import Address
1✔
28
from pants.core.goals.check import CheckRequest, CheckResult, CheckResults
1✔
29
from pants.core.target_types import FileSourceField
1✔
30
from pants.core.util_rules.system_binaries import CpBinary, FindBinary, MkdirBinary, TouchBinary
1✔
31
from pants.engine.fs import (
1✔
32
    EMPTY_DIGEST,
33
    CreateDigest,
34
    Digest,
35
    FileContent,
36
    GlobMatchErrorBehavior,
37
    PathGlobs,
38
)
39
from pants.engine.internals.graph import find_all_targets, hydrate_sources, transitive_targets
1✔
40
from pants.engine.internals.native_engine import MergeDigests
1✔
41
from pants.engine.internals.selectors import concurrently
1✔
42
from pants.engine.intrinsics import (
1✔
43
    create_digest,
44
    execute_process,
45
    merge_digests,
46
    path_globs_to_digest,
47
)
48
from pants.engine.process import Process
1✔
49
from pants.engine.rules import collect_rules, implicitly, rule
1✔
50
from pants.engine.target import (
1✔
51
    AllTargets,
52
    FieldSet,
53
    HydrateSourcesRequest,
54
    Target,
55
    TransitiveTargetsRequest,
56
)
57
from pants.engine.unions import UnionRule
1✔
58
from pants.option.global_options import GlobalOptions
1✔
59
from pants.util.frozendict import FrozenDict
1✔
60
from pants.util.logging import LogLevel
1✔
61
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
1✔
62

63
if TYPE_CHECKING:
64
    from pants.backend.javascript.nodejs_project import NodeJSProject
65

66
logger = logging.getLogger(__name__)
1✔
67

68

69
@dataclass(frozen=True)
1✔
70
class CreateTscWrapperScriptRequest:
1✔
71
    project: NodeJSProject
1✔
72
    package_output_dirs: tuple[str, ...]
1✔
73
    build_root: BuildRoot
1✔
74
    named_cache_sandbox_mount_dir: str
1✔
75
    tsc_wrapper_filename: str
1✔
76

77

78
@dataclass(frozen=True)
1✔
79
class TypeScriptCheckFieldSet(FieldSet):
1✔
80
    required_fields = (JSRuntimeSourceField,)
1✔
81

82
    sources: JSRuntimeSourceField
1✔
83

84

85
class TypeScriptCheckRequest(CheckRequest):
1✔
86
    field_set_type = TypeScriptCheckFieldSet
1✔
87
    tool_name = TypeScriptSubsystem.options_scope
1✔
88

89

90
def _build_workspace_tsconfig_map(
1✔
91
    workspaces: FrozenOrderedSet[PackageJson], all_ts_configs: list[TSConfig]
92
) -> dict[str, TSConfig]:
NEW
93
    workspace_dir_map = {os.path.normpath(pkg.root_dir): pkg for pkg in workspaces}
×
NEW
94
    workspace_dir_to_tsconfig: dict[str, TSConfig] = {}
×
95

96
    # Only consider tsconfig.json files (exclude tsconfig.build.json, tsconfig.test.json, etc.)
97
    # since tsc only reads tsconfig.json by default and we don't support the `--project` flag currently
98
    # Additionally, TSConfig class only supports tsconfig.json.
NEW
99
    tsconfig_json_files = [
×
100
        cfg for cfg in all_ts_configs if os.path.basename(cfg.path) == "tsconfig.json"
101
    ]
102

NEW
103
    for ts_config in tsconfig_json_files:
×
NEW
104
        config_dir = os.path.normpath(os.path.dirname(ts_config.path))
×
105

NEW
106
        if config_dir in workspace_dir_map:
×
NEW
107
            workspace_dir_to_tsconfig[config_dir] = ts_config
×
108

NEW
109
    return workspace_dir_to_tsconfig
×
110

111

112
def _collect_package_output_dirs(
1✔
113
    project: NodeJSProject, project_ts_configs: list[TSConfig], project_root_path: Path
114
) -> tuple[str, ...]:
NEW
115
    workspace_dir_to_tsconfig = _build_workspace_tsconfig_map(
×
116
        project.workspaces, project_ts_configs
117
    )
118

NEW
119
    package_output_dirs: OrderedSet[str] = OrderedSet()
×
NEW
120
    for workspace_pkg in project.workspaces:
×
NEW
121
        workspace_pkg_path = Path(workspace_pkg.root_dir)
×
122

NEW
123
        if workspace_pkg_path == project_root_path:
×
NEW
124
            pkg_prefix = ""
×
125
        else:
NEW
126
            relative_path = workspace_pkg_path.relative_to(project_root_path)
×
NEW
127
            pkg_prefix = f"{relative_path.as_posix()}/"
×
128

NEW
129
        pkg_tsconfig = workspace_dir_to_tsconfig.get(os.path.normpath(workspace_pkg.root_dir))
×
130

NEW
131
        if pkg_tsconfig and pkg_tsconfig.out_dir:
×
NEW
132
            resolved_out_dir = f"{pkg_prefix}{pkg_tsconfig.out_dir.lstrip('./')}"
×
NEW
133
            package_output_dirs.add(resolved_out_dir)
×
134

NEW
135
    return tuple(package_output_dirs)
×
136

137

138
def _collect_project_configs(
1✔
139
    project: NodeJSProject, all_package_jsons: AllPackageJson, all_ts_configs: AllTSConfigs
140
) -> tuple[list, list[TSConfig]]:
NEW
141
    project_package_jsons = [
×
142
        pkg for pkg in all_package_jsons if pkg.root_dir.startswith(project.root_dir)
143
    ]
NEW
144
    project_ts_configs = [
×
145
        config for config in all_ts_configs if config.path.startswith(project.root_dir)
146
    ]
NEW
147
    return project_package_jsons, project_ts_configs
×
148

149

150
async def _collect_config_files_for_project(
1✔
151
    project: NodeJSProject,
152
    targets: list[Target],
153
    project_package_jsons: list,
154
    project_ts_configs: list[TSConfig],
155
) -> list[str]:
NEW
156
    config_files = []
×
157

NEW
158
    for pkg_json in project_package_jsons:
×
NEW
159
        config_files.append(pkg_json.file)
×
160

NEW
161
    for ts_config in project_ts_configs:
×
NEW
162
        config_files.append(ts_config.path)
×
163

164
    # Add file() targets that are dependencies of JS/TS targets
165
    # Note: Package manager config files
166
    # (.npmrc, .pnpmrc, pnpm-workspace.yaml) should be dependencies of package_json targets
167
    # since they affect package installation, not TypeScript compilation directly.
NEW
168
    target_addresses = [target.address for target in targets]
×
NEW
169
    transitive_targets_result = await transitive_targets(
×
170
        TransitiveTargetsRequest(target_addresses), **implicitly()
171
    )
172

NEW
173
    for target in transitive_targets_result.closure:
×
NEW
174
        if target.has_field(FileSourceField):
×
NEW
175
            file_path = target[FileSourceField].file_path
×
NEW
176
            if file_path.startswith(project.root_dir):
×
NEW
177
                config_files.append(file_path)
×
178

NEW
179
    return config_files
×
180

181

182
@rule
1✔
183
async def create_tsc_wrapper_script(
1✔
184
    request: CreateTscWrapperScriptRequest,
185
    cp_binary: CpBinary,
186
    mkdir_binary: MkdirBinary,
187
    find_binary: FindBinary,
188
    touch_binary: TouchBinary,
189
) -> Digest:
NEW
190
    project = request.project
×
NEW
191
    package_output_dirs = request.package_output_dirs
×
NEW
192
    build_root = request.build_root
×
NEW
193
    named_cache_sandbox_mount_dir = request.named_cache_sandbox_mount_dir
×
NEW
194
    tsc_wrapper_filename = request.tsc_wrapper_filename
×
195

NEW
196
    project_abs_path = os.path.join(build_root.path, project.root_dir)
×
NEW
197
    cache_key = sha256(project_abs_path.encode()).hexdigest()
×
198

NEW
199
    project_depth = len([p for p in project.root_dir.strip("/").split("/") if p])
×
NEW
200
    cache_relative_path = (
×
201
        "../" * project_depth + named_cache_sandbox_mount_dir
202
        if project_depth > 0
203
        else named_cache_sandbox_mount_dir
204
    )
NEW
205
    project_cache_subdir = f"{cache_relative_path}/{cache_key}"
×
206

NEW
207
    package_dirs = []
×
NEW
208
    for workspace_pkg in project.workspaces:
×
NEW
209
        if workspace_pkg.root_dir != project.root_dir:
×
NEW
210
            package_relative = workspace_pkg.root_dir.removeprefix(f"{project.root_dir}/")
×
NEW
211
            package_dirs.append(package_relative)
×
212
        else:
NEW
213
            package_dirs.append(".")
×
NEW
214
    package_dirs_str = " ".join(f'"{d}"' for d in package_dirs)
×
NEW
215
    output_dirs_str = " ".join(f'"{d}"' for d in package_output_dirs)
×
216

NEW
217
    script_content = """#!/bin/sh
×
218
# TypeScript incremental compilation cache wrapper
219

220
# All source files in sandbox have mtime as at sandbox creation (e.g. when the process started).
221
# Without any special handling, tsc will do a fast immediate rebuild (without checking tsbuildinfo metadata).
222
# (tsc outputs: "Project 'tsconfig.json' is out of date because output 'tsconfig.tsbuildinfo' is older than input 'tsconfig.json'")
223
# What we want: tsconfig (oldest), tsbuildinfo (newer), source files (newest).
224
# See also https://github.com/microsoft/TypeScript/issues/54563
225

226
set -e
227

228
CP_BIN="{cp_binary_path}"
229
MKDIR_BIN="{mkdir_binary_path}"
230
FIND_BIN="{find_binary_path}"
231
TOUCH_BIN="{touch_binary_path}"
232

233
PROJECT_CACHE_SUBDIR="{project_cache_subdir}"
234

235
# Ensure cache directory exists (it won't on first run)
236
"$MKDIR_BIN" -p "$PROJECT_CACHE_SUBDIR" > /dev/null 2>&1
237

238
# Copy cached files to working directory
239
"$CP_BIN" -a "$PROJECT_CACHE_SUBDIR/." . > /dev/null 2>&1
240

241
# Make tsconfig files oldest
242
# This could be improved by passing a list of tsconfig files, but that might be best done if/when we have a Target for tsconfig files.
243
"$FIND_BIN" . -type f -name "tsconfig*.json" -exec "$TOUCH_BIN" -t 202001010000 {{}} +
244

245
# Run tsc
246
"$@"
247

248
# Update cache
249
# Copy .tsbuildinfo files located at package root to cache
250
for package_dir in {package_dirs_str}; do
251
    for tsbuildinfo_file in "$package_dir"/*.tsbuildinfo; do
252
        if [ -f "$tsbuildinfo_file" ]; then
253
            "$MKDIR_BIN" -p "$PROJECT_CACHE_SUBDIR/$package_dir"
254
            "$CP_BIN" "$tsbuildinfo_file" "$PROJECT_CACHE_SUBDIR/$package_dir/"
255
        fi
256
    done
257
done
258

259
# Copy output directories to cache
260
# (the output files are needed because tsc checks them against tsbuildinfo hashes when determining if a rebuild is required)
261
for output_dir in {output_dirs_str}; do
262
    if [ -d "$output_dir" ]; then
263
        output_parent="$(dirname "$output_dir")"
264
        "$MKDIR_BIN" -p "$PROJECT_CACHE_SUBDIR/$output_parent"
265
        "$CP_BIN" -r "$output_dir" "$PROJECT_CACHE_SUBDIR/$output_parent/"
266
    fi
267
done
268
""".format(
269
        cp_binary_path=cp_binary.path,
270
        mkdir_binary_path=mkdir_binary.path,
271
        find_binary_path=find_binary.path,
272
        touch_binary_path=touch_binary.path,
273
        project_cache_subdir=project_cache_subdir,
274
        package_dirs_str=package_dirs_str,
275
        output_dirs_str=output_dirs_str,
276
    )
277

NEW
278
    script_digest = await create_digest(
×
279
        CreateDigest(
280
            [
281
                FileContent(
282
                    f"{project.root_dir}/{tsc_wrapper_filename}",
283
                    script_content.encode(),
284
                    is_executable=True,
285
                )
286
            ]
287
        ),
288
        **implicitly(),
289
    )
290

NEW
291
    return script_digest
×
292

293

294
async def _collect_project_targets(
1✔
295
    project: NodeJSProject,
296
    all_targets: AllTargets,
297
    all_projects: AllNodeJSProjects,
298
) -> list[Target]:
NEW
299
    targets = [target for target in all_targets if target.has_field(JSRuntimeSourceField)]
×
300

NEW
301
    target_owning_packages = await concurrently(
×
302
        find_owning_package(OwningNodePackageRequest(target.address), **implicitly())
303
        for target in targets
304
    )
305

NEW
306
    project_targets = []
×
NEW
307
    for target, owning_package in zip(targets, target_owning_packages):
×
NEW
308
        if owning_package.target:
×
NEW
309
            package_directory = owning_package.target.address.spec_path
×
NEW
310
            target_project = all_projects.project_for_directory(package_directory)
×
NEW
311
            if target_project == project:
×
NEW
312
                project_targets.append(target)
×
313

NEW
314
    return project_targets
×
315

316

317
async def _hydrate_project_sources(
1✔
318
    project_targets: list[Target],
319
) -> Digest:
NEW
320
    workspace_target_sources = await concurrently(
×
321
        hydrate_sources(
322
            HydrateSourcesRequest(target[JSRuntimeSourceField]),
323
            **implicitly(),
324
        )
325
        for target in project_targets
326
    )
327

NEW
328
    return await merge_digests(
×
329
        MergeDigests([sources.snapshot.digest for sources in workspace_target_sources])
330
    )
331

332

333
async def _prepare_compilation_input(
1✔
334
    project: NodeJSProject,
335
    project_targets: list[Target],
336
    project_package_jsons: list,
337
    project_ts_configs: list[TSConfig],
338
    all_workspace_sources: Digest,
339
) -> Digest:
NEW
340
    config_files = await _collect_config_files_for_project(
×
341
        project, project_targets, project_package_jsons, project_ts_configs
342
    )
NEW
343
    for ts_config in project_ts_configs:
×
NEW
344
        ts_config.validate_outdir()
×
345

NEW
346
    config_digest = (
×
347
        await path_globs_to_digest(
348
            PathGlobs(config_files, glob_match_error_behavior=GlobMatchErrorBehavior.ignore),
349
            **implicitly(),
350
        )
351
        if config_files
352
        else EMPTY_DIGEST
353
    )
354

NEW
355
    return await merge_digests(MergeDigests([all_workspace_sources, config_digest]))
×
356

357

358
async def _prepare_tsc_build_process(
1✔
359
    project: NodeJSProject,
360
    subsystem: TypeScriptSubsystem,
361
    input_digest: Digest,
362
    package_output_dirs: tuple[str, ...],
363
    project_targets: list[Target],
364
    build_root: BuildRoot,
365
) -> Process:
NEW
366
    tsc_args = ("--build", *subsystem.extra_build_args)
×
NEW
367
    tsc_wrapper_filename = "__tsc_wrapper.sh"
×
NEW
368
    named_cache_local_dir = "typescript_cache"
×
NEW
369
    named_cache_sandbox_mount_dir = "._tsc_cache"
×
370

NEW
371
    project_resolve = await resolve_for_package(
×
372
        RequestNodeResolve(Address(project.root_dir)), **implicitly()
373
    )
374

NEW
375
    script_digest = await create_tsc_wrapper_script(
×
376
        CreateTscWrapperScriptRequest(
377
            project=project,
378
            package_output_dirs=package_output_dirs,
379
            build_root=build_root,
380
            named_cache_sandbox_mount_dir=named_cache_sandbox_mount_dir,
381
            tsc_wrapper_filename=tsc_wrapper_filename,
382
        ),
383
        **implicitly(),
384
    )
385

NEW
386
    tool_input = await merge_digests(MergeDigests([input_digest, script_digest]))
×
387

NEW
388
    tool_request = subsystem.request(
×
389
        args=tsc_args,
390
        input_digest=tool_input,
391
        description=f"Type-check TypeScript project {project.root_dir} ({len(project_targets)} targets)",
392
        level=LogLevel.DEBUG,
393
        project_caches=FrozenDict({named_cache_local_dir: named_cache_sandbox_mount_dir}),
394
    )
395

396
    # Uses the project's resolve as TypeScript execution requires project deps.
NEW
397
    tool_request_with_resolve: NodeJSToolRequest = replace(
×
398
        tool_request, resolve=project_resolve.resolve_name
399
    )
400

NEW
401
    process = await prepare_tool_process(tool_request_with_resolve, **implicitly())
×
402

NEW
403
    process_with_wrapper = replace(
×
404
        process,
405
        argv=(f"./{tsc_wrapper_filename}",) + process.argv,
406
        working_directory=project.root_dir,
407
        output_directories=package_output_dirs,
408
    )
409

NEW
410
    return process_with_wrapper
×
411

412

413
async def _typecheck_single_project(
1✔
414
    project: NodeJSProject,
415
    subsystem: TypeScriptSubsystem,
416
    global_options: GlobalOptions,
417
    all_targets: AllTargets,
418
    all_projects: AllNodeJSProjects,
419
    all_package_jsons: AllPackageJson,
420
    all_ts_configs: AllTSConfigs,
421
    build_root: BuildRoot,
422
) -> CheckResult:
NEW
423
    project_package_jsons, project_ts_configs = _collect_project_configs(
×
424
        project, all_package_jsons, all_ts_configs
425
    )
426

NEW
427
    project_targets = await _collect_project_targets(project, all_targets, all_projects)
×
428

NEW
429
    if not project_targets:
×
NEW
430
        return CheckResult(
×
431
            exit_code=0,
432
            stdout="",
433
            stderr="",
434
            partition_description=f"TypeScript check on {project.root_dir} (no targets)",
435
        )
436

NEW
437
    all_workspace_sources = await _hydrate_project_sources(project_targets)
×
NEW
438
    input_digest = await _prepare_compilation_input(
×
439
        project,
440
        project_targets,
441
        project_package_jsons,
442
        project_ts_configs,
443
        all_workspace_sources,
444
    )
NEW
445
    package_output_dirs = _collect_package_output_dirs(
×
446
        project, project_ts_configs, Path(project.root_dir)
447
    )
448

NEW
449
    process = await _prepare_tsc_build_process(
×
450
        project,
451
        subsystem,
452
        input_digest,
453
        package_output_dirs,
454
        project_targets,
455
        build_root,
456
    )
NEW
457
    result = await execute_process(process, **implicitly())
×
458

NEW
459
    return CheckResult.from_fallible_process_result(
×
460
        result,
461
        partition_description=f"TypeScript check on {project.root_dir} ({len(project_targets)} targets)",
462
        output_simplifier=global_options.output_simplifier(),
463
    )
464

465

466
@rule(desc="Check TypeScript compilation")
1✔
467
async def typecheck_typescript(
1✔
468
    request: TypeScriptCheckRequest,
469
    subsystem: TypeScriptSubsystem,
470
    global_options: GlobalOptions,
471
    build_root: BuildRoot,
472
) -> CheckResults:
NEW
473
    if subsystem.skip:
×
NEW
474
        return CheckResults([], checker_name=request.tool_name)
×
475

NEW
476
    field_sets = request.field_sets
×
NEW
477
    (
×
478
        all_projects,
479
        all_targets,
480
        all_package_jsons,
481
        all_ts_configs,
482
        owning_packages,
483
    ) = await concurrently(
484
        find_node_js_projects(**implicitly()),
485
        find_all_targets(),
486
        all_package_json(),
487
        construct_effective_ts_configs(),
488
        concurrently(
489
            find_owning_package(OwningNodePackageRequest(field_set.address), **implicitly())
490
            for field_set in field_sets
491
        ),
492
    )
493

NEW
494
    projects: OrderedSet[NodeJSProject] = OrderedSet()
×
NEW
495
    for owning_package in owning_packages:
×
NEW
496
        if owning_package.target:
×
NEW
497
            package_directory = owning_package.target.address.spec_path
×
NEW
498
            owning_project = all_projects.project_for_directory(package_directory)
×
NEW
499
            if owning_project:
×
NEW
500
                projects.add(owning_project)
×
501

NEW
502
    project_results = await concurrently(
×
503
        _typecheck_single_project(
504
            project,
505
            subsystem,
506
            global_options,
507
            all_targets,
508
            all_projects,
509
            all_package_jsons,
510
            all_ts_configs,
511
            build_root,
512
        )
513
        for project in projects
514
    )
515

NEW
516
    return CheckResults(project_results, checker_name=request.tool_name)
×
517

518

519
def rules():
1✔
520
    return [
1✔
521
        *collect_rules(),
522
        UnionRule(CheckRequest, TypeScriptCheckRequest),
523
    ]
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