diff --git a/packages/cli/src/deprecation/README.md b/packages/cli/src/deprecation/README.md new file mode 100644 index 00000000..4137658f --- /dev/null +++ b/packages/cli/src/deprecation/README.md @@ -0,0 +1,211 @@ +# CLI Flag Deprecation System + +This module provides a comprehensive deprecation warning system for GasGuard CLI flags, enabling graceful migration paths for users while maintaining backward compatibility. + +## Features + +- ✅ **Automatic Detection**: Scans command-line arguments for deprecated flags +- ✅ **Severity Levels**: HIGH, MEDIUM, and LOW severity classifications +- ✅ **Colored Warnings**: User-friendly console output with actionable guidance +- ✅ **Structured Logging**: JSON logs to stderr for operational visibility +- ✅ **Metrics Tracking**: Aggregated usage patterns across invocations +- ✅ **Configurable Behavior**: Environment variable overrides +- ✅ **Commander.js Integration**: Seamless hooks into CLI lifecycle + +## Architecture + +``` +deprecation/ +├── types.ts # TypeScript type definitions +├── config.ts # Deprecated flags registry and configuration +├── detector.ts # Core detection and warning logic +├── logger.ts # Structured logging for operations +├── metrics.ts # Usage metrics collection +├── integration.ts # Commander.js integration utilities +└── index.ts # Main entry point and orchestration +``` + +## Usage + +### Basic Usage + +```typescript +import { checkDeprecatedFlags } from './deprecation'; + +// Check argv for deprecated flags +const result = checkDeprecatedFlags(process.argv); + +if (result.hasHighSeverity) { + console.error('High-severity deprecated flags detected!'); +} +``` + +### Commander.js Integration + +```typescript +import { Command } from 'commander'; +import { attachDeprecationHook } from './deprecation/integration'; + +const program = new Command(); +attachDeprecationHook(program); + +// Deprecation checking now happens automatically before each command +``` + +### Adding a Deprecated Flag + +Edit `config.ts` and add a new entry to `DEPRECATED_FLAGS`: + +```typescript +{ + flag: "--old-flag", + replacement: "--new-flag", + message: "The '--old-flag' is deprecated. Use '--new-flag' instead.", + severity: DeprecationSeverity.HIGH, + deprecatedIn: "1.2.0", + removedIn: "2.0.0", + documentationUrl: "https://docs.gasguard.io/migration/old-to-new", +} +``` + +## Configuration + +### Environment Variables + +- `GASGUARD_DEPRECATION_WARNINGS=false` - Disable warning output +- `GASGUARD_DEPRECATION_METRICS=false` - Disable metrics tracking +- `GASGUARD_FAIL_ON_DEPRECATED=true` - Exit on high-severity deprecations + +### Programmatic Configuration + +```typescript +import { loadDeprecationConfig } from './deprecation'; + +const config = loadDeprecationConfig(); +config.enableWarnings = false; +``` + +## Severity Levels + +### HIGH +- Flag will be removed in the next major version +- Breaks existing workflows if not addressed +- **Color**: Red + +### MEDIUM +- Flag will be removed in a future version +- Users should plan migration +- **Color**: Yellow + +### LOW +- Soft-deprecated, no immediate removal planned +- Informational only +- **Color**: Blue + +## Output Examples + +### Console Warning + +``` +⚠️ Deprecation Warning [HIGH] + The '--analyze-local' flag is deprecated. Use the 'scan' command instead. + → Use: scan + ⏰ Will be removed in version 2.0.0 + 📖 https://docs.gasguard.io/migration/analyze-local-to-scan +``` + +### Structured Log + +```json +{ + "timestamp": "2024-02-20T10:30:45.123Z", + "component": "cli.deprecation", + "level": "warn", + "event": "cli.deprecation.detected", + "correlationId": "dep_a1b2c3d4", + "message": "Deprecated flag detected: --analyze-local", + "flag": "--analyze-local", + "severity": "high", + "replacement": "scan", + "position": 2 +} +``` + +## Metrics + +The system tracks aggregated metrics across invocations: + +```typescript +import { getMetricsCollector } from './deprecation'; + +const collector = getMetricsCollector(); +const metrics = collector.getMetrics(); + +console.log(metrics); +// { +// totalUsages: 42, +// byFlag: { "--old-flag": 42 }, +// bySeverity: { high: 42, medium: 0, low: 0 }, +// firstSeen: "2024-02-20T10:00:00.000Z", +// lastSeen: "2024-02-20T11:00:00.000Z", +// invocationCount: 15 +// } +``` + +## Testing + +The system is fully testable with dependency injection: + +```typescript +import { DeprecationDetector, DeprecationConfig } from './deprecation'; + +const config: DeprecationConfig = { /* test config */ }; +const detector = new DeprecationDetector(config); +const result = detector.analyze(['--old-flag']); + +expect(result.usages.length).toBe(1); +``` + +## Migration Guide + +When deprecating a flag: + +1. **Add entry to `DEPRECATED_FLAGS`** with appropriate severity +2. **Set `deprecatedIn` version** to current release +3. **Set `removedIn` version** to planned removal (usually next major) +4. **Create migration documentation** and link it +5. **Update tests** to cover the deprecation +6. **Announce in release notes** and changelog +7. **Monitor metrics** to track adoption + +## Tenant Isolation + +The deprecation system is stateless and thread-safe: + +- No shared mutable state between invocations +- Metrics are aggregated but don't affect behavior +- Each invocation gets a unique correlation ID +- Concurrent CLI executions cannot interfere + +## Security Considerations + +- **No PII**: Deprecation logs exclude user data and file paths +- **No secrets**: Only flag names and positions are logged +- **Safe defaults**: Warnings are informational, not blocking by default +- **Predictable**: Detection is deterministic and reproducible + +## Performance + +- **Minimal overhead**: Parsing argv is O(n) where n = argument count +- **Non-blocking**: Warnings and logs don't delay execution +- **Async-safe**: All operations are synchronous and deterministic +- **Memory efficient**: No large data structures, immediate output + +## Backward Compatibility + +This system maintains full backward compatibility: + +- Deprecated flags continue to work as before +- Users opt into strict mode via environment variable +- Warnings go to stderr, don't interfere with stdout +- No breaking changes to command behavior diff --git a/packages/cli/src/deprecation/__tests__/config.spec.ts b/packages/cli/src/deprecation/__tests__/config.spec.ts new file mode 100644 index 00000000..ef6e7fe0 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/config.spec.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for deprecation configuration and flag lookup. + */ + +import { + DEPRECATED_FLAGS, + DEFAULT_DEPRECATION_CONFIG, + createFlagLookup, + loadDeprecationConfig, +} from "../config"; +import { DeprecationSeverity } from "../types"; + +describe("Deprecation Config", () => { + describe("DEPRECATED_FLAGS", () => { + it("should contain valid flag definitions", () => { + expect(DEPRECATED_FLAGS).toBeDefined(); + expect(Array.isArray(DEPRECATED_FLAGS)).toBe(true); + expect(DEPRECATED_FLAGS.length).toBeGreaterThan(0); + }); + + it("should have required fields for each flag", () => { + for (const flag of DEPRECATED_FLAGS) { + expect(flag.flag).toBeDefined(); + expect(typeof flag.flag).toBe("string"); + expect(flag.flag.length).toBeGreaterThan(0); + expect(flag.message).toBeDefined(); + expect(typeof flag.message).toBe("string"); + expect(flag.severity).toBeDefined(); + expect(Object.values(DeprecationSeverity)).toContain(flag.severity); + expect(flag.deprecatedIn).toBeDefined(); + expect(typeof flag.deprecatedIn).toBe("string"); + } + }); + + it("should not have duplicate flags", () => { + const flagNames = DEPRECATED_FLAGS.map((f) => f.flag); + const uniqueFlags = new Set(flagNames); + expect(uniqueFlags.size).toBe(flagNames.length); + }); + }); + + describe("DEFAULT_DEPRECATION_CONFIG", () => { + it("should have valid default configuration", () => { + expect(DEFAULT_DEPRECATION_CONFIG.deprecatedFlags).toBeDefined(); + expect(typeof DEFAULT_DEPRECATION_CONFIG.enableWarnings).toBe("boolean"); + expect(typeof DEFAULT_DEPRECATION_CONFIG.enableMetrics).toBe("boolean"); + expect(typeof DEFAULT_DEPRECATION_CONFIG.failOnHighSeverity).toBe("boolean"); + }); + + it("should enable warnings by default", () => { + expect(DEFAULT_DEPRECATION_CONFIG.enableWarnings).toBe(true); + }); + + it("should enable metrics by default", () => { + expect(DEFAULT_DEPRECATION_CONFIG.enableMetrics).toBe(true); + }); + + it("should not fail on high severity by default", () => { + expect(DEFAULT_DEPRECATION_CONFIG.failOnHighSeverity).toBe(false); + }); + }); + + describe("createFlagLookup", () => { + it("should create a lookup map from flags", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + expect(lookup).toBeInstanceOf(Map); + expect(lookup.size).toBeGreaterThan(0); + }); + + it("should support lookup with double dashes", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + const flag = DEPRECATED_FLAGS[0]; + const normalized = flag.flag.startsWith("--") ? flag.flag : `--${flag.flag}`; + expect(lookup.has(normalized)).toBe(true); + }); + + it("should support lookup without dashes", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + const flag = DEPRECATED_FLAGS[0]; + const withoutDashes = flag.flag.replace(/^-+/, ""); + expect(lookup.has(withoutDashes)).toBe(true); + }); + + it("should return correct flag object", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + const flag = DEPRECATED_FLAGS[0]; + const retrieved = lookup.get(flag.flag); + expect(retrieved).toBe(flag); + }); + + it("should handle empty array", () => { + const lookup = createFlagLookup([]); + expect(lookup.size).toBe(0); + }); + }); + + describe("loadDeprecationConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should return default config when no env vars set", () => { + delete process.env.GASGUARD_DEPRECATION_WARNINGS; + delete process.env.GASGUARD_DEPRECATION_METRICS; + delete process.env.GASGUARD_FAIL_ON_DEPRECATED; + + const config = loadDeprecationConfig(); + expect(config.enableWarnings).toBe(true); + expect(config.enableMetrics).toBe(true); + expect(config.failOnHighSeverity).toBe(false); + }); + + it("should disable warnings when env var is false", () => { + process.env.GASGUARD_DEPRECATION_WARNINGS = "false"; + const config = loadDeprecationConfig(); + expect(config.enableWarnings).toBe(false); + }); + + it("should disable metrics when env var is false", () => { + process.env.GASGUARD_DEPRECATION_METRICS = "false"; + const config = loadDeprecationConfig(); + expect(config.enableMetrics).toBe(false); + }); + + it("should enable fail on deprecated when env var is true", () => { + process.env.GASGUARD_FAIL_ON_DEPRECATED = "true"; + const config = loadDeprecationConfig(); + expect(config.failOnHighSeverity).toBe(true); + }); + + it("should handle multiple env var overrides", () => { + process.env.GASGUARD_DEPRECATION_WARNINGS = "false"; + process.env.GASGUARD_DEPRECATION_METRICS = "false"; + process.env.GASGUARD_FAIL_ON_DEPRECATED = "true"; + + const config = loadDeprecationConfig(); + expect(config.enableWarnings).toBe(false); + expect(config.enableMetrics).toBe(false); + expect(config.failOnHighSeverity).toBe(true); + }); + + it("should ignore non-false/true values", () => { + process.env.GASGUARD_DEPRECATION_WARNINGS = "yes"; + process.env.GASGUARD_FAIL_ON_DEPRECATED = "no"; + + const config = loadDeprecationConfig(); + expect(config.enableWarnings).toBe(true); // not "false", so default + expect(config.failOnHighSeverity).toBe(false); // not "true", so default + }); + }); +}); diff --git a/packages/cli/src/deprecation/__tests__/detector.spec.ts b/packages/cli/src/deprecation/__tests__/detector.spec.ts new file mode 100644 index 00000000..92cf0d08 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/detector.spec.ts @@ -0,0 +1,333 @@ +/** + * Unit tests for deprecation detector and warning formatter. + */ + +import { Writable } from "stream"; +import { + DeprecationDetector, + emitDeprecationWarnings, + formatDeprecationSummary, + formatDeprecationWarning, + shouldFailOnDeprecation, +} from "../detector"; +import { + DeprecatedFlag, + DeprecationConfig, + DeprecationSeverity, +} from "../types"; + +const mockDeprecatedFlags: DeprecatedFlag[] = [ + { + flag: "--old-flag", + replacement: "--new-flag", + message: "Use --new-flag instead", + severity: DeprecationSeverity.HIGH, + deprecatedIn: "1.0.0", + removedIn: "2.0.0", + }, + { + flag: "--legacy-option", + replacement: "config: output.legacy", + message: "Use config file instead", + severity: DeprecationSeverity.MEDIUM, + deprecatedIn: "1.0.0", + removedIn: "1.5.0", + }, + { + flag: "--verbose-mode", + message: "This flag is deprecated", + severity: DeprecationSeverity.LOW, + deprecatedIn: "1.0.0", + }, +]; + +const mockConfig: DeprecationConfig = { + deprecatedFlags: mockDeprecatedFlags, + enableWarnings: true, + enableMetrics: true, + failOnHighSeverity: false, +}; + +describe("DeprecationDetector", () => { + describe("analyze", () => { + it("should detect deprecated flags in argv", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + + expect(result.usages.length).toBe(1); + expect(result.usages[0].flag.flag).toBe("--old-flag"); + }); + + it("should detect multiple deprecated flags", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--old-flag", + "--legacy-option", + ]); + + expect(result.usages.length).toBe(2); + expect(result.usages[0].flag.flag).toBe("--old-flag"); + expect(result.usages[1].flag.flag).toBe("--legacy-option"); + }); + + it("should handle flags with values using = syntax", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag=value"]); + + expect(result.usages.length).toBe(1); + expect(result.usages[0].flag.flag).toBe("--old-flag"); + expect(result.usages[0].value).toBe("value"); + }); + + it("should track flag position in argv", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "scan", + "--old-flag", + "path", + ]); + + expect(result.usages[0].position).toBe(3); + }); + + it("should not detect non-deprecated flags", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--valid-flag", + "--another-flag", + ]); + + expect(result.usages.length).toBe(0); + }); + + it("should skip non-flag arguments", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "command", + "path/to/file", + "--old-flag", + ]); + + expect(result.usages.length).toBe(1); + }); + + it("should detect hasHighSeverity correctly", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + + expect(result.hasHighSeverity).toBe(true); + }); + + it("should return false for hasHighSeverity when no high severity flags", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--legacy-option"]); + + expect(result.hasHighSeverity).toBe(false); + }); + + it("should calculate correct metrics", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--old-flag", + "--old-flag", + "--legacy-option", + ]); + + expect(result.metrics.totalUsages).toBe(3); + expect(result.metrics.byFlag["--old-flag"]).toBe(2); + expect(result.metrics.byFlag["--legacy-option"]).toBe(1); + expect(result.metrics.bySeverity[DeprecationSeverity.HIGH]).toBe(2); + expect(result.metrics.bySeverity[DeprecationSeverity.MEDIUM]).toBe(1); + }); + + it("should include correlation ID", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"], "test-123"); + + expect(result.usages[0].correlationId).toBe("test-123"); + }); + + it("should generate correlation ID if not provided", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + + expect(result.usages[0].correlationId).toBeDefined(); + expect(result.usages[0].correlationId).toMatch(/^dep_/); + }); + + it("should handle empty argv", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([]); + + expect(result.usages.length).toBe(0); + expect(result.hasHighSeverity).toBe(false); + expect(result.metrics.totalUsages).toBe(0); + }); + + it("should be reusable after clear", () => { + const detector = new DeprecationDetector(mockConfig); + + detector.analyze(["node", "cli", "--old-flag"]); + expect(detector.getUsages().length).toBe(1); + + detector.clear(); + expect(detector.getUsages().length).toBe(0); + + const result = detector.analyze(["node", "cli", "--legacy-option"]); + expect(result.usages.length).toBe(1); + }); + }); + + describe("formatDeprecationWarning", () => { + it("should format a warning message", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + const message = formatDeprecationWarning(result.usages[0]); + + expect(message).toContain("Deprecation Warning"); + expect(message).toContain("Use --new-flag instead"); + expect(message).toContain("--new-flag"); + }); + + it("should include removal version if present", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + const message = formatDeprecationWarning(result.usages[0]); + + expect(message).toContain("2.0.0"); + }); + + it("should not include removal version if not present", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--verbose-mode"]); + const message = formatDeprecationWarning(result.usages[0]); + + expect(message).not.toContain("Will be removed"); + }); + }); + + describe("formatDeprecationSummary", () => { + it("should format a summary with multiple usages", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--old-flag", + "--legacy-option", + ]); + const summary = formatDeprecationSummary(result); + + expect(summary).toContain("DEPRECATION WARNINGS"); + expect(summary).toContain("Total: 2"); + }); + + it("should return empty string when no usages", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli"]); + const summary = formatDeprecationSummary(result); + + expect(summary).toBe(""); + }); + + it("should group duplicate flags", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--old-flag", + "--old-flag", + ]); + const summary = formatDeprecationSummary(result); + + // Should only show the warning once, but count both + expect(summary).toContain("Total: 2"); + }); + + it("should show severity breakdown", () => { + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze([ + "node", + "cli", + "--old-flag", + "--legacy-option", + "--verbose-mode", + ]); + const summary = formatDeprecationSummary(result); + + expect(summary).toContain("High: 1"); + expect(summary).toContain("Medium: 1"); + expect(summary).toContain("Low: 1"); + }); + }); + + describe("emitDeprecationWarnings", () => { + it("should write warnings to provided stream", () => { + const chunks: string[] = []; + const mockStream = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli", "--old-flag"]); + emitDeprecationWarnings(result, mockStream); + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.join("")).toContain("DEPRECATION WARNINGS"); + }); + + it("should not write when no usages", () => { + const chunks: string[] = []; + const mockStream = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + + const detector = new DeprecationDetector(mockConfig); + const result = detector.analyze(["node", "cli"]); + emitDeprecationWarnings(result, mockStream); + + expect(chunks.length).toBe(0); + }); + }); + + describe("shouldFailOnDeprecation", () => { + it("should return true when config enables and has high severity", () => { + const config = { ...mockConfig, failOnHighSeverity: true }; + const detector = new DeprecationDetector(config); + const result = detector.analyze(["node", "cli", "--old-flag"]); + + expect(shouldFailOnDeprecation(result, config)).toBe(true); + }); + + it("should return false when config disables", () => { + const config = { ...mockConfig, failOnHighSeverity: false }; + const detector = new DeprecationDetector(config); + const result = detector.analyze(["node", "cli", "--old-flag"]); + + expect(shouldFailOnDeprecation(result, config)).toBe(false); + }); + + it("should return false when no high severity flags", () => { + const config = { ...mockConfig, failOnHighSeverity: true }; + const detector = new DeprecationDetector(config); + const result = detector.analyze(["node", "cli", "--legacy-option"]); + + expect(shouldFailOnDeprecation(result, config)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/deprecation/__tests__/e2e.spec.ts b/packages/cli/src/deprecation/__tests__/e2e.spec.ts new file mode 100644 index 00000000..f1950304 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/e2e.spec.ts @@ -0,0 +1,423 @@ +/** + * Integration tests for deprecation system with CLI component boundaries. + * + * These tests verify the deprecation system works correctly with: + * - CLI command parsing + * - Configuration loading + * - Worker concurrency + * - Persistence behavior + */ + +import { Writable } from "stream"; +import { Command } from "commander"; +import chalk from "chalk"; + +// Mock dependencies to avoid actual file system operations +jest.mock("chalk", () => ({ + yellow: (s: string) => s, + red: (s: string) => s, + blue: (s: string) => s, + cyan: (s: string) => s, + green: (s: string) => s, + white: (s: string) => s, + dim: (s: string) => s, + yellowBold: (s: string) => s, + redBold: (s: string) => s, +})); + +import { + DeprecationDetector, + formatDeprecationSummary, + emitDeprecationWarnings, + shouldFailOnDeprecation, +} from "../detector"; +import { checkDeprecatedFlags } from "../index"; +import { + DeprecationConfig, + DeprecatedFlag, + DeprecationSeverity, + DeprecationAnalysisResult, +} from "../types"; +import { createFlagLookup, loadDeprecationConfig, DEPRECATED_FLAGS } from "../config"; +import { DeprecationLogger } from "../logger"; +import { MetricsCollector } from "../metrics"; + +/** + * Test fixtures for integration tests + */ +const testConfig: DeprecationConfig = { + deprecatedFlags: DEPRECATED_FLAGS, + enableWarnings: true, + enableMetrics: true, + failOnHighSeverity: false, +}; + +describe("Deprecation System Integration", () => { + describe("Full CLI Integration Flow", () => { + let stderrOutput: string[]; + let mockStderr: NodeJS.WriteStream; + + beforeEach(() => { + stderrOutput = []; + mockStderr = new Writable({ + write(chunk, encoding, callback) { + stderrOutput.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + }); + + it("should integrate with checkDeprecatedFlags entry point", () => { + // Simulate CLI invocation with deprecated flag + const argv = ["node", "gasguard", "scan", "--analyze-local", "."]; + const result = checkDeprecatedFlags(argv); + + expect(result).toBeDefined(); + expect(result.usages.length).toBe(1); + expect(result.usages[0].flag.flag).toBe("--analyze-local"); + }); + + it("should handle scan command with deprecated flags", () => { + const argv = ["node", "gasguard", "scan", "--no-progress", "--fix-preview", "."]; + const result = checkDeprecatedFlags(argv); + + expect(result.usages.length).toBe(2); + expect(result.metrics.totalUsages).toBe(2); + }); + + it("should handle multiple commands with mixed flags", () => { + const argv = ["node", "gasguard", "policy", "validate", "policy.json", "--no-progress"]; + const result = checkDeprecatedFlags(argv); + + expect(result.usages.length).toBe(1); + expect(result.usages[0].flag.flag).toBe("--no-progress"); + }); + + it("should not interfere with valid commands", () => { + const argv = ["node", "gasguard", "scan", ".", "--format", "json"]; + const result = checkDeprecatedFlags(argv); + + expect(result.usages.length).toBe(0); + expect(result.hasHighSeverity).toBe(false); + }); + }); + + describe("Worker Concurrency Safety", () => { + it("should be safe for concurrent worker invocations", () => { + // Simulate multiple concurrent workers + const detector1 = new DeprecationDetector(testConfig); + const detector2 = new DeprecationDetector(testConfig); + const detector3 = new DeprecationDetector(testConfig); + + const result1 = detector1.analyze(["node", "cli", "--analyze-local"]); + const result2 = detector2.analyze(["node", "cli", "--no-progress"]); + const result3 = detector3.analyze(["node", "cli", "--fix-preview"]); + + // Each detector should have isolated state + expect(result1.usages.length).toBe(1); + expect(result2.usages.length).toBe(1); + expect(result3.usages.length).toBe(1); + + // Metrics should be independent + expect(result1.metrics.byFlag["--analyze-local"]).toBe(1); + expect(result2.metrics.byFlag["--no-progress"]).toBe(1); + expect(result3.metrics.byFlag["--fix-preview"]).toBe(1); + }); + + it("should handle rapid sequential invocations", () => { + const detector = new DeprecationDetector(testConfig); + + for (let i = 0; i < 100; i++) { + const result = detector.analyze(["node", "cli", "--analyze-local"]); + expect(result.usages.length).toBe(1); + detector.clear(); + } + }); + + it("should track metrics correctly across invocations", () => { + const collector = new MetricsCollector(); + + collector.record({ + usages: [], + hasHighSeverity: true, + metrics: { + totalUsages: 2, + byFlag: { "--analyze-local": 2 }, + bySeverity: { + [DeprecationSeverity.HIGH]: 2, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 0, + }, + }, + }); + + collector.record({ + usages: [], + hasHighSeverity: false, + metrics: { + totalUsages: 3, + byFlag: { "--no-progress": 3 }, + bySeverity: { + [DeprecationSeverity.HIGH]: 0, + [DeprecationSeverity.MEDIUM]: 3, + [DeprecationSeverity.LOW]: 0, + }, + }, + }); + + const metrics = collector.getMetrics(); + expect(metrics.totalUsages).toBe(5); + expect(metrics.bySeverity.high).toBe(2); + expect(metrics.bySeverity.medium).toBe(3); + expect(metrics.invocationCount).toBe(2); + }); + }); + + describe("Configuration Loading Integration", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should respect environment variable overrides", () => { + process.env.GASGUARD_DEPRECATION_WARNINGS = "false"; + process.env.GASGUARD_FAIL_ON_DEPRECATED = "true"; + + const config = loadDeprecationConfig(); + + expect(config.enableWarnings).toBe(false); + expect(config.failOnHighSeverity).toBe(true); + }); + + it("should load all deprecated flags from config", () => { + const config = loadDeprecationConfig(); + + expect(config.deprecatedFlags.length).toBeGreaterThan(0); + expect(config.deprecatedFlags.some(f => f.flag === "--analyze-local")).toBe(true); + expect(config.deprecatedFlags.some(f => f.flag === "--no-progress")).toBe(true); + expect(config.deprecatedFlags.some(f => f.flag === "--fix-preview")).toBe(true); + }); + }); + + describe("Flag Lookup Performance", () => { + it("should create efficient lookup map", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + + // Lookup should be O(1) operation + const start = Date.now(); + for (let i = 0; i < 10000; i++) { + lookup.get("--analyze-local"); + lookup.get("--no-progress"); + lookup.get("--fix-preview"); + } + const duration = Date.now() - start; + + // Should complete 30k lookups in under 100ms + expect(duration).toBeLessThan(100); + }); + + it("should handle various flag formats in lookup", () => { + const lookup = createFlagLookup(DEPRECATED_FLAGS); + + // With double dashes + expect(lookup.get("--analyze-local")).toBeDefined(); + + // Without dashes + expect(lookup.get("analyze-local")).toBeDefined(); + + // Exact match + const flag = DEPRECATED_FLAGS[0]; + expect(lookup.get(flag.flag)).toBe(flag); + }); + }); + + describe("Failure and Boundary Cases", () => { + it("should handle empty argv gracefully", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze([]); + + expect(result.usages.length).toBe(0); + expect(result.hasHighSeverity).toBe(false); + expect(result.metrics.totalUsages).toBe(0); + }); + + it("should handle argv with only positional arguments", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "gasguard", "scan", "path/to/project"]); + + expect(result.usages.length).toBe(0); + }); + + it("should handle flags with = value syntax", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "cli", "--format=json", "--analyze-local"]); + + expect(result.usages.length).toBe(1); + expect(result.usages[0].flag.flag).toBe("--analyze-local"); + }); + + it("should detect multiple instances of same flag", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "cli", "--analyze-local", "--analyze-local", "."]); + + expect(result.usages.length).toBe(2); + expect(result.metrics.byFlag["--analyze-local"]).toBe(2); + }); + + it("should handle unknown flags without crashing", () => { + const detector = new DeprecationDetector(testConfig); + + expect(() => { + detector.analyze(["node", "cli", "--unknown-flag", "-x", "--y", "value"]); + }).not.toThrow(); + + expect(() => { + detector.analyze(["node", "cli", "--flag-with-unusual-chars"]); + }).not.toThrow(); + }); + + it("should respect failOnHighSeverity config", () => { + const config: DeprecationConfig = { + ...testConfig, + failOnHighSeverity: true, + }; + + const detector = new DeprecationDetector(config); + const result = detector.analyze(["node", "cli", "--analyze-local"]); + + expect(shouldFailOnDeprecation(result, config)).toBe(true); + }); + + it("should not fail on medium severity when configured", () => { + const config: DeprecationConfig = { + ...testConfig, + failOnHighSeverity: true, + }; + + const detector = new DeprecationDetector(config); + const result = detector.analyze(["node", "cli", "--no-progress"]); + + expect(shouldFailOnDeprecation(result, config)).toBe(false); + }); + }); + + describe("Logger Integration", () => { + let logLines: string[]; + let mockStream: NodeJS.WriteStream; + + beforeEach(() => { + logLines = []; + mockStream = new Writable({ + write(chunk, encoding, callback) { + logLines.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + }); + + it("should log structured JSON to stream", () => { + const logger = new DeprecationLogger(mockStream); + logger.logStart("test-correlation"); + + expect(logLines.length).toBe(1); + const log = JSON.parse(logLines[0]); + expect(log.event).toBe("cli.deprecation.check.start"); + expect(log.correlationId).toBe("test-correlation"); + expect(log.component).toBe("cli.deprecation"); + }); + + it("should include all required fields in log", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage({ + flag: DEPRECATED_FLAGS[0], + value: undefined, + position: 2, + timestamp: new Date(), + correlationId: "test-123", + }); + + const log = JSON.parse(logLines[0]); + expect(log.timestamp).toBeDefined(); + expect(log.level).toBe("warn"); + expect(log.event).toBe("cli.deprecation.detected"); + expect(log.flag).toBeDefined(); + expect(log.severity).toBeDefined(); + expect(log.replacement).toBeDefined(); + expect(log.position).toBeDefined(); + }); + + it("should redact sensitive data from logs", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage({ + flag: DEPRECATED_FLAGS[0], + value: "secret-token", + position: 2, + timestamp: new Date(), + correlationId: "test-123", + }); + + const log = JSON.parse(logLines[0]); + // Value should be logged as part of usage tracking + expect(log).toBeDefined(); + // But ensure we're not logging file paths or other sensitive data + expect(log.message).not.toContain("secret"); + }); + }); + + describe("Output Formatting Integration", () => { + it("should format complete deprecation summary", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze([ + "node", "cli", "--analyze-local", "--no-progress", "--fix-preview", "." + ]); + + const summary = formatDeprecationSummary(result); + + expect(summary).toContain("DEPRECATION WARNINGS"); + expect(summary).toContain("Total: 3"); + expect(summary).toContain("High: 1"); + expect(summary).toContain("Medium: 1"); + expect(summary).toContain("Low: 1"); + }); + + it("should include flag names in output", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "cli", "--analyze-local", "."]); + + const summary = formatDeprecationSummary(result); + + expect(summary).toContain("--analyze-local"); + expect(summary).toContain("scan"); + }); + + it("should return empty string when no deprecations", () => { + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "cli", "scan", "."]); + + const summary = formatDeprecationSummary(result); + + expect(summary).toBe(""); + }); + + it("should emit warnings to provided stream", () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + + const detector = new DeprecationDetector(testConfig); + const result = detector.analyze(["node", "cli", "--analyze-local"]); + emitDeprecationWarnings(result, stream); + + expect(chunks.length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/deprecation/__tests__/integration.spec.ts b/packages/cli/src/deprecation/__tests__/integration.spec.ts new file mode 100644 index 00000000..f48ff169 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/integration.spec.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for CLI integration utilities. + */ + +import { Command } from "commander"; +import { + attachDeprecationHook, + getFlagReplacement, + isFlagDeprecated, + withDeprecationCheck, +} from "../integration"; +import { DEPRECATED_FLAGS } from "../config"; +import { setDeprecationLogger, resetDeprecationLogger } from "../logger"; +import { resetMetricsCollector } from "../metrics"; +import { Writable } from "stream"; + +describe("CLI Integration", () => { + let logLines: string[]; + let mockStream: NodeJS.WriteStream; + + beforeEach(() => { + logLines = []; + mockStream = new Writable({ + write(chunk, encoding, callback) { + logLines.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + + // Set up custom logger to capture output during tests + const { DeprecationLogger } = require("../logger"); + setDeprecationLogger(new DeprecationLogger(mockStream)); + }); + + afterEach(() => { + resetDeprecationLogger(); + resetMetricsCollector(); + }); + + describe("isFlagDeprecated", () => { + it("should return true for deprecated flags", () => { + expect(isFlagDeprecated("--analyze-local")).toBe(true); + expect(isFlagDeprecated("--no-progress")).toBe(true); + expect(isFlagDeprecated("--fix-preview")).toBe(true); + }); + + it("should return false for non-deprecated flags", () => { + expect(isFlagDeprecated("--scan")).toBe(false); + expect(isFlagDeprecated("--help")).toBe(false); + expect(isFlagDeprecated("--version")).toBe(false); + }); + + it("should handle flags without dashes", () => { + expect(isFlagDeprecated("analyze-local")).toBe(true); + expect(isFlagDeprecated("scan")).toBe(false); + }); + }); + + describe("getFlagReplacement", () => { + it("should return replacement for deprecated flag", () => { + expect(getFlagReplacement("--analyze-local")).toBe("scan"); + expect(getFlagReplacement("--no-progress")).toBe("--quiet"); + }); + + it("should return undefined for non-deprecated flag", () => { + expect(getFlagReplacement("--scan")).toBeUndefined(); + }); + + it("should return undefined for flags without replacement", () => { + const flagWithNoReplacement = DEPRECATED_FLAGS.find( + (f: any) => !f.replacement + ); + if (flagWithNoReplacement) { + expect(getFlagReplacement(flagWithNoReplacement.flag)).toBeUndefined(); + } + }); + }); + + describe("withDeprecationCheck", () => { + it("should wrap handler function", async () => { + let handlerCalled = false; + const handler = async () => { + handlerCalled = true; + return "success"; + }; + + const wrapped = withDeprecationCheck(handler); + const result = await wrapped(); + + expect(handlerCalled).toBe(true); + expect(result).toBe("success"); + }); + + it("should check for deprecations when called", async () => { + const handler = async () => "done"; + const wrapped = withDeprecationCheck(handler); + + // Passing deprecated flag in process.argv would trigger check + const originalArgv = process.argv; + process.argv = ["node", "cli", "--analyze-local"]; + + await wrapped(); + + process.argv = originalArgv; + }); + + it("should pass through arguments to handler", async () => { + const handler = async (a: number, b: string, c: boolean) => { + return `${a}-${b}-${c}`; + }; + + const wrapped = withDeprecationCheck(handler); + const result = await wrapped(42, "test", true); + + expect(result).toBe("42-test-true"); + }); + + it("should handle async handlers", async () => { + const handler = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return "async result"; + }; + + const wrapped = withDeprecationCheck(handler); + const result = await wrapped(); + + expect(result).toBe("async result"); + }); + + it("should handle sync handlers", () => { + const handler = () => "sync result"; + const wrapped = withDeprecationCheck(handler); + const result = wrapped(); + + expect(result).toBe("sync result"); + }); + }); + + describe("attachDeprecationHook", () => { + it("should add hook to program", () => { + const program = new Command(); + + // Access life cycle hooks before attaching + const result = attachDeprecationHook(program); + + expect(result).toBe(program); + }); + + it("should allow chaining", () => { + const program = new Command(); + const result = attachDeprecationHook(program); + + expect(result).toBeInstanceOf(Command); + }); + + it("should not interfere with normal command execution", async () => { + let commandExecuted = false; + const program = new Command("test") + .argument("") + .action(() => { + commandExecuted = true; + }); + + attachDeprecationHook(program); + + try { + await program.parseAsync(["node", "test", "myvalue"]); + } catch { + // Ignore parse errors as we're testing the hook integration + } + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/deprecation/__tests__/logger.spec.ts b/packages/cli/src/deprecation/__tests__/logger.spec.ts new file mode 100644 index 00000000..2e507938 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/logger.spec.ts @@ -0,0 +1,292 @@ +/** + * Unit tests for deprecation logger. + */ + +import { Writable } from "stream"; +import { + DeprecationLogger, + getDeprecationLogger, + resetDeprecationLogger, + setDeprecationLogger, +} from "../logger"; +import { + DeprecatedFlag, + DeprecationAnalysisResult, + DeprecationSeverity, + DeprecationUsage, +} from "../types"; + +const mockFlag: DeprecatedFlag = { + flag: "--old-flag", + replacement: "--new-flag", + message: "Use --new-flag instead", + severity: DeprecationSeverity.HIGH, + deprecatedIn: "1.0.0", + removedIn: "2.0.0", +}; + +const mockUsage: DeprecationUsage = { + flag: mockFlag, + value: undefined, + position: 2, + timestamp: new Date("2024-02-20T10:00:00Z"), + correlationId: "test-123", +}; + +describe("DeprecationLogger", () => { + let logLines: string[]; + let mockStream: NodeJS.WriteStream; + + beforeEach(() => { + logLines = []; + mockStream = new Writable({ + write(chunk, encoding, callback) { + logLines.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + }); + + describe("logUsage", () => { + it("should log a usage event", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + expect(logLines.length).toBe(1); + const log = JSON.parse(logLines[0]); + expect(log.event).toBe("cli.deprecation.detected"); + expect(log.correlationId).toBe("test-123"); + expect(log.flag).toBe("--old-flag"); + }); + + it("should include severity and replacement", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + const log = JSON.parse(logLines[0]); + expect(log.severity).toBe(DeprecationSeverity.HIGH); + expect(log.replacement).toBe("--new-flag"); + }); + + it("should include position", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + const log = JSON.parse(logLines[0]); + expect(log.position).toBe(2); + }); + + it("should include timestamp", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + const log = JSON.parse(logLines[0]); + expect(log.timestamp).toBeDefined(); + expect(new Date(log.timestamp).getTime()).not.toBeNaN(); + }); + + it("should include component name", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + const log = JSON.parse(logLines[0]); + expect(log.component).toBe("cli.deprecation"); + }); + + it("should use warn level", () => { + const logger = new DeprecationLogger(mockStream); + logger.logUsage(mockUsage); + + const log = JSON.parse(logLines[0]); + expect(log.level).toBe("warn"); + }); + }); + + describe("logAnalysis", () => { + it("should log analysis summary", () => { + const result: DeprecationAnalysisResult = { + usages: [mockUsage], + hasHighSeverity: true, + metrics: { + totalUsages: 1, + byFlag: { "--old-flag": 1 }, + bySeverity: { + [DeprecationSeverity.HIGH]: 1, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 0, + }, + }, + }; + + const logger = new DeprecationLogger(mockStream); + logger.logAnalysis(result); + + expect(logLines.length).toBe(1); + const log = JSON.parse(logLines[0]); + expect(log.event).toBe("cli.deprecation.analysis"); + expect(log.totalUsages).toBe(1); + }); + + it("should use error level for high severity", () => { + const result: DeprecationAnalysisResult = { + usages: [mockUsage], + hasHighSeverity: true, + metrics: { + totalUsages: 1, + byFlag: {}, + bySeverity: { + [DeprecationSeverity.HIGH]: 1, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 0, + }, + }, + }; + + const logger = new DeprecationLogger(mockStream); + logger.logAnalysis(result); + + const log = JSON.parse(logLines[0]); + expect(log.level).toBe("error"); + }); + + it("should use warn level for non-high severity", () => { + const lowSeverityUsage: DeprecationUsage = { + ...mockUsage, + flag: { ...mockFlag, severity: DeprecationSeverity.LOW }, + }; + const result: DeprecationAnalysisResult = { + usages: [lowSeverityUsage], + hasHighSeverity: false, + metrics: { + totalUsages: 1, + byFlag: {}, + bySeverity: { + [DeprecationSeverity.HIGH]: 0, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 1, + }, + }, + }; + + const logger = new DeprecationLogger(mockStream); + logger.logAnalysis(result); + + const log = JSON.parse(logLines[0]); + expect(log.level).toBe("warn"); + }); + + it("should not log when no usages", () => { + const result: DeprecationAnalysisResult = { + usages: [], + hasHighSeverity: false, + metrics: { + totalUsages: 0, + byFlag: {}, + bySeverity: { + [DeprecationSeverity.HIGH]: 0, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 0, + }, + }, + }; + + const logger = new DeprecationLogger(mockStream); + logger.logAnalysis(result); + + expect(logLines.length).toBe(0); + }); + + it("should include high severity count", () => { + const result: DeprecationAnalysisResult = { + usages: [mockUsage], + hasHighSeverity: true, + metrics: { + totalUsages: 3, + byFlag: {}, + bySeverity: { + [DeprecationSeverity.HIGH]: 2, + [DeprecationSeverity.MEDIUM]: 1, + [DeprecationSeverity.LOW]: 0, + }, + }, + }; + + const logger = new DeprecationLogger(mockStream); + logger.logAnalysis(result); + + const log = JSON.parse(logLines[0]); + expect(log.highSeverityCount).toBe(2); + }); + }); + + describe("logStart", () => { + it("should log start event", () => { + const logger = new DeprecationLogger(mockStream); + logger.logStart("test-123"); + + expect(logLines.length).toBe(1); + const log = JSON.parse(logLines[0]); + expect(log.event).toBe("cli.deprecation.check.start"); + expect(log.correlationId).toBe("test-123"); + expect(log.level).toBe("info"); + }); + }); + + describe("logComplete", () => { + it("should log completion event", () => { + const logger = new DeprecationLogger(mockStream); + logger.logComplete("test-123", 2, 150); + + expect(logLines.length).toBe(1); + const log = JSON.parse(logLines[0]); + expect(log.event).toBe("cli.deprecation.check.complete"); + expect(log.correlationId).toBe("test-123"); + expect(log.totalUsages).toBe(2); + expect(log.message).toContain("150ms"); + expect(log.level).toBe("info"); + }); + }); + + describe("error handling", () => { + it("should not throw on write errors", () => { + const errorStream = new Writable({ + write(chunk, encoding, callback) { + callback(new Error("Write failed")); + }, + }) as NodeJS.WriteStream; + + const logger = new DeprecationLogger(errorStream); + + // Should not throw + expect(() => logger.logUsage(mockUsage)).not.toThrow(); + }); + }); + + describe("global logger", () => { + afterEach(() => { + resetDeprecationLogger(); + }); + + it("should return singleton instance", () => { + const logger1 = getDeprecationLogger(); + const logger2 = getDeprecationLogger(); + expect(logger1).toBe(logger2); + }); + + it("should allow setting custom logger", () => { + const customLogger = new DeprecationLogger(mockStream); + setDeprecationLogger(customLogger); + + const retrieved = getDeprecationLogger(); + expect(retrieved).toBe(customLogger); + }); + + it("should reset global logger", () => { + const logger1 = getDeprecationLogger(); + resetDeprecationLogger(); + const logger2 = getDeprecationLogger(); + + expect(logger1).not.toBe(logger2); + }); + }); +}); diff --git a/packages/cli/src/deprecation/__tests__/metrics.spec.ts b/packages/cli/src/deprecation/__tests__/metrics.spec.ts new file mode 100644 index 00000000..ca0cc0f8 --- /dev/null +++ b/packages/cli/src/deprecation/__tests__/metrics.spec.ts @@ -0,0 +1,221 @@ +/** + * Unit tests for deprecation metrics collector. + */ + +import { + MetricsCollector, + getMetricsCollector, + resetMetricsCollector, + setMetricsCollector, +} from "../metrics"; +import { DeprecationAnalysisResult, DeprecationSeverity } from "../types"; + +const mockAnalysisResult = ( + totalUsages: number, + byFlag: Record = {}, + high: number = 0, + medium: number = 0, + low: number = 0, + firstSeen?: Date, + lastSeen?: Date +): DeprecationAnalysisResult => ({ + usages: [], + hasHighSeverity: high > 0, + metrics: { + totalUsages, + byFlag, + bySeverity: { + [DeprecationSeverity.HIGH]: high, + [DeprecationSeverity.MEDIUM]: medium, + [DeprecationSeverity.LOW]: low, + }, + firstSeen, + lastSeen, + }, +}); + +describe("MetricsCollector", () => { + let collector: MetricsCollector; + + beforeEach(() => { + collector = new MetricsCollector(); + }); + + describe("record", () => { + it("should record metrics from analysis result", () => { + collector.record( + mockAnalysisResult(2, { "--flag1": 1, "--flag2": 1 }, 1, 1, 0) + ); + + const metrics = collector.getMetrics(); + expect(metrics.totalUsages).toBe(2); + expect(metrics.byFlag["--flag1"]).toBe(1); + expect(metrics.byFlag["--flag2"]).toBe(1); + expect(metrics.bySeverity.high).toBe(1); + expect(metrics.bySeverity.medium).toBe(1); + expect(metrics.bySeverity.low).toBe(0); + }); + + it("should aggregate across multiple recordings", () => { + collector.record(mockAnalysisResult(2, { "--flag1": 2 }, 2, 0, 0)); + collector.record(mockAnalysisResult(3, { "--flag2": 3 }, 1, 2, 0)); + collector.record(mockAnalysisResult(1, { "--flag1": 1 }, 0, 0, 1)); + + const metrics = collector.getMetrics(); + expect(metrics.totalUsages).toBe(6); + expect(metrics.byFlag["--flag1"]).toBe(3); + expect(metrics.byFlag["--flag2"]).toBe(3); + expect(metrics.bySeverity.high).toBe(3); + expect(metrics.bySeverity.medium).toBe(2); + expect(metrics.bySeverity.low).toBe(1); + }); + + it("should track first and last seen timestamps", () => { + const date1 = new Date("2024-02-20T10:00:00Z"); + const date2 = new Date("2024-02-20T11:00:00Z"); + const date3 = new Date("2024-02-20T09:00:00Z"); + + collector.record(mockAnalysisResult(1, {}, 1, 0, 0, date1, date1)); + collector.record(mockAnalysisResult(1, {}, 0, 1, 0, date2, date2)); + collector.record(mockAnalysisResult(1, {}, 0, 0, 1, date3, date3)); + + const metrics = collector.getMetrics(); + expect(metrics.firstSeen).toEqual(date3); // earliest + expect(metrics.lastSeen).toEqual(date2); // latest + }); + + it("should handle null first/last seen", () => { + collector.record(mockAnalysisResult(1, {}, 1, 0, 0)); + collector.record( + mockAnalysisResult( + 1, + {}, + 0, + 1, + 0, + new Date("2024-02-20T10:00:00Z"), + new Date("2024-02-20T10:00:00Z") + ) + ); + + const metrics = collector.getMetrics(); + expect(metrics.firstSeen).toBeDefined(); + expect(metrics.lastSeen).toBeDefined(); + }); + + it("should increment invocation count", () => { + expect(collector.getMetrics().invocationCount).toBe(0); + + collector.record(mockAnalysisResult(1, {}, 1, 0, 0)); + expect(collector.getMetrics().invocationCount).toBe(1); + + collector.record(mockAnalysisResult(2, {}, 0, 1, 1)); + expect(collector.getMetrics().invocationCount).toBe(2); + }); + + it("should handle zero usages", () => { + collector.record(mockAnalysisResult(0, {}, 0, 0, 0)); + + const metrics = collector.getMetrics(); + expect(metrics.totalUsages).toBe(0); + expect(metrics.bySeverity.high).toBe(0); + expect(metrics.bySeverity.medium).toBe(0); + expect(metrics.bySeverity.low).toBe(0); + expect(metrics.invocationCount).toBe(1); + }); + }); + + describe("reset", () => { + it("should reset all metrics", () => { + collector.record(mockAnalysisResult(5, { "--flag": 5 }, 3, 2, 0)); + collector.record(mockAnalysisResult(3, { "--other": 3 }, 0, 2, 1)); + + expect(collector.getMetrics().totalUsages).toBe(8); + expect(collector.getMetrics().invocationCount).toBe(2); + + collector.reset(); + + const metrics = collector.getMetrics(); + expect(metrics.totalUsages).toBe(0); + expect(metrics.byFlag).toEqual({}); + expect(metrics.bySeverity.high).toBe(0); + expect(metrics.bySeverity.medium).toBe(0); + expect(metrics.bySeverity.low).toBe(0); + expect(metrics.invocationCount).toBe(0); + expect(metrics.firstSeen).toBeUndefined(); + expect(metrics.lastSeen).toBeUndefined(); + }); + }); + + describe("toJSON", () => { + it("should serialize metrics to JSON", () => { + collector.record( + mockAnalysisResult( + 2, + { "--test": 2 }, + 2, + 0, + 0, + new Date("2024-02-20T10:00:00Z"), + new Date("2024-02-20T10:00:00Z") + ) + ); + + const json = collector.toJSON(); + expect(json).toBeTruthy(); + + const parsed = JSON.parse(json); + expect(parsed.totalUsages).toBe(2); + expect(parsed.byFlag["--test"]).toBe(2); + expect(parsed.invocationCount).toBe(1); + }); + + it("should handle empty collector", () => { + const json = collector.toJSON(); + expect(json).toBeTruthy(); + + const parsed = JSON.parse(json); + expect(parsed.totalUsages).toBe(0); + expect(parsed.invocationCount).toBe(0); + }); + }); + + describe("global collector", () => { + afterEach(() => { + resetMetricsCollector(); + }); + + it("should return singleton instance", () => { + const collector1 = getMetricsCollector(); + const collector2 = getMetricsCollector(); + expect(collector1).toBe(collector2); + }); + + it("should allow setting custom collector", () => { + const customCollector = new MetricsCollector(); + setMetricsCollector(customCollector); + + const retrieved = getMetricsCollector(); + expect(retrieved).toBe(customCollector); + }); + + it("should reset global collector", () => { + const collector1 = getMetricsCollector(); + collector1.record(mockAnalysisResult(1, {}, 1, 0, 0)); + + resetMetricsCollector(); + const collector2 = getMetricsCollector(); + + expect(collector1).not.toBe(collector2); + expect(collector2.getMetrics().totalUsages).toBe(0); + }); + + it("should preserve existing data when not reset", () => { + const collector = getMetricsCollector(); + collector.record(mockAnalysisResult(3, { "--flag": 3 }, 2, 1, 0)); + + const sameCollector = getMetricsCollector(); + expect(sameCollector.getMetrics().totalUsages).toBe(3); + }); + }); +}); diff --git a/packages/cli/src/deprecation/config.ts b/packages/cli/src/deprecation/config.ts new file mode 100644 index 00000000..acf4ede9 --- /dev/null +++ b/packages/cli/src/deprecation/config.ts @@ -0,0 +1,96 @@ +/** + * Default deprecation configuration for GasGuard CLI flags. + * + * This file defines which flags are deprecated, their replacements, + * and when they will be removed. + */ + +import { DeprecatedFlag, DeprecationConfig, DeprecationSeverity } from "./types"; + +/** + * Registry of deprecated CLI flags. + * + * Add new entries here when deprecating flags. Include clear migration + * guidance and timeline information. + */ +export const DEPRECATED_FLAGS: DeprecatedFlag[] = [ + { + flag: "--analyze-local", + replacement: "scan", + message: "The '--analyze-local' flag is deprecated. Use the 'scan' command instead.", + severity: DeprecationSeverity.HIGH, + deprecatedIn: "1.0.0", + removedIn: "2.0.0", + documentationUrl: "https://docs.gasguard.io/migration/analyze-local-to-scan", + }, + { + flag: "--no-progress", + replacement: "--quiet", + message: "The '--no-progress' flag is deprecated. Use '--quiet' for silent output.", + severity: DeprecationSeverity.MEDIUM, + deprecatedIn: "1.0.0", + removedIn: "1.5.0", + documentationUrl: "https://docs.gasguard.io/cli/output-options", + }, + { + flag: "--fix-preview", + replacement: "config: output.fixPreview", + message: "The '--fix-preview' flag is deprecated. Use 'output.fixPreview' in your config file instead.", + severity: DeprecationSeverity.LOW, + deprecatedIn: "1.0.0", + documentationUrl: "https://docs.gasguard.io/config/output", + }, +]; + +/** + * Default deprecation system configuration. + */ +export const DEFAULT_DEPRECATION_CONFIG: DeprecationConfig = { + deprecatedFlags: DEPRECATED_FLAGS, + enableWarnings: true, + enableMetrics: true, + failOnHighSeverity: false, +}; + +/** + * Create a lookup map for fast flag resolution. + */ +export function createFlagLookup( + flags: DeprecatedFlag[] +): Map { + const lookup = new Map(); + + for (const flag of flags) { + // Support both --flag and -flag formats + const normalized = flag.flag.startsWith("--") ? flag.flag : `--${flag.flag}`; + lookup.set(normalized, flag); + + // Also store without dashes for flexible matching + const withoutDashes = flag.flag.replace(/^-+/, ""); + lookup.set(withoutDashes, flag); + } + + return lookup; +} + +/** + * Load deprecation configuration from environment or defaults. + */ +export function loadDeprecationConfig(): DeprecationConfig { + const config = { ...DEFAULT_DEPRECATION_CONFIG }; + + // Allow environment variable overrides + if (process.env.GASGUARD_DEPRECATION_WARNINGS === "false") { + config.enableWarnings = false; + } + + if (process.env.GASGUARD_DEPRECATION_METRICS === "false") { + config.enableMetrics = false; + } + + if (process.env.GASGUARD_FAIL_ON_DEPRECATED === "true") { + config.failOnHighSeverity = true; + } + + return config; +} diff --git a/packages/cli/src/deprecation/detector.ts b/packages/cli/src/deprecation/detector.ts new file mode 100644 index 00000000..0228f94e --- /dev/null +++ b/packages/cli/src/deprecation/detector.ts @@ -0,0 +1,299 @@ +/** + * Core deprecation detection and warning system. + * + * Analyzes CLI arguments to detect deprecated flag usage and emit + * structured warnings with actionable guidance. + */ + +import { randomUUID } from "crypto"; +import chalk from "chalk"; +import { + DeprecatedFlag, + DeprecationAnalysisResult, + DeprecationConfig, + DeprecationMetrics, + DeprecationSeverity, + DeprecationUsage, +} from "./types"; +import { createFlagLookup } from "./config"; + +/** + * Analyzes CLI arguments for deprecated flag usage. + */ +export class DeprecationDetector { + private readonly flagLookup: Map; + private readonly config: DeprecationConfig; + private usages: DeprecationUsage[] = []; + + constructor(config: DeprecationConfig) { + this.config = config; + this.flagLookup = createFlagLookup(config.deprecatedFlags); + } + + /** + * Analyze an array of CLI arguments for deprecated flags. + * + * @param argv - Command-line arguments to analyze + * @param correlationId - Optional correlation ID for request tracing + * @returns Analysis result with detected usages and metrics + */ + public analyze( + argv: string[], + correlationId?: string + ): DeprecationAnalysisResult { + this.usages = []; + const cid = correlationId || `dep_${randomUUID()}`; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + // Skip non-flag arguments + if (!arg.startsWith("-")) { + continue; + } + + // Handle --flag=value format + const [flagPart, valuePart] = arg.split("=", 2); + const deprecatedFlag = this.findDeprecatedFlag(flagPart); + + if (deprecatedFlag) { + const usage: DeprecationUsage = { + flag: deprecatedFlag, + value: valuePart, + position: i, + timestamp: new Date(), + correlationId: cid, + }; + + this.usages.push(usage); + } + } + + const metrics = this.calculateMetrics(); + const hasHighSeverity = this.usages.some( + (u) => u.flag.severity === DeprecationSeverity.HIGH + ); + + return { + usages: this.usages, + hasHighSeverity, + metrics, + }; + } + + /** + * Find a deprecated flag by its name (handles various formats). + */ + private findDeprecatedFlag(flag: string): DeprecatedFlag | undefined { + // Try exact match first + if (this.flagLookup.has(flag)) { + return this.flagLookup.get(flag); + } + + // Try normalized format (with --) + const normalized = flag.startsWith("--") ? flag : `--${flag}`; + if (this.flagLookup.has(normalized)) { + return this.flagLookup.get(normalized); + } + + // Try without dashes + const withoutDashes = flag.replace(/^-+/, ""); + return this.flagLookup.get(withoutDashes); + } + + /** + * Calculate aggregated metrics from detected usages. + */ + private calculateMetrics(): DeprecationMetrics { + const byFlag: Record = {}; + const bySeverity: Record = { + [DeprecationSeverity.HIGH]: 0, + [DeprecationSeverity.MEDIUM]: 0, + [DeprecationSeverity.LOW]: 0, + }; + + let firstSeen: Date | undefined; + let lastSeen: Date | undefined; + + for (const usage of this.usages) { + // Count by flag + byFlag[usage.flag.flag] = (byFlag[usage.flag.flag] || 0) + 1; + + // Count by severity + bySeverity[usage.flag.severity]++; + + // Track timestamps + if (!firstSeen || usage.timestamp < firstSeen) { + firstSeen = usage.timestamp; + } + if (!lastSeen || usage.timestamp > lastSeen) { + lastSeen = usage.timestamp; + } + } + + return { + totalUsages: this.usages.length, + byFlag, + bySeverity, + firstSeen, + lastSeen, + }; + } + + /** + * Get all detected usages. + */ + public getUsages(): DeprecationUsage[] { + return [...this.usages]; + } + + /** + * Clear all detected usages (useful for testing or reuse). + */ + public clear(): void { + this.usages = []; + } +} + +/** + * Format a deprecation warning message for console output. + */ +export function formatDeprecationWarning(usage: DeprecationUsage): string { + const { flag } = usage; + const lines: string[] = []; + + // Header with severity indicator + const severityColor = getSeverityColor(flag.severity); + const severityLabel = flag.severity.toUpperCase(); + lines.push( + chalk.yellow("\n⚠️ Deprecation Warning ") + + severityColor(`[${severityLabel}]`) + ); + + // Main message + lines.push(chalk.white(` ${flag.message}`)); + + // Replacement guidance + if (flag.replacement) { + lines.push(chalk.cyan(` → Use: ${flag.replacement}`)); + } + + // Removal timeline + if (flag.removedIn) { + lines.push( + chalk.red(` ⏰ Will be removed in version ${flag.removedIn}`) + ); + } + + // Documentation link + if (flag.documentationUrl) { + lines.push(chalk.dim(` 📖 ${flag.documentationUrl}`)); + } + + return lines.join("\n"); +} + +/** + * Format multiple deprecation warnings as a summary. + */ +export function formatDeprecationSummary( + result: DeprecationAnalysisResult +): string { + if (result.usages.length === 0) { + return ""; + } + + const lines: string[] = []; + + lines.push( + chalk.yellow.bold( + `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━` + ) + ); + lines.push( + chalk.yellow.bold(` DEPRECATION WARNINGS (${result.usages.length})`) + ); + lines.push( + chalk.yellow.bold( + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━` + ) + ); + + // Group by flag to avoid duplicate messages + const uniqueFlags = new Map(); + for (const usage of result.usages) { + if (!uniqueFlags.has(usage.flag.flag)) { + uniqueFlags.set(usage.flag.flag, usage); + } + } + + // Display each unique deprecation + for (const usage of uniqueFlags.values()) { + lines.push(formatDeprecationWarning(usage)); + } + + // Summary statistics + lines.push( + chalk.yellow.bold( + `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━` + ) + ); + lines.push( + chalk.dim( + ` Total: ${result.metrics.totalUsages} | ` + + `High: ${result.metrics.bySeverity.high} | ` + + `Medium: ${result.metrics.bySeverity.medium} | ` + + `Low: ${result.metrics.bySeverity.low}` + ) + ); + lines.push( + chalk.yellow.bold( + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + ) + ); + + return lines.join("\n"); +} + +/** + * Get chalk color function for a severity level. + */ +function getSeverityColor( + severity: DeprecationSeverity +): (text: string) => string { + switch (severity) { + case DeprecationSeverity.HIGH: + return chalk.red.bold; + case DeprecationSeverity.MEDIUM: + return chalk.yellow.bold; + case DeprecationSeverity.LOW: + return chalk.blue.bold; + default: + return chalk.white; + } +} + +/** + * Emit deprecation warnings to stderr. + */ +export function emitDeprecationWarnings( + result: DeprecationAnalysisResult, + stream: NodeJS.WriteStream = process.stderr +): void { + if (result.usages.length === 0) { + return; + } + + const summary = formatDeprecationSummary(result); + stream.write(summary); +} + +/** + * Check if deprecation analysis should cause process failure. + */ +export function shouldFailOnDeprecation( + result: DeprecationAnalysisResult, + config: DeprecationConfig +): boolean { + return config.failOnHighSeverity && result.hasHighSeverity; +} diff --git a/packages/cli/src/deprecation/index.ts b/packages/cli/src/deprecation/index.ts new file mode 100644 index 00000000..d5bc75f4 --- /dev/null +++ b/packages/cli/src/deprecation/index.ts @@ -0,0 +1,95 @@ +/** + * Deprecation warning system for GasGuard CLI flags. + * + * This module provides detection, warning, and tracking capabilities + * for deprecated CLI flags across the analyzer platform. + * + * @module deprecation + */ + +export * from "./types"; +export * from "./config"; +export * from "./detector"; +export * from "./logger"; +export * from "./metrics"; +export * from "./integration"; + +import { randomUUID } from "crypto"; +import { loadDeprecationConfig } from "./config"; +import { + DeprecationDetector, + emitDeprecationWarnings, + shouldFailOnDeprecation, +} from "./detector"; +import { getDeprecationLogger } from "./logger"; +import { getMetricsCollector } from "./metrics"; +import { DeprecationAnalysisResult } from "./types"; + +/** + * Main entry point for deprecation checking in CLI commands. + * + * Analyzes argv for deprecated flags, emits warnings, tracks metrics, + * and optionally fails the process on high-severity deprecations. + * + * @param argv - Command-line arguments to check + * @param correlationId - Optional correlation ID for request tracing + * @returns Analysis result with detected usages + * + * @example + * ```typescript + * import { checkDeprecatedFlags } from './deprecation'; + * + * const result = checkDeprecatedFlags(process.argv); + * if (result.hasHighSeverity) { + * console.error('High-severity deprecated flags detected!'); + * } + * ``` + */ +export function checkDeprecatedFlags( + argv: string[], + correlationId?: string +): DeprecationAnalysisResult { + const config = loadDeprecationConfig(); + const cid = correlationId || `dep_${randomUUID()}`; + const logger = getDeprecationLogger(); + const metricsCollector = getMetricsCollector(); + + const startTime = Date.now(); + + // Log start of check + if (config.enableWarnings) { + logger.logStart(cid); + } + + // Perform analysis + const detector = new DeprecationDetector(config); + const result = detector.analyze(argv, cid); + + // Emit warnings if enabled + if (config.enableWarnings && result.usages.length > 0) { + emitDeprecationWarnings(result); + logger.logAnalysis(result); + } + + // Track metrics if enabled + if (config.enableMetrics) { + metricsCollector.record(result); + } + + // Log completion + const durationMs = Date.now() - startTime; + if (config.enableWarnings) { + logger.logComplete(cid, result.usages.length, durationMs); + } + + // Handle failure on high severity + if (shouldFailOnDeprecation(result, config)) { + process.stderr.write( + "\n❌ Exiting due to high-severity deprecated flags. " + + "Set GASGUARD_FAIL_ON_DEPRECATED=false to continue.\n\n" + ); + process.exit(1); + } + + return result; +} diff --git a/packages/cli/src/deprecation/integration.ts b/packages/cli/src/deprecation/integration.ts new file mode 100644 index 00000000..5e61e933 --- /dev/null +++ b/packages/cli/src/deprecation/integration.ts @@ -0,0 +1,107 @@ +/** + * Integration utilities for commander.js and CLI commands. + * + * Provides hooks and helpers to integrate deprecation checking + * into the command parsing lifecycle without breaking existing functionality. + */ + +import { Command } from "commander"; +import { checkDeprecatedFlags } from "./index"; + +/** + * Attach deprecation checking to a Commander.js program. + * + * This adds a preAction hook that checks for deprecated flags + * before any command executes. + * + * @param program - Commander.js program instance + * @returns The same program instance (for chaining) + * + * @example + * ```typescript + * import { Command } from 'commander'; + * import { attachDeprecationHook } from './deprecation/integration'; + * + * const program = new Command(); + * attachDeprecationHook(program); + * ``` + */ +export function attachDeprecationHook(program: Command): Command { + // Store the original preAction hook if it exists + const existingHooks = (program as any)._lifeCycleHooks?.preAction || []; + + // Add our deprecation check hook + program.hook("preAction", (thisCommand, actionCommand) => { + // Get the raw arguments that were passed + const argv = process.argv; + + // Check for deprecated flags + checkDeprecatedFlags(argv); + }); + + return program; +} + +/** + * Create a wrapper for command action handlers that checks for deprecated flags. + * + * This is useful for commands that need fine-grained control over when + * deprecation checking occurs. + * + * @param handler - Original command action handler + * @returns Wrapped handler with deprecation checking + * + * @example + * ```typescript + * import { withDeprecationCheck } from './deprecation/integration'; + * + * const scanCommand = new Command('scan') + * .action(withDeprecationCheck(async (options) => { + * // Your command logic here + * })); + * ``` + */ +export function withDeprecationCheck any>( + handler: T +): T { + return ((...args: any[]) => { + // Check for deprecated flags before executing handler + checkDeprecatedFlags(process.argv); + + // Execute the original handler + return handler(...args); + }) as T; +} + +/** + * Check if a specific flag is deprecated. + * + * Useful for conditional logic in commands that need to handle + * deprecated flags differently. + * + * @param flag - Flag name to check + * @returns True if the flag is deprecated + * + * @example + * ```typescript + * if (isFlagDeprecated('--analyze-local')) { + * console.log('Please use the scan command instead'); + * } + * ``` + */ +export function isFlagDeprecated(flag: string): boolean { + const { DEPRECATED_FLAGS } = require("./config"); + return DEPRECATED_FLAGS.some((df: any) => df.flag === flag); +} + +/** + * Get the replacement for a deprecated flag. + * + * @param flag - Deprecated flag name + * @returns Replacement flag/command, or undefined if no replacement + */ +export function getFlagReplacement(flag: string): string | undefined { + const { DEPRECATED_FLAGS } = require("./config"); + const deprecated = DEPRECATED_FLAGS.find((df: any) => df.flag === flag); + return deprecated?.replacement; +} diff --git a/packages/cli/src/deprecation/logger.ts b/packages/cli/src/deprecation/logger.ts new file mode 100644 index 00000000..24cef783 --- /dev/null +++ b/packages/cli/src/deprecation/logger.ts @@ -0,0 +1,157 @@ +/** + * Structured logging for deprecation events. + * + * Provides operational visibility into deprecation usage patterns + * while excluding sensitive information. + */ + +import { DeprecationAnalysisResult, DeprecationUsage } from "./types"; + +/** + * Log entry for deprecation events. + */ +export interface DeprecationLogEntry { + timestamp: string; + level: "warn" | "info" | "error"; + event: string; + correlationId: string; + message: string; + flag?: string; + severity?: string; + replacement?: string; + position?: number; + totalUsages?: number; + highSeverityCount?: number; +} + +/** + * Logger for deprecation events that writes structured JSON to stderr. + */ +export class DeprecationLogger { + private readonly stream: NodeJS.WriteStream; + + constructor(stream: NodeJS.WriteStream = process.stderr) { + this.stream = stream; + } + + /** + * Log a single deprecated flag usage. + */ + public logUsage(usage: DeprecationUsage): void { + const entry: DeprecationLogEntry = { + timestamp: new Date().toISOString(), + level: "warn", + event: "cli.deprecation.detected", + correlationId: usage.correlationId, + message: `Deprecated flag detected: ${usage.flag.flag}`, + flag: usage.flag.flag, + severity: usage.flag.severity, + replacement: usage.flag.replacement, + position: usage.position, + }; + + this.writeLogLine(entry); + } + + /** + * Log a deprecation analysis summary. + */ + public logAnalysis(result: DeprecationAnalysisResult): void { + if (result.usages.length === 0) { + return; + } + + const entry: DeprecationLogEntry = { + timestamp: new Date().toISOString(), + level: result.hasHighSeverity ? "error" : "warn", + event: "cli.deprecation.analysis", + correlationId: + result.usages[0]?.correlationId || `dep_${Date.now()}`, + message: `Deprecation analysis completed: ${result.metrics.totalUsages} usage(s) detected`, + totalUsages: result.metrics.totalUsages, + highSeverityCount: result.metrics.bySeverity.high, + }; + + this.writeLogLine(entry); + } + + /** + * Log start of deprecation check. + */ + public logStart(correlationId: string): void { + const entry: DeprecationLogEntry = { + timestamp: new Date().toISOString(), + level: "info", + event: "cli.deprecation.check.start", + correlationId, + message: "Starting deprecation check", + }; + + this.writeLogLine(entry); + } + + /** + * Log completion of deprecation check. + */ + public logComplete( + correlationId: string, + usageCount: number, + durationMs: number + ): void { + const entry: DeprecationLogEntry = { + timestamp: new Date().toISOString(), + level: "info", + event: "cli.deprecation.check.complete", + correlationId, + message: `Deprecation check completed in ${durationMs}ms`, + totalUsages: usageCount, + }; + + this.writeLogLine(entry); + } + + /** + * Write a structured log line to stderr. + */ + private writeLogLine(entry: DeprecationLogEntry): void { + try { + const line = JSON.stringify({ + component: "cli.deprecation", + ...entry, + }); + this.stream.write(`${line}\n`); + } catch (error) { + // Fail silently to avoid disrupting the CLI + // In production, this should never happen with well-formed objects + } + } +} + +/** + * Global singleton logger instance. + */ +let globalLogger: DeprecationLogger | undefined; + +/** + * Get or create the global deprecation logger. + */ +export function getDeprecationLogger(): DeprecationLogger { + if (!globalLogger) { + globalLogger = new DeprecationLogger(); + } + return globalLogger; +} + +/** + * Set a custom global logger (useful for testing). + */ +export function setDeprecationLogger(logger: DeprecationLogger): void { + globalLogger = logger; +} + +/** + * Reset the global logger (useful for testing). + */ +export function resetDeprecationLogger(): void { + globalLogger = undefined; +} diff --git a/packages/cli/src/deprecation/metrics.ts b/packages/cli/src/deprecation/metrics.ts new file mode 100644 index 00000000..9dd363c7 --- /dev/null +++ b/packages/cli/src/deprecation/metrics.ts @@ -0,0 +1,138 @@ +/** + * Metrics collection for deprecation tracking. + * + * Provides thread-safe aggregation of deprecation usage patterns + * for operational visibility and planning. + */ + +import { + DeprecationAnalysisResult, + DeprecationMetrics, + DeprecationSeverity, +} from "./types"; + +/** + * Collector for aggregating deprecation metrics across multiple invocations. + */ +export class MetricsCollector { + private totalUsages = 0; + private byFlag: Map = new Map(); + private bySeverity: Map = new Map([ + [DeprecationSeverity.HIGH, 0], + [DeprecationSeverity.MEDIUM, 0], + [DeprecationSeverity.LOW, 0], + ]); + private firstSeen?: Date; + private lastSeen?: Date; + private invocationCount = 0; + + /** + * Record a deprecation analysis result. + */ + public record(result: DeprecationAnalysisResult): void { + this.invocationCount++; + this.totalUsages += result.metrics.totalUsages; + + // Aggregate by flag + for (const [flag, count] of Object.entries(result.metrics.byFlag)) { + const current = this.byFlag.get(flag) || 0; + this.byFlag.set(flag, current + count); + } + + // Aggregate by severity + for (const [severity, count] of Object.entries( + result.metrics.bySeverity + )) { + const sev = severity as DeprecationSeverity; + const current = this.bySeverity.get(sev) || 0; + this.bySeverity.set(sev, current + count); + } + + // Track timestamps + if (result.metrics.firstSeen) { + if (!this.firstSeen || result.metrics.firstSeen < this.firstSeen) { + this.firstSeen = result.metrics.firstSeen; + } + } + + if (result.metrics.lastSeen) { + if (!this.lastSeen || result.metrics.lastSeen > this.lastSeen) { + this.lastSeen = result.metrics.lastSeen; + } + } + } + + /** + * Get current aggregated metrics. + */ + public getMetrics(): DeprecationMetrics & { invocationCount: number } { + return { + totalUsages: this.totalUsages, + byFlag: Object.fromEntries(this.byFlag), + bySeverity: { + [DeprecationSeverity.HIGH]: + this.bySeverity.get(DeprecationSeverity.HIGH) || 0, + [DeprecationSeverity.MEDIUM]: + this.bySeverity.get(DeprecationSeverity.MEDIUM) || 0, + [DeprecationSeverity.LOW]: + this.bySeverity.get(DeprecationSeverity.LOW) || 0, + }, + firstSeen: this.firstSeen, + lastSeen: this.lastSeen, + invocationCount: this.invocationCount, + }; + } + + /** + * Reset all metrics (useful for testing). + */ + public reset(): void { + this.totalUsages = 0; + this.byFlag.clear(); + this.bySeverity.set(DeprecationSeverity.HIGH, 0); + this.bySeverity.set(DeprecationSeverity.MEDIUM, 0); + this.bySeverity.set(DeprecationSeverity.LOW, 0); + this.firstSeen = undefined; + this.lastSeen = undefined; + this.invocationCount = 0; + } + + /** + * Export metrics as JSON for persistence or reporting. + */ + public toJSON(): string { + return JSON.stringify(this.getMetrics(), null, 2); + } +} + +/** + * Global singleton metrics collector. + */ +let globalCollector: MetricsCollector | undefined; + +/** + * Get or create the global metrics collector. + */ +export function getMetricsCollector(): MetricsCollector { + if (!globalCollector) { + globalCollector = new MetricsCollector(); + } + return globalCollector; +} + +/** + * Set a custom global metrics collector (useful for testing). + */ +export function setMetricsCollector(collector: MetricsCollector): void { + globalCollector = collector; +} + +/** + * Reset the global metrics collector (useful for testing). + */ +export function resetMetricsCollector(): void { + if (globalCollector) { + globalCollector.reset(); + } + globalCollector = undefined; +} diff --git a/packages/cli/src/deprecation/types.ts b/packages/cli/src/deprecation/types.ts new file mode 100644 index 00000000..639252fa --- /dev/null +++ b/packages/cli/src/deprecation/types.ts @@ -0,0 +1,96 @@ +/** + * Types for CLI flag deprecation system. + * + * This module defines the core types for tracking and warning about deprecated + * CLI flags across the GasGuard analyzer platform. + */ + +/** + * Severity level for deprecation warnings. + */ +export enum DeprecationSeverity { + /** Flag will be removed in the next major version */ + HIGH = "high", + /** Flag will be removed in a future version */ + MEDIUM = "medium", + /** Flag is soft-deprecated but no removal planned yet */ + LOW = "low", +} + +/** + * Metadata about a deprecated CLI flag. + */ +export interface DeprecatedFlag { + /** The deprecated flag name (e.g., "--no-summary") */ + flag: string; + /** Replacement flag or configuration option */ + replacement?: string; + /** Human-readable deprecation message */ + message: string; + /** Severity of the deprecation */ + severity: DeprecationSeverity; + /** Version when the flag was deprecated */ + deprecatedIn: string; + /** Version when the flag will be removed (if known) */ + removedIn?: string; + /** Link to migration guide or documentation */ + documentationUrl?: string; +} + +/** + * Configuration for the deprecation warning system. + */ +export interface DeprecationConfig { + /** List of deprecated flags */ + deprecatedFlags: DeprecatedFlag[]; + /** Whether to emit warnings to stderr */ + enableWarnings: boolean; + /** Whether to track deprecation usage metrics */ + enableMetrics: boolean; + /** Whether to fail on high-severity deprecations */ + failOnHighSeverity: boolean; +} + +/** + * A detected usage of a deprecated flag. + */ +export interface DeprecationUsage { + /** The deprecated flag that was used */ + flag: DeprecatedFlag; + /** The actual argument value provided (if any) */ + value?: string; + /** Position in argv where the flag was found */ + position: number; + /** Timestamp of detection */ + timestamp: Date; + /** Correlation ID for request tracing */ + correlationId: string; +} + +/** + * Aggregated metrics for deprecation tracking. + */ +export interface DeprecationMetrics { + /** Total number of deprecated flags detected */ + totalUsages: number; + /** Usages grouped by flag name */ + byFlag: Record; + /** Usages grouped by severity */ + bySeverity: Record; + /** First detection timestamp */ + firstSeen?: Date; + /** Last detection timestamp */ + lastSeen?: Date; +} + +/** + * Result of deprecation analysis on CLI arguments. + */ +export interface DeprecationAnalysisResult { + /** List of detected deprecated flag usages */ + usages: DeprecationUsage[]; + /** Whether any high-severity deprecations were found */ + hasHighSeverity: boolean; + /** Aggregated metrics */ + metrics: DeprecationMetrics; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b237dd8f..a1d0fc0b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -11,9 +11,15 @@ import { policyCommand } from "./commands/policy"; import { backlogCommand } from "./commands/backlog"; import { compareCommand } from "./commands/compare"; import { setCliOptions } from "./output"; +import { checkDeprecatedFlags } from "./deprecation"; +import { attachDeprecationHook } from "./deprecation/integration"; const program = new Command(); +// Check for deprecated flags before parsing +// This runs early to catch flags at the top level +checkDeprecatedFlags(process.argv); + // Configure CLI program .name("gasguard")