Add workspace default package selection
This commit is contained in:
parent
33668a0793
commit
0c612ad7fd
39
.llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md
Normal file
39
.llm/BETA_5_PACKAGE_WORKSPACE_DISCIPLINE.md
Normal 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.
|
||||
@ -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)
|
||||
|
||||
@ -130,6 +130,7 @@ struct WorkspaceManifest {
|
||||
source: String,
|
||||
root: PathBuf,
|
||||
members: Vec<String>,
|
||||
default_package: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -1194,6 +1195,7 @@ fn parse_workspace_manifest(
|
||||
let mut in_workspace = false;
|
||||
let mut saw_workspace = false;
|
||||
let mut members = None::<Vec<String>>;
|
||||
let mut default_package = None::<String>;
|
||||
|
||||
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::<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(
|
||||
&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::<usize>::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<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
|
||||
.iter()
|
||||
.enumerate()
|
||||
@ -4210,9 +4293,13 @@ fn select_workspace_build_entry(
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
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\"`",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user