@principal-ai/subsystems-react 0.30.2 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/pierre/index.d.ts +2 -0
  2. package/dist/pierre/index.d.ts.map +1 -1
  3. package/dist/pierre/index.js +1 -0
  4. package/dist/pierre/index.js.map +1 -1
  5. package/dist/pierre/sourceLang.d.ts +20 -0
  6. package/dist/pierre/sourceLang.d.ts.map +1 -0
  7. package/dist/pierre/sourceLang.js +78 -0
  8. package/dist/pierre/sourceLang.js.map +1 -0
  9. package/dist/pierre/syntaxTokens.d.ts +6 -4
  10. package/dist/pierre/syntaxTokens.d.ts.map +1 -1
  11. package/dist/pierre/syntaxTokens.js +22 -6
  12. package/dist/pierre/syntaxTokens.js.map +1 -1
  13. package/dist/subsystem/ComponentDeclaration.d.ts +1 -3
  14. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  15. package/dist/subsystem/ComponentDeclaration.js +8 -24
  16. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  17. package/dist/subsystem/ConstructsCatalog.d.ts +1 -2
  18. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  19. package/dist/subsystem/ConstructsCatalog.js +2 -2
  20. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts +0 -2
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  23. package/dist/subsystem/SubsystemComponentGraph.js +3 -3
  24. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  25. package/dist/subsystem/tokenizeComponent.d.ts +7 -5
  26. package/dist/subsystem/tokenizeComponent.d.ts.map +1 -1
  27. package/dist/subsystem/tokenizeComponent.js +38 -24
  28. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  29. package/dist/subsystem/tokenizeFormatted.d.ts +6 -5
  30. package/dist/subsystem/tokenizeFormatted.d.ts.map +1 -1
  31. package/dist/subsystem/tokenizeFormatted.js +6 -6
  32. package/dist/subsystem/tokenizeFormatted.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/pierre/index.ts +5 -0
  35. package/src/pierre/sourceLang.test.ts +40 -0
  36. package/src/pierre/sourceLang.ts +83 -0
  37. package/src/pierre/syntaxTokens.ts +30 -5
  38. package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +103 -0
  39. package/src/subsystem/ComponentDeclaration.tsx +6 -38
  40. package/src/subsystem/ConstructsCatalog.tsx +0 -3
  41. package/src/subsystem/SubsystemComponentGraph.tsx +1 -5
  42. package/src/subsystem/tokenizeComponent.test.ts +56 -0
  43. package/src/subsystem/tokenizeComponent.ts +44 -26
  44. package/src/subsystem/tokenizeFormatted.ts +7 -5
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { isPrettierSourceLang, sourceLangForPath } from './sourceLang';
3
+
4
+ describe('sourceLangForPath', () => {
5
+ test('maps JS/TS extensions to their grammar', () => {
6
+ expect(sourceLangForPath('src/a.ts')).toBe('typescript');
7
+ expect(sourceLangForPath('src/a.tsx')).toBe('tsx');
8
+ expect(sourceLangForPath('src/a.mjs')).toBe('javascript');
9
+ expect(sourceLangForPath('src/a.jsx')).toBe('jsx');
10
+ });
11
+
12
+ test('maps other languages', () => {
13
+ expect(sourceLangForPath('graphify/dedup.py')).toBe('python');
14
+ expect(sourceLangForPath('src/lib.rs')).toBe('rust');
15
+ expect(sourceLangForPath('cmd/main.go')).toBe('go');
16
+ });
17
+
18
+ test('unknown / missing path falls back to text', () => {
19
+ expect(sourceLangForPath(undefined)).toBe('text');
20
+ expect(sourceLangForPath('')).toBe('text');
21
+ expect(sourceLangForPath('Makefile')).toBe('text');
22
+ expect(sourceLangForPath('src/a.unknownext')).toBe('text');
23
+ });
24
+
25
+ test('C-family stays text (WebKit-safe grammars)', () => {
26
+ expect(sourceLangForPath('src/main.cpp')).toBe('text');
27
+ expect(sourceLangForPath('src/graph.h')).toBe('text');
28
+ });
29
+ });
30
+
31
+ describe('isPrettierSourceLang', () => {
32
+ test('true only for the bundled Prettier parsers', () => {
33
+ for (const lang of ['typescript', 'tsx', 'javascript', 'jsx']) {
34
+ expect(isPrettierSourceLang(lang)).toBe(true);
35
+ }
36
+ for (const lang of ['python', 'rust', 'go', 'text']) {
37
+ expect(isPrettierSourceLang(lang)).toBe(false);
38
+ }
39
+ });
40
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Source-language inference for a component's declaration.
3
+ *
4
+ * The declaration panel is language-agnostic in shape (it renders a normalized
5
+ * construct), but formatting + highlighting are language-specific: Prettier
6
+ * only ships JS/TS/CSS/HTML/etc. parsers, and Shiki needs a grammar id. Infer
7
+ * the language from the component's `file` extension so non-JS/TS sources are
8
+ * not forced through the TypeScript parser.
9
+ */
10
+
11
+ import { isPierreCFamilyPath } from './pierreFileLang';
12
+
13
+ /** A Shiki language id (e.g. `typescript`, `python`) or `text` when unknown. */
14
+ export type SourceSyntaxLang = string;
15
+
16
+ const EXT_TO_LANG: Record<string, SourceSyntaxLang> = {
17
+ ts: 'typescript',
18
+ mts: 'typescript',
19
+ cts: 'typescript',
20
+ tsx: 'tsx',
21
+ js: 'javascript',
22
+ mjs: 'javascript',
23
+ cjs: 'javascript',
24
+ jsx: 'jsx',
25
+ py: 'python',
26
+ pyi: 'python',
27
+ rb: 'ruby',
28
+ rs: 'rust',
29
+ go: 'go',
30
+ java: 'java',
31
+ kt: 'kotlin',
32
+ kts: 'kotlin',
33
+ swift: 'swift',
34
+ scala: 'scala',
35
+ php: 'php',
36
+ cs: 'csharp',
37
+ sh: 'shellscript',
38
+ bash: 'shellscript',
39
+ zsh: 'shellscript',
40
+ sql: 'sql',
41
+ graphql: 'graphql',
42
+ gql: 'graphql',
43
+ json: 'json',
44
+ jsonc: 'json',
45
+ yaml: 'yaml',
46
+ yml: 'yaml',
47
+ toml: 'toml',
48
+ md: 'markdown',
49
+ mdx: 'markdown',
50
+ html: 'html',
51
+ htm: 'html',
52
+ css: 'css',
53
+ scss: 'scss',
54
+ less: 'less',
55
+ vue: 'vue',
56
+ svelte: 'svelte',
57
+ };
58
+
59
+ /** Languages Prettier can parse (its bundled parsers, all via `typescript`). */
60
+ const PRETTIER_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
61
+
62
+ function extensionOf(path: string): string {
63
+ const base = path.split(/[/\\]/).pop() ?? path;
64
+ const dot = base.lastIndexOf('.');
65
+ if (dot < 0) return '';
66
+ return base.slice(dot + 1).toLowerCase();
67
+ }
68
+
69
+ /**
70
+ * Shiki language id for a source path, or `text` when unknown. C-family paths
71
+ * stay `text` (loading their grammars has trapped WebKit — see
72
+ * `pierreFileLang`).
73
+ */
74
+ export function sourceLangForPath(path?: string | null): SourceSyntaxLang {
75
+ if (!path) return 'text';
76
+ if (isPierreCFamilyPath(path)) return 'text';
77
+ return EXT_TO_LANG[extensionOf(path)] ?? 'text';
78
+ }
79
+
80
+ /** True when Prettier should format this language's generated declaration. */
81
+ export function isPrettierSourceLang(lang: SourceSyntaxLang): boolean {
82
+ return PRETTIER_LANGS.has(lang);
83
+ }
@@ -1,12 +1,18 @@
1
1
  /**
2
- * Tokenize TypeScript with Shiki + Pierre themes — same stack as @pierre/diffs.
2
+ * Tokenize source text with Shiki + Pierre themes — same stack as @pierre/diffs.
3
3
  */
4
4
 
5
- import { createHighlighter, type Highlighter, type ThemeInput } from 'shiki';
5
+ import {
6
+ createHighlighter,
7
+ type BundledLanguage,
8
+ type Highlighter,
9
+ type ThemeInput,
10
+ } from 'shiki';
6
11
  import pierreDark from '@pierre/theme/pierre-dark';
7
12
  import pierreLight from '@pierre/theme/pierre-light';
8
13
  import type { SubsystemDeclToken } from '../subsystem/model';
9
14
  import type { PierreSyntaxThemeName } from './pierreSyntaxTheme';
15
+ import type { SourceSyntaxLang } from './sourceLang';
10
16
 
11
17
  let highlighterPromise: Promise<Highlighter> | null = null;
12
18
 
@@ -23,21 +29,40 @@ async function getPierreHighlighter(): Promise<Highlighter> {
23
29
  return highlighterPromise;
24
30
  }
25
31
 
32
+ /** Load a grammar on demand; fall back to `text` when it isn't available. */
33
+ async function resolveLang(
34
+ highlighter: Highlighter,
35
+ lang: SourceSyntaxLang,
36
+ ): Promise<BundledLanguage | 'text'> {
37
+ if (lang === 'text') return 'text';
38
+ try {
39
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
40
+ await highlighter.loadLanguage(lang as BundledLanguage);
41
+ }
42
+ return lang as BundledLanguage;
43
+ } catch {
44
+ return 'text';
45
+ }
46
+ }
47
+
26
48
  function normalizeColor(color?: string): string | undefined {
27
49
  return color?.toLowerCase();
28
50
  }
29
51
 
30
52
  /**
31
- * Tokenize formatted TypeScript using Pierre's Shiki themes.
32
- * Each token carries the resolved foreground color Pierre would render.
53
+ * Tokenize code using Pierre's Shiki themes. Each token carries the resolved
54
+ * foreground color Pierre would render. `lang` is the inferred source language
55
+ * (`text` when unknown) — TS by default for backward compatibility.
33
56
  */
34
57
  export async function tokenizeWithPierreSyntax(
35
58
  code: string,
36
59
  themeName: PierreSyntaxThemeName,
60
+ lang: SourceSyntaxLang = 'typescript',
37
61
  ): Promise<SubsystemDeclToken[]> {
38
62
  const highlighter = await getPierreHighlighter();
63
+ const resolved = await resolveLang(highlighter, lang);
39
64
  const { tokens } = highlighter.codeToTokens(code, {
40
- lang: 'typescript',
65
+ lang: resolved,
41
66
  theme: themeName,
42
67
  });
43
68
 
@@ -397,3 +397,106 @@ export const AugmentedSignature: StoryObj<{ constrained: boolean }> = {
397
397
  </div>
398
398
  ),
399
399
  };
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Prettier fallback — when formatting throws (Electrobun's bundled Prettier
403
+ // does), the declaration is tokenized unformatted as one long line. This story
404
+ // reproduces that path deterministically: an unparseable parameter type makes
405
+ // Prettier throw, so `tokenizeComponent` falls back to the raw string. The
406
+ // panel must wrap at spaces, never break a token mid-word (`func` / `c`).
407
+ // ---------------------------------------------------------------------------
408
+
409
+ const fallbackCases: { label: string; component: SubsystemComponent }[] = [
410
+ {
411
+ label: 'formatted — long signature (Prettier wraps at printWidth)',
412
+ component: {
413
+ alias: 'fallback-formatted',
414
+ name: 'mergeSessions',
415
+ construct: 'function',
416
+ file: 'src/session/merge.ts',
417
+ purl: 'pkg:github/principal-ai/agent-monitoring',
418
+ symbol: 'mergeSessions',
419
+ declaration: {
420
+ kind: 'function',
421
+ parameters: [
422
+ { name: 'sessions', type: 'SessionRecord[]' },
423
+ { name: 'options', type: 'MergeOptions' },
424
+ { name: 'onConflict', type: '((a: SessionRecord, b: SessionRecord) => SessionRecord)' },
425
+ ],
426
+ returnType: 'Promise<Map<string, SessionEvent[]>>',
427
+ callers: [],
428
+ callees: [],
429
+ } satisfies GraphifyComponentDetail,
430
+ },
431
+ },
432
+ {
433
+ label: 'fallback — unformatted single line (Prettier throws on `Array<`)',
434
+ component: {
435
+ alias: 'fallback-raw',
436
+ name: 'mergeSessions',
437
+ construct: 'function',
438
+ file: 'src/session/merge.ts',
439
+ purl: 'pkg:github/principal-ai/agent-monitoring',
440
+ symbol: 'mergeSessions',
441
+ declaration: {
442
+ kind: 'function',
443
+ parameters: [
444
+ { name: 'sessions', type: 'SessionRecord[]' },
445
+ { name: 'options', type: 'MergeOptions' },
446
+ { name: 'onConflict', type: 'Array<' },
447
+ ],
448
+ returnType: 'Promise<Map<string, SessionEvent[]>>',
449
+ callers: [],
450
+ callees: [],
451
+ } satisfies GraphifyComponentDetail,
452
+ },
453
+ },
454
+ {
455
+ label: 'non-JS/TS (python) — Prettier skipped, python grammar',
456
+ component: {
457
+ alias: 'fallback-python',
458
+ name: 'deduplicate_entities',
459
+ construct: 'function',
460
+ file: 'graphify/dedup.py',
461
+ purl: 'pkg:github/graphify-labs/graphify#graphify/dedup.py',
462
+ symbol: 'deduplicate_entities',
463
+ declaration: {
464
+ kind: 'function',
465
+ parameters: [
466
+ { name: 'graph', type: 'nx.MultiDiGraph' },
467
+ { name: 'keep', type: 'bool' },
468
+ ],
469
+ returnType: 'list[tuple[str, object]]',
470
+ callers: [],
471
+ callees: [],
472
+ } satisfies GraphifyComponentDetail,
473
+ },
474
+ },
475
+ ];
476
+
477
+ export const UnformattedFallback: StoryObj<{ constrained: boolean }> = {
478
+ render: ({ constrained }) => (
479
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
480
+ {fallbackCases.map((c) => (
481
+ <div key={c.label}>
482
+ <div
483
+ style={{
484
+ fontFamily: 'monospace',
485
+ fontSize: 11,
486
+ color: '#888',
487
+ marginBottom: 4,
488
+ textTransform: 'uppercase',
489
+ letterSpacing: '0.05em',
490
+ }}
491
+ >
492
+ {c.label}
493
+ </div>
494
+ <ComponentDeclaration
495
+ component={c.component}
496
+ maxWidth={constrained ? '80ch' : undefined}
497
+ />
498
+ </div>
499
+ ))}
500
+ </div>
501
+ ),
502
+ };
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useRef, useState, type ReactNode } from 'react';
10
- import { AlignLeft, FileText, ShieldCheck } from 'lucide-react';
10
+ import { AlignLeft, FileText } from 'lucide-react';
11
11
  import { useTheme } from '@principal-ade/industry-theme';
12
12
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
13
13
  import {
@@ -115,8 +115,6 @@ export interface ComponentDeclarationProps {
115
115
  onRelatedSelect?: (ref: string) => void;
116
116
  /** Max width of the declaration panel (CSS value). Defaults to none. */
117
117
  maxWidth?: string | number;
118
- /** When set, shows a Verify control that calls back with the component id. */
119
- onVerify?: (componentAlias: string) => void;
120
118
  /** Live verification status for the selected component. */
121
119
  verification?: ComponentVerificationState | null;
122
120
  /** Start with the file path row visible (catalog / source-first views). */
@@ -270,7 +268,6 @@ export function ComponentDeclaration({
270
268
  onOpenFile,
271
269
  onRelatedSelect: _onRelatedSelect,
272
270
  maxWidth,
273
- onVerify,
274
271
  verification,
275
272
  defaultShowFile = false,
276
273
  fileOpen = false,
@@ -400,39 +397,8 @@ export function ComponentDeclaration({
400
397
  <Icon size={13} />
401
398
  </button>
402
399
  );
403
- const verifyBusy = verification?.phase === 'checking';
404
400
  const headerActions = (
405
401
  <span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 2 }}>
406
- {onVerify && (
407
- <button
408
- type="button"
409
- title="Verify against graphify"
410
- aria-label="Verify against graphify"
411
- disabled={verifyBusy}
412
- onClick={(e) => {
413
- e.stopPropagation();
414
- onVerify(component.alias);
415
- }}
416
- style={{
417
- display: 'flex',
418
- alignItems: 'center',
419
- gap: 4,
420
- height: 20,
421
- padding: '0 6px',
422
- border: `1px solid ${theme.colors.border}`,
423
- borderRadius: 4,
424
- background: 'transparent',
425
- color: verifyBusy ? muted : theme.colors.textSecondary,
426
- cursor: verifyBusy ? 'wait' : 'pointer',
427
- fontSize: theme.fontSizes[0],
428
- fontFamily: theme.fonts.body,
429
- opacity: verifyBusy ? 0.7 : 1,
430
- }}
431
- >
432
- <ShieldCheck size={12} />
433
- {verifyBusy ? '…' : 'Verify'}
434
- </button>
435
- )}
436
402
  {!inlineChrome &&
437
403
  toggleBtn(showFile, () => setShowFile((v) => !v), 'Toggle file path', FileText)}
438
404
  {!inlineChrome && lineLocationLabel}
@@ -501,7 +467,7 @@ export function ComponentDeclaration({
501
467
  'purl',
502
468
  ),
503
469
  );
504
- } else if (onVerify || (!showRepoIdentity && !inlineChrome)) {
470
+ } else if (!showRepoIdentity && !inlineChrome) {
505
471
  lines.push(line(headerActions, 'actions'));
506
472
  }
507
473
 
@@ -786,8 +752,10 @@ export function ComponentDeclaration({
786
752
  <div
787
753
  key={`decl-${li}`}
788
754
  style={{
789
- display: 'flex',
790
- alignItems: 'center',
755
+ // Block (not flex): tokens flow inline and wrap at spaces. A flex
756
+ // row lets each token span shrink and break mid-word when a line
757
+ // overflows (e.g. the unformatted prettier-fallback declaration).
758
+ display: 'block',
791
759
  whiteSpace: 'pre-wrap',
792
760
  overflowWrap: 'anywhere',
793
761
  minWidth: 0,
@@ -39,7 +39,6 @@ export interface ConstructsCatalogProps {
39
39
  renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
40
40
  renderFileView?: (component: SubsystemComponent) => ReactNode;
41
41
  onFileSelect?: (file: string) => void;
42
- onVerifyComponent?: (componentAlias: string) => void;
43
42
  componentVerification?: ComponentVerificationState | null;
44
43
  }
45
44
 
@@ -145,7 +144,6 @@ export function ConstructsCatalog({
145
144
  renderFileViewer,
146
145
  renderFileView,
147
146
  onFileSelect,
148
- onVerifyComponent,
149
147
  componentVerification,
150
148
  }: ConstructsCatalogProps) {
151
149
  const { theme, mode } = useTheme();
@@ -950,7 +948,6 @@ export function ConstructsCatalog({
950
948
  onOpenFile={fileViewer ? onOpenFile : undefined}
951
949
  defaultShowFile={!!fileViewer}
952
950
  onRelatedSelect={onRelatedSelect}
953
- onVerify={onVerifyComponent}
954
951
  fileOpen={fileOpen}
955
952
  declarationOpen={lineOpen}
956
953
  showRepoIdentity={false}
@@ -285,8 +285,6 @@ export interface SubsystemComponentGraphProps {
285
285
  * path). The tree is derived from the components' `file` values.
286
286
  */
287
287
  onFileSelect?: (file: string) => void;
288
- /** Verify the selected component against graphify (declaration panel). */
289
- onVerifyComponent?: (componentAlias: string) => void;
290
288
  /** Live verification status for the selected component. */
291
289
  componentVerification?: ComponentVerificationState | null;
292
290
  /**
@@ -385,7 +383,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
385
383
  measured: { w: number; h: number } | null;
386
384
  }
387
385
 
388
- function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification, persistKey }: InnerProps) {
386
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, persistKey }: InnerProps) {
389
387
  const { theme } = useTheme();
390
388
  const { fitView } = useReactFlow();
391
389
  const viewport = useViewport();
@@ -2138,7 +2136,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2138
2136
  component={selected}
2139
2137
  onOpenFile={onOpenDeclarationFile}
2140
2138
  onRelatedSelect={resolveRelatedComponent}
2141
- onVerify={onVerifyComponent}
2142
2139
  verification={componentVerification}
2143
2140
  fileOpen={
2144
2141
  drawerTarget?.kind === 'file' &&
@@ -2367,7 +2364,6 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2367
2364
  renderFileViewer={props.renderFileViewer}
2368
2365
  renderFileView={props.renderFileView}
2369
2366
  onFileSelect={props.onFileSelect}
2370
- onVerifyComponent={props.onVerifyComponent}
2371
2367
  componentVerification={props.componentVerification}
2372
2368
  />
2373
2369
  ) : (
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { tokenizeComponent } from './tokenizeComponent';
3
+ import type { SubsystemComponent } from './model';
4
+
5
+ const textOf = (toks: { kind: string; text: string }[]) =>
6
+ toks.filter((t) => t.kind !== 'newline').map((t) => t.text).join('');
7
+
8
+ describe('tokenizeComponent — language inference', () => {
9
+ test('non-JS/TS (python) bypasses Prettier and tokenizes unformatted', async () => {
10
+ const component: SubsystemComponent = {
11
+ alias: 'dedup',
12
+ name: 'deduplicate_entities',
13
+ construct: 'function',
14
+ file: 'graphify/dedup.py',
15
+ purl: 'pkg:github/graphify-labs/graphify#graphify/dedup.py',
16
+ symbol: 'deduplicate_entities',
17
+ declaration: {
18
+ kind: 'function',
19
+ parameters: [{ name: 'graph', type: 'nx.MultiDiGraph' }],
20
+ returnType: 'None',
21
+ callers: [],
22
+ callees: [],
23
+ },
24
+ };
25
+ const toks = await tokenizeComponent(component, 80, 'pierre-dark');
26
+ // No Prettier run → the raw one-liner comes through verbatim.
27
+ expect(textOf(toks)).toBe(
28
+ 'function deduplicate_entities(graph: nx.MultiDiGraph): None;',
29
+ );
30
+ });
31
+
32
+ test('JS/TS still goes through Prettier (wraps to printWidth)', async () => {
33
+ const component: SubsystemComponent = {
34
+ alias: 'merge',
35
+ name: 'mergeSessions',
36
+ construct: 'function',
37
+ file: 'src/session/merge.ts',
38
+ purl: 'pkg:github/acme/widget',
39
+ symbol: 'mergeSessions',
40
+ declaration: {
41
+ kind: 'function',
42
+ parameters: [
43
+ { name: 'sessions', type: 'SessionRecord[]' },
44
+ { name: 'options', type: 'MergeOptions' },
45
+ { name: 'onConflict', type: '((a: SessionRecord, b: SessionRecord) => SessionRecord)' },
46
+ ],
47
+ returnType: 'Promise<Map<string, SessionEvent[]>>',
48
+ callers: [],
49
+ callees: [],
50
+ },
51
+ };
52
+ const toks = await tokenizeComponent(component, 40, 'pierre-dark');
53
+ // Formatted output spans multiple lines.
54
+ expect(toks.some((t) => t.kind === 'newline')).toBe(true);
55
+ });
56
+ });
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * Tokenize a SubsystemComponent into a flat token stream for the detail panel.
3
3
  *
4
- * Pipeline: generate declaration string → format with Prettier → tokenize
5
- * with Shiki + Pierre themes. The `component.tokens` field, when present,
6
- * overrides the entire pipeline (for pre-tokenized data from graphify).
4
+ * Pipeline: generate declaration string → (JS/TS only) format with Prettier →
5
+ * tokenize with Shiki + Pierre themes, using the grammar inferred from the
6
+ * component's file. The `component.tokens` field, when present, overrides the
7
+ * entire pipeline (for pre-tokenized data from graphify).
7
8
  *
8
9
  * This function is async because Prettier's format() is async.
9
10
  */
10
11
 
11
12
  import type { PierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
13
+ import {
14
+ isPrettierSourceLang,
15
+ sourceLangForPath,
16
+ } from '../pierre/sourceLang';
12
17
  import type { SubsystemComponent, SubsystemDeclToken } from './model';
13
18
  import { generateDeclarationString } from './formatDeclaration';
14
19
  import { tokenizeFormatted } from './tokenizeFormatted';
@@ -19,6 +24,8 @@ let prettierPluginsPromise: Promise<{
19
24
  typescript: typeof import('prettier/plugins/typescript');
20
25
  estree: typeof import('prettier/plugins/estree');
21
26
  }> | null = null;
27
+ /** One-shot guard so a broken host bundler warns once, not per component. */
28
+ let prettierFailureLogged = false;
22
29
 
23
30
  async function getPrettier() {
24
31
  if (!prettierPromise) {
@@ -38,8 +45,9 @@ async function getPrettier() {
38
45
  * Tokenize a SubsystemComponent into SubsystemDeclToken[].
39
46
  *
40
47
  * When `component.tokens` is present (pre-tokenized data from the wire),
41
- * it's returned as-is. Otherwise the pipeline generates a TypeScript
42
- * declaration string, formats it with Prettier, and tokenizes the output.
48
+ * it's returned as-is. Otherwise the pipeline generates a declaration string,
49
+ * formats it with Prettier when the inferred language is JS/TS, and tokenizes
50
+ * the output with that language's grammar.
43
51
  */
44
52
  export async function tokenizeComponent(
45
53
  component: SubsystemComponent,
@@ -49,38 +57,48 @@ export async function tokenizeComponent(
49
57
  // Pre-tokenized tokens from the wire take precedence.
50
58
  if (component.tokens) return component.tokens;
51
59
 
52
- // External kind — not valid TypeScript, bypass Prettier.
60
+ const lang = sourceLangForPath(component.file);
61
+
62
+ // External kind — not valid code, bypass Prettier; plain text.
53
63
  const kind = component.declaration?.kind ?? component.construct;
54
64
  if (kind === 'external') {
55
65
  const label = component.declaration?.kind === 'external' ? component.declaration.label : component.name;
56
66
  const escaped = label.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
57
- return tokenizeFormatted(`external '${escaped}'`, themeName);
67
+ return tokenizeFormatted(`external '${escaped}'`, themeName, 'text');
58
68
  }
59
69
 
60
- // Custom entity — an actor (Person/agent/queue), not code; bypass Prettier.
70
+ // Custom entity — an actor (Person/agent/queue), not code; plain text.
61
71
  if (kind === 'custom_entity') {
62
- return tokenizeFormatted(generateDeclarationString(component), themeName);
72
+ return tokenizeFormatted(generateDeclarationString(component), themeName, 'text');
63
73
  }
64
74
 
65
- // Generate → format → tokenize. Prettier is best-effort: some host
66
- // bundlers (Electrobun's) mangle Prettier's internal cross-module calls, so
67
- // formatting can throw. Fall back to the unformatted declaration rather than
68
- // rendering nothing — the panel is still correct, just not width-wrapped.
75
+ // Generate → (JS/TS only) format → tokenize.
76
+ //
77
+ // Prettier ships parsers for JS/TS (and CSS/HTML/…) only — never run it for
78
+ // other languages. Even for JS/TS it's best-effort: some host bundlers
79
+ // (Electrobun's) mangle Prettier's internal cross-module calls, so formatting
80
+ // can throw. Fall back to the unformatted declaration rather than rendering
81
+ // nothing — the panel is still correct, just not width-wrapped.
69
82
  const raw = generateDeclarationString(component);
70
83
  let formatted = raw;
71
- try {
72
- const { prettier, plugins } = await getPrettier();
73
- formatted = await prettier.format(raw, {
74
- parser: 'typescript',
75
- plugins: [plugins.typescript, plugins.estree],
76
- printWidth,
77
- });
78
- } catch (err) {
79
- console.warn(
80
- '[subsystem] prettier format failed; tokenizing unformatted declaration',
81
- err,
82
- );
84
+ if (isPrettierSourceLang(lang)) {
85
+ try {
86
+ const { prettier, plugins } = await getPrettier();
87
+ formatted = await prettier.format(raw, {
88
+ parser: 'typescript',
89
+ plugins: [plugins.typescript, plugins.estree],
90
+ printWidth,
91
+ });
92
+ } catch (err) {
93
+ if (!prettierFailureLogged) {
94
+ prettierFailureLogged = true;
95
+ console.warn(
96
+ '[subsystem] prettier format failed; tokenizing unformatted declarations for this session',
97
+ err,
98
+ );
99
+ }
100
+ }
83
101
  }
84
102
 
85
- return tokenizeFormatted(formatted, themeName);
103
+ return tokenizeFormatted(formatted, themeName, lang);
86
104
  }
@@ -1,19 +1,21 @@
1
1
  /**
2
- * Tokenize a formatted TypeScript declaration string into SubsystemDeclToken[]
3
- * using Shiki + Pierre themes (same highlighter stack as @pierre/diffs).
2
+ * Tokenize a formatted source string into SubsystemDeclToken[] using Shiki +
3
+ * Pierre themes (same highlighter stack as @pierre/diffs).
4
4
  */
5
5
 
6
6
  import { tokenizeWithPierreSyntax } from '../pierre/syntaxTokens';
7
7
  import type { PierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
8
+ import type { SourceSyntaxLang } from '../pierre/sourceLang';
8
9
  import type { SubsystemDeclToken } from './model';
9
10
 
10
11
  /**
11
- * Tokenize a formatted TypeScript string into SubsystemDeclToken[].
12
- * The input should already be formatted by Prettier (or be valid TS).
12
+ * Tokenize a source string into SubsystemDeclToken[]. The input should already
13
+ * be formatted (or be valid for the language). `lang` picks the grammar.
13
14
  */
14
15
  export function tokenizeFormatted(
15
16
  code: string,
16
17
  themeName: PierreSyntaxThemeName = 'pierre-dark',
18
+ lang: SourceSyntaxLang = 'typescript',
17
19
  ): Promise<SubsystemDeclToken[]> {
18
- return tokenizeWithPierreSyntax(code, themeName);
20
+ return tokenizeWithPierreSyntax(code, themeName, lang);
19
21
  }