compass-st 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +105 -0
  2. package/VERSION +1 -0
  3. package/bin/install +174 -0
  4. package/bootstrap.sh +95 -0
  5. package/cli/Cargo.lock +270 -0
  6. package/cli/Cargo.toml +24 -0
  7. package/cli/src/cmd/context.rs +59 -0
  8. package/cli/src/cmd/dag.rs +133 -0
  9. package/cli/src/cmd/git.rs +148 -0
  10. package/cli/src/cmd/hook.rs +51 -0
  11. package/cli/src/cmd/index.rs +363 -0
  12. package/cli/src/cmd/manifest.rs +34 -0
  13. package/cli/src/cmd/memory.rs +680 -0
  14. package/cli/src/cmd/migrate.rs +790 -0
  15. package/cli/src/cmd/mod.rs +14 -0
  16. package/cli/src/cmd/progress.rs +107 -0
  17. package/cli/src/cmd/project.rs +1700 -0
  18. package/cli/src/cmd/session.rs +64 -0
  19. package/cli/src/cmd/state.rs +317 -0
  20. package/cli/src/cmd/validate/mod.rs +506 -0
  21. package/cli/src/cmd/validate/prd.rs +472 -0
  22. package/cli/src/cmd/version.rs +89 -0
  23. package/cli/src/helpers.rs +40 -0
  24. package/cli/src/main.rs +75 -0
  25. package/cli/tests/fixtures/plan_empty_pointers.json +60 -0
  26. package/cli/tests/fixtures/plan_missing_pointers.json +59 -0
  27. package/cli/tests/fixtures/plan_too_many_pointers.json +92 -0
  28. package/cli/tests/fixtures/plan_v1_valid.json +64 -0
  29. package/cli/tests/fixtures/prd_bad_flow_bullet.md +37 -0
  30. package/cli/tests/fixtures/prd_bad_flow_prose.md +33 -0
  31. package/cli/tests/fixtures/prd_good_flow.md +41 -0
  32. package/cli/tests/fixtures/prd_xref_dangling.md +38 -0
  33. package/cli/tests/fixtures/prd_xref_valid.md +53 -0
  34. package/cli/tests/fixtures/projects/proj_a/.compass/.state/config.json +12 -0
  35. package/cli/tests/fixtures/projects/proj_b/.compass/.state/config.json +12 -0
  36. package/cli/tests/fixtures/projects/proj_c/.compass/.state/config.json +12 -0
  37. package/cli/tests/fixtures/registry/all_dead.json +18 -0
  38. package/cli/tests/fixtures/registry/corrupt.json +1 -0
  39. package/cli/tests/fixtures/registry/empty.json +1 -0
  40. package/cli/tests/fixtures/registry/last_active_dead.json +24 -0
  41. package/cli/tests/fixtures/registry/multi_alive.json +24 -0
  42. package/cli/tests/fixtures/registry/one_alive.json +12 -0
  43. package/cli/tests/fixtures/v0_project/.compass/.state/config.json +5 -0
  44. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/onboarding-redesign/plan.json +29 -0
  45. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/context.json +11 -0
  46. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/plan.json +49 -0
  47. package/core/colleagues/base-rules.md +112 -0
  48. package/core/colleagues/manifest.json +85 -0
  49. package/core/colleagues/market-analyst.md +50 -0
  50. package/core/colleagues/prioritizer.md +53 -0
  51. package/core/colleagues/researcher.md +54 -0
  52. package/core/colleagues/reviewer.md +55 -0
  53. package/core/colleagues/stakeholder-comm.md +59 -0
  54. package/core/colleagues/story-breaker.md +57 -0
  55. package/core/colleagues/ux-reviewer.md +54 -0
  56. package/core/colleagues/writer.md +55 -0
  57. package/core/commands/compass/brief.md +28 -0
  58. package/core/commands/compass/check.md +27 -0
  59. package/core/commands/compass/epic.md +32 -0
  60. package/core/commands/compass/feedback.md +32 -0
  61. package/core/commands/compass/help.md +24 -0
  62. package/core/commands/compass/ideate.md +32 -0
  63. package/core/commands/compass/init.md +30 -0
  64. package/core/commands/compass/plan.md +27 -0
  65. package/core/commands/compass/prd.md +39 -0
  66. package/core/commands/compass/prioritize.md +36 -0
  67. package/core/commands/compass/prototype.md +28 -0
  68. package/core/commands/compass/release.md +32 -0
  69. package/core/commands/compass/research.md +31 -0
  70. package/core/commands/compass/roadmap.md +32 -0
  71. package/core/commands/compass/run.md +28 -0
  72. package/core/commands/compass/setup.md +32 -0
  73. package/core/commands/compass/sprint.md +32 -0
  74. package/core/commands/compass/status.md +32 -0
  75. package/core/commands/compass/story.md +37 -0
  76. package/core/commands/compass/undo.md +33 -0
  77. package/core/commands/compass/update.md +29 -0
  78. package/core/hooks/context-monitor.sh +5 -0
  79. package/core/hooks/manifest-tracker.sh +62 -0
  80. package/core/hooks/statusline.sh +12 -0
  81. package/core/hooks/update-checker.sh +24 -0
  82. package/core/integrations/confluence.md +267 -0
  83. package/core/integrations/figma.md +277 -0
  84. package/core/integrations/jira.md +436 -0
  85. package/core/integrations/vercel.md +170 -0
  86. package/core/manifest.json +172 -0
  87. package/core/shared/SCHEMAS-v1.md +404 -0
  88. package/core/shared/progress.md +145 -0
  89. package/core/shared/project-scan.md +293 -0
  90. package/core/shared/resolve-project.md +136 -0
  91. package/core/shared/ux-rules.md +52 -0
  92. package/core/shared/version-backup.md +38 -0
  93. package/core/templates/prd-template.md +145 -0
  94. package/core/templates/story-template.md +99 -0
  95. package/core/workflows/brief.md +184 -0
  96. package/core/workflows/check.md +436 -0
  97. package/core/workflows/epic.md +177 -0
  98. package/core/workflows/feedback.md +164 -0
  99. package/core/workflows/help.md +79 -0
  100. package/core/workflows/ideate.md +320 -0
  101. package/core/workflows/init.md +524 -0
  102. package/core/workflows/migrate.md +136 -0
  103. package/core/workflows/plan.md +320 -0
  104. package/core/workflows/prd.md +632 -0
  105. package/core/workflows/prioritize.md +301 -0
  106. package/core/workflows/project.md +177 -0
  107. package/core/workflows/prototype.md +174 -0
  108. package/core/workflows/release.md +179 -0
  109. package/core/workflows/research.md +613 -0
  110. package/core/workflows/roadmap.md +152 -0
  111. package/core/workflows/run.md +367 -0
  112. package/core/workflows/setup.md +294 -0
  113. package/core/workflows/sprint.md +187 -0
  114. package/core/workflows/status.md +185 -0
  115. package/core/workflows/story.md +477 -0
  116. package/core/workflows/undo.md +42 -0
  117. package/core/workflows/update.md +127 -0
  118. package/package.json +37 -0
@@ -0,0 +1,506 @@
1
+ use crate::helpers;
2
+ use serde_json::json;
3
+ use std::path::Path;
4
+
5
+ pub mod prd;
6
+
7
+ pub fn run(args: &[String]) -> Result<String, String> {
8
+ if args.is_empty() {
9
+ return Err("Usage: compass-cli validate <spec|plan|tests|prd> <path>".into());
10
+ }
11
+ match args[0].as_str() {
12
+ "--help" | "-h" | "help" => Ok(help_text()),
13
+ "prd" => {
14
+ if args.len() >= 2 && (args[1] == "--help" || args[1] == "-h") {
15
+ return Ok(prd_help_text());
16
+ }
17
+ if args.len() < 2 {
18
+ return Err("Usage: compass-cli validate prd <path>".into());
19
+ }
20
+ prd::validate_prd(Path::new(&args[1]))
21
+ }
22
+ _ => {
23
+ if args.len() < 2 {
24
+ return Err("Usage: compass-cli validate <spec|plan|tests|prd> <path>".into());
25
+ }
26
+ match args[0].as_str() {
27
+ "spec" => validate_spec(Path::new(&args[1])),
28
+ "plan" => validate_plan(Path::new(&args[1])),
29
+ "tests" => validate_tests(Path::new(&args[1])),
30
+ other => Err(format!("Unknown validate target: {}", other)),
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ fn help_text() -> String {
37
+ "compass-cli validate <spec|plan|tests|prd> <path>\n\
38
+ \n\
39
+ Subcommands:\n \
40
+ spec <path> Validate a spec markdown file\n \
41
+ plan <path> Validate a plan.json (v1.0 schema)\n \
42
+ tests <path> Validate a tests markdown file\n \
43
+ prd <path> Validate a PRD markdown file (R-FLOW, R-XREF)\n"
44
+ .to_string()
45
+ }
46
+
47
+ fn prd_help_text() -> String {
48
+ "compass-cli validate prd <path>\n\
49
+ \n\
50
+ Runs PRD taste rules on the given markdown file:\n \
51
+ R-FLOW User Flows sections must be ordered numeric lists\n \
52
+ R-XREF [LINK-*], [EPIC-*], [REQ-*] tokens must resolve\n\
53
+ \n\
54
+ Emits JSON: {\"ok\": bool, \"violations\": [...]}\n\
55
+ Exit code: 0 if ok, 1 if violations.\n"
56
+ .to_string()
57
+ }
58
+
59
+ fn validate_spec(path: &Path) -> Result<String, String> {
60
+ let content = helpers::read_file(path)?;
61
+ let fm = helpers::parse_frontmatter(&content);
62
+ let mut errors: Vec<String> = vec![];
63
+ let mut warnings: Vec<String> = vec![];
64
+ let mut component = String::new();
65
+
66
+ match fm {
67
+ Some(map) => {
68
+ for field in ["spec_version", "project", "component", "task_type", "category", "status"] {
69
+ if !map.contains_key(field) || map[field].is_empty() {
70
+ errors.push(format!("Missing frontmatter field: {}", field));
71
+ }
72
+ }
73
+ component = map.get("component").cloned().unwrap_or_default();
74
+ // Check for required sections
75
+ if !content.contains("## Overview") { warnings.push("Missing ## Overview section".into()); }
76
+ if !content.contains("## Acceptance") && !content.contains("## Acceptance Criteria") {
77
+ warnings.push("Missing ## Acceptance Criteria section".into());
78
+ }
79
+ }
80
+ None => errors.push("No YAML frontmatter found (expected --- delimiters)".into()),
81
+ }
82
+
83
+ Ok(serde_json::to_string_pretty(&json!({
84
+ "valid": errors.is_empty(),
85
+ "component": component,
86
+ "errors": errors,
87
+ "warnings": warnings,
88
+ })).unwrap())
89
+ }
90
+
91
+ /// v1.0 plan validator. Enforces the schema documented in
92
+ /// `core/shared/SCHEMAS-v1.md`. Legacy plans (no `plan_version` or a
93
+ /// pre-1.0 string) get a backward-compatible pass through the old checks
94
+ /// plus a guidance error pointing to `compass-cli migrate`.
95
+ fn validate_plan(path: &Path) -> Result<String, String> {
96
+ let data = helpers::read_json(path)?;
97
+ let mut errors: Vec<serde_json::Value> = vec![];
98
+ let mut warnings: Vec<String> = vec![];
99
+
100
+ let plan_version = data.get("plan_version").and_then(|v| v.as_str());
101
+
102
+ // Dispatch on plan_version. v1.0 is the new schema. Anything else
103
+ // falls through to the legacy checks but flags an upgrade hint.
104
+ match plan_version {
105
+ Some("1.0") => {
106
+ validate_plan_v1(&data, &mut errors, &mut warnings);
107
+ }
108
+ Some(other) => {
109
+ errors.push(violation(
110
+ "UNSUPPORTED_PLAN_VERSION",
111
+ None,
112
+ Some("plan_version"),
113
+ &format!(
114
+ "Unsupported plan_version '{}'. Run `compass-cli migrate` to upgrade to 1.0.",
115
+ other
116
+ ),
117
+ ));
118
+ validate_plan_legacy(&data, &mut errors, &mut warnings);
119
+ }
120
+ None => {
121
+ errors.push(violation(
122
+ "MISSING_PLAN_VERSION",
123
+ None,
124
+ Some("plan_version"),
125
+ "Missing plan_version. Run `compass-cli migrate` to upgrade to 1.0.",
126
+ ));
127
+ validate_plan_legacy(&data, &mut errors, &mut warnings);
128
+ }
129
+ }
130
+
131
+ let task_count = count_tasks(&data);
132
+
133
+ Ok(serde_json::to_string_pretty(&json!({
134
+ "valid": errors.is_empty(),
135
+ "ok": errors.is_empty(),
136
+ "task_count": task_count,
137
+ "violations": errors,
138
+ "warnings": warnings,
139
+ })).unwrap())
140
+ }
141
+
142
+ fn count_tasks(data: &serde_json::Value) -> usize {
143
+ // v1.0: sum of tasks across waves; legacy: length of tasks/colleagues.
144
+ if let Some(waves) = data.get("waves").and_then(|w| w.as_array()) {
145
+ return waves
146
+ .iter()
147
+ .filter_map(|w| w.get("tasks").and_then(|t| t.as_array()))
148
+ .map(|a| a.len())
149
+ .sum();
150
+ }
151
+ let tasks_key = if data.get("colleagues").is_some() { "colleagues" } else { "tasks" };
152
+ data.get(tasks_key)
153
+ .and_then(|t| t.as_array())
154
+ .map(|a| a.len())
155
+ .unwrap_or(0)
156
+ }
157
+
158
+ fn violation(
159
+ rule: &str,
160
+ task_id: Option<&str>,
161
+ field: Option<&str>,
162
+ message: &str,
163
+ ) -> serde_json::Value {
164
+ json!({
165
+ "rule": rule,
166
+ "task_id": task_id.unwrap_or(""),
167
+ "field": field.unwrap_or(""),
168
+ "message": message,
169
+ })
170
+ }
171
+
172
+ fn validate_plan_v1(
173
+ data: &serde_json::Value,
174
+ errors: &mut Vec<serde_json::Value>,
175
+ _warnings: &mut Vec<String>,
176
+ ) {
177
+ // Top-level required fields per SCHEMAS-v1.md §1.
178
+ for field in ["session_id", "colleagues_selected", "memory_ref", "waves"] {
179
+ if data.get(field).is_none() {
180
+ errors.push(violation(
181
+ "MISSING_FIELD",
182
+ None,
183
+ Some(field),
184
+ &format!("Missing top-level field: {}", field),
185
+ ));
186
+ }
187
+ }
188
+
189
+ // Waves + tasks.
190
+ if let Some(waves) = data.get("waves").and_then(|w| w.as_array()) {
191
+ if waves.is_empty() {
192
+ errors.push(violation(
193
+ "EMPTY_WAVES",
194
+ None,
195
+ Some("waves"),
196
+ "waves must be a non-empty array",
197
+ ));
198
+ }
199
+ for wave in waves {
200
+ let tasks = wave.get("tasks").and_then(|t| t.as_array());
201
+ match tasks {
202
+ Some(arr) if !arr.is_empty() => {
203
+ for task in arr {
204
+ validate_task_v1(task, errors);
205
+ }
206
+ }
207
+ _ => errors.push(violation(
208
+ "EMPTY_TASKS",
209
+ None,
210
+ Some("waves[].tasks"),
211
+ "Each wave must have a non-empty tasks array",
212
+ )),
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ fn validate_task_v1(task: &serde_json::Value, errors: &mut Vec<serde_json::Value>) {
219
+ let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
220
+
221
+ // context_pointers: REQUIRED, non-empty, ≤ 30, each non-empty string.
222
+ match task.get("context_pointers") {
223
+ None => errors.push(violation(
224
+ "MISSING_CONTEXT_POINTERS",
225
+ Some(task_id),
226
+ Some("context_pointers"),
227
+ "Missing required field: context_pointers",
228
+ )),
229
+ Some(v) => match v.as_array() {
230
+ None => errors.push(violation(
231
+ "MISSING_CONTEXT_POINTERS",
232
+ Some(task_id),
233
+ Some("context_pointers"),
234
+ "context_pointers must be an array",
235
+ )),
236
+ Some(arr) if arr.is_empty() => errors.push(violation(
237
+ "EMPTY_CONTEXT_POINTERS",
238
+ Some(task_id),
239
+ Some("context_pointers"),
240
+ "context_pointers must be a non-empty array (1..30 items)",
241
+ )),
242
+ Some(arr) if arr.len() > 30 => errors.push(violation(
243
+ "TOO_MANY_POINTERS",
244
+ Some(task_id),
245
+ Some("context_pointers"),
246
+ &format!(
247
+ "context_pointers has {} items; maximum is 30. Split the task.",
248
+ arr.len()
249
+ ),
250
+ )),
251
+ Some(arr) => {
252
+ for (i, item) in arr.iter().enumerate() {
253
+ match item.as_str() {
254
+ Some(s) if !s.trim().is_empty() => {}
255
+ _ => errors.push(violation(
256
+ "EMPTY_CONTEXT_POINTERS",
257
+ Some(task_id),
258
+ Some(&format!("context_pointers[{}]", i)),
259
+ "Each context_pointer must be a non-empty string",
260
+ )),
261
+ }
262
+ }
263
+ }
264
+ },
265
+ }
266
+ }
267
+
268
+ fn validate_plan_legacy(
269
+ data: &serde_json::Value,
270
+ errors: &mut Vec<serde_json::Value>,
271
+ warnings: &mut Vec<String>,
272
+ ) {
273
+ // Preserve the previous (pre-1.0) behavior so existing callers still
274
+ // get their familiar error surface on top of the upgrade hint.
275
+ for field in ["name", "workspace_dir", "budget_tokens"] {
276
+ if data.get(field).is_none() {
277
+ errors.push(violation(
278
+ "MISSING_FIELD",
279
+ None,
280
+ Some(field),
281
+ &format!("Missing top-level field: {}", field),
282
+ ));
283
+ }
284
+ }
285
+
286
+ let tasks_key = if data.get("colleagues").is_some() { "colleagues" } else { "tasks" };
287
+ match data.get(tasks_key).and_then(|t| t.as_array()) {
288
+ Some(arr) => {
289
+ for (i, task) in arr.iter().enumerate() {
290
+ for field in ["id", "name", "complexity", "budget_tokens"] {
291
+ if task.get(field).is_none() {
292
+ errors.push(violation(
293
+ "MISSING_FIELD",
294
+ task.get("id").and_then(|v| v.as_str()),
295
+ Some(field),
296
+ &format!("Task {}: missing field '{}'", i, field),
297
+ ));
298
+ }
299
+ }
300
+ match task.get("files").or_else(|| task.get("output_files")) {
301
+ Some(f) if f.as_array().map_or(true, |a| a.is_empty()) => {
302
+ if task.get("files").is_some() {
303
+ errors.push(violation(
304
+ "EMPTY_FILES",
305
+ task.get("id").and_then(|v| v.as_str()),
306
+ Some("files"),
307
+ &format!(
308
+ "Task {}: files must be non-empty array",
309
+ task.get("id").and_then(|v| v.as_str()).unwrap_or("?")
310
+ ),
311
+ ));
312
+ }
313
+ }
314
+ None => {
315
+ if tasks_key == "tasks" {
316
+ errors.push(violation(
317
+ "MISSING_FIELD",
318
+ task.get("id").and_then(|v| v.as_str()),
319
+ Some("files"),
320
+ &format!("Task {}: missing 'files' field", i),
321
+ ));
322
+ }
323
+ }
324
+ _ => {}
325
+ }
326
+ }
327
+ let declared = data.get("budget_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
328
+ let sum: u64 = arr
329
+ .iter()
330
+ .filter_map(|t| t.get("budget_tokens").and_then(|v| v.as_u64()))
331
+ .sum();
332
+ if declared != 0 && declared != sum {
333
+ warnings.push(format!(
334
+ "Budget mismatch: declared {}, tasks sum to {}",
335
+ declared, sum
336
+ ));
337
+ }
338
+ }
339
+ None => errors.push(violation(
340
+ "MISSING_FIELD",
341
+ None,
342
+ Some(tasks_key),
343
+ &format!("Missing '{}' array", tasks_key),
344
+ )),
345
+ }
346
+ }
347
+
348
+ fn validate_tests(path: &Path) -> Result<String, String> {
349
+ let content = helpers::read_file(path)?;
350
+ let fm = helpers::parse_frontmatter(&content);
351
+ let mut errors: Vec<String> = vec![];
352
+ let warnings: Vec<String> = vec![];
353
+ let mut component = String::new();
354
+
355
+ match fm {
356
+ Some(map) => {
357
+ for field in ["tests_version", "spec_ref", "component", "category", "strategy"] {
358
+ if !map.contains_key(field) || map[field].is_empty() {
359
+ errors.push(format!("Missing frontmatter field: {}", field));
360
+ }
361
+ }
362
+ component = map.get("component").cloned().unwrap_or_default();
363
+ }
364
+ None => errors.push("No YAML frontmatter found".into()),
365
+ }
366
+
367
+ Ok(serde_json::to_string_pretty(&json!({
368
+ "valid": errors.is_empty(),
369
+ "component": component,
370
+ "errors": errors,
371
+ "warnings": warnings,
372
+ })).unwrap())
373
+ }
374
+
375
+ #[cfg(test)]
376
+ mod tests {
377
+ use super::*;
378
+ use serde_json::json;
379
+
380
+ fn plan_ok() -> serde_json::Value {
381
+ json!({
382
+ "plan_version": "1.0",
383
+ "session_id": "s1",
384
+ "colleagues_selected": ["writer"],
385
+ "memory_ref": ".compass/.state/project-memory.json",
386
+ "waves": [{
387
+ "wave_id": 1,
388
+ "tasks": [{
389
+ "task_id": "C-01",
390
+ "colleague": "writer",
391
+ "budget": 1000,
392
+ "depends_on": [],
393
+ "briefing_notes": "x",
394
+ "context_pointers": ["PRDs/*.md"],
395
+ "output_pattern": "PRDs/out.md"
396
+ }]
397
+ }]
398
+ })
399
+ }
400
+
401
+ #[test]
402
+ fn v1_happy_path_has_no_violations() {
403
+ let mut errs = vec![];
404
+ let mut warns = vec![];
405
+ validate_plan_v1(&plan_ok(), &mut errs, &mut warns);
406
+ assert!(errs.is_empty(), "expected no violations, got {:?}", errs);
407
+ }
408
+
409
+ #[test]
410
+ fn v1_empty_context_pointers_fails() {
411
+ let mut p = plan_ok();
412
+ p["waves"][0]["tasks"][0]["context_pointers"] = json!([]);
413
+ let mut errs = vec![];
414
+ let mut warns = vec![];
415
+ validate_plan_v1(&p, &mut errs, &mut warns);
416
+ assert!(errs.iter().any(|e| e["rule"] == "EMPTY_CONTEXT_POINTERS"));
417
+ }
418
+
419
+ #[test]
420
+ fn v1_missing_context_pointers_fails() {
421
+ let mut p = plan_ok();
422
+ p["waves"][0]["tasks"][0]
423
+ .as_object_mut()
424
+ .unwrap()
425
+ .remove("context_pointers");
426
+ let mut errs = vec![];
427
+ let mut warns = vec![];
428
+ validate_plan_v1(&p, &mut errs, &mut warns);
429
+ assert!(errs.iter().any(|e| e["rule"] == "MISSING_CONTEXT_POINTERS"));
430
+ }
431
+
432
+ #[test]
433
+ fn v1_too_many_pointers_fails() {
434
+ let mut p = plan_ok();
435
+ let many: Vec<String> = (0..31).map(|i| format!("p-{}.md", i)).collect();
436
+ p["waves"][0]["tasks"][0]["context_pointers"] = json!(many);
437
+ let mut errs = vec![];
438
+ let mut warns = vec![];
439
+ validate_plan_v1(&p, &mut errs, &mut warns);
440
+ assert!(errs.iter().any(|e| e["rule"] == "TOO_MANY_POINTERS"));
441
+ }
442
+
443
+ }
444
+
445
+ #[cfg(test)]
446
+ mod plan_tests {
447
+ //! Fixture-backed end-to-end tests for `validate_plan`. Each test loads
448
+ //! a real JSON fixture under `cli/tests/fixtures/`, runs the public
449
+ //! `validate_plan` entry point, and asserts on the JSON result.
450
+ use super::*;
451
+ use std::path::Path;
452
+
453
+ fn fixture(name: &str) -> std::path::PathBuf {
454
+ Path::new(env!("CARGO_MANIFEST_DIR"))
455
+ .join("tests")
456
+ .join("fixtures")
457
+ .join(name)
458
+ }
459
+
460
+ fn run_plan(name: &str) -> serde_json::Value {
461
+ let path = fixture(name);
462
+ let out = validate_plan(&path).expect("validate_plan returned Err");
463
+ serde_json::from_str(&out).expect("validate_plan output was not JSON")
464
+ }
465
+
466
+ #[test]
467
+ fn plan_v1_valid() {
468
+ let result = run_plan("plan_v1_valid.json");
469
+ assert_eq!(result["ok"], serde_json::Value::Bool(true),
470
+ "expected ok=true, got: {}", result);
471
+ let violations = result["violations"].as_array().expect("violations must be array");
472
+ assert!(violations.is_empty(),
473
+ "expected no violations, got: {:?}", violations);
474
+ }
475
+
476
+ #[test]
477
+ fn plan_missing_pointers() {
478
+ let result = run_plan("plan_missing_pointers.json");
479
+ assert_eq!(result["ok"], serde_json::Value::Bool(false));
480
+ let violations = result["violations"].as_array().expect("violations must be array");
481
+ let hit = violations.iter().find(|v| v["rule"] == "MISSING_CONTEXT_POINTERS")
482
+ .expect("expected MISSING_CONTEXT_POINTERS violation");
483
+ assert_eq!(hit["task_id"], "C-02",
484
+ "task_id should be set on the offending task: {:?}", hit);
485
+ }
486
+
487
+ #[test]
488
+ fn empty_pointers() {
489
+ let result = run_plan("plan_empty_pointers.json");
490
+ assert_eq!(result["ok"], serde_json::Value::Bool(false));
491
+ let violations = result["violations"].as_array().expect("violations must be array");
492
+ let hit = violations.iter().find(|v| v["rule"] == "EMPTY_CONTEXT_POINTERS")
493
+ .expect("expected EMPTY_CONTEXT_POINTERS violation");
494
+ assert_eq!(hit["task_id"], "C-02");
495
+ }
496
+
497
+ #[test]
498
+ fn too_many_pointers() {
499
+ let result = run_plan("plan_too_many_pointers.json");
500
+ assert_eq!(result["ok"], serde_json::Value::Bool(false));
501
+ let violations = result["violations"].as_array().expect("violations must be array");
502
+ let hit = violations.iter().find(|v| v["rule"] == "TOO_MANY_POINTERS")
503
+ .expect("expected TOO_MANY_POINTERS violation");
504
+ assert_eq!(hit["task_id"], "C-02");
505
+ }
506
+ }