@ldlework/workmark 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/parse.ts CHANGED
@@ -3,12 +3,10 @@
3
3
  *
4
4
  * 1. tokenize — turn argv into a structured token stream (flags vs. positionals)
5
5
  * 2. dispatch — bind tokens to parameter names using positional order and
6
- * schema-declared array flags (repeats accumulate)
6
+ * schema-declared array fields (repeats/multiples accumulate)
7
7
  * 3. coerce — convert raw strings to JSON Schema-declared types
8
8
  *
9
- * Each phase is independently testable and has one job. Coercion is driven by
10
- * the command's JSON Schema rather than heuristics, so `z.string()` fields
11
- * always stay strings and `z.array(...)` fields always come out as arrays.
9
+ * Each phase is independently testable and has one job.
12
10
  */
13
11
 
14
12
  type Token =
@@ -19,6 +17,7 @@ type SchemaProp = {
19
17
  type?: string;
20
18
  items?: { type?: string; enum?: unknown[] };
21
19
  enum?: unknown[];
20
+ anyOf?: unknown[];
22
21
  };
23
22
 
24
23
  type SchemaObject = {
@@ -57,7 +56,16 @@ function tokenize(argv: string[]): Token[] {
57
56
  // --- 2. dispatch ---------------------------------------------------------
58
57
 
59
58
  function isArrayField(schema: SchemaObject, name: string): boolean {
60
- return schema.properties?.[name]?.type === "array";
59
+ const prop = schema.properties?.[name];
60
+ if (!prop) return false;
61
+ if (prop.type === "array") return true;
62
+ // union: accept array as one option
63
+ if (Array.isArray(prop.anyOf)) {
64
+ for (const alt of prop.anyOf) {
65
+ if ((alt as SchemaProp).type === "array") return true;
66
+ }
67
+ }
68
+ return false;
61
69
  }
62
70
 
63
71
  function appendToArray(out: Record<string, unknown>, key: string, value: string): void {
@@ -82,6 +90,19 @@ function assignFlag(
82
90
  out[key] = value;
83
91
  }
84
92
 
93
+ function assignPositional(
94
+ out: Record<string, unknown>,
95
+ schema: SchemaObject,
96
+ key: string,
97
+ value: string,
98
+ ): void {
99
+ if (isArrayField(schema, key)) {
100
+ appendToArray(out, key, value);
101
+ return;
102
+ }
103
+ out[key] = value;
104
+ }
105
+
85
106
  function dispatch(
86
107
  tokens: Token[],
87
108
  positional: string[],
@@ -93,10 +114,16 @@ function dispatch(
93
114
  for (const tok of tokens) {
94
115
  if (tok.kind === "flag") {
95
116
  assignFlag(out, schema, tok.key, tok.value);
96
- } else if (posIdx < positional.length) {
97
- out[positional[posIdx]] = tok.value;
98
- posIdx++;
117
+ continue;
99
118
  }
119
+
120
+ if (posIdx >= positional.length) continue;
121
+
122
+ const currentKey = positional[posIdx];
123
+ assignPositional(out, schema, currentKey, tok.value);
124
+
125
+ // If the current positional is array-valued, stay on it; else advance.
126
+ if (!isArrayField(schema, currentKey)) posIdx++;
100
127
  }
101
128
  return out;
102
129
  }
@@ -123,14 +150,24 @@ function coerceField(value: unknown, prop: SchemaProp | undefined): unknown {
123
150
  if (prop === undefined) return value;
124
151
  if (typeof value === "boolean") return value;
125
152
 
126
- if (prop.type === "array") {
153
+ if (prop.type === "array" || (Array.isArray(prop.anyOf) && prop.anyOf.some((a) => (a as SchemaProp).type === "array"))) {
127
154
  const items = Array.isArray(value) ? value : [value];
128
- return items.map((item) => coerceScalar(String(item), prop.items?.type));
155
+ const innerType = prop.items?.type ?? extractArrayItemType(prop);
156
+ return items.map((item) => coerceScalar(String(item), innerType));
129
157
  }
130
158
 
131
159
  return coerceScalar(String(value), prop.type);
132
160
  }
133
161
 
162
+ function extractArrayItemType(prop: SchemaProp): string | undefined {
163
+ if (!Array.isArray(prop.anyOf)) return undefined;
164
+ for (const alt of prop.anyOf) {
165
+ const a = alt as SchemaProp;
166
+ if (a.type === "array") return a.items?.type;
167
+ }
168
+ return undefined;
169
+ }
170
+
134
171
  function coerce(
135
172
  raw: Record<string, unknown>,
136
173
  schema: SchemaObject,
@@ -1,32 +1,39 @@
1
1
  import { join } from "node:path";
2
- import type { IProject, ProjectDef } from "./types.js";
2
+ import type { IProject, ProjectDef, Trait } from "./types.js";
3
+ import type { TraitRegistry } from "./registry.js";
3
4
 
4
5
  export class Project implements IProject {
5
6
  readonly name: string;
6
7
  readonly dir: string;
7
8
  readonly tags: readonly string[];
8
- readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
9
+ readonly description?: string;
9
10
 
11
+ /** Parsed (defaulted) trait data, keyed by trait name. */
12
+ private readonly traitData: Readonly<Record<string, unknown>>;
10
13
  private readonly paths: Readonly<Record<string, string>>;
11
14
 
12
- constructor(def: ProjectDef, wsDir: string) {
15
+ constructor(def: ProjectDef, wsDir: string, traitData: Record<string, unknown>) {
13
16
  this.name = def.name;
14
17
  this.dir = def.dir ? join(wsDir, def.dir) : wsDir;
15
- this.tags = Object.freeze(def.tags ?? []);
16
- this.capabilities = Object.freeze(def.capabilities ?? {});
17
- this.paths = Object.freeze(def.paths ?? {});
18
+ this.tags = Object.freeze([...(def.tags ?? [])]);
19
+ this.description = def.description;
20
+ this.traitData = Object.freeze(traitData);
21
+ this.paths = Object.freeze({ ...(def.paths ?? {}) });
18
22
  }
19
23
 
20
- has(capability: string): boolean {
21
- return capability in this.capabilities;
24
+ hasTrait(trait: Trait | string): boolean {
25
+ const name = typeof trait === "string" ? trait : trait.name;
26
+ return name in this.traitData;
22
27
  }
23
28
 
24
- capability<T = true | Record<string, unknown>>(name: string): T {
25
- const cap = this.capabilities[name];
26
- if (cap === undefined) {
27
- throw new Error(`Project "${this.name}" does not have capability "${name}"`);
29
+ trait<T>(trait: Trait<string, T>): T {
30
+ const data = this.traitData[trait.name];
31
+ if (data === undefined) {
32
+ throw new Error(
33
+ `Project "${this.name}" does not fulfill trait "${trait.name}"`,
34
+ );
28
35
  }
29
- return cap as T;
36
+ return data as T;
30
37
  }
31
38
 
32
39
  path(name: string): string {
@@ -37,3 +44,41 @@ export class Project implements IProject {
37
44
  return join(this.dir, rel);
38
45
  }
39
46
  }
47
+
48
+ /** Validate a project's `has` against the registry, returning parsed data. */
49
+ export function validateHas(
50
+ def: ProjectDef,
51
+ registry: TraitRegistry,
52
+ sourceFile: string,
53
+ ): Record<string, unknown> {
54
+ const out: Record<string, unknown> = {};
55
+ const has = def.has ?? {};
56
+ for (const [traitName, raw] of Object.entries(has)) {
57
+ const trait = registry.get(traitName);
58
+ if (!trait) {
59
+ const known = registry.all().map((t) => t.name).join(", ") || "(none)";
60
+ throw new Error(
61
+ `Project "${def.name}" declares has.${traitName}, but no such trait is defined\n at ${sourceFile}\n known traits: ${known}`,
62
+ );
63
+ }
64
+ // Marker-trait sugar: `has: { x: true }` parses the trait with `{}`, which
65
+ // triggers per-field defaults. Zod 4's outer `.default({})` returns `{}`
66
+ // for undefined input — useful but NOT deeply defaulted — so we pass `{}`
67
+ // explicitly to get nested defaults.
68
+ const input = raw === true ? {} : raw;
69
+ const result = trait.schema.safeParse(input);
70
+ if (!result.success) {
71
+ const issues = result.error.issues
72
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
73
+ .join("\n");
74
+ const hint = raw === true
75
+ ? `\n hint: "true" is sugar for defaults; trait "${traitName}" has no top-level .default(). Provide an object literal.`
76
+ : "";
77
+ throw new Error(
78
+ `Project "${def.name}" has.${traitName} failed validation:\n${issues}\n at ${sourceFile}${hint}`,
79
+ );
80
+ }
81
+ out[traitName] = result.data;
82
+ }
83
+ return out;
84
+ }
@@ -0,0 +1,80 @@
1
+ import type { Trait } from "./types.js";
2
+
3
+ /** Per-load trait registry. A fresh instance is created for each `loadWorkspace`
4
+ * call so reloads don't trip duplicate-name checks against prior state. */
5
+ export class TraitRegistry {
6
+ private readonly traits = new Map<string, Trait>();
7
+ private readonly sources = new Map<string, string>();
8
+
9
+ register(trait: Trait, sourceFile?: string): void {
10
+ const existing = this.traits.get(trait.name);
11
+ const prevSource = this.sources.get(trait.name);
12
+ if (existing) {
13
+ // Same source file re-executed (jiti moduleCache:false) is idempotent.
14
+ // Different source files with the same name is an error.
15
+ if (sourceFile && prevSource && sourceFile !== prevSource) {
16
+ throw new Error(
17
+ `Trait "${trait.name}" defined twice:\n ${prevSource}\n ${sourceFile}`,
18
+ );
19
+ }
20
+ // Keep the first-registered trait object as canonical.
21
+ return;
22
+ }
23
+ this.traits.set(trait.name, trait);
24
+ if (sourceFile) this.sources.set(trait.name, sourceFile);
25
+ }
26
+
27
+ get(name: string): Trait | undefined {
28
+ return this.traits.get(name);
29
+ }
30
+
31
+ require(name: string): Trait {
32
+ const t = this.traits.get(name);
33
+ if (!t) {
34
+ const known = [...this.traits.keys()].join(", ") || "(none)";
35
+ throw new Error(`Unknown trait "${name}". Known: ${known}`);
36
+ }
37
+ return t;
38
+ }
39
+
40
+ has(name: string): boolean {
41
+ return this.traits.has(name);
42
+ }
43
+
44
+ all(): readonly Trait[] {
45
+ return [...this.traits.values()];
46
+ }
47
+ }
48
+
49
+ /** Ambient registry used by `defineTrait` at module-import time.
50
+ *
51
+ * Stored on `globalThis` so it survives the pnpm+jiti module-duplication problem:
52
+ * the CLI's workmark module and the jiti-loaded trait file's workmark module are
53
+ * distinct instances, but both see the same globalThis slot.
54
+ *
55
+ * The loader installs a fresh registry, imports trait files (which call
56
+ * defineTrait → registerAmbient → reads this slot), then uninstalls. */
57
+ const GLOBAL_KEY = Symbol.for("workmark.ambientRegistry");
58
+ type GlobalSlot = { [K in typeof GLOBAL_KEY]?: TraitRegistry };
59
+
60
+ function slot(): GlobalSlot {
61
+ return globalThis as unknown as GlobalSlot;
62
+ }
63
+
64
+ export function installRegistry(reg: TraitRegistry): void {
65
+ slot()[GLOBAL_KEY] = reg;
66
+ }
67
+
68
+ export function uninstallRegistry(): void {
69
+ delete slot()[GLOBAL_KEY];
70
+ }
71
+
72
+ export function registerAmbient(trait: Trait, sourceFile?: string): void {
73
+ const reg = slot()[GLOBAL_KEY];
74
+ if (!reg) {
75
+ throw new Error(
76
+ `defineTrait called outside of a workspace load. Traits must live in .wm/traits/*.ts files.`,
77
+ );
78
+ }
79
+ reg.register(trait, sourceFile);
80
+ }
package/src/lib/types.ts CHANGED
@@ -1,37 +1,58 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type { z } from "zod";
3
3
 
4
- /** An input schema: either a Zod schema (converted automatically) or raw JSON Schema. */
5
- export type InputSchema = z.ZodType | Record<string, unknown>;
4
+ // ---- Traits ------------------------------------------------------------
6
5
 
7
- /** A record of named Zod schemas or raw JSON Schema property objects. */
8
- export type SchemaFields = Record<string, z.ZodType | Record<string, unknown>>;
6
+ /** A named, schema-backed contract that projects fulfill and commands require.
7
+ *
8
+ * Identity is `name`. Created via `defineTrait`; the returned object carries a
9
+ * phantom type so `cmd({ needs: [docker] })` can infer trait-data types into
10
+ * the handler's `ctx.traits`. */
11
+ export interface Trait<N extends string = string, T = unknown> {
12
+ readonly name: N;
13
+ readonly schema: z.ZodType;
14
+ readonly description?: string;
15
+ /** Phantom for inference. Never accessed at runtime. */
16
+ readonly __brand?: readonly ["trait", N, T];
17
+ }
18
+
19
+ // ---- Schema fields ------------------------------------------------------
20
+
21
+ /** A field schema: a zod type or raw JSON Schema object. */
22
+ export type SchemaField = z.ZodType | Record<string, unknown>;
23
+
24
+ /** A record of named field schemas. */
25
+ export type SchemaFields = Record<string, SchemaField>;
9
26
 
10
- // ---- Project definitions (used in wm.ts files) ----
27
+ // ---- Projects -----------------------------------------------------------
11
28
 
12
29
  export interface ProjectDef {
13
30
  /** Unique identifier. */
14
31
  name: string;
15
32
  /** Project directory relative to the wm.ts location. Defaults to ".". */
16
33
  dir?: string;
17
- /** Arbitrary string tags for grouping/filtering. */
34
+ /** Free-form labels. Filter only — no handler injection. */
18
35
  tags?: string[];
19
36
  /** Named directories, resolved relative to the project dir. */
20
37
  paths?: Record<string, string>;
21
- /** Capabilities this project supports. `true` = use convention, object = custom config. */
22
- capabilities?: Record<string, true | Record<string, unknown>>;
38
+ /** Trait fulfillments. Keys are trait names; values are validated against
39
+ * the trait schema at load time. */
40
+ has?: Record<string, unknown>;
41
+ /** Optional human-readable description. */
42
+ description?: string;
23
43
  }
24
44
 
25
- // ---- Runtime project/workspace interfaces ----
26
-
27
45
  export interface IProject {
28
46
  readonly name: string;
29
47
  readonly dir: string;
30
48
  readonly tags: readonly string[];
31
- readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
49
+ readonly description?: string;
32
50
 
33
- has(capability: string): boolean;
34
- capability<T = true | Record<string, unknown>>(name: string): T;
51
+ /** Check whether this project fulfills a trait (by object or name). */
52
+ hasTrait(trait: Trait | string): boolean;
53
+ /** Get the parsed trait data. Throws if the project does not fulfill it. */
54
+ trait<T>(trait: Trait<string, T>): T;
55
+ /** Resolve a named path relative to this project's directory. */
35
56
  path(name: string): string;
36
57
  }
37
58
 
@@ -40,50 +61,87 @@ export interface IWorkspace {
40
61
  readonly projects: readonly IProject[];
41
62
 
42
63
  get(name: string): IProject;
43
- withCapability(cap: string): IProject[];
64
+ withTrait(trait: Trait | string): IProject[];
44
65
  withTag(tag: string): IProject[];
45
66
  }
46
67
 
47
- // ---- Command definitions ----
68
+ // ---- Commands -----------------------------------------------------------
48
69
 
49
- export type ToolHandler = (
50
- args: Record<string, unknown>,
51
- ) => Promise<CallToolResult>;
70
+ /** How many projects a command runs against. */
71
+ export type SelectMode = "one" | "one-or-many" | "all";
52
72
 
53
- export interface StaticCommandDef {
54
- name: string;
55
- label: string;
56
- description: string;
57
- /** Positional arguments (order = key order). */
58
- args?: SchemaFields;
59
- /** Named flags (--key value). */
60
- flags?: SchemaFields;
61
- handler: ToolHandler;
73
+ /** Framework-standard context passed to every handler. */
74
+ export interface BaseCtx {
75
+ workspace: IWorkspace;
76
+ /** Run a shell command (or sequence) in the resolved cwd. An array is executed
77
+ * sequentially; fails fast; output is concatenated. */
78
+ sh: (cmd: string | readonly string[], opts?: { timeout?: number; env?: Record<string, string> }) => Promise<CallToolResult>;
79
+ /** Run a shell command with explicit options (cwd, timeout, env). */
80
+ exec: (cmd: string, opts: { cwd: string; timeout?: number; env?: Record<string, string> }) => Promise<CallToolResult>;
81
+ ok: (data: unknown) => CallToolResult;
82
+ fail: (error: unknown) => CallToolResult;
83
+ /** Invoke another command by name with wire-shape args. */
84
+ invoke: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
62
85
  }
63
86
 
64
- export interface DynamicCommandDef {
65
- name: string;
66
- label: string;
67
- description: string;
68
- factory: (workspace: IWorkspace) => {
69
- args?: SchemaFields;
70
- flags?: SchemaFields;
71
- handler: ToolHandler;
72
- };
87
+ /** Context for commands with `needs`. Adds `project` and `traits`. */
88
+ export interface NeedsCtx<Traits extends Record<string, unknown>> extends BaseCtx {
89
+ project: IProject;
90
+ traits: Traits;
73
91
  }
74
92
 
75
- export type CommandDef = StaticCommandDef | DynamicCommandDef;
93
+ export type HandlerReturn = CallToolResult;
76
94
 
95
+ /** A resolved, runnable command — what the framework holds after load. */
77
96
  export interface ResolvedCommand {
78
97
  name: string;
79
98
  label: string;
80
99
  group: string;
81
100
  description: string;
82
- /** Merged JSON Schema from args + flags. */
83
101
  inputSchema: Record<string, unknown>;
84
- /** Positional arg names in order. */
85
102
  positional: string[];
86
- handler: ToolHandler;
87
- /** Absolute path to the command source file. */
103
+ handler: ResolvedHandler;
88
104
  sourceFile?: string;
105
+ select: SelectMode;
106
+ /** Names of traits this command requires. Empty array = no needs. */
107
+ needs: string[];
108
+ }
109
+
110
+ export type ResolvedHandler = (
111
+ args: Record<string, unknown>,
112
+ ) => Promise<CallToolResult>;
113
+
114
+ // ---- Result aggregation -------------------------------------------------
115
+
116
+ /** Per-project result shape for multi-target commands. */
117
+ export interface ProjectResult {
118
+ project: string;
119
+ ok: boolean;
120
+ output?: string;
121
+ error?: string;
122
+ }
123
+
124
+ /** Optional reducer for custom multi-target aggregation. */
125
+ export type ReduceFn = (results: ProjectResult[]) => HandlerReturn | Promise<HandlerReturn>;
126
+
127
+ export interface RunOptions {
128
+ order?: "parallel" | "serial";
129
+ concurrency?: number;
130
+ stopOnFailure?: boolean;
131
+ reduce?: ReduceFn;
132
+ }
133
+
134
+ // ---- fromWorkspace marker ----------------------------------------------
135
+
136
+ /** Internal marker stamped on schemas produced by `fromWorkspace`. */
137
+ export const FROM_WORKSPACE = Symbol.for("workmark.fromWorkspace");
138
+ /** Marker for invocation-time (args-dependent) schemas — resolved per-call. */
139
+ export const FROM_ARGS = Symbol.for("workmark.fromArgs");
140
+
141
+ export interface FromWorkspaceResolver {
142
+ (ws: IWorkspace): z.ZodType;
143
+ }
144
+
145
+ export interface FromArgsResolver {
146
+ (ws: IWorkspace, args: Record<string, unknown>): z.ZodType;
89
147
  }
@@ -2,22 +2,25 @@ import { dirname, join, relative } from "node:path";
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { createJiti } from "jiti";
4
4
  import ignore, { type Ignore } from "ignore";
5
- import { Project } from "./project.js";
6
- import type { IProject, IWorkspace, ProjectDef } from "./types.js";
5
+ import { Project, validateHas } from "./project.js";
6
+ import type { IProject, IWorkspace, ProjectDef, Trait } from "./types.js";
7
+ import { TraitRegistry, installRegistry, uninstallRegistry } from "./registry.js";
7
8
  import { jitiOptions } from "./jiti-options.js";
8
9
 
9
10
  export class Workspace implements IWorkspace {
10
11
  readonly root: string;
11
12
  readonly projects: readonly IProject[];
13
+ /** Trait registry populated during load. Exposed for command-load phase. */
14
+ readonly traits: TraitRegistry;
12
15
 
13
16
  private readonly byName: Map<string, IProject>;
14
17
 
15
- constructor(root: string, projects: IProject[]) {
18
+ constructor(root: string, projects: IProject[], traits: TraitRegistry) {
16
19
  this.root = root;
17
- this.projects = Object.freeze(projects);
20
+ this.projects = Object.freeze([...projects]);
21
+ this.traits = traits;
18
22
  this.byName = new Map(projects.map((p) => [p.name, p]));
19
23
 
20
- // Validate unique names
21
24
  if (this.byName.size !== projects.length) {
22
25
  const seen = new Set<string>();
23
26
  for (const p of projects) {
@@ -33,8 +36,9 @@ export class Workspace implements IWorkspace {
33
36
  return p;
34
37
  }
35
38
 
36
- withCapability(cap: string): IProject[] {
37
- return this.projects.filter((p) => p.has(cap));
39
+ withTrait(trait: Trait | string): IProject[] {
40
+ const name = typeof trait === "string" ? trait : trait.name;
41
+ return this.projects.filter((p) => p.hasTrait(name));
38
42
  }
39
43
 
40
44
  withTag(tag: string): IProject[] {
@@ -42,7 +46,8 @@ export class Workspace implements IWorkspace {
42
46
  }
43
47
  }
44
48
 
45
- /** Build an Ignore instance from .gitignore (if present) + hardcoded defaults. */
49
+ // ---- Discovery ---------------------------------------------------------
50
+
46
51
  function loadIgnore(root: string): Ignore {
47
52
  const ig = ignore().add(["node_modules", "dist"]);
48
53
  const gitignorePath = join(root, ".gitignore");
@@ -52,7 +57,6 @@ function loadIgnore(root: string): Ignore {
52
57
  return ig;
53
58
  }
54
59
 
55
- /** Recursively find all wm.ts files, respecting .gitignore rules. */
56
60
  function findWmFiles(root: string): string[] {
57
61
  const ig = loadIgnore(root);
58
62
  const results: string[] = [];
@@ -72,53 +76,86 @@ function findWmFiles(root: string): string[] {
72
76
  return results;
73
77
  }
74
78
 
75
- /** Find the workspace root by walking up from cwd looking for .wm/. */
79
+ function findTraitFiles(root: string): string[] {
80
+ const traitsDir = join(root, ".wm", "traits");
81
+ if (!existsSync(traitsDir)) return [];
82
+ const out: string[] = [];
83
+ function walk(dir: string): void {
84
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
85
+ const full = join(dir, entry.name);
86
+ if (entry.isDirectory()) walk(full);
87
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
88
+ out.push(full);
89
+ }
90
+ }
91
+ }
92
+ walk(traitsDir);
93
+ return out;
94
+ }
95
+
76
96
  function findRoot(from: string): string {
77
97
  let dir = from;
78
98
  while (true) {
79
- if (existsSync(join(dir, ".wm"))) {
80
- return dir;
81
- }
99
+ if (existsSync(join(dir, ".wm"))) return dir;
82
100
  const parent = dirname(dir);
83
101
  if (parent === dir) throw new Error("Could not find workspace root (.wm/ directory)");
84
102
  dir = parent;
85
103
  }
86
104
  }
87
105
 
88
- export async function loadWorkspace(from?: string): Promise<Workspace> {
89
- const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
90
- const jiti = createJiti(root, jitiOptions());
106
+ // ---- Load --------------------------------------------------------------
91
107
 
92
- // Recursively find all wm.ts files
93
- const wmFiles = findWmFiles(root);
108
+ async function loadTraits(root: string, jiti: ReturnType<typeof createJiti>): Promise<TraitRegistry> {
109
+ const registry = new TraitRegistry();
110
+ installRegistry(registry);
111
+ for (const file of findTraitFiles(root)) {
112
+ try {
113
+ await jiti.import(file);
114
+ } catch (err) {
115
+ uninstallRegistry();
116
+ throw new Error(`Failed to import trait file ${file}: ${(err as Error).message}`);
117
+ }
118
+ }
119
+ // Registry stays installed — command loading re-executes trait files
120
+ // under jiti moduleCache:false, and those defineTrait calls need it.
121
+ return registry;
122
+ }
94
123
 
95
- // Import each and build Project instances
124
+ async function loadProjects(
125
+ root: string,
126
+ jiti: ReturnType<typeof createJiti>,
127
+ registry: TraitRegistry,
128
+ ): Promise<Project[]> {
96
129
  const projects: Project[] = [];
97
-
98
- for (const wmFile of wmFiles) {
130
+ for (const wmFile of findWmFiles(root)) {
99
131
  const dir = dirname(wmFile);
100
132
  let exported: unknown;
101
133
  try {
102
134
  exported = await jiti.import(wmFile);
103
135
  } catch (err) {
104
- // Log import failures helps debug bad wm.ts files
105
- console.error(`[workspace] Skipping ${wmFile}: ${(err as Error).message}`);
106
- continue;
136
+ throw new Error(`Failed to import ${wmFile}: ${(err as Error).message}`);
107
137
  }
108
-
109
- // jiti may return { default: ... } when interopDefault doesn't fully unwrap
110
138
  if (exported && typeof exported === "object" && "default" in (exported as Record<string, unknown>)) {
111
139
  exported = (exported as Record<string, unknown>).default;
112
140
  }
113
-
114
141
  const items: unknown[] = Array.isArray(exported) ? exported : [exported];
115
-
116
142
  for (const item of items) {
117
143
  if (item && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string") {
118
- projects.push(new Project(item as ProjectDef, dir));
144
+ const def = item as ProjectDef;
145
+ const traitData = validateHas(def, registry, wmFile);
146
+ projects.push(new Project(def, dir, traitData));
119
147
  }
120
148
  }
121
149
  }
150
+ return projects;
151
+ }
152
+
153
+ export async function loadWorkspace(from?: string): Promise<Workspace> {
154
+ const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
155
+ const jiti = createJiti(root, jitiOptions());
156
+
157
+ const registry = await loadTraits(root, jiti);
158
+ const projects = await loadProjects(root, jiti, registry);
122
159
 
123
- return new Workspace(root, projects);
160
+ return new Workspace(root, projects, registry);
124
161
  }