@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.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +7 -1
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +169 -5
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +4 -2
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +6 -6
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.d.ts +31 -0
- package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -0
- package/dist/subsystem/SymbolInspectionCard.js +247 -0
- package/dist/subsystem/SymbolInspectionCard.js.map +1 -0
- package/dist/subsystem/prettierProvider.d.ts +24 -0
- package/dist/subsystem/prettierProvider.d.ts.map +1 -0
- package/dist/subsystem/prettierProvider.js +22 -0
- package/dist/subsystem/prettierProvider.js.map +1 -0
- package/dist/subsystem/symbolRefs.d.ts +95 -0
- package/dist/subsystem/symbolRefs.d.ts.map +1 -0
- package/dist/subsystem/symbolRefs.js +167 -0
- package/dist/subsystem/symbolRefs.js.map +1 -0
- package/dist/subsystem/tokenizeComponent.d.ts.map +1 -1
- package/dist/subsystem/tokenizeComponent.js +6 -0
- package/dist/subsystem/tokenizeComponent.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +19 -0
- package/src/stories/Subsystem/ComponentDeclarationSymbols.stories.tsx +269 -0
- package/src/subsystem/ComponentDeclaration.tsx +239 -9
- package/src/subsystem/IssueList.tsx +10 -8
- package/src/subsystem/SymbolInspectionCard.tsx +433 -0
- package/src/subsystem/prettierProvider.ts +32 -0
- package/src/subsystem/symbolRefs.test.ts +127 -0
- package/src/subsystem/symbolRefs.ts +256 -0
- 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 →
|
|
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
|
-
| '
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
80
|
+
'dynamic-topology': 'Dynamic topology',
|
|
79
81
|
walkthrough: 'Walkthrough',
|
|
80
82
|
};
|
|
81
83
|
|
|
82
|
-
/** Default `kind` → layer. `boundary_*`
|
|
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: '
|
|
98
|
-
boundary_process_nest_disagree: '
|
|
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
|
+
});
|