diff --git a/docs/superpowers/specs/2026-06-20-root-matching-workspace-design.md b/docs/superpowers/specs/2026-06-20-root-matching-workspace-design.md new file mode 100644 index 00000000..fbbe0ae4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-root-matching-workspace-design.md @@ -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). diff --git a/packages/nadle/test/__snapshots__/features/workspaces/workspaces-detection.test.ts.snap b/packages/nadle/test/__snapshots__/features/workspaces/workspaces-detection.test.ts.snap index 49cb3a07..d7163e71 100644 --- a/packages/nadle/test/__snapshots__/features/workspaces/workspaces-detection.test.ts.snap +++ b/packages/nadle/test/__snapshots__/features/workspaces/workspaces-detection.test.ts.snap @@ -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}__ diff --git a/packages/nadle/test/features/workspaces/workspaces-detection.test.ts b/packages/nadle/test/features/workspaces/workspaces-detection.test.ts index a9cef740..448316ea 100644 --- a/packages/nadle/test/features/workspaces/workspaces-detection.test.ts +++ b/packages/nadle/test/features/workspaces/workspaces-detection.test.ts @@ -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({ diff --git a/packages/project-resolver/src/project-discovery.ts b/packages/project-resolver/src/project-discovery.ts index 7f705159..5cc6f87e 100644 --- a/packages/project-resolver/src/project-discovery.ts +++ b/packages/project-resolver/src/project-discovery.ts @@ -39,7 +39,13 @@ async function createProject(packages: Packages): Promise { 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); diff --git a/spec/06-project.md b/spec/06-project.md index f45f0ac2..32c0df54 100644 --- a/spec/06-project.md +++ b/spec/06-project.md @@ -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. diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index c20eb83c..48a39e33 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -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 diff --git a/spec/README.md b/spec/README.md index 6ab93ab8..1f3875fc 100644 --- a/spec/README.md +++ b/spec/README.md @@ -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.