@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
@@ -1,161 +0,0 @@
1
- /**
2
- * Regenerate BOTH C4 fixtures from one composition of the live store, so the
3
- * document and its association set can never drift apart.
4
- *
5
- * Run from packages/subsystems-react:
6
- * bun run src/stories/Subsystem/C4Graph/generateFixture.ts
7
- *
8
- * The two files it writes are paired:
9
- * c4Fixture.ts the composed document the graph renders
10
- * c4Associations.fixture.ts proposed associations keyed off that document
11
- *
12
- * They must be generated together. A document from one snapshot and an
13
- * association set from another produces merges that silently do nothing.
14
- */
15
- import * as fs from 'node:fs';
16
- import * as os from 'node:os';
17
- import * as path from 'node:path';
18
- import { toC4 } from '../../../subsystem/toC4';
19
- import { buildAssociations, deriveConcerns, suggestMerges } from '../../../subsystem/c4Associations';
20
- import type { SubsystemModelDocument, SubsystemComponent } from '../../../subsystem/model';
21
-
22
- const REPO = 'pkg:github/principal-ai/subsystem-modeling';
23
- const SYSTEM_LABEL = 'Subsystem Modeling';
24
- const dir = path.join(os.homedir(), '.principal/subsystem-models');
25
- const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && f !== '_index.json');
26
-
27
- /**
28
- * Compose every stored model for one repo key into a single document.
29
- *
30
- * One canonical component per (purl#symbol); aliases rebased to `c0..cn` so
31
- * two models cannot collide in one alias space.
32
- */
33
- const byId = new Map<string, SubsystemComponent>();
34
- const steps: NonNullable<SubsystemModelDocument['trails']>[number]['steps'] = [];
35
- let n = 0;
36
-
37
- for (const f of files) {
38
- const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as SubsystemModelDocument;
39
- const aliasMap = new Map<string, string>();
40
- for (const c of d.components ?? []) {
41
- if ((c.purl ?? '').split('#')[0] !== REPO) continue;
42
- const key = `${c.purl!.split('#')[1] ?? ''}::${c.symbol ?? c.alias}`;
43
- if (!byId.has(key)) byId.set(key, { ...c, alias: `c${n++}` });
44
- aliasMap.set(c.alias, key);
45
- }
46
- for (const t of d.trails ?? []) {
47
- for (const s of t.steps ?? []) {
48
- const a = aliasMap.get(s.from);
49
- const b = aliasMap.get(s.to);
50
- if (!a || !b || a === b) continue;
51
- steps.push({
52
- from: byId.get(a)!.alias,
53
- to: byId.get(b)!.alias,
54
- mechanism: s.mechanism,
55
- // Keep the site so the fixture is a faithful document, not a shape.
56
- file: s.file,
57
- line: s.line,
58
- purl: s.purl,
59
- symbol: s.symbol,
60
- ...(s.annotation ? { annotation: s.annotation } : {}),
61
- });
62
- }
63
- }
64
- }
65
-
66
- // The React-local document type carries components + trails only; the store
67
- // record wraps it with title/description.
68
- const document: SubsystemModelDocument = {
69
- components: [...byId.values()],
70
- trails: [{ id: 'all', title: 'all trails', steps }],
71
- };
72
-
73
- // --- Roll up once, then derive concerns off the same rollup -------------
74
- const raw = toC4(document, { view: 'container', systemLabel: SYSTEM_LABEL });
75
-
76
- const memberOf = new Map<string, SubsystemComponent>();
77
- for (const c of byId.values()) memberOf.set(c.alias, c);
78
-
79
- const rollup = raw.nodes
80
- .filter((x) => x.kind === 'container')
81
- .map((x) => {
82
- const members = x.members.map((a) => memberOf.get(a)!).filter(Boolean);
83
- const frameworks = [...new Set(members.map((m) => m.framework).filter(Boolean) as string[])];
84
- return { key: x.key ?? x.id, members, frameworks };
85
- });
86
-
87
- const set = buildAssociations({ repoKey: REPO, rollup, author: 'c4-proposer' });
88
- const merges = suggestMerges(rollup.map((r) => ({ key: r.key, memberCount: r.members.length })));
89
- const concerns = rollup.flatMap((r) => deriveConcerns(r.key, r.members, { frameworks: r.frameworks }));
90
-
91
- /** Every derived key, so a story can assert nothing was invented. */
92
- const derivedKeys = rollup.map((r) => r.key).sort();
93
-
94
- /** Components behind each derived key — the evidence that picks a merge survivor. */
95
- const memberCounts = Object.fromEntries(
96
- rollup.map((r) => [r.key, r.members.length]).sort(([a], [b]) => String(a).localeCompare(String(b))),
97
- );
98
-
99
- // --- Write the document ------------------------------------------------
100
- const docSrc = `// GENERATED from live store data by generateFixture.ts — do not hand-edit.
101
- //
102
- // Composite of ${files.length} stored subsystem models for ${REPO},
103
- // joined on purl#symbol (one canonical component per real declaration).
104
- //
105
- // Regenerate together with c4Associations.fixture.ts — the association set is
106
- // keyed off the container keys this document derives. Generating them
107
- // separately produces merges that silently do nothing.
108
-
109
- import type { SubsystemModelDocument } from "../../../subsystem/model";
110
-
111
- /** Container keys this document is expected to derive. */
112
- export const c4DerivedKeys = ${JSON.stringify(derivedKeys, null, 2)} as const;
113
-
114
- /** Components behind each derived key — decides which side of a merge survives. */
115
- export const c4MemberCounts: Record<string, number> = ${JSON.stringify(memberCounts, null, 2)};
116
-
117
- export const c4FixtureModel: SubsystemModelDocument = ${JSON.stringify(document, null, 1)};
118
- `;
119
- fs.writeFileSync(path.join(__dirname, 'c4Fixture.ts'), docSrc);
120
-
121
- // --- Write the associations -------------------------------------------
122
- const assocSrc = `// GENERATED from live store data by generateFixture.ts — do not hand-edit.
123
- //
124
- // Derived from the SAME composition as c4Fixture.ts: ${byId.size} components
125
- // → ${rollup.length} container keys → ${set.associations.length} associations.
126
- //
127
- // Every association is state:'proposed'. None of it is accepted — accepting is
128
- // a human's decision, and this story exists to make that decision visible.
129
-
130
- import type { C4Association } from "../../../subsystem/toC4";
131
-
132
- /** Container keys whose trailing role matches — candidates for a merge. */
133
- export const C4_MERGE_CANDIDATES = ${JSON.stringify(merges, null, 2)} as const;
134
-
135
- /** Every mechanical concern, for the review panel. */
136
- export const C4_CONCERNS = ${JSON.stringify(concerns, null, 2)} as const;
137
-
138
- export const c4Associations: C4Association[] = ${JSON.stringify(set.associations, null, 2)};
139
- `;
140
- fs.writeFileSync(path.join(__dirname, 'c4Associations.fixture.ts'), assocSrc);
141
-
142
- // --- Verify the pair actually agrees ----------------------------------
143
- const reDerived = toC4(
144
- JSON.parse(JSON.stringify(document)),
145
- { view: 'container', systemLabel: SYSTEM_LABEL, associations: set.associations },
146
- );
147
- const claimed = new Set(set.associations.flatMap((a) => a.sourceKeys));
148
- const orphans = derivedKeys.filter((k) => !claimed.has(k));
149
-
150
- console.log('wrote c4Fixture.ts + c4Associations.fixture.ts');
151
- console.log(' models composed :', files.length);
152
- console.log(' components :', byId.size);
153
- console.log(' container keys :', rollup.length);
154
- console.log(' associations :', set.associations.length);
155
- console.log(' merge candidates:', merges.length);
156
- console.log(' concerns :', concerns.length);
157
- console.log(' types :', JSON.stringify(
158
- set.associations.reduce<Record<string, number>>((a, x) => ({ ...a, [x.type]: (a[x.type] ?? 0) + 1 }), {}),
159
- ));
160
- console.log(' containers drawn:', reDerived.nodes.filter((x) => x.kind === 'container').length);
161
- if (orphans.length > 0) console.warn(' !! derived keys with no association:', orphans);
@@ -1,484 +0,0 @@
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
- });