@cosmicdrift/kumiko-framework 0.333.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.
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/eagerload.js +2 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/config-deps.js +13 -0
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +44 -4
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +138 -36
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -19
- package/dist/engine/build-config-feature-schema.js +106 -22
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +5 -4
- package/dist/engine/index.js +4 -3
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +2 -0
- package/dist/engine/schema-builder.js +20 -3
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +28 -35
- package/dist/i18n/settings-hub-keys.js +21 -4
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-batch.js +8 -3
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +27 -7
- package/dist/pipeline/dispatcher-utils.js +4 -1
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +218 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// des config-Features (featureName "config") gemerged — der Renderer
|
|
15
15
|
// qualifiziert die kurzen ids/refs mit "config". Daher hier durchweg KURZE
|
|
16
16
|
// ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
|
|
17
|
+
import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
|
|
18
|
+
import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
|
|
19
|
+
import { isEncryptedAtRest } from "./config-helpers.js";
|
|
17
20
|
import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
|
|
18
21
|
import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
|
|
19
22
|
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
@@ -35,6 +38,14 @@ const SCOPE_ICON = {
|
|
|
35
38
|
user: "user",
|
|
36
39
|
};
|
|
37
40
|
const SCOPES_BROAD_TO_DEEP = ["system", "tenant", "user"];
|
|
41
|
+
const audienceDescriptionKey = (scope) => `config.settings.audience.${scope}`;
|
|
42
|
+
const PROVIDER_PANEL_TITLE_ENTRY = {
|
|
43
|
+
en: "Provider",
|
|
44
|
+
de: "Anbieter",
|
|
45
|
+
es: "Proveedor",
|
|
46
|
+
};
|
|
47
|
+
const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
|
|
48
|
+
const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
|
|
38
49
|
const audienceNavShortId = (scope) => `audience-${scope}`;
|
|
39
50
|
// Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
|
|
40
51
|
export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
|
|
@@ -212,7 +223,11 @@ function assertUniquePanelIds(ownerGroup, panels) {
|
|
|
212
223
|
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
213
224
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
214
225
|
const selectionScreen = {
|
|
215
|
-
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub
|
|
226
|
+
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
|
|
227
|
+
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
228
|
+
sectionTitle: PROVIDER_SECTION_TITLE_KEY,
|
|
229
|
+
submitLabel: SAVE_PROVIDER_LABEL_KEY,
|
|
230
|
+
}),
|
|
216
231
|
dormant: true,
|
|
217
232
|
};
|
|
218
233
|
const screens = [selectionScreen];
|
|
@@ -220,10 +235,8 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
220
235
|
const secretsPanels = [];
|
|
221
236
|
const accessRules = [ownerAccess];
|
|
222
237
|
const translations = {};
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (titleValues !== undefined)
|
|
226
|
-
translations[`screen:${selectionScreenId}.title`] = titleValues;
|
|
238
|
+
translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
|
|
239
|
+
Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
|
|
227
240
|
for (const plugin of plugins) {
|
|
228
241
|
const visibleWhen = {
|
|
229
242
|
query: SELECTED_EXTENSIONS_QUERY,
|
|
@@ -234,7 +247,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
234
247
|
const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
|
|
235
248
|
const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
|
|
236
249
|
screens.push({
|
|
237
|
-
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub),
|
|
250
|
+
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
|
|
238
251
|
dormant: true,
|
|
239
252
|
});
|
|
240
253
|
accessRules.push(pluginAccess);
|
|
@@ -243,6 +256,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
243
256
|
id: `${plugin.feature}-config`,
|
|
244
257
|
screen: pluginTenantScreenId(plugin),
|
|
245
258
|
visibleWhen,
|
|
259
|
+
chromeless: true,
|
|
246
260
|
});
|
|
247
261
|
}
|
|
248
262
|
if (plugin.secrets.length > 0) {
|
|
@@ -256,6 +270,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
256
270
|
id: `${plugin.feature}-secrets`,
|
|
257
271
|
screen: secretsScreenId,
|
|
258
272
|
visibleWhen,
|
|
273
|
+
chromeless: true,
|
|
259
274
|
});
|
|
260
275
|
}
|
|
261
276
|
}
|
|
@@ -263,9 +278,9 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
263
278
|
const dashboard = {
|
|
264
279
|
id: `${ownerGroup}-tenant`,
|
|
265
280
|
type: "dashboard",
|
|
266
|
-
|
|
281
|
+
showUpdatedAt: false,
|
|
267
282
|
panels: assertUniquePanelIds(ownerGroup, [
|
|
268
|
-
{ kind: "screen", id: "selection", screen: selectionScreenId },
|
|
283
|
+
{ kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
|
|
269
284
|
...ownerPanels,
|
|
270
285
|
...configPanels,
|
|
271
286
|
...secretsPanels,
|
|
@@ -479,10 +494,12 @@ function buildSettingsWorkspace(navs) {
|
|
|
479
494
|
navMembers,
|
|
480
495
|
};
|
|
481
496
|
}
|
|
482
|
-
function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
497
|
+
function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
483
498
|
const configKeys = {};
|
|
484
499
|
const fields = {};
|
|
485
500
|
const fieldLabels = {};
|
|
501
|
+
const fieldDescriptions = {};
|
|
502
|
+
const requiredFields = [];
|
|
486
503
|
// Field id collapses to the plain shortKey when the key stays in its own
|
|
487
504
|
// feature's group (100% of today's apps — byte-identical output). Only a
|
|
488
505
|
// cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
|
|
@@ -497,18 +514,16 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
|
497
514
|
}
|
|
498
515
|
seenFieldIds.set(id, k.qn);
|
|
499
516
|
configKeys[id] = k.qn;
|
|
500
|
-
fields[id] = deriveField(k
|
|
517
|
+
fields[id] = deriveField(k, hub.selectorOptions.get(k.qn), keys, fieldId);
|
|
501
518
|
// mask is the visibility gate, so collectMaskedKeys guarantees it here.
|
|
502
519
|
if (k.def.mask)
|
|
503
520
|
fieldLabels[id] = k.def.mask.title;
|
|
521
|
+
if (k.def.mask?.description !== undefined)
|
|
522
|
+
fieldDescriptions[id] = k.def.mask.description;
|
|
523
|
+
if (k.def.required === true)
|
|
524
|
+
requiredFields.push(id);
|
|
504
525
|
}
|
|
505
|
-
|
|
506
|
-
const descriptionKey = `${feature}.settings.description`;
|
|
507
|
-
const section = {
|
|
508
|
-
title: `${feature}.settings`,
|
|
509
|
-
...(hub.declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
|
|
510
|
-
fields: keys.map(fieldId),
|
|
511
|
-
};
|
|
526
|
+
const { section, screenDescription } = buildSettingsSection(feature, scope, keys.map(fieldId), hub, options);
|
|
512
527
|
return {
|
|
513
528
|
id: shortId,
|
|
514
529
|
type: "configEdit",
|
|
@@ -516,16 +531,79 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
|
516
531
|
configKeys,
|
|
517
532
|
fields,
|
|
518
533
|
fieldLabels,
|
|
519
|
-
|
|
534
|
+
...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
|
|
535
|
+
...(requiredFields.length > 0 && { requiredFields }),
|
|
536
|
+
...(screenDescription !== undefined && { description: screenDescription }),
|
|
537
|
+
...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
|
|
538
|
+
layout: { sections: [section], width: "full", variant: "settings-list" },
|
|
520
539
|
access,
|
|
521
540
|
};
|
|
522
541
|
}
|
|
523
|
-
|
|
542
|
+
// `{ field: <shortKey> }` names another key of the owner feature; on the form it
|
|
543
|
+
// has to point at that key's generated field id, so it must sit on the same mask.
|
|
544
|
+
function rewriteOptionsQueryFieldRefs(key, payload, screenKeys, fieldIdOf) {
|
|
545
|
+
return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
|
|
546
|
+
if (!isOptionsQueryFieldRef(value))
|
|
547
|
+
return [name, value];
|
|
548
|
+
const target = screenKeys.find((k) => k.ownerFeature === key.ownerFeature && k.shortKey === toKebab(value.field));
|
|
549
|
+
if (target === undefined) {
|
|
550
|
+
throw new Error(`[Settings-Hub] config key "${key.qn}" optionsQueryPayload "${name}" references key "${value.field}", which is not on the same generated settings screen (group "${key.feature}") — give both keys the same group, scope and a mask.`);
|
|
551
|
+
}
|
|
552
|
+
return [name, { field: fieldIdOf(target) }];
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
function buildSettingsSection(feature, scope, fieldIds, hub, options) {
|
|
556
|
+
// translate() echoes an undeclared key, so an ungated description would render raw.
|
|
557
|
+
const descriptionKey = `${feature}.settings.description`;
|
|
558
|
+
const featureDescription = hub.declaredTranslationKeys.has(descriptionKey)
|
|
559
|
+
? descriptionKey
|
|
560
|
+
: undefined;
|
|
561
|
+
const audienceDescription = options.description === null
|
|
562
|
+
? undefined
|
|
563
|
+
: (options.description ?? audienceDescriptionKey(scope));
|
|
564
|
+
// The section's header column holds one sentence: the feature's own, else the
|
|
565
|
+
// audience one. When both exist the audience sentence stays on the screen.
|
|
566
|
+
const sectionDescription = featureDescription ?? audienceDescription;
|
|
567
|
+
const screenDescription = featureDescription !== undefined ? audienceDescription : undefined;
|
|
568
|
+
// A feature whose page title equals its `<feature>.settings` label would have
|
|
569
|
+
// the section heading dropped as a duplicate; `.settings.section` lets it
|
|
570
|
+
// declare a distinct heading.
|
|
571
|
+
const sectionKey = `${feature}.settings.section`;
|
|
572
|
+
const section = {
|
|
573
|
+
title: options.sectionTitle ??
|
|
574
|
+
(hub.declaredTranslationKeys.has(sectionKey) ? sectionKey : `${feature}.settings`),
|
|
575
|
+
...(sectionDescription !== undefined && { description: sectionDescription }),
|
|
576
|
+
fields: fieldIds,
|
|
577
|
+
};
|
|
578
|
+
return { section, screenDescription };
|
|
579
|
+
}
|
|
580
|
+
// Option labels of the provider select: each plugin feature's `<feature>.settings`
|
|
581
|
+
// copy, under the option-label key the configEdit renderer already derives.
|
|
582
|
+
function providerOptionTranslations(selectionScreen, plugins, hub) {
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
|
|
585
|
+
if (!hub.selectorOptions.has(qn))
|
|
586
|
+
continue;
|
|
587
|
+
for (const plugin of plugins) {
|
|
588
|
+
const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
|
|
589
|
+
if (label === undefined)
|
|
590
|
+
continue;
|
|
591
|
+
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
|
|
592
|
+
out[key] = label;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
|
|
598
|
+
const def = key.def;
|
|
524
599
|
if (selectorPluginIds !== undefined)
|
|
525
600
|
return createSelectField({ options: selectorPluginIds });
|
|
526
601
|
switch (def.type) {
|
|
527
602
|
case "number":
|
|
528
|
-
return createNumberField(
|
|
603
|
+
return createNumberField({
|
|
604
|
+
...(def.bounds?.min !== undefined && { min: def.bounds.min }),
|
|
605
|
+
...(def.bounds?.max !== undefined && { max: def.bounds.max }),
|
|
606
|
+
});
|
|
529
607
|
case "boolean":
|
|
530
608
|
return createBooleanField();
|
|
531
609
|
case "select":
|
|
@@ -534,7 +612,7 @@ function deriveField(def, selectorPluginIds) {
|
|
|
534
612
|
options: [],
|
|
535
613
|
optionsQuery: def.optionsQuery,
|
|
536
614
|
...(def.optionsQueryPayload !== undefined && {
|
|
537
|
-
optionsQueryPayload: def.optionsQueryPayload,
|
|
615
|
+
optionsQueryPayload: rewriteOptionsQueryFieldRefs(key, def.optionsQueryPayload, screenKeys, fieldIdOf),
|
|
538
616
|
}),
|
|
539
617
|
});
|
|
540
618
|
}
|
|
@@ -542,7 +620,13 @@ function deriveField(def, selectorPluginIds) {
|
|
|
542
620
|
? createSelectField({ options: def.options })
|
|
543
621
|
: createTextField({ personal: false, reason: "system_metadata" });
|
|
544
622
|
default:
|
|
545
|
-
|
|
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
|
+
});
|
|
546
630
|
}
|
|
547
631
|
}
|
|
548
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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(
|
|
103
|
+
.slice(firstLineIsTitle ? 1 : 0)
|
|
90
104
|
.join("\n")
|
|
91
105
|
.trim();
|
|
92
106
|
const migration = fields.get("migration")?.trim();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions } from "./types/index.js";
|
|
1
|
+
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
|
|
2
2
|
export declare function isEncryptedAtRest(def: Pick<ConfigKeyDefinition, "encrypted" | "backing">): boolean;
|
|
3
3
|
export declare const access: {
|
|
4
4
|
readonly all: readonly string[];
|
|
@@ -19,7 +19,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
19
19
|
piiEncrypted?: T extends "text" ? boolean : never;
|
|
20
20
|
options?: readonly string[];
|
|
21
21
|
optionsQuery?: T extends "select" ? string : never;
|
|
22
|
-
optionsQueryPayload?: T extends "select" ?
|
|
22
|
+
optionsQueryPayload?: T extends "select" ? OptionsQueryPayload : never;
|
|
23
23
|
bounds?: T extends "number" ? ConfigBounds : never;
|
|
24
24
|
pattern?: T extends "text" ? {
|
|
25
25
|
regex: string;
|
|
@@ -7,6 +7,7 @@ import { acknowledgeConventionCrossTenant } from "../db/tenant-db.js";
|
|
|
7
7
|
import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
|
|
8
8
|
import { assertUnreachable } from "../utils/index.js";
|
|
9
9
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
|
|
10
|
+
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
10
11
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
11
12
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
12
13
|
// Convention-based handler factories for event-sourced aggregates.
|
|
@@ -233,7 +234,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
233
234
|
// present that identity, so their payload.id is dropped exactly as
|
|
234
235
|
// before this field became part of the schema.
|
|
235
236
|
const { id: _callerId, ...payloadWithoutId } = event.payload;
|
|
236
|
-
const payload = isSystemIdentity(event.user) ? event.payload : payloadWithoutId;
|
|
237
|
+
const payload = dropEmptyWriteOnlyValues(entity, isSystemIdentity(event.user) ? event.payload : payloadWithoutId);
|
|
237
238
|
return executor.create(payload, event.user, dbFor(ctx), {
|
|
238
239
|
preSave: runPreSave &&
|
|
239
240
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -249,7 +250,11 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
249
250
|
handler = async (event, ctx) => {
|
|
250
251
|
const { runPreSave } = ctx;
|
|
251
252
|
const db = dbFor(ctx);
|
|
252
|
-
const
|
|
253
|
+
const submitted = event.payload; // @cast-boundary engine-payload
|
|
254
|
+
const payload = {
|
|
255
|
+
...submitted,
|
|
256
|
+
changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
|
|
257
|
+
};
|
|
253
258
|
const user = await actingUserFor(event.user, payload.id, db);
|
|
254
259
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
255
260
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
129
|
-
.
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
// =============================================================================
|