archkeep-rule-sdk 0.13.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.
@@ -0,0 +1,306 @@
1
+ Metadata-Version: 2.4
2
+ Name: archkeep-rule-sdk
3
+ Version: 0.13.0
4
+ Summary: Author Archkeep custom architecture rules in Python and compile them to core WebAssembly.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/ecoma-io/archkeep
7
+ Project-URL: Repository, https://github.com/ecoma-io/archkeep
8
+ Keywords: architecture,webassembly,monorepo,archkeep,lint
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+
16
+ # archkeep-rule-sdk (Python)
17
+
18
+ Write a Archkeep custom architecture rule in Python, compile it to core
19
+ WebAssembly, and declare it in your workspace's policy. The engine loads the
20
+ artifact, hands it the facts it already observed, and folds the verdict into
21
+ `check` beside the built-in boundary rules.
22
+
23
+ The distribution is `archkeep-rule-sdk`; the directory carries the `-python`
24
+ suffix because this tree holds four of these and a registry that already
25
+ names the language does not repeat it — the reasoning is in
26
+ [ADR 0002](../../docs/adr/0002-custom-rules-one-contract.md).
27
+
28
+ ## Read this first: your Python runs inside a carrier
29
+
30
+ A rule artifact is one core WebAssembly module that **imports nothing**, and no
31
+ Python toolchain emits one. Pyodide compiles CPython for the browser and expects
32
+ a JavaScript host to be there. componentize-py emits a component-model binary
33
+ over WASI. The engine's host speaks neither, and refuses a module whose import
34
+ section is non-empty — which is the mechanism behind "a rule holds no ambient
35
+ capability" rather than a promise about the host.
36
+
37
+ So your Python does not become the module. It is **carried** by one: the build
38
+ tool generates a Rust crate that embeds a [RustPython](https://rustpython.github.io)
39
+ interpreter, your `rule.py`, and this package's `runtime.py`, and compiles the
40
+ three of them to wasm. Three consequences, and they are the whole character of
41
+ this SDK:
42
+
43
+ - **Building a rule needs `cargo` and the `wasm32-unknown-unknown` target.**
44
+ Once, on the author's machine. A workspace that RUNS the rule needs neither —
45
+ `check` reads bytes.
46
+ - **The Python that exists is the one the measured build provides**, which is
47
+ the language plus 27 built-in modules and none of the pure-Python standard
48
+ library. The exact list is below, including what is missing, because that is
49
+ the half you need before you write rather than after.
50
+ - **The artifact is about 6.9 MB.** The Rust SDK's equivalent rule is 112 KB.
51
+ You are committing an interpreter.
52
+
53
+ If any of those is the wrong trade for your rule, the
54
+ [Rust SDK](../archkeep-rule-sdk-rust/README.md) writes the same contract with
55
+ none of them.
56
+
57
+ ## What a rule is
58
+
59
+ A pure function from evidence to verdict, and nothing else in either direction.
60
+ No filesystem, no network, no clock, no randomness — not by restraint but by
61
+ construction.
62
+
63
+ ```python
64
+ from archkeep_rule_sdk import Finding, from_findings, unknown
65
+
66
+
67
+ def evaluate(evidence):
68
+ tag = evidence.rule.params.get("forbiddenTag")
69
+ if not isinstance(tag, str) or not tag:
70
+ # Not `passed()`. A rule that could not read its own parameters has
71
+ # judged nothing, and saying so is the whole discipline.
72
+ return unknown("params.forbiddenTag is not a string, so nothing was judged")
73
+
74
+ findings = []
75
+ for edge in evidence.edges:
76
+ target = evidence.project(edge.target)
77
+ if target is not None and target.has_tag(tag):
78
+ findings.append(
79
+ Finding("forbidden-edge", "%s depends on %s" % (edge.source, edge.target))
80
+ .in_project(edge.source)
81
+ )
82
+ return from_findings(findings)
83
+
84
+
85
+ ARCHKEEP_RULE = {
86
+ "name": "no-dependency-on-tag",
87
+ "needs": ["model", "graph"],
88
+ "findings": [{"id": "forbidden-edge", "message": "a dependency lands on a forbidden tag"}],
89
+ }
90
+ ```
91
+
92
+ `examples/forbidden_tag_dependency.py` is the same shape, complete, and it is
93
+ the artifact this package ships and tests.
94
+
95
+ `ARCHKEEP_RULE` sits beside the function rather than in a separate manifest, for
96
+ the reason the Rust SDK's `archkeep_rule!` sits beside its `evaluate`: a finding
97
+ id and the code that emits it must not be able to live in two files that
98
+ disagree. The build tool reads it by name; the carrier crate validates its
99
+ grammar — the name's spelling, duplicate ids, an empty catalogue — at compile
100
+ time, so a malformed declaration fails `cargo` rather than a consumer's `check`.
101
+
102
+ ## The four verdicts, and why `passed` is the narrow one
103
+
104
+ `passed()` is a claim: the rule read the evidence it declared it needs and there
105
+ was nothing to report. Everything that is not that claim has its own answer —
106
+ `failed(findings)`, `not_applicable(reason)`, `unknown(reason)` — because an
107
+ empty finding list from a rule that could not look is byte-for-byte identical to
108
+ a clean workspace, and nobody files a bug about it.
109
+
110
+ The hollow shapes are refused at construction: `failed([])` raises,
111
+ `unknown("")` raises, `Finding("", "")` raises, and `Finding.at` takes a file and
112
+ a 1-based position together so "a position with no file" has no spelling.
113
+ `passed` and `failed` are past participles because `pass` is a Python keyword;
114
+ the pair reads as one vocabulary rather than one name bending around the parser.
115
+
116
+ A rule may only read the kinds its `needs` declares. `evidence.imports` on a
117
+ rule that declared `["model", "graph"]` raises `UndeclaredEvidence` — under
118
+ CPython in your tests and inside the artifact alike — because the carrier
119
+ converts only the declared kinds, and returning the empty list instead would be
120
+ a rule scanning nothing and reporting a clean workspace.
121
+
122
+ ## What the interpreter actually provides
123
+
124
+ Measured on the shipped build — RustPython 0.5.0, `default-features = false`,
125
+ `features = ["compiler"]`, no frozen standard library — by compiling a probe
126
+ rule with this package's own build tool and running it through the engine's
127
+ host. Not inferred from RustPython's documentation.
128
+
129
+ - **Language**: Python 3.14.0alpha as RustPython implements it. Classes,
130
+ closures, comprehensions, f-strings, `%` and `.format()`, exceptions,
131
+ generators, `__slots__`, decorators, arbitrary-precision integers and the 153
132
+ builtins are all there. `sys.platform` is `"unknown"`; `sys.maxsize` is 2147483647,
133
+ because the target is 32-bit.
134
+ - **Importable** (27): `sys`, `builtins`, `_abc`, `_ast`, `_codecs`,
135
+ `_collections`, `_functools`, `_imp`, `_io`, `_operator`, `_sre`, `_stat`,
136
+ `_string`, `_symtable`, `_sysconfig`, `_sysconfigdata`, `_thread`, `_types`,
137
+ `_typing`, `_warnings`, `_weakref`, `atexit`, `errno`, `gc`, `itertools`,
138
+ `marshal`, `time`.
139
+ - **NOT importable**, and this is the load-bearing half: `re`, `json`,
140
+ `collections`, `functools`, `typing`, `dataclasses`, `enum`, `abc`, `math`,
141
+ `os`, `io`, `copy`, `string`, `operator`, `warnings`, `traceback`, `datetime`,
142
+ `random`, `decimal`, `pathlib`, `logging`, and the rest of the pure-Python
143
+ standard library. **There is no regular-expression engine.** A rule matching
144
+ paths does it with `str.startswith`, `in`, and `str.split`.
145
+ - **Randomness is fixed.** The interpreter's entropy source is a constant
146
+ generator and its hash seed is pinned to 0, so `hash("archkeep")` answers
147
+ `-850506456041535210` on every run of every copy of every artifact. A rule is
148
+ a pure function; two runs over an unchanged tree that disagreed would be a
149
+ rule nobody could review.
150
+
151
+ Adding the frozen standard library was measured and refused: it makes `re`,
152
+ `json`, `collections`, `functools`, `typing`, `enum`, `abc`, `copy`, `string`,
153
+ `operator`, `textwrap`, `heapq` and `bisect` importable and takes the artifact
154
+ from 6.89 MB to 12.99 MB (2.24 MB to 7.01 MB gzipped, which is what git stores,
155
+ on every rebuild, forever). The deciding argument was not the bytes: the Rust
156
+ binding gives its authors `std` and two serde crates and no regex engine either,
157
+ and an SDK that handed one language a capability the other does not have would
158
+ stop being a binding of one contract.
159
+
160
+ ### The object ceiling, which is the sharp edge
161
+
162
+ RustPython keeps an object's strong reference count in the spare bits of one
163
+ `usize`, which on a 32-bit target leaves **15 bits: 32,767**. Every live Python
164
+ object holds a reference to its own type object, so the number of live dicts,
165
+ live strings and live `True`/`False` references are each capped there — and a
166
+ workspace's evidence is exactly a large number of live dicts and strings.
167
+
168
+ The carrier spends a budget of 30,000 objects and answers `unknown` naming the
169
+ ceiling before the abort can happen. Measured, for a rule declaring
170
+ `["model", "graph"]`:
171
+
172
+ | the rule can judge | up to |
173
+ | ------------------ | ------ |
174
+ | projects | ~3,700 |
175
+ | edges | ~3,300 |
176
+
177
+ Only the declared kinds are converted, so a `["model", "graph"]` rule pays
178
+ nothing for a workspace's import sites — one with 20,000 of them judges fine.
179
+ A rule that declares `imports` spends the same budget on them instead, at about
180
+ 16 objects per record.
181
+
182
+ Past the budget the verdict is `unknown` with the workspace's own size named,
183
+ never a partial judgment over the records that fit. **A rule that must judge a
184
+ workspace larger than this belongs in the Rust SDK**, whose artifact holds no
185
+ interpreter and has no such ceiling — the same rule there judges 100,000 import
186
+ sites in 463 ms.
187
+
188
+ ## Building the artifact
189
+
190
+ ```bash
191
+ rustup target add wasm32-unknown-unknown
192
+ python -m archkeep_rule_sdk.build rule.py --out rule.wasm
193
+ ```
194
+
195
+ It writes `rule.wasm` and `rule.wasm.sha256` beside it — the digest a policy row
196
+ pins, bare lowercase hex and nothing else. The first build of a carrier takes
197
+ about three minutes; every one after it, with `--keep-crate` pointing somewhere
198
+ stable, about forty-five seconds.
199
+
200
+ The result declares **no imports at all** and exports the four symbols the ABI
201
+ names, `memory` among them, plus `__getrandom_v03_custom` (the fixed entropy
202
+ backend, exported because a `no_mangle` symbol on wasm is) and the two linker
203
+ globals the host ignores. Check both before shipping it:
204
+
205
+ ```bash
206
+ node -e 'const m=new WebAssembly.Module(require("fs").readFileSync(process.argv[1]));
207
+ console.log(WebAssembly.Module.imports(m), WebAssembly.Module.exports(m).map(e=>e.name))' \
208
+ rule.wasm
209
+ ```
210
+
211
+ ## Declaring it
212
+
213
+ ```js
214
+ export const customRules = [
215
+ {
216
+ name: "forbidden-tag-dependency",
217
+ artifact: "tools/rules/forbidden_tag_dependency.wasm",
218
+ sha256: "<the contents of examples/forbidden_tag_dependency.wasm.sha256>",
219
+ params: { forbiddenTag: "layer-infrastructure", exemptTags: ["layer-adapter"] },
220
+ reason: "infrastructure is reached through the domain's ports, never directly",
221
+ },
222
+ ];
223
+ ```
224
+
225
+ The `name` here and the `name` in `ARCHKEEP_RULE` must be the same string: the
226
+ host refuses the pair when they differ, because the declared name is what every
227
+ finding is namespaced under.
228
+
229
+ ## The committed artifact, and why a binary is in this tree
230
+
231
+ `examples/forbidden_tag_dependency.wasm` is committed, and
232
+ `examples/forbidden_tag_dependency.wasm.sha256` beside it holds its digest.
233
+ `python3 -m unittest` recomputes the digest over the committed bytes and fails
234
+ when the two have drifted, so a rebuilt artifact cannot land beside the digest
235
+ of the one before it; the same suite also walks the binary's sections and
236
+ refuses one that grew an import section — the host's own no-import refusal,
237
+ checked on bytes CPython cannot instantiate (`tests/test_artifact.py`). Rebuild
238
+ both together:
239
+
240
+ ```bash
241
+ ./rebuild-example.sh
242
+ ```
243
+
244
+ CI does not run that script. The tests check the bytes in the tree, which is the
245
+ point — a green run proves the artifact a reviewer can hash, not one the runner
246
+ just produced. The digest pins those bytes; it does not claim a reproducible
247
+ build, and a different rustc or a different RustPython will produce a different
248
+ artifact and a different digest, which is why the tool writes both files or
249
+ neither.
250
+
251
+ Driven through the engine's own host — `loadCustomRule` and `evaluateCustomRule`
252
+ from `../archkeep/src/custom-rules/host.mjs` — the committed artifact answers
253
+ this, and the five verdicts are the ones
254
+ `../archkeep-rule-sdk-rust/tests/golden.rs` pins for the Rust reference rule:
255
+
256
+ ```text
257
+ artifact bytes: 6919667 sha256: 5f2247cab8d490759f99a0cffafa57aed1c46892a2d3224ea1c5818c4c73ed22
258
+ loadCustomRule ms: 120.6
259
+ describe: {"contract":1,"name":"forbidden-tag-dependency","needs":["model","graph"],
260
+ "findings":[{"id":"dependency-on-forbidden-tag","message":"a project depends on a project carrying the forbidden tag"}]}
261
+
262
+ edge-into-forbidden-tag.json (3517 bytes, 236 ms) fail 1 finding, beta -> gamma, packages/beta/src/store.rs
263
+ every-edge-clean.json (3370 bytes, 85 ms) pass 0 findings
264
+ no-project-carries-the-tag.json (2449 bytes, 86 ms) not_applicable "no project in this workspace carries …"
265
+ params-without-the-tag.json (3320 bytes, 92 ms) unknown "params.forbiddenTag is not a non-empty string …"
266
+ edge-into-an-undeclared-project.json (3289 bytes, 85 ms) unknown "the graph carries an edge into \"epsilon\" …"
267
+ ```
268
+
269
+ Against the host's own bounds: 10,000 ms per call and 256 MiB of linear memory.
270
+ The artifact instantiates holding 3,407,872 bytes, ends a call holding 5,570,560,
271
+ answers `archkeep_describe` in 0.8 ms, and three fresh instances over one bundle
272
+ produce one byte-identical verdict.
273
+
274
+ ## The fixtures
275
+
276
+ `fixtures/` holds evidence bundles produced by the engine's own bundle assembly
277
+ and canonical serializer — not hand-written JSON that looks like one — and they
278
+ are byte-identical copies of `../archkeep-rule-sdk-rust/fixtures/`.
279
+ `tests/test_artifact.py` compares the two directories and FAILS rather than skips
280
+ when the sibling is not there: two SDKs each green against its own idea of the
281
+ evidence would leave the thing the suite exists to prove — that one contract
282
+ reaches two languages — quietly untested.
283
+
284
+ `tests/test_golden.py` pins the verdict each fixture must produce, replaying
285
+ through `drive`, which is the same entry point the carrier's Rust half calls
286
+ inside the artifact. Two of the five are there for the silent direction: a rule
287
+ that cannot read its parameters, and a graph naming a project the model does not
288
+ declare. Both must answer `unknown`; a suite where they answered `pass` would be
289
+ green over a rule that had stopped running.
290
+
291
+ ## What this package will not do
292
+
293
+ It binds the contract and never interprets it. There is no second verdict
294
+ vocabulary, no SDK-specific field, and no re-modelling of the policy — a
295
+ constraint row arrives as the plain JSON the engine loaded, because
296
+ [the engine](../archkeep/README.md) owns that schema and a copy here would drift
297
+ from it. Whether a finding resolves to a catalogue entry, whether a verdict is
298
+ hollow, whether a `needs` entry names a kind the engine can supply: all of that
299
+ is the host's refusal to make, and duplicating it here would put this package's
300
+ opinion in front of the real one.
301
+
302
+ It also declares no dependencies, and the list is meant to stay empty:
303
+ `runtime.py` is embedded verbatim into every artifact and executed by an
304
+ interpreter with no filesystem, so a dependency could never reach the rule that
305
+ imports it. That is why `runtime.py` imports nothing at all, and why
306
+ `tests/test_runtime.py` reads it with `ast` and fails if it ever does.
@@ -0,0 +1,291 @@
1
+ # archkeep-rule-sdk (Python)
2
+
3
+ Write a Archkeep custom architecture rule in Python, compile it to core
4
+ WebAssembly, and declare it in your workspace's policy. The engine loads the
5
+ artifact, hands it the facts it already observed, and folds the verdict into
6
+ `check` beside the built-in boundary rules.
7
+
8
+ The distribution is `archkeep-rule-sdk`; the directory carries the `-python`
9
+ suffix because this tree holds four of these and a registry that already
10
+ names the language does not repeat it — the reasoning is in
11
+ [ADR 0002](../../docs/adr/0002-custom-rules-one-contract.md).
12
+
13
+ ## Read this first: your Python runs inside a carrier
14
+
15
+ A rule artifact is one core WebAssembly module that **imports nothing**, and no
16
+ Python toolchain emits one. Pyodide compiles CPython for the browser and expects
17
+ a JavaScript host to be there. componentize-py emits a component-model binary
18
+ over WASI. The engine's host speaks neither, and refuses a module whose import
19
+ section is non-empty — which is the mechanism behind "a rule holds no ambient
20
+ capability" rather than a promise about the host.
21
+
22
+ So your Python does not become the module. It is **carried** by one: the build
23
+ tool generates a Rust crate that embeds a [RustPython](https://rustpython.github.io)
24
+ interpreter, your `rule.py`, and this package's `runtime.py`, and compiles the
25
+ three of them to wasm. Three consequences, and they are the whole character of
26
+ this SDK:
27
+
28
+ - **Building a rule needs `cargo` and the `wasm32-unknown-unknown` target.**
29
+ Once, on the author's machine. A workspace that RUNS the rule needs neither —
30
+ `check` reads bytes.
31
+ - **The Python that exists is the one the measured build provides**, which is
32
+ the language plus 27 built-in modules and none of the pure-Python standard
33
+ library. The exact list is below, including what is missing, because that is
34
+ the half you need before you write rather than after.
35
+ - **The artifact is about 6.9 MB.** The Rust SDK's equivalent rule is 112 KB.
36
+ You are committing an interpreter.
37
+
38
+ If any of those is the wrong trade for your rule, the
39
+ [Rust SDK](../archkeep-rule-sdk-rust/README.md) writes the same contract with
40
+ none of them.
41
+
42
+ ## What a rule is
43
+
44
+ A pure function from evidence to verdict, and nothing else in either direction.
45
+ No filesystem, no network, no clock, no randomness — not by restraint but by
46
+ construction.
47
+
48
+ ```python
49
+ from archkeep_rule_sdk import Finding, from_findings, unknown
50
+
51
+
52
+ def evaluate(evidence):
53
+ tag = evidence.rule.params.get("forbiddenTag")
54
+ if not isinstance(tag, str) or not tag:
55
+ # Not `passed()`. A rule that could not read its own parameters has
56
+ # judged nothing, and saying so is the whole discipline.
57
+ return unknown("params.forbiddenTag is not a string, so nothing was judged")
58
+
59
+ findings = []
60
+ for edge in evidence.edges:
61
+ target = evidence.project(edge.target)
62
+ if target is not None and target.has_tag(tag):
63
+ findings.append(
64
+ Finding("forbidden-edge", "%s depends on %s" % (edge.source, edge.target))
65
+ .in_project(edge.source)
66
+ )
67
+ return from_findings(findings)
68
+
69
+
70
+ ARCHKEEP_RULE = {
71
+ "name": "no-dependency-on-tag",
72
+ "needs": ["model", "graph"],
73
+ "findings": [{"id": "forbidden-edge", "message": "a dependency lands on a forbidden tag"}],
74
+ }
75
+ ```
76
+
77
+ `examples/forbidden_tag_dependency.py` is the same shape, complete, and it is
78
+ the artifact this package ships and tests.
79
+
80
+ `ARCHKEEP_RULE` sits beside the function rather than in a separate manifest, for
81
+ the reason the Rust SDK's `archkeep_rule!` sits beside its `evaluate`: a finding
82
+ id and the code that emits it must not be able to live in two files that
83
+ disagree. The build tool reads it by name; the carrier crate validates its
84
+ grammar — the name's spelling, duplicate ids, an empty catalogue — at compile
85
+ time, so a malformed declaration fails `cargo` rather than a consumer's `check`.
86
+
87
+ ## The four verdicts, and why `passed` is the narrow one
88
+
89
+ `passed()` is a claim: the rule read the evidence it declared it needs and there
90
+ was nothing to report. Everything that is not that claim has its own answer —
91
+ `failed(findings)`, `not_applicable(reason)`, `unknown(reason)` — because an
92
+ empty finding list from a rule that could not look is byte-for-byte identical to
93
+ a clean workspace, and nobody files a bug about it.
94
+
95
+ The hollow shapes are refused at construction: `failed([])` raises,
96
+ `unknown("")` raises, `Finding("", "")` raises, and `Finding.at` takes a file and
97
+ a 1-based position together so "a position with no file" has no spelling.
98
+ `passed` and `failed` are past participles because `pass` is a Python keyword;
99
+ the pair reads as one vocabulary rather than one name bending around the parser.
100
+
101
+ A rule may only read the kinds its `needs` declares. `evidence.imports` on a
102
+ rule that declared `["model", "graph"]` raises `UndeclaredEvidence` — under
103
+ CPython in your tests and inside the artifact alike — because the carrier
104
+ converts only the declared kinds, and returning the empty list instead would be
105
+ a rule scanning nothing and reporting a clean workspace.
106
+
107
+ ## What the interpreter actually provides
108
+
109
+ Measured on the shipped build — RustPython 0.5.0, `default-features = false`,
110
+ `features = ["compiler"]`, no frozen standard library — by compiling a probe
111
+ rule with this package's own build tool and running it through the engine's
112
+ host. Not inferred from RustPython's documentation.
113
+
114
+ - **Language**: Python 3.14.0alpha as RustPython implements it. Classes,
115
+ closures, comprehensions, f-strings, `%` and `.format()`, exceptions,
116
+ generators, `__slots__`, decorators, arbitrary-precision integers and the 153
117
+ builtins are all there. `sys.platform` is `"unknown"`; `sys.maxsize` is 2147483647,
118
+ because the target is 32-bit.
119
+ - **Importable** (27): `sys`, `builtins`, `_abc`, `_ast`, `_codecs`,
120
+ `_collections`, `_functools`, `_imp`, `_io`, `_operator`, `_sre`, `_stat`,
121
+ `_string`, `_symtable`, `_sysconfig`, `_sysconfigdata`, `_thread`, `_types`,
122
+ `_typing`, `_warnings`, `_weakref`, `atexit`, `errno`, `gc`, `itertools`,
123
+ `marshal`, `time`.
124
+ - **NOT importable**, and this is the load-bearing half: `re`, `json`,
125
+ `collections`, `functools`, `typing`, `dataclasses`, `enum`, `abc`, `math`,
126
+ `os`, `io`, `copy`, `string`, `operator`, `warnings`, `traceback`, `datetime`,
127
+ `random`, `decimal`, `pathlib`, `logging`, and the rest of the pure-Python
128
+ standard library. **There is no regular-expression engine.** A rule matching
129
+ paths does it with `str.startswith`, `in`, and `str.split`.
130
+ - **Randomness is fixed.** The interpreter's entropy source is a constant
131
+ generator and its hash seed is pinned to 0, so `hash("archkeep")` answers
132
+ `-850506456041535210` on every run of every copy of every artifact. A rule is
133
+ a pure function; two runs over an unchanged tree that disagreed would be a
134
+ rule nobody could review.
135
+
136
+ Adding the frozen standard library was measured and refused: it makes `re`,
137
+ `json`, `collections`, `functools`, `typing`, `enum`, `abc`, `copy`, `string`,
138
+ `operator`, `textwrap`, `heapq` and `bisect` importable and takes the artifact
139
+ from 6.89 MB to 12.99 MB (2.24 MB to 7.01 MB gzipped, which is what git stores,
140
+ on every rebuild, forever). The deciding argument was not the bytes: the Rust
141
+ binding gives its authors `std` and two serde crates and no regex engine either,
142
+ and an SDK that handed one language a capability the other does not have would
143
+ stop being a binding of one contract.
144
+
145
+ ### The object ceiling, which is the sharp edge
146
+
147
+ RustPython keeps an object's strong reference count in the spare bits of one
148
+ `usize`, which on a 32-bit target leaves **15 bits: 32,767**. Every live Python
149
+ object holds a reference to its own type object, so the number of live dicts,
150
+ live strings and live `True`/`False` references are each capped there — and a
151
+ workspace's evidence is exactly a large number of live dicts and strings.
152
+
153
+ The carrier spends a budget of 30,000 objects and answers `unknown` naming the
154
+ ceiling before the abort can happen. Measured, for a rule declaring
155
+ `["model", "graph"]`:
156
+
157
+ | the rule can judge | up to |
158
+ | ------------------ | ------ |
159
+ | projects | ~3,700 |
160
+ | edges | ~3,300 |
161
+
162
+ Only the declared kinds are converted, so a `["model", "graph"]` rule pays
163
+ nothing for a workspace's import sites — one with 20,000 of them judges fine.
164
+ A rule that declares `imports` spends the same budget on them instead, at about
165
+ 16 objects per record.
166
+
167
+ Past the budget the verdict is `unknown` with the workspace's own size named,
168
+ never a partial judgment over the records that fit. **A rule that must judge a
169
+ workspace larger than this belongs in the Rust SDK**, whose artifact holds no
170
+ interpreter and has no such ceiling — the same rule there judges 100,000 import
171
+ sites in 463 ms.
172
+
173
+ ## Building the artifact
174
+
175
+ ```bash
176
+ rustup target add wasm32-unknown-unknown
177
+ python -m archkeep_rule_sdk.build rule.py --out rule.wasm
178
+ ```
179
+
180
+ It writes `rule.wasm` and `rule.wasm.sha256` beside it — the digest a policy row
181
+ pins, bare lowercase hex and nothing else. The first build of a carrier takes
182
+ about three minutes; every one after it, with `--keep-crate` pointing somewhere
183
+ stable, about forty-five seconds.
184
+
185
+ The result declares **no imports at all** and exports the four symbols the ABI
186
+ names, `memory` among them, plus `__getrandom_v03_custom` (the fixed entropy
187
+ backend, exported because a `no_mangle` symbol on wasm is) and the two linker
188
+ globals the host ignores. Check both before shipping it:
189
+
190
+ ```bash
191
+ node -e 'const m=new WebAssembly.Module(require("fs").readFileSync(process.argv[1]));
192
+ console.log(WebAssembly.Module.imports(m), WebAssembly.Module.exports(m).map(e=>e.name))' \
193
+ rule.wasm
194
+ ```
195
+
196
+ ## Declaring it
197
+
198
+ ```js
199
+ export const customRules = [
200
+ {
201
+ name: "forbidden-tag-dependency",
202
+ artifact: "tools/rules/forbidden_tag_dependency.wasm",
203
+ sha256: "<the contents of examples/forbidden_tag_dependency.wasm.sha256>",
204
+ params: { forbiddenTag: "layer-infrastructure", exemptTags: ["layer-adapter"] },
205
+ reason: "infrastructure is reached through the domain's ports, never directly",
206
+ },
207
+ ];
208
+ ```
209
+
210
+ The `name` here and the `name` in `ARCHKEEP_RULE` must be the same string: the
211
+ host refuses the pair when they differ, because the declared name is what every
212
+ finding is namespaced under.
213
+
214
+ ## The committed artifact, and why a binary is in this tree
215
+
216
+ `examples/forbidden_tag_dependency.wasm` is committed, and
217
+ `examples/forbidden_tag_dependency.wasm.sha256` beside it holds its digest.
218
+ `python3 -m unittest` recomputes the digest over the committed bytes and fails
219
+ when the two have drifted, so a rebuilt artifact cannot land beside the digest
220
+ of the one before it; the same suite also walks the binary's sections and
221
+ refuses one that grew an import section — the host's own no-import refusal,
222
+ checked on bytes CPython cannot instantiate (`tests/test_artifact.py`). Rebuild
223
+ both together:
224
+
225
+ ```bash
226
+ ./rebuild-example.sh
227
+ ```
228
+
229
+ CI does not run that script. The tests check the bytes in the tree, which is the
230
+ point — a green run proves the artifact a reviewer can hash, not one the runner
231
+ just produced. The digest pins those bytes; it does not claim a reproducible
232
+ build, and a different rustc or a different RustPython will produce a different
233
+ artifact and a different digest, which is why the tool writes both files or
234
+ neither.
235
+
236
+ Driven through the engine's own host — `loadCustomRule` and `evaluateCustomRule`
237
+ from `../archkeep/src/custom-rules/host.mjs` — the committed artifact answers
238
+ this, and the five verdicts are the ones
239
+ `../archkeep-rule-sdk-rust/tests/golden.rs` pins for the Rust reference rule:
240
+
241
+ ```text
242
+ artifact bytes: 6919667 sha256: 5f2247cab8d490759f99a0cffafa57aed1c46892a2d3224ea1c5818c4c73ed22
243
+ loadCustomRule ms: 120.6
244
+ describe: {"contract":1,"name":"forbidden-tag-dependency","needs":["model","graph"],
245
+ "findings":[{"id":"dependency-on-forbidden-tag","message":"a project depends on a project carrying the forbidden tag"}]}
246
+
247
+ edge-into-forbidden-tag.json (3517 bytes, 236 ms) fail 1 finding, beta -> gamma, packages/beta/src/store.rs
248
+ every-edge-clean.json (3370 bytes, 85 ms) pass 0 findings
249
+ no-project-carries-the-tag.json (2449 bytes, 86 ms) not_applicable "no project in this workspace carries …"
250
+ params-without-the-tag.json (3320 bytes, 92 ms) unknown "params.forbiddenTag is not a non-empty string …"
251
+ edge-into-an-undeclared-project.json (3289 bytes, 85 ms) unknown "the graph carries an edge into \"epsilon\" …"
252
+ ```
253
+
254
+ Against the host's own bounds: 10,000 ms per call and 256 MiB of linear memory.
255
+ The artifact instantiates holding 3,407,872 bytes, ends a call holding 5,570,560,
256
+ answers `archkeep_describe` in 0.8 ms, and three fresh instances over one bundle
257
+ produce one byte-identical verdict.
258
+
259
+ ## The fixtures
260
+
261
+ `fixtures/` holds evidence bundles produced by the engine's own bundle assembly
262
+ and canonical serializer — not hand-written JSON that looks like one — and they
263
+ are byte-identical copies of `../archkeep-rule-sdk-rust/fixtures/`.
264
+ `tests/test_artifact.py` compares the two directories and FAILS rather than skips
265
+ when the sibling is not there: two SDKs each green against its own idea of the
266
+ evidence would leave the thing the suite exists to prove — that one contract
267
+ reaches two languages — quietly untested.
268
+
269
+ `tests/test_golden.py` pins the verdict each fixture must produce, replaying
270
+ through `drive`, which is the same entry point the carrier's Rust half calls
271
+ inside the artifact. Two of the five are there for the silent direction: a rule
272
+ that cannot read its parameters, and a graph naming a project the model does not
273
+ declare. Both must answer `unknown`; a suite where they answered `pass` would be
274
+ green over a rule that had stopped running.
275
+
276
+ ## What this package will not do
277
+
278
+ It binds the contract and never interprets it. There is no second verdict
279
+ vocabulary, no SDK-specific field, and no re-modelling of the policy — a
280
+ constraint row arrives as the plain JSON the engine loaded, because
281
+ [the engine](../archkeep/README.md) owns that schema and a copy here would drift
282
+ from it. Whether a finding resolves to a catalogue entry, whether a verdict is
283
+ hollow, whether a `needs` entry names a kind the engine can supply: all of that
284
+ is the host's refusal to make, and duplicating it here would put this package's
285
+ opinion in front of the real one.
286
+
287
+ It also declares no dependencies, and the list is meant to stay empty:
288
+ `runtime.py` is embedded verbatim into every artifact and executed by an
289
+ interpreter with no filesystem, so a dependency could never reach the rule that
290
+ imports it. That is why `runtime.py` imports nothing at all, and why
291
+ `tests/test_runtime.py` reads it with `ast` and fails if it ever does.
@@ -0,0 +1,68 @@
1
+ # The Python binding for the custom-rule contract
2
+ # (`../../docs/adr/0002-custom-rules-one-contract.md`).
3
+ #
4
+ # The directory carries the language suffix and the DISTRIBUTION DOES NOT: the
5
+ # ADR's "One name on every registry" section settled `archkeep-rule-sdk` as the
6
+ # published name, and PyPI already names the language. The tree, which will hold
7
+ # four of these, is where the suffix belongs.
8
+ #
9
+ # The version is the repository's single version
10
+ # (`../../.release-please-manifest.json`), because the releasable unit here is
11
+ # the repository rather than any one package. It is written by release-please's
12
+ # TOML updater at `$.project.version` — the row belongs beside the Rust SDK's in
13
+ # `../../release-please-config.json`, and `../../scripts/check-skills.mjs` holds
14
+ # the chain on every pull request.
15
+ #
16
+ # **No dependencies, and the list is meant to stay empty.** Everything an author
17
+ # imports from this package is in `src/archkeep_rule_sdk/runtime.py`, which
18
+ # imports nothing itself — because that same file is embedded verbatim into a
19
+ # rule's `.wasm` carrier and executed by a RustPython that has no filesystem to
20
+ # import from. A dependency here could never reach the artifact, so a rule using
21
+ # one would pass its tests and answer `unknown` in the field. The build tool is
22
+ # the same story from the other side: it drives `cargo`, which is a process, not
23
+ # a package.
24
+ [project]
25
+ name = "archkeep-rule-sdk"
26
+ version = "0.13.0"
27
+ description = "Author Archkeep custom architecture rules in Python and compile them to core WebAssembly."
28
+ readme = "README.md"
29
+ license = "Apache-2.0"
30
+ keywords = ["architecture", "webassembly", "monorepo", "archkeep", "lint"]
31
+ # 3.11 is the floor because `tomllib` and the `Path | None` spelling this
32
+ # package's build tool uses arrived there, and because it is the oldest release
33
+ # still receiving security fixes. Nothing here needs anything newer.
34
+ requires-python = ">=3.11"
35
+ dependencies = []
36
+ # No license classifier beside the SPDX `license` expression above: PEP 639
37
+ # supersedes the classifier, and the setuptools that PyPI's isolated build
38
+ # environment resolves REFUSES the pair — measured against build 1.5.0, the
39
+ # exact command the release lane runs, which is how this comment got here.
40
+ classifiers = [
41
+ "Development Status :: 4 - Beta",
42
+ "Intended Audience :: Developers",
43
+ "Programming Language :: Python :: 3",
44
+ "Topic :: Software Development :: Quality Assurance",
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/ecoma-io/archkeep"
49
+ Repository = "https://github.com/ecoma-io/archkeep"
50
+
51
+ [build-system]
52
+ # setuptools rather than a newer backend, for one reason: nothing in this
53
+ # repository builds this package. There is no `build` target here — the same
54
+ # decision `../../AGENTS.md` argues for every package in this tree — so the
55
+ # backend is a fact stated for whoever publishes, and the one every environment
56
+ # already has is the one least likely to need explaining.
57
+ requires = ["setuptools>=68"]
58
+ build-backend = "setuptools.build_meta"
59
+
60
+ [tool.setuptools.packages.find]
61
+ where = ["src"]
62
+
63
+ [tool.setuptools.package-data]
64
+ # The carrier crate's templates ship WITH the package: `python -m
65
+ # archkeep_rule_sdk.build` reads them out of the installed distribution to
66
+ # generate a rule's crate, so a wheel without them is a wheel that cannot build
67
+ # anything. `runtime.py` is ordinary module source and needs no entry here.
68
+ archkeep_rule_sdk = ["carrier/*.template"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+