ckdn 1.0.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.
- ckdn/__init__.py +16 -0
- ckdn/__main__.py +7 -0
- ckdn/app/__init__.py +53 -0
- ckdn/app/errors.py +45 -0
- ckdn/app/queries.py +270 -0
- ckdn/app/run.py +177 -0
- ckdn/app/types.py +28 -0
- ckdn/cli.py +211 -0
- ckdn/config.py +327 -0
- ckdn/digest.py +191 -0
- ckdn/mcp/__init__.py +15 -0
- ckdn/mcp/context.py +37 -0
- ckdn/mcp/register.py +37 -0
- ckdn/mcp/server.py +64 -0
- ckdn/mcp/tools/__init__.py +3 -0
- ckdn/mcp/tools/get_digest.py +29 -0
- ckdn/mcp/tools/get_evidence.py +49 -0
- ckdn/mcp/tools/list_checks.py +27 -0
- ckdn/mcp/tools/list_runs.py +26 -0
- ckdn/mcp/tools/run_check.py +48 -0
- ckdn/mcp/tools/run_group.py +39 -0
- ckdn/parsers/__init__.py +46 -0
- ckdn/parsers/bandit_json.py +138 -0
- ckdn/parsers/base.py +204 -0
- ckdn/parsers/coverage_xml.py +165 -0
- ckdn/parsers/generic.py +25 -0
- ckdn/parsers/mypy.py +246 -0
- ckdn/parsers/pip_audit_json.py +112 -0
- ckdn/parsers/pylint_json.py +155 -0
- ckdn/parsers/pyright_json.py +130 -0
- ckdn/parsers/pytest_junit.py +105 -0
- ckdn/parsers/reformat_text.py +89 -0
- ckdn/parsers/ruff_json.py +78 -0
- ckdn/parsers/sarif.py +160 -0
- ckdn/parsers/ty_text.py +186 -0
- ckdn/py.typed +0 -0
- ckdn/reconcile.py +72 -0
- ckdn/runner.py +179 -0
- ckdn-1.0.0.dist-info/METADATA +620 -0
- ckdn-1.0.0.dist-info/RECORD +43 -0
- ckdn-1.0.0.dist-info/WHEEL +4 -0
- ckdn-1.0.0.dist-info/entry_points.txt +3 -0
- ckdn-1.0.0.dist-info/licenses/LICENSE +24 -0
ckdn/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""ckdn: deterministic check runner and log digester for AI-assisted development."""
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
|
|
7
|
+
#: Digest document schema identifier. Bump the trailing integer on any
|
|
8
|
+
#: backward-incompatible change to the digest.json structure.
|
|
9
|
+
DIGEST_SCHEMA = "ckdn.digest/2"
|
|
10
|
+
|
|
11
|
+
#: Meta document schema identifier.
|
|
12
|
+
META_SCHEMA = "ckdn.meta/1"
|
|
13
|
+
|
|
14
|
+
#: Alias aggregate document schema identifier. Bump the trailing integer on any
|
|
15
|
+
#: backward-incompatible change to the aggregate structure.
|
|
16
|
+
AGGREGATE_SCHEMA = "ckdn.aggregate/1"
|
ckdn/__main__.py
ADDED
ckdn/app/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Stdlib application facade shared by CLI and MCP transports."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ckdn.app.errors import (
|
|
8
|
+
AliasExtraArgsError,
|
|
9
|
+
AppError,
|
|
10
|
+
ArtifactError,
|
|
11
|
+
ConfigLoadError,
|
|
12
|
+
DigestError,
|
|
13
|
+
NotAliasError,
|
|
14
|
+
NotAtomicError,
|
|
15
|
+
RunNotFoundError,
|
|
16
|
+
UnknownCheckError,
|
|
17
|
+
UnknownParserError,
|
|
18
|
+
)
|
|
19
|
+
from ckdn.app.queries import (
|
|
20
|
+
DEFAULT_EVIDENCE_LIMIT,
|
|
21
|
+
MAX_EVIDENCE_LIMIT,
|
|
22
|
+
get_digest,
|
|
23
|
+
get_evidence,
|
|
24
|
+
list_checks,
|
|
25
|
+
list_runs,
|
|
26
|
+
)
|
|
27
|
+
from ckdn.app.run import exit_from_outcome, run_alias, run_check, run_one
|
|
28
|
+
from ckdn.app.types import AliasRunResult, AtomicRunResult
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_EVIDENCE_LIMIT",
|
|
32
|
+
"MAX_EVIDENCE_LIMIT",
|
|
33
|
+
"AliasExtraArgsError",
|
|
34
|
+
"AliasRunResult",
|
|
35
|
+
"AppError",
|
|
36
|
+
"ArtifactError",
|
|
37
|
+
"AtomicRunResult",
|
|
38
|
+
"ConfigLoadError",
|
|
39
|
+
"DigestError",
|
|
40
|
+
"NotAliasError",
|
|
41
|
+
"NotAtomicError",
|
|
42
|
+
"RunNotFoundError",
|
|
43
|
+
"UnknownCheckError",
|
|
44
|
+
"UnknownParserError",
|
|
45
|
+
"exit_from_outcome",
|
|
46
|
+
"get_digest",
|
|
47
|
+
"get_evidence",
|
|
48
|
+
"list_checks",
|
|
49
|
+
"list_runs",
|
|
50
|
+
"run_alias",
|
|
51
|
+
"run_check",
|
|
52
|
+
"run_one",
|
|
53
|
+
]
|
ckdn/app/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Application-layer errors (transports map these to exit codes / isError)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AppError(Exception):
|
|
9
|
+
"""Base error for ckdn application operations."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigLoadError(AppError):
|
|
13
|
+
"""ckdn.toml missing or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownCheckError(AppError):
|
|
17
|
+
"""Requested check name is not in the config."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotAtomicError(AppError):
|
|
21
|
+
"""Operation requires an atomic check."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotAliasError(AppError):
|
|
25
|
+
"""Operation requires an alias check."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownParserError(AppError):
|
|
29
|
+
"""Configured parser name is not registered."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AliasExtraArgsError(AppError):
|
|
33
|
+
"""Aliases do not accept extra command arguments."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RunNotFoundError(AppError):
|
|
37
|
+
"""No matching run directory."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DigestError(AppError):
|
|
41
|
+
"""Digest missing or corrupt."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArtifactError(AppError):
|
|
45
|
+
"""Artifact path invalid, missing, or outside the run directory."""
|
ckdn/app/queries.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Read-side application queries over config and run artifacts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ckdn.app.errors import ArtifactError, DigestError, RunNotFoundError
|
|
12
|
+
from ckdn.config import Config
|
|
13
|
+
from ckdn.digest import DIGEST_NAME, META_NAME, list_artifacts
|
|
14
|
+
from ckdn.runner import LOG_NAME, list_run_dirs, resolve_run_dir
|
|
15
|
+
|
|
16
|
+
DEFAULT_EVIDENCE_LIMIT = 200
|
|
17
|
+
MAX_EVIDENCE_LIMIT = 2000
|
|
18
|
+
MAX_LIST_RUNS_LIMIT = 500
|
|
19
|
+
|
|
20
|
+
# Streaming artifact reads: never hold the whole file in memory. Scan in fixed
|
|
21
|
+
# chunks and cap the bytes retained per returned line so a single pathological
|
|
22
|
+
# (e.g. newline-free) artifact cannot exhaust memory via get_evidence.
|
|
23
|
+
_EVIDENCE_READ_CHUNK = 1 << 16 # 64 KiB
|
|
24
|
+
_MAX_EVIDENCE_LINE_BYTES = 64 << 10 # 64 KiB per returned line
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _no_run_error(ref: str | None) -> RunNotFoundError:
|
|
28
|
+
"""Distinguish an unresolved/invalid ref from an empty runs directory.
|
|
29
|
+
|
|
30
|
+
A truthy ``ref`` that resolves to nothing is either unknown or not a valid
|
|
31
|
+
run id inside the runs dir (absolute/``..``/multi-segment/symlink refs are
|
|
32
|
+
refused by ``resolve_run_dir``); say so rather than implying nothing has run.
|
|
33
|
+
"""
|
|
34
|
+
if ref:
|
|
35
|
+
return RunNotFoundError(
|
|
36
|
+
f"no run matching {ref!r} (unknown, or not a valid run id "
|
|
37
|
+
"inside the runs directory)"
|
|
38
|
+
)
|
|
39
|
+
return RunNotFoundError("no matching run found (nothing has been run yet?)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_DIGEST_EVIDENCE_KEYS = (
|
|
43
|
+
"findings",
|
|
44
|
+
"findings_total",
|
|
45
|
+
"findings_truncated",
|
|
46
|
+
"gate_failures",
|
|
47
|
+
"notes",
|
|
48
|
+
"log_tail",
|
|
49
|
+
"summary",
|
|
50
|
+
"status_reason",
|
|
51
|
+
"artifacts",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def list_checks(cfg: Config) -> list[dict[str, Any]]:
|
|
56
|
+
"""Return sorted check metadata for agents / MCP."""
|
|
57
|
+
out: list[dict[str, Any]] = []
|
|
58
|
+
for name in sorted(cfg.checks):
|
|
59
|
+
check = cfg.checks[name]
|
|
60
|
+
if check.is_alias:
|
|
61
|
+
out.append(
|
|
62
|
+
{
|
|
63
|
+
"name": name,
|
|
64
|
+
"kind": "alias",
|
|
65
|
+
"members": list(check.members or ()),
|
|
66
|
+
"fail_fast": check.fail_fast,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
item: dict[str, Any] = {
|
|
71
|
+
"name": name,
|
|
72
|
+
"kind": "atomic",
|
|
73
|
+
"parser": check.parser,
|
|
74
|
+
"command": check.command,
|
|
75
|
+
}
|
|
76
|
+
if check.timeout is not None:
|
|
77
|
+
item["timeout"] = check.timeout
|
|
78
|
+
if check.options:
|
|
79
|
+
item["options"] = dict(check.options)
|
|
80
|
+
out.append(item)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_digest(run_dir: Path) -> dict[str, Any]:
|
|
85
|
+
"""Load and validate ``digest.json`` from an already-resolved run dir.
|
|
86
|
+
|
|
87
|
+
Reading is anchored to ``run_dir`` itself -- never re-resolved by basename
|
|
88
|
+
-- so a run's digest can never be paired with another run's artifacts.
|
|
89
|
+
"""
|
|
90
|
+
digest_path = run_dir / DIGEST_NAME
|
|
91
|
+
if not digest_path.exists():
|
|
92
|
+
raise DigestError(f"run {run_dir.name} has no {DIGEST_NAME}")
|
|
93
|
+
try:
|
|
94
|
+
doc = json.loads(digest_path.read_text(encoding="utf-8"))
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise DigestError(f"run {run_dir.name} has corrupt {DIGEST_NAME}") from exc
|
|
97
|
+
if not isinstance(doc, dict):
|
|
98
|
+
raise DigestError(f"run {run_dir.name} digest root is not an object")
|
|
99
|
+
return doc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_digest(cfg: Config, ref: str | None = None) -> dict[str, Any]:
|
|
103
|
+
"""Load ``digest.json`` for ``ref`` or the latest run."""
|
|
104
|
+
run_dir = resolve_run_dir(cfg.runs_dir, ref)
|
|
105
|
+
if run_dir is None:
|
|
106
|
+
raise _no_run_error(ref)
|
|
107
|
+
return _load_digest(run_dir)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_runs(cfg: Config, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
111
|
+
"""Return recent run summaries (oldest→newest within the window)."""
|
|
112
|
+
n = min(max(0, limit), MAX_LIST_RUNS_LIMIT)
|
|
113
|
+
dirs = list_run_dirs(cfg.runs_dir)[-n:] if n else []
|
|
114
|
+
rows: list[dict[str, Any]] = []
|
|
115
|
+
for run_dir in dirs:
|
|
116
|
+
row: dict[str, Any] = {"run_id": run_dir.name, "check": "?", "status": "?"}
|
|
117
|
+
digest_path = run_dir / DIGEST_NAME
|
|
118
|
+
if digest_path.exists():
|
|
119
|
+
try:
|
|
120
|
+
doc = json.loads(digest_path.read_text(encoding="utf-8"))
|
|
121
|
+
if isinstance(doc, dict):
|
|
122
|
+
row["check"] = str(doc.get("check", "?"))
|
|
123
|
+
row["status"] = str(doc.get("status", "?"))
|
|
124
|
+
if "rc" in doc:
|
|
125
|
+
row["rc"] = doc["rc"]
|
|
126
|
+
if "run_dir" in doc:
|
|
127
|
+
row["run_dir"] = doc["run_dir"]
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
row["status"] = "corrupt"
|
|
130
|
+
rows.append(row)
|
|
131
|
+
return rows
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _safe_artifact_path(run_dir: Path, artifact: str) -> Path:
|
|
135
|
+
if not artifact or artifact.strip() != artifact:
|
|
136
|
+
raise ArtifactError(f"invalid artifact name: {artifact!r}")
|
|
137
|
+
if Path(artifact).is_absolute() or ".." in Path(artifact).parts:
|
|
138
|
+
raise ArtifactError(f"artifact path escapes run directory: {artifact!r}")
|
|
139
|
+
allowed = set(list_artifacts(run_dir))
|
|
140
|
+
# full.log is always an evidence candidate even if list_artifacts filters
|
|
141
|
+
allowed.add(LOG_NAME)
|
|
142
|
+
allowed.add(DIGEST_NAME)
|
|
143
|
+
allowed.add(META_NAME)
|
|
144
|
+
if artifact not in allowed:
|
|
145
|
+
raise ArtifactError(
|
|
146
|
+
f"artifact '{artifact}' not found in run {run_dir.name}; "
|
|
147
|
+
f"available: {', '.join(sorted(allowed))}"
|
|
148
|
+
)
|
|
149
|
+
target = (run_dir / artifact).resolve()
|
|
150
|
+
root = run_dir.resolve()
|
|
151
|
+
try:
|
|
152
|
+
target.relative_to(root)
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise ArtifactError(
|
|
155
|
+
f"artifact path escapes run directory: {artifact!r}"
|
|
156
|
+
) from exc
|
|
157
|
+
if not target.is_file():
|
|
158
|
+
raise ArtifactError(
|
|
159
|
+
f"artifact '{artifact}' is not a file in run {run_dir.name}"
|
|
160
|
+
)
|
|
161
|
+
return target
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_evidence(
|
|
165
|
+
cfg: Config,
|
|
166
|
+
*,
|
|
167
|
+
ref: str | None = None,
|
|
168
|
+
artifact: str | None = None,
|
|
169
|
+
offset: int = 0,
|
|
170
|
+
limit: int = DEFAULT_EVIDENCE_LIMIT,
|
|
171
|
+
include_meta: bool = False,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Return bounded digest evidence and/or a sliced artifact body.
|
|
174
|
+
|
|
175
|
+
When ``artifact`` is omitted, returns digest evidence fields and the
|
|
176
|
+
artifact index — never the full log body.
|
|
177
|
+
"""
|
|
178
|
+
run_dir = resolve_run_dir(cfg.runs_dir, ref)
|
|
179
|
+
if run_dir is None:
|
|
180
|
+
raise _no_run_error(ref)
|
|
181
|
+
|
|
182
|
+
digest = _load_digest(run_dir)
|
|
183
|
+
payload: dict[str, Any] = {
|
|
184
|
+
"run_id": run_dir.name,
|
|
185
|
+
"check": digest.get("check"),
|
|
186
|
+
"status": digest.get("status"),
|
|
187
|
+
"rc": digest.get("rc"),
|
|
188
|
+
"run_dir": digest.get("run_dir"),
|
|
189
|
+
"artifacts": list_artifacts(run_dir),
|
|
190
|
+
}
|
|
191
|
+
for key in _DIGEST_EVIDENCE_KEYS:
|
|
192
|
+
if key in digest and key != "artifacts":
|
|
193
|
+
payload[key] = digest[key]
|
|
194
|
+
|
|
195
|
+
if include_meta:
|
|
196
|
+
meta_path = run_dir / META_NAME
|
|
197
|
+
if meta_path.is_file():
|
|
198
|
+
try:
|
|
199
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
200
|
+
if isinstance(meta, dict):
|
|
201
|
+
payload["meta"] = meta
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
payload["meta_error"] = f"corrupt {META_NAME}"
|
|
204
|
+
|
|
205
|
+
if artifact is None:
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
line_offset = max(0, offset)
|
|
209
|
+
line_limit = min(max(1, limit), MAX_EVIDENCE_LIMIT)
|
|
210
|
+
path = _safe_artifact_path(run_dir, artifact)
|
|
211
|
+
sliced, total_lines = _slice_artifact_lines(path, line_offset, line_limit)
|
|
212
|
+
payload["artifact"] = {
|
|
213
|
+
"name": artifact,
|
|
214
|
+
"offset": line_offset,
|
|
215
|
+
"limit": line_limit,
|
|
216
|
+
"total_lines": total_lines,
|
|
217
|
+
"truncated": line_offset + line_limit < total_lines,
|
|
218
|
+
"lines": sliced,
|
|
219
|
+
}
|
|
220
|
+
return payload
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _slice_artifact_lines(path: Path, offset: int, limit: int) -> tuple[list[str], int]:
|
|
224
|
+
"""Stream ``path`` and return ``(window, total_lines)``.
|
|
225
|
+
|
|
226
|
+
Only the ``[offset, offset + limit)`` window is retained, and each retained
|
|
227
|
+
line is capped at ``_MAX_EVIDENCE_LINE_BYTES``, so neither a huge file nor a
|
|
228
|
+
single unbounded line can be pulled into memory in full. Line splitting
|
|
229
|
+
matches ``str.splitlines`` for ``\\n`` (and trims a trailing ``\\r`` so CRLF
|
|
230
|
+
logs read cleanly).
|
|
231
|
+
"""
|
|
232
|
+
end = offset + limit
|
|
233
|
+
window: list[bytes] = []
|
|
234
|
+
total = 0
|
|
235
|
+
idx = 0
|
|
236
|
+
cur = bytearray()
|
|
237
|
+
line_nonempty = False
|
|
238
|
+
keeping = offset <= idx < end
|
|
239
|
+
|
|
240
|
+
with path.open("rb") as fh:
|
|
241
|
+
while chunk := fh.read(_EVIDENCE_READ_CHUNK):
|
|
242
|
+
pos = 0
|
|
243
|
+
size = len(chunk)
|
|
244
|
+
while pos < size:
|
|
245
|
+
nl = chunk.find(b"\n", pos)
|
|
246
|
+
seg_end = size if nl == -1 else nl
|
|
247
|
+
if seg_end > pos:
|
|
248
|
+
line_nonempty = True
|
|
249
|
+
if keeping and len(cur) < _MAX_EVIDENCE_LINE_BYTES:
|
|
250
|
+
room = _MAX_EVIDENCE_LINE_BYTES - len(cur)
|
|
251
|
+
cur += chunk[pos : min(seg_end, pos + room)]
|
|
252
|
+
if nl == -1:
|
|
253
|
+
break
|
|
254
|
+
total += 1
|
|
255
|
+
if keeping:
|
|
256
|
+
window.append(bytes(cur))
|
|
257
|
+
idx += 1
|
|
258
|
+
cur = bytearray()
|
|
259
|
+
line_nonempty = False
|
|
260
|
+
keeping = offset <= idx < end
|
|
261
|
+
pos = nl + 1
|
|
262
|
+
|
|
263
|
+
# A trailing segment with no terminating newline is a final line.
|
|
264
|
+
if line_nonempty:
|
|
265
|
+
total += 1
|
|
266
|
+
if keeping:
|
|
267
|
+
window.append(bytes(cur))
|
|
268
|
+
|
|
269
|
+
decoded = [b.decode("utf-8", errors="replace").removesuffix("\r") for b in window]
|
|
270
|
+
return decoded, total
|
ckdn/app/run.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Run atomic checks and aliases (shared by CLI and MCP)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ckdn.app.errors import (
|
|
8
|
+
AliasExtraArgsError,
|
|
9
|
+
NotAliasError,
|
|
10
|
+
NotAtomicError,
|
|
11
|
+
UnknownCheckError,
|
|
12
|
+
UnknownParserError,
|
|
13
|
+
)
|
|
14
|
+
from ckdn.app.types import AliasRunResult, AtomicRunResult
|
|
15
|
+
from ckdn.config import CheckConfig, Config
|
|
16
|
+
from ckdn.digest import (
|
|
17
|
+
META_NAME,
|
|
18
|
+
build_alias_aggregate,
|
|
19
|
+
build_digest,
|
|
20
|
+
build_meta,
|
|
21
|
+
dump_json,
|
|
22
|
+
list_artifacts,
|
|
23
|
+
write_documents,
|
|
24
|
+
)
|
|
25
|
+
from ckdn.parsers import available_parsers, get_parser
|
|
26
|
+
from ckdn.parsers.base import ParseContext, ParseResult
|
|
27
|
+
from ckdn.reconcile import reconcile
|
|
28
|
+
from ckdn.runner import (
|
|
29
|
+
build_tokens,
|
|
30
|
+
create_run_dir,
|
|
31
|
+
execute,
|
|
32
|
+
prune,
|
|
33
|
+
update_latest,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exit_from_outcome(rc: int, status: str) -> int:
|
|
38
|
+
if rc != 0:
|
|
39
|
+
return rc if 0 < rc <= 255 else 1
|
|
40
|
+
return 0 if status == "pass" else 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_one(
|
|
44
|
+
cfg: Config,
|
|
45
|
+
check: CheckConfig,
|
|
46
|
+
*,
|
|
47
|
+
extra: list[str] | None = None,
|
|
48
|
+
) -> AtomicRunResult:
|
|
49
|
+
"""Run one atomic check and persist digest/meta under the run directory."""
|
|
50
|
+
if check.is_alias or check.command is None or check.parser is None:
|
|
51
|
+
raise NotAtomicError(f"[check.{check.name}] is not an atomic check")
|
|
52
|
+
|
|
53
|
+
parser = get_parser(check.parser)
|
|
54
|
+
if parser is None:
|
|
55
|
+
raise UnknownParserError(
|
|
56
|
+
f"[check.{check.name}] uses unknown parser '{check.parser}'; "
|
|
57
|
+
"available: " + ", ".join(available_parsers())
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
run_dir = create_run_dir(cfg.runs_dir, check.name)
|
|
61
|
+
tokens = build_tokens(check.command, run_dir, list(extra or ()))
|
|
62
|
+
outcome = execute(tokens, cwd=cfg.root, run_dir=run_dir, timeout=check.timeout)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
result = parser.parse(
|
|
66
|
+
ParseContext(
|
|
67
|
+
run_dir=run_dir,
|
|
68
|
+
log_text=outcome.log_text,
|
|
69
|
+
rc=outcome.rc,
|
|
70
|
+
options=check.options,
|
|
71
|
+
top=int(check.options.get("top", cfg.run.top)),
|
|
72
|
+
max_snippet_lines=cfg.run.max_snippet_lines,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
except Exception as exc: # a parser bug must never hide a result
|
|
76
|
+
result = ParseResult(
|
|
77
|
+
parser_ok=False,
|
|
78
|
+
notes=[f"parser '{check.parser}' crashed: {exc!r}"],
|
|
79
|
+
)
|
|
80
|
+
if outcome.exec_note:
|
|
81
|
+
result.notes.insert(0, outcome.exec_note)
|
|
82
|
+
|
|
83
|
+
status, reason, include_tail = reconcile(outcome.rc, result)
|
|
84
|
+
|
|
85
|
+
meta = build_meta(check=check.name, parser=check.parser, outcome=outcome)
|
|
86
|
+
(run_dir / META_NAME).write_text(dump_json(meta), encoding="utf-8")
|
|
87
|
+
try:
|
|
88
|
+
run_dir_rel = str(run_dir.relative_to(cfg.root))
|
|
89
|
+
except ValueError:
|
|
90
|
+
run_dir_rel = str(run_dir)
|
|
91
|
+
digest = build_digest(
|
|
92
|
+
check=check.name,
|
|
93
|
+
status=status,
|
|
94
|
+
reason=reason,
|
|
95
|
+
outcome=outcome,
|
|
96
|
+
result=result,
|
|
97
|
+
run_dir_rel=run_dir_rel,
|
|
98
|
+
top=int(check.options.get("top", cfg.run.top)),
|
|
99
|
+
include_tail=include_tail,
|
|
100
|
+
tail_lines=cfg.run.log_tail_lines,
|
|
101
|
+
artifacts=list_artifacts(run_dir),
|
|
102
|
+
)
|
|
103
|
+
write_documents(run_dir, digest, meta)
|
|
104
|
+
update_latest(cfg.runs_dir, run_dir)
|
|
105
|
+
prune(cfg.runs_dir, cfg.run.keep)
|
|
106
|
+
|
|
107
|
+
return AtomicRunResult(
|
|
108
|
+
check=check.name,
|
|
109
|
+
status=status,
|
|
110
|
+
rc=outcome.rc,
|
|
111
|
+
run_dir=run_dir,
|
|
112
|
+
digest=digest,
|
|
113
|
+
exit_code=exit_from_outcome(outcome.rc, status),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _alias_aggregate_exit(results: list[AtomicRunResult]) -> int:
|
|
118
|
+
for item in results:
|
|
119
|
+
if item.rc != 0:
|
|
120
|
+
return item.rc if 0 < item.rc <= 255 else 1
|
|
121
|
+
if any(item.status != "pass" for item in results):
|
|
122
|
+
return 1
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_alias(cfg: Config, alias: CheckConfig) -> AliasRunResult:
|
|
127
|
+
"""Run an alias's members in order; return aggregate + member results."""
|
|
128
|
+
if not alias.is_alias or alias.members is None:
|
|
129
|
+
raise NotAliasError(f"[check.{alias.name}] is not an alias")
|
|
130
|
+
|
|
131
|
+
results: list[AtomicRunResult] = []
|
|
132
|
+
for member_name in alias.members:
|
|
133
|
+
member = cfg.checks[member_name]
|
|
134
|
+
outcome = run_one(cfg, member, extra=[])
|
|
135
|
+
results.append(outcome)
|
|
136
|
+
if alias.fail_fast and outcome.exit_code != 0:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
exit_code = _alias_aggregate_exit(results)
|
|
140
|
+
status = "pass" if exit_code == 0 else "fail"
|
|
141
|
+
aggregate = build_alias_aggregate(
|
|
142
|
+
alias=alias.name,
|
|
143
|
+
results=[(r.check, r.status, r.rc, r.run_dir) for r in results],
|
|
144
|
+
status=status,
|
|
145
|
+
rc=exit_code,
|
|
146
|
+
)
|
|
147
|
+
return AliasRunResult(
|
|
148
|
+
alias=alias.name,
|
|
149
|
+
status=status,
|
|
150
|
+
aggregate=aggregate,
|
|
151
|
+
members=tuple(results),
|
|
152
|
+
exit_code=exit_code,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_check(
|
|
157
|
+
cfg: Config,
|
|
158
|
+
name: str,
|
|
159
|
+
*,
|
|
160
|
+
extra: list[str] | None = None,
|
|
161
|
+
) -> AtomicRunResult | AliasRunResult:
|
|
162
|
+
"""Dispatch by check kind. Aliases reject ``extra``."""
|
|
163
|
+
check = cfg.checks.get(name)
|
|
164
|
+
if check is None:
|
|
165
|
+
raise UnknownCheckError(
|
|
166
|
+
f"unknown check '{name}'; configured: " + ", ".join(sorted(cfg.checks))
|
|
167
|
+
)
|
|
168
|
+
extra_args = list(extra or ())
|
|
169
|
+
if check.is_alias:
|
|
170
|
+
if extra_args:
|
|
171
|
+
raise AliasExtraArgsError(
|
|
172
|
+
f"alias '{check.name}' does not accept extra arguments; "
|
|
173
|
+
"run an atomic member check instead "
|
|
174
|
+
f"(members: {', '.join(check.members or ())})"
|
|
175
|
+
)
|
|
176
|
+
return run_alias(cfg, check)
|
|
177
|
+
return run_one(cfg, check, extra=extra_args)
|
ckdn/app/types.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Shared result types for the application layer."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AtomicRunResult:
|
|
14
|
+
check: str
|
|
15
|
+
status: str
|
|
16
|
+
rc: int
|
|
17
|
+
run_dir: Path
|
|
18
|
+
digest: dict[str, Any]
|
|
19
|
+
exit_code: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AliasRunResult:
|
|
24
|
+
alias: str
|
|
25
|
+
status: str
|
|
26
|
+
aggregate: dict[str, Any]
|
|
27
|
+
members: tuple[AtomicRunResult, ...]
|
|
28
|
+
exit_code: int
|