boundry 0.1.0 → 0.2.0
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.
- package/CHANGELOG.md +106 -0
- package/README.md +111 -2
- package/dist/adapters/enforcer/depcruiser.d.ts +1 -1
- package/dist/adapters/enforcer/depcruiser.js +46 -2
- package/dist/adapters/visualizer/likec4.js +68 -45
- package/dist/cli/index.js +21 -10
- package/dist/core/model/boundary-model.d.ts +36 -0
- package/dist/core/model/boundary-model.js +20 -0
- package/dist/core/pipeline/pipeline.d.ts +18 -4
- package/dist/core/pipeline/pipeline.js +9 -5
- package/dist/index.d.ts +1 -1
- package/package.json +6 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Boundry are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
|
+
versioning follows [semver](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] — unreleased
|
|
8
|
+
|
|
9
|
+
The release that makes the diagram a *governed* artifact rather than just a
|
|
10
|
+
source of rules. An agent can now be handed the diagram without being handed the
|
|
11
|
+
ability to grant itself dependencies.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Approval lifecycle — `#proposed`.** An edge marked `#proposed` is intent, not
|
|
16
|
+
permission: it is excluded from the allow-list, so `check` stays red until a
|
|
17
|
+
human approves. Mark an element too and the reviewer sees the new box as well
|
|
18
|
+
as the new arrow.
|
|
19
|
+
- On an **edge** the marker is semantic (the edge grants nothing until
|
|
20
|
+
approved). On an **element** it is visual only — a module is enforced from
|
|
21
|
+
the moment it is drawn, which is safe, because a module grants nothing until
|
|
22
|
+
an edge permits it. **The edge is always the only grant.**
|
|
23
|
+
- **`boundry approve`** — strips `#proposed` markers deterministically by
|
|
24
|
+
splicing the LikeC4 CST. Never an LLM edit: source-preserving, idempotent, and
|
|
25
|
+
byte-exact. Approval is a human act; this command is the human's.
|
|
26
|
+
- **`boundry verify --base <ref>`** — compares the working diagram against an
|
|
27
|
+
approved git ref and rejects any edge granted *without* going through a
|
|
28
|
+
proposal. Because proposals are excluded from the allow-list, the set of
|
|
29
|
+
newly-allowed edges is exactly the set of self-approvals. `approve --base` uses
|
|
30
|
+
the same gate, so it refuses to launder a self-granted edge.
|
|
31
|
+
- **`governRoot` (opt-in)** ([#1]) — `metadata { governRoot 'src' }` declares a code
|
|
32
|
+
root fully governed: importing territory under it that no module claims is a
|
|
33
|
+
violation rather than free, and `check` warns about code no module covers. The
|
|
34
|
+
mirror of the zero-files warning. Without it, unmapped folders stay ignored, so
|
|
35
|
+
a communication diagram still works as an enforcement diagram.
|
|
36
|
+
- **`#anything` wildcard** — a box tagged `#anything` maps to no folder and stands
|
|
37
|
+
for the rest of the code; a module with an edge into it is exempt from every
|
|
38
|
+
rule. This is the explicit way to say "deliberately unconstrained", for the
|
|
39
|
+
composition root that must import everything to wire it together.
|
|
40
|
+
- It rides the same protocol as any other edge: `#proposed` suppresses it until
|
|
41
|
+
approved, and `verify` reports a self-granted wildcard edge like any other.
|
|
42
|
+
- Prior to this, the only way to let `src/index.ts` wire things up under a
|
|
43
|
+
govern root was to leave it *unmapped* — granting the same power by
|
|
44
|
+
**omission**, with nothing drawn for a reviewer to see.
|
|
45
|
+
- The exemption belongs to the wired module alone and does not leak.
|
|
46
|
+
- **`exemptImporters`** ([#2]) — `metadata { exemptImporters '/__tests__/|\\.d\\.ts$' }`
|
|
47
|
+
drops matched files from every rule's `from` side: they are analysed, but never
|
|
48
|
+
held to a boundary as an importer. For test files that legitimately wire real
|
|
49
|
+
adapters across layers, and for ambient `.d.ts` declarations.
|
|
50
|
+
- **From-side only.** Exempt files stay governed as import *targets*, so
|
|
51
|
+
production importing a test helper is still a violation.
|
|
52
|
+
- Patterns are regexes and union across every element that declares one.
|
|
53
|
+
LikeC4 has no repeated metadata keys, so use a `|` alternation or one per
|
|
54
|
+
element. **Backslashes must be doubled** — LikeC4 processes string escapes,
|
|
55
|
+
so `'\.d\.ts$'` arrives as `.d.ts$` and over-matches silently.
|
|
56
|
+
- `check` warns when an exemption matches **zero** files or **every** file;
|
|
57
|
+
both mean the pattern is wrong. An invalid or empty pattern is a hard error.
|
|
58
|
+
- `verify` reports an exemption added since the base, like any other grant.
|
|
59
|
+
- **Skill for coding agents** — `.claude/skills/define-architecture-boundaries`
|
|
60
|
+
documents the annotations and the proposal protocol, including the things an
|
|
61
|
+
agent must never do: add a bare edge, strip a marker, run `approve`, draw a
|
|
62
|
+
wildcard, or route around a boundary through an unmodelled folder.
|
|
63
|
+
- **Nesting support.** A module owns its folder *minus* any mapped descendants,
|
|
64
|
+
so a parent's edges govern only the parent's own files. A child never inherits
|
|
65
|
+
them and must be permitted explicitly; a connection between two subtrees never
|
|
66
|
+
permits every child pair across them.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- **A check that analysed zero files reported success.** `check` now throws when
|
|
71
|
+
dependency-cruiser sees no sources — the setup being broken is a failure, not a
|
|
72
|
+
clean run. This is the guard that caught the 0.1.0 packaging bug below.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- **`Pipeline.verify()` returns `VerifyResult`** (`{ edges, exemptions }`) rather
|
|
77
|
+
than a bare edge array, so both kinds of grant surface. SDK-breaking; the CLI
|
|
78
|
+
is unaffected.
|
|
79
|
+
|
|
80
|
+
[#1]: https://github.com/makspiechota/boundry/issues/1
|
|
81
|
+
[#2]: https://github.com/makspiechota/boundry/issues/2
|
|
82
|
+
|
|
83
|
+
## [0.1.1] — never released
|
|
84
|
+
|
|
85
|
+
Tagged locally, never published or pushed; its contents ship as part of 0.2.0.
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- **`typescript` moved to `dependencies`.** dependency-cruiser needs it at
|
|
90
|
+
runtime to parse `.ts`; as a devDependency it was absent from a real install.
|
|
91
|
+
- **Stale `dist/` artifacts in the published tarball.** `build` now cleans first,
|
|
92
|
+
so a restructure can't leave orphaned files from a previous layout.
|
|
93
|
+
|
|
94
|
+
## [0.1.0] — 2026-07 — **deprecated, do not use**
|
|
95
|
+
|
|
96
|
+
**This version silently enforces nothing.** `typescript` was a devDependency, so
|
|
97
|
+
in a clean install dependency-cruiser could not parse TypeScript and reported
|
|
98
|
+
`✓ no boundary violations` while checking zero files. A guardrail that fails open
|
|
99
|
+
is worse than none. Upgrade.
|
|
100
|
+
|
|
101
|
+
## [0.0.1]
|
|
102
|
+
|
|
103
|
+
First publish. Compiles a LikeC4 diagram into dependency-cruiser rules:
|
|
104
|
+
`metadata { folder '…' }` maps an element to a folder, every drawn relationship
|
|
105
|
+
is an allowed dependency, and everything undrawn is forbidden. Commands: `check`,
|
|
106
|
+
`generate`.
|
package/README.md
CHANGED
|
@@ -47,6 +47,64 @@ mirror of the zero-files warning: the model failing to cover the code is as much
|
|
|
47
47
|
a gap as the code failing to back the model. Nothing changes unless you declare
|
|
48
48
|
it.
|
|
49
49
|
|
|
50
|
+
A mapped folder claims its **entire subtree**, so the abstraction level stays
|
|
51
|
+
yours: one box on `src/domain` covers everything beneath it. You add finer boxes
|
|
52
|
+
where you want finer *rules*, not to satisfy the coverage check.
|
|
53
|
+
|
|
54
|
+
### Exempting test files and ambient declarations
|
|
55
|
+
|
|
56
|
+
Every file under a mapped folder is governed as a rule *source* by default —
|
|
57
|
+
including tests. An integration test that wires a real database adapter across a
|
|
58
|
+
layer boundary is doing its job, not breaking the architecture. Exempt them:
|
|
59
|
+
|
|
60
|
+
```likec4
|
|
61
|
+
system app 'App' {
|
|
62
|
+
metadata {
|
|
63
|
+
exemptImporters '/__tests__/|\\.d\\.ts$'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Matched files are still analysed, but they are dropped from every rule's `from`
|
|
69
|
+
side, so they may import anything. This is **from-side only** — they stay
|
|
70
|
+
governed as import *targets*, so production reaching into a test helper is as
|
|
71
|
+
forbidden as it ever was. Patterns are regexes, union across every element that
|
|
72
|
+
declares one, and change nothing unless you declare one.
|
|
73
|
+
|
|
74
|
+
> **Double your backslashes.** LikeC4 processes string escapes, so `'\.d\.ts$'`
|
|
75
|
+
> arrives as `.d.ts$`, where `.` is a wildcard that matches far more than you
|
|
76
|
+
> meant. Write `'\\.d\\.ts$'`. Boundry warns when an exemption matches **zero**
|
|
77
|
+
> files, or when one matches **every** file, since both mean the pattern is
|
|
78
|
+
> wrong.
|
|
79
|
+
|
|
80
|
+
An exemption is a grant — it lifts whole files out of every rule — so `verify`
|
|
81
|
+
reports one added since the approved base, exactly like an undrawn edge.
|
|
82
|
+
|
|
83
|
+
### The composition root — `#anything`
|
|
84
|
+
|
|
85
|
+
Every repo has one place that legitimately imports everything: the entry point
|
|
86
|
+
that constructs the object graph. Give it a module and wire it to a wildcard:
|
|
87
|
+
|
|
88
|
+
```likec4
|
|
89
|
+
// Owns 'src' minus every mapped descendant — the loose files at the root.
|
|
90
|
+
module entry 'Composition root' {
|
|
91
|
+
metadata { folder 'src' }
|
|
92
|
+
}
|
|
93
|
+
element anything 'Anything' {
|
|
94
|
+
#anything
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
entry -> anything
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A box tagged `#anything` maps to no folder and stands for "the rest of the code".
|
|
101
|
+
A module with an edge into it is exempt from every rule — and only that module;
|
|
102
|
+
the exemption doesn't leak.
|
|
103
|
+
|
|
104
|
+
The alternative was to leave `src/index.ts` unmapped, which grants it the same
|
|
105
|
+
freedom by **omission**: nothing drawn, nothing to review. The wildcard makes the
|
|
106
|
+
exemption a visible box someone approved on purpose.
|
|
107
|
+
|
|
50
108
|
## See it
|
|
51
109
|
|
|
52
110
|
The diagram you draw *is* the whole spec. Below is the example architecture that
|
|
@@ -111,11 +169,49 @@ npx boundry check --arch arch src
|
|
|
111
169
|
|
|
112
170
|
A `domain → infra` import is now a build failure; `infra → domain` is fine.
|
|
113
171
|
|
|
172
|
+
## Changing the architecture — propose, approve, commit
|
|
173
|
+
|
|
174
|
+
If agents can edit the diagram, they can grant themselves any dependency they
|
|
175
|
+
like, and the guardrail is theatre. So a change to the architecture goes through
|
|
176
|
+
a lifecycle:
|
|
177
|
+
|
|
178
|
+
**propose** → **approve** → **commit**
|
|
179
|
+
|
|
180
|
+
An agent blocked by a boundary adds the edge **with a marker**:
|
|
181
|
+
|
|
182
|
+
```likec4
|
|
183
|
+
domain -> shared #proposed
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A `#proposed` edge is *intent, not permission*. It's excluded from the allow-list,
|
|
187
|
+
so `check` stays red and the agent stays blocked. It has asked, not taken.
|
|
188
|
+
|
|
189
|
+
A human approves by stripping the marker — that's what `boundry approve` does,
|
|
190
|
+
deterministically, by splicing the LikeC4 CST. No model call, no reformatting:
|
|
191
|
+
source-preserving, idempotent, byte-exact.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
boundry verify --arch arch --base origin/main # any edge granted without a marker?
|
|
195
|
+
boundry approve --arch arch --base origin/main # HUMAN ONLY: strip markers = approve
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`verify` compares against the approved diagram at a git ref and rejects edges that
|
|
199
|
+
appeared *without* going through a proposal. Because proposals are excluded from
|
|
200
|
+
the allow-list, the newly-allowed set is exactly the set of self-approvals — no
|
|
201
|
+
diff engine required. `approve --base` runs the same gate first, so it won't
|
|
202
|
+
launder a self-granted edge into an approved one.
|
|
203
|
+
|
|
204
|
+
Point your agents at
|
|
205
|
+
[`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
|
|
206
|
+
and they'll follow this protocol.
|
|
207
|
+
|
|
114
208
|
## CLI
|
|
115
209
|
|
|
116
210
|
```
|
|
117
211
|
boundry check [--arch <dir>] [--cwd <dir>] [sources...]
|
|
118
212
|
boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
213
|
+
boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
|
|
214
|
+
boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
|
|
119
215
|
```
|
|
120
216
|
|
|
121
217
|
| Flag | Meaning |
|
|
@@ -123,14 +219,18 @@ boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
|
123
219
|
| `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
|
|
124
220
|
| `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
|
|
125
221
|
| `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
|
|
222
|
+
| `--base <ref>` | `verify`/`approve`: the git ref holding the approved diagram to compare against. |
|
|
126
223
|
| `sources...` | `check` only: paths to lint. Default `src`. |
|
|
127
224
|
|
|
128
225
|
- **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
|
|
129
226
|
- **`generate`** just emits the dependency-cruiser config so you can commit it or
|
|
130
227
|
run `depcruise` yourself.
|
|
228
|
+
- **`verify`** rejects dependencies granted without a proposal.
|
|
229
|
+
- **`approve`** strips `#proposed` markers. For humans, not agents.
|
|
131
230
|
|
|
132
|
-
Boundry warns (but does not fail) when a mapped folder matches **zero** files
|
|
133
|
-
so a passing check can
|
|
231
|
+
Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
|
|
232
|
+
**fails outright** when a check analysed no files at all — so a passing check can
|
|
233
|
+
never silently enforce nothing. A guardrail that fails open is worse than none.
|
|
134
234
|
|
|
135
235
|
## CI
|
|
136
236
|
|
|
@@ -147,6 +247,9 @@ jobs:
|
|
|
147
247
|
with: { node-version: 20 }
|
|
148
248
|
- run: npm ci
|
|
149
249
|
- run: npx boundry check --arch arch src
|
|
250
|
+
# On PRs, also reject dependencies granted without a proposal.
|
|
251
|
+
- run: npx boundry verify --arch arch --base origin/${{ github.base_ref }}
|
|
252
|
+
if: github.event_name == 'pull_request'
|
|
150
253
|
```
|
|
151
254
|
|
|
152
255
|
## Programmatic use (SDK)
|
|
@@ -186,6 +289,12 @@ Current limitations:
|
|
|
186
289
|
parent's edges govern only the parent's own files — a child never inherits
|
|
187
290
|
them and must be permitted explicitly.
|
|
188
291
|
- `folder` paths are relative to the repo root (`--cwd`), not the diagram file.
|
|
292
|
+
- With a `governRoot`, unmapped code is blocked as an import *target* but is not
|
|
293
|
+
yet constrained as an *importer* — rules are generated per mapped module, so
|
|
294
|
+
unmapped code has no rules of its own.
|
|
295
|
+
|
|
296
|
+
See the [changelog](./CHANGELOG.md) for what's in each release.
|
|
297
|
+
**`0.1.0` is deprecated** — it silently enforces nothing (see the changelog).
|
|
189
298
|
|
|
190
299
|
## License
|
|
191
300
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EnforcerPort, EnforcerConfig, CheckResult } from '../../core/ports/ports.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type BoundaryModel } from '../../core/model/boundary-model.js';
|
|
3
3
|
/** First enforcer adapter: targets dependency-cruiser for TypeScript. */
|
|
4
4
|
export declare class DepCruiserEnforcer implements EnforcerPort {
|
|
5
5
|
render(model: BoundaryModel): EnforcerConfig;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as dependencyCruiser from 'dependency-cruiser';
|
|
2
|
+
import { unconstrainedModules } from '../../core/model/boundary-model.js';
|
|
2
3
|
// dependency-cruiser ships CommonJS; reach `cruise` through either interop shape.
|
|
3
4
|
const cruise = dependencyCruiser.cruise ?? dependencyCruiser.default?.cruise;
|
|
4
5
|
function folderPrefix(folder) {
|
|
@@ -38,8 +39,22 @@ function buildForbiddenRules(model) {
|
|
|
38
39
|
? { path: folderToRegex(m.folder), pathNot: descendants }
|
|
39
40
|
: { path: folderToRegex(m.folder) });
|
|
40
41
|
}
|
|
42
|
+
// Exemptions apply to the `from` side only: matched files may import freely,
|
|
43
|
+
// but every module's scope still governs them as a *target*, so importing a
|
|
44
|
+
// test helper from production stays forbidden.
|
|
45
|
+
const asImporter = (id) => {
|
|
46
|
+
const scope = scopeOf.get(id);
|
|
47
|
+
if (model.exemptImporters.length === 0)
|
|
48
|
+
return scope;
|
|
49
|
+
return { ...scope, pathNot: [...(scope.pathNot ?? []), ...model.exemptImporters] };
|
|
50
|
+
};
|
|
51
|
+
// A module wired to an `#anything` wildcard is deliberately exempt: it is the
|
|
52
|
+
// composition root, whose job is to import everything and wire it together.
|
|
53
|
+
const unconstrained = unconstrainedModules(model);
|
|
41
54
|
const rules = [];
|
|
42
55
|
for (const from of model.modules) {
|
|
56
|
+
if (unconstrained.has(from.id))
|
|
57
|
+
continue;
|
|
43
58
|
const allowed = allowedTargets.get(from.id);
|
|
44
59
|
for (const to of model.modules) {
|
|
45
60
|
if (allowed.has(to.id))
|
|
@@ -48,7 +63,7 @@ function buildForbiddenRules(model) {
|
|
|
48
63
|
name: `boundary:${from.id}->${to.id}`,
|
|
49
64
|
comment: `${from.title} may not depend on ${to.title}`,
|
|
50
65
|
severity: 'error',
|
|
51
|
-
from:
|
|
66
|
+
from: asImporter(from.id),
|
|
52
67
|
to: scopeOf.get(to.id),
|
|
53
68
|
});
|
|
54
69
|
}
|
|
@@ -59,11 +74,13 @@ function buildForbiddenRules(model) {
|
|
|
59
74
|
if (model.governRoot) {
|
|
60
75
|
const claimed = model.modules.map((m) => folderToRegex(m.folder));
|
|
61
76
|
for (const from of model.modules) {
|
|
77
|
+
if (unconstrained.has(from.id))
|
|
78
|
+
continue;
|
|
62
79
|
rules.push({
|
|
63
80
|
name: `boundary:${from.id}->unmapped`,
|
|
64
81
|
comment: `${from.title} may not depend on code under '${model.governRoot}' that no module claims`,
|
|
65
82
|
severity: 'error',
|
|
66
|
-
from:
|
|
83
|
+
from: asImporter(from.id),
|
|
67
84
|
to: { path: folderToRegex(model.governRoot), pathNot: claimed },
|
|
68
85
|
});
|
|
69
86
|
}
|
|
@@ -101,6 +118,13 @@ module.exports = {
|
|
|
101
118
|
// A module whose folder matched no source files enforces nothing — the
|
|
102
119
|
// "green but inert" trap. Surface it so a passing check can't hide it.
|
|
103
120
|
const seen = (output?.modules ?? []).map((m) => String(m.source));
|
|
121
|
+
// A guardrail that analysed nothing must never report success. Seeing zero
|
|
122
|
+
// sources means the setup is broken (wrong paths, or no TypeScript for
|
|
123
|
+
// dependency-cruiser to parse with) — that is a failure, not a clean run.
|
|
124
|
+
if (seen.length === 0) {
|
|
125
|
+
throw new Error(`analysed 0 source files under '${sources.join(', ')}' — nothing was enforced. ` +
|
|
126
|
+
'Check the source paths, and that TypeScript is installed alongside Boundry.');
|
|
127
|
+
}
|
|
104
128
|
const warnings = [];
|
|
105
129
|
for (const mod of model.modules) {
|
|
106
130
|
const prefix = `${mod.folder.replace(/\/+$/, '')}/`;
|
|
@@ -108,6 +132,22 @@ module.exports = {
|
|
|
108
132
|
warnings.push(`module '${mod.title}' maps to '${mod.folder}', which matched 0 source files`);
|
|
109
133
|
}
|
|
110
134
|
}
|
|
135
|
+
// An exemption is a grant, so a dead one is worth knowing about: it means
|
|
136
|
+
// the pattern is wrong (LikeC4 eats backslashes) and files the author meant
|
|
137
|
+
// to exempt are still being policed — or were never there to begin with.
|
|
138
|
+
const exemptions = model.exemptImporters.map((pattern) => ({
|
|
139
|
+
pattern,
|
|
140
|
+
matches: seen.filter((source) => new RegExp(pattern).test(source)),
|
|
141
|
+
}));
|
|
142
|
+
for (const { pattern, matches } of exemptions) {
|
|
143
|
+
if (matches.length === 0) {
|
|
144
|
+
warnings.push(`exemption '${pattern}' matched 0 source files`);
|
|
145
|
+
}
|
|
146
|
+
else if (matches.length === seen.length) {
|
|
147
|
+
warnings.push(`exemption '${pattern}' matched every source file — no file can violate a boundary`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const isExempt = (source) => model.exemptImporters.some((pattern) => new RegExp(pattern).test(source));
|
|
111
151
|
// The symmetric guard: code under a declared govern root that no module
|
|
112
152
|
// claims. The model not covering the code is as much a gap as the code not
|
|
113
153
|
// backing the model.
|
|
@@ -120,6 +160,10 @@ module.exports = {
|
|
|
120
160
|
continue;
|
|
121
161
|
if (claimed.some((prefix) => source.startsWith(prefix)))
|
|
122
162
|
continue;
|
|
163
|
+
// Exempt code is deliberately outside the architecture; asking a module
|
|
164
|
+
// to claim it would defeat the point of exempting it.
|
|
165
|
+
if (isExempt(source))
|
|
166
|
+
continue;
|
|
123
167
|
unclaimed.add(source.slice(0, source.lastIndexOf('/')));
|
|
124
168
|
}
|
|
125
169
|
for (const dir of unclaimed) {
|
|
@@ -1,52 +1,54 @@
|
|
|
1
1
|
import { LikeC4 } from 'likec4';
|
|
2
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return n;
|
|
17
|
-
for (const key of Object.keys(n)) {
|
|
18
|
-
if (!key.startsWith('$'))
|
|
19
|
-
stack.push(n[key]);
|
|
20
|
-
}
|
|
3
|
+
/**
|
|
4
|
+
* An exemption pattern has to be a usable regex before it reaches the linter. A
|
|
5
|
+
* broken or empty one would otherwise be a silent hole: it exempts files from
|
|
6
|
+
* every rule, so failing to compile it must be loud, never shrugged off.
|
|
7
|
+
*
|
|
8
|
+
* Note LikeC4 string literals process escapes, so `'\.d\.ts$'` arrives here as
|
|
9
|
+
* `.d.ts$` — a regex where `.` is a wildcard. Patterns must be written
|
|
10
|
+
* `'\\.d\\.ts$'` in the diagram. That silent widening is why an exemption that
|
|
11
|
+
* matches nothing is reported at check time.
|
|
12
|
+
*/
|
|
13
|
+
function assertUsableRegex(pattern, elementId) {
|
|
14
|
+
if (pattern.trim() === '') {
|
|
15
|
+
throw new Error(`element '${elementId}' declares an empty exemption pattern, which would exempt every file`);
|
|
21
16
|
}
|
|
22
|
-
|
|
17
|
+
try {
|
|
18
|
+
new RegExp(pattern);
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
throw new Error(`element '${elementId}' declares an exemption pattern that is not a valid regex: ` +
|
|
22
|
+
`'${pattern}' (${cause.message}). Remember LikeC4 eats backslashes — ` +
|
|
23
|
+
'write \\\\. to mean a literal dot.');
|
|
24
|
+
}
|
|
25
|
+
return pattern;
|
|
23
26
|
}
|
|
27
|
+
/** A `#proposed` marker in the source — on an element or a relationship alike. */
|
|
24
28
|
function isProposedTag(node, text) {
|
|
25
29
|
return (node.$type === 'TagRef' &&
|
|
26
30
|
node.$cstNode &&
|
|
27
31
|
text.slice(node.$cstNode.offset, node.$cstNode.end) === '#proposed');
|
|
28
32
|
}
|
|
29
33
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
34
|
+
* The source to remove for one marker. A marker alone on its line takes the
|
|
35
|
+
* whole line (so approving leaves no blank gap); an inline one takes just the
|
|
36
|
+
* token and the space before it. The `tag proposed` declaration in the
|
|
37
|
+
* specification is untouched — the vocabulary stays for the next proposal.
|
|
33
38
|
*/
|
|
34
|
-
function proposedRemovalRange(
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const tag = findDescendant(relation, (n) => isProposedTag(n, text));
|
|
43
|
-
if (tag) {
|
|
44
|
-
let start = tag.$cstNode.offset;
|
|
45
|
-
while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
|
|
46
|
-
start--;
|
|
47
|
-
return { start, end: tag.$cstNode.end };
|
|
39
|
+
function proposedRemovalRange(tag, text) {
|
|
40
|
+
const { offset, end } = tag.$cstNode;
|
|
41
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
42
|
+
const lineEnd = text.indexOf('\n', end);
|
|
43
|
+
const beforeOnLine = text.slice(lineStart, offset);
|
|
44
|
+
const afterOnLine = text.slice(end, lineEnd === -1 ? text.length : lineEnd);
|
|
45
|
+
if (beforeOnLine.trim() === '' && afterOnLine.trim() === '') {
|
|
46
|
+
return { start: lineStart, end: lineEnd === -1 ? end : lineEnd + 1 };
|
|
48
47
|
}
|
|
49
|
-
|
|
48
|
+
let start = offset;
|
|
49
|
+
while (start > lineStart && (text[start - 1] === ' ' || text[start - 1] === '\t'))
|
|
50
|
+
start--;
|
|
51
|
+
return { start, end };
|
|
50
52
|
}
|
|
51
53
|
function collectProposedRanges(root, text) {
|
|
52
54
|
const ranges = [];
|
|
@@ -60,12 +62,8 @@ function collectProposedRanges(root, text) {
|
|
|
60
62
|
walk(item);
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
|
-
if (node
|
|
64
|
-
node
|
|
65
|
-
findDescendant(node, (n) => isProposedTag(n, text))) {
|
|
66
|
-
const range = proposedRemovalRange(node, text);
|
|
67
|
-
if (range)
|
|
68
|
-
ranges.push(range);
|
|
65
|
+
if (isProposedTag(node, text)) {
|
|
66
|
+
ranges.push(proposedRemovalRange(node, text));
|
|
69
67
|
}
|
|
70
68
|
for (const key of Object.keys(node)) {
|
|
71
69
|
if (!key.startsWith('$'))
|
|
@@ -91,14 +89,30 @@ export class LikeC4Visualizer {
|
|
|
91
89
|
const model = await likec4.computedModel();
|
|
92
90
|
const modules = [];
|
|
93
91
|
const folderIds = new Set();
|
|
92
|
+
const wildcardIds = new Set();
|
|
93
|
+
const exemptImporters = new Set();
|
|
94
94
|
let governRoot;
|
|
95
95
|
for (const el of model.elements()) {
|
|
96
|
+
// Any element may exempt importers; the patterns union across the
|
|
97
|
+
// diagram. LikeC4 forbids a repeated metadata key, so several patterns
|
|
98
|
+
// means either a `|` alternation or one per element.
|
|
99
|
+
const exemptMeta = el.getMetadata('exemptImporters');
|
|
100
|
+
for (const raw of Array.isArray(exemptMeta) ? exemptMeta : exemptMeta ? [exemptMeta] : []) {
|
|
101
|
+
exemptImporters.add(assertUsableRegex(raw, String(el.id)));
|
|
102
|
+
}
|
|
96
103
|
// Opt-in: any element may declare the code root as fully governed. It
|
|
97
104
|
// needs no `folder` of its own — it is a declaration, not a module.
|
|
98
105
|
const rootMeta = el.getMetadata('governRoot');
|
|
99
106
|
const declaredRoot = Array.isArray(rootMeta) ? rootMeta[0] : rootMeta;
|
|
100
107
|
if (declaredRoot && !governRoot)
|
|
101
108
|
governRoot = declaredRoot;
|
|
109
|
+
// A box tagged `#anything` is a wildcard, not a module: it maps to no
|
|
110
|
+
// folder and stands for the rest of the code. An edge into it exempts the
|
|
111
|
+
// source from every rule.
|
|
112
|
+
if (el.tags.some((tag) => String(tag) === 'anything')) {
|
|
113
|
+
wildcardIds.add(String(el.id));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
102
116
|
const meta = el.getMetadata('folder');
|
|
103
117
|
const folder = Array.isArray(meta) ? meta[0] : meta;
|
|
104
118
|
if (folder) {
|
|
@@ -115,11 +129,20 @@ export class LikeC4Visualizer {
|
|
|
115
129
|
continue;
|
|
116
130
|
const from = String(rel.source.id);
|
|
117
131
|
const to = String(rel.target.id);
|
|
118
|
-
|
|
132
|
+
// An edge into a wildcard is kept like any other grant, so `verify` sees a
|
|
133
|
+
// self-granted `#anything` exemption as the newly-allowed edge it is.
|
|
134
|
+
const targetGoverned = folderIds.has(to) || wildcardIds.has(to);
|
|
135
|
+
if (from !== to && folderIds.has(from) && targetGoverned) {
|
|
119
136
|
allowed.push({ from, to });
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
modules,
|
|
141
|
+
allowed,
|
|
142
|
+
governRoot,
|
|
143
|
+
wildcards: [...wildcardIds],
|
|
144
|
+
exemptImporters: [...exemptImporters],
|
|
145
|
+
};
|
|
123
146
|
}
|
|
124
147
|
/**
|
|
125
148
|
* Deterministically strip `#proposed` markers from the diagram source,
|
package/dist/cli/index.js
CHANGED
|
@@ -46,10 +46,21 @@ function materializeArchAt(archDir, ref) {
|
|
|
46
46
|
}
|
|
47
47
|
return workDir;
|
|
48
48
|
}
|
|
49
|
-
function
|
|
50
|
-
|
|
49
|
+
function countGrants(granted) {
|
|
50
|
+
return granted.edges.length + granted.exemptions.length;
|
|
51
|
+
}
|
|
52
|
+
function listGrants(granted) {
|
|
53
|
+
for (const edge of granted.edges)
|
|
51
54
|
console.error(` ${edge.from} → ${edge.to}`);
|
|
52
|
-
|
|
55
|
+
for (const pattern of granted.exemptions) {
|
|
56
|
+
console.error(` exemption '${pattern}' — lifts matching files out of every rule`);
|
|
57
|
+
}
|
|
58
|
+
if (granted.edges.length > 0) {
|
|
59
|
+
console.error(' Mark them #proposed so the grant is an explicit, reviewable act.');
|
|
60
|
+
}
|
|
61
|
+
if (granted.exemptions.length > 0) {
|
|
62
|
+
console.error(' An exemption cannot be proposed — a human adds it to the diagram, or not at all.');
|
|
63
|
+
}
|
|
53
64
|
}
|
|
54
65
|
async function main() {
|
|
55
66
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -97,12 +108,12 @@ async function main() {
|
|
|
97
108
|
return;
|
|
98
109
|
}
|
|
99
110
|
const granted = await grantedSince(baseRef);
|
|
100
|
-
if (granted
|
|
101
|
-
console.log(`Boundry: ✓
|
|
111
|
+
if (countGrants(granted) === 0) {
|
|
112
|
+
console.log(`Boundry: ✓ nothing granted without a #proposed marker (vs ${baseRef})`);
|
|
102
113
|
return;
|
|
103
114
|
}
|
|
104
|
-
console.error(`Boundry: ✗ ${granted
|
|
105
|
-
|
|
115
|
+
console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs ${baseRef})`);
|
|
116
|
+
listGrants(granted);
|
|
106
117
|
process.exitCode = 1;
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
@@ -110,9 +121,9 @@ async function main() {
|
|
|
110
121
|
// Approving must never launder an edge that skipped the proposal protocol.
|
|
111
122
|
if (baseRef) {
|
|
112
123
|
const granted = await grantedSince(baseRef);
|
|
113
|
-
if (granted
|
|
114
|
-
console.error(`Boundry: ✗ refusing to approve — ${granted
|
|
115
|
-
|
|
124
|
+
if (countGrants(granted) > 0) {
|
|
125
|
+
console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs ${baseRef})`);
|
|
126
|
+
listGrants(granted);
|
|
116
127
|
process.exitCode = 1;
|
|
117
128
|
return;
|
|
118
129
|
}
|
|
@@ -19,6 +19,28 @@ export interface AllowedEdge {
|
|
|
19
19
|
export interface BoundaryModel {
|
|
20
20
|
modules: Module[];
|
|
21
21
|
allowed: AllowedEdge[];
|
|
22
|
+
/**
|
|
23
|
+
* Ids of wildcard elements — diagram boxes tagged `#anything`. They map to no
|
|
24
|
+
* folder and are not modules; they stand for "the rest of the code". A module
|
|
25
|
+
* with an allowed edge to one is exempt from every boundary rule.
|
|
26
|
+
*
|
|
27
|
+
* This is the explicit way to say "deliberately unconstrained", for the
|
|
28
|
+
* composition root that must wire everything together. It is a drawn,
|
|
29
|
+
* reviewable grant — unlike opting out by staying unmapped, where the mere
|
|
30
|
+
* ABSENCE of a box would hand out the same power silently.
|
|
31
|
+
*/
|
|
32
|
+
wildcards: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Regex patterns for files that are analysed but never held to a boundary as
|
|
35
|
+
* an *importer*. Matched files are dropped from every rule's `from` side, so
|
|
36
|
+
* they may import anything; they are still governed as import *targets*.
|
|
37
|
+
*
|
|
38
|
+
* For test files and ambient `.d.ts` declarations: an integration test wiring
|
|
39
|
+
* real adapters across layers is doing its job, not breaking the
|
|
40
|
+
* architecture. From-side only — production importing a test helper stays as
|
|
41
|
+
* forbidden as it ever was.
|
|
42
|
+
*/
|
|
43
|
+
exemptImporters: string[];
|
|
22
44
|
/**
|
|
23
45
|
* Optional code root declared fully governed. Every folder under it is
|
|
24
46
|
* expected to be modelled, so importing territory no module claims is
|
|
@@ -27,6 +49,20 @@ export interface BoundaryModel {
|
|
|
27
49
|
*/
|
|
28
50
|
governRoot?: string;
|
|
29
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Modules holding an approved edge to a wildcard element, i.e. free to import
|
|
54
|
+
* anything. A `#proposed` edge to a wildcard is excluded from `allowed` like any
|
|
55
|
+
* other proposal, so the exemption only takes effect once a human approves it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function unconstrainedModules(model: BoundaryModel): Set<string>;
|
|
58
|
+
/**
|
|
59
|
+
* Exemption patterns present at `head` but not at `base`.
|
|
60
|
+
*
|
|
61
|
+
* An exemption is a grant — it lifts whole files out of every rule — so a new
|
|
62
|
+
* one has to face the same gate as a new edge. Without this, an agent blocked by
|
|
63
|
+
* a boundary could add `exemptImporters '/src/'` and walk straight through.
|
|
64
|
+
*/
|
|
65
|
+
export declare function newlyExemptedImporters(base: BoundaryModel, head: BoundaryModel): string[];
|
|
30
66
|
/**
|
|
31
67
|
* Edges allowed at `head` that were not allowed at `base`.
|
|
32
68
|
*
|
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
* The single, source-agnostic representation everything in Boundry compiles
|
|
3
3
|
* from. A visualizer adapter produces it; an enforcer adapter renders it.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Modules holding an approved edge to a wildcard element, i.e. free to import
|
|
7
|
+
* anything. A `#proposed` edge to a wildcard is excluded from `allowed` like any
|
|
8
|
+
* other proposal, so the exemption only takes effect once a human approves it.
|
|
9
|
+
*/
|
|
10
|
+
export function unconstrainedModules(model) {
|
|
11
|
+
const wildcards = new Set(model.wildcards);
|
|
12
|
+
return new Set(model.allowed.filter((edge) => wildcards.has(edge.to)).map((edge) => edge.from));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Exemption patterns present at `head` but not at `base`.
|
|
16
|
+
*
|
|
17
|
+
* An exemption is a grant — it lifts whole files out of every rule — so a new
|
|
18
|
+
* one has to face the same gate as a new edge. Without this, an agent blocked by
|
|
19
|
+
* a boundary could add `exemptImporters '/src/'` and walk straight through.
|
|
20
|
+
*/
|
|
21
|
+
export function newlyExemptedImporters(base, head) {
|
|
22
|
+
const exemptAtBase = new Set(base.exemptImporters);
|
|
23
|
+
return head.exemptImporters.filter((pattern) => !exemptAtBase.has(pattern));
|
|
24
|
+
}
|
|
5
25
|
const edgeKey = (edge) => `${edge.from} -> ${edge.to}`;
|
|
6
26
|
/**
|
|
7
27
|
* Edges allowed at `head` that were not allowed at `base`.
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult } from "../ports/ports.js";
|
|
2
2
|
import type { AllowedEdge } from "../model/boundary-model.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a diagram grants that the approved base did not. Both kinds of grant are
|
|
5
|
+
* reported, because both let code cross a boundary that was previously closed.
|
|
6
|
+
*
|
|
7
|
+
* This lives with the pipeline rather than the ports: no driven port deals in
|
|
8
|
+
* it, it is what `verify` returns.
|
|
9
|
+
*/
|
|
10
|
+
export interface VerifyResult {
|
|
11
|
+
/** Dependencies added without going through a `#proposed` proposal. */
|
|
12
|
+
edges: AllowedEdge[];
|
|
13
|
+
/** Importer exemptions added, lifting files out of every rule. */
|
|
14
|
+
exemptions: string[];
|
|
15
|
+
}
|
|
3
16
|
/**
|
|
4
17
|
* The SDK's public surface. Orchestrates the two driven ports and stays blind
|
|
5
18
|
* to any concrete diagram source or linter — swap adapters, this is untouched.
|
|
@@ -13,11 +26,12 @@ export declare class Pipeline {
|
|
|
13
26
|
/** Diagram -> boundary model -> run the linter over `sources`. */
|
|
14
27
|
check(sources: string[]): Promise<CheckResult>;
|
|
15
28
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* so anything reported here bypassed the approval
|
|
29
|
+
* What this diagram grants that `base` did not — dependencies added without a
|
|
30
|
+
* `#proposed` marker, and importer exemptions added. Proposals are excluded
|
|
31
|
+
* from the allow-list, so anything reported here bypassed the approval
|
|
32
|
+
* protocol.
|
|
19
33
|
*/
|
|
20
|
-
verify(base: VisualizerPort): Promise<
|
|
34
|
+
verify(base: VisualizerPort): Promise<VerifyResult>;
|
|
21
35
|
/** Approve proposed edges: strip their `#proposed` markers from the diagram. */
|
|
22
36
|
approve(): Promise<void>;
|
|
23
37
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newlyAllowedEdges } from "../model/boundary-model.js";
|
|
1
|
+
import { newlyAllowedEdges, newlyExemptedImporters, } from "../model/boundary-model.js";
|
|
2
2
|
/**
|
|
3
3
|
* The SDK's public surface. Orchestrates the two driven ports and stays blind
|
|
4
4
|
* to any concrete diagram source or linter — swap adapters, this is untouched.
|
|
@@ -21,13 +21,17 @@ export class Pipeline {
|
|
|
21
21
|
return this.enforcer.check(model, sources);
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* so anything reported here bypassed the approval
|
|
24
|
+
* What this diagram grants that `base` did not — dependencies added without a
|
|
25
|
+
* `#proposed` marker, and importer exemptions added. Proposals are excluded
|
|
26
|
+
* from the allow-list, so anything reported here bypassed the approval
|
|
27
|
+
* protocol.
|
|
27
28
|
*/
|
|
28
29
|
async verify(base) {
|
|
29
30
|
const [baseModel, headModel] = await Promise.all([base.read(), this.visualizer.read()]);
|
|
30
|
-
return
|
|
31
|
+
return {
|
|
32
|
+
edges: newlyAllowedEdges(baseModel, headModel),
|
|
33
|
+
exemptions: newlyExemptedImporters(baseModel, headModel),
|
|
34
|
+
};
|
|
31
35
|
}
|
|
32
36
|
/** Approve proposed edges: strip their `#proposed` markers from the diagram. */
|
|
33
37
|
async approve() {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { BoundaryModel, Module, AllowedEdge, } from './core/model/boundary-model.js';
|
|
2
2
|
export type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, Violation, } from './core/ports/ports.js';
|
|
3
|
-
export { Pipeline } from './core/pipeline/pipeline.js';
|
|
3
|
+
export { Pipeline, type VerifyResult } from './core/pipeline/pipeline.js';
|
|
4
4
|
export { LikeC4Visualizer } from './adapters/visualizer/likec4.js';
|
|
5
5
|
export { DepCruiserEnforcer } from './adapters/enforcer/depcruiser.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "boundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Compile a C4 architecture diagram into a deterministic dependency linter. Deterministic architectural guardrails for AI agents and humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"README.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
42
43
|
"LICENSE"
|
|
43
44
|
],
|
|
44
45
|
"engines": {
|
|
@@ -50,16 +51,16 @@
|
|
|
50
51
|
"check": "tsx src/cli/index.ts check --arch arch src",
|
|
51
52
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
52
53
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
53
|
-
"test": "node --import tsx --test src/__tests__/e2e
|
|
54
|
+
"test": "node --import tsx --test \"src/__tests__/e2e/**/*.e2e.test.ts\"",
|
|
54
55
|
"prepublishOnly": "npm run build"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
58
|
"dependency-cruiser": "^16.4.0",
|
|
58
|
-
"likec4": "^1.46.0"
|
|
59
|
+
"likec4": "^1.46.0",
|
|
60
|
+
"typescript": "^5.6.0"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
61
63
|
"@types/node": "^22.7.0",
|
|
62
|
-
"tsx": "^4.19.0"
|
|
63
|
-
"typescript": "^5.6.0"
|
|
64
|
+
"tsx": "^4.19.0"
|
|
64
65
|
}
|
|
65
66
|
}
|