boundry 0.1.0 → 0.3.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 +131 -0
- package/README.md +150 -5
- package/dist/adapters/enforcer/depcruiser.d.ts +1 -1
- package/dist/adapters/enforcer/depcruiser.js +87 -25
- package/dist/adapters/visualizer/likec4.js +87 -51
- package/dist/cli/index.js +21 -10
- package/dist/core/model/boundary-model.d.ts +50 -3
- 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,131 @@
|
|
|
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.3.0] — unreleased
|
|
8
|
+
|
|
9
|
+
Makes the "one file can be both the communication diagram and the enforcement
|
|
10
|
+
model" promise hold for deep, nested C4 trees.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **File-level mapping — `metadata { file 'src/x.ts' }`** ([#3]). An element maps
|
|
15
|
+
to a single file instead of a folder. A file module owns exactly its file; a
|
|
16
|
+
folder module owns its subtree minus any mapped descendants — nested folders
|
|
17
|
+
and file leaves alike.
|
|
18
|
+
- This is what lets a deep nested diagram stay legal *and* enforceable. A
|
|
19
|
+
guarded contract or port that sits beside sibling sub-folders can be a file
|
|
20
|
+
leaf rather than collapsing into its parent folder. Collapsed, its edges
|
|
21
|
+
become ancestor↔descendant, which LikeC4 rejects with
|
|
22
|
+
`Invalid parent-child relationship`; as a leaf they stay sibling-to-sibling.
|
|
23
|
+
- Declaring both `folder` and `file` on one element is an error.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`Module.folder: string` → `Module.path: string` + `Module.kind: 'folder' | 'file'`.**
|
|
28
|
+
SDK-breaking; the diagram surface and CLI are unaffected.
|
|
29
|
+
|
|
30
|
+
[#3]: https://github.com/makspiechota/boundry/issues/3
|
|
31
|
+
|
|
32
|
+
## [0.2.0] — 2026-07-16
|
|
33
|
+
|
|
34
|
+
The release that makes the diagram a *governed* artifact rather than just a
|
|
35
|
+
source of rules. An agent can now be handed the diagram without being handed the
|
|
36
|
+
ability to grant itself dependencies.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **Approval lifecycle — `#proposed`.** An edge marked `#proposed` is intent, not
|
|
41
|
+
permission: it is excluded from the allow-list, so `check` stays red until a
|
|
42
|
+
human approves. Mark an element too and the reviewer sees the new box as well
|
|
43
|
+
as the new arrow.
|
|
44
|
+
- On an **edge** the marker is semantic (the edge grants nothing until
|
|
45
|
+
approved). On an **element** it is visual only — a module is enforced from
|
|
46
|
+
the moment it is drawn, which is safe, because a module grants nothing until
|
|
47
|
+
an edge permits it. **The edge is always the only grant.**
|
|
48
|
+
- **`boundry approve`** — strips `#proposed` markers deterministically by
|
|
49
|
+
splicing the LikeC4 CST. Never an LLM edit: source-preserving, idempotent, and
|
|
50
|
+
byte-exact. Approval is a human act; this command is the human's.
|
|
51
|
+
- **`boundry verify --base <ref>`** — compares the working diagram against an
|
|
52
|
+
approved git ref and rejects any edge granted *without* going through a
|
|
53
|
+
proposal. Because proposals are excluded from the allow-list, the set of
|
|
54
|
+
newly-allowed edges is exactly the set of self-approvals. `approve --base` uses
|
|
55
|
+
the same gate, so it refuses to launder a self-granted edge.
|
|
56
|
+
- **`governRoot` (opt-in)** ([#1]) — `metadata { governRoot 'src' }` declares a code
|
|
57
|
+
root fully governed: importing territory under it that no module claims is a
|
|
58
|
+
violation rather than free, and `check` warns about code no module covers. The
|
|
59
|
+
mirror of the zero-files warning. Without it, unmapped folders stay ignored, so
|
|
60
|
+
a communication diagram still works as an enforcement diagram.
|
|
61
|
+
- **`#anything` wildcard** — a box tagged `#anything` maps to no folder and stands
|
|
62
|
+
for the rest of the code; a module with an edge into it is exempt from every
|
|
63
|
+
rule. This is the explicit way to say "deliberately unconstrained", for the
|
|
64
|
+
composition root that must import everything to wire it together.
|
|
65
|
+
- It rides the same protocol as any other edge: `#proposed` suppresses it until
|
|
66
|
+
approved, and `verify` reports a self-granted wildcard edge like any other.
|
|
67
|
+
- Prior to this, the only way to let `src/index.ts` wire things up under a
|
|
68
|
+
govern root was to leave it *unmapped* — granting the same power by
|
|
69
|
+
**omission**, with nothing drawn for a reviewer to see.
|
|
70
|
+
- The exemption belongs to the wired module alone and does not leak.
|
|
71
|
+
- **`exemptImporters`** ([#2]) — `metadata { exemptImporters '/__tests__/|\\.d\\.ts$' }`
|
|
72
|
+
drops matched files from every rule's `from` side: they are analysed, but never
|
|
73
|
+
held to a boundary as an importer. For test files that legitimately wire real
|
|
74
|
+
adapters across layers, and for ambient `.d.ts` declarations.
|
|
75
|
+
- **From-side only.** Exempt files stay governed as import *targets*, so
|
|
76
|
+
production importing a test helper is still a violation.
|
|
77
|
+
- Patterns are regexes and union across every element that declares one.
|
|
78
|
+
LikeC4 has no repeated metadata keys, so use a `|` alternation or one per
|
|
79
|
+
element. **Backslashes must be doubled** — LikeC4 processes string escapes,
|
|
80
|
+
so `'\.d\.ts$'` arrives as `.d.ts$` and over-matches silently.
|
|
81
|
+
- `check` warns when an exemption matches **zero** files or **every** file;
|
|
82
|
+
both mean the pattern is wrong. An invalid or empty pattern is a hard error.
|
|
83
|
+
- `verify` reports an exemption added since the base, like any other grant.
|
|
84
|
+
- **Skill for coding agents** — `.claude/skills/define-architecture-boundaries`
|
|
85
|
+
documents the annotations and the proposal protocol, including the things an
|
|
86
|
+
agent must never do: add a bare edge, strip a marker, run `approve`, draw a
|
|
87
|
+
wildcard, or route around a boundary through an unmodelled folder.
|
|
88
|
+
- **Nesting support.** A module owns its folder *minus* any mapped descendants,
|
|
89
|
+
so a parent's edges govern only the parent's own files. A child never inherits
|
|
90
|
+
them and must be permitted explicitly; a connection between two subtrees never
|
|
91
|
+
permits every child pair across them.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **A check that analysed zero files reported success.** `check` now throws when
|
|
96
|
+
dependency-cruiser sees no sources — the setup being broken is a failure, not a
|
|
97
|
+
clean run. This is the guard that caught the 0.1.0 packaging bug below.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- **`Pipeline.verify()` returns `VerifyResult`** (`{ edges, exemptions }`) rather
|
|
102
|
+
than a bare edge array, so both kinds of grant surface. SDK-breaking; the CLI
|
|
103
|
+
is unaffected.
|
|
104
|
+
|
|
105
|
+
[#1]: https://github.com/makspiechota/boundry/issues/1
|
|
106
|
+
[#2]: https://github.com/makspiechota/boundry/issues/2
|
|
107
|
+
|
|
108
|
+
## [0.1.1] — never released
|
|
109
|
+
|
|
110
|
+
Tagged locally, never published or pushed; its contents ship as part of 0.2.0.
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- **`typescript` moved to `dependencies`.** dependency-cruiser needs it at
|
|
115
|
+
runtime to parse `.ts`; as a devDependency it was absent from a real install.
|
|
116
|
+
- **Stale `dist/` artifacts in the published tarball.** `build` now cleans first,
|
|
117
|
+
so a restructure can't leave orphaned files from a previous layout.
|
|
118
|
+
|
|
119
|
+
## [0.1.0] — 2026-07 — **deprecated, do not use**
|
|
120
|
+
|
|
121
|
+
**This version silently enforces nothing.** `typescript` was a devDependency, so
|
|
122
|
+
in a clean install dependency-cruiser could not parse TypeScript and reported
|
|
123
|
+
`✓ no boundary violations` while checking zero files. A guardrail that fails open
|
|
124
|
+
is worse than none. Upgrade.
|
|
125
|
+
|
|
126
|
+
## [0.0.1]
|
|
127
|
+
|
|
128
|
+
First publish. Compiles a LikeC4 diagram into dependency-cruiser rules:
|
|
129
|
+
`metadata { folder '…' }` maps an element to a folder, every drawn relationship
|
|
130
|
+
is an allowed dependency, and everything undrawn is forbidden. Commands: `check`,
|
|
131
|
+
`generate`.
|
package/README.md
CHANGED
|
@@ -18,8 +18,9 @@ diagram (LikeC4) ──► boundary model ──► dependency-cruiser rules
|
|
|
18
18
|
|
|
19
19
|
## How it works
|
|
20
20
|
|
|
21
|
-
1. You annotate each element in your diagram with the
|
|
22
|
-
`metadata { folder 'src/domain' }
|
|
21
|
+
1. You annotate each element in your diagram with the source it owns — a folder,
|
|
22
|
+
`metadata { folder 'src/domain' }`, or a single file,
|
|
23
|
+
`metadata { file 'src/ports/contract.ts' }`.
|
|
23
24
|
2. Every relationship you draw (`a -> b`) is an **allowed** dependency.
|
|
24
25
|
Anything you don't draw is **forbidden**.
|
|
25
26
|
3. Boundry lifts the diagram into a source-agnostic boundary model, compiles a
|
|
@@ -47,6 +48,99 @@ mirror of the zero-files warning: the model failing to cover the code is as much
|
|
|
47
48
|
a gap as the code failing to back the model. Nothing changes unless you declare
|
|
48
49
|
it.
|
|
49
50
|
|
|
51
|
+
A mapped folder claims its **entire subtree**, so the abstraction level stays
|
|
52
|
+
yours: one box on `src/domain` covers everything beneath it. You add finer boxes
|
|
53
|
+
where you want finer *rules*, not to satisfy the coverage check.
|
|
54
|
+
|
|
55
|
+
### Mapping a single file (deep nested diagrams)
|
|
56
|
+
|
|
57
|
+
A rich C4 model nests: `application` › `metrics` › `ports` › `story-points-read`
|
|
58
|
+
› `stub`, with a drill-down view at each level. Sometimes a *file* is the guarded
|
|
59
|
+
thing — a contract, a port, a single repository — sitting beside its sibling
|
|
60
|
+
sub-folders. Map it to a file instead of a folder:
|
|
61
|
+
|
|
62
|
+
```likec4
|
|
63
|
+
component ports 'ports' {
|
|
64
|
+
metadata { folder 'src/ports' }
|
|
65
|
+
|
|
66
|
+
component store 'in-memory-store' {
|
|
67
|
+
metadata { file 'src/ports/in-memory-store.ts' } // a leaf, not a folder
|
|
68
|
+
}
|
|
69
|
+
component read 'story-points-read' {
|
|
70
|
+
metadata { folder 'src/ports/story-points-read' }
|
|
71
|
+
component stub 'stub' {
|
|
72
|
+
metadata { folder 'src/ports/story-points-read/stub' }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stub -> store // a legal cross-subtree edge
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This is what lets a deep tree be **both** the documentation and the enforcement
|
|
81
|
+
model. Collapse that file into its parent folder and the edge becomes
|
|
82
|
+
`stub -> ports` — a descendant importing its ancestor, which LikeC4 rejects with
|
|
83
|
+
`Invalid parent-child relationship`. As a file leaf, `store` is a sibling, so the
|
|
84
|
+
edge is legal *and* Boundry governs the file exactly: only `stub` may import it,
|
|
85
|
+
and the surrounding `ports` folder no longer owns it.
|
|
86
|
+
|
|
87
|
+
A file module owns exactly its file; a folder module owns its subtree minus any
|
|
88
|
+
mapped descendants — nested folders and file leaves alike.
|
|
89
|
+
|
|
90
|
+
### Exempting test files and ambient declarations
|
|
91
|
+
|
|
92
|
+
Every file under a mapped folder is governed as a rule *source* by default —
|
|
93
|
+
including tests. An integration test that wires a real database adapter across a
|
|
94
|
+
layer boundary is doing its job, not breaking the architecture. Exempt them:
|
|
95
|
+
|
|
96
|
+
```likec4
|
|
97
|
+
system app 'App' {
|
|
98
|
+
metadata {
|
|
99
|
+
exemptImporters '/__tests__/|\\.d\\.ts$'
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Matched files are still analysed, but they are dropped from every rule's `from`
|
|
105
|
+
side, so they may import anything. This is **from-side only** — they stay
|
|
106
|
+
governed as import *targets*, so production reaching into a test helper is as
|
|
107
|
+
forbidden as it ever was. Patterns are regexes, union across every element that
|
|
108
|
+
declares one, and change nothing unless you declare one.
|
|
109
|
+
|
|
110
|
+
> **Double your backslashes.** LikeC4 processes string escapes, so `'\.d\.ts$'`
|
|
111
|
+
> arrives as `.d.ts$`, where `.` is a wildcard that matches far more than you
|
|
112
|
+
> meant. Write `'\\.d\\.ts$'`. Boundry warns when an exemption matches **zero**
|
|
113
|
+
> files, or when one matches **every** file, since both mean the pattern is
|
|
114
|
+
> wrong.
|
|
115
|
+
|
|
116
|
+
An exemption is a grant — it lifts whole files out of every rule — so `verify`
|
|
117
|
+
reports one added since the approved base, exactly like an undrawn edge.
|
|
118
|
+
|
|
119
|
+
### The composition root — `#anything`
|
|
120
|
+
|
|
121
|
+
Every repo has one place that legitimately imports everything: the entry point
|
|
122
|
+
that constructs the object graph. Give it a module and wire it to a wildcard:
|
|
123
|
+
|
|
124
|
+
```likec4
|
|
125
|
+
// Owns 'src' minus every mapped descendant — the loose files at the root.
|
|
126
|
+
module entry 'Composition root' {
|
|
127
|
+
metadata { folder 'src' }
|
|
128
|
+
}
|
|
129
|
+
element anything 'Anything' {
|
|
130
|
+
#anything
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
entry -> anything
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A box tagged `#anything` maps to no folder and stands for "the rest of the code".
|
|
137
|
+
A module with an edge into it is exempt from every rule — and only that module;
|
|
138
|
+
the exemption doesn't leak.
|
|
139
|
+
|
|
140
|
+
The alternative was to leave `src/index.ts` unmapped, which grants it the same
|
|
141
|
+
freedom by **omission**: nothing drawn, nothing to review. The wildcard makes the
|
|
142
|
+
exemption a visible box someone approved on purpose.
|
|
143
|
+
|
|
50
144
|
## See it
|
|
51
145
|
|
|
52
146
|
The diagram you draw *is* the whole spec. Below is the example architecture that
|
|
@@ -111,11 +205,49 @@ npx boundry check --arch arch src
|
|
|
111
205
|
|
|
112
206
|
A `domain → infra` import is now a build failure; `infra → domain` is fine.
|
|
113
207
|
|
|
208
|
+
## Changing the architecture — propose, approve, commit
|
|
209
|
+
|
|
210
|
+
If agents can edit the diagram, they can grant themselves any dependency they
|
|
211
|
+
like, and the guardrail is theatre. So a change to the architecture goes through
|
|
212
|
+
a lifecycle:
|
|
213
|
+
|
|
214
|
+
**propose** → **approve** → **commit**
|
|
215
|
+
|
|
216
|
+
An agent blocked by a boundary adds the edge **with a marker**:
|
|
217
|
+
|
|
218
|
+
```likec4
|
|
219
|
+
domain -> shared #proposed
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
A `#proposed` edge is *intent, not permission*. It's excluded from the allow-list,
|
|
223
|
+
so `check` stays red and the agent stays blocked. It has asked, not taken.
|
|
224
|
+
|
|
225
|
+
A human approves by stripping the marker — that's what `boundry approve` does,
|
|
226
|
+
deterministically, by splicing the LikeC4 CST. No model call, no reformatting:
|
|
227
|
+
source-preserving, idempotent, byte-exact.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
boundry verify --arch arch --base origin/main # any edge granted without a marker?
|
|
231
|
+
boundry approve --arch arch --base origin/main # HUMAN ONLY: strip markers = approve
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`verify` compares against the approved diagram at a git ref and rejects edges that
|
|
235
|
+
appeared *without* going through a proposal. Because proposals are excluded from
|
|
236
|
+
the allow-list, the newly-allowed set is exactly the set of self-approvals — no
|
|
237
|
+
diff engine required. `approve --base` runs the same gate first, so it won't
|
|
238
|
+
launder a self-granted edge into an approved one.
|
|
239
|
+
|
|
240
|
+
Point your agents at
|
|
241
|
+
[`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
|
|
242
|
+
and they'll follow this protocol.
|
|
243
|
+
|
|
114
244
|
## CLI
|
|
115
245
|
|
|
116
246
|
```
|
|
117
247
|
boundry check [--arch <dir>] [--cwd <dir>] [sources...]
|
|
118
248
|
boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
249
|
+
boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
|
|
250
|
+
boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
|
|
119
251
|
```
|
|
120
252
|
|
|
121
253
|
| Flag | Meaning |
|
|
@@ -123,14 +255,18 @@ boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
|
123
255
|
| `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
|
|
124
256
|
| `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
|
|
125
257
|
| `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
|
|
258
|
+
| `--base <ref>` | `verify`/`approve`: the git ref holding the approved diagram to compare against. |
|
|
126
259
|
| `sources...` | `check` only: paths to lint. Default `src`. |
|
|
127
260
|
|
|
128
261
|
- **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
|
|
129
262
|
- **`generate`** just emits the dependency-cruiser config so you can commit it or
|
|
130
263
|
run `depcruise` yourself.
|
|
264
|
+
- **`verify`** rejects dependencies granted without a proposal.
|
|
265
|
+
- **`approve`** strips `#proposed` markers. For humans, not agents.
|
|
131
266
|
|
|
132
|
-
Boundry warns (but does not fail) when a mapped folder matches **zero** files
|
|
133
|
-
so a passing check can
|
|
267
|
+
Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
|
|
268
|
+
**fails outright** when a check analysed no files at all — so a passing check can
|
|
269
|
+
never silently enforce nothing. A guardrail that fails open is worse than none.
|
|
134
270
|
|
|
135
271
|
## CI
|
|
136
272
|
|
|
@@ -147,6 +283,9 @@ jobs:
|
|
|
147
283
|
with: { node-version: 20 }
|
|
148
284
|
- run: npm ci
|
|
149
285
|
- run: npx boundry check --arch arch src
|
|
286
|
+
# On PRs, also reject dependencies granted without a proposal.
|
|
287
|
+
- run: npx boundry verify --arch arch --base origin/${{ github.base_ref }}
|
|
288
|
+
if: github.event_name == 'pull_request'
|
|
150
289
|
```
|
|
151
290
|
|
|
152
291
|
## Programmatic use (SDK)
|
|
@@ -181,11 +320,17 @@ without touching the core.
|
|
|
181
320
|
|
|
182
321
|
Current limitations:
|
|
183
322
|
|
|
184
|
-
- One element maps to exactly one folder.
|
|
323
|
+
- One element maps to exactly one path — a folder or a single file.
|
|
185
324
|
- Nesting is supported: you can map a parent folder *and* its children. A
|
|
186
325
|
parent's edges govern only the parent's own files — a child never inherits
|
|
187
326
|
them and must be permitted explicitly.
|
|
188
327
|
- `folder` paths are relative to the repo root (`--cwd`), not the diagram file.
|
|
328
|
+
- With a `governRoot`, unmapped code is blocked as an import *target* but is not
|
|
329
|
+
yet constrained as an *importer* — rules are generated per mapped module, so
|
|
330
|
+
unmapped code has no rules of its own.
|
|
331
|
+
|
|
332
|
+
See the [changelog](./CHANGELOG.md) for what's in each release.
|
|
333
|
+
**`0.1.0` is deprecated** — it silently enforces nothing (see the changelog).
|
|
189
334
|
|
|
190
335
|
## License
|
|
191
336
|
|
|
@@ -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,17 +1,35 @@
|
|
|
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
|
-
function
|
|
5
|
-
return
|
|
5
|
+
function normalizePath(path) {
|
|
6
|
+
return path.replace(/\/+$/, '');
|
|
6
7
|
}
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
return `^${escaped}/`;
|
|
8
|
+
function escapeRegex(path) {
|
|
9
|
+
return normalizePath(path).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The regex matching exactly the sources a module owns: everything under a
|
|
13
|
+
* folder (`^src/core/`), or a single file exactly (`^src/x.ts$`).
|
|
14
|
+
*/
|
|
15
|
+
function moduleScopeRegex(m) {
|
|
16
|
+
return m.kind === 'file' ? `^${escapeRegex(m.path)}$` : `^${escapeRegex(m.path)}/`;
|
|
17
|
+
}
|
|
18
|
+
/** True if `source` (a file path) belongs to module `m`, ignoring descendants. */
|
|
19
|
+
function ownsSource(m, source) {
|
|
20
|
+
const p = normalizePath(m.path);
|
|
21
|
+
return m.kind === 'file' ? source === p : source.startsWith(`${p}/`);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True if module `child` is nested strictly under module `parent`. Only a folder
|
|
25
|
+
* can contain anything; a file (or a folder equal to the child) contains nothing.
|
|
26
|
+
* A folder nesting a file leaf counts — that is the whole point of file mapping.
|
|
27
|
+
*/
|
|
28
|
+
function isNestedUnder(child, parent) {
|
|
29
|
+
if (parent.kind === 'file')
|
|
30
|
+
return false;
|
|
31
|
+
const c = normalizePath(child.path);
|
|
32
|
+
const p = normalizePath(parent.path);
|
|
15
33
|
return c !== p && c.startsWith(`${p}/`);
|
|
16
34
|
}
|
|
17
35
|
/**
|
|
@@ -28,18 +46,34 @@ function buildForbiddenRules(model) {
|
|
|
28
46
|
allowedTargets.set(m.id, new Set([m.id]));
|
|
29
47
|
for (const edge of model.allowed)
|
|
30
48
|
allowedTargets.get(edge.from)?.add(edge.to);
|
|
31
|
-
// Each module's scope: its
|
|
49
|
+
// Each module's scope: its path, carving out any mapped descendants (a nested
|
|
50
|
+
// folder OR a mapped file leaf living inside it — both belong to their own,
|
|
51
|
+
// more-specific module).
|
|
32
52
|
const scopeOf = new Map();
|
|
33
53
|
for (const m of model.modules) {
|
|
34
54
|
const descendants = model.modules
|
|
35
|
-
.filter((other) =>
|
|
36
|
-
.map((other) =>
|
|
55
|
+
.filter((other) => isNestedUnder(other, m))
|
|
56
|
+
.map((other) => moduleScopeRegex(other));
|
|
37
57
|
scopeOf.set(m.id, descendants.length
|
|
38
|
-
? { path:
|
|
39
|
-
: { path:
|
|
58
|
+
? { path: moduleScopeRegex(m), pathNot: descendants }
|
|
59
|
+
: { path: moduleScopeRegex(m) });
|
|
40
60
|
}
|
|
61
|
+
// Exemptions apply to the `from` side only: matched files may import freely,
|
|
62
|
+
// but every module's scope still governs them as a *target*, so importing a
|
|
63
|
+
// test helper from production stays forbidden.
|
|
64
|
+
const asImporter = (id) => {
|
|
65
|
+
const scope = scopeOf.get(id);
|
|
66
|
+
if (model.exemptImporters.length === 0)
|
|
67
|
+
return scope;
|
|
68
|
+
return { ...scope, pathNot: [...(scope.pathNot ?? []), ...model.exemptImporters] };
|
|
69
|
+
};
|
|
70
|
+
// A module wired to an `#anything` wildcard is deliberately exempt: it is the
|
|
71
|
+
// composition root, whose job is to import everything and wire it together.
|
|
72
|
+
const unconstrained = unconstrainedModules(model);
|
|
41
73
|
const rules = [];
|
|
42
74
|
for (const from of model.modules) {
|
|
75
|
+
if (unconstrained.has(from.id))
|
|
76
|
+
continue;
|
|
43
77
|
const allowed = allowedTargets.get(from.id);
|
|
44
78
|
for (const to of model.modules) {
|
|
45
79
|
if (allowed.has(to.id))
|
|
@@ -48,7 +82,7 @@ function buildForbiddenRules(model) {
|
|
|
48
82
|
name: `boundary:${from.id}->${to.id}`,
|
|
49
83
|
comment: `${from.title} may not depend on ${to.title}`,
|
|
50
84
|
severity: 'error',
|
|
51
|
-
from:
|
|
85
|
+
from: asImporter(from.id),
|
|
52
86
|
to: scopeOf.get(to.id),
|
|
53
87
|
});
|
|
54
88
|
}
|
|
@@ -57,14 +91,17 @@ function buildForbiddenRules(model) {
|
|
|
57
91
|
// module claims is forbidden, not free. Additive — the pair rules above still
|
|
58
92
|
// decide every mapped-to-mapped edge on their own.
|
|
59
93
|
if (model.governRoot) {
|
|
60
|
-
const
|
|
94
|
+
const rootAsFolder = { id: '', title: '', path: model.governRoot, kind: 'folder' };
|
|
95
|
+
const claimed = model.modules.map(moduleScopeRegex);
|
|
61
96
|
for (const from of model.modules) {
|
|
97
|
+
if (unconstrained.has(from.id))
|
|
98
|
+
continue;
|
|
62
99
|
rules.push({
|
|
63
100
|
name: `boundary:${from.id}->unmapped`,
|
|
64
101
|
comment: `${from.title} may not depend on code under '${model.governRoot}' that no module claims`,
|
|
65
102
|
severity: 'error',
|
|
66
|
-
from:
|
|
67
|
-
to: { path:
|
|
103
|
+
from: asImporter(from.id),
|
|
104
|
+
to: { path: moduleScopeRegex(rootAsFolder), pathNot: claimed },
|
|
68
105
|
});
|
|
69
106
|
}
|
|
70
107
|
}
|
|
@@ -98,27 +135,52 @@ module.exports = {
|
|
|
98
135
|
const violations = raw
|
|
99
136
|
.filter((v) => v.rule?.severity === 'error')
|
|
100
137
|
.map((v) => ({ from: v.from, to: v.to, rule: v.rule?.name ?? 'unknown' }));
|
|
101
|
-
// A module whose
|
|
138
|
+
// A module whose path matched no source files enforces nothing — the
|
|
102
139
|
// "green but inert" trap. Surface it so a passing check can't hide it.
|
|
103
140
|
const seen = (output?.modules ?? []).map((m) => String(m.source));
|
|
141
|
+
// A guardrail that analysed nothing must never report success. Seeing zero
|
|
142
|
+
// sources means the setup is broken (wrong paths, or no TypeScript for
|
|
143
|
+
// dependency-cruiser to parse with) — that is a failure, not a clean run.
|
|
144
|
+
if (seen.length === 0) {
|
|
145
|
+
throw new Error(`analysed 0 source files under '${sources.join(', ')}' — nothing was enforced. ` +
|
|
146
|
+
'Check the source paths, and that TypeScript is installed alongside Boundry.');
|
|
147
|
+
}
|
|
104
148
|
const warnings = [];
|
|
105
149
|
for (const mod of model.modules) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
warnings.push(`module '${mod.title}' maps to '${mod.folder}', which matched 0 source files`);
|
|
150
|
+
if (!seen.some((source) => ownsSource(mod, source))) {
|
|
151
|
+
warnings.push(`module '${mod.title}' maps to '${mod.path}', which matched 0 source files`);
|
|
109
152
|
}
|
|
110
153
|
}
|
|
154
|
+
// An exemption is a grant, so a dead one is worth knowing about: it means
|
|
155
|
+
// the pattern is wrong (LikeC4 eats backslashes) and files the author meant
|
|
156
|
+
// to exempt are still being policed — or were never there to begin with.
|
|
157
|
+
const exemptions = model.exemptImporters.map((pattern) => ({
|
|
158
|
+
pattern,
|
|
159
|
+
matches: seen.filter((source) => new RegExp(pattern).test(source)),
|
|
160
|
+
}));
|
|
161
|
+
for (const { pattern, matches } of exemptions) {
|
|
162
|
+
if (matches.length === 0) {
|
|
163
|
+
warnings.push(`exemption '${pattern}' matched 0 source files`);
|
|
164
|
+
}
|
|
165
|
+
else if (matches.length === seen.length) {
|
|
166
|
+
warnings.push(`exemption '${pattern}' matched every source file — no file can violate a boundary`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const isExempt = (source) => model.exemptImporters.some((pattern) => new RegExp(pattern).test(source));
|
|
111
170
|
// The symmetric guard: code under a declared govern root that no module
|
|
112
171
|
// claims. The model not covering the code is as much a gap as the code not
|
|
113
172
|
// backing the model.
|
|
114
173
|
if (model.governRoot) {
|
|
115
|
-
const rootPrefix = `${
|
|
116
|
-
const claimed = model.modules.map((m) => `${folderPrefix(m.folder)}/`);
|
|
174
|
+
const rootPrefix = `${normalizePath(model.governRoot)}/`;
|
|
117
175
|
const unclaimed = new Set();
|
|
118
176
|
for (const source of seen) {
|
|
119
177
|
if (!source.startsWith(rootPrefix))
|
|
120
178
|
continue;
|
|
121
|
-
if (
|
|
179
|
+
if (model.modules.some((m) => ownsSource(m, source)))
|
|
180
|
+
continue;
|
|
181
|
+
// Exempt code is deliberately outside the architecture; asking a module
|
|
182
|
+
// to claim it would defeat the point of exempting it.
|
|
183
|
+
if (isExempt(source))
|
|
122
184
|
continue;
|
|
123
185
|
unclaimed.add(source.slice(0, source.lastIndexOf('/')));
|
|
124
186
|
}
|
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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`);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
new RegExp(pattern);
|
|
21
19
|
}
|
|
22
|
-
|
|
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('$'))
|
|
@@ -90,21 +88,50 @@ export class LikeC4Visualizer {
|
|
|
90
88
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
91
89
|
const model = await likec4.computedModel();
|
|
92
90
|
const modules = [];
|
|
93
|
-
const
|
|
91
|
+
const moduleIds = 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;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
116
|
+
// An element maps to a folder (owns its subtree) or a single file (owns
|
|
117
|
+
// exactly itself). A file leaf keeps a guarded contract sitting beside its
|
|
118
|
+
// sibling sub-folders, so a deep nested diagram stays legal AND enforceable.
|
|
119
|
+
const folderMeta = el.getMetadata('folder');
|
|
120
|
+
const fileMeta = el.getMetadata('file');
|
|
121
|
+
const folder = Array.isArray(folderMeta) ? folderMeta[0] : folderMeta;
|
|
122
|
+
const file = Array.isArray(fileMeta) ? fileMeta[0] : fileMeta;
|
|
123
|
+
if (folder && file) {
|
|
124
|
+
throw new Error(`element '${String(el.id)}' declares both 'folder' and 'file' — a module maps to one path`);
|
|
125
|
+
}
|
|
126
|
+
if (folder || file) {
|
|
105
127
|
const id = String(el.id);
|
|
106
|
-
modules.push({
|
|
107
|
-
|
|
128
|
+
modules.push({
|
|
129
|
+
id,
|
|
130
|
+
title: el.title,
|
|
131
|
+
path: (folder ?? file),
|
|
132
|
+
kind: folder ? 'folder' : 'file',
|
|
133
|
+
});
|
|
134
|
+
moduleIds.add(id);
|
|
108
135
|
}
|
|
109
136
|
}
|
|
110
137
|
const allowed = [];
|
|
@@ -115,11 +142,20 @@ export class LikeC4Visualizer {
|
|
|
115
142
|
continue;
|
|
116
143
|
const from = String(rel.source.id);
|
|
117
144
|
const to = String(rel.target.id);
|
|
118
|
-
|
|
145
|
+
// An edge into a wildcard is kept like any other grant, so `verify` sees a
|
|
146
|
+
// self-granted `#anything` exemption as the newly-allowed edge it is.
|
|
147
|
+
const targetGoverned = moduleIds.has(to) || wildcardIds.has(to);
|
|
148
|
+
if (from !== to && moduleIds.has(from) && targetGoverned) {
|
|
119
149
|
allowed.push({ from, to });
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
|
-
return {
|
|
152
|
+
return {
|
|
153
|
+
modules,
|
|
154
|
+
allowed,
|
|
155
|
+
governRoot,
|
|
156
|
+
wildcards: [...wildcardIds],
|
|
157
|
+
exemptImporters: [...exemptImporters],
|
|
158
|
+
};
|
|
123
159
|
}
|
|
124
160
|
/**
|
|
125
161
|
* 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
|
}
|
|
@@ -2,14 +2,25 @@
|
|
|
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
|
-
/** A unit of architecture that maps to a folder of source
|
|
5
|
+
/** A unit of architecture that maps to a folder or a single file of source. */
|
|
6
6
|
export interface Module {
|
|
7
7
|
/** Stable id, taken from the diagram element. */
|
|
8
8
|
id: string;
|
|
9
9
|
/** Human-readable name for messages. */
|
|
10
10
|
title: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Source path the module owns — a folder prefix (e.g. "src/core") or a single
|
|
13
|
+
* file (e.g. "src/ports/contract.ts"), per `kind`.
|
|
14
|
+
*/
|
|
15
|
+
path: string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether `path` is a folder (owns everything under it) or a single file
|
|
18
|
+
* (owns exactly that file). A file leaf lets a guarded contract or port sit
|
|
19
|
+
* beside its sibling sub-folders in a nested diagram, so its edges stay
|
|
20
|
+
* sibling-to-sibling — which is what keeps a deep C4 tree legal in LikeC4
|
|
21
|
+
* (ancestor↔descendant relationships are rejected) *and* enforceable.
|
|
22
|
+
*/
|
|
23
|
+
kind: 'folder' | 'file';
|
|
13
24
|
}
|
|
14
25
|
/** A permitted dependency: modules in `from` may import modules in `to`. */
|
|
15
26
|
export interface AllowedEdge {
|
|
@@ -19,6 +30,28 @@ export interface AllowedEdge {
|
|
|
19
30
|
export interface BoundaryModel {
|
|
20
31
|
modules: Module[];
|
|
21
32
|
allowed: AllowedEdge[];
|
|
33
|
+
/**
|
|
34
|
+
* Ids of wildcard elements — diagram boxes tagged `#anything`. They map to no
|
|
35
|
+
* folder and are not modules; they stand for "the rest of the code". A module
|
|
36
|
+
* with an allowed edge to one is exempt from every boundary rule.
|
|
37
|
+
*
|
|
38
|
+
* This is the explicit way to say "deliberately unconstrained", for the
|
|
39
|
+
* composition root that must wire everything together. It is a drawn,
|
|
40
|
+
* reviewable grant — unlike opting out by staying unmapped, where the mere
|
|
41
|
+
* ABSENCE of a box would hand out the same power silently.
|
|
42
|
+
*/
|
|
43
|
+
wildcards: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Regex patterns for files that are analysed but never held to a boundary as
|
|
46
|
+
* an *importer*. Matched files are dropped from every rule's `from` side, so
|
|
47
|
+
* they may import anything; they are still governed as import *targets*.
|
|
48
|
+
*
|
|
49
|
+
* For test files and ambient `.d.ts` declarations: an integration test wiring
|
|
50
|
+
* real adapters across layers is doing its job, not breaking the
|
|
51
|
+
* architecture. From-side only — production importing a test helper stays as
|
|
52
|
+
* forbidden as it ever was.
|
|
53
|
+
*/
|
|
54
|
+
exemptImporters: string[];
|
|
22
55
|
/**
|
|
23
56
|
* Optional code root declared fully governed. Every folder under it is
|
|
24
57
|
* expected to be modelled, so importing territory no module claims is
|
|
@@ -27,6 +60,20 @@ export interface BoundaryModel {
|
|
|
27
60
|
*/
|
|
28
61
|
governRoot?: string;
|
|
29
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Modules holding an approved edge to a wildcard element, i.e. free to import
|
|
65
|
+
* anything. A `#proposed` edge to a wildcard is excluded from `allowed` like any
|
|
66
|
+
* other proposal, so the exemption only takes effect once a human approves it.
|
|
67
|
+
*/
|
|
68
|
+
export declare function unconstrainedModules(model: BoundaryModel): Set<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Exemption patterns present at `head` but not at `base`.
|
|
71
|
+
*
|
|
72
|
+
* An exemption is a grant — it lifts whole files out of every rule — so a new
|
|
73
|
+
* one has to face the same gate as a new edge. Without this, an agent blocked by
|
|
74
|
+
* a boundary could add `exemptImporters '/src/'` and walk straight through.
|
|
75
|
+
*/
|
|
76
|
+
export declare function newlyExemptedImporters(base: BoundaryModel, head: BoundaryModel): string[];
|
|
30
77
|
/**
|
|
31
78
|
* Edges allowed at `head` that were not allowed at `base`.
|
|
32
79
|
*
|
|
@@ -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.3.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
|
}
|