@quonfig/cli 0.1.0 → 0.2.1
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/codegen/json-schema-to-zod.js +127 -22
- package/dist/codegen/language-mappers/zod-base-mapper.d.ts +11 -1
- package/dist/codegen/language-mappers/zod-base-mapper.js +14 -2
- package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -1
- package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +34 -5
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +6 -3
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +22 -11
- package/dist/codegen/zod-introspection.d.ts +12 -0
- package/dist/codegen/zod-introspection.js +15 -0
- package/dist/commands/push.js +1 -1
- package/dist/commands/verify.js +1 -1
- package/dist/commands/workspace/bootstrap.js +1 -1
- package/dist/migrate/push-to-cloud.js +1 -1
- package/dist/util/git-ops.js +1 -1
- package/dist/verify/schema-error-reshape.d.ts +69 -0
- package/dist/verify/schema-error-reshape.js +257 -0
- package/dist/verify/schema-validator.d.ts +29 -0
- package/dist/verify/schema-validator.js +108 -0
- package/dist/verify/standalone.js +54 -22
- package/dist/verify/validate.d.ts +15 -2
- package/dist/verify/validate.js +188 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +220 -220
- package/package.json +3 -1
package/dist/verify/validate.js
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
* - Log level constraints (valueType=log_level)
|
|
17
17
|
* - Referential integrity (IN_SEG/NOT_IN_SEG reference existing segments)
|
|
18
18
|
* - schemaKey references existing schemas
|
|
19
|
+
* - Schema-bound JSON values match their schema, and every schema compiles
|
|
20
|
+
* (Ajv, opt-in via `validateValues`, which the CLI and the pre-receive
|
|
21
|
+
* hook both set; see validateBoundJsonValues)
|
|
19
22
|
* - Rule structure (criteria + value present)
|
|
20
23
|
* - Value type consistency
|
|
21
24
|
* - Weighted values non-empty and consistent
|
|
22
25
|
*/
|
|
23
26
|
import { z } from 'zod';
|
|
27
|
+
import { compileSchema, validateAgainstSchema } from './schema-validator.js';
|
|
24
28
|
// Inlined to keep this directory self-contained for the standalone bun-compile
|
|
25
29
|
// build that runs as the qfg-verify pre-receive hook in app-gitea — that build
|
|
26
30
|
// only copies cli/src/verify/, so any out-of-tree imports break it. Source of
|
|
@@ -297,7 +301,7 @@ import * as path from 'node:path';
|
|
|
297
301
|
/**
|
|
298
302
|
* Validate an entire workspace directory on disk.
|
|
299
303
|
*/
|
|
300
|
-
export function validateWorkspace(workspaceDir) {
|
|
304
|
+
export function validateWorkspace(workspaceDir, options = {}) {
|
|
301
305
|
const issues = [];
|
|
302
306
|
let filesChecked = 0;
|
|
303
307
|
const stats = {
|
|
@@ -318,6 +322,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
318
322
|
const allSchemaFiles = [];
|
|
319
323
|
const segmentKeys = new Set();
|
|
320
324
|
const schemaKeys = new Set();
|
|
325
|
+
const schemaDocuments = new Map();
|
|
321
326
|
const declaredEnvIds = new Set();
|
|
322
327
|
// Check for unexpected top-level entries
|
|
323
328
|
let entries;
|
|
@@ -446,6 +451,9 @@ export function validateWorkspace(workspaceDir) {
|
|
|
446
451
|
}
|
|
447
452
|
const schemaKey = file.replace(/\.json$/, '');
|
|
448
453
|
schemaKeys.add(schemaKey);
|
|
454
|
+
schemaDocuments.set(schemaKey, result.data);
|
|
455
|
+
if (options.validateValues)
|
|
456
|
+
validateSchemaCompiles(schemaKey, result.data, relPath, issues);
|
|
449
457
|
validateKey(schemaKey, relPath, issues);
|
|
450
458
|
allSchemaFiles.push({ key: schemaKey, file: relPath });
|
|
451
459
|
stats.schemas++;
|
|
@@ -567,6 +575,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
567
575
|
}
|
|
568
576
|
// Second pass: referential integrity. Keep the plain skip-filter here —
|
|
569
577
|
// ghost entries were already reported (once) by the first pass.
|
|
578
|
+
const cycleNodes = [];
|
|
570
579
|
for (const dir of KNOWN_DIRS) {
|
|
571
580
|
const dirPath = path.join(workspaceDir, dir);
|
|
572
581
|
if (!fs.existsSync(dirPath))
|
|
@@ -596,6 +605,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
596
605
|
if (!result.success)
|
|
597
606
|
continue;
|
|
598
607
|
const config = result.data;
|
|
608
|
+
cycleNodes.push({ config, raw: parsed, relPath });
|
|
599
609
|
// Check segment references in criteria
|
|
600
610
|
const segRefs = collectSegmentReferences(config);
|
|
601
611
|
for (const ref of segRefs) {
|
|
@@ -621,6 +631,8 @@ export function validateWorkspace(workspaceDir) {
|
|
|
621
631
|
});
|
|
622
632
|
}
|
|
623
633
|
}
|
|
634
|
+
if (options.validateValues)
|
|
635
|
+
validateBoundJsonValues(parsed, config, schemaDocuments, relPath, issues);
|
|
624
636
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
625
637
|
if (declaredEnvIds.size > 0) {
|
|
626
638
|
for (const env of config.environments) {
|
|
@@ -637,6 +649,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
637
649
|
}
|
|
638
650
|
}
|
|
639
651
|
}
|
|
652
|
+
detectReferenceCycles(cycleNodes, issues);
|
|
640
653
|
// Check for duplicate keys across directories (case-insensitive — see
|
|
641
654
|
// detectDuplicateKeys).
|
|
642
655
|
const uniqueConfigKeys = detectDuplicateKeys(allConfigs, 'key', issues);
|
|
@@ -666,13 +679,14 @@ function emptyStats() {
|
|
|
666
679
|
* Validate configs provided as a map of { "dir/file.json": jsonString }.
|
|
667
680
|
* Used by the pre-receive hook which reads files from git objects.
|
|
668
681
|
*/
|
|
669
|
-
export function validateFileMap(files) {
|
|
682
|
+
export function validateFileMap(files, options = {}) {
|
|
670
683
|
const issues = [];
|
|
671
684
|
let filesChecked = 0;
|
|
672
685
|
const segmentKeys = new Set();
|
|
673
686
|
const schemaKeys = new Set();
|
|
674
687
|
const allSchemaFiles = [];
|
|
675
688
|
const allConfigs = [];
|
|
689
|
+
const schemaDocuments = new Map();
|
|
676
690
|
const parsedConfigs = [];
|
|
677
691
|
const declaredEnvIds = new Set();
|
|
678
692
|
// Validate quonfig.json if it's included in the file map (it may not be in every commit)
|
|
@@ -745,6 +759,9 @@ export function validateFileMap(files) {
|
|
|
745
759
|
}
|
|
746
760
|
const schemaKey = file.replace(/\.json$/, '');
|
|
747
761
|
schemaKeys.add(schemaKey);
|
|
762
|
+
schemaDocuments.set(schemaKey, result.data);
|
|
763
|
+
if (options.validateValues)
|
|
764
|
+
validateSchemaCompiles(schemaKey, result.data, relPath, issues);
|
|
748
765
|
validateKey(schemaKey, relPath, issues);
|
|
749
766
|
allSchemaFiles.push({ key: schemaKey, file: relPath });
|
|
750
767
|
continue;
|
|
@@ -826,10 +843,10 @@ export function validateFileMap(files) {
|
|
|
826
843
|
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
|
|
827
844
|
}
|
|
828
845
|
allConfigs.push({ key: config.key, file: relPath });
|
|
829
|
-
parsedConfigs.push({
|
|
846
|
+
parsedConfigs.push({ config, dir, raw: parsed, relPath });
|
|
830
847
|
}
|
|
831
848
|
// Referential integrity
|
|
832
|
-
for (const {
|
|
849
|
+
for (const { config, raw, relPath } of parsedConfigs) {
|
|
833
850
|
const segRefs = collectSegmentReferences(config);
|
|
834
851
|
for (const ref of segRefs) {
|
|
835
852
|
if (!segmentKeys.has(ref)) {
|
|
@@ -849,6 +866,8 @@ export function validateFileMap(files) {
|
|
|
849
866
|
suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
|
|
850
867
|
});
|
|
851
868
|
}
|
|
869
|
+
if (options.validateValues)
|
|
870
|
+
validateBoundJsonValues(raw, config, schemaDocuments, relPath, issues);
|
|
852
871
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
853
872
|
if (declaredEnvIds.size > 0) {
|
|
854
873
|
for (const env of config.environments) {
|
|
@@ -863,6 +882,7 @@ export function validateFileMap(files) {
|
|
|
863
882
|
}
|
|
864
883
|
}
|
|
865
884
|
}
|
|
885
|
+
detectReferenceCycles(parsedConfigs, issues);
|
|
866
886
|
// Duplicate keys (case-insensitive — see detectDuplicateKeys).
|
|
867
887
|
detectDuplicateKeys(allConfigs, 'key', issues);
|
|
868
888
|
detectDuplicateKeys(allSchemaFiles, 'schema key', issues);
|
|
@@ -870,6 +890,79 @@ export function validateFileMap(files) {
|
|
|
870
890
|
return { issues, filesChecked, valid: !hasErrors, stats: emptyStats() };
|
|
871
891
|
}
|
|
872
892
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
893
|
+
/**
|
|
894
|
+
* Validate a config's schema-bound JSON values (qfg-phcv). Scope matches the
|
|
895
|
+
* app's save gate (app-quonfig config-crud-handlers.ts findJsonBoundViolations)
|
|
896
|
+
* exactly:
|
|
897
|
+
* - only `type === "config"` with `valueType === "json"` and a `schemaKey`
|
|
898
|
+
* whose schema exists (a missing schema is the reference error above);
|
|
899
|
+
* - every `{type: "json", value}` node anywhere in the RAW document: default
|
|
900
|
+
* and environment rules, weighted-value entries, top-level variants.
|
|
901
|
+
* `provided` values are not json nodes, so they are skipped;
|
|
902
|
+
* - one issue per violation, worded `Value does not match schema "<key>":
|
|
903
|
+
* <document path>.<path in value>: <message>`;
|
|
904
|
+
* - a schema that does not compile is reported once, on the schema file
|
|
905
|
+
* (validateSchemaCompiles), whether or not anything is bound to it. Values
|
|
906
|
+
* bound to it are not reported again.
|
|
907
|
+
*/
|
|
908
|
+
function validateBoundJsonValues(raw, config, schemaDocuments, file, issues) {
|
|
909
|
+
if (config.type !== 'config' || config.valueType !== 'json' || !config.schemaKey)
|
|
910
|
+
return;
|
|
911
|
+
const schemaKey = config.schemaKey;
|
|
912
|
+
const schema = schemaDocuments.get(schemaKey);
|
|
913
|
+
if (!schema)
|
|
914
|
+
return;
|
|
915
|
+
const isPlainObject = (node) => typeof node === 'object' && node !== null && !Array.isArray(node);
|
|
916
|
+
// Returns true to stop the walk.
|
|
917
|
+
const walk = (node, docPath) => {
|
|
918
|
+
if (Array.isArray(node))
|
|
919
|
+
return node.some((child, index) => walk(child, `${docPath}[${index}]`));
|
|
920
|
+
if (!isPlainObject(node))
|
|
921
|
+
return false;
|
|
922
|
+
if (node.type === 'json' && 'value' in node) {
|
|
923
|
+
const valuePath = docPath || 'value';
|
|
924
|
+
const result = validateAgainstSchema(schema, node.value);
|
|
925
|
+
if (result.ok)
|
|
926
|
+
return false;
|
|
927
|
+
if (result.kind === 'invalid-schema')
|
|
928
|
+
return true;
|
|
929
|
+
for (const violation of result.violations) {
|
|
930
|
+
issues.push({
|
|
931
|
+
file,
|
|
932
|
+
message: `Value does not match schema "${schemaKey}": ${joinSchemaPath(valuePath, violation.path)}: ${violation.message}`,
|
|
933
|
+
severity: 'error',
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
return Object.entries(node).some(([key, child]) => walk(child, docPath ? `${docPath}.${key}` : key));
|
|
939
|
+
};
|
|
940
|
+
walk(raw, '');
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* A schema that does not compile is an error on the schema file, bound or not.
|
|
944
|
+
* Gated on `validateValues` with the value checks; the CLI and the pre-receive
|
|
945
|
+
* hook both turn it on.
|
|
946
|
+
*/
|
|
947
|
+
function validateSchemaCompiles(schemaKey, schema, file, issues) {
|
|
948
|
+
const compiled = compileSchema(schema);
|
|
949
|
+
if (compiled.ok)
|
|
950
|
+
return;
|
|
951
|
+
issues.push({
|
|
952
|
+
file,
|
|
953
|
+
message: `schema ${schemaKey} is invalid: ${compiled.reason}`,
|
|
954
|
+
severity: 'error',
|
|
955
|
+
suggestion: `Fix the schema "${schemaKey}" so it compiles as JSON Schema draft 2020-12 or draft-07`,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/** Same as app-quonfig schema-json.ts joinSchemaPath: `[n]` attaches without a dot. */
|
|
959
|
+
function joinSchemaPath(prefix, valuePath) {
|
|
960
|
+
if (!valuePath)
|
|
961
|
+
return prefix;
|
|
962
|
+
if (!prefix)
|
|
963
|
+
return valuePath;
|
|
964
|
+
return valuePath.startsWith('[') ? `${prefix}${valuePath}` : `${prefix}.${valuePath}`;
|
|
965
|
+
}
|
|
873
966
|
function validateEnvironmentIds(environments, file, issues) {
|
|
874
967
|
for (const env of environments) {
|
|
875
968
|
if (UUID_RE.test(env.id)) {
|
|
@@ -1135,6 +1228,97 @@ function collectSegmentReferences(config) {
|
|
|
1135
1228
|
}
|
|
1136
1229
|
return refs;
|
|
1137
1230
|
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Reject reference cycles that SDKs recurse through at eval time (qfg-9dxb.11):
|
|
1233
|
+
* - segment -> segment via IN_SEG / NOT_IN_SEG criteria;
|
|
1234
|
+
* - config -> config via `decryptWith` on a confidential value (the SDKs
|
|
1235
|
+
* resolve the key config's value to decrypt, and follow its decryptWith).
|
|
1236
|
+
* An unguarded SDK recurses until the stack overflows (sdk-net: an
|
|
1237
|
+
* uncatchable StackOverflowException), so a cycle is a hard error at write
|
|
1238
|
+
* time. Edges to keys that do not exist are skipped — a missing segment is
|
|
1239
|
+
* the reference error above. Each cycle is reported once, on the file of its
|
|
1240
|
+
* lexicographically first key, with the path starting there.
|
|
1241
|
+
*/
|
|
1242
|
+
function detectReferenceCycles(nodes, issues) {
|
|
1243
|
+
const segments = nodes.filter((n) => n.config.type === 'segment');
|
|
1244
|
+
const segmentKeys = new Set(segments.map((n) => n.config.key));
|
|
1245
|
+
reportCycles(segments.map((n) => ({
|
|
1246
|
+
key: n.config.key,
|
|
1247
|
+
relPath: n.relPath,
|
|
1248
|
+
targets: collectSegmentReferences(n.config).filter((ref) => segmentKeys.has(ref)),
|
|
1249
|
+
})), 'Segment reference cycle', 'Remove one of the IN_SEG / NOT_IN_SEG references in the cycle', issues);
|
|
1250
|
+
const configKeys = new Set(nodes.map((n) => n.config.key));
|
|
1251
|
+
reportCycles(nodes.map((n) => ({
|
|
1252
|
+
key: n.config.key,
|
|
1253
|
+
relPath: n.relPath,
|
|
1254
|
+
targets: collectDecryptWithReferences(n.raw).filter((ref) => configKeys.has(ref)),
|
|
1255
|
+
})), 'decryptWith cycle', 'A decryption key config must not itself be decrypted with a config in the cycle', issues);
|
|
1256
|
+
}
|
|
1257
|
+
function reportCycles(graph, label, suggestion, issues) {
|
|
1258
|
+
const byKey = new Map(graph.map((n) => [n.key, n]));
|
|
1259
|
+
const state = new Map();
|
|
1260
|
+
const stack = [];
|
|
1261
|
+
const seen = new Set();
|
|
1262
|
+
function visit(key) {
|
|
1263
|
+
state.set(key, 'visiting');
|
|
1264
|
+
stack.push(key);
|
|
1265
|
+
for (const target of [...new Set(byKey.get(key).targets)].sort()) {
|
|
1266
|
+
const s = state.get(target);
|
|
1267
|
+
if (s === 'visiting') {
|
|
1268
|
+
const cycle = stack.slice(stack.indexOf(target));
|
|
1269
|
+
const first = cycle.indexOf([...cycle].sort()[0]);
|
|
1270
|
+
const rotated = [...cycle.slice(first), ...cycle.slice(0, first)];
|
|
1271
|
+
const id = rotated.join('\0');
|
|
1272
|
+
if (!seen.has(id)) {
|
|
1273
|
+
seen.add(id);
|
|
1274
|
+
issues.push({
|
|
1275
|
+
file: byKey.get(rotated[0]).relPath,
|
|
1276
|
+
message: `${label}: ${[...rotated, rotated[0]].join(' -> ')}`,
|
|
1277
|
+
severity: 'error',
|
|
1278
|
+
suggestion,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
else if (s === undefined) {
|
|
1283
|
+
visit(target);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
stack.pop();
|
|
1287
|
+
state.set(key, 'done');
|
|
1288
|
+
}
|
|
1289
|
+
for (const key of [...byKey.keys()].sort()) {
|
|
1290
|
+
if (!state.has(key))
|
|
1291
|
+
visit(key);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
/** decryptWith targets of every confidential value in a config's rules and variants. */
|
|
1295
|
+
function collectDecryptWithReferences(raw) {
|
|
1296
|
+
const refs = [];
|
|
1297
|
+
const doc = (raw ?? {});
|
|
1298
|
+
function scanValue(value) {
|
|
1299
|
+
if (!value || typeof value !== 'object')
|
|
1300
|
+
return;
|
|
1301
|
+
const v = value;
|
|
1302
|
+
if (v.type === 'weighted_values') {
|
|
1303
|
+
const wv = v.value;
|
|
1304
|
+
for (const entry of wv?.weightedValues ?? [])
|
|
1305
|
+
scanValue(entry?.value);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (v.confidential === true && typeof v.decryptWith === 'string')
|
|
1309
|
+
refs.push(v.decryptWith);
|
|
1310
|
+
}
|
|
1311
|
+
function scanRules(rules) {
|
|
1312
|
+
for (const rule of rules ?? [])
|
|
1313
|
+
scanValue(rule?.value);
|
|
1314
|
+
}
|
|
1315
|
+
scanRules(doc.default?.rules);
|
|
1316
|
+
for (const env of doc.environments ?? [])
|
|
1317
|
+
scanRules(env?.rules);
|
|
1318
|
+
for (const variant of doc.variants ?? [])
|
|
1319
|
+
scanValue(variant?.value);
|
|
1320
|
+
return refs;
|
|
1321
|
+
}
|
|
1138
1322
|
function typeToDir(type) {
|
|
1139
1323
|
for (const [dir, t] of Object.entries(DIR_TO_TYPE)) {
|
|
1140
1324
|
if (t === type)
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.1
|
|
1
|
+
declare const _default: "0.2.1";
|
|
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.1
|
|
2
|
+
export default '0.2.1';
|