boundry 0.2.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 +26 -1
- package/README.md +39 -3
- package/dist/adapters/enforcer/depcruiser.js +42 -24
- package/dist/adapters/visualizer/likec4.js +21 -8
- package/dist/core/model/boundary-model.d.ts +14 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,32 @@ All notable changes to Boundry are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
5
|
versioning follows [semver](https://semver.org/).
|
|
6
6
|
|
|
7
|
-
## [0.
|
|
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
|
|
8
33
|
|
|
9
34
|
The release that makes the diagram a *governed* artifact rather than just a
|
|
10
35
|
source of rules. An agent can now be handed the diagram without being handed the
|
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
|
|
@@ -51,6 +52,41 @@ A mapped folder claims its **entire subtree**, so the abstraction level stays
|
|
|
51
52
|
yours: one box on `src/domain` covers everything beneath it. You add finer boxes
|
|
52
53
|
where you want finer *rules*, not to satisfy the coverage check.
|
|
53
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
|
+
|
|
54
90
|
### Exempting test files and ambient declarations
|
|
55
91
|
|
|
56
92
|
Every file under a mapped folder is governed as a rule *source* by default —
|
|
@@ -284,7 +320,7 @@ without touching the core.
|
|
|
284
320
|
|
|
285
321
|
Current limitations:
|
|
286
322
|
|
|
287
|
-
- One element maps to exactly one folder.
|
|
323
|
+
- One element maps to exactly one path — a folder or a single file.
|
|
288
324
|
- Nesting is supported: you can map a parent folder *and* its children. A
|
|
289
325
|
parent's edges govern only the parent's own files — a child never inherits
|
|
290
326
|
them and must be permitted explicitly.
|
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
import * as dependencyCruiser from 'dependency-cruiser';
|
|
2
|
-
import { unconstrainedModules } from '../../core/model/boundary-model.js';
|
|
2
|
+
import { unconstrainedModules, } from '../../core/model/boundary-model.js';
|
|
3
3
|
// dependency-cruiser ships CommonJS; reach `cruise` through either interop shape.
|
|
4
4
|
const cruise = dependencyCruiser.cruise ?? dependencyCruiser.default?.cruise;
|
|
5
|
-
function
|
|
6
|
-
return
|
|
5
|
+
function normalizePath(path) {
|
|
6
|
+
return path.replace(/\/+$/, '');
|
|
7
7
|
}
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
return `^${escaped}/`;
|
|
8
|
+
function escapeRegex(path) {
|
|
9
|
+
return normalizePath(path).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
10
|
}
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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);
|
|
16
33
|
return c !== p && c.startsWith(`${p}/`);
|
|
17
34
|
}
|
|
18
35
|
/**
|
|
@@ -29,15 +46,17 @@ function buildForbiddenRules(model) {
|
|
|
29
46
|
allowedTargets.set(m.id, new Set([m.id]));
|
|
30
47
|
for (const edge of model.allowed)
|
|
31
48
|
allowedTargets.get(edge.from)?.add(edge.to);
|
|
32
|
-
// 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).
|
|
33
52
|
const scopeOf = new Map();
|
|
34
53
|
for (const m of model.modules) {
|
|
35
54
|
const descendants = model.modules
|
|
36
|
-
.filter((other) =>
|
|
37
|
-
.map((other) =>
|
|
55
|
+
.filter((other) => isNestedUnder(other, m))
|
|
56
|
+
.map((other) => moduleScopeRegex(other));
|
|
38
57
|
scopeOf.set(m.id, descendants.length
|
|
39
|
-
? { path:
|
|
40
|
-
: { path:
|
|
58
|
+
? { path: moduleScopeRegex(m), pathNot: descendants }
|
|
59
|
+
: { path: moduleScopeRegex(m) });
|
|
41
60
|
}
|
|
42
61
|
// Exemptions apply to the `from` side only: matched files may import freely,
|
|
43
62
|
// but every module's scope still governs them as a *target*, so importing a
|
|
@@ -72,7 +91,8 @@ function buildForbiddenRules(model) {
|
|
|
72
91
|
// module claims is forbidden, not free. Additive — the pair rules above still
|
|
73
92
|
// decide every mapped-to-mapped edge on their own.
|
|
74
93
|
if (model.governRoot) {
|
|
75
|
-
const
|
|
94
|
+
const rootAsFolder = { id: '', title: '', path: model.governRoot, kind: 'folder' };
|
|
95
|
+
const claimed = model.modules.map(moduleScopeRegex);
|
|
76
96
|
for (const from of model.modules) {
|
|
77
97
|
if (unconstrained.has(from.id))
|
|
78
98
|
continue;
|
|
@@ -81,7 +101,7 @@ function buildForbiddenRules(model) {
|
|
|
81
101
|
comment: `${from.title} may not depend on code under '${model.governRoot}' that no module claims`,
|
|
82
102
|
severity: 'error',
|
|
83
103
|
from: asImporter(from.id),
|
|
84
|
-
to: { path:
|
|
104
|
+
to: { path: moduleScopeRegex(rootAsFolder), pathNot: claimed },
|
|
85
105
|
});
|
|
86
106
|
}
|
|
87
107
|
}
|
|
@@ -115,7 +135,7 @@ module.exports = {
|
|
|
115
135
|
const violations = raw
|
|
116
136
|
.filter((v) => v.rule?.severity === 'error')
|
|
117
137
|
.map((v) => ({ from: v.from, to: v.to, rule: v.rule?.name ?? 'unknown' }));
|
|
118
|
-
// A module whose
|
|
138
|
+
// A module whose path matched no source files enforces nothing — the
|
|
119
139
|
// "green but inert" trap. Surface it so a passing check can't hide it.
|
|
120
140
|
const seen = (output?.modules ?? []).map((m) => String(m.source));
|
|
121
141
|
// A guardrail that analysed nothing must never report success. Seeing zero
|
|
@@ -127,9 +147,8 @@ module.exports = {
|
|
|
127
147
|
}
|
|
128
148
|
const warnings = [];
|
|
129
149
|
for (const mod of model.modules) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
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`);
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
// An exemption is a grant, so a dead one is worth knowing about: it means
|
|
@@ -152,13 +171,12 @@ module.exports = {
|
|
|
152
171
|
// claims. The model not covering the code is as much a gap as the code not
|
|
153
172
|
// backing the model.
|
|
154
173
|
if (model.governRoot) {
|
|
155
|
-
const rootPrefix = `${
|
|
156
|
-
const claimed = model.modules.map((m) => `${folderPrefix(m.folder)}/`);
|
|
174
|
+
const rootPrefix = `${normalizePath(model.governRoot)}/`;
|
|
157
175
|
const unclaimed = new Set();
|
|
158
176
|
for (const source of seen) {
|
|
159
177
|
if (!source.startsWith(rootPrefix))
|
|
160
178
|
continue;
|
|
161
|
-
if (
|
|
179
|
+
if (model.modules.some((m) => ownsSource(m, source)))
|
|
162
180
|
continue;
|
|
163
181
|
// Exempt code is deliberately outside the architecture; asking a module
|
|
164
182
|
// to claim it would defeat the point of exempting it.
|
|
@@ -88,7 +88,7 @@ export class LikeC4Visualizer {
|
|
|
88
88
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
89
89
|
const model = await likec4.computedModel();
|
|
90
90
|
const modules = [];
|
|
91
|
-
const
|
|
91
|
+
const moduleIds = new Set();
|
|
92
92
|
const wildcardIds = new Set();
|
|
93
93
|
const exemptImporters = new Set();
|
|
94
94
|
let governRoot;
|
|
@@ -113,12 +113,25 @@ export class LikeC4Visualizer {
|
|
|
113
113
|
wildcardIds.add(String(el.id));
|
|
114
114
|
continue;
|
|
115
115
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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) {
|
|
119
127
|
const id = String(el.id);
|
|
120
|
-
modules.push({
|
|
121
|
-
|
|
128
|
+
modules.push({
|
|
129
|
+
id,
|
|
130
|
+
title: el.title,
|
|
131
|
+
path: (folder ?? file),
|
|
132
|
+
kind: folder ? 'folder' : 'file',
|
|
133
|
+
});
|
|
134
|
+
moduleIds.add(id);
|
|
122
135
|
}
|
|
123
136
|
}
|
|
124
137
|
const allowed = [];
|
|
@@ -131,8 +144,8 @@ export class LikeC4Visualizer {
|
|
|
131
144
|
const to = String(rel.target.id);
|
|
132
145
|
// An edge into a wildcard is kept like any other grant, so `verify` sees a
|
|
133
146
|
// self-granted `#anything` exemption as the newly-allowed edge it is.
|
|
134
|
-
const targetGoverned =
|
|
135
|
-
if (from !== to &&
|
|
147
|
+
const targetGoverned = moduleIds.has(to) || wildcardIds.has(to);
|
|
148
|
+
if (from !== to && moduleIds.has(from) && targetGoverned) {
|
|
136
149
|
allowed.push({ from, to });
|
|
137
150
|
}
|
|
138
151
|
}
|
|
@@ -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 {
|
package/package.json
CHANGED