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,88 @@
# Alias config validation (#698)

## Problem

`configure({ alias })` accepts an object map or a function mapping workspace paths to
display labels. Today only the **shape** is validated (`assertAlias` in
`file-options-validator.ts` checks object-or-function). Three semantic mistakes were
called out in #698:

1. duplicate aliases mapping different workspaces to the same label,
2. an alias colliding with a real workspace ID,
3. an alias key for a path that matches no workspace.

## Finding: 2 of 3 already validated

`validateWorkspaceLabels` (`packages/kernel/src/workspace-resolver.ts`) runs in
`configureProject` **after** aliases are applied, and already throws on:

- **duplicate label** (case 1) — two workspaces ending up with the same non-empty label,
- **label collides with another workspace's ID** (case 2).

It works for both object- and function-style aliases because it inspects the resolved
labels, not the raw alias input.

The only real gap is **case 3**: an object alias key like `{ "packages/nope": "x" }` where
`packages/nope` is not a workspace. `createAliasResolver` simply never matches that key, so
the typo is silently ignored — no label applied, no error.

## Scope

- **Case 3 only** needs new code. Cases 1 and 2 need tests (characterization), not code.
- **Object-style only.** A function alias is invoked per *known* workspace path, so a
"non-existent path" never arises — there is no enumerable key set to validate.
- The root workspace's relative path is `"."`; `{ ".": "my-root" }` is a valid root alias
and must keep working. Valid key set = `"."` plus every sub-workspace `relativePath`.

## Design

### New unit — `validateAliasKeys(aliasOption, workspaces)`

Location: `packages/kernel/src/workspace-resolver.ts`, beside `validateWorkspaceLabels`
(kernel is zero-dependency; throws a plain `Error` like its siblings).

- No-op when `aliasOption` is `undefined` or a function.
- For an object: every key must equal a known workspace `relativePath` (root's `"."` or a
sub-workspace's). Any key matching none throws:
`Alias key "<key>" does not match any workspace. Known paths: <comma-list>`.
- Pure and independently testable: inputs are the alias keys and the set of known relative
paths; no side effects.

### Wiring

`configureProject` (`packages/project-resolver/src/project-helpers.ts`) calls
`validateAliasKeys(aliasOption, getAllWorkspaces(project))` **before**
`createAliasResolver`, so a typo'd key fails fast — before label resolution and before the
existing post-resolution `validateWorkspaceLabels`.

### Data flow

```
discoverProject
→ configureProject(project, alias)
→ validateAliasKeys(alias, workspaces) // NEW, pre-resolution (case 3)
→ createAliasResolver / apply labels
→ validateWorkspaceLabels(workspaces) // EXISTING, post-resolution (cases 1,2)
```

### Error handling

Plain `Error` from kernel, consistent with the two sibling checks. It propagates out of
project configuration during load and is surfaced like any other config-load failure.

## Testing

Integration (`workspaces-alias.test.ts`, replacing the `it.todo`):

- duplicate alias → error,
- alias equal to an existing workspace ID → error,
- object alias key for a non-existent path → error,
- a fully valid alias config → passes.

Kernel unit test for `validateAliasKeys`: undefined no-op, function no-op, all-valid-keys
no-op, one unknown key throws with the known-paths list.

## Spec

`spec/07-workspace.md` — add the "alias key must match a known workspace path" rule to the
Alias Rules list. CHANGELOG entry + version bump to 4.1.0 (MINOR: materially expanded rule).
2 changes: 1 addition & 1 deletion packages/kernel/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { type AliasOption, createAliasResolver } from "./alias-resolver.js";
export { resolveWorkspace, getWorkspaceById, validateWorkspaceLabels } from "./workspace-resolver.js";
export { parseTaskReference, composeTaskIdentifier, isWorkspaceQualified } from "./task-identifier.js";
export { COLON, SLASH, BACKSLASH, DOT, ROOT_WORKSPACE_ID, VALID_TASK_NAME_PATTERN } from "./constants.js";
export { resolveWorkspace, getWorkspaceById, validateAliasKeys, validateWorkspaceLabels } from "./workspace-resolver.js";
export { type WorkspaceIdentity, type TaskReference, deriveWorkspaceId, isRootWorkspaceId } from "./workspace-identity.js";
15 changes: 15 additions & 0 deletions packages/kernel/src/workspace-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type AliasOption } from "./alias-resolver.js";
import { isRootWorkspaceId, type WorkspaceIdentity } from "./workspace-identity.js";

export function resolveWorkspace<W extends WorkspaceIdentity>(workspaceInput: string, workspaces: readonly W[]): W {
Expand Down Expand Up @@ -42,3 +43,17 @@ export function validateWorkspaceLabels(workspaces: readonly WorkspaceIdentity[]
}
}
}

export function validateAliasKeys(aliasOption: AliasOption, workspaces: readonly WorkspaceIdentity[]): void {
if (aliasOption === undefined || typeof aliasOption === "function") {
return;
}

const knownPaths = new Set(workspaces.map((workspace) => workspace.relativePath));

for (const key of Object.keys(aliasOption)) {
if (!knownPaths.has(key)) {
throw new Error(`Alias key "${key}" does not match any workspace. Known paths: ${[...knownPaths].join(", ")}`);
}
}
}
20 changes: 19 additions & 1 deletion packages/kernel/test/workspace-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { it, expect, describe } from "vitest";

import { getWorkspaceById, resolveWorkspace, type WorkspaceIdentity, validateWorkspaceLabels } from "../src/index.js";
import { getWorkspaceById, resolveWorkspace, validateAliasKeys, type WorkspaceIdentity, validateWorkspaceLabels } from "../src/index.js";

const workspaces: WorkspaceIdentity[] = [
{ label: "", id: "root", relativePath: "." },
Expand Down Expand Up @@ -92,3 +92,21 @@ describe("validateWorkspaceLabels", () => {
expect(() => validateWorkspaceLabels(emptyLabel)).toThrow();
});
});

describe("validateAliasKeys", () => {
it("is a no-op for an undefined alias", () => {
expect(() => validateAliasKeys(undefined, workspaces)).not.toThrow();
});

it("is a no-op for a function alias", () => {
expect(() => validateAliasKeys(() => undefined, workspaces)).not.toThrow();
});

it("passes when every object key matches a known workspace path", () => {
expect(() => validateAliasKeys({ ".": "my-root", "packages/foo": "foo" }, workspaces)).not.toThrow();
});

it("throws when an object key matches no workspace", () => {
expect(() => validateAliasKeys({ "packages/nope": "x" }, workspaces)).toThrow(`Alias key "packages/nope" does not match any workspace`);
});
});
9 changes: 8 additions & 1 deletion packages/nadle/src/core/options/options-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ export class OptionsResolver {

const { alias, ...fileOptions } = this.fileOptionRegistry.get(ROOT_WORKSPACE_ID);

return { fileOptions, project: configureProject(project, alias) };
try {
return { fileOptions, project: configureProject(project, alias) };
} catch (error) {
// configureProject validates aliases via the zero-dependency kernel, which throws
// a plain Error. Translate it to a ConfigurationError so the message is surfaced
// (the top-level handler only prints NadleError messages) with the config exit code.
throw new ConfigurationError(error instanceof Error ? error.message : String(error));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Command: /ROOT/lib/cli.js --max-workers 1 --no-footer --list-workspaces
[log] <Bold>Available workspaces:
</BoldDim>
[log] Root workspace <Bold><Yellow>root</Yellow></BoldDim> <Dim>(alias: projectRoot)</BoldDim>
├── Workspace <Bold><Yellow>minusOne</Yellow></BoldDim>
├── Workspace <Bold><Yellow>minusOne</Yellow></BoldDim> <Dim>(alias: oneMinus)</BoldDim>
├── Workspace <Bold><Yellow>packages</Yellow></BoldDim>
│ ├── Workspace <Bold><Yellow>packages:one</Yellow></BoldDim> <Dim>(alias: one)</BoldDim>
│ └── Workspace <Bold><Yellow>packages:two</Yellow></BoldDim>
Expand Down
56 changes: 49 additions & 7 deletions packages/nadle/test/features/workspaces/workspaces-alias.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { it, describe } from "vitest";
import { expectPass, withFixture, workspaceFixture } from "setup";
import { it, expect, describe } from "vitest";
import { getStderr, expectPass, withFixture, workspaceFixture } from "setup";

describe("workspaces alias", () => {
it("object style", async () => {
Expand Down Expand Up @@ -58,9 +58,51 @@ describe("workspaces alias", () => {
});
});

// TODO(#698): blocked on alias validation not existing yet. Once #698 adds
// semantic validation, assert it rejects invalid configs — duplicate aliases
// mapping to different workspaces, an alias colliding with a real workspace id,
// and an alias for a non-existent path should each error.
it.todo("rejects invalid alias configuration");
it("rejects an alias key that matches no workspace", async () => {
await withFixture({
fixtureDir: "monorepo",
testFn: async ({ exec }) => {
await expect(getStderr(exec`build`)).resolves.toContain(`Alias key "packages/nope" does not match any workspace`);
},
files: workspaceFixture({
workspaces: {
"packages/one": { tasks: [{ name: "build" }] }
},
root: { tasks: [{ name: "build" }], configure: { alias: { "packages/nope": "nope" } } }
})
});
});

it("rejects duplicate aliases mapping to the same label", async () => {
await withFixture({
fixtureDir: "monorepo",
testFn: async ({ exec }) => {
await expect(getStderr(exec`build`)).resolves.toContain(`conflicts with workspace`);
},
files: workspaceFixture({
root: { tasks: [{ name: "build" }], configure: { alias: { "packages/one": "dup", "packages/two": "dup" } } },
workspaces: {
"packages/one": { tasks: [{ name: "build" }] },
"packages/two": { tasks: [{ name: "build" }] }
}
})
});
});

it("rejects an alias that collides with a real workspace id", async () => {
await withFixture({
fixtureDir: "monorepo",
testFn: async ({ exec }) => {
// `packages:two` is another workspace's id; aliasing `packages/one` to it is rejected.
await expect(getStderr(exec`build`)).resolves.toContain(`conflicts with`);
},
files: workspaceFixture({
root: { tasks: [{ name: "build" }], configure: { alias: { "packages/one": "packages:two" } } },
workspaces: {
"packages/one": { tasks: [{ name: "build" }] },
"packages/two": { tasks: [{ name: "build" }] }
}
})
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("workspaces > list", () => {
files: {
[PNPM_WORKSPACE]: createPnpmWorkspace(),
[PACKAGE_JSON]: createPackageJson("root"),
[CONFIG_FILE]: createNadleConfig({ configure: { alias: { ".": "projectRoot", "packages/one": "one", "packages/minusOne": "oneMinus" } } }),
[CONFIG_FILE]: createNadleConfig({ configure: { alias: { ".": "projectRoot", minusOne: "oneMinus", "packages/one": "one" } } }),

zero: {
[PACKAGE_JSON]: createPackageJson("zero")
Expand Down Expand Up @@ -68,7 +68,7 @@ describe("workspaces > list", () => {
files: {
[PNPM_WORKSPACE]: createPnpmWorkspace(),
[PACKAGE_JSON]: createPackageJson("root"),
[CONFIG_FILE]: createNadleConfig({ configure: { alias: { ".": "projectRoot", "packages/one": "one", "packages/minusOne": "oneMinus" } } }),
[CONFIG_FILE]: createNadleConfig({ configure: { alias: { ".": "projectRoot", "packages/one": "one" } } }),

zero: {
[PACKAGE_JSON]: createPackageJson("zero")
Expand Down
3 changes: 3 additions & 0 deletions packages/project-resolver/src/project-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type AliasOption,
validateAliasKeys,
createAliasResolver,
validateWorkspaceLabels,
resolveWorkspace as kernelResolveWorkspace,
Expand All @@ -21,6 +22,8 @@ export function getWorkspaceById(project: Project, workspaceId: string): Workspa
}

export function configureProject(project: Project, aliasOption: AliasOption): Project {
validateAliasKeys(aliasOption, getAllWorkspaces(project));

const resolveAlias = createAliasResolver(aliasOption);

const configuredProject: Project = {
Expand Down
4 changes: 4 additions & 0 deletions spec/07-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ Aliases provide human-readable labels for workspaces. They are configured via th
- An alias must not be empty for non-root workspaces.
- An alias must not duplicate another workspace's label.
- An alias must not duplicate another workspace's ID.
- When the alias is an object map, every key must match a known workspace path — the
root path (`.`) or a sub-workspace's relative path. A key matching no workspace is an
error (this catch does not apply to the function form, which is only ever called with
known workspace paths).
- The root workspace label defaults to empty string (so its tasks display without
a prefix).

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.0 — 2026-06-20

### Added

- 07-workspace: When the workspace alias is given as an object map, every key must match a
known workspace path (the root path `.` or a sub-workspace's relative path). A key that
matches no workspace is now an error, catching typo'd alias keys that were previously
ignored silently. The function form is unaffected — it is only ever invoked with known
workspace paths.

## 4.0.0 — 2026-06-14

### Changed
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.0.0
**Version**: 4.1.0

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