Add workspace default package selection

This commit is contained in:
sanjin 2026-05-22 13:15:21 +02:00
parent 33668a0793
commit 0c612ad7fd
10 changed files with 331 additions and 19 deletions

View File

@ -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.

View File

@ -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 entry-specific diagnostic codes, and non-exhaustive `match` diagnostics have
clearer wording with deterministic found-arm output. 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 ## Documentation
- [Language Manifest](docs/language/MANIFEST.md) - [Language Manifest](docs/language/MANIFEST.md)

View File

@ -130,6 +130,7 @@ struct WorkspaceManifest {
source: String, source: String,
root: PathBuf, root: PathBuf,
members: Vec<String>, members: Vec<String>,
default_package: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -1194,6 +1195,7 @@ fn parse_workspace_manifest(
let mut in_workspace = false; let mut in_workspace = false;
let mut saw_workspace = false; let mut saw_workspace = false;
let mut members = None::<Vec<String>>; let mut members = None::<Vec<String>>;
let mut default_package = None::<String>;
for line in manifest_lines(&source) { for line in manifest_lines(&source) {
let trimmed = line.text.trim(); let trimmed = line.text.trim();
@ -1264,6 +1266,28 @@ fn parse_workspace_manifest(
.with_span(line.span), .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( other => errors.push(
Diagnostic::new( Diagnostic::new(
&file, &file,
@ -1292,8 +1316,22 @@ fn parse_workspace_manifest(
Vec::new() Vec::new()
}); });
for member in &members { let mut normalized_members = Vec::new();
if normalize_workspace_member(member).is_none() { let mut seen_members = BTreeMap::<String, String>::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( errors.push(Diagnostic::new(
&file, &file,
"WorkspaceMemberPathEscape", "WorkspaceMemberPathEscape",
@ -1304,12 +1342,19 @@ fn parse_workspace_manifest(
)); ));
} }
} }
members = members members = normalized_members;
.into_iter()
.filter_map(|member| normalize_workspace_member(&member))
.collect();
members.sort(); 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() { if !errors.is_empty() {
return Err(errors); return Err(errors);
} }
@ -1325,6 +1370,7 @@ fn parse_workspace_manifest(
source, source,
root, root,
members, 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::<usize>::new(); packages.len()]; let mut resolved_dependencies = vec![Vec::<usize>::new(); packages.len()];
for (package_index, package) in packages.iter().enumerate() { for (package_index, package) in packages.iter().enumerate() {
@ -3088,9 +3146,9 @@ fn resolve_and_check_workspace(
let mut selected_build_entry_package = None; let mut selected_build_entry_package = None;
if require_entry_wrapper { if require_entry_wrapper {
selected_build_entry_package = select_workspace_build_entry(&packages, &module_maps); match select_workspace_build_entry(&workspace, &packages, &module_maps) {
match selected_build_entry_package { Ok(package_index) => {
Some(package_index) => { selected_build_entry_package = Some(package_index);
add_workspace_entry_wrapper( add_workspace_entry_wrapper(
&packages, &packages,
package_index, package_index,
@ -3099,11 +3157,7 @@ fn resolve_and_check_workspace(
&mut diagnostics, &mut diagnostics,
); );
} }
None => diagnostics.push(Diagnostic::new( Err(diagnostic) => diagnostics.push(diagnostic),
&workspace.path.display().to_string(),
"WorkspaceBuildAmbiguousEntryPackage",
"workspace build requires exactly one package with its entry module",
)),
} }
if !diagnostics.is_empty() { if !diagnostics.is_empty() {
return Err(ProjectLoadFailure { return Err(ProjectLoadFailure {
@ -4200,9 +4254,38 @@ fn add_entry_wrapper(
} }
fn select_workspace_build_entry( fn select_workspace_build_entry(
workspace: &WorkspaceManifest,
packages: &[PackageUnit], packages: &[PackageUnit],
module_maps: &[HashMap<String, usize>], module_maps: &[HashMap<String, usize>],
) -> Option<usize> { ) -> Result<usize, Diagnostic> {
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 let candidates = packages
.iter() .iter()
.enumerate() .enumerate()
@ -4210,9 +4293,13 @@ fn select_workspace_build_entry(
.map(|(index, _)| index) .map(|(index, _)| index)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if candidates.len() == 1 { if candidates.len() == 1 {
candidates.first().copied() Ok(candidates[0])
} else { } 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\"`",
))
} }
} }

View File

@ -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] #[test]
fn workspace_build_reports_entry_main_contract() { fn workspace_build_reports_entry_main_contract() {
let missing_main = write_workspace( let missing_main = write_workspace(
@ -776,6 +825,74 @@ fn workspace_package_boundaries_are_diagnostics() {
&escape_output, &escape_output,
"WorkspaceMemberPathEscape", "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] #[test]

View File

@ -131,6 +131,13 @@ Work:
- add diagnostics for ambiguous package roots and dependency cycles - add diagnostics for ambiguous package roots and dependency cycles
- keep remote registry, semver solving, and publishing out of scope - 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, Why fifth: stable package rules are a prerequisite for a usable public language,
but remote publishing can wait. but remote publishing can wait.

View File

@ -10,7 +10,14 @@ integration/readiness release, not the first real beta.
## Unreleased ## 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 ## 1.0.0-beta.4

View File

@ -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` project/workspace `main` diagnostics, and clearer non-exhaustive `match`
diagnostics. 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 The final experimental precursor scope is `exp-125`. Its unsigned direct-value
flow, parse/format runtime lanes, and matching staged stdlib helper breadth flow, parse/format runtime lanes, and matching staged stdlib helper breadth
are now absorbed into `1.0.0-beta`. are now absorbed into `1.0.0-beta`.

View File

@ -17,7 +17,15 @@ diagnostics bundle.
## Unreleased ## 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 ## 1.0.0-beta.4

View File

@ -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 standard-library stabilization release, entry-specific project/workspace
`main` diagnostics, and clearer non-exhaustive `match` diagnostics. `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 The final experimental precursor scope is `exp-125`, defined in
`.llm/EXP_125_UNSIGNED_U32_U64_NUMERIC_AND_STDLIB_BREADTH_ALPHA.md`. Its `.llm/EXP_125_UNSIGNED_U32_U64_NUMERIC_AND_STDLIB_BREADTH_ALPHA.md`. Its
unsigned direct-value flow, parse/format runtime lanes, and matching staged unsigned direct-value flow, parse/format runtime lanes, and matching staged

View File

@ -1011,6 +1011,32 @@ recursive filesystem operations, process handles, sockets, async/event-loop
resources, rich host-error ADTs, platform-specific error codes, finalizers, resources, rich host-error ADTs, platform-specific error codes, finalizers,
destructors, and automatic cleanup remain deferred. 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 ## 4.5 v2.0.0-beta.1 Experimental Integration Readiness
Status: current experimental Slovo-side release contract, released 2026-05-17. Status: current experimental Slovo-side release contract, released 2026-05-17.