@quonfig/cli 0.0.69 → 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.
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
4
- import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
4
+ import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, collectExistingWorkspaceKeys, writeQuonfigFiles, } from './local-write.js';
5
5
  import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
6
6
  import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
7
7
  import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
@@ -60,10 +60,14 @@ export const pushMigrationToCloud = async (opts) => {
60
60
  // not per-commit) so key-def and reference sites resolve identically, then
61
61
  // enforce --strict-keys before any clone/commit/push. Mappings persisted by a
62
62
  // previous run (.qf/key-plan.json in the reused clone) are authoritative so a
63
- // delta run cannot replan a subset and land on different finals. (When the
64
- // clone is fresh, the plan only comes down WITH the clone — the collapsed
65
- // commit's apply() re-plans below once it can read the cloned .qf/.)
63
+ // delta run cannot replan a subset and land on different finals. qfg-hbuy.12:
64
+ // keys already in the workspace seed the rewriter so a case-variant collision
65
+ // (incoming `Foo`, existing `foo`) renames instead of aborting, while a
66
+ // byte-equal match keeps the silent-overwrite re-migration path. (When the
67
+ // clone is fresh, both the plan and the existing keys only come down WITH the
68
+ // clone — the post-clone re-plan below repeats this once it can read them.)
66
69
  preflightKeyRewrites(opts.changes, {
70
+ existingKeys: collectExistingWorkspaceKeys(opts.localDir),
67
71
  persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
68
72
  strict: opts.strictKeys,
69
73
  });
@@ -79,8 +83,23 @@ export const pushMigrationToCloud = async (opts) => {
79
83
  mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
80
84
  }
81
85
  };
86
+ // Re-plan once the clone exists: a FRESH clone of a live workspace delivers
87
+ // .qf/key-plan.json AND the existing key files only at this point (the
88
+ // pre-clone preflight above couldn't see either), and both must win before
89
+ // any file is written. The collapsed path runs this at the top of its single
90
+ // commit; audit-log mode prepends it as a no-op commit spec (its apply()
91
+ // stages nothing, so stackCommits silently skips the commit) so it runs
92
+ // BEFORE the first per-change commit writes files (qfg-hbuy.12).
93
+ const replanAgainstClone = (dir) => {
94
+ preflightKeyRewrites(opts.changes, {
95
+ existingKeys: collectExistingWorkspaceKeys(dir),
96
+ persistedKeys: readKeyPlan(dir) ?? undefined,
97
+ strict: opts.strictKeys,
98
+ });
99
+ };
82
100
  const commits = opts.fullHistory
83
101
  ? [
102
+ { apply: replanAgainstClone, message: 'migrator: replan keys against the cloned workspace (no-op)' },
84
103
  ...buildAuditPerChangeCommits(opts.changes, opts.source),
85
104
  buildAuditFinalCommit({
86
105
  changes: opts.changes,
@@ -97,14 +116,7 @@ export const pushMigrationToCloud = async (opts) => {
97
116
  {
98
117
  message: () => computedCommitMessage,
99
118
  async apply(dir) {
100
- // Re-plan now that the clone exists: a FRESH clone of a previously
101
- // migrated workspace delivers .qf/key-plan.json only at this point
102
- // (the pre-clone preflight above couldn't see it), and the
103
- // persisted mappings must win before any file is written.
104
- preflightKeyRewrites(opts.changes, {
105
- persistedKeys: readKeyPlan(dir) ?? undefined,
106
- strict: opts.strictKeys,
107
- });
119
+ replanAgainstClone(dir);
108
120
  mergeEnvsIfPresent(dir);
109
121
  const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
110
122
  removeQfFromGitignore(dir);
@@ -53,7 +53,10 @@ function valueToMatchFor(operator, clause, ctx) {
53
53
  // qfg-6na9.3: this value is a referenced segment KEY — resolve it through
54
54
  // the per-run rewriter so a sanitized/disambiguated segment key stays in
55
55
  // sync with the IN_SEG rules pointing at it (no dangling targeting).
56
- return { type: 'string', value: resolveKey(String(clause.values[0] ?? '')) };
56
+ // qfg-hbuy.10: resolve in the SEGMENT namespace, so a segment renamed to
57
+ // dodge a same-keyed flag keeps its references (falls back to the default
58
+ // namespace for non-colliding segments).
59
+ return { type: 'string', value: resolveKey(String(clause.values[0] ?? ''), 'segment') };
57
60
  }
58
61
  if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
59
62
  if (clause.values.length > 1) {
@@ -2,6 +2,15 @@ import type { ConversionNote } from './quonfig-target/report.js';
2
2
  export interface LegacyChange {
3
3
  changedAt?: number;
4
4
  key?: string;
5
+ /**
6
+ * qfg-hbuy.10: source-side key namespace. LaunchDarkly flags and segments
7
+ * are SEPARATE key namespaces, so a flag and a segment can legitimately
8
+ * share one source key. Sources that model segments as their own namespace
9
+ * tag segment changes `'segment'` so the key rewriter can disambiguate the
10
+ * collision (the flag keeps the clean name; the segment gets a
11
+ * deterministic `-segment` suffix). Absent = the default namespace.
12
+ */
13
+ keyNamespace?: 'segment';
5
14
  raw: unknown;
6
15
  source: string;
7
16
  }
@@ -1,6 +1,6 @@
1
1
  import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
2
2
  import { zodToJsonSchema } from './zod-to-json-schema.js';
3
- import { resolveKey } from '../../key-rewriter.js';
3
+ import { resolveKey, resolveMappedKey } from '../../key-rewriter.js';
4
4
  /**
5
5
  * Thrown by transformConfig when the source config is structurally invalid
6
6
  * (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
@@ -50,6 +50,74 @@ function resolveSegmentRef(valueToMatch) {
50
50
  else if (Array.isArray(v.value))
51
51
  v.value = v.value.map((x) => (typeof x === 'string' ? resolveKey(x) : x));
52
52
  }
53
+ /**
54
+ * qfg-hbuy.11: a value's `decryptWith` names the CONFIG KEY holding its
55
+ * AES-GCM key — a by-key reference, exactly like an IN_SEG segment ref. When
56
+ * the referenced config was renamed by the key rewriter the reference must
57
+ * follow, or the config can never decrypt. A key absent from the map points
58
+ * at a pre-existing workspace config the import does not own — left
59
+ * untouched. Recurses into weighted_values so encrypted variant splits are
60
+ * covered too.
61
+ */
62
+ function rewriteValueKeyRefs(value) {
63
+ if (!value || typeof value !== 'object')
64
+ return;
65
+ const v = value;
66
+ if (typeof v.decryptWith === 'string') {
67
+ const mapped = resolveMappedKey(v.decryptWith);
68
+ if (mapped !== null)
69
+ v.decryptWith = mapped;
70
+ }
71
+ if (v.type === 'weighted_values') {
72
+ const inner = v.value;
73
+ let entries = [];
74
+ if (Array.isArray(inner)) {
75
+ entries = inner;
76
+ }
77
+ else if (inner && typeof inner === 'object') {
78
+ const wvs = inner.weightedValues;
79
+ if (Array.isArray(wvs))
80
+ entries = wvs;
81
+ }
82
+ for (const entry of entries) {
83
+ if (entry && typeof entry === 'object' && entry.value) {
84
+ rewriteValueKeyRefs(entry.value);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * qfg-hbuy.11: walk every value position that can carry `decryptWith`
91
+ * (default rules, per-environment rules, variants) and rewrite the reference
92
+ * through the final key map. Mirrors normalizeJsonValuesInConfig's traversal.
93
+ */
94
+ function rewriteKeyReferencesInConfig(out) {
95
+ const walkRules = (rules) => {
96
+ if (!Array.isArray(rules))
97
+ return;
98
+ for (const rule of rules) {
99
+ if (!rule || typeof rule !== 'object')
100
+ continue;
101
+ const r = rule;
102
+ if (r.value)
103
+ rewriteValueKeyRefs(r.value);
104
+ }
105
+ };
106
+ const defaultSection = out.default;
107
+ if (defaultSection)
108
+ walkRules(defaultSection.rules);
109
+ if (Array.isArray(out.environments)) {
110
+ for (const env of out.environments) {
111
+ walkRules(env.rules);
112
+ }
113
+ }
114
+ if (Array.isArray(out.variants)) {
115
+ for (const variant of out.variants) {
116
+ if (variant && variant.value)
117
+ rewriteValueKeyRefs(variant.value);
118
+ }
119
+ }
120
+ }
53
121
  /**
54
122
  * qfg-l18w / qfg-0q1f: Operators Quonfig accepts in rule criteria. Mirrors
55
123
  * the `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
@@ -328,6 +396,15 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
328
396
  if (typeof out.key === 'string') {
329
397
  out.key = normalizeKey(out.key);
330
398
  }
399
+ // qfg-hbuy.11: `schemaKey` references ANOTHER config's key (the schema this
400
+ // config validates against). If that schema was renamed by the key rewriter
401
+ // the reference must follow; a schemaKey absent from the map references a
402
+ // pre-existing workspace schema and is left untouched.
403
+ if (typeof out.schemaKey === 'string') {
404
+ const mapped = resolveMappedKey(out.schemaKey);
405
+ if (mapped !== null)
406
+ out.schemaKey = mapped;
407
+ }
331
408
  delete out.changedBy;
332
409
  delete out.modifiedAt;
333
410
  delete out.createdAt;
@@ -424,6 +501,9 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
424
501
  }
425
502
  }
426
503
  normalizeJsonValuesInConfig(out);
504
+ // qfg-hbuy.11: rewrite `decryptWith` references AFTER json-normalization so
505
+ // the walker sees the final value shapes.
506
+ rewriteKeyReferencesInConfig(out);
427
507
  normalizeWeightsInConfig(out, onNormalizedWeights);
428
508
  validateOperators(out);
429
509
  return out;
@@ -22,6 +22,16 @@ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
22
22
  function normalizeKey(key) {
23
23
  return resolveKey(key);
24
24
  }
25
+ /**
26
+ * qfg-hbuy.10: LD segments are their OWN key namespace — a project can carry a
27
+ * flag and a segment both keyed `checkout`. Segment key definitions (and the
28
+ * IN_SEG references in `ruleset.ts`) resolve through the segment namespace so
29
+ * the collision case gets the deterministic `-segment` suffix while the flag
30
+ * keeps the clean name. Non-colliding segments resolve exactly as before.
31
+ */
32
+ function normalizeSegmentKey(key) {
33
+ return resolveKey(key, 'segment');
34
+ }
25
35
  /** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
26
36
  function slugifyEnvKey(key) {
27
37
  return key
@@ -53,7 +63,7 @@ export function flagOutputPath(key) {
53
63
  return `feature-flags/${normalizeKey(key)}.json`;
54
64
  }
55
65
  export function segmentOutputPath(key) {
56
- return `segments/${normalizeKey(key)}.json`;
66
+ return `segments/${normalizeSegmentKey(key)}.json`;
57
67
  }
58
68
  /**
59
69
  * Translate one LaunchDarkly flag into a Quonfig feature-flag file.
@@ -199,7 +209,7 @@ export function translateSegment(segment, report) {
199
209
  // Trailing catch-all: anyone not matched above is not in the segment.
200
210
  rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
201
211
  const out = {
202
- key: normalizeKey(segment.key),
212
+ key: normalizeSegmentKey(segment.key),
203
213
  type: 'segment',
204
214
  valueType: 'bool',
205
215
  default: { rules },
@@ -164,7 +164,14 @@ async function* fetchChangesImpl() {
164
164
  yield { key: flag.key, raw: { data: flag, kind: 'flag' }, source: SOURCE_NAME };
165
165
  }
166
166
  for (const segment of snapshot.segments) {
167
- yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
167
+ // qfg-hbuy.10: LD segments are their own key namespace — tag them so the
168
+ // key rewriter can disambiguate a flag and a segment sharing one key.
169
+ yield {
170
+ key: segment.key,
171
+ keyNamespace: 'segment',
172
+ raw: { data: segment, kind: 'segment' },
173
+ source: SOURCE_NAME,
174
+ };
168
175
  }
169
176
  }
170
177
  /** Path to this run's audit-walk resume cursor (always under os.tmpdir; see filename comment). */
@@ -235,7 +242,13 @@ async function* fetchAuditHistory(apiKey) {
235
242
  }
236
243
  }
237
244
  for (const segment of segmentsByKey.values()) {
238
- yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
245
+ // qfg-hbuy.10: see the matching tag in fetchChangesImpl.
246
+ yield {
247
+ key: segment.key,
248
+ keyNamespace: 'segment',
249
+ raw: { data: segment, kind: 'segment' },
250
+ source: SOURCE_NAME,
251
+ };
239
252
  }
240
253
  }
241
254
  function translateImpl(change) {
@@ -10,7 +10,9 @@
10
10
  * - 1..200 chars (ASCII-only, so char length == byte length),
11
11
  * - not the reserved key `"new"`,
12
12
  * - no leading dot (a `.beta.json` config is silently skipped by the loader
13
- * and `qfg verify`).
13
+ * and `qfg verify`),
14
+ * - no trailing dot or space (silently stripped on Windows), and
15
+ * - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
14
16
  *
15
17
  * NOTE the layering: this is the create boundary (hard). The general charset is
16
18
  * a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
@@ -18,6 +20,18 @@
18
20
  * Keep the messages aligned with app-quonfig's for a consistent UX.
19
21
  */
20
22
  export declare const POLICY_A_KEY_RE: RegExp;
23
+ /**
24
+ * Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
25
+ * app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
26
+ * and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
27
+ * `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
28
+ * dot-segment of the key (`k.split('.')[0]`), because Windows treats
29
+ * `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
30
+ * rejected while `foo.con`, `com10`, and `console` are fine. The audit
31
+ * (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
32
+ * checks byte-identical across enforcement points — keep the regex in sync.
33
+ */
34
+ export declare const WINDOWS_RESERVED_DEVICE_NAME_RE: RegExp;
21
35
  /**
22
36
  * Returns a human-readable error message for a key that violates Policy A, or
23
37
  * `null` if the key is valid. First failing rule wins (matches the order Zod
@@ -10,7 +10,9 @@
10
10
  * - 1..200 chars (ASCII-only, so char length == byte length),
11
11
  * - not the reserved key `"new"`,
12
12
  * - no leading dot (a `.beta.json` config is silently skipped by the loader
13
- * and `qfg verify`).
13
+ * and `qfg verify`),
14
+ * - no trailing dot or space (silently stripped on Windows), and
15
+ * - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
14
16
  *
15
17
  * NOTE the layering: this is the create boundary (hard). The general charset is
16
18
  * a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
@@ -22,6 +24,18 @@
22
24
  // across both sources of truth — see the module doc above.
23
25
  // eslint-disable-next-line unicorn/better-regex
24
26
  export const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
27
+ /**
28
+ * Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
29
+ * app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
30
+ * and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
31
+ * `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
32
+ * dot-segment of the key (`k.split('.')[0]`), because Windows treats
33
+ * `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
34
+ * rejected while `foo.con`, `com10`, and `console` are fine. The audit
35
+ * (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
36
+ * checks byte-identical across enforcement points — keep the regex in sync.
37
+ */
38
+ export const WINDOWS_RESERVED_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
25
39
  /**
26
40
  * Returns a human-readable error message for a key that violates Policy A, or
27
41
  * `null` if the key is valid. First failing rule wins (matches the order Zod
@@ -38,5 +52,15 @@ export function policyAKeyError(key) {
38
52
  return 'Key can only contain letters, numbers, dots, dashes, and underscores';
39
53
  if (key.startsWith('.'))
40
54
  return 'Key cannot start with a dot';
55
+ // ── FS-safety floor parity with app-quonfig PolicyAKeySchema (qfg-hbuy.6) ──
56
+ // The charset above already covers the hook's control-char and
57
+ // Windows-reserved-char floor items; these two are the only floor items the
58
+ // charset alone permits. Without them the UI/create boundary accepts the key
59
+ // and the user gets a confusing 422 at push time instead of a clean error.
60
+ if (/[ .]$/.test(key))
61
+ return 'Key cannot end with a dot or space';
62
+ if (WINDOWS_RESERVED_DEVICE_NAME_RE.test(key.split('.')[0])) {
63
+ return 'Key cannot use a Windows reserved device name (con, prn, aux, nul, com1-9, lpt1-9) before the first dot';
64
+ }
41
65
  return null;
42
66
  }
@@ -21,5 +21,12 @@
21
21
  * exists to catch (verified live on staging: a `configs/bad charset key.json`
22
22
  * push was accepted unvalidated). A listed-but-unreadable file is now a hard
23
23
  * failure (fail closed), not a silent skip.
24
+ *
25
+ * qfg-hbuy.4: enumerates RECURSIVELY (`-r`) and with NO name filtering. The
26
+ * old non-recursive listing plus `endsWith('.json') && !includes('/.')` filter
27
+ * meant dotfiles, nested paths (configs/sub/x.json), and case-variant
28
+ * extensions (FOO.JSON) never reached validation at all — ghost files that
29
+ * push fine but no loader reads. The hook must see EVERYTHING under the
30
+ * validated dirs; validateFileMap is the layer that decides what is an error.
24
31
  */
25
32
  export declare function readFilesFromCommit(commitOid: string, cwd?: string): Map<string, string>;
@@ -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)
633
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);
721
+ continue;
722
+ }
634
723
  filesChecked++;
635
724
  let parsed;
636
725
  try {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.69";
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.69';
2
+ export default '0.0.70';
@@ -4136,5 +4136,5 @@
4136
4136
  ]
4137
4137
  }
4138
4138
  },
4139
- "version": "0.0.69"
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.69",
4
+ "version": "0.0.70",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },