@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.
- package/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- 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/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- 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 +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- 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 +32 -11
- 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 +45 -38
- 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/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 +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- 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 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -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.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- 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/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- 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 +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- 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 +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- 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 +172 -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 +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- 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 +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- 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 +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- 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-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- 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 +264 -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 +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 {
|
|
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
|
// =============================================================================
|
|
@@ -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
|
|
206
|
-
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
250
|
+
// Empty literal — insert right after the opening brace.
|
|
256
251
|
const openBrace = obj.getFirstChildByKindOrThrow(SyntaxKind.OpenBraceToken);
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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";
|