@quonfig/cli 0.0.66 → 0.0.67

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.
@@ -22,11 +22,25 @@
22
22
  * unit calls to a source's path builders stay referentially transparent.
23
23
  */
24
24
  import { sanitizePolicyAKey } from './key-sanitize.js';
25
- const state = { bySource: new Map(), takenLower: new Map() };
25
+ const state = { bySource: new Map(), persisted: new Map(), takenLower: new Map() };
26
26
  export function resetKeyRewriter() {
27
27
  state.bySource = new Map();
28
+ state.persisted = new Map();
28
29
  state.takenLower = new Map();
29
30
  }
31
+ /**
32
+ * Seed the rewriter with the complete source->final map persisted by previous
33
+ * runs. Every persisted final is immediately marked taken (case-insensitively)
34
+ * so keys planned later this run can never claim one. Call after
35
+ * `resetKeyRewriter()` and before `planKeyRewrites()` — `planKeyRewritesForChanges`
36
+ * does this ordering for you.
37
+ */
38
+ export function seedPersistedKeyPlan(keys) {
39
+ for (const [sourceKey, final] of Object.entries(keys)) {
40
+ state.persisted.set(sourceKey, final);
41
+ state.takenLower.set(final.toLowerCase(), sourceKey);
42
+ }
43
+ }
30
44
  /** Find the first case-insensitively-free variant of `base` (base, base-2, ...). */
31
45
  function disambiguate(base) {
32
46
  if (!state.takenLower.has(base.toLowerCase()))
@@ -39,44 +53,110 @@ function disambiguate(base) {
39
53
  return candidate;
40
54
  }
41
55
  }
56
+ /** Disambiguate `base`, record the mapping, and mark the final taken. */
57
+ function assignFinal(sourceKey, base, sanitizeReasons) {
58
+ const final = disambiguate(base);
59
+ const reasons = [...sanitizeReasons];
60
+ if (final !== base)
61
+ reasons.push('appended a numeric suffix to keep the key unique');
62
+ state.bySource.set(sourceKey, { final, reasons, source: sourceKey });
63
+ state.takenLower.set(final.toLowerCase(), sourceKey);
64
+ }
42
65
  /**
43
- * Pre-pass: compute the final key for every source key. Sorted first so the
44
- * assignment of the un-suffixed base name on a collision is deterministic and
45
- * independent of the order changes were fetched in.
66
+ * Pre-pass: compute the final key for every source key. Three passes, each
67
+ * sorted so every assignment is deterministic and independent of the order
68
+ * changes were fetched in:
69
+ *
70
+ * 0. Keys mapped by a PREVIOUS run keep their persisted final verbatim (see
71
+ * `seedPersistedKeyPlan`) — full and delta runs must resolve identically.
72
+ * 1. Keys that are ALREADY fully valid (sanitize is an identity: they pass
73
+ * Policy A and the FS-floor) claim their own names FIRST and are never
74
+ * renamed to make room for sanitized junk — customer code calling
75
+ * get("my-flag") must keep resolving to the same flag even when a source
76
+ * key like "my flag" sanitizes to the same name. If two VALID keys
77
+ * collide case-insensitively ("Foo"/"foo" — a genuine source conflict the
78
+ * FS-floor hard-rejects), the lexicographically-first one keeps its name
79
+ * and the other is suffixed.
80
+ * 2. Keys that needed sanitizing then disambiguate AROUND the valid ones
81
+ * (-2, -3, ...).
46
82
  */
47
83
  export function planKeyRewrites(sourceKeys) {
48
- for (const sourceKey of [...new Set(sourceKeys)].sort()) {
49
- if (state.bySource.has(sourceKey))
84
+ const fresh = [...new Set(sourceKeys)].sort().filter((k) => !state.bySource.has(k));
85
+ // Pass 0: previously-mapped keys resolve to their persisted final, always.
86
+ const unpersisted = [];
87
+ for (const sourceKey of fresh) {
88
+ const persistedFinal = state.persisted.get(sourceKey);
89
+ if (persistedFinal === undefined) {
90
+ unpersisted.push(sourceKey);
50
91
  continue;
51
- const san = sanitizePolicyAKey(sourceKey);
52
- const final = disambiguate(san.key);
53
- const reasons = [...san.reasons];
54
- if (final !== san.key)
55
- reasons.push('appended a numeric suffix to keep the key unique');
56
- state.bySource.set(sourceKey, { final, reasons, source: sourceKey });
57
- state.takenLower.set(final.toLowerCase(), sourceKey);
92
+ }
93
+ const sanitized = sanitizePolicyAKey(sourceKey);
94
+ const reasons = persistedFinal === sanitized.key
95
+ ? [...sanitized.reasons]
96
+ : [...sanitized.reasons, 'kept the key assigned by a previous import run'];
97
+ state.bySource.set(sourceKey, { final: persistedFinal, reasons, source: sourceKey });
98
+ state.takenLower.set(persistedFinal.toLowerCase(), sourceKey);
99
+ }
100
+ // Pass 1: already-valid keys claim their own names.
101
+ const needsSanitizing = [];
102
+ for (const sourceKey of unpersisted) {
103
+ const sanitized = sanitizePolicyAKey(sourceKey);
104
+ if (sanitized.changed) {
105
+ needsSanitizing.push({ sanitized, sourceKey });
106
+ }
107
+ else {
108
+ assignFinal(sourceKey, sourceKey, []);
109
+ }
110
+ }
111
+ // Pass 2: sanitized keys disambiguate around them.
112
+ for (const { sanitized, sourceKey } of needsSanitizing) {
113
+ assignFinal(sourceKey, sanitized.key, sanitized.reasons);
58
114
  }
59
115
  }
60
116
  /**
61
- * The final key for `sourceKey`. Uses the planned map when present, else a pure
117
+ * The final key for `sourceKey`. Uses the planned map when present, then the
118
+ * PERSISTED map (a delta run's flag can reference a segment that was migrated
119
+ * in the full run but is absent from the delta's change set), else a pure
62
120
  * sanitize (no disambiguation, no mutation) as a safe fallback.
63
121
  */
64
122
  export function resolveKey(sourceKey) {
65
123
  const planned = state.bySource.get(sourceKey);
66
- return planned ? planned.final : sanitizePolicyAKey(sourceKey).key;
124
+ if (planned)
125
+ return planned.final;
126
+ const persisted = state.persisted.get(sourceKey);
127
+ if (persisted !== undefined)
128
+ return persisted;
129
+ return sanitizePolicyAKey(sourceKey).key;
67
130
  }
68
131
  /** Every key that was actually rewritten (conforming keys are omitted). */
69
132
  export function getKeyRewrites() {
70
133
  return [...state.bySource.values()].filter((r) => r.final !== r.source);
71
134
  }
72
135
  /**
73
- * Run-level pre-pass: reset the rewriter and plan every source key BEFORE any
74
- * change is translated, so both key-definition and by-key reference sites
75
- * resolve against the same fully-disambiguated map. Structurally typed on
76
- * `{key?}` to stay decoupled from LegacyChange.
136
+ * The COMPLETE source->final map to persist to `.qf/key-plan.json`: every key
137
+ * planned this run (unchanged ones included) merged over everything persisted
138
+ * by previous runs, sorted by source key for stable on-disk diffs.
139
+ */
140
+ export function getFullKeyPlan() {
141
+ const merged = new Map(state.persisted);
142
+ for (const [source, rewrite] of state.bySource)
143
+ merged.set(source, rewrite.final);
144
+ const out = {};
145
+ for (const source of [...merged.keys()].sort())
146
+ out[source] = merged.get(source);
147
+ return out;
148
+ }
149
+ /**
150
+ * Run-level pre-pass: reset the rewriter, seed the persisted plan from any
151
+ * previous run, and plan every source key BEFORE any change is translated, so
152
+ * both key-definition and by-key reference sites resolve against the same
153
+ * fully-disambiguated map. Structurally typed on `{key?}` to stay decoupled
154
+ * from LegacyChange.
77
155
  */
78
- export function planKeyRewritesForChanges(changes) {
156
+ export function planKeyRewritesForChanges(changes, persistedKeys) {
79
157
  resetKeyRewriter();
158
+ if (persistedKeys)
159
+ seedPersistedKeyPlan(persistedKeys);
80
160
  planKeyRewrites(changes.flatMap((c) => (typeof c.key === 'string' ? [c.key] : [])));
81
161
  }
82
162
  /**
@@ -97,10 +177,11 @@ export class StrictKeysError extends Error {
97
177
  /**
98
178
  * Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
99
179
  * key would be rewritten. Call once at the top of each migrate orchestrator
100
- * before translating.
180
+ * before translating. `persistedKeys` (from `readKeyPlan`) makes previously
181
+ * mapped keys resolve exactly as they did on the run that mapped them.
101
182
  */
102
183
  export function preflightKeyRewrites(changes, opts) {
103
- planKeyRewritesForChanges(changes);
184
+ planKeyRewritesForChanges(changes, opts?.persistedKeys);
104
185
  if (opts?.strict) {
105
186
  const rewrites = getKeyRewrites();
106
187
  if (rewrites.length > 0)
@@ -35,6 +35,14 @@ function shortHash(raw) {
35
35
  export function sanitizePolicyAKey(raw) {
36
36
  const reasons = [];
37
37
  let k = raw;
38
+ // 0. Trim leading/trailing whitespace FIRST, so an edge space is dropped (a
39
+ // trailing space is an FS-floor violation — stripped by Windows) rather
40
+ // than surviving as a cosmetic dash via the charset replacement below.
41
+ const wsTrimmed = k.replaceAll(/^\s+|\s+$/g, '');
42
+ if (wsTrimmed !== k) {
43
+ k = wsTrimmed;
44
+ reasons.push('trimmed leading/trailing whitespace');
45
+ }
38
46
  // 1. Path separators -> dot. Preserves the dotted-hierarchy intent (a `/` or
39
47
  // `\` in a source key becomes a namespace dot, matching the historical
40
48
  // normalizeKey) rather than a lossy dash.
@@ -47,13 +55,16 @@ export function sanitizePolicyAKey(raw) {
47
55
  k = k.replaceAll(/[^A-Za-z0-9._-]+/g, '-');
48
56
  reasons.push('replaced disallowed characters with "-"');
49
57
  }
50
- // 3. Trim leading/trailing separators. A leading dot is silently skipped by
51
- // the loader/verify; a trailing dot or space is stripped by Windows. A
52
- // leading/trailing dash is merely cosmetic but trimmed for tidiness.
53
- const trimmed = k.replaceAll(/^[.-]+/g, '').replaceAll(/[.-]+$/g, '');
58
+ // 3. Trim leading/trailing DOTS only. A leading dot is silently skipped by
59
+ // the loader/verify; a trailing dot is stripped by Windows — both are
60
+ // floor violations. Leading/trailing DASHES are fully legal under Policy A
61
+ // and the floor, so they are deliberately NOT trimmed: this function must
62
+ // be an identity on every already-valid key (otherwise --strict-keys
63
+ // aborts on keys the platform accepts, and a valid key gets "rewritten").
64
+ const trimmed = k.replaceAll(/^\.+/g, '').replaceAll(/\.+$/g, '');
54
65
  if (trimmed !== k) {
55
66
  k = trimmed;
56
- reasons.push('trimmed leading/trailing separators');
67
+ reasons.push('trimmed leading/trailing dots');
57
68
  }
58
69
  // 4. Windows reserved device name on the FIRST dot-segment (the segment the
59
70
  // floor checks) -> suffix "_" so `con` -> `con_`, `nul.foo` -> `nul_.foo`.
@@ -68,11 +79,20 @@ export function sanitizePolicyAKey(raw) {
68
79
  k = 'new-key';
69
80
  reasons.push('escaped reserved key "new"');
70
81
  }
71
- // 6. Empty after sanitizing (e.g. the source key was all disallowed chars).
82
+ // 6. Empty — or reduced to pure separator junk — after sanitizing (e.g. the
83
+ // source key was all disallowed chars, so every char became a dash). The
84
+ // `k !== raw` guard keeps this an identity on ALREADY-valid separator-only
85
+ // keys like "-" (legal under Policy A); only keys we transformed into
86
+ // meaningless separators get the stable placeholder, so distinct junk
87
+ // source keys ("!!!" vs "???") stay distinguishable.
72
88
  if (k.length === 0) {
73
89
  k = `key-${shortHash(raw)}`;
74
90
  reasons.push('key was empty after sanitizing');
75
91
  }
92
+ else if (k !== raw && !/[A-Za-z0-9_]/.test(k)) {
93
+ k = `key-${shortHash(raw)}`;
94
+ reasons.push('key had no letters, numbers, or underscores after sanitizing');
95
+ }
76
96
  // 7. Length cap 200 (ASCII-only == byte length). Stable hash suffix keeps two
77
97
  // long keys sharing a 200-char prefix from colliding.
78
98
  if (k.length > MAX_KEY_LEN) {
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
- import { getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
3
+ import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
4
+ import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
5
5
  import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
6
6
  import { detectDuplicateKeys } from './sources/launch/translate.js';
7
7
  import { MIGRATOR_IDENTITY, stackCommits } from '../util/clone-and-stack-push.js';
@@ -184,8 +184,13 @@ export const applyLocalMigration = async (opts) => {
184
184
  const author = opts.author ?? MIGRATOR_IDENTITY;
185
185
  // qfg-6na9.3: plan Policy A key rewrites over ALL changes up front (run-level,
186
186
  // not per-commit) so key-def and reference sites resolve identically, then
187
- // enforce --strict-keys before writing anything.
188
- preflightKeyRewrites(opts.changes, { strict: opts.strictKeys });
187
+ // enforce --strict-keys before writing anything. Mappings persisted by a
188
+ // previous run (.qf/key-plan.json) are authoritative so a delta run cannot
189
+ // replan a subset and land on different finals.
190
+ preflightKeyRewrites(opts.changes, {
191
+ persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
192
+ strict: opts.strictKeys,
193
+ });
189
194
  if (opts.fullHistory && !opts.source.getCommitMeta) {
190
195
  throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
191
196
  `Drop --full-summary or use a source (e.g. launch) that supports it.`);
@@ -200,6 +205,7 @@ export const applyLocalMigration = async (opts) => {
200
205
  const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
201
206
  removeQfFromGitignore(opts.localDir);
202
207
  writeImportState(opts.localDir, opts.importState);
208
+ writeKeyPlan(opts.localDir, getFullKeyPlan());
203
209
  const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
204
210
  const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
205
211
  const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
@@ -347,6 +353,7 @@ export const buildAuditFinalCommit = (opts) => ({
347
353
  const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
348
354
  removeQfFromGitignore(dir);
349
355
  writeImportState(dir, opts.importState);
356
+ writeKeyPlan(dir, getFullKeyPlan());
350
357
  const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
351
358
  const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
352
359
  const reportData = {
@@ -613,5 +613,11 @@ export const writeMigrationReport = (outputDir, data) => {
613
613
  source: data.source,
614
614
  }, null, 2) + '\n', 'utf8');
615
615
  }
616
+ else {
617
+ // key-map.json describes ONE run's rewrites. A later run with zero
618
+ // rewrites must not leave a stale map from a previous run lying around
619
+ // implying those keys were rewritten by this run.
620
+ fs.rmSync(keyMapPath(outputDir), { force: true });
621
+ }
616
622
  return filePath;
617
623
  };
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { removeQfFromGitignore, writeImportState } from './import-state.js';
3
+ import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
4
4
  import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
5
- import { getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
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';
8
8
  import { formatResult, validateWorkspace } from '../verify/validate.js';
@@ -58,8 +58,15 @@ export const pushMigrationToCloud = async (opts) => {
58
58
  }
59
59
  // qfg-6na9.3: plan Policy A key rewrites over ALL changes up front (run-level,
60
60
  // not per-commit) so key-def and reference sites resolve identically, then
61
- // enforce --strict-keys before any clone/commit/push.
62
- preflightKeyRewrites(opts.changes, { strict: opts.strictKeys });
61
+ // enforce --strict-keys before any clone/commit/push. Mappings persisted by a
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/.)
66
+ preflightKeyRewrites(opts.changes, {
67
+ persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
68
+ strict: opts.strictKeys,
69
+ });
63
70
  const acc = {
64
71
  coercedSentinels: null,
65
72
  droppedOverrides: null,
@@ -90,10 +97,19 @@ export const pushMigrationToCloud = async (opts) => {
90
97
  {
91
98
  message: () => computedCommitMessage,
92
99
  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
+ });
93
108
  mergeEnvsIfPresent(dir);
94
109
  const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
95
110
  removeQfFromGitignore(dir);
96
111
  writeImportState(dir, opts.importState);
112
+ writeKeyPlan(dir, getFullKeyPlan());
97
113
  acc.droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
98
114
  acc.skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
99
115
  acc.coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
@@ -10,4 +10,16 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- export {};
13
+ /**
14
+ * Read all JSON config files from a git commit using `git show`.
15
+ * Works in bare repos (no working tree needed).
16
+ *
17
+ * qfg-6na9.6: uses execFileSync (never a shell) and `ls-tree -z` (NUL-delimited,
18
+ * disables git's C-quoting of "unusual" paths). The old string-interpolated
19
+ * execSync + default ls-tree output silently SKIPPED any filename containing a
20
+ * space or non-ASCII char — exactly the Policy-A-violating keys the hook
21
+ * exists to catch (verified live on staging: a `configs/bad charset key.json`
22
+ * push was accepted unvalidated). A listed-but-unreadable file is now a hard
23
+ * failure (fail closed), not a silent skip.
24
+ */
25
+ export declare function readFilesFromCommit(commitOid: string, cwd?: string): Map<string, string>;
@@ -10,7 +10,7 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- import { execSync } from 'node:child_process';
13
+ import { execFileSync } from 'node:child_process';
14
14
  import * as readline from 'node:readline';
15
15
  import { formatResult, validateFileMap, validateWorkspace } from './validate.js';
16
16
  async function main() {
@@ -91,42 +91,50 @@ async function runGitHook() {
91
91
  /**
92
92
  * Read all JSON config files from a git commit using `git show`.
93
93
  * Works in bare repos (no working tree needed).
94
+ *
95
+ * qfg-6na9.6: uses execFileSync (never a shell) and `ls-tree -z` (NUL-delimited,
96
+ * disables git's C-quoting of "unusual" paths). The old string-interpolated
97
+ * execSync + default ls-tree output silently SKIPPED any filename containing a
98
+ * space or non-ASCII char — exactly the Policy-A-violating keys the hook
99
+ * exists to catch (verified live on staging: a `configs/bad charset key.json`
100
+ * push was accepted unvalidated). A listed-but-unreadable file is now a hard
101
+ * failure (fail closed), not a silent skip.
94
102
  */
95
- function readFilesFromCommit(commitOid) {
103
+ export function readFilesFromCommit(commitOid, cwd) {
96
104
  const files = new Map();
97
105
  const dirs = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas', 'schemas-protected'];
98
106
  for (const dir of dirs) {
99
- // List files in this directory at the given commit
100
- let listing;
101
- try {
102
- listing = execSync(`git ls-tree --name-only ${commitOid} ${dir}/`, {
103
- encoding: 'utf8',
104
- stdio: ['pipe', 'pipe', 'pipe'],
105
- });
106
- }
107
- catch {
108
- // Directory doesn't exist in this commit
109
- continue;
110
- }
111
- const filenames = listing.trim().split('\n').filter(Boolean);
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}/`], {
111
+ cwd,
112
+ encoding: 'utf8',
113
+ stdio: ['pipe', 'pipe', 'pipe'],
114
+ });
115
+ const filenames = listing.split('\0').filter(Boolean);
112
116
  for (const filePath of filenames) {
113
117
  if (!filePath.endsWith('.json') || filePath.includes('/.'))
114
118
  continue;
115
- try {
116
- const content = execSync(`git show ${commitOid}:${filePath}`, {
117
- encoding: 'utf8',
118
- stdio: ['pipe', 'pipe', 'pipe'],
119
- });
120
- files.set(filePath, content);
121
- }
122
- catch {
123
- // File can't be read, skip
124
- }
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.
122
+ const content = execFileSync('git', ['show', `${commitOid}:${filePath}`], {
123
+ cwd,
124
+ encoding: 'utf8',
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ });
127
+ files.set(filePath, content);
125
128
  }
126
129
  }
127
130
  return files;
128
131
  }
129
- main().catch((error) => {
130
- console.error(`qfg-verify: fatal: ${error.message}`);
131
- process.exit(1);
132
- });
132
+ // Only run as a program when compiled/executed as the entry point (Bun sets
133
+ // import.meta.main; under node test imports it is undefined) — this lets tests
134
+ // import readFilesFromCommit without triggering the CLI.
135
+ if (import.meta.main) {
136
+ main().catch((error) => {
137
+ console.error(`qfg-verify: fatal: ${error.message}`);
138
+ process.exit(1);
139
+ });
140
+ }
@@ -808,8 +808,9 @@ function validateEnvironmentIds(environments, file, issues) {
808
808
  // IMPORTANT: this floor must stay conceptually in lockstep with app-quonfig's
809
809
  // src/lib/domain/config-schemas.ts (the charset precedent there is
810
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.
811
+ // (`^[A-Za-z0-9._-]+$`) is deliberately NOT here — it lives in `validateKey`
812
+ // below (warning per qfg-6na9.5, hard error since qfg-6na9.6). This list is the
813
+ // FS-floor only.
813
814
  //
814
815
  // Structured as a table so severity is trivially adjustable later.
815
816
  const FS_SAFETY_FLOOR_CHECKS = [
@@ -848,20 +849,20 @@ export function validateKey(key, file, issues) {
848
849
  issues.push({ file, message: check.message, severity: 'error' });
849
850
  }
850
851
  }
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.
852
+ // qfg-6na9.6: the general Policy A charset as a HARD ERROR. Shipped as a
853
+ // warning first (qfg-6na9.5), flipped after a full-corpus enumeration of all
854
+ // prod workspaces verified zero non-conforming keys (2026-07-03; see the bead
855
+ // and project/plans/26-06-tighter-naming.md). The charset `^[A-Za-z0-9._-]+$`
856
+ // is kept in sync with app-quonfig's PolicyAKeySchema (hard at create).
857
+ // Skipped when this key already earned an error above (e.g. a
858
+ // Windows-reserved char is both a floor error AND a charset miss) so we
859
+ // don't double-report the same key.
859
860
  // eslint-disable-next-line unicorn/better-regex -- keep the explicit charset in sync with app-quonfig
860
861
  if (issues.length === errorsBefore && !/^[A-Za-z0-9._-]+$/.test(key)) {
861
862
  issues.push({
862
863
  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',
864
+ message: `Key "${key}" has characters outside the allowed set (letters, numbers, ".", "-", "_").`,
865
+ severity: 'error',
865
866
  suggestion: `Rename to use only letters, numbers, ".", "-", and "_"`,
866
867
  });
867
868
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.66";
1
+ declare const _default: "0.0.67";
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.66';
2
+ export default '0.0.67';