@quonfig/cli 0.2.1 → 0.2.3

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,246 @@
1
+ /**
2
+ * Integer map key order warning (qfg-e87r.21).
3
+ *
4
+ * JavaScript objects always put integer keys first, ascending, then every
5
+ * other key in insertion order. The app editor reads a value with JSON.parse
6
+ * and saves it with JSON.stringify, so a hand-written map
7
+ * `{"b": 2, "a": 1, "5": 0}` comes back from an app save as
8
+ * `{"5": 0, "b": 2, "a": 1}`. Nothing is lost, but git gets a one-time
9
+ * key-order diff. This check warns about it up front.
10
+ *
11
+ * Scope: maps only, meaning objects under an `additionalProperties` schema
12
+ * (keys the author chooses). Objects with only schema-fixed `properties` are
13
+ * skipped. A map is checked only if it has at least one integer key, and the
14
+ * file text is scanned for the real key order (JSON.parse already reorders)
15
+ * only if some map has one.
16
+ *
17
+ * Lives inside cli/src/verify/ because the app-gitea hook build only copies
18
+ * this directory (see validate.ts). No dependencies on purpose.
19
+ */
20
+ /** Largest array index in JavaScript: 2^32 - 2. */
21
+ const MAX_INTEGER_KEY = 4_294_967_294;
22
+ /** True for keys JavaScript moves to the front: "0", "5", "1001"; not "007", "-1", "1.5". */
23
+ export function isIntegerKey(key) {
24
+ return /^(?:0|[1-9]\d*)$/.test(key) && Number(key) <= MAX_INTEGER_KEY;
25
+ }
26
+ const isPlainObject = (node) => typeof node === 'object' && node !== null && !Array.isArray(node);
27
+ /**
28
+ * Find maps in `value` (walked with `schema`) whose key order in the file
29
+ * would change on an app save. `readOrderedValue` returns `value` as written
30
+ * in the file (see scanOrderedJson); it is only called when some map has an
31
+ * integer key, so maps without one cost nothing extra.
32
+ */
33
+ export function findMapKeyOrderWarnings(schema, value, readOrderedValue, basePath) {
34
+ const candidates = new Map();
35
+ const walk = (node, nodeSchema, segments, depth) => {
36
+ if (depth > 64)
37
+ return;
38
+ const schemas = expandSchema(schema, nodeSchema);
39
+ if (schemas.length === 0)
40
+ return;
41
+ if (Array.isArray(node)) {
42
+ for (const [index, child] of node.entries()) {
43
+ for (const s of schemas)
44
+ walk(child, itemSchema(s, index), [...segments, index], depth + 1);
45
+ }
46
+ return;
47
+ }
48
+ if (!isPlainObject(node))
49
+ return;
50
+ const keys = Object.keys(node);
51
+ if (schemas.some((s) => isMapSchema(s)) && keys.some((k) => isIntegerKey(k))) {
52
+ candidates.set(formatPath(basePath, segments), segments);
53
+ }
54
+ for (const key of keys) {
55
+ for (const s of schemas) {
56
+ const child = propertySchema(s, key);
57
+ if (child !== undefined)
58
+ walk(node[key], child, [...segments, key], depth + 1);
59
+ }
60
+ }
61
+ };
62
+ walk(value, schema, [], 0);
63
+ if (candidates.size === 0)
64
+ return [];
65
+ const ordered = readOrderedValue();
66
+ if (!ordered)
67
+ return [];
68
+ const warnings = [];
69
+ for (const [path, segments] of candidates) {
70
+ const node = navigate(ordered, segments);
71
+ if (node?.kind !== 'object')
72
+ continue;
73
+ const keys = node.keys;
74
+ const integerKeys = keys.filter((k) => isIntegerKey(k));
75
+ const saved = [...integerKeys].sort((a, b) => Number(a) - Number(b));
76
+ saved.push(...keys.filter((k) => !isIntegerKey(k)));
77
+ if (saved.every((k, i) => k === keys[i]))
78
+ continue;
79
+ warnings.push({ integerKeys, path });
80
+ }
81
+ return warnings.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
82
+ }
83
+ // ── Schema navigation ───────────────────────────────────────────────────
84
+ function isMapSchema(schema) {
85
+ return schema.additionalProperties === true || isPlainObject(schema.additionalProperties);
86
+ }
87
+ /** Resolve local `$ref`s and flatten allOf/anyOf/oneOf into the schemas that apply. */
88
+ function expandSchema(root, schema, seen = new Set()) {
89
+ if (!isPlainObject(schema) || seen.has(schema))
90
+ return [];
91
+ seen.add(schema);
92
+ const out = [schema];
93
+ if (typeof schema.$ref === 'string')
94
+ out.push(...expandSchema(root, resolveRef(root, schema.$ref), seen));
95
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
96
+ const branches = schema[keyword];
97
+ if (Array.isArray(branches))
98
+ for (const branch of branches)
99
+ out.push(...expandSchema(root, branch, seen));
100
+ }
101
+ return out;
102
+ }
103
+ function resolveRef(root, ref) {
104
+ if (!ref.startsWith('#'))
105
+ return undefined;
106
+ let node = root;
107
+ for (const raw of ref.slice(1).split('/').filter(Boolean)) {
108
+ const part = decodeURIComponent(raw).replaceAll('~1', '/').replaceAll('~0', '~');
109
+ if (!isPlainObject(node))
110
+ return undefined;
111
+ node = node[part];
112
+ }
113
+ return node;
114
+ }
115
+ function propertySchema(schema, key) {
116
+ const properties = schema.properties;
117
+ if (isPlainObject(properties) && key in properties)
118
+ return properties[key];
119
+ return isMapSchema(schema) ? schema.additionalProperties : undefined;
120
+ }
121
+ function itemSchema(schema, index) {
122
+ if (Array.isArray(schema.prefixItems) && index < schema.prefixItems.length)
123
+ return schema.prefixItems[index];
124
+ // draft-07 tuple form
125
+ if (Array.isArray(schema.items))
126
+ return index < schema.items.length ? schema.items[index] : schema.additionalItems;
127
+ return schema.items;
128
+ }
129
+ function formatPath(base, segments) {
130
+ let out = base;
131
+ for (const segment of segments)
132
+ out += typeof segment === 'number' ? `[${segment}]` : `.${segment}`;
133
+ return out;
134
+ }
135
+ export function navigate(node, segments) {
136
+ let current = node;
137
+ for (const segment of segments) {
138
+ if (current?.kind === 'object' && typeof segment === 'string')
139
+ current = current.entries.get(segment);
140
+ else if (current?.kind === 'array' && typeof segment === 'number')
141
+ current = current.items[segment];
142
+ else
143
+ return undefined;
144
+ }
145
+ return current;
146
+ }
147
+ /**
148
+ * Parse JSON text keeping each object's keys in file order. Duplicate keys
149
+ * keep their first position, as JavaScript does. Returns undefined on text
150
+ * that is not JSON (JSON.parse has already reported it).
151
+ */
152
+ export function scanOrderedJson(text) {
153
+ let pos = 0;
154
+ const skipWhitespace = () => {
155
+ while (pos < text.length && ' \t\n\r'.includes(text[pos]))
156
+ pos++;
157
+ };
158
+ const readString = () => {
159
+ const start = pos;
160
+ pos++; // opening quote
161
+ while (pos < text.length && text[pos] !== '"')
162
+ pos += text[pos] === '\\' ? 2 : 1;
163
+ if (pos >= text.length)
164
+ throw new Error('unterminated string');
165
+ pos++; // closing quote
166
+ return JSON.parse(text.slice(start, pos));
167
+ };
168
+ const readValue = () => {
169
+ skipWhitespace();
170
+ const ch = text[pos];
171
+ if (ch === '{') {
172
+ pos++;
173
+ const keys = [];
174
+ const entries = new Map();
175
+ skipWhitespace();
176
+ if (text[pos] === '}') {
177
+ pos++;
178
+ return { entries, keys, kind: 'object' };
179
+ }
180
+ for (;;) {
181
+ skipWhitespace();
182
+ if (text[pos] !== '"')
183
+ throw new Error('expected key');
184
+ const key = readString();
185
+ skipWhitespace();
186
+ if (text[pos] !== ':')
187
+ throw new Error('expected colon');
188
+ pos++;
189
+ const child = readValue();
190
+ if (!entries.has(key))
191
+ keys.push(key);
192
+ entries.set(key, child);
193
+ skipWhitespace();
194
+ if (text[pos] === ',') {
195
+ pos++;
196
+ continue;
197
+ }
198
+ if (text[pos] === '}') {
199
+ pos++;
200
+ return { entries, keys, kind: 'object' };
201
+ }
202
+ throw new Error('expected , or }');
203
+ }
204
+ }
205
+ if (ch === '[') {
206
+ pos++;
207
+ const items = [];
208
+ skipWhitespace();
209
+ if (text[pos] === ']') {
210
+ pos++;
211
+ return { items, kind: 'array' };
212
+ }
213
+ for (;;) {
214
+ items.push(readValue());
215
+ skipWhitespace();
216
+ if (text[pos] === ',') {
217
+ pos++;
218
+ continue;
219
+ }
220
+ if (text[pos] === ']') {
221
+ pos++;
222
+ return { items, kind: 'array' };
223
+ }
224
+ throw new Error('expected , or ]');
225
+ }
226
+ }
227
+ if (ch === '"') {
228
+ readString();
229
+ return { kind: 'scalar' };
230
+ }
231
+ const start = pos;
232
+ while (pos < text.length && !',}] \t\n\r'.includes(text[pos]))
233
+ pos++;
234
+ if (pos === start)
235
+ throw new Error('expected value');
236
+ return { kind: 'scalar' };
237
+ };
238
+ try {
239
+ const node = readValue();
240
+ skipWhitespace();
241
+ return pos === text.length ? node : undefined;
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ }
@@ -19,6 +19,8 @@
19
19
  * - Schema-bound JSON values match their schema, and every schema compiles
20
20
  * (Ajv, opt-in via `validateValues`, which the CLI and the pre-receive
21
21
  * hook both set; see validateBoundJsonValues)
22
+ * - Warning: a schema map whose integer keys an app save would move to the
23
+ * front (same gate; see warnMapKeyOrder)
22
24
  * - Rule structure (criteria + value present)
23
25
  * - Value type consistency
24
26
  * - Weighted values non-empty and consistent
@@ -19,12 +19,16 @@
19
19
  * - Schema-bound JSON values match their schema, and every schema compiles
20
20
  * (Ajv, opt-in via `validateValues`, which the CLI and the pre-receive
21
21
  * hook both set; see validateBoundJsonValues)
22
+ * - Warning: a schema map whose integer keys an app save would move to the
23
+ * front (same gate; see warnMapKeyOrder)
22
24
  * - Rule structure (criteria + value present)
23
25
  * - Value type consistency
24
26
  * - Weighted values non-empty and consistent
25
27
  */
26
28
  import { z } from 'zod';
29
+ import { findMapKeyOrderWarnings, navigate, scanOrderedJson } from './map-key-order.js';
27
30
  import { compileSchema, validateAgainstSchema } from './schema-validator.js';
31
+ import { DURATION_FORMAT_HINT, isValidIsoDuration, parseDoubleValue, parseIntValue } from './value-grammar.js';
28
32
  // Inlined to keep this directory self-contained for the standalone bun-compile
29
33
  // build that runs as the qfg-verify pre-receive hook in app-gitea — that build
30
34
  // only copies cli/src/verify/, so any out-of-tree imports break it. Source of
@@ -66,8 +70,26 @@ const OperatorSchema = z.enum(OPERATORS);
66
70
  const LogLevelSchema = z.enum(LOG_LEVELS);
67
71
  const BoolValueSchema = z.object({ type: z.literal('bool'), value: z.boolean() });
68
72
  const StringValueSchema = z.object({ type: z.literal('string'), value: z.string() });
69
- const IntValueSchema = z.object({ type: z.literal('int'), value: z.union([z.number(), z.string()]) });
70
- const DoubleValueSchema = z.object({ type: z.literal('double'), value: z.union([z.number(), z.string()]) });
73
+ // Value grammars (qfg-e87r.30): the same definitions qfg create / set-default /
74
+ // override use (value-grammar.ts). Issues carry params.valueGrammar so the
75
+ // reporter can add the config key (formatSchemaIssue).
76
+ const grammarIssue = (ctx, message) => ctx.addIssue({ code: 'custom', message, params: { valueGrammar: true } });
77
+ const IntValueSchema = z.object({
78
+ type: z.literal('int'),
79
+ value: z.union([z.number(), z.string()]).superRefine((value, ctx) => {
80
+ const ok = typeof value === 'number' ? Number.isSafeInteger(value) : parseIntValue(value) !== undefined;
81
+ if (!ok)
82
+ grammarIssue(ctx, `invalid int ${JSON.stringify(value)}. Expected a whole number (optional -, ASCII digits) within +/-(2^53-1).`);
83
+ }),
84
+ });
85
+ const DoubleValueSchema = z.object({
86
+ type: z.literal('double'),
87
+ value: z.union([z.number(), z.string()]).superRefine((value, ctx) => {
88
+ const ok = typeof value === 'number' ? Number.isFinite(value) : parseDoubleValue(value) !== undefined;
89
+ if (!ok)
90
+ grammarIssue(ctx, `invalid double ${JSON.stringify(value)}. Expected a finite decimal number with an optional exponent, such as 2.5, .5 or -1.5e3.`);
91
+ }),
92
+ });
71
93
  const JsonValueSchema = z.object({
72
94
  type: z.literal('json'),
73
95
  value: z.any().refine((v) => typeof v !== 'string', {
@@ -75,7 +97,13 @@ const JsonValueSchema = z.object({
75
97
  }),
76
98
  });
77
99
  const StringListValueSchema = z.object({ type: z.literal('string_list'), value: z.array(z.string()) });
78
- const DurationValueSchema = z.object({ type: z.literal('duration'), value: z.string() });
100
+ const DurationValueSchema = z.object({
101
+ type: z.literal('duration'),
102
+ value: z.string().superRefine((value, ctx) => {
103
+ if (!isValidIsoDuration(value))
104
+ grammarIssue(ctx, `invalid duration ${JSON.stringify(value)}. ${DURATION_FORMAT_HINT}`);
105
+ }),
106
+ });
79
107
  const LogLevelValueSchema = z.object({ type: z.literal('log_level'), value: LogLevelSchema });
80
108
  const SchemaValueSchema = z.object({
81
109
  type: z.literal('schema'),
@@ -154,6 +182,17 @@ const StoredConfigSchema = z
154
182
  })
155
183
  .passthrough(); // Allow extra fields (tags, schemaUsageMode, etc.)
156
184
  const SchemaDocumentSchema = z.object({}).passthrough();
185
+ /**
186
+ * `Schema: <path> - <message>`. A value-grammar issue (duration / int /
187
+ * double) also names the config key, so the hook's error says which config
188
+ * holds the bad value, not just which file (qfg-e87r.30).
189
+ */
190
+ function formatSchemaIssue(issue, parsed) {
191
+ const base = `Schema: ${issue.path.join('.')} - ${issue.message}`;
192
+ const isGrammar = issue.code === 'custom' && issue.params?.valueGrammar;
193
+ const key = typeof parsed === 'object' && parsed !== null ? parsed.key : undefined;
194
+ return isGrammar && typeof key === 'string' ? `${base} (config "${key}")` : base;
195
+ }
157
196
  /** Maps config directory names to their expected config type. */
158
197
  const DIR_TO_TYPE = {
159
198
  configs: 'config',
@@ -466,7 +505,7 @@ export function validateWorkspace(workspaceDir, options = {}) {
466
505
  for (const issue of result.error.issues) {
467
506
  issues.push({
468
507
  file: relPath,
469
- message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
508
+ message: formatSchemaIssue(issue, parsed),
470
509
  severity: 'error',
471
510
  });
472
511
  }
@@ -631,8 +670,10 @@ export function validateWorkspace(workspaceDir, options = {}) {
631
670
  });
632
671
  }
633
672
  }
634
- if (options.validateValues)
673
+ if (options.validateValues) {
635
674
  validateBoundJsonValues(parsed, config, schemaDocuments, relPath, issues);
675
+ warnMapKeyOrder(raw, parsed, config, schemaDocuments, relPath, issues);
676
+ }
636
677
  // Check environment IDs referenced in config exist in quonfig.json
637
678
  if (declaredEnvIds.size > 0) {
638
679
  for (const env of config.environments) {
@@ -772,7 +813,7 @@ export function validateFileMap(files, options = {}) {
772
813
  for (const issue of result.error.issues) {
773
814
  issues.push({
774
815
  file: relPath,
775
- message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
816
+ message: formatSchemaIssue(issue, parsed),
776
817
  severity: 'error',
777
818
  });
778
819
  }
@@ -866,8 +907,10 @@ export function validateFileMap(files, options = {}) {
866
907
  suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
867
908
  });
868
909
  }
869
- if (options.validateValues)
910
+ if (options.validateValues) {
870
911
  validateBoundJsonValues(raw, config, schemaDocuments, relPath, issues);
912
+ warnMapKeyOrder(files.get(relPath) ?? '', raw, config, schemaDocuments, relPath, issues);
913
+ }
871
914
  // Check environment IDs referenced in config exist in quonfig.json
872
915
  if (declaredEnvIds.size > 0) {
873
916
  for (const env of config.environments) {
@@ -939,6 +982,57 @@ function validateBoundJsonValues(raw, config, schemaDocuments, file, issues) {
939
982
  };
940
983
  walk(raw, '');
941
984
  }
985
+ /**
986
+ * Warn when a schema map (an object under `additionalProperties`) has integer
987
+ * keys that are not first, ascending (qfg-e87r.21). The app saves with
988
+ * JSON.stringify, which moves them there, so a hand-written file gets a
989
+ * one-time key-order diff. Same scope as validateBoundJsonValues. Always a
990
+ * warning: it never fails verify or rejects a push.
991
+ */
992
+ function warnMapKeyOrder(text, raw, config, schemaDocuments, file, issues) {
993
+ if (config.type !== 'config' || config.valueType !== 'json' || !config.schemaKey)
994
+ return;
995
+ const schema = schemaDocuments.get(config.schemaKey);
996
+ if (!schema)
997
+ return;
998
+ // The file is scanned for its real key order at most once, and only if a
999
+ // map with an integer key exists.
1000
+ let ordered = null;
1001
+ const orderedDocument = () => {
1002
+ if (ordered === null)
1003
+ ordered = scanOrderedJson(text);
1004
+ return ordered;
1005
+ };
1006
+ const walk = (node, segments, docPath) => {
1007
+ if (Array.isArray(node)) {
1008
+ for (const [index, child] of node.entries())
1009
+ walk(child, [...segments, index], `${docPath}[${index}]`);
1010
+ return;
1011
+ }
1012
+ if (typeof node !== 'object' || node === null)
1013
+ return;
1014
+ const record = node;
1015
+ if (record.type === 'json' && 'value' in record) {
1016
+ const valueSegments = [...segments, 'value'];
1017
+ const warnings = findMapKeyOrderWarnings(schema, record.value, () => {
1018
+ const doc = orderedDocument();
1019
+ return doc && navigate(doc, valueSegments);
1020
+ }, docPath || 'value');
1021
+ for (const warning of warnings) {
1022
+ const keys = warning.integerKeys.map((k) => `"${k}"`).join(', ');
1023
+ issues.push({
1024
+ file,
1025
+ message: `map at ${warning.path} has integer keys (${keys}) that aren't first, in ascending order. Saving from the app moves them to the front. Reorder them here to avoid a one-time diff.`,
1026
+ severity: 'warning',
1027
+ });
1028
+ }
1029
+ return;
1030
+ }
1031
+ for (const [key, child] of Object.entries(record))
1032
+ walk(child, [...segments, key], docPath ? `${docPath}.${key}` : key);
1033
+ };
1034
+ walk(raw, [], '');
1035
+ }
942
1036
  /**
943
1037
  * A schema that does not compile is an error on the schema file, bound or not.
944
1038
  * Gated on `validateValues` with the value checks; the CLI and the pre-receive
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The ONE value grammar for durations (qfg-2agi.3) and int/double input
3
+ * (qfg-2agi.23), shared by qfg create, set-default, override and verify
4
+ * (qfg-e87r.30). util/coerce.ts re-exports it.
5
+ *
6
+ * SELF-CONTAINED ON PURPOSE: no imports. It lives under src/verify/ because the
7
+ * standalone qfg-verify pre-receive hook (app-gitea Dockerfile) is built from
8
+ * this directory alone.
9
+ */
10
+ export declare const DURATION_FORMAT_HINT = "Expected an ISO 8601 duration such as PT30S, PT5M, PT1H30M or P1DT6H (days, hours, minutes, seconds; a fraction only on seconds; at most P36500D).";
11
+ export declare const isValidIsoDuration: (value: string) => boolean;
12
+ export declare const parseIntValue: (value: string) => number | undefined;
13
+ export declare const parseDoubleValue: (value: string) => number | undefined;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The ONE value grammar for durations (qfg-2agi.3) and int/double input
3
+ * (qfg-2agi.23), shared by qfg create, set-default, override and verify
4
+ * (qfg-e87r.30). util/coerce.ts re-exports it.
5
+ *
6
+ * SELF-CONTAINED ON PURPOSE: no imports. It lives under src/verify/ because the
7
+ * standalone qfg-verify pre-receive hook (app-gitea Dockerfile) is built from
8
+ * this directory alone.
9
+ */
10
+ // The ONE duration grammar (qfg-2agi.29), shared by qfg create, set-default,
11
+ // override and verify. Fixture of record: integration-test-data
12
+ // tests/duration/grammar.yaml (test/util/duration-grammar.test.ts holds this
13
+ // function to it).
14
+ //
15
+ // ^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$
16
+ //
17
+ // plus: at least one component, no dangling T, a fraction only on S with at
18
+ // most 9 digits, total magnitude <= P36500D. [0-9] (not \d) and the whole
19
+ // string anchored so ports to other regex engines stay strict.
20
+ // JS \d is already ASCII-only; [0-9] is kept so a copy into Python/.NET is safe.
21
+ /* eslint-disable unicorn/better-regex */
22
+ const ISO_DURATION_PATTERN = /^P(?:(?<d>[0-9]+)D)?(?:T(?:(?<h>[0-9]+)H)?(?:(?<m>[0-9]+)M)?(?:(?<s>[0-9]+)(?:\.(?<f>[0-9]{1,9}))?S)?)?$/;
23
+ /* eslint-enable unicorn/better-regex */
24
+ export const DURATION_FORMAT_HINT = 'Expected an ISO 8601 duration such as PT30S, PT5M, PT1H30M or P1DT6H (days, hours, minutes, seconds; a fraction only on seconds; at most P36500D).';
25
+ const NANOS_PER_SECOND = 1000000000n;
26
+ const MAX_DURATION_NANOS = 36500n * 86400n * NANOS_PER_SECOND;
27
+ export const isValidIsoDuration = (value) => {
28
+ const match = ISO_DURATION_PATTERN.exec(value);
29
+ if (!match?.groups)
30
+ return false;
31
+ const { d, f, h, m, s } = match.groups;
32
+ // At least one component; a T must be followed by at least one of H/M/S.
33
+ if (d === undefined && h === undefined && m === undefined && s === undefined)
34
+ return false;
35
+ if (value.includes('T') && h === undefined && m === undefined && s === undefined)
36
+ return false;
37
+ // Exact integer arithmetic: huge digit strings cannot overflow past the cap.
38
+ const nanos = (BigInt(d ?? 0) * 86400n + BigInt(h ?? 0) * 3600n + BigInt(m ?? 0) * 60n + BigInt(s ?? 0)) * NANOS_PER_SECOND +
39
+ BigInt((f ?? '').padEnd(9, '0'));
40
+ return nanos <= MAX_DURATION_NANOS;
41
+ };
42
+ // The ONE int/double input grammar (qfg-2agi.23), shared by qfg create and
43
+ // set-default. Strict whole-string matches with ASCII digits only: no
44
+ // surrounding whitespace, no hex, no trailing junk ('12abc'), no NaN/Infinity.
45
+ // An int must stay within +/-(2^53-1) because it is written as a JSON number
46
+ // and anything larger silently loses precision. Returns undefined when the
47
+ // input is not valid.
48
+ /* eslint-disable unicorn/better-regex */
49
+ const INT_PATTERN = /^-?[0-9]+$/;
50
+ const DOUBLE_PATTERN = /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$/;
51
+ /* eslint-enable unicorn/better-regex */
52
+ export const parseIntValue = (value) => {
53
+ if (!INT_PATTERN.test(value))
54
+ return undefined;
55
+ const int = Number(value);
56
+ return Number.isSafeInteger(int) ? int : undefined;
57
+ };
58
+ export const parseDoubleValue = (value) => {
59
+ if (!DOUBLE_PATTERN.test(value))
60
+ return undefined;
61
+ const double = Number(value);
62
+ return Number.isFinite(double) ? double : undefined;
63
+ };
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.2.1";
1
+ declare const _default: "0.2.3";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.2.1';
2
+ export default '0.2.3';