@skyhook-io/radar-app 1.14.0 → 1.14.2

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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -1,5 +1,6 @@
1
1
  // Pure presentation decisions over the durable transcript and evidence projection.
2
2
  // Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
3
+ import { isDiagnosableWorkloadKind } from "./investigationEvidence/observations";
3
4
  import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
4
5
  import {
5
6
  DiagnoseError,
@@ -273,32 +274,130 @@ export function investigationEvidenceInputsEqual(
273
274
  }
274
275
 
275
276
  /**
276
- * The turn whose agent case annotates the Evidence pane. A follow-up answer
277
- * that cites evidence ("chart this and cite it") must reach Findings, so the
278
- * newest completed non-apply, non-explanation turn carrying a bound case or
279
- * linked root-cause refs wins; earlier turns keep their case read-only.
277
+ * Which turns are assessments — the ones Findings may show. The initial turn
278
+ * and explicit verifications always are; an ordinary question is one only
279
+ * when its verdict says so with `revisesAssessment` AND carries a complete
280
+ * verdict (a headline and a finding), because agents restate the root cause
281
+ * on most answers and a bare flag must never retire what the reader is
282
+ * looking at. The server enforces the same completeness rule; this mirrors it
283
+ * so a hosted backend that forgets cannot rewrite Findings by accident.
280
284
  */
281
- export function investigationLiveCaseTurnIndex(
285
+ export function investigationIsAssessmentTurn(
286
+ turn: Pick<
287
+ Turn,
288
+ | "status"
289
+ | "apply"
290
+ | "explainAssessment"
291
+ | "question"
292
+ | "verify"
293
+ | "diagnosis"
294
+ >,
295
+ ): boolean {
296
+ const dx = turn.diagnosis;
297
+ if (!dx || turn.status !== "done" || turn.apply || turn.explainAssessment)
298
+ return false;
299
+ const structured =
300
+ !!dx.rootCause ||
301
+ (dx.remediation?.length ?? 0) > 0 ||
302
+ !!dx.healthy ||
303
+ !!dx.inconclusive;
304
+ if (!structured) return false;
305
+ if (!turn.question || turn.verify) return true;
306
+ return (
307
+ dx.revisesAssessment === true &&
308
+ !!dx.summary &&
309
+ (!!dx.rootCause || !!dx.healthy || !!dx.inconclusive)
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Later turns whose reads do not make the assessment "earlier": a question
315
+ * the agent answered under the story contract and marked as not revising it.
316
+ * The contract is only in force when the assessment itself carries a summary;
317
+ * older runs never asked, so every later read still counts as newer evidence.
318
+ */
319
+ export function investigationSettledAnswerTurnIndexes(
282
320
  turns: readonly Pick<
283
321
  Turn,
284
- "status" | "apply" | "explainAssessment" | "diagnosis"
322
+ | "status"
323
+ | "apply"
324
+ | "explainAssessment"
325
+ | "question"
326
+ | "verify"
327
+ | "diagnosis"
285
328
  >[],
286
329
  currentAssessmentIdx: number,
287
- ): number {
288
- for (let index = turns.length - 1; index >= 0; index -= 1) {
289
- const turn = turns[index];
290
- if (turn.status !== "done" || turn.apply || turn.explainAssessment)
291
- continue;
292
- const diagnosis = turn.diagnosis;
293
- if (!diagnosis) continue;
330
+ ): Set<number> {
331
+ const settled = new Set<number>();
332
+ const assessment = turns[currentAssessmentIdx]?.diagnosis;
333
+ if (!assessment?.summary) return settled;
334
+ turns.forEach((turn, index) => {
335
+ if (index <= currentAssessmentIdx) return;
294
336
  if (
295
- diagnosis.evidence?.some((item) => item.status === "linked") ||
296
- (diagnosis.rootCause && diagnosis.rootCauseEvidence?.status === "linked")
297
- ) {
298
- return Math.max(index, currentAssessmentIdx);
299
- }
300
- }
301
- return currentAssessmentIdx;
337
+ turn.status === "done" &&
338
+ turn.diagnosis &&
339
+ !turn.apply &&
340
+ !turn.verify &&
341
+ !turn.explainAssessment &&
342
+ turn.diagnosis.revisesAssessment !== true
343
+ )
344
+ settled.add(index);
345
+ });
346
+ return settled;
347
+ }
348
+
349
+ export function investigationAssessmentTurnIndexes(
350
+ turns: readonly Pick<
351
+ Turn,
352
+ | "status"
353
+ | "apply"
354
+ | "explainAssessment"
355
+ | "question"
356
+ | "verify"
357
+ | "diagnosis"
358
+ >[],
359
+ ): number[] {
360
+ const indexes: number[] = [];
361
+ turns.forEach((turn, index) => {
362
+ if (investigationIsAssessmentTurn(turn)) indexes.push(index);
363
+ });
364
+ return indexes;
365
+ }
366
+
367
+ /** The two coverage gaps that carry no limitation of their own, named so Still open can say which. */
368
+ export function investigationEvidenceCoverageGaps(
369
+ projection: Pick<InvestigationEvidenceProjection, "coverage"> & {
370
+ sources: readonly { id: string; tool: string; confirmedSuccess: boolean }[];
371
+ groups: readonly {
372
+ latest: {
373
+ relevance: "target" | "producer-related" | "broader";
374
+ source: { id: string };
375
+ };
376
+ }[];
377
+ },
378
+ targetKind?: string,
379
+ ): { noEvidence: boolean; noTargetDiagnosis: boolean } {
380
+ // Diagnose covers workloads only; a target it cannot bundle (an HPA, a
381
+ // Service) is read through get_resource and issues, and that is complete.
382
+ if (targetKind !== undefined && !isDiagnosableWorkloadKind(targetKind))
383
+ return {
384
+ noEvidence: projection.coverage.projected === 0,
385
+ noTargetDiagnosis: false,
386
+ };
387
+ const completeDiagnosisSourceIds = new Set(
388
+ projection.sources
389
+ .filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
390
+ .map((source) => source.id),
391
+ );
392
+ const hasTargetDiagnosis = projection.groups.some(
393
+ (group) =>
394
+ group.latest.relevance !== "broader" &&
395
+ completeDiagnosisSourceIds.has(group.latest.source.id),
396
+ );
397
+ return {
398
+ noEvidence: projection.coverage.projected === 0,
399
+ noTargetDiagnosis: !hasTargetDiagnosis,
400
+ };
302
401
  }
303
402
 
304
403
  export function investigationEvidenceCoverageLimited(
@@ -318,21 +417,13 @@ export function investigationEvidenceCoverageLimited(
318
417
  };
319
418
  }[];
320
419
  },
420
+ targetKind?: string,
321
421
  ): boolean {
322
- const completeDiagnosisSourceIds = new Set(
323
- projection.sources
324
- .filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
325
- .map((source) => source.id),
326
- );
327
- const hasTargetDiagnosis = projection.groups.some(
328
- (group) =>
329
- group.latest.relevance !== "broader" &&
330
- completeDiagnosisSourceIds.has(group.latest.source.id),
331
- );
422
+ const gaps = investigationEvidenceCoverageGaps(projection, targetKind);
332
423
  return (
333
424
  projection.limitations.length > 0 ||
334
- projection.coverage.projected === 0 ||
335
- !hasTargetDiagnosis
425
+ gaps.noEvidence ||
426
+ gaps.noTargetDiagnosis
336
427
  );
337
428
  }
338
429
 
@@ -353,11 +444,142 @@ interface HealthConflictGroup {
353
444
  tier: "key" | "supporting" | "context" | "checked";
354
445
  tone: string;
355
446
  title?: string;
447
+ summary?: string;
448
+ /** The card's payload; an events card carries the events it summarises. */
449
+ data?: {
450
+ type: string;
451
+ events?: readonly { reason: string; message: string; type?: string }[];
452
+ };
356
453
  /** Which turn captured this reading; a note cannot explain a later one. */
357
- source?: { turnIndex: number };
454
+ source?: { turnIndex: number; id?: string };
358
455
  };
359
456
  }
360
457
 
458
+ // Group ids that are the same observation for the reader's question. Two
459
+ // routes: a shared display identity (a log stream read through two calls),
460
+ // and for events, the same warning recorded at two scopes about the
461
+ // investigated workload — its Deployment's event feed and its Pod's carry one
462
+ // readiness failure as two cards. The event's own reason and message decide,
463
+ // within one namespace and only among cards about this target; a name never
464
+ // does.
465
+ function healthTwinIds(
466
+ groups: readonly HealthConflictGroup[],
467
+ ): (group: HealthConflictGroup) => Set<string> {
468
+ const byKey = new Map<string, Set<string>>();
469
+ const keysOf = (group: HealthConflictGroup): string[] => {
470
+ const keys: string[] = [];
471
+ if (group.identity) keys.push(`${group.kind}\u0000${group.identity}`);
472
+ if (
473
+ group.kind === "events" &&
474
+ group.latest.relevance !== "broader" &&
475
+ group.identity &&
476
+ group.latest.data?.type === "events"
477
+ ) {
478
+ // The whole adverse set has to match: a Pod card with warning A is not
479
+ // the Deployment card that carries A and B.
480
+ const events = group.latest.data.events ?? [];
481
+ const warnings = events.filter(
482
+ (event) => event.type === undefined || event.type === "Warning",
483
+ );
484
+ const namespace = /^\S+\s+([^/\s]+)\//.exec(group.identity)?.[1];
485
+ if (warnings.length > 0 && namespace) {
486
+ const set = [
487
+ ...new Set(
488
+ warnings.map(
489
+ (event) =>
490
+ `${event.reason.toLowerCase()}|${event.message.toLowerCase().replace(/\s+/g, " ").trim()}`,
491
+ ),
492
+ ),
493
+ ]
494
+ .sort()
495
+ .join("\u0001");
496
+ keys.push(`events\u0000${namespace}\u0000${set}`);
497
+ }
498
+ }
499
+ return keys;
500
+ };
501
+ for (const group of groups) {
502
+ if (!group.id) continue;
503
+ for (const key of keysOf(group)) {
504
+ const ids = byKey.get(key) ?? new Set<string>();
505
+ ids.add(group.id);
506
+ byKey.set(key, ids);
507
+ }
508
+ }
509
+ return (group) => {
510
+ const ids = new Set<string>([group.id ?? ""]);
511
+ for (const key of keysOf(group))
512
+ for (const id of byKey.get(key) ?? []) ids.add(id);
513
+ return ids;
514
+ };
515
+ }
516
+
517
+ /**
518
+ * One adverse Radar card on a healthy verdict, with the agent's position on
519
+ * it: it read the card as not a live problem (explained), as related but not
520
+ * what matters (related), never linked a reading to it (unaddressed), or
521
+ * called it a cause or symptom while still reporting healthy (contradiction).
522
+ * The reader sees which of the four it is, in words, next to the verdict.
523
+ */
524
+ export interface InvestigationHealthSignal {
525
+ groupId?: string;
526
+ sourceId?: string;
527
+ title: string;
528
+ status: "explained" | "related" | "unaddressed" | "contradiction";
529
+ role?: string;
530
+ claim?: string;
531
+ }
532
+
533
+ export function investigationHealthSignals(
534
+ projection: { groups: readonly HealthConflictGroup[] },
535
+ caseItems:
536
+ | readonly {
537
+ role: string;
538
+ placement: "card" | "revision" | "source";
539
+ claim: string;
540
+ groupId?: string;
541
+ source?: { turnIndex: number };
542
+ }[]
543
+ | undefined,
544
+ ): InvestigationHealthSignal[] {
545
+ const conflicting = investigationHealthConflictGroups(projection);
546
+ if (conflicting.length === 0) return [];
547
+ const twinsOf = healthTwinIds(projection.groups);
548
+ return conflicting.map((group) => {
549
+ const sameStream = twinsOf(group);
550
+ const fresh = (caseItems ?? []).filter(
551
+ (item) =>
552
+ item.groupId &&
553
+ sameStream.has(item.groupId) &&
554
+ item.placement === "card" &&
555
+ !(
556
+ group.latest.source !== undefined &&
557
+ item.source !== undefined &&
558
+ group.latest.source.turnIndex > item.source.turnIndex
559
+ ),
560
+ );
561
+ const base = {
562
+ groupId: group.id,
563
+ sourceId: group.latest.source?.id,
564
+ title: group.latest.title ?? group.kind,
565
+ };
566
+ const asserting = fresh.find(
567
+ (item) => item.role === "cause" || item.role === "symptom",
568
+ );
569
+ if (asserting)
570
+ return { ...base, status: "contradiction", role: asserting.role };
571
+ const benign = fresh.find(
572
+ (item) => item.role === "benign" && item.claim.trim() !== "",
573
+ );
574
+ if (benign) return { ...base, status: "explained", claim: benign.claim };
575
+ const demoted = fresh.find(
576
+ (item) => item.role === "demoted" && item.claim.trim() !== "",
577
+ );
578
+ if (demoted) return { ...base, status: "related", claim: demoted.claim };
579
+ return { ...base, status: "unaddressed" };
580
+ });
581
+ }
582
+
361
583
  export function investigationHealthConflictGroups<
362
584
  G extends HealthConflictGroup,
363
585
  >(projection: { groups: readonly G[] }): G[] {
@@ -410,19 +632,10 @@ export function investigationHealthConflictExplainedBy(
410
632
  // agent addressed the stream: it explained one card, and the conflict is
411
633
  // recorded on its twin. Group ids that share an identity are the same
412
634
  // underlying observation for this question.
413
- const twins = new Map<string, Set<string>>();
414
- for (const group of projection.groups) {
415
- if (!group.id || !group.identity) continue;
416
- const key = `${group.kind}\u0000${group.identity}`;
417
- const ids = twins.get(key) ?? new Set<string>();
418
- ids.add(group.id);
419
- twins.set(key, ids);
420
- }
635
+ const twinsOf = healthTwinIds(projection.groups);
421
636
  const titles: string[] = [];
422
637
  for (const group of conflicting) {
423
- const sameStream =
424
- (group.identity && twins.get(`${group.kind}\u0000${group.identity}`)) ||
425
- new Set<string>([group.id ?? ""]);
638
+ const sameStream = twinsOf(group);
426
639
  const onGroup = caseItems.filter(
427
640
  (item) => item.groupId && sameStream.has(item.groupId),
428
641
  );
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ splitStory,
4
+ storyHasPlacements,
5
+ storyPlainText,
6
+ storyReferenceHref,
7
+ storyReferenceIndex,
8
+ UNRESOLVED_STORY_INDEX,
9
+ } from "./investigationStory";
10
+
11
+ describe("splitStory", () => {
12
+ it("places a marker on its own line and references one inside a sentence", () => {
13
+ const { segments } = splitStory(
14
+ "The pod crashes.\n\n[[radar:evidence=0]]\n\nAs [[radar:evidence=0]] shows, auth fails.\n [[radar:evidence=2]] \nDone.",
15
+ );
16
+ expect(segments).toEqual([
17
+ { kind: "prose", markdown: "The pod crashes." },
18
+ { kind: "placement", index: 0 },
19
+ {
20
+ kind: "prose",
21
+ markdown:
22
+ "As [[[radar:evidence=0]]](#radar-evidence-0) shows, auth fails.",
23
+ },
24
+ { kind: "placement", index: 2 },
25
+ { kind: "prose", markdown: "Done." },
26
+ ]);
27
+ expect(JSON.stringify(segments)).toContain("#radar-evidence-0)");
28
+ });
29
+
30
+ it("treats markers inside fences, inline code and blockquotes as literal text", () => {
31
+ const report = [
32
+ "Quoted output:",
33
+ "```",
34
+ "[[radar:evidence=1]]",
35
+ "```",
36
+ "Inline `[[radar:evidence=1]]` is code, but [[radar:evidence=3]] is not.",
37
+ "> [[radar:evidence=4]]",
38
+ "~~~txt",
39
+ "[[radar:evidence=5]]",
40
+ "~~~",
41
+ ].join("\n");
42
+ const { segments } = splitStory(report);
43
+ expect(segments.filter((s) => s.kind === "placement")).toEqual([]);
44
+ expect(JSON.stringify(segments)).toContain("#radar-evidence-3)");
45
+ const prose = segments
46
+ .map((s) => (s.kind === "prose" ? s.markdown : ""))
47
+ .join("\n");
48
+ expect(prose).toContain("`[[radar:evidence=1]]`");
49
+ expect(prose).toContain("> [[radar:evidence=4]]");
50
+ expect(prose).toContain("[[[radar:evidence=3]]](#radar-evidence-3)");
51
+ });
52
+
53
+ it("keeps indented-code markers and unbalanced closing fences literal", () => {
54
+ const indented = splitStory("Text.\n\n [[radar:evidence=0]]");
55
+ expect(indented.segments.every((s) => s.kind === "prose")).toBe(true);
56
+ expect(JSON.stringify(indented.segments)).not.toContain("#radar-evidence");
57
+ expect(
58
+ indented.segments.map((s) => s.kind === "prose" && s.markdown).join("\n"),
59
+ ).toContain(" [[radar:evidence=0]]");
60
+ const fence = splitStory(
61
+ "```\n````not-a-close\n[[radar:evidence=0]]\n```\n\n[[radar:evidence=1]]",
62
+ );
63
+ expect(
64
+ fence.segments
65
+ .filter((s) => s.kind === "placement")
66
+ .map((s) => s.kind === "placement" && s.index),
67
+ ).toEqual([1]);
68
+ });
69
+
70
+ it("resolves a ref-form marker to its item, and flags one nothing cites", () => {
71
+ const ref = "ev_" + "a".repeat(26) + "_" + "b".repeat(26);
72
+ const stray = "ev_" + "c".repeat(26) + "_" + "d".repeat(26);
73
+ const resolve = (candidate: string) => (candidate === ref ? 2 : undefined);
74
+ const { segments } = splitStory(
75
+ `The log shows it:\n\n[[radar:evidence-ref=${ref}]]\n\nSee [[radar:evidence-ref=${ref}|compact]] and [[radar:evidence-ref=${stray}]].`,
76
+ resolve,
77
+ );
78
+ expect(segments[1]).toEqual({ kind: "placement", index: 2 });
79
+ expect(JSON.stringify(segments)).toContain("#radar-evidence-2)");
80
+ expect(JSON.stringify(segments)).toContain("#radar-evidence--1)");
81
+ expect(segments[2]).toMatchObject({ kind: "prose" });
82
+ const inline = (segments[2] as { markdown: string }).markdown;
83
+ expect(inline).toContain("](#radar-evidence-2)");
84
+ expect(inline).toContain(`](#radar-evidence-${UNRESOLVED_STORY_INDEX})`);
85
+ expect(storyPlainText(`x [[radar:evidence-ref=${ref}]] y`)).toBe("x y");
86
+ });
87
+ it("keeps a marker inside a double-backtick code span literal", () => {
88
+ const { segments } = splitStory(
89
+ "Write `` [[radar:evidence=0]] `` to place, then see [[radar:evidence=0]].",
90
+ );
91
+ expect(JSON.stringify(segments)).toContain("#radar-evidence-0)");
92
+ expect((segments[0] as { markdown: string }).markdown).toContain(
93
+ "`` [[radar:evidence=0]] ``",
94
+ );
95
+ });
96
+
97
+ it("keeps a marker literal inside a code span that runs across lines", () => {
98
+ const split = splitStory(
99
+ "Quoted `example\n[[radar:evidence=0]]\nend` here.\n\n[[radar:evidence=0]]",
100
+ );
101
+ expect(split.segments.map((segment) => segment.kind)).toEqual([
102
+ "prose",
103
+ "placement",
104
+ ]);
105
+ expect(JSON.stringify(split.segments)).not.toContain("#radar-evidence");
106
+ expect((split.segments[0] as { markdown: string }).markdown).toContain(
107
+ "[[radar:evidence=0]]\nend`",
108
+ );
109
+ });
110
+
111
+ it("closes an open span at a fence, at a whitespace-only line, and keeps tab-indented lines literal", () => {
112
+ const kinds = (report: string) =>
113
+ splitStory(report).segments.map((segment) => segment.kind);
114
+ expect(kinds("Quoted `example\n~~~\nx\n~~~\n[[radar:evidence=0]]")).toEqual(
115
+ ["prose", "placement"],
116
+ );
117
+ expect(kinds("Quoted `example\n \n[[radar:evidence=0]]")).toEqual([
118
+ "prose",
119
+ "placement",
120
+ ]);
121
+ // A tab-indented fence is indented code, so the marker between two of
122
+ // them stands on its own line in prose.
123
+ expect(kinds("\t~~~\n[[radar:evidence=0]]\n\t~~~")).toEqual([
124
+ "prose",
125
+ "placement",
126
+ "prose",
127
+ ]);
128
+ expect(kinds("\t[[radar:evidence=0]]")).toEqual(["prose"]);
129
+ expect(
130
+ JSON.stringify(splitStory("\t[[radar:evidence=0]]").segments),
131
+ ).not.toContain("#radar-evidence");
132
+ });
133
+
134
+ it("reads the compact variant as a placement flag", () => {
135
+ const { segments } = splitStory(
136
+ "A.\n[[radar:evidence=2|compact]]\nB [[radar:evidence=2|compact]].",
137
+ );
138
+ expect(segments[1]).toEqual({ kind: "placement", index: 2, compact: true });
139
+ expect(segments[2]).toEqual({
140
+ kind: "prose",
141
+ markdown: "B [[[radar:evidence=2|compact]]](#radar-evidence-2).",
142
+ });
143
+ });
144
+
145
+ it("keeps an unterminated fence literal to the end", () => {
146
+ const { segments } = splitStory("```\n[[radar:evidence=0]]\nstill code");
147
+ expect(segments).toEqual([
148
+ { kind: "prose", markdown: "```\n[[radar:evidence=0]]\nstill code" },
149
+ ]);
150
+ });
151
+
152
+ it("round-trips reference hrefs and strips markers for plain text", () => {
153
+ expect(storyReferenceIndex(storyReferenceHref(7))).toBe(7);
154
+ expect(storyReferenceIndex("#other")).toBeUndefined();
155
+ expect(
156
+ storyPlainText("A.\n\n[[radar:evidence=0]]\n\nB [[radar:evidence=1]]."),
157
+ ).toBe("A.\n\nB .");
158
+ expect(storyHasPlacements("no markers")).toBe(false);
159
+ expect(storyHasPlacements("x [[radar:evidence=0]]")).toBe(true);
160
+ });
161
+ });