fix(#3760): refuse a legacy-key migration into a non-object config section (#3767)

* test(#3760): failing-first regression for non-object config section

Locks the contract from the issue's Expected section before any fix exists:
a legacy-key section holding a string, number, boolean or array must be
preserved verbatim, reported, and never persisted in an expanded form.

Covers both blocks the issue names (branching_strategy -> git.*, sub_repos ->
planning.*), the migrateOnDisk multiRepo branch that shares the shape, and the
loader write paths that are what actually reach the user's config.json.
Includes the negative-space cases that must keep hoisting ({} , null, absent
section, canonical-nested-wins) and two fast-check properties.

Refs #3760

* fix(#3760): refuse a legacy-key migration into a non-object config section

normalizeLegacyKeys hoisted a legacy top-level key into its canonical nested
section by spreading `result[section] ?? {}`. `??` guards only null and
undefined, so a section holding a string was enumerated by index —
`{...'main'}` is `{0:'m',1:'a',2:'i',3:'n'}` — while a number or boolean
spread to `{}` and the value vanished. Because a fired block always pushed a
Normalization, and every caller treats a non-empty normalizations array as
'config is dirty', that shape was written back to .planning/config.json and
the original value became unrecoverable.

Both blocks the issue names are fixed via one shared hoistLegacyKey helper,
plus the two further sites that share the shape and are reachable from the
same input: migrateOnDisk's multiRepo branch, and the loader's two
`if (!planning) planning = {}` guards, where a non-empty string is truthy and
the following assignment threw a strict-mode TypeError that the enclosing
catch swallowed — discarding the user's entire config.

A present non-object section now blocks its own migration. The section, the
legacy key, and the file are left byte-identical; no Normalization is pushed,
so nothing marks the config dirty; the refusal is reported in-band as
`skipped[]` and out-of-band through the ADR-1411 warnUnusableInput seam
(new frozen reason config_section_not_object). null and undefined keep their
long-standing 'absent' meaning and still create the section.

This is the nested-section analog of the ADR-227 shape check _readConfigFile
already performs on the top-level document: valid JSON is not a config object.
isConfigSection is exported and shared by both modules rather than copied.

Fixes #3760

* fix(#3760): keep the multiRepo marker when planning cannot receive it

Follow-up from the isolated adversarial review, and the same defect class as
the two blocks the issue names — in the block it did not name.

normalizeLegacyKeys block 3 deleted `multiRepo` and pushed a Normalization
before anything consulted the planning section, deferring 'can this section
receive sub_repos?' to the caller that runs filesystem detection. By then the
marker was already gone and the config was already dirty, so with
{"multiRepo":true,"planning":"docs"} the loader wrote the file back with
multiRepo removed, the sub_repos injection silently no-opped against the
string, and no diagnostic was emitted at all. migrateOnDisk warned for the
same input; the ~30-caller loadConfig path did not.

Section validity is knowable from the parsed config alone — detection is only
needed for the VALUE, not for whether the destination can hold it. The refusal
moves into block 3: the marker is kept, no Normalization is pushed, and a
skipped entry is recorded, so all three callers inherit the preservation and
the diagnostic together. The caller-side guards drop to pure narrowing.

Also from review: skipped[] now reports sectionType ('string' | 'number' |
'boolean' | 'array') instead of sectionValue. migrateOnDisk's report is printed
verbatim by `migrate-config`, and this module already masks config values on
the set/unset output path; the type is the whole diagnostic and the value is
still in the file.

And `migrate-config --raw` no longer answers a refused migration with 'No
legacy keys found — config is already canonical.' Legacy keys WERE found and
declined, and the decline is the one thing only the user can fix by hand.

Refs #3760

* fix(#3760): keep configuration.cjs dependency-free; emit from its callers

The remote matrix caught a regression my own change introduced: adding
`require('./unusable-input.cjs')` to configuration.cts broke the #3571
install-layout contract. `configuration.cjs` must load from a layout holding
only itself plus bin/shared/*.manifest.json — the installer does not co-locate
arbitrary siblings — so the new require failed at load time:

  Cannot find module './unusable-input.cjs'
  Require stack:
  - /tmp/gsd-3571-.../.codex/gsd-core/bin/lib/configuration.cjs

pinned by 'co-located bin/shared manifests let configuration.cjs load without
sdk/shared' in tests/install.test.cjs (3 failures).

The contract is deliberate and the test is right, so the module goes back to
zero sibling requires and the out-of-band diagnostic moves to the callers that
already carry a dependency budget and hold the resolved path: cmdMigrateConfig
(config.cts) and loadConfigResolved (config-loader.cts). normalizeLegacyKeys
keeps reporting refusals in-band via skipped[], which is what lets it be pure
and dependency-free at the same time.

The emission-count and dedup assertions move to tests/config-loader.test.cjs,
where the diagnostic now originates. A new assertion pins the inverse for the
module itself — migrateOnDisk must emit ZERO diagnostics while still reporting
skipped[] — so regrowing a sibling require fails a unit test instead of only
the install suite. CONTEXT.md records why the emitter is the caller.

Refs #3760

* chore(#3760): backfill changeset pr number to 3767

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-22 18:57:19 -04:00
committed by GitHub
parent b6977d9d11
commit 3fd03bec4c
9 changed files with 817 additions and 46 deletions

View File

@@ -12,7 +12,8 @@
*
* Dependencies (leaf modules only):
* - node:fs / node:os / node:path (stdlib)
* - ./configuration.cjs (normalizeLegacyKeys, CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
* - ./configuration.cjs (normalizeLegacyKeys, isConfigSection, CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
* - ./unusable-input.cjs (warnUnusableInput, UNUSABLE_REASON — #3760)
* - ./config-schema.cjs (VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS)
* - ./planning-workspace.cjs (planningDir, planningRoot)
* - ./shell-command-projection.cjs (execGit, platformWriteSync, platformReadSync)
@@ -31,11 +32,17 @@ const { planningDir, planningRoot } = planningWorkspace;
import coreUtilsModule = require('./core-utils.cjs');
const { detectSubRepos } = coreUtilsModule;
// ─── Configuration Module (generated CJS mirror) ────────────────────────────
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys, isConfigSection } from './configuration.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES } from './model-catalog.cjs';
// #3760: the ADR-1411 out-of-band diagnostic seam. loadConfig returns `.config`
// alone, so an in-band `skipped` record would be unreachable to nearly every
// caller — "a reason no caller reads is an unreachable field" (ADR-1411).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputModule = require('./unusable-input.cjs');
const { UNUSABLE_REASON: _UNUSABLE_REASON, warnUnusableInput: _warnUnusableInput } = unusableInputModule;
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import federatedConfigModule = require('./federated-config.cjs');
@@ -680,13 +687,25 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
}
if (rootRead.kind !== 'ok') throw new Error('root config absent or unusable');
rootParsed = rootRead.data;
const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed);
const { parsed: rootNormalized, normalizations: rootNorms, skipped: rootSkipped } = normalizeLegacyKeys(rootParsed);
if (rootSkipped.length > 0) {
_warnUnusableInput({ reason: _UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT, source: rootConfigPath });
}
if (rootNorms.length > 0) {
for (const norm of rootNorms as unknown as NormalizationEntry[]) {
if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) {
const detected = getDetectedSubRepos();
if (detected.length > 0) {
if (!(rootNormalized as ParsedConfig).planning) (rootNormalized as ParsedConfig).planning = {};
// #3760: `if (!planning) planning = {}` treated a non-empty STRING as an
// already-present section, and the next line then assigned onto a
// primitive — a strict-mode TypeError the enclosing catch swallowed,
// discarding the user's whole config. `requiresFilesystem` now only
// reaches here when the section is absent or an object (configuration.cts
// block 3 refuses otherwise and reports it via `skipped`), so this
// narrowing chooses between merge and create and never discards.
if (!isConfigSection((rootNormalized as ParsedConfig).planning)) {
(rootNormalized as ParsedConfig).planning = {};
}
(rootNormalized as ParsedConfig).planning!['sub_repos'] = detected;
(rootNormalized as ParsedConfig).planning!['commit_docs'] = false;
}
@@ -720,7 +739,10 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
let configDirty = false;
{
const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData);
const { parsed: normalized, normalizations, skipped } = normalizeLegacyKeys(fileData);
if (skipped.length > 0) {
_warnUnusableInput({ reason: _UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT, source: configPath });
}
if (normalizations.length > 0) {
Object.keys(fileData).forEach(k => delete (fileData as Record<string, unknown>)[k]);
Object.assign(fileData, normalized);
@@ -729,7 +751,8 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
const detected = getDetectedSubRepos();
if (detected.length > 0) {
if (!fileData.planning) fileData.planning = {};
// #3760 — see the identical guard on the root-config path above.
if (!isConfigSection(fileData.planning)) fileData.planning = {};
fileData.planning['sub_repos'] = detected;
fileData.planning['commit_docs'] = false;
}
@@ -744,7 +767,10 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
if (detected.length > 0) {
const sorted = [...currentSubRepos].sort();
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
if (!fileData.planning) fileData.planning = {};
// #3760 — reachable only when `planning` already yielded a non-empty
// sub_repos array, so it is an object here; the narrowing keeps the
// assignment total rather than relying on that from three frames away.
if (!isConfigSection(fileData.planning)) fileData.planning = {};
fileData.planning['sub_repos'] = detected;
configDirty = true;
}

View File

@@ -28,6 +28,13 @@ const { VALID_CONFIG_KEYS, isValidConfigKey, getCapabilityConfigSchema } = confi
import { isSecretKey, maskSecret } from './secrets.cjs';
import { normalizeConfiguredDefaultReviewers, INSTANCE_NAME_PATTERN, KNOWN_REVIEWER_SLUGS } from './review-reviewer-selection.cjs';
import { migrateOnDisk } from './configuration.cjs';
// #3760: the ADR-1411 out-of-band diagnostic. It lives here rather than inside
// `migrateOnDisk` because `configuration.cjs` must stay loadable from an install
// layout holding only itself plus its manifests (#3571) — see the note at the top
// of configuration.cts.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputModule = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputModule;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -1199,14 +1206,38 @@ function cmdMigrateConfig(cwd: string, raw: boolean): void {
const ws = process.env['GSD_WORKSTREAM'] || null;
const report = migrateOnDisk(cwd, ws || undefined);
// #3760: deduplicated on (path, reason), so a repeated invocation stays quiet.
if (report.skipped.length > 0) {
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT,
source: path.join(planningDir(cwd, ws || undefined), 'config.json'),
});
}
if (raw) {
if (!report.migrated) {
// #3760: a refused migration is NOT an already-canonical config. Reporting
// "no legacy keys found" when a legacy key was found and declined would send
// the user away believing there is nothing to fix — and the thing to fix is
// the one thing only they can fix, by hand.
const declined = (report.skipped as Array<{ from: string; to: string; section: string; sectionType: string }>);
const declinedLines = declined.map(
s => ` ${s.from} → ${s.to} SKIPPED: '${s.section}' holds a ${s.sectionType}, not an object`,
);
if (!report.migrated && declined.length === 0) {
const msg = 'No legacy keys found — config is already canonical.';
output(msg, true, msg);
} else if (!report.migrated) {
const lines = [
'Not migrated — every legacy key found was left in place:',
...declinedLines,
'Fix the section by hand, then re-run. Nothing was written.',
].join('\n');
output(lines, true, lines);
} else {
const lines = [
`Migrated: ${String(report.wrote)}`,
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
...declinedLines,
].join('\n');
output(lines, true, lines);
}

View File

@@ -12,6 +12,16 @@
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// ⚠️ DO NOT add a sibling `.cjs` import to this module. `configuration.cjs` must load
// from an install layout containing ONLY itself plus `bin/shared/*.manifest.json` —
// that is the #3571 contract, pinned by "co-located bin/shared manifests let
// configuration.cjs load without sdk/shared" in tests/install.test.cjs. A `require`
// for a sibling that the installer does not co-locate fails at load time with
// MODULE_NOT_FOUND. This is why #3760's out-of-band diagnostic is emitted by this
// module's CALLERS (`cmdMigrateConfig` in config.cts, `loadConfigResolved` in
// config-loader.cts) rather than here: `normalizeLegacyKeys` reports refusals
// in-band via `skipped[]`, which keeps it both pure AND dependency-free.
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
@@ -129,53 +139,167 @@ interface Normalization {
requiresFilesystem?: boolean;
}
/**
* A legacy-key migration that could not run because its destination section is
* present but is not an object.
*
* This is deliberately NOT a `Normalization`. Every caller treats a non-empty
* `normalizations` array as "the config changed, write it back" — reporting a
* refusal there would persist a migration that did not happen. Reporting it
* here keeps the two claims separate: `normalizations` is what changed,
* `skipped` is what was declined and why. (#3760)
*/
interface SkippedNormalization {
/** The legacy top-level key that was left in place. */
from: string;
/** Where it would have gone, had the section been usable. */
to: string;
/** The section key that blocked the migration (`git`, `planning`). */
section: string;
reason: 'non_object_section';
/** The legacy value, preserved. */
value: unknown;
/**
* What the section holds, as a type name — `'string' | 'number' | 'boolean' |
* 'array'` — NOT the value itself.
*
* The type is the whole diagnostic ("this is a string where an object belongs"),
* and the value is still sitting untouched in the file, so echoing it buys
* nothing. It would cost something: `cmdMigrateConfig` prints this report
* verbatim on `gsd-tools migrate-config --json`, and config values are treated
* as potentially secret-bearing elsewhere in this module (`maskSecret` on the
* `config set/unset` output path).
*/
sectionType: string;
}
/** Type name for a report — `'array'` for arrays, otherwise `typeof`. */
function describeSectionType(value: unknown): string {
return Array.isArray(value) ? 'array' : typeof value;
}
interface NormalizeLegacyKeysResult {
parsed: Record<string, unknown>;
normalizations: Normalization[];
/** Migrations declined because the destination section is a non-object (#3760). */
skipped: SkippedNormalization[];
}
interface MigrateOnDiskResult {
migrated: boolean;
normalizations: Normalization[];
wrote: string | null;
/** Migrations declined because the destination section is a non-object (#3760). */
skipped: SkippedNormalization[];
}
/**
* Is `value` usable as a config SECTION — something a nested key can be written into?
*
* Three cases, and the distinction between the second and third is the whole of #3760:
*
* - `null` / `undefined` — ABSENT. Not a section yet, but nothing is lost by creating one.
* Callers substitute `{}`; this predicate reports `false` and callers check for absence
* separately, so the two are never conflated.
* - a non-null, non-array `object` — a SECTION. Spreading it is safe and correct.
* - anything else (string, number, boolean, array) — a PRESENT NON-OBJECT, i.e. user data
* that a spread would destroy. `{...'main'}` is `{0:'m',1:'a',2:'i',3:'n'}`; `{...7}` is
* `{}`. An array is included here because `typeof [] === 'object'` and `{...['a']}` is
* `{0:'a'}` — the identical expansion a bare `typeof` guard would wave through.
*
* This is the nested-section analog of the top-level shape check ADR-227 already requires
* in `_readConfigFile` (`config-loader.cts`): valid JSON is not the same as a config object.
* It lives here, exported, rather than being copied into `config-loader.cts`, because two
* hand-rolled copies of one predicate is `DEFECT.GENERATIVE-FIX` by construction — the same
* reasoning that made `unusable-input.cts` a shared seam.
*/
function isConfigSection(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// ─── Exported functions ───────────────────────────────────────────────────────
/**
* Hoist a legacy top-level key into its canonical nested section.
*
* Refuses — and reports the refusal — when the destination section is present but is not an
* object. Refusing means leaving BOTH the section and the legacy key exactly as they were and
* pushing no `Normalization`, so this block never marks the config dirty and nothing it
* touched can be written back. That is the #3760 contract: never expand, never drop, never
* persist a shape the user did not write.
*/
function hoistLegacyKey(
result: Record<string, unknown>,
normalizations: Normalization[],
skipped: SkippedNormalization[],
legacyKey: string,
section: string,
field: string,
): void {
const raw = result[section];
// `null`/`undefined` mean "no section yet" and have always been treated as absent here.
// They carry no user data, so creating the section loses nothing.
const absent = raw === null || raw === undefined;
if (!absent && !isConfigSection(raw)) {
skipped.push({
from: legacyKey,
to: `${section}.${field}`,
section,
reason: 'non_object_section',
value: result[legacyKey],
sectionType: describeSectionType(raw),
});
return;
}
// `raw` is already narrowed by the isConfigSection guard above.
const existing: Record<string, unknown> = absent ? {} : raw;
const value = result[legacyKey];
// Canonical nested wins when it is already set; otherwise the legacy value is hoisted.
result[section] = existing[field] === undefined
? { ...existing, [field]: value }
: { ...existing };
delete result[legacyKey];
normalizations.push({ from: legacyKey, to: `${section}.${field}`, value });
}
function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizeLegacyKeysResult {
const result: Record<string, unknown> = { ...parsed };
const normalizations: Normalization[] = [];
const skipped: SkippedNormalization[] = [];
// 1. branching_strategy → git.branching_strategy
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
const value = result['branching_strategy'];
const git = (result['git'] ?? {}) as Record<string, unknown>;
if (git['branching_strategy'] === undefined) {
result['git'] = { ...git, branching_strategy: value };
}
else {
// canonical nested wins — just delete the stale top-level
result['git'] = { ...git };
}
delete result['branching_strategy'];
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
hoistLegacyKey(result, normalizations, skipped, 'branching_strategy', 'git', 'branching_strategy');
}
// 2. top-level sub_repos → planning.sub_repos
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
const value = result['sub_repos'];
const planning = (result['planning'] ?? {}) as Record<string, unknown>;
if (planning['sub_repos'] === undefined) {
result['planning'] = { ...planning, sub_repos: value };
}
else {
// canonical nested wins — just drop the stale top-level
result['planning'] = { ...planning };
}
delete result['sub_repos'];
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
hoistLegacyKey(result, normalizations, skipped, 'sub_repos', 'planning', 'sub_repos');
}
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
if (result['multiRepo'] === true) {
delete result['multiRepo'];
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
// #3760: refuse here too, for the same reason as blocks 1 and 2 — and it must be
// decided HERE, not in the caller. The caller is the one that runs filesystem
// detection, but whether `planning` can receive the result is knowable from the
// parsed config alone. Deciding it later meant `multiRepo` had already been
// deleted and a Normalization already pushed: the config was written, the
// sub_repos injection silently no-opped against the non-object section, and the
// user's `multiRepo: true` was consumed with nothing to show for it and no
// diagnostic. Refusing here keeps the marker, keeps the config clean of a
// migration that did not happen, and gives all three callers the same report.
const planning = result['planning'];
if (planning !== null && planning !== undefined && !isConfigSection(planning)) {
skipped.push({
from: 'multiRepo',
to: 'planning.sub_repos',
section: 'planning',
reason: 'non_object_section',
value: true,
sectionType: describeSectionType(planning),
});
}
else {
delete result['multiRepo'];
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
}
}
// 4. top-level depth → granularity
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
@@ -185,7 +309,7 @@ function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizeLegacyKe
delete result['depth'];
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
}
return { parsed: result, normalizations };
return { parsed: result, normalizations, skipped };
}
function mergeDefaults(parsed: Record<string, unknown>): Record<string, unknown> {
@@ -202,11 +326,11 @@ function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
}
catch {
// File missing — nothing to migrate
return { migrated: false, normalizations: [], wrote: null };
return { migrated: false, normalizations: [], wrote: null, skipped: [] };
}
const trimmed = raw.trim();
if (trimmed === '') {
return { migrated: false, normalizations: [], wrote: null };
return { migrated: false, normalizations: [], wrote: null, skipped: [] };
}
let parsed: unknown;
try {
@@ -214,23 +338,30 @@ function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
}
catch {
// Malformed — can't migrate
return { migrated: false, normalizations: [], wrote: null };
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed as Record<string, unknown>);
if (normalizations.length === 0) {
return { migrated: false, normalizations: [], wrote: null };
return { migrated: false, normalizations: [], wrote: null, skipped: [] };
}
const { parsed: normalized, normalizations, skipped } = normalizeLegacyKeys(parsed as Record<string, unknown>);
// Resolve multiRepo filesystem detection
const result: Record<string, unknown> = { ...normalized };
for (const norm of normalizations) {
if (norm.requiresFilesystem) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
const planning = (result['planning'] ?? {}) as Record<string, unknown>;
result['planning'] = { ...planning, sub_repos: detected, commit_docs: false };
// #3760: `requiresFilesystem` is pushed by block 3 only when `planning` was
// absent or an object, and nothing between there and here changes it — so
// `isConfigSection` picks between merge and create, and never has to discard.
const planning = result['planning'];
const existing = isConfigSection(planning) ? planning : {};
result['planning'] = { ...existing, sub_repos: detected, commit_docs: false };
}
}
}
if (normalizations.length === 0) {
// Nothing changed — and that now includes the case where every legacy key was
// REFUSED. Returning early here is what keeps the corrupted-section input from
// reaching writeFileSync at all.
return { migrated: false, normalizations: [], wrote: null, skipped };
}
try {
writeFileSync(configPath, JSON.stringify(result, null, 2));
}
@@ -238,13 +369,14 @@ function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
}
return { migrated: true, normalizations, wrote: configPath };
return { migrated: true, normalizations, wrote: configPath, skipped };
}
export {
normalizeLegacyKeys,
mergeDefaults,
migrateOnDisk,
isConfigSection,
CONFIG_DEFAULTS,
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,

View File

@@ -78,6 +78,18 @@ const UNUSABLE_REASON = Object.freeze({
* planning-snapshot's projectSections field)
*/
PROJECT_UNREADABLE: 'project_unreadable',
/**
* A config.json parsed cleanly, but a section that a legacy-key migration needed to write
* into (`git`, `planning`) holds something that is not an object — a string, number, boolean
* or array. Distinct from the section being absent: absence is the ordinary path and the
* migration simply creates the section. Only a PRESENT non-object is corruption, and it is
* exactly the ADR-227 "shape, not just parseability" class one level down from the top-level
* document check `_readConfigFile` already performs. The migration is refused rather than
* applied — spreading the value would enumerate a string into character keys, and a number
* or boolean into nothing at all — so the section, the legacy key, and the file on disk are
* all left exactly as the user wrote them. (#3760, tenth #1879 site)
*/
CONFIG_SECTION_NOT_OBJECT: 'config_section_not_object',
} as const);
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
@@ -96,6 +108,8 @@ const REASON_PROSE: Readonly<Record<UnusableReason, string>> = Object.freeze({
'config.json exists but could not be read or parsed; the config field fell back to unavailable',
[UNUSABLE_REASON.PROJECT_UNREADABLE]:
'PROJECT.md exists but could not be read; the projectSections field fell back to unavailable',
[UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT]:
'a config section that a legacy-key migration targets holds a non-object value; the migration was SKIPPED and both the section and the legacy key were left unchanged — fix the section by hand, or the legacy key will never migrate',
});
// ─── Dedup state ──────────────────────────────────────────────────────────────