@procedures/procedure 0.3.0 → 0.5.0

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.
@@ -8,10 +8,12 @@
8
8
  * The scaffolded code compiles with the strict options of the workspace (deep dive CLI-13):
9
9
  * - the handler names its unused input `_input` (noUnusedParameters);
10
10
  * - types.ts is created, or gets `import { z } from "zod"`, when it needs it;
11
- * - the registration goes into the defineModule([...]) array of src/module.ts. A package of the
12
- * old kind, without module.ts, gets it in the registerProcedures([...]) array of src/register.ts.
11
+ * - the registration goes into the list that defineModule gets in src/module.ts: an array literal,
12
+ * or the array of a variable that module.ts declares. A package of the old kind, without
13
+ * module.ts, gets it in the registerProcedures([...]) array of src/register.ts.
13
14
  * A name whose path, file, types or registration exists already is an error, and nothing is
14
15
  * written. Before, "a.b.c" and "a.c" wrote the same file, and existing types were reused silently.
16
+ * A module whose list procedure.new cannot find is an error too, and nothing is written.
15
17
  */
16
18
 
17
19
  import { join } from "node:path";
@@ -219,40 +221,213 @@ export function ensureNamedImports(source: string, moduleName: string, names: st
219
221
  return addImports(source, [`import { ${names.join(", ")} } from "${moduleName}";\n`]);
220
222
  }
221
223
 
222
- /** The index of the bracket that closes the bracket at `open` (strings are skipped) */
224
+ /** The index after the string or comment that starts at `i`, or `i` when code starts there */
225
+ function skipNonCode(source: string, i: number): number {
226
+ const ch = source[i];
227
+ if (ch === "/" && source[i + 1] === "/") {
228
+ const end = source.indexOf("\n", i);
229
+ return end === -1 ? source.length : end;
230
+ }
231
+ if (ch === "/" && source[i + 1] === "*") {
232
+ const end = source.indexOf("*/", i + 2);
233
+ return end === -1 ? source.length : end + 2;
234
+ }
235
+ if (ch === '"' || ch === "'" || ch === "`") {
236
+ for (let j = i + 1; j < source.length; j++) {
237
+ if (source[j] === "\\") j++;
238
+ else if (source[j] === ch) return j + 1;
239
+ }
240
+ return source.length;
241
+ }
242
+ return i;
243
+ }
244
+
245
+ /** For each character of the source: true when it is code, false in a string or a comment */
246
+ function codeMask(source: string): boolean[] {
247
+ const mask = new Array<boolean>(source.length).fill(true);
248
+ for (let i = 0; i < source.length; i++) {
249
+ const skip = skipNonCode(source, i);
250
+ if (skip !== i) {
251
+ mask.fill(false, i, skip);
252
+ i = skip - 1;
253
+ }
254
+ }
255
+ return mask;
256
+ }
257
+
258
+ /** The index of the bracket that closes the bracket at `open` (strings and comments are skipped) */
223
259
  function closingBracket(source: string, open: number): number {
224
260
  let depth = 0;
225
- let quote: string | undefined;
226
261
  for (let i = open; i < source.length; i++) {
227
- const ch = source[i]!;
228
- if (quote) {
229
- if (ch === "\\") i++;
230
- else if (ch === quote) quote = undefined;
262
+ const skip = skipNonCode(source, i);
263
+ if (skip !== i) {
264
+ i = skip - 1;
231
265
  continue;
232
266
  }
233
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
234
- else if (ch === "[") depth++;
267
+ const ch = source[i];
268
+ if (ch === "[") depth++;
235
269
  else if (ch === "]" && --depth === 0) return i;
236
270
  }
237
271
  return -1;
238
272
  }
239
273
 
274
+ /** The start and the end of one argument of a call, without the spaces and comments around it */
275
+ interface ArgumentRange {
276
+ start: number;
277
+ end: number;
278
+ }
279
+
280
+ /** The arguments of the call whose "(" is at `open`. Undefined when the call does not end. */
281
+ function callArguments(source: string, open: number): ArgumentRange[] | undefined {
282
+ const args: ArgumentRange[] = [];
283
+ let depth = 0;
284
+ let first = -1;
285
+ let last = -1;
286
+ const push = (): void => {
287
+ if (first !== -1) args.push({ start: first, end: last });
288
+ first = -1;
289
+ last = -1;
290
+ };
291
+ for (let i = open; i < source.length; i++) {
292
+ const skip = skipNonCode(source, i);
293
+ if (skip !== i) {
294
+ // A string is a part of the argument. A comment is not.
295
+ if (source[i] !== "/") {
296
+ if (first === -1) first = i;
297
+ last = skip;
298
+ }
299
+ i = skip - 1;
300
+ continue;
301
+ }
302
+ const ch = source[i]!;
303
+ if (/\s/.test(ch)) continue;
304
+ if (ch === "(" || ch === "[" || ch === "{") {
305
+ if (++depth === 1) continue;
306
+ } else if (ch === ")" || ch === "]" || ch === "}") {
307
+ if (--depth === 0) {
308
+ push();
309
+ return args;
310
+ }
311
+ } else if (ch === "," && depth === 1) {
312
+ push();
313
+ continue;
314
+ }
315
+ if (first === -1) first = i;
316
+ last = i + 1;
317
+ }
318
+ return undefined;
319
+ }
320
+
240
321
  /**
241
- * Add an item to the array of the first defineModule("name", [...]), registerProcedures([...]) or
242
- * registerAll([...]) call. Returns undefined when the source has no such call.
322
+ * The index of the line where the statement at `index` starts, above its leading comments: code
323
+ * that goes there comes before the statement.
243
324
  */
244
- export function addToRegistration(source: string, item: string): string | undefined {
245
- // The first call in code, not in a comment
246
- const call = [...source.matchAll(/\b(defineModule|registerProcedures|registerAll)\s*\(/g)].find((match) => {
247
- const lineStart = source.lastIndexOf("\n", match.index) + 1;
248
- const before = source.slice(lineStart, match.index).trim();
249
- return !before.startsWith("*") && !before.startsWith("/*") && !before.includes("//");
250
- });
325
+ function statementStart(source: string, index: number): number {
326
+ let start = source.lastIndexOf("\n", index - 1) + 1;
327
+ for (;;) {
328
+ const before = source.slice(0, start).replace(/\s*$/, "");
329
+ if (before.endsWith("*/")) {
330
+ const comment = before.lastIndexOf("/*");
331
+ if (comment === -1) return start;
332
+ start = source.lastIndexOf("\n", comment - 1) + 1;
333
+ continue;
334
+ }
335
+ const lineStart = before.lastIndexOf("\n") + 1;
336
+ if (before.length > 0 && before.slice(lineStart).trimStart().startsWith("//")) {
337
+ start = lineStart;
338
+ continue;
339
+ }
340
+ return start;
341
+ }
342
+ }
343
+
344
+ /** The declaration of `name` in code as an array literal: the start of the statement and the "[" */
345
+ function arrayDeclaration(source: string, name: string, mask: boolean[]): { statement: number; open: number } | undefined {
346
+ const escaped = name.replace(/\$/g, "\\$");
347
+ const pattern = new RegExp(`(?<![\\w$.])(?:const|let|var)\\s+${escaped}\\s*(?::[^=;]*)?=\\s*\\[`, "g");
348
+ for (const match of source.matchAll(pattern)) {
349
+ if (mask[match.index]) {
350
+ return { statement: statementStart(source, match.index), open: match.index + match[0].length - 1 };
351
+ }
352
+ }
353
+ return undefined;
354
+ }
355
+
356
+ /** The argument of each call that holds the procedures */
357
+ const LIST_ARGUMENT: Readonly<Record<string, number>> = { defineModule: 1, registerProcedures: 0, registerAll: 0 };
358
+
359
+ /** The source with the item in the list, and the place for the definition of the item */
360
+ export interface RegistrationEdit {
361
+ source: string;
362
+ /** The definition of the item goes before this index, so that the list can use it */
363
+ before: number;
364
+ }
365
+
366
+ /** An argument as a short text for a message */
367
+ function shortText(text: string): string {
368
+ const line = text.replace(/\s+/g, " ");
369
+ return line.length > 60 ? `${line.slice(0, 57)}...` : line;
370
+ }
371
+
372
+ /**
373
+ * Add an item to the list of procedures of the first defineModule("name", list),
374
+ * registerProcedures(list) or registerAll(list) call. The list is an array literal, the variable of
375
+ * an array that this file declares, or `[...variable]`. Gives the reason as an error when the
376
+ * source has no such list. Before, the item went into the first array after the call, also when
377
+ * that array was the argument of `registerModules` (the ics and geo packages).
378
+ */
379
+ export function addToRegistration(source: string, item: string): RegistrationEdit | { error: string } {
380
+ const mask = codeMask(source);
381
+ // The first call in code, not in a comment or a string, and not the declaration of the function
382
+ const call = [...source.matchAll(/\b(defineModule|registerProcedures|registerAll)\s*\(/g)].find(
383
+ (match) => mask[match.index] && !/\bfunction\s*$/.test(source.slice(0, match.index))
384
+ );
251
385
  if (!call) {
252
- return undefined;
386
+ return { error: "the file has no defineModule, registerProcedures or registerAll call" };
387
+ }
388
+ const fn = call[1]!;
389
+ const args = callArguments(source, call.index + call[0].length - 1);
390
+ const list = args?.[LIST_ARGUMENT[fn]!];
391
+ if (!list) {
392
+ return { error: `the ${fn} call has no list of procedures` };
393
+ }
394
+ const text = source.slice(list.start, list.end);
395
+
396
+ // The definition goes before the code that registers the procedures: the module, the register
397
+ // function or the call
398
+ const anchor =
399
+ /^export\s+const\s+\w+\s*(:\s*\w+\s*)?=\s*defineModule\s*\(|^(export\s+)?(async\s+)?function\s+register\w*\s*\(|^\s*(registerProcedures|PROCEDURE_REGISTRY\.registerAll)\s*\(/m.exec(
400
+ source
401
+ );
402
+ const callStatement = anchor && anchor.index <= call.index ? anchor.index : statementStart(source, call.index);
403
+
404
+ let target: { open: number; before: number } | undefined;
405
+ if (text.startsWith("[") && closingBracket(source, list.start) === list.end - 1) {
406
+ // [...variable]: the item goes into the array of the variable, when this file declares it
407
+ const spread = /^\[\s*\.\.\.\s*([A-Za-z_$][\w$]*)\s*,?\s*\]$/.exec(text);
408
+ const declared = spread ? arrayDeclaration(source, spread[1]!, mask) : undefined;
409
+ target = declared ? { open: declared.open, before: declared.statement } : { open: list.start, before: callStatement };
410
+ } else if (/^[A-Za-z_$][\w$]*$/.test(text)) {
411
+ const declared = arrayDeclaration(source, text, mask);
412
+ if (!declared) {
413
+ return { error: `${fn} gets "${text}", and this file does not declare "${text}" as an array` };
414
+ }
415
+ target = { open: declared.open, before: declared.statement };
416
+ } else {
417
+ return { error: `${fn} gets "${shortText(text)}", which is not an array or a variable of this file` };
418
+ }
419
+
420
+ const updated = insertItem(source, target.open, item);
421
+ if (updated === undefined) {
422
+ return { error: `the list of ${fn} has no closing bracket` };
253
423
  }
254
- const open = source.indexOf("[", call.index);
255
- const close = open === -1 ? -1 : closingBracket(source, open);
424
+ // The item goes after the place of the definition, so that index does not move
425
+ return { source: updated, before: target.before };
426
+ }
427
+
428
+ /** Add an item to the array whose "[" is at `open`. Undefined when the array does not end. */
429
+ function insertItem(source: string, open: number, item: string): string | undefined {
430
+ const close = closingBracket(source, open);
256
431
  if (close === -1) {
257
432
  return undefined;
258
433
  }
@@ -312,28 +487,36 @@ register();
312
487
 
313
488
  /**
314
489
  * Put the procedure into module.ts (or register.ts): its imports, its definition, and its place
315
- * in the array of the module. Returns undefined when the file has no such array.
490
+ * in the list of procedures of the module. Gives the reason as an error when the file has no such
491
+ * list (see `addToRegistration`).
316
492
  */
317
- export function registerSource(source: string, layout: ProcedureLayout, description: string, importPath: string): string | undefined {
493
+ export function registerSource(
494
+ source: string,
495
+ layout: ProcedureLayout,
496
+ description: string,
497
+ importPath: string
498
+ ): string | { error: string } {
318
499
  const { camelName, pascalName } = layout;
319
- const withArray = addToRegistration(source, `${camelName}Procedure`);
320
- if (withArray === undefined) {
321
- return undefined;
500
+ const edit = addToRegistration(source, `${camelName}Procedure`);
501
+ if ("error" in edit) {
502
+ return edit;
322
503
  }
323
- let updated = ensureNamedImports(withArray, "@procedures/client", ["createProcedure", "zodAdapter"]);
324
- updated = addImports(updated, [
504
+ // The definition goes before the list that uses it, then the imports go after the last import
505
+ const withDefinition =
506
+ edit.source.slice(0, edit.before) + generateRegistration(layout, description) + edit.source.slice(edit.before);
507
+ const updated = ensureNamedImports(withDefinition, "@procedures/client", ["createProcedure", "zodAdapter"]);
508
+ return addImports(updated, [
325
509
  `import { ${camelName} } from "${importPath}";\n`,
326
510
  `import { ${pascalName}InputSchema, ${pascalName}OutputSchema, type ${pascalName}Input, type ${pascalName}Output } from "./types.js";\n`,
327
511
  ]);
328
- // The definition goes before the module, or before the code that registers the procedures
329
- const anchor =
330
- /^export\s+const\s+\w+\s*(:\s*\w+\s*)?=\s*defineModule\s*\(|^(export\s+)?(async\s+)?function\s+register\w*\s*\(|^\s*(registerProcedures|PROCEDURE_REGISTRY\.registerAll)\s*\(/m.exec(
331
- updated
332
- );
333
- const at = anchor ? anchor.index : updated.length;
334
- return updated.slice(0, at) + generateRegistration(layout, description) + updated.slice(at);
335
512
  }
336
513
 
514
+ /**
515
+ * The step after the scaffold for the effects. A host with a capability refuses a procedure
516
+ * without `metadata.effects`, and `lib audit` reports it.
517
+ */
518
+ const EFFECTS_STEP ="Write metadata.effects: lib audit reports a procedure without them";
519
+
337
520
  /**
338
521
  * Generate index.ts export
339
522
  */
@@ -401,6 +584,13 @@ export async function procedureNew(input: ProcedureNewInput, ctx: ProcedureConte
401
584
  }
402
585
  const registerBase = existingRegister ?? REGISTER_TEMPLATE;
403
586
  const newRegister = registerSource(registerBase, layout, description, importPath);
587
+ // Without the list of the module, the procedure would not be registered: write nothing
588
+ if (typeof newRegister !== "string") {
589
+ return fail(
590
+ `${registerFile} has no list of procedures that procedure.new can change: ${newRegister.error}. ` +
591
+ `Nothing was written. Give the module an array of procedures in ${registerName}, and start procedure.new again.`
592
+ );
593
+ }
404
594
 
405
595
  if (input.dryRun) {
406
596
  const [indexExists] = await Promise.all([pathExists(namespaceIndex, ctx)]);
@@ -416,15 +606,13 @@ export async function procedureNew(input: ProcedureNewInput, ctx: ProcedureConte
416
606
  modified: [
417
607
  ...(indexExists ? [namespaceIndex] : []),
418
608
  ...(existingTypes !== undefined ? [typesFile] : []),
419
- ...(existingRegister !== undefined && newRegister !== undefined ? [registerFile] : []),
609
+ ...(existingRegister !== undefined ? [registerFile] : []),
420
610
  ],
421
611
  operations: [
422
612
  `Would create procedure file: ${procedureFile}`,
423
613
  `Would create/update index: ${namespaceIndex}`,
424
614
  existingTypes === undefined ? `Would create ${typesFile}` : `Would append types to: ${typesFile}`,
425
- newRegister === undefined
426
- ? `Would not change ${registerFile}: it has no defineModule or registerProcedures array`
427
- : `Would register the procedure in: ${registerFile}`,
615
+ `Would register the procedure in: ${registerFile}`,
428
616
  ],
429
617
  errors: [],
430
618
  };
@@ -473,20 +661,16 @@ export async function procedureNew(input: ProcedureNewInput, ctx: ProcedureConte
473
661
  }
474
662
 
475
663
  // Step 4: Register the procedure
476
- if (newRegister === undefined) {
477
- operations.push(
478
- `${registerName} has no defineModule or registerProcedures array: add ${camelName}Procedure to the module in ${registerFile}`
479
- );
480
- } else {
481
- operations.push(`${existingRegister === undefined ? "Creating" : "Updating"} registration: ${registerFile}`);
482
- await writeText(registerFile, newRegister, ctx);
483
- (existingRegister === undefined ? created : modified).push(registerFile);
484
- if (existingRegister === undefined) {
485
- operations.push(`Export the procedures from src/index.ts, and give the package a "./register" entry (see lib new)`);
486
- }
664
+ operations.push(`${existingRegister === undefined ? "Creating" : "Updating"} registration: ${registerFile}`);
665
+ await writeText(registerFile, newRegister, ctx);
666
+ (existingRegister === undefined ? created : modified).push(registerFile);
667
+ if (existingRegister === undefined) {
668
+ operations.push(`Export the procedures from src/index.ts, and give the package a "./register" entry (see lib new)`);
487
669
  }
488
670
 
489
671
  operations.push(`Next: implement ${segments.join(".")} in ${procedureFile}, then run pnpm build`);
672
+ // The scaffold writes no effects on purpose: a default of "pure" can be false
673
+ operations.push(EFFECTS_STEP);
490
674
 
491
675
  return {
492
676
  success: errors.length === 0,