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 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 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
@@ -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 never silently enforce nothing.
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 { BoundaryModel } from '../../core/model/boundary-model.js';
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 folderPrefix(folder) {
5
- return folder.replace(/\/+$/, '');
5
+ function normalizePath(path) {
6
+ return path.replace(/\/+$/, '');
6
7
  }
7
- function folderToRegex(folder) {
8
- const escaped = folderPrefix(folder).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
- return `^${escaped}/`;
8
+ function escapeRegex(path) {
9
+ return normalizePath(path).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
10
  }
11
- /** True if `child` is a folder strictly nested under `parent`. */
12
- function isDescendantFolder(child, parent) {
13
- const c = folderPrefix(child);
14
- 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);
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 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).
32
52
  const scopeOf = new Map();
33
53
  for (const m of model.modules) {
34
54
  const descendants = model.modules
35
- .filter((other) => isDescendantFolder(other.folder, m.folder))
36
- .map((other) => folderToRegex(other.folder));
55
+ .filter((other) => isNestedUnder(other, m))
56
+ .map((other) => moduleScopeRegex(other));
37
57
  scopeOf.set(m.id, descendants.length
38
- ? { path: folderToRegex(m.folder), pathNot: descendants }
39
- : { path: folderToRegex(m.folder) });
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: scopeOf.get(from.id),
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 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);
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: scopeOf.get(from.id),
67
- to: { path: folderToRegex(model.governRoot), pathNot: claimed },
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 folder matched no source files enforces nothing — the
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
- const prefix = `${mod.folder.replace(/\/+$/, '')}/`;
107
- if (!seen.some((source) => source.startsWith(prefix))) {
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 = `${folderPrefix(model.governRoot)}/`;
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 (claimed.some((prefix) => source.startsWith(prefix)))
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
- function findDescendant(node, predicate) {
4
- const seen = new Set();
5
- const stack = [node];
6
- while (stack.length) {
7
- const n = stack.pop();
8
- if (!n || typeof n !== 'object' || seen.has(n))
9
- continue;
10
- seen.add(n);
11
- if (Array.isArray(n)) {
12
- stack.push(...n);
13
- continue;
14
- }
15
- if (predicate(n))
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`);
16
+ }
17
+ try {
18
+ new RegExp(pattern);
21
19
  }
22
- return undefined;
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
- * For a relationship carrying a `#proposed` marker, the range of source to
31
- * remove: the whole relationship body (the proposal decoration) plus the
32
- * whitespace before it, or the bare inline tag when there is no body.
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(relation, text) {
35
- const body = findDescendant(relation, (n) => n.$type === 'RelationBody' && n.$cstNode);
36
- if (body) {
37
- let start = body.$cstNode.offset;
38
- while (start > 0 && /\s/.test(text[start - 1]))
39
- start--;
40
- return { start, end: body.$cstNode.end };
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
- return undefined;
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.$type === 'Relation' &&
64
- node.$cstNode &&
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 folderIds = new Set();
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
- const meta = el.getMetadata('folder');
103
- const folder = Array.isArray(meta) ? meta[0] : meta;
104
- if (folder) {
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({ id, title: el.title, folder });
107
- 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);
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
- if (from !== to && folderIds.has(from) && folderIds.has(to)) {
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 { modules, allowed, governRoot };
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 listGrantedEdges(granted) {
50
- for (const edge of granted)
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
- console.error(' Mark them #proposed so the grant is an explicit, reviewable act.');
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.length === 0) {
101
- console.log(`Boundry: ✓ no edges granted without a #proposed marker (vs ${baseRef})`);
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.length} edge(s) granted without a #proposed marker (vs ${baseRef})`);
105
- listGrantedEdges(granted);
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.length > 0) {
114
- console.error(`Boundry: ✗ refusing to approve — ${granted.length} edge(s) were granted without a #proposed marker (vs ${baseRef})`);
115
- listGrantedEdges(granted);
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 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 {
@@ -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
- * Edges this diagram grants that `base` did not — i.e. dependencies added
17
- * without a `#proposed` marker. Proposals are excluded from the allow-list,
18
- * so anything reported here bypassed the approval protocol.
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<AllowedEdge[]>;
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
- * Edges this diagram grants that `base` did not — i.e. dependencies added
25
- * without a `#proposed` marker. Proposals are excluded from the allow-list,
26
- * so anything reported here bypassed the approval protocol.
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 newlyAllowedEdges(baseModel, headModel);
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.1.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/*.e2e.test.ts",
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
  }