@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,804 @@
1
+ //! Composable structural filters over the substrate item list.
2
+ //!
3
+ //! `query()` ANDs all set filter fields and returns matching items in load order.
4
+ //!
5
+ //! `Match` variants:
6
+ //! - `Any` — no filter (matches everything); this is the `Default`.
7
+ //! - `Equals(s)` — field must equal `s` exactly (case-sensitive).
8
+ //! - `IsNull` — field must be `None` (missing/null in frontmatter).
9
+ //!
10
+ //! The CLI adapter uses `Any` and `Equals` (and `IsNull` via the `null` literal
11
+ //! on `--parent`/`--release`/`--gate`). The board can use `IsNull` to filter
12
+ //! unbound items (e.g. `release_binding == null`).
13
+ //!
14
+ //! `Filter.tags` is AND semantics (item must have ALL listed tags).
15
+ //! `Filter.blocking` selects items whose `depends_on` contains the given id.
16
+
17
+ use crate::{index::Substrate, model::Item};
18
+
19
+ /// Three-state field matcher.
20
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
21
+ pub enum Match {
22
+ /// No constraint — matches any value including None.
23
+ #[default]
24
+ Any,
25
+ /// Exact string equality (case-sensitive).
26
+ Equals(String),
27
+ /// Field must be absent / null (None after normalization).
28
+ IsNull,
29
+ }
30
+
31
+ impl Match {
32
+ /// Test this matcher against an optional string field.
33
+ fn matches_opt(&self, value: &Option<String>) -> bool {
34
+ match self {
35
+ Match::Any => true,
36
+ Match::Equals(s) => value.as_deref() == Some(s.as_str()),
37
+ Match::IsNull => value.is_none(),
38
+ }
39
+ }
40
+ }
41
+
42
+ /// Composable filter. All fields default to `Any` (no filter).
43
+ ///
44
+ /// Build with struct-update syntax or field assignment:
45
+ /// ```rust
46
+ /// use work_view_core::filter::{Filter, Match};
47
+ /// let f = Filter {
48
+ /// stage: Match::Equals("implementing".into()),
49
+ /// ..Filter::default()
50
+ /// };
51
+ /// ```
52
+ #[derive(Debug, Default, Clone)]
53
+ pub struct Filter {
54
+ /// Filter on `item.kind`.
55
+ pub kind: Match,
56
+ /// Filter on `item.stage`.
57
+ pub stage: Match,
58
+ /// Filter on `item.parent` (direct children filter).
59
+ pub parent: Match,
60
+ /// Filter on `item.release_binding`.
61
+ pub release: Match,
62
+ /// Filter on `item.gate_origin`.
63
+ pub gate: Match,
64
+ /// Filter on `item.research_origin` (mirror of `gate`).
65
+ pub research: Match,
66
+ /// Filter on `item.scan_origin` (mirror of `research`).
67
+ pub scan: Match,
68
+ /// Membership: select items whose `research_refs` contains this id (mirror of `blocking`).
69
+ pub research_refs: Option<String>,
70
+ /// AND-semantics tag filter. Item must have ALL listed tags.
71
+ pub tags: Vec<String>,
72
+ /// Reverse-dep filter: select items whose `depends_on` contains this id.
73
+ pub blocking: Option<String>,
74
+ }
75
+
76
+ impl Substrate {
77
+ /// Return all items matching `f`, in stable load order.
78
+ ///
79
+ /// All set filter fields are ANDed together.
80
+ pub fn query<'a>(&'a self, f: &Filter) -> Vec<&'a Item> {
81
+ self.items()
82
+ .iter()
83
+ .filter(|item| item_matches(item, f))
84
+ .collect()
85
+ }
86
+ }
87
+
88
+ /// Returns `true` if `item` satisfies all constraints in `f`.
89
+ fn item_matches(item: &Item, f: &Filter) -> bool {
90
+ // Scalar field matchers
91
+ if !f.kind.matches_opt(&item.kind) {
92
+ return false;
93
+ }
94
+ if !f.stage.matches_opt(&item.stage) {
95
+ return false;
96
+ }
97
+ if !f.parent.matches_opt(&item.parent) {
98
+ return false;
99
+ }
100
+ if !f.release.matches_opt(&item.release_binding) {
101
+ return false;
102
+ }
103
+ if !f.gate.matches_opt(&item.gate_origin) {
104
+ return false;
105
+ }
106
+ if !f.research.matches_opt(&item.research_origin) {
107
+ return false;
108
+ }
109
+ if !f.scan.matches_opt(&item.scan_origin) {
110
+ return false;
111
+ }
112
+ if let Some(ref_id) = &f.research_refs {
113
+ if !item.research_refs.iter().any(|r| r == ref_id) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // Tags: AND semantics — item must have all requested tags
119
+ for tag in &f.tags {
120
+ if !item.tags.iter().any(|t| t == tag) {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // Blocking: item must depend on the given id
126
+ if let Some(blocking_id) = &f.blocking {
127
+ if !item.depends_on.iter().any(|d| d == blocking_id) {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ true
133
+ }
134
+
135
+ #[cfg(test)]
136
+ mod tests {
137
+ use super::*;
138
+ use crate::index::Substrate;
139
+ use std::fs;
140
+ use tempfile::TempDir;
141
+
142
+ fn setup_substrate(items: &[(&str, &str)]) -> (TempDir, Substrate) {
143
+ let tmp = TempDir::new().unwrap();
144
+ let root = tmp.path().to_path_buf();
145
+ fs::create_dir_all(root.join(".work")).unwrap();
146
+ fs::write(root.join(".work/CONVENTIONS.md"), "# Conventions\n").unwrap();
147
+
148
+ for (filename, content) in items {
149
+ let path = root.join(".work").join(filename);
150
+ if let Some(p) = path.parent() {
151
+ fs::create_dir_all(p).unwrap();
152
+ }
153
+ fs::write(&path, content).unwrap();
154
+ }
155
+
156
+ let (sub, _) = Substrate::load(&root).unwrap();
157
+ (tmp, sub)
158
+ }
159
+
160
+ #[allow(clippy::too_many_arguments)]
161
+ fn full_item(
162
+ id: &str,
163
+ kind: &str,
164
+ stage: &str,
165
+ tags: &[&str],
166
+ parent: Option<&str>,
167
+ release: Option<&str>,
168
+ gate: Option<&str>,
169
+ depends_on: &[&str],
170
+ ) -> String {
171
+ let tags_yaml = format!(
172
+ "[{}]",
173
+ tags.iter()
174
+ .map(|t| format!("\"{t}\""))
175
+ .collect::<Vec<_>>()
176
+ .join(", ")
177
+ );
178
+ let deps_yaml = format!(
179
+ "[{}]",
180
+ depends_on
181
+ .iter()
182
+ .map(|d| format!("\"{d}\""))
183
+ .collect::<Vec<_>>()
184
+ .join(", ")
185
+ );
186
+ let parent_yaml = parent.unwrap_or("null");
187
+ let release_yaml = release.unwrap_or("null");
188
+ let gate_yaml = gate.unwrap_or("null");
189
+ format!(
190
+ "---\nid: {id}\nkind: {kind}\nstage: {stage}\ntags: {tags_yaml}\nparent: {parent_yaml}\ndepends_on: {deps_yaml}\nrelease_binding: {release_yaml}\ngate_origin: {gate_yaml}\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# {id}\n"
191
+ )
192
+ }
193
+
194
+ #[test]
195
+ fn default_filter_matches_all() {
196
+ let (_tmp, sub) = setup_substrate(&[
197
+ (
198
+ "active/stories/a.md",
199
+ &full_item("a", "story", "implementing", &[], None, None, None, &[]),
200
+ ),
201
+ (
202
+ "active/features/b.md",
203
+ &full_item("b", "feature", "review", &[], None, None, None, &[]),
204
+ ),
205
+ ]);
206
+ let results = sub.query(&Filter::default());
207
+ assert_eq!(results.len(), 2);
208
+ }
209
+
210
+ #[test]
211
+ fn filter_by_stage_equals() {
212
+ let (_tmp, sub) = setup_substrate(&[
213
+ (
214
+ "active/stories/a.md",
215
+ &full_item("a", "story", "implementing", &[], None, None, None, &[]),
216
+ ),
217
+ (
218
+ "active/features/b.md",
219
+ &full_item("b", "feature", "review", &[], None, None, None, &[]),
220
+ ),
221
+ (
222
+ "active/features/c.md",
223
+ &full_item("c", "feature", "implementing", &[], None, None, None, &[]),
224
+ ),
225
+ ]);
226
+ let f = Filter {
227
+ stage: Match::Equals("implementing".into()),
228
+ ..Filter::default()
229
+ };
230
+ let results = sub.query(&f);
231
+ let ids: Vec<&str> = results.iter().map(|i| i.id.as_str()).collect();
232
+ assert!(ids.contains(&"a"));
233
+ assert!(ids.contains(&"c"));
234
+ assert!(!ids.contains(&"b"));
235
+ }
236
+
237
+ #[test]
238
+ fn filter_by_kind_equals() {
239
+ let (_tmp, sub) = setup_substrate(&[
240
+ (
241
+ "active/epics/ep.md",
242
+ &full_item("ep", "epic", "implementing", &[], None, None, None, &[]),
243
+ ),
244
+ (
245
+ "active/features/feat.md",
246
+ &full_item(
247
+ "feat",
248
+ "feature",
249
+ "implementing",
250
+ &[],
251
+ None,
252
+ None,
253
+ None,
254
+ &[],
255
+ ),
256
+ ),
257
+ ]);
258
+ let f = Filter {
259
+ kind: Match::Equals("feature".into()),
260
+ ..Filter::default()
261
+ };
262
+ let results = sub.query(&f);
263
+ assert_eq!(results.len(), 1);
264
+ assert_eq!(results[0].id, "feat");
265
+ }
266
+
267
+ #[test]
268
+ fn filter_by_parent_equals() {
269
+ let (_tmp, sub) = setup_substrate(&[
270
+ (
271
+ "active/epics/epic.md",
272
+ &full_item("epic", "epic", "implementing", &[], None, None, None, &[]),
273
+ ),
274
+ (
275
+ "active/features/child.md",
276
+ &full_item(
277
+ "child",
278
+ "feature",
279
+ "implementing",
280
+ &[],
281
+ Some("epic"),
282
+ None,
283
+ None,
284
+ &[],
285
+ ),
286
+ ),
287
+ (
288
+ "active/features/other.md",
289
+ &full_item(
290
+ "other",
291
+ "feature",
292
+ "implementing",
293
+ &[],
294
+ Some("other-epic"),
295
+ None,
296
+ None,
297
+ &[],
298
+ ),
299
+ ),
300
+ ]);
301
+ let f = Filter {
302
+ parent: Match::Equals("epic".into()),
303
+ ..Filter::default()
304
+ };
305
+ let results = sub.query(&f);
306
+ assert_eq!(results.len(), 1);
307
+ assert_eq!(results[0].id, "child");
308
+ }
309
+
310
+ #[test]
311
+ fn filter_is_null_matches_none_parent() {
312
+ let (_tmp, sub) = setup_substrate(&[
313
+ (
314
+ "active/epics/top.md",
315
+ &full_item("top", "epic", "implementing", &[], None, None, None, &[]),
316
+ ),
317
+ (
318
+ "active/features/child.md",
319
+ &full_item(
320
+ "child",
321
+ "feature",
322
+ "implementing",
323
+ &[],
324
+ Some("top"),
325
+ None,
326
+ None,
327
+ &[],
328
+ ),
329
+ ),
330
+ ]);
331
+ let f = Filter {
332
+ parent: Match::IsNull,
333
+ ..Filter::default()
334
+ };
335
+ let results = sub.query(&f);
336
+ assert_eq!(results.len(), 1);
337
+ assert_eq!(results[0].id, "top");
338
+ }
339
+
340
+ #[test]
341
+ fn filter_is_null_matches_none_release_binding() {
342
+ let (_tmp, sub) = setup_substrate(&[
343
+ (
344
+ "active/features/bound.md",
345
+ &full_item(
346
+ "bound",
347
+ "feature",
348
+ "implementing",
349
+ &[],
350
+ None,
351
+ Some("v1.0.0"),
352
+ None,
353
+ &[],
354
+ ),
355
+ ),
356
+ (
357
+ "active/features/unbound.md",
358
+ &full_item(
359
+ "unbound",
360
+ "feature",
361
+ "implementing",
362
+ &[],
363
+ None,
364
+ None,
365
+ None,
366
+ &[],
367
+ ),
368
+ ),
369
+ ]);
370
+ let f = Filter {
371
+ release: Match::IsNull,
372
+ ..Filter::default()
373
+ };
374
+ let results = sub.query(&f);
375
+ assert_eq!(results.len(), 1);
376
+ assert_eq!(results[0].id, "unbound");
377
+ }
378
+
379
+ #[test]
380
+ fn filter_single_tag() {
381
+ let (_tmp, sub) = setup_substrate(&[
382
+ (
383
+ "active/features/a.md",
384
+ &full_item(
385
+ "a",
386
+ "feature",
387
+ "implementing",
388
+ &["tooling", "perf"],
389
+ None,
390
+ None,
391
+ None,
392
+ &[],
393
+ ),
394
+ ),
395
+ (
396
+ "active/features/b.md",
397
+ &full_item(
398
+ "b",
399
+ "feature",
400
+ "implementing",
401
+ &["tooling"],
402
+ None,
403
+ None,
404
+ None,
405
+ &[],
406
+ ),
407
+ ),
408
+ (
409
+ "active/features/c.md",
410
+ &full_item(
411
+ "c",
412
+ "feature",
413
+ "implementing",
414
+ &["other"],
415
+ None,
416
+ None,
417
+ None,
418
+ &[],
419
+ ),
420
+ ),
421
+ ]);
422
+ let f = Filter {
423
+ tags: vec!["tooling".into()],
424
+ ..Filter::default()
425
+ };
426
+ let results = sub.query(&f);
427
+ let ids: Vec<&str> = results.iter().map(|i| i.id.as_str()).collect();
428
+ assert!(ids.contains(&"a"));
429
+ assert!(ids.contains(&"b"));
430
+ assert!(!ids.contains(&"c"));
431
+ }
432
+
433
+ #[test]
434
+ fn filter_multiple_tags_and_semantics() {
435
+ let (_tmp, sub) = setup_substrate(&[
436
+ (
437
+ "active/features/a.md",
438
+ &full_item(
439
+ "a",
440
+ "feature",
441
+ "implementing",
442
+ &["tooling", "perf"],
443
+ None,
444
+ None,
445
+ None,
446
+ &[],
447
+ ),
448
+ ),
449
+ (
450
+ "active/features/b.md",
451
+ &full_item(
452
+ "b",
453
+ "feature",
454
+ "implementing",
455
+ &["tooling"],
456
+ None,
457
+ None,
458
+ None,
459
+ &[],
460
+ ),
461
+ ),
462
+ ]);
463
+ // AND: must have BOTH tooling AND perf
464
+ let f = Filter {
465
+ tags: vec!["tooling".into(), "perf".into()],
466
+ ..Filter::default()
467
+ };
468
+ let results = sub.query(&f);
469
+ assert_eq!(results.len(), 1);
470
+ assert_eq!(results[0].id, "a");
471
+ }
472
+
473
+ #[test]
474
+ fn filter_blocking_reverse_dep() {
475
+ let (_tmp, sub) = setup_substrate(&[
476
+ (
477
+ "active/stories/a.md",
478
+ &full_item(
479
+ "a",
480
+ "story",
481
+ "implementing",
482
+ &[],
483
+ None,
484
+ None,
485
+ None,
486
+ &["base"],
487
+ ),
488
+ ),
489
+ (
490
+ "active/stories/b.md",
491
+ &full_item(
492
+ "b",
493
+ "story",
494
+ "implementing",
495
+ &[],
496
+ None,
497
+ None,
498
+ None,
499
+ &["base", "other"],
500
+ ),
501
+ ),
502
+ (
503
+ "active/stories/c.md",
504
+ &full_item(
505
+ "c",
506
+ "story",
507
+ "implementing",
508
+ &[],
509
+ None,
510
+ None,
511
+ None,
512
+ &["other"],
513
+ ),
514
+ ),
515
+ (
516
+ "active/stories/base.md",
517
+ &full_item("base", "story", "done", &[], None, None, None, &[]),
518
+ ),
519
+ ]);
520
+ let f = Filter {
521
+ blocking: Some("base".into()),
522
+ ..Filter::default()
523
+ };
524
+ let results = sub.query(&f);
525
+ let ids: Vec<&str> = results.iter().map(|i| i.id.as_str()).collect();
526
+ assert!(ids.contains(&"a"));
527
+ assert!(ids.contains(&"b"));
528
+ assert!(!ids.contains(&"c"));
529
+ assert!(!ids.contains(&"base"));
530
+ }
531
+
532
+ #[test]
533
+ fn filter_combined_and_semantics() {
534
+ let (_tmp, sub) = setup_substrate(&[
535
+ (
536
+ "active/features/a.md",
537
+ &full_item(
538
+ "a",
539
+ "feature",
540
+ "implementing",
541
+ &["tooling"],
542
+ Some("epic-1"),
543
+ None,
544
+ None,
545
+ &[],
546
+ ),
547
+ ),
548
+ (
549
+ "active/features/b.md",
550
+ &full_item(
551
+ "b",
552
+ "feature",
553
+ "review",
554
+ &["tooling"],
555
+ Some("epic-1"),
556
+ None,
557
+ None,
558
+ &[],
559
+ ),
560
+ ),
561
+ (
562
+ "active/features/c.md",
563
+ &full_item(
564
+ "c",
565
+ "feature",
566
+ "implementing",
567
+ &["tooling"],
568
+ Some("epic-2"),
569
+ None,
570
+ None,
571
+ &[],
572
+ ),
573
+ ),
574
+ ]);
575
+ // AND: stage=implementing AND parent=epic-1
576
+ let f = Filter {
577
+ stage: Match::Equals("implementing".into()),
578
+ parent: Match::Equals("epic-1".into()),
579
+ ..Filter::default()
580
+ };
581
+ let results = sub.query(&f);
582
+ assert_eq!(results.len(), 1);
583
+ assert_eq!(results[0].id, "a");
584
+ }
585
+
586
+ #[test]
587
+ fn filter_load_order_preserved() {
588
+ let (_tmp, sub) = setup_substrate(&[
589
+ (
590
+ "active/stories/a-story.md",
591
+ &full_item(
592
+ "a-story",
593
+ "story",
594
+ "implementing",
595
+ &[],
596
+ None,
597
+ None,
598
+ None,
599
+ &[],
600
+ ),
601
+ ),
602
+ (
603
+ "active/stories/z-story.md",
604
+ &full_item(
605
+ "z-story",
606
+ "story",
607
+ "implementing",
608
+ &[],
609
+ None,
610
+ None,
611
+ None,
612
+ &[],
613
+ ),
614
+ ),
615
+ ]);
616
+ let f = Filter {
617
+ stage: Match::Equals("implementing".into()),
618
+ ..Filter::default()
619
+ };
620
+ let results = sub.query(&f);
621
+ assert_eq!(results[0].id, "a-story");
622
+ assert_eq!(results[1].id, "z-story");
623
+ }
624
+
625
+ #[test]
626
+ fn filter_release_equals() {
627
+ let (_tmp, sub) = setup_substrate(&[
628
+ (
629
+ "active/features/a.md",
630
+ &full_item(
631
+ "a",
632
+ "feature",
633
+ "implementing",
634
+ &[],
635
+ None,
636
+ Some("v1.0.0"),
637
+ None,
638
+ &[],
639
+ ),
640
+ ),
641
+ (
642
+ "active/features/b.md",
643
+ &full_item(
644
+ "b",
645
+ "feature",
646
+ "implementing",
647
+ &[],
648
+ None,
649
+ Some("v2.0.0"),
650
+ None,
651
+ &[],
652
+ ),
653
+ ),
654
+ ]);
655
+ let f = Filter {
656
+ release: Match::Equals("v1.0.0".into()),
657
+ ..Filter::default()
658
+ };
659
+ let results = sub.query(&f);
660
+ assert_eq!(results.len(), 1);
661
+ assert_eq!(results[0].id, "a");
662
+ }
663
+
664
+ #[test]
665
+ fn filter_gate_equals() {
666
+ let (_tmp, sub) = setup_substrate(&[
667
+ (
668
+ "active/stories/a.md",
669
+ &full_item(
670
+ "a",
671
+ "story",
672
+ "implementing",
673
+ &[],
674
+ None,
675
+ None,
676
+ Some("security"),
677
+ &[],
678
+ ),
679
+ ),
680
+ (
681
+ "active/stories/b.md",
682
+ &full_item(
683
+ "b",
684
+ "story",
685
+ "implementing",
686
+ &[],
687
+ None,
688
+ None,
689
+ Some("tests"),
690
+ &[],
691
+ ),
692
+ ),
693
+ ]);
694
+ let f = Filter {
695
+ gate: Match::Equals("security".into()),
696
+ ..Filter::default()
697
+ };
698
+ let results = sub.query(&f);
699
+ assert_eq!(results.len(), 1);
700
+ assert_eq!(results[0].id, "a");
701
+ }
702
+
703
+ #[test]
704
+ fn filter_research_equals() {
705
+ // Items with research_origin set via inline frontmatter strings
706
+ let item_a = "---\nid: a\nkind: story\nstage: implementing\nresearch_origin: pos-x\n---\n\n# a\n";
707
+ let item_b = "---\nid: b\nkind: story\nstage: implementing\nresearch_origin: pos-y\n---\n\n# b\n";
708
+ let item_c = "---\nid: c\nkind: story\nstage: implementing\n---\n\n# c\n";
709
+ let (_tmp, sub) = setup_substrate(&[
710
+ ("active/stories/a.md", item_a),
711
+ ("active/stories/b.md", item_b),
712
+ ("active/stories/c.md", item_c),
713
+ ]);
714
+ let f = Filter {
715
+ research: Match::Equals("pos-x".into()),
716
+ ..Filter::default()
717
+ };
718
+ let results = sub.query(&f);
719
+ assert_eq!(results.len(), 1);
720
+ assert_eq!(results[0].id, "a");
721
+ }
722
+
723
+ #[test]
724
+ fn filter_research_is_null() {
725
+ let item_a = "---\nid: a\nkind: story\nstage: implementing\nresearch_origin: pos-x\n---\n\n# a\n";
726
+ let item_b = "---\nid: b\nkind: story\nstage: implementing\n---\n\n# b\n";
727
+ let (_tmp, sub) = setup_substrate(&[
728
+ ("active/stories/a.md", item_a),
729
+ ("active/stories/b.md", item_b),
730
+ ]);
731
+ let f = Filter {
732
+ research: Match::IsNull,
733
+ ..Filter::default()
734
+ };
735
+ let results = sub.query(&f);
736
+ assert_eq!(results.len(), 1);
737
+ assert_eq!(results[0].id, "b");
738
+ }
739
+
740
+ #[test]
741
+ fn filter_scan_equals() {
742
+ // Items with scan_origin set via inline frontmatter strings
743
+ let item_a =
744
+ "---\nid: a\nkind: story\nstage: implementing\nscan_origin: scan-x\n---\n\n# a\n";
745
+ let item_b =
746
+ "---\nid: b\nkind: story\nstage: implementing\nscan_origin: scan-y\n---\n\n# b\n";
747
+ let item_c = "---\nid: c\nkind: story\nstage: implementing\n---\n\n# c\n";
748
+ let (_tmp, sub) = setup_substrate(&[
749
+ ("active/stories/a.md", item_a),
750
+ ("active/stories/b.md", item_b),
751
+ ("active/stories/c.md", item_c),
752
+ ]);
753
+ let f = Filter {
754
+ scan: Match::Equals("scan-x".into()),
755
+ ..Filter::default()
756
+ };
757
+ let results = sub.query(&f);
758
+ assert_eq!(results.len(), 1);
759
+ assert_eq!(results[0].id, "a");
760
+ }
761
+
762
+ #[test]
763
+ fn filter_scan_is_null() {
764
+ let item_a =
765
+ "---\nid: a\nkind: story\nstage: implementing\nscan_origin: scan-x\n---\n\n# a\n";
766
+ let item_b = "---\nid: b\nkind: story\nstage: implementing\n---\n\n# b\n";
767
+ let (_tmp, sub) = setup_substrate(&[
768
+ ("active/stories/a.md", item_a),
769
+ ("active/stories/b.md", item_b),
770
+ ]);
771
+ let f = Filter {
772
+ scan: Match::IsNull,
773
+ ..Filter::default()
774
+ };
775
+ let results = sub.query(&f);
776
+ assert_eq!(results.len(), 1);
777
+ assert_eq!(results[0].id, "b");
778
+ }
779
+
780
+ #[test]
781
+ fn filter_research_refs_membership() {
782
+ // Mirror of filter_blocking_reverse_dep: items whose research_refs contains "slug-a"
783
+ let item_a = "---\nid: a\nkind: story\nstage: implementing\nresearch_refs: [slug-a]\n---\n\n# a\n";
784
+ let item_b = "---\nid: b\nkind: story\nstage: implementing\nresearch_refs: [slug-a, slug-b]\n---\n\n# b\n";
785
+ let item_c = "---\nid: c\nkind: story\nstage: implementing\nresearch_refs: [slug-b]\n---\n\n# c\n";
786
+ let item_d = "---\nid: d\nkind: story\nstage: implementing\n---\n\n# d\n";
787
+ let (_tmp, sub) = setup_substrate(&[
788
+ ("active/stories/a.md", item_a),
789
+ ("active/stories/b.md", item_b),
790
+ ("active/stories/c.md", item_c),
791
+ ("active/stories/d.md", item_d),
792
+ ]);
793
+ let f = Filter {
794
+ research_refs: Some("slug-a".into()),
795
+ ..Filter::default()
796
+ };
797
+ let results = sub.query(&f);
798
+ let ids: Vec<&str> = results.iter().map(|i| i.id.as_str()).collect();
799
+ assert!(ids.contains(&"a"));
800
+ assert!(ids.contains(&"b"));
801
+ assert!(!ids.contains(&"c"));
802
+ assert!(!ids.contains(&"d"));
803
+ }
804
+ }