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 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.2.0] — unreleased
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 folder it owns:
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 folderPrefix(folder) {
6
- return folder.replace(/\/+$/, '');
5
+ function normalizePath(path) {
6
+ return path.replace(/\/+$/, '');
7
7
  }
8
- function folderToRegex(folder) {
9
- const escaped = folderPrefix(folder).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
- return `^${escaped}/`;
8
+ function escapeRegex(path) {
9
+ return normalizePath(path).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
10
  }
12
- /** True if `child` is a folder strictly nested under `parent`. */
13
- function isDescendantFolder(child, parent) {
14
- const c = folderPrefix(child);
15
- const p = folderPrefix(parent);
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 folder, carving out any mapped descendant folders.
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) => isDescendantFolder(other.folder, m.folder))
37
- .map((other) => folderToRegex(other.folder));
55
+ .filter((other) => isNestedUnder(other, m))
56
+ .map((other) => moduleScopeRegex(other));
38
57
  scopeOf.set(m.id, descendants.length
39
- ? { path: folderToRegex(m.folder), pathNot: descendants }
40
- : { path: folderToRegex(m.folder) });
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 claimed = model.modules.map((m) => folderToRegex(m.folder));
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: folderToRegex(model.governRoot), pathNot: claimed },
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 folder matched no source files enforces nothing — the
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
- const prefix = `${mod.folder.replace(/\/+$/, '')}/`;
131
- if (!seen.some((source) => source.startsWith(prefix))) {
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 = `${folderPrefix(model.governRoot)}/`;
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 (claimed.some((prefix) => source.startsWith(prefix)))
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 folderIds = new Set();
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
- const meta = el.getMetadata('folder');
117
- const folder = Array.isArray(meta) ? meta[0] : meta;
118
- if (folder) {
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({ id, title: el.title, folder });
121
- folderIds.add(id);
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 = folderIds.has(to) || wildcardIds.has(to);
135
- if (from !== to && folderIds.has(from) && targetGoverned) {
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 code. */
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
- /** Source path prefix the module owns, e.g. "src/core". */
12
- folder: string;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.2.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",