boundry 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maksymilian Piechota
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # Boundry
2
+
3
+ **Compile a C4 architecture diagram into a deterministic dependency linter.**
4
+
5
+ You draw the allowed architecture once, as a [LikeC4](https://likec4.dev) diagram.
6
+ Boundry turns it into a [dependency-cruiser](https://github.com/sverweij/dependency-cruiser)
7
+ ruleset and checks your code against it — locally and in CI. No model calls, no
8
+ heuristics, no judgement: the architecture you drew *is* the linter.
9
+
10
+ It's built for a world where AI agents write most of the code. Review doesn't
11
+ scale and LLM-judge supervisors are non-deterministic; Boundry gives agents a
12
+ hard boundary they can't cross instead of a suggestion they might.
13
+
14
+ ```
15
+ diagram (LikeC4) ──► boundary model ──► dependency-cruiser rules ──► ✓ / ✗
16
+ you draw source-agnostic generated the gate
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ 1. You annotate each element in your diagram with the folder it owns:
22
+ `metadata { folder 'src/domain' }`.
23
+ 2. Every relationship you draw (`a -> b`) is an **allowed** dependency.
24
+ Anything you don't draw is **forbidden**.
25
+ 3. Boundry lifts the diagram into a source-agnostic boundary model, compiles a
26
+ dependency-cruiser ruleset from it, and runs the linter over your code.
27
+
28
+ Elements without a `folder` (actors, external systems, notes) are ignored, so a
29
+ rich communication diagram and an enforcement diagram can be the same file.
30
+
31
+ ## See it
32
+
33
+ The diagram you draw *is* the whole spec. Below is the example architecture that
34
+ Boundry's own end-to-end suite enforces — a hexagonal model with a pure DDD
35
+ core, CQRS, and a public-API boundary.
36
+
37
+ **[Explore every view interactively →](https://makspiechota.github.io/boundry/)**
38
+
39
+ ### Top-level layers
40
+
41
+ ![Top-level layers: entry point, domain, application, infrastructure](docs/diagrams/example-overview.png)
42
+
43
+ ### Inside the domain — the rules are what's *not* drawn
44
+
45
+ Aggregates compose Entities and hold Value Objects; Entities may reach Value
46
+ Objects but never Aggregates; Value Objects import nothing. Every missing arrow
47
+ is a forbidden dependency Boundry will reject.
48
+
49
+ ![Domain internals: aggregates, entities, value objects](docs/diagrams/example-domain.png)
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ npm install --save-dev boundry
55
+ ```
56
+
57
+ Requires Node 20+. `likec4` and `dependency-cruiser` come along as dependencies.
58
+
59
+ ## Quickstart
60
+
61
+ Draw your architecture — `arch/architecture.likec4`:
62
+
63
+ ```likec4
64
+ specification {
65
+ element module { style { shape rectangle } }
66
+ }
67
+
68
+ model {
69
+ module domain 'Domain' {
70
+ metadata { folder 'src/domain' }
71
+ }
72
+ module infra 'Infrastructure' {
73
+ metadata { folder 'src/infra' }
74
+ }
75
+
76
+ // Allowed dependency. Everything not drawn is forbidden.
77
+ infra -> domain
78
+ }
79
+
80
+ views {
81
+ view index { include * }
82
+ }
83
+ ```
84
+
85
+ Check your code against it:
86
+
87
+ ```bash
88
+ npx boundry check --arch arch src
89
+ # ✓ no boundary violations (exit 0)
90
+ # ✗ src/domain/user.ts → src/infra/db.ts [boundary-domain] (exit 1)
91
+ ```
92
+
93
+ A `domain → infra` import is now a build failure; `infra → domain` is fine.
94
+
95
+ ## CLI
96
+
97
+ ```
98
+ boundry check [--arch <dir>] [--cwd <dir>] [sources...]
99
+ boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
100
+ ```
101
+
102
+ | Flag | Meaning |
103
+ | --- | --- |
104
+ | `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
105
+ | `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
106
+ | `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
107
+ | `sources...` | `check` only: paths to lint. Default `src`. |
108
+
109
+ - **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
110
+ - **`generate`** just emits the dependency-cruiser config so you can commit it or
111
+ run `depcruise` yourself.
112
+
113
+ Boundry warns (but does not fail) when a mapped folder matches **zero** files —
114
+ so a passing check can never silently enforce nothing.
115
+
116
+ ## CI
117
+
118
+ ```yaml
119
+ # .github/workflows/architecture.yml
120
+ name: architecture
121
+ on: [push, pull_request]
122
+ jobs:
123
+ boundry:
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/checkout@v4
127
+ - uses: actions/setup-node@v4
128
+ with: { node-version: 20 }
129
+ - run: npm ci
130
+ - run: npx boundry check --arch arch src
131
+ ```
132
+
133
+ ## Programmatic use (SDK)
134
+
135
+ The CLI is a thin wrapper over the SDK. Everything is pluggable — the diagram
136
+ source and the target linter are both adapters behind ports.
137
+
138
+ ```ts
139
+ import { Pipeline, LikeC4Visualizer, DepCruiserEnforcer } from 'boundry';
140
+
141
+ const pipeline = new Pipeline(
142
+ new LikeC4Visualizer('arch'),
143
+ new DepCruiserEnforcer(),
144
+ );
145
+
146
+ const result = await pipeline.check(['src']);
147
+ if (!result.ok) {
148
+ for (const v of result.violations) console.error(`${v.from} → ${v.to}`);
149
+ process.exit(1);
150
+ }
151
+ ```
152
+
153
+ ## Status & scope
154
+
155
+ Early but real — Boundry enforces its own architecture on itself, and ships an
156
+ end-to-end test suite covering a hexagonal + CQRS + DDD model (pure domain core,
157
+ read/write separation, a public-API boundary).
158
+
159
+ Today: **TypeScript** via dependency-cruiser, **LikeC4** as the diagram source.
160
+ Both are adapters, so more languages/linters and diagram formats can plug in
161
+ without touching the core.
162
+
163
+ Current limitations:
164
+
165
+ - One element maps to exactly one folder.
166
+ - Nesting is supported: you can map a parent folder *and* its children. A
167
+ parent's edges govern only the parent's own files — a child never inherits
168
+ them and must be permitted explicitly.
169
+ - `folder` paths are relative to the repo root (`--cwd`), not the diagram file.
170
+
171
+ ## License
172
+
173
+ [MIT](./LICENSE) © Maksymilian Piechota
@@ -0,0 +1,7 @@
1
+ import type { EnforcerPort, EnforcerConfig, CheckResult } from '../../core/ports/ports.js';
2
+ import type { BoundaryModel } from '../../core/model/boundary-model.js';
3
+ /** First enforcer adapter: targets dependency-cruiser for TypeScript. */
4
+ export declare class DepCruiserEnforcer implements EnforcerPort {
5
+ render(model: BoundaryModel): EnforcerConfig;
6
+ check(model: BoundaryModel, sources: string[]): Promise<CheckResult>;
7
+ }
@@ -0,0 +1,98 @@
1
+ import * as dependencyCruiser from 'dependency-cruiser';
2
+ // dependency-cruiser ships CommonJS; reach `cruise` through either interop shape.
3
+ const cruise = dependencyCruiser.cruise ?? dependencyCruiser.default?.cruise;
4
+ function folderPrefix(folder) {
5
+ return folder.replace(/\/+$/, '');
6
+ }
7
+ function folderToRegex(folder) {
8
+ const escaped = folderPrefix(folder).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ return `^${escaped}/`;
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);
15
+ return c !== p && c.startsWith(`${p}/`);
16
+ }
17
+ /**
18
+ * Derives one forbidden rule per (module, disallowed target) pair. A module owns
19
+ * its folder MINUS any mapped descendant folders (which belong to more-specific
20
+ * modules). That containment handling means a parent-level rule never governs a
21
+ * child's files, and a module's own internal imports are never flagged.
22
+ * Dependencies into non-module paths (node_modules, unmapped folders) are left
23
+ * untouched — the architecture only governs edges it drew.
24
+ */
25
+ function buildForbiddenRules(model) {
26
+ const allowedTargets = new Map();
27
+ for (const m of model.modules)
28
+ allowedTargets.set(m.id, new Set([m.id]));
29
+ for (const edge of model.allowed)
30
+ allowedTargets.get(edge.from)?.add(edge.to);
31
+ // Each module's scope: its folder, carving out any mapped descendant folders.
32
+ const scopeOf = new Map();
33
+ for (const m of model.modules) {
34
+ const descendants = model.modules
35
+ .filter((other) => isDescendantFolder(other.folder, m.folder))
36
+ .map((other) => folderToRegex(other.folder));
37
+ scopeOf.set(m.id, descendants.length
38
+ ? { path: folderToRegex(m.folder), pathNot: descendants }
39
+ : { path: folderToRegex(m.folder) });
40
+ }
41
+ const rules = [];
42
+ for (const from of model.modules) {
43
+ const allowed = allowedTargets.get(from.id);
44
+ for (const to of model.modules) {
45
+ if (allowed.has(to.id))
46
+ continue; // self or an explicitly allowed edge
47
+ rules.push({
48
+ name: `boundary:${from.id}->${to.id}`,
49
+ comment: `${from.title} may not depend on ${to.title}`,
50
+ severity: 'error',
51
+ from: scopeOf.get(from.id),
52
+ to: scopeOf.get(to.id),
53
+ });
54
+ }
55
+ }
56
+ return rules;
57
+ }
58
+ /** First enforcer adapter: targets dependency-cruiser for TypeScript. */
59
+ export class DepCruiserEnforcer {
60
+ render(model) {
61
+ const forbidden = buildForbiddenRules(model);
62
+ const content = `/** Generated by Boundry from the architecture diagram. Do not edit by hand. */
63
+ module.exports = {
64
+ forbidden: ${JSON.stringify(forbidden, null, 2)},
65
+ options: {
66
+ doNotFollow: { path: 'node_modules' },
67
+ tsPreCompilationDeps: true,
68
+ },
69
+ };
70
+ `;
71
+ return { filename: '.dependency-cruiser.cjs', content };
72
+ }
73
+ async check(model, sources) {
74
+ const forbidden = buildForbiddenRules(model);
75
+ const result = await cruise(sources, {
76
+ validate: true,
77
+ ruleSet: { forbidden },
78
+ doNotFollow: { path: 'node_modules' },
79
+ tsPreCompilationDeps: true,
80
+ });
81
+ const output = typeof result.output === 'string' ? JSON.parse(result.output) : result.output;
82
+ const raw = output?.summary?.violations ?? [];
83
+ const violations = raw
84
+ .filter((v) => v.rule?.severity === 'error')
85
+ .map((v) => ({ from: v.from, to: v.to, rule: v.rule?.name ?? 'unknown' }));
86
+ // A module whose folder matched no source files enforces nothing — the
87
+ // "green but inert" trap. Surface it so a passing check can't hide it.
88
+ const seen = (output?.modules ?? []).map((m) => String(m.source));
89
+ const warnings = [];
90
+ for (const mod of model.modules) {
91
+ const prefix = `${mod.folder.replace(/\/+$/, '')}/`;
92
+ if (!seen.some((source) => source.startsWith(prefix))) {
93
+ warnings.push(`module '${mod.title}' maps to '${mod.folder}', which matched 0 source files`);
94
+ }
95
+ }
96
+ return { ok: violations.length === 0, violations, warnings };
97
+ }
98
+ }
@@ -0,0 +1,20 @@
1
+ import type { VisualizerPort } from '../../core/ports/ports.js';
2
+ import type { BoundaryModel } from '../../core/model/boundary-model.js';
3
+ /**
4
+ * First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
5
+ * boundary model: any element carrying a `folder` metadata key becomes a
6
+ * module, and any relationship between two such elements becomes an allowed
7
+ * edge.
8
+ */
9
+ export declare class LikeC4Visualizer implements VisualizerPort {
10
+ private readonly workspaceDir;
11
+ constructor(workspaceDir: string);
12
+ read(): Promise<BoundaryModel>;
13
+ /**
14
+ * Deterministically strip `#proposed` markers from the diagram source,
15
+ * promoting intent edges to approved. Source-preserving; never an LLM edit —
16
+ * it locates each marked relationship in the LikeC4 CST and splices out its
17
+ * proposal decoration, leaving all other formatting intact.
18
+ */
19
+ approve(): Promise<void>;
20
+ }
@@ -0,0 +1,138 @@
1
+ import { LikeC4 } from 'likec4';
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
+ }
21
+ }
22
+ return undefined;
23
+ }
24
+ function isProposedTag(node, text) {
25
+ return (node.$type === 'TagRef' &&
26
+ node.$cstNode &&
27
+ text.slice(node.$cstNode.offset, node.$cstNode.end) === '#proposed');
28
+ }
29
+ /**
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.
33
+ */
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 };
48
+ }
49
+ return undefined;
50
+ }
51
+ function collectProposedRanges(root, text) {
52
+ const ranges = [];
53
+ const seen = new Set();
54
+ const walk = (node) => {
55
+ if (!node || typeof node !== 'object' || seen.has(node))
56
+ return;
57
+ seen.add(node);
58
+ if (Array.isArray(node)) {
59
+ for (const item of node)
60
+ walk(item);
61
+ return;
62
+ }
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);
69
+ }
70
+ for (const key of Object.keys(node)) {
71
+ if (!key.startsWith('$'))
72
+ walk(node[key]);
73
+ }
74
+ };
75
+ walk(root);
76
+ return ranges;
77
+ }
78
+ /**
79
+ * First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
80
+ * boundary model: any element carrying a `folder` metadata key becomes a
81
+ * module, and any relationship between two such elements becomes an allowed
82
+ * edge.
83
+ */
84
+ export class LikeC4Visualizer {
85
+ workspaceDir;
86
+ constructor(workspaceDir) {
87
+ this.workspaceDir = workspaceDir;
88
+ }
89
+ async read() {
90
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
91
+ const model = await likec4.computedModel();
92
+ const modules = [];
93
+ const folderIds = new Set();
94
+ for (const el of model.elements()) {
95
+ const meta = el.getMetadata('folder');
96
+ const folder = Array.isArray(meta) ? meta[0] : meta;
97
+ if (folder) {
98
+ const id = String(el.id);
99
+ modules.push({ id, title: el.title, folder });
100
+ folderIds.add(id);
101
+ }
102
+ }
103
+ const allowed = [];
104
+ for (const rel of model.relationships()) {
105
+ // A `#proposed` edge is an intent, not yet approved — it is NOT enforced
106
+ // as allowed until the marker is stripped (see `approve`).
107
+ if (rel.tags.some((tag) => String(tag) === 'proposed'))
108
+ continue;
109
+ const from = String(rel.source.id);
110
+ const to = String(rel.target.id);
111
+ if (from !== to && folderIds.has(from) && folderIds.has(to)) {
112
+ allowed.push({ from, to });
113
+ }
114
+ }
115
+ return { modules, allowed };
116
+ }
117
+ /**
118
+ * Deterministically strip `#proposed` markers from the diagram source,
119
+ * promoting intent edges to approved. Source-preserving; never an LLM edit —
120
+ * it locates each marked relationship in the LikeC4 CST and splices out its
121
+ * proposal decoration, leaving all other formatting intact.
122
+ */
123
+ async approve() {
124
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
125
+ for (const doc of likec4.LangiumDocuments.all) {
126
+ const filePath = doc.uri.fsPath;
127
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
128
+ const ranges = collectProposedRanges(doc.parseResult?.value, text);
129
+ if (ranges.length === 0)
130
+ continue;
131
+ let next = text;
132
+ for (const { start, end } of ranges.sort((a, b) => b.start - a.start)) {
133
+ next = next.slice(0, start) + next.slice(end);
134
+ }
135
+ writeFileSync(filePath, next);
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, relative, resolve } from 'node:path';
6
+ import { Pipeline } from '../core/pipeline/pipeline.js';
7
+ import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
8
+ import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
9
+ const USAGE = 'usage: boundry <generate|check|approve|verify> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
10
+ function optValue(args, flag) {
11
+ const i = args.indexOf(flag);
12
+ return i >= 0 ? args[i + 1] : undefined;
13
+ }
14
+ function positionals(args) {
15
+ const out = [];
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i].startsWith('--')) {
18
+ i++; // skip the flag's value
19
+ continue;
20
+ }
21
+ out.push(args[i]);
22
+ }
23
+ return out;
24
+ }
25
+ /**
26
+ * Materialize the architecture as of a git ref into a temp workspace, so the
27
+ * previously-approved boundary model can be lifted and compared against HEAD.
28
+ */
29
+ function materializeArchAt(archDir, ref) {
30
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
31
+ cwd: archDir,
32
+ encoding: 'utf8',
33
+ }).trim();
34
+ const workDir = mkdtempSync(join(tmpdir(), 'boundry-base-'));
35
+ for (const file of readdirSync(archDir).filter((f) => f.endsWith('.likec4'))) {
36
+ try {
37
+ const content = execFileSync('git', ['show', `${ref}:${relative(gitRoot, join(archDir, file))}`], {
38
+ cwd: gitRoot,
39
+ encoding: 'utf8',
40
+ });
41
+ writeFileSync(join(workDir, file), content);
42
+ }
43
+ catch {
44
+ // The file did not exist at `ref` — everything it declares is new.
45
+ }
46
+ }
47
+ return workDir;
48
+ }
49
+ function listGrantedEdges(granted) {
50
+ for (const edge of granted)
51
+ console.error(` ${edge.from} → ${edge.to}`);
52
+ console.error(' Mark them #proposed so the grant is an explicit, reviewable act.');
53
+ }
54
+ async function main() {
55
+ const [command, ...rest] = process.argv.slice(2);
56
+ // Resolve paths against the *original* cwd before we optionally move into the
57
+ // target repo, so `--arch` and `--out` are unaffected by `--cwd`.
58
+ const archDir = resolve(optValue(rest, '--arch') ?? '.');
59
+ const outArg = optValue(rest, '--out');
60
+ const outFile = outArg ? resolve(outArg) : undefined;
61
+ const baseRef = optValue(rest, '--base');
62
+ // `--cwd` lets you check a repo without cd-ing into it. `folder` metadata is
63
+ // relative to the target repo root, so the enforcer runs from there.
64
+ const cwd = optValue(rest, '--cwd');
65
+ if (cwd)
66
+ process.chdir(resolve(cwd));
67
+ const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
68
+ const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
69
+ if (command === 'generate') {
70
+ const config = await pipeline.generate();
71
+ const out = outFile ?? resolve(config.filename);
72
+ writeFileSync(out, config.content);
73
+ console.log(`Boundry: wrote ${out}`);
74
+ return;
75
+ }
76
+ if (command === 'check') {
77
+ const sources = positionals(rest);
78
+ const result = await pipeline.check(sources.length ? sources : ['src']);
79
+ for (const warning of result.warnings) {
80
+ console.error(`Boundry: ⚠ ${warning}`);
81
+ }
82
+ if (result.ok) {
83
+ console.log('Boundry: ✓ no boundary violations');
84
+ return;
85
+ }
86
+ console.error(`Boundry: ✗ ${result.violations.length} boundary violation(s)`);
87
+ for (const v of result.violations) {
88
+ console.error(` ${v.from} → ${v.to} [${v.rule}]`);
89
+ }
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ if (command === 'verify') {
94
+ if (!baseRef) {
95
+ console.error('Boundry: verify requires --base <git-ref>');
96
+ process.exitCode = 2;
97
+ return;
98
+ }
99
+ const granted = await grantedSince(baseRef);
100
+ if (granted.length === 0) {
101
+ console.log(`Boundry: ✓ no edges granted without a #proposed marker (vs ${baseRef})`);
102
+ return;
103
+ }
104
+ console.error(`Boundry: ✗ ${granted.length} edge(s) granted without a #proposed marker (vs ${baseRef})`);
105
+ listGrantedEdges(granted);
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ if (command === 'approve') {
110
+ // Approving must never launder an edge that skipped the proposal protocol.
111
+ if (baseRef) {
112
+ 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);
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ }
120
+ else {
121
+ console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
122
+ }
123
+ await pipeline.approve();
124
+ console.log('Boundry: ✓ approved — stripped #proposed markers from the diagram');
125
+ return;
126
+ }
127
+ console.error(USAGE);
128
+ process.exitCode = 2;
129
+ }
130
+ main().catch((err) => {
131
+ console.error(err);
132
+ process.exitCode = 1;
133
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The single, source-agnostic representation everything in Boundry compiles
3
+ * from. A visualizer adapter produces it; an enforcer adapter renders it.
4
+ */
5
+ /** A unit of architecture that maps to a folder of source code. */
6
+ export interface Module {
7
+ /** Stable id, taken from the diagram element. */
8
+ id: string;
9
+ /** Human-readable name for messages. */
10
+ title: string;
11
+ /** Source path prefix the module owns, e.g. "src/core". */
12
+ folder: string;
13
+ }
14
+ /** A permitted dependency: modules in `from` may import modules in `to`. */
15
+ export interface AllowedEdge {
16
+ from: string;
17
+ to: string;
18
+ }
19
+ export interface BoundaryModel {
20
+ modules: Module[];
21
+ allowed: AllowedEdge[];
22
+ }
23
+ /**
24
+ * Edges allowed at `head` that were not allowed at `base`.
25
+ *
26
+ * A `#proposed` edge is deliberately excluded from a model's allow-list, so a
27
+ * proposal never appears here. That makes this delta exactly the set of
28
+ * dependencies granted WITHOUT going through a proposal — i.e. self-approvals.
29
+ */
30
+ export declare function newlyAllowedEdges(base: BoundaryModel, head: BoundaryModel): AllowedEdge[];
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The single, source-agnostic representation everything in Boundry compiles
3
+ * from. A visualizer adapter produces it; an enforcer adapter renders it.
4
+ */
5
+ const edgeKey = (edge) => `${edge.from} -> ${edge.to}`;
6
+ /**
7
+ * Edges allowed at `head` that were not allowed at `base`.
8
+ *
9
+ * A `#proposed` edge is deliberately excluded from a model's allow-list, so a
10
+ * proposal never appears here. That makes this delta exactly the set of
11
+ * dependencies granted WITHOUT going through a proposal — i.e. self-approvals.
12
+ */
13
+ export function newlyAllowedEdges(base, head) {
14
+ const allowedAtBase = new Set(base.allowed.map(edgeKey));
15
+ return head.allowed.filter((edge) => !allowedAtBase.has(edgeKey(edge)));
16
+ }
@@ -0,0 +1,23 @@
1
+ import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult } from "../ports/ports.js";
2
+ import type { AllowedEdge } from "../model/boundary-model.js";
3
+ /**
4
+ * The SDK's public surface. Orchestrates the two driven ports and stays blind
5
+ * to any concrete diagram source or linter — swap adapters, this is untouched.
6
+ */
7
+ export declare class Pipeline {
8
+ private readonly visualizer;
9
+ private readonly enforcer;
10
+ constructor(visualizer: VisualizerPort, enforcer: EnforcerPort);
11
+ /** Diagram -> boundary model -> generated linter config. */
12
+ generate(): Promise<EnforcerConfig>;
13
+ /** Diagram -> boundary model -> run the linter over `sources`. */
14
+ check(sources: string[]): Promise<CheckResult>;
15
+ /**
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.
19
+ */
20
+ verify(base: VisualizerPort): Promise<AllowedEdge[]>;
21
+ /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
22
+ approve(): Promise<void>;
23
+ }
@@ -0,0 +1,36 @@
1
+ import { newlyAllowedEdges } from "../model/boundary-model.js";
2
+ /**
3
+ * The SDK's public surface. Orchestrates the two driven ports and stays blind
4
+ * to any concrete diagram source or linter — swap adapters, this is untouched.
5
+ */
6
+ export class Pipeline {
7
+ visualizer;
8
+ enforcer;
9
+ constructor(visualizer, enforcer) {
10
+ this.visualizer = visualizer;
11
+ this.enforcer = enforcer;
12
+ }
13
+ /** Diagram -> boundary model -> generated linter config. */
14
+ async generate() {
15
+ const model = await this.visualizer.read();
16
+ return this.enforcer.render(model);
17
+ }
18
+ /** Diagram -> boundary model -> run the linter over `sources`. */
19
+ async check(sources) {
20
+ const model = await this.visualizer.read();
21
+ return this.enforcer.check(model, sources);
22
+ }
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.
27
+ */
28
+ async verify(base) {
29
+ const [baseModel, headModel] = await Promise.all([base.read(), this.visualizer.read()]);
30
+ return newlyAllowedEdges(baseModel, headModel);
31
+ }
32
+ /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
33
+ async approve() {
34
+ return this.visualizer.approve();
35
+ }
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { BoundaryModel } from '../model/boundary-model.js';
2
+ /** A driven port: turns some diagram source into the boundary model. */
3
+ export interface VisualizerPort {
4
+ read(): Promise<BoundaryModel>;
5
+ /**
6
+ * Deterministically strip `#proposed` markers from the diagram source,
7
+ * promoting intent edges to approved. Source-preserving; never an LLM edit.
8
+ */
9
+ approve(): Promise<void>;
10
+ }
11
+ /** A generated linter config artifact. */
12
+ export interface EnforcerConfig {
13
+ filename: string;
14
+ content: string;
15
+ }
16
+ /** A single boundary violation found by an enforcer. */
17
+ export interface Violation {
18
+ from: string;
19
+ to: string;
20
+ rule: string;
21
+ }
22
+ export interface CheckResult {
23
+ ok: boolean;
24
+ violations: Violation[];
25
+ /** Non-fatal problems, e.g. a mapped folder that matched no source files. */
26
+ warnings: string[];
27
+ }
28
+ /**
29
+ * A driven port: renders the boundary model into a target linter's native
30
+ * config, and runs that linter against source.
31
+ */
32
+ export interface EnforcerPort {
33
+ render(model: BoundaryModel): EnforcerConfig;
34
+ check(model: BoundaryModel, sources: string[]): Promise<CheckResult>;
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export type { BoundaryModel, Module, AllowedEdge, } from './core/model/boundary-model.js';
2
+ export type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, Violation, } from './core/ports/ports.js';
3
+ export { Pipeline } from './core/pipeline/pipeline.js';
4
+ export { LikeC4Visualizer } from './adapters/visualizer/likec4.js';
5
+ export { DepCruiserEnforcer } from './adapters/enforcer/depcruiser.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Pipeline } from './core/pipeline/pipeline.js';
2
+ export { LikeC4Visualizer } from './adapters/visualizer/likec4.js';
3
+ export { DepCruiserEnforcer } from './adapters/enforcer/depcruiser.js';
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "boundry",
3
+ "version": "0.0.1",
4
+ "description": "Compile a C4 architecture diagram into a deterministic dependency linter. Deterministic architectural guardrails for AI agents and humans.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Maksymilian Piechota",
8
+ "homepage": "https://github.com/makspiechota/boundry#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/makspiechota/boundry.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/makspiechota/boundry/issues"
15
+ },
16
+ "keywords": [
17
+ "architecture",
18
+ "dependency-cruiser",
19
+ "likec4",
20
+ "c4",
21
+ "linter",
22
+ "guardrails",
23
+ "hexagonal",
24
+ "ddd",
25
+ "typescript",
26
+ "ai-agents"
27
+ ],
28
+ "bin": {
29
+ "boundry": "./dist/cli/index.js"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "scripts": {
48
+ "boundry": "tsx src/cli/index.ts",
49
+ "generate": "tsx src/cli/index.ts generate --arch arch --out .dependency-cruiser.cjs",
50
+ "check": "tsx src/cli/index.ts check --arch arch src",
51
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
52
+ "build": "npm run clean && tsc -p tsconfig.json",
53
+ "test": "node --import tsx --test src/__tests__/e2e/*.e2e.test.ts",
54
+ "prepublishOnly": "npm run build"
55
+ },
56
+ "dependencies": {
57
+ "dependency-cruiser": "^16.4.0",
58
+ "likec4": "^1.46.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^22.7.0",
62
+ "tsx": "^4.19.0",
63
+ "typescript": "^5.6.0"
64
+ }
65
+ }