@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/dist/lib/load.js CHANGED
@@ -1,23 +1,140 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, extname, join } from "node:path";
3
3
  import { createJiti } from "jiti";
4
4
  import { z } from "zod";
5
+ import { execAsync, ok, fail, shSeq } from "./helpers.js";
6
+ import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
5
7
  import { jitiOptions } from "./jiti-options.js";
6
- /** Title-case a folder name: "docker" → "Docker" */
8
+ // --- Metadata derivation -------------------------------------------------
7
9
  function titleCase(s) {
8
- return s.charAt(0).toUpperCase() + s.slice(1);
10
+ return s
11
+ .split(/[_\s-]+/)
12
+ .filter(Boolean)
13
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
14
+ .join(" ");
9
15
  }
10
- function isStatic(def) {
11
- return "handler" in def;
16
+ function extractLeadingJsDoc(source) {
17
+ const exportIdx = source.indexOf("export default");
18
+ const before = exportIdx < 0 ? source : source.slice(0, exportIdx);
19
+ const blocks = [...before.matchAll(/\/\*\*([\s\S]*?)\*\//g)];
20
+ if (blocks.length === 0)
21
+ return undefined;
22
+ const last = blocks[blocks.length - 1][1];
23
+ return last
24
+ .split("\n")
25
+ .map((line) => line.replace(/^\s*\*\s?/, "").trimEnd())
26
+ .join(" ")
27
+ .trim() || undefined;
12
28
  }
13
- /** Convert a single field (Zod or raw JSON Schema property) to a JSON Schema property. */
29
+ function deriveMetadata(def, sourceFile) {
30
+ const filenameName = basename(sourceFile, extname(sourceFile));
31
+ const meta = def.meta ?? {};
32
+ const name = meta.name ?? filenameName;
33
+ const label = meta.label ?? titleCase(name);
34
+ const description = meta.description ?? extractLeadingJsDoc(readFileSync(sourceFile, "utf-8")) ?? "";
35
+ return { name, label, description };
36
+ }
37
+ /** Detect whether a schema (or any of its wrappers) carries a FROM_ARGS marker.
38
+ * Such fields are resolved at invocation time, not load time. */
39
+ function hasFromArgs(field) {
40
+ if (!(field instanceof z.ZodType))
41
+ return false;
42
+ return walkForFromArgs(field);
43
+ }
44
+ function walkForFromArgs(node) {
45
+ if (node[FROM_ARGS])
46
+ return true;
47
+ if (node instanceof z.ZodOptional)
48
+ return walkForFromArgs(node.unwrap());
49
+ if (node instanceof z.ZodNullable)
50
+ return walkForFromArgs(node.unwrap());
51
+ if (node instanceof z.ZodDefault)
52
+ return walkForFromArgs(node.unwrap());
53
+ if (node instanceof z.ZodArray)
54
+ return walkForFromArgs(node.element);
55
+ return false;
56
+ }
57
+ function resolveFromWorkspace(field, ws) {
58
+ if (!(field instanceof z.ZodType))
59
+ return field;
60
+ return walk(field, ws);
61
+ }
62
+ function walk(node, ws) {
63
+ // Leave FROM_ARGS markers alone — they resolve per-invocation.
64
+ if (node[FROM_ARGS])
65
+ return node;
66
+ const marked = node[FROM_WORKSPACE];
67
+ if (marked)
68
+ return marked(ws);
69
+ if (node instanceof z.ZodOptional)
70
+ return walk(node.unwrap(), ws).optional();
71
+ if (node instanceof z.ZodNullable)
72
+ return walk(node.unwrap(), ws).nullable();
73
+ if (node instanceof z.ZodDefault) {
74
+ const def = node._def.defaultValue;
75
+ return walk(node.unwrap(), ws).default(def);
76
+ }
77
+ if (node instanceof z.ZodArray) {
78
+ return z.array(walk(node.element, ws));
79
+ }
80
+ return node;
81
+ }
82
+ function resolveFields(fields, ws) {
83
+ if (!fields)
84
+ return fields;
85
+ const out = {};
86
+ for (const [k, v] of Object.entries(fields)) {
87
+ out[k] = resolveFromWorkspace(v, ws);
88
+ }
89
+ return out;
90
+ }
91
+ /** Resolve FROM_ARGS markers in a walked schema tree against a specific args object. */
92
+ function walkWithArgs(node, ws, args) {
93
+ const marked = node[FROM_ARGS];
94
+ if (marked)
95
+ return marked(ws, args);
96
+ if (node instanceof z.ZodOptional)
97
+ return walkWithArgs(node.unwrap(), ws, args).optional();
98
+ if (node instanceof z.ZodNullable)
99
+ return walkWithArgs(node.unwrap(), ws, args).nullable();
100
+ if (node instanceof z.ZodDefault) {
101
+ const def = node._def.defaultValue;
102
+ return walkWithArgs(node.unwrap(), ws, args).default(def);
103
+ }
104
+ if (node instanceof z.ZodArray) {
105
+ return z.array(walkWithArgs(node.element, ws, args));
106
+ }
107
+ return node;
108
+ }
109
+ /** Per-invocation: validate any FROM_ARGS fields against the resolved schema.
110
+ * Returns the parsed args (with fromArgs fields replaced by validated values). */
111
+ function validateFromArgsFields(args, fromArgsFields, ws) {
112
+ if (fromArgsFields.length === 0)
113
+ return args;
114
+ const out = { ...args };
115
+ for (const { name, schema } of fromArgsFields) {
116
+ const resolved = walkWithArgs(schema, ws, args);
117
+ const raw = args[name];
118
+ if (raw === undefined)
119
+ continue;
120
+ const result = resolved.safeParse(raw);
121
+ if (!result.success) {
122
+ const issues = result.error.issues
123
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
124
+ .join("\n");
125
+ throw new Error(`Field "${name}" failed validation:\n${issues}`);
126
+ }
127
+ out[name] = result.data;
128
+ }
129
+ return out;
130
+ }
131
+ // --- Schema merging + project-arg synthesis ------------------------------
14
132
  function fieldToJsonSchema(field) {
15
133
  if (field instanceof z.ZodType) {
16
134
  return z.toJSONSchema(field);
17
135
  }
18
136
  return field;
19
137
  }
20
- /** A field is required unless it has a default or was wrapped with .optional(). */
21
138
  function isRequired(field, schema) {
22
139
  if ("default" in schema)
23
140
  return false;
@@ -25,15 +142,44 @@ function isRequired(field, schema) {
25
142
  return false;
26
143
  return true;
27
144
  }
28
- /** Merge args + flags into a single JSON Schema object and return positional names. */
29
- function mergeSchemas(args, flags) {
145
+ function projectArgFor(needs, select, workspace) {
146
+ if (needs.length === 0)
147
+ return null;
148
+ if (select === "all")
149
+ return null; // framework selects; no user input
150
+ const eligible = workspace.projects
151
+ .filter((p) => needs.every((t) => p.hasTrait(t)))
152
+ .map((p) => p.name);
153
+ if (eligible.length === 0) {
154
+ const traits = needs.map((t) => t.name).join(", ");
155
+ throw new Error(`Command requires needs [${traits}] but no projects fulfill all of them`);
156
+ }
157
+ const enum_ = z.enum(eligible);
158
+ if (select === "one-or-many") {
159
+ // accept string or array; we'll coerce at parse time
160
+ return { schema: z.union([enum_, z.array(enum_)]), required: false, eligible };
161
+ }
162
+ return { schema: enum_, required: true, eligible };
163
+ }
164
+ function buildSchema(args, flags, projectArg) {
30
165
  const properties = {};
31
166
  const required = [];
32
167
  const positional = [];
33
- const collect = (source, isPositional) => {
34
- if (!source)
168
+ let projectArgName = null;
169
+ if (projectArg) {
170
+ projectArgName = "project";
171
+ properties.project = fieldToJsonSchema(projectArg.schema);
172
+ positional.push("project");
173
+ if (projectArg.required)
174
+ required.push("project");
175
+ }
176
+ const collect = (src, isPositional) => {
177
+ if (!src)
35
178
  return;
36
- for (const [name, field] of Object.entries(source)) {
179
+ for (const [name, field] of Object.entries(src)) {
180
+ if (name === "project" && projectArgName) {
181
+ throw new Error(`Field "project" collides with framework-injected ctx.project.\n hint: rename the field; ctx.project is always present under needs`);
182
+ }
37
183
  const schema = fieldToJsonSchema(field);
38
184
  properties[name] = schema;
39
185
  if (isPositional)
@@ -44,32 +190,245 @@ function mergeSchemas(args, flags) {
44
190
  };
45
191
  collect(args, true);
46
192
  collect(flags, false);
47
- const inputSchema = {
48
- type: "object",
49
- properties,
50
- };
51
- if (required.length > 0) {
193
+ const inputSchema = { type: "object", properties };
194
+ if (required.length > 0)
52
195
  inputSchema.required = required;
196
+ return { inputSchema, positional, projectArgName };
197
+ }
198
+ function makeBaseCtx(ws, cwdResolver, host) {
199
+ return {
200
+ workspace: ws,
201
+ ok,
202
+ fail,
203
+ sh: (cmd, opts) => {
204
+ const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
205
+ return Array.isArray(cmd)
206
+ ? shSeq(cmd, options)
207
+ : execAsync(cmd, options);
208
+ },
209
+ exec: (cmd, opts) => execAsync(cmd, opts),
210
+ invoke: host.invoke,
211
+ };
212
+ }
213
+ function makeNeedsCtx(ws, project, needs, host) {
214
+ const traits = {};
215
+ for (const t of needs)
216
+ traits[t.name] = project.trait(t);
217
+ return {
218
+ ...makeBaseCtx(ws, () => project.dir, host),
219
+ project,
220
+ traits,
221
+ };
222
+ }
223
+ // --- Handler wrapping ----------------------------------------------------
224
+ function stripProjectArg(args) {
225
+ const { project: _drop, ...rest } = args;
226
+ return rest;
227
+ }
228
+ function isCallToolResult(v) {
229
+ return typeof v === "object" && v !== null && "content" in v;
230
+ }
231
+ async function runHandler(raw, args, ctx) {
232
+ try {
233
+ const out = await raw(args, ctx);
234
+ if (isCallToolResult(out))
235
+ return out;
236
+ return { content: [{ type: "text", text: String(out) }] };
237
+ }
238
+ catch (e) {
239
+ return fail(e);
240
+ }
241
+ }
242
+ function resolveProjects(needs, select, workspace, argsValue) {
243
+ const eligible = workspace.projects.filter((p) => needs.every((t) => p.hasTrait(t)));
244
+ const eligibleList = eligible.map((p) => p.name).join(", ");
245
+ const traitList = needs.map((t) => t.name).join(", ");
246
+ if (select === "all")
247
+ return eligible;
248
+ const names = Array.isArray(argsValue)
249
+ ? argsValue
250
+ : typeof argsValue === "string"
251
+ ? [argsValue]
252
+ : [];
253
+ if (names.length === 0) {
254
+ if (select === "one") {
255
+ throw new Error(`Missing project argument.\n needs: [${traitList}]\n eligible: ${eligibleList}`);
256
+ }
257
+ throw new Error(`Missing project argument — select "${select}" requires 1+ project names.\n needs: [${traitList}]\n eligible: ${eligibleList}`);
53
258
  }
54
- return { inputSchema, positional };
259
+ const resolved = [];
260
+ for (const n of names) {
261
+ let p;
262
+ try {
263
+ p = workspace.get(n);
264
+ }
265
+ catch {
266
+ throw new Error(`Unknown project "${n}".\n available: ${workspace.projects.map((p) => p.name).join(", ")}\n eligible for this command: ${eligibleList}`);
267
+ }
268
+ const missing = needs.filter((t) => !p.hasTrait(t)).map((t) => t.name);
269
+ if (missing.length > 0) {
270
+ throw new Error(`Project "${p.name}" does not fulfill required trait(s) [${missing.join(", ")}].\n eligible projects: ${eligibleList}`);
271
+ }
272
+ resolved.push(p);
273
+ }
274
+ return resolved;
55
275
  }
56
- function resolve(def, workspace, group, sourceFile) {
57
- let args;
58
- let flags;
59
- let handler;
60
- if (isStatic(def)) {
61
- args = def.args;
62
- flags = def.flags;
63
- handler = def.handler;
276
+ async function runMultiple(raw, args, projects, needs, workspace, run, host) {
277
+ const callerArgs = stripProjectArg(args);
278
+ const runOne = async (p) => {
279
+ const ctx = makeNeedsCtx(workspace, p, needs, host);
280
+ const res = await runHandler(raw, callerArgs, ctx);
281
+ const text = res.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
282
+ return { project: p.name, ok: !res.isError, output: text, error: res.isError ? text : undefined };
283
+ };
284
+ // serial when requested; otherwise parallel with concurrency cap
285
+ const serial = run?.order === "serial";
286
+ const concurrency = serial ? 1 : (run?.concurrency ?? 4);
287
+ const results = new Array(projects.length);
288
+ let idx = 0;
289
+ const workers = Array.from({ length: Math.min(concurrency, projects.length) }, async () => {
290
+ while (true) {
291
+ const i = idx++;
292
+ if (i >= projects.length)
293
+ return;
294
+ results[i] = await runOne(projects[i]);
295
+ if (run?.stopOnFailure && !results[i].ok) {
296
+ idx = projects.length;
297
+ return;
298
+ }
299
+ }
300
+ });
301
+ await Promise.all(workers);
302
+ const realResults = results.filter(Boolean);
303
+ if (run?.reduce) {
304
+ return run.reduce(realResults);
64
305
  }
65
- else {
66
- const result = def.factory(workspace);
67
- args = result.args;
68
- flags = result.flags;
69
- handler = result.handler;
306
+ // default aggregation — if only one ran, return its output directly
307
+ if (realResults.length === 1) {
308
+ const r = realResults[0];
309
+ return r.ok ? ok(r.output ?? "") : fail(r.error ?? r.output ?? "");
70
310
  }
71
- const { inputSchema, positional } = mergeSchemas(args, flags);
72
- return { name: def.name, label: def.label, group, description: def.description, inputSchema, positional, handler, sourceFile };
311
+ const parts = [];
312
+ let anyError = false;
313
+ for (const r of realResults) {
314
+ parts.push(`--- ${r.project} ---\n${r.output ?? ""}`);
315
+ if (!r.ok)
316
+ anyError = true;
317
+ }
318
+ const text = parts.join("\n\n");
319
+ return anyError ? fail(text) : ok(text);
320
+ }
321
+ // --- Main resolve --------------------------------------------------------
322
+ function collectFromArgsFields(fields) {
323
+ const out = [];
324
+ if (!fields)
325
+ return out;
326
+ for (const [name, field] of Object.entries(fields)) {
327
+ if (field instanceof z.ZodType && hasFromArgs(field))
328
+ out.push({ name, schema: field });
329
+ }
330
+ return out;
331
+ }
332
+ function resolve(def, workspace, group, sourceFile, host) {
333
+ const meta = deriveMetadata(def, sourceFile);
334
+ const rawNeeds = def.needs ?? [];
335
+ const needs = rawNeeds.map((t) => typeof t === "string" ? workspace.traits.require(t) : t);
336
+ const select = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
337
+ if (needs.length === 0 && select !== "one") {
338
+ throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
339
+ }
340
+ // `for` binds this command to a named project. No project arg is exposed on
341
+ // any surface; ctx.project is the bound project.
342
+ let boundProject = null;
343
+ if (def.for !== undefined) {
344
+ if (needs.length === 0) {
345
+ throw new Error(`Command "${meta.name}": \`for\` requires \`needs\` (a fixed project without required traits is meaningless)\n at ${sourceFile}`);
346
+ }
347
+ try {
348
+ boundProject = workspace.get(def.for);
349
+ }
350
+ catch {
351
+ throw new Error(`Command "${meta.name}": \`for: "${def.for}"\` — project not found\n at ${sourceFile}\n available: ${workspace.projects.map(p => p.name).join(", ")}`);
352
+ }
353
+ for (const t of needs) {
354
+ if (!boundProject.hasTrait(t)) {
355
+ throw new Error(`Command "${meta.name}": bound project "${def.for}" does not fulfill trait "${t.name}"\n at ${sourceFile}`);
356
+ }
357
+ }
358
+ }
359
+ const resolvedArgs = resolveFields(def.args, workspace);
360
+ const resolvedFlags = resolveFields(def.flags, workspace);
361
+ // Collect invocation-time-resolved fields for per-call validation.
362
+ const fromArgsFields = [
363
+ ...collectFromArgsFields(resolvedArgs),
364
+ ...collectFromArgsFields(resolvedFlags),
365
+ ];
366
+ // `for` suppresses the user-facing project arg.
367
+ const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
368
+ const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
369
+ const handler = async (args) => {
370
+ const validated = validateFromArgsFields(args, fromArgsFields, workspace);
371
+ if (needs.length === 0) {
372
+ const ctx = makeBaseCtx(workspace, () => workspace.root, host);
373
+ return runHandler(def.handler, validated, ctx);
374
+ }
375
+ if (boundProject) {
376
+ const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
377
+ return runHandler(def.handler, validated, ctx);
378
+ }
379
+ const projects = resolveProjects(needs, select, workspace, validated.project);
380
+ if (select === "one") {
381
+ const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
382
+ return runHandler(def.handler, stripProjectArg(validated), ctx);
383
+ }
384
+ return runMultiple(def.handler, validated, projects, needs, workspace, def.run, host);
385
+ };
386
+ return {
387
+ name: meta.name,
388
+ label: meta.label,
389
+ group,
390
+ description: meta.description,
391
+ inputSchema,
392
+ positional,
393
+ handler,
394
+ sourceFile,
395
+ select,
396
+ needs: needs.map((t) => t.name),
397
+ };
398
+ }
399
+ // --- Discovery -----------------------------------------------------------
400
+ async function importCommand(jiti, filePath) {
401
+ const mod = (await jiti.import(filePath));
402
+ return mod.default;
403
+ }
404
+ function isCommandFile(name) {
405
+ return name.endsWith(".ts") && !name.endsWith(".d.ts");
406
+ }
407
+ /** Walk the commands dir recursively. Maps paths to colon-joined command names. */
408
+ function walkCommands(root) {
409
+ const out = [];
410
+ function walk(dir, prefix) {
411
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
412
+ const full = join(dir, entry.name);
413
+ if (entry.isDirectory()) {
414
+ walk(full, [...prefix, entry.name]);
415
+ }
416
+ else if (isCommandFile(entry.name)) {
417
+ const base = entry.name.replace(/\.ts$/, "");
418
+ if (base.includes(":")) {
419
+ throw new Error(`Command filename contains ':' which is reserved: ${full}`);
420
+ }
421
+ const parts = base === "index" ? prefix : [...prefix, base];
422
+ if (parts.length === 0)
423
+ continue; // bare root index.ts not allowed
424
+ const resolvedName = parts.join(":");
425
+ const group = prefix.length > 0 ? titleCase(prefix[0]) : "";
426
+ out.push({ filePath: full, resolvedName, group });
427
+ }
428
+ }
429
+ }
430
+ walk(root, []);
431
+ return out;
73
432
  }
74
433
  export async function loadCommands(workspace) {
75
434
  const commandsDir = join(workspace.root, ".wm", "commands");
@@ -77,29 +436,43 @@ export async function loadCommands(workspace) {
77
436
  return [];
78
437
  const jiti = createJiti(commandsDir, jitiOptions());
79
438
  const commands = [];
80
- // Ungrouped (root-level) commands first
81
- for (const entry of readdirSync(commandsDir)) {
82
- const entryPath = join(commandsDir, entry);
83
- const stat = statSync(entryPath);
84
- if (!stat.isDirectory() && entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
85
- const mod = await jiti.import(entryPath);
86
- commands.push(resolve(mod.default, workspace, "", entryPath));
87
- }
88
- }
89
- // Grouped commands (subdirectories)
90
- for (const entry of readdirSync(commandsDir)) {
91
- const entryPath = join(commandsDir, entry);
92
- const stat = statSync(entryPath);
93
- if (stat.isDirectory()) {
94
- const group = titleCase(entry);
95
- for (const file of readdirSync(entryPath)) {
96
- if (!file.endsWith(".ts") || file.endsWith(".d.ts"))
97
- continue;
98
- const filePath = join(entryPath, file);
99
- const mod = await jiti.import(filePath);
100
- commands.push(resolve(mod.default, workspace, group, filePath));
439
+ const byName = new Map();
440
+ // Invocation host: commands map is populated progressively; cycle-detected per-call.
441
+ const activeStack = new Set();
442
+ const host = {
443
+ invoke: async (name, args) => {
444
+ if (activeStack.has(name)) {
445
+ const trace = [...activeStack, name].join(" ");
446
+ return fail(`Cyclic invocation: ${trace}`);
447
+ }
448
+ const target = byName.get(name);
449
+ if (!target)
450
+ return fail(`Unknown command: "${name}"`);
451
+ activeStack.add(name);
452
+ try {
453
+ return await target.handler(args);
101
454
  }
455
+ finally {
456
+ activeStack.delete(name);
457
+ }
458
+ },
459
+ };
460
+ const discovered = walkCommands(commandsDir);
461
+ for (const d of discovered) {
462
+ const def = await importCommand(jiti, d.filePath);
463
+ if (!def)
464
+ continue;
465
+ const cmd = resolve(def, workspace, d.group, d.filePath, host);
466
+ // Override resolved name from path-derived name (filename was the fallback in meta).
467
+ // Meta.name wins if explicitly set; otherwise use the discovered name.
468
+ const userProvidedName = def.meta?.name;
469
+ if (!userProvidedName)
470
+ cmd.name = d.resolvedName;
471
+ if (byName.has(cmd.name)) {
472
+ throw new Error(`Duplicate command name "${cmd.name}": ${d.filePath} collides with ${byName.get(cmd.name).sourceFile}`);
102
473
  }
474
+ byName.set(cmd.name, cmd);
475
+ commands.push(cmd);
103
476
  }
104
477
  return commands;
105
478
  }
@@ -3,11 +3,9 @@
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
  export declare function parseArgs(argv: string[], positional: string[], schema: Record<string, unknown>): Record<string, unknown>;
package/dist/lib/parse.js 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
  // --- 1. tokenize ---------------------------------------------------------
14
12
  function tokenize(argv) {
@@ -38,7 +36,19 @@ function tokenize(argv) {
38
36
  }
39
37
  // --- 2. dispatch ---------------------------------------------------------
40
38
  function isArrayField(schema, name) {
41
- return schema.properties?.[name]?.type === "array";
39
+ const prop = schema.properties?.[name];
40
+ if (!prop)
41
+ return false;
42
+ if (prop.type === "array")
43
+ return true;
44
+ // union: accept array as one option
45
+ if (Array.isArray(prop.anyOf)) {
46
+ for (const alt of prop.anyOf) {
47
+ if (alt.type === "array")
48
+ return true;
49
+ }
50
+ }
51
+ return false;
42
52
  }
43
53
  function appendToArray(out, key, value) {
44
54
  const prev = out[key];
@@ -55,17 +65,28 @@ function assignFlag(out, schema, key, value) {
55
65
  }
56
66
  out[key] = value;
57
67
  }
68
+ function assignPositional(out, schema, key, value) {
69
+ if (isArrayField(schema, key)) {
70
+ appendToArray(out, key, value);
71
+ return;
72
+ }
73
+ out[key] = value;
74
+ }
58
75
  function dispatch(tokens, positional, schema) {
59
76
  const out = {};
60
77
  let posIdx = 0;
61
78
  for (const tok of tokens) {
62
79
  if (tok.kind === "flag") {
63
80
  assignFlag(out, schema, tok.key, tok.value);
81
+ continue;
64
82
  }
65
- else if (posIdx < positional.length) {
66
- out[positional[posIdx]] = tok.value;
83
+ if (posIdx >= positional.length)
84
+ continue;
85
+ const currentKey = positional[posIdx];
86
+ assignPositional(out, schema, currentKey, tok.value);
87
+ // If the current positional is array-valued, stay on it; else advance.
88
+ if (!isArrayField(schema, currentKey))
67
89
  posIdx++;
68
- }
69
90
  }
70
91
  return out;
71
92
  }
@@ -92,12 +113,23 @@ function coerceField(value, prop) {
92
113
  return value;
93
114
  if (typeof value === "boolean")
94
115
  return value;
95
- if (prop.type === "array") {
116
+ if (prop.type === "array" || (Array.isArray(prop.anyOf) && prop.anyOf.some((a) => a.type === "array"))) {
96
117
  const items = Array.isArray(value) ? value : [value];
97
- return items.map((item) => coerceScalar(String(item), prop.items?.type));
118
+ const innerType = prop.items?.type ?? extractArrayItemType(prop);
119
+ return items.map((item) => coerceScalar(String(item), innerType));
98
120
  }
99
121
  return coerceScalar(String(value), prop.type);
100
122
  }
123
+ function extractArrayItemType(prop) {
124
+ if (!Array.isArray(prop.anyOf))
125
+ return undefined;
126
+ for (const alt of prop.anyOf) {
127
+ const a = alt;
128
+ if (a.type === "array")
129
+ return a.items?.type;
130
+ }
131
+ return undefined;
132
+ }
101
133
  function coerce(raw, schema) {
102
134
  const out = {};
103
135
  const props = schema.properties ?? {};
@@ -1,12 +1,17 @@
1
- import type { IProject, ProjectDef } from "./types.js";
1
+ import type { IProject, ProjectDef, Trait } from "./types.js";
2
+ import type { TraitRegistry } from "./registry.js";
2
3
  export declare class Project implements IProject {
3
4
  readonly name: string;
4
5
  readonly dir: string;
5
6
  readonly tags: readonly string[];
6
- readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
7
+ readonly description?: string;
8
+ /** Parsed (defaulted) trait data, keyed by trait name. */
9
+ private readonly traitData;
7
10
  private readonly paths;
8
- constructor(def: ProjectDef, wsDir: string);
9
- has(capability: string): boolean;
10
- capability<T = true | Record<string, unknown>>(name: string): T;
11
+ constructor(def: ProjectDef, wsDir: string, traitData: Record<string, unknown>);
12
+ hasTrait(trait: Trait | string): boolean;
13
+ trait<T>(trait: Trait<string, T>): T;
11
14
  path(name: string): string;
12
15
  }
16
+ /** Validate a project's `has` against the registry, returning parsed data. */
17
+ export declare function validateHas(def: ProjectDef, registry: TraitRegistry, sourceFile: string): Record<string, unknown>;