@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.
- package/README.md +50 -50
- package/dist/verify/schema-validator.js +3 -3
- package/dist/verify/standalone.js +54 -22
- package/dist/verify/validate.d.ts +7 -6
- package/dist/verify/validate.js +100 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +220 -220
- package/package.json +1 -1
package/dist/verify/validate.js
CHANGED
|
@@ -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
|
|
20
|
-
* `validateValues
|
|
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
|
|
940
|
-
*
|
|
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.
|
|
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.
|
|
2
|
+
export default '0.2.1';
|