lx-tooling 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.
- lx_tooling/__init__.py +3 -0
- lx_tooling/checks/__init__.py +0 -0
- lx_tooling/checks/agents.py +15 -0
- lx_tooling/checks/docs.py +15 -0
- lx_tooling/checks/workflow.py +74 -0
- lx_tooling/cli.py +185 -0
- lx_tooling/git.py +80 -0
- lx_tooling/github.py +50 -0
- lx_tooling/policy.py +60 -0
- lx_tooling/repo.py +186 -0
- lx_tooling/status.py +35 -0
- lx_tooling/templates.py +31 -0
- lx_tooling-0.1.0.dist-info/METADATA +115 -0
- lx_tooling-0.1.0.dist-info/RECORD +17 -0
- lx_tooling-0.1.0.dist-info/WHEEL +4 -0
- lx_tooling-0.1.0.dist-info/entry_points.txt +2 -0
- lx_tooling-0.1.0.dist-info/licenses/LICENSE +21 -0
lx_tooling/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""AGENTS.md presence and content checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lx_tooling.status import StatusRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_agents_file(repo_root: Path) -> list[StatusRecord]:
|
|
11
|
+
"""Check that AGENTS.md exists."""
|
|
12
|
+
agents_path = repo_root / "AGENTS.md"
|
|
13
|
+
if agents_path.is_file():
|
|
14
|
+
return [StatusRecord("OK", "workflow.agents", "AGENTS.md present")]
|
|
15
|
+
return [StatusRecord("WARN", "workflow.agents", "AGENTS.md missing")]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Documentation presence checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lx_tooling.status import StatusRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_readme_file(repo_root: Path) -> list[StatusRecord]:
|
|
11
|
+
"""Check that README.md exists."""
|
|
12
|
+
readme_path = repo_root / "README.md"
|
|
13
|
+
if readme_path.is_file():
|
|
14
|
+
return [StatusRecord("OK", "workflow.readme", "README.md present")]
|
|
15
|
+
return [StatusRecord("WARN", "workflow.readme", "README.md missing")]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Pre-PR workflow checks (pure logic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lx_tooling.checks.agents import check_agents_file
|
|
8
|
+
from lx_tooling.checks.docs import check_readme_file
|
|
9
|
+
from lx_tooling.policy import is_valid_branch_name
|
|
10
|
+
from lx_tooling.repo import RepoMetadata, discover_local_check_command
|
|
11
|
+
from lx_tooling.status import StatusRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_branch_name(branch: str, *, default_branch: str = "main") -> StatusRecord:
|
|
15
|
+
"""Validate the current branch name."""
|
|
16
|
+
valid, detail = is_valid_branch_name(branch, default_branch=default_branch)
|
|
17
|
+
if valid:
|
|
18
|
+
return StatusRecord("OK", "workflow.branch", detail)
|
|
19
|
+
if branch == default_branch:
|
|
20
|
+
return StatusRecord("ERROR", "workflow.branch", detail)
|
|
21
|
+
return StatusRecord("WARN", "workflow.branch", detail)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_local_check_documented(repo_root: Path, metadata: RepoMetadata) -> StatusRecord:
|
|
25
|
+
"""Warn when metadata defines a local check but no runner is discoverable."""
|
|
26
|
+
command = discover_local_check_command(repo_root, metadata)
|
|
27
|
+
if metadata.local_check and command:
|
|
28
|
+
return StatusRecord("OK", "workflow.local_check", command)
|
|
29
|
+
if metadata.local_check:
|
|
30
|
+
return StatusRecord(
|
|
31
|
+
"WARN",
|
|
32
|
+
"workflow.local_check",
|
|
33
|
+
f"configured as {metadata.local_check!r} but not discoverable",
|
|
34
|
+
)
|
|
35
|
+
if command:
|
|
36
|
+
return StatusRecord("OK", "workflow.local_check", command)
|
|
37
|
+
return StatusRecord("WARN", "workflow.local_check", "not configured")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_changed_paths(changed_files: list[str]) -> list[StatusRecord]:
|
|
41
|
+
"""Warn when source changed without obvious test updates."""
|
|
42
|
+
records: list[StatusRecord] = []
|
|
43
|
+
src_changed = any(path.startswith("src/") for path in changed_files)
|
|
44
|
+
tests_changed = any(path.startswith("tests/") for path in changed_files)
|
|
45
|
+
if src_changed and not tests_changed:
|
|
46
|
+
records.append(
|
|
47
|
+
StatusRecord(
|
|
48
|
+
"WARN",
|
|
49
|
+
"workflow.tests",
|
|
50
|
+
"src/ changed without tests/ changes",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
elif changed_files:
|
|
54
|
+
records.append(StatusRecord("OK", "workflow.tests", "changed paths reviewed"))
|
|
55
|
+
else:
|
|
56
|
+
records.append(StatusRecord("SKIP", "workflow.tests", "no changed files detected"))
|
|
57
|
+
return records
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_workflow_checks(
|
|
61
|
+
*,
|
|
62
|
+
repo_root: Path,
|
|
63
|
+
branch: str,
|
|
64
|
+
metadata: RepoMetadata,
|
|
65
|
+
changed_files: list[str],
|
|
66
|
+
) -> list[StatusRecord]:
|
|
67
|
+
"""Run conservative pre-PR workflow checks."""
|
|
68
|
+
records: list[StatusRecord] = []
|
|
69
|
+
records.append(check_branch_name(branch, default_branch=metadata.pr_base))
|
|
70
|
+
records.extend(check_agents_file(repo_root))
|
|
71
|
+
records.extend(check_readme_file(repo_root))
|
|
72
|
+
records.append(check_local_check_documented(repo_root, metadata))
|
|
73
|
+
records.extend(check_changed_paths(changed_files))
|
|
74
|
+
return records
|
lx_tooling/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Typer CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from lx_tooling import __version__
|
|
11
|
+
from lx_tooling import git as git_adapter
|
|
12
|
+
from lx_tooling import github as github_adapter
|
|
13
|
+
from lx_tooling.checks.workflow import run_workflow_checks
|
|
14
|
+
from lx_tooling.policy import (
|
|
15
|
+
DEFAULT_READY_LABELS,
|
|
16
|
+
issue_body_has_scope,
|
|
17
|
+
issue_has_ready_label,
|
|
18
|
+
)
|
|
19
|
+
from lx_tooling.repo import inspect_repository, load_repo_metadata, report_to_dict
|
|
20
|
+
from lx_tooling.status import StatusRecord, exit_code_for_records, format_line, records_to_dicts
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="lx",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
help="Labinetix workflow CLI for humans and AI agents.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
repo_app = typer.Typer(help="Inspect repository metadata and layout.")
|
|
30
|
+
workflow_app = typer.Typer(help="Run local workflow checks before opening a PR.")
|
|
31
|
+
issue_app = typer.Typer(help="Read GitHub issue information.")
|
|
32
|
+
|
|
33
|
+
app.add_typer(repo_app, name="repo")
|
|
34
|
+
app.add_typer(workflow_app, name="workflow")
|
|
35
|
+
app.add_typer(issue_app, name="issue")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _version_callback(value: bool) -> None:
|
|
39
|
+
if value:
|
|
40
|
+
typer.echo(f"lx {__version__}")
|
|
41
|
+
raise typer.Exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_records(records: list, *, as_json: bool) -> None:
|
|
45
|
+
if as_json:
|
|
46
|
+
typer.echo(json.dumps(records_to_dicts(records), indent=2))
|
|
47
|
+
return
|
|
48
|
+
for record in records:
|
|
49
|
+
typer.echo(format_line(record))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback()
|
|
53
|
+
def main(
|
|
54
|
+
version: Annotated[
|
|
55
|
+
bool | None,
|
|
56
|
+
typer.Option("--version", callback=_version_callback, is_eager=True),
|
|
57
|
+
] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Labinetix workflow CLI."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@repo_app.command("inspect")
|
|
63
|
+
def repo_inspect(
|
|
64
|
+
json_output: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option("--json", help="Emit machine-readable JSON."),
|
|
67
|
+
] = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Inspect the current repository and report workflow-relevant files."""
|
|
70
|
+
try:
|
|
71
|
+
report = inspect_repository()
|
|
72
|
+
except git_adapter.GitError as exc:
|
|
73
|
+
typer.echo(format_line(StatusRecord("ERROR", "repo.root", str(exc))), err=True)
|
|
74
|
+
raise typer.Exit(2) from None
|
|
75
|
+
|
|
76
|
+
if json_output:
|
|
77
|
+
typer.echo(json.dumps(report_to_dict(report), indent=2))
|
|
78
|
+
else:
|
|
79
|
+
for record in report.records:
|
|
80
|
+
typer.echo(format_line(record))
|
|
81
|
+
|
|
82
|
+
raise typer.Exit(exit_code_for_records(report.records))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@workflow_app.command("check")
|
|
86
|
+
def workflow_check(
|
|
87
|
+
json_output: Annotated[
|
|
88
|
+
bool,
|
|
89
|
+
typer.Option("--json", help="Emit machine-readable JSON."),
|
|
90
|
+
] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Run conservative local workflow checks before opening a PR."""
|
|
93
|
+
try:
|
|
94
|
+
root = git_adapter.repo_root()
|
|
95
|
+
branch = git_adapter.current_branch(root)
|
|
96
|
+
changed = git_adapter.changed_files(root)
|
|
97
|
+
except git_adapter.GitError as exc:
|
|
98
|
+
typer.echo(format_line(StatusRecord("ERROR", "workflow.git", str(exc))), err=True)
|
|
99
|
+
raise typer.Exit(2) from None
|
|
100
|
+
|
|
101
|
+
metadata = load_repo_metadata(root)
|
|
102
|
+
records = run_workflow_checks(
|
|
103
|
+
repo_root=root,
|
|
104
|
+
branch=branch,
|
|
105
|
+
metadata=metadata,
|
|
106
|
+
changed_files=changed,
|
|
107
|
+
)
|
|
108
|
+
_print_records(records, as_json=json_output)
|
|
109
|
+
raise typer.Exit(exit_code_for_records(records))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@issue_app.command("view")
|
|
113
|
+
def issue_view(
|
|
114
|
+
number: Annotated[int, typer.Argument(help="GitHub issue number.")],
|
|
115
|
+
json_output: Annotated[
|
|
116
|
+
bool,
|
|
117
|
+
typer.Option("--json", help="Emit machine-readable JSON."),
|
|
118
|
+
] = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Read a GitHub issue and summarize Labinetix readiness hints."""
|
|
121
|
+
if not github_adapter.gh_available():
|
|
122
|
+
typer.echo(
|
|
123
|
+
format_line(
|
|
124
|
+
StatusRecord(
|
|
125
|
+
"ERROR",
|
|
126
|
+
"issue.gh",
|
|
127
|
+
"gh is not installed; install GitHub CLI and run gh auth login",
|
|
128
|
+
)
|
|
129
|
+
),
|
|
130
|
+
err=True,
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(2)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
root = git_adapter.repo_root()
|
|
136
|
+
issue = github_adapter.issue_view_json(number, root)
|
|
137
|
+
except git_adapter.GitError as exc:
|
|
138
|
+
typer.echo(format_line(StatusRecord("ERROR", "issue.git", str(exc))), err=True)
|
|
139
|
+
raise typer.Exit(2) from None
|
|
140
|
+
except github_adapter.GhError as exc:
|
|
141
|
+
typer.echo(format_line(StatusRecord("ERROR", "issue.gh", str(exc))), err=True)
|
|
142
|
+
raise typer.Exit(2) from None
|
|
143
|
+
|
|
144
|
+
metadata = load_repo_metadata(root)
|
|
145
|
+
ready_labels = metadata.ready_labels or DEFAULT_READY_LABELS
|
|
146
|
+
labels = [label["name"] for label in issue.get("labels", [])]
|
|
147
|
+
body = issue.get("body") or ""
|
|
148
|
+
|
|
149
|
+
records: list[StatusRecord] = [
|
|
150
|
+
StatusRecord("OK", "issue.number", str(number)),
|
|
151
|
+
StatusRecord("OK", "issue.state", issue.get("state", "unknown")),
|
|
152
|
+
StatusRecord("OK", "issue.title", issue.get("title", "")),
|
|
153
|
+
StatusRecord("OK", "issue.url", issue.get("url", "")),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
if issue_has_ready_label(labels, ready_labels=ready_labels):
|
|
157
|
+
records.append(StatusRecord("OK", "issue.ready", f"labels: {', '.join(labels)}"))
|
|
158
|
+
else:
|
|
159
|
+
expected = ", ".join(ready_labels)
|
|
160
|
+
records.append(
|
|
161
|
+
StatusRecord(
|
|
162
|
+
"WARN",
|
|
163
|
+
"issue.ready",
|
|
164
|
+
f"missing ready label; expected one of: {expected}",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if issue_body_has_scope(body):
|
|
169
|
+
records.append(StatusRecord("OK", "issue.scope", "scope section detected"))
|
|
170
|
+
else:
|
|
171
|
+
records.append(StatusRecord("WARN", "issue.scope", "no scope section detected in body"))
|
|
172
|
+
|
|
173
|
+
if json_output:
|
|
174
|
+
payload: dict[str, Any] = {
|
|
175
|
+
"issue": issue,
|
|
176
|
+
"records": records_to_dicts(records),
|
|
177
|
+
}
|
|
178
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
179
|
+
else:
|
|
180
|
+
for record in records:
|
|
181
|
+
typer.echo(format_line(record))
|
|
182
|
+
typer.echo("")
|
|
183
|
+
typer.echo(issue.get("body", "").strip())
|
|
184
|
+
|
|
185
|
+
raise typer.Exit(exit_code_for_records(records))
|
lx_tooling/git.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Thin git subprocess adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
"""Raised when a git command fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_git(*args: str, cwd: Path | None = None) -> str:
|
|
14
|
+
"""Run a git command and return stdout."""
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["git", *args],
|
|
17
|
+
cwd=cwd,
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
check=False,
|
|
21
|
+
)
|
|
22
|
+
if result.returncode != 0:
|
|
23
|
+
message = result.stderr.strip() or result.stdout.strip() or "git command failed"
|
|
24
|
+
raise GitError(message)
|
|
25
|
+
return result.stdout.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repo_root(start: Path | None = None) -> Path:
|
|
29
|
+
"""Return the git repository root for the current working tree."""
|
|
30
|
+
cwd = start or Path.cwd()
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
if result.returncode != 0:
|
|
39
|
+
raise GitError("not a git repository")
|
|
40
|
+
return Path(result.stdout.strip())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def current_branch(repo_root_path: Path | None = None) -> str:
|
|
44
|
+
"""Return the current branch name."""
|
|
45
|
+
root = repo_root_path or repo_root()
|
|
46
|
+
return run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=root)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_clean(repo_root_path: Path | None = None) -> bool:
|
|
50
|
+
"""Return whether the working tree has no uncommitted changes."""
|
|
51
|
+
root = repo_root_path or repo_root()
|
|
52
|
+
return run_git("status", "--porcelain", cwd=root) == ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remote_repo_name(repo_root_path: Path | None = None) -> str | None:
|
|
56
|
+
"""Return the repository name from origin, if configured."""
|
|
57
|
+
root = repo_root_path or repo_root()
|
|
58
|
+
try:
|
|
59
|
+
url = run_git("remote", "get-url", "origin", cwd=root)
|
|
60
|
+
except GitError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
name = url.rstrip("/").removesuffix(".git")
|
|
64
|
+
if ":" in name and "@" in name:
|
|
65
|
+
return name.rsplit(":", maxsplit=1)[-1].split("/")[-1]
|
|
66
|
+
return name.rsplit("/", maxsplit=1)[-1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def changed_files(repo_root_path: Path | None = None) -> list[str]:
|
|
70
|
+
"""Return paths changed relative to the default branch merge base."""
|
|
71
|
+
root = repo_root_path or repo_root()
|
|
72
|
+
try:
|
|
73
|
+
base = run_git("merge-base", "HEAD", "main", cwd=root)
|
|
74
|
+
except GitError:
|
|
75
|
+
base = run_git("rev-parse", "HEAD", cwd=root)
|
|
76
|
+
|
|
77
|
+
diff = run_git("diff", "--name-only", f"{base}...HEAD", cwd=root)
|
|
78
|
+
if not diff:
|
|
79
|
+
return []
|
|
80
|
+
return diff.splitlines()
|
lx_tooling/github.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Thin GitHub CLI subprocess adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GhError(Exception):
|
|
13
|
+
"""Raised when a gh command fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def gh_available() -> bool:
|
|
17
|
+
"""Return whether the gh executable is on PATH."""
|
|
18
|
+
return shutil.which("gh") is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_gh(*args: str, cwd: Path | None = None) -> str:
|
|
22
|
+
"""Run a gh command and return stdout."""
|
|
23
|
+
if not gh_available():
|
|
24
|
+
raise GhError("gh is not installed or not on PATH")
|
|
25
|
+
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["gh", *args],
|
|
28
|
+
cwd=cwd,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=False,
|
|
32
|
+
)
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
message = result.stderr.strip() or result.stdout.strip() or "gh command failed"
|
|
35
|
+
raise GhError(message)
|
|
36
|
+
return result.stdout.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def issue_view_json(number: int, repo_root_path: Path | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Fetch issue metadata as a parsed JSON object."""
|
|
41
|
+
cwd = repo_root_path or Path.cwd()
|
|
42
|
+
raw = run_gh(
|
|
43
|
+
"issue",
|
|
44
|
+
"view",
|
|
45
|
+
str(number),
|
|
46
|
+
"--json",
|
|
47
|
+
"title,body,state,labels,assignees,url",
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
)
|
|
50
|
+
return json.loads(raw)
|
lx_tooling/policy.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Pure workflow policy helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
BRANCH_PREFIXES: tuple[str, ...] = (
|
|
6
|
+
"feat",
|
|
7
|
+
"fix",
|
|
8
|
+
"docs",
|
|
9
|
+
"test",
|
|
10
|
+
"ci",
|
|
11
|
+
"chore",
|
|
12
|
+
"adr",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
DEFAULT_READY_LABELS: tuple[str, ...] = (
|
|
16
|
+
"status:ready-for-agent",
|
|
17
|
+
"agent:allowed",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SCOPE_MARKERS: tuple[str, ...] = (
|
|
21
|
+
"## Scope",
|
|
22
|
+
"## In scope",
|
|
23
|
+
"- In scope:",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_valid_branch_name(name: str, *, default_branch: str = "main") -> tuple[bool, str]:
|
|
28
|
+
"""Return whether a branch name follows the Labinetix convention."""
|
|
29
|
+
if name == default_branch:
|
|
30
|
+
return False, f"branch is the default branch ({default_branch})"
|
|
31
|
+
|
|
32
|
+
if "/" not in name:
|
|
33
|
+
return False, "expected <prefix>/<topic>"
|
|
34
|
+
|
|
35
|
+
prefix, topic = name.split("/", 1)
|
|
36
|
+
if prefix not in BRANCH_PREFIXES:
|
|
37
|
+
allowed = ", ".join(BRANCH_PREFIXES)
|
|
38
|
+
return False, f"prefix must be one of: {allowed}"
|
|
39
|
+
|
|
40
|
+
if not topic:
|
|
41
|
+
return False, "topic segment must not be empty"
|
|
42
|
+
|
|
43
|
+
normalized = topic.replace("-", "").replace("_", "")
|
|
44
|
+
if not normalized.isalnum():
|
|
45
|
+
return False, "topic must use letters, numbers, hyphens, or underscores"
|
|
46
|
+
|
|
47
|
+
return True, "branch name follows convention"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def issue_has_ready_label(labels: list[str], *, ready_labels: tuple[str, ...]) -> bool:
|
|
51
|
+
"""Return whether an issue has at least one configured ready label."""
|
|
52
|
+
label_set = set(labels)
|
|
53
|
+
return any(label in label_set for label in ready_labels)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def issue_body_has_scope(body: str) -> bool:
|
|
57
|
+
"""Return whether the issue body appears to document scope."""
|
|
58
|
+
if not body.strip():
|
|
59
|
+
return False
|
|
60
|
+
return any(marker in body for marker in SCOPE_MARKERS)
|
lx_tooling/repo.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Repository metadata and inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from lx_tooling import git
|
|
11
|
+
from lx_tooling.status import StatusRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RepoMetadata:
|
|
16
|
+
name: str | None = None
|
|
17
|
+
repo_type: str | None = None
|
|
18
|
+
release: bool | None = None
|
|
19
|
+
artifacts: bool | None = None
|
|
20
|
+
local_check: str | None = None
|
|
21
|
+
docs_check: str | None = None
|
|
22
|
+
pr_base: str = "main"
|
|
23
|
+
ready_labels: tuple[str, ...] = ()
|
|
24
|
+
release_labels: tuple[str, ...] = ()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RepoInspectReport:
|
|
29
|
+
repo_root: Path
|
|
30
|
+
repository_name: str
|
|
31
|
+
repo_type: str
|
|
32
|
+
metadata: RepoMetadata
|
|
33
|
+
records: list[StatusRecord] = field(default_factory=list)
|
|
34
|
+
ci_workflows: list[str] = field(default_factory=list)
|
|
35
|
+
local_check_command: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_repo_metadata(repo_root: Path) -> RepoMetadata:
|
|
39
|
+
"""Load optional .labinetix/repo.toml metadata."""
|
|
40
|
+
metadata_path = repo_root / ".labinetix" / "repo.toml"
|
|
41
|
+
if not metadata_path.is_file():
|
|
42
|
+
return RepoMetadata()
|
|
43
|
+
|
|
44
|
+
with metadata_path.open("rb") as handle:
|
|
45
|
+
data = tomllib.load(handle)
|
|
46
|
+
|
|
47
|
+
repo_section = data.get("repo", {})
|
|
48
|
+
workflow_section = data.get("workflow", {})
|
|
49
|
+
github_section = data.get("github", {})
|
|
50
|
+
|
|
51
|
+
return RepoMetadata(
|
|
52
|
+
name=repo_section.get("name"),
|
|
53
|
+
repo_type=repo_section.get("type"),
|
|
54
|
+
release=repo_section.get("release"),
|
|
55
|
+
artifacts=repo_section.get("artifacts"),
|
|
56
|
+
local_check=workflow_section.get("local_check"),
|
|
57
|
+
docs_check=workflow_section.get("docs_check"),
|
|
58
|
+
pr_base=workflow_section.get("pr_base", "main"),
|
|
59
|
+
ready_labels=tuple(github_section.get("ready_labels", [])),
|
|
60
|
+
release_labels=tuple(github_section.get("release_labels", [])),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect_repo_type(repo_root: Path, metadata: RepoMetadata) -> str:
|
|
65
|
+
"""Infer repository type from metadata or marker files."""
|
|
66
|
+
if metadata.repo_type:
|
|
67
|
+
return metadata.repo_type
|
|
68
|
+
if (repo_root / "Cargo.toml").is_file():
|
|
69
|
+
return "rust"
|
|
70
|
+
if (repo_root / "pyproject.toml").is_file():
|
|
71
|
+
return "python"
|
|
72
|
+
if (repo_root / "package.json").is_file():
|
|
73
|
+
return "node"
|
|
74
|
+
return "unknown"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def discover_local_check_command(repo_root: Path, metadata: RepoMetadata) -> str | None:
|
|
78
|
+
"""Return the best-known local check command for the repository."""
|
|
79
|
+
if metadata.local_check:
|
|
80
|
+
return metadata.local_check
|
|
81
|
+
if (repo_root / "justfile").is_file() or (repo_root / "Justfile").is_file():
|
|
82
|
+
return "just check"
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def inspect_repository(start: Path | None = None) -> RepoInspectReport:
|
|
87
|
+
"""Inspect repository layout and policy-relevant files."""
|
|
88
|
+
root = git.repo_root(start)
|
|
89
|
+
metadata = load_repo_metadata(root)
|
|
90
|
+
remote_name = git.remote_repo_name(root)
|
|
91
|
+
repository_name = metadata.name or remote_name or root.name
|
|
92
|
+
repo_type = detect_repo_type(root, metadata)
|
|
93
|
+
local_check = discover_local_check_command(root, metadata)
|
|
94
|
+
|
|
95
|
+
records: list[StatusRecord] = []
|
|
96
|
+
records.append(StatusRecord("OK", "repo.root", str(root)))
|
|
97
|
+
records.append(StatusRecord("OK", "repo.name", repository_name))
|
|
98
|
+
records.append(StatusRecord("OK", "repo.type", repo_type))
|
|
99
|
+
|
|
100
|
+
for subject, path in (
|
|
101
|
+
("repo.readme", root / "README.md"),
|
|
102
|
+
("repo.agents", root / "AGENTS.md"),
|
|
103
|
+
("repo.metadata", root / ".labinetix" / "repo.toml"),
|
|
104
|
+
):
|
|
105
|
+
if path.is_file():
|
|
106
|
+
records.append(StatusRecord("OK", subject, str(path.relative_to(root))))
|
|
107
|
+
else:
|
|
108
|
+
level = "WARN" if subject != "repo.metadata" else "SKIP"
|
|
109
|
+
records.append(StatusRecord(level, subject, "missing"))
|
|
110
|
+
|
|
111
|
+
docs_dir = root / "docs"
|
|
112
|
+
if docs_dir.is_dir():
|
|
113
|
+
records.append(StatusRecord("OK", "repo.docs", "docs/"))
|
|
114
|
+
else:
|
|
115
|
+
records.append(StatusRecord("WARN", "repo.docs", "missing docs/"))
|
|
116
|
+
|
|
117
|
+
adr_dir = root / "docs" / "decisions"
|
|
118
|
+
design_dir = root / "docs" / "design"
|
|
119
|
+
if adr_dir.is_dir():
|
|
120
|
+
records.append(StatusRecord("OK", "repo.adr", "docs/decisions/"))
|
|
121
|
+
elif design_dir.is_dir():
|
|
122
|
+
records.append(StatusRecord("OK", "repo.adr", "docs/design/"))
|
|
123
|
+
else:
|
|
124
|
+
records.append(StatusRecord("WARN", "repo.adr", "missing docs/decisions/ or docs/design/"))
|
|
125
|
+
|
|
126
|
+
workflow_dir = root / ".github" / "workflows"
|
|
127
|
+
ci_workflows: list[str] = []
|
|
128
|
+
if workflow_dir.is_dir():
|
|
129
|
+
for workflow_file in sorted(workflow_dir.glob("*.yml")) + sorted(
|
|
130
|
+
workflow_dir.glob("*.yaml")
|
|
131
|
+
):
|
|
132
|
+
ci_workflows.append(workflow_file.name)
|
|
133
|
+
if ci_workflows:
|
|
134
|
+
records.append(StatusRecord("OK", "repo.ci", ", ".join(ci_workflows)))
|
|
135
|
+
else:
|
|
136
|
+
records.append(StatusRecord("WARN", "repo.ci", "no workflow files found"))
|
|
137
|
+
else:
|
|
138
|
+
records.append(StatusRecord("WARN", "repo.ci", "missing .github/workflows/"))
|
|
139
|
+
|
|
140
|
+
if local_check:
|
|
141
|
+
records.append(StatusRecord("OK", "repo.local_check", local_check))
|
|
142
|
+
else:
|
|
143
|
+
records.append(StatusRecord("WARN", "repo.local_check", "not configured"))
|
|
144
|
+
|
|
145
|
+
if metadata.release is True:
|
|
146
|
+
records.append(StatusRecord("OK", "repo.release", "enabled"))
|
|
147
|
+
elif metadata.release is False:
|
|
148
|
+
records.append(StatusRecord("SKIP", "repo.release", "disabled in metadata"))
|
|
149
|
+
else:
|
|
150
|
+
records.append(StatusRecord("WARN", "repo.release", "not declared in metadata"))
|
|
151
|
+
|
|
152
|
+
return RepoInspectReport(
|
|
153
|
+
repo_root=root,
|
|
154
|
+
repository_name=repository_name,
|
|
155
|
+
repo_type=repo_type,
|
|
156
|
+
metadata=metadata,
|
|
157
|
+
records=records,
|
|
158
|
+
ci_workflows=ci_workflows,
|
|
159
|
+
local_check_command=local_check,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def report_to_dict(report: RepoInspectReport) -> dict[str, Any]:
|
|
164
|
+
"""Serialize an inspection report for JSON output."""
|
|
165
|
+
return {
|
|
166
|
+
"repository_name": report.repository_name,
|
|
167
|
+
"repo_type": report.repo_type,
|
|
168
|
+
"repo_root": str(report.repo_root),
|
|
169
|
+
"local_check_command": report.local_check_command,
|
|
170
|
+
"ci_workflows": report.ci_workflows,
|
|
171
|
+
"metadata": {
|
|
172
|
+
"name": report.metadata.name,
|
|
173
|
+
"repo_type": report.metadata.repo_type,
|
|
174
|
+
"release": report.metadata.release,
|
|
175
|
+
"artifacts": report.metadata.artifacts,
|
|
176
|
+
"local_check": report.metadata.local_check,
|
|
177
|
+
"docs_check": report.metadata.docs_check,
|
|
178
|
+
"pr_base": report.metadata.pr_base,
|
|
179
|
+
"ready_labels": list(report.metadata.ready_labels),
|
|
180
|
+
"release_labels": list(report.metadata.release_labels),
|
|
181
|
+
},
|
|
182
|
+
"records": [
|
|
183
|
+
{"level": record.level, "subject": record.subject, "detail": record.detail}
|
|
184
|
+
for record in report.records
|
|
185
|
+
],
|
|
186
|
+
}
|
lx_tooling/status.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Status vocabulary and line formatting (pure)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
StatusLevel = Literal["OK", "WOULD UPDATE", "UPDATED", "SKIP", "WARN", "ERROR"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class StatusRecord:
|
|
13
|
+
level: StatusLevel
|
|
14
|
+
subject: str
|
|
15
|
+
detail: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_line(record: StatusRecord) -> str:
|
|
19
|
+
"""Format one machine-friendly status line (fixed-width level column)."""
|
|
20
|
+
return f"{record.level:<13} {record.subject:<28} {record.detail}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def exit_code_for_records(records: list[StatusRecord]) -> int:
|
|
24
|
+
"""0 unless any ERROR."""
|
|
25
|
+
if any(record.level == "ERROR" for record in records):
|
|
26
|
+
return 1
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def records_to_dicts(records: list[StatusRecord]) -> list[dict[str, str]]:
|
|
31
|
+
"""Serialize status records for JSON output."""
|
|
32
|
+
return [
|
|
33
|
+
{"level": record.level, "subject": record.subject, "detail": record.detail}
|
|
34
|
+
for record in records
|
|
35
|
+
]
|
lx_tooling/templates.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Placeholder for PR and release body templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
PR_BODY_TEMPLATE = """## Summary
|
|
6
|
+
|
|
7
|
+
-
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
- In scope:
|
|
12
|
+
- Out of scope:
|
|
13
|
+
|
|
14
|
+
## Verification
|
|
15
|
+
|
|
16
|
+
- [ ] Local checks:
|
|
17
|
+
- [ ] CI:
|
|
18
|
+
- [ ] Docs/examples:
|
|
19
|
+
|
|
20
|
+
## Release Impact
|
|
21
|
+
|
|
22
|
+
- Version impact:
|
|
23
|
+
- Artifact impact:
|
|
24
|
+
- Deploy impact:
|
|
25
|
+
|
|
26
|
+
## Links
|
|
27
|
+
|
|
28
|
+
- Issue:
|
|
29
|
+
- ADR:
|
|
30
|
+
- Related PRs:
|
|
31
|
+
"""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lx-tooling
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Labinetix workflow CLI for humans and AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/labinetix/lx-tooling
|
|
6
|
+
Project-URL: Repository, https://github.com/labinetix/lx-tooling
|
|
7
|
+
Project-URL: Documentation, https://github.com/labinetix/lx-tooling/blob/main/docs/design/lx-tooling.md
|
|
8
|
+
Author-email: Fabian Müller <fabianmueller100295@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: typer>=0.26.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# lx-tooling
|
|
16
|
+
|
|
17
|
+
**Tag:** Org and orchestration
|
|
18
|
+
|
|
19
|
+
**Labinetix workflow CLI** for humans and AI agents. `lx-tooling` orchestrates GitHub issues, branches, pull requests, releases, local verification, and repository policy checks without owning domain logic.
|
|
20
|
+
|
|
21
|
+
**Non-goals:** model semantics, ABI schema ownership, runtime algorithms, or protocol implementations.
|
|
22
|
+
|
|
23
|
+
## Stability
|
|
24
|
+
|
|
25
|
+
Milestone 0 — read-only commands only. Branch creation, PR creation, tags, and releases come in later milestones.
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
Prerequisites:
|
|
30
|
+
|
|
31
|
+
- Python 3.11+
|
|
32
|
+
- [`uv`](https://docs.astral.sh/uv/)
|
|
33
|
+
- [`gh`](https://cli.github.com/) for `lx issue view`
|
|
34
|
+
|
|
35
|
+
Local development:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone git@github.com:labinetix/lx-tooling.git
|
|
39
|
+
cd lx-tooling
|
|
40
|
+
uv sync --all-groups
|
|
41
|
+
uv run lx --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Local checks (same as CI):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
just check
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or explicitly:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv sync --all-groups
|
|
54
|
+
uv run ruff check .
|
|
55
|
+
uv run ruff format --check .
|
|
56
|
+
uv run pytest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
From PyPI after release:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv tool install lx-tooling
|
|
65
|
+
lx --version
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
From a checkout:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv sync --all-groups
|
|
72
|
+
uv run lx --help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands (Milestone 0)
|
|
76
|
+
|
|
77
|
+
Inspect the current repository:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
lx repo inspect
|
|
81
|
+
lx repo inspect --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run conservative pre-PR checks:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
lx workflow check
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Read a GitHub issue with Labinetix readiness hints:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
gh auth login
|
|
94
|
+
lx issue view 123
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
See [`docs/examples/repo-inspect.md`](docs/examples/repo-inspect.md) for sample output.
|
|
98
|
+
|
|
99
|
+
## Design and Agent Rules
|
|
100
|
+
|
|
101
|
+
- Design: [`docs/design/lx-tooling.md`](docs/design/lx-tooling.md)
|
|
102
|
+
- Agent rules: [`AGENTS.md`](AGENTS.md)
|
|
103
|
+
|
|
104
|
+
## Releases and Artifacts
|
|
105
|
+
|
|
106
|
+
- Package name on PyPI: `lx-tooling`
|
|
107
|
+
- CLI command: `lx`
|
|
108
|
+
- Latest release: see [GitHub Releases](https://github.com/labinetix/lx-tooling/releases)
|
|
109
|
+
- Release artifacts: built by CI on protected SemVer tags (`v*`), published to PyPI via trusted publishing (`pypi.yml`, environment `pypi`)
|
|
110
|
+
|
|
111
|
+
Release checklist for maintainers:
|
|
112
|
+
|
|
113
|
+
1. Merge changes to `main`
|
|
114
|
+
2. Tag `v0.y.z` on `main`
|
|
115
|
+
3. CI builds wheel/sdist, creates GitHub Release, publishes to PyPI
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
lx_tooling/__init__.py,sha256=C0Ztb09zOuQtYFjqt0Vv1qlC1bYb38wIakJx3kMyemw,53
|
|
2
|
+
lx_tooling/cli.py,sha256=s1WNBsndXKwYjZnwKPfUHqq1PdsLfDUh_aedWTKnJ5g,5913
|
|
3
|
+
lx_tooling/git.py,sha256=wpApu6bP0aKidjCdgRjlm9IJjW9R9z7CnUjNOpw2l3o,2460
|
|
4
|
+
lx_tooling/github.py,sha256=TaimLb7qEuXfhNp6KyundDp6bSbDHNAmZMfzGoiE9kk,1261
|
|
5
|
+
lx_tooling/policy.py,sha256=EBKRSa6tlv4_NOl9se4E9j2GqXmCMswFHQlsXE109zU,1677
|
|
6
|
+
lx_tooling/repo.py,sha256=YacecS7s0Y4hbJ2woLXsZSj4uZnC-JNeUCO8fRKBgjs,6788
|
|
7
|
+
lx_tooling/status.py,sha256=m3N5Q_l2ATtsNKM5V-HYtIz-D-d99Wst2wfir-3D9DA,975
|
|
8
|
+
lx_tooling/templates.py,sha256=8qufDtjNi3l7V2nleltIX3U6DI-mZYJXhXmR8t-1bX8,354
|
|
9
|
+
lx_tooling/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
lx_tooling/checks/agents.py,sha256=5xBo-9I6UYZT6u-W_2r-vIZbj8WozG1TB9xf-ZHmhKU,476
|
|
11
|
+
lx_tooling/checks/docs.py,sha256=YjZfCV6mfxVaSn-_na7y71ry1VTD1Z_x2LiEGkfnnzY,468
|
|
12
|
+
lx_tooling/checks/workflow.py,sha256=6lwLzhh0XuDXwSsXUWFSa0NxvMKdP-5KROpdQ549Eew,2877
|
|
13
|
+
lx_tooling-0.1.0.dist-info/METADATA,sha256=ADfyNzrIDtqmWie1dA-N6_HdEF0F-DP278m1fK36Rvc,2593
|
|
14
|
+
lx_tooling-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
lx_tooling-0.1.0.dist-info/entry_points.txt,sha256=AFtAryVAEg7MYRwfNQsg2sYTVagWWrYDO6Gwe_uHG_8,42
|
|
16
|
+
lx_tooling-0.1.0.dist-info/licenses/LICENSE,sha256=jBlc-TeGkNEync1zjyUmk-jnVPBhD9FqDbSEy31fZHw,1066
|
|
17
|
+
lx_tooling-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Labinetix
|
|
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.
|