@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -16,6 +16,7 @@
16
16
  // ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
17
17
  import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
18
18
  import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
19
+ import { isEncryptedAtRest } from "./config-helpers.js";
19
20
  import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
20
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
21
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
@@ -619,7 +620,13 @@ function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
619
620
  ? createSelectField({ options: def.options })
620
621
  : createTextField({ personal: false, reason: "system_metadata" });
621
622
  default:
622
- return createTextField({ personal: false, reason: "system_metadata" });
623
+ // A stored secret never goes back to the client: the form shows set/empty
624
+ // and sends only a replacement or a clear.
625
+ return createTextField({
626
+ personal: false,
627
+ reason: "system_metadata",
628
+ ...(def.type === "text" && isEncryptedAtRest(def) && { writeOnly: true }),
629
+ });
623
630
  }
624
631
  }
625
632
  function collectMaskedKeys(registry) {
@@ -54,25 +54,35 @@ function parseBlock(raw, source) {
54
54
  function proseWithoutMetadata(markdown) {
55
55
  const withoutFrontmatter = markdown.replace(/^\s*---\n[\s\S]*?\n---\s*/, "");
56
56
  return withoutFrontmatter
57
- .replace(BLOCK_RE, "")
58
57
  .split("\n")
59
58
  .map((line) => line.trim())
60
59
  .filter((line) => line !== "");
61
60
  }
61
+ function sameTitle(title, line) {
62
+ const normalize = (text) => text.replace(/[.\s]+$/, "").toLowerCase();
63
+ return line !== undefined && normalize(title) === normalize(line);
64
+ }
65
+ // BLOCK_RE closes at the first line starting with "-->"; text after it on
66
+ // that line means a field value contained one and the block ended early.
67
+ function assertBlockNotClosedEarly(markdown, match, source) {
68
+ const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
69
+ if (restOfLine.trim() !== "") {
70
+ throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
71
+ }
72
+ }
62
73
  /** Parse structured upgrade metadata embedded in a Changeset body. */
63
74
  export function parseChangesetChanges(markdown, source) {
64
75
  const changes = [];
65
- const prose = proseWithoutMetadata(markdown);
76
+ // Each block only inherits prose from the markdown between the previous block
77
+ // and itself; document-wide prose would give two features the same title.
78
+ let segmentStart = 0;
66
79
  BLOCK_RE.lastIndex = 0;
67
80
  let match = BLOCK_RE.exec(markdown);
68
81
  while (match !== null) {
69
82
  const block = match[1];
70
- // BLOCK_RE closes at the first line starting with "-->"; text after it on
71
- // that line means a field value contained one and the block ended early.
72
- const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
73
- if (restOfLine.trim() !== "") {
74
- throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
75
- }
83
+ const prose = proseWithoutMetadata(markdown.slice(segmentStart, match.index));
84
+ segmentStart = match.index + match[0].length;
85
+ assertBlockNotClosedEarly(markdown, match, source);
76
86
  if (!block)
77
87
  throw new Error(`${source}: empty kumiko-changes block`);
78
88
  const fields = parseBlock(block, source);
@@ -81,12 +91,16 @@ export function parseChangesetChanges(markdown, source) {
81
91
  if (!TYPE_VALUES.has(rawType)) {
82
92
  throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
83
93
  }
84
- const title = fields.get("title")?.trim() || prose[0];
94
+ const explicitTitle = fields.get("title")?.trim();
95
+ const title = explicitTitle || prose[0];
85
96
  if (!title)
86
97
  throw new Error(`${source}: kumiko-changes title is required`);
98
+ // The first prose line is the title's source only when no title was given or
99
+ // the explicit one restates it; otherwise it is genuine detail.
100
+ const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
87
101
  const detail = fields.get("detail")?.trim() ||
88
102
  prose
89
- .slice(prose.length > 0 ? 1 : 0)
103
+ .slice(firstLineIsTitle ? 1 : 0)
90
104
  .join("\n")
91
105
  .trim();
92
106
  const migration = fields.get("migration")?.trim();
@@ -17,7 +17,16 @@ function inlineFormFieldMaps(screen) {
17
17
  function validateLiteralCurrencyCode(where, field, currencies) {
18
18
  // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
19
19
  const shape = field;
20
- if (shape.type === "money" && shape.currency?.kind === "literal") {
20
+ // skip: not a money field, or no currency source declared; nothing to validate.
21
+ if (shape.type !== "money" || shape.currency === undefined)
22
+ return;
23
+ // An unknown kind would project through to the renderer, which silently falls
24
+ // back to entity.defaultCurrency instead of failing.
25
+ if (shape.currency.kind !== "literal" && shape.currency.kind !== "tenant") {
26
+ throw new Error(`${where} declares an unknown currency kind ${JSON.stringify(shape.currency.kind)}. ` +
27
+ `Expected { kind: "tenant" } or { kind: "literal", code }.`);
28
+ }
29
+ if (shape.currency.kind === "literal") {
21
30
  const code = shape.currency.code;
22
31
  if (typeof code !== "string" || !currencies.includes(code)) {
23
32
  throw new Error(`${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
@@ -101,6 +101,10 @@ export declare const EXT_DERIVATIVE_PUBLIC_PREDICATE: "derivativePublicPredicate
101
101
  * an image silently rendered without its QR would look correct while being
102
102
  * wrong.
103
103
  *
104
+ * `resolve()` runs on every `variant()` call — including cache hits on the
105
+ * anonymous public route — because the spec hash needs the resolved value.
106
+ * It must be cheap or cache its own lookups.
107
+ *
104
108
  * Registered/consumed by: `file-derivatives`'s `variant()`
105
109
  * (derivatives-context.ts), before the variant's spec hash is computed.
106
110
  */
@@ -130,6 +130,10 @@ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate";
130
130
  * an image silently rendered without its QR would look correct while being
131
131
  * wrong.
132
132
  *
133
+ * `resolve()` runs on every `variant()` call — including cache hits on the
134
+ * anonymous public route — because the spec hash needs the resolved value.
135
+ * It must be cheap or cache its own lookups.
136
+ *
133
137
  * Registered/consumed by: `file-derivatives`'s `variant()`
134
138
  * (derivatives-context.ts), before the variant's spec hash is computed.
135
139
  */
@@ -61,6 +61,12 @@ function extractEventMigrationsField(node, sourceFile) {
61
61
  : undefined;
62
62
  return result && Object.keys(result).length > 0 ? result : undefined;
63
63
  }
64
+ // `{ piiFields }` shorthand is a valid stance; its name node doubles as the location.
65
+ function readPiiFieldsNode(obj) {
66
+ const prop = obj?.getProperty("piiFields");
67
+ return (prop?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer() ??
68
+ prop?.asKind(SyntaxKind.ShorthandPropertyAssignment)?.getNameNode());
69
+ }
64
70
  export function extractDefineEvent(call, sourceFile) {
65
71
  const args = call.getArguments();
66
72
  const first = args[0];
@@ -94,10 +100,7 @@ export function extractDefineEvent(call, sourceFile) {
94
100
  if (typeof v === "number")
95
101
  version = v;
96
102
  }
97
- const piiFieldsInit = obj
98
- .getProperty("piiFields")
99
- ?.asKind(SyntaxKind.PropertyAssignment)
100
- ?.getInitializer();
103
+ const piiFieldsInit = readPiiFieldsNode(obj);
101
104
  if (!piiFieldsInit) {
102
105
  return fail("defineEvent", sourceLocationFromNode(call, sourceFile), 'requires an explicit `piiFields` stance (an object literal or "none")');
103
106
  }
@@ -130,11 +133,11 @@ export function extractDefineEvent(call, sourceFile) {
130
133
  let migrations;
131
134
  const optionsArg = args[2];
132
135
  const optionsObj = optionsArg?.asKind(SyntaxKind.ObjectLiteralExpression);
133
- const piiFieldsInit = optionsObj
134
- ?.getProperty("piiFields")
135
- ?.asKind(SyntaxKind.PropertyAssignment)
136
- ?.getInitializer();
136
+ const piiFieldsInit = readPiiFieldsNode(optionsObj);
137
137
  if (!piiFieldsInit) {
138
+ if (optionsArg && !optionsObj) {
139
+ return fail("defineEvent", sourceLocationFromNode(call, sourceFile), "options must be an object literal");
140
+ }
138
141
  return fail("defineEvent", sourceLocationFromNode(call, sourceFile), 'requires an explicit `piiFields` stance (an object literal or "none")');
139
142
  }
140
143
  if (optionsObj) {
@@ -44,6 +44,7 @@ const QUERY_HANDLER_KEY_KINDS = {
44
44
  agent: "modeled",
45
45
  rateLimit: "modeled",
46
46
  outputSchema: "opaque",
47
+ liveEntities: "opaque",
47
48
  escapeHatch: "modeled",
48
49
  };
49
50
  const STREAM_HANDLER_KEY_KINDS = {
@@ -62,6 +63,28 @@ const HANDLER_KEY_KINDS = {
62
63
  function lookupClassification(map, key) {
63
64
  return key in map ? map[key] : undefined;
64
65
  }
66
+ function isStaticStringNode(node) {
67
+ const kind = node.getKind();
68
+ return kind === SyntaxKind.StringLiteral || kind === SyntaxKind.NoSubstitutionTemplateLiteral;
69
+ }
70
+ function isBooleanLiteralNode(node) {
71
+ const kind = node.getKind();
72
+ return kind === SyntaxKind.TrueKeyword || kind === SyntaxKind.FalseKeyword;
73
+ }
74
+ // The literal readers below only understand a static string `description` and
75
+ // a true/false `unsafeSkipTransitionGuard`; any other value shape is lossy.
76
+ function hasUnreadableLiteralValue(key, initializer) {
77
+ if (key === "description")
78
+ return !(initializer && isStaticStringNode(initializer));
79
+ if (key === "unsafeSkipTransitionGuard")
80
+ return !(initializer && isBooleanLiteralNode(initializer));
81
+ return false;
82
+ }
83
+ function readStaticStringProperty(obj, key) {
84
+ const initializer = obj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
85
+ return (initializer?.asKind(SyntaxKind.StringLiteral) ??
86
+ initializer?.asKind(SyntaxKind.NoSubstitutionTemplateLiteral))?.getLiteralValue();
87
+ }
65
88
  // True when the call's object body/options carry a shape the extractor
66
89
  // cannot losslessly model property-by-property (a spread, a method/accessor
67
90
  // shorthand, a computed key, or a key outside the classification map above).
@@ -76,13 +99,21 @@ function hasUnmodeledShape(obj, methodName) {
76
99
  if (propAssign) {
77
100
  if (propAssign.getNameNode().getKind() === SyntaxKind.ComputedPropertyName)
78
101
  return true;
79
- if (lookupClassification(keyKinds, readPropertyKey(propAssign)) !== "modeled")
102
+ const key = readPropertyKey(propAssign);
103
+ if (lookupClassification(keyKinds, key) !== "modeled")
104
+ return true;
105
+ const initializer = propAssign.getInitializer();
106
+ if (hasUnreadableLiteralValue(key, initializer))
80
107
  return true;
81
108
  continue;
82
109
  }
83
110
  const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
84
111
  if (shorthand) {
85
- if (lookupClassification(keyKinds, shorthand.getName()) !== "modeled")
112
+ const key = shorthand.getName();
113
+ if (lookupClassification(keyKinds, key) !== "modeled")
114
+ return true;
115
+ // A shorthand's value is invisible to the literal readers below.
116
+ if (key === "description" || key === "unsafeSkipTransitionGuard")
86
117
  return true;
87
118
  continue;
88
119
  }
@@ -197,11 +228,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
197
228
  const headerResult = readHandlerHeaderFields(obj, methodName, sourceFile);
198
229
  if (headerResult.kind === "error")
199
230
  return headerResult;
200
- const descriptionLiteral = obj
201
- .getProperty("description")
202
- ?.asKind(SyntaxKind.PropertyAssignment)
203
- ?.getInitializer()
204
- ?.asKind(SyntaxKind.StringLiteral);
231
+ const description = readStaticStringProperty(obj, "description");
205
232
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
206
233
  return ok({
207
234
  source: sourceLocationFromNode(call, sourceFile),
@@ -209,9 +236,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
209
236
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
210
237
  handlerBody: sourceLocationFromNode(fn, sourceFile),
211
238
  ...headerResult.pattern,
212
- ...(descriptionLiteral !== undefined && {
213
- description: descriptionLiteral.getLiteralValue(),
214
- }),
239
+ ...(description !== undefined && { description }),
215
240
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
216
241
  });
217
242
  }
@@ -244,6 +269,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
244
269
  const optionsArg = args[3];
245
270
  let headerFields = {};
246
271
  let description;
272
+ let skipTransitionGuard;
247
273
  if (optionsArg) {
248
274
  const optionsObj = unwrapObjectLiteral(optionsArg);
249
275
  if (!optionsObj || hasUnmodeledShape(optionsObj, methodName)) {
@@ -253,12 +279,8 @@ export function parseHandlerCall(call, sourceFile, methodName) {
253
279
  if (headerResult.kind === "error")
254
280
  return headerResult;
255
281
  headerFields = headerResult.pattern;
256
- description = optionsObj
257
- .getProperty("description")
258
- ?.asKind(SyntaxKind.PropertyAssignment)
259
- ?.getInitializer()
260
- ?.asKind(SyntaxKind.StringLiteral)
261
- ?.getLiteralValue();
282
+ description = readStaticStringProperty(optionsObj, "description");
283
+ skipTransitionGuard = readBooleanProperty(optionsObj, "unsafeSkipTransitionGuard");
262
284
  }
263
285
  return ok({
264
286
  source: sourceLocationFromNode(call, sourceFile),
@@ -267,6 +289,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
267
289
  handlerBody: sourceLocationFromNode(fn, sourceFile),
268
290
  ...headerFields,
269
291
  ...(description !== undefined && { description }),
292
+ ...(skipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
270
293
  });
271
294
  }
272
295
  export function extractWriteHandler(call, sourceFile) {
@@ -1,4 +1,5 @@
1
- import type { Node, SourceFile, VariableDeclaration } from "ts-morph";
1
+ import type { SourceFile, VariableDeclaration } from "ts-morph";
2
+ import { Node } from "ts-morph";
2
3
  export declare function resolveModuleFile(fromFile: SourceFile, specifier: string): SourceFile | undefined;
3
4
  export declare function findImportBindingForLocalName(sourceFile: SourceFile, localName: string): {
4
5
  readonly moduleSpecifier: string;
@@ -10,5 +11,9 @@ export declare function findImportBindingForLocalName(sourceFile: SourceFile, lo
10
11
  * the cycle guard; `depth` backstops pathological non-cyclic chains.
11
12
  */
12
13
  export declare function resolveExportedVariable(file: SourceFile, exportedName: string, visited?: Set<string>, depth?: number): VariableDeclaration | undefined;
13
- /** A function parameter of the same name shadows and stops the search — it has no statically readable initializer. */
14
+ /**
15
+ * Any binding of the same name that is not a plain `const NAME = <init>`
16
+ * (parameter, destructuring, loop variable, catch variable) shadows and ends
17
+ * the search — it has no statically readable initializer.
18
+ */
14
19
  export declare function findScopedVariableDeclaration(identifier: Node, name: string): VariableDeclaration | undefined;
@@ -1,4 +1,4 @@
1
- import { SyntaxKind, ts } from "ts-morph";
1
+ import { Node, SyntaxKind, ts } from "ts-morph";
2
2
  const MODULE_RESOLUTION_OPTIONS = {
3
3
  moduleResolution: ts.ModuleResolutionKind.Bundler,
4
4
  module: ts.ModuleKind.ESNext,
@@ -112,11 +112,14 @@ function resolveViaStarExport(file, exportedName, visited, depth) {
112
112
  }
113
113
  return undefined;
114
114
  }
115
- /** A function parameter of the same name shadows and stops the search — it has no statically readable initializer. */
115
+ /**
116
+ * Any binding of the same name that is not a plain `const NAME = <init>`
117
+ * (parameter, destructuring, loop variable, catch variable) shadows and ends
118
+ * the search — it has no statically readable initializer.
119
+ */
116
120
  export function findScopedVariableDeclaration(identifier, name) {
117
121
  for (const ancestor of [identifier, ...identifier.getAncestors()]) {
118
- const params = getShadowingParameters(ancestor);
119
- if (params?.some((p) => p.getName() === name))
122
+ if (declaresShadowingBinding(ancestor, name))
120
123
  return undefined;
121
124
  const statements = getDirectStatements(ancestor);
122
125
  if (!statements)
@@ -125,21 +128,45 @@ export function findScopedVariableDeclaration(identifier, name) {
125
128
  const varStmt = stmt.asKind(SyntaxKind.VariableStatement);
126
129
  if (!varStmt)
127
130
  continue;
128
- const decl = varStmt
129
- .getDeclarationList()
130
- .getDeclarations()
131
- .find((d) => d.getName() === name);
132
- if (decl)
133
- return decl;
131
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
132
+ const nameNode = decl.getNameNode();
133
+ if (Node.isIdentifier(nameNode)) {
134
+ if (nameNode.getText() === name)
135
+ return decl;
136
+ }
137
+ else if (bindingNames(nameNode).includes(name)) {
138
+ return undefined;
139
+ }
140
+ }
134
141
  }
135
142
  }
136
143
  return undefined;
137
144
  }
138
- function getShadowingParameters(node) {
139
- const fn = node.asKind(SyntaxKind.ArrowFunction) ??
140
- node.asKind(SyntaxKind.FunctionExpression) ??
141
- node.asKind(SyntaxKind.FunctionDeclaration);
142
- return fn?.getParameters();
145
+ function bindingNames(nameNode) {
146
+ if (Node.isIdentifier(nameNode))
147
+ return [nameNode.getText()];
148
+ if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) {
149
+ return nameNode
150
+ .getElements()
151
+ .flatMap((element) => Node.isBindingElement(element) ? bindingNames(element.getNameNode()) : []);
152
+ }
153
+ return [];
154
+ }
155
+ function declaresShadowingBinding(node, name) {
156
+ if (Node.isParametered(node)) {
157
+ return node.getParameters().some((p) => bindingNames(p.getNameNode()).includes(name));
158
+ }
159
+ if (Node.isForOfStatement(node) || Node.isForInStatement(node) || Node.isForStatement(node)) {
160
+ const initializer = node.getInitializer();
161
+ return (initializer !== undefined &&
162
+ Node.isVariableDeclarationList(initializer) &&
163
+ initializer.getDeclarations().some((decl) => bindingNames(decl.getNameNode()).includes(name)));
164
+ }
165
+ if (Node.isCatchClause(node)) {
166
+ const decl = node.getVariableDeclaration();
167
+ return decl !== undefined && bindingNames(decl.getNameNode()).includes(name);
168
+ }
169
+ return false;
143
170
  }
144
171
  function getDirectStatements(node) {
145
172
  const block = node.asKind(SyntaxKind.Block);
@@ -28,3 +28,4 @@ export declare function parseFeatureFile(filePath: string): ParseResult;
28
28
  * instance and avoids re-IO per parse).
29
29
  */
30
30
  export declare function parseSourceFile(sourceFile: SourceFile): ParseResult;
31
+ export declare function relativePosixPath(fromDirectory: string, filePath: string): string;
@@ -68,16 +68,31 @@ export function parseSourceFile(sourceFile) {
68
68
  const errors = [];
69
69
  walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
70
70
  walkAiStepCalls(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
71
- // Tie-break on filePath so cross-file registrar wrappers don't interleave by
72
- // foreign line numbers alone.
71
+ // Tie-break on the path relative to the entry file's directory so cross-file
72
+ // registrar wrappers don't interleave by foreign line numbers alone, without
73
+ // the order depending on where the repo is checked out (worktrees).
74
+ const parseRoot = sourceFile.getDirectoryPath();
73
75
  patterns.sort((a, b) => {
74
- const fileCmp = a.source.file.localeCompare(b.source.file);
76
+ const fileCmp = relativePosixPath(parseRoot, a.source.file).localeCompare(relativePosixPath(parseRoot, b.source.file));
75
77
  if (fileCmp !== 0)
76
78
  return fileCmp;
77
79
  return a.source.start.line - b.source.start.line;
78
80
  });
79
81
  return { featureName, patterns, errors };
80
82
  }
83
+ // node:path is avoided: the Designer runs this against an in-memory file
84
+ // system outside Node. ts-morph paths are always posix-style.
85
+ export function relativePosixPath(fromDirectory, filePath) {
86
+ const fromSegments = fromDirectory.split("/").filter(Boolean);
87
+ const fileSegments = filePath.split("/").filter(Boolean);
88
+ let common = 0;
89
+ while (common < fromSegments.length &&
90
+ common < fileSegments.length - 1 &&
91
+ fromSegments[common] === fileSegments[common]) {
92
+ common += 1;
93
+ }
94
+ return [...fromSegments.slice(common).map(() => ".."), ...fileSegments.slice(common)].join("/");
95
+ }
81
96
  // =============================================================================
82
97
  // Internal — locate defineFeature + setup callback
83
98
  // =============================================================================
@@ -33,7 +33,7 @@
33
33
  // roadmap C-Notes for the canonical-comment-attach Pattern that would
34
34
  // preserve prefixed `// kumiko-comment:` markers across roundtrips.
35
35
  import { SyntaxKind, } from "ts-morph";
36
- import { readNameLiteral, readNameOrRef, resolveSameFileObjectLiteral, } from "./extractors/shared.js";
36
+ import { readNameLiteral, readNameOrRef, readPropertyKey, resolveSameFileObjectLiteral, } from "./extractors/shared.js";
37
37
  import { indent, PATTERN_INDENT, renderPattern, renderValue } from "./render.js";
38
38
  /**
39
39
  * Apply a sequence of changes to the source file in-place. The list is
@@ -202,33 +202,22 @@ const HEADER_KEY_FLAGS_BY_HANDLER_KIND = {
202
202
  queryHandler: QUERY_HANDLER_HEADER_KEY_FLAGS,
203
203
  streamHandler: STREAM_HANDLER_HEADER_KEY_FLAGS,
204
204
  };
205
- function propertyName(prop) {
206
- const assign = prop.asKind(SyntaxKind.PropertyAssignment);
207
- if (assign)
208
- return readNameLiteral(assign.getNameNode()) ?? assign.getNameNode().getText();
209
- const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
210
- if (shorthand)
211
- return shorthand.getName();
212
- return undefined;
205
+ function lastProperty(obj) {
206
+ return obj.getProperties().at(-1);
213
207
  }
214
- // Append after the literal's last property, skipping any property this
215
- // same change is about to unset — avoids interleaving the two edits' spans.
216
- function lastRemainingProperty(obj, unsetKeys) {
217
- const props = obj.getProperties();
218
- for (let i = props.length - 1; i >= 0; i--) {
219
- const prop = props[i];
220
- if (prop === undefined)
221
- continue;
222
- const name = propertyName(prop);
223
- if (name !== undefined && unsetKeys.has(name))
224
- continue;
225
- return prop;
226
- }
227
- return undefined;
208
+ // ts-morph's getProperty(name) keeps the quotes of string-literal keys, so `"description": x` would never match `description`.
209
+ function findPropertyByKey(obj, key) {
210
+ return obj.getProperties().find((prop) => {
211
+ const assign = prop.asKind(SyntaxKind.PropertyAssignment);
212
+ if (assign)
213
+ return readPropertyKey(assign) === key;
214
+ return prop.asKind(SyntaxKind.ShorthandPropertyAssignment)?.getName() === key;
215
+ });
228
216
  }
229
- function buildSetEdit(obj, key, value, unsetKeys) {
217
+ // Replaces the value of an already-present key; undefined when the key is missing.
218
+ function buildInPlaceSetEdit(obj, key, value) {
230
219
  const sourceFile = obj.getSourceFile();
231
- const existing = obj.getProperty(key);
220
+ const existing = findPropertyByKey(obj, key);
232
221
  const assign = existing?.asKind(SyntaxKind.PropertyAssignment);
233
222
  if (assign) {
234
223
  const init = assign.getInitializer();
@@ -248,28 +237,28 @@ function buildSetEdit(obj, key, value, unsetKeys) {
248
237
  text: `${key}: ${renderValue(value, col - 1)}`,
249
238
  };
250
239
  }
251
- const last = lastRemainingProperty(obj, unsetKeys);
240
+ return undefined;
241
+ }
242
+ // All missing keys go into ONE edit: separate edits would share the same
243
+ // anchor position and depend on apply order and trailing-comma luck.
244
+ function buildAppendEdit(obj, entries) {
245
+ const sourceFile = obj.getSourceFile();
246
+ const last = lastProperty(obj);
252
247
  const fullText = sourceFile.getFullText();
253
248
  const isSingleLineLiteral = !obj.getText().includes("\n");
254
249
  if (!last) {
255
- // Empty (or fully-unset) literal — insert right after the opening brace.
250
+ // Empty literal — insert right after the opening brace.
256
251
  const openBrace = obj.getFirstChildByKindOrThrow(SyntaxKind.OpenBraceToken);
257
- return {
258
- start: openBrace.getEnd(),
259
- end: openBrace.getEnd(),
260
- text: ` ${key}: ${renderValue(value, 0)} `,
261
- };
252
+ const joined = entries.map(([key, value]) => `${key}: ${renderValue(value, 0)}`).join(", ");
253
+ return { start: openBrace.getEnd(), end: openBrace.getEnd(), text: ` ${joined} ` };
262
254
  }
263
255
  if (isSingleLineLiteral) {
264
256
  let pos = last.getEnd();
265
257
  const hasComma = fullText[pos] === ",";
266
258
  if (hasComma)
267
259
  pos++;
268
- return {
269
- start: pos,
270
- end: pos,
271
- text: `${hasComma ? "" : ","} ${key}: ${renderValue(value, 0)}`,
272
- };
260
+ const joined = entries.map(([key, value]) => `${key}: ${renderValue(value, 0)}`).join(", ");
261
+ return { start: pos, end: pos, text: `${hasComma ? "" : ","} ${joined}` };
273
262
  }
274
263
  const col = sourceFile.getLineAndColumnAtPos(last.getStart()).column;
275
264
  const indentStr = " ".repeat(col - 1);
@@ -285,13 +274,12 @@ function buildSetEdit(obj, key, value, unsetKeys) {
285
274
  // appending here would land after it. Narrower than replace; bail.
286
275
  throw new Error("updatePattern: cannot append a header field — the handler literal's last property shares a line with other code; use replace");
287
276
  }
277
+ const appended = entries
278
+ .map(([key, value]) => `\n${indentStr}${key}: ${renderValue(value, col - 1)},`)
279
+ .join("");
288
280
  // Reconstruct the comma here (not inserted after the value) so it lands
289
281
  // right after the value, never inside a trailing `//` comment.
290
- return {
291
- start: valueEnd,
292
- end: lineEndPos,
293
- text: `,${restOfLine}\n${indentStr}${key}: ${renderValue(value, col - 1)},`,
294
- };
282
+ return { start: valueEnd, end: lineEndPos, text: `,${restOfLine}${appended}` };
295
283
  }
296
284
  // Whole-line delete: property was alone on its line (with its indentation
297
285
  // and trailing newline) — used when nothing else shares that line.
@@ -322,7 +310,7 @@ function buildInlineUnsetEdit(text, propStart, afterPos, hasTrailingComma) {
322
310
  return { start, end, text: "" };
323
311
  }
324
312
  function buildUnsetEdit(obj, key) {
325
- const prop = obj.getProperty(key);
313
+ const prop = findPropertyByKey(obj, key);
326
314
  if (!prop)
327
315
  return undefined; // no-op: nothing to remove, unset is idempotent
328
316
  const sourceFile = obj.getSourceFile();
@@ -346,12 +334,7 @@ function buildUnsetEdit(obj, key) {
346
334
  return buildWholeLineUnsetEdit(sourceFile, lineStartPos, afterPos);
347
335
  return buildInlineUnsetEdit(text, propStart, afterPos, hasTrailingComma);
348
336
  }
349
- /**
350
- * Apply a `HandlerHeaderUpdate` to the header fields named in `set`/`unset`
351
- * only, via targeted text edits — `schemaSource`, `handlerBody`, comments,
352
- * and every unnamed field stay byte-identical (narrower than replacePattern).
353
- */
354
- export function updatePattern(sourceFile, change) {
337
+ function resolveUpdatableHandlerLiteral(sourceFile, change) {
355
338
  const call = findCallForId(sourceFile, change.id);
356
339
  if (!call) {
357
340
  throw new Error(`updatePattern: no call found for ${describeId(change.id)}`);
@@ -371,6 +354,15 @@ export function updatePattern(sourceFile, change) {
371
354
  if (obj.getProperties().some((prop) => prop.isKind(SyntaxKind.SpreadAssignment))) {
372
355
  throw new Error("updatePattern: cannot update a handler literal containing a spread; use replace");
373
356
  }
357
+ return obj;
358
+ }
359
+ /**
360
+ * Apply a `HandlerHeaderUpdate` to the header fields named in `set`/`unset`
361
+ * only, via targeted text edits — `schemaSource`, `handlerBody`, comments,
362
+ * and every unnamed field stay byte-identical (narrower than replacePattern).
363
+ */
364
+ export function updatePattern(sourceFile, change) {
365
+ const obj = resolveUpdatableHandlerLiteral(sourceFile, change);
374
366
  const headerFlags = HEADER_KEY_FLAGS_BY_HANDLER_KIND[change.id.kind];
375
367
  const setEntries = Object.entries(change.set).filter(([, v]) => v !== undefined);
376
368
  for (const [key] of setEntries) {
@@ -396,20 +388,47 @@ export function updatePattern(sourceFile, change) {
396
388
  // Dedupe: two identical `unset` entries would otherwise produce two
397
389
  // overlapping deletes, the second eating text past the first's shrink.
398
390
  const unsetKeys = new Set(unsetKeyList);
399
- const edits = [
400
- ...setEntries.map(([key, value]) => buildSetEdit(obj, key, value, unsetKeys)),
401
- ...[...unsetKeys]
391
+ const originalText = sourceFile.getFullText();
392
+ try {
393
+ // Unsets run first and the set edits are built against the re-parsed
394
+ // result: an append anchor and an inline unset can otherwise share the
395
+ // same comma and overlap.
396
+ applyEditsDescending(sourceFile, [...unsetKeys]
402
397
  .map((key) => buildUnsetEdit(obj, key))
403
- .filter((edit) => edit !== undefined),
404
- ];
405
- // Apply from the highest offset down so an earlier edit's positions,
406
- // captured above against the pre-edit text, stay valid.
407
- edits
408
- .slice()
409
- .sort((a, b) => b.start - a.start)
410
- .forEach((edit) => {
398
+ .filter((edit) => edit !== undefined));
399
+ const settable = unsetKeys.size > 0 ? resolveUpdatableHandlerLiteral(sourceFile, change) : obj;
400
+ const inPlaceEdits = [];
401
+ const missingEntries = [];
402
+ for (const [key, value] of setEntries) {
403
+ const edit = buildInPlaceSetEdit(settable, key, value);
404
+ if (edit)
405
+ inPlaceEdits.push(edit);
406
+ else
407
+ missingEntries.push([key, value]);
408
+ }
409
+ applyEditsDescending(sourceFile, [
410
+ ...inPlaceEdits,
411
+ ...(missingEntries.length > 0 ? [buildAppendEdit(settable, missingEntries)] : []),
412
+ ]);
413
+ }
414
+ catch (error) {
415
+ sourceFile.replaceWithText(originalText);
416
+ throw error;
417
+ }
418
+ }
419
+ // Apply from the highest offset down so an earlier edit's positions,
420
+ // captured against the pre-edit text, stay valid.
421
+ function applyEditsDescending(sourceFile, edits) {
422
+ const sorted = edits.slice().sort((a, b) => b.start - a.start);
423
+ for (let i = 1; i < sorted.length; i++) {
424
+ const previous = sorted[i - 1];
425
+ const current = sorted[i];
426
+ if (previous && current && current.end > previous.start) {
427
+ throw new Error("updatePattern: internal error, text edits overlap");
428
+ }
429
+ }
430
+ for (const edit of sorted)
411
431
  sourceFile.replaceText([edit.start, edit.end], edit.text);
412
- });
413
432
  }
414
433
  // =============================================================================
415
434
  // Lookup
@@ -50,6 +50,7 @@ export type AddStreamHandlerArgs = {
50
50
  readonly handlerSource: string;
51
51
  readonly access?: AccessRule;
52
52
  readonly rateLimit?: RateLimitOption;
53
+ readonly escapeHatch?: EscapeHatchDeclaration;
53
54
  };
54
55
  export type AddHookArgs = {
55
56
  readonly type: LifecycleHookType | "validation";