Skip to content

Commit 3a6d959

Browse files
nam-hleclaude
andauthored
fix: ignore workspace pattern matching the project root (#702)
Closes #699. ## Problem A workspace glob matching the **project root** (e.g. a pnpm `packages: ["."]`, or a broad `./**` that re-includes root) made `@manypkg` return the root as a sub-package. `createProject` turned it into a degenerate **empty-path workspace** duplicating the root, which then failed label validation with a cryptic error: ``` Workspace "root" has label "" which conflicts with the ID of workspace "". ``` This was the `it.todo("single workspace in monorepo")` case in `workspaces-detection.test.ts`. (A genuine single *sub-package* monorepo already worked.) ## Fix In `createProject`, filter out any discovered package whose absolute `dir` equals the root `dir` before mapping to workspaces. The root is already `rootWorkspace`, so the match is dropped and the project resolves cleanly. One `.filter`; no API change. ## Tests - Implements the deferred `workspaces-detection` test: `pnpm-workspace` matching `.` + a real sub-package now resolves to root + the sub (snapshot confirms **no empty workspace**), exit 0. - project-resolver suite 37 pass; nadle workspace suite 53 pass; detection + alias suites pass together (12); lint + prettier clean; pre-commit `nadle check` passes. ## Spec `spec/06-project.md` Workspace Discovery gains the root-exclusion rule. Rebased on top of #698 (now merged); spec entry is **4.1.1** (PATCH — corrects detection behavior on top of #698's 4.1.0). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6868b1d commit 3a6d959

7 files changed

Lines changed: 154 additions & 8 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Drop root-matching workspace (#699)
2+
3+
## Problem
4+
5+
When the workspace glob in `pnpm-workspace.yaml` (or the `workspaces` field) matches the
6+
**project root directory itself** — e.g. `packages: ["."]`, or a broad `./**` that
7+
re-includes the root — `@manypkg` returns the root as one of `packages.packages`.
8+
`createProject` (project-discovery.ts) then maps it to a sub-workspace with an empty
9+
`relativeDir`, producing a workspace with an empty id and empty relativePath that duplicates
10+
the root workspace.
11+
12+
That degenerate workspace later fails label validation with a confusing message:
13+
14+
```
15+
Workspace "root" has label "" which conflicts with the ID of workspace "".
16+
```
17+
18+
This is the "single workspace in monorepo currently errors during detection" case behind the
19+
`it.todo` in `workspaces-detection.test.ts`. (A genuine single *sub-package* monorepo — root
20+
plus one `packages/foo` — already works.)
21+
22+
## Decision
23+
24+
Treat a root-matching workspace pattern as **valid**: drop the root match, since the root is
25+
already its own workspace. The user's config "just works" instead of erroring.
26+
27+
## Design
28+
29+
### Change
30+
31+
In `createProject` (`packages/project-resolver/src/project-discovery.ts`), filter
32+
`packages.packages` to exclude any package whose absolute `dir` equals `packages.rootDir`
33+
before mapping to workspaces:
34+
35+
```ts
36+
workspaces: packages.packages
37+
.filter((pkg) => pkg.dir !== packages.rootDir)
38+
.map((pkg) => createWorkspace(pkg))
39+
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
40+
```
41+
42+
`pkg.dir` and `packages.rootDir` are both absolute paths from `@manypkg`; equality is the
43+
precise "this discovered package is the root" condition, robust regardless of how the
44+
relative path stringifies (`""` vs `"."`).
45+
46+
### Scope
47+
48+
One `.filter` in one function. No new files, no public-API change. `createWorkspace`,
49+
`createRootWorkspace`, and the alias/label validation are untouched — the degenerate
50+
workspace simply never gets created, so the downstream label-collision error no longer
51+
fires.
52+
53+
### Behavior after
54+
55+
- `packages: ["."]` plus real sub-packages → project resolves to root + the real
56+
sub-packages, no empty workspace, exit 0.
57+
- A pattern matching only the root → project resolves to root with zero sub-workspaces.
58+
- Genuine sub-package monorepos are unaffected (their package dirs never equal rootDir).
59+
60+
### Error handling
61+
62+
None added — the case is now valid rather than an error.
63+
64+
## Testing
65+
66+
Implement the deferred `it.todo("single workspace in monorepo")` in
67+
`workspaces-detection.test.ts`: a fixture with `pnpm-workspace.yaml` matching `.` plus a
68+
real sub-package, asserting the project resolves cleanly (root + the real sub, no empty
69+
workspace) via `--show-config --config-key project` / `--list-workspaces`. Optionally a
70+
project-resolver-level assertion that no workspace has an empty relativePath.
71+
72+
## Spec
73+
74+
`spec/06-project.md` — Workspace Discovery gains the root-exclusion rule. CHANGELOG entry +
75+
version bump to 4.1.1 (PATCH: corrects detection behavior; #698's 4.1.0 already merged).

packages/nadle/test/__snapshots__/features/workspaces/workspaces-detection.test.ts.snap

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`workspaces detection > ignores a workspace pattern that matches the project root 1`] = `
4+
---------- Context -----------
5+
Working Directory: /ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__
6+
Command: /ROOT/lib/cli.js --max-workers 1 --no-footer --show-config --config-key project
7+
---------- Stdout ------------
8+
[log] {
9+
"rootWorkspace": {
10+
"label": "",
11+
"packageJson": {
12+
"name": "root",
13+
"type": "module"
14+
},
15+
"absolutePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__",
16+
"dependencies": [],
17+
"relativePath": ".",
18+
"configFilePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__/nadle.config.ts",
19+
"id": "root"
20+
},
21+
"packageManager": "pnpm",
22+
"currentWorkspaceId": "root",
23+
"workspaces": [
24+
{
25+
"id": "packages:only",
26+
"label": "packages:only",
27+
"absolutePath": "/ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__/packages/only",
28+
"relativePath": "packages/only",
29+
"dependencies": [],
30+
"configFilePath": null,
31+
"packageJson": {
32+
"name": "only"
33+
}
34+
}
35+
]
36+
}
37+
`;
38+
339
exports[`workspaces detection > multiple packages 1`] = `
440
---------- Context -----------
541
Working Directory: /ROOT/test/__fixtures__/monorepo/__temp__/__{hash}__

packages/nadle/test/features/workspaces/workspaces-detection.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,27 @@ import { PACKAGE_JSON } from "@nadle/project-resolver";
33
import { expectPass, withFixture, CONFIG_FILE, PNPM_WORKSPACE, createPackageJson, createPnpmWorkspace } from "setup";
44

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

1228
it("one package", async () => {
1329
await withFixture({

packages/project-resolver/src/project-discovery.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ async function createProject(packages: Packages): Promise<Project> {
3939
rootWorkspace,
4040
packageManager: packages.tool.type,
4141
currentWorkspaceId: rootWorkspace.id,
42-
workspaces: packages.packages.map((pkg) => createWorkspace(pkg)).sort((a, b) => a.relativePath.localeCompare(b.relativePath))
42+
// A workspace pattern can match the project root itself (e.g. "."). The root is
43+
// already represented by rootWorkspace, so drop that match instead of creating a
44+
// degenerate empty-path workspace that would later fail label validation.
45+
workspaces: packages.packages
46+
.filter((pkg) => pkg.dir !== packages.rootDir)
47+
.map((pkg) => createWorkspace(pkg))
48+
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
4349
};
4450

4551
return resolveWorkspaceDependencies(project);

spec/06-project.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ Child workspaces are discovered via the package manager's workspace configuratio
4444
- **npm/yarn**: `workspaces` field in root `package.json`
4545

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

4952
Workspaces are sorted by their relative path for deterministic ordering.
5053

spec/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ Versioning follows [Semantic Versioning](https://semver.org/):
88
- **MINOR**: New concept, new section, or materially expanded rules
99
- **PATCH**: Clarifications, corrections, wording improvements
1010

11+
## 4.1.1 — 2026-06-21
12+
13+
### Changed
14+
15+
- 06-project: Workspace discovery now ignores a workspace pattern that matches the project
16+
root directory (e.g. a pattern of `.`). Previously such a match produced a degenerate
17+
empty-path workspace duplicating the root, which then failed label validation with a
18+
confusing error. The root is already represented by the root workspace, so the match is
19+
dropped and the project resolves cleanly.
20+
1121
## 4.1.0 — 2026-06-20
1222

1323
### Added

spec/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Nadle Specification
22

3-
**Version**: 4.1.0
3+
**Version**: 4.1.1
44

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

0 commit comments

Comments
 (0)