diff --git a/bin.ts b/bin.ts index 2f0b20363..87003e0fc 100644 --- a/bin.ts +++ b/bin.ts @@ -72,6 +72,7 @@ import { mcpAnalyticsCommand } from './src/commands/mcp-analytics'; import { replayVisionCommand } from './src/commands/replay-vision'; import { aiObservabilityCommand } from './src/commands/ai-observability'; import { metricsCommand } from './src/commands/metrics'; +import { cullFeatureFlagsCommand } from './src/commands/cull-feature-flags'; import { auditCommand } from './src/commands/audit'; import { doctorCommand } from './src/commands/doctor'; import { migrateCommand } from './src/commands/migrate'; @@ -105,6 +106,7 @@ Wizard.use(basicIntegrationCommand) .use(replayVisionCommand) .use(aiObservabilityCommand) .use(metricsCommand) + .use(cullFeatureFlagsCommand) .use(cliCommand) .use(auditCommand) .use(doctorCommand) diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdca..c50dc7f2b 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -106,6 +106,7 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.SourceMapsIntro]: [confirmSetupAction], [ScreenId.MigrationIntro]: [confirmSetupAction], [ScreenId.AgentSkillIntro]: [confirmSetupAction], + [ScreenId.CullIntro]: [confirmSetupAction], [ScreenId.AiObservabilityIntro]: [confirmSetupAction], [ScreenId.MetricsIntro]: [confirmSetupAction], [ScreenId.AuditIntro]: [confirmSetupAction], diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 360bd3ec6..19e6acc9f 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -281,6 +281,7 @@ export function decideE2eAction( case ScreenId.RevenueIntro: case ScreenId.MigrationIntro: case ScreenId.AgentSkillIntro: + case ScreenId.CullIntro: case ScreenId.AiObservabilityIntro: case ScreenId.MetricsIntro: case ScreenId.AuditIntro: diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 7d3da90d0..3b3e92ca3 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -17,6 +17,7 @@ import fs from 'fs'; import net from 'net'; import { spawnSync } from 'child_process'; import { startTUI } from '@ui/tui/start-tui'; +import { initLocalDev } from '@lib/local-dev'; import { VERSION } from '@lib/version'; import { Program, @@ -195,6 +196,15 @@ async function main() { // requires-interactive-mode the moment they need to ask a question. process.env.WIZARD_ASK_AUTODRIVE = '1'; + // The session flags below only describe the run; `getSkillsBaseUrl` reads + // the process-wide targets that the CLI middleware normally sets from argv. + initLocalDev({ + localDev: process.env.POSTHOG_WIZARD_LOCAL_DEV === 'true', + localMcp: envFlag('POSTHOG_WIZARD_LOCAL_MCP'), + localContextMill: envFlag('POSTHOG_WIZARD_LOCAL_CONTEXT_MILL'), + localPosthog: envFlag('POSTHOG_WIZARD_LOCAL_POSTHOG'), + }); + const { store } = startTUI(VERSION, programId); store.session = buildSession({ installDir: process.env.APP_DIR!, diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 7b891e1ca..841951dab 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -22,6 +22,7 @@ import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; import { mcpAnalyticsCommand } from '../commands/mcp-analytics'; import { replayVisionCommand } from '../commands/replay-vision'; +import { cullFeatureFlagsCommand } from '../commands/cull-feature-flags'; import { revenueCommand } from '../commands/revenue'; import { warehouseCommand } from '../commands/warehouse'; import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps'; @@ -91,6 +92,11 @@ describe('top-level command shapes', () => { expect(replayVisionCommand.children).toBeUndefined(); }); + test('cull-feature-flags is a flat skill command', () => { + expect(cullFeatureFlagsCommand.name).toBe('cull-feature-flags'); + expect(cullFeatureFlagsCommand.children).toBeUndefined(); + }); + test('warehouse is a flat skill command', () => { expect(warehouseCommand.name).toBe('warehouse'); expect(warehouseCommand.children).toBeUndefined(); diff --git a/src/commands/cull-feature-flags.ts b/src/commands/cull-feature-flags.ts new file mode 100644 index 000000000..d675eee63 --- /dev/null +++ b/src/commands/cull-feature-flags.ts @@ -0,0 +1,8 @@ +import { cullFeatureFlagsConfig } from '@lib/programs/cull-feature-flags/index'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +export const cullFeatureFlagsCommand: Command = nativeCommandFactory( + cullFeatureFlagsConfig, +); diff --git a/src/lib/agent/runner/switchboard/index.ts b/src/lib/agent/runner/switchboard/index.ts index fb4232036..4a14790f2 100644 --- a/src/lib/agent/runner/switchboard/index.ts +++ b/src/lib/agent/runner/switchboard/index.ts @@ -134,6 +134,7 @@ export const PROGRAM_BINDINGS: Partial> = { 'events-audit': DEFAULT_BINDING, 'posthog-doctor': DEFAULT_BINDING, 'web-analytics-doctor': DEFAULT_BINDING, + 'cull-feature-flags': DEFAULT_BINDING, migration: DEFAULT_BINDING, 'self-driving': DEFAULT_BINDING, 'agent-skill': DEFAULT_BINDING, diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d5..870a826e3 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -117,6 +117,12 @@ export const ERROR_CATALOG: Record = { retry: 'no', description: 'The project platform has no matching skill variant.', }, + [ErrorCodes.DetectDirtyWorkingTree]: { + group: 'detect', + retry: 'yes', + description: + 'The program edits project files and needs a clean git working tree so the edits stay revertable on their own.', + }, [ErrorCodes.DetectNoPosthogSdk]: { group: 'detect', retry: 'no', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b0..2c73b7c45 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -22,6 +22,7 @@ export const ErrorCodes = { DetectNoFramework: 'PHW_DETECT_NO_FRAMEWORK', DetectUnsupportedVersion: 'PHW_DETECT_UNSUPPORTED_VERSION', DetectUnsupportedPlatform: 'PHW_DETECT_UNSUPPORTED_PLATFORM', + DetectDirtyWorkingTree: 'PHW_DETECT_DIRTY_WORKING_TREE', DetectNoPosthogSdk: 'PHW_DETECT_NO_POSTHOG_SDK', DetectNoProjectFiles: 'PHW_DETECT_NO_PROJECT_FILES', DetectNoSources: 'PHW_DETECT_NO_SOURCES', diff --git a/src/lib/oauth/program-scopes.ts b/src/lib/oauth/program-scopes.ts index 69da4f59a..2b0e3369f 100644 --- a/src/lib/oauth/program-scopes.ts +++ b/src/lib/oauth/program-scopes.ts @@ -280,6 +280,9 @@ const PROGRAM_SCOPE_ADDITIONS: Partial> = { ], slack: CONNECT_SLACK_SCOPE_ADDITIONS, 'replay-vision': REPLAY_VISION_SCOPE_ADDITIONS, + // Disabling a flag needs feature_flag:write; the agent-skill set already + // carries the read/write pair. + 'cull-feature-flags': AGENT_SKILL_SCOPE_ADDITIONS, }; /** diff --git a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts new file mode 100644 index 000000000..5ced60b4f --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts @@ -0,0 +1,256 @@ +import { classifyFlags } from '@lib/programs/cull-feature-flags/classify'; +import type { FlagScanResult } from '@lib/programs/cull-feature-flags/scan'; +import type { FeatureFlag } from '@lib/programs/cull-feature-flags/types'; + +let nextFlagId = 1; + +function flag(overrides: Partial & { key: string }): FeatureFlag { + return { + id: nextFlagId++, + active: true, + archived: false, + deleted: false, + status: 'ACTIVE', + filters: { groups: [{ rollout_percentage: 50, properties: [] }] }, + experiment_set: [], + is_remote_configuration: false, + has_encrypted_payloads: false, + ...overrides, + }; +} + +function rollout(percentage: number | null): FeatureFlag['filters'] { + return { groups: [{ rollout_percentage: percentage, properties: [] }] }; +} + +function site(key: string, file: string, line = 1) { + return { key, file, line, api: 'useFeatureFlagEnabled' }; +} + +function scan(overrides: Partial = {}): FlagScanResult { + return { + callSites: [], + dynamicSites: [], + mentionSites: [], + usesBulkEvaluation: false, + reachableFiles: ['src/app/page.tsx', 'src/lib/flags.ts'], + filesScanned: 2, + truncated: false, + ...overrides, + }; +} + +function bucketOf( + flags: FeatureFlag[], + scanResult: FlagScanResult, + key: string, +) { + const match = classifyFlags(flags, scanResult).find((c) => c.key === key); + return match ? [match.bucket, match.verdict] : undefined; +} + +describe('classifyFlags', () => { + test('fully rolled out flag with a call site is stale', () => { + const flags = [flag({ key: 'new-checkout', filters: rollout(100) })]; + const result = scan({ + callSites: [site('new-checkout', 'src/app/page.tsx')], + }); + expect(bucketOf(flags, result, 'new-checkout')).toEqual([ + 'fully-rolled-out', + 'stale', + ]); + }); + + test('rollout null counts as 100 percent', () => { + const flags = [flag({ key: 'k', filters: rollout(null) })]; + const result = scan({ callSites: [site('k', 'src/app/page.tsx')] }); + expect(bucketOf(flags, result, 'k')).toEqual(['fully-rolled-out', 'stale']); + }); + + test('flag at 0% everywhere is stale but flagged as a possible rollback', () => { + const flags = [flag({ key: 'beta-dashboard', filters: rollout(0) })]; + const result = scan({ + callSites: [site('beta-dashboard', 'src/app/page.tsx')], + }); + expect(bucketOf(flags, result, 'beta-dashboard')).toEqual([ + 'never-enabled', + 'stale', + ]); + const [candidate] = classifyFlags(flags, result); + expect(candidate.area).toBe('Off for everyone'); + expect(candidate.reason).toContain('may be a rollback'); + }); + + test('archived flag still referenced is stale, archived and unreferenced is skipped', () => { + const flags = [ + flag({ + key: 'legacy-banner', + active: false, + archived: true, + filters: rollout(100), + }), + flag({ key: 'gone', active: false, archived: true }), + ]; + const result = scan({ + callSites: [site('legacy-banner', 'src/app/page.tsx')], + }); + expect(bucketOf(flags, result, 'legacy-banner')).toEqual([ + 'archived-still-referenced', + 'stale', + ]); + expect(bucketOf(flags, result, 'gone')).toBeUndefined(); + }); + + test('disabled flag still referenced is stale', () => { + const flags = [flag({ key: 'off', active: false })]; + const result = scan({ callSites: [site('off', 'src/app/page.tsx')] }); + expect(bucketOf(flags, result, 'off')).toEqual([ + 'disabled-but-referenced', + 'stale', + ]); + }); + + test('unreferenced flag is stale, comment-only mention is its own bucket', () => { + const flags = [ + flag({ key: 'pricing-v2-experiment' }), + flag({ key: 'holiday-promo' }), + ]; + const result = scan({ + mentionSites: [ + { key: 'holiday-promo', file: 'src/app/page.tsx', line: 17 }, + ], + }); + expect(bucketOf(flags, result, 'pricing-v2-experiment')).toEqual([ + 'unreferenced', + 'stale', + ]); + expect(bucketOf(flags, result, 'holiday-promo')).toEqual([ + 'unreferenced-comment-only', + 'stale', + ]); + }); + + test('call site only in an unreachable file is dead code, even when fully rolled out', () => { + const flags = [flag({ key: 'legacy-theme', filters: rollout(100) })]; + const result = scan({ + callSites: [site('legacy-theme', 'src/lib/unused/legacyTheme.ts')], + }); + expect(bucketOf(flags, result, 'legacy-theme')).toEqual([ + 'dead-code-reference', + 'stale', + ]); + }); + + test('key evaluated in code with no PostHog flag is stale', () => { + const result = scan({ + callSites: [site('old-pricing-test', 'src/app/page.tsx')], + }); + const [only] = classifyFlags([], result); + expect([only.bucket, only.verdict, only.flagId]).toEqual([ + 'deleted-still-referenced', + 'stale', + undefined, + ]); + }); + + test('three or more files evaluating the same key directly is a warning', () => { + const flags = [flag({ key: 'ai-assistant' })]; + const result = scan({ + callSites: [ + site('ai-assistant', 'src/app/page.tsx'), + site('ai-assistant', 'src/components/A.tsx'), + site('ai-assistant', 'src/components/B.tsx'), + ], + reachableFiles: [ + 'src/app/page.tsx', + 'src/components/A.tsx', + 'src/components/B.tsx', + ], + }); + expect(bucketOf(flags, result, 'ai-assistant')).toEqual([ + 'multi-callsite-no-wrapper', + 'warning', + ]); + }); + + test('partial rollout and multivariate flags with call sites are healthy', () => { + const flags = [ + flag({ key: 'dark-mode', filters: rollout(30) }), + flag({ + key: 'signup-cta-variant', + filters: { + groups: [{ rollout_percentage: 100, properties: [] }], + multivariate: { variants: [{ key: 'a' }, { key: 'b' }] }, + }, + }), + flag({ + key: 'gated', + filters: { + groups: [{ rollout_percentage: 100, properties: [{ key: 'email' }] }], + }, + }), + ]; + const result = scan({ + callSites: [ + site('dark-mode', 'src/lib/flags.ts'), + site('signup-cta-variant', 'src/app/page.tsx'), + site('gated', 'src/app/page.tsx'), + ], + }); + expect(bucketOf(flags, result, 'dark-mode')).toEqual([ + 'healthy', + 'healthy', + ]); + expect(bucketOf(flags, result, 'signup-cta-variant')).toEqual([ + 'healthy', + 'healthy', + ]); + expect(bucketOf(flags, result, 'gated')).toEqual(['healthy', 'healthy']); + }); + + test('experiment, remote config and encrypted payload flags are guarded to healthy', () => { + const flags = [ + flag({ key: 'exp', filters: rollout(100), experiment_set: [1] }), + flag({ key: 'rc', filters: rollout(100), is_remote_configuration: true }), + flag({ key: 'enc', filters: rollout(100), has_encrypted_payloads: true }), + ]; + const result = scan({ + callSites: [ + site('exp', 'src/app/page.tsx'), + site('rc', 'src/app/page.tsx'), + ], + }); + const byKey = Object.fromEntries( + classifyFlags(flags, result).map((c) => [c.key, c]), + ); + expect(byKey.exp.bucket).toBe('healthy'); + expect(byKey.exp.reason).toContain('backs an experiment'); + expect(byKey.rc.bucket).toBe('healthy'); + expect(byKey.enc.bucket).toBe('healthy'); + }); + + test('deleted flags are ignored and candidates carry ledger-ready fields', () => { + const flags = [ + flag({ key: 'zombie', deleted: true }), + flag({ + key: 'new-checkout', + name: 'New checkout', + filters: rollout(100), + status: 'ACTIVE', + }), + ]; + const result = scan({ + callSites: [site('new-checkout', 'src/app/page.tsx', 20)], + }); + const candidates = classifyFlags(flags, result); + expect(candidates.map((c) => c.key)).toEqual(['new-checkout']); + expect(candidates[0]).toMatchObject({ + proposedAction: 'keep on path, drop check, disable flag', + reason: 'rollout 100%, ACTIVE', + flagName: 'New checkout', + callSites: [ + { file: 'src/app/page.tsx', line: 20, api: 'useFeatureFlagEnabled' }, + ], + }); + }); +}); diff --git a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts new file mode 100644 index 000000000..df8772406 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const { + mockAuthenticate, + mockFetchFeatureFlags, + mockDetectFramework, + mockFetchSkillMenu, + mockWizardAbort, + mockListUncommittedPaths, +} = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockFetchFeatureFlags: vi.fn(), + mockDetectFramework: vi.fn(), + mockFetchSkillMenu: vi.fn(), + mockWizardAbort: vi.fn(), + mockListUncommittedPaths: vi.fn(), +})); + +vi.mock('@lib/agent/runner/shared/authenticate', () => ({ + authenticate: mockAuthenticate, +})); +vi.mock('@lib/programs/cull-feature-flags/fetch', () => ({ + fetchFeatureFlags: mockFetchFeatureFlags, +})); +vi.mock('@lib/detection/index', () => ({ + detectFramework: mockDetectFramework, +})); +vi.mock('@lib/wizard-tools', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSkillMenu: mockFetchSkillMenu, +})); +vi.mock('@utils/wizard-abort', () => ({ + wizardAbort: mockWizardAbort, +})); +vi.mock('@lib/programs/cull-feature-flags/working-tree', () => ({ + listUncommittedPaths: mockListUncommittedPaths, +})); + +import { cullFeatureFlagsConfig } from '@lib/programs/cull-feature-flags/index'; +import { AUDIT_CHECKS_FILE } from '@lib/programs/audit/types'; +import { ErrorCodes } from '@lib/errors'; +import { buildSession, type WizardSession } from '@lib/wizard-session'; +import type { ProgramRun } from '@lib/agent/agent-runner'; + +class AbortSignal extends Error {} + +function sessionFor(installDir: string): WizardSession { + return buildSession({ installDir, ci: true }); +} + +function credentialsFor(session: WizardSession): void { + session.credentials = { + accessToken: 'token', + projectApiKey: 'phc_test', + projectId: 590630, + host: { + apiHost: 'https://us.posthog.com', + appHost: 'https://us.posthog.com', + }, + } as WizardSession['credentials']; +} + +async function resolveRun(session: WizardSession): Promise { + const run = cullFeatureFlagsConfig.run; + if (typeof run !== 'function') throw new Error('run must be deferred'); + return run(session); +} + +describe('cullFeatureFlagsConfig.run', () => { + let installDir: string; + + beforeEach(() => { + installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cull-run-')); + fs.mkdirSync(path.join(installDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(installDir, 'src/app/page.tsx'), + 'const on = useFeatureFlagEnabled("new-checkout");', + ); + mockListUncommittedPaths.mockReturnValue([]); + mockDetectFramework.mockResolvedValue('nextjs'); + mockFetchSkillMenu.mockResolvedValue({ + categories: { + 'cull-feature-flags': [ + { + id: 'cull-feature-flags-nextjs', + group: 'cull-feature-flags', + framework: 'nextjs', + default: true, + }, + ], + }, + }); + mockAuthenticate.mockImplementation((session: WizardSession) => { + credentialsFor(session); + return Promise.resolve(); + }); + mockFetchFeatureFlags.mockResolvedValue([ + { + id: 42, + key: 'new-checkout', + active: true, + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }, + { id: 43, key: 'orphan', active: true, filters: { groups: [] } }, + ]); + mockWizardAbort.mockImplementation(() => { + throw new AbortSignal('abort'); + }); + }); + + afterEach(() => { + fs.rmSync(installDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + test('authenticates before fetching, seeds the ledger, resolves the framework variant', async () => { + const session = sessionFor(installDir); + + const run = await resolveRun(session); + + expect(mockAuthenticate.mock.invocationCallOrder[0]).toBeLessThan( + mockFetchFeatureFlags.mock.invocationCallOrder[0], + ); + expect(mockFetchFeatureFlags).toHaveBeenCalledWith( + 'token', + 'https://us.posthog.com', + 590630, + ); + expect(run.skillId).toBe('cull-feature-flags-nextjs'); + const ledger = JSON.parse( + fs.readFileSync(path.join(installDir, AUDIT_CHECKS_FILE), 'utf8'), + ) as { id: string; area: string; status: string }[]; + expect(ledger.map((row) => [row.id, row.area, row.status])).toEqual([ + ['new-checkout', 'Rolled out', 'pending'], + ['orphan', 'Unreferenced', 'pending'], + ]); + expect(run.customPrompt?.({} as never)).toContain('- Rolled out: 1'); + }); + + test('aborts on a dirty working tree before touching PostHog', async () => { + mockListUncommittedPaths.mockReturnValue(['src/app/page.tsx']); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.DetectDirtyWorkingTree }), + ); + expect(mockFetchFeatureFlags).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(installDir, AUDIT_CHECKS_FILE))).toBe(false); + }); + + test('aborts on an unsupported framework', async () => { + mockDetectFramework.mockResolvedValue('django'); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.DetectUnsupportedPlatform }), + ); + }); + + test('aborts instead of seeding when the flag fetch fails', async () => { + mockFetchFeatureFlags.mockRejectedValue(new Error('403')); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.AuthProjectFetchFailed }), + ); + expect(fs.existsSync(path.join(installDir, AUDIT_CHECKS_FILE))).toBe(false); + }); +}); diff --git a/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts new file mode 100644 index 000000000..133341db9 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts @@ -0,0 +1,285 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { scanFlagCallSites } from '@lib/programs/cull-feature-flags/scan'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cull-scan-')); +} + +function writeFile(dir: string, relativePath: string, content: string): void { + const fullPath = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +describe('scanFlagCallSites', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('records one literal call site per flag api with file and line', async () => { + writeFile( + tmpDir, + 'src/app/dashboard/page.tsx', + [ + 'import { useFeatureFlagEnabled, useFeatureFlagPayload, useFeatureFlagVariantKey } from "@posthog/react";', + 'const a = useFeatureFlagEnabled("new-checkout");', + "const b = useFeatureFlagPayload('ai-summaries');", + 'const c = useFeatureFlagVariantKey(`signup-cta-variant`);', + 'if (!posthog.isFeatureEnabled("export-csv")) {}', + 'const d = await client.getFeatureFlag("old-pricing-test", distinctId);', + 'const e = await client.getFeatureFlagPayload("payload-flag", distinctId);', + 'const f = posthog.getFeatureFlagResult("result-flag");', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites).toEqual([ + { + key: 'new-checkout', + file: 'src/app/dashboard/page.tsx', + line: 2, + api: 'useFeatureFlagEnabled', + }, + { + key: 'ai-summaries', + file: 'src/app/dashboard/page.tsx', + line: 3, + api: 'useFeatureFlagPayload', + }, + { + key: 'signup-cta-variant', + file: 'src/app/dashboard/page.tsx', + line: 4, + api: 'useFeatureFlagVariantKey', + }, + { + key: 'export-csv', + file: 'src/app/dashboard/page.tsx', + line: 5, + api: 'isFeatureEnabled', + }, + { + key: 'old-pricing-test', + file: 'src/app/dashboard/page.tsx', + line: 6, + api: 'getFeatureFlag', + }, + { + key: 'payload-flag', + file: 'src/app/dashboard/page.tsx', + line: 7, + api: 'getFeatureFlagPayload', + }, + { + key: 'result-flag', + file: 'src/app/dashboard/page.tsx', + line: 8, + api: 'getFeatureFlagResult', + }, + ]); + expect(result.dynamicSites).toEqual([]); + expect(result.filesScanned).toBe(1); + expect(result.truncated).toBe(false); + }); + + test('records PostHogFeature components as call sites', async () => { + writeFile( + tmpDir, + 'src/app/page.tsx', + [ + '', + "", + '', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites.map((site) => [site.key, site.line])).toEqual([ + ['new-nav', 1], + ['beta-nav', 2], + ]); + expect(result.dynamicSites).toEqual([ + { file: 'src/app/page.tsx', line: 3, api: 'PostHogFeature' }, + ]); + }); + + test('records non-literal first arguments as dynamic sites', async () => { + writeFile( + tmpDir, + 'src/lib/flags.ts', + [ + 'export function isOn(key: string) { return posthog.isFeatureEnabled(key); }', + 'const variant = posthog.getFeatureFlag(FLAGS.pricing);', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites).toEqual([]); + expect(result.dynamicSites).toEqual([ + { file: 'src/lib/flags.ts', line: 1, api: 'isFeatureEnabled' }, + { file: 'src/lib/flags.ts', line: 2, api: 'getFeatureFlag' }, + ]); + }); + + test('flags bulk evaluation and counts known keys read from its result as call sites', async () => { + writeFile( + tmpDir, + 'src/app/pricing/page.tsx', + [ + 'const flags = client ? await client.getAllFlags(distinctId) : {};', + 'const showAnnualDiscount = flags["annual-discount"] === true;', + '// flags["retired-discount"] used to live here', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir, [ + 'annual-discount', + 'retired-discount', + ]); + + expect(result.usesBulkEvaluation).toBe(true); + expect(result.callSites).toEqual([ + { + key: 'annual-discount', + file: 'src/app/pricing/page.tsx', + line: 2, + api: 'getAllFlags', + }, + ]); + expect(result.mentionSites).toEqual([ + { key: 'retired-discount', file: 'src/app/pricing/page.tsx', line: 3 }, + ]); + }); + + test('ignores calls inside comments but records known keys mentioned there', async () => { + writeFile( + tmpDir, + 'src/app/page.tsx', + [ + '// const old = useFeatureFlagEnabled("legacy-banner");', + '/* posthog.isFeatureEnabled("block-flag") */', + '/*', + ' * useFeatureFlagEnabled("multi-line-flag")', + ' */', + '// Holiday promo banner removed; flag "holiday-promo" is still in PostHog.', + 'const live = useFeatureFlagEnabled("live-flag"); // was "old-flag"', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir, [ + 'holiday-promo', + 'live-flag', + 'old-flag', + 'legacy-banner', + ]); + + expect(result.callSites).toEqual([ + { + key: 'live-flag', + file: 'src/app/page.tsx', + line: 7, + api: 'useFeatureFlagEnabled', + }, + ]); + expect(result.mentionSites).toEqual([ + { key: 'legacy-banner', file: 'src/app/page.tsx', line: 1 }, + { key: 'holiday-promo', file: 'src/app/page.tsx', line: 6 }, + { key: 'old-flag', file: 'src/app/page.tsx', line: 7 }, + ]); + }); + + test('marks Next.js convention entries and imported modules as reachable', async () => { + writeFile( + tmpDir, + 'src/app/layout.tsx', + 'import { useDarkMode } from "@/lib/flags";', + ); + writeFile(tmpDir, 'src/proxy.ts', 'export const proxy = 1;'); + writeFile( + tmpDir, + 'src/lib/flags.ts', + 'export const useDarkMode = () => useFeatureFlagEnabled("dark-mode");', + ); + writeFile( + tmpDir, + 'src/lib/unused/legacyTheme.ts', + 'posthog.isFeatureEnabled("legacy-theme");', + ); + writeFile(tmpDir, 'src/components/index.ts', 'export {};'); + writeFile( + tmpDir, + 'src/app/page.tsx', + 'import { Header } from "../components";', + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.reachableFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/page.tsx', + 'src/components/index.ts', + 'src/lib/flags.ts', + 'src/proxy.ts', + ]); + }); + + test('metadata routes and side-effect imports remain reachable', async () => { + writeFile( + tmpDir, + 'src/app/sitemap.ts', + 'export default function sitemap() {}', + ); + writeFile( + tmpDir, + 'src/app/robots.ts', + 'export default function robots() {}', + ); + writeFile( + tmpDir, + 'src/app/opengraph-image.tsx', + 'export default function Image() {}', + ); + writeFile(tmpDir, 'src/app/layout.tsx', 'import "../lib/register";'); + writeFile( + tmpDir, + 'src/lib/register.ts', + 'posthog.isFeatureEnabled("boot-flag");', + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.reachableFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/opengraph-image.tsx', + 'src/app/robots.ts', + 'src/app/sitemap.ts', + 'src/lib/register.ts', + ]); + }); + + test('skips node_modules and build output', async () => { + writeFile( + tmpDir, + 'node_modules/posthog-js/index.js', + 'isFeatureEnabled("vendored");', + ); + writeFile(tmpDir, '.next/server/page.js', 'isFeatureEnabled("built");'); + writeFile(tmpDir, 'src/a.ts', 'isFeatureEnabled("real");'); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites.map((site) => site.key)).toEqual(['real']); + expect(result.filesScanned).toBe(1); + }); +}); diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts new file mode 100644 index 000000000..8a250c872 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -0,0 +1,215 @@ +import { + buildCullPrompt, + candidateToCheck, +} from '@lib/programs/cull-feature-flags/seed'; +import { buildCullOutro } from '@lib/programs/cull-feature-flags/outro'; +import type { FlagScanResult } from '@lib/programs/cull-feature-flags/scan'; +import type { CullCandidate } from '@lib/programs/cull-feature-flags/types'; +import { OutroKind } from '@lib/wizard-session'; + +const STALE: CullCandidate = { + key: 'new-checkout', + bucket: 'fully-rolled-out', + area: 'Rolled out', + verdict: 'stale', + proposedAction: 'keep on path, drop check, disable flag', + reason: 'rollout 100%, ACTIVE', + flagId: 42, + flagName: 'New checkout', + callSites: [ + { + file: 'src/app/dashboard/page.tsx', + line: 20, + api: 'useFeatureFlagEnabled', + }, + { file: 'src/lib/checkout.ts', line: 3, api: 'isFeatureEnabled' }, + ], +}; + +const HEALTHY: CullCandidate = { + key: 'dark-mode', + bucket: 'healthy', + area: 'Healthy', + verdict: 'healthy', + proposedAction: 'keep', + reason: 'rollout 30%, ACTIVE', + flagId: 7, + callSites: [ + { file: 'src/lib/flags.ts', line: 6, api: 'useFeatureFlagEnabled' }, + ], +}; + +const ORPHAN: CullCandidate = { + key: 'pricing-v2-experiment', + bucket: 'unreferenced', + area: 'Unreferenced', + verdict: 'stale', + proposedAction: 'disable the flag', + reason: 'rollout 50%, STALE', + flagId: 9, + callSites: [], +}; + +function scan(overrides: Partial = {}): FlagScanResult { + return { + callSites: [], + dynamicSites: [], + mentionSites: [], + usesBulkEvaluation: false, + reachableFiles: [], + filesScanned: 10, + truncated: false, + ...overrides, + }; +} + +describe('candidateToCheck', () => { + test('stale candidate becomes a pending row keyed by flag with bucket as area', () => { + expect(candidateToCheck(STALE)).toEqual({ + id: 'new-checkout', + area: 'Rolled out', + label: 'new-checkout: keep on path, drop check, disable flag', + status: 'pending', + file: 'src/app/dashboard/page.tsx:20', + details: 'rollout 100%, ACTIVE; also src/lib/checkout.ts:3', + }); + }); + + test('healthy candidate is seeded as pass, unreferenced candidate has no file', () => { + expect(candidateToCheck(HEALTHY).status).toBe('pass'); + const orphan = candidateToCheck(ORPHAN); + expect(orphan.file).toBeUndefined(); + expect(orphan.details).toBe('rollout 50%, STALE; no call sites'); + }); +}); + +describe('buildCullPrompt', () => { + const ledgerFile = '.posthog-audit-checks.json'; + + test('names the ledger and every area the candidates fall in', () => { + const prompt = buildCullPrompt({ + ledgerFile, + candidates: [STALE, HEALTHY, ORPHAN], + scan: scan(), + }); + expect(prompt).toContain(ledgerFile); + expect(prompt).toContain('Rolled out'); + expect(prompt).toContain('Healthy'); + expect(prompt).toContain('Unreferenced'); + }); + + test('dynamic key sites appear only when the scan found them', () => { + const plain = buildCullPrompt({ + ledgerFile, + candidates: [ORPHAN], + scan: scan(), + }); + const withCaveats = buildCullPrompt({ + ledgerFile, + candidates: [ORPHAN], + scan: scan({ + usesBulkEvaluation: true, + dynamicSites: [ + { file: 'src/lib/flags.ts', line: 4, api: 'isFeatureEnabled' }, + ], + truncated: true, + }), + }); + expect(plain).not.toContain('src/lib/flags.ts:4'); + expect(withCaveats).toContain('src/lib/flags.ts:4'); + }); +}); + +describe('buildCullOutro', () => { + const common = { + flagIdByKey: new Map([['new-checkout', 42]]), + installDir: '/srv/app', + reportFile: 'posthog-feature-flag-cull-report.md', + docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', + }; + + test('nothing culled means no changes and no undo block', () => { + const outro = buildCullOutro({ + ...common, + checks: [ + { + ...candidateToCheck(STALE), + status: 'pass', + details: 'x; declined by user', + }, + ], + touchedFiles: [], + }); + expect(outro.kind).toBe(OutroKind.Success); + expect(outro.message).toContain( + '/srv/app/posthog-feature-flag-cull-report.md', + ); + expect(outro.changes).toEqual([]); + expect(outro.nextSteps).toBeUndefined(); + }); + + test('culled rows list the change and an undo step for code and for PostHog', () => { + const outro = buildCullOutro({ + ...common, + checks: [ + { ...candidateToCheck(STALE), status: 'pass', details: 'x; culled' }, + ], + touchedFiles: ['src/app/dashboard/page.tsx', 'src/lib/checkout.ts'], + }); + expect(outro.message).toContain( + '/srv/app/posthog-feature-flag-cull-report.md', + ); + expect(outro.changes).toEqual([candidateToCheck(STALE).label]); + const [codeUndo, posthogUndo] = outro.nextSteps?.items ?? []; + expect(codeUndo).toContain('src/app/dashboard/page.tsx'); + expect(codeUndo).toContain('src/lib/checkout.ts'); + expect(posthogUndo).toBeDefined(); + expect(outro.nextSteps?.items).toHaveLength(2); + }); + + test('counts only culls that disabled a PostHog flag', () => { + const outro = buildCullOutro({ + ...common, + flagIdByKey: new Map([ + ['new-checkout', 42], + ['archived-flag', 43], + ]), + checks: [ + { ...candidateToCheck(STALE), status: 'pass', details: 'x; culled' }, + { + ...candidateToCheck(STALE), + id: 'archived-flag', + area: 'Archived in PostHog', + status: 'pass', + details: 'x; culled', + }, + { + ...candidateToCheck(STALE), + id: 'deleted-flag', + area: 'Deleted in PostHog', + status: 'pass', + details: 'x; culled', + }, + { + ...candidateToCheck(STALE), + id: 'failed-flag', + status: 'error', + details: 'x; failed', + }, + { + ...candidateToCheck(STALE), + id: 'declined-flag', + status: 'pass', + details: 'x; declined by user', + }, + ], + touchedFiles: ['src/app/dashboard/page.tsx'], + }); + + expect(outro.message).toContain('Culled 3 feature flags'); + expect(outro.message).toContain('1 failed'); + expect(outro.message).toContain('1 left for you'); + expect(outro.nextSteps?.items).toHaveLength(2); + expect(outro.nextSteps?.items[1]).toContain('1 flag'); + }); +}); diff --git a/src/lib/programs/__tests__/self-driving-deck.test.ts b/src/lib/programs/__tests__/self-driving-deck.test.ts index d4c552a23..bb7163fa9 100644 --- a/src/lib/programs/__tests__/self-driving-deck.test.ts +++ b/src/lib/programs/__tests__/self-driving-deck.test.ts @@ -1,41 +1,80 @@ -/** - * Layout guards for the self-driving learn deck. The LearnCard pane is - * ~37 chars wide at an 80-column terminal (the narrowest split view; below - * 80 cols the pane is dropped entirely). Prose blocks word-wrap fine, but - * fixed-layout `lines` blocks (diagrams, lists) must fit unwrapped, and no - * scene should stack more prose than the pane can show at once. - */ - -import type { ReactNode, ReactElement } from 'react'; -import { getContentBlocks } from '@lib/programs/self-driving/content/index'; - -/** paneWidth in LearnCard at 80 cols: (min(120, 80) - 2) / 2 - 2 */ +import type { ReactElement, ReactNode } from 'react'; +import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; +import { WizardStore } from '@ui/tui/store'; + const PANE_WIDTH_80COL = 37; +const LEGACY_FIXED_LINE_WIDTH_BY_PROGRAM = new Map([ + ['posthog-integration', 45], + ['migration', 53], +]); +const LEGACY_PROSE_ROWS_BY_PROGRAM = new Map([ + ['error-tracking-upload-source-maps', 6], +]); function textOf(node: ReactNode): string { if (node == null || typeof node === 'boolean') return ''; if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(textOf).join(''); - const el = node as ReactElement<{ children?: ReactNode }>; - return textOf(el.props?.children); + const element = node as ReactElement<{ children?: ReactNode }>; + return textOf(element.props?.children); } -describe('self-driving learn deck', () => { - const blocks = getContentBlocks(); +const baseStore = new WizardStore(); +const decks = PROGRAM_REGISTRY.flatMap((program) => { + if (!program.getContentBlocks) return []; + const store = program.skillId + ? withSessionOverride(baseStore, { skillId: program.skillId }) + : baseStore; + return [ + { + id: program.id, + blocks: program.getContentBlocks(store), + }, + ]; +}); + +function withSessionOverride( + store: WizardStore, + patch: Partial, +): WizardStore { + const stub = Object.create(Object.getPrototypeOf(store)) as WizardStore; + Object.assign(stub, store); + Object.defineProperty(stub, 'session', { + value: { ...store.session, ...patch }, + writable: false, + configurable: true, + }); + return stub; +} - it('has blocks', () => { - expect(blocks.length).toBeGreaterThan(0); +describe('program learn decks', () => { + it('has blocks in every registered deck', () => { + const emptyDecks = decks + .filter((deck) => deck.blocks.length === 0) + .map((deck) => deck.id); + expect(emptyDecks).toEqual([]); }); - it('keeps every fixed-layout line within the 80-col pane', () => { + it('keeps every fixed-layout line within its width ceiling', () => { const wide: string[] = []; - for (const b of blocks) { - if (typeof b !== 'object' || !('type' in b) || b.type !== 'lines') { - continue; - } - for (const line of b.lines) { - const text = textOf(line); - if ([...text].length > PANE_WIDTH_80COL) wide.push(text); + for (const deck of decks) { + const maxLineWidth = + LEGACY_FIXED_LINE_WIDTH_BY_PROGRAM.get(deck.id) ?? PANE_WIDTH_80COL; + for (const block of deck.blocks) { + if ( + typeof block !== 'object' || + !('type' in block) || + block.type !== 'lines' + ) { + continue; + } + for (const line of block.lines) { + for (const physicalLine of textOf(line).split('\n')) { + if ([...physicalLine].length > maxLineWidth) { + wide.push(`${deck.id}: ${physicalLine}`); + } + } + } } } expect(wide).toEqual([]); @@ -43,13 +82,31 @@ describe('self-driving learn deck', () => { it('keeps every prose beat short enough to never fill the pane', () => { const long: string[] = []; - for (const b of blocks) { - if (typeof b !== 'object' || !('content' in b)) continue; - if (typeof b.content !== 'string') continue; - if (Math.ceil(b.content.length / PANE_WIDTH_80COL) > 4) { - long.push(b.content); + for (const deck of decks) { + const maxProseRows = LEGACY_PROSE_ROWS_BY_PROGRAM.get(deck.id) ?? 4; + for (const block of deck.blocks) { + if (typeof block !== 'object' || !('content' in block)) continue; + if (typeof block.content !== 'string') continue; + if (Math.ceil(block.content.length / PANE_WIDTH_80COL) > maxProseRows) { + long.push(`${deck.id}: ${block.content}`); + } } } expect(long).toEqual([]); }); + + it('keeps implementation jargon out of the cull deck', () => { + const cullDeck = decks.find((deck) => deck.id === 'cull-feature-flags'); + expect(cullDeck).toBeDefined(); + const forbiddenContent = cullDeck?.blocks.filter((block) => { + if (typeof block === 'string') { + return /winning branch|grep|bucket/i.test(block); + } + if (!('content' in block) || typeof block.content !== 'string') { + return false; + } + return /winning branch|grep|bucket/i.test(block.content); + }); + expect(forbiddenContent).toEqual([]); + }); }); diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts new file mode 100644 index 000000000..db4b6fc5c --- /dev/null +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -0,0 +1,253 @@ +import type { FlagCallSite, FlagScanResult } from './scan.js'; +import type { CullBucket, CullCandidate, FeatureFlag } from './types.js'; + +const MULTI_CALLSITE_FILE_THRESHOLD = 3; + +const PROPOSED_ACTION_BY_BUCKET: Record = { + 'dead-code-reference': 'delete dead module, disable flag', + 'archived-still-referenced': 'keep off path, drop check', + 'disabled-but-referenced': 'keep off path, drop check', + 'unreferenced-comment-only': 'disable flag, drop comment', + unreferenced: 'disable flag', + 'fully-rolled-out': 'keep on path, drop check, disable flag', + 'never-enabled': 'keep off path, drop check, disable flag', + 'deleted-still-referenced': 'keep off path, drop check', + 'multi-callsite-no-wrapper': 'suggest one wrapper hook', + healthy: 'keep', +}; + +/** What the ledger and the run screen show as the row's area. Keys the cull slides too. */ +export const AREA_BY_BUCKET: Record = { + 'dead-code-reference': 'Dead code', + 'archived-still-referenced': 'Archived in PostHog', + 'disabled-but-referenced': 'Disabled in PostHog', + 'unreferenced-comment-only': 'Comment only', + unreferenced: 'Unreferenced', + 'fully-rolled-out': 'Rolled out', + 'never-enabled': 'Off for everyone', + 'deleted-still-referenced': 'Deleted in PostHog', + 'multi-callsite-no-wrapper': 'Many call sites', + healthy: 'Healthy', +}; + +export type CullLane = + | 'decided' + | 'off-in-posthog' + | 'not-in-code' + | 'nothing-to-cull'; + +export const LANE_ORDER: readonly CullLane[] = [ + 'decided', + 'off-in-posthog', + 'not-in-code', + 'nothing-to-cull', +]; + +export const LANE_LABEL: Record = { + decided: 'Decided in PostHog', + 'off-in-posthog': 'Off in PostHog, still in code', + 'not-in-code': 'In PostHog, not in code', + 'nothing-to-cull': 'Nothing to cull', +}; + +const LANE_BY_BUCKET: Record = { + 'dead-code-reference': 'not-in-code', + 'archived-still-referenced': 'off-in-posthog', + 'disabled-but-referenced': 'off-in-posthog', + 'unreferenced-comment-only': 'not-in-code', + unreferenced: 'not-in-code', + 'fully-rolled-out': 'decided', + 'never-enabled': 'decided', + 'deleted-still-referenced': 'off-in-posthog', + 'multi-callsite-no-wrapper': 'nothing-to-cull', + healthy: 'nothing-to-cull', +}; + +export const LANE_BY_AREA: Record = Object.fromEntries( + (Object.keys(AREA_BY_BUCKET) as CullBucket[]).map((bucket) => [ + AREA_BY_BUCKET[bucket], + LANE_BY_BUCKET[bucket], + ]), +); + +/** Areas whose cull disables the flag in PostHog; the other areas leave PostHog untouched. */ +export const DISABLING_AREAS: ReadonlySet = new Set( + ( + [ + 'fully-rolled-out', + 'never-enabled', + 'unreferenced', + 'unreferenced-comment-only', + 'dead-code-reference', + ] as CullBucket[] + ).map((bucket) => AREA_BY_BUCKET[bucket]), +); + +export const BUCKET_ORDER: readonly CullBucket[] = [ + 'fully-rolled-out', + 'never-enabled', + 'archived-still-referenced', + 'disabled-but-referenced', + 'deleted-still-referenced', + 'unreferenced', + 'unreferenced-comment-only', + 'dead-code-reference', + 'multi-callsite-no-wrapper', + 'healthy', +]; + +const VERDICT_BY_BUCKET: Record = { + 'dead-code-reference': 'stale', + 'archived-still-referenced': 'stale', + 'disabled-but-referenced': 'stale', + 'unreferenced-comment-only': 'stale', + unreferenced: 'stale', + 'fully-rolled-out': 'stale', + 'never-enabled': 'stale', + 'deleted-still-referenced': 'stale', + 'multi-callsite-no-wrapper': 'warning', + healthy: 'healthy', +}; + +function guardReason(flag: FeatureFlag): string | undefined { + if ((flag.experiment_set?.length ?? 0) > 0) return 'backs an experiment'; + if (flag.is_remote_configuration) return 'remote configuration flag'; + if (flag.has_encrypted_payloads) return 'carries encrypted payloads'; + return undefined; +} + +function isFullyRolledOut(flag: FeatureFlag): boolean { + const groups = flag.filters?.groups ?? []; + if (groups.length === 0) return false; + if ((flag.filters?.multivariate?.variants?.length ?? 0) > 0) return false; + return groups.every( + (group) => + (group.rollout_percentage ?? 100) === 100 && + (group.properties?.length ?? 0) === 0, + ); +} + +function isNeverEnabled(flag: FeatureFlag): boolean { + const groups = flag.filters?.groups ?? []; + if (groups.length === 0) return false; + return groups.every((group) => group.rollout_percentage === 0); +} + +function rolloutSummary(flag: FeatureFlag): string { + const groups = flag.filters?.groups ?? []; + const parts: string[] = []; + if (groups.length === 0) parts.push('no release conditions'); + if (groups.length > 0) { + const percentages = groups.map((group) => + String(group.rollout_percentage ?? 100), + ); + parts.push(`rollout ${percentages.join('/')}%`); + } + if (groups.some((group) => (group.properties?.length ?? 0) > 0)) + parts.push('with property filters'); + if ((flag.filters?.multivariate?.variants?.length ?? 0) > 0) + parts.push('multivariate'); + if (flag.archived) parts.push('archived'); + if (!flag.active) parts.push('inactive'); + if (flag.status) parts.push(flag.status); + return parts.join(', '); +} + +function reasonForBucket(bucket: CullBucket, summary: string): string { + if (bucket !== 'never-enabled') return summary; + return `${summary}; may be a rollback, verify before culling`; +} + +function bucketForFlag( + flag: FeatureFlag, + sites: FlagCallSite[], + hasMentions: boolean, + reachableFiles: ReadonlySet, +): CullBucket { + if (sites.length === 0) + return hasMentions ? 'unreferenced-comment-only' : 'unreferenced'; + if (sites.every((site) => !reachableFiles.has(site.file))) + return 'dead-code-reference'; + if (flag.archived) return 'archived-still-referenced'; + if (!flag.active) return 'disabled-but-referenced'; + if (isFullyRolledOut(flag)) return 'fully-rolled-out'; + if (isNeverEnabled(flag)) return 'never-enabled'; + const distinctFiles = new Set(sites.map((site) => site.file)); + if (distinctFiles.size >= MULTI_CALLSITE_FILE_THRESHOLD) + return 'multi-callsite-no-wrapper'; + return 'healthy'; +} + +function candidate( + key: string, + bucket: CullBucket, + reason: string, + sites: FlagCallSite[], + flag?: FeatureFlag, +): CullCandidate { + return { + key, + bucket, + area: AREA_BY_BUCKET[bucket], + verdict: VERDICT_BY_BUCKET[bucket], + proposedAction: PROPOSED_ACTION_BY_BUCKET[bucket], + reason, + flagId: flag?.id, + flagName: flag?.name, + callSites: sites.map(({ file, line, api }) => ({ file, line, api })), + }; +} + +/** + * Pure classification of every PostHog flag plus every key the code evaluates + * that PostHog no longer has. Rules only look at rollout, active, archived, + * and the scan; age is never read. + */ +export function classifyFlags( + flags: readonly FeatureFlag[], + scan: FlagScanResult, +): CullCandidate[] { + const sitesByKey = new Map(); + for (const site of scan.callSites) { + sitesByKey.set(site.key, [...(sitesByKey.get(site.key) ?? []), site]); + } + const mentionedKeys = new Set(scan.mentionSites.map((site) => site.key)); + const reachableFiles = new Set(scan.reachableFiles); + const candidates: CullCandidate[] = []; + + for (const flag of flags) { + if (flag.deleted) continue; + const sites = sitesByKey.get(flag.key) ?? []; + sitesByKey.delete(flag.key); + if (flag.archived && sites.length === 0) continue; + const summary = rolloutSummary(flag); + const guard = guardReason(flag); + if (guard) { + candidates.push( + candidate(flag.key, 'healthy', `${guard}; ${summary}`, sites, flag), + ); + continue; + } + const bucket = bucketForFlag( + flag, + sites, + mentionedKeys.has(flag.key), + reachableFiles, + ); + const reason = reasonForBucket(bucket, summary); + candidates.push(candidate(flag.key, bucket, reason, sites, flag)); + } + + for (const [key, sites] of sitesByKey) { + candidates.push( + candidate( + key, + 'deleted-still-referenced', + 'no flag with this key in PostHog (deleted or never created)', + sites, + ), + ); + } + + return candidates; +} diff --git a/src/lib/programs/cull-feature-flags/content/index.tsx b/src/lib/programs/cull-feature-flags/content/index.tsx new file mode 100644 index 000000000..d4df44612 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/content/index.tsx @@ -0,0 +1,104 @@ +import { Text } from 'ink'; +import { Colors } from '@ui/tui/styles'; +import type { WizardStore } from '@ui/tui/store'; +import { TextRevealMode } from '@ui/tui/primitives/TextBlock'; +import type { ContentBlock } from '@ui/tui/primitives/content-types'; +import { StatusPeekTrigger } from '@ui/tui/components/StatusPeekTrigger'; + +const CULL_LANES: ContentBlock = { + type: 'lines', + interval: 500, + pause: 8000, + lines: [ + + Decided in PostHog + {'\nPostHog is at 100% or 0%'} + , + + Off in PostHog, still in code + {'\nPostHog says off, code still asks'} + , + + In PostHog, not in code + {'\nPostHog has it, nobody asks'} + , + ], +}; + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => [ + { + content: store?.session.apiUser?.first_name + ? `Welcome, ${store.session.apiUser.first_name}.` + : 'Welcome.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + { + content: "I'm looking for feature flags this project no longer needs.", + pause: 5000, + }, + + { type: 'clear', pause: 1500 }, + + { content: 'A flag lives in two places.', pause: 3000 }, + { content: 'PostHog decides. Your code asks.', pause: 3500 }, + { content: 'Stale is when the two drift apart.', pause: 4000 }, + CULL_LANES, + { content: 'Everything else is “Nothing to cull”.', pause: 3000 }, + + { type: 'clear', pause: 1500 }, + + { + content: 'The wizard found every call site before I started.', + pause: 4000, + }, + { + content: 'A call site is the line of code that asks PostHog about a flag.', + pause: 5500, + }, + { + content: 'I read each one and confirm the flag is done, or keep it.', + pause: 5000, + }, + { + pause: 5000, + persist: true, + content: , + }, + { + pause: 6000, + content: ( + + Press{' '} + + S + {' '} + to expand or collapse the status. + + ), + }, + + { type: 'clear', pause: 1500 }, + + { content: 'Nothing changes until you pick.', pause: 3000 }, + { + content: + 'Culling keeps the code that runs today, drops the check, and disables the flag in PostHog.', + pause: 5000, + }, + { content: 'Never deleted.', pause: 1500 }, + { + content: + 'Undo is one step each: git checkout for the code, one toggle on the flag page for PostHog.', + pause: 6500, + }, + + { type: 'clear', pause: 1500 }, + + { + content: 'Verifying now. The list on the right moves as I go.', + pause: 20000, + persist: true, + }, +]; diff --git a/src/lib/programs/cull-feature-flags/fetch.ts b/src/lib/programs/cull-feature-flags/fetch.ts new file mode 100644 index 000000000..a99e1fef5 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/fetch.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; +import { handleApiError } from '@lib/api'; +import { WIZARD_USER_AGENT } from '@lib/constants'; +import { analytics } from '@utils/analytics'; +import { FeatureFlagListResponseSchema, type FeatureFlag } from './types.js'; + +const PAGE_LIMIT = 300; + +async function fetchFlagList( + accessToken: string, + apiHost: string, + projectId: number, + query: string, +): Promise { + const endpoint = `/api/projects/${projectId}/feature_flags/`; + const url = `${apiHost}${endpoint}?limit=${PAGE_LIMIT}${query}`; + try { + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': WIZARD_USER_AGENT, + }, + }); + return FeatureFlagListResponseSchema.parse(response.data).results; + } catch (error) { + const apiError = handleApiError(error, 'fetch feature flags'); + analytics.captureException(apiError, { endpoint, apiHost, projectId }); + throw apiError; + } +} + +/** + * The default list hides archived flags and never returns deleted ones, so an + * archived-but-referenced flag needs the second query to show up at all. + */ +export async function fetchFeatureFlags( + accessToken: string, + apiHost: string, + projectId: number, +): Promise { + const [live, archived] = await Promise.all([ + fetchFlagList(accessToken, apiHost, projectId, ''), + fetchFlagList(accessToken, apiHost, projectId, '&archived=true'), + ]); + const seen = new Set(); + return [...live, ...archived].filter((flag) => { + if (seen.has(flag.key)) return false; + seen.add(flag.key); + return !flag.deleted; + }); +} diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts new file mode 100644 index 000000000..6f38ed55c --- /dev/null +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -0,0 +1,220 @@ +import * as path from 'path'; +import type { ProgramRun } from '@lib/agent/agent-runner'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { resolveSkillVariantId } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner'; +import { getSkillsBaseUrl, Integration } from '@lib/constants'; +import { detectFramework } from '@lib/detection/index'; +import { ErrorCodes } from '@lib/errors'; +import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; +import { createSkillProgram } from '@lib/programs/agent-skill/index'; +import { + AUDIT_CHECKS_FILE, + AUDIT_CHECKS_KEY, + coerceAuditChecks, +} from '@lib/programs/audit/types'; +import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; +import type { ProgramId } from '@lib/programs/program-registry'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import type { WizardSession } from '@lib/wizard-session'; +import { fetchSkillMenu } from '@lib/wizard-tools'; +import { analytics } from '@utils/analytics'; +import { readProjectFile } from '@utils/bounded-fs'; +import { logToFile } from '@utils/debug'; +import { wizardAbort } from '@utils/wizard-abort'; +import { classifyFlags } from './classify.js'; +import { getContentBlocks } from './content/index.js'; +import { fetchFeatureFlags } from './fetch.js'; +import { buildCullOutro } from './outro.js'; +import { scanFlagCallSites } from './scan.js'; +import { buildCullPrompt, seedCullLedger } from './seed.js'; +import type { FeatureFlag } from './types.js'; +import { + listModifiedTrackedPaths, + listUncommittedPaths, +} from './working-tree.js'; + +export const CULL_FEATURE_FLAGS_REPORT_FILE = + 'posthog-feature-flag-cull-report.md'; +const CULL_SKILL_GROUP = 'cull-feature-flags'; +const PROGRAM_ID: ProgramId = 'cull-feature-flags'; +const DOCS_URL = 'https://posthog.com/docs/feature-flags/best-practices'; + +/** Frameworks the scanner has patterns for; one context-mill variant each. */ +export const CULL_FEATURE_FLAGS_SUPPORTED: ReadonlySet = new Set([ + Integration.nextjs, +]); + +const SCREEN_BY_STEP: Record = { + intro: 'cull-intro', + run: 'audit-run', +}; + +const cullSteps: ProgramStep[] = AGENT_SKILL_STEPS.map((step) => { + const override = SCREEN_BY_STEP[step.id]; + return override ? { ...step, screenId: override } : step; +}); + +const base = createSkillProgram({ + skillId: `${CULL_SKILL_GROUP}-nextjs`, + command: 'cull-feature-flags', + id: PROGRAM_ID, + description: + 'Find stale PostHog feature flags in this project and remove the ones you pick', + integrationLabel: 'cull-feature-flags', + successMessage: `Feature flag cull complete! View the report at ./${CULL_FEATURE_FLAGS_REPORT_FILE}`, + reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, + docsUrl: DOCS_URL, + spinnerMessage: 'Culling stale feature flags...', + estimatedDurationMinutes: 5, +}); + +async function abortDirtyWorkingTree(paths: string[]): Promise { + const shown = paths.slice(0, 10).map((p) => ` ${p}`); + const more = paths.length > 10 ? ` ...and ${paths.length - 10} more` : ''; + await wizardAbort({ + code: ErrorCodes.DetectDirtyWorkingTree, + message: + 'This project has uncommitted changes. Culling edits files, and the undo is a plain git revert, ' + + 'which only works when the tree starts clean.\n\n' + + 'Commit or stash these first, then run the command again:\n' + + [...shown, more].filter(Boolean).join('\n'), + error: new Error('cull-feature-flags: dirty working tree'), + }); +} + +async function abortUnsupportedPlatform( + integration: Integration | undefined, +): Promise { + const name = integration + ? FRAMEWORK_REGISTRY[integration]?.metadata.name ?? integration + : 'this'; + await wizardAbort({ + code: ErrorCodes.DetectUnsupportedPlatform, + message: + `Feature flag culling has no scanner for ${name} projects yet. ` + + 'Supported today: Next.js.', + error: new Error( + `cull-feature-flags unsupported platform: ${integration ?? 'unknown'}`, + ), + }); +} + +async function resolveVariantSkillId(framework: Integration): Promise { + const menu = await fetchSkillMenu(getSkillsBaseUrl()); + const entries = menu ? Object.values(menu.categories).flat() : []; + return ( + resolveSkillVariantId(entries, CULL_SKILL_GROUP, framework) ?? + `${CULL_SKILL_GROUP}-${framework}` + ); +} + +async function abortFlagFetchFailed(error: unknown): Promise { + await wizardAbort({ + code: ErrorCodes.AuthProjectFetchFailed, + message: + "Could not read this project's feature flags from PostHog, so there is " + + 'nothing deterministic to propose. Check the token carries ' + + 'feature_flag:read and try again.', + error: error instanceof Error ? error : new Error(String(error)), + }); +} + +// Headless and CI paths resolve `run` before the runner's own auth step, so +// take the same path early; it is a no-op once credentials exist. +async function fetchFlagsOrAbort( + session: WizardSession, +): Promise { + await authenticate(session, PROGRAM_ID); + const credentials = session.credentials; + if (!credentials) { + await abortFlagFetchFailed(new Error('no credentials after authenticate')); + return []; + } + try { + return await fetchFeatureFlags( + credentials.accessToken, + credentials.host.apiHost, + credentials.projectId, + ); + } catch (error) { + logToFile(`[cull-feature-flags] flag fetch failed: ${String(error)}`); + analytics.wizardCapture('cull feature flags fetch failed'); + await abortFlagFetchFailed(error); + return []; + } +} + +function readLedger(installDir: string) { + const raw = readProjectFile(path.join(installDir, AUDIT_CHECKS_FILE)); + if (raw === null) return []; + try { + return coerceAuditChecks(JSON.parse(raw)); + } catch { + return []; + } +} + +const cullRun = async (session: WizardSession): Promise => { + const { installDir } = session; + const uncommitted = listUncommittedPaths(installDir); + if (uncommitted.length > 0) await abortDirtyWorkingTree(uncommitted); + + const framework = await detectFramework(installDir); + if (!framework || !CULL_FEATURE_FLAGS_SUPPORTED.has(framework)) { + await abortUnsupportedPlatform(framework); + } + const skillId = await resolveVariantSkillId(framework as Integration); + + const flags = await fetchFlagsOrAbort(session); + const scan = await scanFlagCallSites( + installDir, + flags.map((flag) => flag.key), + ); + const candidates = classifyFlags(flags, scan); + const checks = seedCullLedger(installDir, candidates); + session.frameworkContext[AUDIT_CHECKS_KEY] = checks; + const flagIdByKey = new Map( + candidates + .filter((candidate) => candidate.flagId !== undefined) + .map((candidate) => [candidate.key, candidate.flagId as number]), + ); + analytics.wizardCapture('cull feature flags seeded', { + framework, + flags: flags.length, + candidates: candidates.length, + stale: candidates.filter((c) => c.verdict === 'stale').length, + files_scanned: scan.filesScanned, + truncated: scan.truncated, + }); + + const baseRun = + typeof base.run === 'function' ? await base.run(session) : base.run; + if (!baseRun) throw new Error('cull-feature-flags has no run configuration'); + + return { + ...baseRun, + skillId, + customPrompt: () => + buildCullPrompt({ + ledgerFile: AUDIT_CHECKS_FILE, + candidates, + scan, + }), + buildOutroData: (sess) => + buildCullOutro({ + checks: readLedger(sess.installDir), + touchedFiles: listModifiedTrackedPaths(sess.installDir), + flagIdByKey, + installDir: sess.installDir, + reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, + docsUrl: DOCS_URL, + }), + }; +}; + +export const cullFeatureFlagsConfig: ProgramConfig = { + ...base, + steps: cullSteps, + run: cullRun, + getContentBlocks, +}; diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts new file mode 100644 index 000000000..23c788b24 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import type { AuditCheck } from '@lib/programs/audit/types'; +import { OutroKind, type OutroData } from '@lib/wizard-session'; +import { DISABLING_AREAS } from './classify.js'; +import { CULLED_MARKER, DECLINED_MARKER } from './seed.js'; + +export interface CullOutroInput { + checks: readonly AuditCheck[]; + touchedFiles: readonly string[]; + flagIdByKey: ReadonlyMap; + installDir: string; + reportFile: string; + docsUrl: string; +} + +/** Outro that carries the undo recipe: the git revert for code, the flag page for PostHog. */ +export function buildCullOutro(input: CullOutroInput): OutroData { + const culled = input.checks.filter( + (check) => + check.status === 'pass' && (check.details ?? '').includes(CULLED_MARKER), + ); + const failed = input.checks.filter((check) => check.status === 'error'); + const leftForYou = input.checks.filter((check) => + (check.details ?? '').includes(DECLINED_MARKER), + ).length; + const undoItems: string[] = []; + if (input.touchedFiles.length > 0) { + undoItems.push( + `Code: git checkout -- ${input.touchedFiles.join( + ' ', + )} (or git diff to review first). The tree was clean when this run started, so every change git diff shows is the wizard's. Each flag was its own unit: a failed row left its flag untouched, and earlier culls stand.`, + ); + } + const disabledCount = culled.filter( + (check) => + DISABLING_AREAS.has(check.area) && input.flagIdByKey.has(check.id), + ).length; + if (disabledCount > 0) { + undoItems.push( + `PostHog: ${disabledCount} flag${ + disabledCount === 1 ? '' : 's' + } disabled, never deleted; one toggle each to re-enable. Disabling kept the flag's rollout conditions, variants, and payloads, so re-enabling restores exactly what was there. The report links every flag page.`, + ); + } + const reportPath = path.join(input.installDir, input.reportFile); + const message = + culled.length === 0 + ? `Nothing was changed. The report at ${reportPath} lists what you can cull by hand.` + : `Culled ${culled.length} feature flag${ + culled.length === 1 ? '' : 's' + }.${failed.length > 0 ? ` ${failed.length} failed.` : ''}${ + leftForYou > 0 ? ` ${leftForYou} left for you.` : '' + } Flags were disabled, never deleted. Report: ${reportPath}`; + return { + kind: OutroKind.Success, + message, + reportFile: input.reportFile, + docsUrl: input.docsUrl, + changes: culled.map((check) => check.label), + ...(undoItems.length > 0 + ? { + nextSteps: { + heading: 'Undo, if you want it back:', + items: undoItems, + }, + } + : {}), + }; +} diff --git a/src/lib/programs/cull-feature-flags/phase.ts b/src/lib/programs/cull-feature-flags/phase.ts new file mode 100644 index 000000000..9891f1dea --- /dev/null +++ b/src/lib/programs/cull-feature-flags/phase.ts @@ -0,0 +1,60 @@ +export interface CullProgress { + pass: 'idle' | 'edit' | 'verify' | 'disable'; + activeKey: string | null; + activeFile: string | null; + edited: string[]; +} + +export const INITIAL_CULL_PROGRESS: CullProgress = { + pass: 'idle', + activeKey: null, + activeFile: null, + edited: [], +}; + +export function reduceCullProgress( + state: CullProgress, + message: string, +): CullProgress { + const cullingMatch = message.match(/^Culling (\S+)$/); + if (cullingMatch) { + const edited = state.activeKey + ? [...state.edited, state.activeKey] + : state.edited; + return { + pass: 'edit', + activeKey: cullingMatch[1], + activeFile: null, + edited, + }; + } + + const editingMatch = message.match(/^Editing (.+)$/); + if (editingMatch) { + return { ...state, activeFile: editingMatch[1] }; + } + + if (/^Type checking \d+ files$/.test(message)) { + const edited = state.activeKey + ? [...state.edited, state.activeKey] + : state.edited; + return { + pass: 'verify', + activeKey: null, + activeFile: null, + edited, + }; + } + + const disablingMatch = message.match(/^Disabling (.+) in PostHog$/); + if (disablingMatch) { + return { + ...state, + pass: 'disable', + activeKey: disablingMatch[1], + activeFile: null, + }; + } + + return state; +} diff --git a/src/lib/programs/cull-feature-flags/scan.ts b/src/lib/programs/cull-feature-flags/scan.ts new file mode 100644 index 000000000..0f0c36ff8 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/scan.ts @@ -0,0 +1,246 @@ +import * as path from 'path'; +import { + boundedGlob, + GLOB_DEADLINE_MS, + readProjectFile, +} from '@utils/bounded-fs'; + +export interface FlagCallSite { + key: string; + file: string; + line: number; + api: string; +} + +export interface FlagDynamicSite { + file: string; + line: number; + api: string; +} + +export interface FlagMentionSite { + key: string; + file: string; + line: number; +} + +export interface FlagScanResult { + callSites: FlagCallSite[]; + dynamicSites: FlagDynamicSite[]; + /** Known keys that appear as a quoted string somewhere they are not evaluated (comments, config). */ + mentionSites: FlagMentionSite[]; + /** `getAllFlags` / `getAllFlagsAndPayloads` present, so every flag may be read without a literal key. */ + usesBulkEvaluation: boolean; + /** Files that are a Next.js convention entry or imported by another scanned file. */ + reachableFiles: string[]; + filesScanned: number; + truncated: boolean; +} + +const SOURCE_GLOB = '**/*.{ts,tsx,js,jsx,mjs,cjs}'; +const SCAN_FILE_LIMIT = 2000; +const MAX_SOURCE_FILE_BYTES = 512 * 1024; + +const FLAG_APIS = [ + 'isFeatureEnabled', + 'getFeatureFlag', + 'getFeatureFlagPayload', + 'getFeatureFlagResult', + 'useFeatureFlagEnabled', + 'useFeatureFlagVariantKey', + 'useFeatureFlagPayload', +]; +const FLAG_API_ALTERNATION = FLAG_APIS.join('|'); +const LITERAL_CALL_RE = new RegExp( + `\\b(${FLAG_API_ALTERNATION})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, + 'g', +); +const DYNAMIC_CALL_RE = new RegExp( + `\\b(${FLAG_API_ALTERNATION})\\s*\\(\\s*(?!['"\`])[^)\\s]`, + 'g', +); +const LITERAL_JSX_RE = + /]*?\bflag=(?:"([^"]+)"|'([^']+)'|\{\s*['"]([^'"]+)['"]\s*\})/g; +const DYNAMIC_JSX_RE = /]*?\bflag=\{\s*(?!['"])[^}\s]/g; +const BULK_RE = /\b(getAllFlags|getAllFlagsAndPayloads)\s*\(/; +const PREFILTER = /FeatureFlag|isFeatureEnabled|PostHogFeature|getAllFlags/; +const IMPORT_SPECIFIER_RE = + /(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s+)['"]([^'"]+)['"]/g; +const NEXT_ENTRY_FILE_RE = + /(?:^|\/)(?:app\/.*\/?(?:page|layout|route|loading|error|not-found|template|default|global-error|sitemap|robots|manifest|opengraph-image|twitter-image|icon|apple-icon)|pages\/.*|middleware|proxy|instrumentation(?:-client)?|next\.config)\.(?:tsx?|[mc]?jsx?)$/; + +function stripComments(lines: string[]): string[] { + let isInsideBlockComment = false; + return lines.map((line) => { + let code = ''; + let index = 0; + while (index < line.length) { + if (isInsideBlockComment) { + const end = line.indexOf('*/', index); + if (end === -1) return code; + isInsideBlockComment = false; + index = end + 2; + continue; + } + const blockStart = line.indexOf('/*', index); + const lineStart = line.indexOf('//', index); + const hasLineComment = + lineStart !== -1 && (blockStart === -1 || lineStart < blockStart); + if (hasLineComment) return code + line.slice(index, lineStart); + if (blockStart === -1) return code + line.slice(index); + code += line.slice(index, blockStart); + isInsideBlockComment = true; + index = blockStart + 2; + } + return code; + }); +} + +function jsxKey(match: RegExpExecArray): string { + return match[1] ?? match[2] ?? match[3]; +} + +function collectSites( + file: string, + codeLines: string[], + result: FlagScanResult, +): void { + codeLines.forEach((code, lineIndex) => { + const line = lineIndex + 1; + for (const match of code.matchAll(LITERAL_CALL_RE)) { + result.callSites.push({ key: match[3], file, line, api: match[1] }); + } + for (const match of code.matchAll(DYNAMIC_CALL_RE)) { + result.dynamicSites.push({ file, line, api: match[1] }); + } + for (const match of code.matchAll(LITERAL_JSX_RE)) { + result.callSites.push({ + key: jsxKey(match), + file, + line, + api: 'PostHogFeature', + }); + } + if (DYNAMIC_JSX_RE.test(code)) { + result.dynamicSites.push({ file, line, api: 'PostHogFeature' }); + } + DYNAMIC_JSX_RE.lastIndex = 0; + if (BULK_RE.test(code)) result.usesBulkEvaluation = true; + }); +} + +// A file that called getAllFlags reads keys out of the result object, so a +// quoted key there is an evaluation, not a stray mention. +function collectMentions( + file: string, + rawLines: string[], + codeLines: string[], + knownKeys: readonly string[], + callSitesInFile: FlagCallSite[], + result: FlagScanResult, +): void { + if (knownKeys.length === 0) return; + const hasBulkCall = codeLines.some((code) => BULK_RE.test(code)); + const evaluatedOnLine = new Set( + callSitesInFile.map((site) => `${site.line}:${site.key}`), + ); + rawLines.forEach((raw, lineIndex) => { + const line = lineIndex + 1; + for (const key of knownKeys) { + if (evaluatedOnLine.has(`${line}:${key}`)) continue; + if (!raw.includes(key)) continue; + const quoted = new RegExp(`['"\`]${escapeRegExp(key)}['"\`]`); + if (!quoted.test(raw)) continue; + const isBulkLookup = hasBulkCall && quoted.test(codeLines[lineIndex]); + if (isBulkLookup) { + result.callSites.push({ key, file, line, api: 'getAllFlags' }); + continue; + } + result.mentionSites.push({ key, file, line }); + } + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function importedModuleNames(source: string): string[] { + const names: string[] = []; + for (const match of source.matchAll(IMPORT_SPECIFIER_RE)) { + const specifier = match[1].replace(/\?.*$/, ''); + const segments = specifier.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (!last) continue; + names.push(last.replace(/\.(?:tsx?|[mc]?jsx?)$/, '')); + } + return names; +} + +function isReachable(file: string, importedNames: Set): boolean { + if (NEXT_ENTRY_FILE_RE.test(file)) return true; + const baseName = path.basename(file).replace(/\.(?:tsx?|[mc]?jsx?)$/, ''); + if (importedNames.has(baseName)) return true; + if (baseName !== 'index') return false; + return importedNames.has(path.basename(path.dirname(file))); +} + +/** + * Deterministic scan of a JS/TS project for PostHog feature flag evaluation + * sites. Comments are stripped before matching; `knownKeys` (the project's + * flags from PostHog) turn quoted-but-unevaluated keys into mention sites. + */ +export async function scanFlagCallSites( + installDir: string, + knownKeys: readonly string[] = [], +): Promise { + const startedAt = Date.now(); + const files = await boundedGlob(SOURCE_GLOB, { + cwd: installDir, + limit: SCAN_FILE_LIMIT, + }); + const elapsedMs = Date.now() - startedAt; + const result: FlagScanResult = { + callSites: [], + dynamicSites: [], + mentionSites: [], + usesBulkEvaluation: false, + reachableFiles: [], + filesScanned: 0, + truncated: files.length >= SCAN_FILE_LIMIT || elapsedMs >= GLOB_DEADLINE_MS, + }; + const importedNames = new Set(); + const sortedFiles = [...files].sort(); + + for (const file of sortedFiles) { + const source = readProjectFile( + path.join(installDir, file), + MAX_SOURCE_FILE_BYTES, + ); + if (source === null) continue; + result.filesScanned += 1; + for (const name of importedModuleNames(source)) importedNames.add(name); + if ( + !PREFILTER.test(source) && + !knownKeys.some((key) => source.includes(key)) + ) + continue; + const rawLines = source.split('\n'); + const codeLines = stripComments(rawLines); + const before = result.callSites.length; + collectSites(file, codeLines, result); + collectMentions( + file, + rawLines, + codeLines, + knownKeys, + result.callSites.slice(before), + result, + ); + } + + result.reachableFiles = sortedFiles.filter((file) => + isReachable(file, importedNames), + ); + return result; +} diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts new file mode 100644 index 000000000..98028232e --- /dev/null +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -0,0 +1,80 @@ +import { seedAuditLedger } from '@lib/programs/audit/seed'; +import type { AuditCheck } from '@lib/programs/audit/types'; +import type { FlagScanResult } from './scan.js'; +import type { CullCandidate } from './types.js'; +import { BUCKET_ORDER } from './classify.js'; + +export const CULLED_MARKER = '; culled'; +export const DECLINED_MARKER = '; declined by user'; + +// The row's `file` already names the first site, so details only add the rest. +function describeSites(candidate: CullCandidate): string | undefined { + if (candidate.callSites.length === 0) return 'no call sites'; + if (candidate.callSites.length === 1) return undefined; + const rest = candidate.callSites + .slice(1) + .map((site) => `${site.file}:${site.line}`); + return `also ${rest.join(', ')}`; +} + +export function candidateToCheck(candidate: CullCandidate): AuditCheck { + const first = candidate.callSites[0]; + return { + id: candidate.key, + area: candidate.area, + label: `${candidate.key}: ${candidate.proposedAction}`, + status: candidate.verdict === 'healthy' ? 'pass' : 'pending', + ...(first ? { file: `${first.file}:${first.line}` } : {}), + details: [candidate.reason, describeSites(candidate)] + .filter(Boolean) + .join('; '), + }; +} + +export function seedCullLedger( + installDir: string, + candidates: readonly CullCandidate[], +): AuditCheck[] { + const checks = [...candidates] + .sort( + (left, right) => + BUCKET_ORDER.indexOf(left.bucket) - BUCKET_ORDER.indexOf(right.bucket), + ) + .map(candidateToCheck); + seedAuditLedger(installDir, checks); + return checks; +} + +export interface CullPromptInput { + ledgerFile: string; + candidates: readonly CullCandidate[]; + scan: FlagScanResult; +} + +function countByBucket(candidates: readonly CullCandidate[]): string[] { + const counts = new Map(); + for (const candidate of candidates) { + counts.set(candidate.area, (counts.get(candidate.area) ?? 0) + 1); + } + return [...counts.entries()].map( + ([bucket, count]) => `- ${bucket}: ${count}`, + ); +} + +export function buildCullPrompt(input: CullPromptInput): string { + const yesNo = (value: boolean): string => (value ? 'yes' : 'no'); + const dynamicSites = input.scan.dynamicSites.map( + (site) => `${site.file}:${site.line} (${site.api})`, + ); + return [ + `Run the cull-feature-flags skill end-to-end. The ledger at ./${input.ledgerFile} is ground truth, one row per flag, grouped by area:`, + ...countByBucket(input.candidates), + '', + "Scan facts (deterministic, from the wizard's scan of this project):", + `- Bulk evaluation (getAllFlags): ${yesNo(input.scan.usesBulkEvaluation)}`, + `- Dynamic flag keys: ${ + dynamicSites.length > 0 ? dynamicSites.join(', ') : 'none' + }`, + `- Scan truncated at the file limit: ${yesNo(input.scan.truncated)}`, + ].join('\n'); +} diff --git a/src/lib/programs/cull-feature-flags/types.ts b/src/lib/programs/cull-feature-flags/types.ts new file mode 100644 index 000000000..218cd94af --- /dev/null +++ b/src/lib/programs/cull-feature-flags/types.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +const FlagGroupSchema = z.object({ + rollout_percentage: z.number().nullable().optional(), + properties: z.array(z.unknown()).optional(), + variant: z.string().nullable().optional(), +}); + +// Every classification field is optional so an API drift degrades a flag to +// healthy, never to cullable. +export const FeatureFlagSchema = z.object({ + id: z.number(), + key: z.string(), + name: z.string().optional(), + active: z.boolean(), + archived: z.boolean().optional(), + deleted: z.boolean().optional(), + status: z.string().optional(), + filters: z + .object({ + groups: z.array(FlagGroupSchema).optional(), + multivariate: z + .object({ variants: z.array(z.unknown()).optional() }) + .nullable() + .optional(), + }) + .optional(), + experiment_set: z.array(z.unknown()).nullable().optional(), + is_remote_configuration: z.boolean().optional(), + has_encrypted_payloads: z.boolean().optional(), +}); +export type FeatureFlag = z.infer; + +export const FeatureFlagListResponseSchema = z.object({ + results: z.array(FeatureFlagSchema), + next: z.string().nullable().optional(), +}); + +export type CullBucket = + | 'dead-code-reference' + | 'archived-still-referenced' + | 'disabled-but-referenced' + | 'unreferenced-comment-only' + | 'unreferenced' + | 'fully-rolled-out' + | 'never-enabled' + | 'deleted-still-referenced' + | 'multi-callsite-no-wrapper' + | 'healthy'; + +export type CullVerdict = 'stale' | 'warning' | 'healthy'; + +export interface CullCandidate { + key: string; + bucket: CullBucket; + /** Display name of the bucket, the ledger row's `area`. */ + area: string; + verdict: CullVerdict; + /** One line the ledger label shows next to the key. */ + proposedAction: string; + /** Why the flag landed in its bucket, plus PostHog state the skill should see. */ + reason: string; + flagId?: number; + flagName?: string; + callSites: { file: string; line: number; api: string }[]; +} diff --git a/src/lib/programs/cull-feature-flags/working-tree.ts b/src/lib/programs/cull-feature-flags/working-tree.ts new file mode 100644 index 000000000..b13e7ef55 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/working-tree.ts @@ -0,0 +1,31 @@ +import { execSync } from 'child_process'; + +/** Paths under `installDir` with uncommitted or untracked changes, empty when clean or not a git repo. */ +export function listUncommittedPaths(installDir: string): string[] { + return listStatusPaths(installDir, () => true); +} + +/** Tracked paths under `installDir` that are modified or deleted; untracked files never revert with `git checkout --`. */ +export function listModifiedTrackedPaths(installDir: string): string[] { + return listStatusPaths(installDir, (line) => !line.startsWith('??')); +} + +function listStatusPaths( + installDir: string, + keep: (line: string) => boolean, +): string[] { + let status: string; + try { + status = execSync('git status --porcelain=v1 -- .', { + cwd: installDir, + stdio: ['ignore', 'pipe', 'ignore'], + }).toString(); + } catch { + return []; + } + return status + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && keep(line)) + .map((line) => line.replace(/^\S+\s+/, '')); +} diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 2633c295e..fb7b321fb 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -33,6 +33,7 @@ import { mcpAnalyticsConfig } from './mcp-analytics/index.js'; import { replayVisionConfig } from './replay-vision/index.js'; import { aiObservabilityConfig } from './ai-observability/index.js'; import { metricsConfig } from './metrics/index.js'; +import { cullFeatureFlagsConfig } from './cull-feature-flags/index.js'; import { slackConnectConfig } from './slack/index.js'; // Generic skill program — runs an arbitrary context-mill skill chosen at @@ -84,6 +85,7 @@ export const PROGRAM_REGISTRY = [ replayVisionConfig, aiObservabilityConfig, metricsConfig, + cullFeatureFlagsConfig, slackConnectConfig, ] as const satisfies readonly ProgramConfig[]; diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 3c06e130e..87f1b373c 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -25,6 +25,7 @@ import { EndScreensDemo } from './demos/EndScreensDemo.js'; import { AiOptInDemo } from './demos/AiOptInDemo.js'; import { AskModalDemo } from './demos/AskModalDemo.js'; import { ViewportGuardDemo } from './demos/ViewportGuardDemo.js'; +import { CullRunDemo } from './demos/CullRunDemo.js'; interface PlaygroundAppProps { store: WizardStore; @@ -77,6 +78,11 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { label: 'Audit checks', component: , }, + { + id: 'cull-run', + label: 'Cull run', + component: , + }, { id: 'learn-deck', label: 'Learn deck', diff --git a/src/ui/tui/playground/demos/CullRunDemo.tsx b/src/ui/tui/playground/demos/CullRunDemo.tsx new file mode 100644 index 000000000..88e4a717b --- /dev/null +++ b/src/ui/tui/playground/demos/CullRunDemo.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + reduceCullProgress, +} from '@lib/programs/cull-feature-flags/phase'; +import { SplitView } from '@ui/tui/primitives/index'; +import { Colors } from '@ui/tui/styles'; +import { AuditAreaPane } from '@ui/tui/screens/audit/AuditAreaPane'; +import { + CullFlagList, + PhaseStepper, +} from '@ui/tui/screens/audit/cull/CullFlagList'; +import { CULL_AREA_SLIDES } from '@ui/tui/screens/audit/slides/cull'; +import { + cullPhase, + type CullPhase, +} from '@ui/tui/screens/audit/slides/cull/phase'; + +const REPORT_PATH = './posthog-feature-flag-cull-report.md'; +const PHASES: CullPhase[] = ['verify', 'pick', 'cull', 'report']; +const MOCK_STATUS_SCRIPT = [ + 'Culling legacy-banner', + 'Editing src/dashboard.tsx', + 'Culling old-pricing-test', + 'Type checking 2 files', + 'Disabling legacy-banner in PostHog', +]; + +const STALE_FLAGS: AuditCheck[] = [ + { + id: 'new-checkout', + area: 'Rolled out', + label: 'new-checkout: keep on path, drop check, disable flag', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'beta-dashboard', + area: 'Off for everyone', + label: 'beta-dashboard: keep off path, drop check, disable flag', + status: 'pending', + details: 'rollout 0%', + }, + { + id: 'legacy-banner', + area: 'Archived in PostHog', + label: 'legacy-banner: keep off path, drop check', + status: 'pending', + details: 'rollout 100%, archived', + }, + { + id: 'old-pricing-test', + area: 'Disabled in PostHog', + label: 'old-pricing-test: keep off path, drop check', + status: 'pending', + details: 'rollout 50%, inactive', + }, + { + id: 'pricing-v2-experiment', + area: 'Unreferenced', + label: 'pricing-v2-experiment: disable flag', + status: 'pending', + details: 'rollout 30%', + }, + { + id: 'holiday-promo', + area: 'Comment only', + label: 'holiday-promo: disable flag, drop comment', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'legacy-theme', + area: 'Dead code', + label: 'legacy-theme: delete dead module, disable flag', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'server-rate-limit', + area: 'Deleted in PostHog', + label: 'server-rate-limit: keep off path, drop check', + status: 'pending', + details: 'no flag with this key in PostHog', + }, +]; + +const SMALL_LEDGER: AuditCheck[] = [ + ...STALE_FLAGS, + { + id: 'ai-assistant', + area: 'Many call sites', + label: 'ai-assistant: suggest one wrapper hook', + status: 'suggestion', + details: 'rollout 40%; also src/chat.ts:12, src/nav.ts:8', + }, + ...Array.from( + { length: 10 }, + (_, index): AuditCheck => ({ + id: `healthy-flag-${String(index + 1).padStart(2, '0')}`, + area: 'Healthy', + label: `healthy-flag-${index + 1}: keep`, + status: 'pass', + details: `rollout ${20 + index * 5}%`, + }), + ), +]; + +function generatedLedger(): AuditCheck[] { + return Array.from({ length: 400 }, (_, index) => { + const template = SMALL_LEDGER[index % SMALL_LEDGER.length]; + return { + ...template, + id: `${template.id}-${String(index + 1).padStart(3, '0')}`, + label: `${template.label} ${index + 1}`, + }; + }); +} + +function appendDetail(details: string | undefined, detail: string): string { + if (!details) return detail; + return `${details}; ${detail}`; +} + +function ledgerForPhase( + sourceChecks: readonly AuditCheck[], + phase: CullPhase, +): AuditCheck[] { + let staleIndex = 0; + return sourceChecks.map((check) => { + if (check.area === 'Healthy' || check.area === 'Many call sites') { + return { ...check }; + } + const currentStaleIndex = staleIndex; + staleIndex += 1; + if (phase === 'verify') return { ...check, status: 'pending' }; + if (phase === 'pick') { + if (currentStaleIndex % 8 === 0) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'kept: rollback switch'), + }; + } + return { ...check, status: 'warning' }; + } + if (phase === 'cull') { + if (currentStaleIndex % 8 === 0) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'culled'), + }; + } + if (currentStaleIndex % 8 === 1) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'declined by user'), + }; + } + return { ...check, status: 'warning' }; + } + if (currentStaleIndex % 8 < 5) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'culled'), + }; + } + if (currentStaleIndex % 8 === 6) { + return { + ...check, + status: 'error', + details: appendDetail(check.details, 'failed: type check failed'), + }; + } + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'declined by user'), + }; + }); +} + +export const CullRunDemo = () => { + const [phaseIndex, setPhaseIndex] = useState(0); + const [progress, setProgress] = useState(INITIAL_CULL_PROGRESS); + const [statusIndex, setStatusIndex] = useState(0); + const [isLargeLedger, setIsLargeLedger] = useState(false); + const selectedPhase = PHASES[phaseIndex]; + const checks = useMemo(() => { + const sourceChecks = isLargeLedger ? generatedLedger() : SMALL_LEDGER; + return ledgerForPhase(sourceChecks, selectedPhase); + }, [isLargeLedger, selectedPhase]); + const { phase, copy } = cullPhase(checks, progress, REPORT_PATH); + + const resetProgress = () => { + setProgress(INITIAL_CULL_PROGRESS); + setStatusIndex(0); + }; + + useInput((input) => { + if (input === 'n') { + setPhaseIndex((current) => Math.min(PHASES.length - 1, current + 1)); + resetProgress(); + return; + } + if (input === 'p') { + setPhaseIndex((current) => Math.max(0, current - 1)); + resetProgress(); + return; + } + if (input === 's') { + const status = MOCK_STATUS_SCRIPT[statusIndex]; + setProgress((current) => reduceCullProgress(current, status)); + setStatusIndex((current) => (current + 1) % MOCK_STATUS_SCRIPT.length); + return; + } + if (input === 'l') setIsLargeLedger((current) => !current); + }); + + const leftPane = ( + + ); + + return ( + + + + + } + /> + + + + n/p phase ·{' '} + s status ·{' '} + l ledger ({checks.length}) + + + + ); +}; diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3bc..7a8f2ff29 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -28,6 +28,7 @@ import { SourceMapsIntroScreen } from './screens/SourceMapsIntroScreen.js'; import { SourceMapsDetectScreen } from './screens/SourceMapsDetectScreen.js'; import { SourceMapsOutroScreen } from './screens/SourceMapsOutroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; +import { CullIntroScreen } from './screens/CullIntroScreen.js'; import { AiObservabilityIntroScreen } from './screens/AiObservabilityIntroScreen.js'; import { MetricsIntroScreen } from './screens/MetricsIntroScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; @@ -92,6 +93,7 @@ export function createScreens( [ScreenId.SourceMapsOutro]: , [ScreenId.MigrationIntro]: , [ScreenId.AgentSkillIntro]: , + [ScreenId.CullIntro]: , [ScreenId.AiObservabilityIntro]: ( ), diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cfb..f61156709 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -24,6 +24,7 @@ export enum ScreenId { SourceMapsOutro = 'source-maps-outro', MigrationIntro = 'migration-intro', AgentSkillIntro = 'agent-skill-intro', + CullIntro = 'cull-intro', AiObservabilityIntro = 'ai-observability-intro', MetricsIntro = 'metrics-intro', SelfDrivingIntro = 'self-driving-intro', diff --git a/src/ui/tui/screens/CullIntroScreen.tsx b/src/ui/tui/screens/CullIntroScreen.tsx new file mode 100644 index 000000000..7473195a3 --- /dev/null +++ b/src/ui/tui/screens/CullIntroScreen.tsx @@ -0,0 +1,91 @@ +import { Box, Text } from 'ink'; +import type { ReactNode } from 'react'; +import { useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { IntroScreenLayout } from './IntroScreenLayout.js'; +import { SkillSourceInfo, useSkillEntry } from './SkillSourceInfo.js'; + +interface CullIntroScreenProps { + store: WizardStore; +} + +// The generic agent-skill intro promises nothing about edits and the audit +// intro promises none happen; culling edits on consent, so it says so up front. +export const CullIntroScreen = ({ store }: CullIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [showingMoreInfo, setShowingMoreInfo] = useState(false); + const { session } = store; + const skillId = session.skillId ?? 'cull-feature-flags'; + const { skillEntry, fetchFailed } = useSkillEntry(skillId); + + const moreInfoBody: ReactNode = ( + + + + The wizard is an agent that executes PostHog tasks. Its code is open + source: https://github.com/PostHog/wizard + + + + The wizard scans this project for feature flag calls and compares them + with the flags in your PostHog project, then shows you the ones that + look done. Nothing changes until you pick which ones to cull. Flags get + disabled in PostHog, never deleted, and code edits land as an ordinary + git diff, so either side is a one-step undo. + + + + + + ); + + const introBody: ReactNode = ( + + + We'll find feature flags that look done and show you the list. + + + Nothing changes until you pick which ones to cull. Disable only, never + delete; code edits are a git diff away from undo. + + + ); + + const body = showingMoreInfo ? moreInfoBody : introBody; + + const menuOptions = showingMoreInfo + ? [{ label: 'Back', value: 'back' }] + : [ + { label: 'Continue', value: 'continue' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (value: string) => { + if (value === 'cancel') return process.exit(0); + if (value === 'more-info') return setShowingMoreInfo(true); + if (value === 'back') return setShowingMoreInfo(false); + store.completeSetup(); + }; + + return ( + + ); +}; diff --git a/src/ui/tui/screens/audit/AuditAreaPane.tsx b/src/ui/tui/screens/audit/AuditAreaPane.tsx index 2ceb1e512..257700147 100644 --- a/src/ui/tui/screens/audit/AuditAreaPane.tsx +++ b/src/ui/tui/screens/audit/AuditAreaPane.tsx @@ -15,6 +15,7 @@ import { Fragment } from 'react'; import { Box, Text, useInput } from 'ink'; +import { Spinner } from '@inkjs/ui'; import { spawn } from 'node:child_process'; import { Colors } from '@ui/tui/styles'; import { type AuditCheck } from '@lib/programs/audit/types'; @@ -61,6 +62,16 @@ interface AuditAreaPaneProps { /** Notebook URL once the agent emits `[NOTEBOOK_URL]`. Same sticky-footer * treatment as the dashboard URL. */ notebookUrl?: string | null; + /** Replaces the report wrap-up once no check is pending; programs with + * stages after verification (consent, apply) pass their own copy. */ + wrapUp?: WrapUpCopy; +} + +export interface WrapUpCopy { + title: string; + paragraphs: string[]; + /** The agent is mid-turn behind this copy; show a spinner so a long wait reads as work, not a hang. */ + isWorking?: boolean; } export const AuditAreaPane = ({ @@ -69,6 +80,7 @@ export const AuditAreaPane = ({ slides = AUDIT_AREA_SLIDES, dashboardUrl, notebookUrl, + wrapUp, }: AuditAreaPaneProps) => { const pendingChecks = checks.filter((c) => c.status === 'pending'); const activeArea = pendingChecks[0]?.area; @@ -108,12 +120,37 @@ export const AuditAreaPane = ({ // Every check is resolved and the agent is composing the report. return ( - + {wrapUp ? ( + + ) : ( + + )} {urlsFooter} ); }; +const StageCopy = ({ copy }: { copy: WrapUpCopy }) => ( + + + {copy.isWorking ? ( + + + + ) : null} + + {copy.title} + + + {copy.paragraphs.map((paragraph, i) => ( + + + {paragraph} + + ))} + +); + // ── States ─────────────────────────────────────────────────────────── const ActiveSlide = ({ diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index 1f5e22b7f..7bf26012b 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { join } from 'node:path'; import { Box } from 'ink'; import type { WizardStore } from '@ui/tui/store'; @@ -10,11 +10,15 @@ import { } from '@ui/tui/primitives/index'; import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; import { useFileWatcher } from '@ui/tui/hooks/file-watcher'; +import { LearnCard } from '@ui/tui/components/LearnCard'; import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js'; import { AuditAreaPane } from './AuditAreaPane.js'; import { AUDIT_AREA_SLIDES } from './slides/index.js'; import { EVENTS_AUDIT_AREA_SLIDES } from './slides/events-audit/index.js'; +import { CULL_AREA_SLIDES } from './slides/cull/index.js'; +import { cullPhase } from './slides/cull/phase.js'; import { PendingChecksList } from './PendingChecksList.js'; +import { CullFlagList, PhaseStepper } from './cull/CullFlagList.js'; import { AUDIT_CHECKS_FILE, AUDIT_CHECKS_KEY, @@ -29,6 +33,12 @@ interface AuditRunScreenProps { store: WizardStore; } +const slidesFor = (activeProgram: string, skillId: string | null) => { + if (activeProgram === 'cull-feature-flags') return CULL_AREA_SLIDES; + if (skillId === 'audit-events') return EVENTS_AUDIT_AREA_SLIDES; + return AUDIT_AREA_SLIDES; +}; + export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { useSyncExternalStore( (cb) => store.subscribe(cb), @@ -49,29 +59,61 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { getProgramConfig(store.router.activeProgram).reportFile ?? AUDIT_REPORT_FILE; const reportPath = `./${reportFile}`; - const pendingChecksList = ; - const slides = - store.session.skillId === 'audit-events' - ? EVENTS_AUDIT_AREA_SLIDES - : AUDIT_AREA_SLIDES; - const areaPane = ( + const activeProgram = store.router.activeProgram; + const isCull = activeProgram === 'cull-feature-flags'; + const slides = slidesFor(activeProgram, store.session.skillId); + const { phase, copy } = isCull + ? cullPhase(checks, store.cullProgress, reportPath) + : { phase: undefined, copy: undefined }; + const learnBlocks = useMemo(() => { + if (activeProgram !== 'cull-feature-flags') return undefined; + return getProgramConfig(activeProgram).getContentBlocks?.(store); + }, [activeProgram, store]); + const showLearnDeck = + isCull && phase === 'verify' && !store.learnCardComplete && !!learnBlocks; + let leftPane = ( ); + if (showLearnDeck) { + leftPane = ( + store.setLearnCardComplete()} + /> + ); + } + + const pendingChecksList = ; + const cullFlagList = phase ? ( + + ) : null; + const rightPane = isCull ? cullFlagList : pendingChecksList; // Narrow terminals: drop the area pane. - const statusComponent = + const statusLayout = columns < 80 ? ( - {pendingChecksList} + {rightPane} + + ) : ( + + ); + const statusComponent = + isCull && phase ? ( + + + {statusLayout} ) : ( - + statusLayout ); const tabs = [ diff --git a/src/ui/tui/screens/audit/PendingChecksList.tsx b/src/ui/tui/screens/audit/PendingChecksList.tsx index 530dacafc..2c57227e0 100644 --- a/src/ui/tui/screens/audit/PendingChecksList.tsx +++ b/src/ui/tui/screens/audit/PendingChecksList.tsx @@ -75,7 +75,7 @@ const GroupHeader = ({ const CheckRow = ({ check }: { check: AuditCheck }) => { const { glyph, color } = AUDIT_SEVERITY_STYLE[check.status]; return ( - + {glyph} {check.label} diff --git a/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts new file mode 100644 index 000000000..48648ff4a --- /dev/null +++ b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts @@ -0,0 +1,175 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; +import { toLaneGroups } from '../cull/CullFlagList.js'; + +function check( + id: string, + area: string, + status: AuditCheck['status'] = 'pending', + details = 'rollout 100%', +): AuditCheck { + return { id, area, status, details, label: id }; +} + +function progress(overrides: Partial): CullProgress { + return { ...INITIAL_CULL_PROGRESS, ...overrides }; +} + +describe('toLaneGroups', () => { + it('groups rows in the configured lane order and sends unknown areas to nothing to cull', () => { + const groups = toLaneGroups( + [ + check('unknown', 'Future classifier area'), + check('missing', 'Unreferenced'), + check('off', 'Archived in PostHog'), + check('decided', 'Rolled out'), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(groups.map((group) => group.lane)).toEqual([ + 'decided', + 'off-in-posthog', + 'not-in-code', + 'nothing-to-cull', + ]); + expect(groups[3].rows.map((row) => row.id)).toEqual(['unknown']); + }); + + it('folds healthy and many-call-site rows into one verify footer', () => { + const groups = toLaneGroups( + [ + check('healthy-one', 'Healthy', 'pass', 'rollout 25%'), + check('healthy-two', 'Healthy', 'pass', 'multivariate'), + check('wrapper', 'Many call sites', 'suggestion', 'rollout 50%'), + check('candidate', 'Rolled out'), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + const nothingToCull = groups.find( + (group) => group.lane === 'nothing-to-cull', + ); + + expect(nothingToCull).toMatchObject({ + rows: [], + footer: '2 healthy, 1 suggestion, details in the report', + }); + }); + + it('folds declined rows into their lane footer during cull', () => { + const groups = toLaneGroups( + [ + check( + 'declined-one', + 'Rolled out', + 'pass', + 'rollout 100%; declined by user', + ), + check( + 'declined-two', + 'Off for everyone', + 'pass', + 'rollout 0%; declined by user', + ), + check( + 'declined-three', + 'Disabled in PostHog', + 'pass', + 'inactive; declined by user', + ), + ], + INITIAL_CULL_PROGRESS, + 'cull', + ); + + expect(groups).toEqual([ + expect.objectContaining({ + lane: 'decided', + rows: [], + footer: '2 left for you', + complete: 2, + total: 2, + }), + expect.objectContaining({ + lane: 'off-in-posthog', + rows: [], + footer: '1 left for you', + complete: 1, + total: 1, + }), + ]); + }); + + it('keeps kept rows visible in verify with the kept reason', () => { + const [group] = toLaneGroups( + [ + check( + 'kill-switch', + 'Off for everyone', + 'pass', + 'rollout 0%; kept: protects emergency rollback; reviewed', + ), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(group.rows[0]).toMatchObject({ + id: 'kill-switch', + state: 'kept', + text: 'kill-switch protects emergency rollback', + }); + }); + + it('caps each lane at five rows and reports the hidden count', () => { + const [group] = toLaneGroups( + Array.from({ length: 7 }, (_, index) => + check(`flag-${index + 1}`, 'Rolled out'), + ), + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(group.rows).toHaveLength(5); + expect(group.hiddenCount).toBe(2); + expect(group.total).toBe(7); + }); + + it.each([ + ['edit', 'active', [], 'editing'], + ['disable', 'active', [], 'disabling'], + ['verify', null, ['active'], 'edited'], + ] as const)( + 'maps %s progress to the %s row state', + (pass, activeKey, edited, expectedState) => { + const [group] = toLaneGroups( + [check('active', 'Rolled out', 'warning')], + progress({ pass, activeKey, edited: [...edited] }), + 'cull', + ); + + expect(group.rows[0]).toMatchObject({ + state: expectedState, + text: `active ${expectedState}`, + }); + }, + ); + + it('does not mark any row active when the active key is unknown', () => { + const states = toLaneGroups( + [ + check('first', 'Rolled out', 'warning'), + check('second', 'Off for everyone', 'warning'), + ], + progress({ pass: 'edit', activeKey: 'not-in-ledger' }), + 'cull', + ).flatMap((lane) => lane.rows.map((row) => row.state)); + + expect(states).toEqual(['proposed', 'proposed']); + }); +}); diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts new file mode 100644 index 000000000..5c8ed5669 --- /dev/null +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -0,0 +1,249 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + cullPhase, + reduceCullProgress, + type CullProgress, +} from '../slides/cull/phase'; + +function buildCheck(overrides: Partial = {}): AuditCheck { + return { + id: 'checkout-redesign', + area: 'Rolled out', + label: 'Keeps the on path', + status: 'warning', + file: 'src/checkout.ts', + details: 'winning branch: true', + ...overrides, + }; +} + +function progress(overrides: Partial = {}): CullProgress { + return { ...INITIAL_CULL_PROGRESS, ...overrides }; +} + +const REPORT_PATH = './posthog-feature-flag-cull-report.md'; + +describe('reduceCullProgress', () => { + it('reduces the complete cull status script as messages arrive', () => { + const waiting = reduceCullProgress( + INITIAL_CULL_PROGRESS, + 'Waiting for confirmation', + ); + expect(waiting).toBe(INITIAL_CULL_PROGRESS); + + const spinner = reduceCullProgress( + waiting, + 'Culling stale feature flags...', + ); + expect(spinner).toBe(INITIAL_CULL_PROGRESS); + + const firstFlag = reduceCullProgress(spinner, 'Culling checkout-redesign'); + expect(firstFlag).toEqual({ + pass: 'edit', + activeKey: 'checkout-redesign', + activeFile: null, + edited: [], + }); + + const firstFile = reduceCullProgress(firstFlag, 'Editing src/checkout.ts'); + expect(firstFile).toEqual({ + ...firstFlag, + activeFile: 'src/checkout.ts', + }); + + const secondFile = reduceCullProgress( + firstFile, + 'Editing src/checkout.test.ts', + ); + expect(secondFile).toEqual({ + ...firstFile, + activeFile: 'src/checkout.test.ts', + }); + + const secondFlag = reduceCullProgress(secondFile, 'Culling search-v2'); + expect(secondFlag).toEqual({ + pass: 'edit', + activeKey: 'search-v2', + activeFile: null, + edited: ['checkout-redesign'], + }); + + const verifying = reduceCullProgress(secondFlag, 'Type checking 2 files'); + expect(verifying).toEqual({ + pass: 'verify', + activeKey: null, + activeFile: null, + edited: ['checkout-redesign', 'search-v2'], + }); + + const disabling = reduceCullProgress( + verifying, + 'Disabling checkout-redesign in PostHog', + ); + expect(disabling).toEqual({ + pass: 'disable', + activeKey: 'checkout-redesign', + activeFile: null, + edited: ['checkout-redesign', 'search-v2'], + }); + + const completed = reduceCullProgress( + disabling, + 'Culled 2 flags, 0 failed, 0 left for you', + ); + expect(completed).toBe(disabling); + }); + + it('returns the same object for an unknown status line', () => { + const currentProgress = progress({ pass: 'verify' }); + + expect(reduceCullProgress(currentProgress, 'Writing report')).toBe( + currentProgress, + ); + }); +}); + +describe('cullPhase', () => { + it('stays in verify while any ledger row is pending', () => { + expect( + cullPhase( + [buildCheck({ status: 'pending' })], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ), + ).toEqual({ phase: 'verify', copy: undefined }); + }); + + it('waits for a pick when proposals are undecided and progress is idle', () => { + const phase = cullPhase([buildCheck()], INITIAL_CULL_PROGRESS, REPORT_PATH); + + expect(phase.phase).toBe('pick'); + expect(phase.copy).toEqual({ + title: 'Your pick list is on its way', + paragraphs: expect.any(Array), + isWorking: true, + }); + }); + + it('enters cull when every approved proposal is still warning', () => { + const checks = [ + buildCheck(), + buildCheck({ id: 'search-v2', file: 'src/search.ts' }), + ]; + const activeProgress = reduceCullProgress( + INITIAL_CULL_PROGRESS, + 'Culling checkout-redesign', + ); + const phase = cullPhase(checks, activeProgress, REPORT_PATH); + + expect(phase.phase).toBe('cull'); + expect(phase.copy).toMatchObject({ + title: 'Editing code', + paragraphs: expect.any(Array), + }); + expect(phase.copy?.paragraphs.join(' ')).toContain('checkout-redesign'); + const why = phase.copy?.paragraphs.find((paragraph) => + paragraph.startsWith('Why: '), + ); + expect(why).toContain('Rolled out'); + expect(why).toContain('Keeps the code that runs today'); + expect(why).toContain('disabled in PostHog'); + }); + + it.each([ + ['edit', 'Editing code'], + ['verify', 'Checking the edits'], + ['disable', 'Disabling flags in PostHog'], + ] as const)('shows the %s pass card', (pass, expectedTitle) => { + const phase = cullPhase([buildCheck()], progress({ pass }), REPORT_PATH); + + expect(phase).toMatchObject({ + phase: 'cull', + copy: { title: expectedTitle, paragraphs: expect.any(Array) }, + }); + }); + + it('enters cull while progress is idle after any row is decided', () => { + const phase = cullPhase( + [ + buildCheck(), + buildCheck({ + id: 'old-search', + status: 'pass', + details: 'winning branch: false; declined by user', + }), + ], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase).toMatchObject({ + phase: 'cull', + copy: { title: 'Culling', paragraphs: expect.any(Array) }, + }); + }); + + it('uses the approved count when the active key is absent from the ledger', () => { + const phase = cullPhase( + [buildCheck(), buildCheck({ id: 'search-v2' })], + progress({ pass: 'edit', activeKey: 'not-in-the-ledger' }), + REPORT_PATH, + ); + const paragraphs = phase.copy?.paragraphs.join(' ') ?? ''; + + expect(phase.phase).toBe('cull'); + expect(paragraphs).toContain('Culling 2 flags'); + expect(paragraphs).not.toContain('not-in-the-ledger'); + }); + + it('reports after proposals are resolved', () => { + const phase = cullPhase( + [buildCheck({ status: 'pass', details: 'winning branch: true; culled' })], + progress({ pass: 'disable' }), + REPORT_PATH, + ); + + expect(phase).toMatchObject({ + phase: 'report', + copy: { paragraphs: expect.any(Array) }, + }); + }); + + it('uses the report-only outcome after every proposal is declined', () => { + const phase = cullPhase( + [ + buildCheck({ + status: 'pass', + details: 'winning branch: true; declined by user', + }), + ], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase).toEqual({ + phase: 'report', + copy: { + title: 'Report only. Nothing changed.', + paragraphs: [ + expect.stringContaining('./posthog-feature-flag-cull-report.md'), + ], + }, + }); + }); + + it('excludes report-only many-call-site warnings from proposals', () => { + const phase = cullPhase( + [buildCheck({ area: 'Many call sites' })], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase.phase).toBe('report'); + expect(phase.copy).toEqual({ + title: expect.any(String), + paragraphs: expect.any(Array), + }); + }); +}); diff --git a/src/ui/tui/screens/audit/cull/CullFlagList.tsx b/src/ui/tui/screens/audit/cull/CullFlagList.tsx new file mode 100644 index 000000000..fc3229997 --- /dev/null +++ b/src/ui/tui/screens/audit/cull/CullFlagList.tsx @@ -0,0 +1,449 @@ +import { Fragment } from 'react'; +import { Box, Text } from 'ink'; +import { Spinner } from '@inkjs/ui'; +import { + AUDIT_SEVERITY_STYLE, + type AuditCheck, +} from '@lib/programs/audit/types'; +import { + LANE_BY_AREA, + LANE_LABEL, + LANE_ORDER, + type CullLane, +} from '@lib/programs/cull-feature-flags/classify'; +import type { CullProgress } from '@lib/programs/cull-feature-flags/phase'; +import { + CULLED_MARKER, + DECLINED_MARKER, +} from '@lib/programs/cull-feature-flags/seed'; +import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; +import { LoadingBox } from '@ui/tui/primitives/index'; +import { Colors, Icons } from '@ui/tui/styles'; +import type { CullPhase } from '../slides/cull/phase.js'; + +export type LaneRowState = + | 'pending' + | 'proposed' + | 'kept' + | 'editing' + | 'edited' + | 'disabling' + | 'culled' + | 'failed' + | 'declined'; + +export interface LaneRow { + id: string; + glyph: string; + color: string; + text: string; + state: LaneRowState; +} + +export interface LaneGroup { + lane: CullLane; + label: string; + rows: LaneRow[]; + hiddenCount: number; + footer?: string; + complete: number; + total: number; +} + +interface MutableLaneGroup { + rows: LaneRow[]; + complete: number; + total: number; + declinedCount: number; +} + +interface FoldedCounts { + healthy: number; + kept: number; + suggestions: number; +} + +interface CullFlagListProps { + checks: readonly AuditCheck[]; + progress: CullProgress; + phase: CullPhase; +} + +const MAX_VISIBLE_ROWS = 5; +const COLLAPSE_BELOW_ROWS = 30; +const KEPT_MARKER = '; kept:'; +const FAILED_MARKER = '; failed:'; + +const PHASE_STEPS: ReadonlyArray<{ phase: CullPhase; label: string }> = [ + { phase: 'verify', label: 'Verify' }, + { phase: 'pick', label: 'Pick' }, + { phase: 'cull', label: 'Cull' }, + { phase: 'report', label: 'Report' }, +]; + +function hasMarker(check: AuditCheck, marker: string): boolean { + return (check.details ?? '').includes(marker); +} + +function markerReason(details: string, marker: string): string { + const markerIndex = details.indexOf(marker); + if (markerIndex < 0) return ''; + return details + .slice(markerIndex + marker.length) + .split(';')[0] + .trim(); +} + +function rowReason(check: AuditCheck): string { + const details = check.details ?? ''; + if (hasMarker(check, KEPT_MARKER)) { + return markerReason(details, KEPT_MARKER); + } + if (hasMarker(check, FAILED_MARKER)) { + return markerReason(details, FAILED_MARKER); + } + return details.split(';')[0].trim(); +} + +function baseRowState(check: AuditCheck): LaneRowState { + if (hasMarker(check, CULLED_MARKER)) return 'culled'; + if (check.status === 'error' || hasMarker(check, FAILED_MARKER)) { + return 'failed'; + } + if (hasMarker(check, DECLINED_MARKER)) return 'declined'; + if (hasMarker(check, KEPT_MARKER)) return 'kept'; + if (check.status === 'warning') return 'proposed'; + return 'pending'; +} + +function rowState( + check: AuditCheck, + progress: CullProgress, + phase: CullPhase, +): LaneRowState { + const state = baseRowState(check); + if (state === 'culled' || state === 'failed' || state === 'declined') { + return state; + } + if (phase !== 'cull') return state; + if (check.id === progress.activeKey && progress.pass === 'edit') { + return 'editing'; + } + if (check.id === progress.activeKey && progress.pass === 'disable') { + return 'disabling'; + } + if (progress.edited.includes(check.id)) return 'edited'; + return state; +} + +function stateReason(state: LaneRowState, check: AuditCheck): string { + if (state === 'editing') return 'editing'; + if (state === 'edited') return 'edited'; + if (state === 'disabling') return 'disabling'; + if (state === 'culled') return 'culled'; + return rowReason(check); +} + +function toLaneRow( + check: AuditCheck, + progress: CullProgress, + phase: CullPhase, +): LaneRow { + const state = rowState(check, progress, phase); + const { glyph, color } = AUDIT_SEVERITY_STYLE[check.status]; + return { + id: check.id, + glyph, + color, + text: `${check.id} ${stateReason(state, check)}`, + state, + }; +} + +function isCompleteState(state: LaneRowState): boolean { + return ['kept', 'edited', 'culled', 'failed', 'declined'].includes(state); +} + +function isHealthy(check: AuditCheck): boolean { + if (check.status !== 'pass') return false; + return ![KEPT_MARKER, CULLED_MARKER, DECLINED_MARKER].some((marker) => + hasMarker(check, marker), + ); +} + +function shouldShowInVerify(check: AuditCheck): boolean { + if (hasMarker(check, KEPT_MARKER)) return true; + return check.status === 'pending' || check.status === 'warning'; +} + +function shouldShowInCull(check: AuditCheck): boolean { + if (hasMarker(check, CULLED_MARKER)) return true; + if (check.status === 'error') return true; + return check.status === 'warning'; +} + +function foldedFooter(counts: FoldedCounts): string | undefined { + const parts = [ + counts.healthy > 0 ? `${counts.healthy} healthy` : null, + counts.kept > 0 ? `${counts.kept} kept` : null, + counts.suggestions > 0 + ? `${counts.suggestions} suggestion${counts.suggestions === 1 ? '' : 's'}` + : null, + ].filter((part): part is string => part !== null); + if (parts.length === 0) return undefined; + const hasReportItems = counts.kept > 0 || counts.suggestions > 0; + const tail = hasReportItems ? 'details in the report' : 'nothing to do'; + return `${parts.join(', ')}, ${tail}`; +} + +export function toLaneGroups( + checks: readonly AuditCheck[], + progress: CullProgress, + phase: CullPhase, +): LaneGroup[] { + const groupsByLane: Record = { + decided: { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'off-in-posthog': { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'not-in-code': { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'nothing-to-cull': { + rows: [], + complete: 0, + total: 0, + declinedCount: 0, + }, + }; + const foldedCounts: FoldedCounts = { + healthy: 0, + kept: 0, + suggestions: 0, + }; + const isVerifyOrPick = phase === 'verify' || phase === 'pick'; + + for (const check of checks) { + const sourceLane = LANE_BY_AREA[check.area] ?? 'nothing-to-cull'; + const isSuggestion = check.area === 'Many call sites'; + const isApprovedSuggestion = + !isVerifyOrPick && + (hasMarker(check, CULLED_MARKER) || check.status === 'error'); + if (isSuggestion && !isApprovedSuggestion) { + foldedCounts.suggestions += 1; + continue; + } + if (isVerifyOrPick && isHealthy(check)) { + foldedCounts.healthy += 1; + continue; + } + if (!isVerifyOrPick && hasMarker(check, DECLINED_MARKER)) { + const group = groupsByLane[sourceLane]; + group.declinedCount += 1; + group.complete += 1; + group.total += 1; + continue; + } + if (!isVerifyOrPick && hasMarker(check, KEPT_MARKER)) { + foldedCounts.kept += 1; + continue; + } + if (!isVerifyOrPick && isHealthy(check)) { + foldedCounts.healthy += 1; + continue; + } + + const shouldShow = isVerifyOrPick + ? shouldShowInVerify(check) + : shouldShowInCull(check); + if (!shouldShow) continue; + const group = groupsByLane[sourceLane]; + const row = toLaneRow(check, progress, phase); + group.rows.push(row); + group.total += 1; + if (isCompleteState(row.state)) group.complete += 1; + } + + const nothingToCullFooter = foldedFooter(foldedCounts); + return LANE_ORDER.map((lane): LaneGroup | null => { + const group = groupsByLane[lane]; + const hiddenCount = Math.max(0, group.rows.length - MAX_VISIBLE_ROWS); + const declinedFooter = + group.declinedCount > 0 + ? `${group.declinedCount} left for you` + : undefined; + const footer = + lane === 'nothing-to-cull' ? nothingToCullFooter : declinedFooter; + if (group.rows.length === 0 && !footer) return null; + return { + lane, + label: LANE_LABEL[lane], + rows: group.rows.slice(0, MAX_VISIBLE_ROWS), + hiddenCount, + footer, + complete: group.complete, + total: group.total, + }; + }).filter((group): group is LaneGroup => group !== null); +} + +function groupIcon(group: LaneGroup): { icon: string; color: string } { + if (group.complete === 0) { + return { icon: Icons.squareOpen, color: Colors.muted }; + } + if (group.complete === group.total) { + return { icon: Icons.squareFilled, color: Colors.success }; + } + return { icon: Icons.triangleRight, color: Colors.primary }; +} + +const LaneHeader = ({ + group, + isActive, +}: { + group: LaneGroup; + isActive: boolean; +}) => { + const { icon, color } = groupIcon(group); + return ( + + {isActive ? ( + + + + ) : ( + + {icon}{' '} + + )} + + {group.label}{' '} + {group.lane !== 'nothing-to-cull' && ( + + ({group.complete}/{group.total}) + + )} + + + ); +}; + +const FlagRow = ({ row }: { row: LaneRow }) => { + if (row.state === 'editing' || row.state === 'disabling') { + return ( + + + + + {row.text} + + ); + } + return ( + + {row.glyph} {row.text} + + ); +}; + +function activeLaneFor( + checks: readonly AuditCheck[], + progress: CullProgress, + phase: CullPhase, +): CullLane | undefined { + if (phase === 'verify') { + const firstPending = checks.find( + (check) => check.status === 'pending' && check.area !== 'Many call sites', + ); + if (!firstPending) return undefined; + return LANE_BY_AREA[firstPending.area] ?? 'nothing-to-cull'; + } + if (phase !== 'cull' || !progress.activeKey) return undefined; + const activeCheck = checks.find((check) => check.id === progress.activeKey); + if (!activeCheck) return undefined; + return LANE_BY_AREA[activeCheck.area] ?? 'nothing-to-cull'; +} + +export const PhaseStepper = ({ phase }: { phase: CullPhase }) => { + const currentIndex = PHASE_STEPS.findIndex((step) => step.phase === phase); + return ( + + {PHASE_STEPS.map((step, index) => ( + + {index > 0 && } + {index < currentIndex ? ( + + {Icons.check} {step.label} + + ) : null} + {index === currentIndex ? ( + + {step.label} + + ) : null} + {index > currentIndex ? ( + {step.label} + ) : null} + + ))} + + ); +}; + +export const CullFlagList = ({ + checks, + progress, + phase, +}: CullFlagListProps) => { + const [, terminalRows] = useStdoutDimensions(); + if (checks.length === 0) { + return ( + + Flags + + + + ); + } + + const groups = toLaneGroups(checks, progress, phase); + const activeLane = activeLaneFor(checks, progress, phase); + const shouldCollapse = terminalRows < COLLAPSE_BELOW_ROWS; + + return ( + + Flags + + {groups.map((group, index) => { + const isActive = group.lane === activeLane; + const isExpanded = !shouldCollapse || isActive; + const showsOnlyFooter = + group.lane === 'nothing-to-cull' && group.rows.length === 0; + if (showsOnlyFooter) { + return ( + + {group.footer} + + ); + } + return ( + + + {isExpanded && + group.rows.map((row) => )} + {isExpanded && group.hiddenCount > 0 && ( + + +{group.hiddenCount} more + + )} + {isExpanded && group.footer && ( + + {group.footer} + + )} + + ); + })} + + ); +}; diff --git a/src/ui/tui/screens/audit/slides/cull/index.ts b/src/ui/tui/screens/audit/slides/cull/index.ts new file mode 100644 index 000000000..435963345 --- /dev/null +++ b/src/ui/tui/screens/audit/slides/cull/index.ts @@ -0,0 +1,55 @@ +import type { AreaSlide } from '../shared.js'; + +const DOCS_URL = 'https://posthog.com/docs/feature-flags/best-practices'; + +const slide = (area: string, intro: string[]): AreaSlide => ({ + area, + intro, + docsUrl: DOCS_URL, +}); + +export const CULL_AREA_SLIDES: AreaSlide[] = [ + slide('Rolled out', [ + 'This flag is at 100% for everyone, so culling preserves the on path and removes the check.', + 'The wizard checked that no rollout conditions remain; the agent confirms the call site is a plain on/off check.', + ]), + slide('Off for everyone', [ + 'This flag is at 0% for everyone, so culling preserves the off path and removes the check.', + 'A flag rolled back after an incident looks the same; if it reads like a kill switch, keep it.', + ]), + slide('Archived in PostHog', [ + 'PostHog archived this flag, but the code still checks it, so culling preserves the off path and removes the check.', + 'The flag stays archived.', + ]), + slide('Disabled in PostHog', [ + 'PostHog switched this flag off, but the code still checks it, so culling preserves the off path and removes the check.', + 'The flag stays off in PostHog.', + ]), + slide('Unreferenced', [ + 'PostHog has this flag, but nothing in this repository evaluates it.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent checks bulk and computed-key reads before suggesting it.', + ]), + slide('Comment only', [ + 'This key appears only in a comment or config string, not an evaluation.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent confirms that no executable evaluation exists before removing the mention.', + ]), + slide('Dead code', [ + 'The only file checking this flag is not imported and is not a Next.js route.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent verifies that the file is unreachable before deleting it.', + ]), + slide('Deleted in PostHog', [ + 'The code checks a key PostHog no longer has, so culling preserves the off path and removes the check.', + 'The agent confirms that the key is not a typo of a live flag.', + ]), + slide('Many call sites', [ + 'Three or more files evaluate this flag directly, so the report suggests one hook or helper.', + 'Nothing is edited or disabled for this flag.', + ]), + slide('Healthy', [ + 'This flag is live, partially rolled out or multivariate, and the code still needs it.', + 'It is kept unchanged.', + ]), +]; diff --git a/src/ui/tui/screens/audit/slides/cull/phase.ts b/src/ui/tui/screens/audit/slides/cull/phase.ts new file mode 100644 index 000000000..0d44a9475 --- /dev/null +++ b/src/ui/tui/screens/audit/slides/cull/phase.ts @@ -0,0 +1,151 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import type { CullProgress } from '@lib/programs/cull-feature-flags/phase'; +import { DISABLING_AREAS } from '@lib/programs/cull-feature-flags/classify'; +import { + CULLED_MARKER, + DECLINED_MARKER, +} from '@lib/programs/cull-feature-flags/seed'; +import type { WrapUpCopy } from '../../AuditAreaPane.js'; + +export { + INITIAL_CULL_PROGRESS, + reduceCullProgress, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; + +const CULL_SAFETY_COPY = + 'Code first, then a type check, then PostHog: a failed edit never leaves a disabled flag behind live code.'; + +export type CullPhase = 'verify' | 'pick' | 'cull' | 'report'; + +function hasMarker(check: AuditCheck, marker: string): boolean { + return (check.details ?? '').includes(marker); +} + +const MARKER_PREFIXES = [ + 'also ', + 'winning branch:', + 'kept:', + 'culled', + 'failed:', + 'declined by user', +]; + +function whyCulled(check: AuditCheck): string { + const clauses = (check.details ?? '') + .split(';') + .map((clause) => clause.trim()) + .filter((clause) => clause.length > 0) + .filter( + (clause) => !MARKER_PREFIXES.some((prefix) => clause.startsWith(prefix)), + ); + const branch = (check.details ?? '').includes('winning branch: false') + ? 'Keeps the off branch and drops the check.' + : (check.details ?? '').includes('winning branch: true') + ? 'Keeps the code that runs today and drops the check.' + : ''; + const posthog = DISABLING_AREAS.has(check.area) + ? 'Then the flag is disabled in PostHog, never deleted.' + : 'PostHog is left untouched for this one.'; + return [`Why: ${check.area}, ${clauses.join(', ')}.`, branch, posthog] + .filter((sentence) => sentence.length > 0) + .join(' '); +} + +function buildCullCopy( + checks: readonly AuditCheck[], + progress: CullProgress, + proposals: readonly AuditCheck[], + culled: readonly AuditCheck[], + failed: readonly AuditCheck[], +): WrapUpCopy { + const titleByPass: Record = { + idle: 'Culling', + edit: 'Editing code', + verify: 'Checking the edits', + disable: 'Disabling flags in PostHog', + }; + const activeCheck = checks.find((check) => check.id === progress.activeKey); + const approvedCount = proposals.length + culled.length + failed.length; + const activeFile = progress.activeFile ?? activeCheck?.file; + const firstParagraph = activeCheck + ? `Culling ${activeCheck.id}${activeFile ? ` in ${activeFile}` : ''}.` + : `Culling ${approvedCount} ${approvedCount === 1 ? 'flag' : 'flags'}.`; + const completedCounts = [ + culled.length > 0 ? `${culled.length} culled` : null, + failed.length > 0 ? `${failed.length} failed` : null, + ].filter((count): count is string => count !== null); + const paragraphs = [firstParagraph]; + if (activeCheck) paragraphs.push(whyCulled(activeCheck)); + if (completedCounts.length > 0) { + paragraphs.push(`${completedCounts.join(', ')} so far.`); + } + paragraphs.push(CULL_SAFETY_COPY); + + return { title: titleByPass[progress.pass], paragraphs, isWorking: true }; +} + +export function cullPhase( + checks: readonly AuditCheck[], + progress: CullProgress, + reportPath: string, +): { phase: CullPhase; copy: WrapUpCopy | undefined } { + if (checks.some((check) => check.status === 'pending')) { + return { phase: 'verify', copy: undefined }; + } + + const proposals = checks.filter( + (check) => check.status === 'warning' && check.area !== 'Many call sites', + ); + const culled = checks.filter((check) => hasMarker(check, CULLED_MARKER)); + const declined = checks.filter((check) => hasMarker(check, DECLINED_MARKER)); + const failed = checks.filter((check) => check.status === 'error'); + const hasDecisions = culled.length + declined.length + failed.length > 0; + + if (proposals.length > 0 && progress.pass === 'idle' && !hasDecisions) { + return { + phase: 'pick', + copy: { + title: 'Your pick list is on its way', + paragraphs: [ + `Every flag is verified at its call site. ${proposals.length} look done and are up for culling; the healthy ones stay.`, + 'The agent is writing the prompt now, one question per group with the plan for each flag. It opens here when ready, usually within a minute or two.', + 'Nothing changes until you confirm in that prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', + ], + isWorking: true, + }, + }; + } + + if (proposals.length > 0) { + return { + phase: 'cull', + copy: buildCullCopy(checks, progress, proposals, culled, failed), + }; + } + + if (culled.length === 0 && declined.length > 0) { + return { + phase: 'report', + copy: { + title: 'Report only. Nothing changed.', + paragraphs: [ + `The report at ${reportPath} lists the ${declined.length} ${ + declined.length === 1 ? 'flag' : 'flags' + } left for you.`, + ], + }, + }; + } + + return { + phase: 'report', + copy: { + title: 'Writing the cull report', + paragraphs: [ + `${culled.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, + 'Hang tight!', + ], + }, + }; +} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 1f2f8a16f..2f3114213 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -61,6 +61,11 @@ import { reportWarehouseSourcesDetected } from '@lib/programs/posthog-integratio import { EXPANDED_COUNT } from '@ui/tui/constants'; import { IS_DEV } from '@lib/constants'; import { computeTokenCostUsd } from '@lib/agent/token-pricing'; +import { + INITIAL_CULL_PROGRESS, + reduceCullProgress, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; export { TaskStatus, ScreenId, Overlay, Program, RunPhase, McpOutcome }; export type { ScreenName, OutroData, WizardSession, ProgramId }; @@ -188,6 +193,7 @@ export class WizardStore { private $statusMessages = atom([]); private $statusExpanded = atom(false); private $tasks = atom([]); + private $cullProgress = atom(INITIAL_CULL_PROGRESS); private $eventPlan = atom([]); private $handoffText = atom(null); private $learnCardBlockIdx = atom(0); @@ -389,6 +395,10 @@ export class WizardStore { return this.$tasks.get(); } + get cullProgress(): CullProgress { + return this.$cullProgress.get(); + } + get eventPlan(): PlannedEvent[] { return this.$eventPlan.get(); } @@ -905,6 +915,7 @@ export class WizardStore { this.$session.setKey('completedRuns', [...done, stepId]); } this.$tasks.set([]); + this.$cullProgress.set(INITIAL_CULL_PROGRESS); this.setRunPhase(RunPhase.Idle); } @@ -1048,6 +1059,11 @@ export class WizardStore { const msgs = this.$statusMessages.get(); // Skip consecutive duplicate messages (no allocation on the hot path) if (msgs.length > 0 && msgs[msgs.length - 1] === message) return; + const cullProgress = this.$cullProgress.get(); + const nextCullProgress = reduceCullProgress(cullProgress, message); + if (nextCullProgress !== cullProgress) { + this.$cullProgress.set(nextCullProgress); + } // Nanostore detects change by reference equality, so a new array is // required. At the cap, allocate exactly once at the final size (dropping // the oldest entry) rather than push-then-truncate.