From 2f94f35ec35bc3710c3ab1d6f1019f704e58d4c7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 18 Jun 2026 18:48:13 -0400 Subject: [PATCH] feat(lint): P7 enforcement gates (no-new-throw-error + max-lines) + docs Epic P7. Locks in the epic's gains with ESLint gates so the adoption work does not regress. - ccs/no-new-throw-error (error): custom flat-config rule that flags NEW throw new Error(...) outside a generated baseline allowlist. Forces the typed-error taxonomy (src/errors/error-types.ts). Existing 338 sites are grandfathered in eslint-rules/throw-error-baseline.json; only NEW violations error. Rule normalizes the filename to repo-root-relative to match baseline keys regardless of how ESLint reports paths. - max-lines (warn, 400, skipBlankLines/skipComments): warns on files over 400 LOC (goal of P5/P6 god-file splits). Currently 51 warnings on the not-yet-split god-files (P6 territory). - scripts/generate-throw-error-baseline.js: emits the allowlist from raw source (superset of real throws; never undercounts). Run after intentionally grandfathering a site, or quarterly to prune. - tests/unit/eslint-rules/no-throw-new-error.test.ts: rule logic (flags off-allowlist, passes on-allowlist/typed/rethrow, line-sensitivity). - docs/code-standards.md: Lint Enforcement Gates section. - docs/logging-contract.md: error.code -> ExitCode table (from P4). validate + validate:ci-parity green. --- docs/code-standards.md | 12 + docs/logging-contract.md | 19 + eslint-rules/no-new-throw-error.js | 82 +++++ eslint-rules/throw-error-baseline.json | 340 ++++++++++++++++++ eslint.config.mjs | 19 + scripts/generate-throw-error-baseline.js | 103 ++++++ .../eslint-rules/no-throw-new-error.test.ts | 67 ++++ 7 files changed, 642 insertions(+) create mode 100644 eslint-rules/no-new-throw-error.js create mode 100644 eslint-rules/throw-error-baseline.json create mode 100644 scripts/generate-throw-error-baseline.js create mode 100644 tests/unit/eslint-rules/no-throw-new-error.test.ts diff --git a/docs/code-standards.md b/docs/code-standards.md index 6c71122f..459c7330 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -730,6 +730,18 @@ This pattern is used in: --- +## Lint Enforcement Gates + +Two ESLint gates (`eslint.config.mjs`) lock in the maintainability epic's gains: + +- **`ccs/no-new-throw-error`** (error): flags new `throw new Error(...)`. Use a typed error from `src/errors/error-types.ts` (`AuthError`, `ConfigError`, `ProfileError`, `ProviderError`, `NetworkError`, `ProxyError`, `MigrationError`, `ValidationError`, `RetryableError`) so `handleError` emits a differentiated exit code. Existing ~340 sites are grandfathered in `eslint-rules/throw-error-baseline.json`; only **new** violations error. Regenerate the baseline when intentionally grandfathering a new site, or quarterly to prune converted entries: + ```bash + node scripts/generate-throw-error-baseline.js + ``` +- **`max-lines`** (warn, 400): warns on source files over 400 lines (`skipBlankLines`, `skipComments`). Split via the Monster File Splitting methodology above (barrel `index.ts` preserves the public API). + +When the no-throw rule blocks a change, prefer converting to the matching typed error. Only add to the baseline when the throw is genuinely out of scope to convert (and regenerate the baseline so the entry is explicit, not silent). + ## Related Documentation - [Codebase Summary](./codebase-summary.md) - Full directory structure diff --git a/docs/logging-contract.md b/docs/logging-contract.md index 8eebf58a..b51c68a9 100644 --- a/docs/logging-contract.md +++ b/docs/logging-contract.md @@ -146,6 +146,25 @@ Use `stage()` whenever the entry corresponds to one of the canonical lifecycle s Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`. +## `error.code` values (exit codes) + +Typed errors (`src/errors/error-types.ts`) carry an `ExitCode` that `handleError` propagates to `process.exit`. Log readers can branch on `error.code` for differentiated handling. The full mapping lives in `src/errors/exit-codes.ts`; the per-class assignment: + +| Typed class | ExitCode | Value | +|---|---|---:| +| `ConfigError` | `CONFIG_ERROR` | 2 | +| `NetworkError` | `NETWORK_ERROR` | 3 (recoverable) | +| `AuthError` | `AUTH_ERROR` | 4 | +| `BinaryError` | `BINARY_ERROR` | 5 | +| `ProviderError` | `PROVIDER_ERROR` | 6 (recoverable) | +| `ProfileError` | `PROFILE_ERROR` | 7 | +| `ProxyError` | `PROXY_ERROR` | 8 | +| `MigrationError` | `MIGRATION_ERROR` | 9 | +| `UserAbortError` | `USER_ABORT` | 130 | +| `ValidationError`, `RetryableError` | `GENERAL_ERROR` | 1 | + +New throws must use a typed class (enforced by `ccs/no-new-throw-error`, see `docs/code-standards.md`). Redaction scrubs credential token shapes in both context values and message strings, so routing errors into the logger is safe — but keep messages clean prose and put sensitive data in context under a sensitive key (auto-redacted). + ## Backward Compatibility - All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them. diff --git a/eslint-rules/no-new-throw-error.js b/eslint-rules/no-new-throw-error.js new file mode 100644 index 00000000..89678c79 --- /dev/null +++ b/eslint-rules/no-new-throw-error.js @@ -0,0 +1,82 @@ +/** + * Custom ESLint rule: disallow `throw new Error(...)` outside a baseline allowlist. + * + * P7 enforcement gate. Forces new error sites to use the typed-error taxonomy + * (src/errors/error-types.ts: AuthError, ConfigError, ProfileError, ProviderError, + * ...) so handleError emits differentiated exit codes. Existing ~400 sites are + * grandfathered via a generated baseline (scripts/generate-throw-error-baseline.js + * -> eslint-rules/throw-error-baseline.json); only NEW violations are reported. + * + * Detects `throw new Error(...)` (NewExpression with callee name 'Error'). + * Typed subclasses (throw new ConfigError(...)) and re-throws are allowed. + * + * Option: { allowlist: string[] } — entries are `${relativePath}:${line}` keys. + * The relative path matches ESLint's context filename (relative to the repo root + * when eslint is invoked from the root). Line drift after edits above an + * allowlisted site causes a false positive until the baseline is regenerated; + * quarterly pruning keeps it accurate. + */ + +'use strict'; + +const path = require('path'); + +function isNewErrorExpression(node) { + return ( + node !== null && + node !== undefined && + node.type === 'NewExpression' && + node.callee !== null && + node.callee !== undefined && + node.callee.type === 'Identifier' && + node.callee.name === 'Error' + ); +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow throw new Error(...) outside the baseline; use a typed error from src/errors/error-types.ts.', + }, + schema: [ + { + type: 'object', + properties: { + allowlist: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], + messages: { + unexpected: + "Unexpected throw new Error(...). Use a typed error from src/errors/error-types.ts (AuthError, ConfigError, ProfileError, ProviderError, ...) so handleError emits a differentiated exit code, or regenerate the baseline via 'node scripts/generate-throw-error-baseline.js'.", + }, + }, + create(context) { + const options = context.options[0] || {}; + const allowlist = new Set(options.allowlist || []); + + return { + ThrowStatement(node) { + if (!isNewErrorExpression(node.argument)) { + return; + } + const filename = context.getFilename(); + // Normalize to a repo-root-relative path so the baseline keys (which are + // relative, e.g. 'src/auth/profile-registry.ts') match regardless of + // whether ESLint reports an absolute or relative filename. + const normalized = path.isAbsolute(filename) + ? path.relative(process.cwd(), filename) + : filename; + const line = node.loc && node.loc.start ? node.loc.start.line : -1; + const key = `${normalized}:${line}`; + if (allowlist.has(key)) { + return; + } + context.report({ node, messageId: 'unexpected' }); + }, + }; + }, +}; diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json new file mode 100644 index 00000000..62680ec3 --- /dev/null +++ b/eslint-rules/throw-error-baseline.json @@ -0,0 +1,340 @@ +[ + "src/api/services/local-runtime-readiness.ts:68", + "src/api/services/openrouter-catalog.ts:72", + "src/api/services/profile-lifecycle-service.ts:127", + "src/api/services/profile-lifecycle-service.ts:73", + "src/api/services/profile-lifecycle-service.ts:88", + "src/api/services/profile-writer.ts:364", + "src/api/services/profile-writer.ts:415", + "src/bin/ccsxp-runtime.ts:90", + "src/channels/official-channels-store.ts:291", + "src/channels/official-channels-store.ts:296", + "src/channels/official-channels-store.ts:299", + "src/cliproxy/accounts/drain-order.ts:131", + "src/cliproxy/accounts/drain-order.ts:139", + "src/cliproxy/accounts/drain-order.ts:246", + "src/cliproxy/accounts/drain-order.ts:253", + "src/cliproxy/accounts/drain-order.ts:279", + "src/cliproxy/accounts/drain-order.ts:342", + "src/cliproxy/accounts/registry.ts:521", + "src/cliproxy/accounts/registry.ts:536", + "src/cliproxy/accounts/registry.ts:544", + "src/cliproxy/accounts/registry.ts:589", + "src/cliproxy/accounts/registry.ts:597", + "src/cliproxy/accounts/registry.ts:755", + "src/cliproxy/accounts/registry.ts:770", + "src/cliproxy/ai-providers/managed-model-prefixes.ts:54", + "src/cliproxy/ai-providers/managed-model-prefixes.ts:71", + "src/cliproxy/ai-providers/managed-model-prefixes.ts:81", + "src/cliproxy/ai-providers/openai-compat-manager.ts:139", + "src/cliproxy/ai-providers/openai-compat-manager.ts:167", + "src/cliproxy/ai-providers/openai-compat-manager.ts:172", + "src/cliproxy/ai-providers/service.ts:242", + "src/cliproxy/ai-providers/service.ts:247", + "src/cliproxy/ai-providers/service.ts:254", + "src/cliproxy/ai-providers/service.ts:257", + "src/cliproxy/ai-providers/service.ts:260", + "src/cliproxy/ai-providers/service.ts:266", + "src/cliproxy/binary/installer.ts:127", + "src/cliproxy/binary/installer.ts:55", + "src/cliproxy/binary/installer.ts:74", + "src/cliproxy/binary/installer.ts:88", + "src/cliproxy/binary/lifecycle.ts:139", + "src/cliproxy/binary/platform-detector.ts:153", + "src/cliproxy/binary/platform-detector.ts:160", + "src/cliproxy/binary/verifier.ts:55", + "src/cliproxy/binary/version-checker.ts:108", + "src/cliproxy/config/base-config-loader.ts:54", + "src/cliproxy/config/base-config-loader.ts:66", + "src/cliproxy/config/base-config-loader.ts:81", + "src/cliproxy/config/base-config-loader.ts:91", + "src/cliproxy/config/env-builder.ts:1005", + "src/cliproxy/config/env-builder.ts:700", + "src/cliproxy/executor/auth-coordinator.ts:176", + "src/cliproxy/executor/auth-coordinator.ts:317", + "src/cliproxy/executor/browser-launch-setup.ts:120", + "src/cliproxy/executor/index.ts:378", + "src/cliproxy/executor/lifecycle-manager.ts:129", + "src/cliproxy/executor/lifecycle-manager.ts:162", + "src/cliproxy/executor/lifecycle-manager.ts:59", + "src/cliproxy/executor/proxy-resolver.ts:142", + "src/cliproxy/executor/proxy-resolver.ts:157", + "src/cliproxy/executor/proxy-resolver.ts:163", + "src/cliproxy/executor/proxy-resolver.ts:177", + "src/cliproxy/executor/session-bridge.ts:147", + "src/cliproxy/provider-capabilities.ts:257", + "src/cliproxy/proxy/https-tunnel-proxy.ts:61", + "src/cliproxy/routing/routing-strategy.ts:480", + "src/cliproxy/routing/routing-strategy.ts:488", + "src/cliproxy/routing/routing-strategy.ts:708", + "src/cliproxy/services/remote-auth-fetcher.ts:139", + "src/cliproxy/services/remote-auth-fetcher.ts:150", + "src/cliproxy/services/remote-auth-fetcher.ts:152", + "src/cliproxy/services/remote-auth-fetcher.ts:159", + "src/cliproxy/services/remote-auth-fetcher.ts:165", + "src/cliproxy/services/remote-auth-fetcher.ts:168", + "src/cliproxy/services/startup-lock.ts:208", + "src/cliproxy/services/variant-config-adapter.ts:72", + "src/cliproxy/services/variant-service.ts:371", + "src/cliproxy/services/variant-service.ts:446", + "src/codex-auth/codex-auth-dashboard-service.ts:108", + "src/codex-auth/codex-auth-dashboard-service.ts:84", + "src/codex-auth/codex-profile-paths.ts:17", + "src/codex-auth/codex-profile-paths.ts:26", + "src/codex-auth/codex-profile-registry.ts:204", + "src/codex-auth/codex-profile-registry.ts:232", + "src/codex-auth/codex-profile-registry.ts:277", + "src/codex-auth/codex-profile-registry.ts:29", + "src/codex-auth/codex-profile-registry.ts:295", + "src/codex-auth/codex-profile-registry.ts:305", + "src/codex-auth/codex-profile-registry.ts:317", + "src/codex-auth/codex-profile-registry.ts:320", + "src/codex-auth/codex-profile-registry.ts:34", + "src/codex-auth/codex-profile-registry.ts:351", + "src/codex-auth/codex-profile-registry.ts:37", + "src/codex-auth/codex-profile-registry.ts:52", + "src/codex-auth/codex-profile-registry.ts:65", + "src/codex-auth/codex-profile-registry.ts:71", + "src/codex-auth/codex-profile-registry.ts:75", + "src/codex-auth/codex-profile-registry.ts:78", + "src/codex-auth/codex-profile-registry.ts:81", + "src/codex-auth/codex-profile-registry.ts:86", + "src/codex-auth/codex-profile-registry.ts:93", + "src/codex-auth/codex-profile-registry.ts:96", + "src/codex-auth/commands/import-default-command.ts:134", + "src/codex-auth/commands/import-default-command.ts:146", + "src/codex-auth/commands/import-default-command.ts:167", + "src/commands/bar/install-subcommand.ts:137", + "src/commands/bar/install-subcommand.ts:141", + "src/commands/bar/install-subcommand.ts:149", + "src/commands/bar/install-subcommand.ts:172", + "src/commands/bar/install-subcommand.ts:181", + "src/commands/bar/install-subcommand.ts:187", + "src/commands/bar/install-subcommand.ts:228", + "src/commands/bar/install-subcommand.ts:249", + "src/commands/bar/install-subcommand.ts:259", + "src/commands/bar/install-subcommand.ts:271", + "src/commands/bar/install-subcommand.ts:289", + "src/commands/bar/install-subcommand.ts:316", + "src/commands/bar/launch-subcommand.ts:212", + "src/commands/config-channels-command.ts:431", + "src/commands/config-channels-command.ts:436", + "src/commands/config-channels-command.ts:447", + "src/commands/persist-command/arg-parsing.ts:31", + "src/commands/persist-command/backup-rotation.ts:226", + "src/commands/persist-command/backup-rotation.ts:244", + "src/commands/persist-command/backup-rotation.ts:249", + "src/commands/persist-command/backup-rotation.ts:86", + "src/commands/persist-command/handler.ts:166", + "src/commands/persist-command/handler.ts:38", + "src/commands/persist-command/secure-file.ts:104", + "src/commands/persist-command/secure-file.ts:120", + "src/commands/persist-command/secure-file.ts:142", + "src/commands/persist-command/secure-file.ts:172", + "src/commands/persist-command/secure-file.ts:174", + "src/commands/persist-command/secure-file.ts:182", + "src/commands/persist-command/secure-file.ts:98", + "src/commands/proxy-command.ts:89", + "src/commands/setup-command.ts:218", + "src/config/loader/io-locks.ts:205", + "src/config/loader/io-locks.ts:241", + "src/config/loader/io-locks.ts:295", + "src/config/loader/io-locks.ts:297", + "src/config/reserved-names.ts:92", + "src/config/unified-config-loader.ts:149", + "src/copilot/copilot-package-manager.ts:444", + "src/copilot/copilot-package-manager.ts:467", + "src/cursor/cursor-anthropic-translator.ts:113", + "src/cursor/cursor-anthropic-translator.ts:119", + "src/cursor/cursor-anthropic-translator.ts:129", + "src/cursor/cursor-anthropic-translator.ts:149", + "src/cursor/cursor-anthropic-translator.ts:169", + "src/cursor/cursor-anthropic-translator.ts:174", + "src/cursor/cursor-anthropic-translator.ts:197", + "src/cursor/cursor-anthropic-translator.ts:21", + "src/cursor/cursor-anthropic-translator.ts:44", + "src/cursor/cursor-anthropic-translator.ts:51", + "src/cursor/cursor-anthropic-translator.ts:94", + "src/cursor/cursor-client-policy.ts:33", + "src/cursor/cursor-client-policy.ts:81", + "src/cursor/cursor-client-policy.ts:85", + "src/cursor/cursor-daemon-entry.ts:148", + "src/cursor/cursor-daemon-entry.ts:153", + "src/cursor/cursor-daemon-entry.ts:158", + "src/cursor/cursor-daemon-entry.ts:168", + "src/cursor/cursor-executor.ts:206", + "src/cursor/cursor-protobuf.ts:50", + "src/cursor/cursor-translator.ts:189", + "src/delegation/headless-executor.ts:107", + "src/delegation/headless-executor.ts:122", + "src/delegation/headless-executor.ts:251", + "src/delegation/headless-executor.ts:654", + "src/dispatcher/cli-argument-parser.ts:310", + "src/dispatcher/cli-argument-parser.ts:314", + "src/dispatcher/cli-argument-parser.ts:324", + "src/dispatcher/cli-argument-parser.ts:328", + "src/dispatcher/flows/default-flow.ts:76", + "src/dispatcher/flows/settings-flow.ts:132", + "src/docker/docker-assets.ts:35", + "src/docker/docker-executor.ts:154", + "src/docker/docker-executor.ts:435", + "src/glmt/delta-accumulator.ts:154", + "src/glmt/delta-accumulator.ts:196", + "src/glmt/delta-accumulator.ts:216", + "src/glmt/delta-accumulator.ts:304", + "src/glmt/glmt-transformer.ts:92", + "src/glmt/sse-parser.ts:130", + "src/glmt/sse-parser.ts:73", + "src/management/instance-manager.ts:133", + "src/management/profile-context-sync-lock.ts:162", + "src/management/profile-context-sync-lock.ts:232", + "src/proxy/proxy-daemon-entry.ts:67", + "src/proxy/proxy-daemon-entry.ts:78", + "src/proxy/proxy-daemon-entry.ts:88", + "src/proxy/proxy-daemon.ts:98", + "src/proxy/transformers/request-transformer.ts:141", + "src/proxy/transformers/request-transformer.ts:182", + "src/proxy/transformers/request-transformer.ts:189", + "src/proxy/transformers/request-transformer.ts:242", + "src/proxy/transformers/request-transformer.ts:259", + "src/proxy/transformers/request-transformer.ts:278", + "src/proxy/transformers/request-transformer.ts:344", + "src/proxy/transformers/request-transformer.ts:360", + "src/proxy/transformers/request-transformer.ts:370", + "src/proxy/transformers/request-transformer.ts:390", + "src/proxy/transformers/request-transformer.ts:428", + "src/proxy/transformers/request-transformer.ts:439", + "src/proxy/transformers/request-transformer.ts:443", + "src/proxy/transformers/request-transformer.ts:459", + "src/proxy/transformers/request-transformer.ts:468", + "src/proxy/transformers/request-transformer.ts:487", + "src/proxy/transformers/request-transformer.ts:493", + "src/proxy/transformers/request-transformer.ts:528", + "src/proxy/transformers/request-transformer.ts:533", + "src/proxy/transformers/request-transformer.ts:538", + "src/proxy/transformers/request-transformer.ts:543", + "src/proxy/transformers/request-transformer.ts:561", + "src/proxy/transformers/request-transformer.ts:566", + "src/proxy/transformers/request-transformer.ts:573", + "src/proxy/transformers/request-transformer.ts:582", + "src/proxy/transformers/request-transformer.ts:633", + "src/proxy/transformers/request-transformer.ts:639", + "src/proxy/transformers/request-transformer.ts:644", + "src/proxy/transformers/request-transformer.ts:665", + "src/proxy/upstream-url.ts:28", + "src/shared/claude-extension-setup.ts:240", + "src/shared/claude-extension-setup.ts:247", + "src/shared/claude-extension-setup.ts:257", + "src/shared/claude-extension-setup.ts:317", + "src/shared/provider-preset-catalog.ts:308", + "src/shared/provider-preset-catalog.ts:311", + "src/shared/provider-preset-catalog.ts:320", + "src/shared/provider-preset-catalog.ts:323", + "src/shared/provider-preset-catalog.ts:326", + "src/shared/provider-preset-catalog.ts:331", + "src/shared/provider-preset-catalog.ts:334", + "src/shared/toml-object.ts:23", + "src/targets/codex-adapter.ts:103", + "src/targets/codex-adapter.ts:275", + "src/targets/codex-adapter.ts:319", + "src/targets/codex-adapter.ts:326", + "src/targets/codex-adapter.ts:363", + "src/targets/codex-cliproxy-provider-config.ts:138", + "src/targets/codex-cliproxy-provider-config.ts:141", + "src/targets/codex-cliproxy-provider-config.ts:151", + "src/targets/codex-cliproxy-provider-config.ts:177", + "src/targets/droid-adapter.ts:32", + "src/targets/droid-adapter.ts:35", + "src/targets/droid-adapter.ts:68", + "src/targets/droid-adapter.ts:74", + "src/targets/droid-config-manager.ts:335", + "src/targets/droid-config-manager.ts:34", + "src/targets/droid-config-manager.ts:354", + "src/targets/droid-config-manager.ts:357", + "src/targets/droid-config-manager.ts:390", + "src/targets/droid-config-manager.ts:421", + "src/targets/droid-config-manager.ts:429", + "src/targets/droid-config-manager.ts:449", + "src/targets/target-registry.ts:27", + "src/targets/target-resolver.ts:137", + "src/targets/target-resolver.ts:161", + "src/targets/target-resolver.ts:171", + "src/utils/browser/browser-policy.ts:35", + "src/utils/browser/browser-policy.ts:43", + "src/utils/browser/chrome-reuse.ts:114", + "src/utils/browser/chrome-reuse.ts:119", + "src/utils/browser/chrome-reuse.ts:142", + "src/utils/browser/chrome-reuse.ts:155", + "src/utils/browser/chrome-reuse.ts:160", + "src/utils/browser/chrome-reuse.ts:165", + "src/utils/browser/chrome-reuse.ts:41", + "src/utils/browser/chrome-reuse.ts:64", + "src/utils/browser/chrome-reuse.ts:80", + "src/utils/browser/chrome-reuse.ts:84", + "src/utils/browser/chrome-reuse.ts:95", + "src/utils/browser/mcp-installer.ts:342", + "src/utils/claude-spawner.ts:46", + "src/utils/config-manager.ts:298", + "src/utils/config-manager.ts:302", + "src/utils/prompt.ts:112", + "src/utils/prompt.ts:157", + "src/utils/prompt.ts:54", + "src/utils/proxy-env.ts:36", + "src/utils/proxy-env.ts:40", + "src/utils/retry-strategy.ts:96", + "src/utils/retry-strategy.ts:99", + "src/utils/shell-completion.ts:101", + "src/utils/shell-completion.ts:143", + "src/utils/shell-completion.ts:191", + "src/utils/shell-completion.ts:224", + "src/utils/shell-completion.ts:269", + "src/utils/shell-completion.ts:288", + "src/utils/shell-completion.ts:74", + "src/utils/websearch/mcp-installer.ts:438", + "src/utils/websearch/profile-hook-injector.ts:206", + "src/web-server/index.ts:266", + "src/web-server/services/claude-extension-binding-service.ts:144", + "src/web-server/services/claude-extension-binding-service.ts:185", + "src/web-server/services/claude-extension-binding-service.ts:195", + "src/web-server/services/claude-extension-binding-service.ts:207", + "src/web-server/services/claude-extension-binding-service.ts:255", + "src/web-server/services/claude-extension-binding-service.ts:273", + "src/web-server/services/claude-extension-binding-service.ts:82", + "src/web-server/services/claude-extension-binding-service.ts:83", + "src/web-server/services/claude-extension-binding-service.ts:84", + "src/web-server/services/claude-extension-binding-service.ts:89", + "src/web-server/services/claude-extension-settings-service.ts:186", + "src/web-server/services/claude-extension-settings-service.ts:190", + "src/web-server/services/claude-extension-settings-service.ts:202", + "src/web-server/services/claude-extension-settings-service.ts:226", + "src/web-server/services/compatible-cli-docs-registry.ts:171", + "src/web-server/services/compatible-cli-json-file-service.ts:171", + "src/web-server/services/compatible-cli-json-file-service.ts:174", + "src/web-server/services/compatible-cli-json-file-service.ts:191", + "src/web-server/services/compatible-cli-json-file-service.ts:194", + "src/web-server/services/compatible-cli-json-file-service.ts:203", + "src/web-server/services/compatible-cli-json-file-service.ts:206", + "src/web-server/services/compatible-cli-toml-file-service.ts:124", + "src/web-server/services/compatible-cli-toml-file-service.ts:127", + "src/web-server/services/compatible-cli-toml-file-service.ts:292", + "src/web-server/services/compatible-cli-toml-file-service.ts:295", + "src/web-server/services/compatible-cli-toml-file-service.ts:89", + "src/web-server/services/compatible-cli-toml-file-service.ts:92", + "src/web-server/usage/cliproxy-usage-syncer.ts:302", + "src/web-server/usage/handlers.ts:101", + "src/web-server/usage/handlers.ts:66", + "src/web-server/usage/handlers.ts:73", + "src/web-server/usage/handlers.ts:74", + "src/web-server/usage/handlers.ts:75", + "src/web-server/usage/handlers.ts:84", + "src/web-server/usage/handlers.ts:93", + "src/web-server/usage/profile-filter.ts:15", + "src/web-server/usage/profile-filter.ts:21", + "src/web-server/usage/sqlite-cli.ts:101", + "src/web-server/usage/sqlite-cli.ts:138", + "src/web-server/usage/sqlite-cli.ts:159", + "src/web-server/usage/sqlite-cli.ts:175", + "src/web-server/usage/sqlite-cli.ts:179", + "src/web-server/usage/sqlite-cli.ts:77", + "src/web-server/usage/sqlite-cli.ts:90" +] diff --git a/eslint.config.mjs b/eslint.config.mjs index b2799cc5..9c749b7b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,14 @@ import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; import prettier from 'eslint-config-prettier'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import noNewThrowError from './eslint-rules/no-new-throw-error.js'; + +const __configDir = path.dirname(fileURLToPath(import.meta.url)); +const baselinePath = path.join(__configDir, 'eslint-rules', 'throw-error-baseline.json'); +const throwErrorBaseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); export default [ { @@ -16,6 +24,8 @@ export default [ }, plugins: { '@typescript-eslint': tseslint, + // Local enforcement rules (P7 gates). + ccs: { rules: { 'no-new-throw-error': noNewThrowError } }, }, rules: { // TypeScript rules - upgraded to errors for stricter type safety @@ -32,6 +42,15 @@ export default [ 'prefer-const': 'error', 'no-var': 'error', 'eqeqeq': ['error', 'always'], + + // P7 enforcement gates: + // - no-new-throw-error: error on NEW throw new Error(...) outside the + // generated baseline (eslint-rules/throw-error-baseline.json). Forces + // the typed-error taxonomy (src/errors/error-types.ts). Regenerate the + // baseline with: node scripts/generate-throw-error-baseline.js + // - max-lines: warn on files over 400 LOC (goal of P5/P6 god-file splits). + 'ccs/no-new-throw-error': ['error', { allowlist: throwErrorBaseline }], + 'max-lines': ['warn', { max: 400, skipBlankLines: true, skipComments: true }], }, }, { diff --git a/scripts/generate-throw-error-baseline.js b/scripts/generate-throw-error-baseline.js new file mode 100644 index 00000000..07412a5b --- /dev/null +++ b/scripts/generate-throw-error-baseline.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * Generate the baseline allowlist for eslint-rules/no-new-throw-error.js. + * + * Walks src/ (non-test), finds every `throw new Error(...)` site using the same + * comment-stripping as scripts/maintainability-metrics.js (so the baseline + * matches what the ESLint AST sees — comments are not ThrowStatement nodes), + * and writes eslint-rules/throw-error-baseline.json as a sorted array of + * `${relativePath}:${line}` keys. + * + * Run after intentionally adding a new grandfathered throw, or quarterly to + * prune entries that have since been converted to typed errors. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const OUTPUT_PATH = path.join(ROOT_DIR, 'eslint-rules', 'throw-error-baseline.json'); + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); +const THROW_NEW_ERROR_REGEX = /\bthrow\s+new\s+Error\s*\(/g; + +function isTestPath(relPath) { + return ( + /(?:^|\/)(?:__tests__|tests?)\//.test(relPath) || + /\.test\./.test(relPath) || + /\.spec\./.test(relPath) + ); +} + +function toPosix(p) { + return p.split(path.sep).join('/'); +} + +function relPath(fullPath) { + return toPosix(path.relative(ROOT_DIR, fullPath)); +} + +// Lazy require: hardening-inventory.js requires this module's sibling maintainability-metrics.js +// at top level; requiring it back at top level here would capture a partial module.exports. +// A function-scope require resolves against the fully-loaded module at call time. +function stripCommentsOnce(sourceText) { + return require('./hardening-inventory.js').stripComments(sourceText); +} + +function walkFiles(dirPath) { + const out = []; + let entries; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const full = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + out.push.apply(out, walkFiles(full)); + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(full))) { + out.push(full); + } + } + return out; +} + +function collectSites() { + const sites = []; + for (const full of walkFiles(SRC_DIR)) { + const rel = relPath(full); + if (isTestPath(rel)) continue; + // Match on the RAW source (not comment-stripped). ESLint lints the raw + // file, so the baseline must reflect real throw lines as the AST sees them. + // stripComments can undercount on files whose regex/template literals confuse + // its state machine; raw matching is a superset (may include comment/string + // mentions, which are harmless unused allowlist entries) and never undercounts. + const sourceText = fs.readFileSync(full, 'utf8'); + const re = new RegExp(THROW_NEW_ERROR_REGEX.source, 'g'); + let match; + while ((match = re.exec(sourceText)) !== null) { + const line = sourceText.slice(0, match.index).split(/\r?\n/).length; + sites.push(`${rel}:${line}`); + } + } + return sites.sort(); +} + +function main() { + const sites = collectSites(); + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(sites, null, 2) + '\n', 'utf8'); + console.log(`[throw-error-baseline] ${sites.length} sites -> ${relPath(OUTPUT_PATH)}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { collectSites, OUTPUT_PATH }; diff --git a/tests/unit/eslint-rules/no-throw-new-error.test.ts b/tests/unit/eslint-rules/no-throw-new-error.test.ts new file mode 100644 index 00000000..deea7c9b --- /dev/null +++ b/tests/unit/eslint-rules/no-throw-new-error.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; + +const rule = require('../../../eslint-rules/no-new-throw-error.js'); + +/** + * P7 rule test: no-new-throw-error flags NEW throw new Error(...) sites not in + * the baseline allowlist, while grandfathered sites, typed throws, and + * re-throws pass. + * + * Exercises the rule's create() directly with a mock context and AST node so + * the test does not depend on the ESLint runtime under bun. The live + * `bun run lint` already proves the ESLint integration end-to-end (0 violations + * against the generated baseline). + */ +describe('ccs/no-new-throw-error rule logic', () => { + const FILENAME = 'src/sample/fixture.ts'; + + function node(argument: unknown, line: number) { + return { + type: 'ThrowStatement', + loc: { start: { line, column: 0 } }, + argument, + }; + } + + function runRule(throwNode: unknown, allowlist: string[]): Array<{ messageId: string }> { + const reports: Array<{ messageId: string }> = []; + const context = { + options: [{ allowlist }], + getFilename: () => FILENAME, + report: (r: { messageId: string }) => reports.push(r), + }; + const visitors = rule.create(context); + visitors.ThrowStatement(throwNode); + return reports; + } + + const newErrorArg = { type: 'NewExpression', callee: { type: 'Identifier', name: 'Error' } }; + const newTypedArg = { type: 'NewExpression', callee: { type: 'Identifier', name: 'ConfigError' } }; + const rethrowArg = { type: 'Identifier', name: 'err' }; + + test('flags a throw new Error off the allowlist', () => { + expect(runRule(node(newErrorArg, 1), []).some((r) => r.messageId === 'unexpected')).toBe(true); + }); + + test('passes a throw new Error that is on the allowlist (file:line match)', () => { + expect(runRule(node(newErrorArg, 1), [`${FILENAME}:1`]).some((r) => r.messageId === 'unexpected')).toBe( + false + ); + }); + + test('flags when the throw line differs from the allowlisted line', () => { + expect(runRule(node(newErrorArg, 2), [`${FILENAME}:1`]).some((r) => r.messageId === 'unexpected')).toBe( + true + ); + }); + + test('passes typed throws (ConfigError / AuthError)', () => { + const typed = { type: 'NewExpression', callee: { type: 'Identifier', name: 'AuthError' } }; + expect(runRule(node(typed, 1), []).some((r) => r.messageId === 'unexpected')).toBe(false); + expect(runRule(node(newTypedArg, 1), []).some((r) => r.messageId === 'unexpected')).toBe(false); + }); + + test('passes re-throws and non-NewExpression throws', () => { + expect(runRule(node(rethrowArg, 1), []).some((r) => r.messageId === 'unexpected')).toBe(false); + }); +});