@principal-ai/principal-view-react 0.16.49 → 0.16.51
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/graphify/anchor.d.ts +28 -0
- package/dist/graphify/anchor.d.ts.map +1 -0
- package/dist/graphify/anchor.js +132 -0
- package/dist/graphify/anchor.js.map +1 -0
- package/dist/graphify/ids.d.ts +19 -0
- package/dist/graphify/ids.d.ts.map +1 -0
- package/dist/graphify/ids.js +46 -0
- package/dist/graphify/ids.js.map +1 -0
- package/dist/graphify/index.d.ts +7 -0
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +4 -0
- package/dist/graphify/index.js.map +1 -1
- package/dist/graphify/kind.d.ts +23 -0
- package/dist/graphify/kind.d.ts.map +1 -0
- package/dist/graphify/kind.js +71 -0
- package/dist/graphify/kind.js.map +1 -0
- package/dist/graphify/signature.d.ts +87 -0
- package/dist/graphify/signature.d.ts.map +1 -0
- package/dist/graphify/signature.js +276 -0
- package/dist/graphify/signature.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +68 -5
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +183 -38
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +7 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +25 -15
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/declarationRef.d.ts +39 -0
- package/dist/subsystem/declarationRef.d.ts.map +1 -0
- package/dist/subsystem/declarationRef.js +32 -0
- package/dist/subsystem/declarationRef.js.map +1 -0
- package/dist/subsystem/model.d.ts +6 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/anchor.test.ts +124 -0
- package/src/graphify/anchor.ts +160 -0
- package/src/graphify/ids.ts +48 -0
- package/src/graphify/index.ts +26 -0
- package/src/graphify/kind.test.ts +108 -0
- package/src/graphify/kind.ts +114 -0
- package/src/graphify/signature.test.ts +278 -0
- package/src/graphify/signature.ts +354 -0
- package/src/index.ts +37 -0
- package/src/subsystem/ComponentDeclaration.tsx +318 -44
- package/src/subsystem/SubsystemComponentGraph.tsx +53 -19
- package/src/subsystem/declarationRef.test.ts +33 -0
- package/src/subsystem/declarationRef.ts +63 -0
- package/src/subsystem/model.ts +6 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
10
|
-
import { AlignLeft, FileText } from 'lucide-react';
|
|
10
|
+
import { AlignLeft, FileText, ShieldCheck } from 'lucide-react';
|
|
11
11
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
12
12
|
import {
|
|
13
13
|
KIND_COLOR,
|
|
@@ -15,8 +15,64 @@ import {
|
|
|
15
15
|
type SubsystemComponent,
|
|
16
16
|
type SubsystemDeclTokenKind,
|
|
17
17
|
} from './model';
|
|
18
|
+
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
19
|
+
import { parseSourceLocation } from './declarationRef';
|
|
18
20
|
import { tokenizeComponent } from './tokenizeComponent';
|
|
19
21
|
|
|
22
|
+
/** Live / result state for the declaration-panel Verify control. */
|
|
23
|
+
export type ComponentVerificationPhase =
|
|
24
|
+
| 'idle'
|
|
25
|
+
| 'checking'
|
|
26
|
+
| 'done'
|
|
27
|
+
| 'error';
|
|
28
|
+
|
|
29
|
+
export interface ComponentVerificationState {
|
|
30
|
+
phase: ComponentVerificationPhase;
|
|
31
|
+
message?: string;
|
|
32
|
+
/** Mirrors host `ok` when a structured verify result is available. */
|
|
33
|
+
ok?: boolean;
|
|
34
|
+
code?: string;
|
|
35
|
+
file?: {
|
|
36
|
+
exists: boolean;
|
|
37
|
+
symbolDeclared?: boolean | null;
|
|
38
|
+
};
|
|
39
|
+
cache?: {
|
|
40
|
+
status: 'ready' | 'missing' | 'unavailable';
|
|
41
|
+
purl: string;
|
|
42
|
+
};
|
|
43
|
+
anchor?: {
|
|
44
|
+
resolution: 'exact' | 'file-only' | 'ambiguous' | 'missing';
|
|
45
|
+
nodeId?: string;
|
|
46
|
+
label?: string;
|
|
47
|
+
source_file?: string;
|
|
48
|
+
source_location?: string;
|
|
49
|
+
candidates?: Array<{ nodeId: string; label: string; source_file?: string }>;
|
|
50
|
+
};
|
|
51
|
+
kind?: {
|
|
52
|
+
claimed: string;
|
|
53
|
+
inferred: string;
|
|
54
|
+
match: boolean;
|
|
55
|
+
evidence?: string[];
|
|
56
|
+
};
|
|
57
|
+
signature?: {
|
|
58
|
+
match: boolean;
|
|
59
|
+
skipped: boolean;
|
|
60
|
+
skipCode?: string;
|
|
61
|
+
reason?: string;
|
|
62
|
+
claimed: { parameterTypes: string[]; returnTypes: string[] };
|
|
63
|
+
inferred: { parameterTypes: string[]; returnTypes: string[] };
|
|
64
|
+
/** Graphify `inline_parameter` marker count (anonymous eager args). */
|
|
65
|
+
inlineParameters?: number;
|
|
66
|
+
};
|
|
67
|
+
declaration?: {
|
|
68
|
+
freshness: 'valid' | 'stale' | 'missing' | 'unanchored' | 'unchecked';
|
|
69
|
+
ref?: {
|
|
70
|
+
startLine: number;
|
|
71
|
+
lineHash: string;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
/** Shared affordance for clickable text pieces. */
|
|
21
77
|
const clickableStyle = {
|
|
22
78
|
cursor: 'pointer',
|
|
@@ -46,31 +102,169 @@ function DetailLink({ children, onClick }: { children: ReactNode; onClick?: () =
|
|
|
46
102
|
);
|
|
47
103
|
}
|
|
48
104
|
|
|
49
|
-
/** Panel props.
|
|
50
|
-
*
|
|
105
|
+
/** Panel props. Callbacks are wired by the graph; standalone usage without
|
|
106
|
+
* them renders the same panel with nothing clickable. */
|
|
51
107
|
export interface ComponentDeclarationProps {
|
|
52
108
|
component: SubsystemComponent;
|
|
53
|
-
/** File-path click → open that file in the bottom drawer. */
|
|
54
|
-
onOpenFile?: (file: string) => void;
|
|
109
|
+
/** File-path click → open that file in the bottom drawer (optional start line). */
|
|
110
|
+
onOpenFile?: (file: string, opts?: SubsystemOpenFileOptions) => void;
|
|
55
111
|
/** Related-name click (types, callers/callees, implementors, …) → select
|
|
56
112
|
* that component if one matches; unmatched refs no-op. */
|
|
57
113
|
onRelatedSelect?: (ref: string) => void;
|
|
58
114
|
/** Max width of the declaration panel (CSS value). Defaults to none. */
|
|
59
115
|
maxWidth?: string | number;
|
|
116
|
+
/** When set, shows a Verify control that calls back with the component id. */
|
|
117
|
+
onVerify?: (componentId: string) => void;
|
|
118
|
+
/** Live verification status for the selected component. */
|
|
119
|
+
verification?: ComponentVerificationState | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function verificationSummary(
|
|
123
|
+
v: ComponentVerificationState,
|
|
124
|
+
muted: string,
|
|
125
|
+
ok: string,
|
|
126
|
+
warn: string,
|
|
127
|
+
): { text: string; color: string } {
|
|
128
|
+
if (v.phase === 'checking') {
|
|
129
|
+
return { text: v.message ?? 'Verifying…', color: muted };
|
|
130
|
+
}
|
|
131
|
+
if (v.phase === 'error') {
|
|
132
|
+
return { text: v.message ?? 'Verification failed', color: '#e5534b' };
|
|
133
|
+
}
|
|
134
|
+
if (v.phase !== 'done') {
|
|
135
|
+
return { text: '', color: muted };
|
|
136
|
+
}
|
|
137
|
+
const bits: string[] = [];
|
|
138
|
+
if (v.file) {
|
|
139
|
+
if (!v.file.exists) bits.push('file missing');
|
|
140
|
+
else if (v.file.symbolDeclared === false) bits.push('symbol not declared in file');
|
|
141
|
+
else if (v.file.symbolDeclared === true) bits.push('file+symbol ok');
|
|
142
|
+
else bits.push('file exists');
|
|
143
|
+
}
|
|
144
|
+
if (v.cache?.status === 'missing') {
|
|
145
|
+
return {
|
|
146
|
+
text: [...bits, 'graphify cache not ready — run graphify from Subsystems list'].join(' · '),
|
|
147
|
+
color: warn,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (v.cache?.status === 'unavailable') {
|
|
151
|
+
return {
|
|
152
|
+
text: [...bits, 'no local checkout for this purl'].join(' · '),
|
|
153
|
+
color: muted,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const res = v.anchor?.resolution;
|
|
157
|
+
if (res === 'exact') {
|
|
158
|
+
const loc = v.anchor?.source_location ? ` @ ${v.anchor.source_location}` : '';
|
|
159
|
+
const declFresh = v.declaration?.freshness;
|
|
160
|
+
const declBit =
|
|
161
|
+
declFresh && declFresh !== 'unanchored'
|
|
162
|
+
? ` · declaration ${declFresh}${v.declaration?.ref ? ` L${v.declaration.ref.startLine}` : ''}`
|
|
163
|
+
: '';
|
|
164
|
+
const anchorBit = `exact → ${v.anchor?.label ?? v.anchor?.nodeId ?? 'node'}${loc}${declBit}`;
|
|
165
|
+
if (v.kind && !v.kind.match) {
|
|
166
|
+
const why =
|
|
167
|
+
v.kind.inferred === 'unknown'
|
|
168
|
+
? `kind unknown (claimed ${v.kind.claimed})`
|
|
169
|
+
: `kind mismatch: claimed ${v.kind.claimed}, inferred ${v.kind.inferred}`;
|
|
170
|
+
return {
|
|
171
|
+
text: [...bits, anchorBit, why].join(' · '),
|
|
172
|
+
color: '#e5534b',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (v.signature && !v.signature.skipped && !v.signature.match) {
|
|
176
|
+
const cParams = v.signature.claimed.parameterTypes.join(', ') || '∅';
|
|
177
|
+
const iParams = v.signature.inferred.parameterTypes.join(', ') || '∅';
|
|
178
|
+
const cRet = v.signature.claimed.returnTypes.join(', ') || '∅';
|
|
179
|
+
const iRet = v.signature.inferred.returnTypes.join(', ') || '∅';
|
|
180
|
+
return {
|
|
181
|
+
text: [
|
|
182
|
+
...bits,
|
|
183
|
+
anchorBit,
|
|
184
|
+
v.kind?.match ? `kind ${v.kind.inferred}` : undefined,
|
|
185
|
+
`signature mismatch: params [${cParams}]≠[${iParams}] return [${cRet}]≠[${iRet}]`,
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(' · '),
|
|
189
|
+
color: '#e5534b',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const kindBit =
|
|
193
|
+
v.kind?.match ? `kind ${v.kind.inferred}` : undefined;
|
|
194
|
+
const inlineBit =
|
|
195
|
+
v.signature?.inlineParameters
|
|
196
|
+
? `${v.signature.inlineParameters} inline param${v.signature.inlineParameters === 1 ? '' : 's'} verified`
|
|
197
|
+
: undefined;
|
|
198
|
+
if (v.signature?.skipped) {
|
|
199
|
+
const skipPhrase: Record<string, string> = {
|
|
200
|
+
no_claimed_types: 'no claimable named types (primitives/inline only)',
|
|
201
|
+
generic_arg_only:
|
|
202
|
+
'return only as generic_arg — wrapper types (Promise/Omit/Map/Array) not read as return edges',
|
|
203
|
+
partially_generic_arg:
|
|
204
|
+
'claimed types resolve only via generic_arg; rest unresolved',
|
|
205
|
+
unresolved_claimed_types:
|
|
206
|
+
'claimed return type has no graphify edge (npm/global/DOM type)',
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
text: [
|
|
210
|
+
...bits,
|
|
211
|
+
anchorBit,
|
|
212
|
+
...(kindBit ? [kindBit] : []),
|
|
213
|
+
...(inlineBit ? [inlineBit] : []),
|
|
214
|
+
v.signature.skipCode
|
|
215
|
+
? `params/return not fully checked — ${skipPhrase[v.signature.skipCode] ?? v.signature.skipCode}`
|
|
216
|
+
: 'params/return not checked',
|
|
217
|
+
].join(' · '),
|
|
218
|
+
color: warn,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const sigBit =
|
|
222
|
+
v.signature && v.signature.match ? 'params/return ok' : undefined;
|
|
223
|
+
return {
|
|
224
|
+
text: [...bits, anchorBit, ...(kindBit ? [kindBit] : []), ...(sigBit ? [sigBit] : [])].join(' · '),
|
|
225
|
+
color: ok,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (res === 'file-only') {
|
|
229
|
+
return {
|
|
230
|
+
text: [...bits, 'file in graph, symbol not uniquely matched'].join(' · '),
|
|
231
|
+
color: warn,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (res === 'ambiguous') {
|
|
235
|
+
const n = v.anchor?.candidates?.length ?? 0;
|
|
236
|
+
return {
|
|
237
|
+
text: [...bits, `ambiguous (${n} candidates)`].join(' · '),
|
|
238
|
+
color: warn,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (res === 'missing') {
|
|
242
|
+
return {
|
|
243
|
+
text: [...bits, 'no matching graphify node'].join(' · '),
|
|
244
|
+
color: '#e5534b',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { text: bits.join(' · ') || 'done', color: muted };
|
|
60
248
|
}
|
|
61
249
|
|
|
62
250
|
/** Declaration panel for the selected component — its definition, code-style. */
|
|
63
|
-
export function ComponentDeclaration({
|
|
251
|
+
export function ComponentDeclaration({
|
|
252
|
+
component,
|
|
253
|
+
onOpenFile,
|
|
254
|
+
onRelatedSelect: _onRelatedSelect,
|
|
255
|
+
maxWidth,
|
|
256
|
+
onVerify,
|
|
257
|
+
verification,
|
|
258
|
+
}: ComponentDeclarationProps) {
|
|
64
259
|
const { theme } = useTheme();
|
|
65
260
|
const [fileHovered, setFileHovered] = useState(false);
|
|
66
|
-
// Header visibility toggles — off by default so the panel opens as pure
|
|
67
|
-
// code; the repo-row icons bring the path and description back.
|
|
68
261
|
const [showFile, setShowFile] = useState(false);
|
|
69
262
|
const [showPurpose, setShowPurpose] = useState(false);
|
|
70
263
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
71
264
|
const color = KIND_COLOR[component.kind] ?? '#888';
|
|
265
|
+
const okColor = '#3d9a5f';
|
|
266
|
+
const warnColor = theme.colors.textSecondary;
|
|
72
267
|
|
|
73
|
-
// Token-kind → theme-color map (used by the token iterator below).
|
|
74
268
|
const tokenColor: Record<SubsystemDeclTokenKind, string> = {
|
|
75
269
|
keyword: theme.colors.secondary,
|
|
76
270
|
name: color,
|
|
@@ -78,7 +272,7 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
78
272
|
type: theme.colors.accent,
|
|
79
273
|
punctuation: muted,
|
|
80
274
|
string: theme.colors.success,
|
|
81
|
-
newline: '',
|
|
275
|
+
newline: '',
|
|
82
276
|
};
|
|
83
277
|
|
|
84
278
|
const line = (children: ReactNode, key?: string, indent?: boolean | number) => {
|
|
@@ -98,9 +292,6 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
98
292
|
key,
|
|
99
293
|
indent,
|
|
100
294
|
);
|
|
101
|
-
// Purpose is the panel's actual prose, not code — body font, readable
|
|
102
|
-
// size/contrast, capped measure. The `//` metadata lines above it stay
|
|
103
|
-
// muted mono on purpose.
|
|
104
295
|
const purposeStyle = {
|
|
105
296
|
color: theme.colors.text,
|
|
106
297
|
fontFamily: theme.fonts.body,
|
|
@@ -108,17 +299,9 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
108
299
|
lineHeight: 1.5,
|
|
109
300
|
maxWidth: '52ch',
|
|
110
301
|
} as const;
|
|
111
|
-
|
|
112
|
-
// `// implemented by:`, …) are deliberately not rendered — the graph's
|
|
113
|
-
// edges carry interactions, and duplicated name lists read as clutter. The
|
|
114
|
-
// data stays on the Graphify detail types for other surfaces.
|
|
302
|
+
|
|
115
303
|
const lines: ReactNode[] = [];
|
|
116
|
-
// Repo identity — owner avatar + name for GitHub-hosted purls; anything
|
|
117
|
-
// else falls back to the formatted purl as a quiet comment. The avatar
|
|
118
|
-
// hides itself on load failure (offline / blocked hosts).
|
|
119
304
|
const ghMatch = /^pkg:github\/([^/]+)\/([^/#?]+)/.exec(component.purl ?? '');
|
|
120
|
-
// Small ghost icon button for the header toggles — accent when its section
|
|
121
|
-
// is shown, muted when hidden.
|
|
122
305
|
const toggleBtn = (on: boolean, onClick: () => void, title: string, Icon: typeof FileText) => (
|
|
123
306
|
<button
|
|
124
307
|
type="button"
|
|
@@ -142,6 +325,44 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
142
325
|
<Icon size={13} />
|
|
143
326
|
</button>
|
|
144
327
|
);
|
|
328
|
+
const verifyBusy = verification?.phase === 'checking';
|
|
329
|
+
const headerActions = (
|
|
330
|
+
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
331
|
+
{onVerify && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
title="Verify against graphify"
|
|
335
|
+
aria-label="Verify against graphify"
|
|
336
|
+
disabled={verifyBusy}
|
|
337
|
+
onClick={(e) => {
|
|
338
|
+
e.stopPropagation();
|
|
339
|
+
onVerify(component.id);
|
|
340
|
+
}}
|
|
341
|
+
style={{
|
|
342
|
+
display: 'flex',
|
|
343
|
+
alignItems: 'center',
|
|
344
|
+
gap: 4,
|
|
345
|
+
height: 20,
|
|
346
|
+
padding: '0 6px',
|
|
347
|
+
border: `1px solid ${theme.colors.border}`,
|
|
348
|
+
borderRadius: 4,
|
|
349
|
+
background: 'transparent',
|
|
350
|
+
color: verifyBusy ? muted : theme.colors.textSecondary,
|
|
351
|
+
cursor: verifyBusy ? 'wait' : 'pointer',
|
|
352
|
+
fontSize: theme.fontSizes[0],
|
|
353
|
+
fontFamily: theme.fonts.body,
|
|
354
|
+
opacity: verifyBusy ? 0.7 : 1,
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<ShieldCheck size={12} />
|
|
358
|
+
{verifyBusy ? '…' : 'Verify'}
|
|
359
|
+
</button>
|
|
360
|
+
)}
|
|
361
|
+
{toggleBtn(showFile, () => setShowFile((v) => !v), 'Toggle file path', FileText)}
|
|
362
|
+
{toggleBtn(showPurpose, () => setShowPurpose((v) => !v), 'Toggle description', AlignLeft)}
|
|
363
|
+
</span>
|
|
364
|
+
);
|
|
365
|
+
|
|
145
366
|
if (ghMatch) {
|
|
146
367
|
const [, ghOwner, ghRepo] = ghMatch;
|
|
147
368
|
lines.push(
|
|
@@ -166,27 +387,87 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
166
387
|
>
|
|
167
388
|
{ghRepo}
|
|
168
389
|
</span>
|
|
169
|
-
|
|
170
|
-
{toggleBtn(showFile, () => setShowFile((v) => !v), 'Toggle file path', FileText)}
|
|
171
|
-
{toggleBtn(showPurpose, () => setShowPurpose((v) => !v), 'Toggle description', AlignLeft)}
|
|
172
|
-
</span>
|
|
390
|
+
{headerActions}
|
|
173
391
|
</span>,
|
|
174
392
|
'repo',
|
|
175
393
|
),
|
|
176
394
|
);
|
|
177
395
|
} else if (component.purl) {
|
|
178
|
-
lines.push(
|
|
396
|
+
lines.push(
|
|
397
|
+
line(
|
|
398
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%' }}>
|
|
399
|
+
<span style={commentStyle}>{`// ${formatPurl(component.purl)}`}</span>
|
|
400
|
+
{headerActions}
|
|
401
|
+
</span>,
|
|
402
|
+
'purl',
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
} else if (onVerify) {
|
|
406
|
+
lines.push(line(headerActions, 'actions'));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (verification && verification.phase !== 'idle') {
|
|
410
|
+
const summary = verificationSummary(verification, muted, okColor, warnColor);
|
|
411
|
+
if (summary.text) {
|
|
412
|
+
lines.push(
|
|
413
|
+
line(
|
|
414
|
+
<span
|
|
415
|
+
style={{
|
|
416
|
+
color: summary.color,
|
|
417
|
+
fontFamily: theme.fonts.body,
|
|
418
|
+
fontSize: theme.fontSizes[0],
|
|
419
|
+
whiteSpace: 'normal',
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{summary.text}
|
|
423
|
+
</span>,
|
|
424
|
+
'verify-status',
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
verification.anchor?.resolution === 'ambiguous' &&
|
|
430
|
+
verification.anchor.candidates &&
|
|
431
|
+
verification.anchor.candidates.length > 0
|
|
432
|
+
) {
|
|
433
|
+
for (const c of verification.anchor.candidates.slice(0, 5)) {
|
|
434
|
+
lines.push(
|
|
435
|
+
commentLine(
|
|
436
|
+
`// ${c.label}${c.source_file ? ` — ${c.source_file}` : ''}`,
|
|
437
|
+
`cand-${c.nodeId}`,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (verification.signature?.skipped && verification.signature.reason) {
|
|
443
|
+
lines.push(
|
|
444
|
+
commentLine(
|
|
445
|
+
`// why: ${verification.signature.reason}`,
|
|
446
|
+
'verify-skip-reason',
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
179
450
|
}
|
|
180
|
-
|
|
451
|
+
|
|
452
|
+
if (showFile && component.file) {
|
|
453
|
+
const startLine =
|
|
454
|
+
component.declarationRef?.startLine ??
|
|
455
|
+
verification?.declaration?.ref?.startLine ??
|
|
456
|
+
parseSourceLocation(verification?.anchor?.source_location) ??
|
|
457
|
+
undefined;
|
|
181
458
|
lines.push(
|
|
182
459
|
line(
|
|
183
|
-
<DetailLink
|
|
460
|
+
<DetailLink
|
|
461
|
+
onClick={
|
|
462
|
+
onOpenFile
|
|
463
|
+
? () => onOpenFile(component.file, startLine != null ? { startLine } : undefined)
|
|
464
|
+
: undefined
|
|
465
|
+
}
|
|
466
|
+
>
|
|
184
467
|
<span
|
|
185
468
|
onMouseEnter={() => setFileHovered(true)}
|
|
186
469
|
onMouseLeave={() => setFileHovered(false)}
|
|
187
470
|
style={{
|
|
188
|
-
// Path as UI text, not a code comment — matches the repo-name
|
|
189
|
-
// row. Quiet until hover; the underline only appears then.
|
|
190
471
|
color: theme.colors.textSecondary ?? muted,
|
|
191
472
|
fontFamily: theme.fonts.body,
|
|
192
473
|
fontSize: theme.fontSizes[1],
|
|
@@ -195,15 +476,14 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
195
476
|
}}
|
|
196
477
|
>
|
|
197
478
|
{component.file}
|
|
479
|
+
{startLine != null ? `:${startLine}` : ''}
|
|
198
480
|
</span>
|
|
199
481
|
</DetailLink>,
|
|
200
482
|
'file',
|
|
201
483
|
),
|
|
202
484
|
);
|
|
485
|
+
}
|
|
203
486
|
|
|
204
|
-
// --- Container width measurement --------------------------------------
|
|
205
|
-
// Approximate character count from the container's clientWidth so Prettier
|
|
206
|
-
// wraps at the same column the code block will actually use.
|
|
207
487
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
208
488
|
const [printWidth, setPrintWidth] = useState(80);
|
|
209
489
|
|
|
@@ -212,8 +492,6 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
212
492
|
if (!el) return;
|
|
213
493
|
|
|
214
494
|
function measureWidth() {
|
|
215
|
-
// Measure a single `ch` using an offscreen element so we don't disturb
|
|
216
|
-
// the component's own DOM.
|
|
217
495
|
const probe = document.createElement('div');
|
|
218
496
|
probe.style.cssText =
|
|
219
497
|
'position:absolute;visibility:hidden;white-space:pre;' +
|
|
@@ -233,9 +511,6 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
233
511
|
return () => ro.disconnect();
|
|
234
512
|
}, []);
|
|
235
513
|
|
|
236
|
-
// --- Declaration tokens ------------------------------------------------
|
|
237
|
-
// Tokenize asynchronously via Prettier + highlight.js. Pre-tokenized
|
|
238
|
-
// tokens from the wire skip the pipeline entirely.
|
|
239
514
|
const [tokens, setTokens] = useState(component.tokens ?? []);
|
|
240
515
|
useEffect(() => {
|
|
241
516
|
if (component.tokens) {
|
|
@@ -246,7 +521,9 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
246
521
|
tokenizeComponent(component, printWidth).then((t) => {
|
|
247
522
|
if (!cancelled) setTokens(t);
|
|
248
523
|
});
|
|
249
|
-
return () => {
|
|
524
|
+
return () => {
|
|
525
|
+
cancelled = true;
|
|
526
|
+
};
|
|
250
527
|
}, [component, printWidth]);
|
|
251
528
|
const declLines: ReactNode[][] = [[]];
|
|
252
529
|
let di = 0;
|
|
@@ -271,12 +548,9 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _
|
|
|
271
548
|
);
|
|
272
549
|
}
|
|
273
550
|
|
|
274
|
-
// Purpose prose follows the declaration — identity and syntax first, the
|
|
275
|
-
// explanation underneath. Newlines split into separate prose lines so a
|
|
276
|
-
// two-thought purpose reads as a short paragraph instead of one blob.
|
|
277
551
|
if (showPurpose && component.purpose) {
|
|
278
552
|
lines.push(line(<div style={{ height: 6 }} />, 'purpose-gap'));
|
|
279
|
-
component.purpose.split(
|
|
553
|
+
component.purpose.split('\n').forEach((purposeLine, i) =>
|
|
280
554
|
lines.push(
|
|
281
555
|
line(
|
|
282
556
|
<span style={{ ...purposeStyle, display: 'block' }} key={`purpose${i}`}>
|
|
@@ -41,10 +41,12 @@ import {
|
|
|
41
41
|
type SubsystemComponent,
|
|
42
42
|
type SubsystemEdgeMechanism,
|
|
43
43
|
} from './model';
|
|
44
|
+
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
44
45
|
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
45
46
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
46
47
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
47
48
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
49
|
+
import type { ComponentVerificationState } from './ComponentDeclaration';
|
|
48
50
|
import { FileDrawer } from './FileDrawer';
|
|
49
51
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
50
52
|
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
@@ -76,7 +78,7 @@ export interface SubsystemComponentGraphProps {
|
|
|
76
78
|
* `file`) and sidebar file-tree click. Keeps this package free of fs and
|
|
77
79
|
* code-view dependencies.
|
|
78
80
|
*/
|
|
79
|
-
renderFileViewer?: (file: string) => ReactNode;
|
|
81
|
+
renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
80
82
|
/**
|
|
81
83
|
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
82
84
|
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
@@ -88,6 +90,10 @@ export interface SubsystemComponentGraphProps {
|
|
|
88
90
|
* path). The tree is derived from the components' `file` values.
|
|
89
91
|
*/
|
|
90
92
|
onFileSelect?: (file: string) => void;
|
|
93
|
+
/** Verify the selected component against graphify (declaration panel). */
|
|
94
|
+
onVerifyComponent?: (componentId: string) => void;
|
|
95
|
+
/** Live verification status for the selected component. */
|
|
96
|
+
componentVerification?: ComponentVerificationState | null;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
const nodeTypes: NodeTypes = {
|
|
@@ -105,19 +111,21 @@ const edgeTypes: EdgeTypes = {
|
|
|
105
111
|
const DrawerContent = memo(function DrawerContent({
|
|
106
112
|
render,
|
|
107
113
|
file,
|
|
114
|
+
startLine,
|
|
108
115
|
}: {
|
|
109
|
-
render: (file: string) => ReactNode;
|
|
116
|
+
render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
110
117
|
file: string | null;
|
|
118
|
+
startLine?: number;
|
|
111
119
|
}) {
|
|
112
120
|
if (!file) return null;
|
|
113
|
-
return <>{render(file)}</>;
|
|
121
|
+
return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
|
|
114
122
|
});
|
|
115
123
|
|
|
116
124
|
interface InnerProps extends SubsystemComponentGraphProps {
|
|
117
125
|
measured: { w: number; h: number } | null;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
|
|
128
|
+
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
121
129
|
const { theme } = useTheme();
|
|
122
130
|
const { fitView } = useReactFlow();
|
|
123
131
|
const viewport = useViewport();
|
|
@@ -127,8 +135,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
127
135
|
});
|
|
128
136
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
129
137
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
130
|
-
|
|
131
|
-
const [
|
|
138
|
+
/** File shown in the bottom drawer + optional declaration scroll target. */
|
|
139
|
+
const [openFileTarget, setOpenFileTarget] = useState<{
|
|
140
|
+
file: string;
|
|
141
|
+
startLine?: number;
|
|
142
|
+
} | null>(null);
|
|
143
|
+
const openFile = openFileTarget?.file ?? null;
|
|
144
|
+
const openFileStartLine = openFileTarget?.startLine;
|
|
132
145
|
// Edge-legend modal visibility (opened from the canvas's top-left button).
|
|
133
146
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
134
147
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
@@ -138,8 +151,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
138
151
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
139
152
|
selectedRef.current = selected;
|
|
140
153
|
// Ref mirror of `openFile` for the tree-click toggle.
|
|
141
|
-
const openFileRef = useRef<string | null>(null);
|
|
142
|
-
openFileRef.current =
|
|
154
|
+
const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
|
|
155
|
+
openFileRef.current = openFileTarget;
|
|
143
156
|
|
|
144
157
|
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
145
158
|
// The pane stays hidden until Pass 2 completes.
|
|
@@ -367,11 +380,27 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
367
380
|
);
|
|
368
381
|
const onTreeSelectFile = useCallback(
|
|
369
382
|
(file: string) => {
|
|
370
|
-
if (openFileRef.current === file) {
|
|
371
|
-
|
|
383
|
+
if (openFileRef.current?.file === file && openFileRef.current.startLine == null) {
|
|
384
|
+
setOpenFileTarget(null);
|
|
372
385
|
return;
|
|
373
386
|
}
|
|
374
|
-
|
|
387
|
+
setOpenFileTarget({ file });
|
|
388
|
+
onFileSelect?.(file);
|
|
389
|
+
},
|
|
390
|
+
[onFileSelect],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const onOpenDeclarationFile = useCallback(
|
|
394
|
+
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
395
|
+
const startLine = opts?.startLine;
|
|
396
|
+
if (
|
|
397
|
+
openFileRef.current?.file === file &&
|
|
398
|
+
openFileRef.current.startLine === startLine
|
|
399
|
+
) {
|
|
400
|
+
setOpenFileTarget(null);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
setOpenFileTarget({ file, startLine });
|
|
375
404
|
onFileSelect?.(file);
|
|
376
405
|
},
|
|
377
406
|
[onFileSelect],
|
|
@@ -434,20 +463,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
434
463
|
const byFile = new Map(
|
|
435
464
|
components.filter((c) => c.file).map((c) => [c.file, c] as const),
|
|
436
465
|
);
|
|
437
|
-
return (file: string) => {
|
|
466
|
+
return (file: string, _opts?: SubsystemOpenFileOptions) => {
|
|
438
467
|
const comp = byFile.get(file);
|
|
439
468
|
return comp ? renderFileView(comp) : null;
|
|
440
469
|
};
|
|
441
470
|
}
|
|
442
|
-
return
|
|
471
|
+
return renderFileViewer;
|
|
443
472
|
}, [renderFileViewer, renderFileView, components]);
|
|
444
473
|
|
|
445
|
-
// Latest-viewer ref + stable callback so DrawerContent's memo holds across
|
|
446
|
-
// unrelated Inner renders (see comment there).
|
|
447
474
|
const fileViewerRef = useRef(fileViewer);
|
|
448
475
|
fileViewerRef.current = fileViewer;
|
|
449
476
|
const renderDrawerContent = useCallback(
|
|
450
|
-
(file: string
|
|
477
|
+
(file: string, opts?: SubsystemOpenFileOptions) =>
|
|
478
|
+
fileViewerRef.current?.(file, opts) ?? null,
|
|
451
479
|
[],
|
|
452
480
|
);
|
|
453
481
|
|
|
@@ -697,8 +725,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
697
725
|
>
|
|
698
726
|
<ComponentDeclaration
|
|
699
727
|
component={selected}
|
|
700
|
-
onOpenFile={
|
|
728
|
+
onOpenFile={onOpenDeclarationFile}
|
|
701
729
|
onRelatedSelect={resolveRelatedComponent}
|
|
730
|
+
onVerify={onVerifyComponent}
|
|
731
|
+
verification={componentVerification}
|
|
702
732
|
/>
|
|
703
733
|
</div>
|
|
704
734
|
)}
|
|
@@ -708,8 +738,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
708
738
|
mechanisms={usedMechanisms}
|
|
709
739
|
onClose={() => setLegendOpen(false)}
|
|
710
740
|
/>
|
|
711
|
-
<FileDrawer file={openFile} onClose={() =>
|
|
712
|
-
<DrawerContent
|
|
741
|
+
<FileDrawer file={openFile} onClose={() => setOpenFileTarget(null)}>
|
|
742
|
+
<DrawerContent
|
|
743
|
+
render={renderDrawerContent}
|
|
744
|
+
file={openFile}
|
|
745
|
+
startLine={openFileStartLine}
|
|
746
|
+
/>
|
|
713
747
|
</FileDrawer>
|
|
714
748
|
{/* Startup cover — hides measurement, layout swap, and camera settle. */}
|
|
715
749
|
<GraphLayoutCover revealed={layoutReady} />
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
extractDeclarationLine,
|
|
4
|
+
normalizeDeclarationLine,
|
|
5
|
+
parseSourceLocation,
|
|
6
|
+
} from './declarationRef';
|
|
7
|
+
|
|
8
|
+
describe('parseSourceLocation', () => {
|
|
9
|
+
test('parses L-prefixed lines', () => {
|
|
10
|
+
expect(parseSourceLocation('L42')).toBe(42);
|
|
11
|
+
expect(parseSourceLocation(' L1 ')).toBe(1);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('rejects invalid', () => {
|
|
15
|
+
expect(parseSourceLocation('42')).toBeNull();
|
|
16
|
+
expect(parseSourceLocation('')).toBeNull();
|
|
17
|
+
expect(parseSourceLocation(undefined)).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('normalizeDeclarationLine', () => {
|
|
22
|
+
test('trims trailing whitespace and CR', () => {
|
|
23
|
+
expect(normalizeDeclarationLine('export class Foo {\r')).toBe('export class Foo {');
|
|
24
|
+
expect(normalizeDeclarationLine(' x ')).toBe(' x');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('extractDeclarationLine', () => {
|
|
29
|
+
test('1-based indexing', () => {
|
|
30
|
+
expect(extractDeclarationLine('a\nb\nc', 2)).toBe('b');
|
|
31
|
+
expect(extractDeclarationLine('a\nb', 3)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|