@nklisch/pi-agile-workflow 0.15.3

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 (203) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +28 -0
  3. package/CHANGELOG.md +591 -0
  4. package/README.md +107 -0
  5. package/docs/ARCHITECTURE.md +716 -0
  6. package/docs/MIGRATION.md +419 -0
  7. package/docs/PRINCIPLES.md +464 -0
  8. package/docs/ROADMAP.md +95 -0
  9. package/docs/SPEC.md +855 -0
  10. package/docs/VISION.md +106 -0
  11. package/extensions/agile-workflow.test.ts +419 -0
  12. package/extensions/agile-workflow.ts +467 -0
  13. package/hooks/hooks.json +52 -0
  14. package/hooks/scripts/prompt-context.py +628 -0
  15. package/hooks/scripts/substrate-maintainer.py +359 -0
  16. package/hooks/scripts/test_prompt_context.py +757 -0
  17. package/package.json +30 -0
  18. package/scripts/install-work-view.sh +165 -0
  19. package/scripts/tests/agent-metadata.test.sh +75 -0
  20. package/scripts/tests/bump-version.test.sh +552 -0
  21. package/scripts/tests/channel-parity.test.sh +117 -0
  22. package/scripts/tests/convert-content-integrity.test.sh +246 -0
  23. package/scripts/tests/convert-install-routing.test.sh +159 -0
  24. package/scripts/tests/install-work-view.test.sh +411 -0
  25. package/scripts/tests/pi-package-metadata.test.sh +119 -0
  26. package/scripts/tests/work-board-shim.test.sh +239 -0
  27. package/scripts/tests/work-view-dist-version.test.sh +105 -0
  28. package/scripts/work-board.sh +64 -0
  29. package/scripts/work-view.sh +423 -0
  30. package/skills/autopilot/SKILL.md +330 -0
  31. package/skills/autopilot/agents/openai.yaml +6 -0
  32. package/skills/board/SKILL.md +62 -0
  33. package/skills/board/agents/openai.yaml +6 -0
  34. package/skills/bold-refactor/SKILL.md +244 -0
  35. package/skills/bold-refactor/agents/openai.yaml +6 -0
  36. package/skills/bug-scan/SKILL.md +378 -0
  37. package/skills/bug-scan/agents/openai.yaml +6 -0
  38. package/skills/bug-scan/references/async-promises.md +119 -0
  39. package/skills/bug-scan/references/concurrency-races.md +148 -0
  40. package/skills/bug-scan/references/data-layer.md +167 -0
  41. package/skills/bug-scan/references/error-handling.md +197 -0
  42. package/skills/bug-scan/references/gate-item-template.md +94 -0
  43. package/skills/bug-scan/references/language-footguns.md +219 -0
  44. package/skills/bug-scan/references/parked-item-template.md +103 -0
  45. package/skills/bug-scan/references/report-template.md +115 -0
  46. package/skills/bug-scan/references/resource-leaks.md +199 -0
  47. package/skills/bug-scan/references/state-closures.md +148 -0
  48. package/skills/bug-scan/references/time-numbers.md +158 -0
  49. package/skills/convert/SKILL.md +1463 -0
  50. package/skills/convert/agents/openai.yaml +6 -0
  51. package/skills/convert/references/legacy-overlap-migration.md +179 -0
  52. package/skills/deep-code-scan/SKILL.md +378 -0
  53. package/skills/deep-code-scan/agents/openai.yaml +6 -0
  54. package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
  55. package/skills/deep-code-scan/references/consolidation.md +91 -0
  56. package/skills/deep-code-scan/references/decomposition.md +127 -0
  57. package/skills/deep-code-scan/references/item-templates.md +263 -0
  58. package/skills/deep-code-scan/references/lane-catalog.md +129 -0
  59. package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
  60. package/skills/deep-code-scan/references/scanner-brief.md +152 -0
  61. package/skills/e2e-test-design/SKILL.md +517 -0
  62. package/skills/e2e-test-design/references/anti-tautology.md +261 -0
  63. package/skills/e2e-test-design/references/service-mocks.md +234 -0
  64. package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
  65. package/skills/epic-design/SKILL.md +476 -0
  66. package/skills/epicize/SKILL.md +210 -0
  67. package/skills/epicize/agents/openai.yaml +6 -0
  68. package/skills/feature-design/SKILL.md +472 -0
  69. package/skills/fix/SKILL.md +173 -0
  70. package/skills/gate-cruft/SKILL.md +258 -0
  71. package/skills/gate-docs/SKILL.md +278 -0
  72. package/skills/gate-patterns/SKILL.md +389 -0
  73. package/skills/gate-refactor/SKILL.md +408 -0
  74. package/skills/gate-security/SKILL.md +275 -0
  75. package/skills/gate-tests/SKILL.md +367 -0
  76. package/skills/groom/SKILL.md +162 -0
  77. package/skills/groom/agents/openai.yaml +6 -0
  78. package/skills/ideate/SKILL.md +185 -0
  79. package/skills/ideate/agents/openai.yaml +6 -0
  80. package/skills/implement/SKILL.md +288 -0
  81. package/skills/implement-orchestrator/SKILL.md +604 -0
  82. package/skills/park/SKILL.md +111 -0
  83. package/skills/perf-design/SKILL.md +427 -0
  84. package/skills/perf-scout/SKILL.md +404 -0
  85. package/skills/perf-scout/agents/openai.yaml +6 -0
  86. package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
  87. package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
  88. package/skills/perf-scout/references/caching-and-memoization.md +123 -0
  89. package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
  90. package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
  91. package/skills/perf-scout/references/distributed-systems.md +150 -0
  92. package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
  93. package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
  94. package/skills/perf-scout/references/idea-ranking.md +75 -0
  95. package/skills/perf-scout/references/io-and-batching.md +135 -0
  96. package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
  97. package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
  98. package/skills/perf-scout/references/parked-item-template.md +117 -0
  99. package/skills/perf-scout/references/peer-review-pass.md +150 -0
  100. package/skills/perf-scout/references/report-template.md +148 -0
  101. package/skills/principles/SKILL.md +678 -0
  102. package/skills/principles/references/models.md +191 -0
  103. package/skills/principles/references/subagents.md +158 -0
  104. package/skills/prose-author/SKILL.md +185 -0
  105. package/skills/refactor-conventions-creator/SKILL.md +359 -0
  106. package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
  107. package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
  108. package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
  109. package/skills/refactor-design/SKILL.md +384 -0
  110. package/skills/release-deploy/SKILL.md +667 -0
  111. package/skills/release-deploy/agents/openai.yaml +6 -0
  112. package/skills/research/SKILL.md +166 -0
  113. package/skills/research/agents/openai.yaml +6 -0
  114. package/skills/review/SKILL.md +267 -0
  115. package/skills/review/references/deep-review.md +98 -0
  116. package/skills/review/references/review-lenses.md +68 -0
  117. package/skills/review/references/substrate-side-effects.md +161 -0
  118. package/skills/review/references/target-resolution.md +52 -0
  119. package/skills/scope/SKILL.md +486 -0
  120. package/work-view/Cargo.lock +486 -0
  121. package/work-view/Cargo.toml +9 -0
  122. package/work-view/crates/cli/.work-view-version +1 -0
  123. package/work-view/crates/cli/Cargo.toml +18 -0
  124. package/work-view/crates/cli/src/actionable.rs +500 -0
  125. package/work-view/crates/cli/src/args.rs +792 -0
  126. package/work-view/crates/cli/src/board/assets/board.css +855 -0
  127. package/work-view/crates/cli/src/board/assets/board.js +259 -0
  128. package/work-view/crates/cli/src/board/assets/card.js +139 -0
  129. package/work-view/crates/cli/src/board/assets/components.css +290 -0
  130. package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
  131. package/work-view/crates/cli/src/board/assets/detail.js +256 -0
  132. package/work-view/crates/cli/src/board/assets/filters.js +389 -0
  133. package/work-view/crates/cli/src/board/assets/index.html +77 -0
  134. package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
  135. package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
  136. package/work-view/crates/cli/src/board/assets/motion.css +131 -0
  137. package/work-view/crates/cli/src/board/assets/state.js +245 -0
  138. package/work-view/crates/cli/src/board/assets/table.js +304 -0
  139. package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
  140. package/work-view/crates/cli/src/board/assets/views.js +41 -0
  141. package/work-view/crates/cli/src/board/assets.rs +162 -0
  142. package/work-view/crates/cli/src/board/feed.rs +204 -0
  143. package/work-view/crates/cli/src/board/mod.rs +246 -0
  144. package/work-view/crates/cli/src/board/open.rs +144 -0
  145. package/work-view/crates/cli/src/board/server.rs +407 -0
  146. package/work-view/crates/cli/src/main.rs +195 -0
  147. package/work-view/crates/cli/src/render.rs +354 -0
  148. package/work-view/crates/cli/src/scope.rs +157 -0
  149. package/work-view/crates/cli/src/stale.rs +581 -0
  150. package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
  151. package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
  152. package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
  153. package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
  154. package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
  155. package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
  156. package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
  157. package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
  158. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
  159. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
  160. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
  161. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
  162. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
  163. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
  164. package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
  165. package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  166. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
  167. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
  168. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
  169. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
  170. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
  171. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
  172. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
  173. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
  174. package/work-view/crates/cli/tests/integration.rs +2833 -0
  175. package/work-view/crates/core/Cargo.toml +13 -0
  176. package/work-view/crates/core/src/error.rs +61 -0
  177. package/work-view/crates/core/src/filter.rs +804 -0
  178. package/work-view/crates/core/src/graph.rs +324 -0
  179. package/work-view/crates/core/src/index.rs +590 -0
  180. package/work-view/crates/core/src/lib.rs +54 -0
  181. package/work-view/crates/core/src/model.rs +202 -0
  182. package/work-view/crates/core/src/parse.rs +494 -0
  183. package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
  184. package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
  185. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
  186. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
  187. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
  188. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
  189. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
  190. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
  191. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
  192. package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
  193. package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  194. package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
  195. package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
  196. package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
  197. package/work-view/crates/core/tests/integration.rs +541 -0
  198. package/work-view/dist/.gitattributes +1 -0
  199. package/work-view/dist/README.md +42 -0
  200. package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
  201. package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
  202. package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
  203. package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
@@ -0,0 +1,2833 @@
1
+ //! Integration tests for the work-view CLI binary.
2
+ //!
3
+ //! Drives the binary via `std::process::Command` using `CARGO_BIN_EXE_work-view`.
4
+ //! Exercises exit codes, output modes, filter flags, table format, and BrokenPipe.
5
+
6
+ use std::collections::BTreeSet;
7
+ use std::io::{BufRead, BufReader, Read, Write};
8
+ use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
9
+ use std::path::Path;
10
+ use std::process::{Child, Command, Stdio};
11
+ use std::sync::mpsc::{self, Receiver};
12
+ use std::thread;
13
+ use std::time::{Duration, Instant};
14
+
15
+ use serde_json::Value;
16
+
17
+ /// Absolute path to the compiled `work-view` binary.
18
+ macro_rules! bin {
19
+ () => {
20
+ env!("CARGO_BIN_EXE_work-view")
21
+ };
22
+ }
23
+
24
+ /// Absolute path to the golden fixture substrate root.
25
+ fn fixture_root() -> &'static Path {
26
+ Path::new(concat!(
27
+ env!("CARGO_MANIFEST_DIR"),
28
+ "/tests/fixtures/golden"
29
+ ))
30
+ }
31
+
32
+ /// Absolute path to the with-malformed fixture substrate root.
33
+ ///
34
+ /// This fixture contains one valid item and one file with no closing `---`
35
+ /// frontmatter fence, which the parser cannot handle (ParseError).
36
+ fn malformed_fixture_root() -> &'static Path {
37
+ Path::new(concat!(
38
+ env!("CARGO_MANIFEST_DIR"),
39
+ "/tests/fixtures/with-malformed"
40
+ ))
41
+ }
42
+
43
+ /// Run the binary with the given args, cwd = fixture root.
44
+ /// Returns `(stdout, stderr, exit_code)`.
45
+ fn run(args: &[&str]) -> (String, String, i32) {
46
+ let out = Command::new(bin!())
47
+ .args(args)
48
+ .current_dir(fixture_root())
49
+ .output()
50
+ .expect("failed to run work-view");
51
+ (
52
+ String::from_utf8_lossy(&out.stdout).into_owned(),
53
+ String::from_utf8_lossy(&out.stderr).into_owned(),
54
+ out.status.code().unwrap_or(-1),
55
+ )
56
+ }
57
+
58
+ /// Run the binary in the malformed fixture, returning (stdout, stderr, exit_code).
59
+ fn run_malformed(args: &[&str]) -> (String, String, i32) {
60
+ let out = Command::new(bin!())
61
+ .args(args)
62
+ .current_dir(malformed_fixture_root())
63
+ .output()
64
+ .expect("failed to run work-view in malformed fixture");
65
+ (
66
+ String::from_utf8_lossy(&out.stdout).into_owned(),
67
+ String::from_utf8_lossy(&out.stderr).into_owned(),
68
+ out.status.code().unwrap_or(-1),
69
+ )
70
+ }
71
+
72
+ /// Absolute path to the ready-drafting fixture substrate root.
73
+ ///
74
+ /// This dedicated fixture has a drafting item (`feat-design-ready`) whose only
75
+ /// dependency (`feat-dep-done`) is terminal — the headline `--ready` case that
76
+ /// the golden fixture cannot express (its only drafting item, feat-b, has a
77
+ /// non-terminal dep). Also carries one implementing-ready and one review-ready
78
+ /// item so `--ready --stage drafting` filtering is exercised.
79
+ fn ready_drafting_root() -> &'static Path {
80
+ Path::new(concat!(
81
+ env!("CARGO_MANIFEST_DIR"),
82
+ "/tests/fixtures/ready-drafting"
83
+ ))
84
+ }
85
+
86
+ /// Run the binary with the given args, cwd = ready-drafting fixture root.
87
+ /// Returns `(stdout, stderr, exit_code)`.
88
+ fn run_ready_drafting(args: &[&str]) -> (String, String, i32) {
89
+ let out = Command::new(bin!())
90
+ .args(args)
91
+ .current_dir(ready_drafting_root())
92
+ .output()
93
+ .expect("failed to run work-view in ready-drafting fixture");
94
+ (
95
+ String::from_utf8_lossy(&out.stdout).into_owned(),
96
+ String::from_utf8_lossy(&out.stderr).into_owned(),
97
+ out.status.code().unwrap_or(-1),
98
+ )
99
+ }
100
+
101
+ fn unused_local_port() -> u16 {
102
+ let listener =
103
+ TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("failed to reserve local port");
104
+ listener
105
+ .local_addr()
106
+ .expect("failed to read reserved local port")
107
+ .port()
108
+ }
109
+
110
+ fn busy_local_port_below_max() -> TcpListener {
111
+ for _ in 0..32 {
112
+ let listener =
113
+ TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("failed to reserve busy port");
114
+ let port = listener
115
+ .local_addr()
116
+ .expect("failed to read busy port")
117
+ .port();
118
+ if port < u16::MAX {
119
+ return listener;
120
+ }
121
+ }
122
+ panic!("failed to reserve a busy port below u16::MAX");
123
+ }
124
+
125
+ fn spawn_board_once_with_args_in(root: &Path, args: &[&str]) -> (Child, Receiver<String>) {
126
+ let mut child = Command::new(bin!())
127
+ .args(args)
128
+ .current_dir(root)
129
+ .stdout(Stdio::piped())
130
+ .stderr(Stdio::piped())
131
+ .spawn()
132
+ .expect("failed to spawn work-view board");
133
+
134
+ let stdout = child.stdout.take().expect("board stdout should be piped");
135
+ let (tx, rx) = mpsc::channel();
136
+ thread::spawn(move || {
137
+ let mut reader = BufReader::new(stdout);
138
+ let mut line = String::new();
139
+ let message = match reader.read_line(&mut line) {
140
+ Ok(_) => line,
141
+ Err(e) => format!("stdout read error: {e}"),
142
+ };
143
+ let _ = tx.send(message);
144
+ });
145
+
146
+ (child, rx)
147
+ }
148
+
149
+ fn spawn_board_once_with_args(args: &[&str]) -> (Child, Receiver<String>) {
150
+ spawn_board_once_with_args_in(fixture_root(), args)
151
+ }
152
+
153
+ fn spawn_board_once(port: u16) -> (Child, Receiver<String>) {
154
+ let port_arg = port.to_string();
155
+ spawn_board_once_with_args(&["board", "--once", "--no-open", "--port", port_arg.as_str()])
156
+ }
157
+
158
+ fn spawn_board_once_in(root: &Path, port: u16) -> (Child, Receiver<String>) {
159
+ let port_arg = port.to_string();
160
+ spawn_board_once_with_args_in(
161
+ root,
162
+ &["board", "--once", "--no-open", "--port", port_arg.as_str()],
163
+ )
164
+ }
165
+
166
+ fn read_bound_line(rx: Receiver<String>) -> String {
167
+ let line = rx
168
+ .recv_timeout(Duration::from_secs(3))
169
+ .expect("board did not print a bound URL before the timeout");
170
+ assert!(!line.trim().is_empty(), "board printed no bound URL");
171
+ line
172
+ }
173
+
174
+ fn parse_bound_port(line: &str) -> u16 {
175
+ line.split_whitespace()
176
+ .find_map(|token| {
177
+ token
178
+ .strip_prefix("http://127.0.0.1:")
179
+ .and_then(|rest| rest.trim_end_matches('/').parse::<u16>().ok())
180
+ })
181
+ .unwrap_or_else(|| panic!("could not parse localhost URL from line: {line:?}"))
182
+ }
183
+
184
+ fn http_get(port: u16, path: &str) -> String {
185
+ http_request(
186
+ port,
187
+ &format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"),
188
+ )
189
+ }
190
+
191
+ fn http_request(port: u16, request: &str) -> String {
192
+ let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
193
+ let deadline = Instant::now() + Duration::from_secs(3);
194
+
195
+ loop {
196
+ match TcpStream::connect(addr) {
197
+ Ok(mut stream) => {
198
+ stream
199
+ .write_all(request.as_bytes())
200
+ .expect("failed to write HTTP request");
201
+ let mut response = String::new();
202
+ stream
203
+ .read_to_string(&mut response)
204
+ .expect("failed to read HTTP response");
205
+ return response;
206
+ }
207
+ Err(e) if Instant::now() < deadline => {
208
+ let _ = e;
209
+ thread::sleep(Duration::from_millis(20));
210
+ }
211
+ Err(e) => panic!("failed to connect to board server on {addr}: {e}"),
212
+ }
213
+ }
214
+ }
215
+
216
+ fn board_response_once(path: &str) -> String {
217
+ let (mut child, rx) = spawn_board_once(unused_local_port());
218
+ let port = parse_bound_port(&read_bound_line(rx));
219
+ let response = http_get(port, path);
220
+ wait_for_success(&mut child);
221
+ response
222
+ }
223
+
224
+ fn wait_for_success(child: &mut Child) {
225
+ let deadline = Instant::now() + Duration::from_secs(3);
226
+ loop {
227
+ match child.try_wait().expect("failed to poll board process") {
228
+ Some(status) => {
229
+ assert!(status.success(), "board exited with status {status}");
230
+ return;
231
+ }
232
+ None if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)),
233
+ None => {
234
+ let _ = child.kill();
235
+ let _ = child.wait();
236
+ panic!("board process did not exit after --once request");
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ fn http_body(response: &str) -> &str {
243
+ response
244
+ .split_once("\r\n\r\n")
245
+ .map(|(_, body)| body)
246
+ .unwrap_or("")
247
+ }
248
+
249
+ fn css_rule_body<'a>(css: &'a str, selector: &str) -> &'a str {
250
+ let start = css
251
+ .find(selector)
252
+ .unwrap_or_else(|| panic!("missing CSS selector {selector:?}; css: {css}"));
253
+ let body_start = css[start..]
254
+ .find('{')
255
+ .unwrap_or_else(|| panic!("missing rule body for CSS selector {selector:?}; css: {css}"));
256
+ let body_offset = start + body_start + 1;
257
+ let body_end = css[body_offset..]
258
+ .find('}')
259
+ .unwrap_or_else(|| panic!("missing rule close for CSS selector {selector:?}; css: {css}"));
260
+ &css[body_offset..body_offset + body_end]
261
+ }
262
+
263
+ fn json_body(response: &str) -> Value {
264
+ serde_json::from_str(http_body(response)).expect("response body should be valid JSON")
265
+ }
266
+
267
+ fn ids_from_paths(stdout: &str) -> Vec<String> {
268
+ stdout
269
+ .lines()
270
+ .filter_map(|line| {
271
+ Path::new(line)
272
+ .file_stem()
273
+ .map(|stem| stem.to_string_lossy().into_owned())
274
+ })
275
+ .collect()
276
+ }
277
+
278
+ fn board_asset_modules() -> [(&'static str, &'static str); 9] {
279
+ [
280
+ (
281
+ "/assets/state.js",
282
+ include_str!("../src/board/assets/state.js"),
283
+ ),
284
+ (
285
+ "/assets/filters.js",
286
+ include_str!("../src/board/assets/filters.js"),
287
+ ),
288
+ (
289
+ "/assets/markdown.js",
290
+ include_str!("../src/board/assets/markdown.js"),
291
+ ),
292
+ (
293
+ "/assets/card.js",
294
+ include_str!("../src/board/assets/card.js"),
295
+ ),
296
+ (
297
+ "/assets/detail.js",
298
+ include_str!("../src/board/assets/detail.js"),
299
+ ),
300
+ (
301
+ "/assets/views.js",
302
+ include_str!("../src/board/assets/views.js"),
303
+ ),
304
+ (
305
+ "/assets/kanban.js",
306
+ include_str!("../src/board/assets/kanban.js"),
307
+ ),
308
+ (
309
+ "/assets/dependency.js",
310
+ include_str!("../src/board/assets/dependency.js"),
311
+ ),
312
+ (
313
+ "/assets/table.js",
314
+ include_str!("../src/board/assets/table.js"),
315
+ ),
316
+ ]
317
+ }
318
+
319
+ fn exported_names(body: &str) -> BTreeSet<String> {
320
+ body.lines()
321
+ .filter_map(|line| {
322
+ let trimmed = line.trim_start();
323
+ let rest = trimmed
324
+ .strip_prefix("export function ")
325
+ .or_else(|| trimmed.strip_prefix("export const "))?;
326
+ let name = rest
327
+ .split(|c: char| !(c == '_' || c == '-' || c.is_ascii_alphanumeric()))
328
+ .next()
329
+ .filter(|name| !name.is_empty())?;
330
+ Some(name.to_string())
331
+ })
332
+ .collect()
333
+ }
334
+
335
+ fn named_imports_from(body: &str, source: &str) -> Vec<String> {
336
+ let mut imports = Vec::new();
337
+ let mut pending = String::new();
338
+ let mut collecting = false;
339
+
340
+ for line in body.lines() {
341
+ let trimmed = line.trim();
342
+ if !collecting && trimmed.starts_with("import {") {
343
+ collecting = true;
344
+ pending.clear();
345
+ }
346
+ if collecting {
347
+ pending.push_str(trimmed);
348
+ pending.push('\n');
349
+ if trimmed.contains("} from ") {
350
+ if pending.contains(&format!("from \"{source}\""))
351
+ || pending.contains(&format!("from '{source}'"))
352
+ {
353
+ if let Some((_, rest)) = pending.split_once('{') {
354
+ if let Some((names, _)) = rest.split_once('}') {
355
+ imports.extend(names.split(',').filter_map(|name| {
356
+ let imported = name
357
+ .trim()
358
+ .split_once(" as ")
359
+ .map_or_else(|| name.trim(), |(actual, _)| actual.trim());
360
+ (!imported.is_empty()).then(|| imported.to_string())
361
+ }));
362
+ }
363
+ }
364
+ }
365
+ collecting = false;
366
+ pending.clear();
367
+ }
368
+ }
369
+ }
370
+
371
+ imports
372
+ }
373
+
374
+ /// Extract the item IDs from a table-mode output.
375
+ /// Returns only the IDs from data rows (skips header and separator lines).
376
+ /// The ID is the first column: chars 0..40 trimmed.
377
+ fn table_ids(stdout: &str) -> Vec<&str> {
378
+ stdout
379
+ .lines()
380
+ .skip(2) // skip header and separator
381
+ .filter(|l| !l.trim().is_empty())
382
+ .map(|l| l[..l.len().min(40)].trim())
383
+ .collect()
384
+ }
385
+
386
+ // ── Exit codes ────────────────────────────────────────────────────────────────
387
+
388
+ #[test]
389
+ fn exit_0_on_success() {
390
+ let (_, _, code) = run(&[]);
391
+ assert_eq!(code, 0, "default run should exit 0");
392
+ }
393
+
394
+ #[test]
395
+ fn exit_0_on_help() {
396
+ let (stdout, _, code) = run(&["--help"]);
397
+ assert_eq!(code, 0);
398
+ assert!(
399
+ stdout.contains("work-view"),
400
+ "help should contain 'work-view'"
401
+ );
402
+ }
403
+
404
+ #[test]
405
+ fn top_level_help_mentions_board_subcommand() {
406
+ let (stdout, stderr, code) = run(&["--help"]);
407
+ assert_eq!(code, 0);
408
+ assert_eq!(stderr, "");
409
+ assert!(
410
+ stdout.contains("work-view board [OPTIONS]"),
411
+ "top-level help should mention board subcommand; stdout: {stdout}"
412
+ );
413
+ assert!(
414
+ stdout.contains("Serve the live substrate board"),
415
+ "top-level help should describe board subcommand; stdout: {stdout}"
416
+ );
417
+ }
418
+
419
+ #[test]
420
+ fn exit_0_on_help_short() {
421
+ let (_, _, code) = run(&["-h"]);
422
+ assert_eq!(code, 0);
423
+ }
424
+
425
+ #[test]
426
+ fn exit_0_on_board_help() {
427
+ let (stdout, stderr, code) = run(&["board", "--help"]);
428
+ assert_eq!(code, 0);
429
+ assert!(
430
+ stdout.contains("work-view board"),
431
+ "board help should contain command name; stdout: {stdout}"
432
+ );
433
+ assert!(
434
+ stdout.contains("--port <n>"),
435
+ "board help should contain board options; stdout: {stdout}"
436
+ );
437
+ assert_eq!(stderr, "");
438
+ }
439
+
440
+ #[test]
441
+ fn exit_0_on_serve_help() {
442
+ let (stdout, stderr, code) = run(&["serve", "--help"]);
443
+ assert_eq!(code, 0);
444
+ assert!(
445
+ stdout.contains("work-view board"),
446
+ "serve alias should print board help; stdout: {stdout}"
447
+ );
448
+ assert_eq!(stderr, "");
449
+ }
450
+
451
+ #[test]
452
+ fn exit_1_on_unknown_flag() {
453
+ let (_, stderr, code) = run(&["--unknown-flag"]);
454
+ assert_eq!(code, 1, "unknown flag should exit 1");
455
+ assert!(stderr.contains("unknown flag"), "stderr: {stderr}");
456
+ }
457
+
458
+ #[test]
459
+ fn exit_1_on_unknown_board_flag() {
460
+ let (_, stderr, code) = run(&["board", "--unknown-flag"]);
461
+ assert_eq!(code, 1, "unknown board flag should exit 1");
462
+ assert!(
463
+ stderr.contains("unknown board flag"),
464
+ "stderr should mention board flag parsing; stderr: {stderr}"
465
+ );
466
+ }
467
+
468
+ #[test]
469
+ fn board_without_substrate_exits_2() {
470
+ let out = Command::new(bin!())
471
+ .args(["board", "--no-open"])
472
+ .current_dir("/tmp")
473
+ .output()
474
+ .expect("failed to run work-view board");
475
+ let code = out.status.code().unwrap_or(-1);
476
+ assert_eq!(code, 2, "no-substrate board should exit 2");
477
+ let stderr = String::from_utf8_lossy(&out.stderr);
478
+ assert!(stderr.contains("no substrate"), "stderr: {stderr}");
479
+ }
480
+
481
+ #[test]
482
+ fn board_healthz_binds_localhost_and_exits_after_once() {
483
+ let (mut child, rx) = spawn_board_once(unused_local_port());
484
+ let line = read_bound_line(rx);
485
+ assert!(
486
+ line.contains("http://127.0.0.1:"),
487
+ "board should print a localhost URL; line: {line}"
488
+ );
489
+ assert!(
490
+ !line.contains("0.0.0.0"),
491
+ "board must not bind or print a wildcard address; line: {line}"
492
+ );
493
+
494
+ let port = parse_bound_port(&line);
495
+ let response = http_get(port, "/healthz");
496
+ assert!(
497
+ response.starts_with("HTTP/1.1 200 OK"),
498
+ "healthz should return 200; response: {response}"
499
+ );
500
+ assert!(
501
+ response.contains("Content-Type: text/plain; charset=utf-8"),
502
+ "healthz should return text/plain; response: {response}"
503
+ );
504
+ assert!(
505
+ response.ends_with("ok\n"),
506
+ "healthz body mismatch: {response}"
507
+ );
508
+ wait_for_success(&mut child);
509
+ }
510
+
511
+ #[test]
512
+ fn board_unknown_route_returns_404() {
513
+ let (mut child, rx) = spawn_board_once(unused_local_port());
514
+ let port = parse_bound_port(&read_bound_line(rx));
515
+
516
+ let response = http_get(port, "/missing");
517
+ assert!(
518
+ response.starts_with("HTTP/1.1 404 Not Found"),
519
+ "unknown route should return 404; response: {response}"
520
+ );
521
+ wait_for_success(&mut child);
522
+ }
523
+
524
+ #[test]
525
+ fn board_head_request_returns_headers_without_body() {
526
+ let (mut child, rx) = spawn_board_once(unused_local_port());
527
+ let port = parse_bound_port(&read_bound_line(rx));
528
+
529
+ let response = http_request(
530
+ port,
531
+ "HEAD /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
532
+ );
533
+ assert!(
534
+ response.starts_with("HTTP/1.1 200 OK"),
535
+ "HEAD healthz should return 200; response: {response}"
536
+ );
537
+ assert!(
538
+ response.contains("Content-Length: 3"),
539
+ "HEAD healthz should retain the GET content length; response: {response}"
540
+ );
541
+ assert!(
542
+ response.ends_with("\r\n\r\n"),
543
+ "HEAD response should not include a body; response: {response:?}"
544
+ );
545
+ wait_for_success(&mut child);
546
+ }
547
+
548
+ #[test]
549
+ fn board_rejects_non_loopback_host_header() {
550
+ let (mut child, rx) = spawn_board_once(unused_local_port());
551
+ let port = parse_bound_port(&read_bound_line(rx));
552
+
553
+ let response = http_request(
554
+ port,
555
+ "GET /api/substrate HTTP/1.1\r\nHost: attacker.example\r\nConnection: close\r\n\r\n",
556
+ );
557
+ assert!(
558
+ response.starts_with("HTTP/1.1 403 Forbidden"),
559
+ "non-loopback Host should be rejected; response: {response}"
560
+ );
561
+ wait_for_success(&mut child);
562
+ }
563
+
564
+ #[test]
565
+ fn board_rejects_missing_host_header() {
566
+ let (mut child, rx) = spawn_board_once(unused_local_port());
567
+ let port = parse_bound_port(&read_bound_line(rx));
568
+
569
+ let response = http_request(port, "GET /healthz HTTP/1.1\r\nConnection: close\r\n\r\n");
570
+ assert!(
571
+ response.starts_with("HTTP/1.1 403 Forbidden"),
572
+ "missing Host should be rejected; response: {response}"
573
+ );
574
+ wait_for_success(&mut child);
575
+ }
576
+
577
+ #[test]
578
+ fn board_rejects_unsupported_method_with_allow_header() {
579
+ let (mut child, rx) = spawn_board_once(unused_local_port());
580
+ let port = parse_bound_port(&read_bound_line(rx));
581
+
582
+ let response = http_request(
583
+ port,
584
+ "POST /api/substrate HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
585
+ );
586
+ assert!(
587
+ response.starts_with("HTTP/1.1 405 Method Not Allowed"),
588
+ "unsupported method should return 405; response: {response}"
589
+ );
590
+ assert!(
591
+ response.contains("Allow: GET, HEAD"),
592
+ "405 should include Allow header; response: {response}"
593
+ );
594
+ wait_for_success(&mut child);
595
+ }
596
+
597
+ #[test]
598
+ fn board_embedded_assets_return_expected_content_types() {
599
+ for path in ["/", "/index.html"] {
600
+ let response = board_response_once(path);
601
+ assert!(
602
+ response.starts_with("HTTP/1.1 200 OK"),
603
+ "{path} should return 200; response: {response}"
604
+ );
605
+ assert!(
606
+ response.contains("Content-Type: text/html; charset=utf-8"),
607
+ "{path} should return HTML; response: {response}"
608
+ );
609
+ let body = http_body(&response);
610
+ assert!(
611
+ body.contains("global-filter-container")
612
+ && body.contains("id=\"view-root\"")
613
+ && body.contains("/assets/tokens.css")
614
+ && body.contains("/assets/board.js"),
615
+ "{path} should return the embedded shell frame HTML; body: {body}"
616
+ );
617
+ }
618
+
619
+ for path in [
620
+ "/assets/tokens.css",
621
+ "/assets/components.css",
622
+ "/assets/motion.css",
623
+ "/assets/board.css",
624
+ ] {
625
+ let css = board_response_once(path);
626
+ assert!(
627
+ css.starts_with("HTTP/1.1 200 OK"),
628
+ "{path} should return 200; response: {css}"
629
+ );
630
+ assert!(
631
+ css.contains("Content-Type: text/css; charset=utf-8"),
632
+ "{path} should return text/css; response: {css}"
633
+ );
634
+ let body = http_body(&css);
635
+ assert!(
636
+ !body.contains("@import url")
637
+ && !body.contains("fonts.googleapis")
638
+ && !body.contains("https://"),
639
+ "{path} should not contain remote asset references; response: {css}"
640
+ );
641
+ }
642
+ let board_css = board_response_once("/assets/board.css");
643
+ let board_css_body = http_body(&board_css);
644
+ assert!(
645
+ board_css_body.contains(".kanban-board")
646
+ && board_css_body.contains(".kanban-lane")
647
+ && board_css_body.contains(".kanban-column")
648
+ && board_css_body.contains("grid-auto-flow: row")
649
+ && board_css_body.contains("scroll-snap-type")
650
+ && board_css_body.contains(".table-wrap")
651
+ && board_css_body.contains("position: sticky")
652
+ && board_css_body.contains("min-width: 820px"),
653
+ "board CSS should ship responsive board view layout primitives; body: {board_css_body}"
654
+ );
655
+
656
+ for path in [
657
+ "/assets/board.js",
658
+ "/assets/state.js",
659
+ "/assets/filters.js",
660
+ "/assets/markdown.js",
661
+ "/assets/card.js",
662
+ "/assets/detail.js",
663
+ "/assets/views.js",
664
+ "/assets/kanban.js",
665
+ "/assets/dependency.js",
666
+ "/assets/table.js",
667
+ ] {
668
+ let js = board_response_once(path);
669
+ assert!(
670
+ js.starts_with("HTTP/1.1 200 OK"),
671
+ "{path} should return 200; response: {js}"
672
+ );
673
+ assert!(
674
+ js.contains("Content-Type: text/javascript; charset=utf-8"),
675
+ "{path} should return text/javascript; response: {js}"
676
+ );
677
+ }
678
+ let board_js = board_response_once("/assets/board.js");
679
+ let board_body = http_body(&board_js);
680
+ assert!(
681
+ board_body.contains("createBoardStore")
682
+ && board_body.contains("renderFilterBar")
683
+ && board_body.contains("filterSignature")
684
+ && board_body.contains("context.refresh()")
685
+ && !board_body.contains("filters.releases"),
686
+ "board JS should bootstrap the store, filters, and initial refresh; body: {board_body}"
687
+ );
688
+ let state_js = board_response_once("/assets/state.js");
689
+ let state_body = http_body(&state_js);
690
+ assert!(
691
+ state_body.contains("export function createBoardStore")
692
+ && state_body.contains("\"/api/substrate\"")
693
+ && state_body.contains("matchesFilters")
694
+ && state_body.contains("localStorage"),
695
+ "state JS should export the board store and fetch the substrate feed; body: {state_body}"
696
+ );
697
+ let filters_js = board_response_once("/assets/filters.js");
698
+ let filters_body = http_body(&filters_js);
699
+ assert!(
700
+ filters_body.contains("export function createDefaultFilters")
701
+ && filters_body.contains("export function matchesFilters")
702
+ && filters_body.contains("export function renderFilterBar")
703
+ && !filters_body.contains("Release filters")
704
+ && !filters_body.contains("item?.release_binding")
705
+ && !filters_body.contains("(none)"),
706
+ "filters JS should own filter defaults, matching, and controls without release/none filter chips; body: {filters_body}"
707
+ );
708
+ assert!(
709
+ filters_body.contains("item.tier === \"releases\" || item.tier === \"archive\"")
710
+ && !filters_body.contains("is_terminal"),
711
+ "auto-hide should key off tier, not terminal stage; body: {filters_body}"
712
+ );
713
+ assert!(
714
+ filters_body.contains("new Set([\"kinds\", \"stages\", \"parents\", \"tags\", \"epics\"]")
715
+ && filters_body.contains("epics: new Set()")
716
+ && filters_body.contains("export function epicIdForItem")
717
+ && filters_body.contains("function epicsAllow")
718
+ && filters_body.contains("matchesFilters(item, filters, snapshot = null)")
719
+ && filters_body.contains("filterGroup(\"Epic\", epicOptions")
720
+ && filters_body.contains("filterGroup(\"Tags\", tagOptions, { expandKey: \"tags\" })"),
721
+ "filters JS should ship ancestor-aware epic filtering and expandable sidebar groups; body: {filters_body}"
722
+ );
723
+ let filter_header_rule = css_rule_body(board_css_body, ".filter-group__header");
724
+ let expand_toggle_rule = css_rule_body(board_css_body, ".filter-expand-toggle");
725
+ let expanded_filter_rule = css_rule_body(board_css_body, ".filter-options[data-expanded=\"true\"]");
726
+ assert!(
727
+ filter_header_rule.contains("justify-content: space-between"),
728
+ "filter group headers should align titles and expansion controls; rule: {filter_header_rule}"
729
+ );
730
+ assert!(
731
+ expand_toggle_rule.contains("min-height: 22px") && expand_toggle_rule.contains("cursor: pointer"),
732
+ "filter expansion control should be compact and interactive; rule: {expand_toggle_rule}"
733
+ );
734
+ assert!(
735
+ expanded_filter_rule.contains("grid-template-columns")
736
+ && expanded_filter_rule.contains("max-height: min(46vh, 360px)")
737
+ && expanded_filter_rule.contains("overflow-y: auto"),
738
+ "expanded filter groups should stay dense and internally scrollable; rule: {expanded_filter_rule}"
739
+ );
740
+ let markdown_js = board_response_once("/assets/markdown.js");
741
+ let markdown_body = http_body(&markdown_js);
742
+ assert!(
743
+ markdown_body.contains("export function renderMarkdown")
744
+ && markdown_body.contains("export function markdownSummary"),
745
+ "markdown JS should export renderer helpers; body: {markdown_body}"
746
+ );
747
+ let card_js = board_response_once("/assets/card.js");
748
+ let card_body = http_body(&card_js);
749
+ assert!(
750
+ card_body.contains("export function renderCard")
751
+ && card_body.contains("addEventListener(\"keydown\"")
752
+ && !card_body.contains("release_binding"),
753
+ "card JS should export keyboard-accessible cards; body: {card_body}"
754
+ );
755
+ let detail_js = board_response_once("/assets/detail.js");
756
+ let detail_body = http_body(&detail_js);
757
+ assert!(
758
+ detail_body.contains("export function openDetail")
759
+ && detail_body.contains("export function closeDetail")
760
+ && detail_body.contains("export function detectDetailPresentation")
761
+ && detail_body.contains("ctx.getItemById(id)")
762
+ && detail_body.contains("focus({ preventScroll: true })")
763
+ && detail_body.contains("aria-modal")
764
+ && detail_body.contains("shell.inert = true")
765
+ && detail_body.contains("renderMarkdown")
766
+ && !detail_body.contains("release_binding"),
767
+ "detail JS should export id-based detail helpers using safe markdown; body: {detail_body}"
768
+ );
769
+ let views_js = board_response_once("/assets/views.js");
770
+ let views_body = http_body(&views_js);
771
+ assert!(
772
+ views_body.contains("export function registerView")
773
+ && views_body.contains("export function mountCurrentView")
774
+ && views_body.contains("kanbanView")
775
+ && views_body.contains("dependencyView")
776
+ && views_body.contains("tableView"),
777
+ "views JS should export the BoardView registry and register real view modules; body: {views_body}"
778
+ );
779
+ let kanban_js = board_response_once("/assets/kanban.js");
780
+ let kanban_body = http_body(&kanban_js);
781
+ assert!(
782
+ kanban_body.contains("export const kanbanView")
783
+ && kanban_body.contains("deriveFilterOptions")
784
+ && kanban_body.contains("ctx.visibleItems()")
785
+ && kanban_body.contains("ctx.renderCard(item, { compact: true, context: ctx })")
786
+ && kanban_body.contains("groupItemsByStage")
787
+ && kanban_body.contains("groupItemsByLane")
788
+ && kanban_body.contains("focusedLane")
789
+ && kanban_body.contains("pendingFocusLane")
790
+ && kanban_body.contains("focus({ preventScroll: true })")
791
+ && !kanban_body.contains("ctx.setFilter"),
792
+ "kanban JS should register the stage-grid view through shared shell contracts; body: {kanban_body}"
793
+ );
794
+ let dependency_js = board_response_once("/assets/dependency.js");
795
+ let dependency_body = http_body(&dependency_js);
796
+ assert!(
797
+ dependency_body.contains("export const dependencyView")
798
+ && dependency_body.contains("buildDependencyModel")
799
+ && dependency_body.contains("layerGraph")
800
+ && dependency_body.contains("renderGraphCanvas")
801
+ && dependency_body.contains("createElementNS")
802
+ && dependency_body.contains("dependency-edge--unmet")
803
+ && dependency_body.contains("focusedNode")
804
+ && dependency_body.contains("activeLayoutId")
805
+ && dependency_body.contains("GRAPH_LAYOUTS")
806
+ && dependency_body.contains("showTerminalBranches")
807
+ && dependency_body.contains("is-traced")
808
+ && !dependency_body.contains("ctx.setFilter")
809
+ && dependency_body.contains("cycleIds")
810
+ && dependency_body.contains("ctx.openDetail(node.id)"),
811
+ "dependency JS should provide the layered graph-model view; body: {dependency_body}"
812
+ );
813
+ let table_js = board_response_once("/assets/table.js");
814
+ let table_body = http_body(&table_js);
815
+ assert!(
816
+ table_body.contains("export const tableView")
817
+ && table_body.contains("tableValue")
818
+ && table_body.contains("compareRows")
819
+ && table_body.contains("filterItems")
820
+ && table_body.contains("columnFilters")
821
+ && table_body.contains("deriveFilterOptions")
822
+ && table_body.contains("sortState")
823
+ && table_body.contains("pendingHeaderFocus")
824
+ && table_body.contains("pendingFilterFocus")
825
+ && table_body.contains("focus({ preventScroll: true })")
826
+ && table_body.contains("aria-sort")
827
+ && !table_body.contains("ctx.setFilter")
828
+ && !table_body.contains("release_binding")
829
+ && !table_body.contains("(none)")
830
+ && table_body.contains("ctx.visibleItems()")
831
+ && table_body.contains("ctx.openDetail(item.id)")
832
+ && table_body.contains("addEventListener(\"keydown\""),
833
+ "table JS should provide the registered dense table view; body: {table_body}"
834
+ );
835
+ }
836
+
837
+ #[test]
838
+ fn dependency_canvas_is_canvas_only_with_layout_buttons() {
839
+ let dependency_js = board_response_once("/assets/dependency.js");
840
+ let body = http_body(&dependency_js);
841
+
842
+ assert!(
843
+ body.contains("GRAPH_LAYOUTS")
844
+ && body.contains("activeLayoutId")
845
+ && body.contains("renderLayoutButtons")
846
+ && body.contains("layoutGraph(model, activeLayoutId)")
847
+ && body.contains("button.dataset.layoutId = layout.id")
848
+ && body.contains("button.setAttribute(\"aria-pressed\"")
849
+ && body.contains("renderGraphCanvas(model, ctx)")
850
+ && !body.contains("Show canvas")
851
+ && !body.contains("Show list")
852
+ && !body.contains("renderLayeredList")
853
+ && !body.contains("preferredRenderMode")
854
+ && !body.contains("matchMedia"),
855
+ "dependency view should be canvas-only with layout selector buttons; body: {body}"
856
+ );
857
+ }
858
+
859
+ #[test]
860
+ fn dependency_canvas_layout_buttons_offer_relationship_organizations() {
861
+ let dependency_js = board_response_once("/assets/dependency.js");
862
+ let body = http_body(&dependency_js);
863
+ let board_css = board_response_once("/assets/board.css");
864
+ let css = http_body(&board_css);
865
+ let label_rule = css_rule_body(css, ".dependency-layout-label");
866
+
867
+ assert!(
868
+ body.contains("id: \"flow\"")
869
+ && body.contains("id: \"stage\"")
870
+ && body.contains("id: \"kind\"")
871
+ && body.contains("id: \"impact\"")
872
+ && body.contains("id: \"web\"")
873
+ && body.contains("function groupNodesByStage")
874
+ && body.contains("function groupNodesByKind")
875
+ && body.contains("function groupNodesByImpact")
876
+ && body.contains("function layoutWebGraph")
877
+ && body.contains("function renderLayoutLabels")
878
+ && body.contains("layout.groups"),
879
+ "dependency canvas should expose flow, stage, kind, impact, and web organizations over the same edge graph; body: {body}"
880
+ );
881
+ assert!(
882
+ label_rule.contains("position: absolute") && label_rule.contains("text-transform: uppercase"),
883
+ "canvas layout columns should have compact labels; rule: {label_rule}"
884
+ );
885
+ }
886
+
887
+ #[test]
888
+ fn dependency_canvas_impact_layout_surfaces_high_unlock_items() {
889
+ let dependency_js = board_response_once("/assets/dependency.js");
890
+ let body = http_body(&dependency_js);
891
+
892
+ assert!(
893
+ body.contains("function unlockImpactCounts")
894
+ && body.contains("const reachable = new Set")
895
+ && body.contains("stack.push(child)")
896
+ && body.contains("counts.set(id, reachable.size)")
897
+ && body.contains("function impactGroupLabel")
898
+ && body.contains("function groupNodesByImpact")
899
+ && body.contains("`Unblocks ${count} Downstream ${noun}`")
900
+ && body.contains(".sort(([a], [b]) => b - a)")
901
+ && body.contains("layoutId === \"impact\""),
902
+ "impact layout should group visible graph nodes by transitive downstream unlock count; body: {body}"
903
+ );
904
+ }
905
+
906
+ #[test]
907
+ fn dependency_canvas_explains_impact_and_edge_color_semantics() {
908
+ let dependency_js = board_response_once("/assets/dependency.js");
909
+ let body = http_body(&dependency_js);
910
+ let board_css = board_response_once("/assets/board.css");
911
+ let css = http_body(&board_css);
912
+ let legend_rule = css_rule_body(css, ".dependency-edge-legend");
913
+ let met_line_rule = css_rule_body(css, ".dependency-edge-legend__line--met");
914
+ let unmet_line_rule = css_rule_body(css, ".dependency-edge-legend__line--unmet");
915
+
916
+ assert!(
917
+ body.contains("function renderEdgeLegend")
918
+ && body.contains("Satisfied dependency")
919
+ && body.contains("Unmet dependency")
920
+ && body.contains("dependency-edge-legend__line--met")
921
+ && body.contains("dependency-edge-legend__line--unmet")
922
+ && body.contains("Unblocks ${count} Downstream"),
923
+ "dependency canvas should explain downstream impact counts and edge color semantics; body: {body}"
924
+ );
925
+ assert!(
926
+ legend_rule.contains("inline-flex") && legend_rule.contains("align-items: center"),
927
+ "edge legend should be compact and aligned with the toolbar; rule: {legend_rule}"
928
+ );
929
+ assert!(
930
+ met_line_rule.contains("var(--status-ready)")
931
+ && unmet_line_rule.contains("var(--status-blocked)")
932
+ && unmet_line_rule.contains("dashed"),
933
+ "edge legend lines should match met/unmet edge colors and dash semantics; met: {met_line_rule}; unmet: {unmet_line_rule}"
934
+ );
935
+ }
936
+
937
+ #[test]
938
+ fn dependency_canvas_web_layout_uses_compact_non_card_nodes() {
939
+ let dependency_js = board_response_once("/assets/dependency.js");
940
+ let body = http_body(&dependency_js);
941
+ let board_css = board_response_once("/assets/board.css");
942
+ let css = http_body(&board_css);
943
+ let web_rule = css_rule_body(css, ".dep-node--web");
944
+ let web_id_rule = css_rule_body(css, ".dep-node--web .ic-id");
945
+ let web_meta_rule = css_rule_body(css, ".dn-web-meta");
946
+
947
+ assert!(
948
+ body.contains("NODE_WEB_WIDTH")
949
+ && body.contains("NODE_WEB_HEIGHT")
950
+ && body.contains("WEB_RING_RADIUS")
951
+ && body.contains("function layoutWebGraph")
952
+ && body.contains("function renderWebNode")
953
+ && body.contains("function webEdgePath")
954
+ && body.contains("activeLayoutId === \"web\"")
955
+ && body.contains("renderWebNode(node, model, ctx)")
956
+ && body.contains("webEdgePath(from, to)"),
957
+ "web layout should have its own compact node renderer and center-anchored edge path; body: {body}"
958
+ );
959
+ assert!(
960
+ web_rule.contains("border-radius: 999px")
961
+ && web_rule.contains("width: 100%")
962
+ && web_rule.contains("min-height"),
963
+ "web dependency nodes should render as compact graph tokens, not full cards; rule: {web_rule}"
964
+ );
965
+ assert!(
966
+ web_id_rule.contains("text-overflow: ellipsis")
967
+ && web_id_rule.contains("white-space: nowrap"),
968
+ "web node ids should be compact and truncating; rule: {web_id_rule}"
969
+ );
970
+ assert!(
971
+ web_meta_rule.contains("text-overflow: ellipsis")
972
+ && web_meta_rule.contains("var(--font-mono)"),
973
+ "web node metadata should fit compact node geometry; rule: {web_meta_rule}"
974
+ );
975
+ }
976
+
977
+ #[test]
978
+ fn dependency_canvas_viewport_constrains_oversized_graph_to_scroll_container() {
979
+ let board_css = board_response_once("/assets/board.css");
980
+ let css = http_body(&board_css);
981
+ let view_rule = css_rule_body(css, ".dependency-view");
982
+ let viewport_rule = css_rule_body(css, ".dependency-canvas-viewport");
983
+
984
+ assert!(
985
+ view_rule.contains("min-width: 0"),
986
+ "dependency view grid item must be allowed to shrink inside the board shell; rule: {view_rule}"
987
+ );
988
+ assert!(
989
+ view_rule.contains("grid-template-rows: auto auto minmax(0, 1fr)")
990
+ && view_rule.contains("height: calc(100vh - 146px)")
991
+ && view_rule.contains("overflow: hidden"),
992
+ "dependency view should dedicate remaining viewport space to the canvas instead of leaving dead space; rule: {view_rule}"
993
+ );
994
+ assert!(
995
+ viewport_rule.contains("min-width: 0"),
996
+ "dependency canvas viewport must not use graph min-content width as panel width; rule: {viewport_rule}"
997
+ );
998
+ assert!(
999
+ viewport_rule.contains("max-width: 100%"),
1000
+ "dependency canvas viewport should clip to the board panel and expose internal scroll; rule: {viewport_rule}"
1001
+ );
1002
+ assert!(
1003
+ viewport_rule.contains("overflow: auto"),
1004
+ "dependency canvas viewport should provide internal scrolling for wide graphs; rule: {viewport_rule}"
1005
+ );
1006
+ assert!(
1007
+ viewport_rule.contains("height: auto") && viewport_rule.contains("min-height: 0"),
1008
+ "dependency canvas viewport should fill its grid track rather than use a fixed clamp; rule: {viewport_rule}"
1009
+ );
1010
+ }
1011
+
1012
+ #[test]
1013
+ fn dependency_canvas_layout_accounts_for_variable_node_heights() {
1014
+ let dependency_js = board_response_once("/assets/dependency.js");
1015
+ let body = http_body(&dependency_js);
1016
+
1017
+ assert!(
1018
+ body.contains("estimatedNodeHeight")
1019
+ && body.contains("position.height")
1020
+ && body.contains("edgePath(from, to)")
1021
+ && !body.contains("rowIndex * (NODE_HEIGHT + ROW_GAP)"),
1022
+ "dependency canvas layout should reserve per-node heights instead of fixed rows; body: {body}"
1023
+ );
1024
+ }
1025
+
1026
+ #[test]
1027
+ fn dependency_canvas_edges_resync_from_measured_nodes_on_resize() {
1028
+ let dependency_js = board_response_once("/assets/dependency.js");
1029
+ let body = http_body(&dependency_js);
1030
+
1031
+ assert!(
1032
+ body.contains("function syncEdgeGeometry")
1033
+ && body.contains("getBoundingClientRect")
1034
+ && body.contains("ResizeObserver")
1035
+ && body.contains("requestAnimationFrame")
1036
+ && body.contains("path.setAttribute(\"d\", edgePath(from, to))")
1037
+ && body.contains("svg.setAttribute(\"viewBox\"")
1038
+ && body.contains("observe(wrapper)")
1039
+ && !body.contains("canvas.append(renderEdges(model, layout));"),
1040
+ "dependency canvas edges should be redrawn from measured rendered nodes after resize; body: {body}"
1041
+ );
1042
+ }
1043
+
1044
+ #[test]
1045
+ fn dependency_canvas_has_zoom_and_hand_pan_tools() {
1046
+ let dependency_js = board_response_once("/assets/dependency.js");
1047
+ let body = http_body(&dependency_js);
1048
+ let board_css = board_response_once("/assets/board.css");
1049
+ let css = http_body(&board_css);
1050
+ let surface_rule = css_rule_body(css, ".dependency-canvas-surface");
1051
+ let pan_rule = css_rule_body(css, ".dependency-canvas-viewport[data-tool=\"pan\"]");
1052
+ let canvas_rule = css_rule_body(css, ".dependency-canvas {");
1053
+ let tool_button_rule = css_rule_body(css, ".dependency-tool-buttons .dependency-toolbar__button");
1054
+ let icon_rule = css_rule_body(css, ".dependency-toolbar__icon");
1055
+
1056
+ assert!(
1057
+ body.contains("const MIN_GRAPH_ZOOM")
1058
+ && body.contains("const MAX_GRAPH_ZOOM")
1059
+ && body.contains("const DEFAULT_GRAPH_ZOOM")
1060
+ && body.contains("const GRAPH_TOOLS")
1061
+ && body.contains("{ id: \"inspect\", label: \"Inspect\", icon: \"inspect\"")
1062
+ && body.contains("{ id: \"pan\", label: \"Hand\", icon: \"pan\"")
1063
+ && body.contains("Inspect items; hold and drag nodes to rearrange")
1064
+ && body.contains("Pan empty canvas; click nodes for details; hold and drag nodes to rearrange")
1065
+ && body.contains("function svgIcon")
1066
+ && body.contains("button.append(svgIcon(tool.icon), textElement(\"span\", \"dependency-tool-label\", tool.label))")
1067
+ && body.contains("button.setAttribute(\"aria-label\", tool.title)")
1068
+ && body.contains("let activeGraphTool")
1069
+ && body.contains("let activeGraphZoom = DEFAULT_GRAPH_ZOOM")
1070
+ && body.contains("function renderGraphInteractionControls")
1071
+ && body.contains("function applyGraphZoom")
1072
+ && body.contains("button.dataset.toolId = tool.id")
1073
+ && body.contains("zoomOut.dataset.zoomAction = \"out\"")
1074
+ && body.contains("zoomReset.dataset.zoomAction = \"reset\"")
1075
+ && body.contains("zoomIn.dataset.zoomAction = \"in\"")
1076
+ && body.contains("function installViewportPanning")
1077
+ && body.contains("function installWheelZoom")
1078
+ && body.contains("addEventListener(\"wheel\"")
1079
+ && body.contains("event.preventDefault()")
1080
+ && body.contains("graphX * activeGraphZoom")
1081
+ && body.contains("viewport.scrollLeft = pan.startLeft - deltaX")
1082
+ && body.contains("viewport.dataset.tool = activeGraphTool")
1083
+ && body.contains("viewport.dataset.zoom = String(activeGraphZoom)")
1084
+ && body.contains("surface.style.width")
1085
+ && body.contains("canvas.style.transform")
1086
+ && !body.contains("activeGraphTool === \"inspect\"")
1087
+ && body.contains("ctx.openDetail(node.id);")
1088
+ && !body.contains("label: \"Select\""),
1089
+ "dependency canvas should expose icon-backed inspect/pan tools plus zoom controls over a scaled surface; body: {body}"
1090
+ );
1091
+ assert!(
1092
+ body.contains("rect.width / scale")
1093
+ && body.contains("rawDeltaX / activeGraphZoom")
1094
+ && body.contains("event.target.closest(\".dependency-graph-node\")"),
1095
+ "dependency canvas geometry should be zoom-aware, and hand panning should not intercept node dragging; body: {body}"
1096
+ );
1097
+ assert!(
1098
+ surface_rule.contains("overflow: hidden") && surface_rule.contains("min-width: 100%"),
1099
+ "scaled dependency canvas should live inside a clipped surface whose dimensions drive scrolling; rule: {surface_rule}"
1100
+ );
1101
+ assert!(
1102
+ pan_rule.contains("cursor: grab") && pan_rule.contains("touch-action: none"),
1103
+ "hand tool should expose a pan cursor and own touch movement; rule: {pan_rule}"
1104
+ );
1105
+ assert!(
1106
+ canvas_rule.contains("transform-origin: top left"),
1107
+ "zoomed canvas should scale from the scroll origin; rule: {canvas_rule}"
1108
+ );
1109
+ assert!(
1110
+ tool_button_rule.contains("inline-flex") && tool_button_rule.contains("align-items: center"),
1111
+ "graph tool buttons should visually pair icon and label; rule: {tool_button_rule}"
1112
+ );
1113
+ assert!(
1114
+ icon_rule.contains("width: 14px") && icon_rule.contains("height: 14px"),
1115
+ "graph tool icons should have stable compact dimensions; rule: {icon_rule}"
1116
+ );
1117
+ }
1118
+
1119
+ #[test]
1120
+ fn dependency_canvas_nodes_can_be_dragged_ephemerally() {
1121
+ let dependency_js = board_response_once("/assets/dependency.js");
1122
+ let body = http_body(&dependency_js);
1123
+ let board_css = board_response_once("/assets/board.css");
1124
+ let css = http_body(&board_css);
1125
+ let node_rule = css_rule_body(css, ".dependency-graph-node");
1126
+ let dragging_rule = css_rule_body(css, ".dependency-graph-node.is-dragging");
1127
+
1128
+ assert!(
1129
+ body.contains("function installNodeDragging")
1130
+ && body.contains("const NODE_DRAG_HOLD_MS")
1131
+ && body.contains("pointerdown")
1132
+ && body.contains("setPointerCapture")
1133
+ && body.contains("holdTimer")
1134
+ && body.contains("window.addEventListener(\"pointermove\", onPointerMove, true)")
1135
+ && body.contains("window.removeEventListener(\"pointermove\", dragState.onPointerMove, true)")
1136
+ && body.contains("ready: false")
1137
+ && body.contains("drag.ready = true")
1138
+ && body.contains("if (!drag.ready) {")
1139
+ && body.contains("if (!drag.captured) {")
1140
+ && body.contains("drag.captured = true")
1141
+ && body.contains("wrapper.style.left")
1142
+ && body.contains("wrapper.style.top")
1143
+ && body.contains("suppressClick")
1144
+ && body.contains("syncEdgeGeometry(canvas, model)")
1145
+ && body.contains("installNodeDragging(canvas, wrapper, model)")
1146
+ && !body.contains("wrapper.setPointerCapture(pointerId);"),
1147
+ "dependency canvas should support session-only pointer dragging with edge resync; body: {body}"
1148
+ );
1149
+ assert!(
1150
+ node_rule.contains("touch-action: none"),
1151
+ "dependency graph nodes should opt out of touch panning while dragging; rule: {node_rule}"
1152
+ );
1153
+ assert!(
1154
+ dragging_rule.contains("z-index") && dragging_rule.contains("grabbing"),
1155
+ "dragging nodes should rise above peers and show drag affordance; rule: {dragging_rule}"
1156
+ );
1157
+ }
1158
+
1159
+ #[test]
1160
+ fn board_asset_named_imports_are_satisfied_by_shipped_modules() {
1161
+ let modules = board_asset_modules();
1162
+ for (importer_path, importer_body) in modules {
1163
+ for (source_path, source_body) in modules {
1164
+ let imports = named_imports_from(importer_body, source_path);
1165
+ if imports.is_empty() {
1166
+ continue;
1167
+ }
1168
+ let exports = exported_names(source_body);
1169
+ for imported in imports {
1170
+ assert!(
1171
+ exports.contains(&imported),
1172
+ "{importer_path} imports {imported} from {source_path}, but {source_path} does not export it; exports: {exports:?}"
1173
+ );
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ #[test]
1180
+ fn board_renderer_assets_do_not_ship_raw_html_injection_patterns() {
1181
+ for path in [
1182
+ "/assets/markdown.js",
1183
+ "/assets/card.js",
1184
+ "/assets/detail.js",
1185
+ "/assets/views.js",
1186
+ "/assets/kanban.js",
1187
+ "/assets/dependency.js",
1188
+ "/assets/table.js",
1189
+ ] {
1190
+ let response = board_response_once(path);
1191
+ let body = http_body(&response);
1192
+ for forbidden in [
1193
+ "innerHTML",
1194
+ "outerHTML",
1195
+ "insertAdjacentHTML",
1196
+ "<script",
1197
+ " onerror=",
1198
+ " onclick=",
1199
+ "javascript:",
1200
+ ] {
1201
+ assert!(
1202
+ !body.contains(forbidden),
1203
+ "{path} should not ship raw HTML injection pattern {forbidden:?}; body: {body}"
1204
+ );
1205
+ }
1206
+ assert!(
1207
+ body.contains("createTextNode") || body.contains("textContent"),
1208
+ "{path} should build escaped text through DOM text APIs; body: {body}"
1209
+ );
1210
+ }
1211
+ }
1212
+
1213
+ #[test]
1214
+ fn board_substrate_feed_returns_json_shape_without_private_fields() {
1215
+ let (mut child, rx) = spawn_board_once(unused_local_port());
1216
+ let port = parse_bound_port(&read_bound_line(rx));
1217
+
1218
+ let response = http_get(port, "/api/substrate");
1219
+ assert!(
1220
+ response.starts_with("HTTP/1.1 200 OK"),
1221
+ "feed should return 200; response: {response}"
1222
+ );
1223
+ assert!(
1224
+ response.contains("Content-Type: application/json; charset=utf-8"),
1225
+ "feed should return JSON; response: {response}"
1226
+ );
1227
+ let body = http_body(&response);
1228
+ let json = json_body(&response);
1229
+
1230
+ assert!(
1231
+ json.get("work_view_version")
1232
+ .and_then(Value::as_str)
1233
+ .is_some_and(|version| !version.is_empty()),
1234
+ "feed should include work_view_version; json: {json}"
1235
+ );
1236
+ assert!(
1237
+ json.get("diagnostics").is_some(),
1238
+ "feed should include diagnostics; json: {json}"
1239
+ );
1240
+
1241
+ let items = json
1242
+ .get("items")
1243
+ .and_then(Value::as_array)
1244
+ .expect("feed items should be an array");
1245
+ let feat_b = items
1246
+ .iter()
1247
+ .find(|item| item.get("id").and_then(Value::as_str) == Some("feat-b"))
1248
+ .expect("feed should include feat-b");
1249
+ let feat_b_obj = feat_b.as_object().expect("feed item should be an object");
1250
+ for field in [
1251
+ "body",
1252
+ "rel_path",
1253
+ "tier",
1254
+ "unmet_deps",
1255
+ "dependents",
1256
+ "children",
1257
+ ] {
1258
+ assert!(
1259
+ feat_b_obj.contains_key(field),
1260
+ "feed item should include {field}; item: {feat_b}"
1261
+ );
1262
+ }
1263
+ assert!(
1264
+ !feat_b_obj.contains_key("path"),
1265
+ "feed item should not expose absolute path; item: {feat_b}"
1266
+ );
1267
+ assert!(
1268
+ !feat_b_obj.contains_key("raw_text"),
1269
+ "feed item should not expose raw_text; item: {feat_b}"
1270
+ );
1271
+ assert!(
1272
+ !body.contains(&fixture_root().display().to_string()),
1273
+ "feed should not leak absolute fixture paths; body: {body}"
1274
+ );
1275
+
1276
+ // story-research-1 carries research_origin + research_refs — assert they
1277
+ // actually serialize into the feed item (the gate-tests gap: the DTO test
1278
+ // proved private fields are hidden but never that the linkage fields reach
1279
+ // the feed; feed_item() silently dropping one would have gone unnoticed).
1280
+ let story_research = items
1281
+ .iter()
1282
+ .find(|item| item.get("id").and_then(Value::as_str) == Some("story-research-1"))
1283
+ .expect("feed should include story-research-1");
1284
+ assert_eq!(
1285
+ story_research.get("research_origin").and_then(Value::as_str),
1286
+ Some("ard-pos-x"),
1287
+ "feed item should serialize research_origin; item: {story_research}"
1288
+ );
1289
+ assert_eq!(
1290
+ story_research.get("scan_origin").and_then(Value::as_str),
1291
+ Some("scan-demo"),
1292
+ "feed item should serialize scan_origin; item: {story_research}"
1293
+ );
1294
+ let refs: Vec<&str> = story_research
1295
+ .get("research_refs")
1296
+ .and_then(Value::as_array)
1297
+ .expect("feed item should serialize research_refs as an array")
1298
+ .iter()
1299
+ .filter_map(Value::as_str)
1300
+ .collect();
1301
+ assert_eq!(
1302
+ refs,
1303
+ vec!["ard-pos-x"],
1304
+ "feed item research_refs should be [ard-pos-x]; item: {story_research}"
1305
+ );
1306
+ wait_for_success(&mut child);
1307
+ }
1308
+
1309
+ #[test]
1310
+ fn board_substrate_feed_reports_parse_errors_and_keeps_valid_siblings() {
1311
+ let (mut child, rx) = spawn_board_once_in(malformed_fixture_root(), unused_local_port());
1312
+ let port = parse_bound_port(&read_bound_line(rx));
1313
+
1314
+ let response = http_get(port, "/api/substrate");
1315
+ assert!(
1316
+ response.starts_with("HTTP/1.1 200 OK"),
1317
+ "malformed feed should still return 200; response: {response}"
1318
+ );
1319
+ let body = http_body(&response);
1320
+ let json = json_body(&response);
1321
+ let items = json
1322
+ .get("items")
1323
+ .and_then(Value::as_array)
1324
+ .expect("feed items should be an array");
1325
+ assert!(
1326
+ items
1327
+ .iter()
1328
+ .any(|item| item.get("id").and_then(Value::as_str) == Some("good-item")),
1329
+ "valid sibling should remain in feed; json: {json}"
1330
+ );
1331
+
1332
+ let parse_errors = json
1333
+ .get("diagnostics")
1334
+ .and_then(|diagnostics| diagnostics.get("parse_errors"))
1335
+ .and_then(Value::as_array)
1336
+ .expect("parse_errors should be an array");
1337
+ assert_eq!(
1338
+ parse_errors.len(),
1339
+ 1,
1340
+ "malformed fixture should produce one parse error; json: {json}"
1341
+ );
1342
+ assert!(
1343
+ parse_errors[0]
1344
+ .get("rel_path")
1345
+ .and_then(Value::as_str)
1346
+ .is_some_and(|path| path.ends_with("malformed-no-closing-fence.md")),
1347
+ "parse error should include relative path; json: {json}"
1348
+ );
1349
+ assert!(
1350
+ !body.contains(&malformed_fixture_root().display().to_string()),
1351
+ "diagnostics should not leak absolute paths; body: {body}"
1352
+ );
1353
+ wait_for_success(&mut child);
1354
+ }
1355
+
1356
+ #[test]
1357
+ fn board_substrate_feed_ready_and_blocked_match_cli() {
1358
+ let (ready_stdout, _, ready_code) = run(&["--ready", "--paths"]);
1359
+ assert_eq!(ready_code, 0);
1360
+ let (blocked_stdout, _, blocked_code) = run(&["--blocked", "--paths"]);
1361
+ assert_eq!(blocked_code, 0);
1362
+
1363
+ let (mut child, rx) = spawn_board_once(unused_local_port());
1364
+ let port = parse_bound_port(&read_bound_line(rx));
1365
+ let response = http_get(port, "/api/substrate");
1366
+ let json = json_body(&response);
1367
+ let items = json
1368
+ .get("items")
1369
+ .and_then(Value::as_array)
1370
+ .expect("feed items should be an array");
1371
+
1372
+ let mut feed_ready: Vec<String> = items
1373
+ .iter()
1374
+ .filter(|item| item.get("ready").and_then(Value::as_bool) == Some(true))
1375
+ .filter_map(|item| item.get("id").and_then(Value::as_str).map(str::to_string))
1376
+ .collect();
1377
+ let mut feed_blocked: Vec<String> = items
1378
+ .iter()
1379
+ .filter(|item| item.get("blocked").and_then(Value::as_bool) == Some(true))
1380
+ .filter_map(|item| item.get("id").and_then(Value::as_str).map(str::to_string))
1381
+ .collect();
1382
+ let mut cli_ready = ids_from_paths(&ready_stdout);
1383
+ let mut cli_blocked = ids_from_paths(&blocked_stdout);
1384
+ feed_ready.sort();
1385
+ feed_blocked.sort();
1386
+ cli_ready.sort();
1387
+ cli_blocked.sort();
1388
+
1389
+ assert_eq!(feed_ready, cli_ready, "feed ready ids should match CLI");
1390
+ assert_eq!(
1391
+ feed_blocked, cli_blocked,
1392
+ "feed blocked ids should match CLI"
1393
+ );
1394
+ wait_for_success(&mut child);
1395
+ }
1396
+
1397
+ #[test]
1398
+ fn board_busy_default_port_scans_upward_and_prints_bound_url() {
1399
+ let busy = match TcpListener::bind((Ipv4Addr::LOCALHOST, 8181)) {
1400
+ Ok(listener) => Some(listener),
1401
+ Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => None,
1402
+ Err(e) => panic!("failed to reserve default board port 8181: {e}"),
1403
+ };
1404
+
1405
+ let (mut child, rx) = spawn_board_once_with_args(&["board", "--once", "--no-open"]);
1406
+ let line = read_bound_line(rx);
1407
+ let bound_port = parse_bound_port(&line);
1408
+ assert!(
1409
+ bound_port > 8181,
1410
+ "busy default port should scan upward from 8181; line: {line}"
1411
+ );
1412
+
1413
+ let response = http_get(bound_port, "/healthz");
1414
+ assert!(
1415
+ response.starts_with("HTTP/1.1 200 OK"),
1416
+ "scanned port should serve requests; response: {response}"
1417
+ );
1418
+ wait_for_success(&mut child);
1419
+ drop(busy);
1420
+ }
1421
+
1422
+ #[test]
1423
+ fn board_busy_requested_port_scans_upward_and_prints_bound_url() {
1424
+ let busy = busy_local_port_below_max();
1425
+ let busy_port = busy
1426
+ .local_addr()
1427
+ .expect("failed to read busy listener port")
1428
+ .port();
1429
+
1430
+ let (mut child, rx) = spawn_board_once(busy_port);
1431
+ let line = read_bound_line(rx);
1432
+ let bound_port = parse_bound_port(&line);
1433
+ assert!(
1434
+ bound_port > busy_port,
1435
+ "busy requested port should scan upward; requested {busy_port}, line: {line}"
1436
+ );
1437
+
1438
+ let response = http_get(bound_port, "/healthz");
1439
+ assert!(
1440
+ response.starts_with("HTTP/1.1 200 OK"),
1441
+ "scanned port should serve requests; response: {response}"
1442
+ );
1443
+ wait_for_success(&mut child);
1444
+ drop(busy);
1445
+ }
1446
+
1447
+ #[test]
1448
+ fn exit_1_on_positional_arg() {
1449
+ let (_, stderr, code) = run(&["some-arg"]);
1450
+ assert_eq!(code, 1);
1451
+ assert!(stderr.contains("unexpected argument"), "stderr: {stderr}");
1452
+ }
1453
+
1454
+ #[test]
1455
+ fn exit_1_on_missing_flag_value() {
1456
+ let (_, stderr, code) = run(&["--stage"]);
1457
+ assert_eq!(code, 1);
1458
+ assert!(stderr.contains("missing value"), "stderr: {stderr}");
1459
+ }
1460
+
1461
+ #[test]
1462
+ fn exit_1_on_ready_and_blocked() {
1463
+ let (_, stderr, code) = run(&["--ready", "--blocked"]);
1464
+ assert_eq!(code, 1);
1465
+ assert!(stderr.contains("mutually exclusive"), "stderr: {stderr}");
1466
+ }
1467
+
1468
+ #[test]
1469
+ fn exit_2_when_no_substrate() {
1470
+ // Run from /tmp — no .work/CONVENTIONS.md ancestor
1471
+ let out = Command::new(bin!())
1472
+ .current_dir("/tmp")
1473
+ .output()
1474
+ .expect("failed to run work-view");
1475
+ let code = out.status.code().unwrap_or(-1);
1476
+ assert_eq!(code, 2, "no-substrate should exit 2");
1477
+ let stderr = String::from_utf8_lossy(&out.stderr);
1478
+ assert!(stderr.contains("no substrate"), "stderr: {stderr}");
1479
+ }
1480
+
1481
+ #[test]
1482
+ fn exit_3_on_unreadable_substrate_traversal() {
1483
+ // A valid .work/CONVENTIONS.md but an UNREADABLE tier dir (.work/active,
1484
+ // chmod 000) must yield a fatal LoadError::Io → exit code 3.
1485
+ //
1486
+ // Built at runtime in a TempDir (a chmod-000 dir cannot be committed as a
1487
+ // fixture). Skipped when running as root, since root bypasses permission
1488
+ // bits and the dir stays readable — detected by reading after chmod.
1489
+ use std::fs;
1490
+ use std::os::unix::fs::PermissionsExt;
1491
+ use tempfile::TempDir;
1492
+
1493
+ let tmp = TempDir::new().expect("failed to create tempdir");
1494
+ let root = tmp.path();
1495
+ let work = root.join(".work");
1496
+ let active = work.join("active");
1497
+ fs::create_dir_all(&active).expect("failed to create .work/active");
1498
+ fs::write(work.join("CONVENTIONS.md"), "# Conventions\n").expect("failed to write conventions");
1499
+
1500
+ // Make the active tier dir unreadable.
1501
+ fs::set_permissions(&active, fs::Permissions::from_mode(0o000))
1502
+ .expect("failed to chmod active dir to 000");
1503
+
1504
+ // Skip guard: if the dir is still readable (running as root / perms bypassed),
1505
+ // restore perms and bail cleanly rather than asserting a behavior we can't reach.
1506
+ if fs::read_dir(&active).is_ok() {
1507
+ let _ = fs::set_permissions(&active, fs::Permissions::from_mode(0o755));
1508
+ eprintln!(
1509
+ "skipping exit_3_on_unreadable_substrate_traversal: chmod 000 was bypassed \
1510
+ (likely running as root); cannot make the tier dir unreadable"
1511
+ );
1512
+ return;
1513
+ }
1514
+
1515
+ let out = Command::new(bin!())
1516
+ .current_dir(root)
1517
+ .output()
1518
+ .expect("failed to run work-view");
1519
+ let code = out.status.code().unwrap_or(-1);
1520
+ let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1521
+
1522
+ // Restore perms BEFORE the TempDir drops so cleanup does not fail.
1523
+ fs::set_permissions(&active, fs::Permissions::from_mode(0o755))
1524
+ .expect("failed to restore active dir perms");
1525
+
1526
+ assert_eq!(
1527
+ code, 3,
1528
+ "unreadable tier dir under a valid CONVENTIONS.md should exit 3; stderr: {stderr}"
1529
+ );
1530
+ assert!(
1531
+ stderr.contains("I/O error"),
1532
+ "stderr should mention the I/O failure; stderr: {stderr}"
1533
+ );
1534
+ }
1535
+
1536
+ // ── Intentional tightening over bash: --/missing-value ────────────────────────
1537
+ //
1538
+ // These are deliberate contract hardenings over bash's `shift 2` behavior:
1539
+ // - bash silently breaks on `--` and leaves remaining args in `$@`
1540
+ // - bash blindly shifts past a missing flag value, consuming the next flag
1541
+ // Rust treats both as UsageError (exit 1) — a robust refinement, not a bug.
1542
+
1543
+ #[test]
1544
+ fn exit_1_on_positional_after_dash_dash_intentional_tightening() {
1545
+ // Rust rejects positionals after `--` as UsageError (exit 1).
1546
+ // Bash breaks on `--` and leaves remaining args unprocessed — it would
1547
+ // just ignore them. The Rust behavior is a deliberate improvement.
1548
+ let (_, stderr, code) = run(&["--", "some-arg"]);
1549
+ assert_eq!(
1550
+ code, 1,
1551
+ "positional after `--` should be UsageError/exit 1 (deliberate Rust tightening)"
1552
+ );
1553
+ assert!(
1554
+ stderr.contains("unexpected argument"),
1555
+ "stderr should mention unexpected argument: {stderr}"
1556
+ );
1557
+ }
1558
+
1559
+ #[test]
1560
+ fn exit_1_on_missing_value_flag_followed_by_another_flag_intentional_tightening() {
1561
+ // Rust rejects `--stage --kind` as a missing value for `--stage` (exit 1).
1562
+ // Bash's blind `shift 2` would consume `--kind` as the stage value and
1563
+ // then quietly drop `--kind` from consideration. The Rust behavior is
1564
+ // a deliberate improvement: reject it loudly rather than silently misparse.
1565
+ let (_, stderr, code) = run(&["--stage", "--kind"]);
1566
+ assert_eq!(
1567
+ code, 1,
1568
+ "missing value (next token is flag) should be UsageError/exit 1 (deliberate Rust tightening)"
1569
+ );
1570
+ assert!(
1571
+ stderr.contains("missing value"),
1572
+ "stderr should mention missing value: {stderr}"
1573
+ );
1574
+ }
1575
+
1576
+ // ── Table output ──────────────────────────────────────────────────────────────
1577
+
1578
+ #[test]
1579
+ fn table_has_header_and_rows() {
1580
+ let (stdout, _, code) = run(&[]);
1581
+ assert_eq!(code, 0);
1582
+ let lines: Vec<&str> = stdout.lines().collect();
1583
+ // Header line
1584
+ assert!(
1585
+ lines[0].starts_with("ID"),
1586
+ "first line should be header: {}",
1587
+ lines[0]
1588
+ );
1589
+ // Separator line
1590
+ assert!(
1591
+ lines[1].starts_with("---"),
1592
+ "second line should be separator: {}",
1593
+ lines[1]
1594
+ );
1595
+ // At least one data row
1596
+ assert!(lines.len() >= 3, "should have at least one data row");
1597
+ }
1598
+
1599
+ #[test]
1600
+ fn table_header_format_matches_bash() {
1601
+ let (stdout, _, _) = run(&[]);
1602
+ let header = stdout.lines().next().unwrap();
1603
+ // Exact format: %-40s %-8s %-14s %-30s %s
1604
+ let expected = format!(
1605
+ "{:<40} {:<8} {:<14} {:<30} {}",
1606
+ "ID", "KIND", "STAGE", "TAGS", "PARENT"
1607
+ );
1608
+ assert_eq!(header, expected, "header format mismatch");
1609
+ }
1610
+
1611
+ #[test]
1612
+ fn table_empty_result_prints_nothing() {
1613
+ // Filter for a stage that doesn't exist
1614
+ let (stdout, _, code) = run(&["--stage", "nonexistent-stage-xyz"]);
1615
+ assert_eq!(code, 0);
1616
+ assert_eq!(stdout, "", "empty result should print nothing");
1617
+ }
1618
+
1619
+ // ── Filter flags ──────────────────────────────────────────────────────────────
1620
+
1621
+ #[test]
1622
+ fn filter_stage_implementing() {
1623
+ let (stdout, _, code) = run(&["--stage", "implementing"]);
1624
+ assert_eq!(code, 0);
1625
+ let ids = table_ids(&stdout);
1626
+ // feat-a is implementing; feat-b is drafting; epic-alpha is implementing
1627
+ assert!(
1628
+ ids.contains(&"feat-a"),
1629
+ "should contain feat-a; ids: {ids:?}"
1630
+ );
1631
+ assert!(
1632
+ ids.contains(&"epic-alpha"),
1633
+ "should contain epic-alpha; ids: {ids:?}"
1634
+ );
1635
+ assert!(
1636
+ !ids.contains(&"feat-b"),
1637
+ "should NOT contain feat-b (drafting); ids: {ids:?}"
1638
+ );
1639
+ }
1640
+
1641
+ #[test]
1642
+ fn filter_kind_feature() {
1643
+ let (stdout, _, code) = run(&["--kind", "feature"]);
1644
+ assert_eq!(code, 0);
1645
+ let ids = table_ids(&stdout);
1646
+ assert!(
1647
+ ids.contains(&"feat-a"),
1648
+ "should contain feat-a; ids: {ids:?}"
1649
+ );
1650
+ assert!(
1651
+ ids.contains(&"feat-b"),
1652
+ "should contain feat-b; ids: {ids:?}"
1653
+ );
1654
+ assert!(
1655
+ !ids.contains(&"epic-alpha"),
1656
+ "should NOT contain epic (wrong kind); ids: {ids:?}"
1657
+ );
1658
+ assert!(
1659
+ !ids.iter().any(|id| id.starts_with("story")),
1660
+ "should NOT contain story; ids: {ids:?}"
1661
+ );
1662
+ }
1663
+
1664
+ #[test]
1665
+ fn filter_parent_id() {
1666
+ let (stdout, _, code) = run(&["--parent", "epic-alpha"]);
1667
+ assert_eq!(code, 0);
1668
+ let ids = table_ids(&stdout);
1669
+ assert!(
1670
+ ids.contains(&"feat-a"),
1671
+ "should contain feat-a; ids: {ids:?}"
1672
+ );
1673
+ assert!(
1674
+ ids.contains(&"feat-b"),
1675
+ "should contain feat-b; ids: {ids:?}"
1676
+ );
1677
+ assert!(
1678
+ !ids.contains(&"epic-alpha"),
1679
+ "epic should NOT be its own child; ids: {ids:?}"
1680
+ );
1681
+ }
1682
+
1683
+ #[test]
1684
+ fn filter_parent_null_matches_top_level() {
1685
+ let (stdout, _, code) = run(&["--parent", "null"]);
1686
+ assert_eq!(code, 0);
1687
+ let ids = table_ids(&stdout);
1688
+ // epic-alpha has no parent → should appear
1689
+ assert!(
1690
+ ids.contains(&"epic-alpha"),
1691
+ "epic-alpha should appear (parent=null); ids: {ids:?}"
1692
+ );
1693
+ // feat-a has parent epic-alpha → should NOT appear
1694
+ assert!(
1695
+ !ids.contains(&"feat-a"),
1696
+ "feat-a should NOT appear (has parent); ids: {ids:?}"
1697
+ );
1698
+ }
1699
+
1700
+ #[test]
1701
+ fn filter_tag_single() {
1702
+ let (stdout, _, code) = run(&["--tag", "perf"]);
1703
+ assert_eq!(code, 0);
1704
+ let ids = table_ids(&stdout);
1705
+ // Only feat-b has [tooling, perf]
1706
+ assert!(
1707
+ ids.contains(&"feat-b"),
1708
+ "should contain feat-b; ids: {ids:?}"
1709
+ );
1710
+ assert!(
1711
+ !ids.contains(&"feat-a"),
1712
+ "feat-a has no perf tag; ids: {ids:?}"
1713
+ );
1714
+ }
1715
+
1716
+ #[test]
1717
+ fn filter_tag_and_semantics() {
1718
+ let (stdout, _, code) = run(&["--tag", "tooling", "--tag", "perf"]);
1719
+ assert_eq!(code, 0);
1720
+ let ids = table_ids(&stdout);
1721
+ // Only feat-b has both tooling AND perf
1722
+ assert!(
1723
+ ids.contains(&"feat-b"),
1724
+ "should contain feat-b; ids: {ids:?}"
1725
+ );
1726
+ assert!(
1727
+ !ids.contains(&"feat-a"),
1728
+ "feat-a has only tooling; ids: {ids:?}"
1729
+ );
1730
+ }
1731
+
1732
+ #[test]
1733
+ fn filter_release_version() {
1734
+ let (stdout, _, code) = run(&["--release", "v1.0.0"]);
1735
+ assert_eq!(code, 0);
1736
+ let ids = table_ids(&stdout);
1737
+ assert!(
1738
+ ids.contains(&"feat-a"),
1739
+ "feat-a has release_binding=v1.0.0; ids: {ids:?}"
1740
+ );
1741
+ assert!(
1742
+ !ids.contains(&"feat-b"),
1743
+ "feat-b has no release_binding; ids: {ids:?}"
1744
+ );
1745
+ }
1746
+
1747
+ #[test]
1748
+ fn filter_release_null() {
1749
+ let (stdout, _, code) = run(&["--release", "null"]);
1750
+ assert_eq!(code, 0);
1751
+ let ids = table_ids(&stdout);
1752
+ // feat-b has null release_binding
1753
+ assert!(
1754
+ ids.contains(&"feat-b"),
1755
+ "feat-b should appear (release=null); ids: {ids:?}"
1756
+ );
1757
+ assert!(
1758
+ !ids.contains(&"feat-a"),
1759
+ "feat-a has release binding set; ids: {ids:?}"
1760
+ );
1761
+ }
1762
+
1763
+ #[test]
1764
+ fn filter_blocking_id() {
1765
+ let (stdout, _, code) = run(&["--blocking", "feat-a"]);
1766
+ assert_eq!(code, 0);
1767
+ let ids = table_ids(&stdout);
1768
+ // feat-b depends on feat-a
1769
+ assert!(
1770
+ ids.contains(&"feat-b"),
1771
+ "feat-b depends_on feat-a; ids: {ids:?}"
1772
+ );
1773
+ assert!(
1774
+ !ids.contains(&"feat-a"),
1775
+ "feat-a doesn't depend on itself; ids: {ids:?}"
1776
+ );
1777
+ }
1778
+
1779
+ // ── IsNull semantics: Rust --X null matches missing-field AND explicit null ───
1780
+ //
1781
+ // Rust `--parent null` / `--release null` / `--gate null` map to `Match::IsNull`.
1782
+ // The core normalises explicit YAML `null`, the literal string `"null"`, and a
1783
+ // MISSING field all to `None`. So `IsNull` matches ALL THREE.
1784
+ //
1785
+ // Bash stores the raw extracted string and tests `== "null"`, which matches
1786
+ // items with an explicit `null` field but NOT items whose field is absent.
1787
+ //
1788
+ // This is a documented, intentional divergence (see Risks in adapter design):
1789
+ // the binary supersedes the bash; no current skill consumer uses `--X null`.
1790
+ // The fixture `idea-backlog.md` has no `parent`, `release_binding`, or
1791
+ // `gate_origin` fields at all — it is the "missing field" case.
1792
+
1793
+ #[test]
1794
+ fn is_null_matches_missing_and_explicit_null_gate() {
1795
+ // idea-backlog.md has NO gate_origin field (missing → None → IsNull matches)
1796
+ // epic-alpha.md has gate_origin: null (explicit null → None → IsNull matches)
1797
+ let (stdout, _, code) = run(&["--gate", "null", "--paths"]);
1798
+ assert_eq!(code, 0);
1799
+ assert!(
1800
+ stdout.contains("idea-backlog"),
1801
+ "--gate null should match backlog item with MISSING gate_origin (missing → None → IsNull); stdout: {stdout}"
1802
+ );
1803
+ assert!(
1804
+ stdout.contains("epic-alpha"),
1805
+ "--gate null should match active item with explicit gate_origin: null; stdout: {stdout}"
1806
+ );
1807
+ }
1808
+
1809
+ #[test]
1810
+ fn is_null_matches_missing_and_explicit_null_release() {
1811
+ // idea-backlog.md has NO release_binding field (missing → None → IsNull matches)
1812
+ // epic-alpha.md has release_binding: null (explicit null → None → IsNull matches)
1813
+ let (stdout, _, code) = run(&["--release", "null", "--paths"]);
1814
+ assert_eq!(code, 0);
1815
+ assert!(
1816
+ stdout.contains("idea-backlog"),
1817
+ "--release null should match backlog item with MISSING release_binding (missing → None → IsNull); stdout: {stdout}"
1818
+ );
1819
+ assert!(
1820
+ stdout.contains("epic-alpha"),
1821
+ "--release null should match active item with explicit release_binding: null; stdout: {stdout}"
1822
+ );
1823
+ }
1824
+
1825
+ #[test]
1826
+ fn is_null_matches_missing_and_explicit_null_parent() {
1827
+ // idea-backlog.md has NO parent field (missing → None → IsNull matches)
1828
+ // epic-alpha.md has parent: null (explicit null → None → IsNull matches)
1829
+ let (stdout, _, code) = run(&["--parent", "null", "--paths"]);
1830
+ assert_eq!(code, 0);
1831
+ assert!(
1832
+ stdout.contains("idea-backlog"),
1833
+ "--parent null should match backlog item with MISSING parent field (missing → None → IsNull); stdout: {stdout}"
1834
+ );
1835
+ assert!(
1836
+ stdout.contains("epic-alpha"),
1837
+ "--parent null should match active item with explicit parent: null; stdout: {stdout}"
1838
+ );
1839
+ }
1840
+
1841
+ // ── --scope flag (binary-level pipeline wiring) ───────────────────────────────
1842
+ //
1843
+ // Run the COMPILED binary against the golden fixture so these guard the
1844
+ // `apply_scope` wiring in main.rs — parser/unit tests alone would still pass if
1845
+ // apply_scope were dropped from the pipeline. Golden fixture tiers:
1846
+ // non-terminal (5): epic-alpha, feat-a, feat-b, story-alpha-1 (active) + idea-backlog (backlog)
1847
+ // terminal (3): feat-done, feat-shipped (archive) + release-v1.0 (releases)
1848
+ // feat-shipped carries release_binding: v1.0.0 and gate_origin: tests in a
1849
+ // terminal tier, so it proves --release/--gate implicit-widen and --scope
1850
+ // precedence end-to-end.
1851
+
1852
+ /// Parse a `--count` invocation's integer output (asserts exit 0).
1853
+ fn count_of(args: &[&str]) -> usize {
1854
+ let (stdout, _, code) = run(args);
1855
+ assert_eq!(code, 0, "args {args:?} should exit 0");
1856
+ stdout
1857
+ .trim()
1858
+ .parse()
1859
+ .unwrap_or_else(|_| panic!("count not an integer for {args:?}: {stdout:?}"))
1860
+ }
1861
+
1862
+ #[test]
1863
+ fn scope_default_excludes_terminal_tiers() {
1864
+ // Use table mode (real ids): the release item's file is v1.0.md, so a
1865
+ // --paths substring check for "release-v1.0" would falsely pass.
1866
+ let (out, _, code) = run(&[]);
1867
+ assert_eq!(code, 0);
1868
+ let ids = table_ids(&out);
1869
+ for id in [
1870
+ "epic-alpha",
1871
+ "feat-a",
1872
+ "feat-b",
1873
+ "story-alpha-1",
1874
+ "story-research-1",
1875
+ "idea-backlog",
1876
+ ] {
1877
+ assert!(
1878
+ ids.contains(&id),
1879
+ "default scope should include non-terminal {id}; ids: {ids:?}"
1880
+ );
1881
+ }
1882
+ for id in ["feat-done", "feat-shipped", "release-v1.0"] {
1883
+ assert!(
1884
+ !ids.contains(&id),
1885
+ "default scope must hide terminal {id}; ids: {ids:?}"
1886
+ );
1887
+ }
1888
+ assert_eq!(count_of(&["--count"]), 6, "default = 6 non-terminal items");
1889
+ }
1890
+
1891
+ #[test]
1892
+ fn scope_all_includes_every_tier() {
1893
+ let (out, _, code) = run(&["--scope", "all"]);
1894
+ assert_eq!(code, 0);
1895
+ let ids = table_ids(&out);
1896
+ for id in [
1897
+ "epic-alpha",
1898
+ "feat-a",
1899
+ "feat-b",
1900
+ "story-alpha-1",
1901
+ "story-research-1",
1902
+ "idea-backlog",
1903
+ "feat-done",
1904
+ "feat-shipped",
1905
+ "release-v1.0",
1906
+ ] {
1907
+ assert!(
1908
+ ids.contains(&id),
1909
+ "--scope all should include {id}; ids: {ids:?}"
1910
+ );
1911
+ }
1912
+ assert_eq!(count_of(&["--scope", "all", "--count"]), 9);
1913
+ }
1914
+
1915
+ #[test]
1916
+ fn scope_archive_only_returns_archive_tier() {
1917
+ let (out, _, _) = run(&["--scope", "archive"]);
1918
+ let ids = table_ids(&out);
1919
+ assert!(ids.contains(&"feat-done"), "ids: {ids:?}");
1920
+ assert!(ids.contains(&"feat-shipped"), "ids: {ids:?}");
1921
+ assert!(!ids.contains(&"epic-alpha"), "ids: {ids:?}");
1922
+ assert!(!ids.contains(&"release-v1.0"), "ids: {ids:?}");
1923
+ }
1924
+
1925
+ #[test]
1926
+ fn scope_releases_only_returns_release_tier() {
1927
+ let (out, _, _) = run(&["--scope", "releases"]);
1928
+ let ids = table_ids(&out);
1929
+ assert_eq!(ids, vec!["release-v1.0"], "ids: {ids:?}");
1930
+ }
1931
+
1932
+ #[test]
1933
+ fn scope_active_excludes_backlog() {
1934
+ let (out, _, _) = run(&["--scope", "active"]);
1935
+ let ids = table_ids(&out);
1936
+ assert!(ids.contains(&"epic-alpha"), "ids: {ids:?}");
1937
+ assert!(
1938
+ !ids.contains(&"idea-backlog"),
1939
+ "--scope active must exclude backlog; ids: {ids:?}"
1940
+ );
1941
+ assert!(!ids.contains(&"feat-done"), "ids: {ids:?}");
1942
+ }
1943
+
1944
+ #[test]
1945
+ fn scope_backlog_only_returns_backlog_tier() {
1946
+ let (out, _, _) = run(&["--scope", "backlog"]);
1947
+ let ids = table_ids(&out);
1948
+ assert_eq!(ids, vec!["idea-backlog"], "ids: {ids:?}");
1949
+ }
1950
+
1951
+ #[test]
1952
+ fn scope_invalid_value_exits_1() {
1953
+ let (_, stderr, code) = run(&["--scope", "bogus"]);
1954
+ assert_eq!(code, 1, "invalid --scope value should exit 1");
1955
+ assert!(
1956
+ stderr.contains("invalid --scope value"),
1957
+ "stderr should explain the bad value; got: {stderr}"
1958
+ );
1959
+ }
1960
+
1961
+ #[test]
1962
+ fn implicit_widen_release_reaches_terminal_tier() {
1963
+ // feat-shipped (archive) has release_binding v1.0.0. With no --scope, the
1964
+ // --release filter must widen to all tiers and surface it.
1965
+ let (out, _, _) = run(&["--release", "v1.0.0"]);
1966
+ let ids = table_ids(&out);
1967
+ assert!(ids.contains(&"feat-a"), "active v1.0.0 item; ids: {ids:?}");
1968
+ assert!(
1969
+ ids.contains(&"feat-shipped"),
1970
+ "archived v1.0.0 item must surface via implicit-widen; ids: {ids:?}"
1971
+ );
1972
+ // Explicit --scope active overrides the widen.
1973
+ let (out, _, _) = run(&["--release", "v1.0.0", "--scope", "active"]);
1974
+ let ids = table_ids(&out);
1975
+ assert!(ids.contains(&"feat-a"), "ids: {ids:?}");
1976
+ assert!(
1977
+ !ids.contains(&"feat-shipped"),
1978
+ "explicit --scope active must beat implicit-widen; ids: {ids:?}"
1979
+ );
1980
+ }
1981
+
1982
+ #[test]
1983
+ fn implicit_widen_gate_reaches_terminal_tier() {
1984
+ // feat-shipped (archive) has gate_origin tests; story-alpha-1 (active) too.
1985
+ let (out, _, _) = run(&["--gate", "tests"]);
1986
+ let ids = table_ids(&out);
1987
+ assert!(ids.contains(&"story-alpha-1"), "ids: {ids:?}");
1988
+ assert!(
1989
+ ids.contains(&"feat-shipped"),
1990
+ "archived gate item must surface via implicit-widen; ids: {ids:?}"
1991
+ );
1992
+ // Explicit --scope active overrides the gate widen.
1993
+ let (out, _, _) = run(&["--gate", "tests", "--scope", "active"]);
1994
+ let ids = table_ids(&out);
1995
+ assert!(
1996
+ !ids.contains(&"feat-shipped"),
1997
+ "explicit --scope active must beat gate widen; ids: {ids:?}"
1998
+ );
1999
+ }
2000
+
2001
+ // ── --research-origin / --research-refs filters ───────────────────────────────
2002
+ //
2003
+ // story-research-1.md has research_origin: ard-pos-x and research_refs: [ard-pos-x].
2004
+ // All other golden fixture items have neither field set.
2005
+
2006
+ #[test]
2007
+ fn research_origin_filter_selects_matching_item() {
2008
+ let (stdout, _, code) = run(&["--research-origin", "ard-pos-x", "--paths"]);
2009
+ assert_eq!(code, 0);
2010
+ assert!(
2011
+ stdout.contains("story-research-1"),
2012
+ "--research-origin ard-pos-x should select the fixture item; stdout: {stdout}"
2013
+ );
2014
+ // Ensure it does NOT return items without research_origin set
2015
+ assert!(
2016
+ !stdout.contains("epic-alpha"),
2017
+ "--research-origin ard-pos-x should not match items with no research_origin; stdout: {stdout}"
2018
+ );
2019
+ }
2020
+
2021
+ #[test]
2022
+ fn scan_origin_filter_selects_matching_item() {
2023
+ let (stdout, _, code) = run(&["--scan-origin", "scan-demo", "--paths"]);
2024
+ assert_eq!(code, 0);
2025
+ assert!(
2026
+ stdout.contains("story-research-1"),
2027
+ "--scan-origin scan-demo should select the fixture item; stdout: {stdout}"
2028
+ );
2029
+ // Ensure it does NOT return items without scan_origin set
2030
+ assert!(
2031
+ !stdout.contains("epic-alpha"),
2032
+ "--scan-origin scan-demo should not match items with no scan_origin; stdout: {stdout}"
2033
+ );
2034
+ }
2035
+
2036
+ #[test]
2037
+ fn research_refs_filter_selects_matching_item() {
2038
+ let (stdout, _, code) = run(&["--research-refs", "ard-pos-x", "--paths"]);
2039
+ assert_eq!(code, 0);
2040
+ assert!(
2041
+ stdout.contains("story-research-1"),
2042
+ "--research-refs ard-pos-x should select the fixture item; stdout: {stdout}"
2043
+ );
2044
+ // Ensure it does NOT return items without research_refs set
2045
+ assert!(
2046
+ !stdout.contains("epic-alpha"),
2047
+ "--research-refs ard-pos-x should not match items whose research_refs lacks the slug; stdout: {stdout}"
2048
+ );
2049
+ }
2050
+
2051
+ // ── Output modes ──────────────────────────────────────────────────────────────
2052
+
2053
+ #[test]
2054
+ fn paths_mode_one_absolute_path_per_line() {
2055
+ let (stdout, _, code) = run(&["--paths"]);
2056
+ assert_eq!(code, 0);
2057
+ for line in stdout.lines() {
2058
+ assert!(
2059
+ line.starts_with('/'),
2060
+ "each path should be absolute: {line}"
2061
+ );
2062
+ assert!(
2063
+ line.ends_with(".md"),
2064
+ "each path should be a .md file: {line}"
2065
+ );
2066
+ }
2067
+ }
2068
+
2069
+ #[test]
2070
+ fn count_mode_prints_integer() {
2071
+ let (stdout, _, code) = run(&["--count"]);
2072
+ assert_eq!(code, 0);
2073
+ let n: usize = stdout.trim().parse().expect("count should be an integer");
2074
+ assert!(n > 0, "count should be > 0 for fixture with items");
2075
+ }
2076
+
2077
+ #[test]
2078
+ fn count_mode_zero_for_no_match() {
2079
+ let (stdout, _, code) = run(&["--stage", "nonexistent-xyz", "--count"]);
2080
+ assert_eq!(code, 0);
2081
+ assert_eq!(stdout.trim(), "0");
2082
+ }
2083
+
2084
+ #[test]
2085
+ fn cat_mode_contains_raw_frontmatter() {
2086
+ let (stdout, _, code) = run(&["--stage", "implementing", "--cat"]);
2087
+ assert_eq!(code, 0);
2088
+ // Should contain raw frontmatter delimiters
2089
+ assert!(
2090
+ stdout.contains("---"),
2091
+ "cat output should contain frontmatter delimiters"
2092
+ );
2093
+ }
2094
+
2095
+ #[test]
2096
+ fn cat_mode_separator_between_items() {
2097
+ // For 2 implementing items (epic-alpha, feat-a), the inter-item separator
2098
+ // is exactly `\n---\n\n` emitted BETWEEN item bodies.
2099
+ //
2100
+ // A weak check (bare `---` line) is insufficient because frontmatter
2101
+ // delimiters are also bare `---` lines and would satisfy it trivially.
2102
+ //
2103
+ // Strengthened: assert the EXACT bytes between the end of item 1's body
2104
+ // and the start of item 2's frontmatter. This can only be satisfied by
2105
+ // the real inter-item separator, not by frontmatter `---` lines.
2106
+ //
2107
+ // epic-alpha.md body ends with "CLI adapter.\n" and feat-a.md starts with
2108
+ // "---\nid: feat-a\n...". The separator inserts `\n---\n\n` between them,
2109
+ // producing "CLI adapter.\n\n---\n\n---\nid: feat-a" in the full output.
2110
+ let (stdout, _, code) = run(&["--stage", "implementing", "--cat"]);
2111
+ assert_eq!(code, 0);
2112
+
2113
+ // Exact inter-item separator pattern: last line of item 1's body,
2114
+ // then blank-line/---/blank-line separator, then first line of item 2.
2115
+ let separator_pattern = "CLI adapter.\n\n---\n\n---\nid: feat-a";
2116
+ assert!(
2117
+ stdout.contains(separator_pattern),
2118
+ "cat output should contain the exact inter-item separator '\\n---\\n\\n' \
2119
+ between item 1's body and item 2's frontmatter; got: {stdout:?}"
2120
+ );
2121
+ }
2122
+
2123
+ // ── Stderr warnings, stdout clean ────────────────────────────────────────────
2124
+
2125
+ #[test]
2126
+ fn warnings_go_to_stderr_stdout_clean_with_malformed_item() {
2127
+ // The with-malformed fixture contains one malformed item (no closing `---`)
2128
+ // alongside one valid item.
2129
+ //
2130
+ // Asserts:
2131
+ // (a) A warning is emitted on STDERR about the malformed file.
2132
+ // (b) STDOUT (--count) stays clean and counts only the valid item.
2133
+ // (c) Exit code is 0 (non-fatal parse error doesn't abort the query).
2134
+ let (stdout, stderr, code) = run_malformed(&["--count"]);
2135
+ assert_eq!(
2136
+ code, 0,
2137
+ "parse error in one item should NOT cause non-zero exit; stderr: {stderr}"
2138
+ );
2139
+ // (a) Warning on stderr
2140
+ assert!(
2141
+ stderr.contains("parse error"),
2142
+ "stderr should contain a parse error warning; stderr: {stderr}"
2143
+ );
2144
+ assert!(
2145
+ stderr.contains("malformed-no-closing-fence"),
2146
+ "stderr warning should name the bad file; stderr: {stderr}"
2147
+ );
2148
+ // (b) Stdout is a clean integer (count of valid items only)
2149
+ let n: usize = stdout
2150
+ .trim()
2151
+ .parse()
2152
+ .unwrap_or_else(|_| panic!("stdout should be a clean integer, got: {stdout:?}"));
2153
+ assert_eq!(
2154
+ n, 1,
2155
+ "only the 1 valid item should be counted; found {n}; stderr: {stderr}"
2156
+ );
2157
+ }
2158
+
2159
+ #[test]
2160
+ fn warnings_go_to_stderr_paths_clean_with_malformed_item() {
2161
+ // Same fixture, --paths mode: stdout must list only valid item paths.
2162
+ // The malformed item's path must NOT appear in stdout (it was skipped).
2163
+ let (stdout, stderr, code) = run_malformed(&["--paths"]);
2164
+ assert_eq!(code, 0, "stderr: {stderr}");
2165
+ assert!(
2166
+ stderr.contains("parse error"),
2167
+ "stderr should contain warning; stderr: {stderr}"
2168
+ );
2169
+ // stdout should contain only valid item paths (no malformed)
2170
+ assert!(
2171
+ stdout.contains("good-item.md"),
2172
+ "stdout should list the valid item; stdout: {stdout}"
2173
+ );
2174
+ assert!(
2175
+ !stdout.contains("malformed-no-closing-fence"),
2176
+ "malformed item should NOT appear in stdout; stdout: {stdout}"
2177
+ );
2178
+ // Every stdout line should be a clean .md path
2179
+ for line in stdout.lines() {
2180
+ assert!(
2181
+ !line.starts_with("work-view:"),
2182
+ "warning text should NOT appear in stdout: {line}"
2183
+ );
2184
+ }
2185
+ }
2186
+
2187
+ // ── BrokenPipe ────────────────────────────────────────────────────────────────
2188
+
2189
+ #[test]
2190
+ fn broken_pipe_exits_zero() {
2191
+ // Pipe work-view to a process that closes the pipe immediately.
2192
+ // On Linux `true` closes stdin immediately.
2193
+ use std::process::Stdio;
2194
+ let mut child = Command::new(bin!())
2195
+ .current_dir(fixture_root())
2196
+ .stdout(Stdio::piped())
2197
+ .spawn()
2198
+ .expect("failed to spawn work-view");
2199
+ // Drop stdout to simulate a closed pipe consumer
2200
+ drop(child.stdout.take());
2201
+ let status = child.wait().expect("failed to wait");
2202
+ // Should exit 0 (BrokenPipe handled gracefully)
2203
+ // Note: the process may exit 0 before or after detecting the broken pipe.
2204
+ let code = status.code().unwrap_or(-1);
2205
+ assert_eq!(code, 0, "broken pipe should produce exit 0, got {code}");
2206
+ }
2207
+
2208
+ // ── Output-mode checks against expected literals from the fixture ────────────
2209
+ // These assert known expected literals; the live binary-vs-bash byte-parity
2210
+ // matrix is in the "Expanded parity matrix" section below.
2211
+
2212
+ #[test]
2213
+ fn paths_output_contains_known_fixture_paths() {
2214
+ let (stdout, _, code) = run(&["--kind", "epic", "--paths"]);
2215
+ assert_eq!(code, 0);
2216
+ // The fixture has exactly one epic
2217
+ let paths: Vec<&str> = stdout.lines().collect();
2218
+ assert_eq!(
2219
+ paths.len(),
2220
+ 1,
2221
+ "expected exactly 1 epic path, got: {paths:?}"
2222
+ );
2223
+ assert!(
2224
+ paths[0].contains("epic-alpha.md"),
2225
+ "path should contain epic-alpha.md: {}",
2226
+ paths[0]
2227
+ );
2228
+ }
2229
+
2230
+ #[test]
2231
+ fn count_implementing_items_matches_expected() {
2232
+ let (stdout, _, code) = run(&["--stage", "implementing", "--count"]);
2233
+ assert_eq!(code, 0);
2234
+ // epic-alpha (implementing) + feat-a (implementing) + story-research-1 (implementing) = 3
2235
+ let n: usize = stdout.trim().parse().unwrap();
2236
+ assert_eq!(n, 3, "expected 3 implementing items in fixture");
2237
+ }
2238
+
2239
+ #[test]
2240
+ fn table_row_format_for_known_item() {
2241
+ let (stdout, _, code) = run(&["--kind", "epic"]);
2242
+ assert_eq!(code, 0);
2243
+ let row = stdout.lines().nth(2).unwrap(); // header + sep + row
2244
+ // epic-alpha, kind=epic, stage=implementing, tags=tooling, parent=- (null)
2245
+ let expected = format!(
2246
+ "{:<40} {:<8} {:<14} {:<30} {}",
2247
+ "epic-alpha", "epic", "implementing", "tooling", "-"
2248
+ );
2249
+ assert_eq!(row, expected, "table row format mismatch");
2250
+ }
2251
+
2252
+ // ── --ready / --blocked (item 2: next-actionable) ─────────────────────────────
2253
+ //
2254
+ // Fixture recap (for --ready/--blocked tests):
2255
+ // epic-alpha Active implementing deps:[] → READY
2256
+ // feat-a Active implementing deps:[] → READY
2257
+ // feat-b Active drafting deps:[feat-a] → BLOCKED (feat-a not done)
2258
+ // story-alpha-1 Active review deps:[] → READY
2259
+ // story-research-1 Active implementing deps:[] → READY
2260
+ // idea-backlog Backlog (no stage) → excluded (tier gate)
2261
+ // release-v1.0 Releases released → excluded (tier gate)
2262
+ // feat-done Archive done → excluded (tier gate + stage)
2263
+
2264
+ #[test]
2265
+ fn ready_returns_items_with_satisfied_deps_across_stages() {
2266
+ let (stdout, _, code) = run(&["--ready", "--paths"]);
2267
+ assert_eq!(code, 0);
2268
+ let paths: Vec<&str> = stdout.lines().collect();
2269
+ // Exactly 4 ready items: epic-alpha, feat-a, story-alpha-1, story-research-1
2270
+ assert_eq!(paths.len(), 4, "expected 4 ready items, got: {paths:?}");
2271
+ assert!(
2272
+ paths.iter().any(|p| p.contains("epic-alpha")),
2273
+ "epic-alpha should be ready"
2274
+ );
2275
+ assert!(
2276
+ paths.iter().any(|p| p.contains("feat-a")),
2277
+ "feat-a should be ready"
2278
+ );
2279
+ assert!(
2280
+ paths.iter().any(|p| p.contains("story-alpha-1")),
2281
+ "story-alpha-1 (review) should be ready (stage-aware)"
2282
+ );
2283
+ assert!(
2284
+ paths.iter().any(|p| p.contains("story-research-1")),
2285
+ "story-research-1 (implementing, no deps) should be ready"
2286
+ );
2287
+ }
2288
+
2289
+ #[test]
2290
+ fn ready_excludes_items_with_unmet_deps() {
2291
+ let (stdout, _, code) = run(&["--ready", "--paths"]);
2292
+ assert_eq!(code, 0);
2293
+ assert!(
2294
+ !stdout.contains("feat-b"),
2295
+ "feat-b should NOT be ready (blocked by feat-a)"
2296
+ );
2297
+ }
2298
+
2299
+ #[test]
2300
+ fn ready_excludes_non_active_tier_items() {
2301
+ let (stdout, _, code) = run(&["--ready", "--paths"]);
2302
+ assert_eq!(code, 0);
2303
+ assert!(
2304
+ !stdout.contains("idea-backlog"),
2305
+ "backlog item should NOT be ready"
2306
+ );
2307
+ assert!(
2308
+ !stdout.contains("feat-done"),
2309
+ "archive item should NOT be ready"
2310
+ );
2311
+ assert!(
2312
+ !stdout.contains("release-v1.0"),
2313
+ "releases item should NOT be ready"
2314
+ );
2315
+ }
2316
+
2317
+ #[test]
2318
+ fn blocked_returns_items_with_unmet_deps() {
2319
+ let (stdout, _, code) = run(&["--blocked", "--paths"]);
2320
+ assert_eq!(code, 0);
2321
+ let paths: Vec<&str> = stdout.lines().collect();
2322
+ // Exactly 1 blocked item: feat-b (drafting, dep feat-a is implementing)
2323
+ assert_eq!(paths.len(), 1, "expected 1 blocked item, got: {paths:?}");
2324
+ assert!(
2325
+ paths[0].contains("feat-b"),
2326
+ "feat-b should be blocked: {paths:?}"
2327
+ );
2328
+ }
2329
+
2330
+ #[test]
2331
+ fn blocked_excludes_non_active_tier_items() {
2332
+ let (stdout, _, code) = run(&["--blocked", "--paths"]);
2333
+ assert_eq!(code, 0);
2334
+ assert!(
2335
+ !stdout.contains("idea-backlog"),
2336
+ "backlog item should NOT be blocked"
2337
+ );
2338
+ assert!(
2339
+ !stdout.contains("feat-done"),
2340
+ "archive item should NOT be blocked"
2341
+ );
2342
+ }
2343
+
2344
+ #[test]
2345
+ fn ready_stage_implementing_reproduces_old_narrow_set() {
2346
+ // --ready --stage implementing should reproduce the OLD behavior:
2347
+ // only implementing items with satisfied deps.
2348
+ // In fixture: epic-alpha (implementing, ready) + feat-a (implementing, ready)
2349
+ // + story-research-1 (implementing, ready) = 3 items.
2350
+ // story-alpha-1 (review) is excluded by the --stage filter.
2351
+ let (stdout, _, code) = run(&["--ready", "--stage", "implementing", "--paths"]);
2352
+ assert_eq!(code, 0);
2353
+ let paths: Vec<&str> = stdout.lines().collect();
2354
+ assert_eq!(
2355
+ paths.len(),
2356
+ 3,
2357
+ "expected 3 items for --ready --stage implementing, got: {paths:?}"
2358
+ );
2359
+ assert!(
2360
+ paths.iter().any(|p| p.contains("epic-alpha")),
2361
+ "epic-alpha should be in implementing-only set"
2362
+ );
2363
+ assert!(
2364
+ paths.iter().any(|p| p.contains("feat-a")),
2365
+ "feat-a should be in implementing-only set"
2366
+ );
2367
+ assert!(
2368
+ paths.iter().any(|p| p.contains("story-research-1")),
2369
+ "story-research-1 should be in implementing-only set"
2370
+ );
2371
+ assert!(
2372
+ !paths.iter().any(|p| p.contains("story-alpha-1")),
2373
+ "story-alpha-1 should NOT be in implementing-only set"
2374
+ );
2375
+ }
2376
+
2377
+ #[test]
2378
+ fn ready_stage_drafting_returns_only_drafting_ready_items() {
2379
+ // --ready --stage drafting: only feat-b would qualify if its deps were met.
2380
+ // feat-b depends on feat-a (implementing) → not ready.
2381
+ // So result should be empty.
2382
+ let (stdout, _, code) = run(&["--ready", "--stage", "drafting", "--count"]);
2383
+ assert_eq!(code, 0);
2384
+ assert_eq!(
2385
+ stdout.trim(),
2386
+ "0",
2387
+ "--ready --stage drafting should return 0 (feat-b is blocked)"
2388
+ );
2389
+ }
2390
+
2391
+ #[test]
2392
+ fn ready_and_blocked_counts_are_consistent() {
2393
+ let (ready_out, _, _) = run(&["--ready", "--count"]);
2394
+ let (blocked_out, _, _) = run(&["--blocked", "--count"]);
2395
+ let ready_n: usize = ready_out.trim().parse().unwrap();
2396
+ let blocked_n: usize = blocked_out.trim().parse().unwrap();
2397
+ // All active movable items = ready + blocked
2398
+ // Active movable: epic-alpha, feat-a, feat-b, story-alpha-1, story-research-1 = 5
2399
+ assert_eq!(
2400
+ ready_n + blocked_n,
2401
+ 5,
2402
+ "ready + blocked should account for all active movable items"
2403
+ );
2404
+ }
2405
+
2406
+ // ── Headline fix at the binary level: drafting + satisfied deps ───────────────
2407
+ //
2408
+ // The golden fixture's only drafting item (feat-b) has a non-terminal dep, so it
2409
+ // can only prove the *blocked* path. These tests run against the dedicated
2410
+ // `ready-drafting` fixture, where `feat-design-ready` is drafting with a single
2411
+ // terminal (done) dep — the central positive case for the epic's reason-for-being.
2412
+ //
2413
+ // ready-drafting fixture recap:
2414
+ // feat-dep-done Active done deps:[] → excluded (terminal)
2415
+ // feat-design-ready Active drafting deps:[feat-dep-done] → READY (headline fix)
2416
+ // feat-impl-ready Active implementing deps:[] → READY
2417
+ // story-review-ready Active review deps:[] → READY
2418
+
2419
+ #[test]
2420
+ fn ready_surfaces_drafting_item_with_satisfied_deps() {
2421
+ // Headline fix: a drafting active item whose deps are ALL terminal MUST
2422
+ // surface in --ready, proven through the compiled binary (not just in-memory).
2423
+ let (stdout, _, code) = run_ready_drafting(&["--ready", "--paths"]);
2424
+ assert_eq!(code, 0);
2425
+ assert!(
2426
+ stdout.contains("feat-design-ready"),
2427
+ "a drafting item with satisfied deps MUST surface in --ready (headline fix); stdout: {stdout}"
2428
+ );
2429
+ // The terminal dep itself (done) must NOT surface.
2430
+ assert!(
2431
+ !stdout.contains("feat-dep-done"),
2432
+ "the done dependency should NOT be ready (terminal stage); stdout: {stdout}"
2433
+ );
2434
+ }
2435
+
2436
+ #[test]
2437
+ fn ready_stage_drafting_returns_design_ready_items() {
2438
+ // --ready --stage drafting = design-ready only: contains the drafting item,
2439
+ // excludes the implementing-ready and review-ready items (the --stage filter
2440
+ // composes by AND with the dependency view).
2441
+ let (stdout, _, code) = run_ready_drafting(&["--ready", "--stage", "drafting", "--paths"]);
2442
+ assert_eq!(code, 0);
2443
+ assert!(
2444
+ stdout.contains("feat-design-ready"),
2445
+ "--ready --stage drafting MUST contain the design-ready item; stdout: {stdout}"
2446
+ );
2447
+ assert!(
2448
+ !stdout.contains("feat-impl-ready"),
2449
+ "--ready --stage drafting must EXCLUDE the implementing-ready item; stdout: {stdout}"
2450
+ );
2451
+ assert!(
2452
+ !stdout.contains("story-review-ready"),
2453
+ "--ready --stage drafting must EXCLUDE the review-ready item; stdout: {stdout}"
2454
+ );
2455
+ // Exactly one design-ready item in this fixture.
2456
+ let paths: Vec<&str> = stdout.lines().collect();
2457
+ assert_eq!(
2458
+ paths.len(),
2459
+ 1,
2460
+ "expected exactly 1 design-ready item, got: {paths:?}"
2461
+ );
2462
+ }
2463
+
2464
+ // ── --help text content ───────────────────────────────────────────────────────
2465
+ //
2466
+ // Assert that the Rust --help matches the bash --help wording for the stage-
2467
+ // aware --ready / --blocked flags (finding 6). Full byte-parity of the entire
2468
+ // help block is NOT required — only the flag description lines must match.
2469
+
2470
+ #[test]
2471
+ fn help_ready_flag_description_matches_bash_wording() {
2472
+ let (stdout, _, code) = run(&["--help"]);
2473
+ assert_eq!(code, 0);
2474
+ // Bash help line (verbatim):
2475
+ // --ready Active items at drafting/implementing/review with all depends_on done
2476
+ assert!(
2477
+ stdout.contains("Active items at drafting/implementing/review with all depends_on done"),
2478
+ "--help should contain bash-matching --ready description; help text:\n{stdout}"
2479
+ );
2480
+ }
2481
+
2482
+ #[test]
2483
+ fn help_blocked_flag_description_matches_bash_wording() {
2484
+ let (stdout, _, code) = run(&["--help"]);
2485
+ assert_eq!(code, 0);
2486
+ // Bash help line (verbatim):
2487
+ // --blocked Active items at drafting/implementing/review with unmet dependencies
2488
+ assert!(
2489
+ stdout.contains("Active items at drafting/implementing/review with unmet dependencies"),
2490
+ "--help should contain bash-matching --blocked description; help text:\n{stdout}"
2491
+ );
2492
+ }
2493
+
2494
+ // ── --version flag ────────────────────────────────────────────────────────────
2495
+ //
2496
+ // The version stamp lives in `crates/cli/.work-view-version` (written with no
2497
+ // trailing newline by bump-version.sh). The Rust binary reports
2498
+ // `work-view <semver>\n` via --version, and bump-version.sh keeps the stamp in
2499
+ // lockstep with plugin.json. These tests pin the output shape and the
2500
+ // file<->plugin.json equality. --version is substrate-independent
2501
+ // (short-circuits before substrate detection), so the fixture cwd is irrelevant
2502
+ // here.
2503
+ //
2504
+ // NOTE: byte-parity with the bash fallback (`scripts/work-view.sh`) is no longer
2505
+ // enforced — the Rust binary is the canonical work-view and the bash script is a
2506
+ // frozen degraded fallback (no `--scope`, no board). See
2507
+ // feature-work-view-scope and the parked bash-retirement epic.
2508
+
2509
+ /// Contents of the committed version stamp, read at compile time.
2510
+ /// Written with NO trailing newline, so this is the bare semver.
2511
+ const VERSION_STAMP: &str =
2512
+ include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/.work-view-version"));
2513
+
2514
+ #[test]
2515
+ fn version_long_prints_stamp_and_exits_zero() {
2516
+ let (stdout, _, code) = run(&["--version"]);
2517
+ assert_eq!(code, 0, "--version must exit 0");
2518
+ assert_eq!(
2519
+ stdout,
2520
+ format!("work-view {VERSION_STAMP}\n"),
2521
+ "--version stdout must be `work-view <semver>\\n` with a single trailing newline"
2522
+ );
2523
+ }
2524
+
2525
+ #[test]
2526
+ fn version_short_matches_long() {
2527
+ let (long_stdout, _, long_code) = run(&["--version"]);
2528
+ let (short_stdout, _, short_code) = run(&["-V"]);
2529
+ assert_eq!(short_code, 0, "-V must exit 0");
2530
+ assert_eq!(
2531
+ short_code, long_code,
2532
+ "-V and --version exit codes must match"
2533
+ );
2534
+ assert_eq!(
2535
+ short_stdout, long_stdout,
2536
+ "-V and --version stdout must match"
2537
+ );
2538
+ }
2539
+
2540
+ #[test]
2541
+ fn version_stamp_has_no_trailing_newline() {
2542
+ // Pins the no-newline write contract: a stray newline would print a blank
2543
+ // line after the semver and break byte-parity with the bash fallback.
2544
+ assert!(
2545
+ !VERSION_STAMP.ends_with('\n'),
2546
+ ".work-view-version must not end with a newline; got {VERSION_STAMP:?}"
2547
+ );
2548
+ }
2549
+
2550
+ #[test]
2551
+ fn version_stamp_equals_plugin_json_version() {
2552
+ // The version stamp is projected from plugin.json by bump-version.sh. If a
2553
+ // hand-edit or a bump-script regression drifts them, fail loudly here.
2554
+ // The repo keeps the Claude AND Codex manifests in lockstep, so check BOTH —
2555
+ // a Codex-only drift must not slip past this test.
2556
+ // crates/cli -> ../../../{.claude-plugin,.codex-plugin}/plugin.json
2557
+ const CLAUDE_JSON: &str = include_str!(concat!(
2558
+ env!("CARGO_MANIFEST_DIR"),
2559
+ "/../../../.claude-plugin/plugin.json"
2560
+ ));
2561
+ const CODEX_JSON: &str = include_str!(concat!(
2562
+ env!("CARGO_MANIFEST_DIR"),
2563
+ "/../../../.codex-plugin/plugin.json"
2564
+ ));
2565
+
2566
+ // Minimal extraction (no serde dependency in this crate): find the
2567
+ // "version": "x.y.z" entry and pull the quoted value.
2568
+ fn extract_version(json: &str) -> &str {
2569
+ let marker = "\"version\":";
2570
+ let after = json
2571
+ .find(marker)
2572
+ .map(|i| &json[i + marker.len()..])
2573
+ .expect("plugin.json must contain a \"version\" key");
2574
+ let open = after.find('"').expect("version value must be quoted") + 1;
2575
+ let rest = &after[open..];
2576
+ let close = rest.find('"').expect("version value must be closed");
2577
+ &rest[..close]
2578
+ }
2579
+
2580
+ let claude_version = extract_version(CLAUDE_JSON);
2581
+ let codex_version = extract_version(CODEX_JSON);
2582
+ assert_eq!(
2583
+ VERSION_STAMP, claude_version,
2584
+ ".work-view-version ({VERSION_STAMP:?}) must equal .claude-plugin/plugin.json version \
2585
+ ({claude_version:?}); run scripts/bump-version.sh to re-project the stamp in lockstep"
2586
+ );
2587
+ assert_eq!(
2588
+ VERSION_STAMP, codex_version,
2589
+ ".work-view-version ({VERSION_STAMP:?}) must equal .codex-plugin/plugin.json version \
2590
+ ({codex_version:?}); the Claude and Codex manifests must stay in lockstep"
2591
+ );
2592
+ }
2593
+
2594
+ // ── --stale flag ──────────────────────────────────────────────────────────────
2595
+ //
2596
+ // Integration tests for `--stale`. Each test builds a minimal substrate in a
2597
+ // TempDir, writes .work/CONVENTIONS.md with (or without) `backlog_staleness_days`,
2598
+ // then drives the binary via subprocess.
2599
+ //
2600
+ // Dates used in fixtures are chosen so they are always in the past relative to
2601
+ // any realistic test-run date (>= 2026). "Old" = 2020-01-01 (clearly stale for
2602
+ // any threshold <= a few years). "Fresh" = 2099-12-31 (clearly in the future,
2603
+ // i.e. never stale, immune to clock drift).
2604
+
2605
+ /// Write files into a TempDir-based substrate.
2606
+ fn setup_stale_substrate(items: &[(&str, &str)], conventions: &str) -> tempfile::TempDir {
2607
+ use std::fs;
2608
+ let tmp = tempfile::TempDir::new().expect("failed to create tempdir");
2609
+ let root = tmp.path();
2610
+ fs::create_dir_all(root.join(".work")).expect("failed to create .work");
2611
+ fs::write(root.join(".work/CONVENTIONS.md"), conventions).expect("failed to write CONVENTIONS.md");
2612
+ for (rel_path, content) in items {
2613
+ let path = root.join(".work").join(rel_path);
2614
+ if let Some(p) = path.parent() {
2615
+ fs::create_dir_all(p).expect("failed to create parent dirs");
2616
+ }
2617
+ fs::write(&path, content).expect("failed to write item");
2618
+ }
2619
+ tmp
2620
+ }
2621
+
2622
+ /// Run `work-view` with `args` in the given directory.
2623
+ fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {
2624
+ let out = Command::new(bin!())
2625
+ .args(args)
2626
+ .current_dir(dir)
2627
+ .output()
2628
+ .expect("failed to run work-view");
2629
+ (
2630
+ String::from_utf8_lossy(&out.stdout).into_owned(),
2631
+ String::from_utf8_lossy(&out.stderr).into_owned(),
2632
+ out.status.code().unwrap_or(-1),
2633
+ )
2634
+ }
2635
+
2636
+ const OLD_DATE: &str = "2020-01-01"; // always older than any reasonable threshold
2637
+ const FRESH_DATE: &str = "2099-12-31"; // far in the future, never stale
2638
+
2639
+ fn backlog_item(id: &str, created: &str, updated: Option<&str>) -> String {
2640
+ let updated_line = match updated {
2641
+ Some(u) => format!("updated: {u}\n"),
2642
+ None => String::new(),
2643
+ };
2644
+ format!(
2645
+ "---\nid: {id}\ncreated: {created}\n{updated_line}tags: []\n---\nIdea: {id}\n"
2646
+ )
2647
+ }
2648
+
2649
+ fn active_item(id: &str, created: &str) -> String {
2650
+ format!(
2651
+ "---\nid: {id}\nkind: feature\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: {created}\nupdated: {created}\n---\n\n# {id}\n"
2652
+ )
2653
+ }
2654
+
2655
+ // ── Test 1: stale with configured threshold lists old backlog items ───────────
2656
+
2657
+ #[test]
2658
+ fn stale_lists_backlog_items_older_than_threshold() {
2659
+ let conventions = "# Conventions\nbacklog_staleness_days: 90\n";
2660
+ let old_item = backlog_item("old-idea", OLD_DATE, None);
2661
+ let fresh_item = backlog_item("fresh-idea", FRESH_DATE, None);
2662
+
2663
+ let tmp = setup_stale_substrate(
2664
+ &[
2665
+ ("backlog/old-idea.md", &old_item),
2666
+ ("backlog/fresh-idea.md", &fresh_item),
2667
+ ],
2668
+ conventions,
2669
+ );
2670
+
2671
+ let (stdout, stderr, code) = run_in(tmp.path(), &["--stale"]);
2672
+ assert_eq!(code, 0, "exit 0 on success; stderr: {stderr}");
2673
+ assert!(
2674
+ stdout.contains("old-idea"),
2675
+ "--stale should list old-idea; stdout: {stdout}"
2676
+ );
2677
+ assert!(
2678
+ !stdout.contains("fresh-idea"),
2679
+ "--stale should NOT list fresh-idea; stdout: {stdout}"
2680
+ );
2681
+ }
2682
+
2683
+ // ── Test 2: absent key → inert (notice + exit 0, empty result) ───────────────
2684
+
2685
+ #[test]
2686
+ fn stale_absent_key_is_inert_with_notice() {
2687
+ // CONVENTIONS.md has no backlog_staleness_days key.
2688
+ let conventions = "# Conventions\nsome_other_key: 30\n";
2689
+ let old_item = backlog_item("old-idea", OLD_DATE, None);
2690
+
2691
+ let tmp = setup_stale_substrate(
2692
+ &[("backlog/old-idea.md", &old_item)],
2693
+ conventions,
2694
+ );
2695
+
2696
+ let (stdout, stderr, code) = run_in(tmp.path(), &["--stale"]);
2697
+ assert_eq!(
2698
+ code, 0,
2699
+ "absent key must exit 0 (inert, not an error); stderr: {stderr}"
2700
+ );
2701
+ // Must print the notice to stdout.
2702
+ assert!(
2703
+ stdout.contains("no backlog_staleness_days configured"),
2704
+ "--stale with no key must print the inert notice; stdout: {stdout}"
2705
+ );
2706
+ // Must produce no item rows (inert).
2707
+ assert!(
2708
+ !stdout.contains("old-idea"),
2709
+ "--stale with no key must not list items; stdout: {stdout}"
2710
+ );
2711
+ assert_eq!(stderr, "", "stderr must be empty when inert; stderr: {stderr}");
2712
+ }
2713
+
2714
+ // ── Test 2b: inert-path notice is BrokenPipe-safe (regression) ───────────────
2715
+
2716
+ #[test]
2717
+ fn stale_inert_notice_survives_broken_pipe() {
2718
+ // The inert branch (no backlog_staleness_days) prints a notice. It must use
2719
+ // the BrokenPipe-safe write path, not a bare println! that panics when the
2720
+ // consumer closes the pipe early (e.g. `work-view --stale | head -n 0`).
2721
+ use std::process::Stdio;
2722
+ let conventions = "# Conventions\nsome_other_key: 30\n";
2723
+ let old_item = backlog_item("old-idea", OLD_DATE, None);
2724
+ let tmp = setup_stale_substrate(&[("backlog/old-idea.md", &old_item)], conventions);
2725
+
2726
+ let mut child = Command::new(bin!())
2727
+ .current_dir(tmp.path())
2728
+ .arg("--stale")
2729
+ .stdout(Stdio::piped())
2730
+ .spawn()
2731
+ .expect("failed to spawn work-view");
2732
+ // Close the read end immediately to simulate a broken pipe consumer.
2733
+ drop(child.stdout.take());
2734
+ let status = child.wait().expect("failed to wait");
2735
+ let code = status.code().unwrap_or(-1);
2736
+ assert_eq!(
2737
+ code, 0,
2738
+ "inert --stale notice on a broken pipe must exit 0 (not panic), got {code}"
2739
+ );
2740
+ }
2741
+
2742
+ // ── Test 3: non-backlog tiers excluded ───────────────────────────────────────
2743
+
2744
+ #[test]
2745
+ fn stale_excludes_non_backlog_tiers() {
2746
+ let conventions = "# Conventions\nbacklog_staleness_days: 90\n";
2747
+ let backlog_old = backlog_item("backlog-old", OLD_DATE, None);
2748
+ let active_old = active_item("active-old", OLD_DATE);
2749
+
2750
+ let tmp = setup_stale_substrate(
2751
+ &[
2752
+ ("backlog/backlog-old.md", &backlog_old),
2753
+ ("active/features/active-old.md", &active_old),
2754
+ ],
2755
+ conventions,
2756
+ );
2757
+
2758
+ let (stdout, _stderr, code) = run_in(tmp.path(), &["--stale"]);
2759
+ assert_eq!(code, 0);
2760
+ assert!(
2761
+ stdout.contains("backlog-old"),
2762
+ "--stale should include old backlog item; stdout: {stdout}"
2763
+ );
2764
+ assert!(
2765
+ !stdout.contains("active-old"),
2766
+ "--stale must exclude active items; stdout: {stdout}"
2767
+ );
2768
+ }
2769
+
2770
+ // ── Test 4: updated takes precedence over created ─────────────────────────────
2771
+
2772
+ #[test]
2773
+ fn stale_updated_date_takes_precedence_over_created() {
2774
+ let conventions = "# Conventions\nbacklog_staleness_days: 90\n";
2775
+
2776
+ // created is old but updated is fresh → not stale (updated wins)
2777
+ let updated_recently = backlog_item("not-stale", OLD_DATE, Some(FRESH_DATE));
2778
+ // created is fresh but updated is old → stale (updated wins)
2779
+ let updated_old = backlog_item("is-stale", FRESH_DATE, Some(OLD_DATE));
2780
+
2781
+ let tmp = setup_stale_substrate(
2782
+ &[
2783
+ ("backlog/not-stale.md", &updated_recently),
2784
+ ("backlog/is-stale.md", &updated_old),
2785
+ ],
2786
+ conventions,
2787
+ );
2788
+
2789
+ let (stdout, _stderr, code) = run_in(tmp.path(), &["--stale"]);
2790
+ assert_eq!(code, 0);
2791
+ assert!(
2792
+ stdout.contains("is-stale"),
2793
+ "--stale should include item where updated is old; stdout: {stdout}"
2794
+ );
2795
+ assert!(
2796
+ !stdout.contains("not-stale"),
2797
+ "--stale should exclude item where updated is fresh; stdout: {stdout}"
2798
+ );
2799
+ }
2800
+
2801
+ // ── Test 5: dateless item surfaces as stale ───────────────────────────────────
2802
+
2803
+ #[test]
2804
+ fn stale_dateless_item_surfaces_as_stale() {
2805
+ let conventions = "# Conventions\nbacklog_staleness_days: 90\n";
2806
+
2807
+ // Item with neither created nor updated.
2808
+ let dateless = "---\nid: dateless-idea\ntags: []\n---\nIdea with no dates.\n";
2809
+
2810
+ let tmp = setup_stale_substrate(
2811
+ &[("backlog/dateless-idea.md", dateless)],
2812
+ conventions,
2813
+ );
2814
+
2815
+ let (stdout, _stderr, code) = run_in(tmp.path(), &["--stale"]);
2816
+ assert_eq!(code, 0);
2817
+ assert!(
2818
+ stdout.contains("dateless-idea"),
2819
+ "--stale should surface a dateless backlog item; stdout: {stdout}"
2820
+ );
2821
+ }
2822
+
2823
+ // ── Test 6: --stale appears in help text ─────────────────────────────────────
2824
+
2825
+ #[test]
2826
+ fn stale_flag_appears_in_help() {
2827
+ let (stdout, _, code) = run(&["--help"]);
2828
+ assert_eq!(code, 0);
2829
+ assert!(
2830
+ stdout.contains("--stale"),
2831
+ "--stale should appear in help text; stdout: {stdout}"
2832
+ );
2833
+ }