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
6 changes: 3 additions & 3 deletions src/bundle/compose.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { UsageError } from "../errors.ts";
import { NotFoundError, UsageError } from "../errors.ts";
import { ARTIFACT_KINDS } from "./kinds.ts";
import type { BundleManifest, BundleSettings } from "./manifest.ts";

export type ResolvedBundle = Omit<BundleManifest, "extends">;

export function compose(name: string, index: Map<string, BundleManifest>): ResolvedBundle {
if (!index.has(name)) {
throw new UsageError(`bundle '${name}' not found`);
throw new NotFoundError(`bundle '${name}' not found`);
}
const order = linearize(name, index);
// order is oldest-first: ancestors before descendants. Merge left-to-right
Expand Down Expand Up @@ -43,7 +43,7 @@ function linearize(start: string, index: Map<string, BundleManifest>): string[]
}
const cur = index.get(n);
if (!cur) {
throw new UsageError(
throw new NotFoundError(
`bundle '${start}' extends missing parent '${n}' (chain: ${[...stack, n].join(" → ")})`,
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/bundle/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { UsageError } from "../errors.ts";
import { NotFoundError, UsageError } from "../errors.ts";
import { isDir } from "../target/walk.ts";
import type { ResolvedBundle } from "./compose.ts";
import { ARTIFACT_KINDS, type ArtifactKind } from "./kinds.ts";
Expand Down Expand Up @@ -50,7 +50,7 @@ export function resolveSources(bundle: ResolvedBundle, opts: ResolveOpts): Resol
);
}
if (missing.length > 0) {
throw new UsageError(`bundle '${bundle.name}': source(s) not found: ${missing.join(", ")}`);
throw new NotFoundError(`bundle '${bundle.name}': source(s) not found: ${missing.join(", ")}`);
}

if (opts.projectSkillsDir && isDir(opts.projectSkillsDir)) {
Expand Down
36 changes: 30 additions & 6 deletions test/unit/bundle/compose.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { compose } from "../../../src/bundle/compose.ts";
import type { BundleManifest } from "../../../src/bundle/manifest.ts";
import { CliError } from "../../../src/errors.ts";

function m(name: string, partial: Partial<BundleManifest> = {}): BundleManifest {
return { name, sourcePath: `/x/${name}.md`, body: "", ...partial };
Expand All @@ -10,6 +11,16 @@ function index(...mans: BundleManifest[]): Map<string, BundleManifest> {
return new Map(mans.map((x) => [x.name, x]));
}

function thrown(fn: () => unknown): CliError {
try {
fn();
} catch (e) {
if (e instanceof CliError) return e;
throw e;
}
throw new Error("expected function to throw");
}

describe("compose", () => {
it("returns the bundle as-is when extends is absent", () => {
const ix = index(m("base", { skills: ["a", "b"] }));
Expand Down Expand Up @@ -113,14 +124,27 @@ describe("compose", () => {
expect(r.skills).toEqual(["g1", "p11", "p21"]);
});

it("missing parent → error names the missing bundle and chain", () => {
const ix = index(m("child", { extends: ["ghost"] }));
expect(() => compose("child", ix)).toThrow(/ghost/);
it("unknown bundle name → not found (exit 3)", () => {
const err = thrown(() => compose("ghost", index(m("base"))));
expect(err.name).toBe("NotFoundError");
expect(err.exitCode).toBe(3);
expect(err.message).toMatch(/ghost.*not found/);
});

it("cycle in extends → error", () => {
const ix = index(m("a", { extends: ["b"] }), m("b", { extends: ["a"] }));
expect(() => compose("a", ix)).toThrow(/cycle/i);
it("missing parent → not found (exit 3), names the missing bundle and chain", () => {
const err = thrown(() => compose("child", index(m("child", { extends: ["ghost"] }))));
expect(err.name).toBe("NotFoundError");
expect(err.exitCode).toBe(3);
expect(err.message).toMatch(/ghost/);
});

it("cycle in extends → usage error (exit 2), not not-found", () => {
const err = thrown(() =>
compose("a", index(m("a", { extends: ["b"] }), m("b", { extends: ["a"] }))),
);
expect(err.name).toBe("UsageError");
expect(err.exitCode).toBe(2);
expect(err.message).toMatch(/cycle/i);
});
});

Expand Down
29 changes: 21 additions & 8 deletions test/unit/bundle/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ResolvedBundle } from "../../../src/bundle/compose.ts";
import { resolveSources } from "../../../src/bundle/resolve.ts";
import { CliError } from "../../../src/errors.ts";
import { cleanup, makeTmpDir } from "../../helpers/tmp.ts";

function thrown(fn: () => unknown): CliError {
try {
fn();
} catch (e) {
if (e instanceof CliError) return e;
throw e;
}
throw new Error("expected function to throw");
}

describe("resolveSources", () => {
let root: string;
let roots: {
Expand Down Expand Up @@ -60,18 +71,20 @@ describe("resolveSources", () => {
expect(out.warnings).toEqual([]);
});

it("bare ref (no '/') in manifest → hints at missing source qualifier", () => {
it("bare ref (no '/') in manifest → usage error (exit 2), hints at missing source qualifier", () => {
mkSubArtifact(roots.skills, "local", "tdd");
expect(() => resolveSources(bundle({ skills: ["tdd"] }), { roots })).toThrow(
/missing source qualifier; use '<source>\/<leaf>'/,
);
const err = thrown(() => resolveSources(bundle({ skills: ["tdd"] }), { roots }));
expect(err.name).toBe("UsageError");
expect(err.exitCode).toBe(2);
expect(err.message).toMatch(/missing source qualifier; use '<source>\/<leaf>'/);
});

it("qualified-but-missing ref → original 'not found' message (no hint)", () => {
it("qualified-but-missing ref → not found (exit 3), original 'not found' message (no hint)", () => {
mkSubArtifact(roots.skills, "local", "tdd");
expect(() => resolveSources(bundle({ skills: ["local/ghost"] }), { roots })).toThrow(
/source\(s\) not found: skills\/local\/ghost/,
);
const err = thrown(() => resolveSources(bundle({ skills: ["local/ghost"] }), { roots }));
expect(err.name).toBe("NotFoundError");
expect(err.exitCode).toBe(3);
expect(err.message).toMatch(/source\(s\) not found: skills\/local\/ghost/);
});

it("resolves all artifact kinds independently", () => {
Expand Down
Loading