From c3cbb00ff23c37f1fa9d8aed547568a61558f4d8 Mon Sep 17 00:00:00 2001 From: Nam Hoang Le Date: Sat, 20 Jun 2026 13:17:03 +0700 Subject: [PATCH 1/2] docs: spec for alias config key validation (#698) Add the rule that an object-map alias key must match a known workspace path (root '.' or a sub-workspace relative path); an unmatched key is an error. Function-form aliases are exempt. Bumps spec to 4.1.0. Includes the brainstorming design doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-20-alias-config-validation-design.md | 88 +++++++++++++++++++ spec/07-workspace.md | 4 + spec/CHANGELOG.md | 10 +++ spec/README.md | 2 +- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-06-20-alias-config-validation-design.md diff --git a/docs/superpowers/specs/2026-06-20-alias-config-validation-design.md b/docs/superpowers/specs/2026-06-20-alias-config-validation-design.md new file mode 100644 index 00000000..79df8162 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-alias-config-validation-design.md @@ -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 "" does not match any workspace. Known paths: `. +- 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). diff --git a/spec/07-workspace.md b/spec/07-workspace.md index 7ef605af..c784774e 100644 --- a/spec/07-workspace.md +++ b/spec/07-workspace.md @@ -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). diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 17a80c4e..c20eb83c 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.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 diff --git a/spec/README.md b/spec/README.md index e88b4f48..6ab93ab8 100644 --- a/spec/README.md +++ b/spec/README.md @@ -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. From 31ac97c6764df085f0ef33b176f4c4842ba48f41 Mon Sep 17 00:00:00 2001 From: Nam Hoang Le Date: Sat, 20 Jun 2026 13:31:56 +0700 Subject: [PATCH 2/2] feat: validate object-map alias keys against known workspaces (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validateAliasKeys in @nadle/kernel: an object-map alias key must match a known workspace path (root '.' or a sub-workspace relativePath); an unmatched key now errors instead of being silently ignored. Wired into configureProject, ahead of the existing post-resolution validateWorkspaceLabels (which already covers duplicate-label and label/id-collision cases). Alias validation runs in the zero-dependency kernel and throws a plain Error; options-resolver now translates it to a ConfigurationError so the message is surfaced (the top-level handler only prints NadleError) with the config exit code — this also fixes the previously-swallowed validateWorkspaceLabels messages. Tests: kernel unit tests for validateAliasKeys; integration tests for the three rejection cases. Fixes a latent dead-alias typo in workspaces-list fixtures that the new validation exposed (packages/minusOne -> minusOne). Closes #698 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/kernel/src/index.ts | 2 +- packages/kernel/src/workspace-resolver.ts | 15 +++++ .../kernel/test/workspace-resolver.test.ts | 20 ++++++- .../src/core/options/options-resolver.ts | 9 ++- .../workspaces/workspaces-list.test.ts.snap | 2 +- .../workspaces/workspaces-alias.test.ts | 56 ++++++++++++++++--- .../workspaces/workspaces-list.test.ts | 4 +- .../project-resolver/src/project-helpers.ts | 3 + 8 files changed, 98 insertions(+), 13 deletions(-) diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index b9fc8773..43ae71da 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -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"; diff --git a/packages/kernel/src/workspace-resolver.ts b/packages/kernel/src/workspace-resolver.ts index 8cc12357..0b1e157a 100644 --- a/packages/kernel/src/workspace-resolver.ts +++ b/packages/kernel/src/workspace-resolver.ts @@ -1,3 +1,4 @@ +import { type AliasOption } from "./alias-resolver.js"; import { isRootWorkspaceId, type WorkspaceIdentity } from "./workspace-identity.js"; export function resolveWorkspace(workspaceInput: string, workspaces: readonly W[]): W { @@ -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(", ")}`); + } + } +} diff --git a/packages/kernel/test/workspace-resolver.test.ts b/packages/kernel/test/workspace-resolver.test.ts index f6cd9318..8999216d 100644 --- a/packages/kernel/test/workspace-resolver.test.ts +++ b/packages/kernel/test/workspace-resolver.test.ts @@ -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: "." }, @@ -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`); + }); +}); diff --git a/packages/nadle/src/core/options/options-resolver.ts b/packages/nadle/src/core/options/options-resolver.ts index 0ba89ab1..cb5cd8af 100644 --- a/packages/nadle/src/core/options/options-resolver.ts +++ b/packages/nadle/src/core/options/options-resolver.ts @@ -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)); + } } } diff --git a/packages/nadle/test/__snapshots__/features/workspaces/workspaces-list.test.ts.snap b/packages/nadle/test/__snapshots__/features/workspaces/workspaces-list.test.ts.snap index c71542ce..3196ac94 100644 --- a/packages/nadle/test/__snapshots__/features/workspaces/workspaces-list.test.ts.snap +++ b/packages/nadle/test/__snapshots__/features/workspaces/workspaces-list.test.ts.snap @@ -12,7 +12,7 @@ Command: /ROOT/lib/cli.js --max-workers 1 --no-footer --list-workspaces [log] Available workspaces: [log] Root workspace root (alias: projectRoot) -├── Workspace minusOne +├── Workspace minusOne (alias: oneMinus) ├── Workspace packages │ ├── Workspace packages:one (alias: one) │ └── Workspace packages:two diff --git a/packages/nadle/test/features/workspaces/workspaces-alias.test.ts b/packages/nadle/test/features/workspaces/workspaces-alias.test.ts index 2945cc62..82c11e8c 100644 --- a/packages/nadle/test/features/workspaces/workspaces-alias.test.ts +++ b/packages/nadle/test/features/workspaces/workspaces-alias.test.ts @@ -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 () => { @@ -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" }] } + } + }) + }); + }); }); diff --git a/packages/nadle/test/features/workspaces/workspaces-list.test.ts b/packages/nadle/test/features/workspaces/workspaces-list.test.ts index 322e1718..b7089de9 100644 --- a/packages/nadle/test/features/workspaces/workspaces-list.test.ts +++ b/packages/nadle/test/features/workspaces/workspaces-list.test.ts @@ -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") @@ -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") diff --git a/packages/project-resolver/src/project-helpers.ts b/packages/project-resolver/src/project-helpers.ts index 01ea955f..cf1ad2e5 100644 --- a/packages/project-resolver/src/project-helpers.ts +++ b/packages/project-resolver/src/project-helpers.ts @@ -1,5 +1,6 @@ import { type AliasOption, + validateAliasKeys, createAliasResolver, validateWorkspaceLabels, resolveWorkspace as kernelResolveWorkspace, @@ -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 = {