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.
- archkeep_rule_sdk-0.13.0/PKG-INFO +306 -0
- archkeep_rule_sdk-0.13.0/README.md +291 -0
- archkeep_rule_sdk-0.13.0/pyproject.toml +68 -0
- archkeep_rule_sdk-0.13.0/setup.cfg +4 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk/__init__.py +60 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk/build.py +336 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk/carrier/Cargo.toml.template +70 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk/carrier/lib.rs.template +737 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk/runtime.py +561 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk.egg-info/PKG-INFO +306 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk.egg-info/SOURCES.txt +15 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk.egg-info/dependency_links.txt +1 -0
- archkeep_rule_sdk-0.13.0/src/archkeep_rule_sdk.egg-info/top_level.txt +1 -0
- archkeep_rule_sdk-0.13.0/tests/test_artifact.py +219 -0
- archkeep_rule_sdk-0.13.0/tests/test_build.py +253 -0
- archkeep_rule_sdk-0.13.0/tests/test_golden.py +173 -0
- archkeep_rule_sdk-0.13.0/tests/test_runtime.py +257 -0
|
@@ -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"]
|