@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.
Files changed (54) hide show
  1. package/dist/graphify/anchor.d.ts +28 -0
  2. package/dist/graphify/anchor.d.ts.map +1 -0
  3. package/dist/graphify/anchor.js +132 -0
  4. package/dist/graphify/anchor.js.map +1 -0
  5. package/dist/graphify/ids.d.ts +19 -0
  6. package/dist/graphify/ids.d.ts.map +1 -0
  7. package/dist/graphify/ids.js +46 -0
  8. package/dist/graphify/ids.js.map +1 -0
  9. package/dist/graphify/index.d.ts +7 -0
  10. package/dist/graphify/index.d.ts.map +1 -1
  11. package/dist/graphify/index.js +4 -0
  12. package/dist/graphify/index.js.map +1 -1
  13. package/dist/graphify/kind.d.ts +23 -0
  14. package/dist/graphify/kind.d.ts.map +1 -0
  15. package/dist/graphify/kind.js +71 -0
  16. package/dist/graphify/kind.js.map +1 -0
  17. package/dist/graphify/signature.d.ts +87 -0
  18. package/dist/graphify/signature.d.ts.map +1 -0
  19. package/dist/graphify/signature.js +276 -0
  20. package/dist/graphify/signature.js.map +1 -0
  21. package/dist/index.d.ts +6 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/subsystem/ComponentDeclaration.d.ts +68 -5
  26. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  27. package/dist/subsystem/ComponentDeclaration.js +183 -38
  28. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.d.ts +7 -1
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +25 -15
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/declarationRef.d.ts +39 -0
  34. package/dist/subsystem/declarationRef.d.ts.map +1 -0
  35. package/dist/subsystem/declarationRef.js +32 -0
  36. package/dist/subsystem/declarationRef.js.map +1 -0
  37. package/dist/subsystem/model.d.ts +6 -0
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/graphify/anchor.test.ts +124 -0
  42. package/src/graphify/anchor.ts +160 -0
  43. package/src/graphify/ids.ts +48 -0
  44. package/src/graphify/index.ts +26 -0
  45. package/src/graphify/kind.test.ts +108 -0
  46. package/src/graphify/kind.ts +114 -0
  47. package/src/graphify/signature.test.ts +278 -0
  48. package/src/graphify/signature.ts +354 -0
  49. package/src/index.ts +37 -0
  50. package/src/subsystem/ComponentDeclaration.tsx +318 -44
  51. package/src/subsystem/SubsystemComponentGraph.tsx +53 -19
  52. package/src/subsystem/declarationRef.test.ts +33 -0
  53. package/src/subsystem/declarationRef.ts +63 -0
  54. 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. The two callbacks are wired by the graph; standalone usage
50
- * without them renders the same panel with nothing clickable. */
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({ component, onOpenFile, onRelatedSelect: _onRelatedSelect, maxWidth }: ComponentDeclarationProps) {
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: '', // not rendered — drives line breaks
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
- // Trailing relationship comments (`// called by:`, `// constructed by:`,
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
- <span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 2 }}>
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(commentLine(`// ${formatPurl(component.purl)}`, 'purl'));
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
- if (showFile && component.file)
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 onClick={onOpenFile ? () => onOpenFile(component.file) : undefined}>
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 () => { cancelled = true; };
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("\n").forEach((purposeLine, i) =>
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
- // File currently shown in the bottom drawer (repo-root-relative path).
131
- const [openFile, setOpenFile] = useState<string | null>(null);
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 = openFile;
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
- setOpenFile(null);
383
+ if (openFileRef.current?.file === file && openFileRef.current.startLine == null) {
384
+ setOpenFileTarget(null);
372
385
  return;
373
386
  }
374
- setOpenFile(file);
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 undefined;
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) => fileViewerRef.current?.(file) ?? null,
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={onTreeSelectFile}
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={() => setOpenFile(null)}>
712
- <DrawerContent render={renderDrawerContent} file={openFile} />
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
+ });