@principal-ai/subsystems-react 0.55.3 → 0.56.1

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 (98) hide show
  1. package/dist/index.d.ts +6 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -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 +4 -9
  8. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  9. package/dist/pierre/PierreTrailCodeView.js +28 -25
  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/pierre/trailSnippet.d.ts +10 -0
  16. package/dist/pierre/trailSnippet.d.ts.map +1 -0
  17. package/dist/pierre/trailSnippet.js +22 -0
  18. package/dist/pierre/trailSnippet.js.map +1 -0
  19. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  20. package/dist/subsystem/C4Graph.js +1 -1
  21. package/dist/subsystem/C4Graph.js.map +1 -1
  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/model.d.ts +5 -18
  46. package/dist/subsystem/model.d.ts.map +1 -1
  47. package/dist/subsystem/model.js +3 -11
  48. package/dist/subsystem/model.js.map +1 -1
  49. package/dist/subsystem/nodes.d.ts.map +1 -1
  50. package/dist/subsystem/nodes.js +17 -24
  51. package/dist/subsystem/nodes.js.map +1 -1
  52. package/dist/subsystem/stepEmphasis.d.ts +78 -0
  53. package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
  54. package/dist/subsystem/stepEmphasis.js +89 -0
  55. package/dist/subsystem/stepEmphasis.js.map +1 -0
  56. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  57. package/dist/subsystem/trailBrief.js +20 -0
  58. package/dist/subsystem/trailBrief.js.map +1 -1
  59. package/dist/subsystem/trailStack.d.ts +53 -0
  60. package/dist/subsystem/trailStack.d.ts.map +1 -0
  61. package/dist/subsystem/trailStack.js +154 -0
  62. package/dist/subsystem/trailStack.js.map +1 -0
  63. package/dist/subsystem/trailStepDisplay.d.ts +38 -0
  64. package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
  65. package/dist/subsystem/trailStepDisplay.js +41 -0
  66. package/dist/subsystem/trailStepDisplay.js.map +1 -0
  67. package/package.json +3 -3
  68. package/src/index.ts +10 -1
  69. package/src/pierre/PierreFileView.tsx +3 -3
  70. package/src/pierre/PierreTrailCodeView.tsx +24 -36
  71. package/src/pierre/fileAvailability.test.ts +0 -6
  72. package/src/pierre/fileAvailability.ts +5 -13
  73. package/src/pierre/trailSnippet.test.ts +74 -0
  74. package/src/pierre/trailSnippet.ts +35 -0
  75. package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
  76. package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
  77. package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
  78. package/src/subsystem/C4Graph.tsx +0 -1
  79. package/src/subsystem/FileDrawer.tsx +6 -0
  80. package/src/subsystem/IssueList.tsx +26 -0
  81. package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
  82. package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
  83. package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
  84. package/src/subsystem/TrailStepsNav.test.tsx +35 -0
  85. package/src/subsystem/TrailStepsNav.tsx +18 -19
  86. package/src/subsystem/TrailsPanel.test.tsx +149 -0
  87. package/src/subsystem/TrailsPanel.tsx +165 -39
  88. package/src/subsystem/model.test.ts +7 -22
  89. package/src/subsystem/model.ts +6 -20
  90. package/src/subsystem/nodes.tsx +19 -28
  91. package/src/subsystem/stepEmphasis.ts +151 -0
  92. package/src/subsystem/trailBrief.test.ts +29 -0
  93. package/src/subsystem/trailBrief.ts +20 -0
  94. package/src/subsystem/trailStack.test.ts +133 -0
  95. package/src/subsystem/trailStack.ts +229 -0
  96. package/src/subsystem/trailStepDisplay.ts +68 -0
  97. package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
  98. package/src/stories/Canvases/Subsystem/ProposedTrails.stories.tsx +0 -184
@@ -98,7 +98,7 @@ describe('subsystem graph model', () => {
98
98
  const withExternal: SubsystemComponent[] = [
99
99
  ...comps,
100
100
  {
101
- alias: "proposed-watcher",
101
+ alias: "external-watcher",
102
102
  name: "watchDir",
103
103
  construct: "external",
104
104
  // Intentionally omit file/purl — agents often leave these off for externals.
@@ -111,10 +111,10 @@ describe('subsystem graph model', () => {
111
111
  trails: [{
112
112
  id: 'w1',
113
113
  title: 'feed',
114
- steps: [{ from: 'reader', to: 'proposed-watcher', mechanism: 'feeds', file: 'x.ts' }],
114
+ steps: [{ from: 'reader', to: 'external-watcher', mechanism: 'feeds', file: 'x.ts' }],
115
115
  }],
116
116
  });
117
- expect(nodes.find((n) => n.id === 'proposed-watcher')).toBeDefined();
117
+ expect(nodes.find((n) => n.id === 'external-watcher')).toBeDefined();
118
118
  });
119
119
 
120
120
  test('buildSubsystemGraph creates external stub nodes for non-component targets', async () => {
@@ -223,33 +223,18 @@ describe('subsystem graph model', () => {
223
223
  });
224
224
  expect(withRole).toBeGreaterThan(stereotype);
225
225
 
226
- const withProposed = nodeMinWidthForBadges({
227
- construct: 'function',
228
- proposed: true,
229
- });
230
- expect(withProposed).toBeGreaterThan(plain);
231
-
232
- const withBoth = nodeMinWidthForBadges({
233
- construct: 'function',
234
- role: 'entry',
235
- proposed: true,
236
- });
237
- // proposed-only label is shorter than entry · proposed; still wider than plain
238
- expect(withBoth).toBe(withProposed);
239
-
240
226
  // The pair floor must leave a real gap between the left and right badge,
241
227
  // over and above both insets — regression: the node border was omitted from
242
228
  // the floor, and the char width was underestimated, so the two badges met.
243
229
  const left = estimateBadgeLabelWidth(constructBadgeLabel({ construct: 'function' }));
244
- const right = estimateBadgeLabelWidth(rightBadgeLabel({ proposed: true })!);
245
- expect(withBoth).toBeGreaterThan(left + right + 2 * BADGE_EDGE_INSET);
230
+ const right = estimateBadgeLabelWidth(rightBadgeLabel({ role: 'entry' })!);
231
+ expect(withRole).toBeGreaterThan(left + right + 2 * BADGE_EDGE_INSET);
246
232
  });
247
233
 
248
- test('rightBadgeLabel prefers proposed over role', () => {
234
+ test('rightBadgeLabel reports the role', () => {
249
235
  expect(rightBadgeLabel({})).toBeNull();
250
- expect(rightBadgeLabel({ proposed: true })).toBe('proposed');
251
236
  expect(rightBadgeLabel({ role: 'entry' })).toBe('entry');
252
- expect(rightBadgeLabel({ role: 'service', proposed: true })).toBe('proposed');
237
+ expect(rightBadgeLabel({ role: 'service' })).toBe('service');
253
238
  });
254
239
 
255
240
  test('deriveNameFromSymbol falls back to existing name when no symbol', () => {
@@ -101,6 +101,8 @@ export type {
101
101
  SubsystemModelDocument,
102
102
  SubsystemTrail,
103
103
  SubsystemTrailStep,
104
+ SubsystemSiteConstruct,
105
+ SubsystemTargetConstruct,
104
106
  SubsystemComponentRole,
105
107
  SubsystemFramework,
106
108
  SubsystemStereotype,
@@ -1156,7 +1158,7 @@ export type SubsystemIssueRung =
1156
1158
  | 'signature';
1157
1159
 
1158
1160
  /**
1159
- * Per-node diagnostics badge — the proposed issue overlay (severity ring +
1161
+ * Per-node diagnostics badge — the issue overlay (severity ring +
1160
1162
  * earliest-rung corner chip). The graph derives one per component from the
1161
1163
  * audit issues whose component target resolves to that node. Absent when the
1162
1164
  * node has no rung-mapped findings.
@@ -1226,12 +1228,6 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
1226
1228
  /** True while another edge is selected — render this edge (and its label)
1227
1229
  * dimmed to focus the selected relationship. */
1228
1230
  dimmed?: boolean;
1229
- /**
1230
- * A backing trail step declares itself `proposed` — a planned seam between
1231
- * otherwise-real components. The SVG edge and its label draw dashed;
1232
- * distinct from a proposed ENDPOINT, which dashes the node instead.
1233
- */
1234
- proposedStep?: boolean;
1235
1231
  /** ELK-computed label midpoint (from the actual edge path, not node centers). */
1236
1232
  labelX?: number;
1237
1233
  labelY?: number;
@@ -1522,9 +1518,6 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
1522
1518
  service: 'service',
1523
1519
  };
1524
1520
 
1525
- /** Right-badge accent for `proposed: true` (no role, or combined with role). */
1526
- export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
1527
-
1528
1521
  /**
1529
1522
  * Border accent for `deprecated: true` — the claim was real, its source is gone.
1530
1523
  * A desaturated slate rather than true silver: role badges already own silver
@@ -1545,25 +1538,19 @@ export function deprecatedBadgeColor(component: {
1545
1538
  }
1546
1539
 
1547
1540
  /**
1548
- * Top-right badge label: proposed wins over role when both are set
1549
- * (proposed is the temporary exception to scan for; role stays on the model).
1550
- * Returns null when neither applies.
1541
+ * Top-right badge label: the role, when set. Returns null otherwise.
1551
1542
  */
1552
1543
  export function rightBadgeLabel(component: {
1553
1544
  role?: SubsystemComponentRole;
1554
- proposed?: boolean;
1555
1545
  }): string | null {
1556
- if (component.proposed) return 'proposed';
1557
1546
  if (component.role != null) return ROLE_LABEL[component.role];
1558
1547
  return null;
1559
1548
  }
1560
1549
 
1561
- /** Color for the top-right badge (role and/or proposed). */
1550
+ /** Color for the top-right role badge. */
1562
1551
  export function rightBadgeColor(component: {
1563
1552
  role?: SubsystemComponentRole;
1564
- proposed?: boolean;
1565
1553
  }): string | null {
1566
- if (component.proposed) return PROPOSED_COLOR;
1567
1554
  if (component.role != null) return ROLE_COLOR[component.role];
1568
1555
  return null;
1569
1556
  }
@@ -1593,7 +1580,7 @@ function storeStorage(component: {
1593
1580
 
1594
1581
  /** Top-right storage badge for `construct: 'store'` nodes with an authored
1595
1582
  * `storage` declaration — how the retained state is backed. Sits beside the
1596
- * role/proposed badge (storage is orthogonal to topology). Null otherwise. */
1583
+ * role badge (storage is orthogonal to topology). Null otherwise. */
1597
1584
  export function storageBadgeLabel(component: {
1598
1585
  construct: SubsystemComponentConstruct;
1599
1586
  declaration?: AnyComponentDetail;
@@ -1688,7 +1675,6 @@ export function nodeMinWidthForBadges(component: {
1688
1675
  framework?: string;
1689
1676
  stereotype?: string;
1690
1677
  role?: SubsystemComponentRole;
1691
- proposed?: boolean;
1692
1678
  deprecated?: boolean;
1693
1679
  entityKind?: string;
1694
1680
  declaration?: AnyComponentDetail;
@@ -20,7 +20,6 @@ import {
20
20
  edgeColor,
21
21
  edgeStrokeStyle,
22
22
  DEPRECATED_COLOR,
23
- PROPOSED_COLOR,
24
23
  deprecatedBadgeColor,
25
24
  deprecatedBadgeLabel,
26
25
  constructBadgeColor,
@@ -221,7 +220,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
221
220
  // `symbol` is the source of truth; `name` is derived from it consistently.
222
221
  const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype);
223
222
  // Top badges are absolutely positioned — widen the node so they nowrap
224
- // instead of wrapping, including when construct + role/proposed share the top.
223
+ // instead of wrapping, including when construct + role share the top.
225
224
  const badgeMinWidth = nodeMinWidthForBadges(c);
226
225
  const topRightLabel = rightBadgeLabel(c);
227
226
  const topRightColor = rightBadgeColor(c);
@@ -243,17 +242,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
243
242
  const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
244
243
  const hoverBg =
245
244
  theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
246
- // Border: proposed uses the goldenrod accent (dashed), deprecated the slate
247
- // accent (dotted, same family as issue) since both mean "not a live claim";
248
- // construct color stays on the left badge. File-open spotlight still wins
249
- // with primary.
245
+ // Border: deprecated uses the slate accent (dotted, same family as issue)
246
+ // since it means "not a live claim"; construct color stays on the left
247
+ // badge. File-open spotlight still wins with primary.
250
248
  const borderColor = fileMatch
251
249
  ? theme.colors.primary
252
- : c.proposed
253
- ? PROPOSED_COLOR
254
- : c.deprecated
255
- ? DEPRECATED_COLOR
256
- : color;
250
+ : c.deprecated
251
+ ? DEPRECATED_COLOR
252
+ : color;
257
253
  // Selection is stamped into data by the graph component — React Flow's own
258
254
  // `selected` never updates because the node stops click propagation.
259
255
  const isSelected = selected || (data.isSelected as boolean | undefined) === true;
@@ -269,21 +265,19 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
269
265
  const borderW = isSelected || fileMatch ? 4 : 2;
270
266
  const badgeTop = -9 - borderW;
271
267
  const badgeEdge = -borderW;
272
- // A node with diagnostics turns its border dotted — a "something's off"
273
- // sibling of `proposed`'s dashed, but distinct so the two don't read alike.
274
- // The border keeps its construct color; the severity rides on the chip.
275
- // Verification skips `proposed` nodes, so the two rarely stack — issue wins.
268
+ // A node with diagnostics turns its border dotted — an "something's off"
269
+ // signal. The border keeps its construct color; the severity rides on the
270
+ // chip. Deprecated is also dotted, but slate rather than construct-colored:
271
+ // "this claim was retired" is closer to a diagnostic than to a placeholder.
276
272
  const hasIssue = data.issue != null;
277
273
  // Deprecated is dotted like issue but slate rather than construct-colored:
278
274
  // "this claim was retired" is closer to a diagnostic than to a placeholder,
279
275
  // and verification skips deprecated nodes so the two never stack in practice.
280
276
  const borderStyle = hasIssue
281
277
  ? 'dotted'
282
- : c.proposed
283
- ? 'dashed'
284
- : c.deprecated
285
- ? 'dotted'
286
- : 'solid';
278
+ : c.deprecated
279
+ ? 'dotted'
280
+ : 'solid';
287
281
 
288
282
  return (
289
283
  <div
@@ -352,9 +346,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
352
346
  {constructBadgeLabel(c)}
353
347
  </div>
354
348
 
355
- {/* Secondary badge cluster — proposed wins over role when both are set;
356
- store storage (retention backing) sits beside, since it is
357
- orthogonal to topology. Persistent; pointer-events pass through.
349
+ {/* Secondary badge cluster — the role badge, with store storage
350
+ (retention backing) beside it since it is orthogonal to topology.
351
+ Persistent; pointer-events pass through.
358
352
  Sits astride the bottom edge, centered (mirroring the top construct
359
353
  badge's straddle), so the top edge stays free for the construct tag. */}
360
354
  {(topRightLabel != null || storageLabel != null || deprecatedLabel != null) && (
@@ -869,11 +863,8 @@ export function SubsystemEdge({
869
863
  const edgeRef = { mechanism, provenance: data?.provenance };
870
864
  const color = edgeColor(edgeRef);
871
865
  // Dash style encodes provenance (graphify) and relationship kind (dashed =
872
- // inverted-control or observational: hierarchy, registration, watches). A
873
- // step-proposed edge also dashes — the seam itself is planned — matching
874
- // the dashed edge label and a proposed node's dashed border.
875
- const isDashed =
876
- edgeStrokeStyle(edgeRef) === 'dashed' || data?.proposedStep === true;
866
+ // inverted-control or observational: hierarchy, registration, watches).
867
+ const isDashed = edgeStrokeStyle(edgeRef) === 'dashed';
877
868
  const dimmed = data?.dimmed === true;
878
869
  // Dim the stroke by color, never via path `opacity`. SVG markers are shared
879
870
  // by id; opacity on the referencing path paints every arrowhead that uses
@@ -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
+ });