@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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 (157) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -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();
@@ -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) {
@@ -62,6 +62,28 @@ const HANDLER_KEY_KINDS = {
62
62
  function lookupClassification(map, key) {
63
63
  return key in map ? map[key] : undefined;
64
64
  }
65
+ function isStaticStringNode(node) {
66
+ const kind = node.getKind();
67
+ return kind === SyntaxKind.StringLiteral || kind === SyntaxKind.NoSubstitutionTemplateLiteral;
68
+ }
69
+ function isBooleanLiteralNode(node) {
70
+ const kind = node.getKind();
71
+ return kind === SyntaxKind.TrueKeyword || kind === SyntaxKind.FalseKeyword;
72
+ }
73
+ // The literal readers below only understand a static string `description` and
74
+ // a true/false `unsafeSkipTransitionGuard`; any other value shape is lossy.
75
+ function hasUnreadableLiteralValue(key, initializer) {
76
+ if (key === "description")
77
+ return !(initializer && isStaticStringNode(initializer));
78
+ if (key === "unsafeSkipTransitionGuard")
79
+ return !(initializer && isBooleanLiteralNode(initializer));
80
+ return false;
81
+ }
82
+ function readStaticStringProperty(obj, key) {
83
+ const initializer = obj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
84
+ return (initializer?.asKind(SyntaxKind.StringLiteral) ??
85
+ initializer?.asKind(SyntaxKind.NoSubstitutionTemplateLiteral))?.getLiteralValue();
86
+ }
65
87
  // True when the call's object body/options carry a shape the extractor
66
88
  // cannot losslessly model property-by-property (a spread, a method/accessor
67
89
  // shorthand, a computed key, or a key outside the classification map above).
@@ -76,13 +98,21 @@ function hasUnmodeledShape(obj, methodName) {
76
98
  if (propAssign) {
77
99
  if (propAssign.getNameNode().getKind() === SyntaxKind.ComputedPropertyName)
78
100
  return true;
79
- if (lookupClassification(keyKinds, readPropertyKey(propAssign)) !== "modeled")
101
+ const key = readPropertyKey(propAssign);
102
+ if (lookupClassification(keyKinds, key) !== "modeled")
103
+ return true;
104
+ const initializer = propAssign.getInitializer();
105
+ if (hasUnreadableLiteralValue(key, initializer))
80
106
  return true;
81
107
  continue;
82
108
  }
83
109
  const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
84
110
  if (shorthand) {
85
- if (lookupClassification(keyKinds, shorthand.getName()) !== "modeled")
111
+ const key = shorthand.getName();
112
+ if (lookupClassification(keyKinds, key) !== "modeled")
113
+ return true;
114
+ // A shorthand's value is invisible to the literal readers below.
115
+ if (key === "description" || key === "unsafeSkipTransitionGuard")
86
116
  return true;
87
117
  continue;
88
118
  }
@@ -197,11 +227,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
197
227
  const headerResult = readHandlerHeaderFields(obj, methodName, sourceFile);
198
228
  if (headerResult.kind === "error")
199
229
  return headerResult;
200
- const descriptionLiteral = obj
201
- .getProperty("description")
202
- ?.asKind(SyntaxKind.PropertyAssignment)
203
- ?.getInitializer()
204
- ?.asKind(SyntaxKind.StringLiteral);
230
+ const description = readStaticStringProperty(obj, "description");
205
231
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
206
232
  return ok({
207
233
  source: sourceLocationFromNode(call, sourceFile),
@@ -209,9 +235,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
209
235
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
210
236
  handlerBody: sourceLocationFromNode(fn, sourceFile),
211
237
  ...headerResult.pattern,
212
- ...(descriptionLiteral !== undefined && {
213
- description: descriptionLiteral.getLiteralValue(),
214
- }),
238
+ ...(description !== undefined && { description }),
215
239
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
216
240
  });
217
241
  }
@@ -244,6 +268,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
244
268
  const optionsArg = args[3];
245
269
  let headerFields = {};
246
270
  let description;
271
+ let skipTransitionGuard;
247
272
  if (optionsArg) {
248
273
  const optionsObj = unwrapObjectLiteral(optionsArg);
249
274
  if (!optionsObj || hasUnmodeledShape(optionsObj, methodName)) {
@@ -253,12 +278,8 @@ export function parseHandlerCall(call, sourceFile, methodName) {
253
278
  if (headerResult.kind === "error")
254
279
  return headerResult;
255
280
  headerFields = headerResult.pattern;
256
- description = optionsObj
257
- .getProperty("description")
258
- ?.asKind(SyntaxKind.PropertyAssignment)
259
- ?.getInitializer()
260
- ?.asKind(SyntaxKind.StringLiteral)
261
- ?.getLiteralValue();
281
+ description = readStaticStringProperty(optionsObj, "description");
282
+ skipTransitionGuard = readBooleanProperty(optionsObj, "unsafeSkipTransitionGuard");
262
283
  }
263
284
  return ok({
264
285
  source: sourceLocationFromNode(call, sourceFile),
@@ -267,6 +288,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
267
288
  handlerBody: sourceLocationFromNode(fn, sourceFile),
268
289
  ...headerFields,
269
290
  ...(description !== undefined && { description }),
291
+ ...(skipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
270
292
  });
271
293
  }
272
294
  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";
@@ -162,7 +162,7 @@ export function createFeaturePatcher(sourceFile) {
162
162
  ...(escapeHatch !== undefined && { escapeHatch }),
163
163
  });
164
164
  },
165
- addStreamHandler({ name, schemaSource, handlerSource, access, rateLimit }) {
165
+ addStreamHandler({ name, schemaSource, handlerSource, access, rateLimit, escapeHatch }) {
166
166
  add({
167
167
  kind: "streamHandler",
168
168
  source: SYNTHETIC_LOC,
@@ -171,6 +171,7 @@ export function createFeaturePatcher(sourceFile) {
171
171
  handlerBody: rawLoc(handlerSource),
172
172
  ...(access !== undefined && { access }),
173
173
  ...(rateLimit !== undefined && { rateLimit }),
174
+ ...(escapeHatch !== undefined && { escapeHatch }),
174
175
  });
175
176
  },
176
177
  addHook({ type, target, handlerSource, phase, escapeHatch }) {
@@ -640,7 +640,7 @@ function renderValueWithRawSlots(value, indent) {
640
640
  }
641
641
  const DEFAULT_IMPORTS = [
642
642
  'import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";',
643
- 'import { z } from "zod";',
643
+ 'import * as z from "zod";',
644
644
  ];
645
645
  /**
646
646
  * Render a complete feature-file: schema-version header, imports, the