@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.
@@ -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({ relPath, config, dir });
846
+ parsedConfigs.push({ config, dir, raw: parsed, relPath });
830
847
  }
831
848
  // Referential integrity
832
- for (const { relPath, config } of parsedConfigs) {
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.0";
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.0';
2
+ export default '0.2.1';