@quonfig/cli 0.0.66 → 0.0.68
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 +89 -90
- package/dist/commands/init.js +1 -0
- package/dist/commands/migrate.js +1 -0
- package/dist/commands/pull.js +1 -0
- package/dist/commands/push.js +1 -0
- package/dist/migrate/import-state.d.ts +3 -0
- package/dist/migrate/import-state.js +32 -0
- package/dist/migrate/key-rewriter.d.ts +42 -10
- package/dist/migrate/key-rewriter.js +103 -22
- package/dist/migrate/key-sanitize.js +26 -6
- package/dist/migrate/local-write.js +11 -4
- package/dist/migrate/migration-report.js +6 -0
- package/dist/migrate/push-to-cloud.js +20 -4
- package/dist/verify/standalone.d.ts +13 -1
- package/dist/verify/standalone.js +37 -29
- package/dist/verify/validate.js +13 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +156 -152
- package/package.json +1 -1
|
@@ -8,27 +8,57 @@ export interface KeyRewrite {
|
|
|
8
8
|
}
|
|
9
9
|
export declare function resetKeyRewriter(): void;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Seed the rewriter with the complete source->final map persisted by previous
|
|
12
|
+
* runs. Every persisted final is immediately marked taken (case-insensitively)
|
|
13
|
+
* so keys planned later this run can never claim one. Call after
|
|
14
|
+
* `resetKeyRewriter()` and before `planKeyRewrites()` — `planKeyRewritesForChanges`
|
|
15
|
+
* does this ordering for you.
|
|
16
|
+
*/
|
|
17
|
+
export declare function seedPersistedKeyPlan(keys: Readonly<Record<string, string>>): void;
|
|
18
|
+
/**
|
|
19
|
+
* Pre-pass: compute the final key for every source key. Three passes, each
|
|
20
|
+
* sorted so every assignment is deterministic and independent of the order
|
|
21
|
+
* changes were fetched in:
|
|
22
|
+
*
|
|
23
|
+
* 0. Keys mapped by a PREVIOUS run keep their persisted final verbatim (see
|
|
24
|
+
* `seedPersistedKeyPlan`) — full and delta runs must resolve identically.
|
|
25
|
+
* 1. Keys that are ALREADY fully valid (sanitize is an identity: they pass
|
|
26
|
+
* Policy A and the FS-floor) claim their own names FIRST and are never
|
|
27
|
+
* renamed to make room for sanitized junk — customer code calling
|
|
28
|
+
* get("my-flag") must keep resolving to the same flag even when a source
|
|
29
|
+
* key like "my flag" sanitizes to the same name. If two VALID keys
|
|
30
|
+
* collide case-insensitively ("Foo"/"foo" — a genuine source conflict the
|
|
31
|
+
* FS-floor hard-rejects), the lexicographically-first one keeps its name
|
|
32
|
+
* and the other is suffixed.
|
|
33
|
+
* 2. Keys that needed sanitizing then disambiguate AROUND the valid ones
|
|
34
|
+
* (-2, -3, ...).
|
|
14
35
|
*/
|
|
15
36
|
export declare function planKeyRewrites(sourceKeys: Iterable<string>): void;
|
|
16
37
|
/**
|
|
17
|
-
* The final key for `sourceKey`. Uses the planned map when present,
|
|
38
|
+
* The final key for `sourceKey`. Uses the planned map when present, then the
|
|
39
|
+
* PERSISTED map (a delta run's flag can reference a segment that was migrated
|
|
40
|
+
* in the full run but is absent from the delta's change set), else a pure
|
|
18
41
|
* sanitize (no disambiguation, no mutation) as a safe fallback.
|
|
19
42
|
*/
|
|
20
43
|
export declare function resolveKey(sourceKey: string): string;
|
|
21
44
|
/** Every key that was actually rewritten (conforming keys are omitted). */
|
|
22
45
|
export declare function getKeyRewrites(): KeyRewrite[];
|
|
23
46
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
47
|
+
* The COMPLETE source->final map to persist to `.qf/key-plan.json`: every key
|
|
48
|
+
* planned this run (unchanged ones included) merged over everything persisted
|
|
49
|
+
* by previous runs, sorted by source key for stable on-disk diffs.
|
|
50
|
+
*/
|
|
51
|
+
export declare function getFullKeyPlan(): Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Run-level pre-pass: reset the rewriter, seed the persisted plan from any
|
|
54
|
+
* previous run, and plan every source key BEFORE any change is translated, so
|
|
55
|
+
* both key-definition and by-key reference sites resolve against the same
|
|
56
|
+
* fully-disambiguated map. Structurally typed on `{key?}` to stay decoupled
|
|
57
|
+
* from LegacyChange.
|
|
28
58
|
*/
|
|
29
59
|
export declare function planKeyRewritesForChanges(changes: ReadonlyArray<{
|
|
30
60
|
key?: string;
|
|
31
|
-
}
|
|
61
|
+
}>, persistedKeys?: Readonly<Record<string, string>>): void;
|
|
32
62
|
/**
|
|
33
63
|
* `--strict-keys` escape hatch: refuse to migrate if any source key would need
|
|
34
64
|
* rewriting, so a customer who requires byte-identical keys can clean up the
|
|
@@ -41,10 +71,12 @@ export declare class StrictKeysError extends Error {
|
|
|
41
71
|
/**
|
|
42
72
|
* Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
|
|
43
73
|
* key would be rewritten. Call once at the top of each migrate orchestrator
|
|
44
|
-
* before translating.
|
|
74
|
+
* before translating. `persistedKeys` (from `readKeyPlan`) makes previously
|
|
75
|
+
* mapped keys resolve exactly as they did on the run that mapped them.
|
|
45
76
|
*/
|
|
46
77
|
export declare function preflightKeyRewrites(changes: ReadonlyArray<{
|
|
47
78
|
key?: string;
|
|
48
79
|
}>, opts?: {
|
|
80
|
+
persistedKeys?: Readonly<Record<string, string>>;
|
|
49
81
|
strict?: boolean;
|
|
50
82
|
}): void;
|
|
@@ -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.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
const
|
|
53
|
-
const reasons =
|
|
54
|
-
|
|
55
|
-
reasons
|
|
56
|
-
state.bySource.set(sourceKey, { final, reasons, source: sourceKey });
|
|
57
|
-
state.takenLower.set(
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
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
|
|
51
|
-
// the loader/verify; a trailing dot
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
package/dist/verify/validate.js
CHANGED
|
@@ -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
|
|
812
|
-
//
|
|
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.
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
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, ".", "-", "_")
|
|
864
|
-
severity: '
|
|
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.
|
|
1
|
+
declare const _default: "0.0.68";
|
|
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.
|
|
2
|
+
export default '0.0.68';
|