boundry 0.1.0 → 0.2.0

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