abom-cli 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {abom_cli-0.1.1 → abom_cli-0.1.3}/PKG-INFO +3 -1
- {abom_cli-0.1.1 → abom_cli-0.1.3}/README.md +2 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/pyproject.toml +1 -1
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/__init__.py +1 -1
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/bom.py +12 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/cli.py +16 -2
- abom_cli-0.1.3/src/abom/log.py +66 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/scan.py +12 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/sign.py +11 -0
- abom_cli-0.1.3/tests/test_log.py +36 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/.env.example +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/.gitignore +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/Dockerfile +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/MVP_SPEC.md +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/Makefile +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/charts/abom/Chart.yaml +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/charts/abom/values.yaml +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/demo/README.md +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/demo/demo.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/demo/sample_repo/tests.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/demo/sample_repo/validator.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/docker-compose.yml +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/scripts/init_db.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/agents.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/api.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/audit.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/config.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/db.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/execution.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/models_router.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/orchestration.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/policy.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/src/abom/schemas.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/fixtures/sample-agent/agent.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/fixtures/sample-agent/mcp.json +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/fixtures/sample-agent/prompts/system.txt +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/fixtures/sample-agent/requirements.txt +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/test_audit_chain.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/test_scan.py +0 -0
- {abom_cli-0.1.1 → abom_cli-0.1.3}/tests/test_sign.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: abom-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: ABOM — the Agent Bill of Materials. Scan, sign, and verify what your AI agents are made of.
|
|
5
5
|
Project-URL: Homepage, https://abom.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/josephassiga/abom
|
|
@@ -59,6 +59,8 @@ abom verify abom.json --policy policy.json # + enforce a policy (exit 1 on vio
|
|
|
59
59
|
| `abom keygen` | Show (or create) the local ed25519 signing key (`~/.abom/signing_key.pem`, override with `ABOM_KEY`). |
|
|
60
60
|
| `abom version` | Print the tool and spec versions. |
|
|
61
61
|
|
|
62
|
+
All commands accept **`-v`** (info) / **`-vv`** (debug) / **`-q`** (errors only) / **`--json-logs`** (NDJSON for CI) — logs go to stderr, so the ABOM on stdout stays clean.
|
|
63
|
+
|
|
62
64
|
### Example
|
|
63
65
|
|
|
64
66
|
```
|
|
@@ -19,6 +19,8 @@ abom verify abom.json --policy policy.json # + enforce a policy (exit 1 on vio
|
|
|
19
19
|
| `abom keygen` | Show (or create) the local ed25519 signing key (`~/.abom/signing_key.pem`, override with `ABOM_KEY`). |
|
|
20
20
|
| `abom version` | Print the tool and spec versions. |
|
|
21
21
|
|
|
22
|
+
All commands accept **`-v`** (info) / **`-vv`** (debug) / **`-q`** (errors only) / **`--json-logs`** (NDJSON for CI) — logs go to stderr, so the ABOM on stdout stays clean.
|
|
23
|
+
|
|
22
24
|
### Example
|
|
23
25
|
|
|
24
26
|
```
|
|
@@ -14,11 +14,13 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import dataclasses
|
|
16
16
|
import hashlib
|
|
17
|
+
import logging
|
|
17
18
|
|
|
18
19
|
from . import sign as _sign
|
|
19
20
|
from .audit import GENESIS, canonical_json, make_record, verify_chain
|
|
20
21
|
|
|
21
22
|
ABOM_VERSION = "0.1"
|
|
23
|
+
log = logging.getLogger("abom.bom")
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def _sha256(obj) -> str:
|
|
@@ -44,6 +46,8 @@ def build_composition(agent: dict, components: list[dict], controls: dict,
|
|
|
44
46
|
"agent": agent, "components": components, "controls": controls,
|
|
45
47
|
}
|
|
46
48
|
body["composition_sha256"] = _sha256(body)
|
|
49
|
+
log.debug("composition built: %d components, sha256=%s…",
|
|
50
|
+
len(components), body["composition_sha256"][:16])
|
|
47
51
|
return _sign.sign_obj(body, key) if sign else body
|
|
48
52
|
|
|
49
53
|
|
|
@@ -143,5 +147,13 @@ def verify_abom(composition: dict, chain: list | None = None, policy: dict | Non
|
|
|
143
147
|
findings.append({"rule": "approval_coverage", "seq": seq, "severity": "high",
|
|
144
148
|
"detail": "consequential action without approval"})
|
|
145
149
|
|
|
150
|
+
log.info("verify: %d finding(s) over %d components / %d action(s)",
|
|
151
|
+
len(findings), len(composition.get("components", [])), len(chain),
|
|
152
|
+
extra={"event": "verify_done", "findings": len(findings),
|
|
153
|
+
"components": len(composition.get("components", [])), "actions": len(chain)})
|
|
154
|
+
for f in findings:
|
|
155
|
+
log.debug("finding: [%s] %s — %s", f.get("severity"), f.get("rule"), f.get("detail"),
|
|
156
|
+
extra={"event": "finding", "rule": f.get("rule"),
|
|
157
|
+
"severity": f.get("severity"), "detail": f.get("detail")})
|
|
146
158
|
return {"ok": len(findings) == 0, "findings": findings,
|
|
147
159
|
"actions": len(chain), "components": len(composition.get("components", []))}
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
import typer
|
|
16
16
|
|
|
17
|
-
from . import __version__, bom, scan as scanner, sign
|
|
17
|
+
from . import __version__, bom, log, scan as scanner, sign
|
|
18
18
|
|
|
19
19
|
app = typer.Typer(
|
|
20
20
|
add_completion=False,
|
|
@@ -35,8 +35,12 @@ def scan(
|
|
|
35
35
|
sign_manifest: bool = typer.Option(True, "--sign/--no-sign", help="ed25519-sign the manifest."),
|
|
36
36
|
name: str = typer.Option(None, "--name", help="Override the detected agent name."),
|
|
37
37
|
version: str = typer.Option(None, "--agent-version", help="Override the detected agent version."),
|
|
38
|
+
verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
|
|
39
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
|
|
40
|
+
json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
|
|
38
41
|
):
|
|
39
42
|
"""Scan a repo and emit a signed ABOM Composition Manifest."""
|
|
43
|
+
log.setup(verbose, quiet, json_logs)
|
|
40
44
|
root = Path(path)
|
|
41
45
|
if not root.exists():
|
|
42
46
|
typer.secho(f"path not found: {path}", fg="red", err=True)
|
|
@@ -79,8 +83,12 @@ def scan(
|
|
|
79
83
|
def verify(
|
|
80
84
|
abom_file: str = typer.Argument("abom.json", help="ABOM file to verify."),
|
|
81
85
|
policy_file: str = typer.Option(None, "--policy", "-p", help="Policy JSON to enforce."),
|
|
86
|
+
verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
|
|
87
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
|
|
88
|
+
json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
|
|
82
89
|
):
|
|
83
90
|
"""Verify an ABOM's signature, and (with --policy) its compliance."""
|
|
91
|
+
log.setup(verbose, quiet, json_logs)
|
|
84
92
|
try:
|
|
85
93
|
doc = json.loads(Path(abom_file).read_text())
|
|
86
94
|
except Exception as exc:
|
|
@@ -107,8 +115,14 @@ def verify(
|
|
|
107
115
|
|
|
108
116
|
|
|
109
117
|
@app.command()
|
|
110
|
-
def keygen(
|
|
118
|
+
def keygen(
|
|
119
|
+
show_private: bool = typer.Option(False, "--show-private", help="Print the private key path."),
|
|
120
|
+
verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
|
|
121
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
|
|
122
|
+
json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
|
|
123
|
+
):
|
|
111
124
|
"""Show (or create) the local ed25519 signing key."""
|
|
125
|
+
log.setup(verbose, quiet, json_logs)
|
|
112
126
|
key = sign.load_or_create_key()
|
|
113
127
|
pub_b64 = sign._pub_b64(key.public_key())
|
|
114
128
|
path = sign.default_key_path()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Logging for the abom CLI.
|
|
2
|
+
|
|
3
|
+
Logs go to **stderr** so they never mix into the ABOM written to stdout.
|
|
4
|
+
|
|
5
|
+
(default) warnings + errors only
|
|
6
|
+
-v INFO — high-level steps (what was scanned / verified)
|
|
7
|
+
-vv DEBUG — per-dependency / per-file / per-check detail
|
|
8
|
+
-q / --quiet ERROR — errors only
|
|
9
|
+
--json-logs one NDJSON object per line on stderr (for CI pipelines)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("abom")
|
|
19
|
+
|
|
20
|
+
# Standard LogRecord attributes — everything else is treated as structured extra.
|
|
21
|
+
_STD = {
|
|
22
|
+
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
|
|
23
|
+
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
|
|
24
|
+
"created", "msecs", "relativeCreated", "thread", "threadName",
|
|
25
|
+
"processName", "process", "taskName", "message", "asctime",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonFormatter(logging.Formatter):
|
|
30
|
+
"""Render each record as a single JSON line, including any ``extra=`` fields."""
|
|
31
|
+
|
|
32
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
33
|
+
data = {
|
|
34
|
+
"ts": _dt.datetime.fromtimestamp(record.created, _dt.timezone.utc)
|
|
35
|
+
.isoformat(timespec="milliseconds"),
|
|
36
|
+
"level": record.levelname,
|
|
37
|
+
"logger": record.name,
|
|
38
|
+
"msg": record.getMessage(),
|
|
39
|
+
}
|
|
40
|
+
for key, value in record.__dict__.items():
|
|
41
|
+
if key not in _STD and not key.startswith("_"):
|
|
42
|
+
data[key] = value
|
|
43
|
+
if record.exc_info:
|
|
44
|
+
data["exc"] = self.formatException(record.exc_info)
|
|
45
|
+
return json.dumps(data, default=str)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def setup(verbose: int = 0, quiet: bool = False, json_logs: bool = False) -> None:
|
|
49
|
+
if quiet:
|
|
50
|
+
level = logging.ERROR
|
|
51
|
+
elif verbose >= 2:
|
|
52
|
+
level = logging.DEBUG
|
|
53
|
+
elif verbose == 1:
|
|
54
|
+
level = logging.INFO
|
|
55
|
+
else:
|
|
56
|
+
level = logging.WARNING
|
|
57
|
+
|
|
58
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
59
|
+
handler.setFormatter(
|
|
60
|
+
JsonFormatter() if json_logs
|
|
61
|
+
else logging.Formatter("%(levelname).1s %(name)s | %(message)s")
|
|
62
|
+
)
|
|
63
|
+
root = logging.getLogger("abom")
|
|
64
|
+
root.handlers[:] = [handler]
|
|
65
|
+
root.setLevel(level)
|
|
66
|
+
root.propagate = False
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import json
|
|
12
|
+
import logging
|
|
12
13
|
import re
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
@@ -17,6 +18,8 @@ try:
|
|
|
17
18
|
except ModuleNotFoundError: # Python 3.10
|
|
18
19
|
import tomli as tomllib
|
|
19
20
|
|
|
21
|
+
log = logging.getLogger("abom.scan")
|
|
22
|
+
|
|
20
23
|
# --- known signals -----------------------------------------------------------
|
|
21
24
|
FRAMEWORKS = {
|
|
22
25
|
"langchain": "LangChain", "langchain-core": "LangChain", "langgraph": "LangGraph",
|
|
@@ -144,7 +147,10 @@ def _agent_meta(root: Path) -> dict:
|
|
|
144
147
|
|
|
145
148
|
def scan(root: Path) -> dict:
|
|
146
149
|
root = Path(root)
|
|
150
|
+
log.info("scanning %s", root.resolve())
|
|
147
151
|
deps = parse_dependencies(root)
|
|
152
|
+
log.info("parsed %d dependencies", len(deps))
|
|
153
|
+
log.debug("dependencies: %s", ", ".join(sorted(deps)) or "(none)")
|
|
148
154
|
components: list[dict] = []
|
|
149
155
|
seen: set[tuple] = set()
|
|
150
156
|
|
|
@@ -153,6 +159,10 @@ def scan(root: Path) -> dict:
|
|
|
153
159
|
if key not in seen:
|
|
154
160
|
seen.add(key)
|
|
155
161
|
components.append(comp)
|
|
162
|
+
log.debug("+ %-10s %-28s [%s]", comp["type"], comp.get("name", "?"),
|
|
163
|
+
comp.get("detected_from", "?"),
|
|
164
|
+
extra={"event": "component", "comp_type": comp["type"],
|
|
165
|
+
"comp_name": comp.get("name"), "source": comp.get("detected_from")})
|
|
156
166
|
|
|
157
167
|
# 1. from dependencies
|
|
158
168
|
for d in sorted(deps):
|
|
@@ -203,6 +213,8 @@ def scan(root: Path) -> dict:
|
|
|
203
213
|
add({"type": "model", "name": name, "provenance": "referenced in source",
|
|
204
214
|
"egress": provider == "external", "detected_from": "source"})
|
|
205
215
|
|
|
216
|
+
log.info("detected %d components", len(components),
|
|
217
|
+
extra={"event": "scan_done", "components": len(components)})
|
|
206
218
|
return {
|
|
207
219
|
"agent": _agent_meta(root),
|
|
208
220
|
"components": components,
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import hashlib
|
|
12
|
+
import logging
|
|
12
13
|
import os
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
@@ -20,6 +21,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
|
20
21
|
|
|
21
22
|
from .audit import canonical_json
|
|
22
23
|
|
|
24
|
+
log = logging.getLogger("abom.sign")
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
def default_key_path() -> Path:
|
|
25
28
|
return Path(os.environ.get("ABOM_KEY", str(Path.home() / ".abom" / "signing_key.pem")))
|
|
@@ -28,6 +31,7 @@ def default_key_path() -> Path:
|
|
|
28
31
|
def load_or_create_key(path: Path | None = None) -> Ed25519PrivateKey:
|
|
29
32
|
path = Path(path or default_key_path())
|
|
30
33
|
if path.exists():
|
|
34
|
+
log.debug("loaded signing key from %s", path)
|
|
31
35
|
return serialization.load_pem_private_key(path.read_bytes(), password=None)
|
|
32
36
|
key = Ed25519PrivateKey.generate()
|
|
33
37
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -42,6 +46,7 @@ def load_or_create_key(path: Path | None = None) -> Ed25519PrivateKey:
|
|
|
42
46
|
os.chmod(path, 0o600)
|
|
43
47
|
except OSError:
|
|
44
48
|
pass
|
|
49
|
+
log.info("generated new ed25519 signing key at %s", path)
|
|
45
50
|
return key
|
|
46
51
|
|
|
47
52
|
|
|
@@ -60,6 +65,7 @@ def sign_obj(obj: dict, key: Ed25519PrivateKey | None = None) -> dict:
|
|
|
60
65
|
body = {k: v for k, v in obj.items() if k != "signature"}
|
|
61
66
|
sig = key.sign(canonical_json(body).encode("utf-8"))
|
|
62
67
|
pub_b64 = _pub_b64(key.public_key())
|
|
68
|
+
log.debug("signed with key_id=%s", key_id(pub_b64))
|
|
63
69
|
return {
|
|
64
70
|
**obj,
|
|
65
71
|
"signature": {
|
|
@@ -76,16 +82,21 @@ def verify_obj(obj: dict, trusted_keys: set[str] | None = None) -> bool:
|
|
|
76
82
|
signer's key id must be in it."""
|
|
77
83
|
sig = obj.get("signature") or {}
|
|
78
84
|
if sig.get("alg") != "ed25519":
|
|
85
|
+
log.debug("verify: unsupported or missing signature alg %r", sig.get("alg"))
|
|
79
86
|
return False
|
|
80
87
|
pub_b64, value = sig.get("public_key"), sig.get("value")
|
|
81
88
|
if not pub_b64 or not value:
|
|
89
|
+
log.debug("verify: signature missing public_key or value")
|
|
82
90
|
return False
|
|
83
91
|
if trusted_keys is not None and key_id(pub_b64) not in trusted_keys:
|
|
92
|
+
log.debug("verify: signer key_id %s not in trusted set", key_id(pub_b64))
|
|
84
93
|
return False
|
|
85
94
|
try:
|
|
86
95
|
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64))
|
|
87
96
|
body = {k: v for k, v in obj.items() if k != "signature"}
|
|
88
97
|
pub.verify(base64.b64decode(value), canonical_json(body).encode("utf-8"))
|
|
98
|
+
log.debug("verify: signature OK (key_id=%s)", key_id(pub_b64))
|
|
89
99
|
return True
|
|
90
100
|
except Exception:
|
|
101
|
+
log.debug("verify: signature INVALID (key_id=%s)", key_id(pub_b64))
|
|
91
102
|
return False
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for the logging verbosity setup."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from abom import log as logmod
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_levels():
|
|
8
|
+
logmod.setup(verbose=0)
|
|
9
|
+
assert logging.getLogger("abom").level == logging.WARNING
|
|
10
|
+
logmod.setup(verbose=1)
|
|
11
|
+
assert logging.getLogger("abom").level == logging.INFO
|
|
12
|
+
logmod.setup(verbose=2)
|
|
13
|
+
assert logging.getLogger("abom").level == logging.DEBUG
|
|
14
|
+
logmod.setup(quiet=True)
|
|
15
|
+
assert logging.getLogger("abom").level == logging.ERROR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_handler_goes_to_stderr():
|
|
19
|
+
logmod.setup(verbose=1)
|
|
20
|
+
handlers = logging.getLogger("abom").handlers
|
|
21
|
+
assert len(handlers) == 1
|
|
22
|
+
import sys
|
|
23
|
+
assert handlers[0].stream is sys.stderr
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_json_logs_are_parseable(capsys):
|
|
27
|
+
import json
|
|
28
|
+
logmod.setup(verbose=1, json_logs=True)
|
|
29
|
+
logging.getLogger("abom.t").info("hi there", extra={"event": "x", "n": 3})
|
|
30
|
+
line = capsys.readouterr().err.strip().splitlines()[-1]
|
|
31
|
+
rec = json.loads(line) # valid JSON per line
|
|
32
|
+
assert rec["level"] == "INFO"
|
|
33
|
+
assert rec["logger"] == "abom.t"
|
|
34
|
+
assert rec["msg"] == "hi there"
|
|
35
|
+
assert rec["event"] == "x" and rec["n"] == 3
|
|
36
|
+
assert "ts" in rec
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|