@quonfig/cli 0.2.1 → 0.2.2
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/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 +60 -2
- 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,11 +19,14 @@
|
|
|
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';
|
|
28
31
|
// Inlined to keep this directory self-contained for the standalone bun-compile
|
|
29
32
|
// build that runs as the qfg-verify pre-receive hook in app-gitea — that build
|
|
@@ -631,8 +634,10 @@ export function validateWorkspace(workspaceDir, options = {}) {
|
|
|
631
634
|
});
|
|
632
635
|
}
|
|
633
636
|
}
|
|
634
|
-
if (options.validateValues)
|
|
637
|
+
if (options.validateValues) {
|
|
635
638
|
validateBoundJsonValues(parsed, config, schemaDocuments, relPath, issues);
|
|
639
|
+
warnMapKeyOrder(raw, parsed, config, schemaDocuments, relPath, issues);
|
|
640
|
+
}
|
|
636
641
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
637
642
|
if (declaredEnvIds.size > 0) {
|
|
638
643
|
for (const env of config.environments) {
|
|
@@ -866,8 +871,10 @@ export function validateFileMap(files, options = {}) {
|
|
|
866
871
|
suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
|
|
867
872
|
});
|
|
868
873
|
}
|
|
869
|
-
if (options.validateValues)
|
|
874
|
+
if (options.validateValues) {
|
|
870
875
|
validateBoundJsonValues(raw, config, schemaDocuments, relPath, issues);
|
|
876
|
+
warnMapKeyOrder(files.get(relPath) ?? '', raw, config, schemaDocuments, relPath, issues);
|
|
877
|
+
}
|
|
871
878
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
872
879
|
if (declaredEnvIds.size > 0) {
|
|
873
880
|
for (const env of config.environments) {
|
|
@@ -939,6 +946,57 @@ function validateBoundJsonValues(raw, config, schemaDocuments, file, issues) {
|
|
|
939
946
|
};
|
|
940
947
|
walk(raw, '');
|
|
941
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* Warn when a schema map (an object under `additionalProperties`) has integer
|
|
951
|
+
* keys that are not first, ascending (qfg-e87r.21). The app saves with
|
|
952
|
+
* JSON.stringify, which moves them there, so a hand-written file gets a
|
|
953
|
+
* one-time key-order diff. Same scope as validateBoundJsonValues. Always a
|
|
954
|
+
* warning: it never fails verify or rejects a push.
|
|
955
|
+
*/
|
|
956
|
+
function warnMapKeyOrder(text, raw, config, schemaDocuments, file, issues) {
|
|
957
|
+
if (config.type !== 'config' || config.valueType !== 'json' || !config.schemaKey)
|
|
958
|
+
return;
|
|
959
|
+
const schema = schemaDocuments.get(config.schemaKey);
|
|
960
|
+
if (!schema)
|
|
961
|
+
return;
|
|
962
|
+
// The file is scanned for its real key order at most once, and only if a
|
|
963
|
+
// map with an integer key exists.
|
|
964
|
+
let ordered = null;
|
|
965
|
+
const orderedDocument = () => {
|
|
966
|
+
if (ordered === null)
|
|
967
|
+
ordered = scanOrderedJson(text);
|
|
968
|
+
return ordered;
|
|
969
|
+
};
|
|
970
|
+
const walk = (node, segments, docPath) => {
|
|
971
|
+
if (Array.isArray(node)) {
|
|
972
|
+
for (const [index, child] of node.entries())
|
|
973
|
+
walk(child, [...segments, index], `${docPath}[${index}]`);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (typeof node !== 'object' || node === null)
|
|
977
|
+
return;
|
|
978
|
+
const record = node;
|
|
979
|
+
if (record.type === 'json' && 'value' in record) {
|
|
980
|
+
const valueSegments = [...segments, 'value'];
|
|
981
|
+
const warnings = findMapKeyOrderWarnings(schema, record.value, () => {
|
|
982
|
+
const doc = orderedDocument();
|
|
983
|
+
return doc && navigate(doc, valueSegments);
|
|
984
|
+
}, docPath || 'value');
|
|
985
|
+
for (const warning of warnings) {
|
|
986
|
+
const keys = warning.integerKeys.map((k) => `"${k}"`).join(', ');
|
|
987
|
+
issues.push({
|
|
988
|
+
file,
|
|
989
|
+
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.`,
|
|
990
|
+
severity: 'warning',
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
for (const [key, child] of Object.entries(record))
|
|
996
|
+
walk(child, [...segments, key], docPath ? `${docPath}.${key}` : key);
|
|
997
|
+
};
|
|
998
|
+
walk(raw, [], '');
|
|
999
|
+
}
|
|
942
1000
|
/**
|
|
943
1001
|
* A schema that does not compile is an error on the schema file, bound or not.
|
|
944
1002
|
* Gated on `validateValues` with the value checks; the CLI and the pre-receive
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.2.
|
|
1
|
+
declare const _default: "0.2.2";
|
|
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.2';
|