@team-agent/installer 0.5.91 → 0.5.94

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.
@@ -902,6 +902,7 @@ mod tests {
902
902
 
903
903
  #[cfg(unix)]
904
904
  #[test]
905
+ #[serial_test::serial(env)]
905
906
  fn command_boundary_connects_runner_parser_search_and_contract() {
906
907
  let path = fixture(
907
908
  "printf 'provider model\\nopenai-codex gpt-5.6-sol\\nopenai-codex gpt-5.6-luna\\n'",
@@ -1,7 +1,7 @@
1
1
  //! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
2
2
  //!
3
3
  //! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)。
4
- //! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
4
+ //! Cleanup is always per-session and ownership-gated; a socket is never sufficient authority for kill-server.
5
5
  //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
6
6
 
7
7
  use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
@@ -23,12 +23,15 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
23
23
  raw.iter().map(|s| s.to_string()).collect()
24
24
  }
25
25
 
26
- // RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
26
+ // RC-4: even an apparently exclusive socket uses per-session cleanup.
27
27
  #[test]
28
- fn rc4_exclusive_socket_kills_server() {
28
+ fn rc4_exclusive_socket_is_fail_closed() {
29
29
  assert_eq!(
30
30
  sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
31
- KillDecision::KillServerExclusive
31
+ KillDecision::KillIndividually {
32
+ to_kill: names(&["team-x", "team-y"]),
33
+ spared: vec![]
34
+ }
32
35
  );
33
36
  // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
34
37
  assert_eq!(
@@ -87,10 +90,13 @@ fn rc2_no_anchor_falls_back_to_naming() {
87
90
  &BTreeSet::new(),
88
91
  );
89
92
  assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
90
- // 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
93
+ // 无锚 + 无前缀仍只能逐 session 返回,实际 shutdown 会因 marker 缺失拒绝杀灭。
91
94
  assert_eq!(
92
95
  sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
93
- KillDecision::KillServerExclusive
96
+ KillDecision::KillIndividually {
97
+ to_kill: names(&["team-x"]),
98
+ spared: vec![]
99
+ }
94
100
  );
95
101
  }
96
102
 
@@ -169,6 +175,8 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
169
175
  &ws,
170
176
  &json!({
171
177
  "session_name": "team-owned-clean",
178
+ "team_key": "team-owned-clean",
179
+ "generation": "team-owned-clean",
172
180
  "tmux_endpoint": socket.to_string_lossy(),
173
181
  "tmux_socket": socket.to_string_lossy(),
174
182
  "tmux_socket_source": "workspace",
@@ -177,7 +185,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
177
185
  }),
178
186
  )
179
187
  .unwrap();
180
- let transport = CleanShutdownTransport::new();
188
+ let transport = CleanShutdownTransport::new().with_owned_session(
189
+ &ws,
190
+ "team-owned-clean",
191
+ "team-owned-clean",
192
+ "team-owned-clean",
193
+ );
181
194
 
182
195
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
183
196
  .expect("shutdown should complete");
@@ -186,12 +199,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
186
199
  assert_eq!(out["status"], json!("ok"));
187
200
  assert_eq!(out["residuals"]["owned_files"], json!([]));
188
201
  assert!(
189
- !socket.exists(),
190
- "owned empty endpoint socket file must be removed"
202
+ socket.exists(),
203
+ "fail-closed endpoint cleanup must preserve the socket file"
191
204
  );
192
205
  assert!(
193
- transport.kill_server_called(),
194
- "owned empty endpoint should be torn down after session cleanup"
206
+ !transport.kill_server_called(),
207
+ "endpoint ownership must not authorize server teardown"
195
208
  );
196
209
  }
197
210
 
@@ -235,6 +248,8 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
235
248
  &ws,
236
249
  &json!({
237
250
  "session_name": "team-owned-residual",
251
+ "team_key": "team-owned-residual",
252
+ "generation": "team-owned-residual",
238
253
  "tmux_endpoint": socket_dir.to_string_lossy(),
239
254
  "tmux_socket": socket_dir.to_string_lossy(),
240
255
  "tmux_socket_source": "workspace",
@@ -243,18 +258,22 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
243
258
  }),
244
259
  )
245
260
  .unwrap();
246
- let transport = CleanShutdownTransport::new();
261
+ let transport = CleanShutdownTransport::new().with_owned_session(
262
+ &ws,
263
+ "team-owned-residual",
264
+ "team-owned-residual",
265
+ "team-owned-residual",
266
+ );
247
267
 
248
268
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
249
269
  .expect("shutdown should complete");
250
270
 
251
- assert_eq!(out["ok"], json!(false));
252
- assert_eq!(out["status"], json!("failed"));
271
+ assert_eq!(out["ok"], json!(true));
272
+ assert_eq!(out["status"], json!("ok"));
253
273
  assert_eq!(out["phase"], json!(null));
254
- assert_eq!(
255
- out["residuals"]["owned_files"],
256
- json!([{ "path": socket_dir.display().to_string() }])
257
- );
274
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
275
+ assert!(socket_dir.exists(), "fail-closed cleanup preserves residual files");
276
+ assert!(!transport.kill_server_called());
258
277
  }
259
278
 
260
279
  #[test]
@@ -340,6 +359,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
340
359
  "schema_version": 1,
341
360
  "active_team_key": "current",
342
361
  "team_key": "current",
362
+ "generation": "current",
343
363
  "session_name": "team-current",
344
364
  "team_dir": ws.display().to_string(),
345
365
  "workspace": ws.display().to_string(),
@@ -354,6 +374,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
354
374
  "teams": {
355
375
  "current": {
356
376
  "team_key": "current",
377
+ "generation": "current",
357
378
  "session_name": "team-current",
358
379
  "team_dir": ws.display().to_string(),
359
380
  "workspace": ws.display().to_string(),
@@ -375,7 +396,12 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
375
396
  &ws,
376
397
  true,
377
398
  Some("current"),
378
- &CleanShutdownTransport::new(),
399
+ &CleanShutdownTransport::new().with_owned_session(
400
+ &ws,
401
+ "team-current",
402
+ "current",
403
+ "current",
404
+ ),
379
405
  )
380
406
  .expect("shutdown should complete");
381
407
 
@@ -414,6 +440,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
414
440
  "schema_version": 1,
415
441
  "active_team_key": "current",
416
442
  "team_key": "current",
443
+ "generation": "current",
417
444
  "session_name": "team-current",
418
445
  "agents": {
419
446
  "adminweb": {
@@ -426,6 +453,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
426
453
  "teams": {
427
454
  "current": {
428
455
  "team_key": "current",
456
+ "generation": "current",
429
457
  "session_name": "team-current",
430
458
  "agents": {
431
459
  "adminweb": {
@@ -445,7 +473,9 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
445
473
  &ws,
446
474
  true,
447
475
  Some("current"),
448
- &CleanShutdownTransport::new().with_probe_timeout("ps_table"),
476
+ &CleanShutdownTransport::new()
477
+ .with_owned_session(&ws, "team-current", "current", "current")
478
+ .with_probe_timeout("ps_table"),
449
479
  )
450
480
  .expect("shutdown should complete");
451
481
 
@@ -519,6 +549,8 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
519
549
  &ws,
520
550
  &json!({
521
551
  "session_name": "team-owned-loop",
552
+ "team_key": "team-owned-loop",
553
+ "generation": "team-owned-loop",
522
554
  "tmux_endpoint": socket.to_string_lossy(),
523
555
  "tmux_socket": socket.to_string_lossy(),
524
556
  "tmux_socket_source": "workspace",
@@ -535,7 +567,12 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
535
567
  &ws,
536
568
  true,
537
569
  None,
538
- &CleanShutdownTransport::new(),
570
+ &CleanShutdownTransport::new().with_owned_session(
571
+ &ws,
572
+ "team-owned-loop",
573
+ "team-owned-loop",
574
+ "team-owned-loop",
575
+ ),
539
576
  );
540
577
  let out = match &result {
541
578
  Ok(out) if !shutdown_result_is_degraded(out) => out,
@@ -573,8 +610,9 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
573
610
  }
574
611
  let ending = sockets.iter().filter(|path| path.exists()).count();
575
612
  assert_eq!(
576
- ending, starting,
577
- "owned socket files must not grow across loops"
613
+ ending,
614
+ starting + sockets.len(),
615
+ "fail-closed endpoint cleanup must not remove unverified socket files"
578
616
  );
579
617
  #[cfg(unix)]
580
618
  if expected_probe_failure {
@@ -793,6 +831,7 @@ struct CleanShutdownTransport {
793
831
  probe_timeout_kind: Option<&'static str>,
794
832
  targets_persist_after_kill: bool,
795
833
  kill_session_error: Option<String>,
834
+ owner: Option<crate::transport::SessionOwner>,
796
835
  // E49 (0.3.24 P0, shutdown kills leader CLI): record per-pane / per-window /
797
836
  // per-session kill targets so RED contracts can assert (a) the leader pane is
798
837
  // never killed and (b) worker panes ARE killed via the new per-pane path.
@@ -810,6 +849,7 @@ impl CleanShutdownTransport {
810
849
  probe_timeout_kind: None,
811
850
  targets_persist_after_kill: false,
812
851
  kill_session_error: None,
852
+ owner: None,
813
853
  killed_panes: Mutex::new(Vec::new()),
814
854
  killed_window_targets: Mutex::new(Vec::new()),
815
855
  killed_sessions: Mutex::new(Vec::new()),
@@ -821,6 +861,46 @@ impl CleanShutdownTransport {
821
861
  self
822
862
  }
823
863
 
864
+ fn with_owned_session(
865
+ mut self,
866
+ workspace: &Path,
867
+ session: &str,
868
+ team: &str,
869
+ generation: &str,
870
+ ) -> Self {
871
+ self.owner = Some(crate::transport::SessionOwner {
872
+ workspace: workspace
873
+ .canonicalize()
874
+ .unwrap_or_else(|_| workspace.to_path_buf())
875
+ .to_string_lossy()
876
+ .into_owned(),
877
+ team: team.to_string(),
878
+ generation: generation.to_string(),
879
+ });
880
+ if self.targets.is_empty() {
881
+ self.targets.push(PaneInfo {
882
+ pane_id: PaneId::new("%owned"),
883
+ session: SessionName::new(session),
884
+ window_index: Some(0),
885
+ window_name: Some(WindowName::new("owned")),
886
+ pane_index: Some(0),
887
+ tty: None,
888
+ current_command: Some("codex".to_string()),
889
+ current_path: Some(workspace.to_path_buf()),
890
+ active: true,
891
+ pane_pid: None,
892
+ leader_env: BTreeMap::new(),
893
+ });
894
+ } else {
895
+ for target in &mut self.targets {
896
+ if target.current_path.is_none() {
897
+ target.current_path = Some(workspace.to_path_buf());
898
+ }
899
+ }
900
+ }
901
+ self
902
+ }
903
+
824
904
  fn kill_server_called(&self) -> bool {
825
905
  *self.kill_server_called.lock().unwrap()
826
906
  }
@@ -931,6 +1011,13 @@ impl Transport for CleanShutdownTransport {
931
1011
  Ok(*self.session_present.lock().unwrap())
932
1012
  }
933
1013
 
1014
+ fn session_owner(
1015
+ &self,
1016
+ _session: &SessionName,
1017
+ ) -> Result<Option<crate::transport::SessionOwner>, TransportError> {
1018
+ Ok(self.owner.clone())
1019
+ }
1020
+
934
1021
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
935
1022
  Ok(Vec::new())
936
1023
  }
@@ -1001,6 +1088,8 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
1001
1088
  &ws,
1002
1089
  &json!({
1003
1090
  "session_name": "team-lsof-cwd-timeout",
1091
+ "team_key": "team-lsof-cwd-timeout",
1092
+ "generation": "team-lsof-cwd-timeout",
1004
1093
  "is_external_leader": true,
1005
1094
  "agents": {
1006
1095
  "fake_impl": {
@@ -1017,7 +1106,14 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
1017
1106
  &ws,
1018
1107
  true,
1019
1108
  None,
1020
- &CleanShutdownTransport::new().with_probe_timeout("lsof_cwd"),
1109
+ &CleanShutdownTransport::new()
1110
+ .with_owned_session(
1111
+ &ws,
1112
+ "team-lsof-cwd-timeout",
1113
+ "team-lsof-cwd-timeout",
1114
+ "team-lsof-cwd-timeout",
1115
+ )
1116
+ .with_probe_timeout("lsof_cwd"),
1021
1117
  )
1022
1118
  .expect("shutdown should complete");
1023
1119
 
@@ -1051,6 +1147,8 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
1051
1147
  &ws,
1052
1148
  &json!({
1053
1149
  "session_name": "team-ps-table-timeout",
1150
+ "team_key": "team-ps-table-timeout",
1151
+ "generation": "team-ps-table-timeout",
1054
1152
  "agents": {
1055
1153
  "fake_impl": {
1056
1154
  "status": "running",
@@ -1066,7 +1164,14 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
1066
1164
  &ws,
1067
1165
  true,
1068
1166
  None,
1069
- &CleanShutdownTransport::new().with_probe_timeout("ps_table"),
1167
+ &CleanShutdownTransport::new()
1168
+ .with_owned_session(
1169
+ &ws,
1170
+ "team-ps-table-timeout",
1171
+ "team-ps-table-timeout",
1172
+ "team-ps-table-timeout",
1173
+ )
1174
+ .with_probe_timeout("ps_table"),
1070
1175
  )
1071
1176
  .expect("shutdown should complete");
1072
1177
 
@@ -1169,6 +1274,9 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
1169
1274
  &ws,
1170
1275
  &json!({
1171
1276
  "is_external_leader": true,
1277
+ "team_key": "team-layout",
1278
+ "generation": "team-layout",
1279
+ "session_name": "team-layout",
1172
1280
  "tmux_socket_source": "leader_env",
1173
1281
  "agents": {
1174
1282
  "fake_impl": {
@@ -1193,6 +1301,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
1193
1301
  pane_pid: None,
1194
1302
  leader_env: BTreeMap::new(),
1195
1303
  }]);
1304
+ let transport = transport.with_owned_session(&ws, "team-layout", "team-layout", "team-layout");
1196
1305
 
1197
1306
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1198
1307
  .expect("shutdown should complete");
@@ -1239,6 +1348,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
1239
1348
  &ws,
1240
1349
  &json!({
1241
1350
  "session_name": "team-current",
1351
+ "team_key": "current",
1352
+ "generation": "current",
1242
1353
  "is_external_leader": false,
1243
1354
  "leader_receiver": {"pane_id": "%leader"},
1244
1355
  "team_owner": {"pane_id": "%leader"},
@@ -1294,7 +1405,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
1294
1405
  leader_env: BTreeMap::new(),
1295
1406
  },
1296
1407
  ])
1297
- .with_targets_persist_after_kill();
1408
+ .with_targets_persist_after_kill()
1409
+ .with_owned_session(&ws, "team-current", "current", "current");
1298
1410
 
1299
1411
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1300
1412
  .expect("shutdown should complete");
@@ -1381,6 +1493,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
1381
1493
  &ws,
1382
1494
  &json!({
1383
1495
  "session_name": "team-external",
1496
+ "team_key": "team-external",
1497
+ "generation": "team-external",
1384
1498
  "is_external_leader": true,
1385
1499
  "leader_receiver": {"pane_id": "%leaderelsewhere"},
1386
1500
  "agents": {
@@ -1420,7 +1534,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
1420
1534
  leader_env: BTreeMap::new(),
1421
1535
  },
1422
1536
  ])
1423
- .with_targets_persist_after_kill();
1537
+ .with_targets_persist_after_kill()
1538
+ .with_owned_session(&ws, "team-external", "team-external", "team-external");
1424
1539
 
1425
1540
  let event_log = crate::event_log::EventLog::new(&ws);
1426
1541
  let event_start = event_log.tail(0).expect("events before shutdown").len();
@@ -1455,6 +1570,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
1455
1570
  &ws,
1456
1571
  &json!({
1457
1572
  "session_name": "team-external",
1573
+ "team_key": "team-external",
1574
+ "generation": "team-external",
1458
1575
  "is_external_leader": true,
1459
1576
  "leader_receiver": {"pane_id": "%leaderelsewhere"},
1460
1577
  "agents": {
@@ -1477,7 +1594,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
1477
1594
  pane_pid: None,
1478
1595
  leader_env: BTreeMap::new(),
1479
1596
  }])
1480
- .with_kill_session_error("deterministic E49 kill-session failure");
1597
+ .with_kill_session_error("deterministic E49 kill-session failure")
1598
+ .with_owned_session(&ws, "team-external", "team-external", "team-external");
1481
1599
 
1482
1600
  let event_log = crate::event_log::EventLog::new(&ws);
1483
1601
  let event_start = event_log.tail(0).expect("events before shutdown").len();
@@ -1537,6 +1655,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
1537
1655
  &ws,
1538
1656
  &json!({
1539
1657
  "session_name": "team-orphan",
1658
+ "team_key": "team-orphan",
1659
+ "generation": "team-orphan",
1540
1660
  "is_external_leader": false,
1541
1661
  "agents": {
1542
1662
  "w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
@@ -1558,7 +1678,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
1558
1678
  pane_pid: None,
1559
1679
  leader_env: BTreeMap::new(),
1560
1680
  }])
1561
- .with_targets_persist_after_kill();
1681
+ .with_targets_persist_after_kill()
1682
+ .with_owned_session(&ws, "team-orphan", "team-orphan", "team-orphan");
1562
1683
 
1563
1684
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1564
1685
  .expect("shutdown should complete");
@@ -1580,6 +1701,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
1580
1701
  &ws,
1581
1702
  &json!({
1582
1703
  "session_name": "team-current",
1704
+ "team_key": "team-current",
1705
+ "generation": "team-current",
1583
1706
  "agents": {}
1584
1707
  }),
1585
1708
  )
@@ -1598,7 +1721,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
1598
1721
  pane_pid: None,
1599
1722
  leader_env: BTreeMap::new(),
1600
1723
  }])
1601
- .with_targets_persist_after_kill();
1724
+ .with_targets_persist_after_kill()
1725
+ .with_owned_session(&ws, "team-current", "team-current", "team-current");
1602
1726
 
1603
1727
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1604
1728
  .expect("shutdown should complete");
@@ -0,0 +1,183 @@
1
+ //! Command-specific human triage projections for `doctor` and `diagnose`.
2
+ //!
3
+ //! The JSON report remains the source of truth. This renderer only projects its
4
+ //! stable issue and repair entries; it does not perform I/O or recompute health.
5
+
6
+ use serde_json::Value;
7
+
8
+ use super::{CmdOutput, CmdResult};
9
+
10
+ const LINE_LIMIT_BYTES: usize = 160;
11
+
12
+ pub(crate) fn report(value: Value, as_json: bool, command: &str) -> CmdResult {
13
+ let result = CmdResult::from_json(value, as_json);
14
+ if as_json {
15
+ result
16
+ } else {
17
+ human_result(result, command)
18
+ }
19
+ }
20
+
21
+ pub(crate) fn human_result(result: CmdResult, command: &str) -> CmdResult {
22
+ let text = match &result.output {
23
+ CmdOutput::Json(report) => render(command, report),
24
+ CmdOutput::Human(text) => text.clone(),
25
+ CmdOutput::None => String::new(),
26
+ };
27
+ CmdResult {
28
+ output: CmdOutput::Human(text),
29
+ ..result
30
+ }
31
+ }
32
+
33
+ pub(crate) fn render(command: &str, report: &Value) -> String {
34
+ // Keep the renderer downstream of the external redaction boundary. The
35
+ // generic emitter redacts again for defense in depth.
36
+ let report = crate::redaction::redact_external_value(report);
37
+ let ok = report.get("ok").and_then(Value::as_bool).unwrap_or(false);
38
+ let mut lines = vec![bounded(format!(
39
+ "{command}: {}",
40
+ if ok { "ok" } else { "needs attention" }
41
+ ))];
42
+
43
+ if command == "doctor" {
44
+ for finding in array_items(
45
+ report
46
+ .get("secret_scan")
47
+ .and_then(|scan| scan.get("findings")),
48
+ ) {
49
+ let Some(object) = finding.as_object() else {
50
+ continue;
51
+ };
52
+ let (Some(rule), Some(path), Some(line)) = (
53
+ object.get("rule").and_then(Value::as_str),
54
+ object.get("path").and_then(Value::as_str),
55
+ object.get("line").and_then(Value::as_u64),
56
+ ) else {
57
+ continue;
58
+ };
59
+ lines.push(bounded(format!("warn: {rule} in {path}:{line}")));
60
+ }
61
+ }
62
+
63
+ for issue in array_items(report.get("issues")) {
64
+ lines.push(bounded(format!("issue: {}", summarize(issue, false))));
65
+ }
66
+ for repair in array_items(report.get("suggested_repairs")) {
67
+ lines.push(bounded(format!("repair: {}", summarize(repair, true))));
68
+ }
69
+ if let Some(error) = report.get("error").filter(|value| !value.is_null()) {
70
+ lines.push(bounded(format!("detail: {}", summarize(error, false))));
71
+ }
72
+ if lines.len() == 1 {
73
+ lines.push(bounded("detail: no issues detected".to_string()));
74
+ }
75
+ lines.join("\n")
76
+ }
77
+
78
+ fn array_items(value: Option<&Value>) -> &[Value] {
79
+ value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
80
+ }
81
+
82
+ fn summarize(value: &Value, repair: bool) -> String {
83
+ match value {
84
+ Value::String(value) => value.clone(),
85
+ Value::Null => "null".to_string(),
86
+ Value::Bool(value) => value.to_string(),
87
+ Value::Number(value) => value.to_string(),
88
+ Value::Array(values) => format!("{} entries", values.len()),
89
+ Value::Object(object) => {
90
+ let keys = if repair {
91
+ ["action", "hint_action", "next_action", "issue", "code", "reason"]
92
+ .as_slice()
93
+ } else {
94
+ ["code", "issue", "id", "type", "status", "reason", "message"]
95
+ .as_slice()
96
+ };
97
+ let mut parts = Vec::new();
98
+ for key in keys {
99
+ if let Some(value) = object.get(*key).and_then(Value::as_str) {
100
+ parts.push(format!("{key}={value}"));
101
+ }
102
+ }
103
+ if parts.is_empty() {
104
+ let mut names = object.keys().cloned().collect::<Vec<_>>();
105
+ names.sort_unstable();
106
+ format!("fields={}", names.join(","))
107
+ } else {
108
+ parts.join("; ")
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ fn bounded(line: String) -> String {
115
+ let clean = line
116
+ .chars()
117
+ .map(|ch| if ch.is_control() { ' ' } else { ch })
118
+ .collect::<String>();
119
+ if clean.len() <= LINE_LIMIT_BYTES {
120
+ return clean;
121
+ }
122
+ let budget = LINE_LIMIT_BYTES.saturating_sub('…'.len_utf8());
123
+ let mut end = 0;
124
+ for (index, ch) in clean.char_indices() {
125
+ let next = index + ch.len_utf8();
126
+ if next > budget {
127
+ break;
128
+ }
129
+ end = next;
130
+ }
131
+ format!("{}…", &clean[..end.min(budget)])
132
+ }
133
+
134
+ #[cfg(test)]
135
+ mod tests {
136
+ use super::*;
137
+ use serde_json::json;
138
+
139
+ #[test]
140
+ fn renderer_projects_each_issue_and_repair_without_dumping_runtime() {
141
+ let report = json!({
142
+ "ok": false,
143
+ "issues": ["short_issue", {"code": "object_issue", "message": "detail"}],
144
+ "suggested_repairs": [{"issue": "object_issue", "action": "run repair"}],
145
+ "providers": {"claude": {"version": "secret"}},
146
+ "runtime": {"workspace": "/tmp/secret"}
147
+ });
148
+ let output = render("diagnose", &report);
149
+ assert!(output.contains("issue: short_issue"));
150
+ assert!(output.contains("issue: code=object_issue"));
151
+ assert!(output.contains("repair: action=run repair"));
152
+ assert!(!output.contains("providers:"));
153
+ assert!(!output.contains("runtime:"));
154
+ }
155
+
156
+ #[test]
157
+ fn renderer_sanitizes_and_bounds_utf8_lines() {
158
+ let report = json!({"ok": false, "issues": ["问题\n".to_string() + &"x".repeat(200)]});
159
+ for line in render("doctor", &report).lines() {
160
+ assert!(line.len() <= LINE_LIMIT_BYTES);
161
+ assert!(!line.chars().any(char::is_control));
162
+ }
163
+ }
164
+
165
+ #[test]
166
+ fn renderer_projects_secret_findings_without_values() {
167
+ let report = json!({
168
+ "ok": false,
169
+ "secret_scan": {
170
+ "findings": [{
171
+ "rule": "api_key_assignment",
172
+ "path": "leaky-role.md",
173
+ "line": 7,
174
+ "match_excerpt": "OPENAI_API_KEY=secret"
175
+ }]
176
+ }
177
+ });
178
+ let output = render("doctor", &report);
179
+ assert!(output.contains("warn: api_key_assignment in leaky-role.md:7"));
180
+ assert!(!output.contains("match_excerpt"));
181
+ assert!(!output.contains("OPENAI_API_KEY=secret"));
182
+ }
183
+ }