From 0c612ad7fd17fe464f5d04204beab7353450ab88 Mon Sep 17 00:00:00 2001 From: sanjin Date: Fri, 22 May 2026 13:15:21 +0200 Subject: [PATCH] Add workspace default package selection --- .llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md | 39 +++++++ README.md | 9 ++ compiler/src/project.rs | 121 +++++++++++++++++--- compiler/tests/project_mode.rs | 117 +++++++++++++++++++ docs/POST_BETA_ROADMAP.md | 7 ++ docs/compiler/RELEASE_NOTES.md | 9 +- docs/compiler/ROADMAP.md | 6 + docs/language/RELEASE_NOTES.md | 10 +- docs/language/ROADMAP.md | 6 + docs/language/SPEC-v1.md | 26 +++++ 10 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 .llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md diff --git a/.llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md b/.llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md new file mode 100644 index 0000000..a16240e --- /dev/null +++ b/.llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md @@ -0,0 +1,39 @@ +# Beta 5 Package And Workspace Discipline + +Release label: `1.0.0-beta.5` candidate scope + +Status: in progress. + +## Scope + +This post-`1.0.0-beta.4` slice tightens local package/workspace behavior +without adding a remote registry, package solver, lockfile, or stable package +ABI promise. + +## Current Work + +- Add `[workspace] default_package = "name"` as an explicit build/run entry + selector for workspaces with multiple packages that contain their entry + module. +- Keep `check`, `test`, `fmt`, and `doc` project-wide over the full closed + workspace graph. +- Diagnose missing `default_package` references during workspace loading. +- Diagnose duplicate workspace members after path normalization before package + loading. +- Keep dependency resolution local-path-only and deterministic. + +## Acceptance Gates + +- `cargo test --test project_mode workspace_default_package` +- `cargo test --test project_mode workspace_package_boundaries` +- `cargo fmt --check` +- `git diff --check` + +## Deferrals + +- No remote registry. +- No lockfile. +- No semantic-version solver. +- No optional, dev, target, or feature dependencies. +- No package publishing or archive format. +- No stable package ABI/layout guarantee. diff --git a/README.md b/README.md index bd4e271..6d2b782 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,15 @@ language surface. Project/workspace build and run entry failures now use entry-specific diagnostic codes, and non-exhaustive `match` diagnostics have clearer wording with deterministic found-arm output. +## Current Main: Package And Workspace Discipline + +After `1.0.0-beta.4`, `main` is tracking a package/workspace discipline slice. +Local workspaces may declare `[workspace] default_package = "name"` to select +the build/run entry package when multiple packages have entry modules. +Duplicate normalized workspace members and missing default-package references +are now dedicated diagnostics. Remote registries, lockfiles, semantic-version +solving, package publishing, and stable package ABI/layout remain deferred. + ## Documentation - [Language Manifest](docs/language/MANIFEST.md) diff --git a/compiler/src/project.rs b/compiler/src/project.rs index 4b7bba7..e758567 100644 --- a/compiler/src/project.rs +++ b/compiler/src/project.rs @@ -130,6 +130,7 @@ struct WorkspaceManifest { source: String, root: PathBuf, members: Vec, + default_package: Option, } #[derive(Clone)] @@ -1194,6 +1195,7 @@ fn parse_workspace_manifest( let mut in_workspace = false; let mut saw_workspace = false; let mut members = None::>; + let mut default_package = None::; for line in manifest_lines(&source) { let trimmed = line.text.trim(); @@ -1264,6 +1266,28 @@ fn parse_workspace_manifest( .with_span(line.span), ), }, + "default_package" => match parse_manifest_string(value.trim()) { + Some(parsed) => { + if default_package.replace(parsed).is_some() { + errors.push( + Diagnostic::new( + &file, + "WorkspaceManifestInvalid", + "duplicate workspace manifest key `default_package`", + ) + .with_span(line.span), + ); + } + } + None => errors.push( + Diagnostic::new( + &file, + "WorkspaceManifestInvalid", + "workspace default_package must be a string", + ) + .with_span(line.span), + ), + }, other => errors.push( Diagnostic::new( &file, @@ -1292,8 +1316,22 @@ fn parse_workspace_manifest( Vec::new() }); - for member in &members { - if normalize_workspace_member(member).is_none() { + let mut normalized_members = Vec::new(); + let mut seen_members = BTreeMap::::new(); + for member in members { + if let Some(normalized) = normalize_workspace_member(&member) { + if let Some(original) = seen_members.insert(normalized.clone(), member.clone()) { + errors.push(Diagnostic::new( + &file, + "DuplicateWorkspaceMember", + format!( + "workspace member path `{}` duplicates `{}` after normalization", + member, original + ), + )); + } + normalized_members.push(normalized); + } else { errors.push(Diagnostic::new( &file, "WorkspaceMemberPathEscape", @@ -1304,12 +1342,19 @@ fn parse_workspace_manifest( )); } } - members = members - .into_iter() - .filter_map(|member| normalize_workspace_member(&member)) - .collect(); + members = normalized_members; members.sort(); + if let Some(default_package) = &default_package { + if !is_project_name(default_package) { + errors.push(Diagnostic::new( + &file, + "WorkspaceManifestInvalid", + "workspace default_package must be a package name", + )); + } + } + if !errors.is_empty() { return Err(errors); } @@ -1325,6 +1370,7 @@ fn parse_workspace_manifest( source, root, members, + default_package, }) } @@ -2750,6 +2796,18 @@ fn validate_workspace_packages( )); } } + if let Some(default_package) = &workspace.default_package { + if !by_name.contains_key(default_package) { + diagnostics.push(Diagnostic::new( + &workspace.path.display().to_string(), + "WorkspaceDefaultPackageMissing", + format!( + "workspace default_package `{}` is not a workspace package", + default_package + ), + )); + } + } let mut resolved_dependencies = vec![Vec::::new(); packages.len()]; for (package_index, package) in packages.iter().enumerate() { @@ -3088,9 +3146,9 @@ fn resolve_and_check_workspace( let mut selected_build_entry_package = None; if require_entry_wrapper { - selected_build_entry_package = select_workspace_build_entry(&packages, &module_maps); - match selected_build_entry_package { - Some(package_index) => { + match select_workspace_build_entry(&workspace, &packages, &module_maps) { + Ok(package_index) => { + selected_build_entry_package = Some(package_index); add_workspace_entry_wrapper( &packages, package_index, @@ -3099,11 +3157,7 @@ fn resolve_and_check_workspace( &mut diagnostics, ); } - None => diagnostics.push(Diagnostic::new( - &workspace.path.display().to_string(), - "WorkspaceBuildAmbiguousEntryPackage", - "workspace build requires exactly one package with its entry module", - )), + Err(diagnostic) => diagnostics.push(diagnostic), } if !diagnostics.is_empty() { return Err(ProjectLoadFailure { @@ -4200,9 +4254,38 @@ fn add_entry_wrapper( } fn select_workspace_build_entry( + workspace: &WorkspaceManifest, packages: &[PackageUnit], module_maps: &[HashMap], -) -> Option { +) -> Result { + if let Some(default_package) = &workspace.default_package { + let Some(package_index) = packages + .iter() + .position(|package| package.manifest.name == *default_package) + else { + return Err(Diagnostic::new( + &workspace.path.display().to_string(), + "WorkspaceDefaultPackageMissing", + format!( + "workspace default_package `{}` is not a workspace package", + default_package + ), + )); + }; + let package = &packages[package_index]; + if module_maps[package_index].contains_key(&package.manifest.entry) { + return Ok(package_index); + } + return Err(Diagnostic::new( + &package.manifest.path.display().to_string(), + "WorkspaceDefaultPackageEntryMissing", + format!( + "workspace default_package `{}` has no entry module `{}`; build/run require one selected package entry module", + default_package, package.manifest.entry + ), + )); + } + let candidates = packages .iter() .enumerate() @@ -4210,9 +4293,13 @@ fn select_workspace_build_entry( .map(|(index, _)| index) .collect::>(); if candidates.len() == 1 { - candidates.first().copied() + Ok(candidates[0]) } else { - None + Err(Diagnostic::new( + &workspace.path.display().to_string(), + "WorkspaceBuildAmbiguousEntryPackage", + "workspace build requires exactly one package with its entry module or `[workspace] default_package = \"name\"`", + )) } } diff --git a/compiler/tests/project_mode.rs b/compiler/tests/project_mode.rs index 6c94ede..1834302 100644 --- a/compiler/tests/project_mode.rs +++ b/compiler/tests/project_mode.rs @@ -587,6 +587,55 @@ fn workspace_build_requires_one_entry_package() { ); } +#[test] +fn workspace_default_package_selects_build_entry() { + let workspace = write_workspace( + "workspace-default-build-entry", + "[workspace]\nmembers = [\"packages/app\", \"packages/tool\"]\ndefault_package = \"app\"\n", + &[ + WorkspacePackageSpec { + member: "packages/app", + manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 0)\n")], + }, + WorkspacePackageSpec { + member: "packages/tool", + manifest: "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 7)\n")], + }, + ], + ); + let binary = unique_path("workspace-default-build-bin"); + + let output = run_glagol([ + "build".as_ref(), + "-o".as_ref(), + binary.as_os_str(), + workspace.as_os_str(), + ]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("WorkspaceBuildAmbiguousEntryPackage"), + "default package should avoid ambiguous build entry diagnostic:\n{}", + stderr + ); + + if output.status.code() == Some(3) { + assert_stderr_contains( + "workspace default package build toolchain", + &output, + "ToolchainUnavailable", + ); + return; + } + + assert_success("workspace default package build", &output); + let run = Command::new(&binary) + .output() + .expect("run workspace default package build output"); + assert_success("workspace default package build binary", &run); +} + #[test] fn workspace_build_reports_entry_main_contract() { let missing_main = write_workspace( @@ -776,6 +825,74 @@ fn workspace_package_boundaries_are_diagnostics() { &escape_output, "WorkspaceMemberPathEscape", ); + + let duplicate_member = write_workspace( + "workspace-duplicate-member", + "[workspace]\nmembers = [\"packages/app\", \"packages/./app\"]\n", + &[WorkspacePackageSpec { + member: "packages/app", + manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 0)\n")], + }], + ); + let duplicate_member_output = run_glagol(["check".as_ref(), duplicate_member.as_os_str()]); + assert_exit_code("duplicate workspace member", &duplicate_member_output, 1); + assert_stderr_contains( + "duplicate workspace member", + &duplicate_member_output, + "DuplicateWorkspaceMember", + ); + + let missing_default = write_workspace( + "workspace-missing-default-package", + "[workspace]\nmembers = [\"packages/app\"]\ndefault_package = \"tool\"\n", + &[WorkspacePackageSpec { + member: "packages/app", + manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 0)\n")], + }], + ); + let missing_default_output = run_glagol(["check".as_ref(), missing_default.as_os_str()]); + assert_exit_code("missing default package", &missing_default_output, 1); + assert_stderr_contains( + "missing default package", + &missing_default_output, + "WorkspaceDefaultPackageMissing", + ); + + let missing_default_entry = write_workspace( + "workspace-missing-default-entry", + "[workspace]\nmembers = [\"packages/app\", \"packages/tool\"]\ndefault_package = \"app\"\n", + &[ + WorkspacePackageSpec { + member: "packages/app", + manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\nentry = \"app\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 0)\n")], + }, + WorkspacePackageSpec { + member: "packages/tool", + manifest: "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n", + modules: &[("main", "(module main)\n\n(fn main () -> i32\n 0)\n")], + }, + ], + ); + let missing_default_entry_binary = unique_path("workspace-missing-default-entry-bin"); + let missing_default_entry_output = run_glagol([ + "build".as_ref(), + "-o".as_ref(), + missing_default_entry_binary.as_os_str(), + missing_default_entry.as_os_str(), + ]); + assert_exit_code( + "missing default package entry", + &missing_default_entry_output, + 1, + ); + assert_stderr_contains( + "missing default package entry", + &missing_default_entry_output, + "WorkspaceDefaultPackageEntryMissing", + ); } #[test] diff --git a/docs/POST_BETA_ROADMAP.md b/docs/POST_BETA_ROADMAP.md index fbc8888..df755f8 100644 --- a/docs/POST_BETA_ROADMAP.md +++ b/docs/POST_BETA_ROADMAP.md @@ -131,6 +131,13 @@ Work: - add diagnostics for ambiguous package roots and dependency cycles - keep remote registry, semver solving, and publishing out of scope +In progress after `1.0.0-beta.4`: local workspaces can declare +`default_package` to select the build/run entry package when multiple packages +have entry modules. Duplicate normalized member paths and missing default +package references are dedicated diagnostics. Lockfiles, remote registries, +semver solving, publishing, optional/dev/target dependencies, and stable +package ABI/layout remain out of scope. + Why fifth: stable package rules are a prerequisite for a usable public language, but remote publishing can wait. diff --git a/docs/compiler/RELEASE_NOTES.md b/docs/compiler/RELEASE_NOTES.md index 87c935d..20dab70 100644 --- a/docs/compiler/RELEASE_NOTES.md +++ b/docs/compiler/RELEASE_NOTES.md @@ -10,7 +10,14 @@ integration/readiness release, not the first real beta. ## Unreleased -No unreleased changes yet. +- Workspace manifests may now declare + `[workspace] default_package = "name"` to choose the build/run entry package + when multiple workspace packages contain their entry module. +- Workspace loading now diagnoses duplicate member paths after normalization + with `DuplicateWorkspaceMember`. +- Workspace loading now diagnoses a missing `default_package` reference with + `WorkspaceDefaultPackageMissing`, and build/run diagnose a selected package + that lacks its entry module with `WorkspaceDefaultPackageEntryMissing`. ## 1.0.0-beta.4 diff --git a/docs/compiler/ROADMAP.md b/docs/compiler/ROADMAP.md index 4fe2bb9..3d0098e 100644 --- a/docs/compiler/ROADMAP.md +++ b/docs/compiler/ROADMAP.md @@ -30,6 +30,12 @@ hardening release, the `1.0.0-beta.2` runtime/resource foundation release, the project/workspace `main` diagnostics, and clearer non-exhaustive `match` diagnostics. +Current unreleased work is the package/workspace discipline slice. It adds +`[workspace] default_package = "name"` for deterministic build/run entry +selection in multi-entry workspaces and tightens workspace-member/default +package diagnostics while keeping registries, lockfiles, semver solving, and +publishing deferred. + The final experimental precursor scope is `exp-125`. Its unsigned direct-value flow, parse/format runtime lanes, and matching staged stdlib helper breadth are now absorbed into `1.0.0-beta`. diff --git a/docs/language/RELEASE_NOTES.md b/docs/language/RELEASE_NOTES.md index 8dfa6c1..3616287 100644 --- a/docs/language/RELEASE_NOTES.md +++ b/docs/language/RELEASE_NOTES.md @@ -17,7 +17,15 @@ diagnostics bundle. ## Unreleased -No unreleased changes yet. +- Local workspace manifests may now declare + `[workspace] default_package = "name"` to make build/run entry selection + deterministic when more than one package has an entry module. +- Duplicate workspace member paths after normalization are now a dedicated + `DuplicateWorkspaceMember` diagnostic. +- Missing `default_package` references are now a dedicated + `WorkspaceDefaultPackageMissing` diagnostic. A selected default package that + lacks its configured entry module is diagnosed as + `WorkspaceDefaultPackageEntryMissing` during build/run. ## 1.0.0-beta.4 diff --git a/docs/language/ROADMAP.md b/docs/language/ROADMAP.md index 65e9ba4..8ba9904 100644 --- a/docs/language/ROADMAP.md +++ b/docs/language/ROADMAP.md @@ -17,6 +17,12 @@ contract and includes the `1.0.0-beta.1` tooling hardening release, the standard-library stabilization release, entry-specific project/workspace `main` diagnostics, and clearer non-exhaustive `match` diagnostics. +Current unreleased work is the package/workspace discipline slice. It adds +`[workspace] default_package = "name"` for deterministic build/run entry +selection in multi-entry workspaces and tightens duplicate-member/default +package diagnostics while keeping registries, lockfiles, semver solving, and +publishing deferred. + The final experimental precursor scope is `exp-125`, defined in `.llm/EXP_125_UNSIGNED_U32_U64_NUMERIC_AND_STDLIB_BREADTH_ALPHA.md`. Its unsigned direct-value flow, parse/format runtime lanes, and matching staged diff --git a/docs/language/SPEC-v1.md b/docs/language/SPEC-v1.md index f12f42b..fcec885 100644 --- a/docs/language/SPEC-v1.md +++ b/docs/language/SPEC-v1.md @@ -1011,6 +1011,32 @@ recursive filesystem operations, process handles, sockets, async/event-loop resources, rich host-error ADTs, platform-specific error codes, finalizers, destructors, and automatic cleanup remain deferred. +### 4.4.3 Post-Beta Package And Workspace Discipline Additions + +The package/workspace discipline slice keeps the existing closed local +workspace model from exp-5 and adds one beta manifest key: + +```toml +[workspace] +members = ["packages/app", "packages/tool"] +default_package = "app" +``` + +`default_package` is optional. When absent, `glagol build` and `glagol run` +continue to require exactly one workspace package whose configured `entry` +module exists. When present, the named package is the selected build/run entry +package. The named package must be a workspace package, and build/run require +that package to contain its configured entry module. + +Workspace member paths are normalized under the workspace root before package +loading. Duplicate normalized member paths are invalid, even if they were +spelled differently in the manifest. + +This slice does not add remote registries, lockfiles, semantic-version +solving, package publishing, archive formats, optional/dev/target +dependencies, feature flags, package build scripts, or stable package +ABI/layout promises. + ## 4.5 v2.0.0-beta.1 Experimental Integration Readiness Status: current experimental Slovo-side release contract, released 2026-05-17.