feat(errors): P4 typed-error taxonomy adoption (0->91% locked) + erasable-syntax fix

Epic P4. Migrates plain throw new Error to the typed-error classes in the four
locked subdomains, and makes the taxonomy erasable-syntax-compatible so it can
be adopted across UI-reachable code.

Migration (cliproxy/auth, web-server/routes, auth):
- 21 of 23 throws in the locked subdomains now use typed subclasses
  (ProfileError, AuthError, ConfigError, ValidationError, ProviderError).
- Typed adoption in locked subdomains: 0/23 -> 21/23 (91.3%), > 40% target.
- Overall typed adoption: 0.9% -> 8.6%.
- Messages preserved exactly (message-based tests stable). Exit codes now
  differentiate via handleError (ProfileError=7, AuthError=4, ConfigError=2,
  ProviderError=6). ccs doctor 0/1 contract untouched (outside scope).

Erasable-syntax fix (unblocks the migration in the UI build graph):
- exit-codes.ts: enum ExitCode -> const object + union type (value and type
  usage both preserved; no Object.values(ExitCode) consumers).
- error-types.ts: constructor parameter properties -> explicit readonly field
  declarations + body assignment.
- The web UI build enforces erasableSyntaxOnly (ui/tsconfig.app.json) and
  reaches src/errors via the @shared -> src/auth graph; pre-erasable
  error-types blocked the build once profile-registry adopted typed errors.

Compat audit: docs/reports/typed-error-exit-code-compat-audit.md (Q1 resolved:
migrate freely; only documented contract is ccs doctor, which is untouched).
Behavior-lock: src/errors/__tests__/typed-error-migration-exit-codes.test.ts
(taxonomy -> exit-code mapping, instanceof chains, context fields).

validate + validate:ci-parity green (incl. UI build).
This commit is contained in:
Tam Nhu Tran committed 2026-06-18 18:48:12 -04:00
1 parent 87aeb8f193
commit 7234ef8fcf
15 files changed
+256 -99

No files matched your search

+13 -12
View File
@@ -13,6 +13,7 @@ import {
mutateConfig,
} from '../config/config-loader-facade';
import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy';
import { ConfigError, ProfileError } from '../errors/error-types';
const logger = createLogger('auth:profile-registry');
@@ -132,7 +133,7 @@ export class ProfileRegistry {
return JSON.parse(data) as ProfileData;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to read profiles: ${message}`);
throw new ConfigError(`Failed to read profiles: ${message}`, this.profilesPath);
}
}
@@ -159,7 +160,7 @@ export class ProfileRegistry {
fs.unlinkSync(tempPath);
}
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to write profiles: ${message}`);
throw new ConfigError(`Failed to write profiles: ${message}`, this.profilesPath);
}
}
@@ -170,7 +171,7 @@ export class ProfileRegistry {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
throw new ProfileError(`Profile already exists: ${name}`, name);
}
// v3.0 minimal schema: only essential fields
@@ -203,7 +204,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
@@ -216,7 +217,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
data.profiles[name] = this.normalizeLegacyProfileMetadata({
@@ -234,7 +235,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
delete data.profiles[name];
@@ -287,7 +288,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
data.default = name;
@@ -330,7 +331,7 @@ export class ProfileRegistry {
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
mutateConfig((config) => {
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
throw new ProfileError(`Account already exists: ${name}`, name);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
@@ -350,7 +351,7 @@ export class ProfileRegistry {
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
@@ -365,7 +366,7 @@ export class ProfileRegistry {
removeAccountUnified(name: string): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
delete config.accounts[name];
if (config.default === name) {
@@ -382,7 +383,7 @@ export class ProfileRegistry {
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
config.default = name;
});
@@ -434,7 +435,7 @@ export class ProfileRegistry {
touchAccountUnified(name: string): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);