@cosmicdrift/kumiko-framework 0.199.1 → 0.199.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.199.1",
3
+ "version": "0.199.2",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.199.1",
189
+ "@cosmicdrift/kumiko-types": "0.199.2",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.199.1",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.2",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -0,0 +1 @@
1
+ export const EXT_AUDIT = "audit" as const;
@@ -0,0 +1,8 @@
1
+ import { EXT_AUDIT } from "./constants";
2
+
3
+ // biome-ignore lint/suspicious/noExplicitAny: structural parser test fixture, never executed or type-checked at runtime
4
+ declare function defineFeature(name: string, setup: (r: any) => void): void;
5
+
6
+ defineFeature("cross-file-name-const", (r) => {
7
+ r.extendsRegistrar(EXT_AUDIT, {});
8
+ });
@@ -473,7 +473,7 @@ defineFeature("f", (r) => {
473
473
  expect(result.errors[0]?.methodName).toBe("entity");
474
474
  });
475
475
 
476
- test("emits a ParseError when the name is not a string literal", () => {
476
+ test("resolves the name when it's an identifier initialized to a string literal", () => {
477
477
  const result = parseInline(`
478
478
  const ENTITY = "task";
479
479
  defineFeature("f", (r) => {
@@ -481,6 +481,22 @@ defineFeature("f", (r) => {
481
481
  });
482
482
  `);
483
483
 
484
+ expect(result.errors).toEqual([]);
485
+ expect(result.patterns[0]).toMatchObject({
486
+ kind: "entity",
487
+ entityName: "task",
488
+ });
489
+ });
490
+
491
+ test("emits a ParseError when the name identifier does not resolve to a string literal", () => {
492
+ const result = parseInline(`
493
+ const ENTITY = computeEntityName();
494
+ defineFeature("f", (r) => {
495
+ r.entity(ENTITY, { fields: {} });
496
+ });
497
+ `);
498
+
499
+ expect(result.patterns).toEqual([]);
484
500
  expect(result.errors[0]?.methodName).toBe("entity");
485
501
  });
486
502
 
@@ -2366,3 +2382,18 @@ describe("cross-file registrar-wrapper resolution against a real filesystem Proj
2366
2382
  expect(navPattern?.source.file).not.toBe(fixture);
2367
2383
  });
2368
2384
  });
2385
+
2386
+ // #1746 — registrar-call name args authored as an imported constant
2387
+ // (`r.useExtension(EXT_TENANT_DATA, ...)`) instead of a string literal.
2388
+ // This is the dominant real-world style across the framework's own
2389
+ // bundled-features (see extension-names.ts), and previously ParseErrored
2390
+ // on every such call.
2391
+ describe("cross-file imported-constant name resolution against a real filesystem Project (#1746)", () => {
2392
+ const fixture = resolve(__dirname, "fixtures/cross-file-name-const/feature.ts");
2393
+ const result = parseFeatureFile(fixture);
2394
+
2395
+ test("resolves the identifier to the imported const's string value", () => {
2396
+ expect(result.errors).toEqual([]);
2397
+ expect(result.patterns).toMatchObject([{ kind: "extendsRegistrar", extensionName: "audit" }]);
2398
+ });
2399
+ });
@@ -9,6 +9,7 @@ import {
9
9
  findFunctionLiteral,
10
10
  ok,
11
11
  readDataLiteralNode,
12
+ readNameLiteral,
12
13
  readNameOrRef,
13
14
  readPropertyKey,
14
15
  } from "./shared";
@@ -146,12 +147,12 @@ export function extractDefineEvent(
146
147
  });
147
148
  }
148
149
 
149
- const nameArg = first.asKind(SyntaxKind.StringLiteral);
150
- if (!nameArg) {
150
+ const eventName = readNameLiteral(first);
151
+ if (eventName === undefined) {
151
152
  return fail(
152
153
  "defineEvent",
153
154
  sourceLocationFromNode(call, sourceFile),
154
- "first argument must be a string literal event name (or use the object form)",
155
+ "first argument must be a string literal event name, or an identifier resolving to one (or use the object form)",
155
156
  );
156
157
  }
157
158
  const schemaArg = args[1];
@@ -186,7 +187,7 @@ export function extractDefineEvent(
186
187
  return ok({
187
188
  kind: "defineEvent",
188
189
  source: sourceLocationFromNode(call, sourceFile),
189
- eventName: nameArg.getLiteralValue(),
190
+ eventName,
190
191
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
191
192
  ...(version !== undefined && { version }),
192
193
  ...(migrations !== undefined && { migrations }),
@@ -207,31 +208,32 @@ export function extractNotification(
207
208
  );
208
209
  }
209
210
 
210
- let nameLiteral: ReturnType<typeof first.asKind<SyntaxKind.StringLiteral>>;
211
+ let notificationName: string | undefined;
211
212
  let defObj: ReturnType<typeof first.asKind<SyntaxKind.ObjectLiteralExpression>>;
212
213
 
213
214
  const firstObj = first.asKind(SyntaxKind.ObjectLiteralExpression);
214
215
  if (firstObj && args.length === 1) {
215
- nameLiteral = firstObj
216
+ const nameInit = firstObj
216
217
  .getProperty("name")
217
218
  ?.asKind(SyntaxKind.PropertyAssignment)
218
219
  ?.getInitializer()
219
220
  ?.asKind(SyntaxKind.StringLiteral);
220
- if (!nameLiteral) {
221
+ if (!nameInit) {
221
222
  return fail(
222
223
  "notification",
223
224
  sourceLocationFromNode(call, sourceFile),
224
225
  "object form requires a string-literal `name` property",
225
226
  );
226
227
  }
228
+ notificationName = nameInit.getLiteralValue();
227
229
  defObj = firstObj;
228
230
  } else {
229
- nameLiteral = first.asKind(SyntaxKind.StringLiteral);
230
- if (!nameLiteral) {
231
+ notificationName = readNameLiteral(first);
232
+ if (notificationName === undefined) {
231
233
  return fail(
232
234
  "notification",
233
235
  sourceLocationFromNode(call, sourceFile),
234
- "first argument must be a string literal notification name (or use the object form)",
236
+ "first argument must be a string literal notification name, or an identifier resolving to one (or use the object form)",
235
237
  );
236
238
  }
237
239
  defObj = args[1]?.asKind(SyntaxKind.ObjectLiteralExpression);
@@ -243,7 +245,6 @@ export function extractNotification(
243
245
  );
244
246
  }
245
247
  }
246
- const nameArg = nameLiteral;
247
248
  const triggerObj = defObj
248
249
  .getProperty("trigger")
249
250
  ?.asKind(SyntaxKind.PropertyAssignment)
@@ -313,7 +314,7 @@ export function extractNotification(
313
314
  return ok({
314
315
  kind: "notification",
315
316
  source: sourceLocationFromNode(call, sourceFile),
316
- notificationName: nameArg.getLiteralValue(),
317
+ notificationName,
317
318
  trigger: { on: onName },
318
319
  recipientBody: sourceLocationFromNode(recipientFn, sourceFile),
319
320
  dataBody: sourceLocationFromNode(dataFn, sourceFile),
@@ -14,6 +14,7 @@ import {
14
14
  ok,
15
15
  readBooleanProperty,
16
16
  readDataLiteralNode,
17
+ readNameLiteral,
17
18
  } from "./shared";
18
19
 
19
20
  export type ParsedHandlerCall = {
@@ -134,12 +135,12 @@ export function parseHandlerCall(
134
135
  if (args.length === 1 && isRawRefSentinel(readDataLiteralNode(first))) {
135
136
  return ok({ source: sourceLocationFromNode(call, sourceFile) });
136
137
  }
137
- const nameLiteral = first.asKind(SyntaxKind.StringLiteral);
138
- if (!nameLiteral) {
138
+ const handlerName = readNameLiteral(first);
139
+ if (handlerName === undefined) {
139
140
  return fail(
140
141
  methodName,
141
142
  sourceLocationFromNode(call, sourceFile),
142
- "first argument must be a string literal handler name (or use the object form)",
143
+ "first argument must be a string literal handler name, or an identifier resolving to one (or use the object form)",
143
144
  );
144
145
  }
145
146
  const schemaArg = args[1];
@@ -178,7 +179,7 @@ export function parseHandlerCall(
178
179
  }
179
180
  return ok({
180
181
  source: sourceLocationFromNode(call, sourceFile),
181
- handlerName: nameLiteral.getLiteralValue(),
182
+ handlerName,
182
183
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
183
184
  handlerBody: sourceLocationFromNode(fn, sourceFile),
184
185
  ...(access !== undefined && { access }),
@@ -12,6 +12,7 @@ import {
12
12
  isPlainObject,
13
13
  ok,
14
14
  readDataLiteralNode,
15
+ readNameLiteral,
15
16
  readNameOrRef,
16
17
  readNameOrRefOrList,
17
18
  } from "./shared";
@@ -155,15 +156,14 @@ export function extractHook(
155
156
  });
156
157
  }
157
158
 
158
- const typeArg = first.asKind(SyntaxKind.StringLiteral);
159
- if (!typeArg) {
159
+ const hookType = readNameLiteral(first);
160
+ if (hookType === undefined) {
160
161
  return fail(
161
162
  "hook",
162
163
  sourceLocationFromNode(call, sourceFile),
163
- "first argument must be a string literal hook type (or use the object form)",
164
+ "first argument must be a string literal hook type, or an identifier resolving to one (or use the object form)",
164
165
  );
165
166
  }
166
- const hookType = typeArg.getLiteralValue();
167
167
  if (!isHookType(hookType)) {
168
168
  return fail(
169
169
  "hook",
@@ -12,6 +12,7 @@ import {
12
12
  ok,
13
13
  readBooleanProperty,
14
14
  readDataLiteralNode,
15
+ readNameLiteral,
15
16
  readPropertyKey,
16
17
  } from "./shared";
17
18
 
@@ -97,12 +98,12 @@ export function extractJob(
97
98
  });
98
99
  }
99
100
 
100
- const nameArg = first.asKind(SyntaxKind.StringLiteral);
101
- if (!nameArg) {
101
+ const jobName = readNameLiteral(first);
102
+ if (jobName === undefined) {
102
103
  return fail(
103
104
  "job",
104
105
  sourceLocationFromNode(call, sourceFile),
105
- "first argument must be a string literal job name (or use the object form)",
106
+ "first argument must be a string literal job name, or an identifier resolving to one (or use the object form)",
106
107
  );
107
108
  }
108
109
  const optionsArg = args[1];
@@ -140,7 +141,7 @@ export function extractJob(
140
141
  return ok({
141
142
  kind: "job",
142
143
  source: sourceLocationFromNode(call, sourceFile),
143
- jobName: nameArg.getLiteralValue(),
144
+ jobName,
144
145
  options: options as Omit<JobDefinition, "name" | "handler">,
145
146
  handlerBody: sourceLocationFromNode(fn, sourceFile),
146
147
  });
@@ -12,6 +12,7 @@ import {
12
12
  isPlainObject,
13
13
  ok,
14
14
  readDataLiteralNode,
15
+ readNameLiteral,
15
16
  readNameOrRef,
16
17
  } from "./shared";
17
18
 
@@ -60,12 +61,12 @@ export function extractEntity(
60
61
  });
61
62
  }
62
63
 
63
- const nameArg = first.asKind(SyntaxKind.StringLiteral);
64
- if (!nameArg) {
64
+ const entityName = readNameLiteral(first);
65
+ if (entityName === undefined) {
65
66
  return fail(
66
67
  "entity",
67
68
  sourceLocationFromNode(call, sourceFile),
68
- "first argument must be a string literal name (or use the object form)",
69
+ "first argument must be a string literal name, or an identifier resolving to one (or use the object form)",
69
70
  );
70
71
  }
71
72
  const defArg = args[1];
@@ -87,7 +88,7 @@ export function extractEntity(
87
88
  return ok({
88
89
  kind: "entity",
89
90
  source: sourceLocationFromNode(call, sourceFile),
90
- entityName: nameArg.getLiteralValue(),
91
+ entityName,
91
92
  definition: definition as EntityDefinition,
92
93
  });
93
94
  }
@@ -165,12 +166,13 @@ export function extractRelation(
165
166
  'first argument must be a string literal or an inline { name: "..." } object (or use the object form)',
166
167
  );
167
168
  }
168
- const nameArg = args[1]?.asKind(SyntaxKind.StringLiteral);
169
- if (!nameArg) {
169
+ const relationNameArg = args[1];
170
+ const relationName = relationNameArg && readNameLiteral(relationNameArg);
171
+ if (!relationName) {
170
172
  return fail(
171
173
  "relation",
172
174
  sourceLocationFromNode(call, sourceFile),
173
- "second argument must be a string literal relation name",
175
+ "second argument must be a string literal relation name, or an identifier resolving to one",
174
176
  );
175
177
  }
176
178
  const defArg = args[2];
@@ -193,7 +195,7 @@ export function extractRelation(
193
195
  kind: "relation",
194
196
  source: sourceLocationFromNode(call, sourceFile),
195
197
  entityName,
196
- relationName: nameArg.getLiteralValue(),
198
+ relationName,
197
199
  definition: definition as RelationDefinition,
198
200
  });
199
201
  }
@@ -20,6 +20,7 @@ import {
20
20
  isPlainObject,
21
21
  ok,
22
22
  readDataLiteralNode,
23
+ readNameLiteral,
23
24
  readNameOrRef,
24
25
  } from "./shared";
25
26
 
@@ -68,12 +69,12 @@ export function readNamedOptions(
68
69
  return { kind: "ok", name: nameInit.getLiteralValue(), options: optionsWithoutName };
69
70
  }
70
71
 
71
- const nameLiteral = first.asKind(SyntaxKind.StringLiteral);
72
- if (!nameLiteral) {
72
+ const name = readNameLiteral(first);
73
+ if (name === undefined) {
73
74
  return fail(
74
75
  methodName,
75
76
  sourceLocationFromNode(call, sourceFile),
76
- "first argument must be a string literal name (or use the object form)",
77
+ "first argument must be a string literal name, or an identifier resolving to one (or use the object form)",
77
78
  );
78
79
  }
79
80
  const optionsArg = args[1];
@@ -92,7 +93,7 @@ export function readNamedOptions(
92
93
  "options could not be read as a plain object",
93
94
  );
94
95
  }
95
- return { kind: "ok", name: nameLiteral.getLiteralValue(), options };
96
+ return { kind: "ok", name, options };
96
97
  }
97
98
 
98
99
  export function extractConfig(
@@ -424,12 +425,12 @@ export function extractUseExtension(
424
425
  });
425
426
  }
426
427
 
427
- const nameArg = first.asKind(SyntaxKind.StringLiteral);
428
- if (!nameArg) {
428
+ const extensionName = readNameLiteral(first);
429
+ if (extensionName === undefined) {
429
430
  return fail(
430
431
  "useExtension",
431
432
  sourceLocationFromNode(call, sourceFile),
432
- "first argument must be a string literal extension name (or use the object form)",
433
+ "first argument must be a string literal extension name, or an identifier resolving to one (or use the object form)",
433
434
  );
434
435
  }
435
436
  const entityRefArg = args[1];
@@ -464,7 +465,7 @@ export function extractUseExtension(
464
465
  return ok({
465
466
  kind: "useExtension",
466
467
  source: sourceLocationFromNode(call, sourceFile),
467
- extensionName: nameArg.getLiteralValue(),
468
+ extensionName,
468
469
  entityName,
469
470
  ...(options !== undefined && { options }),
470
471
  });
@@ -1,5 +1,4 @@
1
1
  import type { CallExpression, SourceFile } from "ts-morph";
2
- import { SyntaxKind } from "ts-morph";
3
2
  import type {
4
3
  EnvSchemaPattern,
5
4
  ExposesApiPattern,
@@ -7,7 +6,7 @@ import type {
7
6
  UsesApiPattern,
8
7
  } from "../patterns";
9
8
  import { sourceLocationFromNode } from "../source-location";
10
- import { type ExtractOutput, fail, ok } from "./shared";
9
+ import { type ExtractOutput, fail, ok, readNameLiteral } from "./shared";
11
10
 
12
11
  export function extractEnvSchema(
13
12
  call: CallExpression,
@@ -33,12 +32,13 @@ export function extractExtendsRegistrar(
33
32
  sourceFile: SourceFile,
34
33
  ): ExtractOutput<ExtendsRegistrarPattern> {
35
34
  const args = call.getArguments();
36
- const nameArg = args[0]?.asKind(SyntaxKind.StringLiteral);
37
- if (!nameArg) {
35
+ const first = args[0];
36
+ const extensionName = first && readNameLiteral(first);
37
+ if (!extensionName) {
38
38
  return fail(
39
39
  "extendsRegistrar",
40
40
  sourceLocationFromNode(call, sourceFile),
41
- "first argument must be a string literal extension name",
41
+ "first argument must be a string literal extension name, or an identifier resolving to one",
42
42
  );
43
43
  }
44
44
  const defArg = args[1];
@@ -52,7 +52,7 @@ export function extractExtendsRegistrar(
52
52
  return ok({
53
53
  kind: "extendsRegistrar",
54
54
  source: sourceLocationFromNode(call, sourceFile),
55
- extensionName: nameArg.getLiteralValue(),
55
+ extensionName,
56
56
  defBody: sourceLocationFromNode(defArg, sourceFile),
57
57
  });
58
58
  }
@@ -61,18 +61,19 @@ export function extractUsesApi(
61
61
  call: CallExpression,
62
62
  sourceFile: SourceFile,
63
63
  ): ExtractOutput<UsesApiPattern> {
64
- const arg = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral);
65
- if (!arg) {
64
+ const arg = call.getArguments()[0];
65
+ const apiName = arg && readNameLiteral(arg);
66
+ if (!apiName) {
66
67
  return fail(
67
68
  "usesApi",
68
69
  sourceLocationFromNode(call, sourceFile),
69
- 'expected a single string-literal API name (e.g. "sessions.revokeAllForUser")',
70
+ 'expected a single string-literal API name (e.g. "sessions.revokeAllForUser"), or an identifier resolving to one',
70
71
  );
71
72
  }
72
73
  return ok({
73
74
  kind: "usesApi",
74
75
  source: sourceLocationFromNode(call, sourceFile),
75
- apiName: arg.getLiteralValue(),
76
+ apiName,
76
77
  });
77
78
  }
78
79
 
@@ -80,18 +81,19 @@ export function extractExposesApi(
80
81
  call: CallExpression,
81
82
  sourceFile: SourceFile,
82
83
  ): ExtractOutput<ExposesApiPattern> {
83
- const arg = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral);
84
- if (!arg) {
84
+ const arg = call.getArguments()[0];
85
+ const apiName = arg && readNameLiteral(arg);
86
+ if (!apiName) {
85
87
  return fail(
86
88
  "exposesApi",
87
89
  sourceLocationFromNode(call, sourceFile),
88
- 'expected a single string-literal API name (e.g. "sessions.revokeAllForUser")',
90
+ 'expected a single string-literal API name (e.g. "sessions.revokeAllForUser"), or an identifier resolving to one',
89
91
  );
90
92
  }
91
93
  return ok({
92
94
  kind: "exposesApi",
93
95
  source: sourceLocationFromNode(call, sourceFile),
94
- apiName: arg.getLiteralValue(),
96
+ apiName,
95
97
  });
96
98
  }
97
99
 
@@ -175,9 +175,65 @@ export function readPropertyKey(propAssign: import("ts-morph").PropertyAssignmen
175
175
  return propAssign.getName();
176
176
  }
177
177
 
178
- export function readNameOrRef(node: Node): string | undefined {
178
+ function unwrapLiteralInitializer(node: Node): string | undefined {
179
+ const literal =
180
+ node.asKind(SyntaxKind.StringLiteral) ?? node.asKind(SyntaxKind.NoSubstitutionTemplateLiteral);
181
+ if (literal) return literal.getLiteralValue();
182
+ const asExpr = node.asKind(SyntaxKind.AsExpression);
183
+ if (asExpr) return unwrapLiteralInitializer(asExpr.getExpression());
184
+ const satisfiesExpr = node.asKind(SyntaxKind.SatisfiesExpression);
185
+ if (satisfiesExpr) return unwrapLiteralInitializer(satisfiesExpr.getExpression());
186
+ const paren = node.asKind(SyntaxKind.ParenthesizedExpression);
187
+ if (paren) return unwrapLiteralInitializer(paren.getExpression());
188
+ return undefined;
189
+ }
190
+
191
+ /**
192
+ * Resolves a bare Identifier to the string value of its declaration's
193
+ * initializer (`export const EXT_TENANT_DATA = "tenant-data" as const`),
194
+ * following imports via ts-morph's definition lookup — works across files
195
+ * and packages against a real-filesystem Project (see #1008 precedent in
196
+ * parse.ts). Only descends into VariableDeclaration initializers; a
197
+ * function/class/type definition or an unresolvable import (external
198
+ * package, ambient declaration) yields undefined, never a throw.
199
+ */
200
+ function resolveIdentifierToStringLiteral(identifier: Node): string | undefined {
201
+ const id = identifier.asKind(SyntaxKind.Identifier);
202
+ if (!id) return undefined;
203
+ let defs: readonly Node[];
204
+ try {
205
+ defs = id.getDefinitionNodes();
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ for (const def of defs) {
210
+ const init = def.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
211
+ if (!init) continue;
212
+ const value = unwrapLiteralInitializer(init);
213
+ if (value !== undefined) return value;
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * A node's string value when it's a string literal, or when it's a bare
220
+ * Identifier that resolves to one via a `const X = "..."` declaration
221
+ * (same-file or imported) — the pattern used throughout the framework's
222
+ * own bundled-features for registrar-call names (`EXT_TENANT_DATA`,
223
+ * `TENANT_SECRET_READ_EVENT`, ...) instead of repeating string literals.
224
+ * undefined for anything unresolvable (factory call, member access,
225
+ * external/ambient identifier) — callers keep their existing ParseError
226
+ * fallback, no crash.
227
+ */
228
+ export function readNameLiteral(node: Node): string | undefined {
179
229
  const literal = node.asKind(SyntaxKind.StringLiteral);
180
230
  if (literal) return literal.getLiteralValue();
231
+ return resolveIdentifierToStringLiteral(node);
232
+ }
233
+
234
+ export function readNameOrRef(node: Node): string | undefined {
235
+ const literal = readNameLiteral(node);
236
+ if (literal !== undefined) return literal;
181
237
  const obj = readDataLiteralNode(node);
182
238
  if (isPlainObject(obj) && typeof obj["name"] === "string") return obj["name"];
183
239
  return undefined;