@quonfig/cli 0.0.68 → 0.0.70

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.
Files changed (37) hide show
  1. package/README.md +50 -50
  2. package/dist/codegen/code-generators/base-generator.d.ts +8 -1
  3. package/dist/codegen/code-generators/base-generator.js +3 -1
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +34 -1
  5. package/dist/codegen/code-generators/base-typescript-generator.js +78 -2
  6. package/dist/codegen/code-generators/node-typescript-generator.js +7 -12
  7. package/dist/codegen/code-generators/react-typescript-generator.js +9 -12
  8. package/dist/commands/generate.js +4 -2
  9. package/dist/init/schema.js +11 -2
  10. package/dist/migrate/import-state.d.ts +3 -2
  11. package/dist/migrate/import-state.js +20 -7
  12. package/dist/migrate/key-rewriter.d.ts +70 -14
  13. package/dist/migrate/key-rewriter.js +186 -38
  14. package/dist/migrate/local-write.d.ts +10 -0
  15. package/dist/migrate/local-write.js +27 -1
  16. package/dist/migrate/migration-report.js +1 -0
  17. package/dist/migrate/push-to-cloud.js +24 -12
  18. package/dist/migrate/quonfig-target/report.d.ts +7 -1
  19. package/dist/migrate/quonfig-target/ruleset.js +15 -3
  20. package/dist/migrate/quonfig-target/weights.d.ts +20 -0
  21. package/dist/migrate/quonfig-target/weights.js +48 -0
  22. package/dist/migrate/source.d.ts +9 -0
  23. package/dist/migrate/sources/flagsmith/translate.js +9 -0
  24. package/dist/migrate/sources/launch/translate.d.ts +1 -1
  25. package/dist/migrate/sources/launch/translate.js +133 -2
  26. package/dist/migrate/sources/launch.js +10 -0
  27. package/dist/migrate/sources/launchdarkly/translate.js +12 -2
  28. package/dist/migrate/sources/launchdarkly.js +15 -2
  29. package/dist/util/policy-a-key.d.ts +15 -1
  30. package/dist/util/policy-a-key.js +25 -1
  31. package/dist/verify/standalone.d.ts +7 -0
  32. package/dist/verify/standalone.js +15 -9
  33. package/dist/verify/validate.js +111 -4
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/oclif.manifest.json +1 -1
  37. package/package.json +1 -1
@@ -99,26 +99,32 @@ async function runGitHook() {
99
99
  * exists to catch (verified live on staging: a `configs/bad charset key.json`
100
100
  * push was accepted unvalidated). A listed-but-unreadable file is now a hard
101
101
  * failure (fail closed), not a silent skip.
102
+ *
103
+ * qfg-hbuy.4: enumerates RECURSIVELY (`-r`) and with NO name filtering. The
104
+ * old non-recursive listing plus `endsWith('.json') && !includes('/.')` filter
105
+ * meant dotfiles, nested paths (configs/sub/x.json), and case-variant
106
+ * extensions (FOO.JSON) never reached validation at all — ghost files that
107
+ * push fine but no loader reads. The hook must see EVERYTHING under the
108
+ * validated dirs; validateFileMap is the layer that decides what is an error.
102
109
  */
103
110
  export function readFilesFromCommit(commitOid, cwd) {
104
111
  const files = new Map();
105
112
  const dirs = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas', 'schemas-protected'];
106
113
  for (const dir of dirs) {
107
- // List files in this directory at the given commit. A directory that
108
- // doesn't exist yields an empty listing (exit 0); a bad/unreadable OID
109
- // throws — fail closed, the hook rejects the push.
110
- const listing = execFileSync('git', ['ls-tree', '-z', '--name-only', commitOid, `${dir}/`], {
114
+ // List every leaf entry under this directory at the given commit. A
115
+ // directory that doesn't exist yields an empty listing (exit 0); a
116
+ // bad/unreadable OID throws — fail closed, the hook rejects the push.
117
+ const listing = execFileSync('git', ['ls-tree', '-z', '-r', '--name-only', commitOid, `${dir}/`], {
111
118
  cwd,
112
119
  encoding: 'utf8',
113
120
  stdio: ['pipe', 'pipe', 'pipe'],
114
121
  });
115
122
  const filenames = listing.split('\0').filter(Boolean);
116
123
  for (const filePath of filenames) {
117
- if (!filePath.endsWith('.json') || filePath.includes('/.'))
118
- continue;
119
- // Fail closed: if a listed file can't be read, the push must not be
120
- // accepted with that file unvalidated — let the error propagate to the
121
- // hook's per-ref handler, which rejects the push.
124
+ // Fail closed: if a listed entry can't be read (bad object, submodule
125
+ // gitlink, ...), the push must not be accepted with that entry
126
+ // unvalidated — let the error propagate to the hook's per-ref handler,
127
+ // which rejects the push.
122
128
  const content = execFileSync('git', ['show', `${commitOid}:${filePath}`], {
123
129
  cwd,
124
130
  encoding: 'utf8',
@@ -160,6 +160,86 @@ const DIR_TO_TYPE = {
160
160
  const CONFIG_DIRS = new Set(Object.keys(DIR_TO_TYPE));
161
161
  const SCHEMA_DIRS = new Set(['schemas', 'schemas-protected']);
162
162
  const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
163
+ // ── Ghost-file prevention (qfg-hbuy.4) ──────────────────────────────────
164
+ // Within the validated content dirs (KNOWN_DIRS), every entry must be a
165
+ // top-level, non-dot, lowercase-`.json` file. Anything else used to be
166
+ // silently SKIPPED by the enumeration filters — a "ghost" file that pushes
167
+ // fine but is invisible to the hook and to every loader, and a mixed-case
168
+ // FOO.JSON can collide with foo.json on a case-insensitive customer clone
169
+ // (the exact clonability failure Policy A exists to prevent). A skipped
170
+ // entry is a bypass; an error is a rejected push. Paths OUTSIDE the
171
+ // validated dirs (quonfig.json, README.md, .qf/, ...) are not this rule's
172
+ // business and stay untouched.
173
+ function ghostEntryIssue(dir, relPath, name, nested) {
174
+ if (nested) {
175
+ return {
176
+ file: relPath,
177
+ message: `Subdirectories are not allowed inside "${dir}/" — nested files are never loaded (ghost file)`,
178
+ severity: 'error',
179
+ suggestion: `Move config files to the top level: "${dir}/<key>.json"`,
180
+ };
181
+ }
182
+ if (name.startsWith('.')) {
183
+ return {
184
+ file: relPath,
185
+ message: `Dotfile "${name}" is not allowed inside "${dir}/" — hidden files are never loaded (ghost file)`,
186
+ severity: 'error',
187
+ suggestion: `Remove the file or rename it without the leading dot`,
188
+ };
189
+ }
190
+ if (!name.endsWith('.json')) {
191
+ if (name.toLowerCase().endsWith('.json')) {
192
+ return {
193
+ file: relPath,
194
+ message: `File extension must be lowercase ".json" (found "${name}") — a case-variant extension is never loaded and can collide with another file on a case-insensitive filesystem`,
195
+ severity: 'error',
196
+ suggestion: `Rename to "${name.slice(0, -5)}.json"`,
197
+ };
198
+ }
199
+ return {
200
+ file: relPath,
201
+ message: `Only ".json" files are allowed inside "${dir}/" (found "${name}")`,
202
+ severity: 'error',
203
+ suggestion: `Remove the file or rename it to "<key>.json"`,
204
+ };
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * Disk-walk variant of the ghost rule (validateWorkspace). Returns the list
210
+ * of names that should proceed to content validation, pushing an issue for
211
+ * every ghost entry.
212
+ *
213
+ * Dotfiles are the one place the disk rule is softer than the committed-tree
214
+ * rule: `qfg push` never sends dotfiles (collectFiles skips them), so a local
215
+ * `configs/.DS_Store` is inert OS junk — erroring on it would break every
216
+ * macOS user. A `.json`-looking dotfile is almost certainly a mistake, so it
217
+ * gets a WARNING; other dotfiles are skipped silently.
218
+ */
219
+ function listValidatedDirEntries(dirPath, dir, issues) {
220
+ const names = [];
221
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
222
+ const relPath = `${dir}/${entry.name}`;
223
+ if (entry.name.startsWith('.')) {
224
+ if (entry.name.toLowerCase().endsWith('.json')) {
225
+ issues.push({
226
+ file: relPath,
227
+ message: `Dotfile "${entry.name}" in "${dir}/" is ignored — it is never validated, pushed, or loaded`,
228
+ severity: 'warning',
229
+ suggestion: `Remove the file or rename it without the leading dot`,
230
+ });
231
+ }
232
+ continue;
233
+ }
234
+ const ghost = ghostEntryIssue(dir, relPath, entry.name, entry.isDirectory());
235
+ if (ghost) {
236
+ issues.push(ghost);
237
+ continue;
238
+ }
239
+ names.push(entry.name);
240
+ }
241
+ return names;
242
+ }
163
243
  // Operators that reference segments
164
244
  const SEGMENT_OPERATORS = new Set(['IN_SEG', 'NOT_IN_SEG']);
165
245
  // Operators that require a propertyName
@@ -318,12 +398,13 @@ export function validateWorkspace(workspaceDir) {
318
398
  suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
319
399
  });
320
400
  }
321
- // First pass: parse all files and collect keys
401
+ // First pass: parse all files and collect keys. Ghost entries (dotfiles,
402
+ // subdirectories, non-lowercase-.json names) are reported here (qfg-hbuy.4).
322
403
  for (const dir of KNOWN_DIRS) {
323
404
  const dirPath = path.join(workspaceDir, dir);
324
405
  if (!fs.existsSync(dirPath))
325
406
  continue;
326
- const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.json') && !f.startsWith('.'));
407
+ const files = listValidatedDirEntries(dirPath, dir, issues);
327
408
  for (const file of files) {
328
409
  const filePath = path.join(dirPath, file);
329
410
  const relPath = `${dir}/${file}`;
@@ -482,7 +563,8 @@ export function validateWorkspace(workspaceDir) {
482
563
  allConfigs.push({ key: config.key, type: config.type, dir, file: relPath });
483
564
  }
484
565
  }
485
- // Second pass: referential integrity
566
+ // Second pass: referential integrity. Keep the plain skip-filter here —
567
+ // ghost entries were already reported (once) by the first pass.
486
568
  for (const dir of KNOWN_DIRS) {
487
569
  const dirPath = path.join(workspaceDir, dir);
488
570
  if (!fs.existsSync(dirPath))
@@ -629,8 +711,15 @@ export function validateFileMap(files) {
629
711
  const file = parts.at(-1);
630
712
  if (!KNOWN_DIRS.has(dir))
631
713
  continue;
632
- if (!file || !file.endsWith('.json') || file.startsWith('.'))
714
+ if (!file)
715
+ continue;
716
+ // qfg-hbuy.4: a dotfile, nested path, or non-lowercase-.json entry inside
717
+ // a validated dir is a hard error (rejected push), not a silent skip.
718
+ const ghost = ghostEntryIssue(dir, relPath, file, parts.length > 2);
719
+ if (ghost) {
720
+ issues.push(ghost);
633
721
  continue;
722
+ }
634
723
  filesChecked++;
635
724
  let parsed;
636
725
  try {
@@ -980,6 +1069,24 @@ function validateRules(rules, file, context, expectedValueType, issues, variants
980
1069
  }
981
1070
  }
982
1071
  }
1072
+ // Weight predicate (qfg-wis6.10): stored weights must be either an
1073
+ // even split (all weights equal and > 0) or percentages summing to
1074
+ // MAX_WEIGHT. Anything else was written by a broken client: evaluators
1075
+ // normalize by total, so it silently serves different percentages
1076
+ // than any display of the raw weights suggests.
1077
+ if (wv.weightedValues.length > 0) {
1078
+ const weights = wv.weightedValues.map((entry) => entry.weight);
1079
+ const total = weights.reduce((a, b) => a + b, 0);
1080
+ const evenSplit = weights[0] > 0 && weights.every((w) => w === weights[0]);
1081
+ if (!evenSplit && total !== MAX_WEIGHT) {
1082
+ issues.push({
1083
+ file,
1084
+ message: `${ruleCtx}: weighted values must either be an even split (all weights equal and > 0) or sum to ${MAX_WEIGHT}; got [${weights.join(', ')}] summing to ${total}`,
1085
+ severity: 'error',
1086
+ suggestion: `Use equal weights for an even split, or make the weights sum to ${MAX_WEIGHT} (1000 units per percent).`,
1087
+ });
1088
+ }
1089
+ }
983
1090
  }
984
1091
  // Check criteria
985
1092
  for (let j = 0; j < rule.criteria.length; j++) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.68";
1
+ declare const _default: "0.0.70";
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.0.68';
2
+ export default '0.0.70';
@@ -4136,5 +4136,5 @@
4136
4136
  ]
4137
4137
  }
4138
4138
  },
4139
- "version": "0.0.68"
4139
+ "version": "0.0.70"
4140
4140
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.68",
4
+ "version": "0.0.70",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },