@principal-ai/principal-view-react 0.16.36 → 0.16.38

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/subsystem/paths.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/principal-view-react",
3
- "version": "0.16.36",
3
+ "version": "0.16.38",
4
4
  "description": "React components for graph-based principal view framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -256,6 +256,18 @@ export {
256
256
  export { SubsystemComponentGraph } from './subsystem/SubsystemComponentGraph';
257
257
  export type { SubsystemComponentGraphProps } from './subsystem/SubsystemComponentGraph';
258
258
  export { MECHANISM_COLOR, KIND_COLOR, MECHANISM_STYLE } from './subsystem/model';
259
+ export {
260
+ purlRepoKey,
261
+ purlOwnerName,
262
+ buildTreePathMapping,
263
+ buildRepoGroups,
264
+ repoAvatarUrl,
265
+ } from './subsystem/paths';
266
+ export type {
267
+ TreeEntry,
268
+ TreePathMapping,
269
+ RepoGroup,
270
+ } from './subsystem/paths';
259
271
  export { SubsystemFileTree } from './subsystem/SubsystemFileTree';
260
272
  export type { SubsystemFileTreeProps } from './subsystem/SubsystemFileTree';
261
273
  export { GraphLayoutCover } from './subsystem/GraphLayoutCover';
@@ -1087,3 +1087,43 @@ function MermaidDemo() {
1087
1087
  export const MermaidPipeline: Story = {
1088
1088
  render: () => <MermaidDemo />,
1089
1089
  };
1090
+
1091
+ // ---------------------------------------------------------------------------
1092
+ // Multi-repo: two repos → two sidebar trees, one per repo, each under its own
1093
+ // owner-avatar + repo-name header. Exercises buildRepoGroups grouping,
1094
+ // cross-repo edges (external stub targets), and per-tree selection.
1095
+ // ---------------------------------------------------------------------------
1096
+ const coreLibPurl = 'pkg:github/principal-ai/principal-view-core-library';
1097
+ const graphifyPurl = 'pkg:github/Graphify-Labs/graphify';
1098
+
1099
+ const multiRepoComponents = components([
1100
+ ['resolver', 'resolve.ts', 'module', 'packages/react/src/graphify/resolve.ts', coreLibPurl, 'consumer-side resolution mirroring graphify rewire tiers', 'createGraphifyTypeResolver'],
1101
+ ['reftypes', 'consolidated.ts', 'module', 'packages/react/src/graphify/consolidated.ts', coreLibPurl, 'drill-down payload types carrying ref.nodeId', 'GraphifyParamInfo'],
1102
+ ['detail', 'ComponentDetail.tsx', 'module', 'packages/react/src/subsystem/ComponentDetail.tsx', coreLibPurl, 'declaration-style detail panel with clickable type tokens', 'ComponentDetail'],
1103
+ ['engine', 'engine.py', 'module', 'graphify/extractors/engine.py', graphifyPurl, 'tree-sitter walk emitting references[parameter_type] edges + sourceless stubs', 'ensure_named_node'],
1104
+ ['rewire', 'extract.py', 'module', 'graphify/extract.py', graphifyPurl, 'corpus pass folding unique-label stubs onto definitions', '_rewire_unique_stub_nodes'],
1105
+ ]);
1106
+
1107
+ const multiRepoEdges = edges([
1108
+ ['detail', 'reftypes', 'imports'],
1109
+ ['resolver', 'reftypes', 'imports'],
1110
+ ['resolver', 'engine', 'references'],
1111
+ ['rewire', 'resolver', 'calls'],
1112
+ ]);
1113
+
1114
+ function MultiRepoDemo() {
1115
+ return (
1116
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
1117
+ <SubsystemComponentGraph
1118
+ title="Type-ref resolution across repos"
1119
+ description="Two repos → two file trees. Each tree is scoped to its repo's files and headed by the owner avatar + repo name. Cross-repo edges land on external stubs."
1120
+ components={multiRepoComponents}
1121
+ edges={multiRepoEdges}
1122
+ />
1123
+ </div>
1124
+ );
1125
+ }
1126
+
1127
+ export const MultiRepoTrees: Story = {
1128
+ render: () => <MultiRepoDemo />,
1129
+ };
@@ -47,6 +47,7 @@ import { GraphLayoutCover } from './GraphLayoutCover';
47
47
  import { ComponentDetail } from './ComponentDetail';
48
48
  import { FileDrawer } from './FileDrawer';
49
49
  import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
50
+ import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
50
51
 
51
52
  export interface SubsystemComponentGraphProps {
52
53
  components: SubsystemComponent[];
@@ -307,29 +308,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
307
308
  return () => clearTimeout(t);
308
309
  }, [baseNodesKey, baseEdgesKey, fitView]);
309
310
 
310
- // When the file drawer opens/closes the canvas resizes reframe once the
311
- // height transition settles: onto the matching (spotlit) nodes while a file
312
- // is open, back to the whole graph when it closes.
313
- const prevOpenFileRef = useRef<string | null>(null);
314
- useEffect(() => {
315
- const prev = prevOpenFileRef.current;
316
- prevOpenFileRef.current = openFile;
317
- if (!layoutReady || prev === openFile) return;
318
- const t = setTimeout(() => {
319
- if (openFile) {
320
- const ids = xyflowNodesBase
321
- .filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
322
- .map((n) => ({ id: n.id }));
323
- if (ids.length > 0) {
324
- fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
325
- }
326
- } else {
327
- fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
328
- }
329
- }, 230);
330
- return () => clearTimeout(t);
331
- }, [openFile, layoutReady, xyflowNodesBase, fitView]);
332
-
311
+ // When the file drawer opens or closes the canvas is resized but the
312
+ // camera stays put no refit/zoom. Spotlight + dimming already convey
313
+ // which nodes belong to the open file.
333
314
  const onNodeClick: NodeMouseHandler = useCallback(
334
315
  (_e, node: Node) => {
335
316
  const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
@@ -361,8 +342,22 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
361
342
  setSelectedEdgeId(null);
362
343
  }, []);
363
344
 
364
- // Sidebar file-tree click toggle in the bottom drawer (+ host hook only
365
- // when opening, so hosts don't see close events).
345
+ // Sidebar file trees one per repo on multi-repo graphs, each under its
346
+ // own owner-avatar header. Clicking a header collapses that repo's tree.
347
+ const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
348
+ const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
349
+ const toggleRepoCollapsed = useCallback((key: string) => {
350
+ setCollapsedRepos((prev) => {
351
+ const next = new Set(prev);
352
+ if (next.has(key)) next.delete(key);
353
+ else next.add(key);
354
+ return next;
355
+ });
356
+ }, []);
357
+ const treeFiles = useMemo(
358
+ () => repoGroups.groups.flatMap((g) => g.entries.map((e) => e.file)),
359
+ [repoGroups],
360
+ );
366
361
  const onTreeSelectFile = useCallback(
367
362
  (file: string) => {
368
363
  if (openFileRef.current === file) {
@@ -415,11 +410,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
415
410
  return new Set(edgeLabels.map((l) => l.mechanism));
416
411
  }, [edgeLabels]);
417
412
 
418
- // Unique source files across components → sidebar file tree.
419
- const files = useMemo(
420
- () => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
421
- [components],
422
- );
413
+ // Unique source files across components → sidebar file trees.
414
+ const treeFilePaths = treeFiles;
423
415
 
424
416
  // The hovered node's file (null when not hovering / file-less component).
425
417
  const hoveredFile = useMemo(() => {
@@ -455,7 +447,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
455
447
  return (
456
448
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
457
449
  {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
458
- {(title || description || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
450
+ {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
459
451
  <div
460
452
  style={{
461
453
  width: 340,
@@ -509,13 +501,52 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
509
501
  )}
510
502
  {sidebarAfterDescription}
511
503
  </div>
512
- {files.length > 0 && (
513
- <SubsystemFileTree
514
- files={files}
515
- selectedFile={selected?.file ?? openFile}
516
- hoveredFile={hoveredFile}
517
- onSelectFile={onTreeSelectFile}
518
- />
504
+ {treeFilePaths.length > 0 && (
505
+ <div
506
+ style={{
507
+ height: '50%',
508
+ minHeight: 160,
509
+ flexShrink: 0,
510
+ borderTop: `1px solid ${theme.colors.border}`,
511
+ display: 'flex',
512
+ flexDirection: 'column',
513
+ overflow: 'hidden',
514
+ }}
515
+ >
516
+ {repoGroups.groups.map((group, i) => {
517
+ const groupKey = group.repoKey ?? '__no-repo__';
518
+ const collapsed = collapsedRepos.has(groupKey);
519
+ return (
520
+ <div
521
+ key={groupKey}
522
+ style={{
523
+ flex: collapsed ? '0 0 auto' : 1,
524
+ minHeight: collapsed ? 0 : undefined,
525
+ display: 'flex',
526
+ flexDirection: 'column',
527
+ borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
528
+ }}
529
+ >
530
+ {repoGroups.multiRepo && (
531
+ <RepoGroupHeader
532
+ group={group}
533
+ collapsed={collapsed}
534
+ onToggle={() => toggleRepoCollapsed(groupKey)}
535
+ />
536
+ )}
537
+ {!collapsed && (
538
+ <SubsystemFileTree
539
+ files={group.entries.map((e) => e.displayPath)}
540
+ selectedFile={selected?.file ?? openFile}
541
+ hoveredFile={hoveredFile}
542
+ onSelectFile={onTreeSelectFile}
543
+ headerless={repoGroups.multiRepo}
544
+ />
545
+ )}
546
+ </div>
547
+ );
548
+ })}
549
+ </div>
519
550
  )}
520
551
  </div>
521
552
  )}
@@ -658,6 +689,85 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
658
689
  );
659
690
  }
660
691
 
692
+ /** Sidebar header for one repo's tree: owner avatar + repo name + file count.
693
+ * Clicking toggles the tree's visibility. */
694
+ function RepoGroupHeader({
695
+ group,
696
+ collapsed,
697
+ onToggle,
698
+ }: {
699
+ group: RepoGroup;
700
+ collapsed: boolean;
701
+ onToggle: () => void;
702
+ }) {
703
+ const { theme } = useTheme();
704
+ const avatar = repoAvatarUrl(group.repoKey);
705
+ const label = group.repo ?? 'No repo';
706
+ return (
707
+ <div
708
+ role="button"
709
+ aria-expanded={!collapsed}
710
+ onClick={(e) => {
711
+ e.stopPropagation();
712
+ onToggle();
713
+ }}
714
+ title={collapsed ? 'Show files' : 'Hide files'}
715
+ style={{
716
+ display: 'flex',
717
+ alignItems: 'center',
718
+ gap: 8,
719
+ padding: '10px 16px 4px',
720
+ flexShrink: 0,
721
+ cursor: 'pointer',
722
+ userSelect: 'none',
723
+ }}
724
+ >
725
+ {avatar ? (
726
+ <img
727
+ src={avatar}
728
+ alt=""
729
+ width={16}
730
+ height={16}
731
+ loading="lazy"
732
+ style={{ borderRadius: 4, flexShrink: 0, background: theme.colors.border }}
733
+ />
734
+ ) : group.owner ? (
735
+ <span
736
+ style={{
737
+ width: 16,
738
+ height: 16,
739
+ borderRadius: 4,
740
+ flexShrink: 0,
741
+ display: 'inline-flex',
742
+ alignItems: 'center',
743
+ justifyContent: 'center',
744
+ fontSize: 9,
745
+ fontWeight: 700,
746
+ fontFamily: theme.fonts.monospace,
747
+ color: theme.colors.text,
748
+ background: theme.colors.border,
749
+ }}
750
+ >
751
+ {group.owner.charAt(0).toUpperCase()}
752
+ </span>
753
+ ) : null}
754
+ <span
755
+ style={{
756
+ fontSize: theme.fontSizes[1],
757
+ fontFamily: theme.fonts.monospace,
758
+ color: theme.colors.text,
759
+ fontWeight: 600,
760
+ overflow: 'hidden',
761
+ textOverflow: 'ellipsis',
762
+ whiteSpace: 'nowrap',
763
+ }}
764
+ >
765
+ {label}
766
+ </span>
767
+ </div>
768
+ );
769
+ }
770
+
661
771
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
662
772
  const wrapRef = useRef<HTMLDivElement>(null);
663
773
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);
@@ -20,10 +20,13 @@ export interface SubsystemFileTreeProps {
20
20
  hoveredFile?: string | null;
21
21
  /** Called when a file row is clicked; upstream toggles the drawer. */
22
22
  onSelectFile?: (file: string) => void;
23
+ /** Hide the built-in "Files N" header row — used when an upstream repo
24
+ * header already labels the tree. */
25
+ headerless?: boolean;
23
26
  }
24
27
 
25
28
  /** Fills its parent height by design — pin it with a sized flex container. */
26
- export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile }: SubsystemFileTreeProps) {
29
+ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile, headerless }: SubsystemFileTreeProps) {
27
30
  const { theme } = useTheme();
28
31
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
29
32
  const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
@@ -57,26 +60,54 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
57
60
  },
58
61
  });
59
62
 
60
- // Reconcile the tree's native selection with the desired highlight: the
61
- // hovered node's file while hovering, else the drawer-open file. Deselects
62
- // the previous target and scrolls the new one into view.
63
+ // Reconcile the tree's native selection with the OPEN file only. Hover is
64
+ // deliberately NOT folded into selection pierre's model is single-select,
65
+ // so routing hover through select()/deselect() would steal the highlight
66
+ // from the open file. The hovered row gets its own transient marker instead.
63
67
  const lastSyncedRef = useRef<string | null>(null);
64
68
  useEffect(() => {
65
- const target = hoveredFile ?? selectedFile ?? null;
66
- if (lastSyncedRef.current === target) return;
69
+ if (lastSyncedRef.current === (selectedFile ?? null)) return;
67
70
  const prev = lastSyncedRef.current;
68
- lastSyncedRef.current = target;
71
+ lastSyncedRef.current = selectedFile ?? null;
69
72
  suppressSelectRef.current = true;
70
73
  try {
71
74
  if (prev) model.getItem(prev)?.deselect();
72
- if (target) {
73
- model.getItem(target)?.select();
74
- model.scrollToPath(target);
75
+ if (selectedFile) {
76
+ model.getItem(selectedFile)?.select();
77
+ model.scrollToPath(selectedFile);
75
78
  }
76
79
  } finally {
77
80
  suppressSelectRef.current = false;
78
81
  }
79
- }, [model, selectedFile, hoveredFile]);
82
+ }, [model, selectedFile]);
83
+
84
+ // Transient hover highlight (e.g. hovering a graph node spotlights its
85
+ // file), independent of selection. Pierre renders rows inside a shadow
86
+ // root, so we resolve the row through shadow boundaries and paint an
87
+ // inline background — document-level <style> tags cannot reach it.
88
+ const treeRef = useRef<HTMLDivElement | null>(null);
89
+ const hoveredRowRef = useRef<HTMLElement | null>(null);
90
+ useEffect(() => {
91
+ hoveredRowRef.current?.style.removeProperty('background');
92
+ hoveredRowRef.current = null;
93
+ const target = hoveredFile ?? null;
94
+ if (!target || !pathSetRef.current.has(target)) return;
95
+
96
+ // Depth-first walk across shadow roots — handles nested custom elements.
97
+ const roots: Array<Document | ShadowRoot> = [document];
98
+ while (roots.length > 0) {
99
+ const root = roots.shift()!;
100
+ const row = root.querySelector(`[data-item-path="${CSS.escape(target)}"]`);
101
+ if (row instanceof HTMLElement) {
102
+ row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
103
+ hoveredRowRef.current = row;
104
+ return;
105
+ }
106
+ for (const el of Array.from(root.querySelectorAll('*'))) {
107
+ if (el.shadowRoot) roots.push(el.shadowRoot);
108
+ }
109
+ }
110
+ }, [hoveredFile]);
80
111
 
81
112
  // Re-scope the tree in place when the subsystem's file set changes.
82
113
  useEffect(() => {
@@ -101,40 +132,41 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
101
132
 
102
133
  return (
103
134
  <div
135
+ ref={treeRef}
104
136
  onClickCapture={handleContainerClick}
105
137
  style={{
106
- height: '50%',
107
- minHeight: 120,
108
- flexShrink: 0,
109
- borderTop: `1px solid ${theme.colors.border}`,
138
+ flex: 1,
139
+ minHeight: 0,
110
140
  display: 'flex',
111
141
  flexDirection: 'column',
112
142
  }}
113
143
  >
114
- <div
115
- style={{
116
- padding: '10px 16px 4px',
117
- display: 'flex',
118
- alignItems: 'baseline',
119
- gap: 6,
120
- flexShrink: 0,
121
- }}
122
- >
123
- <span
144
+ {!headerless && (
145
+ <div
124
146
  style={{
125
- fontSize: theme.fontSizes[0] * 0.8,
126
- fontFamily: theme.fonts.monospace,
127
- textTransform: 'uppercase',
128
- color: muted,
129
- fontWeight: 600,
147
+ padding: '10px 16px 4px',
148
+ display: 'flex',
149
+ alignItems: 'baseline',
150
+ gap: 6,
151
+ flexShrink: 0,
130
152
  }}
131
153
  >
132
- Files
133
- </span>
134
- <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
135
- {paths.length}
136
- </span>
137
- </div>
154
+ <span
155
+ style={{
156
+ fontSize: theme.fontSizes[0] * 0.8,
157
+ fontFamily: theme.fonts.monospace,
158
+ textTransform: 'uppercase',
159
+ color: muted,
160
+ fontWeight: 600,
161
+ }}
162
+ >
163
+ Files
164
+ </span>
165
+ <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
166
+ {paths.length}
167
+ </span>
168
+ </div>
169
+ )}
138
170
  <FileTree
139
171
  model={model}
140
172
  style={
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ buildRepoGroups,
4
+ buildTreePathMapping,
5
+ purlOwnerName,
6
+ purlRepoKey,
7
+ repoAvatarUrl,
8
+ } from './paths';
9
+
10
+ describe('purlRepoKey', () => {
11
+ test('strips fragments and whitespace', () => {
12
+ expect(purlRepoKey('pkg:github/acme/widget#src/x.ts')).toBe('pkg:github/acme/widget');
13
+ expect(purlRepoKey(' pkg:github/acme/widget ')).toBe('pkg:github/acme/widget');
14
+ expect(purlRepoKey(undefined)).toBeUndefined();
15
+ expect(purlRepoKey('#only-a-fragment')).toBeUndefined();
16
+ });
17
+ });
18
+
19
+ describe('purlOwnerName', () => {
20
+ test('extracts the last two path segments', () => {
21
+ expect(purlOwnerName('pkg:github/acme/widget#src/x.ts')).toBe('acme/widget');
22
+ expect(purlOwnerName('pkg:npm/@scope/lib')).toBe('@scope/lib');
23
+ });
24
+ });
25
+
26
+ describe('buildTreePathMapping', () => {
27
+ test('single-repo graphs keep bare file paths', () => {
28
+ const m = buildTreePathMapping([
29
+ { file: 'src/a.ts', purl: 'pkg:github/acme/widget' },
30
+ { file: 'src/b.ts', purl: 'pkg:github/acme/widget' },
31
+ ]);
32
+ expect(m.multiRepo).toBe(false);
33
+ expect(m.entries.map((e) => e.displayPath)).toEqual(['src/a.ts', 'src/b.ts']);
34
+ expect(m.toFile.get('src/a.ts')).toBe('src/a.ts');
35
+ });
36
+
37
+ test('multi-repo graphs prefix entries with owner/name', () => {
38
+ const m = buildTreePathMapping([
39
+ { file: 'graphify/ids.py', purl: 'pkg:github/Graphify-Labs/graphify' },
40
+ { file: 'packages/react/src/x.ts', purl: 'pkg:github/principal-ai/core-lib#packages/react/src/x.ts' },
41
+ ]);
42
+ expect(m.multiRepo).toBe(true);
43
+ expect(m.toFile.get('Graphify-Labs/graphify/graphify/ids.py')).toBe('graphify/ids.py');
44
+ expect(m.toDisplay.get('packages/react/src/x.ts')).toBe(
45
+ 'principal-ai/core-lib/packages/react/src/x.ts',
46
+ );
47
+ });
48
+
49
+ test('same-named files in different repos stay distinct', () => {
50
+ const m = buildTreePathMapping([
51
+ { file: 'README.md', purl: 'pkg:github/acme/widget' },
52
+ { file: 'README.md', purl: 'pkg:github/acme/other' },
53
+ ]);
54
+ expect(m.entries).toHaveLength(2);
55
+ });
56
+
57
+ test('components without files are skipped; purl-less components stay unprefixed', () => {
58
+ const m = buildTreePathMapping([
59
+ { file: 'a.ts', purl: 'pkg:github/acme/widget' },
60
+ { file: '', purl: 'pkg:github/acme/widget' },
61
+ { purl: 'pkg:github/acme/widget' },
62
+ { file: 'loose.ts' },
63
+ ]);
64
+ // Two repos' worth of purls? No — the purl-less one has no repo key.
65
+ expect(m.multiRepo).toBe(false);
66
+ expect(m.toFile.get('loose.ts')).toBe('loose.ts');
67
+ });
68
+ });
69
+
70
+ describe('buildRepoGroups', () => {
71
+ test('groups by repo preserving first-appearance order', () => {
72
+ const { multiRepo, groups } = buildRepoGroups([
73
+ { file: 'a.ts', purl: 'pkg:github/acme/widget' },
74
+ { file: 'g/x.py', purl: 'pkg:github/Graphify-Labs/graphify' },
75
+ { file: 'b.ts', purl: 'pkg:github/acme/widget' },
76
+ ]);
77
+ expect(multiRepo).toBe(true);
78
+ expect(groups.map((g) => g.repoKey)).toEqual(['pkg:github/acme/widget', 'pkg:github/Graphify-Labs/graphify']);
79
+ expect(groups[0]!.owner).toBe('acme');
80
+ expect(groups[0]!.repo).toBe('widget');
81
+ expect(groups[0]!.entries.map((e) => e.file)).toEqual(['a.ts', 'b.ts']);
82
+ });
83
+
84
+ test('entries carry bare files (no synthetic prefixes)', () => {
85
+ const { groups } = buildRepoGroups([
86
+ { file: 'deep/nested/x.ts', purl: 'pkg:github/acme/widget#deep/nested/x.ts' },
87
+ ]);
88
+ expect(groups[0]!.entries[0]).toEqual({ displayPath: 'deep/nested/x.ts', file: 'deep/nested/x.ts' });
89
+ });
90
+
91
+ test('purl-less components land in a trailing unbadged group', () => {
92
+ const { multiRepo, groups } = buildRepoGroups([
93
+ { file: 'loose.ts' },
94
+ { file: 'a.ts', purl: 'pkg:github/acme/widget' },
95
+ ]);
96
+ expect(multiRepo).toBe(false);
97
+ expect(groups).toHaveLength(2);
98
+ expect(groups[0]!.repoKey).toBeUndefined();
99
+ expect(groups[1]!.repoKey).toBe('pkg:github/acme/widget');
100
+ });
101
+
102
+ test('single-repo graphs yield one group and multiRepo=false', () => {
103
+ const { multiRepo, groups } = buildRepoGroups([
104
+ { file: 'a.ts', purl: 'pkg:github/acme/widget' },
105
+ { file: 'b.ts', purl: 'pkg:github/acme/widget' },
106
+ ]);
107
+ expect(multiRepo).toBe(false);
108
+ expect(groups).toHaveLength(1);
109
+ });
110
+ });
111
+
112
+ describe('repoAvatarUrl', () => {
113
+ test('github purls map to the owner avatar', () => {
114
+ expect(repoAvatarUrl('pkg:github/Graphify-Labs/graphify#x/y.py')).toBe(
115
+ 'https://github.com/Graphify-Labs.png?size=64',
116
+ );
117
+ });
118
+
119
+ test('non-github purls have no avatar', () => {
120
+ expect(repoAvatarUrl('pkg:npm/@scope/lib')).toBeUndefined();
121
+ expect(repoAvatarUrl(undefined)).toBeUndefined();
122
+ });
123
+ });