@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.
- package/README.md +50 -50
- package/dist/commands/override.js +19 -1
- package/dist/commands/set-default.js +15 -3
- package/dist/init/schema.js +4 -1
- package/dist/util/coerce.d.ts +1 -1
- package/dist/util/coerce.js +10 -19
- package/dist/verify/map-key-order.d.ts +52 -0
- package/dist/verify/map-key-order.js +246 -0
- package/dist/verify/validate.d.ts +2 -0
- package/dist/verify/validate.js +101 -7
- package/dist/verify/value-grammar.d.ts +13 -0
- package/dist/verify/value-grammar.js +63 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +220 -220
- package/package.json +1 -1
|
@@ -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
|
package/dist/verify/validate.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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({
|
|
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:
|
|
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:
|
|
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
|
+
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.
|
|
2
|
+
export default '0.2.3';
|