Skip to content

Commit cbd8aad

Browse files
committed
feat: implement generic module contract verification library
1 parent 8fa8dc1 commit cbd8aad

13 files changed

Lines changed: 1339 additions & 28 deletions

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,42 @@ gleam add gleam_contracts
1313

1414
## Usage
1515

16-
See [SPEC.md](SPEC.md) for the complete technical specification.
16+
Create a contract check entrypoint in your package:
1717

18+
```gleam
19+
import gleam_contracts
20+
import gleam_contracts/rule
21+
22+
pub fn main() {
23+
gleam_contracts.check(
24+
interface_path: "build/dev/docs/my_package/package-interface.json",
25+
rules: [
26+
gleam_contracts.mirror_rule(
27+
source: "my_package/headless/button",
28+
target: "my_package/button",
29+
prefix_params: [rule.Labeled(label: "context")],
30+
)
31+
|> gleam_contracts.with_exceptions(exceptions: ["button"]),
32+
gleam_contracts.shared_types(
33+
module_a: "my_package/headless/button",
34+
module_b: "my_package/button",
35+
type_names: ["ButtonConfig"],
36+
),
37+
],
38+
)
39+
}
40+
```
41+
42+
Then run it in your build chain:
43+
44+
```sh
45+
gleam export package-interface --out build/dev/docs/my_package/package-interface.json
46+
gleam run -m contract_test
47+
```
48+
49+
Dogfooding belongs in the consuming package repository. Keep any stack-specific
50+
contract rules and CI wiring in that consumer repo rather than this library.
51+
52+
## Spec
53+
54+
See [SPEC.md](SPEC.md) for the full technical specification.

SPEC.md

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Today this is caught by manual review or by users who discover the gap.
2121
- Run as a verification gate in CI, like `gleam format --check` or grep-gates
2222
- Provide clear, actionable error messages identifying exactly which function
2323
is missing or which parameter doesn't match
24-
- Work with any Gleam package, not just weft_lustre_ui
24+
- Work with any Gleam package
2525
- Stay pure Gleam, cross-target compatible
2626

2727
## Non-Goals
@@ -94,6 +94,12 @@ pub type ExportSpec {
9494
)
9595
}
9696
97+
/// Errors produced while loading package-interface JSON.
98+
pub type LoadError {
99+
ReadError(path: String, reason: simplifile.FileError)
100+
DecodeError(path: String, reason: json.DecodeError)
101+
}
102+
97103
/// A single contract violation.
98104
pub type Violation {
99105
/// Function exists in source but not in target.
@@ -132,6 +138,11 @@ pub type Violation {
132138
ModuleNotFound(
133139
module: String,
134140
)
141+
/// Package-interface file could not be loaded.
142+
InterfaceLoadFailure(
143+
path: String,
144+
error: LoadError,
145+
)
135146
}
136147
137148
/// Verification result.
@@ -145,7 +156,7 @@ pub type ContractResult =
145156
/// Load and decode a package interface from a JSON file path.
146157
pub fn load_package_interface(
147158
path path: String,
148-
) -> Result(PackageInterface, String)
159+
) -> Result(PackageInterface, LoadError)
149160
150161
/// Verify a list of rules against a package interface.
151162
/// Returns Ok(Nil) if all rules pass, or Error with a list
@@ -159,6 +170,12 @@ pub fn verify(
159170
pub fn format_violations(
160171
violations violations: List(Violation),
161172
) -> String
173+
174+
/// Load interface and verify rules in one call.
175+
pub fn check_result(
176+
interface_path interface_path: String,
177+
rules rules: List(Rule),
178+
) -> ContractResult
162179
```
163180

164181
### Rule Constructors
@@ -168,7 +185,7 @@ pub fn format_violations(
168185
/// public functions, each gaining the specified prefix parameters.
169186
///
170187
/// Example: headless badge -> styled badge, where styled adds
171-
/// a leading `theme` parameter.
188+
/// a leading `context` parameter.
172189
pub fn mirror_rule(
173190
source source: String,
174191
target target: String,
@@ -200,13 +217,10 @@ pub fn shared_types(
200217
### Convenience
201218

202219
```gleam
203-
/// Shorthand for [Labeled("theme")] — the common prefix for
204-
/// styled wrappers.
205-
pub fn theme_prefix() -> List(ParamSpec)
206-
207-
/// Load interface from default path and verify rules in one call.
208-
/// Exits with code 1 and prints violations if any are found.
209-
/// Intended for use in scripts/check.sh.
220+
/// Print violations for terminal usage.
221+
///
222+
/// `check` is a convenience wrapper around `check_result`.
223+
/// It does not force a process exit.
210224
pub fn check(
211225
interface_path interface_path: String,
212226
rules rules: List(Rule),
@@ -231,6 +245,7 @@ creates a verification entry point:
231245
```gleam
232246
// test/contract_test.gleam (or a standalone script)
233247
import gleam_contracts
248+
import gleam_contracts/rule
234249
235250
pub fn main() {
236251
gleam_contracts.check(
@@ -239,12 +254,12 @@ pub fn main() {
239254
gleam_contracts.mirror_rule(
240255
source: "my_package/headless/badge",
241256
target: "my_package/badge",
242-
prefix_params: gleam_contracts.theme_prefix(),
257+
prefix_params: [rule.Labeled(label: "context")],
243258
),
244259
gleam_contracts.mirror_rule(
245260
source: "my_package/headless/button",
246261
target: "my_package/button",
247-
prefix_params: gleam_contracts.theme_prefix(),
262+
prefix_params: [rule.Labeled(label: "context")],
248263
)
249264
|> gleam_contracts.with_exceptions(exceptions: ["button"]),
250265
],
@@ -282,13 +297,13 @@ pub fn contract_tests() {
282297

283298
## MirrorRule Semantics
284299

285-
Given `MirrorRule(source: "a/headless/foo", target: "a/foo", prefix_params: [Labeled("theme")], exceptions: [])`:
300+
Given `MirrorRule(source: "a/headless/foo", target: "a/foo", prefix_params: [Labeled("context")], exceptions: [])`:
286301

287302
1. For every public function `f` in `a/headless/foo`:
288303
- `a/foo` must have a public function also named `f`
289304
- If `f` is in `exceptions`: only existence is checked, not parameters
290305
- Otherwise: `a/foo.f`'s parameter labels must equal
291-
`["theme"] ++ labels_of(a/headless/foo.f)`
306+
`["context"] ++ labels_of(a/headless/foo.f)`
292307

293308
2. Extra functions in the target (not present in source) are allowed.
294309
The target is a superset, not an exact mirror.
@@ -301,15 +316,15 @@ Given `MirrorRule(source: "a/headless/foo", target: "a/foo", prefix_params: [Lab
301316
Violations produce messages like:
302317

303318
```
304-
FAIL: weft_lustre_ui/badge is missing function "badge_variant"
305-
from weft_lustre_ui/headless/badge
319+
FAIL: my_package/badge is missing function "badge_variant"
320+
from my_package/headless/badge
306321
307-
FAIL: weft_lustre_ui/button.button has parameter mismatch
308-
expected: [theme, config, label]
309-
actual: [theme, config, child]
322+
FAIL: my_package/button.button has parameter mismatch
323+
expected: [context, config, label]
324+
actual: [context, config, child]
310325
311-
FAIL: weft_lustre_ui/toggle is missing type "ToggleConfig"
312-
from weft_lustre_ui/headless/toggle
326+
FAIL: my_package/toggle is missing type "ToggleConfig"
327+
from my_package/headless/toggle
313328
```
314329

315330
## Verification
@@ -320,6 +335,12 @@ Run the full verification chain:
320335
bash scripts/check.sh
321336
```
322337

338+
## Dogfooding (Optional)
339+
340+
Dogfooding should live in the consuming package repository, where real module
341+
pairs and CI constraints are known. Keep this library repository focused on the
342+
generic engine and deterministic fixtures.
343+
323344
## Test Plan
324345

325346
- MirrorRule: source function present in target -> Ok
@@ -336,4 +357,5 @@ bash scripts/check.sh
336357
- format_violations: produces readable output
337358
- load_package_interface: valid JSON -> Ok(interface)
338359
- load_package_interface: invalid path -> Error
339-
- Integration: real package-interface.json from weft_lustre_ui
360+
- check_result: load failure -> InterfaceLoadFailure
361+
- Dogfood (optional): consumer-owned integration checks in consuming repos

gleam.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ gleam = ">= 1.14.0"
77

88
[dependencies]
99
gleam_stdlib = ">= 0.69.0"
10-
gleam_json = ">= 2.0.0 and < 3.0.0"
10+
gleam_json = ">= 3.0.0 and < 4.0.0"
1111
gleam_package_interface = ">= 3.0.0 and < 4.0.0"
1212
simplifile = ">= 2.0.0 and < 3.0.0"
1313

@@ -16,5 +16,5 @@ startest = ">= 0.8.0"
1616

1717
[documentation]
1818
pages = [
19-
{ title = "Technical Specification", path = "SPEC.md" },
19+
{ title = "Technical Specification", path = "SPEC.html", source = "./SPEC.md" },
2020
]

manifest.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# This file was generated by Gleam
2+
# You typically do not need to edit this file
3+
4+
packages = [
5+
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
6+
{ name = "bigben", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time", "interior"], otp_app = "bigben", source = "hex", outer_checksum = "4B0D9F2514C06AE577F284532270A6F89007781620E4B12A843388BA549C17D2" },
7+
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
8+
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
9+
{ name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" },
10+
{ name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" },
11+
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
12+
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
13+
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
14+
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
15+
{ name = "gleam_package_interface", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "8F2D19DE9876D9401BB0626260958A6B1580BB233489C32831FE74CE0ACAE8B4" },
16+
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
17+
{ name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" },
18+
{ name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" },
19+
{ name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" },
20+
{ name = "interior", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "interior", source = "hex", outer_checksum = "EE2728791E34400CB3CD986B6AD0E02A2F970F50B5ED59896E2820702C7DDD29" },
21+
{ name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" },
22+
{ name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" },
23+
{ name = "startest", version = "0.8.0", build_tools = ["gleam"], requirements = ["argv", "bigben", "exception", "gleam_community_ansi", "gleam_erlang", "gleam_javascript", "gleam_regexp", "gleam_stdlib", "gleam_time", "glint", "simplifile", "tom"], otp_app = "startest", source = "hex", outer_checksum = "47362F1D9DFEFC2F81C590F73A5BFBE902F427408EFBFB48765A05512AED02DC" },
24+
{ name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" },
25+
]
26+
27+
[requirements]
28+
gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
29+
gleam_package_interface = { version = ">= 3.0.0 and < 4.0.0" }
30+
gleam_stdlib = { version = ">= 0.69.0" }
31+
simplifile = { version = ">= 2.0.0 and < 3.0.0" }
32+
startest = { version = ">= 0.8.0" }

src/gleam_contracts.gleam

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,121 @@
11
//// Build-time module contract verification for Gleam — enforce that paired modules stay in sync.
2+
3+
import gleam/io
4+
import gleam/package_interface.{type Package}
5+
import gleam/result
6+
import gleam_contracts/loader
7+
import gleam_contracts/rule
8+
import gleam_contracts/verify as contract_verify
9+
import gleam_contracts/violation
10+
11+
/// Decoded package-interface model exported by the compiler.
12+
pub type PackageInterface =
13+
Package
14+
15+
/// Errors that can occur while loading a package-interface file.
16+
pub type LoadError =
17+
loader.LoadError
18+
19+
/// A single contract rule to verify.
20+
pub type Rule =
21+
rule.Rule
22+
23+
/// Expected parameter specification.
24+
pub type ParamSpec =
25+
rule.ParamSpec
26+
27+
/// Expected function export specification.
28+
pub type ExportSpec =
29+
rule.ExportSpec
30+
31+
/// A single contract violation.
32+
pub type Violation =
33+
violation.Violation
34+
35+
/// Verification result.
36+
pub type ContractResult =
37+
contract_verify.ContractResult
38+
39+
/// Load and decode a package interface from a JSON file path.
40+
pub fn load_package_interface(
41+
path path: String,
42+
) -> Result(PackageInterface, LoadError) {
43+
loader.load_package_interface(path:)
44+
}
45+
46+
/// Verify a list of rules against a package interface.
47+
pub fn verify(
48+
interface interface: PackageInterface,
49+
rules rules: List(Rule),
50+
) -> ContractResult {
51+
contract_verify.verify(interface:, rules:)
52+
}
53+
54+
/// Format violations as human-readable lines for terminal output.
55+
pub fn format_violations(violations violations: List(Violation)) -> String {
56+
violation.format_violations(violations:)
57+
}
58+
59+
/// Create a mirror rule.
60+
pub fn mirror_rule(
61+
source source: String,
62+
target target: String,
63+
prefix_params prefix_params: List(ParamSpec),
64+
) -> Rule {
65+
rule.mirror_rule(source:, target:, prefix_params:)
66+
}
67+
68+
/// Add function-name exceptions to a mirror rule.
69+
pub fn with_exceptions(
70+
rule rule: Rule,
71+
exceptions exceptions: List(String),
72+
) -> Rule {
73+
rule.with_exceptions(rule:, exceptions:)
74+
}
75+
76+
/// Create a require-exports rule.
77+
pub fn require_exports(
78+
module module: String,
79+
exports exports: List(ExportSpec),
80+
) -> Rule {
81+
rule.require_exports(module:, exports:)
82+
}
83+
84+
/// Create a shared-types rule.
85+
pub fn shared_types(
86+
module_a module_a: String,
87+
module_b module_b: String,
88+
type_names type_names: List(String),
89+
) -> Rule {
90+
rule.shared_types(module_a:, module_b:, type_names:)
91+
}
92+
93+
/// Load and verify in one step.
94+
pub fn check_result(
95+
interface_path interface_path: String,
96+
rules rules: List(Rule),
97+
) -> ContractResult {
98+
use interface <- result.try(
99+
load_package_interface(path: interface_path)
100+
|> result.map_error(fn(error) {
101+
[
102+
violation.InterfaceLoadFailure(path: interface_path, error: error),
103+
]
104+
}),
105+
)
106+
verify(interface:, rules:)
107+
}
108+
109+
/// Convenience terminal helper for scripts.
110+
pub fn check(
111+
interface_path interface_path: String,
112+
rules rules: List(Rule),
113+
) -> Nil {
114+
case check_result(interface_path:, rules:) {
115+
Ok(Nil) -> Nil
116+
Error(violations) ->
117+
violations
118+
|> format_violations
119+
|> io.println_error
120+
}
121+
}

0 commit comments

Comments
 (0)