mkbundle 0.2.0__tar.gz → 1.1.21__tar.gz

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.
Files changed (30) hide show
  1. {mkbundle-0.2.0 → mkbundle-1.1.21}/.gitignore +8 -0
  2. {mkbundle-0.2.0 → mkbundle-1.1.21}/PKG-INFO +2 -2
  3. {mkbundle-0.2.0 → mkbundle-1.1.21}/README.md +30 -21
  4. {mkbundle-0.2.0 → mkbundle-1.1.21}/pyproject.toml +1 -1
  5. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/__init__.py +1 -1
  6. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/build.py +12 -7
  7. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/cli.py +36 -4
  8. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/rules.py +32 -0
  9. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/conftest.py +18 -0
  10. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/connectors/microsoft-graph/connector.yaml +1 -1
  11. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_build.py +23 -0
  12. mkbundle-1.1.21/tests/test_machine_capabilities.py +15 -0
  13. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_rules.py +44 -0
  14. mkbundle-1.1.21/tests/test_validate.py +126 -0
  15. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_version.py +12 -0
  16. {mkbundle-0.2.0 → mkbundle-1.1.21}/uv.lock +2 -2
  17. mkbundle-0.2.0/tests/test_validate.py +0 -56
  18. {mkbundle-0.2.0 → mkbundle-1.1.21}/LICENSE +0 -0
  19. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/catalog.py +0 -0
  20. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/errors.py +0 -0
  21. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/py.typed +0 -0
  22. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/signing.py +0 -0
  23. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/source.py +0 -0
  24. {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/toolchain.py +0 -0
  25. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/__init__.py +0 -0
  26. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/connectors/slack/connector.yaml +0 -0
  27. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/bundle.yaml +0 -0
  28. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/machines/triage.mkl +0 -0
  29. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/workflows/triage.yaml +0 -0
  30. {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_signing.py +0 -0
@@ -10,6 +10,7 @@ build/
10
10
  .ruff_cache/
11
11
  .pytest_cache/
12
12
  .coverage
13
+ web/app/coverage/
13
14
  htmlcov/
14
15
 
15
16
  # Node (web/)
@@ -34,6 +35,10 @@ deploy/backups/
34
35
  # Runtime state
35
36
  .mklang-platform/
36
37
  *.db
38
+ # SQLite in WAL mode, which is what the BFF's session store asks for: running the
39
+ # service or its tests leaves these two beside the ignored database.
40
+ *.db-wal
41
+ *.db-shm
37
42
  *.sqlite3
38
43
  run-artifacts/
39
44
 
@@ -41,3 +46,6 @@ run-artifacts/
41
46
  .DS_Store
42
47
  .idea/
43
48
  .vscode/
49
+
50
+ # The site pin is data the host pulls — tracked on purpose (deploy-site.yml writes it).
51
+ !deploy/pins/caddy.env
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: mkbundle
3
- Version: 0.2.0
3
+ Version: 1.1.21
4
4
  Summary: Build, validate and sign bundles (ADR 0008)
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,11 +1,18 @@
1
1
  # mkbundle — bundle build and validation
2
2
 
3
3
  The gate between authoring and everything downstream. A bundle that fails here never
4
- becomes a deployment candidate ([ADR 0008](../../docs/adr/0008-bundles-and-pull-based-deployment.md)).
4
+ becomes a deployment candidate ([ADR 0008](../../docs/adr/0008-bundles-and-pull-based-deployment.md));
5
+ one that passes is what [`mkctl bundle push`](../mkctl/README.md) registers and uploads.
5
6
 
6
7
  ```
7
8
  mkbundle validate <path>... a workflow, connector manifest, or bundle manifest
8
- mkbundle build <src> -o <out> validate a source tree and archive it
9
+ (--kind to skip detection and name the schema)
10
+ mkbundle validate-connectors <dir> validate every connector manifest in a deployment
11
+ manifest set before a bundle is signed
12
+ mkbundle build <src> -o <out> validate a source tree and archive it; `-o` is
13
+ `--output`, and omitting it validates without writing.
14
+ A target without a .tar.gz suffix is a directory
15
+ (created if missing); the archive is named in it
9
16
  mkbundle inspect <archive> the manifest and digest of a built bundle
10
17
  mkbundle verify <archive> its signature, and its files against the manifest
11
18
  ```
@@ -30,8 +37,9 @@ what actually validated the bundle; an observed one cannot.
30
37
 
31
38
  ## Identity
32
39
 
33
- A bundle's identity is the sha256 of its canonicalized manifest. Three fields are
34
- outside that canonical form:
40
+ A bundle's identity is the sha256 of its canonicalized manifest — computed by
41
+ [`mkp-contracts`](../../packages/mkp-contracts/README.md), which is also what the control
42
+ plane recomputes it with. Three fields are outside that canonical form:
35
43
 
36
44
  | Field | Why |
37
45
  | ----------- | --------------------------------------------------------------------------------------- |
@@ -52,23 +60,24 @@ no filename in the gzip header — so identical content also produces identical
52
60
  Schema validation is the shape. These are the rules, and they are build-time because
53
61
  each is answerable from the bundle alone (`schema/README.md`):
54
62
 
55
- | Rule | What it catches |
56
- | ---------------------------- | -------------------------------------------------------- |
57
- | `missing-idempotency-key` | An `effect: write` action with no key — see below |
58
- | `machine-missing` | An mklang step pointing outside the bundle |
59
- | `machine-check` | A machine `mklang check` rejects |
60
- | `spec-mismatch` | Machines targeting different language spec versions |
61
- | `undeclared-connection` | A connection used but not in `requires` |
62
- | `undeclared-secret` | A secretRef used but not in `requires` |
63
- | `unknown-connector` | A connector not pinned in `lock.connectors` |
64
- | `connector-version-mismatch` | A pin that disagrees with the catalog |
65
- | `unknown-action` | An action the connector manifest does not have |
66
- | `duplicate-step-id` | Two steps sharing an id within a workflow |
67
- | `wait-declares-both` | A `wait` with both `duration` and `until_event` |
68
- | `wait-declares-nothing` | A `wait` with neither, so nothing says what it waits for |
69
- | `machine-gate-unresolvable` | A machine gating on a hook the platform cannot answer |
70
- | `duplicate-workflow-name` | Two workflows sharing a name within the bundle |
71
- | `schema` | A document that does not match its contract |
63
+ | Rule | What it catches |
64
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `missing-idempotency-key` | An `effect: write` action with no key — see below |
66
+ | `machine-missing` | An mklang step pointing outside the bundle |
67
+ | `machine-check` | A machine `mklang check` rejects |
68
+ | `spec-mismatch` | Machines targeting different language spec versions |
69
+ | `undeclared-connection` | A connection used but not in `requires` |
70
+ | `undeclared-secret` | A secretRef used but not in `requires` |
71
+ | `unknown-connector` | A connector not pinned in `lock.connectors` |
72
+ | `connector-version-mismatch` | A pin that disagrees with the catalog |
73
+ | `unknown-action` | An action the connector manifest does not have |
74
+ | `duplicate-step-id` | Two steps sharing an id within a workflow |
75
+ | `wait-declares-both` | A `wait` with both `duration` and `until_event` |
76
+ | `wait-declares-nothing` | A `wait` with neither, so nothing says what it waits for |
77
+ | `connector-timeout-out-of-bounds` | A connector step `timeout` above 300 s, zero, or not a duration ([ADR 0033](../../docs/adr/0033-a-connector-step-timeout-bounds-its-call.md)) |
78
+ | `machine-gate-unresolvable` | A machine gating on a hook the platform cannot answer |
79
+ | `duplicate-workflow-name` | Two workflows sharing a name within the bundle |
80
+ | `schema` | A document that does not match its contract |
72
81
 
73
82
  `missing-idempotency-key` is the one that matters most. Durable execution is
74
83
  at-least-once for external effects ([ADR 0009](../../docs/adr/0009-durable-execution-engine.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkbundle"
3
- version = "0.2.0"
3
+ version = "1.1.21"
4
4
  description = "Build, validate and sign bundles (ADR 0008)"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
@@ -9,4 +9,4 @@ that name belongs to its roles, its settings and its services rather than to one
9
9
  its tools.
10
10
  """
11
11
 
12
- __version__ = "0.1.0"
12
+ __version__ = "1.1.21"
@@ -186,12 +186,10 @@ def _assemble(
186
186
  spec_versions = sorted(set(specs.values()))
187
187
  if not spec_versions:
188
188
  declared_spec = bundle.declared.get("lock", {}).get("spec")
189
- if not declared_spec:
190
- raise BuildError(
191
- "the bundle has no machines, so `lock.spec` cannot be observed — "
192
- "declare it in bundle.yaml or add a machine"
193
- )
194
- spec_versions = [str(declared_spec)]
189
+ # An explicit no-workload bundle has no machine from which to observe a spec.
190
+ # Keep its identity deterministic without making ordinary source manifests
191
+ # declare a produced lock field.
192
+ spec_versions = [str(declared_spec or "0.3")]
195
193
 
196
194
  contents: dict[str, Any] = {
197
195
  "workflows": [_file_entry(bundle, p) for p in bundle.workflows],
@@ -232,7 +230,14 @@ def _write_archive(
232
230
  archive is what makes "nothing is rebuilt on promote" checkable rather than
233
231
  merely asserted.
234
232
  """
235
- target = output / f"{bundle.project}-{digest[:12]}.tar.gz" if output.is_dir() else output
233
+ # A target without a suffix is a directory to fill, not a file to write:
234
+ # `-o out/` loses its trailing slash inside Path, so on a fresh checkout
235
+ # `is_dir()` alone wrote a single extensionless file named `out`.
236
+ if output.is_dir() or not output.suffix:
237
+ output.mkdir(parents=True, exist_ok=True)
238
+ target = output / f"{bundle.project}-{digest[:12]}.tar.gz"
239
+ else:
240
+ target = output
236
241
  target.parent.mkdir(parents=True, exist_ok=True)
237
242
 
238
243
  full_manifest = json.dumps(
@@ -1,4 +1,4 @@
1
- """The `mkp` command line.
1
+ """The `mkbundle` command line.
2
2
 
3
3
  Three verbs: validate a document, build a bundle, inspect a built one. One tool
4
4
  rather than a builder and a separate validator, because the rules would otherwise
@@ -17,6 +17,7 @@ from mkp_contracts import schemas
17
17
  from mkp_contracts.canonical import manifest_digest
18
18
 
19
19
  from . import build as build_module
20
+ from .catalog import load as load_catalog
20
21
  from .errors import BuildError, Report
21
22
 
22
23
 
@@ -34,15 +35,19 @@ def main(argv: list[str] | None = None) -> int:
34
35
  subparsers = parser.add_subparsers(dest="command", required=True)
35
36
 
36
37
  validate = subparsers.add_parser(
37
- "validate", help="validate a workflow, connector manifest, or bundle manifest"
38
+ "validate", help="validate a workflow, connector, bundle, or report manifest"
38
39
  )
39
40
  validate.add_argument("paths", nargs="+", type=Path)
40
41
  validate.add_argument(
41
42
  "--kind",
42
- choices=schemas.KINDS,
43
+ choices=schemas.DOCUMENT_KINDS,
43
44
  help="skip detection and validate against this schema",
44
45
  )
45
46
 
47
+ subparsers.add_parser(
48
+ "validate-connectors", help="validate every connector manifest in a manifest set"
49
+ ).add_argument("directory", type=Path)
50
+
46
51
  build = subparsers.add_parser("build", help="validate a bundle source tree and archive it")
47
52
  build.add_argument("src", type=Path)
48
53
  build.add_argument(
@@ -72,6 +77,8 @@ def main(argv: list[str] | None = None) -> int:
72
77
  try:
73
78
  if args.command == "validate":
74
79
  return _validate(args)
80
+ if args.command == "validate-connectors":
81
+ return _validate_connectors(args)
75
82
  if args.command == "build":
76
83
  return _build(args)
77
84
  if args.command == "verify":
@@ -101,6 +108,31 @@ def _validate(args: argparse.Namespace) -> int:
101
108
  return _report(report, args.format, ok_message=f"{len(args.paths)} document(s) valid")
102
109
 
103
110
 
111
+ def _validate_connectors(args: argparse.Namespace) -> int:
112
+ report = Report()
113
+ try:
114
+ manifests = load_catalog(args.directory)
115
+ except BuildError as exc:
116
+ report.fail("connector-manifests", str(exc), path=str(args.directory))
117
+ return _report(report, args.format, ok_message="connector manifests valid")
118
+ if not manifests:
119
+ report.fail(
120
+ "empty-connector-manifests",
121
+ "no connector manifests found",
122
+ path=str(args.directory),
123
+ )
124
+ for name, connector in manifests.items():
125
+ document = schemas.load_document(connector.path)
126
+ report.extend(schemas.validate_document(document, "connector", path=str(connector.path)))
127
+ if connector.path.parent.name != name:
128
+ report.fail(
129
+ "catalog-name-mismatch",
130
+ f"manifest name {name!r} does not match its directory",
131
+ path=str(connector.path),
132
+ )
133
+ return _report(report, args.format, ok_message=f"{len(manifests)} connector manifest(s) valid")
134
+
135
+
104
136
  def _build(args: argparse.Namespace) -> int:
105
137
  connectors = args.connectors
106
138
  if connectors is None:
@@ -195,7 +227,7 @@ def _emit_error(message: str, output_format: str) -> None:
195
227
  if output_format == "json":
196
228
  print(json.dumps({"ok": False, "error": message}, indent=2))
197
229
  else:
198
- print(f"mkp: {message}", file=sys.stderr)
230
+ print(f"mkbundle: {message}", file=sys.stderr)
199
231
 
200
232
 
201
233
  if __name__ == "__main__":
@@ -18,7 +18,9 @@ from pathlib import Path
18
18
  from typing import Any
19
19
 
20
20
  import yaml
21
+ from mkp_contracts import connectors as connector_contracts
21
22
  from mkp_contracts import expressions, gates
23
+ from mkp_contracts.capabilities import machine_tool_refusal
22
24
 
23
25
  from .catalog import Connector
24
26
  from .errors import Report
@@ -101,6 +103,7 @@ def check_workflow(
101
103
  _declared_names(workflow, path, declared_connections, declared_secrets, report)
102
104
  _connector_actions(steps, path, connector_pins, catalog, report)
103
105
  _waits_are_decidable(steps, path, report)
106
+ _connector_timeouts_within_cap(steps, path, report)
104
107
  _expressions_are_evaluable(workflow, path, report)
105
108
  _schedule_can_satisfy_inputs(workflow, path, report)
106
109
 
@@ -212,6 +215,28 @@ def _waits_are_decidable(
212
215
  )
213
216
 
214
217
 
218
+ def _connector_timeouts_within_cap(
219
+ steps: list[tuple[dict[str, Any], str]], path: str, report: Report
220
+ ) -> None:
221
+ """A connector step's `timeout` bounds its HTTP call, up to the platform cap (ADR 0033).
222
+
223
+ Refused here rather than clamped at run time: a bundle asking for more than the cap is
224
+ a bundle whose author expects a call the runner will not wait for. The runner refuses
225
+ the same step terminally, because a bundle signed before this rule is still loadable.
226
+ """
227
+ for step, location in steps:
228
+ if step.get("kind") != "connector" or "timeout" not in step:
229
+ continue
230
+ refusal = connector_contracts.connector_timeout_refusal(step["timeout"])
231
+ if refusal:
232
+ report.fail(
233
+ "connector-timeout-out-of-bounds",
234
+ f"step {step.get('id')!r}: {refusal}",
235
+ path=path,
236
+ location=f"{location}.timeout",
237
+ )
238
+
239
+
215
240
  def _step_ids_unique(steps: list[tuple[dict[str, Any], str]], path: str, report: Report) -> None:
216
241
  seen: dict[str, str] = {}
217
242
  for step, location in steps:
@@ -372,6 +397,13 @@ def check_machine_gates(text: str, path: str) -> Report:
372
397
  for state_id, state in states.items():
373
398
  if not isinstance(state, dict):
374
399
  continue
400
+ if state.get("tool") is not None:
401
+ report.fail(
402
+ "machine-tool-forbidden",
403
+ machine_tool_refusal(str(state["tool"])),
404
+ path=path,
405
+ location=f"states.{state_id}.tool",
406
+ )
375
407
  for index, gate in enumerate(state.get("gates") or []):
376
408
  if not isinstance(gate, dict):
377
409
  continue
@@ -29,6 +29,24 @@ def valid_bundle() -> Path:
29
29
  return VALID
30
30
 
31
31
 
32
+ @pytest.fixture
33
+ def empty_bundle(tmp_path: Path) -> Path:
34
+ root = tmp_path / "empty"
35
+ (root / "workflows").mkdir(parents=True)
36
+ (root / "machines").mkdir()
37
+ (root / "assets").mkdir()
38
+ (root / "bundle.yaml").write_text(
39
+ "schema_version: 1\n"
40
+ "project: empty\n"
41
+ "lock:\n"
42
+ " connectors: {}\n"
43
+ "requires:\n"
44
+ " connections: []\n",
45
+ encoding="utf-8",
46
+ )
47
+ return root
48
+
49
+
32
50
  @pytest.fixture
33
51
  def variant(tmp_path: Path) -> Callable[[Callable[[Path], None]], Path]:
34
52
  """A copy of the valid bundle with one thing changed."""
@@ -12,7 +12,7 @@ egress:
12
12
  auth:
13
13
  kind: oauth2_authcode
14
14
  authorize_url: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
15
- token_url: https://login.microsoftonline.com/common/oauth2/v2.0/token
15
+ token_url: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
16
16
  scopes:
17
17
  - Mail.ReadWrite
18
18
  - Mail.Send
@@ -19,6 +19,16 @@ def test_valid_bundle_builds(valid_bundle: Path, connectors: Path) -> None:
19
19
  assert len(result.digest) == 64
20
20
 
21
21
 
22
+ def test_explicit_empty_bundle_builds(empty_bundle: Path, connectors: Path) -> None:
23
+ """An organisation with no enabled plugins still needs a signed deployable pointer."""
24
+ result = build_module.build(empty_bundle, connectors_dir=connectors)
25
+
26
+ assert result.report.ok, [str(f) for f in result.report.failures]
27
+ assert result.manifest["contents"]["workflows"] == []
28
+ assert result.manifest["contents"]["machines"] == []
29
+ assert result.manifest["lock"]["spec"] == "0.3"
30
+
31
+
22
32
  def test_manifest_records_what_the_build_observed(valid_bundle: Path, connectors: Path) -> None:
23
33
  manifest = build_module.build(valid_bundle, connectors_dir=connectors).manifest
24
34
 
@@ -93,3 +103,16 @@ def test_without_a_catalog_the_skipped_rules_are_named(valid_bundle: Path) -> No
93
103
 
94
104
  assert result.report.ok
95
105
  assert any("missing-idempotency-key" in rule for rule in result.report.skipped_rules)
106
+
107
+
108
+ def test_a_directory_output_that_does_not_exist_yet_is_still_a_directory(
109
+ valid_bundle: Path, connectors: Path, tmp_path: Path
110
+ ) -> None:
111
+ """`-o out/` on a fresh checkout used to write a single extensionless file
112
+ named `out` — Path drops the trailing slash, so `is_dir()` was the only
113
+ signal and a not-yet-created directory failed it."""
114
+ out = tmp_path / "out"
115
+ result = build_module.build(valid_bundle, connectors_dir=connectors, output=out)
116
+ assert result.archive is not None
117
+ assert result.archive.parent == out
118
+ assert result.archive.name.endswith(".tar.gz")
@@ -0,0 +1,15 @@
1
+ """Build-time capability admission uses the runner's closed policy."""
2
+
3
+ import pytest
4
+
5
+ from mkbundle.rules import check_machine_gates
6
+
7
+
8
+ @pytest.mark.parametrize("tool", ["read_file", "write_file", "calc", "search", "plugin"])
9
+ def test_machine_tool_is_refused_at_build_time(tool):
10
+ report = check_machine_gates(f"states:\n read:\n tool: {tool}\n", "machine.mkl")
11
+ assert not report.ok
12
+
13
+
14
+ def test_judgement_machine_remains_valid():
15
+ assert check_machine_gates("states:\n decide:\n execution: classify\n", "m.mkl").ok
@@ -79,6 +79,22 @@ def test_undeclared_connection_fails(
79
79
  assert "undeclared-connection" in rules_broken(variant(undeclare), connectors)
80
80
 
81
81
 
82
+ def test_an_mklang_connection_must_be_declared(
83
+ variant: Callable[[Callable[[Path], None]], Path], connectors: Path
84
+ ) -> None:
85
+ def add_mklang_connection(root: Path) -> None:
86
+ def mutate(document: dict) -> None:
87
+ for step in document["steps"]:
88
+ if step.get("kind") == "mklang":
89
+ step["connection"] = "llm"
90
+ return
91
+ raise AssertionError("no mklang step")
92
+
93
+ edit_yaml(root / "workflows" / "triage.yaml", mutate)
94
+
95
+ assert "undeclared-connection" in rules_broken(variant(add_mklang_connection), connectors)
96
+
97
+
82
98
  def test_undeclared_secret_fails(
83
99
  variant: Callable[[Callable[[Path], None]], Path], connectors: Path
84
100
  ) -> None:
@@ -184,6 +200,34 @@ def test_a_wait_with_one_of_the_two_is_accepted(
184
200
  assert not {"wait-declares-both", "wait-declares-nothing"} & broken
185
201
 
186
202
 
203
+ def _connector_timeout(duration: str) -> Callable[[Path], None]:
204
+ def declare(root: Path) -> None:
205
+ def mutate(document: dict) -> None:
206
+ step = document["steps"][0]
207
+ assert step["kind"] == "connector"
208
+ step["timeout"] = duration
209
+
210
+ edit_yaml(root / "workflows" / "triage.yaml", mutate)
211
+
212
+ return declare
213
+
214
+
215
+ def test_a_connector_timeout_above_the_cap_fails(
216
+ variant: Callable[[Callable[[Path], None]], Path], connectors: Path
217
+ ) -> None:
218
+ """ADR 0033: refused where it is written, not clamped where nobody sees it. A bundle
219
+ asking for ten minutes would otherwise get five, and learn it from a failed run."""
220
+ broken = rules_broken(variant(_connector_timeout("PT600S")), connectors)
221
+ assert "connector-timeout-out-of-bounds" in broken
222
+
223
+
224
+ def test_a_connector_timeout_within_the_cap_is_accepted(
225
+ variant: Callable[[Callable[[Path], None]], Path], connectors: Path
226
+ ) -> None:
227
+ broken = rules_broken(variant(_connector_timeout("PT120S")), connectors)
228
+ assert "connector-timeout-out-of-bounds" not in broken
229
+
230
+
187
231
  def test_malformed_workflow_fails_on_schema(
188
232
  variant: Callable[[Callable[[Path], None]], Path], connectors: Path
189
233
  ) -> None:
@@ -0,0 +1,126 @@
1
+ """Schema validation, and the worked examples the schemas ship with.
2
+
3
+ `schema/README.md` says the examples are fixtures used by the validator tests.
4
+ This is that claim, made true.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+ from mkp_contracts import schemas
13
+
14
+ from mkbundle.cli import main
15
+
16
+ EXAMPLES = {
17
+ "workflow-triage-inbound.yaml": "workflow",
18
+ "connector-slack.yaml": "connector",
19
+ "bundle.json": "bundle",
20
+ "report-manifest.json": "report_manifest",
21
+ }
22
+
23
+
24
+ @pytest.mark.parametrize(("filename", "kind"), sorted(EXAMPLES.items()))
25
+ def test_worked_examples_validate(filename: str, kind: str) -> None:
26
+ path = schemas.schema_dir() / "examples" / filename
27
+ document = schemas.load_document(path)
28
+
29
+ assert schemas.detect_kind(document) == kind
30
+ report = schemas.validate_document(document, kind, path=filename)
31
+ assert report.ok, [str(f) for f in report.failures]
32
+
33
+
34
+ def test_kind_detection_needs_no_annotation() -> None:
35
+ assert schemas.detect_kind({"steps": []}) == "workflow"
36
+ assert schemas.detect_kind({"actions": {}, "auth": {}}) == "connector"
37
+ assert schemas.detect_kind({"contents": {}, "lock": {}}) == "bundle"
38
+ assert schemas.detect_kind({"run_id": "r", "claims": [], "sources": []}) == "report_manifest"
39
+ assert schemas.detect_kind({"unrelated": True}) is None
40
+
41
+
42
+ def test_workflow_repeat_requires_a_bound_and_decidable_loop() -> None:
43
+ document = {
44
+ "schema_version": 1,
45
+ "name": "revision_loop",
46
+ "steps": [
47
+ {
48
+ "id": "review_loop",
49
+ "kind": "repeat",
50
+ "until": "{{ steps.review.result.done == true }}",
51
+ "max_iterations": 3,
52
+ "steps": [
53
+ {"id": "review", "kind": "wait", "duration": "PT1S"},
54
+ ],
55
+ }
56
+ ],
57
+ }
58
+
59
+ report = schemas.validate_document(document, "workflow", path="repeat.yaml")
60
+ assert report.ok, [str(f) for f in report.failures]
61
+
62
+
63
+ def test_cli_validate_accepts_the_examples() -> None:
64
+ examples = schemas.schema_dir() / "examples"
65
+ assert main(["validate", *[str(p) for p in sorted(examples.iterdir())]]) == 0
66
+
67
+
68
+ def test_cli_validate_rejects_a_broken_document(tmp_path: Path) -> None:
69
+ broken = tmp_path / "workflow.yaml"
70
+ broken.write_text("schema_version: 1\nname: no_steps\nsteps: []\n")
71
+
72
+ assert main(["validate", str(broken)]) == 1
73
+
74
+
75
+ def test_cli_reports_a_document_it_cannot_place(tmp_path: Path) -> None:
76
+ unknown = tmp_path / "thing.yaml"
77
+ unknown.write_text("some: value\n")
78
+
79
+ assert main(["validate", str(unknown)]) == 1
80
+
81
+
82
+ def test_validate_connectors_checks_every_manifest(tmp_path: Path) -> None:
83
+ connector = tmp_path / "alpha"
84
+ connector.mkdir()
85
+ (connector / "connector.yaml").write_text(
86
+ "schema_version: 1\n"
87
+ "name: alpha\n"
88
+ "version: 1\n"
89
+ "title: Alpha\n"
90
+ "auth:\n kind: none\n"
91
+ "actions:\n"
92
+ " ping:\n"
93
+ " summary: Ping\n"
94
+ " method: GET\n"
95
+ " path: /ping\n"
96
+ " effect: read\n",
97
+ encoding="utf-8",
98
+ )
99
+
100
+ assert main(["validate-connectors", str(tmp_path)]) == 0
101
+
102
+
103
+ def test_validate_connectors_fails_on_empty_directory(tmp_path: Path) -> None:
104
+ assert main(["validate-connectors", str(tmp_path)]) == 1
105
+
106
+
107
+ def test_validate_connectors_refuses_a_url_from_input_that_writes(tmp_path: Path) -> None:
108
+ """ADR 0032: the builder refuses what the runner would refuse at load."""
109
+ connector = tmp_path / "alpha"
110
+ connector.mkdir()
111
+ (connector / "connector.yaml").write_text(
112
+ "schema_version: 1\n"
113
+ "name: alpha\n"
114
+ "version: 1\n"
115
+ "title: Alpha\n"
116
+ "auth:\n kind: none\n"
117
+ "actions:\n"
118
+ " fetch:\n"
119
+ " summary: Fetch\n"
120
+ " method: POST\n"
121
+ " url_from_input: target\n"
122
+ " effect: read\n",
123
+ encoding="utf-8",
124
+ )
125
+
126
+ assert main(["validate-connectors", str(tmp_path)]) == 1
@@ -18,3 +18,15 @@ def test_version_prints_the_installed_version(capsys: pytest.CaptureFixture[str]
18
18
  # parser's `prog` was `mkp`, `--version` printed it, and the test agreed. `mkp` is
19
19
  # the product namespace and never a tool's name (README.md §Stack).
20
20
  assert out.strip() == f"mkbundle {importlib.metadata.version('mkbundle')}"
21
+
22
+
23
+ def test_errors_speak_in_the_tools_own_name(capsys: pytest.CaptureFixture[str], tmp_path) -> None:
24
+ # The error path missed the 2026-08-07 rename: `--version` said mkbundle while a
25
+ # failed build still said `mkp:`. Same rule, other stream.
26
+ not_a_bundle = tmp_path / "not-a-bundle.tar.gz"
27
+ not_a_bundle.write_bytes(b"not a gzip at all")
28
+ code = main(["inspect", str(not_a_bundle)])
29
+ assert code == 2
30
+ err = capsys.readouterr().err
31
+ assert err.startswith("mkbundle: ")
32
+ assert "mkp:" not in err
@@ -58,7 +58,7 @@ wheels = [
58
58
 
59
59
  [[package]]
60
60
  name = "mkbundle"
61
- version = "0.2.0"
61
+ version = "1.1.21"
62
62
  source = { editable = "." }
63
63
  dependencies = [
64
64
  { name = "mkp-contracts" },
@@ -85,7 +85,7 @@ dev = [
85
85
 
86
86
  [[package]]
87
87
  name = "mkp-contracts"
88
- version = "0.2.0"
88
+ version = "1.1.21"
89
89
  source = { editable = "../../packages/mkp-contracts" }
90
90
  dependencies = [
91
91
  { name = "jsonschema" },
@@ -1,56 +0,0 @@
1
- """Schema validation, and the worked examples the schemas ship with.
2
-
3
- `schema/README.md` says the examples are fixtures used by the validator tests.
4
- This is that claim, made true.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
-
11
- import pytest
12
- from mkp_contracts import schemas
13
-
14
- from mkbundle.cli import main
15
-
16
- EXAMPLES = {
17
- "workflow-triage-inbound.yaml": "workflow",
18
- "connector-slack.yaml": "connector",
19
- "bundle.json": "bundle",
20
- }
21
-
22
-
23
- @pytest.mark.parametrize(("filename", "kind"), sorted(EXAMPLES.items()))
24
- def test_worked_examples_validate(filename: str, kind: str) -> None:
25
- path = schemas.schema_dir() / "examples" / filename
26
- document = schemas.load_document(path)
27
-
28
- assert schemas.detect_kind(document) == kind
29
- report = schemas.validate_document(document, kind, path=filename)
30
- assert report.ok, [str(f) for f in report.failures]
31
-
32
-
33
- def test_kind_detection_needs_no_annotation() -> None:
34
- assert schemas.detect_kind({"steps": []}) == "workflow"
35
- assert schemas.detect_kind({"actions": {}, "auth": {}}) == "connector"
36
- assert schemas.detect_kind({"contents": {}, "lock": {}}) == "bundle"
37
- assert schemas.detect_kind({"unrelated": True}) is None
38
-
39
-
40
- def test_cli_validate_accepts_the_examples() -> None:
41
- examples = schemas.schema_dir() / "examples"
42
- assert main(["validate", *[str(p) for p in sorted(examples.iterdir())]]) == 0
43
-
44
-
45
- def test_cli_validate_rejects_a_broken_document(tmp_path: Path) -> None:
46
- broken = tmp_path / "workflow.yaml"
47
- broken.write_text("schema_version: 1\nname: no_steps\nsteps: []\n")
48
-
49
- assert main(["validate", str(broken)]) == 1
50
-
51
-
52
- def test_cli_reports_a_document_it_cannot_place(tmp_path: Path) -> None:
53
- unknown = tmp_path / "thing.yaml"
54
- unknown.write_text("some: value\n")
55
-
56
- assert main(["validate", str(unknown)]) == 1
File without changes
File without changes