@mattstack/rt-client 0.2.0 → 0.4.0

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.
@@ -0,0 +1,608 @@
1
+ /**
2
+ * The settings resolver (RT-47): one read path that layers the four stores
3
+ * and the registry default into a single answer plus the provenance that
4
+ * explains it.
5
+ *
6
+ * Scope ladder, weakest → strongest:
7
+ *
8
+ * default < team < user < team.repo < user.repo < machine < machine.repo
9
+ *
10
+ * Merge is per-key schema, never global (`SettingDef.merge`):
11
+ * - `replace` — the strongest valid scope wins atomically; provenance has
12
+ * exactly one entry.
13
+ * - `deep` — object values overlay field-by-field walking weakest → strongest;
14
+ * arrays and scalars inside a deep key still replace atomically. Provenance
15
+ * lists every scope that still owns at least one leaf of the resolved value,
16
+ * weakest-first — a scope whose every field was overridden is NOT listed
17
+ * (same honesty rule that makes `replace` provenance length 1).
18
+ *
19
+ * Degrade rules (teammates run version-skewed binaries; one unknown key in the
20
+ * team store must never brick resolution):
21
+ * - explicit `get`/`explain` of an unregistered key → throw.
22
+ * - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings`
23
+ * with `unregistered: true`.
24
+ * - a registered key whose found value fails validation → warn + skip THAT
25
+ * scope only, labeled `invalid` in list/explain; weaker and stronger scopes
26
+ * still apply.
27
+ *
28
+ * Three deliberate decisions this file makes that the spec left to the
29
+ * implementation:
30
+ * 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded
31
+ * fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine
32
+ * store is explicitly allowed path literals. So team/user rungs get the
33
+ * full check, the machine rung gets the type check alone.
34
+ * 2. **A value found in a store the def does not allow is skipped**, labeled
35
+ * like any other invalid value (`rt.repoIdentityOverrides` is machine-only;
36
+ * honouring a team-store copy of it would defeat the schema).
37
+ * 3. **`explain` shows values AS AUTHORED** (never expanded) because its job
38
+ * is to say what is in which file, and **`list` degrades** an unexpandable
39
+ * value to its raw form with an `expandError` label rather than throwing —
40
+ * one bad value must not brick a survey of every key. `get` is the loud
41
+ * one: an unsatisfiable closed-set variable throws.
42
+ *
43
+ * The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a
44
+ * pre-derived input (see identity.ts for the async derivation). Store files are
45
+ * parsed fresh per call — they are small, and memoization is a later
46
+ * optimization that would need invalidation this wave does not have.
47
+ *
48
+ * Writes (`setSetting`) land in a later task; this module is read-side only.
49
+ */
50
+
51
+ import { homedir } from "os";
52
+ import { join } from "path";
53
+ import {
54
+ machineSettingsPath,
55
+ teamSettingsPath,
56
+ teamsDir,
57
+ userSettingsPath,
58
+ } from "./paths.ts";
59
+ import { allDefs, getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts";
60
+ import { listTeams, readStore, type StoreFile } from "./stores.ts";
61
+
62
+ // ─── Public types ────────────────────────────────────────────────────────────
63
+
64
+ export type Scope =
65
+ | "machine.repo"
66
+ | "machine"
67
+ | "user.repo"
68
+ | "team.repo"
69
+ | "user"
70
+ | "team"
71
+ | "default";
72
+
73
+ /** The scope ladder, weakest first. Also the order every result is built in. */
74
+ export const SCOPE_ORDER: Scope[] = [
75
+ "default",
76
+ "team",
77
+ "user",
78
+ "team.repo",
79
+ "user.repo",
80
+ "machine",
81
+ "machine.repo",
82
+ ];
83
+
84
+ export interface Provenance {
85
+ scope: Scope;
86
+ /** The file the value came from; null for the registry default. */
87
+ file: string | null;
88
+ }
89
+
90
+ export interface ResolveOpts {
91
+ /** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */
92
+ repoIdentity?: string | null;
93
+ /** Expand closed-set variables in the resolved value. Default true. */
94
+ expand?: boolean;
95
+ expandCtx?: { repoRoot?: string; worktree?: string };
96
+ }
97
+
98
+ export interface Resolved<T> {
99
+ value: T;
100
+ /** ALWAYS an array, weakest-first. Length 1 for replace keys. */
101
+ provenance: Provenance[];
102
+ }
103
+
104
+ /** A scope whose authored value was found but refused (type, path guard, or store). */
105
+ export interface InvalidScope {
106
+ scope: Scope;
107
+ file: string | null;
108
+ reason: string;
109
+ }
110
+
111
+ export interface ListedSetting {
112
+ key: string;
113
+ value: unknown;
114
+ provenance: Provenance[];
115
+ migrated: boolean;
116
+ /** Present only for keys found in files but absent from the registry. */
117
+ unregistered?: true;
118
+ /** Scopes skipped during resolution, with the reason each was refused. */
119
+ invalid?: InvalidScope[];
120
+ /** Set when the value could not be expanded here; `value` is then raw. */
121
+ expandError?: string;
122
+ }
123
+
124
+ export interface ExplainRow {
125
+ scope: Scope;
126
+ file: string | null;
127
+ present: boolean;
128
+ /** The value AS AUTHORED — never variable-expanded. */
129
+ value?: unknown;
130
+ /** Set when the value was ignored because the key is teamLocked. */
131
+ shadowed?: "teamLocked";
132
+ /** Set when the value was refused; the reason it was refused. */
133
+ invalid?: string;
134
+ }
135
+
136
+ export interface ExpandCtx {
137
+ repoRoot?: string;
138
+ worktree?: string;
139
+ home: string;
140
+ teamsDir: string;
141
+ }
142
+
143
+ // ─── Variables ───────────────────────────────────────────────────────────────
144
+
145
+ const VAR_RE = /\$\{([^}]*)\}/g;
146
+ const TEAM_VAR_RE = /^team:(.+)$/;
147
+
148
+ /**
149
+ * Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:<name>}`.
150
+ * Every other `${...}` passes through verbatim — domain templates like the
151
+ * interceptor's `${port}` are not ours to expand, and the same string may hold
152
+ * both kinds, so substitution is per-occurrence. `${team:<name>}` is lexical:
153
+ * `<teamsDir>/<name>` with no existence check (a missing team surfaces at use
154
+ * time through the consumer's own fail-open path), but the name must be a
155
+ * single directory segment — see `teamPath`. A closed-set variable with no
156
+ * context in `ctx` throws — silently emitting a half-expanded path is the
157
+ * dishonesty this design bans.
158
+ *
159
+ * Recurses through arrays and plain objects; non-strings pass through. Never
160
+ * mutates its input.
161
+ */
162
+ export function expandVariables(value: unknown, ctx: ExpandCtx): unknown {
163
+ if (typeof value === "string") return expandString(value, ctx);
164
+ if (Array.isArray(value)) return value.map((item) => expandVariables(item, ctx));
165
+ if (isPlainObject(value)) {
166
+ const out: Record<string, unknown> = {};
167
+ for (const [k, v] of Object.entries(value)) out[k] = expandVariables(v, ctx);
168
+ return out;
169
+ }
170
+ return value;
171
+ }
172
+
173
+ function expandString(input: string, ctx: ExpandCtx): string {
174
+ return input.replace(VAR_RE, (match, name: string) => {
175
+ if (name === "home") return ctx.home;
176
+ if (name === "repoRoot") return required(ctx.repoRoot, "repoRoot", "a repo path");
177
+ if (name === "worktree") return required(ctx.worktree, "worktree", "a worktree path");
178
+ const team = TEAM_VAR_RE.exec(name);
179
+ if (team) return teamPath(ctx.teamsDir, team[1] as string);
180
+ return match; // not ours — pass through verbatim
181
+ });
182
+ }
183
+
184
+ /**
185
+ * `${team:<name>}` → `<teamsDir>/<name>`, but only for a name that is a single
186
+ * directory segment. `<name>` is a team NAME, and `join()` normalizes away
187
+ * `..`, so `${team:../../.ssh}` would quietly resolve to a path OUTSIDE the
188
+ * teams dir — a store value (a team store's own, even) that reads or executes
189
+ * from anywhere on disk while still looking like a team-relative reference.
190
+ * Any `/`, `\` or `..` therefore throws, on the same closed-set footing as an
191
+ * unsatisfiable `${repoRoot}`: `get` surfaces it, `list` degrades that one
192
+ * value to an `expandError`, and no half-expanded path is ever emitted.
193
+ */
194
+ function teamPath(teamsDir: string, name: string): string {
195
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) {
196
+ throw new Error(
197
+ `rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`,
198
+ );
199
+ }
200
+ return join(teamsDir, name);
201
+ }
202
+
203
+ function required(value: string | undefined, name: string, needs: string): string {
204
+ if (value === undefined || value === "") {
205
+ throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`);
206
+ }
207
+ return value;
208
+ }
209
+
210
+ // ─── Store reading ───────────────────────────────────────────────────────────
211
+
212
+ interface StoreBundle {
213
+ user: StoreFile;
214
+ machine: StoreFile;
215
+ /** One per team that has a local settings file, alphabetical (wave 1: overlay all). */
216
+ teams: StoreFile[];
217
+ }
218
+
219
+ function readStores(): StoreBundle {
220
+ return {
221
+ user: readStore(userSettingsPath()),
222
+ machine: readStore(machineSettingsPath()),
223
+ teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team))),
224
+ };
225
+ }
226
+
227
+ // ─── Slots: every rung a key could come from, weakest-first ──────────────────
228
+
229
+ interface Slot {
230
+ scope: Scope;
231
+ file: string | null;
232
+ present: boolean;
233
+ value?: unknown;
234
+ }
235
+
236
+ function collectSlots(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Slot[] {
237
+ const slots: Slot[] = [];
238
+ const identity = opts.repoIdentity ?? null;
239
+ const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== "";
240
+ const repoSection = (store: StoreFile): Record<string, unknown> | undefined =>
241
+ useRepo ? store.repos[identity as string] : undefined;
242
+
243
+ const push = (scope: Scope, file: string | null, section: Record<string, unknown> | undefined) => {
244
+ const value = section?.[def.key];
245
+ if (value === undefined) slots.push({ scope, file, present: false });
246
+ else slots.push({ scope, file, present: true, value });
247
+ };
248
+
249
+ /**
250
+ * Wave 1 overlays EVERY cloned team, alphabetically, so the result is
251
+ * deterministic; multi-team precedence is explicitly deferred (spec: out of
252
+ * scope — one team exists today). With no team cloned at all we still emit
253
+ * one absent rung so `explain` shows the ladder in full.
254
+ */
255
+ const pushTeams = (scope: Scope, section: (store: StoreFile) => Record<string, unknown> | undefined) => {
256
+ if (stores.teams.length === 0) {
257
+ slots.push({ scope, file: null, present: false });
258
+ return;
259
+ }
260
+ for (const store of stores.teams) push(scope, store.file, section(store));
261
+ };
262
+
263
+ // default — cloned so a caller mutating the resolved value cannot corrupt
264
+ // the registry's shared def object.
265
+ slots.push(
266
+ def.default === undefined
267
+ ? { scope: "default", file: null, present: false }
268
+ : { scope: "default", file: null, present: true, value: structuredClone(def.default) },
269
+ );
270
+
271
+ // The ladder itself, weakest → strongest. Repo rungs are omitted entirely
272
+ // when they are unreachable (key not repoScoped, or no identity in hand) —
273
+ // an unreachable rung in `explain` would be noise, not honesty.
274
+ pushTeams("team", (store) => store.global);
275
+ push("user", stores.user.file, stores.user.global);
276
+ if (useRepo) pushTeams("team.repo", repoSection);
277
+ if (useRepo) push("user.repo", stores.user.file, repoSection(stores.user));
278
+ push("machine", stores.machine.file, stores.machine.global);
279
+ if (useRepo) push("machine.repo", stores.machine.file, repoSection(stores.machine));
280
+
281
+ return slots;
282
+ }
283
+
284
+ // ─── Resolution ──────────────────────────────────────────────────────────────
285
+
286
+ interface Resolution {
287
+ value: unknown;
288
+ provenance: Provenance[];
289
+ invalid: InvalidScope[];
290
+ rows: ExplainRow[];
291
+ }
292
+
293
+ const TEAM_LOCKED_SCOPES: Scope[] = ["default", "team", "team.repo"];
294
+
295
+ /** The store a scope's value is authored in — the rung's write-side scope. */
296
+ function baseScope(scope: Scope): SettingScope | null {
297
+ if (scope === "team" || scope === "team.repo") return "team";
298
+ if (scope === "user" || scope === "user.repo") return "user";
299
+ if (scope === "machine" || scope === "machine.repo") return "machine";
300
+ return null; // default is not authored in a store
301
+ }
302
+
303
+ /**
304
+ * The path-literal guard applies to SHARED scopes only — the machine store is
305
+ * the one place path literals are legal.
306
+ */
307
+ function validateForScope(
308
+ def: SettingDef,
309
+ scope: Scope,
310
+ value: unknown,
311
+ ): { ok: true } | { ok: false; reason: string } {
312
+ const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo";
313
+ return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value);
314
+ }
315
+
316
+ function resolveDef(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Resolution {
317
+ const slots = collectSlots(def, stores, opts);
318
+ const rows: ExplainRow[] = [];
319
+ const invalid: InvalidScope[] = [];
320
+ const applied: Array<{ scope: Scope; file: string | null; value: unknown }> = [];
321
+
322
+ for (const slot of slots) {
323
+ const row: ExplainRow = { scope: slot.scope, file: slot.file, present: slot.present };
324
+ if (!slot.present) {
325
+ rows.push(row);
326
+ continue;
327
+ }
328
+ row.value = slot.value;
329
+
330
+ // teamLocked: team.repo > team > default and nothing else. Other scopes'
331
+ // values are reported, never applied.
332
+ if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) {
333
+ row.shadowed = "teamLocked";
334
+ rows.push(row);
335
+ continue;
336
+ }
337
+
338
+ // A key authored in a store its def does not list is not this key.
339
+ const base = baseScope(slot.scope);
340
+ if (base !== null && !def.scopes.includes(base)) {
341
+ const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`;
342
+ row.invalid = reason;
343
+ invalid.push({ scope: slot.scope, file: slot.file, reason });
344
+ rows.push(row);
345
+ continue;
346
+ }
347
+
348
+ // The registry default is trusted; everything read off disk is checked.
349
+ if (slot.scope !== "default") {
350
+ const check = validateForScope(def, slot.scope, slot.value);
351
+ if (!check.ok) {
352
+ row.invalid = check.reason;
353
+ invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason });
354
+ rows.push(row);
355
+ continue;
356
+ }
357
+ }
358
+
359
+ rows.push(row);
360
+ applied.push({ scope: slot.scope, file: slot.file, value: slot.value });
361
+ }
362
+
363
+ const merged = mergeApplied(def, applied);
364
+ return { value: merged.value, provenance: merged.provenance, invalid, rows };
365
+ }
366
+
367
+ function mergeApplied(
368
+ def: SettingDef,
369
+ applied: Array<{ scope: Scope; file: string | null; value: unknown }>,
370
+ ): { value: unknown; provenance: Provenance[] } {
371
+ if (applied.length === 0) return { value: undefined, provenance: [] };
372
+
373
+ // Deep merge is only meaningful for objects; a `deep` def with any other
374
+ // type — or a non-object layer, only reachable through a malformed registry
375
+ // default since every value read off disk is type-checked — falls back to
376
+ // replace rather than inventing semantics for it.
377
+ if (def.merge === "deep" && def.type === "object") {
378
+ const objectLayers = applied.filter((layer) => isPlainObject(layer.value));
379
+ if (objectLayers.length > 0) {
380
+ const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value));
381
+ return {
382
+ value,
383
+ provenance: contributors.map((i) => {
384
+ const layer = objectLayers[i] as (typeof applied)[number];
385
+ return { scope: layer.scope, file: layer.file };
386
+ }),
387
+ };
388
+ }
389
+ }
390
+
391
+ const winner = applied[applied.length - 1] as (typeof applied)[number];
392
+ return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] };
393
+ }
394
+
395
+ // ─── Deep merge with per-leaf attribution ────────────────────────────────────
396
+
397
+ // Leaf paths are joined with NUL so a field name containing a dot cannot
398
+ // collide with a nested path of the same spelling.
399
+ const PATH_SEP = "\u0000";
400
+
401
+ /**
402
+ * Overlays object layers weakest → strongest, tracking which layer owns each
403
+ * surviving leaf. Arrays and scalars replace atomically (an array IS a leaf);
404
+ * objects recurse. `contributors` is the ascending list of layer indexes that
405
+ * still own at least one leaf of the result.
406
+ */
407
+ function deepMerge(layers: unknown[]): { value: Record<string, unknown>; contributors: number[] } {
408
+ const owner = new Map<string, number>();
409
+ let acc: Record<string, unknown> = {};
410
+
411
+ layers.forEach((layer, index) => {
412
+ acc = overlay(acc, layer as Record<string, unknown>, owner, index, "");
413
+ });
414
+
415
+ const contributors = [...new Set(owner.values())].sort((a, b) => a - b);
416
+ return { value: acc, contributors };
417
+ }
418
+
419
+ function overlay(
420
+ base: Record<string, unknown>,
421
+ over: Record<string, unknown>,
422
+ owner: Map<string, number>,
423
+ index: number,
424
+ prefix: string,
425
+ ): Record<string, unknown> {
426
+ const out: Record<string, unknown> = { ...base };
427
+
428
+ for (const [key, value] of Object.entries(over)) {
429
+ const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`;
430
+ const current = out[key];
431
+
432
+ if (isPlainObject(value) && isPlainObject(current)) {
433
+ out[key] = overlay(current, value, owner, index, path);
434
+ continue;
435
+ }
436
+
437
+ out[key] = value;
438
+ clearOwners(owner, path);
439
+ registerLeaves(value, path, owner, index);
440
+ }
441
+
442
+ return out;
443
+ }
444
+
445
+ function clearOwners(owner: Map<string, number>, path: string): void {
446
+ owner.delete(path);
447
+ const under = `${path}${PATH_SEP}`;
448
+ for (const existing of [...owner.keys()]) {
449
+ if (existing.startsWith(under)) owner.delete(existing);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Records ownership at LEAF granularity: an object is walked into so that a
455
+ * stronger layer overriding every one of its fields takes the whole thing over
456
+ * (and the weaker layer correctly drops out of provenance).
457
+ */
458
+ function registerLeaves(value: unknown, path: string, owner: Map<string, number>, index: number): void {
459
+ if (isPlainObject(value)) {
460
+ const entries = Object.entries(value);
461
+ if (entries.length > 0) {
462
+ for (const [key, child] of entries) {
463
+ registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index);
464
+ }
465
+ return;
466
+ }
467
+ }
468
+ owner.set(path, index);
469
+ }
470
+
471
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
472
+ return value !== null && typeof value === "object" && !Array.isArray(value);
473
+ }
474
+
475
+ // ─── Public API ──────────────────────────────────────────────────────────────
476
+
477
+ function unknownKey(key: string): Error {
478
+ return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
479
+ }
480
+
481
+ function expandCtxFrom(opts: ResolveOpts): ExpandCtx {
482
+ return {
483
+ repoRoot: opts.expandCtx?.repoRoot,
484
+ worktree: opts.expandCtx?.worktree,
485
+ home: process.env.HOME ?? homedir(),
486
+ teamsDir: teamsDir(),
487
+ };
488
+ }
489
+
490
+ function warnInvalid(key: string, entry: InvalidScope): void {
491
+ console.warn(
492
+ `rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`,
493
+ );
494
+ }
495
+
496
+ /**
497
+ * Resolves one key across the whole ladder. Throws for an unregistered key —
498
+ * an explicit get of something rt has never heard of is a caller bug, not a
499
+ * degrade (contrast: unknown keys FOUND in files, which only warn).
500
+ */
501
+ export function getSetting<T>(key: string, opts: ResolveOpts = {}): Resolved<T> {
502
+ const def = getDef(key);
503
+ if (!def) throw unknownKey(key);
504
+
505
+ const resolution = resolveDef(def, readStores(), opts);
506
+ for (const entry of resolution.invalid) warnInvalid(key, entry);
507
+
508
+ const shouldExpand = opts.expand ?? true;
509
+ const value =
510
+ shouldExpand && resolution.value !== undefined
511
+ ? expandVariables(resolution.value, expandCtxFrom(opts))
512
+ : resolution.value;
513
+
514
+ return { value: value as T, provenance: resolution.provenance };
515
+ }
516
+
517
+ /**
518
+ * Every registered key resolved (registry order), then every unregistered key
519
+ * found in the stores (alphabetical). Nothing here throws: a survey of the
520
+ * whole settings map must survive one bad value, so an unexpandable value
521
+ * degrades to its raw form plus an `expandError` label.
522
+ */
523
+ export function listSettings(opts: ResolveOpts = {}): ListedSetting[] {
524
+ const stores = readStores();
525
+ const ctx = expandCtxFrom(opts);
526
+ const shouldExpand = opts.expand ?? true;
527
+ const out: ListedSetting[] = [];
528
+
529
+ for (const def of allDefs()) {
530
+ const resolution = resolveDef(def, stores, opts);
531
+ for (const entry of resolution.invalid) warnInvalid(def.key, entry);
532
+
533
+ const listed: ListedSetting = {
534
+ key: def.key,
535
+ value: resolution.value,
536
+ provenance: resolution.provenance,
537
+ migrated: isMigrated(def),
538
+ };
539
+ if (resolution.invalid.length > 0) listed.invalid = resolution.invalid;
540
+
541
+ if (shouldExpand && resolution.value !== undefined) {
542
+ try {
543
+ listed.value = expandVariables(resolution.value, ctx);
544
+ } catch (err) {
545
+ listed.expandError = (err as Error).message;
546
+ console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
547
+ }
548
+ }
549
+
550
+ out.push(listed);
551
+ }
552
+
553
+ out.push(...listUnregistered(stores, opts));
554
+ return out;
555
+ }
556
+
557
+ /**
558
+ * Keys present in a store file that the registry has never heard of. They are
559
+ * never merged (there is no def to say how) — the strongest scope holding one
560
+ * is reported as-is, so a teammate's newer key is visible rather than silently
561
+ * dropped.
562
+ */
563
+ function listUnregistered(stores: StoreBundle, opts: ResolveOpts): ListedSetting[] {
564
+ const identity = opts.repoIdentity ?? null;
565
+ const found = new Map<string, Provenance & { value: unknown }>();
566
+
567
+ const scan = (scope: Scope, file: string, section: Record<string, unknown> | undefined) => {
568
+ for (const [key, value] of Object.entries(section ?? {})) {
569
+ if (getDef(key)) continue;
570
+ found.set(key, { scope, file, value }); // later (stronger) scans win
571
+ }
572
+ };
573
+ const repoSection = (store: StoreFile) =>
574
+ typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined;
575
+
576
+ for (const store of stores.teams) scan("team", store.file, store.global);
577
+ scan("user", stores.user.file, stores.user.global);
578
+ for (const store of stores.teams) scan("team.repo", store.file, repoSection(store));
579
+ scan("user.repo", stores.user.file, repoSection(stores.user));
580
+ scan("machine", stores.machine.file, stores.machine.global);
581
+ scan("machine.repo", stores.machine.file, repoSection(stores.machine));
582
+
583
+ return [...found.entries()]
584
+ .sort(([a], [b]) => a.localeCompare(b))
585
+ .map(([key, hit]) => {
586
+ console.warn(
587
+ `rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`,
588
+ );
589
+ return {
590
+ key,
591
+ value: hit.value,
592
+ provenance: [{ scope: hit.scope, file: hit.file }],
593
+ migrated: false,
594
+ unregistered: true as const,
595
+ };
596
+ });
597
+ }
598
+
599
+ /**
600
+ * One row per reachable rung, weakest-first, with values AS AUTHORED. Repo
601
+ * rungs are omitted entirely when the key is not repoScoped or no identity was
602
+ * supplied — showing rungs that could never apply would be noise, not honesty.
603
+ */
604
+ export function explainSetting(key: string, opts: ResolveOpts = {}): ExplainRow[] {
605
+ const def = getDef(key);
606
+ if (!def) throw unknownKey(key);
607
+ return resolveDef(def, readStores(), opts).rows;
608
+ }