plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


2172082Peter John 2026-07-23T21:09:28+05:30
docs: correct cross-file-imports spec scope; add implementation plan
docs/superpowers/plans/2026-07-23-cross-file-imports.md ADDED
@@ -0,0 +1,618 @@
1
+ # Cross-File Import Resolution Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `import <path>` actually resolve to a file on disk and merge its declarations into the compiling program, so files split across `libs/std/*.plum` can reference each other.
6
+
7
+ **Architecture:** Add a `load_and_merge(entry, lib_path)` loader in `plum-core` that recursively resolves and parses `import` targets (tracking visited files so diamond imports and cycles both resolve cleanly) and merges every reachable file's items into one `ast::Source`, erroring on duplicate top-level names. `plum-checker::check_source` and `plum_wasm_codegen::compile_source` are completely unchanged — they only ever see the one merged `Source`. `plum-cli`'s `compile` subcommand routes through the loader and gains a `--lib-path` flag.
8
+
9
+ **Tech Stack:** Rust, tree-sitter (parsing, unchanged), std::fs/std::path for file resolution. No grammar changes — `import <path>` already parses into `ast::Import { path }`.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-23-cross-file-imports-design.md`
14
+ - `import std/option` resolves to `<lib-path>/std/option.plum`. `--lib-path` defaults to `./libs` (relative to CWD).
15
+ - In scope: transitive imports, diamond imports (no duplication), import cycles (resolve without hanging or erroring), a clear error for an unresolvable import path, a clear error for two files declaring the same top-level name.
16
+ - Out of scope: selective/aliased imports, a package manager/registry, fixing OTHER `libs/std/*.plum` files' pre-existing broken `import` lines (e.g. `http.plum`'s `import std/path`), any new semantics for `module <name>`.
17
+ - **Known, deliberately accepted scope boundary**: the real `libs/std/list.plum` does NOT fully type-check standalone even after this plan — its `join` method calls `Buffer()`, which is declared nowhere in this repo, and `plum-checker` doesn't process `Item::Trait` at all (no trait-bounded dispatch). Both are separate, unscoped gaps. This plan proves the *loader mechanism* with small fixture files, not with the real, currently-broken `list.plum`.
18
+ - Run `cargo build --workspace` and `cargo test --workspace` after every task that touches Rust code — all pre-existing tests must keep passing throughout.
19
+
20
+ ---
21
+
22
+ ### Task 1: `plum-core` — `load_and_merge` loader
23
+
24
+ **Files:**
25
+ - Create: `plum-core/src/loader.rs`
26
+ - Modify: `plum-core/src/lib.rs` (export the new module)
27
+ - Modify: `plum-core/Cargo.toml` (add a dev-dependency for on-disk test fixtures)
28
+ - Test: `plum-core/tests/loader_test.rs`
29
+
30
+ **Interfaces:**
31
+ - Consumes: `crate::ast::{Source, Item, Import}` (existing), `crate::parser::AstParser` (existing), `tree_sitter`/`tree_sitter_plum` (existing dependencies of this crate).
32
+ - Produces: `pub fn load_and_merge(entry: &std::path::Path, lib_path: &std::path::Path) -> Result<ast::Source, String>`. Task 2 (`plum-cli`) calls this directly; Task 3's test also calls it.
33
+
34
+ - [ ] **Step 1: Add the `tempfile` dev-dependency**
35
+
36
+ `plum-core/Cargo.toml` currently has no `[dev-dependencies]` section. Add one:
37
+
38
+ ```toml
39
+ [dev-dependencies]
40
+ tempfile = "3"
41
+ ```
42
+
43
+ - [ ] **Step 2: Write the failing loader tests**
44
+
45
+ Create `plum-core/tests/loader_test.rs`:
46
+
47
+ ```rust
48
+ use plum_core::load_and_merge;
49
+ use std::fs;
50
+ use tempfile::TempDir;
51
+
52
+ /// Writes `name` (without a directory prefix — always placed directly under
53
+ /// the temp dir's `lib_path` subdirectory) with `contents`, creating parent
54
+ /// directories as needed (so `"std/option"` works).
55
+ fn write_lib_file(lib_path: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
56
+ let path = lib_path.join(format!("{}.plum", name));
57
+ fs::create_dir_all(path.parent().unwrap()).unwrap();
58
+ fs::write(&path, contents).unwrap();
59
+ path
60
+ }
61
+
62
+ #[test]
63
+ fn import_sees_imported_declarations() {
64
+ let dir = TempDir::new().unwrap();
65
+ let lib_path = dir.path().join("libs");
66
+ write_lib_file(&lib_path, "helper", "module fixtures\n\nhelperFn() -> Int =\n 42\n");
67
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nmain() -> Int =\n helperFn()\n");
68
+
69
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
70
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
71
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
72
+ _ => None,
73
+ }).collect();
74
+ assert!(names.contains(&"main"), "expected 'main' in merged items, got {:?}", names);
75
+ assert!(names.contains(&"helperFn"), "expected 'helperFn' in merged items, got {:?}", names);
76
+ }
77
+
78
+ #[test]
79
+ fn transitive_import_surfaces_grandchild_declarations() {
80
+ let dir = TempDir::new().unwrap();
81
+ let lib_path = dir.path().join("libs");
82
+ write_lib_file(&lib_path, "c", "module fixtures\n\ncFn() -> Int =\n 3\n");
83
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport c\n\nbFn() -> Int =\n cFn()\n");
84
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n bFn()\n");
85
+
86
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
87
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
88
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
89
+ _ => None,
90
+ }).collect();
91
+ assert!(names.contains(&"cFn"), "expected transitively-imported 'cFn', got {:?}", names);
92
+ }
93
+
94
+ #[test]
95
+ fn diamond_import_includes_shared_dependency_once() {
96
+ let dir = TempDir::new().unwrap();
97
+ let lib_path = dir.path().join("libs");
98
+ write_lib_file(&lib_path, "d", "module fixtures\n\ndFn() -> Int =\n 4\n");
99
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport d\n\nbFn() -> Int =\n dFn()\n");
100
+ write_lib_file(&lib_path, "c", "module fixtures\n\nimport d\n\ncFn() -> Int =\n dFn()\n");
101
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\nimport c\n\nmain() -> Int =\n bFn() + cFn()\n");
102
+
103
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
104
+ let d_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "dFn")).count();
105
+ assert_eq!(d_count, 1, "expected 'dFn' exactly once in a diamond import, got {}", d_count);
106
+ }
107
+
108
+ #[test]
109
+ fn import_cycle_resolves_without_hanging_or_erroring() {
110
+ let dir = TempDir::new().unwrap();
111
+ let lib_path = dir.path().join("libs");
112
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport a\n\nbFn() -> Int =\n 1\n");
113
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n bFn()\n");
114
+
115
+ let merged = load_and_merge(&entry, &lib_path).expect("import cycle should resolve, not error");
116
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
117
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
118
+ _ => None,
119
+ }).collect();
120
+ assert!(names.contains(&"main"));
121
+ assert!(names.contains(&"bFn"));
122
+ }
123
+
124
+ #[test]
125
+ fn unresolvable_import_is_a_clear_error() {
126
+ let dir = TempDir::new().unwrap();
127
+ let lib_path = dir.path().join("libs");
128
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport does_not_exist\n\nmain() -> Int =\n 0\n");
129
+
130
+ let result = load_and_merge(&entry, &lib_path);
131
+ assert!(result.is_err());
132
+ let msg = result.unwrap_err();
133
+ assert!(msg.contains("does_not_exist"), "expected error to name the missing import, got: {}", msg);
134
+ }
135
+
136
+ #[test]
137
+ fn duplicate_top_level_name_across_files_is_a_clear_error() {
138
+ let dir = TempDir::new().unwrap();
139
+ let lib_path = dir.path().join("libs");
140
+ write_lib_file(&lib_path, "helper", "module fixtures\n\nsharedFn() -> Int =\n 1\n");
141
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nsharedFn() -> Int =\n 2\n");
142
+
143
+ let result = load_and_merge(&entry, &lib_path);
144
+ assert!(result.is_err());
145
+ let msg = result.unwrap_err();
146
+ assert!(msg.contains("sharedFn"), "expected error to name the duplicate 'sharedFn', got: {}", msg);
147
+ }
148
+
149
+ #[test]
150
+ fn same_method_name_on_different_receivers_across_files_is_not_a_collision() {
151
+ let dir = TempDir::new().unwrap();
152
+ let lib_path = dir.path().join("libs");
153
+ write_lib_file(&lib_path, "cat", "\
154
+ module fixtures
155
+
156
+ type Cat =
157
+ age: Int
158
+
159
+ length<Cat>(self) -> Int =
160
+ self.age
161
+ ");
162
+ let entry = write_lib_file(&lib_path, "main", "\
163
+ module fixtures
164
+
165
+ import cat
166
+
167
+ type Box =
168
+ items: Int
169
+
170
+ length<Box>(self) -> Int =
171
+ self.items
172
+
173
+ main() -> Int =
174
+ 0
175
+ ");
176
+
177
+ let merged = load_and_merge(&entry, &lib_path).expect("same method name on different receivers should not collide");
178
+ let method_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "length")).count();
179
+ assert_eq!(method_count, 2, "expected both 'length' methods to survive the merge, got {}", method_count);
180
+ }
181
+ ```
182
+
183
+ - [ ] **Step 3: Run the tests to verify they fail**
184
+
185
+ Run: `cargo test -p plum-core --test loader_test 2>&1 | tail -60`
186
+ Expected: FAIL to compile — `plum_core::load_and_merge` doesn't exist yet.
187
+
188
+ - [ ] **Step 4: Implement `plum-core/src/loader.rs`**
189
+
190
+ ```rust
191
+ use std::collections::{HashMap, HashSet};
192
+ use std::path::{Path, PathBuf};
193
+
194
+ use crate::ast::{Import, Item, Source};
195
+ use crate::parser::AstParser;
196
+
197
+ /// Resolves `entry`'s `import` statements (transitively, and tolerating
198
+ /// diamond imports and cycles) against `lib_path`, and merges every reachable
199
+ /// file's top-level items into a single `Source`. The result's `module` and
200
+ /// `imports` fields are `entry`'s own (imported files' `module`/`imports`
201
+ /// fields are not otherwise used once their `items` have been merged in) —
202
+ /// only `items` accumulates across files.
203
+ ///
204
+ /// `import "std/option"` resolves to `<lib_path>/std/option.plum`.
205
+ pub fn load_and_merge(entry: &Path, lib_path: &Path) -> Result<Source, String> {
206
+ let entry_canon = std::fs::canonicalize(entry)
207
+ .map_err(|e| format!("cannot read entry file '{}': {}", entry.display(), e))?;
208
+ let entry_source = parse_file(&entry_canon)?;
209
+
210
+ let mut visited: HashSet<PathBuf> = HashSet::new();
211
+ let mut items: Vec<Item> = Vec::new();
212
+ let mut names: HashMap<String, PathBuf> = HashMap::new();
213
+
214
+ visited.insert(entry_canon.clone());
215
+ merge_items(&entry_canon, &entry_source, &mut items, &mut names)?;
216
+ for import in &entry_source.imports {
217
+ load_import(import, lib_path, &mut visited, &mut items, &mut names)?;
218
+ }
219
+
220
+ Ok(Source {
221
+ module: entry_source.module,
222
+ imports: entry_source.imports,
223
+ items,
224
+ })
225
+ }
226
+
227
+ fn load_import(
228
+ import: &Import,
229
+ lib_path: &Path,
230
+ visited: &mut HashSet<PathBuf>,
231
+ items: &mut Vec<Item>,
232
+ names: &mut HashMap<String, PathBuf>,
233
+ ) -> Result<(), String> {
234
+ let target = lib_path.join(format!("{}.plum", import.path));
235
+ let target_canon = std::fs::canonicalize(&target)
236
+ .map_err(|_| format!("import '{}': no such file '{}'", import.path, target.display()))?;
237
+
238
+ if !visited.insert(target_canon.clone()) {
239
+ // Already loaded — a diamond import or a cycle. Either way, its items
240
+ // are already in `items`; nothing more to do.
241
+ return Ok(());
242
+ }
243
+
244
+ let source = parse_file(&target_canon)?;
245
+ merge_items(&target_canon, &source, items, names)?;
246
+ for nested in &source.imports {
247
+ load_import(nested, lib_path, visited, items, names)?;
248
+ }
249
+ Ok(())
250
+ }
251
+
252
+ fn parse_file(path: &Path) -> Result<Source, String> {
253
+ let text = std::fs::read_to_string(path)
254
+ .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
255
+ let mut parser = tree_sitter::Parser::new();
256
+ parser
257
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
258
+ .map_err(|e| format!("language error: {}", e))?;
259
+ let tree = parser
260
+ .parse(&text, None)
261
+ .ok_or_else(|| format!("parse failed for '{}'", path.display()))?;
262
+ let ap = AstParser::new(&text);
263
+ Ok(ap.parse_source(tree.root_node()))
264
+ }
265
+
266
+ fn merge_items(
267
+ file: &Path,
268
+ source: &Source,
269
+ items: &mut Vec<Item>,
270
+ names: &mut HashMap<String, PathBuf>,
271
+ ) -> Result<(), String> {
272
+ for item in &source.items {
273
+ let key = item_name_key(item);
274
+ if let Some(existing_file) = names.get(&key) {
275
+ return Err(format!(
276
+ "duplicate declaration '{}': declared in both '{}' and '{}'",
277
+ key,
278
+ existing_file.display(),
279
+ file.display()
280
+ ));
281
+ }
282
+ names.insert(key, file.to_path_buf());
283
+ items.push(item.clone());
284
+ }
285
+ Ok(())
286
+ }
287
+
288
+ /// A collision key that mirrors `plum-checker`'s own separation of
289
+ /// namespaces: methods are keyed by `(receiver, name)` (so `length<Cat>` and
290
+ /// `length<Box>` never collide, exactly like `plum_checker::MethodEnv`),
291
+ /// while classes/traits/enums/consts/free-functions are each their own
292
+ /// flat, kind-qualified namespace.
293
+ fn item_name_key(item: &Item) -> String {
294
+ match item {
295
+ Item::Class(c) => format!("class::{}", c.name),
296
+ Item::Trait(t) => format!("trait::{}", t.name),
297
+ Item::Enum(e) => format!("enum::{}", e.name),
298
+ Item::Const(c) => format!("const::{}", c.name),
299
+ Item::Fn(f) => match &f.type_param {
300
+ Some(recv) => format!("method::{}::{}", recv, f.name),
301
+ None => format!("fn::{}", f.name),
302
+ },
303
+ }
304
+ }
305
+ ```
306
+
307
+ - [ ] **Step 5: Export the new module**
308
+
309
+ In `plum-core/src/lib.rs`, replace:
310
+
311
+ ```rust
312
+ pub mod ast;
313
+ pub mod parser;
314
+ pub mod formatter;
315
+
316
+ pub use formatter::{format_source, format_source_with_opts, FormatterError};
317
+ pub use parser::AstParser;
318
+ ```
319
+
320
+ with:
321
+
322
+ ```rust
323
+ pub mod ast;
324
+ pub mod parser;
325
+ pub mod formatter;
326
+ pub mod loader;
327
+
328
+ pub use formatter::{format_source, format_source_with_opts, FormatterError};
329
+ pub use parser::AstParser;
330
+ pub use loader::load_and_merge;
331
+ ```
332
+
333
+ - [ ] **Step 6: Run the tests to verify they pass**
334
+
335
+ Run: `cargo test -p plum-core --test loader_test 2>&1 | tail -80`
336
+ Expected: all 7 tests PASS.
337
+
338
+ - [ ] **Step 7: Run the full workspace test suite**
339
+
340
+ Run: `cargo test --workspace 2>&1 | tail -100`
341
+ Expected: all pre-existing tests still PASS (this task only adds a new module and a new dev-dependency; nothing existing is touched).
342
+
343
+ - [ ] **Step 8: Commit**
344
+
345
+ ```bash
346
+ git add plum-core/src/loader.rs plum-core/src/lib.rs plum-core/Cargo.toml plum-core/tests/loader_test.rs Cargo.lock
347
+ git commit -m "feat(plum-core): add load_and_merge cross-file import loader"
348
+ ```
349
+
350
+ ---
351
+
352
+ ### Task 2: `plum-cli` — `--lib-path` and loader integration
353
+
354
+ **Files:**
355
+ - Modify: `plum-cli/src/main.rs` (`Command::Compile` variant, `cmd_compile`)
356
+ - Test: `plum-cli/tests/compile_tests.rs`
357
+ - Test fixtures: `test/import_fixtures/helper.plum`, `test/import_fixtures/main.plum` (new)
358
+
359
+ **Interfaces:**
360
+ - Consumes: `plum_core::load_and_merge(entry: &Path, lib_path: &Path) -> Result<ast::Source, String>` (Task 1).
361
+ - Produces: `plum compile <file> --lib-path <dir>` resolves imports. No new public Rust functions (this is the CLI's own binary entry point).
362
+
363
+ - [ ] **Step 1: Write the failing CLI integration test and its fixtures**
364
+
365
+ Create `test/import_fixtures/helper.plum`:
366
+
367
+ ```
368
+ module fixtures
369
+
370
+ helperValue() -> Int =
371
+ 42
372
+ ```
373
+
374
+ Create `test/import_fixtures/main.plum`:
375
+
376
+ ```
377
+ module fixtures
378
+
379
+ import helper
380
+
381
+ main() -> Int =
382
+ helperValue()
383
+ ```
384
+
385
+ Add to `plum-cli/tests/compile_tests.rs` (this file already has a `plum_bin()` helper — reuse it, don't redefine it):
386
+
387
+ ```rust
388
+ #[test]
389
+ fn compile_with_import_resolves_via_lib_path() {
390
+ let src_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/import_fixtures/main.plum");
391
+ let lib_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/import_fixtures");
392
+ let out_path = std::env::temp_dir().join(format!("plum_test_import_{}.wasm", std::process::id()));
393
+ let out_path_str = out_path.to_str().unwrap();
394
+ let status = Command::new(plum_bin())
395
+ .args(["compile", src_path, "--lib-path", lib_path, "-o", out_path_str])
396
+ .status()
397
+ .expect("failed to run plum compile");
398
+ assert!(status.success(), "plum compile exited with: {}", status);
399
+ let bytes = std::fs::read(out_path).expect("output wasm not found");
400
+ assert_eq!(&bytes[0..4], b"\0asm");
401
+ }
402
+ ```
403
+
404
+ - [ ] **Step 2: Run the test to verify it fails**
405
+
406
+ Run: `cargo test -p plum-cli compile_with_import_resolves_via_lib_path 2>&1 | tail -40`
407
+ Expected: FAIL — `--lib-path` isn't a recognized flag yet (clap error), or (if you haven't rebuilt) the import is silently dropped and `helperValue` is undefined, producing a type-check error.
408
+
409
+ - [ ] **Step 3: Add `--lib-path` and route `cmd_compile` through the loader**
410
+
411
+ In `plum-cli/src/main.rs`, the `Compile` variant currently reads:
412
+
413
+ ```rust
414
+ /// Compile a Plum source file to WASM
415
+ Compile {
416
+ /// Source file to compile
417
+ file: std::path::PathBuf,
418
+ /// Output path (default: input with .wasm extension)
419
+ #[arg(short, long)]
420
+ output: Option<std::path::PathBuf>,
421
+ },
422
+ ```
423
+
424
+ Replace it with:
425
+
426
+ ```rust
427
+ /// Compile a Plum source file to WASM
428
+ Compile {
429
+ /// Source file to compile
430
+ file: std::path::PathBuf,
431
+ /// Output path (default: input with .wasm extension)
432
+ #[arg(short, long)]
433
+ output: Option<std::path::PathBuf>,
434
+ /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
435
+ #[arg(long, default_value = "libs")]
436
+ lib_path: std::path::PathBuf,
437
+ },
438
+ ```
439
+
440
+ The dispatch in `run()` currently reads:
441
+
442
+ ```rust
443
+ Command::Compile { file, output } => cmd_compile(file, output),
444
+ ```
445
+
446
+ Replace with:
447
+
448
+ ```rust
449
+ Command::Compile { file, output, lib_path } => cmd_compile(file, output, lib_path),
450
+ ```
451
+
452
+ `cmd_compile` currently reads (~line 106-137):
453
+
454
+ ```rust
455
+ fn cmd_compile(file: std::path::PathBuf, output: Option<std::path::PathBuf>) -> Result<()> {
456
+ let source = fs::read_to_string(&file)
457
+ .with_context(|| format!("failed to read {}", file.display()))?;
458
+
459
+ // Parse
460
+ let mut parser = tree_sitter::Parser::new();
461
+ parser
462
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
463
+ .map_err(|e| anyhow::anyhow!("language error: {e}"))?;
464
+ let tree = parser
465
+ .parse(&source, None)
466
+ .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
467
+ let ap = AstParser::new(&source);
468
+ let ast = ap.parse_source(tree.root_node());
469
+
470
+ // Type check
471
+ if let Err(errors) = plum_checker::check_source(&ast) {
472
+ for e in &errors {
473
+ eprintln!("type error: {}", e.message);
474
+ }
475
+ process::exit(1);
476
+ }
477
+
478
+ // Codegen
479
+ let wasm_bytes = plum_wasm_codegen::compile_source(&ast)
480
+ .map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
481
+
482
+ // Write output
483
+ let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
484
+ fs::write(&out_path, &wasm_bytes)
485
+ .with_context(|| format!("failed to write {}", out_path.display()))?;
486
+ ```
487
+
488
+ Replace the parse block (everything from `let source = fs::read_to_string...` through `let ast = ap.parse_source(tree.root_node());`) with a single call to the loader, and add the new `lib_path` parameter:
489
+
490
+ ```rust
491
+ fn cmd_compile(
492
+ file: std::path::PathBuf,
493
+ output: Option<std::path::PathBuf>,
494
+ lib_path: std::path::PathBuf,
495
+ ) -> Result<()> {
496
+ let ast = plum_core::load_and_merge(&file, &lib_path)
497
+ .map_err(|e| anyhow::anyhow!("{e}"))?;
498
+
499
+ // Type check
500
+ if let Err(errors) = plum_checker::check_source(&ast) {
501
+ for e in &errors {
502
+ eprintln!("type error: {}", e.message);
503
+ }
504
+ process::exit(1);
505
+ }
506
+
507
+ // Codegen
508
+ let wasm_bytes = plum_wasm_codegen::compile_source(&ast)
509
+ .map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
510
+
511
+ // Write output
512
+ let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
513
+ fs::write(&out_path, &wasm_bytes)
514
+ .with_context(|| format!("failed to write {}", out_path.display()))?;
515
+ ```
516
+
517
+ (Leave the rest of the function — whatever follows the `fs::write` call — unchanged.)
518
+
519
+ `AstParser`, `tree_sitter::Parser`, and `tree_sitter_plum::LANGUAGE` are used in `plum-cli/src/main.rs` ONLY inside the block you just replaced in `cmd_compile` — `cmd_format` does not use them. After this edit, remove the now-unused `use plum_core::AstParser;` import near the top of the file (~line 9). The `tree_sitter`/`tree_sitter_plum` crates were referenced only via their fully-qualified paths (`tree_sitter::Parser::new()`, `tree_sitter_plum::LANGUAGE`) with no `use` import for them, so there's no `use` line to remove for those — but confirm with `cargo build -p plum-cli 2>&1 | grep -i warning` that no unused-import warning remains after removing the `AstParser` import.
520
+
521
+ - [ ] **Step 4: Run the test to verify it passes**
522
+
523
+ Run: `cargo test -p plum-cli compile_with_import_resolves_via_lib_path 2>&1 | tail -40`
524
+ Expected: PASS.
525
+
526
+ - [ ] **Step 5: Run the full plum-cli test suite**
527
+
528
+ Run: `cargo test -p plum-cli 2>&1 | tail -60`
529
+ Expected: all tests PASS, including the pre-existing `compile_simple_add_produces_wasm` (which passes no `--lib-path`, exercising the new flag's default value — `simple_add.plum` has no imports, so the loader trivially returns just that one file, and the default `./libs` never needs to exist for a file with no imports since `load_and_merge` only ever touches `lib_path` when resolving an actual `import`).
530
+
531
+ - [ ] **Step 6: Run the full workspace test suite**
532
+
533
+ Run: `cargo test --workspace 2>&1 | tail -100`
534
+ Expected: all tests PASS.
535
+
536
+ - [ ] **Step 7: Commit**
537
+
538
+ ```bash
539
+ git add plum-cli/src/main.rs plum-cli/tests/compile_tests.rs test/import_fixtures/
540
+ git commit -m "feat(plum-cli): resolve import via --lib-path using the new loader"
541
+ ```
542
+
543
+ ---
544
+
545
+ ### Task 3: `libs/std/list.plum` — add the missing import
546
+
547
+ **Files:**
548
+ - Modify: `libs/std/list.plum`
549
+
550
+ **Interfaces:**
551
+ - Consumes: nothing new (this task doesn't touch Rust code).
552
+ - Produces: nothing new (this is a one-line library fix; Task 2's loader is already fully tested and proven).
553
+
554
+ - [ ] **Step 1: Add the import line**
555
+
556
+ In `libs/std/list.plum`, the file currently opens:
557
+
558
+ ```
559
+ module std
560
+
561
+ # A node stores the data in a list and contains pointers to the previous and next sibling nodes
562
+ type Node(a) =
563
+ ```
564
+
565
+ Change it to:
566
+
567
+ ```
568
+ module std
569
+
570
+ import std/option
571
+
572
+ # A node stores the data in a list and contains pointers to the previous and next sibling nodes
573
+ type Node(a) =
574
+ ```
575
+
576
+ - [ ] **Step 2: Confirm no test regresses**
577
+
578
+ Run: `cargo test --workspace 2>&1 | tail -100`
579
+ Expected: all tests PASS (no existing test currently compiles `libs/std/list.plum` at all — this is a one-line fix to a library file that isn't part of the Rust build, so nothing should change). This step exists to catch the unexpected case where something DOES already reference this file's exact text.
580
+
581
+ - [ ] **Step 3: Commit**
582
+
583
+ ```bash
584
+ git add libs/std/list.plum
585
+ git commit -m "fix(libs/std): list.plum imports std/option instead of relying on unresolved cross-file references"
586
+ ```
587
+
588
+ ---
589
+
590
+ ### Task 4: README — close the gap
591
+
592
+ **Files:**
593
+ - Modify: `README.md` (the "Known gaps" section)
594
+
595
+ **Interfaces:**
596
+ - Consumes: nothing.
597
+ - Produces: nothing (docs only).
598
+
599
+ - [ ] **Step 1: Update the Known gaps bullet**
600
+
601
+ Run: `grep -n "cross-file" README.md` to find the current bullet, which reads along the lines of:
602
+
603
+ ```
604
+ - `libs/std`'s actual `List`/`Map` still don't fully compile — there's no cross-file import resolution yet, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone; separately, `List`'s methods beyond `get`/`length` (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are still `todo` — variadic parameters (`values: ...a`) now work as a language feature, but wiring these methods up is separate, unstarted work
605
+ ```
606
+
607
+ Replace it with:
608
+
609
+ ```
610
+ - `libs/std`'s actual `List`/`Map` still don't fully compile — cross-file `import` resolution now works (`import <path>` resolves against `--lib-path`, defaulting to `./libs`), and variadic parameters work, but `List`'s methods beyond `get`/`length` (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are still `todo`; separately, `List`'s own `join` method (and `Map`) reference a `Buffer` type and trait-bounded dispatch (`Stringable`) that don't exist yet — `plum-checker` doesn't process trait declarations at all currently
611
+ ```
612
+
613
+ - [ ] **Step 2: Commit**
614
+
615
+ ```bash
616
+ git add README.md
617
+ git commit -m "docs: cross-file import resolution is no longer a known gap"
618
+ ```
docs/superpowers/specs/2026-07-23-cross-file-imports-design.md CHANGED
@@ -125,17 +125,23 @@ argument (default `"libs"`), passed straight through to `load_and_merge`.
125
125
  error naming both files
126
126
  - two different classes/methods across files sharing a method name (but
127
127
  different receivers) is NOT an error
128
- - `libs/std/list.plum` gains the one new line: `import std/option`.
128
+ - `libs/std/list.plum` gains the one new line: `import std/option` — a
129
+ correct, useful fix on its own regardless of the point below.
129
- - A new end-to-end test (in whichever crate is the natural home for a
130
+ - A new end-to-end `plum-cli` integration test (matching the existing
130
- real-filesystem integration test — `plum-cli` or a new top-level
131
+ `plum-cli/tests/compile_tests.rs` convention: invoke the actual built
132
+ `plum` binary via `std::process::Command`, using
131
- `tests/` — using `concat!(env!("CARGO_MANIFEST_DIR"), "/../libs")` or
133
+ `concat!(env!("CARGO_MANIFEST_DIR"), "/...")`-relative fixture paths) that
132
- equivalent to locate the real repo `libs/` directory) that loads and merges
133
- `libs/std/list.plum` via `load_and_merge`, then runs the existing
134
- `check_source` and `compile_source` against the merged result, asserting
134
+ proves the *loader mechanism* end-to-end: two small, purpose-built fixture
135
- both succeed proving `list.plum` compiles standalone for the first time.
135
+ files (one importing the other) compile successfully via `plum compile
136
+ <entry> --lib-path <dir>`.
137
+ **Not** in scope for this test: asserting that the real `libs/std/list.plum`
138
+ fully type-checks/compiles standalone. It doesn't, independent of this
136
- (Its still-`todo` methods compile to a runtime trap exactly as they do
139
+ spec `list.plum`'s `join` method calls `Buffer()`, and `Buffer` isn't
140
+ declared anywhere in this repo; more generally, `plum-checker` doesn't
141
+ process `Item::Trait` at all yet (no trait-bounded dispatch, e.g. `a.toStr()`
142
+ on a generic constrained to `Stringable`). Both are separate, unscoped gaps.
137
- today; this test only proves the *file* now resolves and compiles, not that
143
+ This spec only makes the *import mechanism* correct and proven; it doesn't
138
- every method works that's the next cycle's job.)
144
+ make every existing `libs/std` file type-check.
139
145
 
140
146
  ## README
141
147