compass-st 1.1.2 → 1.2.0
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.
- package/README.md +130 -56
- package/VERSION +1 -1
- package/bin/install +0 -3
- package/cli/Cargo.lock +327 -1
- package/cli/Cargo.toml +4 -1
- package/cli/src/cmd/hook.rs +263 -3
- package/cli/src/cmd/version.rs +2 -2
- package/cli/tests/e2e_check_validates_prd.rs +104 -0
- package/cli/tests/e2e_common.rs +217 -0
- package/cli/tests/e2e_cross_cwd.rs +91 -0
- package/cli/tests/e2e_fallback.rs +175 -0
- package/cli/tests/e2e_migrate_v05.rs +299 -0
- package/cli/tests/e2e_no_shell_remains.rs +53 -0
- package/cli/tests/e2e_run_missing_context.rs +129 -0
- package/cli/tests/e2e_smart_init.rs +194 -0
- package/cli/tests/e2e_v1_smoke.rs +261 -0
- package/core/colleagues/manifest.json +1 -1
- package/core/manifest.json +1 -1
- package/core/workflows/update.md +5 -5
- package/package.json +1 -1
- package/bootstrap.sh +0 -95
- package/core/hooks/context-monitor.sh +0 -5
- package/core/hooks/manifest-tracker.sh +0 -62
- package/core/hooks/statusline.sh +0 -12
- package/core/hooks/update-checker.sh +0 -24
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//! E2E — resolve works across cwd (shell cwd ≠ project root) + zero-effort
|
|
2
|
+
//! v1.1 → v1.2 registry migration. Ported from `tests/integration/e2e_cross_cwd.sh`.
|
|
3
|
+
|
|
4
|
+
#[path = "e2e_common.rs"]
|
|
5
|
+
mod e2e_common;
|
|
6
|
+
|
|
7
|
+
use e2e_common::{run_cli_ok, HomeGuard};
|
|
8
|
+
use serde_json::Value;
|
|
9
|
+
use std::fs;
|
|
10
|
+
use std::path::Path;
|
|
11
|
+
use tempfile::TempDir;
|
|
12
|
+
|
|
13
|
+
fn json(out: &str) -> Value {
|
|
14
|
+
serde_json::from_str(out).unwrap_or_else(|e| panic!("invalid JSON: {e}\n---\n{out}"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn make_project(root: &Path, name: &str, prefix: &str) {
|
|
18
|
+
let state = root.join(".compass").join(".state");
|
|
19
|
+
fs::create_dir_all(&state).expect("mkdir .compass/.state");
|
|
20
|
+
fs::create_dir_all(root.join("prd")).expect("mkdir prd");
|
|
21
|
+
fs::create_dir_all(root.join("epics")).expect("mkdir epics");
|
|
22
|
+
|
|
23
|
+
let cfg = serde_json::json!({
|
|
24
|
+
"version": "1.2.0",
|
|
25
|
+
"project": {"name": name, "po": "@t"},
|
|
26
|
+
"lang": "en", "spec_lang": "en", "mode": "standalone",
|
|
27
|
+
"prefix": prefix, "domain": "ard",
|
|
28
|
+
"output_paths": {"prd": "prd", "story": "epics/{EPIC}/user-stories", "epic": "epics"},
|
|
29
|
+
"naming": {"prd": "{slug}.md"}
|
|
30
|
+
});
|
|
31
|
+
fs::write(
|
|
32
|
+
state.join("config.json"),
|
|
33
|
+
serde_json::to_vec_pretty(&cfg).expect("serialize config"),
|
|
34
|
+
)
|
|
35
|
+
.expect("write config.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Covers REQ-06, REQ-07, REQ-10 — the whole cross-cwd lifecycle as a single
|
|
39
|
+
/// test since the stages share state (registry mutations accumulate).
|
|
40
|
+
#[test]
|
|
41
|
+
fn resolve_works_across_cwd_and_handles_v11_migration() {
|
|
42
|
+
let _g = HomeGuard::new();
|
|
43
|
+
|
|
44
|
+
let tmp = TempDir::new().expect("tempdir");
|
|
45
|
+
let dir_a = tmp.path().join("project-a");
|
|
46
|
+
let dir_b = tmp.path().join("random-dir-b");
|
|
47
|
+
fs::create_dir_all(&dir_b).expect("mkdir dir_b");
|
|
48
|
+
make_project(&dir_a, "ProjectA", "PA");
|
|
49
|
+
|
|
50
|
+
// --- Stage 1: register + activate ProjectA --------------------------
|
|
51
|
+
run_cli_ok(&["project", "add", dir_a.to_str().unwrap()]);
|
|
52
|
+
run_cli_ok(&["project", "use", dir_a.to_str().unwrap()]);
|
|
53
|
+
|
|
54
|
+
// --- Stage 2: resolve from dir_b (unrelated sibling) ----------------
|
|
55
|
+
std::env::set_current_dir(&dir_b).expect("cd dir_b");
|
|
56
|
+
let r = json(&run_cli_ok(&["project", "resolve"]));
|
|
57
|
+
assert_eq!(r["status"], "ok", "expected ok from sibling cwd; got {r}");
|
|
58
|
+
let project_root = r["project_root"].as_str().expect("project_root string");
|
|
59
|
+
assert!(
|
|
60
|
+
project_root.contains("project-a"),
|
|
61
|
+
"project_root should point at project-a, got {project_root}"
|
|
62
|
+
);
|
|
63
|
+
assert_eq!(r["name"], "ProjectA");
|
|
64
|
+
|
|
65
|
+
// --- Stage 3: resolve from /tmp (even more unrelated cwd) -----------
|
|
66
|
+
std::env::set_current_dir("/tmp").expect("cd /tmp");
|
|
67
|
+
let r = json(&run_cli_ok(&["project", "resolve"]));
|
|
68
|
+
assert_eq!(r["status"], "ok", "expected ok from /tmp; got {r}");
|
|
69
|
+
|
|
70
|
+
// --- Stage 4: writes to $PROJECT_ROOT land at dir_a ----------------
|
|
71
|
+
let prd_path = dir_a.join("prd").join("test-prd.md");
|
|
72
|
+
fs::write(&prd_path, "# test\n").expect("write PRD");
|
|
73
|
+
assert!(prd_path.exists(), "PRD should exist at {}", prd_path.display());
|
|
74
|
+
|
|
75
|
+
// --- Stage 5: zero-effort v1.1 → registry migration -----------------
|
|
76
|
+
let home = std::env::var("HOME").expect("HOME set by HomeGuard");
|
|
77
|
+
let registry = Path::new(&home).join(".compass").join("projects.json");
|
|
78
|
+
if registry.exists() {
|
|
79
|
+
fs::remove_file(®istry).expect("rm registry");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
std::env::set_current_dir(&dir_a).expect("cd dir_a");
|
|
83
|
+
let r = json(&run_cli_ok(&["project", "resolve"]));
|
|
84
|
+
assert_eq!(r["status"], "ok", "auto-migrate should succeed; got {r}");
|
|
85
|
+
assert_eq!(r["migrated_from_v11"], true, "migrated_from_v11 should be true");
|
|
86
|
+
assert!(
|
|
87
|
+
registry.exists(),
|
|
88
|
+
"registry should have been auto-created at {}",
|
|
89
|
+
registry.display()
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
//! E2E fallback — ambiguous fallback after last_active dies.
|
|
2
|
+
//!
|
|
3
|
+
//! Port of `tests/integration/e2e_fallback.sh`. Covers REQ-03, REQ-04, REQ-11.
|
|
4
|
+
//!
|
|
5
|
+
//! Scenarios exercised against `compass-cli project resolve`:
|
|
6
|
+
//! 1. Happy path: last_active alive returns status=ok.
|
|
7
|
+
//! 2. last_active dies → status=ambiguous, dead path pruned from registry.
|
|
8
|
+
//! 3. User picks a survivor → status=ok.
|
|
9
|
+
//! 4. Only one survivor left → smart auto-pick, status=ok.
|
|
10
|
+
//! 5. All registry paths dead → status=none, reason=all_paths_dead.
|
|
11
|
+
//!
|
|
12
|
+
//! All 5 stages share state — the registry mutates progressively across
|
|
13
|
+
//! stages — so they run as a single sequential test function.
|
|
14
|
+
|
|
15
|
+
#[path = "e2e_common.rs"]
|
|
16
|
+
mod e2e_common;
|
|
17
|
+
|
|
18
|
+
use e2e_common::{run_cli_ok, HomeGuard};
|
|
19
|
+
use std::fs;
|
|
20
|
+
use std::path::Path;
|
|
21
|
+
use tempfile::TempDir;
|
|
22
|
+
|
|
23
|
+
/// Seed a minimal valid v1.2.0 project config at `<root>/.compass/.state/config.json`.
|
|
24
|
+
fn make_project(root: &Path, name: &str) {
|
|
25
|
+
let state = root.join(".compass").join(".state");
|
|
26
|
+
fs::create_dir_all(&state).expect("mkdir .compass/.state");
|
|
27
|
+
let cfg = serde_json::json!({
|
|
28
|
+
"version": "1.2.0",
|
|
29
|
+
"project": { "name": name, "po": "@t" },
|
|
30
|
+
"lang": "en",
|
|
31
|
+
"spec_lang": "en",
|
|
32
|
+
"mode": "standalone",
|
|
33
|
+
"prefix": "PX",
|
|
34
|
+
"domain": "ard",
|
|
35
|
+
"output_paths": {
|
|
36
|
+
"prd": "prd",
|
|
37
|
+
"story": "epics/{EPIC}/user-stories",
|
|
38
|
+
"epic": "epics"
|
|
39
|
+
},
|
|
40
|
+
"naming": { "prd": "{slug}.md" }
|
|
41
|
+
});
|
|
42
|
+
fs::write(state.join("config.json"), cfg.to_string()).expect("write config.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[test]
|
|
46
|
+
fn fallback_full_lifecycle() {
|
|
47
|
+
let _guard = HomeGuard::new();
|
|
48
|
+
|
|
49
|
+
// Parent dir holding the 3 fake projects. Kept alive via `tmp`.
|
|
50
|
+
let tmp = TempDir::new().expect("create TMP_ROOT");
|
|
51
|
+
let alpha = tmp.path().join("proj_alpha");
|
|
52
|
+
let beta = tmp.path().join("proj_beta");
|
|
53
|
+
let gamma = tmp.path().join("proj_gamma");
|
|
54
|
+
|
|
55
|
+
// -------------------------------------------------------------------
|
|
56
|
+
// Setup — three projects, each with a valid v1.2.0 config.json.
|
|
57
|
+
// Register all three and mark beta as last_active.
|
|
58
|
+
// -------------------------------------------------------------------
|
|
59
|
+
make_project(&alpha, "proj_alpha");
|
|
60
|
+
make_project(&beta, "proj_beta");
|
|
61
|
+
make_project(&gamma, "proj_gamma");
|
|
62
|
+
|
|
63
|
+
run_cli_ok(&["project", "add", alpha.to_str().unwrap()]);
|
|
64
|
+
run_cli_ok(&["project", "add", beta.to_str().unwrap()]);
|
|
65
|
+
run_cli_ok(&["project", "add", gamma.to_str().unwrap()]);
|
|
66
|
+
run_cli_ok(&["project", "use", beta.to_str().unwrap()]);
|
|
67
|
+
|
|
68
|
+
// -------------------------------------------------------------------
|
|
69
|
+
// Test 1 — resolve happy path: last_active alive returns ok/beta.
|
|
70
|
+
// -------------------------------------------------------------------
|
|
71
|
+
let out = run_cli_ok(&["project", "resolve"]);
|
|
72
|
+
let r: serde_json::Value = serde_json::from_str(&out)
|
|
73
|
+
.unwrap_or_else(|e| panic!("T1 resolve stdout not JSON: {e}\n{out}"));
|
|
74
|
+
assert_eq!(r["status"], "ok", "T1 expected ok, got {}", r["status"]);
|
|
75
|
+
let active = r["project_root"]
|
|
76
|
+
.as_str()
|
|
77
|
+
.expect("T1 project_root missing");
|
|
78
|
+
assert!(
|
|
79
|
+
active.contains("proj_beta"),
|
|
80
|
+
"T1 expected beta active, got {active}"
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// -------------------------------------------------------------------
|
|
84
|
+
// Test 2 — kill last_active, expect ambiguous with 2 survivors + pruning.
|
|
85
|
+
// -------------------------------------------------------------------
|
|
86
|
+
fs::remove_dir_all(&beta).expect("rm beta");
|
|
87
|
+
let out = run_cli_ok(&["project", "resolve"]);
|
|
88
|
+
let r: serde_json::Value = serde_json::from_str(&out)
|
|
89
|
+
.unwrap_or_else(|e| panic!("T2 resolve stdout not JSON: {e}\n{out}"));
|
|
90
|
+
assert_eq!(
|
|
91
|
+
r["status"], "ambiguous",
|
|
92
|
+
"T2 expected ambiguous, got {}",
|
|
93
|
+
r["status"]
|
|
94
|
+
);
|
|
95
|
+
let cands = r["candidates"]
|
|
96
|
+
.as_array()
|
|
97
|
+
.expect("T2 candidates not array");
|
|
98
|
+
assert_eq!(cands.len(), 2, "T2 expected 2 candidates, got {}", cands.len());
|
|
99
|
+
|
|
100
|
+
// Registry should have pruned the dead beta entry.
|
|
101
|
+
let home = std::env::var("HOME").expect("HOME set");
|
|
102
|
+
let registry_path = Path::new(&home).join(".compass").join("projects.json");
|
|
103
|
+
let registry_raw = fs::read_to_string(®istry_path).expect("read registry");
|
|
104
|
+
let registry: serde_json::Value =
|
|
105
|
+
serde_json::from_str(®istry_raw).expect("registry is JSON");
|
|
106
|
+
let paths: Vec<String> = registry["projects"]
|
|
107
|
+
.as_array()
|
|
108
|
+
.expect("projects array")
|
|
109
|
+
.iter()
|
|
110
|
+
.map(|p| p["path"].as_str().unwrap_or("").to_string())
|
|
111
|
+
.collect();
|
|
112
|
+
let joined = paths.join(",");
|
|
113
|
+
assert!(
|
|
114
|
+
!joined.contains("proj_beta"),
|
|
115
|
+
"T2 beta should have been pruned from registry, paths={joined}"
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------
|
|
119
|
+
// Test 3 — user picks alpha; resolve returns ok/alpha.
|
|
120
|
+
// -------------------------------------------------------------------
|
|
121
|
+
run_cli_ok(&["project", "use", alpha.to_str().unwrap()]);
|
|
122
|
+
let out = run_cli_ok(&["project", "resolve"]);
|
|
123
|
+
let r: serde_json::Value = serde_json::from_str(&out)
|
|
124
|
+
.unwrap_or_else(|e| panic!("T3 resolve stdout not JSON: {e}\n{out}"));
|
|
125
|
+
assert_eq!(
|
|
126
|
+
r["status"], "ok",
|
|
127
|
+
"T3 expected ok after use alpha, got {}",
|
|
128
|
+
r["status"]
|
|
129
|
+
);
|
|
130
|
+
let active = r["project_root"]
|
|
131
|
+
.as_str()
|
|
132
|
+
.expect("T3 project_root missing");
|
|
133
|
+
assert!(
|
|
134
|
+
active.contains("proj_alpha"),
|
|
135
|
+
"T3 expected alpha active, got {active}"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// -------------------------------------------------------------------
|
|
139
|
+
// Test 4 — kill alpha too; only gamma survives → smart auto-pick.
|
|
140
|
+
// -------------------------------------------------------------------
|
|
141
|
+
fs::remove_dir_all(&alpha).expect("rm alpha");
|
|
142
|
+
let out = run_cli_ok(&["project", "resolve"]);
|
|
143
|
+
let r: serde_json::Value = serde_json::from_str(&out)
|
|
144
|
+
.unwrap_or_else(|e| panic!("T4 resolve stdout not JSON: {e}\n{out}"));
|
|
145
|
+
assert_eq!(
|
|
146
|
+
r["status"], "ok",
|
|
147
|
+
"T4 expected ok (smart auto-pick single survivor), got {}",
|
|
148
|
+
r["status"]
|
|
149
|
+
);
|
|
150
|
+
let active = r["project_root"]
|
|
151
|
+
.as_str()
|
|
152
|
+
.expect("T4 project_root missing");
|
|
153
|
+
assert!(
|
|
154
|
+
active.contains("proj_gamma"),
|
|
155
|
+
"T4 expected gamma active after auto-pick, got {active}"
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// -------------------------------------------------------------------
|
|
159
|
+
// Test 5 — kill gamma; no survivors → status=none, reason=all_paths_dead.
|
|
160
|
+
// -------------------------------------------------------------------
|
|
161
|
+
fs::remove_dir_all(&gamma).expect("rm gamma");
|
|
162
|
+
let out = run_cli_ok(&["project", "resolve"]);
|
|
163
|
+
let r: serde_json::Value = serde_json::from_str(&out)
|
|
164
|
+
.unwrap_or_else(|e| panic!("T5 resolve stdout not JSON: {e}\n{out}"));
|
|
165
|
+
assert_eq!(
|
|
166
|
+
r["status"], "none",
|
|
167
|
+
"T5 expected none, got {}",
|
|
168
|
+
r["status"]
|
|
169
|
+
);
|
|
170
|
+
assert_eq!(
|
|
171
|
+
r["reason"], "all_paths_dead",
|
|
172
|
+
"T5 expected all_paths_dead, got {}",
|
|
173
|
+
r["reason"]
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
//! E2E test for `compass-cli migrate` (v0.5 -> v1.0).
|
|
2
|
+
//!
|
|
3
|
+
//! Port of `tests/integration/e2e_migrate_v05.sh`. Covers REQ-06:
|
|
4
|
+
//!
|
|
5
|
+
//! 1. First migrate rewrites every session's `plan.json` to `plan_version:
|
|
6
|
+
//! "1.0"`, writes a `plan.v0.json` backup equal to the original plan, and
|
|
7
|
+
//! creates `.compass/.state/project-memory.json`.
|
|
8
|
+
//! 2. Each migrated plan validates via `compass-cli validate plan`.
|
|
9
|
+
//! 3. A second migrate is a no-op: exit 0, logs "already v1.0, no-op" for
|
|
10
|
+
//! each session, no `plan.v0.plan.v0.json` double-backup, and backup
|
|
11
|
+
//! mtimes unchanged.
|
|
12
|
+
//!
|
|
13
|
+
//! Each test stages its OWN copy of the fixture into a fresh tempdir so the
|
|
14
|
+
//! tests are safe under `cargo test` parallelism.
|
|
15
|
+
|
|
16
|
+
#[path = "e2e_common.rs"]
|
|
17
|
+
mod e2e_common;
|
|
18
|
+
|
|
19
|
+
use std::path::{Path, PathBuf};
|
|
20
|
+
|
|
21
|
+
use e2e_common::{fixture_root, run_cli};
|
|
22
|
+
|
|
23
|
+
/// Recursively copy `src` into `dst`. `dst` is created if it does not exist.
|
|
24
|
+
/// Rust stdlib has no such helper, hence this tiny clone of `cp -R src/. dst/`.
|
|
25
|
+
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|
26
|
+
std::fs::create_dir_all(dst)?;
|
|
27
|
+
for entry in std::fs::read_dir(src)? {
|
|
28
|
+
let entry = entry?;
|
|
29
|
+
let target = dst.join(entry.file_name());
|
|
30
|
+
if entry.file_type()?.is_dir() {
|
|
31
|
+
copy_dir_recursive(&entry.path(), &target)?;
|
|
32
|
+
} else {
|
|
33
|
+
std::fs::copy(entry.path(), &target)?;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
Ok(())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Stage the v0 fixture into a fresh tempdir. Returns `(TempDir guard,
|
|
40
|
+
/// work_dir)`. Keep the guard alive for the duration of the test.
|
|
41
|
+
fn setup() -> (tempfile::TempDir, PathBuf) {
|
|
42
|
+
let tmp = tempfile::tempdir().expect("create tempdir for migrate e2e");
|
|
43
|
+
let work_dir = tmp.path().join("compass-migrate-test");
|
|
44
|
+
let fixture_src = fixture_root().join("v0_project");
|
|
45
|
+
assert!(
|
|
46
|
+
fixture_src.is_dir(),
|
|
47
|
+
"fixture missing: {}",
|
|
48
|
+
fixture_src.display()
|
|
49
|
+
);
|
|
50
|
+
copy_dir_recursive(&fixture_src, &work_dir).expect("copy v0 fixture into tempdir");
|
|
51
|
+
(tmp, work_dir)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// List session directories under `<work_dir>/.compass/.state/sessions`, sorted
|
|
55
|
+
/// lexicographically (matching the bash `find ... | sort`).
|
|
56
|
+
fn list_sessions(work_dir: &Path) -> Vec<PathBuf> {
|
|
57
|
+
let base = work_dir.join(".compass").join(".state").join("sessions");
|
|
58
|
+
let mut out: Vec<PathBuf> = std::fs::read_dir(&base)
|
|
59
|
+
.unwrap_or_else(|e| panic!("read_dir {}: {e}", base.display()))
|
|
60
|
+
.filter_map(|e| e.ok())
|
|
61
|
+
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
|
62
|
+
.map(|e| e.path())
|
|
63
|
+
.collect();
|
|
64
|
+
out.sort();
|
|
65
|
+
out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Read a top-level string field from a JSON file.
|
|
69
|
+
fn read_json_string_field(path: &Path, field: &str) -> Option<String> {
|
|
70
|
+
let raw = std::fs::read_to_string(path).ok()?;
|
|
71
|
+
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
|
72
|
+
v.get(field).and_then(|x| x.as_str()).map(str::to_string)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn first_migrate_rewrites_plans_and_creates_memory() {
|
|
77
|
+
let (_guard, work_dir) = setup();
|
|
78
|
+
|
|
79
|
+
let sessions = list_sessions(&work_dir);
|
|
80
|
+
assert!(
|
|
81
|
+
sessions.len() >= 2,
|
|
82
|
+
"fixture should provide >= 2 session dirs, got {}",
|
|
83
|
+
sessions.len()
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Capture originals + assert precondition plan_version == "0.5".
|
|
87
|
+
let mut originals: Vec<String> = Vec::with_capacity(sessions.len());
|
|
88
|
+
for sd in &sessions {
|
|
89
|
+
let plan_path = sd.join("plan.json");
|
|
90
|
+
let orig = std::fs::read_to_string(&plan_path)
|
|
91
|
+
.unwrap_or_else(|e| panic!("read {}: {e}", plan_path.display()));
|
|
92
|
+
let v = read_json_string_field(&plan_path, "plan_version");
|
|
93
|
+
assert_eq!(
|
|
94
|
+
v.as_deref(),
|
|
95
|
+
Some("0.5"),
|
|
96
|
+
"fixture precondition: {} expected plan_version 0.5, got {:?}",
|
|
97
|
+
plan_path.display(),
|
|
98
|
+
v
|
|
99
|
+
);
|
|
100
|
+
originals.push(orig);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Run migrate — must exit 0.
|
|
104
|
+
let out = run_cli(&["migrate", work_dir.to_str().unwrap()]);
|
|
105
|
+
assert!(
|
|
106
|
+
out.status.success(),
|
|
107
|
+
"first migrate expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
|
|
108
|
+
out.status,
|
|
109
|
+
String::from_utf8_lossy(&out.stdout),
|
|
110
|
+
String::from_utf8_lossy(&out.stderr),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Per-session assertions: plan.json is v1.0, plan.v0.json matches original.
|
|
114
|
+
for (i, sd) in sessions.iter().enumerate() {
|
|
115
|
+
let plan_path = sd.join("plan.json");
|
|
116
|
+
let backup_path = sd.join("plan.v0.json");
|
|
117
|
+
|
|
118
|
+
assert!(
|
|
119
|
+
plan_path.is_file(),
|
|
120
|
+
"plan.json missing after migrate: {}",
|
|
121
|
+
plan_path.display()
|
|
122
|
+
);
|
|
123
|
+
assert!(
|
|
124
|
+
backup_path.is_file(),
|
|
125
|
+
"plan.v0.json backup missing after migrate: {}",
|
|
126
|
+
backup_path.display()
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
let v = read_json_string_field(&plan_path, "plan_version");
|
|
130
|
+
assert_eq!(
|
|
131
|
+
v.as_deref(),
|
|
132
|
+
Some("1.0"),
|
|
133
|
+
"expected plan_version 1.0 in {}, got {:?}",
|
|
134
|
+
plan_path.display(),
|
|
135
|
+
v
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
let backup_raw = std::fs::read_to_string(&backup_path)
|
|
139
|
+
.unwrap_or_else(|e| panic!("read {}: {e}", backup_path.display()));
|
|
140
|
+
assert_eq!(
|
|
141
|
+
backup_raw, originals[i],
|
|
142
|
+
"{} does not match original plan.json",
|
|
143
|
+
backup_path.display()
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// project-memory.json must be created.
|
|
148
|
+
let memory_path = work_dir
|
|
149
|
+
.join(".compass")
|
|
150
|
+
.join(".state")
|
|
151
|
+
.join("project-memory.json");
|
|
152
|
+
assert!(
|
|
153
|
+
memory_path.is_file(),
|
|
154
|
+
"project-memory.json missing after migrate: {}",
|
|
155
|
+
memory_path.display()
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn migrated_plans_validate_v1() {
|
|
161
|
+
let (_guard, work_dir) = setup();
|
|
162
|
+
|
|
163
|
+
// Migrate first — prerequisite for this test.
|
|
164
|
+
let out = run_cli(&["migrate", work_dir.to_str().unwrap()]);
|
|
165
|
+
assert!(
|
|
166
|
+
out.status.success(),
|
|
167
|
+
"migrate failed: {:?}\nstdout: {}\nstderr: {}",
|
|
168
|
+
out.status,
|
|
169
|
+
String::from_utf8_lossy(&out.stdout),
|
|
170
|
+
String::from_utf8_lossy(&out.stderr),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
let sessions = list_sessions(&work_dir);
|
|
174
|
+
assert!(sessions.len() >= 2, "need >= 2 sessions");
|
|
175
|
+
|
|
176
|
+
for sd in &sessions {
|
|
177
|
+
let plan_path = sd.join("plan.json");
|
|
178
|
+
let out = run_cli(&["validate", "plan", plan_path.to_str().unwrap()]);
|
|
179
|
+
assert!(
|
|
180
|
+
out.status.success(),
|
|
181
|
+
"validate plan exited {:?} for {}\nstdout: {}\nstderr: {}",
|
|
182
|
+
out.status,
|
|
183
|
+
plan_path.display(),
|
|
184
|
+
String::from_utf8_lossy(&out.stdout),
|
|
185
|
+
String::from_utf8_lossy(&out.stderr),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn second_migrate_is_idempotent() {
|
|
192
|
+
let (_guard, work_dir) = setup();
|
|
193
|
+
|
|
194
|
+
// First migrate.
|
|
195
|
+
let out1 = run_cli(&["migrate", work_dir.to_str().unwrap()]);
|
|
196
|
+
assert!(
|
|
197
|
+
out1.status.success(),
|
|
198
|
+
"first migrate failed: {:?}\nstdout: {}\nstderr: {}",
|
|
199
|
+
out1.status,
|
|
200
|
+
String::from_utf8_lossy(&out1.stdout),
|
|
201
|
+
String::from_utf8_lossy(&out1.stderr),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
let sessions = list_sessions(&work_dir);
|
|
205
|
+
assert!(sessions.len() >= 2, "need >= 2 sessions");
|
|
206
|
+
|
|
207
|
+
// Snapshot backup mtimes.
|
|
208
|
+
let mtimes_before: Vec<std::time::SystemTime> = sessions
|
|
209
|
+
.iter()
|
|
210
|
+
.map(|sd| {
|
|
211
|
+
let bp = sd.join("plan.v0.json");
|
|
212
|
+
std::fs::metadata(&bp)
|
|
213
|
+
.unwrap_or_else(|e| panic!("stat {}: {e}", bp.display()))
|
|
214
|
+
.modified()
|
|
215
|
+
.expect("backup mtime")
|
|
216
|
+
})
|
|
217
|
+
.collect();
|
|
218
|
+
|
|
219
|
+
// Second migrate — must succeed and log "already v1.0, no-op" per session.
|
|
220
|
+
let out2 = run_cli(&["migrate", work_dir.to_str().unwrap()]);
|
|
221
|
+
assert!(
|
|
222
|
+
out2.status.success(),
|
|
223
|
+
"second migrate expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
|
|
224
|
+
out2.status,
|
|
225
|
+
String::from_utf8_lossy(&out2.stdout),
|
|
226
|
+
String::from_utf8_lossy(&out2.stderr),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
let combined = format!(
|
|
230
|
+
"{}{}",
|
|
231
|
+
String::from_utf8_lossy(&out2.stdout),
|
|
232
|
+
String::from_utf8_lossy(&out2.stderr),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
for sd in &sessions {
|
|
236
|
+
let slug = sd
|
|
237
|
+
.file_name()
|
|
238
|
+
.and_then(|s| s.to_str())
|
|
239
|
+
.expect("session dir name");
|
|
240
|
+
// Bash used: grep -qE "${slug}:[[:space:]]+already v1.0, no-op"
|
|
241
|
+
// Check that somewhere in the combined output a line contains
|
|
242
|
+
// "<slug>:" followed by whitespace then "already v1.0, no-op".
|
|
243
|
+
let needle_ok = combined.lines().any(|line| {
|
|
244
|
+
if let Some(idx) = line.find(&format!("{slug}:")) {
|
|
245
|
+
let after = &line[idx + slug.len() + 1..];
|
|
246
|
+
let trimmed = after.trim_start();
|
|
247
|
+
after.len() != trimmed.len() && trimmed.contains("already v1.0, no-op")
|
|
248
|
+
} else {
|
|
249
|
+
false
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
assert!(
|
|
253
|
+
needle_ok,
|
|
254
|
+
"expected '{slug}: already v1.0, no-op' on second run\n--- combined ---\n{combined}",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// No double-backup: `plan.v0.plan.v0.json` must not exist anywhere under
|
|
259
|
+
// `work_dir`.
|
|
260
|
+
fn assert_no_double_backup(dir: &Path) {
|
|
261
|
+
for entry in std::fs::read_dir(dir)
|
|
262
|
+
.unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
|
|
263
|
+
{
|
|
264
|
+
let entry = entry.expect("dir entry");
|
|
265
|
+
let ft = entry.file_type().expect("file type");
|
|
266
|
+
if ft.is_dir() {
|
|
267
|
+
assert_no_double_backup(&entry.path());
|
|
268
|
+
} else if ft.is_file() {
|
|
269
|
+
let name = entry.file_name();
|
|
270
|
+
assert_ne!(
|
|
271
|
+
name.to_string_lossy(),
|
|
272
|
+
"plan.v0.plan.v0.json",
|
|
273
|
+
"found double-backup at {}",
|
|
274
|
+
entry.path().display()
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
assert_no_double_backup(&work_dir);
|
|
280
|
+
|
|
281
|
+
// Backups must still exist with unchanged mtimes.
|
|
282
|
+
for (i, sd) in sessions.iter().enumerate() {
|
|
283
|
+
let backup_path = sd.join("plan.v0.json");
|
|
284
|
+
assert!(
|
|
285
|
+
backup_path.is_file(),
|
|
286
|
+
"{} vanished after second migrate",
|
|
287
|
+
backup_path.display()
|
|
288
|
+
);
|
|
289
|
+
let m_after = std::fs::metadata(&backup_path)
|
|
290
|
+
.unwrap_or_else(|e| panic!("stat {}: {e}", backup_path.display()))
|
|
291
|
+
.modified()
|
|
292
|
+
.expect("backup mtime after");
|
|
293
|
+
assert_eq!(
|
|
294
|
+
m_after, mtimes_before[i],
|
|
295
|
+
"{} mtime changed across idempotent migrate",
|
|
296
|
+
backup_path.display()
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//! Regression guard — Compass is a Rust + JS-launcher project. No `.sh`
|
|
2
|
+
//! files should ever land in the repo. This test walks the repo from
|
|
3
|
+
//! `cli/`'s parent (the repo root) and fails if any `.sh` file is present
|
|
4
|
+
//! outside `target/` and `.git/`. Catches accidental re-introduction of
|
|
5
|
+
//! bash artifacts in any future change.
|
|
6
|
+
//!
|
|
7
|
+
//! Covers REQ-01, REQ-02, REQ-05, REQ-07.
|
|
8
|
+
|
|
9
|
+
use std::fs;
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
|
|
12
|
+
fn repo_root() -> PathBuf {
|
|
13
|
+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
14
|
+
.parent()
|
|
15
|
+
.expect("CARGO_MANIFEST_DIR has a parent (the repo root)")
|
|
16
|
+
.to_path_buf()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn collect_shell_files(dir: &Path, found: &mut Vec<PathBuf>) {
|
|
20
|
+
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
21
|
+
if name == "target" || name == ".git" || name == "node_modules" {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let entries = match fs::read_dir(dir) {
|
|
25
|
+
Ok(e) => e,
|
|
26
|
+
Err(_) => return,
|
|
27
|
+
};
|
|
28
|
+
for entry in entries.flatten() {
|
|
29
|
+
let path = entry.path();
|
|
30
|
+
if path.is_dir() {
|
|
31
|
+
collect_shell_files(&path, found);
|
|
32
|
+
} else if path.extension().and_then(|e| e.to_str()) == Some("sh") {
|
|
33
|
+
found.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[test]
|
|
39
|
+
fn no_shell_files_remain_in_repo() {
|
|
40
|
+
let root = repo_root();
|
|
41
|
+
let mut found = Vec::new();
|
|
42
|
+
collect_shell_files(&root, &mut found);
|
|
43
|
+
assert!(
|
|
44
|
+
found.is_empty(),
|
|
45
|
+
"Found {} unexpected `.sh` file(s) in the repo (excluding target/, .git/, node_modules/):\n{}",
|
|
46
|
+
found.len(),
|
|
47
|
+
found
|
|
48
|
+
.iter()
|
|
49
|
+
.map(|p| format!(" {}", p.display()))
|
|
50
|
+
.collect::<Vec<_>>()
|
|
51
|
+
.join("\n")
|
|
52
|
+
);
|
|
53
|
+
}
|