-
Notifications
You must be signed in to change notification settings - Fork 49
feat(cull-feature-flags): cull stale feature flags from a repo, on consent #1210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
johncwaters
wants to merge
29
commits into
PostHog:main
Choose a base branch
from
johncwaters:feat/cull-feature-flags
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
6caf9fa
feat(cull-feature-flags): register the command
johncwaters efc68fc
feat(cull-feature-flags): scan source for flag call sites
johncwaters e823f1c
feat(cull-feature-flags): fetch project flags and classify them
johncwaters e82790e
feat(cull-feature-flags): seed the ledger and prompt the skill
johncwaters 4b2e4e5
feat(cull-feature-flags): intro screen and per-bucket slides
johncwaters bb69dc4
fix(cull-feature-flags): bind the program to the switchboard and e2e …
johncwaters e5bf593
fix(e2e): honor the local context-mill env flag in the tui host
johncwaters 0c52748
fix(e2e): drive cull-intro and mirror capture-aio in the tui host
johncwaters 102fb08
fix(cull-feature-flags): authenticate before the flag fetch
johncwaters c3da102
fix(cull-feature-flags): make the run screen read like the setup flow
johncwaters 3183ac0
fix(cull-feature-flags): keep the outro to one revert line and one po…
johncwaters b6de1b5
fix(cull-feature-flags): name the report by absolute path in the outro
johncwaters 551731c
fix(cull-feature-flags): say culled, not applied
johncwaters 011febb
fix(e2e): drop the capture-aio mirror from the tui host
johncwaters 0232e01
test(cull-feature-flags): pin shape, not copy, in the seed test
johncwaters 9fddf3b
refactor(cull-feature-flags): prompt carries facts, the skill carries…
johncwaters cbd65f9
fix(cull-feature-flags): honest 0% bucket, every next.js convention i…
johncwaters 444077d
feat(cull-feature-flags): learn deck until the area pane takes over
johncwaters 0e72d26
fix(cull-feature-flags): count only flags that were disabled
johncwaters 7a6881b
feat(cull-feature-flags): group buckets into lanes
johncwaters 38ce3c6
feat(cull-feature-flags): phase from the ledger and the status feed
johncwaters a97f316
feat(cull-feature-flags): stepper and lane list on the cull run screen
johncwaters dff4365
fix(cull-feature-flags): the deck teaches the lanes in plain words
johncwaters ff7ddd2
feat(cull-feature-flags): say why each flag is being culled
johncwaters 9432f48
fix(cull-feature-flags): kept flags and suggestions are not "nothing …
johncwaters bc09d02
fix(cull-feature-flags): the spinner line is not a flag being culled
johncwaters d91a3b9
fix(cull-feature-flags): say the prompt is coming, not that we are wa…
johncwaters d96d511
fix(cull-feature-flags): spin while the agent is mid-turn on the run …
johncwaters 36746f6
Merge remote-tracking branch 'upstream/main' into feat/cull-feature-f…
johncwaters File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
256 changes: 256 additions & 0 deletions
256
src/lib/programs/__tests__/cull-feature-flags-classify.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FeatureFlag> & { 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> = {}): 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' }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the program is a bit slow, understandable since it scans and analyzes all feature flags. any thoughts on what model or harness you might use instead of the defaults?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Default binding this pass. Cut the reads and the per-flag tool discovery instead, and added a learn deck to cover the wait. Harness or model swap is worth a measured run later.