boundry 0.3.0 → 0.5.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/dist/cli/index.js CHANGED
@@ -1,12 +1,10 @@
1
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';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
3
  import { join, relative, resolve } from 'node:path';
6
4
  import { Pipeline } from '../core/pipeline/pipeline.js';
7
5
  import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
8
6
  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...]';
7
+ const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [sources...]';
10
8
  function optValue(args, flag) {
11
9
  const i = args.indexOf(flag);
12
10
  return i >= 0 ? args[i + 1] : undefined;
@@ -22,30 +20,6 @@ function positionals(args) {
22
20
  }
23
21
  return out;
24
22
  }
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
23
  function countGrants(granted) {
50
24
  return granted.edges.length + granted.exemptions.length;
51
25
  }
@@ -69,14 +43,17 @@ async function main() {
69
43
  const archDir = resolve(optValue(rest, '--arch') ?? '.');
70
44
  const outArg = optValue(rest, '--out');
71
45
  const outFile = outArg ? resolve(outArg) : undefined;
72
- const baseRef = optValue(rest, '--base');
73
46
  // `--cwd` lets you check a repo without cd-ing into it. `folder` metadata is
74
47
  // relative to the target repo root, so the enforcer runs from there.
75
48
  const cwd = optValue(rest, '--cwd');
76
49
  if (cwd)
77
50
  process.chdir(resolve(cwd));
51
+ // The accepted-state lock lives beside the diagram it locks. `approve` writes
52
+ // it; `verify` and `annotate` read it — the baseline Boundry owns, decoupled
53
+ // from git, so "accepted" never means merely "committed".
54
+ const lockFile = join(archDir, 'boundry.lock');
55
+ const readLock = () => existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : undefined;
78
56
  const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
79
- const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
80
57
  if (command === 'generate') {
81
58
  const config = await pipeline.generate();
82
59
  const out = outFile ?? resolve(config.filename);
@@ -102,37 +79,78 @@ async function main() {
102
79
  return;
103
80
  }
104
81
  if (command === 'verify') {
105
- if (!baseRef) {
106
- console.error('Boundry: verify requires --base <git-ref>');
82
+ const lock = readLock();
83
+ if (!lock) {
84
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record the accepted state`);
107
85
  process.exitCode = 2;
108
86
  return;
109
87
  }
110
- const granted = await grantedSince(baseRef);
88
+ const granted = await pipeline.verify(lock);
111
89
  if (countGrants(granted) === 0) {
112
- console.log(`Boundry: ✓ nothing granted without a #proposed marker (vs ${baseRef})`);
90
+ console.log('Boundry: ✓ nothing granted without a #proposed marker (vs the accepted lock)');
113
91
  return;
114
92
  }
115
- console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs ${baseRef})`);
93
+ console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs the accepted lock)`);
116
94
  listGrants(granted);
117
95
  process.exitCode = 1;
118
96
  return;
119
97
  }
120
98
  if (command === 'approve') {
121
99
  // Approving must never launder an edge that skipped the proposal protocol.
122
- if (baseRef) {
123
- const granted = await grantedSince(baseRef);
100
+ // The accepted lock is the baseline: a bare edge added since the last approve
101
+ // is in the allow-list but not the lock, so it surfaces here; a #proposed one
102
+ // is excluded from the allow-list, so it passes and gets enacted below. The
103
+ // first approve has no lock yet — it establishes the initial accepted state.
104
+ const lock = readLock();
105
+ if (lock) {
106
+ const granted = await pipeline.verify(lock);
124
107
  if (countGrants(granted) > 0) {
125
- console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs ${baseRef})`);
108
+ console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs the accepted lock)`);
126
109
  listGrants(granted);
127
110
  process.exitCode = 1;
128
111
  return;
129
112
  }
130
113
  }
131
114
  else {
132
- console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
115
+ console.error('Boundry: ⚠ no boundry.lock yet — approving to record the initial accepted state');
116
+ }
117
+ const nextLock = await pipeline.approve();
118
+ writeFileSync(lockFile, nextLock);
119
+ console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
120
+ return;
121
+ }
122
+ if (command === 'annotate') {
123
+ if (!existsSync(lockFile)) {
124
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record an accepted state`);
125
+ process.exitCode = 2;
126
+ return;
127
+ }
128
+ const { edges, modules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
129
+ if (edges.length === 0 && modules.length === 0) {
130
+ console.log('Boundry: ✓ nothing to annotate — the diagram matches the accepted lock');
131
+ return;
132
+ }
133
+ console.log(`Boundry: ✎ marked ${edges.length} edge(s) and ${modules.length} box(es) #proposed:`);
134
+ for (const edge of edges)
135
+ console.log(` ${edge.from} → ${edge.to}`);
136
+ for (const mod of modules)
137
+ console.log(` [${mod.title}]`);
138
+ console.log(' Review the highlighted diagram, then approve or revert.');
139
+ return;
140
+ }
141
+ if (command === 'diff') {
142
+ const views = await pipeline.diffViews();
143
+ const diffFile = join(archDir, 'boundry.diff.likec4');
144
+ if (views.length === 0) {
145
+ console.log('Boundry: ✓ nothing proposed — no diff views to draw');
146
+ return;
147
+ }
148
+ console.log(`Boundry: ✎ wrote ${views.length} diff view(s) to ${relative(process.cwd(), diffFile)}:`);
149
+ for (const view of views) {
150
+ const layer = view.scope ?? 'root';
151
+ console.log(` ${view.id} (layer ${layer}, ${view.changes} change(s))`);
133
152
  }
134
- await pipeline.approve();
135
- console.log('Boundry: ✓ approved — stripped #proposed markers from the diagram');
153
+ console.log(' Open the diagram in `likec4 serve` to review each layer.');
136
154
  return;
137
155
  }
138
156
  console.error(USAGE);
@@ -82,3 +82,18 @@ export declare function newlyExemptedImporters(base: BoundaryModel, head: Bounda
82
82
  * dependencies granted WITHOUT going through a proposal — i.e. self-approvals.
83
83
  */
84
84
  export declare function newlyAllowedEdges(base: BoundaryModel, head: BoundaryModel): AllowedEdge[];
85
+ /**
86
+ * Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
87
+ * stays a module (the marker is visual-only), so this reports both bare-added and
88
+ * already-proposed new boxes — the annotator makes marking idempotent.
89
+ */
90
+ export declare function newlyAddedModules(base: BoundaryModel, head: BoundaryModel): Module[];
91
+ /**
92
+ * The accepted state, written as a canonical string — the lock. `approve` records
93
+ * it so change detection has a baseline it OWNS, rather than trusting whatever git
94
+ * happens to hold. Deterministic: fields and lists are sorted, so the same model
95
+ * always serializes byte-for-byte the same.
96
+ */
97
+ export declare function serializeModel(model: BoundaryModel): string;
98
+ /** Read a lock back into a model. Inverse of {@link serializeModel}. */
99
+ export declare function parseModel(text: string): BoundaryModel;
@@ -34,3 +34,43 @@ export function newlyAllowedEdges(base, head) {
34
34
  const allowedAtBase = new Set(base.allowed.map(edgeKey));
35
35
  return head.allowed.filter((edge) => !allowedAtBase.has(edgeKey(edge)));
36
36
  }
37
+ /**
38
+ * Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
39
+ * stays a module (the marker is visual-only), so this reports both bare-added and
40
+ * already-proposed new boxes — the annotator makes marking idempotent.
41
+ */
42
+ export function newlyAddedModules(base, head) {
43
+ const atBase = new Set(base.modules.map((m) => m.id));
44
+ return head.modules.filter((m) => !atBase.has(m.id));
45
+ }
46
+ /**
47
+ * The accepted state, written as a canonical string — the lock. `approve` records
48
+ * it so change detection has a baseline it OWNS, rather than trusting whatever git
49
+ * happens to hold. Deterministic: fields and lists are sorted, so the same model
50
+ * always serializes byte-for-byte the same.
51
+ */
52
+ export function serializeModel(model) {
53
+ const canonical = {
54
+ modules: [...model.modules]
55
+ .sort((a, b) => a.id.localeCompare(b.id))
56
+ .map((m) => ({ id: m.id, title: m.title, path: m.path, kind: m.kind })),
57
+ allowed: [...model.allowed]
58
+ .sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)))
59
+ .map((e) => ({ from: e.from, to: e.to })),
60
+ wildcards: [...model.wildcards].sort(),
61
+ exemptImporters: [...model.exemptImporters].sort(),
62
+ ...(model.governRoot ? { governRoot: model.governRoot } : {}),
63
+ };
64
+ return `${JSON.stringify(canonical, null, 2)}\n`;
65
+ }
66
+ /** Read a lock back into a model. Inverse of {@link serializeModel}. */
67
+ export function parseModel(text) {
68
+ const raw = JSON.parse(text);
69
+ return {
70
+ modules: raw.modules ?? [],
71
+ allowed: raw.allowed ?? [],
72
+ wildcards: raw.wildcards ?? [],
73
+ exemptImporters: raw.exemptImporters ?? [],
74
+ governRoot: raw.governRoot,
75
+ };
76
+ }
@@ -1,5 +1,10 @@
1
- import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult } from "../ports/ports.js";
2
- import type { AllowedEdge } from "../model/boundary-model.js";
1
+ import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, DiffView } from "../ports/ports.js";
2
+ import type { AllowedEdge, Module } from "../model/boundary-model.js";
3
+ /** What the annotator turned into explicit `#proposed` proposals. */
4
+ export interface AnnotateResult {
5
+ edges: AllowedEdge[];
6
+ modules: Module[];
7
+ }
3
8
  /**
4
9
  * What a diagram grants that the approved base did not. Both kinds of grant are
5
10
  * reported, because both let code cross a boundary that was previously closed.
@@ -26,12 +31,40 @@ export declare class Pipeline {
26
31
  /** Diagram -> boundary model -> run the linter over `sources`. */
27
32
  check(sources: string[]): Promise<CheckResult>;
28
33
  /**
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.
34
+ * What this diagram grants that the accepted `lock` did not — dependencies
35
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
36
+ * are excluded from the allow-list, so anything reported here bypassed the
37
+ * approval protocol.
38
+ *
39
+ * The baseline is the lock — the accepted state `approve` records — not a git
40
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
41
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
42
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
43
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
44
+ */
45
+ verify(lock: string): Promise<VerifyResult>;
46
+ /**
47
+ * Enact the diagram's pending proposals (strip `#proposed`, remove
48
+ * `#proposal-delete`), then return the resulting accepted model as a lock
49
+ * string. The caller persists it: that lock — written by approve, not inferred
50
+ * from git — is the baseline change detection compares against.
51
+ */
52
+ approve(): Promise<string>;
53
+ /**
54
+ * Compare the diagram against a previously accepted lock and rewrite every
55
+ * undeclared addition — a bare new edge (a self-grant) or box — into an
56
+ * explicit `#proposed` proposal in the source, then paints intrinsic styling on
57
+ * every marker so the proposal is highlighted on every LikeC4 surface. Turns
58
+ * silent drift into a reviewable, colourable proposal without trusting git for
59
+ * the baseline.
60
+ */
61
+ annotate(lock: string): Promise<AnnotateResult>;
62
+ /**
63
+ * (Re)generate a focused diff view per layer that holds a pending change, so a
64
+ * reviewer sees each proposal in the scope where it is actually drawn — a
65
+ * proposal nested in a box is invisible once that box collapses at a wider
66
+ * view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
67
+ * composes with `annotate` (annotate marks drift, this frames it).
33
68
  */
34
- verify(base: VisualizerPort): Promise<VerifyResult>;
35
- /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
36
- approve(): Promise<void>;
69
+ diffViews(): Promise<DiffView[]>;
37
70
  }
@@ -1,4 +1,4 @@
1
- import { newlyAllowedEdges, newlyExemptedImporters, } from "../model/boundary-model.js";
1
+ import { newlyAddedModules, newlyAllowedEdges, newlyExemptedImporters, parseModel, serializeModel, } 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,20 +21,61 @@ export class Pipeline {
21
21
  return this.enforcer.check(model, sources);
22
22
  }
23
23
  /**
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.
24
+ * What this diagram grants that the accepted `lock` did not — dependencies
25
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
26
+ * are excluded from the allow-list, so anything reported here bypassed the
27
+ * approval protocol.
28
+ *
29
+ * The baseline is the lock — the accepted state `approve` records — not a git
30
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
31
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
32
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
33
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
28
34
  */
29
- async verify(base) {
30
- const [baseModel, headModel] = await Promise.all([base.read(), this.visualizer.read()]);
35
+ async verify(lock) {
36
+ const base = parseModel(lock);
37
+ const head = await this.visualizer.read();
31
38
  return {
32
- edges: newlyAllowedEdges(baseModel, headModel),
33
- exemptions: newlyExemptedImporters(baseModel, headModel),
39
+ edges: newlyAllowedEdges(base, head),
40
+ exemptions: newlyExemptedImporters(base, head),
34
41
  };
35
42
  }
36
- /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
43
+ /**
44
+ * Enact the diagram's pending proposals (strip `#proposed`, remove
45
+ * `#proposal-delete`), then return the resulting accepted model as a lock
46
+ * string. The caller persists it: that lock — written by approve, not inferred
47
+ * from git — is the baseline change detection compares against.
48
+ */
37
49
  async approve() {
38
- return this.visualizer.approve();
50
+ await this.visualizer.approve();
51
+ const accepted = await this.visualizer.read();
52
+ return serializeModel(accepted);
53
+ }
54
+ /**
55
+ * Compare the diagram against a previously accepted lock and rewrite every
56
+ * undeclared addition — a bare new edge (a self-grant) or box — into an
57
+ * explicit `#proposed` proposal in the source, then paints intrinsic styling on
58
+ * every marker so the proposal is highlighted on every LikeC4 surface. Turns
59
+ * silent drift into a reviewable, colourable proposal without trusting git for
60
+ * the baseline.
61
+ */
62
+ async annotate(lock) {
63
+ const accepted = parseModel(lock);
64
+ const head = await this.visualizer.read();
65
+ const edges = newlyAllowedEdges(accepted, head);
66
+ const modules = newlyAddedModules(accepted, head);
67
+ await this.visualizer.propose(edges, modules.map((m) => m.id));
68
+ await this.visualizer.styleMarkers();
69
+ return { edges, modules };
70
+ }
71
+ /**
72
+ * (Re)generate a focused diff view per layer that holds a pending change, so a
73
+ * reviewer sees each proposal in the scope where it is actually drawn — a
74
+ * proposal nested in a box is invisible once that box collapses at a wider
75
+ * view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
76
+ * composes with `annotate` (annotate marks drift, this frames it).
77
+ */
78
+ async diffViews() {
79
+ return this.visualizer.emitDiffViews();
39
80
  }
40
81
  }
@@ -1,4 +1,4 @@
1
- import type { BoundaryModel } from '../model/boundary-model.js';
1
+ import type { AllowedEdge, BoundaryModel } from '../model/boundary-model.js';
2
2
  /** A driven port: turns some diagram source into the boundary model. */
3
3
  export interface VisualizerPort {
4
4
  read(): Promise<BoundaryModel>;
@@ -7,6 +7,42 @@ export interface VisualizerPort {
7
7
  * promoting intent edges to approved. Source-preserving; never an LLM edit.
8
8
  */
9
9
  approve(): Promise<void>;
10
+ /**
11
+ * Deterministically mark the given edges and elements `#proposed` in the
12
+ * source. The inverse of `approve` for additions: it rewrites an undeclared
13
+ * change into an explicit, colourable proposal. Idempotent — anything already
14
+ * marked is left alone. Source-preserving; never an LLM edit.
15
+ */
16
+ propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
17
+ /**
18
+ * Paint intrinsic `style { color … }` on every edge/box carrying a marker, so a
19
+ * proposal is highlighted on every LikeC4 surface — base views and the
20
+ * relationships panel, not only the generated diff views. Amber for `#proposed`,
21
+ * red for `#proposal-delete`. Idempotent; `approve` strips it back out.
22
+ * Source-preserving; never an LLM edit.
23
+ */
24
+ styleMarkers(): Promise<void>;
25
+ /**
26
+ * (Re)generate a focused diff view for every layer that holds a pending
27
+ * change — an edge or box tagged `#proposed` or `#proposal-delete`. Each view
28
+ * is scoped to the tightest element that contains the change (the model root
29
+ * for a top-level one), because a proposal nested inside a box is invisible
30
+ * once that box collapses at a wider scope. A derived artifact: overwritten
31
+ * each run, and removed when nothing is proposed. Returns one entry per view.
32
+ */
33
+ emitDiffViews(): Promise<DiffView[]>;
34
+ }
35
+ /**
36
+ * One emitted diff view: a single layer (element scope) that holds at least one
37
+ * pending change, and how many changes fall in it.
38
+ */
39
+ export interface DiffView {
40
+ /** The generated view id, e.g. `boundry_diff_root` or `boundry_diff_billing`. */
41
+ id: string;
42
+ /** The scope element's fqn, or undefined for the model root. */
43
+ scope?: string;
44
+ /** How many pending `#proposed` / `#proposal-delete` changes this layer holds. */
45
+ changes: number;
10
46
  }
11
47
  /** A generated linter config artifact. */
12
48
  export interface EnforcerConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "dependency-cruiser": "^16.4.0",
59
- "likec4": "^1.46.0",
59
+ "likec4": "^1.58.0",
60
60
  "typescript": "^5.6.0"
61
61
  },
62
62
  "devDependencies": {