@principal-ai/subsystems-react 0.51.4 → 0.53.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.
Files changed (69) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +3 -7
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +193 -47
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +33 -27
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +89 -43
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts +1 -1
  14. package/dist/subsystem/IssueList.d.ts.map +1 -1
  15. package/dist/subsystem/IssueList.js +20 -1
  16. package/dist/subsystem/IssueList.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +42 -19
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/c4.d.ts +48 -49
  21. package/dist/subsystem/c4.d.ts.map +1 -1
  22. package/dist/subsystem/c4.js +50 -0
  23. package/dist/subsystem/c4.js.map +1 -1
  24. package/dist/subsystem/c4Evidence.d.ts +88 -84
  25. package/dist/subsystem/c4Evidence.d.ts.map +1 -1
  26. package/dist/subsystem/c4Evidence.js +113 -166
  27. package/dist/subsystem/c4Evidence.js.map +1 -1
  28. package/dist/subsystem/model.d.ts +35 -245
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js +11 -29
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/nodes.js +5 -5
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/techIcons/index.d.ts +5 -0
  35. package/dist/subsystem/techIcons/index.d.ts.map +1 -1
  36. package/dist/subsystem/techIcons/index.js +3 -0
  37. package/dist/subsystem/techIcons/index.js.map +1 -1
  38. package/dist/utils/edgeLabel.d.ts +15 -1
  39. package/dist/utils/edgeLabel.d.ts.map +1 -1
  40. package/dist/utils/edgeLabel.js +25 -1
  41. package/dist/utils/edgeLabel.js.map +1 -1
  42. package/dist/utils/elkLayout.d.ts +47 -0
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +71 -7
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/index.ts +8 -6
  48. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
  49. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
  50. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
  51. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
  52. package/src/subsystem/C4Graph.tsx +278 -82
  53. package/src/subsystem/C4NodeCard.test.ts +32 -22
  54. package/src/subsystem/C4NodeCard.tsx +134 -69
  55. package/src/subsystem/IssueList.tsx +23 -0
  56. package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
  57. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  58. package/src/subsystem/c4.ts +76 -52
  59. package/src/subsystem/c4Evidence.test.ts +177 -237
  60. package/src/subsystem/c4Evidence.ts +160 -235
  61. package/src/subsystem/model.ts +62 -305
  62. package/src/subsystem/nodes.group.test.tsx +15 -0
  63. package/src/subsystem/nodes.tsx +7 -7
  64. package/src/subsystem/techIcons/index.tsx +8 -0
  65. package/src/utils/edgeLabel.test.ts +21 -2
  66. package/src/utils/edgeLabel.ts +29 -1
  67. package/src/utils/elkLayout.test.ts +16 -0
  68. package/src/utils/elkLayout.ts +119 -7
  69. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -1,12 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
- deriveConcerns,
4
- suggestMerges,
5
3
  findUnassigned,
6
4
  isRuntimeConstruct,
7
5
  suggestContainerKind,
8
6
  proposeElements,
9
- mergeElements,
7
+ verificationIssues,
8
+ verifyProcessBoundaries,
10
9
  } from './c4Evidence';
11
10
  import type { C4MemberView } from './c4Evidence';
12
11
  import type { C4Container, C4Element } from './c4';
@@ -31,136 +30,6 @@ describe('isRuntimeConstruct', () => {
31
30
  });
32
31
  });
33
32
 
34
- describe('deriveConcerns — questions, not answers', () => {
35
- test('flags a lone member as a singleton', () => {
36
- const out = deriveConcerns('graphify/extract', [member({ alias: 'x' })]);
37
- expect(out.some((c) => c.kind === 'singleton')).toBe(true);
38
- });
39
-
40
- test('flags a group with no runtime member as library-shaped', () => {
41
- const out = deriveConcerns(
42
- 'subsystems-core',
43
- [
44
- member({ alias: 'a', construct: 'interface' }),
45
- member({ alias: 'b', construct: 'type_alias' }),
46
- ],
47
- { frameworks: [] },
48
- );
49
- expect(out.some((c) => c.kind === 'library_shaped')).toBe(true);
50
- });
51
-
52
- test('a single non-runtime member is also library-shaped', () => {
53
- const out = deriveConcerns('shared', [member({ alias: 'a', construct: 'type_alias' })]);
54
- expect(out.some((c) => c.kind === 'library_shaped')).toBe(true);
55
- });
56
-
57
- test('flags a runtime container with no technology signal', () => {
58
- const out = deriveConcerns('p2', [member({ alias: 'c' })], { frameworks: [] });
59
- expect(out.some((c) => c.kind === 'missing_technology')).toBe(true);
60
- });
61
-
62
- test('does not demand technology once a framework is present', () => {
63
- const out = deriveConcerns(
64
- 'p1',
65
- [member({ alias: 'a' }), member({ alias: 'b' })],
66
- { frameworks: ['react'] },
67
- );
68
- expect(out.some((c) => c.kind === 'missing_technology')).toBe(false);
69
- });
70
-
71
- test('flags members spanning two repo keys', () => {
72
- const out = deriveConcerns('p1', [
73
- member({ alias: 'a' }),
74
- member({ alias: 'z', purl: 'pkg:github/other/thing#z.ts' }),
75
- ]);
76
- expect(out.some((c) => c.kind === 'spans_repos')).toBe(true);
77
- });
78
-
79
- test('flags a store with no storage kind', () => {
80
- const out = deriveConcerns('p1', [
81
- member({ alias: 'a' }),
82
- member({ alias: 's', construct: 'store', declaration: { kind: 'store', properties: [] } }),
83
- ]);
84
- expect(out.some((c) => c.kind === 'store_unclassified')).toBe(true);
85
- });
86
-
87
- test('does not flag a store that declared its storage', () => {
88
- const out = deriveConcerns('p1', [
89
- member({ alias: 'a' }),
90
- member({ alias: 's', construct: 'store', declaration: { kind: 'store', storage: 'memory' } }),
91
- ]);
92
- expect(out.some((c) => c.kind === 'store_unclassified')).toBe(false);
93
- });
94
-
95
- test('flags an external with no resolvable identity', () => {
96
- const out = deriveConcerns('p1', [
97
- member({ alias: 'a' }),
98
- member({ alias: 'x', construct: 'external', purl: 'external' }),
99
- ]);
100
- expect(out.some((c) => c.kind === 'external_unidentified')).toBe(true);
101
- });
102
-
103
- test('does not flag an external that has a real purl', () => {
104
- const out = deriveConcerns('p1', [
105
- member({ alias: 'a' }),
106
- member({ alias: 'x', construct: 'external', purl: 'pkg:npm/some-pkg' }),
107
- ]);
108
- expect(out.some((c) => c.kind === 'external_unidentified')).toBe(false);
109
- });
110
-
111
- test('a healthy container raises nothing', () => {
112
- const out = deriveConcerns(
113
- 'subsystems-studio/host',
114
- [
115
- member({ alias: 'a' }),
116
- member({ alias: 'b' }),
117
- member({ alias: 's', construct: 'store', declaration: { kind: 'store', storage: 'memory' } }),
118
- ],
119
- { frameworks: ['bun'] },
120
- );
121
- expect(out).toHaveLength(0);
122
- });
123
-
124
- test('never returns a decision — only concerns', () => {
125
- const out = deriveConcerns('graphify/extract', [member({ alias: 'x' })]);
126
- // Every field describes something to look at, never a verdict.
127
- for (const c of out) {
128
- expect(typeof c.kind).toBe('string');
129
- expect(c.message.length).toBeGreaterThan(0);
130
- expect(c.message).not.toMatch(/^\s*(is|use)\b/i);
131
- }
132
- });
133
- });
134
-
135
- describe('suggestMerges', () => {
136
- test('groups keys that share a trailing role', () => {
137
- const out = suggestMerges([
138
- { key: 'subsystems-studio/host', memberCount: 9 },
139
- { key: 'principal-studio/host', memberCount: 1 },
140
- { key: 'subsystems-studio/renderer', memberCount: 5 },
141
- ]);
142
- expect(out).toHaveLength(1);
143
- expect(out[0]!.role).toBe('host');
144
- expect(out[0]!.keys).toEqual(['principal-studio/host', 'subsystems-studio/host']);
145
- });
146
-
147
- test('ignores a bare key with no app/role shape', () => {
148
- const out = suggestMerges([
149
- { key: 'subsystems-react', memberCount: 2 },
150
- { key: 'subsystems-core', memberCount: 1 },
151
- ]);
152
- expect(out).toHaveLength(0);
153
- });
154
-
155
- test('frames the finding as a question', () => {
156
- const out = suggestMerges([
157
- { key: 'a/host', memberCount: 1 },
158
- { key: 'b/host', memberCount: 1 },
159
- ]);
160
- expect(out[0]!.reason).toContain('or two units?');
161
- });
162
- });
163
-
164
33
  describe('findUnassigned', () => {
165
34
  test('returns runtime components that state no process', () => {
166
35
  const out = findUnassigned([
@@ -197,11 +66,9 @@ describe('suggestContainerKind', () => {
197
66
 
198
67
  test('there is no library answer, even for a library-shaped group', () => {
199
68
  // The kinds are application | data-store. A package of interfaces still
200
- // comes back `application`, with the question left to a person in the
201
- // `library_shaped` concern. Returning `library` here would put a non-C4
202
- // concept into the vocabulary.
69
+ // comes back `application`; the library verdict is the propose gate's
70
+ // business (no proposal at all), not this function's.
203
71
  const members = [member({ alias: 'a', construct: 'interface' })];
204
- expect(deriveConcerns('x', members).some((c) => c.kind === 'library_shaped')).toBe(true);
205
72
  expect(suggestContainerKind(members)).toBe('application');
206
73
  });
207
74
  });
@@ -223,164 +90,237 @@ describe('proposeElements', () => {
223
90
  },
224
91
  ];
225
92
 
226
- test('proposes one element per derived key, never accepted', () => {
93
+ test('a library-shaped boundary gets no proposal — it cannot be discerned into a container', () => {
94
+ // Question 1: code that never runs is not a deployable unit. The concern
95
+ // stands for review; no box is proposed for a person to reject.
227
96
  const set = proposeElements({ repoKey: REPO, rollup });
228
- expect(set.elements).toHaveLength(2);
229
- expect(set.elements.every((e) => e.state === 'proposed')).toBe(true);
97
+ expect(set.elements.map((e) => e.id)).toEqual(['container:subsystems-studio/host']);
230
98
  });
231
99
 
232
- test('every proposal is a container, since a key names a process', () => {
100
+ test('every proposal is a container claiming its key via `process`, never accepted', () => {
233
101
  const set = proposeElements({ repoKey: REPO, rollup });
234
102
  expect(set.elements.every((e) => e.kind === 'container')).toBe(true);
103
+ expect(set.elements.every((e) => e.state === 'proposed')).toBe(true);
104
+ expect(set.elements.map((e) => (e as C4Container).process)).toEqual(['subsystems-studio/host']);
235
105
  });
236
106
 
237
- test('every proposal carries the open questions as its rationale', () => {
238
- const set = proposeElements({ repoKey: REPO, rollup });
239
- const core = set.elements.find((e) => e.derivedFrom?.includes('subsystems-core'))!;
240
- expect(core.rationale).toContain('library');
107
+ test('a key a container already claims is skipped — no duplicates', () => {
108
+ // Question 2: the container exists; proposing another would be the
109
+ // duplicate this module exists to prevent.
110
+ const existing: C4Element[] = [
111
+ {
112
+ kind: 'container',
113
+ containerKind: 'application',
114
+ technology: 'Bun',
115
+ id: 'container:subsystems-studio/host',
116
+ process: 'subsystems-studio/host',
117
+ label: 'studio host',
118
+ state: 'accepted',
119
+ },
120
+ ];
121
+ const set = proposeElements({ repoKey: REPO, rollup, elements: existing });
122
+ expect(set.elements).toHaveLength(0);
123
+ });
124
+
125
+ test('the scaffold fabricates no rationale — that is the proposing agent\'s to write', () => {
126
+ const set = proposeElements({
127
+ repoKey: REPO,
128
+ rollup: [{ key: 'app/worker', frameworks: ['bun'], members: [member({ alias: 'w' })] }],
129
+ });
130
+ expect(set.elements[0]!.rationale).toBeUndefined();
241
131
  });
242
132
 
243
- test('a healthy key gets no rationale, because there is nothing to ask', () => {
133
+ test('proposes strictly one container per key — folding lives in the model, not here', () => {
134
+ // Two keys that are one deployable unit are a model discrepancy: a
135
+ // consolidation proposal rewrites the components' `process` fields first,
136
+ // and proposeElements then naturally proposes one container. The read
137
+ // side never folds.
244
138
  const set = proposeElements({
245
139
  repoKey: REPO,
246
140
  rollup: [
141
+ rollup[0]!,
247
142
  {
248
- key: 'app/host',
249
- frameworks: ['bun'],
250
- members: [member({ alias: 'a' }), member({ alias: 'b' })],
143
+ key: 'principal-studio/host',
144
+ frameworks: ['electrobun'],
145
+ members: [member({ alias: 'z' })],
251
146
  },
252
147
  ],
253
148
  });
254
- expect(set.elements[0]!.rationale).toBeUndefined();
255
- });
256
-
257
- test('members are named, not inferred from the key', () => {
258
- const set = proposeElements({ repoKey: REPO, rollup });
259
- const host = set.elements.find((e) => e.derivedFrom?.includes('subsystems-studio/host'))!;
260
- expect(host.members).toEqual(['a', 's']);
149
+ expect(set.elements).toHaveLength(2);
261
150
  });
262
151
 
263
152
  test('a key with no framework signal gets an empty technology, not a guess', () => {
264
- // `technology` is required by the type, and an empty one is reported.
153
+ // `technology` is required by the type, and an empty one is reported as a
265
154
  // gap. Writing "TypeScript" here would be the derivation this module does
266
155
  // not do.
267
- const set = proposeElements({ repoKey: REPO, rollup });
268
- const core = set.elements.find((e) => e.derivedFrom?.includes('subsystems-core'))!;
269
- expect((core as C4Container).technology).toBe('');
156
+ const set = proposeElements({
157
+ repoKey: REPO,
158
+ rollup: [{ key: 'app/host', frameworks: [], members: [member({ alias: 'a' })] }],
159
+ });
160
+ expect((set.elements[0]! as C4Container).technology).toBe('');
270
161
  });
271
162
 
272
163
  test('the set is sorted by id, so a review list is stable', () => {
273
- const set = proposeElements({ repoKey: REPO, rollup });
274
- expect(set.elements.map((e) => e.id)).toEqual([...set.elements.map((e) => e.id)].sort());
164
+ const set = proposeElements({
165
+ repoKey: REPO,
166
+ rollup: [
167
+ { key: 'b/host', frameworks: ['bun'], members: [member({ alias: 'a' })] },
168
+ { key: 'a/host', frameworks: ['bun'], members: [member({ alias: 'b' })] },
169
+ ],
170
+ });
171
+ expect(set.elements.map((e) => e.id)).toEqual(['container:a/host', 'container:b/host']);
275
172
  });
276
173
  });
277
174
 
278
- describe('mergeElements', () => {
279
- function container(over: Partial<C4Container> & { id: string }): C4Container {
175
+ describe('verifyProcessBoundaries', () => {
176
+ function container(over: Partial<C4Container> & { id: string; process: string }): C4Container {
280
177
  return {
281
178
  kind: 'container',
282
179
  containerKind: 'application',
283
180
  technology: 'Bun',
284
- members: [],
285
181
  label: over.id,
286
182
  state: 'proposed',
287
183
  ...over,
288
184
  };
289
185
  }
290
186
 
291
- const set: C4Element[] = [
292
- container({ id: 'container:big', derivedFrom: ['subsystems-studio/host'], members: ['a', 'b', 'c'] }),
293
- container({ id: 'container:small', derivedFrom: ['principal-studio/host'], members: ['z'] }),
294
- container({ id: 'container:other', derivedFrom: ['site/web'], members: ['w'] }),
187
+ const rollup = [
188
+ { key: 'app/host', memberAliases: ['a', 'b'] },
189
+ { key: 'app/store', memberAliases: ['s'] },
190
+ { key: 'app/orphan' },
295
191
  ];
296
192
 
297
- test('folds the named keys into one element holding both member sets', () => {
298
- const out = mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
299
- expect(out).toHaveLength(2);
300
- const host = out.find((e) => e.id === 'container:big')!;
301
- expect(host.members.sort()).toEqual(['a', 'b', 'c', 'z']);
302
- expect(host.derivedFrom!.sort()).toEqual(['principal-studio/host', 'subsystems-studio/host']);
193
+ test('a key no container claims is unassigned', () => {
194
+ const out = verifyProcessBoundaries({ rollup: [rollup[0]!], elements: [] });
195
+ expect(out[0]).toMatchObject({ key: 'app/host', status: 'unassigned' });
196
+ expect(out[0]!.element).toBeUndefined();
303
197
  });
304
198
 
305
- test('the evidence-heavy side survives by default', () => {
306
- // container:big holds 3 members and container:small holds 1.
307
- const out = mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
308
- expect(out.some((e) => e.id === 'container:big')).toBe(true);
309
- expect(out.some((e) => e.id === 'container:small')).toBe(false);
199
+ test('memberAliases pass through for the confirm UI', () => {
200
+ const out = verifyProcessBoundaries({ rollup, elements: [] });
201
+ expect(out[0]!.memberAliases).toEqual(['a', 'b']);
202
+ expect(out[2]!.memberAliases).toEqual([]);
310
203
  });
311
204
 
312
- test('a caller can override which side survives', () => {
313
- const out = mergeElements(
314
- set,
315
- ['subsystems-studio/host', 'principal-studio/host'],
316
- 'container:small',
317
- );
318
- expect(out.some((e) => e.id === 'container:small')).toBe(true);
205
+ test('a proposal assigns the boundary and reads as proposed', () => {
206
+ const set = proposeElements({
207
+ repoKey: REPO,
208
+ rollup: [
209
+ { key: 'app/host', frameworks: ['bun'], members: [member({ alias: 'a' }), member({ alias: 'b' })] },
210
+ ],
211
+ });
212
+ const out = verifyProcessBoundaries({
213
+ rollup: [{ key: 'app/host', memberAliases: ['a', 'b'] }],
214
+ elements: set.elements,
215
+ });
216
+ expect(out[0]).toMatchObject({ key: 'app/host', status: 'proposed' });
217
+ expect(out[0]!.element!.id).toBe('container:app/host');
319
218
  });
320
219
 
321
- test('leaves unrelated elements alone', () => {
322
- const out = mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
323
- expect(out.some((e) => e.id === 'container:other')).toBe(true);
220
+ test('an accepted container verifies the boundary', () => {
221
+ const out = verifyProcessBoundaries({
222
+ rollup: [rollup[0]!],
223
+ elements: [container({ id: 'container:app/host', process: 'app/host', state: 'accepted' })],
224
+ });
225
+ expect(out[0]).toMatchObject({ key: 'app/host', status: 'verified' });
324
226
  });
325
227
 
326
- test('a merge with nothing to absorb is a no-op', () => {
327
- expect(mergeElements(set, ['subsystems-studio/host'])).toHaveLength(3);
228
+ test('a rejected container marks the boundary rejected', () => {
229
+ const out = verifyProcessBoundaries({
230
+ rollup: [rollup[0]!],
231
+ elements: [container({ id: 'container:app/host', process: 'app/host', state: 'rejected' })],
232
+ });
233
+ expect(out[0]).toMatchObject({ key: 'app/host', status: 'rejected' });
328
234
  });
329
235
 
330
- test('is a no-op when only one side is present', () => {
331
- expect(mergeElements(set, ['nope/nothing', 'site/web'])).toHaveLength(3);
236
+ test('a boundary matches its container by key, nothing else', () => {
237
+ // A near-miss key is not a claimant: no redirect, no fold — if the model
238
+ // says two keys are one unit, the model gets rewritten, not the lookup.
239
+ const out = verifyProcessBoundaries({
240
+ rollup: [{ key: 'legacy/host', memberAliases: ['z'] }],
241
+ elements: [container({ id: 'container:app/host', process: 'app/host', state: 'accepted' })],
242
+ });
243
+ expect(out[0]).toMatchObject({ key: 'legacy/host', status: 'unassigned' });
332
244
  });
333
245
 
334
- test('refuses to fold a component into another element', () => {
335
- // A component has a required `container`, so absorbing one would silently
336
- // repoint it at a parent the caller never agreed to.
337
- const withComponent: C4Element[] = [
338
- container({ id: 'container:a', derivedFrom: ['x/host'], members: ['a'] }),
339
- {
340
- kind: 'component',
341
- container: 'container:a',
342
- technology: 'React',
343
- members: ['b'],
344
- label: 'component:b',
345
- state: 'proposed',
346
- derivedFrom: ['y/host'],
347
- },
348
- ];
349
- expect(mergeElements(withComponent, ['x/host', 'y/host'])).toHaveLength(2);
246
+ test('accepted outranks rejected when two containers claim one key', () => {
247
+ const out = verifyProcessBoundaries({
248
+ rollup: [rollup[0]!],
249
+ elements: [
250
+ container({ id: 'container:a', process: 'app/host', state: 'rejected' }),
251
+ container({ id: 'container:b', process: 'app/host', state: 'accepted' }),
252
+ ],
253
+ });
254
+ expect(out[0]).toMatchObject({ status: 'verified', element: { id: 'container:b' } });
350
255
  });
351
256
 
352
- test('does not touch the input set', () => {
353
- mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
354
- expect(set).toHaveLength(3);
257
+ test('a non-container never claims a boundary', () => {
258
+ // Only containers carry a `process` claim; components point at their
259
+ // parent and cannot verify anything.
260
+ const component: C4Element = {
261
+ kind: 'component',
262
+ container: 'container:app/host',
263
+ technology: 'React',
264
+ label: 'component:a',
265
+ state: 'accepted',
266
+ };
267
+ const out = verifyProcessBoundaries({ rollup: [rollup[0]!], elements: [component] });
268
+ expect(out[0]).toMatchObject({ status: 'unassigned' });
269
+ });
270
+ });
271
+
272
+ describe('verificationIssues — statuses as dynamic-topology findings', () => {
273
+ test('a verified boundary reports nothing — absence only', () => {
274
+ const out = verificationIssues([
275
+ { key: 'app/host', memberAliases: ['a'], status: 'verified' },
276
+ ]);
277
+ expect(out).toHaveLength(0);
355
278
  });
356
279
 
357
- test('is idempotent', () => {
358
- const once = mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
359
- expect(mergeElements(once, ['subsystems-studio/host', 'principal-studio/host'])).toEqual(once);
280
+ test('an unclaimed boundary is a finding whose fix proposes a container', () => {
281
+ const out = verificationIssues([
282
+ { key: 'app/worker', memberAliases: ['w'], status: 'unassigned' },
283
+ ]);
284
+ expect(out).toHaveLength(1);
285
+ expect(out[0]).toMatchObject({
286
+ id: 'process-verification:app/worker',
287
+ severity: 'info',
288
+ kind: 'boundary_process_unassigned',
289
+ target: { kind: 'process', id: 'app/worker' },
290
+ fix: { label: 'Propose container' },
291
+ });
360
292
  });
361
293
 
362
- test('keeps the confirmed attributes of the survivor', () => {
363
- const withTech = set.map((e) =>
364
- e.id === 'container:big' ? { ...e, technology: 'Electrobun', state: 'accepted' as const } : e,
365
- );
366
- const out = mergeElements(withTech, ['subsystems-studio/host', 'principal-studio/host']);
367
- expect(out.find((e) => e.id === 'container:big')).toMatchObject({
368
- technology: 'Electrobun',
369
- state: 'accepted',
294
+ test('a proposed boundary awaits the decision; its fix accepts the container', () => {
295
+ const out = verificationIssues([
296
+ {
297
+ key: 'app/renderer',
298
+ memberAliases: ['v'],
299
+ status: 'proposed',
300
+ element: { id: 'container:app/renderer', label: 'Studio renderer' } as C4Element,
301
+ },
302
+ ]);
303
+ expect(out[0]).toMatchObject({
304
+ kind: 'boundary_process_proposed',
305
+ fix: { label: 'Accept container' },
370
306
  });
307
+ expect(out[0]!.message).toContain('Studio renderer');
371
308
  });
372
309
 
373
- test('both rationales survive, so the decision is still readable', () => {
374
- const annotated = set.map((e) =>
375
- e.id === 'container:big'
376
- ? { ...e, rationale: 'two app names, one process' }
377
- : e.id === 'container:small'
378
- ? { ...e, rationale: 'one member' }
379
- : e,
380
- );
381
- const out = mergeElements(annotated, ['subsystems-studio/host', 'principal-studio/host']);
382
- const host = out.find((e) => e.id === 'container:big')!;
383
- expect(host.rationale).toContain('two app names');
384
- expect(host.rationale).toContain('one member');
310
+ test('a rejected boundary records the declined container and offers no fix', () => {
311
+ const out = verificationIssues([
312
+ {
313
+ key: 'app/worker',
314
+ memberAliases: ['w'],
315
+ status: 'rejected',
316
+ element: { id: 'container:app/worker', label: 'Studio worker' } as C4Element,
317
+ },
318
+ ]);
319
+ expect(out[0]).toMatchObject({
320
+ kind: 'boundary_process_rejected',
321
+ target: { kind: 'process', id: 'app/worker' },
322
+ });
323
+ expect(out[0]!.fix).toBeUndefined();
324
+ expect(out[0]!.message).toContain('rejected');
385
325
  });
386
- });
326
+ });