ghostrules 0.1.0__py3-none-any.whl

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.
ghostrules/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ghostrules: find the instructions your AI agent sees but you don't."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Used for SARIF help links. Keep it in sync with the repository URL (scripts/set_owner.py).
6
+ PROJECT_URL = "https://github.com/BardLaudian/ghostrules"
ghostrules/_compat.py ADDED
@@ -0,0 +1,12 @@
1
+ """Python-version compatibility shims."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else: # pragma: no cover - exercised only on Python 3.10
10
+ import tomli as tomllib
11
+
12
+ __all__ = ["tomllib"]
ghostrules/clean.py ADDED
@@ -0,0 +1,41 @@
1
+ """``ghostrules clean``: remove the invisible characters the unicode rules flag."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from dataclasses import dataclass
7
+
8
+ from ghostrules.rules.unicode import removable
9
+ from ghostrules.secrets import mask_secrets
10
+ from ghostrules.unicode_utils import render_invisible
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CleanResult:
15
+ """Outcome of cleaning a text."""
16
+
17
+ original: str
18
+ cleaned: str
19
+ removed: int
20
+ kept_bidi: int
21
+
22
+ @property
23
+ def changed(self) -> bool:
24
+ return self.original != self.cleaned
25
+
26
+
27
+ def clean_text(text: str, *, keep_bom: bool = False) -> CleanResult:
28
+ """Remove exactly the characters GR001-GR004 flag (see ``rules.unicode.removable``)."""
29
+ removal = removable(text, keep_bom=keep_bom)
30
+ drop = set(removal.offsets)
31
+ cleaned = "".join(ch for i, ch in enumerate(text) if i not in drop)
32
+ return CleanResult(text, cleaned, len(drop), removal.kept_bidi)
33
+
34
+
35
+ def unified_diff(result: CleanResult, name: str) -> str:
36
+ """Unified diff of the change. Invisible characters are shown as tokens so the removed
37
+ ones are actually visible, and secrets are masked."""
38
+ old = result.original.splitlines(keepends=True)
39
+ new = result.cleaned.splitlines(keepends=True)
40
+ diff = difflib.unified_diff(old, new, fromfile=name, tofile=f"{name} (cleaned)")
41
+ return mask_secrets(render_invisible("".join(diff), with_name=False))
ghostrules/cli.py ADDED
@@ -0,0 +1,319 @@
1
+ """Command line interface: ``scan`` (default), ``reveal``, ``clean``, ``rules``, ``version``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import sys
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import Annotated, Any, NoReturn, Optional
10
+
11
+ import typer
12
+ from rich.console import Console, Group
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+ from typer.core import TyperGroup
16
+
17
+ from ghostrules import __version__
18
+ from ghostrules.clean import clean_text, unified_diff
19
+ from ghostrules.config import Config, ConfigError, load_config
20
+ from ghostrules.models import ScanResult, Severity
21
+ from ghostrules.reporters import json_, markdown, sarif, table
22
+ from ghostrules.reporters.common import safe
23
+ from ghostrules.reveal import build as build_reveal
24
+ from ghostrules.reveal import has_hidden_content
25
+ from ghostrules.rules import all_rules
26
+ from ghostrules.scanner import scan_paths
27
+
28
+ EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
29
+
30
+
31
+ class DefaultScanGroup(TyperGroup):
32
+ """Make ``scan`` the default command: ``ghostrules .`` and ``ghostrules --format json``."""
33
+
34
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
35
+ first = args[0] if args else None
36
+ if first is None or (first not in self.commands and first not in ctx.help_option_names):
37
+ args = ["scan", *args]
38
+ return super().parse_args(ctx, args)
39
+
40
+
41
+ app = typer.Typer(
42
+ name="ghostrules",
43
+ cls=DefaultScanGroup,
44
+ help="Find the instructions your AI agent sees but you don't.",
45
+ add_completion=False,
46
+ rich_markup_mode=None,
47
+ )
48
+
49
+
50
+ class OutputFormat(str, enum.Enum):
51
+ """Report formats for ``scan --format``."""
52
+
53
+ table = "table"
54
+ json = "json"
55
+ sarif = "sarif"
56
+ markdown = "markdown"
57
+
58
+
59
+ _RENDERERS = {
60
+ OutputFormat.json: json_.render,
61
+ OutputFormat.sarif: sarif.render,
62
+ OutputFormat.markdown: markdown.render,
63
+ }
64
+
65
+
66
+ def _fail(message: str) -> NoReturn:
67
+ typer.echo(f"error: {safe(message)}", err=True)
68
+ raise typer.Exit(EXIT_ERROR)
69
+
70
+
71
+ def _utf8_streams() -> None:
72
+ """Reports contain box-drawing and ⟦U+XXXX⟧ tokens: never crash on a legacy code page."""
73
+ for stream in (sys.stdout, sys.stderr):
74
+ encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
75
+ reconfigure = getattr(stream, "reconfigure", None)
76
+ if reconfigure is not None and encoding != "utf8":
77
+ reconfigure(encoding="utf-8", errors="replace")
78
+
79
+
80
+ def _read_text(path: Path) -> tuple[bytes, str]:
81
+ try:
82
+ data = path.read_bytes()
83
+ except OSError as exc:
84
+ _fail(f"cannot read {path}: {exc.strerror or exc}")
85
+ return data, data.decode("utf-8", errors="surrogateescape")
86
+
87
+
88
+ def _parse_severity(value: str | None, *, allow_none: bool) -> Severity | None:
89
+ if value is None:
90
+ return None
91
+ if allow_none and value.strip().lower() == "none":
92
+ return None
93
+ try:
94
+ return Severity.parse(value)
95
+ except ValueError as exc:
96
+ raise typer.BadParameter(str(exc)) from None
97
+
98
+
99
+ def _console(no_color: bool) -> Console:
100
+ return table.make_console(color=False if no_color else None)
101
+
102
+
103
+ # --- scan ----------------------------------------------------------------------------------
104
+
105
+
106
+ @app.command()
107
+ def scan(
108
+ paths: Annotated[
109
+ Optional[list[Path]], # noqa: UP045 - Typer needs Optional on 3.10
110
+ typer.Argument(help="Files or directories to scan (default: current directory)."),
111
+ ] = None,
112
+ format_: Annotated[
113
+ OutputFormat, typer.Option("--format", help="Report format.", case_sensitive=False)
114
+ ] = OutputFormat.table,
115
+ output: Annotated[
116
+ Optional[Path], # noqa: UP045
117
+ typer.Option("--output", "-o", help="Write the report to this file instead of stdout."),
118
+ ] = None,
119
+ fail_on: Annotated[
120
+ Optional[str], # noqa: UP045
121
+ typer.Option(help="Exit 1 if a finding has at least this severity, or 'none'. [high]"),
122
+ ] = None,
123
+ min_severity: Annotated[
124
+ Optional[str], # noqa: UP045
125
+ typer.Option(help="Hide findings below this severity. [low]"),
126
+ ] = None,
127
+ all_files: Annotated[
128
+ bool, typer.Option("--all-files", help="Also check every text file for hidden Unicode.")
129
+ ] = False,
130
+ home: Annotated[
131
+ bool, typer.Option("--home", help="Also scan your user-level agent configs (read-only).")
132
+ ] = False,
133
+ disable: Annotated[
134
+ Optional[str], # noqa: UP045
135
+ typer.Option(help="Comma-separated rule IDs to disable, e.g. GR010,GR012."),
136
+ ] = None,
137
+ config: Annotated[
138
+ Optional[Path], # noqa: UP045
139
+ typer.Option(help="Path to a config file (default: .ghostrules.toml in the scan root)."),
140
+ ] = None,
141
+ no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
142
+ ) -> None:
143
+ """Scan agent instruction and configuration files for hidden or dangerous content.
144
+
145
+ Exit codes: 0 clean (or below --fail-on), 1 findings at or above --fail-on, 2 error.
146
+ """
147
+ _utf8_streams()
148
+ targets = paths or [Path(".")]
149
+ base = targets[0] if targets[0].is_dir() else targets[0].parent
150
+ try:
151
+ cfg = load_config(base, config)
152
+ except ConfigError as exc:
153
+ _fail(str(exc))
154
+ cfg = _apply_overrides(cfg, fail_on, min_severity, disable)
155
+
156
+ result = scan_paths(targets, cfg, all_files=all_files, include_home=home)
157
+ label = ", ".join(str(p) for p in targets)
158
+ _emit(result, format_, output, no_color, label)
159
+
160
+ if result.errors:
161
+ raise typer.Exit(EXIT_ERROR)
162
+ if cfg.fail_on is not None and any(f.severity >= cfg.fail_on for f in result.findings):
163
+ raise typer.Exit(EXIT_FINDINGS)
164
+
165
+
166
+ def _apply_overrides(
167
+ cfg: Config, fail_on: str | None, min_severity: str | None, disable: str | None
168
+ ) -> Config:
169
+ changes: dict[str, object] = {}
170
+ if fail_on is not None:
171
+ changes["fail_on"] = _parse_severity(fail_on, allow_none=True)
172
+ if min_severity is not None:
173
+ changes["min_severity"] = _parse_severity(min_severity, allow_none=False)
174
+ if disable:
175
+ ids = {part.strip().upper() for part in disable.split(",") if part.strip()}
176
+ changes["disable"] = cfg.disable | ids
177
+ return replace(cfg, **changes) # type: ignore[arg-type]
178
+
179
+
180
+ def _emit(
181
+ result: ScanResult, fmt: OutputFormat, output: Path | None, no_color: bool, label: str
182
+ ) -> None:
183
+ if fmt is OutputFormat.table:
184
+ if output is None:
185
+ _console(no_color).print(table.build(result, target=label))
186
+ return
187
+ text = table.render(result, color=False, width=100, target=label)
188
+ else:
189
+ text = _RENDERERS[fmt](result)
190
+ for warning in result.warnings:
191
+ typer.echo(f"warning: {safe(warning)}", err=True)
192
+ for error in result.errors:
193
+ typer.echo(f"error: {safe(error)}", err=True)
194
+ if output is None:
195
+ sys.stdout.write(text)
196
+ return
197
+ try:
198
+ output.write_text(text, encoding="utf-8")
199
+ except OSError as exc:
200
+ _fail(f"cannot write {output}: {exc.strerror or exc}")
201
+ typer.echo(f"report written to {output}", err=True)
202
+
203
+
204
+ # --- reveal / clean ------------------------------------------------------------------------
205
+
206
+
207
+ @app.command()
208
+ def reveal(
209
+ file: Annotated[Path, typer.Argument(help="File to show.")],
210
+ no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
211
+ ) -> None:
212
+ """Print a file with invisible characters made visible, plus the text they hide."""
213
+ _utf8_streams()
214
+ _data, text = _read_text(file)
215
+ console = _console(no_color)
216
+ header = Text.assemble(("◆ ghostrules reveal", "bold"), (f" {safe(str(file))}", "dim"))
217
+ verdict = (
218
+ Text("hidden content found: see highlighted tokens and decoded text", style="bold red")
219
+ if has_hidden_content(text)
220
+ else Text("✔ nothing hidden: no invisible characters or HTML comments", style="green")
221
+ )
222
+ console.print(Group(header, Text(""), build_reveal(text), Text(""), verdict))
223
+
224
+
225
+ @app.command()
226
+ def clean(
227
+ file: Annotated[Path, typer.Argument(help="File to clean.")],
228
+ write: Annotated[
229
+ bool, typer.Option("--write", help="Modify the file (a .ghostrules.bak copy is kept).")
230
+ ] = False,
231
+ keep_bom: Annotated[
232
+ bool, typer.Option("--keep-bom", help="Keep a leading byte-order mark.")
233
+ ] = False,
234
+ no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
235
+ ) -> None:
236
+ """Remove invisible characters (GR001-GR004). Without --write only show the diff."""
237
+ _utf8_streams()
238
+ data, text = _read_text(file)
239
+ console = _console(no_color)
240
+ result = clean_text(text, keep_bom=keep_bom)
241
+ if result.kept_bidi:
242
+ console.print(
243
+ Text(
244
+ f"note: kept {result.kept_bidi} bidirectional control(s): the file is mostly "
245
+ "right-to-left text, where they are legitimate.",
246
+ style="yellow",
247
+ )
248
+ )
249
+ if not result.changed:
250
+ console.print(Text("✔ Nothing to clean.", style="green"))
251
+ return
252
+ if not write:
253
+ console.print(_styled_diff(unified_diff(result, safe(str(file)))))
254
+ console.print(
255
+ Text(
256
+ f"{result.removed} character(s) would be removed. Run again with --write.",
257
+ style="bold",
258
+ )
259
+ )
260
+ return
261
+ backup = file.with_name(file.name + ".ghostrules.bak")
262
+ if backup.exists():
263
+ _fail(f"{backup} already exists; move it away first so no earlier backup is lost")
264
+ try:
265
+ backup.write_bytes(data)
266
+ file.write_bytes(result.cleaned.encode("utf-8", errors="surrogateescape"))
267
+ except OSError as exc:
268
+ _fail(f"cannot write {file}: {exc.strerror or exc}")
269
+ console.print(
270
+ Text(f"✔ Removed {result.removed} character(s) from {safe(str(file))}", style="bold green")
271
+ )
272
+ console.print(Text(f" original saved as {safe(str(backup))}", style="dim"))
273
+
274
+
275
+ def _styled_diff(diff: str) -> Text:
276
+ out = Text()
277
+ for line in diff.splitlines():
278
+ if line.startswith(("+++", "---")):
279
+ style = "bold"
280
+ elif line.startswith("@@"):
281
+ style = "cyan"
282
+ elif line.startswith("+"):
283
+ style = "green"
284
+ elif line.startswith("-"):
285
+ style = "red"
286
+ else:
287
+ style = ""
288
+ out.append(safe(line) + "\n", style=style)
289
+ return out
290
+
291
+
292
+ # --- rules / version -----------------------------------------------------------------------
293
+
294
+
295
+ @app.command()
296
+ def rules(
297
+ no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
298
+ ) -> None:
299
+ """List every detection rule."""
300
+ _utf8_streams()
301
+ grid = Table(box=None, pad_edge=False, header_style="bold")
302
+ grid.add_column("ID", no_wrap=True)
303
+ grid.add_column("Severity", no_wrap=True)
304
+ grid.add_column("Applies to", no_wrap=True, style="dim")
305
+ grid.add_column("Title")
306
+ for rule in all_rules():
307
+ grid.add_row(
308
+ Text(rule.id, style="bold"),
309
+ Text(rule.severity.label, style=table.TEXT_STYLE[rule.severity]),
310
+ Text(", ".join(sorted(rule.applies_to))),
311
+ Text(rule.title),
312
+ )
313
+ _console(no_color).print(grid)
314
+
315
+
316
+ @app.command()
317
+ def version() -> None:
318
+ """Print the ghostrules version."""
319
+ typer.echo(f"ghostrules {__version__}")
ghostrules/config.py ADDED
@@ -0,0 +1,93 @@
1
+ """Configuration loading: ``--config``, ``.ghostrules.toml`` or ``[tool.ghostrules]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ghostrules._compat import tomllib
10
+ from ghostrules.models import Severity
11
+
12
+ CONFIG_FILENAME = ".ghostrules.toml"
13
+ _LIST_KEYS = ("disable", "exclude", "extra_targets", "allow_domains")
14
+ _KNOWN_KEYS = {*_LIST_KEYS, "fail_on", "min_severity"}
15
+
16
+
17
+ class ConfigError(Exception):
18
+ """Raised for unreadable or invalid configuration."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ """Effective configuration. ``fail_on=None`` means never fail."""
24
+
25
+ disable: frozenset[str] = frozenset()
26
+ fail_on: Severity | None = Severity.HIGH
27
+ min_severity: Severity = Severity.LOW
28
+ exclude: tuple[str, ...] = ()
29
+ extra_targets: tuple[str, ...] = ()
30
+ allow_domains: tuple[str, ...] = ()
31
+ source: str | None = None
32
+
33
+
34
+ def _string_list(table: dict[str, Any], key: str) -> list[str]:
35
+ value = table.get(key, [])
36
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
37
+ raise ConfigError(f"'{key}' must be a list of strings")
38
+ return value
39
+
40
+
41
+ def parse_config(table: dict[str, Any], source: str | None = None) -> Config:
42
+ """Build a ``Config`` from an already-parsed TOML table, validating keys and values."""
43
+ unknown = sorted(set(table) - _KNOWN_KEYS)
44
+ if unknown:
45
+ raise ConfigError(f"unknown config key(s): {', '.join(unknown)}")
46
+ try:
47
+ fail_on_raw = table.get("fail_on", "high")
48
+ if not isinstance(fail_on_raw, str):
49
+ raise ConfigError("'fail_on' must be a string")
50
+ fail_on = None if fail_on_raw.strip().lower() == "none" else Severity.parse(fail_on_raw)
51
+ min_raw = table.get("min_severity", "low")
52
+ if not isinstance(min_raw, str):
53
+ raise ConfigError("'min_severity' must be a string")
54
+ min_severity = Severity.parse(min_raw)
55
+ except ValueError as exc:
56
+ raise ConfigError(str(exc)) from None
57
+ return Config(
58
+ disable=frozenset(rule.strip().upper() for rule in _string_list(table, "disable")),
59
+ fail_on=fail_on,
60
+ min_severity=min_severity,
61
+ exclude=tuple(_string_list(table, "exclude")),
62
+ extra_targets=tuple(_string_list(table, "extra_targets")),
63
+ allow_domains=tuple(d.lower() for d in _string_list(table, "allow_domains")),
64
+ source=source,
65
+ )
66
+
67
+
68
+ def _read_toml(path: Path) -> dict[str, Any]:
69
+ try:
70
+ with path.open("rb") as handle:
71
+ return tomllib.load(handle)
72
+ except OSError as exc:
73
+ raise ConfigError(f"cannot read {path}: {exc.strerror or exc}") from None
74
+ except tomllib.TOMLDecodeError as exc:
75
+ raise ConfigError(f"invalid TOML in {path}: {exc}") from None
76
+
77
+
78
+ def load_config(base_dir: Path, explicit: Path | None = None) -> Config:
79
+ """Load configuration, trying in order: ``explicit``, ``.ghostrules.toml``, pyproject.
80
+
81
+ Returns defaults when none is found. A pyproject without ``[tool.ghostrules]`` is skipped.
82
+ """
83
+ if explicit is not None:
84
+ return parse_config(_read_toml(explicit), source=str(explicit))
85
+ dotfile = base_dir / CONFIG_FILENAME
86
+ if dotfile.is_file():
87
+ return parse_config(_read_toml(dotfile), source=CONFIG_FILENAME)
88
+ pyproject = base_dir / "pyproject.toml"
89
+ if pyproject.is_file():
90
+ table = _read_toml(pyproject).get("tool", {}).get("ghostrules")
91
+ if isinstance(table, dict):
92
+ return parse_config(table, source="pyproject.toml [tool.ghostrules]")
93
+ return Config()
ghostrules/jsonloc.py ADDED
@@ -0,0 +1,194 @@
1
+ """JSON with comments: tolerant stripping and a parser that records source offsets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ def strip_jsonc(text: str) -> str:
12
+ """Blank out ``//`` and ``/* */`` comments and trailing commas, outside of strings.
13
+
14
+ Characters are replaced by spaces (newlines kept) so offsets, lines and columns still match
15
+ the original text.
16
+ """
17
+ out: list[str] = []
18
+ pending_comma: int | None = None
19
+ in_string = False
20
+ i, n = 0, len(text)
21
+ while i < n:
22
+ ch = text[i]
23
+ if in_string:
24
+ out.append(ch)
25
+ if ch == "\\" and i + 1 < n:
26
+ out.append(text[i + 1])
27
+ i += 1
28
+ elif ch == '"':
29
+ in_string = False
30
+ i += 1
31
+ elif text.startswith("//", i):
32
+ end = text.find("\n", i)
33
+ end = n if end == -1 else end
34
+ out.append(" " * (end - i))
35
+ i = end
36
+ elif text.startswith("/*", i):
37
+ close = text.find("*/", i + 2)
38
+ end = n if close == -1 else close + 2
39
+ out.append("".join(c if c == "\n" else " " for c in text[i:end]))
40
+ i = end
41
+ else:
42
+ if ch in "}]" and pending_comma is not None:
43
+ out[pending_comma] = " "
44
+ if ch == ",":
45
+ pending_comma = len(out)
46
+ elif not ch.isspace():
47
+ pending_comma = None
48
+ in_string = ch == '"'
49
+ out.append(ch)
50
+ i += 1
51
+ return "".join(out)
52
+
53
+
54
+ @dataclass
55
+ class JNode:
56
+ """A parsed JSON value with its source span. ``end`` is exclusive.
57
+
58
+ ``members`` (objects) maps each key to ``(key_start, value_node)``; ``elements`` holds array
59
+ items. Both are ``None`` for scalars.
60
+ """
61
+
62
+ value: Any
63
+ start: int
64
+ end: int
65
+ members: dict[str, tuple[int, JNode]] | None = None
66
+ elements: list[JNode] | None = None
67
+
68
+ def get(self, key: str) -> JNode | None:
69
+ """Child node of an object by key."""
70
+ if self.members is None or key not in self.members:
71
+ return None
72
+ return self.members[key][1]
73
+
74
+ def string_span(self) -> tuple[int, int]:
75
+ """Span of a string value without its quotes (or the whole node for other scalars)."""
76
+ if isinstance(self.value, str):
77
+ return self.start + 1, max(self.start + 1, self.end - 1)
78
+ return self.start, self.end
79
+
80
+
81
+ _SCALAR_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null")
82
+ _MAX_DEPTH = 100
83
+
84
+
85
+ class _Parser:
86
+ def __init__(self, text: str) -> None:
87
+ self.text = text
88
+ self.pos = 1 if text.startswith("\ufeff") else 0
89
+
90
+ def _skip_ws(self) -> None:
91
+ while self.pos < len(self.text) and self.text[self.pos] in " \t\r\n":
92
+ self.pos += 1
93
+
94
+ def parse(self) -> JNode:
95
+ node = self._value(0)
96
+ self._skip_ws()
97
+ if self.pos != len(self.text):
98
+ raise ValueError("trailing data")
99
+ return node
100
+
101
+ def _value(self, depth: int) -> JNode:
102
+ if depth > _MAX_DEPTH:
103
+ raise ValueError("too deep")
104
+ self._skip_ws()
105
+ if self.pos >= len(self.text):
106
+ raise ValueError("unexpected end")
107
+ ch = self.text[self.pos]
108
+ if ch == "{":
109
+ return self._object(depth)
110
+ if ch == "[":
111
+ return self._array(depth)
112
+ if ch == '"':
113
+ start, raw = self.pos, self._raw_string()
114
+ return JNode(json.loads(raw), start, self.pos)
115
+ match = _SCALAR_RE.match(self.text, self.pos)
116
+ if not match:
117
+ raise ValueError("bad value")
118
+ self.pos = match.end()
119
+ return JNode(json.loads(match.group()), match.start(), match.end())
120
+
121
+ def _raw_string(self) -> str:
122
+ start = self.pos
123
+ self.pos += 1
124
+ while self.pos < len(self.text):
125
+ ch = self.text[self.pos]
126
+ if ch == "\\":
127
+ self.pos += 2
128
+ continue
129
+ self.pos += 1
130
+ if ch == '"':
131
+ return self.text[start : self.pos]
132
+ raise ValueError("unterminated string")
133
+
134
+ def _object(self, depth: int) -> JNode:
135
+ start = self.pos
136
+ self.pos += 1
137
+ members: dict[str, tuple[int, JNode]] = {}
138
+ value: dict[str, Any] = {}
139
+ self._skip_ws()
140
+ if self.text.startswith("}", self.pos):
141
+ self.pos += 1
142
+ return JNode(value, start, self.pos, members=members)
143
+ while True:
144
+ self._skip_ws()
145
+ if not self.text.startswith('"', self.pos):
146
+ raise ValueError("expected key")
147
+ key_start = self.pos
148
+ key = json.loads(self._raw_string())
149
+ self._skip_ws()
150
+ if not self.text.startswith(":", self.pos):
151
+ raise ValueError("expected colon")
152
+ self.pos += 1
153
+ node = self._value(depth + 1)
154
+ members[key] = (key_start, node)
155
+ value[key] = node.value
156
+ self._skip_ws()
157
+ if self.text.startswith(",", self.pos):
158
+ self.pos += 1
159
+ elif self.text.startswith("}", self.pos):
160
+ self.pos += 1
161
+ return JNode(value, start, self.pos, members=members)
162
+ else:
163
+ raise ValueError("expected comma or brace")
164
+
165
+ def _array(self, depth: int) -> JNode:
166
+ start = self.pos
167
+ self.pos += 1
168
+ elements: list[JNode] = []
169
+ self._skip_ws()
170
+ if self.text.startswith("]", self.pos):
171
+ self.pos += 1
172
+ return JNode([], start, self.pos, elements=elements)
173
+ while True:
174
+ elements.append(self._value(depth + 1))
175
+ self._skip_ws()
176
+ if self.text.startswith(",", self.pos):
177
+ self.pos += 1
178
+ elif self.text.startswith("]", self.pos):
179
+ self.pos += 1
180
+ return JNode([e.value for e in elements], start, self.pos, elements=elements)
181
+ else:
182
+ raise ValueError("expected comma or bracket")
183
+
184
+
185
+ def parse_located(text: str) -> JNode | None:
186
+ """Parse JSON (comments and trailing commas tolerated) keeping source offsets.
187
+
188
+ Offsets refer to ``text`` itself, because comment stripping preserves length. Returns ``None``
189
+ if the text is not valid JSON.
190
+ """
191
+ try:
192
+ return _Parser(strip_jsonc(text)).parse()
193
+ except (ValueError, RecursionError):
194
+ return None