mkbundle 0.2.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.
mkbundle/rules.py ADDED
@@ -0,0 +1,417 @@
1
+ """The build-time cross-file rules.
2
+
3
+ The schemas cannot express these: each needs context beyond one document
4
+ (`schema/README.md`). The rules that need *tenant* state — budgets against a
5
+ tenant's constraints, connections against a target environment — are deploy-time
6
+ checks and deliberately absent here. A builder has no tenant.
7
+
8
+ The one that carries the most weight is `missing-idempotency-key`. Durable
9
+ execution is at-least-once for external effects (ADR 0009 Amendment 1), so the
10
+ idempotency key is what makes an effect happen once. A write step without one is
11
+ not a style problem, it is a duplicate email waiting for the first crash.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+ from mkp_contracts import expressions, gates
22
+
23
+ from .catalog import Connector
24
+ from .errors import Report
25
+
26
+ SECRET_REF_RE = re.compile(r"\{\{\s*secrets\.([a-zA-Z_][a-zA-Z0-9_]*)")
27
+
28
+
29
+ def walk_steps(workflow: dict[str, Any]) -> list[tuple[dict[str, Any], str]]:
30
+ """Every step in a workflow, including those nested in branches and foreach.
31
+
32
+ Returns each step with a location, so a failure points at the step and not
33
+ merely at the file.
34
+ """
35
+ found: list[tuple[dict[str, Any], str]] = []
36
+
37
+ def visit(steps: Any, prefix: str) -> None:
38
+ if not isinstance(steps, list):
39
+ return
40
+ for index, step in enumerate(steps):
41
+ if not isinstance(step, dict):
42
+ continue
43
+ location = f"{prefix}[{index}]"
44
+ found.append((step, location))
45
+ visit(step.get("steps"), f"{location}.steps")
46
+ for case_index, case in enumerate(step.get("cases") or []):
47
+ if isinstance(case, dict):
48
+ visit(case.get("steps"), f"{location}.cases[{case_index}].steps")
49
+ visit(step.get("default"), f"{location}.default")
50
+
51
+ visit(workflow.get("steps"), "steps")
52
+ visit(workflow.get("on_failure"), "on_failure")
53
+ return found
54
+
55
+
56
+ def secret_refs(document: Any) -> set[str]:
57
+ """secretRefs a workflow uses: `verify` on a webhook trigger, and expressions."""
58
+ found: set[str] = set()
59
+
60
+ def visit(node: Any, key: str | None) -> None:
61
+ if isinstance(node, dict):
62
+ for child_key, value in node.items():
63
+ visit(value, child_key)
64
+ elif isinstance(node, list):
65
+ for value in node:
66
+ visit(value, key)
67
+ elif isinstance(node, str):
68
+ if key == "verify":
69
+ found.add(node)
70
+ found.update(SECRET_REF_RE.findall(node))
71
+
72
+ visit(document, None)
73
+ return found
74
+
75
+
76
+ def connections(workflow: dict[str, Any]) -> set[str]:
77
+ used = {
78
+ trigger["connection"]
79
+ for trigger in workflow.get("triggers") or []
80
+ if isinstance(trigger, dict) and trigger.get("connection")
81
+ }
82
+ used.update(step["connection"] for step, _ in walk_steps(workflow) if step.get("connection"))
83
+ return used
84
+
85
+
86
+ def check_workflow(
87
+ workflow: dict[str, Any],
88
+ path: str,
89
+ *,
90
+ machines: set[str],
91
+ declared_connections: set[str],
92
+ declared_secrets: set[str],
93
+ connector_pins: dict[str, int],
94
+ catalog: dict[str, Connector] | None,
95
+ ) -> Report:
96
+ report = Report()
97
+ steps = walk_steps(workflow)
98
+
99
+ _step_ids_unique(steps, path, report)
100
+ _machines_present(steps, path, machines, report)
101
+ _declared_names(workflow, path, declared_connections, declared_secrets, report)
102
+ _connector_actions(steps, path, connector_pins, catalog, report)
103
+ _waits_are_decidable(steps, path, report)
104
+ _expressions_are_evaluable(workflow, path, report)
105
+ _schedule_can_satisfy_inputs(workflow, path, report)
106
+
107
+ return report
108
+
109
+
110
+ def _schedule_can_satisfy_inputs(workflow: dict[str, Any], path: str, report: Report) -> None:
111
+ """A cron-triggered workflow can actually be started by its cron.
112
+
113
+ The scheduler starts a run with `{"scheduled_for": …}` and nothing else — a schedule
114
+ fires on a standing instruction, so there is no caller to supply anything. A required
115
+ input with no default is therefore unsatisfiable on every tick: the run fails at the
116
+ start (once the runner binds inputs) or, before that, walked the document over
117
+ `None`s. Either way the schedule is decoration.
118
+
119
+ Declaring a `default` is the fix, and it is per-bundle, which is per environment —
120
+ which is where a value like the mailbox to poll belongs.
121
+ """
122
+ triggers = workflow.get("triggers") or []
123
+ if not any(isinstance(t, dict) and t.get("kind") == "schedule" for t in triggers):
124
+ return
125
+
126
+ declared = workflow.get("inputs") or {}
127
+ if not isinstance(declared, dict):
128
+ return
129
+
130
+ unsatisfiable = sorted(
131
+ name
132
+ for name, spec in declared.items()
133
+ if isinstance(spec, dict) and spec.get("required") and "default" not in spec
134
+ )
135
+ if unsatisfiable:
136
+ report.fail(
137
+ "schedule-cannot-satisfy-inputs",
138
+ f"workflow has a `schedule` trigger but requires input(s) "
139
+ f"{', '.join(unsatisfiable)} with no default. A schedule supplies only "
140
+ "`scheduled_for`, so every tick would start a run that cannot proceed. "
141
+ "Give them defaults, or drop the schedule trigger and start the workflow "
142
+ "with its inputs",
143
+ path=path,
144
+ )
145
+
146
+
147
+ def _expressions_are_evaluable(workflow: dict[str, Any], path: str, report: Report) -> None:
148
+ """Every `{{ … }}` is one the runner can evaluate (`mkp_contracts.expressions`).
149
+
150
+ A bundle is validated before it can be pointed at (ADR 0008), and until this rule
151
+ existed that promise stopped at the document's shape: an expression the grammar does
152
+ not have was schema-valid, signed, deployed, and then either failed the first run
153
+ that reached the step or — worse, for a half-quoted comparison — compared false
154
+ forever without failing anything.
155
+ """
156
+ for location, text in _template_strings(workflow, "").items():
157
+ for expression in expressions.expressions_in(text):
158
+ refusal = expressions.refusal(expression)
159
+ if refusal:
160
+ report.fail("unevaluable-expression", refusal, path=path, location=location)
161
+
162
+
163
+ def _template_strings(node: Any, location: str) -> dict[str, str]:
164
+ """Every string in the document that contains a `{{ … }}`, by location."""
165
+ found: dict[str, str] = {}
166
+ if isinstance(node, dict):
167
+ for key, value in node.items():
168
+ found.update(_template_strings(value, f"{location}.{key}" if location else str(key)))
169
+ elif isinstance(node, list):
170
+ for index, value in enumerate(node):
171
+ found.update(_template_strings(value, f"{location}[{index}]"))
172
+ elif isinstance(node, str) and expressions.TEMPLATE.search(node):
173
+ found[location] = node
174
+ return found
175
+
176
+
177
+ def _waits_are_decidable(
178
+ steps: list[tuple[dict[str, Any], str]], path: str, report: Report
179
+ ) -> None:
180
+ """A `wait` says either how long or what for, and never both or neither.
181
+
182
+ The schema declares `duration`, `until_event` and `deadline` as three independent
183
+ optional fields, which makes all four combinations schema-valid — including the two a
184
+ runner cannot act on. `duration` sleeps and `until_event` waits to be told; a step
185
+ carrying both is a document whose author meant one of them, and choosing for them would
186
+ be a runner deciding what a workflow does.
187
+
188
+ The interpreter refuses the same two shapes, because a bundle built before this rule
189
+ existed is still loadable. This is the half that means nobody ships one.
190
+ """
191
+ for step, location in steps:
192
+ if step.get("kind") != "wait":
193
+ continue
194
+ duration, event = step.get("duration"), step.get("until_event")
195
+ if duration and event:
196
+ report.fail(
197
+ "wait-declares-both",
198
+ f"step {step.get('id')!r} is a wait with both `duration` and "
199
+ "`until_event`. One sleeps for a fixed time and the other holds until "
200
+ "something arrives — declare the one that was meant",
201
+ path=path,
202
+ location=location,
203
+ )
204
+ elif not duration and not event:
205
+ report.fail(
206
+ "wait-declares-nothing",
207
+ f"step {step.get('id')!r} is a wait with neither `duration` nor "
208
+ "`until_event`, so nothing says what it waits for or for how long. A run "
209
+ "reaching it would stop until its deadline and then fail",
210
+ path=path,
211
+ location=location,
212
+ )
213
+
214
+
215
+ def _step_ids_unique(steps: list[tuple[dict[str, Any], str]], path: str, report: Report) -> None:
216
+ seen: dict[str, str] = {}
217
+ for step, location in steps:
218
+ step_id = step.get("id")
219
+ if not step_id:
220
+ continue
221
+ if step_id in seen:
222
+ report.fail(
223
+ "duplicate-step-id",
224
+ f"step id {step_id!r} is already used at {seen[step_id]}; "
225
+ "step ids are how results are referenced and how attempts are audited",
226
+ path=path,
227
+ location=location,
228
+ )
229
+ else:
230
+ seen[step_id] = location
231
+
232
+
233
+ def _machines_present(
234
+ steps: list[tuple[dict[str, Any], str]], path: str, machines: set[str], report: Report
235
+ ) -> None:
236
+ for step, location in steps:
237
+ if step.get("kind") != "mklang":
238
+ continue
239
+ machine = step.get("machine")
240
+ if machine and machine not in machines:
241
+ report.fail(
242
+ "machine-missing",
243
+ f"machine {machine!r} is not in the bundle; a bundle pins everything that "
244
+ "affects behaviour (ADR 0008)",
245
+ path=path,
246
+ location=f"{location}.machine",
247
+ )
248
+
249
+
250
+ def _declared_names(
251
+ workflow: dict[str, Any],
252
+ path: str,
253
+ declared_connections: set[str],
254
+ declared_secrets: set[str],
255
+ report: Report,
256
+ ) -> None:
257
+ for name in sorted(connections(workflow) - declared_connections):
258
+ report.fail(
259
+ "undeclared-connection",
260
+ f"connection {name!r} is used but not declared in requires.connections; "
261
+ "a missing connection must fail the deploy, not the first run",
262
+ path=path,
263
+ )
264
+ for name in sorted(secret_refs(workflow) - declared_secrets):
265
+ report.fail(
266
+ "undeclared-secret",
267
+ f"secretRef {name!r} is used but not declared in requires.secrets",
268
+ path=path,
269
+ )
270
+
271
+
272
+ def _connector_actions(
273
+ steps: list[tuple[dict[str, Any], str]],
274
+ path: str,
275
+ connector_pins: dict[str, int],
276
+ catalog: dict[str, Connector] | None,
277
+ report: Report,
278
+ ) -> None:
279
+ for step, location in steps:
280
+ if step.get("kind") != "connector":
281
+ continue
282
+ connector_name = step.get("connector")
283
+ action_name = step.get("action")
284
+ if not connector_name or not action_name:
285
+ continue
286
+
287
+ if connector_name not in connector_pins:
288
+ report.fail(
289
+ "unknown-connector",
290
+ f"connector {connector_name!r} is not pinned in lock.connectors; "
291
+ "a newer manifest requires a new bundle",
292
+ path=path,
293
+ location=f"{location}.connector",
294
+ )
295
+ continue
296
+
297
+ if catalog is None:
298
+ continue
299
+
300
+ connector = catalog.get(connector_name)
301
+ if connector is None:
302
+ report.fail(
303
+ "unknown-connector",
304
+ f"connector {connector_name!r} is not in the catalog",
305
+ path=path,
306
+ location=f"{location}.connector",
307
+ )
308
+ continue
309
+
310
+ if connector.version != connector_pins[connector_name]:
311
+ report.fail(
312
+ "connector-version-mismatch",
313
+ f"lock pins {connector_name} v{connector_pins[connector_name]} but the "
314
+ f"catalog holds v{connector.version}",
315
+ path=path,
316
+ location=f"{location}.connector",
317
+ )
318
+
319
+ action = connector.action(action_name)
320
+ if action is None:
321
+ report.fail(
322
+ "unknown-action",
323
+ f"{connector_name} has no action {action_name!r}",
324
+ path=path,
325
+ location=f"{location}.action",
326
+ )
327
+ continue
328
+
329
+ if action.get("effect") == "write" and not step.get("idempotency_key"):
330
+ report.fail(
331
+ "missing-idempotency-key",
332
+ f"{connector_name}.{action_name} is effect: write and this step has no "
333
+ "idempotency_key; durable execution is at-least-once, so without a key a "
334
+ "crash or a retry duplicates the effect (ADR 0009 Amendment 1)",
335
+ path=path,
336
+ location=location,
337
+ )
338
+
339
+
340
+ def check_machine_gates(text: str, path: str) -> Report:
341
+ """Every `hook:` a machine declares names a gate the platform can resolve (`C21`).
342
+
343
+ mklang resolves a hook by name and answers `None` for one it does not know — and a gate
344
+ it cannot resolve **halts the run when the gate is reached**. Not at load, not at the
345
+ first state: when that gate is reached, which may be one model call later and only for
346
+ some inputs. So a bundle that names one is a bundle that builds, deploys, and then stops
347
+ somewhere expensive.
348
+
349
+ What a gate may name is `mkp_contracts.gates` — the platform's declarative vocabulary,
350
+ plus the two mklang builtins and the two parametric forms a machine may keep using. It
351
+ deliberately cannot name a Python predicate: ADR 0003 runs machines in the runner process
352
+ *because* "there is no customer code in a `.mkl`, only data", and a bundle carrying code
353
+ for its gates would take that reason away.
354
+
355
+ Read as YAML rather than through mklang, because this is the builder: `mklang check` has
356
+ already run over the same file and has no opinion about which hooks a host provides.
357
+ """
358
+ report = Report()
359
+ try:
360
+ document = yaml.safe_load(text)
361
+ except yaml.YAMLError:
362
+ # `mklang check` owns "this file does not parse" and says it better. A second,
363
+ # vaguer copy of that failure would be two rules for one mistake.
364
+ return report
365
+ if not isinstance(document, dict):
366
+ return report
367
+
368
+ states = document.get("states")
369
+ if not isinstance(states, dict):
370
+ return report
371
+
372
+ for state_id, state in states.items():
373
+ if not isinstance(state, dict):
374
+ continue
375
+ for index, gate in enumerate(state.get("gates") or []):
376
+ if not isinstance(gate, dict):
377
+ continue
378
+ hook = gate.get("hook")
379
+ if not hook or gates.resolvable(hook):
380
+ continue
381
+ report.fail(
382
+ "machine-gate-unresolvable",
383
+ gates.reason(hook),
384
+ path=path,
385
+ location=f"states.{state_id}.gates[{index}].hook",
386
+ )
387
+ return report
388
+
389
+
390
+ def check_spec_versions(specs: dict[str, str]) -> Report:
391
+ """Every machine in a bundle must target one language spec version.
392
+
393
+ Two versions in one bundle means `lock.spec` cannot honestly say what the
394
+ machines were validated against.
395
+ """
396
+ report = Report()
397
+ distinct = sorted(set(specs.values()))
398
+ if len(distinct) > 1:
399
+ detail = ", ".join(f"{path} -> {spec}" for path, spec in sorted(specs.items()))
400
+ report.fail(
401
+ "spec-mismatch",
402
+ f"machines declare more than one mklang spec version ({', '.join(distinct)}): {detail}",
403
+ )
404
+ return report
405
+
406
+
407
+ def read_spec_version(text: str) -> str | None:
408
+ """The `mklang:` key a `.mkl` declares — the language spec it targets."""
409
+ for line in text.splitlines():
410
+ stripped = line.strip()
411
+ if stripped.startswith("mklang:"):
412
+ return stripped.split(":", 1)[1].strip().strip("\"'")
413
+ return None
414
+
415
+
416
+ def machine_paths(paths: list[Path]) -> set[str]:
417
+ return {p.as_posix() for p in paths}
mkbundle/signing.py ADDED
@@ -0,0 +1,87 @@
1
+ """Signing a bundle with minisign. Verification lives in `mkp_contracts`.
2
+
3
+ A runner refuses an unsigned or unverifiable bundle (ADR 0008), so the signature is what
4
+ makes a pulled artifact trustworthy in a plane we may not operate. The algorithm is
5
+ minisign at launch and cosign when an audit needs provenance, which is why
6
+ `signature.algorithm` exists in the schema rather than being assumed.
7
+
8
+ **Signing is a build-time act; verifying is a runtime one.** This module signs, and
9
+ re-exports the verifier so the builder can check its own output — but the verifier
10
+ itself is in `mkp_contracts.signature`, because the runner has to do the same check at
11
+ load and must not depend on a build tool to do it.
12
+
13
+ What is signed is exactly the canonical manifest bytes — the same bytes the digest is
14
+ taken over, so there is one canonical form and not two. `signature` is outside that
15
+ form, which is what lets a signature be inserted into a manifest without changing the
16
+ bundle's identity.
17
+
18
+ `signature.value` holds the whole `.minisig` file, base64-encoded: it round-trips to
19
+ bytes minisign verifies as-is, and it stays a single JSON-friendly line.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import subprocess
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+ from mkp_contracts.signature import ALGORITHM, CANONICAL_NAME, available, key_id, verify
30
+
31
+ from .errors import BuildError
32
+
33
+
34
+ def _require() -> None:
35
+ if not available():
36
+ raise BuildError("minisign is not on PATH; a bundle cannot be signed or verified")
37
+
38
+
39
+ def sign(canonical: bytes, secret_key: Path, public_key: Path, *, digest: str) -> dict[str, str]:
40
+ """Sign the canonical manifest, returning the manifest's `signature` object.
41
+
42
+ The trusted comment carries the bundle digest rather than minisign's default
43
+ (a timestamp and the temporary file's name), so what the signature asserts is
44
+ legible without unpacking anything.
45
+ """
46
+ _require()
47
+ with tempfile.TemporaryDirectory() as workspace:
48
+ target = Path(workspace) / CANONICAL_NAME
49
+ target.write_bytes(canonical)
50
+ signature_path = target.with_suffix(".minisig")
51
+
52
+ # A fixed argv list and no shell. The key path is the operator's own argument
53
+ # and reaches the process as one list element, never as text to be parsed.
54
+ completed = subprocess.run(
55
+ [
56
+ ALGORITHM,
57
+ "-S",
58
+ "-s",
59
+ str(secret_key),
60
+ "-m",
61
+ str(target),
62
+ "-x",
63
+ str(signature_path),
64
+ "-t",
65
+ f"bundle:{digest}",
66
+ "-c",
67
+ "mklang-platform bundle",
68
+ ],
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ if completed.returncode != 0:
73
+ raise BuildError(
74
+ f"minisign refused to sign (exit {completed.returncode}): "
75
+ f"{completed.stderr.strip() or completed.stdout.strip()}. "
76
+ "A build key must be unencrypted — minisign prompts on a terminal otherwise"
77
+ )
78
+ payload = signature_path.read_bytes()
79
+
80
+ return {
81
+ "algorithm": ALGORITHM,
82
+ "key_id": key_id(public_key),
83
+ "value": base64.b64encode(payload).decode("ascii"),
84
+ }
85
+
86
+
87
+ __all__ = ["ALGORITHM", "available", "key_id", "sign", "verify"]
mkbundle/source.py ADDED
@@ -0,0 +1,107 @@
1
+ """Reading a bundle source directory.
2
+
3
+ src/
4
+ bundle.yaml project, pinned connector versions, requires
5
+ workflows/*.yaml one workflow definition each
6
+ machines/*.mkl the machines those workflows commission
7
+ assets/** templates, prompts, fixtures
8
+
9
+ `bundle.yaml` declares intent. Everything a build observes — the file inventory,
10
+ their digests, the mklang and spec versions actually used, the build time — is an
11
+ output and may not be declared, because a declared value can disagree with what
12
+ happened.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from .errors import BuildError
24
+
25
+ SOURCE_MANIFEST = "bundle.yaml"
26
+
27
+ _DECLARABLE = {"schema_version", "project", "source", "lock", "requires"}
28
+ _PRODUCED = {"created", "contents", "signature"}
29
+ _PRODUCED_LOCK = {"mklang", "spec"}
30
+
31
+ _WORKFLOW_SUFFIXES = (".yaml", ".yml", ".json")
32
+
33
+
34
+ @dataclass
35
+ class BundleSource:
36
+ root: Path
37
+ project: str
38
+ declared: dict[str, Any]
39
+ workflows: list[Path] = field(default_factory=list)
40
+ machines: list[Path] = field(default_factory=list)
41
+ assets: list[Path] = field(default_factory=list)
42
+
43
+ @property
44
+ def connector_pins(self) -> dict[str, int]:
45
+ return dict(self.declared.get("lock", {}).get("connectors", {}))
46
+
47
+ @property
48
+ def requires(self) -> dict[str, Any]:
49
+ return dict(self.declared.get("requires", {}))
50
+
51
+ def read(self, relative: Path) -> bytes:
52
+ return (self.root / relative).read_bytes()
53
+
54
+
55
+ def load(root: Path) -> BundleSource:
56
+ root = root.resolve()
57
+ manifest_path = root / SOURCE_MANIFEST
58
+ if not manifest_path.is_file():
59
+ raise BuildError(f"{root}: no {SOURCE_MANIFEST} — this is not a bundle source directory")
60
+
61
+ declared = yaml.safe_load(manifest_path.read_text()) or {}
62
+ if not isinstance(declared, dict):
63
+ raise BuildError(f"{manifest_path}: expected a mapping")
64
+
65
+ _reject_produced_fields(declared, manifest_path)
66
+
67
+ project = declared.get("project")
68
+ if not project:
69
+ raise BuildError(f"{manifest_path}: `project` is required")
70
+
71
+ return BundleSource(
72
+ root=root,
73
+ project=project,
74
+ declared=declared,
75
+ workflows=_collect(root, "workflows", _WORKFLOW_SUFFIXES),
76
+ machines=_collect(root, "machines", (".mkl",)),
77
+ assets=_collect(root, "assets", None),
78
+ )
79
+
80
+
81
+ def _reject_produced_fields(declared: dict[str, Any], path: Path) -> None:
82
+ """Refuse a source manifest that declares what the build observes."""
83
+ for key in sorted(_PRODUCED & declared.keys()):
84
+ raise BuildError(
85
+ f"{path}: `{key}` is produced by the build and must not be declared — "
86
+ "a declared value can disagree with what was actually built"
87
+ )
88
+ for key in sorted(_PRODUCED_LOCK & set(declared.get("lock", {}))):
89
+ raise BuildError(
90
+ f"{path}: `lock.{key}` is read off the toolchain that validates the bundle "
91
+ "and must not be declared"
92
+ )
93
+ unknown = sorted(declared.keys() - _DECLARABLE)
94
+ if unknown:
95
+ raise BuildError(f"{path}: unknown field(s) {', '.join(unknown)}")
96
+
97
+
98
+ def _collect(root: Path, subdir: str, suffixes: tuple[str, ...] | None) -> list[Path]:
99
+ directory = root / subdir
100
+ if not directory.is_dir():
101
+ return []
102
+ found = [
103
+ p
104
+ for p in sorted(directory.rglob("*"))
105
+ if p.is_file() and (suffixes is None or p.suffix in suffixes)
106
+ ]
107
+ return [p.relative_to(root) for p in found]
mkbundle/toolchain.py ADDED
@@ -0,0 +1,86 @@
1
+ """The mklang toolchain, called as a subprocess.
2
+
3
+ mklang is a pinned dependency and stays a language (ADR 0010): the platform runs
4
+ its CLI, it does not reimplement `check`. The versions recorded in a bundle's
5
+ lock are the ones that actually validated it, read off the toolchain rather than
6
+ declared by the author — a declared version can be wrong, an observed one cannot.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ from .errors import BuildError, Report
18
+
19
+ _VERSION_RE = re.compile(r"(\d+\.\d+\.\d+\S*)")
20
+
21
+
22
+ def available() -> bool:
23
+ return shutil.which("mklang") is not None
24
+
25
+
26
+ def version() -> str:
27
+ """The mklang package version, for `lock.mklang`."""
28
+ if not available():
29
+ raise BuildError("mklang is not on PATH; the bundle's machines cannot be validated")
30
+ # `mklang` by name, from PATH, on purpose (S607): the version it reports is what
31
+ # goes into the bundle's lock, so the toolchain that validated a bundle is the one the
32
+ # builder had installed. A hard-coded path would be wrong on the next machine.
33
+ out = subprocess.run(
34
+ ["mklang", "--version"],
35
+ capture_output=True,
36
+ text=True,
37
+ check=True,
38
+ ).stdout.strip()
39
+ match = _VERSION_RE.search(out)
40
+ if not match:
41
+ raise BuildError(f"cannot read a version out of `mklang --version` output: {out!r}")
42
+ return match.group(1)
43
+
44
+
45
+ def check(machines: list[Path], root: Path) -> Report:
46
+ """Run `mklang check` over every machine in the bundle.
47
+
48
+ A bundle whose machines do not check does not become a deployment candidate
49
+ (ADR 0008), so this is a build-time gate and not a warning.
50
+ """
51
+ report = Report()
52
+ if not machines:
53
+ return report
54
+ if not available():
55
+ raise BuildError("mklang is not on PATH; the bundle's machines cannot be validated")
56
+
57
+ # Same reasoning as `version()`: by name from PATH, fixed argv, no shell. The
58
+ # machine paths are inside the bundle root and arrive as separate list elements.
59
+ completed = subprocess.run(
60
+ ["mklang", "check", "--format", "json", *[str(root / m) for m in machines]],
61
+ capture_output=True,
62
+ text=True,
63
+ )
64
+ try:
65
+ result = json.loads(completed.stdout)
66
+ except json.JSONDecodeError as exc:
67
+ raise BuildError(
68
+ f"`mklang check` produced output that is not JSON (exit {completed.returncode}): "
69
+ f"{completed.stdout[:400]}{completed.stderr[:400]}"
70
+ ) from exc
71
+
72
+ for item in result.get("items", []):
73
+ relative = Path(item["path"]).resolve().relative_to(root.resolve()).as_posix()
74
+ for error in item.get("errors", []):
75
+ report.fail("machine-check", _describe(error), path=relative)
76
+ if item.get("status") not in ("ok", "warning") and not item.get("errors"):
77
+ report.fail("machine-check", f"status {item.get('status')!r}", path=relative)
78
+ return report
79
+
80
+
81
+ def _describe(error: object) -> str:
82
+ if isinstance(error, dict):
83
+ code = error.get("code") or error.get("kind") or "error"
84
+ message = error.get("message") or error.get("detail") or ""
85
+ return f"{code}: {message}".strip(": ")
86
+ return str(error)