@quonfig/cli 0.0.64 → 0.0.66

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.
@@ -9,7 +9,8 @@
9
9
  * - Valid JSON
10
10
  * - Schema compliance (StoredConfigSchema from Zod)
11
11
  * - Key matches filename
12
- * - Key constraints (1-512 chars, no slashes, not "new")
12
+ * - Key constraints (1-200 chars, no slashes, not "new", FS-safety floor)
13
+ * - Case-insensitive key uniqueness (no Foo/foo collisions)
13
14
  * - Config type matches directory
14
15
  * - Segment constraints (valueType=bool, sendToClientSdk=false)
15
16
  * - Log level constraints (valueType=log_level)
@@ -552,40 +553,11 @@ export function validateWorkspace(workspaceDir) {
552
553
  }
553
554
  }
554
555
  }
555
- // Check for duplicate keys across directories
556
- const keyToFiles = new Map();
557
- for (const c of allConfigs) {
558
- const existing = keyToFiles.get(c.key) || [];
559
- existing.push(c.file);
560
- keyToFiles.set(c.key, existing);
561
- }
562
- for (const [key, files] of keyToFiles) {
563
- if (files.length > 1) {
564
- issues.push({
565
- file: files.join(', '),
566
- message: `Duplicate key "${key}" found in multiple files`,
567
- severity: 'error',
568
- suggestion: `Each key must be unique across the workspace`,
569
- });
570
- }
571
- }
572
- const schemaKeyToFiles = new Map();
573
- for (const schemaFile of allSchemaFiles) {
574
- const existing = schemaKeyToFiles.get(schemaFile.key) || [];
575
- existing.push(schemaFile.file);
576
- schemaKeyToFiles.set(schemaFile.key, existing);
577
- }
578
- for (const [key, files] of schemaKeyToFiles) {
579
- if (files.length > 1) {
580
- issues.push({
581
- file: files.join(', '),
582
- message: `Duplicate schema key "${key}" found in multiple files`,
583
- severity: 'error',
584
- suggestion: `Each schema key must be unique across schema directories`,
585
- });
586
- }
587
- }
588
- stats.uniqueKeysVerified = keyToFiles.size + schemaKeyToFiles.size;
556
+ // Check for duplicate keys across directories (case-insensitive — see
557
+ // detectDuplicateKeys).
558
+ const uniqueConfigKeys = detectDuplicateKeys(allConfigs, 'key', issues);
559
+ const uniqueSchemaKeys = detectDuplicateKeys(allSchemaFiles, 'schema key', issues);
560
+ stats.uniqueKeysVerified = uniqueConfigKeys + uniqueSchemaKeys;
589
561
  const hasErrors = issues.some((i) => i.severity === 'error');
590
562
  return { issues, filesChecked, valid: !hasErrors, stats };
591
563
  }
@@ -800,37 +772,9 @@ export function validateFileMap(files) {
800
772
  }
801
773
  }
802
774
  }
803
- // Duplicate keys
804
- const keyToFiles = new Map();
805
- for (const c of allConfigs) {
806
- const existing = keyToFiles.get(c.key) || [];
807
- existing.push(c.file);
808
- keyToFiles.set(c.key, existing);
809
- }
810
- for (const [key, fileList] of keyToFiles) {
811
- if (fileList.length > 1) {
812
- issues.push({
813
- file: fileList.join(', '),
814
- message: `Duplicate key "${key}" found in multiple files`,
815
- severity: 'error',
816
- });
817
- }
818
- }
819
- const schemaKeyToFiles = new Map();
820
- for (const schemaFile of allSchemaFiles) {
821
- const existing = schemaKeyToFiles.get(schemaFile.key) || [];
822
- existing.push(schemaFile.file);
823
- schemaKeyToFiles.set(schemaFile.key, existing);
824
- }
825
- for (const [key, fileList] of schemaKeyToFiles) {
826
- if (fileList.length > 1) {
827
- issues.push({
828
- file: fileList.join(', '),
829
- message: `Duplicate schema key "${key}" found in multiple files`,
830
- severity: 'error',
831
- });
832
- }
833
- }
775
+ // Duplicate keys (case-insensitive — see detectDuplicateKeys).
776
+ detectDuplicateKeys(allConfigs, 'key', issues);
777
+ detectDuplicateKeys(allSchemaFiles, 'schema key', issues);
834
778
  const hasErrors = issues.some((i) => i.severity === 'error');
835
779
  return { issues, filesChecked, valid: !hasErrors, stats: emptyStats() };
836
780
  }
@@ -855,12 +799,43 @@ function validateEnvironmentIds(environments, file, issues) {
855
799
  }
856
800
  }
857
801
  }
858
- function validateKey(key, file, issues) {
802
+ // FS-safety floor checks (qfg-6na9.4). These are the names that pass the loose
803
+ // historical rule but produce a file that cannot be cloned/checked-out on a
804
+ // customer machine (macOS/Windows). The audit (project/plans/26-06-tighter-naming.md,
805
+ // "FS-safety floor") found ZERO existing violations, so every one is a hard
806
+ // error from day one.
807
+ //
808
+ // IMPORTANT: this floor must stay conceptually in lockstep with app-quonfig's
809
+ // src/lib/domain/config-schemas.ts (the charset precedent there is
810
+ // `SchemaKeySchema`; Policy A is `PolicyAKeySchema`). The general charset check
811
+ // (`^[A-Za-z0-9._-]+$`) is deliberately NOT here — it ships separately as a
812
+ // warning (bead qfg-6na9.5). This module is the FS-floor + dedup only.
813
+ //
814
+ // Structured as a table so severity is trivially adjustable later.
815
+ const FS_SAFETY_FLOOR_CHECKS = [
816
+ { test: (k) => k.startsWith('.'), message: `Key has a leading dot (files starting with "." are skipped by verify)` },
817
+ // eslint-disable-next-line no-control-regex -- matching control chars/NUL is the point
818
+ { test: (k) => /[\u0000-\u001F\u007F]/.test(k), message: `Key contains control chars or NUL` },
819
+ { test: (k) => /["*:<>?|]/.test(k), message: `Key contains Windows-reserved chars (: * ? " < > |)` },
820
+ {
821
+ test: (k) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(k.split('.')[0]),
822
+ message: `Key is a Windows reserved device name (con/prn/aux/nul/com1-9/lpt1-9)`,
823
+ },
824
+ { test: (k) => /[ .]$/.test(k), message: `Key has a trailing dot or space (silently stripped on Windows)` },
825
+ ];
826
+ // Exported so the FS-safety floor (incl. the leading-dot rule, whose files are
827
+ // pre-filtered by the directory walk before they'd reach the file-map path) can
828
+ // be unit-tested directly.
829
+ export function validateKey(key, file, issues) {
830
+ const errorsBefore = issues.length;
859
831
  if (key.length === 0) {
860
832
  issues.push({ file, message: `Key is empty`, severity: 'error' });
861
833
  }
862
- if (key.length > 512) {
863
- issues.push({ file, message: `Key exceeds 512 characters (${key.length})`, severity: 'error' });
834
+ // Length cap lowered 512 -> 200 (project/plans/26-06-tighter-naming.md,
835
+ // "Length cap: 200"): ASCII-only Policy A means 200 chars == 200 bytes, well
836
+ // under the 255-byte filesystem path-component limit even with `.json`.
837
+ if (key.length > 200) {
838
+ issues.push({ file, message: `Key exceeds 200 characters (${key.length})`, severity: 'error' });
864
839
  }
865
840
  if (key === 'new') {
866
841
  issues.push({ file, message: `Key cannot be "new" (reserved)`, severity: 'error' });
@@ -868,6 +843,71 @@ function validateKey(key, file, issues) {
868
843
  if (/[/\\]/.test(key)) {
869
844
  issues.push({ file, message: `Key contains slash or backslash`, severity: 'error' });
870
845
  }
846
+ for (const check of FS_SAFETY_FLOOR_CHECKS) {
847
+ if (check.test(key)) {
848
+ issues.push({ file, message: check.message, severity: 'error' });
849
+ }
850
+ }
851
+ // qfg-6na9.5: the general Policy A charset as a WARNING — the warn stage of the
852
+ // warn->error soak (project/plans/26-06-tighter-naming.md). A config that trips
853
+ // this must STILL push, so severity is 'warning' (does not set result.valid =
854
+ // false); qfg-6na9.6 will flip it to a hard error after a clean soak. The
855
+ // charset `^[A-Za-z0-9._-]+$` is kept in sync with app-quonfig's
856
+ // PolicyAKeySchema (hard at create). Skipped when this key already earned a
857
+ // hard error above (e.g. a Windows-reserved char is both a floor error AND a
858
+ // charset miss) so we don't double-report the same key.
859
+ // eslint-disable-next-line unicorn/better-regex -- keep the explicit charset in sync with app-quonfig
860
+ if (issues.length === errorsBefore && !/^[A-Za-z0-9._-]+$/.test(key)) {
861
+ issues.push({
862
+ file,
863
+ message: `Key "${key}" has characters outside the allowed set (letters, numbers, ".", "-", "_"). This will become a hard error in a future release — rename it to conform.`,
864
+ severity: 'warning',
865
+ suggestion: `Rename to use only letters, numbers, ".", "-", and "_"`,
866
+ });
867
+ }
868
+ }
869
+ // Case-insensitive duplicate-key detection (qfg-6na9.4,
870
+ // project/plans/26-06-tighter-naming.md "Case-insensitive uniqueness").
871
+ // `Foo` and `foo` are distinct keys server-side (case-sensitive Linux ext4) but
872
+ // collide to one file on a case-insensitive macOS/Windows clone, silently
873
+ // dropping a config. Group by the DOWNCASED key and branch the message:
874
+ // - all original keys byte-identical -> exact-duplicate message
875
+ // - they differ only by case -> case-collision message
876
+ // The audit found 0 such collisions today, so this is a hard error from day one.
877
+ function detectDuplicateKeys(entries, label, issues) {
878
+ const byLowercase = new Map();
879
+ for (const e of entries) {
880
+ const lower = e.key.toLowerCase();
881
+ const existing = byLowercase.get(lower) || [];
882
+ existing.push(e);
883
+ byLowercase.set(lower, existing);
884
+ }
885
+ for (const group of byLowercase.values()) {
886
+ if (group.length <= 1)
887
+ continue;
888
+ const files = group.map((g) => g.file).join(', ');
889
+ const originalKeys = group.map((g) => g.key);
890
+ const allIdentical = originalKeys.every((k) => k === originalKeys[0]);
891
+ if (allIdentical) {
892
+ issues.push({
893
+ file: files,
894
+ message: `Duplicate ${label} "${originalKeys[0]}" found in multiple files`,
895
+ severity: 'error',
896
+ suggestion: label === 'schema key'
897
+ ? `Each schema key must be unique across schema directories`
898
+ : `Each key must be unique across the workspace`,
899
+ });
900
+ }
901
+ else {
902
+ issues.push({
903
+ file: files,
904
+ message: `Keys differ only by case (${originalKeys.join(', ')}); they collide to one file on case-insensitive filesystems (macOS/Windows)`,
905
+ severity: 'error',
906
+ suggestion: `Rename so the ${label}s are also unique when lowercased`,
907
+ });
908
+ }
909
+ }
910
+ return byLowercase.size;
871
911
  }
872
912
  function validateRules(rules, file, context, expectedValueType, issues, variants = []) {
873
913
  if (rules.length === 0) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.64";
1
+ declare const _default: "0.0.66";
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.64';
2
+ export default '0.0.66';