Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Drop root-matching workspace (#699)

## Problem

When the workspace glob in `pnpm-workspace.yaml` (or the `workspaces` field) matches the
**project root directory itself** — e.g. `packages: ["."]`, or a broad `./**` that
re-includes the root — `@manypkg` returns the root as one of `packages.packages`.
`createProject` (project-discovery.ts) then maps it to a sub-workspace with an empty
`relativeDir`, producing a workspace with an empty id and empty relativePath that duplicates
the root workspace.

That degenerate workspace later fails label validation with a confusing message:

```
Workspace "root" has label "" which conflicts with the ID of workspace "".
```

This is the "single workspace in monorepo currently errors during detection" case behind the
`it.todo` in `workspaces-detection.test.ts`. (A genuine single *sub-package* monorepo — root
plus one `packages/foo` — already works.)

## Decision

Treat a root-matching workspace pattern as **valid**: drop the root match, since the root is
already its own workspace. The user's config "just works" instead of erroring.

## Design

### Change

In `createProject` (`packages/project-resolver/src/project-discovery.ts`), filter
`packages.packages` to exclude any package whose absolute `dir` equals `packages.rootDir`
before mapping to workspaces:

```ts
workspaces: packages.packages
.filter((pkg) => pkg.dir !== packages.rootDir)
.map((pkg) => createWorkspace(pkg))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
```

`pkg.dir` and `packages.rootDir` are both absolute paths from `@manypkg`; equality is the
precise "this discovered package is the root" condition, robust regardless of how the
relative path stringifies (`""` vs `"."`).

### Scope

One `.filter` in one function. No new files, no public-API change. `createWorkspace`,
`createRootWorkspace`, and the alias/label validation are untouched — the degenerate
workspace simply never gets created, so the downstream label-collision error no longer
fires.

### Behavior after

- `packages: ["."]` plus real sub-packages → project resolves to root + the real
sub-packages, no empty workspace, exit 0.
- A pattern matching only the root → project resolves to root with zero sub-workspaces.
- Genuine sub-package monorepos are unaffected (their package dirs never equal rootDir).

### Error handling

None added — the case is now valid rather than an error.

## Testing

Implement the deferred `it.todo("single workspace in monorepo")` in
`workspaces-detection.test.ts`: a fixture with `pnpm-workspace.yaml` matching `.` plus a
real sub-package, asserting the project resolves cleanly (root + the real sub, no empty
workspace) via `--show-config --config-key project` / `--list-workspaces`. Optionally a
project-resolver-level assertion that no workspace has an empty relativePath.

## Spec

`spec/06-project.md` — Workspace Discovery gains the root-exclusion rule. CHANGELOG entry +
version bump to 4.1.1 (PATCH: corrects detection behavior; #698's 4.1.0 already merged).
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`workspaces detection > ignores a workspace pattern that matches the project root 1`] = `
---------- Context -----------
Working Directory: /ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__
Command: /ROOT/lib/cli.js --max-workers 1 --no-footer --show-config --config-key project
---------- Stdout ------------
[log] {
"rootWorkspace": {
"label": "",
"packageJson": {
"name": "root",
"type": "module"
},
"absolutePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__",
"dependencies": [],
"relativePath": ".",
"configFilePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__/nadle.config.ts",
"id": "root"
},
"packageManager": "pnpm",
"currentWorkspaceId": "root",
"workspaces": [
{
"id": "packages:only",
"label": "packages:only",
"absolutePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__/packages/only",
"relativePath": "packages/only",
"dependencies": [],
"configFilePath": null,
"packageJson": {
"name": "only"
}
}
]
}
`;

exports[`workspaces detection > multiple packages 1`] = `
---------- Context -----------
Working Directory: /ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@ import { PACKAGE_JSON } from "@nadle/project-resolver";
import { expectPass, withFixture, CONFIG_FILE, PNPM_WORKSPACE, createPackageJson, createPnpmWorkspace } from "setup";

describe("workspaces detection", () => {
// TODO(#699): blocked on a behavior decision. A monorepo whose pnpm-workspace
// matches exactly one package currently errors during detection. Once #699
// settles the intended behavior (valid single-workspace monorepo vs. a clear
// error message), assert it.
it.todo("single workspace in monorepo");
it("ignores a workspace pattern that matches the project root", async () => {
// A pattern of "." matches the root directory. The root is already its own
// workspace, so the match is dropped instead of creating a degenerate empty
// workspace that would fail label validation. The real sub-package resolves.
await withFixture({
fixtureDir: "monorepo",
testFn: async ({ exec }) => {
await expectPass(exec`--show-config --config-key project`);
},
files: {
[CONFIG_FILE]: "",
[PACKAGE_JSON]: createPackageJson("root"),
[PNPM_WORKSPACE]: createPnpmWorkspace([".", "packages/*"]),
packages: {
only: {
[PACKAGE_JSON]: createPackageJson("only")
}
}
}
});
});

it("one package", async () => {
await withFixture({
Expand Down
8 changes: 7 additions & 1 deletion packages/project-resolver/src/project-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ async function createProject(packages: Packages): Promise<Project> {
rootWorkspace,
packageManager: packages.tool.type,
currentWorkspaceId: rootWorkspace.id,
workspaces: packages.packages.map((pkg) => createWorkspace(pkg)).sort((a, b) => a.relativePath.localeCompare(b.relativePath))
// A workspace pattern can match the project root itself (e.g. "."). The root is
// already represented by rootWorkspace, so drop that match instead of creating a
// degenerate empty-path workspace that would later fail label validation.
workspaces: packages.packages
.filter((pkg) => pkg.dir !== packages.rootDir)
.map((pkg) => createWorkspace(pkg))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
};

return resolveWorkspaceDependencies(project);
Expand Down
5 changes: 4 additions & 1 deletion spec/06-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ Child workspaces are discovered via the package manager's workspace configuratio
- **npm/yarn**: `workspaces` field in root `package.json`

Each discovered package directory becomes a workspace (see
[07-workspace.md](07-workspace.md)).
[07-workspace.md](07-workspace.md)), **except the project root itself**: a workspace
pattern that matches the root directory (for example a pattern of `.`) does not create a
second workspace, because the root is already represented by the root workspace. Such a
match is ignored rather than treated as an error.

Workspaces are sorted by their relative path for deterministic ordering.

Expand Down
10 changes: 10 additions & 0 deletions spec/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Versioning follows [Semantic Versioning](https://semver.org/):
- **MINOR**: New concept, new section, or materially expanded rules
- **PATCH**: Clarifications, corrections, wording improvements

## 4.1.1 — 2026-06-21

### Changed

- 06-project: Workspace discovery now ignores a workspace pattern that matches the project
root directory (e.g. a pattern of `.`). Previously such a match produced a degenerate
empty-path workspace duplicating the root, which then failed label validation with a
confusing error. The root is already represented by the root workspace, so the match is
dropped and the project resolves cleanly.

## 4.1.0 — 2026-06-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion spec/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Nadle Specification

**Version**: 4.1.0
**Version**: 4.1.1

This directory contains the language-agnostic specification for Nadle, a type-safe,
Gradle-inspired task runner for Node.js.
Expand Down
Loading