fix(settings): quote numeric-shaped YAML keys so hash-keyed settings survive reload
The emitter wrote block-map keys raw while only values went through quoting. A workspace-hash key segment that happens to be all digits with a leading zero (or digits-e-digits) reloaded as an int/float — leading zero dropped, or collapsed to Infinity — orphaning the stored value. Hit ~1 in 1200 repos, deterministically and permanently: the PATH preset (D-106) and the pre-existing Claude account binding for such a repo vanished on every restart with no diagnostics. Same defect class as the T-376 value-corruption fixes in this file; found by the T-511 review pass, confirmed against the live store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Hash-keyed settings (per-workspace PATH presets, Claude account bindings)
|
||||||
|
with a number-shaped hash suffix were silently lost on restart — YAML map
|
||||||
|
keys are now quoted on write.
|
||||||
|
|
||||||
## [2.9.0] — 2026-06-30
|
## [2.9.0] — 2026-06-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -282,7 +282,14 @@ void _emit(StringBuffer buf, Object? v, int indent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
v.forEach((k, vv) {
|
v.forEach((k, vv) {
|
||||||
buf.write('$pad$k:');
|
// Keys go through the same quoting as values: a numeric-shaped key —
|
||||||
|
// e.g. an all-digit FNV workspace-hash suffix (`app.env.pathPrepend.
|
||||||
|
// 0123…`, `app.claude.account.<hash>`) — would otherwise reload as an
|
||||||
|
// int/float and silently corrupt the key (leading zero dropped, or
|
||||||
|
// `1e…` collapsing to Infinity), orphaning the stored value.
|
||||||
|
buf.write(pad);
|
||||||
|
_emitScalar(buf, '$k');
|
||||||
|
buf.write(':');
|
||||||
if (vv is Map && vv.isNotEmpty) {
|
if (vv is Map && vv.isNotEmpty) {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
_emit(buf, vv, indent + 1);
|
_emit(buf, vv, indent + 1);
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ void main() {
|
|||||||
loaded.dispose();
|
loaded.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('numeric-shaped key segments round-trip unmangled (workspace-hash keys)', () async {
|
||||||
|
// FNV workspace-hash suffixes (app.env.pathPrepend.<hash>, the account
|
||||||
|
// bindings) can be number-shaped; an unquoted YAML key would reload as
|
||||||
|
// an int (leading zero dropped) or a float ('1e…' → Infinity) and
|
||||||
|
// silently orphan the stored value.
|
||||||
|
await store.set<List<String>>('app.env.pathPrepend.0123456789012345', const ['/opt/go/bin']);
|
||||||
|
await store.set<String>('app.claude.account.1e23456789012345', 'work');
|
||||||
|
final loaded = SettingsStore(appDir: tmp);
|
||||||
|
await loaded.load();
|
||||||
|
expect(loaded.get<List<dynamic>>('app.env.pathPrepend.0123456789012345'), ['/opt/go/bin']);
|
||||||
|
expect(loaded.get<String>('app.claude.account.1e23456789012345'), 'work');
|
||||||
|
loaded.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('app.* scope supports bool + int + list', () async {
|
test('app.* scope supports bool + int + list', () async {
|
||||||
await store.set<bool>('app.extensions.git.enabled', false);
|
await store.set<bool>('app.extensions.git.enabled', false);
|
||||||
await store.set<int>('app.layout.width', 240);
|
await store.set<int>('app.layout.width', 240);
|
||||||
|
|||||||
Reference in New Issue
Block a user