@principal-ai/subsystems-react 0.55.2 → 0.56.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 (107) hide show
  1. package/dist/index.d.ts +12 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreFileView.js +3 -3
  6. package/dist/pierre/PierreFileView.js.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.d.ts +1 -6
  8. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  9. package/dist/pierre/PierreTrailCodeView.js +21 -18
  10. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  11. package/dist/pierre/fileAvailability.d.ts +4 -7
  12. package/dist/pierre/fileAvailability.d.ts.map +1 -1
  13. package/dist/pierre/fileAvailability.js +5 -10
  14. package/dist/pierre/fileAvailability.js.map +1 -1
  15. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  16. package/dist/subsystem/C4Graph.js +1 -1
  17. package/dist/subsystem/C4Graph.js.map +1 -1
  18. package/dist/subsystem/DecorationPanel.d.ts +37 -0
  19. package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
  20. package/dist/subsystem/DecorationPanel.js +147 -0
  21. package/dist/subsystem/DecorationPanel.js.map +1 -0
  22. package/dist/subsystem/FileDrawer.d.ts +3 -1
  23. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  24. package/dist/subsystem/FileDrawer.js +2 -2
  25. package/dist/subsystem/FileDrawer.js.map +1 -1
  26. package/dist/subsystem/IssueList.d.ts.map +1 -1
  27. package/dist/subsystem/IssueList.js +26 -0
  28. package/dist/subsystem/IssueList.js.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -5
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +37 -47
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/TrailStackBreadcrumb.d.ts +21 -0
  34. package/dist/subsystem/TrailStackBreadcrumb.d.ts.map +1 -0
  35. package/dist/subsystem/TrailStackBreadcrumb.js +77 -0
  36. package/dist/subsystem/TrailStackBreadcrumb.js.map +1 -0
  37. package/dist/subsystem/TrailStepsNav.d.ts +4 -3
  38. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  39. package/dist/subsystem/TrailStepsNav.js +13 -16
  40. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  41. package/dist/subsystem/TrailsPanel.d.ts +9 -7
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +126 -76
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/decoration.d.ts +112 -0
  46. package/dist/subsystem/decoration.d.ts.map +1 -0
  47. package/dist/subsystem/decoration.js +54 -0
  48. package/dist/subsystem/decoration.js.map +1 -0
  49. package/dist/subsystem/model.d.ts +5 -18
  50. package/dist/subsystem/model.d.ts.map +1 -1
  51. package/dist/subsystem/model.js +3 -11
  52. package/dist/subsystem/model.js.map +1 -1
  53. package/dist/subsystem/nodes.d.ts.map +1 -1
  54. package/dist/subsystem/nodes.js +17 -24
  55. package/dist/subsystem/nodes.js.map +1 -1
  56. package/dist/subsystem/stepEmphasis.d.ts +78 -0
  57. package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
  58. package/dist/subsystem/stepEmphasis.js +89 -0
  59. package/dist/subsystem/stepEmphasis.js.map +1 -0
  60. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  61. package/dist/subsystem/trailBrief.js +20 -0
  62. package/dist/subsystem/trailBrief.js.map +1 -1
  63. package/dist/subsystem/trailStack.d.ts +53 -0
  64. package/dist/subsystem/trailStack.d.ts.map +1 -0
  65. package/dist/subsystem/trailStack.js +154 -0
  66. package/dist/subsystem/trailStack.js.map +1 -0
  67. package/dist/subsystem/trailStepDisplay.d.ts +38 -0
  68. package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
  69. package/dist/subsystem/trailStepDisplay.js +41 -0
  70. package/dist/subsystem/trailStepDisplay.js.map +1 -0
  71. package/package.json +3 -3
  72. package/src/index.ts +47 -1
  73. package/src/pierre/PierreFileView.tsx +3 -3
  74. package/src/pierre/PierreTrailCodeView.tsx +25 -28
  75. package/src/pierre/fileAvailability.test.ts +0 -6
  76. package/src/pierre/fileAvailability.ts +5 -13
  77. package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
  78. package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
  79. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
  80. package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
  81. package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
  82. package/src/subsystem/C4Graph.tsx +0 -1
  83. package/src/subsystem/DecorationPanel.test.tsx +128 -0
  84. package/src/subsystem/DecorationPanel.tsx +334 -0
  85. package/src/subsystem/FileDrawer.tsx +6 -0
  86. package/src/subsystem/IssueList.tsx +26 -0
  87. package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
  88. package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
  89. package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
  90. package/src/subsystem/TrailStepsNav.test.tsx +35 -0
  91. package/src/subsystem/TrailStepsNav.tsx +18 -19
  92. package/src/subsystem/TrailsPanel.test.tsx +149 -0
  93. package/src/subsystem/TrailsPanel.tsx +165 -39
  94. package/src/subsystem/decoration.test.ts +72 -0
  95. package/src/subsystem/decoration.ts +151 -0
  96. package/src/subsystem/model.test.ts +7 -22
  97. package/src/subsystem/model.ts +6 -20
  98. package/src/subsystem/nodes.tsx +19 -28
  99. package/src/subsystem/stepEmphasis.test.ts +110 -0
  100. package/src/subsystem/stepEmphasis.ts +151 -0
  101. package/src/subsystem/trailBrief.test.ts +29 -0
  102. package/src/subsystem/trailBrief.ts +20 -0
  103. package/src/subsystem/trailStack.test.ts +133 -0
  104. package/src/subsystem/trailStack.ts +229 -0
  105. package/src/subsystem/trailStepDisplay.ts +68 -0
  106. package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
  107. package/src/stories/Canvases/Subsystem/ProposedTrails.stories.tsx +0 -184
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Step emphasis — PROTOTYPE contract for the "guiding focus" augmentation.
3
+ *
4
+ * A step emphasis is an authored pointer at a walkthrough step: "look here
5
+ * first", with optional markdown guidance explaining why. It is deliberately
6
+ * **not** part of the portable subsystem model and **not** a verification
7
+ * signal — it belongs in a separate, host-owned augmentation document:
8
+ *
9
+ * `~/.principal/subsystem-model-augmentations/<model-identity>.json`
10
+ *
11
+ * The key question this prototype is here to answer is identity: a step has no
12
+ * id today and steps move. `stepClaimKey` fingerprints the step's *claim*
13
+ * (`from`/`to`/`mechanism`/site/symbol/span), so a mark survives a reorder but
14
+ * stops resolving when the claim itself is edited. Orphaned records are kept
15
+ * (and can be pruned deliberately) rather than silently dropped.
16
+ *
17
+ * The mark is a single star rather than a closed kind vocabulary. `guidance`
18
+ * is markdown so a mark can carry the same kind of prose as a model
19
+ * description — headings, emphasis, links, code — while staying a string.
20
+ *
21
+ * Nothing here is wired to disk yet. The story `Canvases/Subsystem/Step
22
+ * Emphasis` exercises the shape with an in-memory store and a live JSON view.
23
+ */
24
+
25
+ import type { SubsystemTrail, SubsystemTrailStep } from './model';
26
+
27
+ export interface StepEmphasisProvenance {
28
+ /** Who authored the mark. Agent marks are expected too. */
29
+ source: 'author' | 'agent';
30
+ /** ISO timestamp. */
31
+ at: string;
32
+ /** Optional human/agent identity. */
33
+ author?: string;
34
+ }
35
+
36
+ /** One mark in the separate augmentation document. */
37
+ export interface StepEmphasisRecord {
38
+ /** The trail the step belongs to. */
39
+ trailId: string;
40
+ /**
41
+ * Fingerprint of the step's claim (see `stepClaimKey`). Stable across
42
+ * reorder; changes when the claim changes.
43
+ */
44
+ stepKey: string;
45
+ /** Optional markdown "why look here", rendered like a model description. */
46
+ guidance?: string;
47
+ provenance: StepEmphasisProvenance;
48
+ }
49
+
50
+ /**
51
+ * The whole separate document. `modelId` is the host-store id (or an identity
52
+ * key) — this document is host-local and never rides on the portable model.
53
+ */
54
+ export interface StepEmphasisDocument {
55
+ version: 1;
56
+ modelId: string;
57
+ emphasis: StepEmphasisRecord[];
58
+ }
59
+
60
+ /**
61
+ * Lookup key for the UI. `trailId:stepKey` — one flat map is enough for a
62
+ * graph and keeps the panel from re-deriving a nested structure per render.
63
+ */
64
+ export type StepEmphasisMap = ReadonlyMap<string, StepEmphasisRecord>;
65
+
66
+ /** The claim fields a mark is attached to. Deliberately excludes `annotation`. */
67
+ function claimFields(step: Pick<SubsystemTrailStep, 'from' | 'to' | 'mechanism' | 'file' | 'symbol' | 'span'>): string[] {
68
+ return [
69
+ step.from,
70
+ step.to,
71
+ step.mechanism,
72
+ step.file,
73
+ step.symbol,
74
+ step.span?.startLine == null ? '' : String(step.span.startLine),
75
+ step.span?.endLine == null ? '' : String(step.span.endLine),
76
+ step.span?.contentHash ?? '',
77
+ ];
78
+ }
79
+
80
+ /**
81
+ * FNV-1a (32-bit) fingerprint. Good enough for a local key: the point is
82
+ * "same claim => same key", not cryptographic resistance. Swap for SHA-256 at
83
+ * the store boundary if marks ever travel between hosts.
84
+ */
85
+ function fnv1a(value: string): string {
86
+ let hash = 0x811c9dc5;
87
+ for (let i = 0; i < value.length; i++) {
88
+ hash ^= value.charCodeAt(i);
89
+ hash = Math.imul(hash, 0x01000193);
90
+ }
91
+ return (hash >>> 0).toString(16).padStart(8, '0');
92
+ }
93
+
94
+ /** Stable content key for a step's claim. */
95
+ export function stepClaimKey(
96
+ step: Pick<SubsystemTrailStep, 'from' | 'to' | 'mechanism' | 'file' | 'symbol' | 'span'>,
97
+ ): string {
98
+ return fnv1a(claimFields(step).join('\u001f'));
99
+ }
100
+
101
+ /** Map key for one step's mark. */
102
+ export function emphasisKey(
103
+ trailId: string,
104
+ step: Pick<SubsystemTrailStep, 'from' | 'to' | 'mechanism' | 'file' | 'symbol' | 'span'>,
105
+ ): string {
106
+ return `${trailId}:${stepClaimKey(step)}`;
107
+ }
108
+
109
+ export function buildStepEmphasisMap(
110
+ records: readonly StepEmphasisRecord[],
111
+ ): StepEmphasisMap {
112
+ return new Map(records.map((r) => [`${r.trailId}:${r.stepKey}`, r]));
113
+ }
114
+
115
+ export function findStepEmphasis(
116
+ map: StepEmphasisMap | undefined,
117
+ trailId: string,
118
+ step: SubsystemTrailStep,
119
+ ): StepEmphasisRecord | undefined {
120
+ return map?.get(emphasisKey(trailId, step));
121
+ }
122
+
123
+ /**
124
+ * Records whose step claim no longer exists in the current trails. Kept in the
125
+ * document (so a bad edit does not destroy authored intent) but surfaced so
126
+ * the author can prune them.
127
+ */
128
+ export function orphanedEmphasis(
129
+ records: readonly StepEmphasisRecord[],
130
+ trails: readonly SubsystemTrail[],
131
+ ): StepEmphasisRecord[] {
132
+ const live = new Set<string>();
133
+ for (const trail of trails) {
134
+ for (const step of trail.steps) live.add(emphasisKey(trail.id, step));
135
+ }
136
+ return records.filter((r) => !live.has(`${r.trailId}:${r.stepKey}`));
137
+ }
138
+
139
+ /** Records for one trail, in current step order (for reader surfaces). */
140
+ export function emphasisForTrail(
141
+ map: StepEmphasisMap | undefined,
142
+ trail: SubsystemTrail,
143
+ ): Array<{ stepIndex: number; step: SubsystemTrailStep; record: StepEmphasisRecord }> {
144
+ if (!map) return [];
145
+ const rows: Array<{ stepIndex: number; step: SubsystemTrailStep; record: StepEmphasisRecord }> = [];
146
+ trail.steps.forEach((step, stepIndex) => {
147
+ const record = map.get(emphasisKey(trail.id, step));
148
+ if (record) rows.push({ stepIndex, step, record });
149
+ });
150
+ return rows;
151
+ }
@@ -38,6 +38,11 @@ describe('buildStepBrief', () => {
38
38
  expect(brief).toContain('- symbol: `Login.submit`');
39
39
  expect(brief).toContain('- site: `src/ui/login.tsx:42`');
40
40
  expect(brief).toContain('- purl: `pkg:github/acme/app`');
41
+ expect(brief).not.toContain('- site role:');
42
+ });
43
+
44
+ test('marks a non-call site as via evidence', () => {
45
+ expect(buildStepBrief(trail, 1)).toContain('- site role: `via`');
41
46
  });
42
47
 
43
48
  test('omits the note when the step has no annotation', () => {
@@ -50,6 +55,30 @@ describe('buildStepBrief', () => {
50
55
  expect(brief).toContain('- note: session row is written here');
51
56
  });
52
57
 
58
+ test('includes resolved site and target constructs when present', () => {
59
+ const resolved: SubsystemTrail = {
60
+ ...trail,
61
+ steps: [
62
+ {
63
+ ...trail.steps[0]!,
64
+ site: {
65
+ symbol: 'Login.submit',
66
+ span: { startLine: 30, endLine: 60, contentHash: 'h' },
67
+ },
68
+ target: {
69
+ purl: 'pkg:github/acme/app#src/api/session.ts',
70
+ file: 'src/api/session.ts',
71
+ symbol: 'Session.create',
72
+ span: { startLine: 7, endLine: 7, contentHash: 'h' },
73
+ },
74
+ },
75
+ ],
76
+ };
77
+ const brief = buildStepBrief(resolved, 0);
78
+ expect(brief).toContain('- site construct: `Login.submit` (src/ui/login.tsx:30-60)');
79
+ expect(brief).toContain('- target: `Session.create` (src/api/session.ts:7)');
80
+ });
81
+
53
82
  test('returns an empty string for an out-of-range index', () => {
54
83
  expect(buildStepBrief(trail, 9)).toBe('');
55
84
  expect(buildStepBrief(trail, -1)).toBe('');
@@ -31,12 +31,32 @@ export function buildStepBrief(
31
31
  lines.push(`- to: \`${step.to}\``);
32
32
  lines.push(`- mechanism: \`${step.mechanism}\``);
33
33
  lines.push(`- symbol: \`${step.symbol}\``);
34
+ // `calls` means the site is the caller frame. Every other mechanism is a
35
+ // logical hop whose evidence can live in another construct; say so, so an
36
+ // agent does not treat the site symbol as the source component.
37
+ if (step.mechanism !== 'calls') {
38
+ lines.push(`- site role: \`via\``);
39
+ }
34
40
  const site = step.span
35
41
  ? step.span.startLine === step.span.endLine
36
42
  ? `${step.file}:${step.span.startLine}`
37
43
  : `${step.file}:${step.span.startLine}-${step.span.endLine}`
38
44
  : step.file;
39
45
  lines.push(`- site: \`${site}\``);
46
+ if (step.site) {
47
+ const frame = step.site.span.startLine === step.site.span.endLine
48
+ ? `${step.file}:${step.site.span.startLine}`
49
+ : `${step.file}:${step.site.span.startLine}-${step.site.span.endLine}`;
50
+ lines.push(`- site construct: \`${step.site.symbol}\` (${frame})`);
51
+ }
52
+ if (step.target) {
53
+ const targetSpan = step.target.span
54
+ ? step.target.span.startLine === step.target.span.endLine
55
+ ? `:${step.target.span.startLine}`
56
+ : `:${step.target.span.startLine}-${step.target.span.endLine}`
57
+ : '';
58
+ lines.push(`- target: \`${step.target.symbol}\` (${step.target.file}${targetSpan})`);
59
+ }
40
60
  lines.push(`- purl: \`${step.purl}\``);
41
61
  if (step.annotation != null && step.annotation.length > 0) {
42
62
  lines.push(`- note: ${step.annotation}`);
@@ -0,0 +1,133 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SubsystemTrail } from './model';
3
+ import { buildTrailStack } from './trailStack';
4
+
5
+ const trail: SubsystemTrail = {
6
+ id: 'stack',
7
+ title: 'Stack',
8
+ steps: [
9
+ {
10
+ from: 'panel',
11
+ to: 'rpc',
12
+ mechanism: 'calls',
13
+ file: 'src/panel.ts',
14
+ purl: 'pkg:github/acme/app#src/panel.ts',
15
+ symbol: 'MaintenancePanel',
16
+ target: {
17
+ purl: 'pkg:github/acme/app#src/rpc.ts',
18
+ file: 'src/rpc.ts',
19
+ symbol: 'getRpc',
20
+ },
21
+ },
22
+ {
23
+ from: 'rpc',
24
+ to: 'detail',
25
+ mechanism: 'calls',
26
+ file: 'src/rpc.ts',
27
+ purl: 'pkg:github/acme/app#src/rpc.ts',
28
+ symbol: 'getRpc',
29
+ target: {
30
+ purl: 'pkg:github/acme/app#src/detail.ts',
31
+ file: 'src/detail.ts',
32
+ symbol: 'modelDetail',
33
+ },
34
+ },
35
+ {
36
+ from: 'detail',
37
+ to: 'touches',
38
+ mechanism: 'calls',
39
+ file: 'src/detail.ts',
40
+ purl: 'pkg:github/acme/app#src/detail.ts',
41
+ symbol: 'modelDetail',
42
+ target: {
43
+ purl: 'pkg:github/acme/app#src/touches.ts',
44
+ file: 'src/touches.ts',
45
+ symbol: 'commitTouches',
46
+ },
47
+ },
48
+ {
49
+ from: 'detail',
50
+ to: 'remote',
51
+ mechanism: 'calls',
52
+ file: 'src/detail.ts',
53
+ purl: 'pkg:github/acme/app#src/detail.ts',
54
+ symbol: 'modelDetail',
55
+ target: {
56
+ purl: 'pkg:github/acme/app#src/remote.ts',
57
+ file: 'src/remote.ts',
58
+ symbol: 'remoteRefSha',
59
+ },
60
+ },
61
+ {
62
+ from: 'panel',
63
+ to: 'merge',
64
+ mechanism: 'uses',
65
+ file: 'src/list.tsx',
66
+ purl: 'pkg:github/acme/app#src/list.tsx',
67
+ symbol: 'ModelList',
68
+ },
69
+ {
70
+ from: 'merge',
71
+ to: 'dots',
72
+ mechanism: 'feeds',
73
+ file: 'src/dots.tsx',
74
+ purl: 'pkg:github/acme/app#src/dots.tsx',
75
+ symbol: 'CommitDots',
76
+ },
77
+ ],
78
+ };
79
+
80
+ describe('buildTrailStack', () => {
81
+ test('builds the caller chain for a nested call', () => {
82
+ const stack = buildTrailStack(trail, 2);
83
+ expect(stack.isCall).toBe(true);
84
+ expect(stack.frames.map((f) => f.label)).toEqual([
85
+ 'MaintenancePanel',
86
+ 'getRpc',
87
+ 'modelDetail',
88
+ ]);
89
+ expect(stack.frames.map((f) => f.current)).toEqual([false, false, true]);
90
+ expect(stack.callee).toBe('commitTouches');
91
+ });
92
+
93
+ test('a sibling call links to the shared parent, not the previous sibling', () => {
94
+ const stack = buildTrailStack(trail, 3);
95
+ expect(stack.frames.map((f) => f.stepIndex)).toEqual([0, 1, 3]);
96
+ expect(stack.frames.map((f) => f.label)).toEqual([
97
+ 'MaintenancePanel',
98
+ 'getRpc',
99
+ 'modelDetail',
100
+ ]);
101
+ expect(stack.callee).toBe('remoteRefSha');
102
+ });
103
+
104
+ test('uses is an annotation on the frame it belongs to, not a frame', () => {
105
+ const stack = buildTrailStack(trail, 4);
106
+ expect(stack.isCall).toBe(false);
107
+ expect(stack.frames.map((f) => f.label)).toEqual(['MaintenancePanel']);
108
+ expect(stack.annotations).toEqual([
109
+ { stepIndex: 4, mechanism: 'uses', label: 'uses merge', selected: true },
110
+ ]);
111
+ });
112
+
113
+ test('a root call still shows its caller frame and callee', () => {
114
+ const stack = buildTrailStack(trail, 0);
115
+ expect(stack.frames.map((f) => f.label)).toEqual(['MaintenancePanel']);
116
+ expect(stack.callee).toBe('getRpc');
117
+ });
118
+
119
+ test('a non-call step with no call context falls back to its source frame', () => {
120
+ const stack = buildTrailStack(trail, 5);
121
+ expect(stack.isCall).toBe(false);
122
+ expect(stack.frames.map((f) => f.label)).toEqual(['CommitDots']);
123
+ expect(stack.annotations[0]?.label).toBe('feeds dots');
124
+ });
125
+
126
+ test('out-of-range returns an empty view', () => {
127
+ expect(buildTrailStack(trail, 99)).toEqual({
128
+ frames: [],
129
+ annotations: [],
130
+ isCall: false,
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Derive a call stack for one trail step.
3
+ *
4
+ * A trail is a timeline, not a stack frame dump: steps can return to an
5
+ * ancestor, take sibling calls, or describe a non-call seam (`uses`, `feeds`,
6
+ * …). This module links each `calls` step to the prior call that entered its
7
+ * caller construct, then renders that ancestry as a stack. Non-call steps are
8
+ * annotations on the nearest frame — they are not frames.
9
+ *
10
+ * The links use the resolved `site`/`target` when present, falling back to the
11
+ * component aliases. That keeps the view useful before every step has its
12
+ * construct refs captured.
13
+ */
14
+
15
+ import type {
16
+ SubsystemTrail,
17
+ SubsystemTrailMechanism,
18
+ SubsystemTrailStep,
19
+ } from './model';
20
+ import {
21
+ trailComponentLabel,
22
+ type TrailComponentLabels,
23
+ } from './trailStepDisplay';
24
+
25
+ export interface TrailStackFrame {
26
+ /** The step whose call creates this frame. */
27
+ stepIndex: number;
28
+ /** Frame label — the caller construct, or the component alias. */
29
+ label: string;
30
+ /** Component alias of the caller (for focus/selection wiring). */
31
+ componentAlias: string;
32
+ /** The selected step's own frame (innermost). */
33
+ current: boolean;
34
+ }
35
+
36
+ export interface TrailStackAnnotation {
37
+ /** Non-call step attached to the current frame. */
38
+ stepIndex: number;
39
+ mechanism: SubsystemTrailMechanism;
40
+ label: string;
41
+ /** The selected step itself (vs context around it). */
42
+ selected: boolean;
43
+ }
44
+
45
+ export interface TrailStackView {
46
+ /** Outermost → innermost. Empty only for an out-of-range index. */
47
+ frames: TrailStackFrame[];
48
+ /** Non-call seams attached to the current frame. */
49
+ annotations: TrailStackAnnotation[];
50
+ /** True when the selected step is a call frame. */
51
+ isCall: boolean;
52
+ /** The selected call's target, shown as the next frame-to-be. */
53
+ callee?: string;
54
+ }
55
+
56
+ function callerSymbol(step: SubsystemTrailStep): string {
57
+ return step.site?.symbol?.trim() || step.symbol?.trim() || step.from;
58
+ }
59
+
60
+ function calleeSymbol(step: SubsystemTrailStep): string {
61
+ return step.target?.symbol?.trim() || step.to;
62
+ }
63
+
64
+ function callerLabel(
65
+ step: SubsystemTrailStep,
66
+ byAlias?: TrailComponentLabels,
67
+ ): string {
68
+ return callerSymbol(step) || trailComponentLabel(step.from, byAlias);
69
+ }
70
+
71
+ function calleeLabel(
72
+ step: SubsystemTrailStep,
73
+ byAlias?: TrailComponentLabels,
74
+ ): string {
75
+ return step.target?.symbol?.trim() || trailComponentLabel(step.to, byAlias);
76
+ }
77
+
78
+ function isCallStep(step: SubsystemTrailStep | undefined): boolean {
79
+ return step?.mechanism === 'calls';
80
+ }
81
+
82
+ /** Does call `a` lead into the caller of call `b`? */
83
+ function leadsInto(a: SubsystemTrailStep, b: SubsystemTrailStep): boolean {
84
+ return a.to === b.from || calleeSymbol(a) === callerSymbol(b);
85
+ }
86
+
87
+ /** The nearest earlier call that entered this step's caller frame. */
88
+ function parentCallIndex(
89
+ trail: SubsystemTrail,
90
+ stepIndex: number,
91
+ ): number | null {
92
+ const child = trail.steps[stepIndex];
93
+ if (!child) return null;
94
+ for (let i = stepIndex - 1; i >= 0; i--) {
95
+ const candidate = trail.steps[i];
96
+ if (!isCallStep(candidate)) continue;
97
+ if (leadsInto(candidate!, child)) return i;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** The call chain ending at `stepIndex`, outermost first. */
103
+ function callChain(trail: SubsystemTrail, stepIndex: number): number[] {
104
+ const chain: number[] = [];
105
+ const seen = new Set<number>();
106
+ let cursor: number | null = stepIndex;
107
+ while (cursor != null && !seen.has(cursor)) {
108
+ seen.add(cursor);
109
+ chain.unshift(cursor);
110
+ cursor = parentCallIndex(trail, cursor);
111
+ }
112
+ return chain;
113
+ }
114
+
115
+ function framesForChain(
116
+ trail: SubsystemTrail,
117
+ chain: number[],
118
+ selectedStepIndex: number | null,
119
+ byAlias?: TrailComponentLabels,
120
+ ): TrailStackFrame[] {
121
+ return chain.map((idx) => {
122
+ const step = trail.steps[idx]!;
123
+ return {
124
+ stepIndex: idx,
125
+ label: callerLabel(step, byAlias),
126
+ componentAlias: step.from,
127
+ current: idx === selectedStepIndex,
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The nearest earlier call that shares this non-call step's source component.
134
+ * `uses`/`feeds`/… are dependencies of that frame, not new frames.
135
+ */
136
+ function anchorCallIndex(
137
+ trail: SubsystemTrail,
138
+ stepIndex: number,
139
+ ): number | null {
140
+ const step = trail.steps[stepIndex];
141
+ if (!step) return null;
142
+ const source = callerSymbol(step);
143
+ for (let i = stepIndex - 1; i >= 0; i--) {
144
+ const candidate = trail.steps[i];
145
+ if (!isCallStep(candidate)) continue;
146
+ if (
147
+ candidate!.from === step.from ||
148
+ candidate!.to === step.from ||
149
+ callerSymbol(candidate!) === source ||
150
+ calleeSymbol(candidate!) === source
151
+ ) {
152
+ return i;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function annotationLabel(
159
+ step: SubsystemTrailStep,
160
+ byAlias?: TrailComponentLabels,
161
+ ): string {
162
+ return `${step.mechanism} ${calleeLabel(step, byAlias)}`;
163
+ }
164
+
165
+ /**
166
+ * Build the stack at one trail step.
167
+ *
168
+ * - `calls` → the chain of calls into the caller frame, plus the callee as the
169
+ * next frame (not yet on the stack).
170
+ * - `uses` / data + event seams → the nearest call frame they attach to, with
171
+ * the seam as an annotation.
172
+ */
173
+ export function buildTrailStack(
174
+ trail: SubsystemTrail,
175
+ stepIndex: number,
176
+ byAlias?: TrailComponentLabels,
177
+ ): TrailStackView {
178
+ const step = trail.steps[stepIndex];
179
+ if (!step) {
180
+ return { frames: [], annotations: [], isCall: false };
181
+ }
182
+
183
+ if (isCallStep(step)) {
184
+ const chain = callChain(trail, stepIndex);
185
+ return {
186
+ frames: framesForChain(trail, chain, stepIndex, byAlias),
187
+ annotations: [],
188
+ isCall: true,
189
+ callee: calleeLabel(step, byAlias),
190
+ };
191
+ }
192
+
193
+ const anchor = anchorCallIndex(trail, stepIndex);
194
+ if (anchor != null) {
195
+ const chain = callChain(trail, anchor);
196
+ return {
197
+ frames: framesForChain(trail, chain, anchor, byAlias),
198
+ annotations: [
199
+ {
200
+ stepIndex,
201
+ mechanism: step.mechanism,
202
+ label: annotationLabel(step, byAlias),
203
+ selected: true,
204
+ },
205
+ ],
206
+ isCall: false,
207
+ };
208
+ }
209
+
210
+ return {
211
+ frames: [
212
+ {
213
+ stepIndex,
214
+ label: callerLabel(step, byAlias),
215
+ componentAlias: step.from,
216
+ current: true,
217
+ },
218
+ ],
219
+ annotations: [
220
+ {
221
+ stepIndex,
222
+ mechanism: step.mechanism,
223
+ label: annotationLabel(step, byAlias),
224
+ selected: true,
225
+ },
226
+ ],
227
+ isCall: false,
228
+ };
229
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * How a trail step reads in the Trails panel.
3
+ *
4
+ * A step is one seam, but the panel shows the logical hop and the site in a
5
+ * single wrapping line:
6
+ *
7
+ * - `calls` — the caller frame (the site construct).
8
+ * - `uses` — `from uses to`, where `to` is the precise target when resolved.
9
+ * - Everything else — `from mechanism to`.
10
+ *
11
+ * The evidence site lives in the code view and the stack breadcrumb; the
12
+ * sidebar row stays one line so it can wrap instead of truncating.
13
+ */
14
+
15
+ import type { SubsystemTrailStep, SubsystemTrailMechanism } from './model';
16
+
17
+ /** The slice of a component the labels need — keeps fixtures light. */
18
+ export interface TrailComponentLabelSource {
19
+ alias: string;
20
+ name?: string;
21
+ symbol?: string;
22
+ }
23
+
24
+ /** Alias → component, for turning model-local aliases into display names. */
25
+ export type TrailComponentLabels = ReadonlyMap<string, TrailComponentLabelSource>;
26
+
27
+ /** One row's primary text. */
28
+ export interface TrailStepDisplay {
29
+ mechanism: SubsystemTrailMechanism;
30
+ /** The line that names the step: caller frame for `calls`, hop otherwise. */
31
+ primary: string;
32
+ }
33
+
34
+ /** Display label for a component alias; falls back to the alias. */
35
+ export function trailComponentLabel(
36
+ alias: string,
37
+ byAlias?: TrailComponentLabels,
38
+ ): string {
39
+ const component = byAlias?.get(alias);
40
+ const label = component?.name?.trim() || component?.symbol?.trim();
41
+ return label || alias;
42
+ }
43
+
44
+ /**
45
+ * Build the single display line for one step.
46
+ *
47
+ * The resolved target (`step.target.symbol`) is the precise callee, while `to`
48
+ * is only a component alias and can be a whole class/store. Prefer the target.
49
+ */
50
+ export function trailStepDisplay(
51
+ step: SubsystemTrailStep,
52
+ byAlias?: TrailComponentLabels,
53
+ ): TrailStepDisplay {
54
+ const from = trailComponentLabel(step.from, byAlias);
55
+ const to = step.target?.symbol?.trim() || trailComponentLabel(step.to, byAlias);
56
+ const siteSymbol = step.site?.symbol?.trim() || step.symbol?.trim() || from;
57
+
58
+ if (step.mechanism === 'calls') {
59
+ return { mechanism: step.mechanism, primary: siteSymbol };
60
+ }
61
+ if (step.mechanism === 'uses') {
62
+ return { mechanism: step.mechanism, primary: `${from} uses ${to}` };
63
+ }
64
+ return {
65
+ mechanism: step.mechanism,
66
+ primary: `${from} ${step.mechanism} ${to}`,
67
+ };
68
+ }