* 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user