功能标志过期清理审计器
来自 Wikiprompt,自由的提示词百科全书
功能标志过期清理审计器 一个完整的AI技能,用于审计代码库中的功能开关,发现过期、失效或拼写错误的开关,并生成带有文件:行号引用的优先清理计划。包含完整的系统提示词、参考文档、模板、示例以及一个独立的Python扫描脚本。
提示词内容收藏
🌐
---
name: feature-flag-stale-cleanup-auditor
description: 对照你的代码库审计功能开关并规划其清理工作,,找出已100%开启或关闭数周的发布开关、卡在部分百分比的灰度发布、从未结束的实验、已过期、已归档和未被引用的开关、代码中使用但开关服务中缺失(或拼写错误)的键、预发布与生产环境漂移以及没有负责人的开关。列出每个需要修改的file:line及工作量。包含一个离线标准库Python扫描器。
---
# 功能开关过期清理审计器
你帮助工程团队偿还功能开关债务。开关添加成本低,但没人会移除它们:一个发布开关在100%状态持续数月,旧代码路径仍被编译在内;灰度发布停在25%,一半用户得到不同的产品;A/B测试运行半年;代码中拼写错误的键悄悄返回SDK默认值,导致"已上线"的功能从未展示。你将开关清单与代码进行对比,为每个开关决定是移除(保留ON或OFF路径)、完成、决策还是归档,并交回一份包含确切修改引用的清理计划。
你基于导出或手写的开关清单以及给定的源码文件夹工作。你不会在开关服务中更改开关:团队只有在代码变更部署到所有环境后才会归档开关。
## 此技能中的文件
- `scripts/flag_audit.py` - 读取开关JSON(键、类型release/experiment/ops/kill-switch/permission、负责人、创建时间、过期时间、是否归档、各环境on/rollout/updated),并扫描源码文件夹(.ts .tsx .js .jsx .py .go .rb .java .kt .cs .php .swift .vue .svelte;跳过node_modules、dist、vendor)以查找SDK调用、字符串字面量和`flags.camelCase`属性;报告FF01-FF14及带file:line引用和工作量的排序清理计划(仅Python 3.8+标准库)
- `references/flag-lifecycle-and-removal.md` - 开关类型及其预期生命周期、如何安全移除开关(保留ON/保留OFF)、从常见开关服务导出清单,以及防止开关债务复发的策略
- `references/checks-and-codes.md` - 每个发现代码、其严重级别及处理方法,以及输入格式
- `templates/cleanup-ticket.md` - 每个开关的清理工单/PR描述
- `examples/flags.json` - 一个网店的九个开关(release、experiment、kill-switch、permission、archived)
- `examples/src/checkout.ts`、`examples/src/Header.tsx`、`examples/src/search.py` - 使用它们的代码,包括一个拼写错误的键、一个不存在的键和一个运行时构建的键
- `examples/cleanup-report.md` - 扫描器在示例上的真实输出
## 工作流程
### 1. 收集清单和代码
询问他们使用哪个开关服务,并要求导出所有开关,包括类型、负责人、创建日期、过期时间或工单,以及各环境状态(开/关、灰度百分比、最后变更时间)。将其映射为类似`examples/flags.json`的JSON(参见LaunchDarkly、Unleash、Flagsmith、GrowthBook和ConfigCat字段名的参考)。询问读取开关的仓库或文件夹,以及如果开关检查函数不是常见SDK调用时的名称。
### 2. 运行扫描器
```
python3 scripts/flag_audit.py examples/flags.json examples/src
python3 scripts/flag_audit.py flags.json web/src api/app --today 2026-11-05 --stale-days 21
python3 scripts/flag_audit.py flags.json src --call featureOn --call can
python3 scripts/flag_audit.py flags.json src --json
python3 scripts/flag_audit.py --self-test
```
退出码0 = 无高严重级别发现,1 = 有高严重级别发现,2 = 输入错误。如果没有shell,手动搜索代码中的每个键(`git grep -n "<key>"`)并应用`references/checks-and-codes.md`中的规则。
### 3. 阅读结果
- 首先处理高严重级别:FF01代码中使用但开关服务不认识的键(拼写错误读取默认值,因此功能实际关闭)和FF14已归档但仍被代码引用的开关。
- 中严重级别:FF02完全开启和FF03完全关闭超过过期窗口,FF12卡住的灰度发布,FF09长时间实验,FF05已过期,FF08生产环境领先于预发布环境。
- 低/信息级别:未被引用的开关(先检查其他仓库)、缺少负责人或过期时间、长期存在的开关(kill switches、permissions)列出但不判定为过期、搜索无法找到的运行时构建键。
### 4. 规划清理
按计划表向下工作:先修复拼写错误,然后移除引用较少的开关(工作量S)以建立势头。对于"保留ON",删除检查、OFF分支以及仅其使用的代码;对于"保留OFF",删除受保护的代码。卡住的灰度发布和实验需要负责人在代码变更前做出决定。为每个开关填写`templates/cleanup-ticket.md`。
### 5. 交付
返回判定、带负责人和file:line列表的排序计划、工单,以及两三条策略建议(每个临时开关设置过期日期、每月清理时段、对未知键失败的lint规则或CI检查)。
## 示例结果
三个高严重级别发现:`new-checkout-flow-v3`在checkout.ts中被读取但不存在,`recs_carousel`在search.py中是`recs-carousel`的拼写错误,,因此"100%开启"170天的轮播从未展示过,,以及已归档的`legacy-pdf-invoices`仍被检查。`new-checkout-flow`已100%开启86天并于9月过期(2个引用,保留ON),2025年假日横幅已关闭303天(保留OFF),`search-v2`卡在25%达63天,定价实验已运行199天。`dark-mode`在生产环境开启但在预发布环境关闭。保留kill switch和权限开关。
## 规则
- 绝不因kill switch、ops或权限开关老旧而建议移除;它们按设计是长期存在的。
- 扫描文件夹中无引用的开关可能在其他地方使用(移动应用、其他服务、配置);在建议归档前说明这一点。
- 先移除代码、部署,然后在服务中归档开关,,绝不能反过来。
- 卡住的灰度发布或运行中的实验需要负责人的决定;不要替他们选择赢家。
- 将`today`和过期窗口视为输入;说明你使用了哪些值。
FILE:references/flag-lifecycle-and-removal.md
# 开关生命周期与安全移除
## 开关类型及预期生命周期
| 类型 | 目的 | 预期生命周期 | 完成条件 |
|---|---|---|---|
| release | 隐藏未完成的工作,逐步推出 | 100%后数天至数周 | 所有环境100%且在过期窗口内稳定 |
| experiment | A/B或多变量测试 | 测试持续时间(通常2-6周) | 做出决定 |
| ops | 运维开关(缓存开/关、批处理大小) | 长期 | 很少;每年审查 |
| kill-switch | 快速关闭有风险的依赖 | 永久 | 绝不因时间而移除 |
| permission | 权限、beta访问、套餐功能 | 永久(或移至计费/权限系统) | 产品决定 |
## 安全移除开关
1. 与负责人确认状态:完全开启(保留ON)或完全关闭(保留OFF),在所有环境和所有读取它的服务中。
2. 找到所有引用(扫描器列表加上其他仓库、移动应用、基础设施配置、分析仪表板和测试中的`git grep -n "<key>"`)。
3. 保留ON:用ON分支替换检查,删除OFF分支,然后删除仅OFF分支使用的代码(旧组件、端点、样式、迁移保护)。保留OFF:删除受保护的代码及仅其使用的任何内容。
4. 删除开关特定的测试和夹具;保留保留行为的测试。
5. 部署到所有环境。移动应用:等待读取该开关的旧应用版本低于你的支持阈值。
6. 然后在开关服务中归档开关(归档,不删除,以保留历史)。
7. 用PR链接关闭工单。
## 常见陷阱
- 拼写错误和重命名键:SDK对未知键返回默认值而不报错,因此读取`recs_carousel`的代码永远不会看到`recs-carousel`。将字面键与清单比较的CI检查可捕获此问题。
- 先归档:已归档的开关在所有仍读取它的地方返回默认值,,通常是OFF路径,即已上线功能的即时回滚。
- 卡住的灰度发布:25%持续两个月意味着需要支持和测试两个产品;完成或回滚。
- 没有结束日期的实验持续扭曲指标和分析事件。
- 运行时构建的键(`"export-format-" + region`)无法通过搜索找到;保留列出所有可能键的常量文件。
## 导出清单
- LaunchDarkly: REST `GET /api/v2/flags/{projectKey}?summary=0` -> `key`、`creationDate`(毫秒)、`temporary`、`archived`、`maintainerId`/`_maintainer`、`tags`、`environments.<env>.on`、`.lastModified`、`fallthrough.rollout.variations`中的灰度权重(weight/1000 = 百分比)。将`temporary: false`映射为`permanent: true`。
- Unleash: `GET /api/admin/projects/<p>/features` -> `name`、`type`(release、experiment、operational、kill-switch、permission)、`createdAt`、`stale`、`environments[].enabled`、渐进式发布策略`parameters.rollout`。
- Flagsmith: `/api/v1/projects/<id>/features/` + 各环境`featurestates`(`enabled`、`updated_at`),通过分段覆盖的百分比。
- GrowthBook: `/api/v1/features` -> `id`、`owner`、`dateCreated`、`archived`、`environments.<env>.enabled`、发布规则`coverage`(0-1 -> 百分比)。
- ConfigCat: 管理API设置 + 各环境值;百分比选项给出灰度。
- 内部配置(YAML/JSON):每个开关一个条目;从git blame手动添加created/owner/expires。
## 保持开关债务低的策略
- 每个临时开关在创建时获得负责人、工单和过期日期。
- 清理是完成定义的一部分:移除工单与开关一起创建。
- 每月30分钟使用此审计的开关审查;每个团队的活动发布开关上限。
- CI:清单中不存在的键失败;仍被引用的过期开关警告。
FILE:references/checks-and-codes.md
# 检查与发现代码
| 代码 | 严重级别 | 检查 | 处理方法 |
|---|---|---|---|
| FF01 | 高 | 开关检查调用(isEnabled、variation、useFlag、is_enabled...)使用清单中不存在的键;建议近似匹配(拼写错误、`_` vs `-`) | 修复键或创建开关;在此之前SDK返回默认值 |
| FF02 | 中 | 发布开关在主环境100%达`stale_days`或更久 | 移除开关,保留ON路径 |
| FF03 | 中 | 发布或实验开关在主环境关闭/0%达`stale_days`或更久 | 移除开关和受保护代码,或决定上线 |
| FF04 | 低 | 扫描文件中无引用 | 检查其他仓库和服务;如未使用则归档 |
| FF05 | 中 | `expires`日期已过(临时开关) | 移除或与负责人商定新日期 |
| FF06 | 信息 | ops、kill-switch、permission(或`permanent: true`)开关,,不判定为过期 | 每年审查 |
| FF07 | 低 | 无负责人 | 指定一个 |
| FF08 | 中 | 主环境领先于staging/qa/test/dev,或主环境无状态 | 对齐环境 |
| FF09 | 中 | 实验超过`experiment_max_days` | 决策、发布赢家、移除 |
| FF10 | 信息 | 开关键在运行时构建(变量、拼接、模板字符串) | 使用字面键或常量文件 |
| FF11 | 低 | 临时开关无`expires` | 设置过期时间 |
| FF12 | 中 | 发布灰度在1-99%之间且`stale_days`未变 | 完成(100%)或回滚,然后移除 |
| FF13 | 中 | 清单中键定义两次 | 合并 |
| FF14 | 高 | 开关在服务中已归档但仍被代码引用 | 移除引用 |
## 判定
- 存在FF01/FF14时:**N个高严重级别发现**;否则:**N个开关可移除**;否则:**无过期开关**。
## 计划操作(按此顺序)
移除代码(已归档)-> 移除,保留ON -> 移除,保留OFF -> 完成灰度 -> 决策实验 -> 归档(无引用)。在同一操作内,引用多的开关优先。工作量:S 1-2个引用,M 3-6个,L 7个以上。
## 如何找到引用
1. 调用:`<name>(`带字面第一个参数(或一个前导参数如client或user后的字面参数),针对内置名称加上`call_names`/`--call`。
2. 任何等于已知键的字符串字面量(常量文件、配置映射)。
3. `flags.`、`features.`、`featureFlags.`、`toggles.`后跟键或其camelCase形式(`new-search` -> `flags.newSearch`)。
注释中包含带引号的键也被计数,,检查列出的行。
## 开关JSON
顶层:`today, primary_env (production), environments, stale_days (30), experiment_max_days (90), call_names[], flags[]`。
开关:`key, type (release|experiment|ops|kill-switch|permission), owner, created, expires, permanent, archived, description, ticket, environments{name: {on, rollout 0-100, updated}}`。`rollout`在开启时默认为100,关闭时默认为0;`updated`是该环境的最后变更时间(回退到`created`)。
FILE:templates/cleanup-ticket.md
# 移除功能开关 `<key>`
**负责人:** <team-payments> **类型:** <release> **工单:** <PAY-412> **操作:** <移除,保留ON>
**状态:** <自2026-08-11起生产环境100%开启(86天),预发布环境100%> **已过期:** <2026-09-30>
## 原因
<已完全推出并在86天内稳定;OFF路径(旧结账)是死代码。>
## 变更
- [ ] `<examples/src/checkout.ts:4>` - 用ON分支替换检查(`renderOnePageCheckout`)
- [ ] `<examples/src/checkout.ts:14>` - 始终使用`oneClickButton()`
- [ ] 删除仅OFF路径使用的代码:<renderLegacyCheckout、classicButton、旧结账样式>
- [ ] 删除开关特定的测试和夹具
- [ ] 搜索其他仓库/移动应用:`git grep -n "<key>"`
## 发布
1. 合并并部署到预发布环境,然后生产环境。
2. 观察<结账转化率、错误率>持续<2天>。
3. 在<开关服务>中归档`<key>`(归档,不删除)。
## 风险/问题
- <移动应用是否读取此开关?最低支持版本?>
- <是否有以开关为键的仪表板或警报?>
FILE:examples/flags.json
{
"today": "2026-11-05",
"primary_env": "production",
"stale_days": 30,
"experiment_max_days": 90,
"flags": [
{"key": "new-checkout-flow", "type": "release", "owner": "team-payments", "created": "2026-06-02", "expires": "2026-09-30", "ticket": "PAY-412",
"description": "新单页结账",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-08-11"}, "staging": {"on": true, "rollout": 100, "updated": "2026-07-20"}}},
{"key": "search-v2", "type": "release", "owner": "team-search", "created": "2026-07-15", "expires": "2026-12-31",
"description": "基于Typesense的搜索",
"environments": {"production": {"on": true, "rollout": 25, "updated": "2026-09-03"}, "staging": {"on": true, "rollout": 100, "updated": "2026-08-01"}}},
{"key": "holiday-banner-2025", "type": "release", "owner": "team-growth", "created": "2025-11-10", "expires": "2026-01-10",
"description": "2025年圣诞促销横幅",
"environments": {"production": {"on": false, "rollout": 0, "updated": "2026-01-06"}, "staging": {"on": false, "rollout": 0, "updated": "2026-01-06"}}},
{"key": "pricing-page-test", "type": "experiment", "owner": "team-growth", "created": "2026-04-20", "expires": "2026-07-01",
"description": "A/B测试:年度套餐优先",
"environments": {"production": {"on": true, "rollout": 50, "updated": "2026-04-22"}, "staging": {"on": true, "rollout": 50, "updated": "2026-04-22"}}},
{"key": "dark-mode", "type": "release", "owner": "team-web", "created": "2026-10-01", "expires": "2026-12-15",
"description": "深色主题",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-10-28"}, "staging": {"on": false, "rollout": 0, "updated": "2026-10-02"}}},
{"key": "payments-kill-switch", "type": "kill-switch", "owner": "team-payments", "created": "2025-03-01",
"description": "PSP中断期间关闭银行卡支付",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2025-03-01"}, "staging": {"on": true, "rollout": 100, "updated": "2025-03-01"}}},
{"key": "beta-reports-export", "type": "permission", "owner": "", "created": "2025-09-12",
"description": "为beta客户提供CSV导出",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2025-09-12"}}},
{"key": "legacy-pdf-invoices", "type": "release", "owner": "team-billing", "created": "2025-05-01", "archived": true,
"description": "旧PDF渲染器",
"environments": {"production": {"on": false, "rollout": 0, "updated": "2026-02-01"}}},
{"key": "recs-carousel", "type": "release", "owner": "team-growth", "created": "2026-03-03",
"description": "产品页面上的推荐轮播",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-05-19"}, "staging": {"on": true, "rollout": 100, "updated": "2026-05-01"}}}
]
}
FILE:examples/src/checkout.ts
import { flags } from "./flags";
export async function renderCheckout(user: User, cart: Cart) {
if (await flags.isEnabled("new-checkout-flow", user)) {
return renderOnePageCheckout(user, cart);
}
return renderLegacyCheckout(user, cart);
}
export async function payButton(user: User) {
if (await flags.isEnabled("payments-kill-switch", user)) {
return disabledButton("银行卡支付已暂停。请稍后再试。");
}
const oneClick = await flags.isEnabled("new-checkout-flow", user);
return oneClick ? oneClickButton() : classicButton();
}
export async function invoiceLink(order: Order) {
// TODO 在PDF迁移后移除
if (await flags.isEnabled("legacy-pdf-invoices")) {
return legacyPdfUrl(order.id);
}
return invoiceServiceUrl(order.id);
}
export async function trackCheckoutVariant(user: User) {
const variant = await flags.variation("new-checkout-flow-v3", user, "control");
analytics.track("checkout_variant", { variant });
}
FILE:examples/src/Header.tsx
import { useFlag } from "@acme/flags-react";
export function Header() {
const darkMode = useFlag("dark-mode");
const showBanner = useFlag("holiday-banner-2025");
return (
<header className={darkMode ? "header header--dark" : "header"}>
{showBanner && <PromoBanner text="假日促销:所有商品20%折扣" />}
<SearchBox />
</header>
);
}
export function PricingPage({ user }: { user: User }) {
const annualFirst = useFlag("pricing-page-test");
return annualFirst ? <PlansAnnualFirst /> : <PlansMonthlyFirst />;
}
FILE:examples/src/search.py
from app.flags import flags
def search(query, user):
if flags.is_enabled("search-v2", user):
return typesense_search(query)
return postgres_search(query)
def export_reports(user, region):
if not flags.is_enabled("beta-reports-export", user):
raise PermissionError("导出功能处于beta阶段")
# 按地区的实验键,例如 "export-format-eu"
if flags.is_enabled("export-format-" + region, user):
return export_parquet(user)
return export_csv(user)
def recommendations(product):
if flags.is_enabled("recs_carousel"):
return top_related(product, limit=12)
return []
FILE:examples/cleanup-report.md
# 功能开关清理审计
今天2026-11-05,主环境`production`,30天后过期。清单中有9个开关,在examples/src中扫描了3个源文件。
**判定:3个高严重级别发现,,代码中使用但开关服务不认识的开关**
清理候选:6个(4个现在可移除,4个引用需更改)。发现:3个高严重级别,9个中严重级别,2个低严重级别,3个信息级别。
## 清理计划
| # | 开关 | 类型 | 负责人 | 主环境 | 操作 | 原因 | 引用(文件) | 工作量 | 位置 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | `legacy-pdf-invoices` | release | team-billing | 已归档 | **移除代码** | 已归档但仍被代码引用 | 1 (1) | S | `examples/src/checkout.ts:20` |
| 2 | `new-checkout-flow` | release | team-payments | 100% | **移除,保留ON** | 100%开启86天 | 2 (1) | S | `examples/src/checkout.ts:4`、`examples/src/checkout.ts:14` |
| 3 | `recs-carousel` | release | team-growth | 100% | **移除,保留ON** | 100%开启170天 | 0 (0) | - | - |
| 4 | `holiday-banner-2025` | release | team-growth | 0% | **移除,保留OFF** | 关闭303天 | 1 (1) | S | `examples/src/Header.tsx:5` |
| 5 | `search-v2` | release | team-search | 25% | **完成灰度** | 卡在25% | 1 (1) | S | `examples/src/search.py:5` |
| 6 | `pricing-page-test` | experiment | team-growth | 50% | **决策实验** | 运行199天 | 1 (1) | S | `examples/src/Header.tsx:15` |
工作量:S = 1-2个引用,M = 3-6个,L = 7个或更多。"保留ON" = 删除开关检查和OFF分支;"保留OFF" = 删除受保护的代码。仅在代码变更部署到所有环境后,才在开关服务中归档开关。
## 发现
| 严重级别 | 代码 | 开关 | 发现 | 修复 |
|---|---|---|---|---|
| 高 | FF01 | `new-checkout-flow-v3` | 代码中使用(examples/src/checkout.ts:27)但开关清单中未定义,,SDK返回默认值,因此此代码路径读取了错误的键 | 是new-checkout-flow的拼写错误?使用确切键 |
| 高 | FF01 | `recs_carousel` | 代码中使用(examples/src/search.py:20)但开关清单中未定义,,SDK返回默认值,因此此代码路径读取了错误的键 | 是recs-carousel的拼写错误?使用确切键 |
| 高 | FF14 | `legacy-pdf-invoices` | 开关在开关服务中已归档但仍被1处引用,,现在返回默认值 | 移除引用 |
| 中 | FF02 | `new-checkout-flow` | 发布开关在生产环境100%开启86天 | 移除开关并保留ON路径;删除OFF分支和旧代码 |
| 中 | FF02 | `recs-carousel` | 发布开关在生产环境100%开启170天 | 移除开关并保留ON路径;删除OFF分支和旧代码 |
| 中 | FF03 | `holiday-banner-2025` | 发布开关在生产环境关闭303天,,受保护代码是死代码 | 移除开关和受保护代码(或决定上线) |
| 中 | FF05 | `holiday-banner-2025` | 已于2026-01-10过期(299天前) | 立即移除或与负责人商定新日期 |
| 中 | FF05 | `new-checkout-flow` | 已于2026-09-30过期(36天前) | 立即移除或与负责人商定新日期 |
| 中 | FF05 | `pricing-page-test` | 已于2026-07-01过期(127天前) | 立即移除或与负责人商定新日期 |
| 中 | FF08 | `dark-mode` | 生产环境100%但预发布环境0%,,生产环境运行预发布环境未测试的路径 | 将预发布环境与生产环境对齐 |
| 中 | FF09 | `pricing-page-test` | 实验运行199天(限制90) | 调用结果、发布赢家、移除开关 |
| 中 | FF12 | `search-v2` | 灰度卡在生产环境25%达63天,,用户得到两个版本 | 完成灰度(100%)或回滚到0,然后移除 |
| 低 | FF04 | `recs-carousel` | 3个扫描文件中无引用 | 检查其他仓库/服务;如未使用,在开关服务中归档 |
| 低 | FF07 | `beta-reports-export` | 无负责人 | 指定负责人团队或个人 |
| 信息 | FF06 | `beta-reports-export` | 权限开关,,按设计长期存在,不检查过期 | 每年审查;保留描述和负责人 |
| 信息 | FF06 | `payments-kill-switch` | kill-switch开关,,按设计长期存在,不检查过期 | 每年审查;保留描述和负责人 |
| 信息 | FF10 | `-` | 在examples/src/search.py:14运行时构建开关键,,搜索无法找到其引用 | 优先使用字面键(或常量文件)以便清理开关 |
## 每个开关的引用
| 开关 | 引用 |
|---|---|
| `beta-reports-export` | 1 |
| `dark-mode` | 1 |
| `holiday-banner-2025` | 1 |
| `legacy-pdf-invoices` | 1 |
| `new-checkout-flow` | 2 |
| `payments-kill-switch` | 1 |
| `pricing-page-test` | 1 |
| `recs-carousel` | 0 |
| `search-v2` | 1 |
搜索仅覆盖扫描文件夹中的字符串字面量、常见SDK调用和`flags.camelCase`属性;归档开关前检查其他服务、移动应用和配置仓库。
FILE:scripts/flag_audit.py
#!/usr/bin/env python3
"""功能开关过期清理审计器(仅标准库,Python 3.8+)。
读取功能开关清单(从LaunchDarkly、Unleash、Flagsmith、GrowthBook、ConfigCat或内部配置导出或手写的JSON),并扫描源码树以查找开关引用。找出已完全开启(或关闭)数周且可移除的发布开关、卡住的部分灰度、已过期和孤立的开关、从未结束的实验、代码中使用但开关服务中缺失的开关(它们静默回退到默认值)、环境漂移和无负责人的开关,,并写出带确切file:line引用的优先清理计划。
用法:
flag_audit.py FLAGS.json SRC_DIR [SRC_DIR ...] [--today YYYY-MM-DD] [--stale-days N]
[--call NAME ...] [--json]
flag_audit.py --self-test
退出:0 = 无高严重级别发现,1 = 至少一个高严重级别发现,2 = 输入错误。
"""
import argparse, datetime, difflib, json, os, re, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
TYPES = {"release", "experiment", "ops", "kill-switch", "permission"}
LONG_LIVED = {"ops", "kill-switch", "permission"}
EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java", ".kt", ".cs", ".php", ".swift", ".vue", ".svelte"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "vendor", "__pycache__", ".venv", "venv", "coverage"}
CALLS = ["isEnabled", "isFeatureEnabled", "variation", "boolVariation", "stringVariation", "useFlag", "useFeatureFlag",
"useFeatureIsOn", "getFlag", "getFeatureValue", "isOn", "feature_enabled", "is_enabled", "flag_enabled", "enabled?",
"Enabled", "IsEnabled", "getBooleanValue", "hasFeature"]
TOP_KEYS = {"today", "stale_days", "experiment_max_days", "primary_env", "environments", "flags", "call_names", "source"}
FLAG_KEYS = {"key", "type", "owner", "created", "expires", "permanent", "description", "environments", "archived", "ticket"}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip()[:10])
except ValueError:
raise InputError("%s必须是YYYY-MM-DD,得到%r" % (what, s))
def camel(k):
parts = re.split(r"[-_.]", k)
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])
# ---------------------------------------------------------------- load
def load_flags(cfg, today=None, stale_days=None):
if not isinstance(cfg, dict) or not isinstance(cfg.get("flags"), list):
raise InputError("flags文件必须是带\"flags\"列表的JSON对象")
bad = set(cfg) - TOP_KEYS
if bad:
raise InputError("未知顶层键:%s" % ", ".join(sorted(bad)))
c = {"today": pdate(today or cfg.get("today") or datetime.date.today().isoformat(), "today"),
"stale": int(stale_days if stale_days is not None else cfg.get("stale_days", 30)),
"exp_max": int(cfg.get("experiment_max_days", 90)),
"primary": cfg.get("primary_env", "production"),
"envs": cfg.get("environments") or [], "flags": {}, "dups": [], "calls": cfg.get("call_names") or []}
for i, f in enumerate(cfg["flags"], 1):
if not isinstance(f, dict) or not f.get("key"):
raise InputError("第%d个开关无键" % i)
badk = set(f) - FLAG_KEYS
if badk:
raise InputError("开关%s:未知键%s" % (f["key"], ", ".join(sorted(badk))))
t = (f.get("type") or "release").lower()
if t not in TYPES:
raise InputError("开关%s:类型必须是%s之一,得到%r" % (f["key"], ", ".join(sorted(TYPES)), t))
envs = {}
for e, st in (f.get("environments") or {}).items():
if not isinstance(st, dict):
raise InputError("开关%s环境%s必须是对象{on, rollout, updated}" % (f["key"], e))
r = st.get("rollout", 100 if st.get("on") else 0)
if not isinstance(r, (int, float)) or not 0 <= r <= 100:
raise InputError("开关%s环境%s:rollout必须是0-100,得到%r" % (f["key"], e, r))
envs[e] = {"on": bool(st.get("on")), "rollout": float(r),
"updated": pdate(st["updated"], "开关%s环境%s updated" % (f["key"], e)) if st.get("updated") else None}
fl = {"key": f["key"], "type": t, "owner": f.get("owner") or "", "ticket": f.get("ticket", ""),
"created": pdate(f["created"], "开关%s created" % f["key"]) if f.get("created") else None,
"expires": pdate(f["expires"], "开关%s expires" % f["key"]) if f.get("expires") else None,
"permanent": bool(f.get("permanent")) or t in LONG_LIVED, "archived": bool(f.get("archived")),
"description": f.get("description", ""), "envs": envs}
if fl["key"] in c["flags"]:
c["dups"].append(fl["key"])
c["flags"][fl["key"]] = fl
return c
def eff(st):
"""获得开关的用户有效份额:0-100"""
return st["rollout"] if st["on"] else 0.0
# ---------------------------------------------------------------- scan
def scan(dirs, keys, calls):
"""返回refs {key: [(path, line, text)]}、unknown {literal: [(path,line)]}、dynamic [(path,line,text)]、files"""
calls = sorted(set(CALLS + list(calls)), key=len, reverse=True)
alt = "|".join(re.escape(c) for c in calls)
call_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?:\w+\s*,\s*)?([\"'`])([^\"'`$]+)\2\s*[,)]" % alt)
dyn_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?![\"'`][^\"'`$]*[\"'`]\s*[,)])([^,)]+)[,)]" % alt)
lit_re = re.compile(r"([\"'`])([A-Za-z0-9][A-Za-z0-9_.:-]{2,80})\1")
camels = {camel(k): k for k in keys if camel(k) != k}
prop_re = re.compile(r"\b(?:flags|features|featureFlags|toggles)\.([A-Za-z_]\w*)")
refs, unknown, dynamic, nfiles = {k: [] for k in keys}, {}, [], 0
for d in dirs:
if not os.path.isdir(d):
raise InputError("源目录未找到:%s" % d)
for root, ds, fs in os.walk(d):
ds[:] = sorted(x for x in ds if x not in SKIP_DIRS)
for fn in sorted(fs):
if os.path.splitext(fn)[1] not in EXTS:
continue
p = os.path.join(root, fn)
nfiles += 1
try:
lines = open(p, encoding="utf-8", errors="replace").read().splitlines()
except OSError as e:
raise InputError("无法读取%s:%s" % (p, e))
for i, ln in enumerate(lines, 1):
seen = set()
for m in call_re.finditer(ln):
lit = m.group(3)
if lit in refs:
refs[lit].append((p, i, ln.strip())); seen.add(lit)
else:
unknown.setdefault(lit, []).append((p, i, ln.strip()))
seen.add(lit)
for m in dyn_re.finditer(ln):
if m.group(2).strip() not in ("true", "false", "None", "null", ""):
dynamic.append((p, i, ln.strip()))
for m in lit_re.finditer(ln):
lit = m.group(2)
if lit in refs and lit not in seen:
refs[lit].append((p, i, ln.strip())); seen.add(lit)
for m in prop_re.finditer(ln):
k = camels.get(m.group(1)) or (m.group(1) if m.group(1) in refs else None)
if k and k not in seen:
refs[k].append((p, i, ln.strip())); seen.add(k)
return refs, unknown, dynamic, nfiles
# ---------------------------------------------------------------- audit
def audit(c, refs, unknown, dynamic, nfiles):
F, plan = [], []
today, stale, prim = c["today"], c["stale"], c["primary"]
add = lambda sev, code, key, msg, fix: F.append((sev, code, key, msg, fix))
for k in c["dups"]:
add("medium", "FF13", k, "清单中开关定义两次(使用最后一个)", "合并两个条目")
keys = list(c["flags"])
norm = {re.sub(r"[-_.]", "", k.lower()): k for k in keys}
for lit, where in sorted(unknown.items()):
close = norm.get(re.sub(r"[-_.]", "", lit.lower())) or (difflib.get_close_matches(lit, keys, 1, 0.8) or [None])[0]
locs = ", ".join("%s:%d" % (p, i) for p, i, _ in where[:3])
add("high", "FF01", lit, "代码中使用(%s)但开关清单中未定义,,SDK返回默认值,因此此代码路径%s" %
(locs, "可能从未开启" if not close else "读取了错误的键"),
("是%s的拼写错误?使用确切键" % close) if close else "创建开关或移除检查")
for p, i, txt in dynamic[:20]:
add("info", "FF10", "-", "在%s:%d运行时构建开关键,,搜索无法找到其引用" % (p, i), "优先使用字面键(或常量文件)以便清理开关")
for k, f in c["flags"].items():
r = refs.get(k, [])
files = sorted(set(p for p, _, _ in r))
st = f["envs"].get(prim)
age = (today - f["created"]).days if f["created"] else None
action, why = None, ""
if not f["owner"]:
add("low", "FF07", k, "无负责人", "指定负责人团队或个人")
if f["archived"]:
if r:
add("high", "FF14", k, "开关在开关服务中已归档但仍被%d处引用,,现在返回默认值" % len(r), "移除引用")
plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": "已归档", "action": "移除代码",
"why": "已归档但仍被代码引用", "refs": len(r), "files": len(files),
"locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
continue
if st is None and not f["archived"]:
add("medium", "FF08", k, "主环境%r无状态" % prim, "添加%s状态或修复primary_env" % prim)
if f["permanent"]:
add("info", "FF06", k, "%s开关,,按设计长期存在,不检查过期" % f["type"], "每年审查;保留描述和负责人")
if not r:
add("low", "FF04", k, "%d个扫描文件中无引用" % nfiles, "检查其他仓库/服务;如未使用,在开关服务中归档")
action, why = action or "归档", "无引用"
elif st is not None:
since = st["updated"] or f["created"]
days = (today - since).days if since else None
e = eff(st)
all_on = all(eff(s) == 100 for s in f["envs"].values())
if f["expires"] and f["expires"] < today:
add("medium", "FF05", k, "已于%s过期(%d天前)" % (f["expires"], (today - f["expires"]).days), "立即移除或与负责人商定新日期")
if f["type"] == "release" and e == 100 and days is not None and days >= stale:
add("medium", "FF02", k, "发布开关在%s 100%%开启%d天%s" % (prim, days, "" if all_on else "(并非所有环境100%)"),
"移除开关并保留ON路径;删除OFF分支和旧代码")
action, why = "移除,保留ON", "100%%开启%d天" % days
elif e == 0 and days is not None and days >= stale and f["type"] in ("release", "experiment"):
add("medium", "FF03", k, "%s开关在%s关闭%d天,,受保护代码是死代码" % (f["type"], prim, days),
"移除开关和受保护代码(或决定上线)")
action, why = "移除,保留OFF", "关闭%d天" % days
elif 0 < e < 100 and days is not None and days >= stale and f["type"] == "release":
add("medium", "FF12", k, "灰度卡在%s的%g%%达%d天,,用户得到两个版本" % (prim, e, days), "完成灰度(100%)或回滚到0,然后移除")
action, why = "完成灰度", "卡在%g%%" % e
if f["type"] == "experiment" and age is not None and age > c["exp_max"]:
add("medium", "FF09", k, "实验运行%d天(限制%d)" % (age, c["exp_max"]), "调用结果、发布赢家、移除开关")
action, why = action or "决策实验", "运行%d天" % age
if not r:
add("low", "FF04", k, "%d个扫描文件中无引用" % nfiles, "检查其他仓库/服务;如未使用,在开关服务中归档")
action, why = action or "归档", why or "无引用"
for en, s2 in f["envs"].items():
if en != prim and eff(s2) < e and (en in ("staging", "qa", "test", "dev", "development")):
add("medium", "FF08", k, "%s为%g%%但%s为%g%%,,生产环境运行预发布环境未测试的路径" % (prim, e, en, eff(s2)), "将%s与%s对齐" % (en, prim))
if f["expires"] is None and f["type"] in ("release", "experiment") and action is None:
add("low", "FF11", k, "临时%s开关无过期日期" % f["type"], "设置expires(例如完全推出后30-60天)")
if action:
plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": ("%g%%" % eff(st)) if st else "-", "action": action, "why": why,
"refs": len(r), "files": len(files), "locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
rank = {"移除代码": 0, "移除,保留ON": 1, "移除,保留OFF": 2, "完成灰度": 3, "决策实验": 4, "归档": 5}
plan.sort(key=lambda x: (rank[x["action"]], -x["refs"], x["key"]))
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
return {"today": today.isoformat(), "primary": prim, "stale_days": stale, "flags": len(c["flags"]), "files": nfiles,
"refs": {k: len(v) for k, v in refs.items()}, "plan": plan, "findings": F}
def effort(n):
return "S" if n <= 2 else "M" if n <= 6 else "L"
def report(r, src):
c = {s: 0 for s in SEV}
for f in r["findings"]:
c[f[0]] += 1
removable = [p for p in r["plan"] if p["action"].startswith("移除")]
out = ["# 功能开关清理审计", "",
"今天%s,主环境`%s`,%d天后过期。清单中有%d个开关,在%s中扫描了%d个源文件。" % (
r["today"], r["primary"], r["stale_days"], r["flags"], r["files"], ", ".join(src)), ""]
if c["high"]:
v = "%d个高严重级别发现,,代码中使用但开关服务不认识的开关" % c["high"]
elif removable:
v = "%d个开关可移除" % len(removable)
else:
v = "无过期开关"
out += ["**判定:%s**" % v, "", "清理候选:%d个(%d个现在可移除,%d个引用需更改)。发现:%d个高严重级别,%d个中严重级别,%d个低严重级别,%d个信息级别。" % (
len(r["plan"]), len(removable), sum(p["refs"] for p in removable), c["high"], c["medium"], c["low"], c["info"]), ""]
out += ["## 清理计划", "", "| # | 开关 | 类型 | 负责人 | 主环境 | 操作 | 原因 | 引用(文件) | 工作量 | 位置 |", "|---|---|---|---|---|---|---|---|---|---|"]
for i, p in enumerate(r["plan"], 1):
locs = ", ".join("`%s`" % x for x in p["locs"][:4]) + (" +%d更多" % (len(p["locs"]) - 4) if len(p["locs"]) > 4 else "")
out.append("| %d | `%s` | %s | %s | %s | **%s** | %s | %d (%d) | %s | %s |" % (i, p["key"], p["type"], p["owner"], p["state"], p["action"], p["why"],
p["refs"], p["files"], effort(p["refs"]) if p["refs"] else "-", locs or "-"))
out += ["", "工作量:S = 1-2个引用,M = 3-6个,L = 7个或更多。\"保留ON\" = 删除开关检查和OFF分支;\"保留OFF\" = 删除受保护的代码。仅在代码变更部署到所有环境后,才在开关服务中归档开关。", ""]
out += ["## 发现", "", "| 严重级别 | 代码 | 开关 | 发现 | 修复 |", "|---|---|---|---|---|"]
for s, code, k, msg, fix in r["findings"]:
out.append("| %s | %s | `%s` | %s | %s |" % (s, code, k, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
out += ["", "## 每个开关的引用", "", "| 开关 | 引用 |", "|---|---|"]
for k, n in sorted(r["refs"].items()):
out.append("| `%s` | %d |" % (k, n))
out += ["", "搜索仅覆盖扫描文件夹中的字符串字面量、常见SDK调用和`flags.camelCase`属性;归档开关前检查其他服务、移动应用和配置仓库。"]
return "\n".join(out) + "\n"
def run(flags_path, dirs, today=None, stale=None, calls=()):
try:
cfg = json.load(open(flags_path, encoding="utf-8"))
except (OSError, ValueError) as e:
raise InputError("无法读取%s:%s" % (flags_path, e))
c = load_flags(cfg, today, stale)
refs, unknown, dynamic, n = scan(dirs, list(c["flags"]), list(c["calls"]) + list(calls))
return audit(c, refs, unknown, dynamic, n)
# ---------------------------------------------------------------- self-test
def self_test():
import tempfile
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def mk(flags, files, **kw):
d = tempfile.mkdtemp()
for name, body in files.items():
os.makedirs(os.path.dirname(os.path.join(d, name)) or d, exist_ok=True)
open(os.path.join(d, name), "w").write(body)
cfg = {"today": "2026-11-05", "flags": flags}
cfg.update(kw)
c = load_flags(cfg)
refs, unk, dyn, n = scan([d], list(c["flags"]), c["calls"])
return audit(c, refs, unk, dyn, n)
def codes(r):
return [(f[0], f[1], f[2]) for f in r["findings"]]
P = lambda on, r, up: {"production": {"on": on, "rollout": r, "updated": up}}
rel = lambda k, on=True, r=100, up="2026-09-01", **x: dict({"key": k, "type": "release", "owner": "web", "created": "2026-08-01", "expires": "2027-01-01", "environments": P(on, r, up)}, **x)
src = {"a.ts": "if (client.isEnabled('new-checkout')) {}\nconst x = useFlag(\"dark-mode\")\n"}
r = mk([rel("new-checkout"), rel("dark-mode", up="2026-10-30")], src)
t("100%开启65天 -> FF02", ("medium", "FF02", "new-checkout") in codes(r))
t("100%开启6天 -> 不过期", ("medium", "FF02", "dark-mode") not in codes(r))
t("计划操作保留ON", [p["action"] for p in r["plan"] if p["key"] == "new-checkout"] == ["移除,保留ON"])
t("通过调用找到引用", r["refs"]["new-checkout"] == 1 and r["refs"]["dark-mode"] == 1)
r = mk([rel("old-banner", on=False, r=0)], {"b.py": "if flags.is_enabled(\"old-banner\"):\n pass\n"})
t("关闭65天 -> FF03保留OFF", ("medium", "FF03", "old-banner") in codes(r) and r["plan"][0]["action"] == "移除,保留OFF")
r = mk([rel("half", r=50)], {"c.js": "isEnabled('half')"})
t("卡住的灰度 -> FF12", ("medium", "FF12", "half") in codes(r))
r = mk([rel("new-checkout")], {"a.ts": "isEnabled('new_checkout')\n"})
t("代码中未定义的键 -> FF01高", ("high", "FF01", "new_checkout") in codes(r))
t("FF01建议近似键", any("是new-checkout的拼写错误" in f[4] for f in r["findings"]))
t("FF01单独意味着真实键0引用 -> FF04", ("low", "FF04", "new-checkout") in codes(r))
r = mk([rel("ghost")], {"a.ts": "const a = 1\n"})
t("无引用 -> FF04 + 归档", ("low", "FF04", "ghost") in codes(r))
r = mk([rel("exp1", type="experiment", created="2026-05-01", r=50, up="2026-10-20")], {"a.ts": "variation('exp1', false)"})
t("实验超过90天 -> FF09", ("medium", "FF09", "exp1") in codes(r))
r = mk([rel("exp2", expires="2026-10-01", up="2026-10-30")], {"a.ts": "isEnabled('exp2')"})
t("已过期 -> FF05", ("medium", "FF05", "exp2") in codes(r))
r = mk([dict(rel("ks"), type="kill-switch", expires=None)], {"a.ts": "isEnabled('ks')"})
t("kill-switch跳过 -> 仅FF06信息", [x[1] for x in codes(r)] == ["FF06"])
r = mk([dict(rel("p", up="2026-10-30"), owner="")], {"a.ts": "isEnabled('p')"})
t("无负责人 -> FF07", ("low", "FF07", "p") in codes(r))
r = mk([dict(rel("d", up="2026-10-30"), environments={"production": {"on": True, "rollout": 100, "updated": "2026-10-30"}, "staging": {"on": False}})], {"a.ts": "isEnabled('d')"})
t("生产领先于预发布 -> FF08", ("medium", "FF08", "d") in codes(r))
r = mk([rel("x")], {"a.ts": "const k = 'x' + region\nisEnabled(flagName)\n"})
t("动态键 -> FF10信息", ("info", "FF10", "-") in codes(r))
r = mk([rel("x")], {"a.py": "is_enabled('export-' + region)\n"})
t("字面量+变量是动态,不是未定义", ("info", "FF10", "-") in codes(r) and not [f for f in r["findings"] if f[1] == "FF01"])
r = mk([rel("new-search", up="2026-10-30")], {"s.tsx": "if (flags.newSearch) {}\n"})
t("flags.camelCase属性计数", r["refs"]["new-search"] == 1)
r = mk([rel("new-search", up="2026-10-30")], {"node_modules/x.js": "isEnabled('new-search')", "a.md": "isEnabled('new-search')"})
t("跳过node_modules和非代码文件", r["refs"]["new-search"] == 0)
r = mk([rel("t", up="2026-10-30", expires=None)], {"a.ts": "isEnabled('t')"})
t("无过期时间 -> FF11", ("low", "FF11", "t") in codes(r))
r = mk([rel("arch", archived=True)], {"a.ts": "isEnabled('arch')"})
t("已归档但被引用 -> FF14高", ("high", "FF14", "arch") in codes(r) and r["plan"][0]["action"] == "移除代码")
r = mk([rel("dup", up="2026-10-30"), rel("dup", up="2026-10-30")], {"a.ts": "isEnabled('dup')"})
t("重复键 -> FF13", ("medium", "FF13", "dup") in codes(r))
r = mk([rel("cust", up="2026-10-30")], {"a.ts": "myFlags.check('cust')\nmyFlags.check('nope')\n"}, call_names=["check"])
t("自定义调用名找到未定义键", ("high", "FF01", "nope") in codes(r))
r = mk([rel("stale14", up="2026-10-20")], {"a.ts": "isEnabled('stale14')"}, stale_days=14)
t("尊重stale_days设置", ("medium", "FF02", "stale14") in codes(r))
for bad, name in (({"flags": [{"key": "a", "type": "temp"}]}, "错误类型"), ({"flags": [{"key": "a", "environments": {"production": {"on": True, "rollout": 150}}}]}, "rollout > 100"),
({"flags": [{"key": "a", "colour": 1}]}, "未知开关键"), ({"flag": []}, "缺少flags列表")):
try:
load_flags(bad); t("拒绝:" + name, False)
except InputError:
t("拒绝:" + name, True)
r = mk([rel("new-checkout")], src)
t("报告有判定和计划", "**判定:" in report(r, ["x"]) and "## 清理计划" in report(r, ["x"]))
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d通过" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="查找过期功能开关并规划其移除。")
ap.add_argument("flags", nargs="?")
ap.add_argument("src", nargs="*")
ap.add_argument("--today")
ap.add_argument("--stale-days", type=int)
ap.add_argument("--call", action="append", default=[], help="额外的开关检查函数名(可重复)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.flags or not a.src:
ap.error("给出FLAGS.json和至少一个源目录")
try:
r = run(a.flags, a.src, a.today, a.stale_days, a.call)
except InputError as e:
print("错误:%s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps({**r, "findings": [dict(zip(("severity", "code", "flag", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
else:
sys.stdout.write(report(r, a.src))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())
用法
此提示词专为 coding 设计。复制上方内容并粘贴到你常用的 AI 工具中。
为获得最佳效果,可将占位符(方括号或大写字母标示)替换为你的具体需求。
讨论
0 条评论