@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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.
Files changed (47) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  4. package/src/api/__tests__/api.test.ts +116 -1
  5. package/src/api/__tests__/batch.integration.test.ts +53 -0
  6. package/src/api/__tests__/body-limit.test.ts +90 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  8. package/src/api/api-constants.ts +44 -7
  9. package/src/api/auth-middleware.ts +19 -3
  10. package/src/api/index.ts +1 -0
  11. package/src/api/route-registrars.ts +19 -21
  12. package/src/api/routes.ts +47 -1
  13. package/src/api/server.ts +1 -1
  14. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  15. package/src/db/tenant-db.ts +46 -2
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +25 -0
  18. package/src/engine/boot-validator/detail-screens.ts +35 -0
  19. package/src/engine/boot-validator/index.ts +2 -0
  20. package/src/engine/entity-handlers.ts +8 -1
  21. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  23. package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  25. package/src/engine/feature-ast/extractors/events.ts +5 -3
  26. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  27. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  28. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  29. package/src/engine/feature-ast/patch.ts +48 -21
  30. package/src/engine/feature-ast/patterns.ts +18 -0
  31. package/src/engine/feature-ast/render.ts +19 -6
  32. package/src/engine/index.ts +1 -0
  33. package/src/files/__tests__/files.integration.test.ts +97 -1
  34. package/src/files/file-routes.ts +10 -2
  35. package/src/files/types.ts +72 -0
  36. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  37. package/src/http/__tests__/egress.test.ts +440 -0
  38. package/src/http/__tests__/policy.test.ts +125 -0
  39. package/src/http/egress.ts +158 -0
  40. package/src/http/index.ts +2 -0
  41. package/src/http/policy.ts +193 -0
  42. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  43. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  44. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  45. package/src/pipeline/dispatch-batch.ts +11 -5
  46. package/src/pipeline/dispatch-shared.ts +42 -16
  47. package/src/pipeline/idempotency.ts +91 -30
@@ -6,6 +6,7 @@
6
6
  // here narrows the cause to patch.ts itself.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
+ import { resolve } from "node:path";
9
10
  import { Project, type SourceFile } from "ts-morph";
10
11
  import { parseSourceFile } from "../parse";
11
12
  import { addPattern, applyChanges, type PatternId, removePattern, replacePattern } from "../patch";
@@ -552,3 +553,158 @@ defineFeature("object", (r) => {
552
553
  expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
553
554
  });
554
555
  });
556
+
557
+ // #2121 — findCallForId only accepted a StringLiteral first argument, so
558
+ // it couldn't locate calls authored with an imported/local constant name
559
+ // (the dominant style in the framework's own bundled-features, per #1746
560
+ // on the parser side). Covers both the single-arg case (matchFirstArgString)
561
+ // and the two-arg case (relation/hook/useExtension's second positional arg),
562
+ // same-file and genuinely cross-file via a real filesystem Project.
563
+ describe("findCallForId resolves identifier-authored names (#2121)", () => {
564
+ function loadFixture(relPath: string): SourceFile {
565
+ const project = new Project({
566
+ skipAddingFilesFromTsConfig: true,
567
+ skipFileDependencyResolution: true,
568
+ });
569
+ return project.addSourceFileAtPath(resolve(__dirname, relPath));
570
+ }
571
+
572
+ const SAME_FILE_CONST = `
573
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
574
+
575
+ const ENTITY = "task";
576
+
577
+ defineFeature("inventory", (r) => {
578
+ r.entity(ENTITY, { fields: { name: { type: "text", required: true } } });
579
+ });
580
+ `;
581
+
582
+ test("removePattern finds a call whose name arg is a same-file const identifier", () => {
583
+ const sf = makeSourceFile(SAME_FILE_CONST);
584
+ removePattern(sf, { kind: "entity", entityName: "task" });
585
+ const reparsed = parseSourceFile(sf);
586
+ expect(reparsed.errors).toEqual([]);
587
+ expect(reparsed.patterns).toEqual([]);
588
+ });
589
+
590
+ test("replacePattern finds a call whose name arg is a same-file const identifier", () => {
591
+ const sf = makeSourceFile(SAME_FILE_CONST);
592
+ const replacement: FeaturePattern = {
593
+ kind: "entity",
594
+ source: SAMPLE_LOC,
595
+ entityName: "task",
596
+ definition: {
597
+ fields: { name: { type: "text", required: true }, sku: { type: "text" } },
598
+ } as never,
599
+ };
600
+ replacePattern(sf, { kind: "entity", entityName: "task" }, replacement);
601
+ const reparsed = parseSourceFile(sf);
602
+ expect(reparsed.errors).toEqual([]);
603
+ const entity = reparsed.patterns.find((p) => p.kind === "entity");
604
+ if (entity?.kind === "entity") {
605
+ const fields = (entity.definition as { fields: Record<string, unknown> }).fields;
606
+ expect(Object.keys(fields)).toEqual(["name", "sku"]);
607
+ } else {
608
+ throw new Error("expected an entity pattern");
609
+ }
610
+ });
611
+
612
+ test("does not match when the identifier resolves to a different string", () => {
613
+ const sf = makeSourceFile(SAME_FILE_CONST);
614
+ expect(() => removePattern(sf, { kind: "entity", entityName: "other" })).toThrow(
615
+ /no call found/,
616
+ );
617
+ });
618
+
619
+ test("removePattern finds an extendsRegistrar call authored with a cross-file imported constant (#1746 fixture)", () => {
620
+ const sf = loadFixture("fixtures/cross-file-name-const/feature.ts");
621
+ removePattern(sf, { kind: "extendsRegistrar", extensionName: "audit" });
622
+ const reparsed = parseSourceFile(sf);
623
+ expect(reparsed.errors).toEqual([]);
624
+ expect(reparsed.patterns).toEqual([]);
625
+ });
626
+
627
+ test("removePattern resolves both positional args when authored as cross-file imported constants", () => {
628
+ const sf = loadFixture("fixtures/cross-file-patch-const/feature.ts");
629
+ removePattern(sf, {
630
+ kind: "useExtension",
631
+ extensionName: "tenant-data",
632
+ entityName: "item",
633
+ });
634
+ const reparsed = parseSourceFile(sf);
635
+ expect(reparsed.errors).toEqual([]);
636
+ expect(reparsed.patterns).toEqual([]);
637
+ });
638
+
639
+ test("removePattern finds an object-form useExtension whose entity is an inline { name } ref", () => {
640
+ const sf = makeSourceFile(`
641
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
642
+
643
+ defineFeature("inventory", (r) => {
644
+ r.useExtension({ name: "audit", entity: { name: "item" } });
645
+ });
646
+ `);
647
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
648
+ const reparsed = parseSourceFile(sf);
649
+ expect(reparsed.errors).toEqual([]);
650
+ expect(reparsed.patterns).toEqual([]);
651
+ });
652
+ });
653
+
654
+ // #2133 — matchArgString (shared by relation's second positional arg,
655
+ // hook's target, and useExtension's entity) only accepted a string literal
656
+ // or a name-resolving identifier. The parser itself is more permissive at
657
+ // two of those positions: useExtension's entity (round3.ts:445) and hook's
658
+ // target (hooks.ts:75) both additionally accept an inline `{ name: "..." }`
659
+ // object ref, same as the object-form fix in #2121 — so findCallForId
660
+ // couldn't locate a call authored that way. relation's second positional
661
+ // arg stays narrow on purpose: round2.ts:170 parses it via readNameLiteral,
662
+ // not readNameOrRef, so widening it would exceed what the parser accepts.
663
+ describe("callMatchesId — positional inline-ref args (#2133)", () => {
664
+ test("removePattern finds a positional useExtension whose entity is an inline { name } ref", () => {
665
+ const sf = makeSourceFile(`
666
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
667
+
668
+ defineFeature("inventory", (r) => {
669
+ r.useExtension("audit", { name: "item" });
670
+ });
671
+ `);
672
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
673
+ const reparsed = parseSourceFile(sf);
674
+ expect(reparsed.errors).toEqual([]);
675
+ expect(reparsed.patterns).toEqual([]);
676
+ });
677
+
678
+ test("removePattern finds a positional hook whose target is an inline { name } ref", () => {
679
+ const sf = makeSourceFile(`
680
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
681
+
682
+ defineFeature("hooks", (r) => {
683
+ r.hook("postSave", { name: "task" }, () => {});
684
+ });
685
+ `);
686
+ removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
687
+ const reparsed = parseSourceFile(sf);
688
+ expect(reparsed.errors).toEqual([]);
689
+ expect(reparsed.patterns).toEqual([]);
690
+ });
691
+
692
+ // Boundary, not a gap left open by this fix: readDataLiteralNode (used by
693
+ // readNameOrRef's object-literal branch) keeps a nested Identifier as a
694
+ // RawRefSentinel instead of resolving it — same as the parser side, which
695
+ // is why this call fails to extract too (not just to patch-match).
696
+ test("does not resolve an identifier nested inside an inline-ref positional arg", () => {
697
+ const sf = makeSourceFile(`
698
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
699
+
700
+ const ENTITY_NAME = "item";
701
+
702
+ defineFeature("inventory", (r) => {
703
+ r.useExtension("audit", { name: ENTITY_NAME });
704
+ });
705
+ `);
706
+ expect(() =>
707
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" }),
708
+ ).toThrow(/no call found/);
709
+ });
710
+ });
@@ -380,6 +380,161 @@ describe("renderPattern — single-pattern shape", () => {
380
380
  });
381
381
  expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
382
382
  });
383
+
384
+ // #2111 — a name-carrying pattern with a `*Raw` field re-emits the
385
+ // identifier verbatim; without it, the name flattens to its resolved
386
+ // literal same as before the fix.
387
+ test("useExtension pattern re-emits extensionNameRaw verbatim over the resolved literal", () => {
388
+ const out = renderPattern({
389
+ kind: "useExtension",
390
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
391
+ extensionName: "tenant-data",
392
+ extensionNameRaw: "EXT_TENANT_DATA",
393
+ entityName: "document",
394
+ });
395
+ expect(out).toBe('r.useExtension(EXT_TENANT_DATA, "document");');
396
+ });
397
+
398
+ test("useExtension pattern falls back to the resolved literal when extensionNameRaw is absent", () => {
399
+ const out = renderPattern({
400
+ kind: "useExtension",
401
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
402
+ extensionName: "tenant-data",
403
+ entityName: "document",
404
+ });
405
+ expect(out).toBe('r.useExtension({ name: "tenant-data", entity: "document" });');
406
+ });
407
+
408
+ test("useExtension pattern with extensionNameRaw indents a multi-line options object correctly", () => {
409
+ const out = renderPattern({
410
+ kind: "useExtension",
411
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
412
+ extensionName: "tenant-data",
413
+ extensionNameRaw: "EXT_TENANT_DATA",
414
+ entityName: "document",
415
+ options: {
416
+ description:
417
+ "a fairly long description that pushes this object past eighty characters wide",
418
+ scope: "tenant",
419
+ exportable: true,
420
+ },
421
+ });
422
+ expect(out).toBe(
423
+ [
424
+ 'r.useExtension(EXT_TENANT_DATA, "document", {',
425
+ ' description: "a fairly long description that pushes this object past eighty characters wide",',
426
+ ' scope: "tenant",',
427
+ " exportable: true,",
428
+ "});",
429
+ ].join("\n"),
430
+ );
431
+ });
432
+
433
+ test("extendsRegistrar pattern re-emits extensionNameRaw verbatim over the resolved literal", () => {
434
+ const out = renderPattern({
435
+ kind: "extendsRegistrar",
436
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
437
+ extensionName: "tenant-data",
438
+ extensionNameRaw: "EXT_TENANT_DATA",
439
+ defBody: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "{}" },
440
+ });
441
+ expect(out).toBe("r.extendsRegistrar(EXT_TENANT_DATA, {});");
442
+ });
443
+
444
+ test("defineEvent pattern re-emits eventNameRaw verbatim over the resolved literal", () => {
445
+ const out = renderPattern({
446
+ kind: "defineEvent",
447
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
448
+ eventName: "docIngested",
449
+ eventNameRaw: "DOC_INGESTED_EVENT",
450
+ schemaSource: {
451
+ file: "x",
452
+ start: { line: 1, column: 1 },
453
+ end: { line: 1, column: 1 },
454
+ raw: "z.object({})",
455
+ },
456
+ });
457
+ expect(out).toBe("r.defineEvent(DOC_INGESTED_EVENT, z.object({}));");
458
+ });
459
+ });
460
+
461
+ // #2111 — registrar-call names authored as an imported/local constant
462
+ // (`r.useExtension(EXT_TENANT_DATA, ...)`, the style #1746 taught the
463
+ // parser to resolve) must not get inlined into their resolved string
464
+ // literal on a render → parse round-trip — the framework's own
465
+ // bundled-features rely on the identifier reference surviving edits made
466
+ // through the Designer to unrelated fields on the same pattern.
467
+ const NAME_CONST_FEATURE = `
468
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
469
+ import { z } from "zod";
470
+
471
+ const EXT_TENANT_DATA = "tenant-data" as const;
472
+ const DOC_INGESTED_EVENT = "docIngested" as const;
473
+
474
+ defineFeature("user-data-rights", (r) => {
475
+ r.extendsRegistrar(EXT_TENANT_DATA, { onRegister: () => {} });
476
+ r.useExtension(EXT_TENANT_DATA, "document", { description: "org-scoped" });
477
+ r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }));
478
+ });
479
+ `;
480
+
481
+ describe("render → parse roundtrip — imported-constant registrar-call names (#2111)", () => {
482
+ const initial = parse(NAME_CONST_FEATURE);
483
+
484
+ test("parses the identifier-authored names, keeping the source text alongside the resolved value", () => {
485
+ expect(initial.patterns).toMatchObject([
486
+ {
487
+ kind: "extendsRegistrar",
488
+ extensionName: "tenant-data",
489
+ extensionNameRaw: "EXT_TENANT_DATA",
490
+ },
491
+ {
492
+ kind: "useExtension",
493
+ extensionName: "tenant-data",
494
+ extensionNameRaw: "EXT_TENANT_DATA",
495
+ },
496
+ { kind: "defineEvent", eventName: "docIngested", eventNameRaw: "DOC_INGESTED_EVENT" },
497
+ ]);
498
+ });
499
+
500
+ test("rendering re-emits the identifier verbatim instead of the resolved literal", () => {
501
+ const rendered = renderFeatureFile({
502
+ featureName: initial.featureName ?? "",
503
+ patterns: initial.patterns,
504
+ });
505
+ expect(rendered).toContain("r.extendsRegistrar(EXT_TENANT_DATA,");
506
+ expect(rendered).toContain("r.useExtension(EXT_TENANT_DATA,");
507
+ expect(rendered).toContain("r.defineEvent(DOC_INGESTED_EVENT,");
508
+ expect(rendered).not.toContain('"tenant-data"');
509
+ expect(rendered).not.toContain('"docIngested"');
510
+ });
511
+
512
+ test("editing an unrelated field on the useExtension pattern still preserves the identifier name", () => {
513
+ // Simulates a Designer save: only `options` changes; `extensionName` /
514
+ // `extensionNameRaw` pass through untouched, as replacePattern callers
515
+ // are expected to do (see UseExtensionPattern.extensionNameRaw doc).
516
+ const edited = initial.patterns.map((p) =>
517
+ p.kind === "useExtension"
518
+ ? { ...p, options: { ...p.options, description: "tenant-scoped" } }
519
+ : p,
520
+ );
521
+ const rendered = renderFeatureFile({
522
+ featureName: initial.featureName ?? "",
523
+ patterns: edited,
524
+ });
525
+ expect(rendered).toContain("r.useExtension(EXT_TENANT_DATA,");
526
+ expect(rendered).toContain("tenant-scoped");
527
+ });
528
+
529
+ // No "reparse the full renderFeatureFile output" case here, unlike the
530
+ // other roundtrip blocks in this file: renderFeatureFile only emits the
531
+ // version header + imports + defineFeature body (see its doc comment),
532
+ // so it drops the top-level `const EXT_TENANT_DATA = ...` this fixture
533
+ // relies on — reparsing would ParseError for an unrelated, pre-existing
534
+ // reason (the const is gone), not because the raw-preservation broke.
535
+ // The real edit path (`replacePattern` patching a single call in place
536
+ // on the *original* SourceFile, which keeps the const) is covered by
537
+ // patch.test.ts.
383
538
  });
384
539
 
385
540
  // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
@@ -10,6 +10,7 @@ import {
10
10
  ok,
11
11
  readDataLiteralNode,
12
12
  readNameLiteral,
13
+ readNameLiteralRef,
13
14
  readNameOrRef,
14
15
  readPropertyKey,
15
16
  } from "./shared";
@@ -147,8 +148,8 @@ export function extractDefineEvent(
147
148
  });
148
149
  }
149
150
 
150
- const eventName = readNameLiteral(first);
151
- if (eventName === undefined) {
151
+ const eventNameRef = readNameLiteralRef(first);
152
+ if (eventNameRef === undefined) {
152
153
  return fail(
153
154
  "defineEvent",
154
155
  sourceLocationFromNode(call, sourceFile),
@@ -187,7 +188,8 @@ export function extractDefineEvent(
187
188
  return ok({
188
189
  kind: "defineEvent",
189
190
  source: sourceLocationFromNode(call, sourceFile),
190
- eventName,
191
+ eventName: eventNameRef.value,
192
+ ...(eventNameRef.raw !== undefined && { eventNameRaw: eventNameRef.raw }),
191
193
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
192
194
  ...(version !== undefined && { version }),
193
195
  ...(migrations !== undefined && { migrations }),
@@ -21,6 +21,7 @@ import {
21
21
  ok,
22
22
  readDataLiteralNode,
23
23
  readNameLiteral,
24
+ readNameLiteralRef,
24
25
  readNameOrRef,
25
26
  } from "./shared";
26
27
 
@@ -425,8 +426,8 @@ export function extractUseExtension(
425
426
  });
426
427
  }
427
428
 
428
- const extensionName = readNameLiteral(first);
429
- if (extensionName === undefined) {
429
+ const extensionNameRef = readNameLiteralRef(first);
430
+ if (extensionNameRef === undefined) {
430
431
  return fail(
431
432
  "useExtension",
432
433
  sourceLocationFromNode(call, sourceFile),
@@ -465,7 +466,8 @@ export function extractUseExtension(
465
466
  return ok({
466
467
  kind: "useExtension",
467
468
  source: sourceLocationFromNode(call, sourceFile),
468
- extensionName,
469
+ extensionName: extensionNameRef.value,
470
+ ...(extensionNameRef.raw !== undefined && { extensionNameRaw: extensionNameRef.raw }),
469
471
  entityName,
470
472
  ...(options !== undefined && { options }),
471
473
  });
@@ -6,7 +6,7 @@ import type {
6
6
  UsesApiPattern,
7
7
  } from "../patterns";
8
8
  import { sourceLocationFromNode } from "../source-location";
9
- import { type ExtractOutput, fail, ok, readNameLiteral } from "./shared";
9
+ import { type ExtractOutput, fail, ok, readNameLiteral, readNameLiteralRef } from "./shared";
10
10
 
11
11
  export function extractEnvSchema(
12
12
  call: CallExpression,
@@ -33,8 +33,8 @@ export function extractExtendsRegistrar(
33
33
  ): ExtractOutput<ExtendsRegistrarPattern> {
34
34
  const args = call.getArguments();
35
35
  const first = args[0];
36
- const extensionName = first && readNameLiteral(first);
37
- if (!extensionName) {
36
+ const extensionNameRef = first && readNameLiteralRef(first);
37
+ if (!extensionNameRef) {
38
38
  return fail(
39
39
  "extendsRegistrar",
40
40
  sourceLocationFromNode(call, sourceFile),
@@ -52,7 +52,8 @@ export function extractExtendsRegistrar(
52
52
  return ok({
53
53
  kind: "extendsRegistrar",
54
54
  source: sourceLocationFromNode(call, sourceFile),
55
- extensionName,
55
+ extensionName: extensionNameRef.value,
56
+ ...(extensionNameRef.raw !== undefined && { extensionNameRaw: extensionNameRef.raw }),
56
57
  defBody: sourceLocationFromNode(defArg, sourceFile),
57
58
  });
58
59
  }
@@ -215,6 +215,32 @@ function resolveIdentifierToStringLiteral(identifier: Node): string | undefined
215
215
  return undefined;
216
216
  }
217
217
 
218
+ /**
219
+ * Resolved name plus, when the source node was an identifier rather than
220
+ * a string literal, the identifier's exact source text. Mirrors
221
+ * `RawRefSentinel`'s round-trip contract: extractors that need to re-emit
222
+ * the original reference (not its resolved value) keep `raw` alongside
223
+ * `value`; extractors that only need the string keep using
224
+ * `readNameLiteral`, which discards it.
225
+ */
226
+ export type NameLiteralRef = { readonly value: string; readonly raw?: string };
227
+
228
+ /**
229
+ * Like `readNameLiteral`, but for callers that must preserve an
230
+ * identifier-authored name across a render → parse round-trip (see #2111).
231
+ * `raw` is populated only when the node resolved via an identifier — a
232
+ * string-literal node has nothing worth preserving beyond its value, and
233
+ * setting `raw` for it too would make `JSON.stringify(value)` and
234
+ * `node.getText()` diverge on quote style.
235
+ */
236
+ export function readNameLiteralRef(node: Node): NameLiteralRef | undefined {
237
+ const literal = node.asKind(SyntaxKind.StringLiteral);
238
+ if (literal) return { value: literal.getLiteralValue() };
239
+ const resolved = resolveIdentifierToStringLiteral(node);
240
+ if (resolved === undefined) return undefined;
241
+ return { value: resolved, raw: node.getText() };
242
+ }
243
+
218
244
  /**
219
245
  * A node's string value when it's a string literal, or when it's a bare
220
246
  * Identifier that resolves to one via a `const X = "..."` declaration
@@ -223,12 +249,11 @@ function resolveIdentifierToStringLiteral(identifier: Node): string | undefined
223
249
  * `TENANT_SECRET_READ_EVENT`, ...) instead of repeating string literals.
224
250
  * undefined for anything unresolvable (factory call, member access,
225
251
  * external/ambient identifier) — callers keep their existing ParseError
226
- * fallback, no crash.
252
+ * fallback, no crash. Discards the original identifier text; use
253
+ * `readNameLiteralRef` when the caller must preserve it for rendering.
227
254
  */
228
255
  export function readNameLiteral(node: Node): string | undefined {
229
- const literal = node.asKind(SyntaxKind.StringLiteral);
230
- if (literal) return literal.getLiteralValue();
231
- return resolveIdentifierToStringLiteral(node);
256
+ return readNameLiteralRef(node)?.value;
232
257
  }
233
258
 
234
259
  export function readNameOrRef(node: Node): string | undefined {
@@ -31,6 +31,7 @@
31
31
  // preserve prefixed `// kumiko-comment:` markers across roundtrips.
32
32
 
33
33
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
34
+ import { readNameLiteral, readNameOrRef } from "./extractors/shared";
34
35
  import type { FeaturePattern, FeaturePatternKind } from "./patterns";
35
36
  import { indent, PATTERN_INDENT, renderPattern } from "./render";
36
37
 
@@ -341,8 +342,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
341
342
  case "relation":
342
343
  // Positional: r.relation(entity, name, def) | Object: { entity, name, ... }
343
344
  if (matchFirstArgString(call, id.entityName)) {
344
- const second = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
345
- return second === id.relationName;
345
+ return matchArgString(call, 1, id.relationName);
346
346
  }
347
347
  return (
348
348
  matchObjectProperty(call, "entity", id.entityName) &&
@@ -374,8 +374,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
374
374
  );
375
375
  }
376
376
  if (matchFirstArgString(call, id.hookType)) {
377
- const target = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
378
- return target === id.target;
377
+ return matchArgNameOrRef(call, 1, id.target);
379
378
  }
380
379
  return (
381
380
  matchObjectProperty(call, "type", id.hookType) &&
@@ -395,8 +394,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
395
394
  case "useExtension":
396
395
  // Positional: r.useExtension(name, entity) | Object: { name, entity }
397
396
  if (matchFirstArgString(call, id.extensionName)) {
398
- const ent = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
399
- return ent === id.entityName;
397
+ return matchArgNameOrRef(call, 1, id.entityName);
400
398
  }
401
399
  return (
402
400
  matchObjectProperty(call, "name", id.extensionName) &&
@@ -430,33 +428,62 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
430
428
  }
431
429
  }
432
430
 
431
+ // Resolves an argument the same way the extractors do (readNameLiteral):
432
+ // a string literal directly, or a bare identifier following its
433
+ // declaration (same-file or imported) to a string-literal initializer —
434
+ // the dominant naming style in the framework's own bundled-features
435
+ // (`r.entity(ENTITY, ...)`, `r.useExtension(EXT_X, ...)`, see #1746).
436
+ //
437
+ // Narrow on purpose: every kind's arg-0 (and relation's arg-1) is parsed
438
+ // via readNameLiteral, never readNameOrRef (see round2.ts/round3.ts) —
439
+ // widening this shared helper to readNameOrRef would let an object-form
440
+ // call's first argument (an ObjectLiteralExpression) match here too,
441
+ // short-circuiting the object-form branch in callMatchesId. Positions
442
+ // where the parser itself accepts an inline `{ name: "..." }` ref use
443
+ // `matchArgNameOrRef` below instead.
444
+ function matchArgString(call: CallExpression, index: number, expected: string): boolean {
445
+ const arg = call.getArguments()[index];
446
+ if (!arg) return false;
447
+ return readNameLiteral(arg) === expected;
448
+ }
449
+
433
450
  function matchFirstArgString(call: CallExpression, expected: string): boolean {
434
- const first = call.getArguments()[0];
435
- const lit = first?.asKind(SyntaxKind.StringLiteral);
436
- return lit?.getLiteralValue() === expected;
451
+ return matchArgString(call, 0, expected);
437
452
  }
438
453
 
454
+ // Like matchArgString, but via readNameOrRef — for the specific positional
455
+ // slots where the parser accepts an inline `{ name: "..." }` object ref in
456
+ // addition to a literal/identifier (useExtension's entity arg, hook's
457
+ // target arg; see round3.ts:445 / hooks.ts:75). Not a drop-in replacement
458
+ // for matchArgString: applying it to an arg-0 position would match an
459
+ // object-form call's first (and only) argument, see the comment above.
460
+ function matchArgNameOrRef(call: CallExpression, index: number, expected: string): boolean {
461
+ const arg = call.getArguments()[index];
462
+ if (!arg) return false;
463
+ return readNameOrRef(arg) === expected;
464
+ }
465
+
466
+ // Object-form property values are resolved via readNameOrRef, not the
467
+ // narrower readNameLiteral — some properties (useExtension's `entity`,
468
+ // hook's `target`/`allOf`) accept an inline `{ name: "..." }` ref in the
469
+ // parser (see extractors/round3.ts, extractors/hooks.ts), not just a
470
+ // literal or identifier. readNameOrRef is a superset of readNameLiteral,
471
+ // so this is safe for every other property too.
439
472
  function matchObjectProperty(call: CallExpression, propName: string, expected: string): boolean {
440
473
  const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
441
474
  if (!obj) return false;
442
- const init = obj
443
- .getProperty(propName)
444
- ?.asKind(SyntaxKind.PropertyAssignment)
445
- ?.getInitializer()
446
- ?.asKind(SyntaxKind.StringLiteral);
447
- return init?.getLiteralValue() === expected;
475
+ const init = obj.getProperty(propName)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
476
+ if (!init) return false;
477
+ return readNameOrRef(init) === expected;
448
478
  }
449
479
 
450
480
  // Matches an r.hook `{ allOf: entity }` target — as the arg node directly
451
481
  // (positional call form) or as a nested object property (object call form).
452
482
  function matchAllOfArg(node: Node | undefined, expectedEntity: string): boolean {
453
483
  const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
454
- const init = obj
455
- ?.getProperty("allOf")
456
- ?.asKind(SyntaxKind.PropertyAssignment)
457
- ?.getInitializer()
458
- ?.asKind(SyntaxKind.StringLiteral);
459
- return init?.getLiteralValue() === expectedEntity;
484
+ const init = obj?.getProperty("allOf")?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
485
+ if (!init) return false;
486
+ return readNameOrRef(init) === expectedEntity;
460
487
  }
461
488
 
462
489
  function matchObjectAllOfProperty(
@@ -283,6 +283,14 @@ export type UseExtensionPattern = {
283
283
  readonly kind: "useExtension";
284
284
  readonly source: SourceLocation;
285
285
  readonly extensionName: string;
286
+ // Set only when `extensionName` was authored as an identifier resolving
287
+ // to a string constant (`r.useExtension(EXT_TENANT_DATA, ...)`) rather
288
+ // than a literal — the renderer emits this verbatim instead of
289
+ // `JSON.stringify(extensionName)` so the round-trip doesn't inline the
290
+ // reference (#2111). A caller constructing an edited pattern must omit
291
+ // this field (or recompute it) whenever it changes `extensionName`,
292
+ // otherwise the stale identifier text wins over the new value.
293
+ readonly extensionNameRaw?: string;
286
294
  readonly entityName: string;
287
295
  readonly options?: Readonly<Record<string, unknown>>;
288
296
  };
@@ -516,6 +524,11 @@ export type DefineEventPattern = {
516
524
  readonly kind: "defineEvent";
517
525
  readonly source: SourceLocation;
518
526
  readonly eventName: string;
527
+ // Set only when `eventName` was authored as an identifier resolving to
528
+ // a string constant rather than a literal — see UseExtensionPattern's
529
+ // `extensionNameRaw` doc for the round-trip contract (#2111). A caller
530
+ // editing `eventName` must omit or recompute this field.
531
+ readonly eventNameRaw?: string;
519
532
  readonly schemaSource: SourceLocation;
520
533
  readonly version?: number;
521
534
  // Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
@@ -532,6 +545,11 @@ export type ExtendsRegistrarPattern = {
532
545
  readonly kind: "extendsRegistrar";
533
546
  readonly source: SourceLocation;
534
547
  readonly extensionName: string;
548
+ // Set only when `extensionName` was authored as an identifier resolving
549
+ // to a string constant rather than a literal — see UseExtensionPattern's
550
+ // `extensionNameRaw` doc for the round-trip contract (#2111). A caller
551
+ // editing `extensionName` must omit or recompute this field.
552
+ readonly extensionNameRaw?: string;
535
553
  // Meta-programming surface — kept fully opaque in the MVP. The
536
554
  // Designer shows "Custom Registrar Extension"; AI leaves it alone.
537
555
  readonly defBody: SourceLocation;