recx-cli 0.0.1__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.
- recx_cli/__init__.py +1 -0
- recx_cli/__main__.py +5 -0
- recx_cli/check.py +328 -0
- recx_cli/cli.py +67 -0
- recx_cli/recx.schema.json +469 -0
- recx_cli-0.0.1.dist-info/METADATA +40 -0
- recx_cli-0.0.1.dist-info/RECORD +10 -0
- recx_cli-0.0.1.dist-info/WHEEL +4 -0
- recx_cli-0.0.1.dist-info/entry_points.txt +2 -0
- recx_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
recx_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""recx-cli: the validator for RECX.yaml manifests."""
|
recx_cli/__main__.py
ADDED
recx_cli/check.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Validate a RECX.yaml manifest: the schema plus the rules SPEC.md adds to it.
|
|
2
|
+
|
|
3
|
+
Manifest text is untrusted. It is parsed with a restricted YAML loader, never
|
|
4
|
+
evaluated, and anything echoed back is escaped and truncated.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
20
|
+
|
|
21
|
+
MAX_BYTES = 64 * 1024 # SPEC.md rule 5
|
|
22
|
+
ROOT = "/" # how the whole document is named in an error line
|
|
23
|
+
_SCHEMA_NAME = "recx.schema.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Problem:
|
|
28
|
+
pointer: str
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# -- schema ---------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_schema() -> dict:
|
|
36
|
+
"""The bundled schema; in a source checkout, spec/recx.schema.json itself."""
|
|
37
|
+
try:
|
|
38
|
+
text = (resources.files("recx_cli") / _SCHEMA_NAME).read_text(encoding="utf-8")
|
|
39
|
+
except (FileNotFoundError, OSError):
|
|
40
|
+
# Not built into a wheel (editable install or plain checkout): the one
|
|
41
|
+
# copy under version control sits at <repo>/spec/.
|
|
42
|
+
text = (Path(__file__).resolve().parents[2] / "spec" / _SCHEMA_NAME).read_text(
|
|
43
|
+
encoding="utf-8"
|
|
44
|
+
)
|
|
45
|
+
return json.loads(text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- parsing (rule 6) -----------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ManifestError(Exception):
|
|
52
|
+
def __init__(self, message: str, line: int | None = None):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.line = line
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_MERGE_TAG = "tag:yaml.org,2002:merge"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StrictLoader(yaml.SafeLoader):
|
|
61
|
+
"""SafeLoader that refuses anchors, aliases, merge keys and repeated keys."""
|
|
62
|
+
|
|
63
|
+
def compose_node(self, parent, index):
|
|
64
|
+
if self.check_event(yaml.events.AliasEvent):
|
|
65
|
+
event = self.peek_event()
|
|
66
|
+
raise ManifestError(
|
|
67
|
+
"aliases (*name) are not allowed (SPEC.md rule 6)",
|
|
68
|
+
event.start_mark.line + 1,
|
|
69
|
+
)
|
|
70
|
+
event = self.peek_event()
|
|
71
|
+
if getattr(event, "anchor", None) is not None:
|
|
72
|
+
raise ManifestError(
|
|
73
|
+
"anchors (&name) are not allowed (SPEC.md rule 6)",
|
|
74
|
+
event.start_mark.line + 1,
|
|
75
|
+
)
|
|
76
|
+
return super().compose_node(parent, index)
|
|
77
|
+
|
|
78
|
+
def construct_mapping(self, node, deep=False):
|
|
79
|
+
if isinstance(node, yaml.MappingNode):
|
|
80
|
+
seen: set = set()
|
|
81
|
+
for key_node, _ in node.value:
|
|
82
|
+
line = key_node.start_mark.line + 1
|
|
83
|
+
if key_node.tag == _MERGE_TAG:
|
|
84
|
+
raise ManifestError(
|
|
85
|
+
"merge keys (<<) are not allowed (SPEC.md rule 6)", line
|
|
86
|
+
)
|
|
87
|
+
if isinstance(key_node, yaml.ScalarNode):
|
|
88
|
+
key = self.construct_object(key_node)
|
|
89
|
+
try:
|
|
90
|
+
if key in seen:
|
|
91
|
+
raise ManifestError(
|
|
92
|
+
f"mapping key {_show(key)} is repeated (SPEC.md rule 6)",
|
|
93
|
+
line,
|
|
94
|
+
)
|
|
95
|
+
seen.add(key)
|
|
96
|
+
except TypeError:
|
|
97
|
+
pass
|
|
98
|
+
return super().construct_mapping(node, deep=deep)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse(text: str) -> Any:
|
|
102
|
+
try:
|
|
103
|
+
return yaml.load(text, Loader=StrictLoader) # noqa: S506 - SafeLoader subclass
|
|
104
|
+
except ManifestError:
|
|
105
|
+
raise
|
|
106
|
+
except yaml.YAMLError as exc:
|
|
107
|
+
mark = getattr(exc, "problem_mark", None)
|
|
108
|
+
problem = getattr(exc, "problem", None) or "invalid YAML"
|
|
109
|
+
raise ManifestError(
|
|
110
|
+
f"invalid YAML: {problem}", mark.line + 1 if mark is not None else None
|
|
111
|
+
) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# -- output hygiene -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Control characters, plus the line separators and bidi controls a terminal would
|
|
118
|
+
# otherwise act on: a bidi override can make echoed manifest text read differently
|
|
119
|
+
# from what it is.
|
|
120
|
+
_UNSAFE = re.compile(r"[^\x20-\x7e -\U0010ffff]|[
--]")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _clean(text: str, limit: int = 200) -> str:
|
|
124
|
+
if len(text) > limit:
|
|
125
|
+
text = text[:limit] + "..."
|
|
126
|
+
return _UNSAFE.sub(
|
|
127
|
+
lambda m: "\\x%02x" % ord(m.group()) if ord(m.group()) < 256 else "\\u%04x" % ord(m.group()),
|
|
128
|
+
text,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _show(value: Any) -> str:
|
|
133
|
+
return _clean(repr(value), 80)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _pointer(parts) -> str:
|
|
137
|
+
if not parts:
|
|
138
|
+
return ROOT
|
|
139
|
+
return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# -- schema errors, with quoting hints ------------------------------------
|
|
143
|
+
|
|
144
|
+
_YAML_SCALAR_KINDS = (
|
|
145
|
+
(bool, "a boolean"),
|
|
146
|
+
(int, "an integer"),
|
|
147
|
+
(float, "a number"),
|
|
148
|
+
(datetime.datetime, "a date-time"),
|
|
149
|
+
(datetime.date, "a date"),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _yaml_kind(value: Any) -> str | None:
|
|
154
|
+
for cls, name in _YAML_SCALAR_KINDS:
|
|
155
|
+
if isinstance(value, cls):
|
|
156
|
+
return name
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _expects_string(error) -> bool:
|
|
161
|
+
if error.validator == "type":
|
|
162
|
+
expected = error.validator_value
|
|
163
|
+
return "string" in (expected if isinstance(expected, list) else [expected])
|
|
164
|
+
if error.validator in ("enum", "const"):
|
|
165
|
+
options = error.validator_value if error.validator == "enum" else [error.validator_value]
|
|
166
|
+
return any(isinstance(o, str) for o in options)
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _value_text(value: Any) -> str:
|
|
171
|
+
if isinstance(value, bool):
|
|
172
|
+
return "true" if value else "false"
|
|
173
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
174
|
+
return value.isoformat()
|
|
175
|
+
return str(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def schema_problems(data: Any, schema: dict) -> list[Problem]:
|
|
179
|
+
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
|
180
|
+
errors = sorted(validator.iter_errors(data), key=lambda e: [str(p) for p in e.absolute_path])
|
|
181
|
+
type_error_at = {
|
|
182
|
+
_pointer(e.absolute_path)
|
|
183
|
+
for e in errors
|
|
184
|
+
if e.validator == "type" and _yaml_kind(e.instance) and _expects_string(e)
|
|
185
|
+
}
|
|
186
|
+
problems = []
|
|
187
|
+
for error in errors:
|
|
188
|
+
pointer = _pointer(error.absolute_path)
|
|
189
|
+
kind = _yaml_kind(error.instance)
|
|
190
|
+
if kind and _expects_string(error):
|
|
191
|
+
value = _clean(_value_text(error.instance), 60)
|
|
192
|
+
if error.validator != "type" and pointer in type_error_at:
|
|
193
|
+
continue # the type error says it all
|
|
194
|
+
problems.append(
|
|
195
|
+
Problem(
|
|
196
|
+
pointer,
|
|
197
|
+
f"expected a string, but YAML read {value} as {kind}; "
|
|
198
|
+
f'quote the value (for example "{value}")',
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
continue
|
|
202
|
+
problems.append(Problem(pointer, _clean(error.message)))
|
|
203
|
+
return problems
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# -- rules 1, 2 and 3 -----------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _entries(data: Any, key: str) -> list[tuple[int, dict]]:
|
|
210
|
+
items = data.get(key) if isinstance(data, dict) else None
|
|
211
|
+
if not isinstance(items, list):
|
|
212
|
+
return []
|
|
213
|
+
return [(i, item) for i, item in enumerate(items) if isinstance(item, dict)]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def rule_problems(data: Any) -> list[Problem]:
|
|
217
|
+
problems: list[Problem] = []
|
|
218
|
+
modules = _entries(data, "modules")
|
|
219
|
+
evidence = _entries(data, "evidence")
|
|
220
|
+
|
|
221
|
+
module_ids: set[str] = set()
|
|
222
|
+
first_seen: dict[str, str] = {}
|
|
223
|
+
for i, mod in modules:
|
|
224
|
+
mid = mod.get("id")
|
|
225
|
+
if not isinstance(mid, str):
|
|
226
|
+
continue
|
|
227
|
+
where = f"/modules/{i}/id"
|
|
228
|
+
if mid in first_seen:
|
|
229
|
+
problems.append(
|
|
230
|
+
Problem(where, f"duplicate module id {_show(mid)} (rule 1; first used at {first_seen[mid]})")
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
first_seen[mid] = where
|
|
234
|
+
module_ids.add(mid)
|
|
235
|
+
|
|
236
|
+
seen_ev: dict[str, str] = {}
|
|
237
|
+
for i, ev in evidence:
|
|
238
|
+
eid = ev.get("id")
|
|
239
|
+
if not isinstance(eid, str):
|
|
240
|
+
continue
|
|
241
|
+
where = f"/evidence/{i}/id"
|
|
242
|
+
if eid in seen_ev:
|
|
243
|
+
problems.append(
|
|
244
|
+
Problem(where, f"duplicate evidence id {_show(eid)} (rule 1; first used at {seen_ev[eid]})")
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
seen_ev[eid] = where
|
|
248
|
+
|
|
249
|
+
def check_refs(base: str, refs: Any, what: str) -> None:
|
|
250
|
+
if not isinstance(refs, list):
|
|
251
|
+
return
|
|
252
|
+
for j, ref in enumerate(refs):
|
|
253
|
+
if isinstance(ref, str) and ref not in module_ids:
|
|
254
|
+
problems.append(
|
|
255
|
+
Problem(f"{base}/{j}", f"{what} {_show(ref)} names no module in this manifest (rule 2)")
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for i, mod in modules:
|
|
259
|
+
requires = mod.get("requires")
|
|
260
|
+
if isinstance(requires, dict):
|
|
261
|
+
check_refs(f"/modules/{i}/requires/modules", requires.get("modules"), "required module")
|
|
262
|
+
for i, ev in evidence:
|
|
263
|
+
check_refs(f"/evidence/{i}/covers", ev.get("covers"), "covered module")
|
|
264
|
+
return problems
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def path_problems(data: Any, base: Path, skip: set[str]) -> list[Problem]:
|
|
268
|
+
"""Rule 3. Paths the schema already rejected are never touched on disk."""
|
|
269
|
+
problems = []
|
|
270
|
+
for i, mod in _entries(data, "modules"):
|
|
271
|
+
path = mod.get("path")
|
|
272
|
+
pointer = f"/modules/{i}/path"
|
|
273
|
+
if not isinstance(path, str) or pointer in skip:
|
|
274
|
+
continue
|
|
275
|
+
target = base / path
|
|
276
|
+
try:
|
|
277
|
+
exists = target.exists()
|
|
278
|
+
is_dir = target.is_dir()
|
|
279
|
+
except OSError as exc:
|
|
280
|
+
problems.append(Problem(pointer, f"cannot inspect path: {_clean(exc.strerror or 'error')} (rule 3)"))
|
|
281
|
+
continue
|
|
282
|
+
shown = _show(path)
|
|
283
|
+
if not exists:
|
|
284
|
+
problems.append(Problem(pointer, f"path {shown} does not exist relative to the manifest (rule 3)"))
|
|
285
|
+
elif path.endswith("/") and not is_dir:
|
|
286
|
+
problems.append(Problem(pointer, f"path {shown} ends in / but is a file (rule 3)"))
|
|
287
|
+
elif not path.endswith("/") and is_dir:
|
|
288
|
+
problems.append(Problem(pointer, f"path {shown} is a directory, so it must end in / (rule 3)"))
|
|
289
|
+
return problems
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# -- entry point ----------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Unreadable(Exception):
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def check_file(path: Path, *, repo: bool = True) -> list[Problem]:
|
|
300
|
+
"""Return every problem with the manifest at `path`; raise Unreadable if it cannot be read."""
|
|
301
|
+
try:
|
|
302
|
+
size = path.stat().st_size
|
|
303
|
+
if not path.is_file():
|
|
304
|
+
raise Unreadable("not a regular file")
|
|
305
|
+
if size > MAX_BYTES:
|
|
306
|
+
return [Problem(ROOT, f"file is {size} bytes, over the 64 KiB limit (rule 5)")]
|
|
307
|
+
raw = path.read_bytes()
|
|
308
|
+
except OSError as exc:
|
|
309
|
+
raise Unreadable(_clean(exc.strerror or str(exc))) from None
|
|
310
|
+
if len(raw) > MAX_BYTES:
|
|
311
|
+
return [Problem(ROOT, f"file is {len(raw)} bytes, over the 64 KiB limit (rule 5)")]
|
|
312
|
+
try:
|
|
313
|
+
text = raw.decode("utf-8")
|
|
314
|
+
except UnicodeDecodeError:
|
|
315
|
+
return [Problem(ROOT, "file is not valid UTF-8")]
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
data = parse(text)
|
|
319
|
+
except ManifestError as exc:
|
|
320
|
+
where = f"line {exc.line}: " if exc.line else ""
|
|
321
|
+
return [Problem(ROOT, f"{where}{_clean(str(exc))}")]
|
|
322
|
+
|
|
323
|
+
problems = schema_problems(data, load_schema())
|
|
324
|
+
problems += rule_problems(data)
|
|
325
|
+
if repo:
|
|
326
|
+
skip = {p.pointer for p in problems}
|
|
327
|
+
problems += path_problems(data, path.resolve().parent, skip)
|
|
328
|
+
return problems
|
recx_cli/cli.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Command line: `recx check [PATH] [--no-repo]`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from recx_cli.check import Unreadable, check_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version() -> str:
|
|
14
|
+
try:
|
|
15
|
+
return metadata.version("recx-cli")
|
|
16
|
+
except metadata.PackageNotFoundError:
|
|
17
|
+
return "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Parser(argparse.ArgumentParser):
|
|
21
|
+
def error(self, message): # argparse exits 2 already; keep it explicit
|
|
22
|
+
self.print_usage(sys.stderr)
|
|
23
|
+
self.exit(2, f"{self.prog}: error: {message}\n")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = _Parser(prog="recx", description="Tools for RECX.yaml manifests.")
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"recx {_version()}")
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
|
|
30
|
+
check = sub.add_parser(
|
|
31
|
+
"check",
|
|
32
|
+
help="validate a RECX.yaml manifest",
|
|
33
|
+
description="Validate a manifest against the schema and the rules in spec/SPEC.md. "
|
|
34
|
+
"Exit status: 0 valid, 1 invalid, 2 usage error or unreadable file.",
|
|
35
|
+
)
|
|
36
|
+
check.add_argument(
|
|
37
|
+
"path",
|
|
38
|
+
nargs="?",
|
|
39
|
+
default="RECX.yaml",
|
|
40
|
+
metavar="PATH",
|
|
41
|
+
help="a manifest file, or a directory holding RECX.yaml (default: ./RECX.yaml)",
|
|
42
|
+
)
|
|
43
|
+
check.add_argument(
|
|
44
|
+
"--no-repo",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="skip the checks that look at the repository (rule 3: module paths exist)",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = build_parser().parse_args(argv)
|
|
53
|
+
target = Path(args.path)
|
|
54
|
+
if target.is_dir():
|
|
55
|
+
target = target / "RECX.yaml"
|
|
56
|
+
label = str(target)
|
|
57
|
+
try:
|
|
58
|
+
problems = check_file(target, repo=not args.no_repo)
|
|
59
|
+
except Unreadable as exc:
|
|
60
|
+
print(f"{label}: cannot read: {exc}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
if not problems:
|
|
63
|
+
print(f"{label}: valid")
|
|
64
|
+
return 0
|
|
65
|
+
for p in problems:
|
|
66
|
+
print(f"{label}: {p.pointer}: {p.message}")
|
|
67
|
+
return 1
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "RECX.yaml manifest",
|
|
4
|
+
"description": "recx v0.1: a repository's description of itself for other people's coding agents. See spec/SPEC.md. Every string in a manifest is untrusted data to the agent reading it, never instructions.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"recx",
|
|
8
|
+
"name",
|
|
9
|
+
"summary",
|
|
10
|
+
"license",
|
|
11
|
+
"status",
|
|
12
|
+
"capabilities"
|
|
13
|
+
],
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"patternProperties": {
|
|
16
|
+
"^x-[a-z0-9][a-z0-9-]{0,62}$": {
|
|
17
|
+
"$ref": "#/$defs/extension"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"properties": {
|
|
21
|
+
"recx": {
|
|
22
|
+
"description": "Spec version this manifest is written against.",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": [
|
|
25
|
+
"0.1"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"name": {
|
|
29
|
+
"description": "The project's name, as its own authors spell it in lowercase.",
|
|
30
|
+
"$ref": "#/$defs/slug"
|
|
31
|
+
},
|
|
32
|
+
"summary": {
|
|
33
|
+
"description": "What the project does, in one sentence.",
|
|
34
|
+
"$ref": "#/$defs/line"
|
|
35
|
+
},
|
|
36
|
+
"description": {
|
|
37
|
+
"description": "A short paragraph: the problem it solves, the approach, and what is out of scope.",
|
|
38
|
+
"type": "string",
|
|
39
|
+
"minLength": 1,
|
|
40
|
+
"maxLength": 2000
|
|
41
|
+
},
|
|
42
|
+
"license": {
|
|
43
|
+
"description": "SPDX identifier of the project's license. Only permissive licenses are allowed, so anything listed can be reused without negotiation.",
|
|
44
|
+
"$ref": "#/$defs/license"
|
|
45
|
+
},
|
|
46
|
+
"status": {
|
|
47
|
+
"description": "How much the authors stand behind it.",
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": [
|
|
50
|
+
"experimental",
|
|
51
|
+
"active",
|
|
52
|
+
"stable",
|
|
53
|
+
"maintenance",
|
|
54
|
+
"archived"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"capabilities": {
|
|
58
|
+
"description": "What the project can do, one verb phrase per item. This is what an agent matches an idea against.",
|
|
59
|
+
"type": "array",
|
|
60
|
+
"minItems": 1,
|
|
61
|
+
"maxItems": 30,
|
|
62
|
+
"uniqueItems": true,
|
|
63
|
+
"items": {
|
|
64
|
+
"$ref": "#/$defs/phrase"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"languages": {
|
|
68
|
+
"description": "Programming languages the project is written in, lowercase.",
|
|
69
|
+
"type": "array",
|
|
70
|
+
"maxItems": 10,
|
|
71
|
+
"uniqueItems": true,
|
|
72
|
+
"items": {
|
|
73
|
+
"$ref": "#/$defs/tag"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"tags": {
|
|
77
|
+
"description": "Lowercase kebab-case topic tags.",
|
|
78
|
+
"type": "array",
|
|
79
|
+
"maxItems": 20,
|
|
80
|
+
"uniqueItems": true,
|
|
81
|
+
"items": {
|
|
82
|
+
"$ref": "#/$defs/tag"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"repository": {
|
|
86
|
+
"description": "Canonical URL of the source repository. Optional: an agent that found this file already knows where it is.",
|
|
87
|
+
"$ref": "#/$defs/url"
|
|
88
|
+
},
|
|
89
|
+
"homepage": {
|
|
90
|
+
"description": "Project homepage or documentation URL.",
|
|
91
|
+
"$ref": "#/$defs/url"
|
|
92
|
+
},
|
|
93
|
+
"generator": {
|
|
94
|
+
"description": "The tool that wrote this manifest, with its version, when a tool wrote it.",
|
|
95
|
+
"type": "string",
|
|
96
|
+
"minLength": 1,
|
|
97
|
+
"maxLength": 100
|
|
98
|
+
},
|
|
99
|
+
"modules": {
|
|
100
|
+
"description": "The reusable pieces of the project, each one something another project could take on its own.",
|
|
101
|
+
"type": "array",
|
|
102
|
+
"maxItems": 100,
|
|
103
|
+
"items": {
|
|
104
|
+
"$ref": "#/$defs/module"
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"evidence": {
|
|
108
|
+
"description": "Checks anyone can rerun to see whether the project works.",
|
|
109
|
+
"type": "array",
|
|
110
|
+
"maxItems": 50,
|
|
111
|
+
"items": {
|
|
112
|
+
"$ref": "#/$defs/evidence"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"reuses": {
|
|
116
|
+
"description": "Work from other repositories this project took and built on. Read across many manifests, these entries are the reuse graph.",
|
|
117
|
+
"type": "array",
|
|
118
|
+
"maxItems": 100,
|
|
119
|
+
"items": {
|
|
120
|
+
"$ref": "#/$defs/reuse"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"$defs": {
|
|
125
|
+
"slug": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"maxLength": 64,
|
|
128
|
+
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
|
|
129
|
+
},
|
|
130
|
+
"id": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"maxLength": 64,
|
|
133
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
|
|
134
|
+
},
|
|
135
|
+
"tag": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"maxLength": 40,
|
|
138
|
+
"pattern": "^[a-z0-9][a-z0-9+#.-]{0,39}$"
|
|
139
|
+
},
|
|
140
|
+
"line": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"minLength": 1,
|
|
143
|
+
"maxLength": 160,
|
|
144
|
+
"pattern": "^[^\\n\\r]*$"
|
|
145
|
+
},
|
|
146
|
+
"phrase": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"minLength": 1,
|
|
149
|
+
"maxLength": 200,
|
|
150
|
+
"pattern": "^[^\\n\\r]*$"
|
|
151
|
+
},
|
|
152
|
+
"command": {
|
|
153
|
+
"description": "One shell command line, run from the repository root.",
|
|
154
|
+
"type": "string",
|
|
155
|
+
"minLength": 1,
|
|
156
|
+
"maxLength": 300,
|
|
157
|
+
"pattern": "^[^\\n\\r]*$"
|
|
158
|
+
},
|
|
159
|
+
"url": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"format": "uri",
|
|
162
|
+
"maxLength": 300,
|
|
163
|
+
"pattern": "^https://"
|
|
164
|
+
},
|
|
165
|
+
"relpath": {
|
|
166
|
+
"description": "A path relative to the repository root: no leading slash, no '..' segment, no spaces. A trailing slash names a directory.",
|
|
167
|
+
"type": "string",
|
|
168
|
+
"minLength": 1,
|
|
169
|
+
"maxLength": 255,
|
|
170
|
+
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._@+/-]+$"
|
|
171
|
+
},
|
|
172
|
+
"purl": {
|
|
173
|
+
"description": "A Package URL (https://github.com/package-url/purl-spec), e.g. pkg:pypi/jsonschema or pkg:npm/%40scope/name@1.2.0.",
|
|
174
|
+
"type": "string",
|
|
175
|
+
"maxLength": 200,
|
|
176
|
+
"pattern": "^pkg:[a-z0-9.+-]+/[^\\s]+$"
|
|
177
|
+
},
|
|
178
|
+
"sha": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"maxLength": 40,
|
|
181
|
+
"pattern": "^[0-9a-f]{7,40}$"
|
|
182
|
+
},
|
|
183
|
+
"extension": {
|
|
184
|
+
"description": "The value of an x- extension key: a short scalar. Consumers ignore keys they do not know.",
|
|
185
|
+
"type": [
|
|
186
|
+
"string",
|
|
187
|
+
"number",
|
|
188
|
+
"boolean"
|
|
189
|
+
],
|
|
190
|
+
"maxLength": 500
|
|
191
|
+
},
|
|
192
|
+
"license": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"enum": [
|
|
195
|
+
"0BSD",
|
|
196
|
+
"Apache-2.0",
|
|
197
|
+
"BlueOak-1.0.0",
|
|
198
|
+
"BSD-2-Clause",
|
|
199
|
+
"BSD-3-Clause",
|
|
200
|
+
"BSL-1.0",
|
|
201
|
+
"CC0-1.0",
|
|
202
|
+
"ISC",
|
|
203
|
+
"MIT",
|
|
204
|
+
"MIT-0",
|
|
205
|
+
"UPL-1.0",
|
|
206
|
+
"Unlicense",
|
|
207
|
+
"Zlib"
|
|
208
|
+
]
|
|
209
|
+
},
|
|
210
|
+
"module": {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"required": [
|
|
213
|
+
"id",
|
|
214
|
+
"path",
|
|
215
|
+
"summary",
|
|
216
|
+
"interface",
|
|
217
|
+
"extract"
|
|
218
|
+
],
|
|
219
|
+
"additionalProperties": false,
|
|
220
|
+
"patternProperties": {
|
|
221
|
+
"^x-[a-z0-9][a-z0-9-]{0,62}$": {
|
|
222
|
+
"$ref": "#/$defs/extension"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
"properties": {
|
|
226
|
+
"id": {
|
|
227
|
+
"description": "Unique within this manifest.",
|
|
228
|
+
"$ref": "#/$defs/id"
|
|
229
|
+
},
|
|
230
|
+
"path": {
|
|
231
|
+
"description": "Where the module lives: a file, or a directory with a trailing slash.",
|
|
232
|
+
"$ref": "#/$defs/relpath"
|
|
233
|
+
},
|
|
234
|
+
"summary": {
|
|
235
|
+
"description": "What the module does, in one sentence.",
|
|
236
|
+
"$ref": "#/$defs/line"
|
|
237
|
+
},
|
|
238
|
+
"capabilities": {
|
|
239
|
+
"description": "What this module alone can do, when that is narrower than the project's list.",
|
|
240
|
+
"type": "array",
|
|
241
|
+
"maxItems": 20,
|
|
242
|
+
"uniqueItems": true,
|
|
243
|
+
"items": {
|
|
244
|
+
"$ref": "#/$defs/phrase"
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"language": {
|
|
248
|
+
"$ref": "#/$defs/tag"
|
|
249
|
+
},
|
|
250
|
+
"interface": {
|
|
251
|
+
"description": "How the module is called.",
|
|
252
|
+
"type": "object",
|
|
253
|
+
"required": [
|
|
254
|
+
"type",
|
|
255
|
+
"entrypoints"
|
|
256
|
+
],
|
|
257
|
+
"additionalProperties": false,
|
|
258
|
+
"properties": {
|
|
259
|
+
"type": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"enum": [
|
|
262
|
+
"library",
|
|
263
|
+
"cli",
|
|
264
|
+
"http-api",
|
|
265
|
+
"mcp-server",
|
|
266
|
+
"github-action",
|
|
267
|
+
"claude-code-plugin",
|
|
268
|
+
"agent-skill",
|
|
269
|
+
"schema",
|
|
270
|
+
"config",
|
|
271
|
+
"other"
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
"entrypoints": {
|
|
275
|
+
"description": "Signatures, commands or endpoints, one per item, as a caller would write them.",
|
|
276
|
+
"type": "array",
|
|
277
|
+
"minItems": 1,
|
|
278
|
+
"maxItems": 30,
|
|
279
|
+
"items": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"minLength": 1,
|
|
282
|
+
"maxLength": 300,
|
|
283
|
+
"pattern": "^[^\\n\\r]*$"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
"requires": {
|
|
289
|
+
"description": "What the module needs besides itself.",
|
|
290
|
+
"type": "object",
|
|
291
|
+
"additionalProperties": false,
|
|
292
|
+
"properties": {
|
|
293
|
+
"packages": {
|
|
294
|
+
"description": "Third-party packages, as Package URLs.",
|
|
295
|
+
"type": "array",
|
|
296
|
+
"maxItems": 50,
|
|
297
|
+
"uniqueItems": true,
|
|
298
|
+
"items": {
|
|
299
|
+
"$ref": "#/$defs/purl"
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
"modules": {
|
|
303
|
+
"description": "Ids of other modules in this manifest.",
|
|
304
|
+
"type": "array",
|
|
305
|
+
"maxItems": 50,
|
|
306
|
+
"uniqueItems": true,
|
|
307
|
+
"items": {
|
|
308
|
+
"$ref": "#/$defs/id"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
"extract": {
|
|
314
|
+
"description": "How another project takes this module.",
|
|
315
|
+
"type": "object",
|
|
316
|
+
"required": [
|
|
317
|
+
"method"
|
|
318
|
+
],
|
|
319
|
+
"additionalProperties": false,
|
|
320
|
+
"properties": {
|
|
321
|
+
"method": {
|
|
322
|
+
"description": "copy: copy the path into your project. package: install the published package. fork: the module is not separable; fork the repository.",
|
|
323
|
+
"type": "string",
|
|
324
|
+
"enum": [
|
|
325
|
+
"copy",
|
|
326
|
+
"package",
|
|
327
|
+
"fork"
|
|
328
|
+
]
|
|
329
|
+
},
|
|
330
|
+
"package": {
|
|
331
|
+
"description": "The published package, when method is package.",
|
|
332
|
+
"$ref": "#/$defs/purl"
|
|
333
|
+
},
|
|
334
|
+
"notes": {
|
|
335
|
+
"description": "Anything a copier must change or know, such as a config value or an import path to rewrite.",
|
|
336
|
+
"type": "string",
|
|
337
|
+
"minLength": 1,
|
|
338
|
+
"maxLength": 500
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
"if": {
|
|
342
|
+
"properties": {
|
|
343
|
+
"method": {
|
|
344
|
+
"const": "package"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
"then": {
|
|
349
|
+
"required": [
|
|
350
|
+
"package"
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
"evidence": {
|
|
357
|
+
"type": "object",
|
|
358
|
+
"required": [
|
|
359
|
+
"id",
|
|
360
|
+
"run",
|
|
361
|
+
"covers"
|
|
362
|
+
],
|
|
363
|
+
"additionalProperties": false,
|
|
364
|
+
"patternProperties": {
|
|
365
|
+
"^x-[a-z0-9][a-z0-9-]{0,62}$": {
|
|
366
|
+
"$ref": "#/$defs/extension"
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
"properties": {
|
|
370
|
+
"id": {
|
|
371
|
+
"description": "Unique within this manifest.",
|
|
372
|
+
"$ref": "#/$defs/id"
|
|
373
|
+
},
|
|
374
|
+
"summary": {
|
|
375
|
+
"description": "What the check checks, in one sentence.",
|
|
376
|
+
"$ref": "#/$defs/line"
|
|
377
|
+
},
|
|
378
|
+
"setup": {
|
|
379
|
+
"description": "The command that prepares a fresh clone for run, if one is needed.",
|
|
380
|
+
"$ref": "#/$defs/command"
|
|
381
|
+
},
|
|
382
|
+
"run": {
|
|
383
|
+
"description": "The command that performs the check. Exit status 0 means pass.",
|
|
384
|
+
"$ref": "#/$defs/command"
|
|
385
|
+
},
|
|
386
|
+
"covers": {
|
|
387
|
+
"description": "Ids of the modules this check exercises. An empty list means the project as a whole.",
|
|
388
|
+
"type": "array",
|
|
389
|
+
"maxItems": 100,
|
|
390
|
+
"uniqueItems": true,
|
|
391
|
+
"items": {
|
|
392
|
+
"$ref": "#/$defs/id"
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
"last_result": {
|
|
396
|
+
"description": "The most recent run. Self-reported unless a consumer reruns it; see SPEC.md, Trust.",
|
|
397
|
+
"type": "object",
|
|
398
|
+
"required": [
|
|
399
|
+
"status",
|
|
400
|
+
"commit",
|
|
401
|
+
"date",
|
|
402
|
+
"runner"
|
|
403
|
+
],
|
|
404
|
+
"additionalProperties": false,
|
|
405
|
+
"properties": {
|
|
406
|
+
"status": {
|
|
407
|
+
"type": "string",
|
|
408
|
+
"enum": [
|
|
409
|
+
"pass",
|
|
410
|
+
"fail"
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
"commit": {
|
|
414
|
+
"description": "The commit the run checked.",
|
|
415
|
+
"$ref": "#/$defs/sha"
|
|
416
|
+
},
|
|
417
|
+
"date": {
|
|
418
|
+
"description": "When the run finished, RFC 3339.",
|
|
419
|
+
"type": "string",
|
|
420
|
+
"format": "date-time",
|
|
421
|
+
"maxLength": 40
|
|
422
|
+
},
|
|
423
|
+
"runner": {
|
|
424
|
+
"description": "ci: a CI service ran it; local: someone's own machine did.",
|
|
425
|
+
"type": "string",
|
|
426
|
+
"enum": [
|
|
427
|
+
"ci",
|
|
428
|
+
"local"
|
|
429
|
+
]
|
|
430
|
+
},
|
|
431
|
+
"url": {
|
|
432
|
+
"description": "Where the run's log can be read, such as a CI job page.",
|
|
433
|
+
"$ref": "#/$defs/url"
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
"reuse": {
|
|
440
|
+
"type": "object",
|
|
441
|
+
"required": [
|
|
442
|
+
"repository"
|
|
443
|
+
],
|
|
444
|
+
"additionalProperties": false,
|
|
445
|
+
"properties": {
|
|
446
|
+
"repository": {
|
|
447
|
+
"description": "The repository the work came from.",
|
|
448
|
+
"$ref": "#/$defs/url"
|
|
449
|
+
},
|
|
450
|
+
"module": {
|
|
451
|
+
"description": "The id of the module taken, when the source carries a RECX.yaml.",
|
|
452
|
+
"$ref": "#/$defs/id"
|
|
453
|
+
},
|
|
454
|
+
"path": {
|
|
455
|
+
"description": "The path taken, in the source repository.",
|
|
456
|
+
"$ref": "#/$defs/relpath"
|
|
457
|
+
},
|
|
458
|
+
"commit": {
|
|
459
|
+
"description": "The source commit it was taken at.",
|
|
460
|
+
"$ref": "#/$defs/sha"
|
|
461
|
+
},
|
|
462
|
+
"into": {
|
|
463
|
+
"description": "Where it lives in this repository now.",
|
|
464
|
+
"$ref": "#/$defs/relpath"
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: recx-cli
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Validator for RECX.yaml manifests: the recx check command.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jbmv/recx
|
|
6
|
+
Project-URL: Repository, https://github.com/jbmv/recx
|
|
7
|
+
Author: James Bullard
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: jsonschema[format-nongpl]>=4.18
|
|
12
|
+
Requires-Dist: pyyaml>=6
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# recx
|
|
16
|
+
|
|
17
|
+
*res extra commercium*: things outside commerce. A repository drops a `RECX.yaml` at its
|
|
18
|
+
root to tell other people's coding agents what it does, which of its modules can be taken on
|
|
19
|
+
their own, how to take them, and which checks anyone can rerun to see that they work.
|
|
20
|
+
|
|
21
|
+
The format is specified in [spec/SPEC.md](spec/SPEC.md); the schema is
|
|
22
|
+
[spec/recx.schema.json](spec/recx.schema.json).
|
|
23
|
+
|
|
24
|
+
## Checking a manifest
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install recx-cli # or: uvx --from recx-cli recx check
|
|
28
|
+
|
|
29
|
+
recx check # ./RECX.yaml, including that module paths exist
|
|
30
|
+
recx check path/to/repo # a directory holding RECX.yaml
|
|
31
|
+
recx check --no-repo FILE # the file alone, without looking at the repository
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. Exit status 0
|
|
35
|
+
means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
|
|
36
|
+
check that the license file agrees with `license` (rule 4 of the spec).
|
|
37
|
+
|
|
38
|
+
The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
|
|
39
|
+
|
|
40
|
+
This README is an interim one. The full README is still to come.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
recx_cli/__init__.py,sha256=xOr2-4_763vaMHuUpNycY6C9RFRGMZZq9KqMRJlcTVE,55
|
|
2
|
+
recx_cli/__main__.py,sha256=u_9wZdHeDK_XOOL7DuoLe5ENEYCyYn6R51mEYRdRkpI,60
|
|
3
|
+
recx_cli/check.py,sha256=892HT5pGw1WIMzff0kRRT3jWgBli06Jlh49CvKQSEI8,11315
|
|
4
|
+
recx_cli/cli.py,sha256=rGNJ2m9f6bXWDcje5tMcIVuLx1wGzYH1fr120raoKHs,2089
|
|
5
|
+
recx_cli/recx.schema.json,sha256=ig44nYMPpHlnBHA5nsCCcZsTjsCtwhzDtsu62zesVfU,13366
|
|
6
|
+
recx_cli-0.0.1.dist-info/METADATA,sha256=X6vayuHEID_6Z6_eLy9Bctef6SZOo8hyKkdrXcQIMPw,1558
|
|
7
|
+
recx_cli-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
recx_cli-0.0.1.dist-info/entry_points.txt,sha256=2gqAsQLrd8vlNRF7jkzHPbM5RGZA1rFwA9u_IDwwzdA,43
|
|
9
|
+
recx_cli-0.0.1.dist-info/licenses/LICENSE,sha256=bVBvwsmFseFZxdXisG_Aib8rcHlEwzNreODYnMEpKr8,1070
|
|
10
|
+
recx_cli-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Bullard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|