@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.
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreFileView.js +3 -3
- package/dist/pierre/PierreFileView.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +4 -9
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +28 -25
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/pierre/fileAvailability.d.ts +4 -7
- package/dist/pierre/fileAvailability.d.ts.map +1 -1
- package/dist/pierre/fileAvailability.js +5 -10
- package/dist/pierre/fileAvailability.js.map +1 -1
- package/dist/pierre/trailSnippet.d.ts +10 -0
- package/dist/pierre/trailSnippet.d.ts.map +1 -0
- package/dist/pierre/trailSnippet.js +22 -0
- package/dist/pierre/trailSnippet.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +1 -1
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts +3 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +2 -2
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +26 -0
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -5
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +37 -47
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailStackBreadcrumb.d.ts +21 -0
- package/dist/subsystem/TrailStackBreadcrumb.d.ts.map +1 -0
- package/dist/subsystem/TrailStackBreadcrumb.js +77 -0
- package/dist/subsystem/TrailStackBreadcrumb.js.map +1 -0
- package/dist/subsystem/TrailStepsNav.d.ts +4 -3
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
- package/dist/subsystem/TrailStepsNav.js +13 -16
- package/dist/subsystem/TrailStepsNav.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts +9 -7
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +126 -76
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/model.d.ts +5 -18
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +3 -11
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +17 -24
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/stepEmphasis.d.ts +78 -0
- package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
- package/dist/subsystem/stepEmphasis.js +89 -0
- package/dist/subsystem/stepEmphasis.js.map +1 -0
- package/dist/subsystem/trailBrief.d.ts.map +1 -1
- package/dist/subsystem/trailBrief.js +20 -0
- package/dist/subsystem/trailBrief.js.map +1 -1
- package/dist/subsystem/trailStack.d.ts +53 -0
- package/dist/subsystem/trailStack.d.ts.map +1 -0
- package/dist/subsystem/trailStack.js +154 -0
- package/dist/subsystem/trailStack.js.map +1 -0
- package/dist/subsystem/trailStepDisplay.d.ts +38 -0
- package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
- package/dist/subsystem/trailStepDisplay.js +41 -0
- package/dist/subsystem/trailStepDisplay.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +10 -1
- package/src/pierre/PierreFileView.tsx +3 -3
- package/src/pierre/PierreTrailCodeView.tsx +24 -36
- package/src/pierre/fileAvailability.test.ts +0 -6
- package/src/pierre/fileAvailability.ts +5 -13
- package/src/pierre/trailSnippet.test.ts +74 -0
- package/src/pierre/trailSnippet.ts +35 -0
- package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
- package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
- package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
- package/src/subsystem/C4Graph.tsx +0 -1
- package/src/subsystem/FileDrawer.tsx +6 -0
- package/src/subsystem/IssueList.tsx +26 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
- package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
- package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
- package/src/subsystem/TrailStepsNav.test.tsx +35 -0
- package/src/subsystem/TrailStepsNav.tsx +18 -19
- package/src/subsystem/TrailsPanel.test.tsx +149 -0
- package/src/subsystem/TrailsPanel.tsx +165 -39
- package/src/subsystem/model.test.ts +7 -22
- package/src/subsystem/model.ts +6 -20
- package/src/subsystem/nodes.tsx +19 -28
- package/src/subsystem/stepEmphasis.ts +151 -0
- package/src/subsystem/trailBrief.test.ts +29 -0
- package/src/subsystem/trailBrief.ts +20 -0
- package/src/subsystem/trailStack.test.ts +133 -0
- package/src/subsystem/trailStack.ts +229 -0
- package/src/subsystem/trailStepDisplay.ts +68 -0
- package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
- 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: "
|
|
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: '
|
|
114
|
+
steps: [{ from: 'reader', to: 'external-watcher', mechanism: 'feeds', file: 'x.ts' }],
|
|
115
115
|
}],
|
|
116
116
|
});
|
|
117
|
-
expect(nodes.find((n) => n.id === '
|
|
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({
|
|
245
|
-
expect(
|
|
230
|
+
const right = estimateBadgeLabelWidth(rightBadgeLabel({ role: 'entry' })!);
|
|
231
|
+
expect(withRole).toBeGreaterThan(left + right + 2 * BADGE_EDGE_INSET);
|
|
246
232
|
});
|
|
247
233
|
|
|
248
|
-
test('rightBadgeLabel
|
|
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'
|
|
237
|
+
expect(rightBadgeLabel({ role: 'service' })).toBe('service');
|
|
253
238
|
});
|
|
254
239
|
|
|
255
240
|
test('deriveNameFromSymbol falls back to existing name when no symbol', () => {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
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;
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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.
|
|
253
|
-
?
|
|
254
|
-
:
|
|
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 —
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
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.
|
|
283
|
-
? '
|
|
284
|
-
:
|
|
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 —
|
|
356
|
-
|
|
357
|
-
|
|
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).
|
|
873
|
-
|
|
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
|
+
});
|