From b29c3ab01cf76265a20f6a3c9899001754955e22 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 20 Aug 2026 15:46:52 +0700 Subject: [PATCH 1/3] fix(application): check the boundary of the major being compiled `boundaryFor()` maps Solid 1 to `solid-layouts/application-boundary` and Solid 2 to `solid-layouts/solid-2/application-boundary`, and the library generator uses it, so a `solid: 2` build writes the solid-2 spelling into its entry. `validateComponent()` did not ask. It greped for the hardcoded 1.9 specifier, which made the pairing this package generates the pairing it rejects: a correct Solid 2 bundle failed with : entry has no application compiler boundary naming a file that was exactly right. Neither specifier contains the other as written, so `includes` cannot accidentally accept the wrong one either way round. Thread the major from `compileApplication` through `resolveLayoutSource` to `validateComponent` and ask `boundaryFor()`, which is what the plugin two functions away already does. `boundaryFor(undefined)` still returns the 1.9 boundary, so nothing changes for a consumer that never sets `solid`. Found from the consumer side: a Chuzz build on Solid 2 could not get a library past validation with either arm. Generating for 1.9 emits `solid-js/web` imports, a subpath Solid 2 does not export; generating for 2 failed the check above. There was no combination that worked. Two tests, both of which fail without the change: a solid-2 entry is accepted under `solid: 2`, and a 1.9 entry is rejected under it. The boundary check had no coverage at all, which is how the mismatch shipped. --- packages/solid-layouts-oxc/application.js | 22 ++++++++++--- .../solid-layouts-oxc/application.test.js | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/solid-layouts-oxc/application.js b/packages/solid-layouts-oxc/application.js index e3e026f..87d961b 100644 --- a/packages/solid-layouts-oxc/application.js +++ b/packages/solid-layouts-oxc/application.js @@ -186,7 +186,7 @@ function resolvePublicPackageEntry(root, module, subpath = ".") { ); } -function validateComponent(module, packageRoot, name, component) { +function validateComponent(module, packageRoot, name, component, solid) { if (component?.kind === "embedded") return; if (component?.kind !== "generated") { throw new Error(`${module}: component ${name} has unsupported manifest kind ${JSON.stringify(component?.kind)}`); @@ -214,7 +214,19 @@ function validateComponent(module, packageRoot, name, component) { } } - if (!entrySource.includes(APPLICATION_BOUNDARY)) { + /* + * The boundary this major actually emits, not the 1.9 one. + * + * `boundaryFor()` already encodes the mapping and the library generator uses + * it, so a `solid: 2` build writes `solid-layouts/solid-2/application-boundary` + * into the entry. Checking for the hardcoded 1.9 spelling made the pairing we + * generate the pairing we reject: the entry is correct and validation fails on + * output this package just produced. + * + * `includes` on the Solid 2 specifier is not satisfied by the 1.9 one either + * way round, because neither string contains the other as written. + */ + if (!entrySource.includes(boundaryFor(solid).specifier)) { throw new Error(`${module}: ${name} entry has no application compiler boundary`); } if (!new RegExp(`\\bexport\\s+const\\s+${component.recipeExport}\\b`).test(recipeSource)) { @@ -229,7 +241,7 @@ function validateComponent(module, packageRoot, name, component) { } } -function resolveLayoutSource(root, configured) { +function resolveLayoutSource(root, configured, solid) { const module = typeof configured === "string" ? configured : configured.module; if (!module) throw new Error("configured Layout source is missing its module name"); const packageRoot = typeof configured === "string" @@ -271,7 +283,7 @@ function resolveLayoutSource(root, configured) { } const exports = Object.keys(components).sort(); if (exports.length === 0) throw new Error(`${module} Layout manifest has no components`); - for (const name of exports) validateComponent(module, packageRoot, name, components[name]); + for (const name of exports) validateComponent(module, packageRoot, name, components[name], solid); return { module, @@ -291,7 +303,7 @@ function compileApplication(options = {}) { if (!Array.isArray(layouts) || layouts.length === 0) { throw new Error("application compiler requires at least one Layout package"); } - const sources = layouts.map((configured) => resolveLayoutSource(root, configured)); + const sources = layouts.map((configured) => resolveLayoutSource(root, configured, options.solid)); const rootSources = sources.map(({ module, exports, publicEntry }) => ({ module, exports, diff --git a/packages/solid-layouts-oxc/application.test.js b/packages/solid-layouts-oxc/application.test.js index a1d3de3..fd969d4 100644 --- a/packages/solid-layouts-oxc/application.test.js +++ b/packages/solid-layouts-oxc/application.test.js @@ -266,3 +266,34 @@ test("rejects a generated entry that disagrees with its component record", () => "entry call site disagrees", ); }); + +/* + * The boundary check has to follow the major being compiled. + * + * `boundaryFor()` already maps 1 and 2 to different specifiers, and the library + * generator uses it, so a `solid: 2` library writes the solid-2 spelling into + * its entry. Validation greping for the hardcoded 1.9 spelling made the pairing + * this package generates the pairing it rejects: a correct Solid 2 bundle failed + * with "entry has no application compiler boundary", naming a file that was + * exactly right. + */ +test("accepts a solid-2 entry when compiling for Solid 2", () => { + const { root, packageRoot } = fixture(); + const copy = makePackageEditable(root, packageRoot); + const entryPath = join(copy, "index.ts"); + const entry = readFileSync(entryPath, "utf8").replaceAll( + "solid-layouts/application-boundary", + "solid-layouts/solid-2/application-boundary", + ); + writeFileSync(entryPath, entry); + expect(() => + compileApplication({ root, layouts: ["@pathscale/test-ui"], solid: 2 }), + ).not.toThrow(); +}); + +test("rejects a 1.9 entry when compiling for Solid 2", () => { + const { root, packageRoot } = fixture(); + expect(() => compileApplication({ root, layouts: ["@pathscale/test-ui"], solid: 2 })).toThrow( + "entry has no application compiler boundary", + ); +}); From 96166d06f71201de26b5ebb52c60dc15152004a3 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 30 Aug 2026 15:48:33 +0700 Subject: [PATCH 2/3] fix(lint): validate compound recipe slots together --- .../crates/transform/src/linter.rs | 103 ++++++++++++++--- packages/solid-layouts-oxc/index.js | 108 +++++++++--------- packages/solid-layouts-oxc/package.json | 2 +- 3 files changed, 142 insertions(+), 71 deletions(-) diff --git a/packages/solid-layouts-oxc/crates/transform/src/linter.rs b/packages/solid-layouts-oxc/crates/transform/src/linter.rs index eeb266f..f3c27a9 100644 --- a/packages/solid-layouts-oxc/crates/transform/src/linter.rs +++ b/packages/solid-layouts-oxc/crates/transform/src/linter.rs @@ -174,6 +174,15 @@ struct RenderedSlot { slot_api: bool, } +struct RecipeUsage { + rendered: HashSet, + has_legacy_layout: bool, + filename: String, + source: String, + span: Span, + recipe_name: String, +} + impl<'a> Visit<'a> for Usage { fn visit_member_expression(&mut self, member: &MemberExpression<'a>) { if let Expression::Identifier(object) = member.object() @@ -276,6 +285,7 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec { .map(|file| canonical(Path::new(&file.filename))) .collect(); let mut recipes: HashMap<(String, String), Recipe> = HashMap::new(); + let mut recipe_usage: HashMap<(String, String), RecipeUsage> = HashMap::new(); let mut diagnostics = Vec::new(); for file in files { @@ -367,7 +377,8 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec { }); continue; }; - let Some(recipe) = recipes.get(&(recipe_file, export_name.clone())) else { + let recipe_key = (recipe_file, export_name.clone()); + let Some(recipe) = recipes.get(&recipe_key) else { diagnostics.push(ProjectDiagnostic { filename: file.filename.clone(), source: file.source.clone(), @@ -390,6 +401,22 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec { rendered.iter().map(|slot| slot.name.as_str()).collect(); let declared_names: HashSet<_> = recipe.slots.iter().map(|(name, _)| name.as_str()).collect(); + let uses_slot_api = rendered.iter().any(|slot| slot.slot_api); + + let aggregate = recipe_usage + .entry(recipe_key) + .or_insert_with(|| RecipeUsage { + rendered: HashSet::new(), + has_legacy_layout: false, + filename: file.filename.clone(), + source: file.source.clone(), + span: layout.span, + recipe_name: recipe_name.clone(), + }); + aggregate + .rendered + .extend(rendered_names.iter().map(|name| (*name).to_owned())); + aggregate.has_legacy_layout |= !uses_slot_api; for span in usage .computed_slots @@ -424,21 +451,6 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec { }); } } - for (slot, _) in &recipe.slots { - if !rendered_names.contains(slot.as_str()) { - diagnostics.push(ProjectDiagnostic { - filename: file.filename.clone(), - source: file.source.clone(), - rule: "slot-unused", - suggestion: None, - diagnostic: Diagnostic::error( - format!("declared slot `{slot}` is not rendered by `{recipe_name}`"), - layout.span, - ), - }); - } - } - let uses_slot_api = rendered.iter().any(|slot| slot.slot_api); if !uses_slot_api { diagnostics.push(ProjectDiagnostic { filename: file.filename.clone(), @@ -470,6 +482,41 @@ pub fn lint_project(files: &[ProjectFile]) -> Vec { } } + // A compound component deliberately splits one recipe across several + // Layout exports, and those exports may live in separate source files. + // Validate the recipe against their union. Requiring every leaf export to + // render every shared slot reports correct compounds as wholly invalid. + for (recipe_key, usage) in recipe_usage { + let Some(recipe) = recipes.get(&recipe_key) else { + continue; + }; + // A legacy layout uses ordinary class composition rather than the + // slot API. Its warning is actionable, but absence from `slot.*` is + // not proof that the recipe slot is dead. Keep strict unused + // validation for recipes whose complete compound uses the typed slot + // contract. + if usage.has_legacy_layout { + continue; + } + for (slot, _) in &recipe.slots { + if !usage.rendered.contains(slot) { + diagnostics.push(ProjectDiagnostic { + filename: usage.filename.clone(), + source: usage.source.clone(), + rule: "slot-unused", + suggestion: None, + diagnostic: Diagnostic::error( + format!( + "declared slot `{slot}` is not rendered by `{}`", + usage.recipe_name + ), + usage.span, + ), + }); + } + } + } + diagnostics } @@ -849,6 +896,29 @@ export const Button: Layout = () =>