@principal-ai/subsystems-react 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/ComponentDeclaration.d.ts +7 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +169 -5
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts +4 -2
  10. package/dist/subsystem/IssueList.d.ts.map +1 -1
  11. package/dist/subsystem/IssueList.js +6 -6
  12. package/dist/subsystem/IssueList.js.map +1 -1
  13. package/dist/subsystem/SymbolInspectionCard.d.ts +31 -0
  14. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -0
  15. package/dist/subsystem/SymbolInspectionCard.js +247 -0
  16. package/dist/subsystem/SymbolInspectionCard.js.map +1 -0
  17. package/dist/subsystem/prettierProvider.d.ts +24 -0
  18. package/dist/subsystem/prettierProvider.d.ts.map +1 -0
  19. package/dist/subsystem/prettierProvider.js +22 -0
  20. package/dist/subsystem/prettierProvider.js.map +1 -0
  21. package/dist/subsystem/symbolRefs.d.ts +95 -0
  22. package/dist/subsystem/symbolRefs.d.ts.map +1 -0
  23. package/dist/subsystem/symbolRefs.js +167 -0
  24. package/dist/subsystem/symbolRefs.js.map +1 -0
  25. package/dist/subsystem/tokenizeComponent.d.ts.map +1 -1
  26. package/dist/subsystem/tokenizeComponent.js +6 -0
  27. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/index.ts +19 -0
  30. package/src/stories/Subsystem/ComponentDeclarationSymbols.stories.tsx +269 -0
  31. package/src/subsystem/ComponentDeclaration.tsx +239 -9
  32. package/src/subsystem/IssueList.tsx +10 -8
  33. package/src/subsystem/SymbolInspectionCard.tsx +433 -0
  34. package/src/subsystem/prettierProvider.ts +32 -0
  35. package/src/subsystem/symbolRefs.test.ts +127 -0
  36. package/src/subsystem/symbolRefs.ts +256 -0
  37. package/src/subsystem/tokenizeComponent.ts +5 -0
@@ -38,13 +38,15 @@ export type SubsystemIssueTargetKind =
38
38
 
39
39
  /**
40
40
  * The four verification layers, in order. Mirrors the docs' progression:
41
- * constructs → static topology → runtime topology → walkthrough.
41
+ * constructs → static topology → dynamic topology → walkthrough.
42
+ * Static topology = relations; dynamic topology = process (runtime) +
43
+ * package/module (containment).
42
44
  */
43
45
  export type SubsystemIssueCategory =
44
46
  | 'repo'
45
47
  | 'construct'
46
48
  | 'static-topology'
47
- | 'runtime-topology'
49
+ | 'dynamic-topology'
48
50
  | 'walkthrough';
49
51
 
50
52
  /** Layer order + display names, shared by the list and its headers. */
@@ -52,7 +54,7 @@ export const SUBSYSTEM_ISSUE_CATEGORIES: SubsystemIssueCategory[] = [
52
54
  'repo',
53
55
  'construct',
54
56
  'static-topology',
55
- 'runtime-topology',
57
+ 'dynamic-topology',
56
58
  'walkthrough',
57
59
  ];
58
60
 
@@ -64,7 +66,7 @@ export const SUBSYSTEM_ISSUE_CATEGORY_ICON: Record<
64
66
  repo: FolderGit2,
65
67
  construct: Component,
66
68
  'static-topology': Network,
67
- 'runtime-topology': Server,
69
+ 'dynamic-topology': Server,
68
70
  walkthrough: Route,
69
71
  };
70
72
 
@@ -75,11 +77,11 @@ export const SUBSYSTEM_ISSUE_CATEGORY_LABEL: Record<
75
77
  repo: 'Repositories',
76
78
  construct: 'Constructs',
77
79
  'static-topology': 'Static topology',
78
- 'runtime-topology': 'Runtime topology',
80
+ 'dynamic-topology': 'Dynamic topology',
79
81
  walkthrough: 'Walkthrough',
80
82
  };
81
83
 
82
- /** Default `kind` → layer. `boundary_*` splits by module (static) vs process (runtime). */
84
+ /** Default `kind` → layer. `boundary_*` (module containment + process) is dynamic. */
83
85
  const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
84
86
  missing_file: 'construct',
85
87
  symbol_ambiguous: 'construct',
@@ -94,8 +96,8 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
94
96
  topology_broken_endpoint: 'static-topology',
95
97
  topology_import_unconfirmed: 'static-topology',
96
98
  topology_relation_unconfirmed: 'static-topology',
97
- boundary_module_file_mismatch: 'static-topology',
98
- boundary_process_nest_disagree: 'runtime-topology',
99
+ boundary_module_file_mismatch: 'dynamic-topology',
100
+ boundary_process_nest_disagree: 'dynamic-topology',
99
101
  walkthrough: 'walkthrough',
100
102
  };
101
103
 
@@ -0,0 +1,433 @@
1
+ /**
2
+ * SymbolInspectionCard — the declaration of a symbol referenced by a
3
+ * declaration panel, resolved through graphify. Rendered as a popover from the
4
+ * declaration panel.
5
+ *
6
+ * The useful payload is the declaration itself (`inspection.declaration`,
7
+ * rebuilt from graph edges), with `source` as the fallback. `resolution` and
8
+ * `candidates` explain a miss. The "Add to references" affordance is
9
+ * intentionally hidden until it's wired up (`onAddToModel` is accepted but not
10
+ * rendered yet).
11
+ */
12
+
13
+ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
14
+ import { ExternalLink } from 'lucide-react';
15
+ import { useTheme } from '@principal-ade/industry-theme';
16
+ import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
17
+ import { sourceLangForPath, type SourceSyntaxLang } from '../pierre/sourceLang';
18
+ import type { SubsystemOpenFileOptions } from './declarationRef';
19
+ import { parseSourceLocation } from './declarationRef';
20
+ import { generateDeclarationString } from './formatDeclaration';
21
+ import type {
22
+ SubsystemComponent,
23
+ SubsystemComponentConstruct,
24
+ SubsystemDeclToken,
25
+ } from './model';
26
+ import { tokenizeComponent } from './tokenizeComponent';
27
+ import { tokenizeFormatted } from './tokenizeFormatted';
28
+ import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
29
+
30
+ export interface SymbolInspectionCardProps {
31
+ /** The symbol that was clicked. */
32
+ symbolRef: DeclarationSymbolRef;
33
+ /** Graphify result; absent when the lookup failed. */
34
+ inspection?: SymbolInspection | null;
35
+ error?: string;
36
+ onClose?: () => void;
37
+ /** Add the symbol to the model's references (for an agent to maintain).
38
+ * Accepted but not rendered yet — wired in a later slice. */
39
+ onAddToModel?: (ref: DeclarationSymbolRef, info?: SymbolInspection) => void;
40
+ /** Open the definition file at its line. */
41
+ onOpenFile?: (file: string, opts?: SubsystemOpenFileOptions) => void;
42
+ style?: CSSProperties;
43
+ }
44
+
45
+ /**
46
+ * A repo path that wraps at `/` only. Each segment is unbreakable, so the
47
+ * browser can't fall back to breaking at a hyphen (`event-` / `processing`);
48
+ * the `<wbr>` after each slash supplies the break opportunity instead.
49
+ */
50
+ function WrappablePath({ path }: { path: string }) {
51
+ const nodes: ReactNode[] = [];
52
+ path.split('/').forEach((seg, i) => {
53
+ if (i > 0) {
54
+ nodes.push('/');
55
+ nodes.push(<wbr key={`b${i}`} />);
56
+ }
57
+ nodes.push(
58
+ <span key={`s${i}`} style={{ whiteSpace: 'nowrap' }}>
59
+ {seg}
60
+ </span>,
61
+ );
62
+ });
63
+ return <>{nodes}</>;
64
+ }
65
+
66
+ /** Label stacked above the value, so long values get the full width. */
67
+ function StackedRow({ label, children }: { label: string; children: ReactNode }) {
68
+ const { theme } = useTheme();
69
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
70
+ return (
71
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
72
+ <span
73
+ style={{
74
+ color: muted,
75
+ fontFamily: theme.fonts.body,
76
+ fontSize: theme.fontSizes[0],
77
+ textTransform: 'uppercase',
78
+ letterSpacing: 0.4,
79
+ }}
80
+ >
81
+ {label}
82
+ </span>
83
+ <span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{children}</span>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** Map a graphify declaration kind to the construct the renderer expects. */
89
+ function constructForDeclaration(info: SymbolInspection): SubsystemComponentConstruct {
90
+ const d = info.declaration;
91
+ switch (d?.kind) {
92
+ case 'class':
93
+ return 'class';
94
+ case 'function':
95
+ return 'function';
96
+ case 'method':
97
+ return 'method';
98
+ case 'store':
99
+ return 'store';
100
+ case 'external':
101
+ return 'external';
102
+ case 'custom_entity':
103
+ return 'custom_entity';
104
+ case 'type':
105
+ if (d.enumMembers?.length) return 'enum';
106
+ if (d.rhs || d.aliasOf || d.unionOf?.length || d.signature) return 'type_alias';
107
+ return 'interface';
108
+ default:
109
+ return 'interface';
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A valid TS identifier used while generating/formatting the declaration, then
115
+ * swapped for the real symbol name on display. Graphify labels aren't always
116
+ * valid identifiers (`capture-session`), which would make Prettier's parser
117
+ * throw and skip formatting.
118
+ */
119
+ const SYMBOL_PLACEHOLDER = '__SYMBOL__';
120
+
121
+ /** Build a renderable component from the inspection's structured declaration. */
122
+ function componentForInspection(info: SymbolInspection): SubsystemComponent | null {
123
+ if (!info.declaration) return null;
124
+ return {
125
+ alias: info.symbol,
126
+ name: SYMBOL_PLACEHOLDER,
127
+ construct: constructForDeclaration(info),
128
+ file: info.node?.sourceFile ?? '',
129
+ purl: info.purl ?? '',
130
+ declaration: info.declaration,
131
+ };
132
+ }
133
+
134
+ /** Put the real symbol name back into a generated/formatted declaration. */
135
+ function withSymbolName(text: string, symbol: string): string {
136
+ return text.split(SYMBOL_PLACEHOLDER).join(symbol);
137
+ }
138
+
139
+ type Theme = ReturnType<typeof useTheme>['theme'];
140
+
141
+ /** Typography for the declaration text — the section supplies the framing. */
142
+ function declBlockStyle(theme: Theme): CSSProperties {
143
+ return {
144
+ fontFamily: theme.fonts.monospace,
145
+ fontSize: theme.fontSizes[1],
146
+ lineHeight: 1.6,
147
+ whiteSpace: 'pre-wrap',
148
+ overflowWrap: 'anywhere',
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Token stream → per-line colored spans, substituting a placeholder name. Each
154
+ * line is its own block: token text carries no newline (the `newline` token is
155
+ * a marker), so without a block per line everything collapses onto one row.
156
+ */
157
+ function renderTokenLines(
158
+ tokens: SubsystemDeclToken[],
159
+ theme: Theme,
160
+ symbol?: string,
161
+ ): ReactNode[] {
162
+ const lines: ReactNode[][] = [[]];
163
+ let li = 0;
164
+ for (const tok of tokens) {
165
+ if (tok.kind === 'newline') {
166
+ lines.push([]);
167
+ li++;
168
+ continue;
169
+ }
170
+ const text = symbol ? tok.text.split(SYMBOL_PLACEHOLDER).join(symbol) : tok.text;
171
+ lines[li].push(
172
+ <span
173
+ key={`${li}-${lines[li].length}`}
174
+ style={{ color: tok.color ?? theme.colors.text }}
175
+ >
176
+ {text}
177
+ </span>,
178
+ );
179
+ }
180
+ return lines
181
+ .filter((spans) => spans.length > 0)
182
+ .map((spans, i) => (
183
+ <div key={i} style={{ minHeight: '1em' }}>
184
+ {spans}
185
+ </div>
186
+ ));
187
+ }
188
+
189
+ /**
190
+ * The declaration, syntax-colored via the same Shiki/Pierre pipeline as the
191
+ * main panel. Falls back to the plain generated string until tokens arrive.
192
+ */
193
+ function DeclarationPreview({ info }: { info: SymbolInspection }) {
194
+ const { theme, mode } = useTheme();
195
+ const [tokens, setTokens] = useState<SubsystemDeclToken[] | null>(null);
196
+ const component = useMemo(() => componentForInspection(info), [info]);
197
+ const fallbackText = useMemo(
198
+ () => (component ? withSymbolName(generateDeclarationString(component), info.symbol) : ''),
199
+ [component, info.symbol],
200
+ );
201
+
202
+ useEffect(() => {
203
+ if (!component) return;
204
+ let cancelled = false;
205
+ tokenizeComponent(component, 40, resolvePierreSyntaxThemeName(mode))
206
+ .then((t) => {
207
+ if (!cancelled) setTokens(t);
208
+ })
209
+ .catch(() => {
210
+ if (!cancelled) setTokens(null);
211
+ });
212
+ return () => {
213
+ cancelled = true;
214
+ };
215
+ }, [component, mode]);
216
+
217
+ return (
218
+ <div style={declBlockStyle(theme)}>
219
+ {tokens ? renderTokenLines(tokens, theme, info.symbol) : fallbackText}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ /** Raw source declaration text, syntax-colored with the file's grammar. */
225
+ function TokenizedSource({ code, lang }: { code: string; lang: SourceSyntaxLang }) {
226
+ const { theme, mode } = useTheme();
227
+ const [tokens, setTokens] = useState<SubsystemDeclToken[] | null>(null);
228
+
229
+ useEffect(() => {
230
+ let cancelled = false;
231
+ tokenizeFormatted(code, resolvePierreSyntaxThemeName(mode), lang)
232
+ .then((t) => {
233
+ if (!cancelled) setTokens(t);
234
+ })
235
+ .catch(() => {
236
+ if (!cancelled) setTokens(null);
237
+ });
238
+ return () => {
239
+ cancelled = true;
240
+ };
241
+ }, [code, lang, mode]);
242
+
243
+ return (
244
+ <div style={declBlockStyle(theme)}>
245
+ {tokens ? renderTokenLines(tokens, theme) : code}
246
+ </div>
247
+ );
248
+ }
249
+
250
+ /** Popover showing the declaration of one referenced symbol. */
251
+ export function SymbolInspectionCard({
252
+ symbolRef,
253
+ inspection,
254
+ error,
255
+ onClose,
256
+ onOpenFile,
257
+ style,
258
+ }: SymbolInspectionCardProps) {
259
+ const { theme } = useTheme();
260
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
261
+ const mono = theme.fonts.monospace;
262
+ const small = theme.fontSizes[0];
263
+ const body = theme.fontSizes[1];
264
+
265
+ const info = inspection ?? undefined;
266
+ const node = info?.node;
267
+ const line = parseSourceLocation(node?.sourceLocation);
268
+ // Structured declaration when graphify reconstructed it; otherwise the raw
269
+ // source declaration when only the location is known.
270
+ const component = info ? componentForInspection(info) : null;
271
+ const sourceText = info?.source?.text ?? null;
272
+ // The declaration block already carries the symbol's name, so only fall back
273
+ // to a header title when there's nothing to show (loading / miss).
274
+ const hasBody = !!component || !!sourceText;
275
+ // What to say when there's no declaration: ambiguous is "pick one", not a
276
+ // dead end — so it must not read as "no declaration available".
277
+ const statusText = !info
278
+ ? 'Nothing in graphify for this symbol.'
279
+ : info.candidates && info.candidates.length > 0
280
+ ? `Multiple definitions match (${info.candidates.length}).`
281
+ : `No declaration available from graphify${info.reason ? ` — ${info.reason}` : '.'}`;
282
+
283
+ const openFile = () => {
284
+ if (!onOpenFile || !node?.sourceFile) return;
285
+ onOpenFile(node.sourceFile, line != null ? { startLine: line } : undefined);
286
+ };
287
+
288
+ return (
289
+ <div
290
+ data-symbol-inspection
291
+ style={{
292
+ position: 'absolute',
293
+ zIndex: 40,
294
+ width: 360,
295
+ maxWidth: 'calc(100vw - 24px)',
296
+ boxSizing: 'border-box',
297
+ background: theme.colors.backgroundSecondary,
298
+ border: `1px solid ${theme.colors.border}`,
299
+ borderRadius: 8,
300
+ overflow: 'hidden',
301
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)',
302
+ fontFamily: mono,
303
+ fontSize: body,
304
+ color: theme.colors.text,
305
+ ...style,
306
+ }}
307
+ onClick={(e) => e.stopPropagation()}
308
+ >
309
+ <button
310
+ type="button"
311
+ aria-label="Close"
312
+ onClick={onClose}
313
+ style={{
314
+ position: 'absolute',
315
+ top: 6,
316
+ right: 8,
317
+ zIndex: 1,
318
+ border: 'none',
319
+ background: 'transparent',
320
+ color: muted,
321
+ cursor: 'pointer',
322
+ fontSize: 15,
323
+ lineHeight: 1,
324
+ padding: '2px 4px',
325
+ }}
326
+ >
327
+ ×
328
+ </button>
329
+
330
+ {/* Top: the declaration itself, full-bleed (or status when there's none). */}
331
+ <div
332
+ style={{
333
+ padding: '12px 32px 12px 12px',
334
+ borderBottom: `1px solid ${theme.colors.border}`,
335
+ }}
336
+ >
337
+ {error && (
338
+ <span style={{ color: '#e5534b', fontFamily: theme.fonts.body, fontSize: small }}>
339
+ {error}
340
+ </span>
341
+ )}
342
+
343
+ {!error && component && info && (
344
+ <DeclarationPreview key={info.symbol} info={info} />
345
+ )}
346
+
347
+ {!error && !component && sourceText && (
348
+ <TokenizedSource
349
+ key={info?.symbol}
350
+ code={sourceText}
351
+ lang={sourceLangForPath(info?.source?.file ?? info?.node?.sourceFile ?? '')}
352
+ />
353
+ )}
354
+
355
+ {!error && !hasBody && (
356
+ <span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
357
+ {symbolRef.name}
358
+ </span>
359
+ )}
360
+ </div>
361
+
362
+ {/* Meta: where it lives, ambiguity, and the action. */}
363
+ {!error && (
364
+ <div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
365
+ {node?.sourceFile && (
366
+ <StackedRow label="Source">
367
+ {onOpenFile ? (
368
+ <span
369
+ role="button"
370
+ tabIndex={0}
371
+ onClick={openFile}
372
+ style={{
373
+ cursor: 'pointer',
374
+ color: theme.colors.accent ?? theme.colors.secondary,
375
+ }}
376
+ >
377
+ <WrappablePath path={node.sourceFile} />
378
+ {line != null ? `:${line}` : ''}{' '}
379
+ <ExternalLink
380
+ size={11}
381
+ style={{ display: 'inline', verticalAlign: 'text-bottom' }}
382
+ />
383
+ </span>
384
+ ) : (
385
+ <span>
386
+ <WrappablePath path={node.sourceFile} />
387
+ {line != null ? `:${line}` : ''}
388
+ </span>
389
+ )}
390
+ </StackedRow>
391
+ )}
392
+
393
+ {!hasBody && (
394
+ <span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: small }}>
395
+ {statusText}
396
+ </span>
397
+ )}
398
+
399
+ {info?.candidates && info.candidates.length > 0 && (
400
+ <StackedRow label="Candidates">
401
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
402
+ {info.candidates.slice(0, 6).map((c) => (
403
+ <span key={c.nodeId} style={{ fontFamily: mono, fontSize: small }}>
404
+ <span style={{ color: theme.colors.text }}>{c.label}</span>
405
+ {c.sourceFile && (
406
+ <>
407
+ {' '}
408
+ <span
409
+ role={onOpenFile ? 'button' : undefined}
410
+ tabIndex={onOpenFile ? 0 : undefined}
411
+ onClick={
412
+ onOpenFile ? () => onOpenFile(c.sourceFile!, undefined) : undefined
413
+ }
414
+ style={{
415
+ color: theme.colors.accent ?? theme.colors.secondary,
416
+ cursor: onOpenFile ? 'pointer' : 'default',
417
+ }}
418
+ >
419
+ <WrappablePath path={c.sourceFile} />
420
+ </span>
421
+ </>
422
+ )}
423
+ </span>
424
+ ))}
425
+ </div>
426
+ </StackedRow>
427
+ )}
428
+
429
+ </div>
430
+ )}
431
+ </div>
432
+ );
433
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Optional host-provided Prettier.
3
+ *
4
+ * Some host bundlers mangle Prettier's already-minified plugin code. Cottontail
5
+ * (Electrobun) re-bundles it and renames a local parameter to a name that
6
+ * collides with an outer function used inside a closure (`e3 is not a
7
+ * function`), which breaks `prettier.format` for every input in the renderer.
8
+ *
9
+ * A host can load a correctly-bundled Prettier outside its own bundler and
10
+ * register it here; `tokenizeComponent` prefers it over the bundled dynamic
11
+ * import. When nothing is registered the built-in import path is unchanged.
12
+ */
13
+
14
+ export type PrettierBundle = {
15
+ prettier: typeof import('prettier/standalone');
16
+ plugins: {
17
+ typescript: typeof import('prettier/plugins/typescript');
18
+ estree: typeof import('prettier/plugins/estree');
19
+ };
20
+ };
21
+
22
+ let provider: PrettierBundle | null = null;
23
+
24
+ /** Register (or clear with `null`) a host-provided Prettier bundle. */
25
+ export function setPrettierProvider(bundle: PrettierBundle | null): void {
26
+ provider = bundle;
27
+ }
28
+
29
+ /** The currently registered bundle, if any. */
30
+ export function getPrettierProvider(): PrettierBundle | null {
31
+ return provider;
32
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { GraphifyComponentDetail } from '../graphify';
3
+ import {
4
+ extractDeclarationSymbolRefs,
5
+ isLimitedInspection,
6
+ type SymbolInspection,
7
+ } from './symbolRefs';
8
+
9
+ function names(declaration: GraphifyComponentDetail | undefined): string[] {
10
+ return extractDeclarationSymbolRefs(declaration)
11
+ .map((r) => r.name)
12
+ .sort();
13
+ }
14
+
15
+ describe('extractDeclarationSymbolRefs — function', () => {
16
+ test('keeps simple named types, drops primitives and composite types', () => {
17
+ const declaration: GraphifyComponentDetail = {
18
+ kind: 'function',
19
+ parameters: [
20
+ { name: 'session', type: 'SessionRecord' },
21
+ { name: 'limit', type: 'number' },
22
+ { name: 'options', type: 'Required<ProcessingOptions>' },
23
+ ],
24
+ returnType: 'SessionEvent[]',
25
+ callers: [{ nodeId: 'c1', name: 'capture-session', source_location: 'L120' }],
26
+ callees: [{ nodeId: 'c2', name: 'toUniversalEvents()', source_location: 'L64' }],
27
+ };
28
+ expect(names(declaration)).toEqual([
29
+ 'SessionRecord',
30
+ 'capture-session',
31
+ 'toUniversalEvents',
32
+ ]);
33
+ });
34
+
35
+ test('carries the reference nodeId/context through', () => {
36
+ const declaration: GraphifyComponentDetail = {
37
+ kind: 'function',
38
+ parameters: [
39
+ {
40
+ name: 'session',
41
+ type: 'SessionRecord',
42
+ ref: { nodeId: 'n1', name: 'SessionRecord', context: 'parameter_type' },
43
+ },
44
+ ],
45
+ };
46
+ const refs = extractDeclarationSymbolRefs(declaration);
47
+ expect(refs).toHaveLength(1);
48
+ expect(refs[0]).toMatchObject({
49
+ name: 'SessionRecord',
50
+ nodeId: 'n1',
51
+ context: 'parameter_type',
52
+ });
53
+ });
54
+ });
55
+
56
+ describe('extractDeclarationSymbolRefs — class', () => {
57
+ test('collects params, returns, extends, implements and references', () => {
58
+ const declaration: GraphifyComponentDetail = {
59
+ kind: 'class',
60
+ methods: [
61
+ {
62
+ nodeId: 'm1',
63
+ name: 'process',
64
+ parameters: [{ name: 'event', type: 'RawEvent' }],
65
+ returnType: 'ProcessedEvent',
66
+ },
67
+ ],
68
+ properties: [{ name: 'options', type: 'ProcessingOptions' }],
69
+ extends: ['BaseProcessor'],
70
+ implements: ['Disposable', 'EventEmitterLike'],
71
+ references: [{ nodeId: 'n-emitter', name: 'EventEmitterLike', context: 'type' }],
72
+ };
73
+ expect(names(declaration)).toEqual([
74
+ 'BaseProcessor',
75
+ 'Disposable',
76
+ 'EventEmitterLike',
77
+ 'ProcessedEvent',
78
+ 'ProcessingOptions',
79
+ 'RawEvent',
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe('extractDeclarationSymbolRefs — type', () => {
85
+ test('collects usedBy, implementors, alias and union members', () => {
86
+ const declaration: GraphifyComponentDetail = {
87
+ kind: 'type',
88
+ properties: [{ name: 'id', type: 'BrandedId' }],
89
+ usedBy: [{ nodeId: 'u1', name: 'normalize', context: 'type' }],
90
+ implementors: ['HostInfo'],
91
+ aliasOf: 'ServerSessionRow',
92
+ unionOf: ["'started'", 'StoppedState'],
93
+ };
94
+ expect(names(declaration)).toEqual([
95
+ 'BrandedId',
96
+ 'HostInfo',
97
+ 'ServerSessionRow',
98
+ 'StoppedState',
99
+ 'normalize',
100
+ ]);
101
+ });
102
+
103
+ test('undefined declaration yields no refs', () => {
104
+ expect(extractDeclarationSymbolRefs(undefined)).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe('isLimitedInspection', () => {
109
+ const declaration: GraphifyComponentDetail = {
110
+ kind: 'type',
111
+ properties: [{ name: 'id', type: 'string' }],
112
+ };
113
+
114
+ test('a declaration is not limited', () => {
115
+ expect(
116
+ isLimitedInspection({ symbol: 'X', resolution: 'resolved', declaration }),
117
+ ).toBe(false);
118
+ });
119
+
120
+ test('no declaration is limited, regardless of resolution', () => {
121
+ expect(isLimitedInspection({ symbol: 'X', resolution: 'resolved' })).toBe(true);
122
+ expect(isLimitedInspection({ symbol: 'X', resolution: 'ambiguous' })).toBe(true);
123
+ expect(isLimitedInspection({ symbol: 'X', resolution: 'unresolved' })).toBe(true);
124
+ expect(isLimitedInspection({ symbol: 'X', resolution: 'missing' })).toBe(true);
125
+ expect(isLimitedInspection(null)).toBe(true);
126
+ });
127
+ });