• 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

55.05
/src/python/pants/backend/typescript/tsconfig.py
1
# Copyright 2024 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
"""tsconfig.json is primarily used by the typescript compiler in order to resolve types during
4
compilation. The format is also used by IDE:s to provide intellisense. The format is used for
5
projects that use only javascript, and is then named jsconfig.json.
6

7
See https://code.visualstudio.com/docs/languages/jsconfig
8
"""
9

10
from __future__ import annotations
4✔
11

12
import json
4✔
13
import logging
4✔
14
import os
4✔
15
import re
4✔
16
from collections.abc import Iterable
4✔
17
from dataclasses import dataclass
4✔
18
from pathlib import PurePath
4✔
19

20
from pants.engine.collection import Collection
4✔
21
from pants.engine.fs import DigestContents, FileContent, PathGlobs
4✔
22
from pants.engine.internals.selectors import concurrently
4✔
23
from pants.engine.intrinsics import get_digest_contents, path_globs_to_digest
4✔
24
from pants.engine.rules import Rule, collect_rules, rule
4✔
25
from pants.util.frozendict import FrozenDict
4✔
26

27
logger = logging.getLogger(__name__)
4✔
28

29

30
@dataclass(frozen=True)
4✔
31
class TSConfig:
4✔
32
    """Parsed tsconfig.json fields with `extends` substitution applied."""
33

34
    path: str
4✔
35
    module_resolution: str | None = None
4✔
36
    paths: FrozenDict[str, tuple[str, ...]] | None = None
4✔
37
    base_url: str | None = None
4✔
38
    out_dir: str | None = None
4✔
39
    allow_js: bool | None = None
4✔
40
    check_js: bool | None = None
4✔
41

42
    @property
4✔
43
    def resolution_root_dir(self) -> str:
4✔
UNCOV
44
        directory = os.path.dirname(self.path)
×
UNCOV
45
        return os.path.join(directory, self.base_url) if self.base_url else directory
×
46

47
    def validate_outdir(self) -> None:
4✔
UNCOV
48
        if not self.out_dir:
×
UNCOV
49
            raise ValueError(
×
50
                f"TypeScript configuration at '{self.path}' is missing required 'outDir' setting. "
51
                f"TypeScript type-checking requires an explicit outDir in compilerOptions to work properly. "
52
                f'Add \'"outDir": "./dist"\' (or your preferred output directory) to the compilerOptions '
53
                f"in {self.path}."
54
            )
55

UNCOV
56
        if ".." in self.out_dir:
×
UNCOV
57
            raise ValueError(
×
58
                f"TypeScript configuration at '{self.path}' has outDir '{self.out_dir}' "
59
                f"that uses '..' path components. Each package should use its own output directory "
60
                f"within its package boundary (e.g., './dist', './build'). Cross-package output "
61
                f"directories can cause build conflicts where packages overwrite each other's artifacts."
62
            )
63

UNCOV
64
        if os.path.isabs(self.out_dir):
×
UNCOV
65
            raise ValueError(
×
66
                f"TypeScript configuration at '{self.path}' has absolute outDir '{self.out_dir}'. "
67
                f"Use a relative path within the package directory instead (e.g., './dist', './build')."
68
            )
69

70

71
class AllTSConfigs(Collection[TSConfig]):
4✔
72
    pass
4✔
73

74

75
@dataclass(frozen=True)
4✔
76
class ParseTSConfigRequest:
4✔
77
    content: FileContent
4✔
78
    others: DigestContents
4✔
79

80

81
def _get_parent_config_content(
4✔
82
    child_path: str,
83
    extends_path: str,
84
    others: DigestContents,
85
) -> FileContent | None:
UNCOV
86
    if child_path.endswith(".json"):
×
UNCOV
87
        relative = os.path.dirname(child_path)
×
88
    else:
89
        relative = child_path
×
UNCOV
90
    relative = os.path.normpath(os.path.join(relative, extends_path))
×
UNCOV
91
    for target_file in ("tsconfig.json", "jsconfig.json"):
×
UNCOV
92
        if not extends_path.endswith(".json"):
×
UNCOV
93
            relative = os.path.join(relative, target_file)
×
UNCOV
94
        parent = next((other for other in others if other.path == relative), None)
×
UNCOV
95
        if parent:
×
UNCOV
96
            break
×
UNCOV
97
    if not parent:
×
UNCOV
98
        logger.warning(
×
99
            f"pants could not locate {child_path}'s 'extends' at {relative}. Found: {[other.path for other in others]}."
100
        )
UNCOV
101
        return None
×
UNCOV
102
    return parent
×
103

104

105
def _clean_tsconfig_contents(content: str) -> str:
4✔
106
    """The tsconfig.json uses a format similar to JSON ("JSON with comments"), but there are some
107
    important differences:
108

109
    * tsconfig.json allows both single-line (`// comment`) and multi-line comments (`/* comment */`) to be added
110
    anywhere in the file.
111
    * Trailing commas in arrays and objects are permitted.
112

113
    TypeScript uses its own parser to read the file; in standard JSON, trailing commas or comments are not allowed.
114
    """
115
    # This pattern matches:
116
    # 1. Strings: "..." or '...'
117
    # 2. Single-line comments: //...
118
    # 3. Multi-line comments: /*...*/
119
    # 4. Everything else (including potential trailing commas)
UNCOV
120
    pattern = r'("(?:\\.|[^"\\])*")|(\'(?:\\.|[^\'\\])*\')|(//.*?$)|(/\*.*?\*/)|,(\s*[\]}])'
×
121

UNCOV
122
    def replace(match):
×
UNCOV
123
        if match.group(1) or match.group(2):  # It's a string
×
UNCOV
124
            return match.group(0)  # Return the string as is
×
UNCOV
125
        elif match.group(3) or match.group(4):  # It's a comment
×
UNCOV
126
            return ""  # Remove the comment
×
UNCOV
127
        elif match.group(5):  # It's a trailing comma
×
UNCOV
128
            return match.group(5)  # Remove the comma keeping the closing brace/bracket
×
129
        return match.group(0)
×
130

UNCOV
131
    cleaned_content = re.sub(pattern, replace, content, flags=re.DOTALL | re.MULTILINE)
×
UNCOV
132
    return cleaned_content
×
133

134

135
def _parse_config_from_content(content: FileContent) -> tuple[TSConfig, str | None]:
4✔
UNCOV
136
    cleaned_tsconfig_contents = _clean_tsconfig_contents(content.content.decode("utf-8"))
×
UNCOV
137
    parsed_ts_config_json = FrozenDict.deep_freeze(json.loads(cleaned_tsconfig_contents))
×
138

UNCOV
139
    compiler_options = parsed_ts_config_json.get("compilerOptions", FrozenDict())
×
UNCOV
140
    return TSConfig(
×
141
        content.path,
142
        module_resolution=compiler_options.get("moduleResolution"),
143
        paths=compiler_options.get("paths"),
144
        base_url=compiler_options.get("baseUrl"),
145
        out_dir=compiler_options.get("outDir"),
146
        allow_js=compiler_options.get("allowJs"),
147
        check_js=compiler_options.get("checkJs"),
148
    ), parsed_ts_config_json.get("extends")
149

150

151
@rule
4✔
152
async def parse_extended_ts_config(request: ParseTSConfigRequest) -> TSConfig:
4✔
UNCOV
153
    ts_config, extends = _parse_config_from_content(request.content)
×
UNCOV
154
    if not extends:
×
UNCOV
155
        return ts_config
×
156

UNCOV
157
    parent_content = _get_parent_config_content(ts_config.path, extends, request.others)
×
UNCOV
158
    if parent_content:
×
UNCOV
159
        extended_parent = await parse_extended_ts_config(
×
160
            ParseTSConfigRequest(parent_content, request.others)
161
        )
162
    else:
UNCOV
163
        extended_parent = None
×
164

UNCOV
165
    if not extended_parent:
×
UNCOV
166
        return ts_config
×
UNCOV
167
    return TSConfig(
×
168
        ts_config.path,
169
        module_resolution=ts_config.module_resolution or extended_parent.module_resolution,
170
        paths=ts_config.paths or extended_parent.paths,
171
        base_url=ts_config.base_url or extended_parent.base_url,
172
        # Do NOT inherit outDir - paths in extended configs are resolved relative to where they're defined,
173
        # not where they're used, making inherited outDir values incorrect for child projects
174
        out_dir=ts_config.out_dir,
175
        allow_js=ts_config.allow_js if ts_config.allow_js is not None else extended_parent.allow_js,
176
        check_js=ts_config.check_js if ts_config.check_js is not None else extended_parent.check_js,
177
    )
178

179

180
@dataclass(frozen=True)
4✔
181
class TSConfigsRequest:
4✔
182
    target_file: str
4✔
183

184

185
@rule
4✔
186
async def construct_effective_ts_configs() -> AllTSConfigs:
4✔
187
    all_files = await path_globs_to_digest(PathGlobs(["**/tsconfig*.json", "**/jsconfig*.json"]))
1✔
188
    digest_contents = await get_digest_contents(all_files)
1✔
189

190
    return AllTSConfigs(
1✔
191
        await concurrently(
192
            parse_extended_ts_config(ParseTSConfigRequest(digest_content, digest_contents))
193
            for digest_content in digest_contents
194
        )
195
    )
196

197

198
@dataclass(frozen=True)
4✔
199
class ClosestTSConfig:
4✔
200
    ts_config: TSConfig | None
4✔
201

202

203
@dataclass(frozen=True)
4✔
204
class ParentTSConfigRequest:
4✔
205
    file: str
4✔
206

207

208
@rule(desc="Finding parent tsconfig.json")
4✔
209
async def find_parent_ts_config(req: ParentTSConfigRequest) -> ClosestTSConfig:
4✔
210
    all_configs = await construct_effective_ts_configs()
1✔
211
    configs_by_longest_path = sorted(all_configs, key=lambda config: len(config.path), reverse=True)
1✔
212
    for config in configs_by_longest_path:
1✔
UNCOV
213
        if PurePath(req.file).is_relative_to(os.path.dirname(config.path)):
×
UNCOV
214
            return ClosestTSConfig(config)
×
215
    return ClosestTSConfig(None)
1✔
216

217

218
def rules() -> Iterable[Rule]:
4✔
219
    return collect_rules()
4✔
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