@quonfig/cli 0.2.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,8 +16,9 @@
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 (Ajv, opt-in via
20
- * `validateValues`; see validateBoundJsonValues)
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)
21
22
  * - Rule structure (criteria + value present)
22
23
  * - Value type consistency
23
24
  * - Weighted values non-empty and consistent
@@ -574,6 +575,7 @@ export function validateWorkspace(workspaceDir, options = {}) {
574
575
  }
575
576
  // Second pass: referential integrity. Keep the plain skip-filter here —
576
577
  // ghost entries were already reported (once) by the first pass.
578
+ const cycleNodes = [];
577
579
  for (const dir of KNOWN_DIRS) {
578
580
  const dirPath = path.join(workspaceDir, dir);
579
581
  if (!fs.existsSync(dirPath))
@@ -603,6 +605,7 @@ export function validateWorkspace(workspaceDir, options = {}) {
603
605
  if (!result.success)
604
606
  continue;
605
607
  const config = result.data;
608
+ cycleNodes.push({ config, raw: parsed, relPath });
606
609
  // Check segment references in criteria
607
610
  const segRefs = collectSegmentReferences(config);
608
611
  for (const ref of segRefs) {
@@ -646,6 +649,7 @@ export function validateWorkspace(workspaceDir, options = {}) {
646
649
  }
647
650
  }
648
651
  }
652
+ detectReferenceCycles(cycleNodes, issues);
649
653
  // Check for duplicate keys across directories (case-insensitive — see
650
654
  // detectDuplicateKeys).
651
655
  const uniqueConfigKeys = detectDuplicateKeys(allConfigs, 'key', issues);
@@ -878,6 +882,7 @@ export function validateFileMap(files, options = {}) {
878
882
  }
879
883
  }
880
884
  }
885
+ detectReferenceCycles(parsedConfigs, issues);
881
886
  // Duplicate keys (case-insensitive — see detectDuplicateKeys).
882
887
  detectDuplicateKeys(allConfigs, 'key', issues);
883
888
  detectDuplicateKeys(allSchemaFiles, 'schema key', issues);
@@ -936,8 +941,8 @@ function validateBoundJsonValues(raw, config, schemaDocuments, file, issues) {
936
941
  }
937
942
  /**
938
943
  * A schema that does not compile is an error on the schema file, bound or not.
939
- * Gated on `validateValues` with the value checks, so the pre-receive hook
940
- * leaves it off until phase 1b.
944
+ * Gated on `validateValues` with the value checks; the CLI and the pre-receive
945
+ * hook both turn it on.
941
946
  */
942
947
  function validateSchemaCompiles(schemaKey, schema, file, issues) {
943
948
  const compiled = compileSchema(schema);
@@ -1223,6 +1228,97 @@ function collectSegmentReferences(config) {
1223
1228
  }
1224
1229
  return refs;
1225
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
+ }
1226
1322
  function typeToDir(type) {
1227
1323
  for (const [dir, t] of Object.entries(DIR_TO_TYPE)) {
1228
1324
  if (t === type)
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.2.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.2.0';
2
+ export default '0.2.1';