@workbench-kit/field-remap 0.0.1-prototype.0 → 0.0.2-prototype.0.2.10

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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Object-path safety gate for field-remap read/write and template placeholders.
3
+ * Keeps prototype-mutating segments out of dotted path traversal.
4
+ *
5
+ * Grammar (no JSONPath / eval):
6
+ * - property: `city`, `meta.label`
7
+ * - index: `items[0].name`
8
+ * - wildcard: `items[*].name` (projection only; see `projectObjectPath`)
9
+ */
10
+
11
+ /** Identifier / index / wildcard dotted path. */
12
+ const SAFE_PATH_RE =
13
+ /^[A-Za-z_][A-Za-z0-9_]*(?:\[\d+\]|\[\*\])?(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\[\d+\]|\[\*\])?)*$/;
14
+
15
+ /** One dotted segment: `name`, `items[0]`, or `items[*]`. */
16
+ const SEGMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+|\*)\])?$/;
17
+
18
+ const UNSAFE_OBJECT_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
19
+
20
+ export type ObjectPathSegment =
21
+ | { readonly kind: 'property'; readonly name: string }
22
+ | { readonly kind: 'index'; readonly name: string; readonly index: number }
23
+ | { readonly kind: 'wildcard'; readonly name: string };
24
+
25
+ export class UnsafeObjectPathError extends Error {
26
+ readonly code = 'unsafe_object_path' as const;
27
+ readonly path: string;
28
+ readonly segment: string;
29
+
30
+ constructor(path: string, segment: string) {
31
+ super(`Object path "${path}" contains unsafe segment "${segment}".`);
32
+ this.name = 'UnsafeObjectPathError';
33
+ this.path = path;
34
+ this.segment = segment;
35
+ }
36
+ }
37
+
38
+ export class InvalidObjectPathError extends Error {
39
+ readonly code = 'invalid_object_path' as const;
40
+ readonly path: string;
41
+
42
+ constructor(path: string, reason?: string) {
43
+ super(
44
+ reason ? `Object path "${path}" is invalid: ${reason}` : `Object path "${path}" is invalid.`,
45
+ );
46
+ this.name = 'InvalidObjectPathError';
47
+ this.path = path;
48
+ }
49
+ }
50
+
51
+ function objectPathRawParts(path: string): string[] {
52
+ return path
53
+ .split('.')
54
+ .map((part) => part.trim())
55
+ .filter((part) => part.length > 0);
56
+ }
57
+
58
+ function findUnsafeObjectPathSegment(names: readonly string[]): string | undefined {
59
+ return names.find((part) => UNSAFE_OBJECT_PATH_SEGMENTS.has(part));
60
+ }
61
+
62
+ /**
63
+ * Parse a safe object path into typed segments.
64
+ * Throws {@link UnsafeObjectPathError} / {@link InvalidObjectPathError}.
65
+ */
66
+ export function parseObjectPath(path: string): ObjectPathSegment[] {
67
+ const trimmed = path.trim();
68
+ if (!trimmed) {
69
+ return [];
70
+ }
71
+ if (!SAFE_PATH_RE.test(trimmed)) {
72
+ throw new InvalidObjectPathError(trimmed, 'unsupported grammar');
73
+ }
74
+
75
+ const rawParts = objectPathRawParts(trimmed);
76
+ const propertyNames = rawParts.map((part) => {
77
+ const match = SEGMENT_RE.exec(part);
78
+ return match?.[1] ?? part;
79
+ });
80
+ const unsafeSegment = findUnsafeObjectPathSegment(propertyNames);
81
+ if (unsafeSegment) {
82
+ throw new UnsafeObjectPathError(trimmed, unsafeSegment);
83
+ }
84
+
85
+ return rawParts.map((part) => {
86
+ const match = SEGMENT_RE.exec(part);
87
+ if (!match) {
88
+ throw new InvalidObjectPathError(trimmed, `bad segment "${part}"`);
89
+ }
90
+ const name = match[1]!;
91
+ const bracket = match[2];
92
+ if (bracket === undefined) {
93
+ return { kind: 'property', name } satisfies ObjectPathSegment;
94
+ }
95
+ if (bracket === '*') {
96
+ return { kind: 'wildcard', name } satisfies ObjectPathSegment;
97
+ }
98
+ return {
99
+ kind: 'index',
100
+ name,
101
+ index: Number.parseInt(bracket, 10),
102
+ } satisfies ObjectPathSegment;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Parse dotted path segments as plain property names (no `[index]` / `[*]`).
108
+ * Rejects unsafe segments when any parts exist.
109
+ */
110
+ export function requireObjectPathParts(path: string): string[] {
111
+ const segments = parseObjectPath(path);
112
+ if (segments.some((segment) => segment.kind !== 'property')) {
113
+ throw new InvalidObjectPathError(
114
+ path,
115
+ 'index/wildcard segments are not allowed in this context',
116
+ );
117
+ }
118
+ return segments.map((segment) => segment.name);
119
+ }
120
+
121
+ export function isSafeObjectPath(path: string): boolean {
122
+ const trimmed = path.trim();
123
+ if (!SAFE_PATH_RE.test(trimmed)) {
124
+ return false;
125
+ }
126
+ try {
127
+ parseObjectPath(trimmed);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** True when the path contains at least one `[*]` segment. */
135
+ export function objectPathHasWildcard(path: string): boolean {
136
+ try {
137
+ return parseObjectPath(path).some((segment) => segment.kind === 'wildcard');
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
@@ -1,156 +1,302 @@
1
- /**
2
- * Lightweight path helpers for collection item projection and safe templates.
3
- * Supports simple dotted paths (`name`, `meta.label`) on plain objects.
4
- */
5
-
6
- /** Identifier or dotted path: `city`, `a.b` (no expressions / eval). */
7
- const SAFE_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
8
-
9
- /** Placeholder matcher: `{city}`, `{a.b}` — rejects expressions / spaces. */
10
- const TEMPLATE_PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}/g;
11
-
12
- export function isPlainObject(value: unknown): value is Record<string, unknown> {
13
- return value !== null && typeof value === 'object' && !Array.isArray(value);
14
- }
15
-
16
- export function isSafeObjectPath(path: string): boolean {
17
- return SAFE_PATH_RE.test(path.trim());
18
- }
19
-
20
- /**
21
- * Fill `{path}` placeholders from a plain object using safe dotted paths only.
22
- * Unknown / unsafe placeholders become empty strings. No JS eval.
23
- */
24
- export function applyStringTemplate(
25
- template: string,
26
- record: Readonly<Record<string, unknown>> | null | undefined,
27
- ): string {
28
- return template.replace(TEMPLATE_PLACEHOLDER_RE, (_match, path: string) => {
29
- if (!record || !isSafeObjectPath(path)) {
30
- return '';
31
- }
32
- const resolved = readObjectPath(record, path);
33
- if (resolved === null || resolved === undefined) {
34
- return '';
35
- }
36
- if (
37
- typeof resolved === 'string' ||
38
- typeof resolved === 'number' ||
39
- typeof resolved === 'boolean'
40
- ) {
41
- return String(resolved);
42
- }
43
- return '';
44
- });
45
- }
46
-
47
- export function readObjectPath(value: unknown, path: string): unknown {
48
- const parts = path
49
- .split('.')
50
- .map((part) => part.trim())
51
- .filter((part) => part.length > 0);
52
- if (parts.length === 0) {
53
- return value;
54
- }
55
-
56
- let current: unknown = value;
57
- for (const part of parts) {
58
- if (current === null || current === undefined || typeof current !== 'object') {
59
- return undefined;
60
- }
61
- current = (current as Record<string, unknown>)[part];
62
- }
63
- return current;
64
- }
65
-
66
- /**
67
- * Write `value` at a dotted path, creating plain-object parents as needed.
68
- * Returns a new root object (does not mutate `root`).
69
- */
70
- export function writeObjectPath(
71
- root: Readonly<Record<string, unknown>> | null | undefined,
72
- path: string,
73
- value: unknown,
74
- ): Record<string, unknown> {
75
- const parts = path
76
- .split('.')
77
- .map((part) => part.trim())
78
- .filter((part) => part.length > 0);
79
- if (parts.length === 0) {
80
- return isPlainObject(root) ? { ...root } : {};
81
- }
82
-
83
- const result: Record<string, unknown> = isPlainObject(root) ? { ...root } : {};
84
- let cursor: Record<string, unknown> = result;
85
-
86
- for (let index = 0; index < parts.length - 1; index += 1) {
87
- const part = parts[index]!;
88
- const existing = cursor[part];
89
- const nextChild: Record<string, unknown> = isPlainObject(existing) ? { ...existing } : {};
90
- cursor[part] = nextChild;
91
- cursor = nextChild;
92
- }
93
-
94
- cursor[parts[parts.length - 1]!] = value;
95
- return result;
96
- }
97
-
98
- /**
99
- * Project each array element through `itemSourcePath`.
100
- * Non-arrays are returned unchanged (callers decide whether that is valid).
101
- */
102
- export function projectCollectionItems(value: unknown, itemSourcePath: string): unknown {
103
- const path = itemSourcePath.trim();
104
- if (!path || !Array.isArray(value)) {
105
- return value;
106
- }
107
- return value.map((item) => readObjectPath(item, path));
108
- }
109
-
110
- export interface ArrayItemProjectionOption {
111
- readonly path: string;
112
- readonly label: string;
113
- readonly dataType?: string;
114
- }
115
-
116
- /**
117
- * Projection candidates for an array source:
118
- * 1) explicit item-schema `children` (path / label)
119
- * 2) else keys of the first object in `sampleValue`
120
- */
121
- export function listArrayItemProjectionOptions(source: {
122
- readonly children?: readonly {
123
- readonly label: string;
124
- readonly path?: string;
125
- readonly dataType?: string;
126
- }[];
127
- readonly sampleValue?: unknown;
128
- }): ArrayItemProjectionOption[] {
129
- if (source.children?.length) {
130
- const options: ArrayItemProjectionOption[] = [];
131
- for (const child of source.children) {
132
- const path = (child.path ?? child.label).trim();
133
- if (!path) {
134
- continue;
135
- }
136
- options.push({
137
- path,
138
- label: child.label,
139
- dataType: child.dataType,
140
- });
141
- }
142
- return options;
143
- }
144
-
145
- if (!Array.isArray(source.sampleValue) || source.sampleValue.length === 0) {
146
- return [];
147
- }
148
- const first = source.sampleValue[0];
149
- if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
150
- return [];
151
- }
152
- return Object.keys(first as Record<string, unknown>).map((key) => ({
153
- path: key,
154
- label: key,
155
- }));
156
- }
1
+ /**
2
+ * Lightweight path helpers for collection item projection and safe templates.
3
+ * Supports dotted paths (`name`, `meta.label`), indexes (`items[0].name`),
4
+ * and wildcard projection (`items[*].name`).
5
+ */
6
+
7
+ import {
8
+ InvalidObjectPathError,
9
+ isSafeObjectPath,
10
+ objectPathHasWildcard,
11
+ parseObjectPath,
12
+ type ObjectPathSegment,
13
+ UnsafeObjectPathError,
14
+ } from './objectPathSafety.js';
15
+
16
+ /** Default cap for `[*]` expansion to avoid resource exhaustion. */
17
+ export const DEFAULT_MAX_PATH_WILDCARD_EXPANSION = 1_000;
18
+
19
+ /** Placeholder matcher: `{city}`, `{a.b}` — rejects expressions / spaces / brackets. */
20
+ const TEMPLATE_PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}/g;
21
+
22
+ export class PathExpansionLimitError extends Error {
23
+ readonly code = 'path_expansion_limit' as const;
24
+ readonly path: string;
25
+ readonly limit: number;
26
+
27
+ constructor(path: string, limit: number) {
28
+ super(`Object path "${path}" exceeded wildcard expansion limit (${limit}).`);
29
+ this.name = 'PathExpansionLimitError';
30
+ this.path = path;
31
+ this.limit = limit;
32
+ }
33
+ }
34
+
35
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * Fill `{path}` placeholders from a plain object using safe dotted paths only.
41
+ * Unknown / unsafe placeholders become empty strings. No JS eval.
42
+ * Index/wildcard placeholders are not supported (left unchanged / empty).
43
+ */
44
+ export function applyStringTemplate(
45
+ template: string,
46
+ record: Readonly<Record<string, unknown>> | null | undefined,
47
+ ): string {
48
+ return template.replace(TEMPLATE_PLACEHOLDER_RE, (_match, path: string) => {
49
+ if (!record || !isSafeObjectPath(path) || objectPathHasWildcard(path)) {
50
+ return '';
51
+ }
52
+ const resolved = readObjectPath(record, path);
53
+ if (resolved === null || resolved === undefined) {
54
+ return '';
55
+ }
56
+ if (
57
+ typeof resolved === 'string' ||
58
+ typeof resolved === 'number' ||
59
+ typeof resolved === 'boolean'
60
+ ) {
61
+ return String(resolved);
62
+ }
63
+ return '';
64
+ });
65
+ }
66
+
67
+ function readProperty(current: unknown, name: string): unknown {
68
+ if (current === null || current === undefined || typeof current !== 'object') {
69
+ return undefined;
70
+ }
71
+ return (current as Record<string, unknown>)[name];
72
+ }
73
+
74
+ function readSegment(current: unknown, segment: ObjectPathSegment): unknown {
75
+ const container = readProperty(current, segment.name);
76
+ if (segment.kind === 'property') {
77
+ return container;
78
+ }
79
+ if (segment.kind === 'index') {
80
+ if (!Array.isArray(container)) {
81
+ return undefined;
82
+ }
83
+ return container[segment.index];
84
+ }
85
+ // Wildcard is not a single-value read.
86
+ throw new InvalidObjectPathError(segment.name, 'wildcard segments require projectObjectPath');
87
+ }
88
+
89
+ /**
90
+ * Read a single value at `path`. Supports property and numeric index segments.
91
+ * Wildcard (`[*]`) paths throw {@link InvalidObjectPathError} — use
92
+ * {@link projectObjectPath} instead.
93
+ */
94
+ export function readObjectPath(value: unknown, path: string): unknown {
95
+ const segments = parseObjectPath(path);
96
+ if (segments.length === 0) {
97
+ return value;
98
+ }
99
+ if (segments.some((segment) => segment.kind === 'wildcard')) {
100
+ throw new InvalidObjectPathError(path, 'wildcard segments require projectObjectPath');
101
+ }
102
+
103
+ let current: unknown = value;
104
+ for (const segment of segments) {
105
+ current = readSegment(current, segment);
106
+ if (current === undefined) {
107
+ return undefined;
108
+ }
109
+ }
110
+ return current;
111
+ }
112
+
113
+ export interface ProjectObjectPathOptions {
114
+ /** Max values produced by all `[*]` expansions combined (default 1000). */
115
+ readonly maxExpansion?: number;
116
+ }
117
+
118
+ /**
119
+ * Project values through a path that may include `[*]` wildcards and indexes.
120
+ * Non-array containers under a wildcard / missing paths fail closed (`[]` for
121
+ * a leading wildcard miss; `undefined` leaves when a non-wildcard branch misses).
122
+ */
123
+ export function projectObjectPath(
124
+ value: unknown,
125
+ path: string,
126
+ options: ProjectObjectPathOptions = {},
127
+ ): unknown {
128
+ const segments = parseObjectPath(path);
129
+ if (segments.length === 0) {
130
+ return value;
131
+ }
132
+ const maxExpansion = Math.max(1, options.maxExpansion ?? DEFAULT_MAX_PATH_WILDCARD_EXPANSION);
133
+
134
+ const walk = (current: unknown, index: number, expansionCount: { n: number }): unknown => {
135
+ if (index >= segments.length) {
136
+ expansionCount.n += 1;
137
+ if (expansionCount.n > maxExpansion) {
138
+ throw new PathExpansionLimitError(path, maxExpansion);
139
+ }
140
+ return current;
141
+ }
142
+
143
+ const segment = segments[index]!;
144
+ const container = readProperty(current, segment.name);
145
+
146
+ if (segment.kind === 'property') {
147
+ if (container === undefined) {
148
+ return undefined;
149
+ }
150
+ return walk(container, index + 1, expansionCount);
151
+ }
152
+
153
+ if (segment.kind === 'index') {
154
+ if (!Array.isArray(container)) {
155
+ return undefined;
156
+ }
157
+ return walk(container[segment.index], index + 1, expansionCount);
158
+ }
159
+
160
+ // wildcard
161
+ if (!Array.isArray(container)) {
162
+ return [];
163
+ }
164
+ return container.map((item) => walk(item, index + 1, expansionCount));
165
+ };
166
+
167
+ return walk(value, 0, { n: 0 });
168
+ }
169
+
170
+ /**
171
+ * Write `value` at a dotted / indexed path, creating plain-object parents as needed.
172
+ * Index segments grow arrays with `undefined` holes when needed.
173
+ * Wildcard paths throw {@link InvalidObjectPathError}.
174
+ * Returns a new root object (does not mutate `root`).
175
+ */
176
+ export function writeObjectPath(
177
+ root: Readonly<Record<string, unknown>> | null | undefined,
178
+ path: string,
179
+ value: unknown,
180
+ ): Record<string, unknown> {
181
+ const segments = parseObjectPath(path);
182
+ if (segments.length === 0) {
183
+ return isPlainObject(root) ? { ...root } : {};
184
+ }
185
+ if (segments.some((segment) => segment.kind === 'wildcard')) {
186
+ throw new InvalidObjectPathError(path, 'wildcard segments cannot be written');
187
+ }
188
+
189
+ const writeInto = (current: unknown, segmentIndex: number): unknown => {
190
+ const segment = segments[segmentIndex]!;
191
+ const isLast = segmentIndex === segments.length - 1;
192
+ const asObject: Record<string, unknown> = isPlainObject(current) ? { ...current } : {};
193
+
194
+ if (segment.kind === 'property') {
195
+ if (isLast) {
196
+ asObject[segment.name] = value;
197
+ return asObject;
198
+ }
199
+ asObject[segment.name] = writeInto(asObject[segment.name], segmentIndex + 1);
200
+ return asObject;
201
+ }
202
+
203
+ // index: object[name][index]... (wildcards rejected above)
204
+ if (segment.kind !== 'index') {
205
+ return asObject;
206
+ }
207
+ const existingArr = asObject[segment.name];
208
+ const nextArr = Array.isArray(existingArr) ? [...existingArr] : [];
209
+ if (isLast) {
210
+ nextArr[segment.index] = value;
211
+ asObject[segment.name] = nextArr;
212
+ return asObject;
213
+ }
214
+ nextArr[segment.index] = writeInto(nextArr[segment.index], segmentIndex + 1);
215
+ asObject[segment.name] = nextArr;
216
+ return asObject;
217
+ };
218
+
219
+ const nextRoot = writeInto(root, 0);
220
+ return isPlainObject(nextRoot) ? nextRoot : {};
221
+ }
222
+
223
+ /**
224
+ * Project each array element through `itemSourcePath`.
225
+ * When `itemSourcePath` includes indexes / wildcards, uses {@link projectObjectPath}
226
+ * per element (or on the array when the path starts with a collection key).
227
+ * Non-arrays are returned unchanged (callers decide whether that is valid).
228
+ */
229
+ export function projectCollectionItems(value: unknown, itemSourcePath: string): unknown {
230
+ const path = itemSourcePath.trim();
231
+ if (!path || !Array.isArray(value)) {
232
+ return value;
233
+ }
234
+ if (objectPathHasWildcard(path) || path.includes('[')) {
235
+ // Per-item relative paths (`name`, `meta.label`, `tags[0]`) — map each element.
236
+ return value.map((item) => {
237
+ try {
238
+ return objectPathHasWildcard(path)
239
+ ? projectObjectPath(item, path)
240
+ : readObjectPath(item, path);
241
+ } catch (error) {
242
+ if (
243
+ error instanceof UnsafeObjectPathError ||
244
+ error instanceof InvalidObjectPathError ||
245
+ error instanceof PathExpansionLimitError
246
+ ) {
247
+ return undefined;
248
+ }
249
+ throw error;
250
+ }
251
+ });
252
+ }
253
+ return value.map((item) => readObjectPath(item, path));
254
+ }
255
+
256
+ export interface ArrayItemProjectionOption {
257
+ readonly path: string;
258
+ readonly label: string;
259
+ readonly dataType?: string;
260
+ }
261
+
262
+ /**
263
+ * Projection candidates for an array source:
264
+ * 1) explicit item-schema `children` (path / label)
265
+ * 2) else keys of the first object in `sampleValue`
266
+ */
267
+ export function listArrayItemProjectionOptions(source: {
268
+ readonly children?: readonly {
269
+ readonly label: string;
270
+ readonly path?: string;
271
+ readonly dataType?: string;
272
+ }[];
273
+ readonly sampleValue?: unknown;
274
+ }): ArrayItemProjectionOption[] {
275
+ if (source.children?.length) {
276
+ const options: ArrayItemProjectionOption[] = [];
277
+ for (const child of source.children) {
278
+ const path = (child.path ?? child.label).trim();
279
+ if (!path) {
280
+ continue;
281
+ }
282
+ options.push({
283
+ path,
284
+ label: child.label,
285
+ dataType: child.dataType,
286
+ });
287
+ }
288
+ return options;
289
+ }
290
+
291
+ if (!Array.isArray(source.sampleValue) || source.sampleValue.length === 0) {
292
+ return [];
293
+ }
294
+ const first = source.sampleValue[0];
295
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
296
+ return [];
297
+ }
298
+ return Object.keys(first as Record<string, unknown>).map((key) => ({
299
+ path: key,
300
+ label: key,
301
+ }));
302
+ }