vibedata-odcs 0.1.0__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 (38) hide show
  1. vibedata_odcs-0.1.0/.gitignore +8 -0
  2. vibedata_odcs-0.1.0/CHANGELOG.md +10 -0
  3. vibedata_odcs-0.1.0/PKG-INFO +64 -0
  4. vibedata_odcs-0.1.0/README.md +45 -0
  5. vibedata_odcs-0.1.0/pyproject.toml +43 -0
  6. vibedata_odcs-0.1.0/src/vibedata/odcs/__init__.py +13 -0
  7. vibedata_odcs-0.1.0/src/vibedata/odcs/__main__.py +3 -0
  8. vibedata_odcs-0.1.0/src/vibedata/odcs/boundary.py +297 -0
  9. vibedata_odcs-0.1.0/src/vibedata/odcs/check.py +284 -0
  10. vibedata_odcs-0.1.0/src/vibedata/odcs/cli.py +92 -0
  11. vibedata_odcs-0.1.0/src/vibedata/odcs/contracts.py +188 -0
  12. vibedata_odcs-0.1.0/src/vibedata/odcs/diagnostics.py +68 -0
  13. vibedata_odcs-0.1.0/src/vibedata/odcs/diff.py +261 -0
  14. vibedata_odcs-0.1.0/src/vibedata/odcs/generate.py +644 -0
  15. vibedata_odcs-0.1.0/src/vibedata/odcs/model.py +314 -0
  16. vibedata_odcs-0.1.0/src/vibedata/odcs/profile.py +168 -0
  17. vibedata_odcs-0.1.0/src/vibedata/odcs/py.typed +0 -0
  18. vibedata_odcs-0.1.0/src/vibedata/odcs/results.py +183 -0
  19. vibedata_odcs-0.1.0/src/vibedata/odcs/reverse.py +433 -0
  20. vibedata_odcs-0.1.0/src/vibedata/odcs/schema.py +72 -0
  21. vibedata_odcs-0.1.0/src/vibedata/odcs/schemas/README.md +19 -0
  22. vibedata_odcs-0.1.0/src/vibedata/odcs/schemas/odcs-json-schema-v3.2.0.json +3648 -0
  23. vibedata_odcs-0.1.0/src/vibedata/odcs/warehouse.py +171 -0
  24. vibedata_odcs-0.1.0/tests/conftest.py +0 -0
  25. vibedata_odcs-0.1.0/tests/snapshots/diagnostic-codes.txt +52 -0
  26. vibedata_odcs-0.1.0/tests/snapshots/help-check.txt +25 -0
  27. vibedata_odcs-0.1.0/tests/snapshots/help-diff.txt +24 -0
  28. vibedata_odcs-0.1.0/tests/snapshots/help-generate.txt +15 -0
  29. vibedata_odcs-0.1.0/tests/snapshots/help-profile.txt +17 -0
  30. vibedata_odcs-0.1.0/tests/snapshots/help-results.txt +18 -0
  31. vibedata_odcs-0.1.0/tests/snapshots/help-reverse.txt +26 -0
  32. vibedata_odcs-0.1.0/tests/snapshots/help-root.txt +21 -0
  33. vibedata_odcs-0.1.0/tests/snapshots/help-validate.txt +18 -0
  34. vibedata_odcs-0.1.0/tests/snapshots/help-version.txt +8 -0
  35. vibedata_odcs-0.1.0/tests/snapshots/version-fields.txt +4 -0
  36. vibedata_odcs-0.1.0/tests/test_cli_contract.py +136 -0
  37. vibedata_odcs-0.1.0/tests/test_odcs.py +2614 -0
  38. vibedata_odcs-0.1.0/tests/test_odcs_commands.py +713 -0
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .pytest_cache/
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ The public API is the `odcs` command line: its commands and options, the JSON diagnostics on stdout (each diagnostic's `code` included), the `results` record shape, and the exit codes. `vibedata.odcs.cli.main`, `vibedata.odcs.cli.app` and `vibedata.odcs.warehouse.set_destination_resolver` (re-exported, with `DestinationResolver`, from `vibedata.odcs.cli`) are the Python API. Everything else is internal. While the version is 0.x, a breaking change bumps the minor version.
4
+
5
+ ## 0.1.0
6
+
7
+ - First release. The console script is `vibedata-odcs` (also `python -m vibedata.odcs`). Ported from the plugin's `skills/data-contract/scripts/odcs.py` at vibedata-data-engineering `f73ce0b`, with no change to any command's behaviour or output.
8
+ - `version` reports the package version beside the standard and schema pins.
9
+ - The Intent's sandbox comes from a resolver the caller installs (`main(destination_resolver=...)`), not from the plugin's `platforms` module. With neither a resolver nor `--catalog`, a warehouse command refuses with `catalog/missing`.
10
+ - `duckdb` moves behind the `warehouse` extra.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: vibedata-odcs
3
+ Version: 0.1.0
4
+ Summary: VibeData data-contract tooling: check, generate, reverse, diff, profile and results for Open Data Contract Standard (ODCS v3.2.0) contracts at a Domain's boundary. One library and one `odcs` command shared by the Build agent's skills, Domain CI and Studio.
5
+ Project-URL: Repository, https://github.com/accelerate-data/vibedata-python-packages
6
+ Project-URL: Changelog, https://github.com/accelerate-data/vibedata-python-packages/blob/main/packages/vibedata-odcs/CHANGELOG.md
7
+ Author-email: Vibedata <eng@acceleratedata.ai>
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: jsonschema>=4.18
11
+ Requires-Dist: pyyaml>=6
12
+ Requires-Dist: typer>=0.13
13
+ Provides-Extra: dev
14
+ Requires-Dist: duckdb; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Provides-Extra: warehouse
17
+ Requires-Dist: duckdb; extra == 'warehouse'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # vibedata-odcs
21
+
22
+ Data-contract tooling for [Open Data Contract Standard](https://github.com/bitol-io/open-data-contract-standard) (ODCS v3.2.0) contracts at a VibeData Domain's boundary.
23
+ One library and one `vibedata-odcs` command, shared by the Build agent's `data-contract` skills, Domain CI's logical-model gate, and Studio. It replaces the `odcs.py` script that was copied into each of them.
24
+
25
+ ```bash
26
+ pip install vibedata-odcs # check, generate, diff, results, version
27
+ pip install "vibedata-odcs[warehouse]" # adds duckdb for check --relations, reverse and profile
28
+ ```
29
+
30
+ Run from the Domain's repository root:
31
+
32
+ | Command | What it does | Reads a warehouse |
33
+ | --- | --- | --- |
34
+ | `vibedata-odcs check` | schema and structure of the committed contracts, one per boundary relation, agreement with the canonical model | no |
35
+ | `vibedata-odcs check --relations` | adds the comparison against the built relation (or dbt's `catalog.json`) | yes |
36
+ | `vibedata-odcs reverse [--force]` | drafts the boundary's contracts from a built warehouse, with the open questions as findings | yes |
37
+ | `vibedata-odcs generate` | writes the dbt keys and the dlt `COLUMNS` hints from the contracts | no |
38
+ | `vibedata-odcs diff <base-ref>` | classifies each changed contract against a git base and requires the version bump it needs | no |
39
+ | `vibedata-odcs profile [--relation S.T]` | tests each contract's key, `required` and value-set claims against the built relation | yes |
40
+ | `vibedata-odcs results <run_results.json>` | maps a dbt run's test results to the contract assertions they enforce (JSON lines) | no |
41
+ | `vibedata-odcs version` | package, standard and pinned-schema versions | no |
42
+
43
+ Diagnostics are JSON on stdout. The exit code is 0 when there are no errors (warnings are allowed) and 1 otherwise. That JSON, its diagnostic `code` values and the exit codes are the public API this package versions (see `CHANGELOG.md`).
44
+
45
+ The console script is `vibedata-odcs`, prefixed so it cannot collide with another tool's `odcs` in a shared environment; `python -m vibedata.odcs` is the same command.
46
+
47
+ ## The warehouse
48
+
49
+ `--catalog PATH` names the local database file (or dbt's `catalog.json` for `check --relations`).
50
+ Without it, the command asks the installed destination resolver. The library ships none, because it knows no platform. A caller that knows the Intent's platform passes one:
51
+
52
+ ```python
53
+ from vibedata.odcs.cli import main
54
+
55
+ main(destination_resolver=lambda repo_map: {"family": "duckdb", "path": "/sandbox.duckdb", "platform": "duckdb_local"})
56
+ ```
57
+
58
+ With no resolver and no `--catalog`, a warehouse command refuses with `catalog/missing` rather than passing silently.
59
+
60
+ The resolver is process-wide state: every `main()` call sets it, `None` included, so a long-lived process that calls `main()` repeatedly passes the resolver it wants on each call.
61
+
62
+ ## Vendored schema
63
+
64
+ `src/vibedata/odcs/schemas/` holds the pinned ODCS JSON schema; see its README for provenance. Validation never fetches, so `check` runs offline.
@@ -0,0 +1,45 @@
1
+ # vibedata-odcs
2
+
3
+ Data-contract tooling for [Open Data Contract Standard](https://github.com/bitol-io/open-data-contract-standard) (ODCS v3.2.0) contracts at a VibeData Domain's boundary.
4
+ One library and one `vibedata-odcs` command, shared by the Build agent's `data-contract` skills, Domain CI's logical-model gate, and Studio. It replaces the `odcs.py` script that was copied into each of them.
5
+
6
+ ```bash
7
+ pip install vibedata-odcs # check, generate, diff, results, version
8
+ pip install "vibedata-odcs[warehouse]" # adds duckdb for check --relations, reverse and profile
9
+ ```
10
+
11
+ Run from the Domain's repository root:
12
+
13
+ | Command | What it does | Reads a warehouse |
14
+ | --- | --- | --- |
15
+ | `vibedata-odcs check` | schema and structure of the committed contracts, one per boundary relation, agreement with the canonical model | no |
16
+ | `vibedata-odcs check --relations` | adds the comparison against the built relation (or dbt's `catalog.json`) | yes |
17
+ | `vibedata-odcs reverse [--force]` | drafts the boundary's contracts from a built warehouse, with the open questions as findings | yes |
18
+ | `vibedata-odcs generate` | writes the dbt keys and the dlt `COLUMNS` hints from the contracts | no |
19
+ | `vibedata-odcs diff <base-ref>` | classifies each changed contract against a git base and requires the version bump it needs | no |
20
+ | `vibedata-odcs profile [--relation S.T]` | tests each contract's key, `required` and value-set claims against the built relation | yes |
21
+ | `vibedata-odcs results <run_results.json>` | maps a dbt run's test results to the contract assertions they enforce (JSON lines) | no |
22
+ | `vibedata-odcs version` | package, standard and pinned-schema versions | no |
23
+
24
+ Diagnostics are JSON on stdout. The exit code is 0 when there are no errors (warnings are allowed) and 1 otherwise. That JSON, its diagnostic `code` values and the exit codes are the public API this package versions (see `CHANGELOG.md`).
25
+
26
+ The console script is `vibedata-odcs`, prefixed so it cannot collide with another tool's `odcs` in a shared environment; `python -m vibedata.odcs` is the same command.
27
+
28
+ ## The warehouse
29
+
30
+ `--catalog PATH` names the local database file (or dbt's `catalog.json` for `check --relations`).
31
+ Without it, the command asks the installed destination resolver. The library ships none, because it knows no platform. A caller that knows the Intent's platform passes one:
32
+
33
+ ```python
34
+ from vibedata.odcs.cli import main
35
+
36
+ main(destination_resolver=lambda repo_map: {"family": "duckdb", "path": "/sandbox.duckdb", "platform": "duckdb_local"})
37
+ ```
38
+
39
+ With no resolver and no `--catalog`, a warehouse command refuses with `catalog/missing` rather than passing silently.
40
+
41
+ The resolver is process-wide state: every `main()` call sets it, `None` included, so a long-lived process that calls `main()` repeatedly passes the resolver it wants on each call.
42
+
43
+ ## Vendored schema
44
+
45
+ `src/vibedata/odcs/schemas/` holds the pinned ODCS JSON schema; see its README for provenance. Validation never fetches, so `check` runs offline.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vibedata-odcs"
7
+ version = "0.1.0"
8
+ description = "VibeData data-contract tooling: check, generate, reverse, diff, profile and results for Open Data Contract Standard (ODCS v3.2.0) contracts at a Domain's boundary. One library and one `odcs` command shared by the Build agent's skills, Domain CI and Studio."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Vibedata", email = "eng@acceleratedata.ai" }]
13
+
14
+ dependencies = [
15
+ # >=4.18: the referencing-based resolver the vendored v3.2.0 schema's $defs need.
16
+ "jsonschema>=4.18",
17
+ "pyyaml>=6",
18
+ # >=0.13: 0.12 cannot run against the current click ("Secondary flag is not valid
19
+ # for non-boolean flag"); proven by the lowest-direct CI job.
20
+ "typer>=0.13",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ # Read only by `check --relations`, `reverse` and `profile`. Its version is the
25
+ # runtime's own (the managed constraints pin it), so no floor is stated here.
26
+ warehouse = ["duckdb"]
27
+ dev = ["pytest>=8.0", "duckdb"]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/accelerate-data/vibedata-python-packages"
31
+ Changelog = "https://github.com/accelerate-data/vibedata-python-packages/blob/main/packages/vibedata-odcs/CHANGELOG.md"
32
+
33
+ [project.scripts]
34
+ vibedata-odcs = "vibedata.odcs.cli:main"
35
+
36
+ # PEP 420 namespace package: `vibedata` carries no __init__.py, so this
37
+ # distribution contributes `vibedata.odcs` beside `vibedata.dlt.*`.
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/vibedata"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ pythonpath = ["src"]
@@ -0,0 +1,13 @@
1
+ """VibeData data-contract tooling for Open Data Contract Standard (ODCS) contracts.
2
+
3
+ The command line is the public interface: `odcs <command>` (or `python -m vibedata.odcs`)
4
+ prints JSON diagnostics and exits 0 or 1. `vibedata.odcs.cli.main` runs it in-process and
5
+ accepts a destination resolver for callers that know the Intent's platform.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version as _version
9
+
10
+ try:
11
+ __version__ = _version("vibedata-odcs")
12
+ except PackageNotFoundError: # a source checkout run without installing the distribution
13
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,3 @@
1
+ from vibedata.odcs.cli import main
2
+
3
+ main()
@@ -0,0 +1,297 @@
1
+ """Where a contract is owed: the Domain's repo map, its layers' roles, and the boundary relations."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from vibedata.odcs.diagnostics import _Refused, _diag
9
+
10
+ # Relations the installed loader library writes into every dlt schema. No dlt resource declares
11
+ # them, so none is on the boundary, a contract on one is no orphan, and nothing is generated
12
+ # from it.
13
+ AUDIT_RELATIONS = ("audit_runs", "audit_table_loads")
14
+
15
+
16
+ def _repo_root(root: str) -> Path:
17
+ """The Domain root: `--root`, else the nearest directory above the working one with a map."""
18
+ start = Path(root).resolve() if root else Path.cwd().resolve()
19
+ for candidate in [start, *start.parents]:
20
+ if (candidate / "repo-map.json").is_file():
21
+ return candidate
22
+ raise _Refused(_diag("error", "map/missing",
23
+ f"no repo-map.json at or above {start}; run from the Domain's root"))
24
+
25
+
26
+ def _repo_map(root: Path) -> dict:
27
+ return json.loads((root / "repo-map.json").read_text(encoding="utf-8"))
28
+
29
+
30
+ def _declared(root: Path, raw: str) -> Path:
31
+ """The path an artifact pattern names, up to its first placeholder."""
32
+ prefix = raw.split("{")[0].split("*")[0].rstrip("/")
33
+ return root / prefix
34
+
35
+
36
+ def declared_relations(root: Path, repo_map: dict) -> dict[tuple[str, str], str]:
37
+ """Every relation the map declares, as `(schema, table)` mapped to what declares it.
38
+
39
+ dbt models and seeds come from the layer paths. Raw relations come from each source's
40
+ resource docs. The loader's audit relations are written by the installed library and appear
41
+ in none of these: a contract on one is no orphan, and none is owed.
42
+ """
43
+ expected: dict[tuple[str, str], str] = {}
44
+ for name, layer in (repo_map.get("layers") or {}).items():
45
+ path = root / layer["path"] if layer.get("path") else None
46
+ if path is None or not path.is_dir() or layer.get("built_by") != "dbt":
47
+ continue
48
+ # A dbt layer builds whatever it holds: a model from a `.sql`, a seed from a `.csv`.
49
+ # Which suffix belongs to which is the file's fact, not the layer's name.
50
+ for suffix in (".sql", ".csv"):
51
+ for file in sorted(path.rglob(f"*{suffix}")):
52
+ expected[(layer["schema"], file.stem)] = f"layer {name}"
53
+ for name, source in (repo_map.get("sources") or {}).items():
54
+ docs = source.get("docs")
55
+ if not docs:
56
+ continue
57
+ entries = yaml.safe_load((root / docs).read_text(encoding="utf-8")) or {}
58
+ for resource in sorted(entries.get("resources") or {}):
59
+ expected[(source["dataset"], resource)] = f"source {name}"
60
+ return expected
61
+
62
+
63
+ def _unknown_gold_layers(repo_map: dict) -> list[dict]:
64
+ """A legacy `gold_layers` entry that names no layer declares no boundary; warn, so a stale entry never fails an existing Domain."""
65
+ named = repo_map.get("gold_layers")
66
+ if named is None:
67
+ return []
68
+ if not isinstance(named, list):
69
+ return [_diag("warning", "map/gold-layers",
70
+ "repo-map.json `gold_layers` is not a list of layer names")]
71
+ layers = repo_map.get("layers") or {}
72
+ return [
73
+ _diag("warning", "map/gold-layers",
74
+ f"repo-map.json `gold_layers` names `{name}`, which `layers` does not declare")
75
+ for name in named
76
+ if name not in layers
77
+ ]
78
+
79
+
80
+ def _unknown_contract_layers(repo_map: dict) -> list[dict]:
81
+ """A `contract_layers` entry that names no layer scopes nothing; saying so is the report."""
82
+ named = repo_map.get("contract_layers")
83
+ if named is None:
84
+ return []
85
+ if not isinstance(named, list):
86
+ return [_diag("error", "map/contract-layers",
87
+ "repo-map.json `contract_layers` is not a list of layer names")]
88
+ layers = repo_map.get("layers") or {}
89
+ return [
90
+ _diag("error", "map/contract-layers",
91
+ f"repo-map.json `contract_layers` names `{name}`, which `layers` does not declare")
92
+ for name in named
93
+ if name not in layers
94
+ ]
95
+
96
+
97
+ def _exclusions(repo_map: dict) -> tuple[dict[tuple[str, str], str], list[dict]]:
98
+ """The relations the canonical model deliberately leaves out, with the reason each states.
99
+
100
+ An entry the tool cannot read excludes nothing; saying so is the report.
101
+ """
102
+ entries = repo_map.get("semantic_exclusions")
103
+ if entries is None:
104
+ return {}, []
105
+ if not isinstance(entries, list):
106
+ return {}, [_diag("error", "map/semantic-exclusions",
107
+ "repo-map.json `semantic_exclusions` is not a list of "
108
+ "{relation, reason} entries")]
109
+ excluded: dict[tuple[str, str], str] = {}
110
+ diags = []
111
+ for entry in entries:
112
+ relation = entry.get("relation") if isinstance(entry, dict) else None
113
+ reason = entry.get("reason") if isinstance(entry, dict) else None
114
+ if (isinstance(relation, str) and relation.count(".") == 1 and all(relation.split("."))
115
+ and isinstance(reason, str) and reason.strip()):
116
+ schema, table = relation.split(".")
117
+ excluded[(schema, table)] = reason.strip()
118
+ else:
119
+ diags.append(_diag("error", "map/semantic-exclusions",
120
+ f"repo-map.json `semantic_exclusions` entry {entry!r} needs a "
121
+ f"`relation` as `schema.table` and a `reason`"))
122
+ return excluded, diags
123
+
124
+
125
+ LAYER_ROLES = ("input", "internal", "boundary")
126
+
127
+
128
+ def _role_for(repo_map: dict, name: str) -> str:
129
+ """A layer's role: `layers.<name>.role` when valid, else derived from the legacy keys.
130
+
131
+ Derived: `built_by == "dlt"` is `input` (even when the layer is also named in `gold_layers`);
132
+ a name in `gold_layers` (the legacy way to declare a
133
+ boundary layer) is `boundary`; anything else is `internal`. An invalid explicit role falls
134
+ back to the derivation; `check` reports it as `map/layer-role`.
135
+ """
136
+ layer = (repo_map.get("layers") or {}).get(name)
137
+ layer = layer if isinstance(layer, dict) else {}
138
+ if layer.get("role") in LAYER_ROLES:
139
+ return layer["role"]
140
+ if layer.get("built_by") == "dlt":
141
+ return "input"
142
+ if name in (repo_map.get("gold_layers") or []):
143
+ return "boundary"
144
+ return "internal"
145
+
146
+
147
+ def _roles(repo_map: dict) -> dict[str, str]:
148
+ """The role of every layer the map declares."""
149
+ return {name: _role_for(repo_map, name) for name in repo_map.get("layers") or {}}
150
+
151
+
152
+ def _role_schemas(repo_map: dict, *roles: str) -> set[str]:
153
+ """Schemas of the layers whose role is one of `roles`."""
154
+ layers = repo_map.get("layers") or {}
155
+ return {layers[name]["schema"] for name, role in _roles(repo_map).items()
156
+ if role in roles and "schema" in layers[name]}
157
+
158
+
159
+ def _layer_role_diags(repo_map: dict) -> list[dict]:
160
+ """A `role` outside `input`, `internal` and `boundary` is reported, not guessed at."""
161
+ return [
162
+ _diag("error", "map/layer-role",
163
+ f"repo-map.json layers.{name}.role must be input, internal or boundary")
164
+ for name, layer in (repo_map.get("layers") or {}).items()
165
+ if isinstance(layer, dict) and "role" in layer and layer["role"] not in LAYER_ROLES
166
+ ]
167
+
168
+
169
+ def boundary_relations(repo_map: dict,
170
+ declared: dict[tuple[str, str], str]) -> dict[tuple[str, str], str]:
171
+ """The relations a contract is owed for: the inputs and outputs of the Domain.
172
+
173
+ By default, every relation a dlt source loads (a consumer of the load sees its shape first)
174
+ and every relation a layer with role `input` or `boundary` builds (what a consumer outside
175
+ the repo reads). Layers with role `internal` are guarded by their dbt tests and owe none.
176
+ `contract_layers`, when the map declares it, replaces that default with the layers it names, a dlt layer
177
+ contributing the resources its sources land in that schema.
178
+ """
179
+ layers = repo_map.get("layers") or {}
180
+ named = repo_map.get("contract_layers")
181
+ if isinstance(named, list):
182
+ schemas = {layers[name]["schema"] for name in named if name in layers}
183
+ return {relation: by for relation, by in declared.items() if relation[0] in schemas}
184
+ owed = _role_schemas(repo_map, "input", "boundary")
185
+ # Only an explicit `role: internal` stops a source from owing a contract; a layer whose role
186
+ # is merely derived as internal still owes one for the sources that land in it.
187
+ internal = {layer["schema"] for layer in layers.values()
188
+ if isinstance(layer, dict) and layer.get("role") == "internal" and "schema" in layer}
189
+ return {
190
+ relation: by
191
+ for relation, by in declared.items()
192
+ if (by.startswith("source ") and relation[0] not in internal)
193
+ or (relation[0] in owed and by.startswith("layer "))
194
+ }
195
+
196
+
197
+ def _boundary_rank(relation: tuple[str, str], repo_map: dict,
198
+ declared: dict[tuple[str, str], str]) -> int:
199
+ """Boundary first, then inputs, then anything else: the order a reviewer triages in."""
200
+ if relation[0] in _role_schemas(repo_map, "boundary"):
201
+ return 0
202
+ if (declared.get(relation) or "").startswith("source "):
203
+ return 1
204
+ return 2
205
+
206
+
207
+ def _boundary_schemas(repo_map: dict, expected: dict[tuple[str, str], str]) -> set[str]:
208
+ """Schemas holding boundary relations: the layers with role `boundary`, where they are built.
209
+
210
+ The map is the only source. Another Domain names its layers differently, so a layer name is
211
+ something this tool cannot know; guessing one — by exclusion, or by the shape of the
212
+ directory — would gate relations the Domain never called boundary and pass over the ones it did.
213
+ An undeclared set is therefore empty and names nothing, and `check` warns about the gap
214
+ instead of inventing a boundary set to fill it.
215
+ """
216
+ built = {schema for schema, _ in expected}
217
+ return _role_schemas(repo_map, "boundary") & built
218
+
219
+
220
+ def _layer_for(repo_map: dict, schema: str) -> dict | None:
221
+ """The layer that declares a schema; the map is the only place a schema is named."""
222
+ for layer in (repo_map.get("layers") or {}).values():
223
+ if layer.get("schema") == schema:
224
+ return layer
225
+ return None
226
+
227
+
228
+ def _is_dlt(relation: tuple[str, str], repo_map: dict) -> bool:
229
+ """Whether a dlt pipeline builds the relation: the only place the loader adds `_dlt_*`."""
230
+ layer = _layer_for(repo_map, relation[0])
231
+ return layer is not None and layer.get("built_by") == "dlt"
232
+
233
+
234
+ def _is_dbt(relation: tuple[str, str], repo_map: dict) -> bool:
235
+ """Whether a dbt layer builds the relation, as opposed to a dlt pipeline or the loader."""
236
+ layer = _layer_for(repo_map, relation[0])
237
+ return layer is not None and layer.get("built_by") == "dbt"
238
+
239
+
240
+ def _built_models(root: Path, layer: dict) -> set[str] | None:
241
+ """The models dbt built successfully, from the `target/run_results.json` of the project
242
+ holding the layer's path; None when there is no such file (no evidence either way)."""
243
+ folder = (root / layer["path"]).resolve()
244
+ for project in (folder, *folder.parents):
245
+ if (project / "dbt_project.yml").is_file():
246
+ break
247
+ if project == root.resolve():
248
+ return None
249
+ else:
250
+ return None
251
+ path = project / "target" / "run_results.json"
252
+ if not path.is_file():
253
+ return None
254
+ try:
255
+ found = json.loads(path.read_text(encoding="utf-8")).get("results") or []
256
+ except (OSError, ValueError, AttributeError):
257
+ return None
258
+ return {str(result.get("unique_id", "")).split(".")[-1] for result in found
259
+ if isinstance(result, dict) and result.get("status") in ("success", "pass")
260
+ and str(result.get("unique_id", "")).split(".")[0] in ("model", "seed", "snapshot")}
261
+
262
+
263
+ def _active_unbuilt(root: Path, repo_map: dict, relation: tuple[str, str], path: Path,
264
+ doc: dict, excluded: dict) -> list[dict]:
265
+ """An `active` contract whose dbt relation the last run did not build: still a proposal."""
266
+ if doc.get("status") != "active" or relation in excluded or not _is_dbt(relation, repo_map):
267
+ return []
268
+ built = _built_models(root, _layer_for(repo_map, relation[0]))
269
+ if built is None or relation[1] in built:
270
+ return []
271
+ return [_diag("warning", "contract/active-unbuilt",
272
+ f"{relation[0]}.{relation[1]}: status is active but dbt has not built "
273
+ f"{relation[0]}.{relation[1]}; mark it proposed until the table exists", path)]
274
+
275
+
276
+ def _is_loader_column(relation: tuple[str, str], name: str, repo_map: dict) -> bool:
277
+ """A `_dlt_*` column dlt adds to a relation it loaded.
278
+
279
+ A dbt model that selects the column propagates it deliberately, and its enforced contract
280
+ must declare every output column, so only a raw relation excludes the loader's own.
281
+ """
282
+ return name.startswith("_dlt") and not _is_dbt(relation, repo_map)
283
+
284
+
285
+ def _is_audit(relation: tuple[str, str], repo_map: dict) -> bool:
286
+ """A relation the installed loader writes into a dlt layer's schema, not a dlt resource."""
287
+ layer = _layer_for(repo_map, relation[0])
288
+ return (
289
+ layer is not None
290
+ and layer.get("built_by") == "dlt"
291
+ and relation[1] in AUDIT_RELATIONS
292
+ )
293
+
294
+
295
+ def _platform(repo_map: dict) -> str:
296
+ """The platform the map declares. One Domain runs on one, so the map is the only source."""
297
+ return str(repo_map.get("data_platform") or "")