@principal-ai/subsystems-react 0.49.0 → 0.51.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/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreFileView.d.ts +1 -1
- package/dist/pierre/PierreFileView.d.ts.map +1 -1
- package/dist/pierre/PierreFileView.js +2 -1
- package/dist/pierre/PierreFileView.js.map +1 -1
- package/dist/pierre/PierreSnippetView.d.ts +1 -1
- package/dist/pierre/PierreSnippetView.d.ts.map +1 -1
- package/dist/pierre/PierreSnippetView.js +2 -1
- package/dist/pierre/PierreSnippetView.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +7 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +71 -18
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/pierre/pierreBackground.d.ts +36 -2
- package/dist/pierre/pierreBackground.d.ts.map +1 -1
- package/dist/pierre/pierreBackground.js +40 -13
- package/dist/pierre/pierreBackground.js.map +1 -1
- package/dist/pierre/visibleStep.d.ts +48 -0
- package/dist/pierre/visibleStep.d.ts.map +1 -0
- package/dist/pierre/visibleStep.js +53 -0
- package/dist/pierre/visibleStep.js.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +95 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +340 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +4 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +7334 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +2 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.js +145 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +16 -60
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +84 -0
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
- package/dist/subsystem/C4NodeCard.js +229 -0
- package/dist/subsystem/C4NodeCard.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +40 -6
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts +10 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +61 -40
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +6 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +21 -10
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4Associations.d.ts +186 -0
- package/dist/subsystem/c4Associations.d.ts.map +1 -0
- package/dist/subsystem/c4Associations.js +284 -0
- package/dist/subsystem/c4Associations.js.map +1 -0
- package/dist/subsystem/model.d.ts +25 -1
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +15 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +78 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/toC4.d.ts +119 -0
- package/dist/subsystem/toC4.d.ts.map +1 -1
- package/dist/subsystem/toC4.js +95 -5
- package/dist/subsystem/toC4.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +39 -0
- package/src/pierre/PierreFileView.tsx +2 -1
- package/src/pierre/PierreSnippetView.tsx +2 -1
- package/src/pierre/PierreTrailCodeView.tsx +81 -22
- package/src/pierre/pierreBackground.ts +62 -12
- package/src/pierre/visibleStep.test.ts +108 -0
- package/src/pierre/visibleStep.ts +76 -0
- package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +273 -0
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +325 -0
- package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +344 -0
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +7337 -4
- package/src/stories/Subsystem/C4Graph/generateFixture.ts +161 -0
- package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
- package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
- package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +10 -4
- package/src/subsystem/C4Graph.tsx +51 -92
- package/src/subsystem/C4NodeCard.test.ts +196 -0
- package/src/subsystem/C4NodeCard.tsx +307 -0
- package/src/subsystem/DiagnosticToggle.tsx +58 -4
- package/src/subsystem/FileDrawer.tsx +112 -63
- package/src/subsystem/SubsystemComponentGraph.tsx +29 -5
- package/src/subsystem/c4Associations.test.ts +484 -0
- package/src/subsystem/c4Associations.ts +398 -0
- package/src/subsystem/model.ts +34 -1
- package/src/subsystem/nodes.tsx +124 -5
- package/src/subsystem/toC4.ts +210 -5
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { toC4, indexAssociations } from './toC4';
|
|
3
|
+
import type { C4Association, C4AssociationState, C4ElementType } from './toC4';
|
|
4
|
+
import { deriveConcerns, suggestMerges, buildAssociations, mergeAssociations, summarizeAssociations } from './c4Associations';
|
|
5
|
+
import type { SubsystemModelDocument } from './model';
|
|
6
|
+
|
|
7
|
+
const REPO = 'pkg:github/acme/repo';
|
|
8
|
+
|
|
9
|
+
function assoc(over: Partial<C4Association> & { id: string; sourceKeys: string[] }): C4Association {
|
|
10
|
+
return {
|
|
11
|
+
level: 'container',
|
|
12
|
+
label: over.id,
|
|
13
|
+
type: 'application' as C4ElementType,
|
|
14
|
+
state: 'proposed' as C4AssociationState,
|
|
15
|
+
...over,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const doc: SubsystemModelDocument = {
|
|
20
|
+
title: 'test',
|
|
21
|
+
components: [
|
|
22
|
+
{ alias: 'a', name: 'Alpha', construct: 'function', file: 'src/a.ts', purl: `${REPO}#src/a.ts`, process: 'p1', framework: 'react' },
|
|
23
|
+
{ alias: 'b', name: 'Beta', construct: 'function', file: 'src/b.ts', purl: `${REPO}#src/b.ts`, process: 'p1', framework: 'react' },
|
|
24
|
+
{ alias: 's', name: 'Cache', construct: 'store', file: 'src/s.ts', purl: `${REPO}#src/s.ts`, process: 'p1', declaration: { kind: 'store', properties: [] } },
|
|
25
|
+
{ alias: 'c', name: 'Gamma', construct: 'function', file: 'src/c.ts', purl: `${REPO}#src/c.ts`, process: 'p2' },
|
|
26
|
+
{ alias: 'd', name: 'Delta', construct: 'function', file: 'src/d.ts', purl: `${REPO}#src/d.ts`, process: 'p3' },
|
|
27
|
+
{ alias: 'lib', name: 'Lib', construct: 'interface', file: 'src/lib.ts', purl: `${REPO}#src/lib.ts`, process: 'plib' },
|
|
28
|
+
{ alias: 'u', name: 'Orphan', construct: 'function', file: 'src/u.ts', purl: `${REPO}#src/u.ts` },
|
|
29
|
+
],
|
|
30
|
+
trails: [
|
|
31
|
+
{
|
|
32
|
+
id: 'w1',
|
|
33
|
+
title: 'flow',
|
|
34
|
+
steps: [
|
|
35
|
+
{ from: 'a', to: 'b', mechanism: 'calls', file: 'src/a.ts', line: 1, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
|
|
36
|
+
{ from: 'b', to: 'c', mechanism: 'calls', file: 'src/b.ts', line: 1, purl: `${REPO}#src/b.ts`, symbol: 'Beta' },
|
|
37
|
+
{ from: 'a', to: 'c', mechanism: 'calls', file: 'src/a.ts', line: 2, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
|
|
38
|
+
{ from: 'c', to: 'd', mechanism: 'calls', file: 'src/c.ts', line: 1, purl: `${REPO}#src/c.ts`, symbol: 'Gamma' },
|
|
39
|
+
{ from: 'd', to: 'u', mechanism: 'calls', file: 'src/d.ts', line: 1, purl: `${REPO}#src/d.ts`, symbol: 'Delta' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('indexAssociations', () => {
|
|
46
|
+
test('is empty for undefined input', () => {
|
|
47
|
+
expect(indexAssociations(undefined).byKey.size).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('maps every claimed source key to its association', () => {
|
|
51
|
+
const { byKey } = indexAssociations([assoc({ id: 'container:x', sourceKeys: ['p1', 'p1b'] })]);
|
|
52
|
+
expect(byKey.get('p1')?.id).toBe('container:x');
|
|
53
|
+
expect(byKey.get('p1b')?.id).toBe('container:x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('accepted outranks proposed for the same key', () => {
|
|
57
|
+
const { byKey } = indexAssociations([
|
|
58
|
+
assoc({ id: 'container:guess', sourceKeys: ['p1'], state: 'proposed' }),
|
|
59
|
+
assoc({ id: 'container:sure', sourceKeys: ['p1'], state: 'accepted' }),
|
|
60
|
+
]);
|
|
61
|
+
expect(byKey.get('p1')?.id).toBe('container:sure');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('reports a contested key rather than silently picking one', () => {
|
|
65
|
+
const { byKey, contested } = indexAssociations([
|
|
66
|
+
assoc({ id: 'container:a', sourceKeys: ['p1'], state: 'accepted' }),
|
|
67
|
+
assoc({ id: 'container:b', sourceKeys: ['p1'], state: 'accepted' }),
|
|
68
|
+
]);
|
|
69
|
+
expect(byKey.get('p1')?.id).toBe('container:a');
|
|
70
|
+
expect(contested).toEqual([{ key: 'p1', kept: 'container:a', dropped: 'container:b' }]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('merging (the dedup mechanism)', () => {
|
|
75
|
+
test('two process keys claimed by one id collapse into one box', () => {
|
|
76
|
+
const model = toC4(doc, {
|
|
77
|
+
view: 'container',
|
|
78
|
+
associations: [assoc({ id: 'container:studio-host', sourceKeys: ['p1', 'p2'], state: 'accepted', label: 'Studio Host' })],
|
|
79
|
+
});
|
|
80
|
+
const host = model.nodes.find((n) => n.id === 'container:studio-host');
|
|
81
|
+
expect(host).toBeDefined();
|
|
82
|
+
expect(host!.members.sort()).toEqual(['a', 'b', 'c', 's']);
|
|
83
|
+
expect(model.nodes.filter((n) => n.kind === 'container').map((n) => n.id)).not.toContain('container:p1');
|
|
84
|
+
expect(model.nodes.filter((n) => n.kind === 'container').map((n) => n.id)).not.toContain('container:p2');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the merged box records every key it absorbed, once each', () => {
|
|
88
|
+
const model = toC4(doc, {
|
|
89
|
+
associations: [assoc({ id: 'container:studio-host', sourceKeys: ['p1', 'p2'], state: 'accepted', label: 'Studio Host' })],
|
|
90
|
+
});
|
|
91
|
+
const host = model.nodes.find((n) => n.id === 'container:studio-host')!;
|
|
92
|
+
expect(host.sourceKeys).toEqual(['p1', 'p2']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a single-key node carries no sourceKeys list', () => {
|
|
96
|
+
const model = toC4(doc, {
|
|
97
|
+
associations: [assoc({ id: 'container:p1', sourceKeys: ['p1'], state: 'accepted' })],
|
|
98
|
+
});
|
|
99
|
+
expect(model.nodes.find((n) => n.id === 'container:p1')!.sourceKeys).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('edges fold into the merged box and count once', () => {
|
|
103
|
+
const model = toC4(doc, {
|
|
104
|
+
associations: [assoc({ id: 'container:studio-host', sourceKeys: ['p1', 'p2'], state: 'accepted' })],
|
|
105
|
+
});
|
|
106
|
+
// a->b was intra-p1 (dropped); a->c + b->c were p1->p2 and now intra-box.
|
|
107
|
+
expect(model.edges.some((e) => e.source === e.target)).toBe(false);
|
|
108
|
+
// c->d is now studio-host -> p3, exactly one edge.
|
|
109
|
+
const e = model.edges.find((x) => x.source === 'container:studio-host' && x.target === 'container:p3');
|
|
110
|
+
expect(e?.count).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('unclaimed keys still render, so the projection works with zero associations', () => {
|
|
114
|
+
const model = toC4(doc, { view: 'container' });
|
|
115
|
+
expect(model.nodes.map((n) => n.id).sort()).toContain('container:p1');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('rejection', () => {
|
|
120
|
+
test('a rejected container is not drawn', () => {
|
|
121
|
+
const model = toC4(doc, {
|
|
122
|
+
associations: [assoc({ id: 'container:singleton', sourceKeys: ['p3'], state: 'rejected' })],
|
|
123
|
+
});
|
|
124
|
+
expect(model.nodes.find((n) => n.id === 'container:p3')).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('edges to a rejected container are dropped, not left dangling', () => {
|
|
128
|
+
const model = toC4(doc, {
|
|
129
|
+
associations: [assoc({ id: 'container:singleton', sourceKeys: ['p3'], state: 'rejected' })],
|
|
130
|
+
});
|
|
131
|
+
expect(model.edges.some((e) => e.target === 'container:p3' || e.source === 'container:p3')).toBe(false);
|
|
132
|
+
for (const e of model.edges) {
|
|
133
|
+
expect(model.nodes.some((n) => n.id === e.source)).toBe(true);
|
|
134
|
+
expect(model.nodes.some((n) => n.id === e.target)).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the system group does not list a rejected container', () => {
|
|
139
|
+
const model = toC4(doc, {
|
|
140
|
+
associations: [assoc({ id: 'container:singleton', sourceKeys: ['p3'], state: 'rejected' })],
|
|
141
|
+
});
|
|
142
|
+
const sys = model.groups.find((g) => g.kind === 'system')!;
|
|
143
|
+
expect(sys.memberIds).not.toContain('container:p3');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('decoration', () => {
|
|
148
|
+
test('carries the confirmed C4 attributes onto the node', () => {
|
|
149
|
+
const model = toC4(doc, {
|
|
150
|
+
associations: [
|
|
151
|
+
assoc({
|
|
152
|
+
id: 'container:p1',
|
|
153
|
+
sourceKeys: ['p1'],
|
|
154
|
+
state: 'accepted',
|
|
155
|
+
label: 'Studio Host',
|
|
156
|
+
type: 'application',
|
|
157
|
+
technology: 'Bun + Electrobun',
|
|
158
|
+
description: 'Runs the audit pipeline and serves the model store.',
|
|
159
|
+
}),
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
const n = model.nodes.find((x) => x.id === 'container:p1')!;
|
|
163
|
+
expect(n.label).toBe('Studio Host');
|
|
164
|
+
expect(n.decoration?.type).toBe('application');
|
|
165
|
+
expect(n.decoration?.technology).toBe('Bun + Electrobun');
|
|
166
|
+
expect(n.decoration?.state).toBe('accepted');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a proposed association still draws, marked as unconfirmed', () => {
|
|
170
|
+
const model = toC4(doc, {
|
|
171
|
+
associations: [assoc({ id: 'container:p1', sourceKeys: ['p1'], state: 'proposed', technology: 'React' })],
|
|
172
|
+
});
|
|
173
|
+
const n = model.nodes.find((x) => x.id === 'container:p1')!;
|
|
174
|
+
expect(n.decoration?.state).toBe('proposed');
|
|
175
|
+
expect(n.decoration?.technology).toBe('React');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('in the component view, children nest under the merged container', () => {
|
|
179
|
+
const model = toC4(doc, {
|
|
180
|
+
view: 'component',
|
|
181
|
+
associations: [assoc({ id: 'container:studio-host', sourceKeys: ['p1', 'p2'], state: 'accepted', label: 'Studio Host' })],
|
|
182
|
+
});
|
|
183
|
+
const a = model.nodes.find((n) => n.id === 'component:a')!;
|
|
184
|
+
const c = model.nodes.find((n) => n.id === 'component:c')!;
|
|
185
|
+
expect(a.parentId).toBe('container:studio-host');
|
|
186
|
+
expect(c.parentId).toBe('container:studio-host');
|
|
187
|
+
const frame = model.groups.find((g) => g.id === 'container:studio-host')!;
|
|
188
|
+
expect(frame.label).toBe('Studio Host');
|
|
189
|
+
expect(frame.memberIds.sort()).toEqual(['component:a', 'component:b', 'component:c', 'component:s']);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('deriveConcerns', () => {
|
|
194
|
+
test('flags a lone member as a singleton', () => {
|
|
195
|
+
const out = deriveConcerns('graphify/extract', [
|
|
196
|
+
{ alias: 'x', construct: 'function', purl: `${REPO}#x.ts` },
|
|
197
|
+
]);
|
|
198
|
+
expect(out.some((c) => c.kind === 'singleton')).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('flags a memberless-of-runtime group as library-shaped', () => {
|
|
202
|
+
const out = deriveConcerns(
|
|
203
|
+
'subsystems-core',
|
|
204
|
+
[
|
|
205
|
+
{ alias: 'a', construct: 'interface', purl: `${REPO}#a.ts` },
|
|
206
|
+
{ alias: 'b', construct: 'type_alias', purl: `${REPO}#b.ts` },
|
|
207
|
+
],
|
|
208
|
+
{ frameworks: [] },
|
|
209
|
+
);
|
|
210
|
+
expect(out.some((c) => c.kind === 'library_shaped')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('flags a runtime container with no technology', () => {
|
|
214
|
+
const out = deriveConcerns('p2', [{ alias: 'c', construct: 'function', purl: `${REPO}#c.ts` }], { frameworks: [] });
|
|
215
|
+
expect(out.some((c) => c.kind === 'missing_technology')).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('does not demand technology once a framework is present', () => {
|
|
219
|
+
const out = deriveConcerns('p1', [
|
|
220
|
+
{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` },
|
|
221
|
+
{ alias: 'b', construct: 'function', purl: `${REPO}#b.ts` },
|
|
222
|
+
], { frameworks: ['react'] });
|
|
223
|
+
expect(out.some((c) => c.kind === 'missing_technology')).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('flags members spanning two repo keys', () => {
|
|
227
|
+
const out = deriveConcerns('p1', [
|
|
228
|
+
{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` },
|
|
229
|
+
{ alias: 'z', construct: 'function', purl: 'pkg:github/other/thing#z.ts' },
|
|
230
|
+
]);
|
|
231
|
+
expect(out.some((c) => c.kind === 'spans_repos')).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('flags a store with no storage kind', () => {
|
|
235
|
+
const out = deriveConcerns('p1', [
|
|
236
|
+
{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` },
|
|
237
|
+
{ alias: 's', construct: 'store', purl: `${REPO}#s.ts`, declaration: { kind: 'store', properties: [] } },
|
|
238
|
+
]);
|
|
239
|
+
expect(out.some((c) => c.kind === 'store_unclassified')).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('a healthy container raises nothing', () => {
|
|
243
|
+
const out = deriveConcerns(
|
|
244
|
+
'subsystems-studio/host',
|
|
245
|
+
[
|
|
246
|
+
{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` },
|
|
247
|
+
{ alias: 'b', construct: 'function', purl: `${REPO}#b.ts` },
|
|
248
|
+
{ alias: 's', construct: 'store', purl: `${REPO}#s.ts`, declaration: { kind: 'store', storage: 'memory', properties: [] } },
|
|
249
|
+
],
|
|
250
|
+
{ frameworks: ['bun'] },
|
|
251
|
+
);
|
|
252
|
+
expect(out).toHaveLength(0);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('suggestMerges', () => {
|
|
257
|
+
test('groups keys that share a trailing role', () => {
|
|
258
|
+
const out = suggestMerges([
|
|
259
|
+
{ key: 'subsystems-studio/host', memberCount: 9 },
|
|
260
|
+
{ key: 'principal-studio/host', memberCount: 1 },
|
|
261
|
+
{ key: 'subsystems-studio/renderer', memberCount: 5 },
|
|
262
|
+
]);
|
|
263
|
+
expect(out).toHaveLength(1);
|
|
264
|
+
expect(out[0].role).toBe('host');
|
|
265
|
+
expect(out[0].keys).toEqual(['principal-studio/host', 'subsystems-studio/host']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('ignores a bare key that has no app/role shape', () => {
|
|
269
|
+
const out = suggestMerges([{ key: 'subsystems-react', memberCount: 2 }, { key: 'subsystems-core', memberCount: 1 }]);
|
|
270
|
+
expect(out).toHaveLength(0);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('buildAssociations', () => {
|
|
275
|
+
const rollup = [
|
|
276
|
+
{
|
|
277
|
+
key: 'subsystems-studio/host',
|
|
278
|
+
frameworks: ['bun'],
|
|
279
|
+
members: [
|
|
280
|
+
{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` },
|
|
281
|
+
{ alias: 's', construct: 'store', purl: `${REPO}#s.ts`, declaration: { kind: 'store', storage: 'disk', properties: [] } },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
{ key: 'subsystems-core', frameworks: [], members: [{ alias: 'i', construct: 'interface', purl: `${REPO}#i.ts` }] },
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
test('proposes one association per derived key, never accepted', () => {
|
|
288
|
+
const set = buildAssociations({ repoKey: REPO, rollup });
|
|
289
|
+
expect(set.associations).toHaveLength(2);
|
|
290
|
+
expect(set.associations.every((a) => a.state === 'proposed')).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('infers a data store when a member declares disk storage', () => {
|
|
294
|
+
const set = buildAssociations({ repoKey: REPO, rollup });
|
|
295
|
+
const host = set.associations.find((a) => a.sourceKeys.includes('subsystems-studio/host'))!;
|
|
296
|
+
expect(host.type).toBe('data-store');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('infers a library for a memberless-of-runtime group', () => {
|
|
300
|
+
const set = buildAssociations({ repoKey: REPO, rollup });
|
|
301
|
+
const core = set.associations.find((a) => a.sourceKeys.includes('subsystems-core'))!;
|
|
302
|
+
expect(core.type).toBe('library');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('carries rationale so a reviewer sees why it was proposed', () => {
|
|
306
|
+
const set = buildAssociations({ repoKey: REPO, rollup });
|
|
307
|
+
const core = set.associations.find((a) => a.sourceKeys.includes('subsystems-core'))!;
|
|
308
|
+
expect(core.rationale).toContain('library');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('folds merged keys under one id without duplicating the element', () => {
|
|
312
|
+
const set = buildAssociations({
|
|
313
|
+
repoKey: REPO,
|
|
314
|
+
rollup: [
|
|
315
|
+
...rollup,
|
|
316
|
+
{ key: 'principal-studio/host', frameworks: ['bun'], members: [{ alias: 'z', construct: 'function', purl: `${REPO}#z.ts` }] },
|
|
317
|
+
],
|
|
318
|
+
merges: [{ id: 'container:studio-host', keys: ['subsystems-studio/host', 'principal-studio/host'] }],
|
|
319
|
+
});
|
|
320
|
+
const hosts = set.associations.filter((a) => a.id === 'container:studio-host');
|
|
321
|
+
expect(hosts).toHaveLength(1);
|
|
322
|
+
expect(hosts[0].sourceKeys.sort()).toEqual(['principal-studio/host', 'subsystems-studio/host']);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the built set, fed back to toC4, merges the graph', () => {
|
|
326
|
+
const set = buildAssociations({
|
|
327
|
+
repoKey: REPO,
|
|
328
|
+
rollup: [
|
|
329
|
+
{ key: 'p1', frameworks: ['react'], members: [{ alias: 'a', construct: 'function', purl: `${REPO}#a.ts` }] },
|
|
330
|
+
{ key: 'p2', frameworks: ['react'], members: [{ alias: 'c', construct: 'function', purl: `${REPO}#c.ts` }] },
|
|
331
|
+
],
|
|
332
|
+
merges: [{ id: 'container:studio-host', keys: ['p1', 'p2'] }],
|
|
333
|
+
});
|
|
334
|
+
const model = toC4(doc, { associations: set.associations });
|
|
335
|
+
const host = model.nodes.find((n) => n.id === 'container:studio-host');
|
|
336
|
+
expect(host).toBeDefined();
|
|
337
|
+
// p1 holds a, b and the store s; p2 holds c. The merge takes them all.
|
|
338
|
+
expect(host!.members.sort()).toEqual(['a', 'b', 'c', 's']);
|
|
339
|
+
// Only the rolled-up keys are claimed. Keys the agent never looked at
|
|
340
|
+
// still render as raw derived boxes — a partial association set must
|
|
341
|
+
// never silently drop part of the architecture.
|
|
342
|
+
expect(model.nodes.find((n) => n.id === 'container:p1')).toBeUndefined();
|
|
343
|
+
expect(model.nodes.find((n) => n.id === 'container:p2')).toBeUndefined();
|
|
344
|
+
expect(model.nodes.find((n) => n.id === 'container:p3')).toBeDefined();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('mergeAssociations', () => {
|
|
349
|
+
// Keys that appear in the shared `doc` fixture above, so the collapse can be
|
|
350
|
+
// checked against a real projection rather than only a reshaped array.
|
|
351
|
+
const set: C4Association[] = [
|
|
352
|
+
assoc({ id: 'container:p1', sourceKeys: ['p1'] }),
|
|
353
|
+
assoc({ id: 'container:p2', sourceKeys: ['p2'] }),
|
|
354
|
+
assoc({ id: 'container:p3', sourceKeys: ['p3'] }),
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
test('folds the named keys into the survivor as one association', () => {
|
|
358
|
+
const out = mergeAssociations(set, ['p1', 'p2']);
|
|
359
|
+
expect(out).toHaveLength(2);
|
|
360
|
+
expect(out.find((a) => a.sourceKeys.includes('p1'))!.sourceKeys.sort()).toEqual(['p1', 'p2']);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('the survivor keeps its id, so the box does not get a new identity', () => {
|
|
364
|
+
const out = mergeAssociations(set, ['p1', 'p2']);
|
|
365
|
+
expect(out.some((a) => a.id === 'container:p1')).toBe(true);
|
|
366
|
+
expect(out.some((a) => a.id === 'container:p2')).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('keeps source keys as keys — never rewrites one to an element id', () => {
|
|
370
|
+
const out = mergeAssociations(set, ['p1', 'p2']);
|
|
371
|
+
const host = out.find((a) => a.sourceKeys.includes('p1'))!;
|
|
372
|
+
expect(host.sourceKeys.every((k) => !k.startsWith('container:'))).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('a merge with nothing to absorb is a no-op', () => {
|
|
376
|
+
expect(mergeAssociations(set, ['p1'])).toHaveLength(3);
|
|
377
|
+
expect(mergeAssociations(set, ['p1', 'p1'])).toHaveLength(3);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('is a no-op when only one side is present in the set', () => {
|
|
381
|
+
expect(mergeAssociations(set, ['nope/nothing', 'p2'])).toHaveLength(3);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('the side carrying the most keys survives by default', () => {
|
|
385
|
+
const wide: C4Association[] = [
|
|
386
|
+
assoc({ id: 'container:small', sourceKeys: ['app/host'] }),
|
|
387
|
+
assoc({ id: 'container:big', sourceKeys: ['studio/host', 'other/host'] }),
|
|
388
|
+
];
|
|
389
|
+
const out = mergeAssociations(wide, ['app/host', 'studio/host', 'other/host']);
|
|
390
|
+
expect(out).toHaveLength(1);
|
|
391
|
+
expect(out[0].id).toBe('container:big');
|
|
392
|
+
expect(out[0].sourceKeys).toHaveLength(3);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('member count breaks the tie when each side claims one key', () => {
|
|
396
|
+
// The real case: both host processes hold a single derived key, so key
|
|
397
|
+
// count cannot decide. The 113-component one must survive.
|
|
398
|
+
const hosts: C4Association[] = [
|
|
399
|
+
assoc({ id: 'container:principal-studio/host', sourceKeys: ['principal-studio/host'] }),
|
|
400
|
+
assoc({ id: 'container:subsystems-studio/host', sourceKeys: ['subsystems-studio/host'] }),
|
|
401
|
+
];
|
|
402
|
+
const counts = new Map([
|
|
403
|
+
['principal-studio/host', 1],
|
|
404
|
+
['subsystems-studio/host', 113],
|
|
405
|
+
]);
|
|
406
|
+
const out = mergeAssociations(hosts, ['principal-studio/host', 'subsystems-studio/host'], undefined, counts);
|
|
407
|
+
expect(out).toHaveLength(1);
|
|
408
|
+
expect(out[0].id).toBe('container:subsystems-studio/host');
|
|
409
|
+
expect(out[0].sourceKeys).toHaveLength(2);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('without member counts the tie falls back to a stable pick', () => {
|
|
413
|
+
const hosts: C4Association[] = [
|
|
414
|
+
assoc({ id: 'container:principal-studio/host', sourceKeys: ['principal-studio/host'] }),
|
|
415
|
+
assoc({ id: 'container:subsystems-studio/host', sourceKeys: ['subsystems-studio/host'] }),
|
|
416
|
+
];
|
|
417
|
+
const out = mergeAssociations(hosts, ['principal-studio/host', 'subsystems-studio/host']);
|
|
418
|
+
// Deterministic even if it is not the obvious choice.
|
|
419
|
+
expect(out).toHaveLength(1);
|
|
420
|
+
expect(['container:principal-studio/host', 'container:subsystems-studio/host']).toContain(out[0].id);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('a caller can still override which side survives', () => {
|
|
424
|
+
const wide: C4Association[] = [
|
|
425
|
+
assoc({ id: 'container:small', sourceKeys: ['app/host'] }),
|
|
426
|
+
assoc({ id: 'container:big', sourceKeys: ['studio/host', 'other/host'] }),
|
|
427
|
+
];
|
|
428
|
+
const out = mergeAssociations(wide, ['app/host', 'studio/host', 'other/host'], 'app/host');
|
|
429
|
+
expect(out[0].id).toBe('container:small');
|
|
430
|
+
expect(out[0].sourceKeys).toHaveLength(3);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('leaves unrelated associations alone', () => {
|
|
434
|
+
const out = mergeAssociations(set, ['p1', 'p2']);
|
|
435
|
+
expect(out.some((a) => a.sourceKeys.includes('p3'))).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('does not touch the input set', () => {
|
|
439
|
+
const before = set.length;
|
|
440
|
+
mergeAssociations(set, ['p1', 'p2']);
|
|
441
|
+
expect(set).toHaveLength(before);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('the merged set actually collapses the graph', () => {
|
|
445
|
+
const out = mergeAssociations(set, ['p1', 'p2']);
|
|
446
|
+
const model = toC4(doc, { associations: out });
|
|
447
|
+
expect(model.nodes.find((n) => n.id === 'container:p1')).toBeDefined();
|
|
448
|
+
expect(model.nodes.find((n) => n.id === 'container:p2')).toBeUndefined();
|
|
449
|
+
// p1 held a, b and the store s; p2 held c. One box now.
|
|
450
|
+
expect(model.nodes.find((n) => n.id === 'container:p1')!.members.sort()).toEqual(['a', 'b', 'c', 's']);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('is idempotent — merging again changes nothing', () => {
|
|
454
|
+
const once = mergeAssociations(set, ['p1', 'p2']);
|
|
455
|
+
expect(mergeAssociations(once, ['p1', 'p2'])).toEqual(once);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('summarizeAssociations', () => {
|
|
460
|
+
test('counts confirmed, proposed, and unconfirmed derived keys', () => {
|
|
461
|
+
const model = toC4(doc, { view: 'container' });
|
|
462
|
+
const s = summarizeAssociations(model, [
|
|
463
|
+
assoc({ id: 'container:p1', sourceKeys: ['p1'], state: 'accepted', technology: 'React' }),
|
|
464
|
+
assoc({ id: 'container:p2', sourceKeys: ['p2'], state: 'proposed', technology: 'React' }),
|
|
465
|
+
]);
|
|
466
|
+
expect(s.confirmed).toBe(1);
|
|
467
|
+
expect(s.proposed).toBe(1);
|
|
468
|
+
expect(s.needsDecision).toBeGreaterThan(0);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('counts how many keys a merge absorbed', () => {
|
|
472
|
+
const model = toC4(doc, { view: 'container' });
|
|
473
|
+
const s = summarizeAssociations(model, [
|
|
474
|
+
assoc({ id: 'container:host', sourceKeys: ['p1', 'p2', 'p3'], state: 'accepted', technology: 'X' }),
|
|
475
|
+
]);
|
|
476
|
+
expect(s.mergedAway).toBe(2);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('counts containers still missing a technology', () => {
|
|
480
|
+
const model = toC4(doc, { view: 'container' });
|
|
481
|
+
const s = summarizeAssociations(model, []);
|
|
482
|
+
expect(s.missingTechnology).toBe(model.nodes.filter((n) => n.kind === 'container').length);
|
|
483
|
+
});
|
|
484
|
+
});
|