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 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
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """Allow `python -m ckdn`."""
4
+
5
+ from ckdn.cli import main
6
+
7
+ raise SystemExit(main())
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