@principal-ai/subsystems-react 0.51.0 → 0.51.2

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 (88) hide show
  1. package/dist/index.d.ts +6 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +44 -21
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
  10. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
  12. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
  13. package/dist/subsystem/C4Graph.d.ts +2 -2
  14. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  15. package/dist/subsystem/C4Graph.js +25 -16
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +85 -35
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  19. package/dist/subsystem/C4NodeCard.js +112 -83
  20. package/dist/subsystem/C4NodeCard.js.map +1 -1
  21. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  22. package/dist/subsystem/FileDrawer.js +20 -21
  23. package/dist/subsystem/FileDrawer.js.map +1 -1
  24. package/dist/subsystem/IssueList.d.ts.map +1 -1
  25. package/dist/subsystem/IssueList.js +12 -3
  26. package/dist/subsystem/IssueList.js.map +1 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +85 -7
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/c4.d.ts +273 -0
  31. package/dist/subsystem/c4.d.ts.map +1 -0
  32. package/dist/subsystem/c4.js +322 -0
  33. package/dist/subsystem/c4.js.map +1 -0
  34. package/dist/subsystem/c4Evidence.d.ts +143 -0
  35. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  36. package/dist/subsystem/c4Evidence.js +240 -0
  37. package/dist/subsystem/c4Evidence.js.map +1 -0
  38. package/dist/subsystem/model.d.ts +2 -6
  39. package/dist/subsystem/model.d.ts.map +1 -1
  40. package/dist/subsystem/model.js.map +1 -1
  41. package/package.json +5 -4
  42. package/src/index.ts +26 -28
  43. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  44. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +171 -80
  45. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  46. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  47. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  48. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +169 -3
  49. package/src/subsystem/C4Graph.tsx +39 -24
  50. package/src/subsystem/C4NodeCard.test.ts +204 -62
  51. package/src/subsystem/C4NodeCard.tsx +146 -101
  52. package/src/subsystem/FileDrawer.tsx +32 -34
  53. package/src/subsystem/IssueList.tsx +13 -2
  54. package/src/subsystem/SubsystemComponentGraph.tsx +98 -7
  55. package/src/subsystem/c4.test.ts +573 -0
  56. package/src/subsystem/c4.ts +559 -0
  57. package/src/subsystem/c4Evidence.test.ts +386 -0
  58. package/src/subsystem/c4Evidence.ts +329 -0
  59. package/src/subsystem/model.ts +2 -6
  60. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
  61. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
  62. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
  63. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
  64. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
  65. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  66. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
  67. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  68. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
  69. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
  70. package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
  71. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
  72. package/dist/subsystem/c4Associations.d.ts +0 -186
  73. package/dist/subsystem/c4Associations.d.ts.map +0 -1
  74. package/dist/subsystem/c4Associations.js +0 -284
  75. package/dist/subsystem/c4Associations.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -214
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -282
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
  81. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  82. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
  83. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
  84. package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
  85. package/src/subsystem/c4Associations.test.ts +0 -484
  86. package/src/subsystem/c4Associations.ts +0 -398
  87. package/src/subsystem/toC4.test.ts +0 -114
  88. package/src/subsystem/toC4.ts +0 -478
@@ -0,0 +1,386 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ deriveConcerns,
4
+ suggestMerges,
5
+ findUnassigned,
6
+ isRuntimeConstruct,
7
+ suggestContainerKind,
8
+ proposeElements,
9
+ mergeElements,
10
+ } from './c4Evidence';
11
+ import type { C4MemberView } from './c4Evidence';
12
+ import type { C4Container, C4Element } from './c4';
13
+
14
+ const REPO = 'pkg:github/acme/repo';
15
+
16
+ function member(over: Partial<C4MemberView> & { alias: string }): C4MemberView {
17
+ return { construct: 'function', purl: `${REPO}#src/x.ts`, ...over };
18
+ }
19
+
20
+ describe('isRuntimeConstruct', () => {
21
+ test('only code that runs can be inside a deployable unit', () => {
22
+ expect(isRuntimeConstruct('function')).toBe(true);
23
+ expect(isRuntimeConstruct('class')).toBe(true);
24
+ expect(isRuntimeConstruct('custom_entity')).toBe(true);
25
+ });
26
+
27
+ test('types, stores, and externals do not run', () => {
28
+ for (const c of ['interface', 'type_alias', 'enum', 'store', 'external', 'method']) {
29
+ expect(isRuntimeConstruct(c)).toBe(false);
30
+ }
31
+ });
32
+ });
33
+
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
+ describe('findUnassigned', () => {
165
+ test('returns runtime components that state no process', () => {
166
+ const out = findUnassigned([
167
+ member({ alias: 'a' }),
168
+ member({ alias: 'b', process: 'p1' }),
169
+ member({ alias: 'i', construct: 'interface' }),
170
+ ]);
171
+ expect(out.map((m) => m.alias)).toEqual(['a']);
172
+ });
173
+ });
174
+
175
+ describe('suggestContainerKind', () => {
176
+ test('disk- or externally-backed stores make a data store', () => {
177
+ for (const storage of ['disk', 'external']) {
178
+ const members = [
179
+ member({ alias: 'a' }),
180
+ member({ alias: 's', construct: 'store', declaration: { kind: 'store', storage } }),
181
+ ];
182
+ expect(suggestContainerKind(members)).toBe('data-store');
183
+ }
184
+ });
185
+
186
+ test('in-memory state stays an application, not a data store', () => {
187
+ const members = [
188
+ member({ alias: 'a' }),
189
+ member({ alias: 's', construct: 'store', declaration: { kind: 'store', storage: 'memory' } }),
190
+ ];
191
+ expect(suggestContainerKind(members)).toBe('application');
192
+ });
193
+
194
+ test('defaults to application when the evidence is thin', () => {
195
+ expect(suggestContainerKind([member({ alias: 'a' })])).toBe('application');
196
+ });
197
+
198
+ test('there is no library answer, even for a library-shaped group', () => {
199
+ // 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.
203
+ const members = [member({ alias: 'a', construct: 'interface' })];
204
+ expect(deriveConcerns('x', members).some((c) => c.kind === 'library_shaped')).toBe(true);
205
+ expect(suggestContainerKind(members)).toBe('application');
206
+ });
207
+ });
208
+
209
+ describe('proposeElements', () => {
210
+ const rollup = [
211
+ {
212
+ key: 'subsystems-studio/host',
213
+ frameworks: ['bun'],
214
+ members: [
215
+ member({ alias: 'a' }),
216
+ member({ alias: 's', construct: 'store', declaration: { kind: 'store', storage: 'disk' } }),
217
+ ],
218
+ },
219
+ {
220
+ key: 'subsystems-core',
221
+ frameworks: [],
222
+ members: [member({ alias: 'i', construct: 'interface' })],
223
+ },
224
+ ];
225
+
226
+ test('proposes one element per derived key, never accepted', () => {
227
+ const set = proposeElements({ repoKey: REPO, rollup });
228
+ expect(set.elements).toHaveLength(2);
229
+ expect(set.elements.every((e) => e.state === 'proposed')).toBe(true);
230
+ });
231
+
232
+ test('every proposal is a container, since a key names a process', () => {
233
+ const set = proposeElements({ repoKey: REPO, rollup });
234
+ expect(set.elements.every((e) => e.kind === 'container')).toBe(true);
235
+ });
236
+
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');
241
+ });
242
+
243
+ test('a healthy key gets no rationale, because there is nothing to ask', () => {
244
+ const set = proposeElements({
245
+ repoKey: REPO,
246
+ rollup: [
247
+ {
248
+ key: 'app/host',
249
+ frameworks: ['bun'],
250
+ members: [member({ alias: 'a' }), member({ alias: 'b' })],
251
+ },
252
+ ],
253
+ });
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']);
261
+ });
262
+
263
+ test('a key with no framework signal gets an empty technology, not a guess', () => {
264
+ // `technology` is required by the type, and `summarizeElements` reports the
265
+ // gap. Writing "TypeScript" here would be the derivation this module does
266
+ // 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('');
270
+ });
271
+
272
+ 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());
275
+ });
276
+ });
277
+
278
+ describe('mergeElements', () => {
279
+ function container(over: Partial<C4Container> & { id: string }): C4Container {
280
+ return {
281
+ kind: 'container',
282
+ containerKind: 'application',
283
+ technology: 'Bun',
284
+ members: [],
285
+ label: over.id,
286
+ state: 'proposed',
287
+ ...over,
288
+ };
289
+ }
290
+
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'] }),
295
+ ];
296
+
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']);
303
+ });
304
+
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);
310
+ });
311
+
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);
319
+ });
320
+
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);
324
+ });
325
+
326
+ test('a merge with nothing to absorb is a no-op', () => {
327
+ expect(mergeElements(set, ['subsystems-studio/host'])).toHaveLength(3);
328
+ });
329
+
330
+ test('is a no-op when only one side is present', () => {
331
+ expect(mergeElements(set, ['nope/nothing', 'site/web'])).toHaveLength(3);
332
+ });
333
+
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);
350
+ });
351
+
352
+ test('does not touch the input set', () => {
353
+ mergeElements(set, ['subsystems-studio/host', 'principal-studio/host']);
354
+ expect(set).toHaveLength(3);
355
+ });
356
+
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);
360
+ });
361
+
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',
370
+ });
371
+ });
372
+
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');
385
+ });
386
+ });