giacope/hashira
GitHub: giacope/hashira
hashira 是一款 Ruby 代码结构分析 CLI 工具,通过 AST 解析提供耦合度、认知复杂度和重复度三项指标,帮助团队在 CI 中逐步改善代码质量。
Stars: 0 | Forks: 0
# hashira
🏛️ **针对 Ruby 的耦合度、认知复杂度和重复度指标,通过 [Prism](https://github.com/ruby/prism) 直接从 AST 读取。**
hashira 告诉你首先该打开哪个文件。它从三个维度读取 Ruby 代码库 —— 包与包之间的依赖关系、每个方法的阅读难度,以及哪些代码被复制粘贴过 —— 然后根据文件的成本与你实际修改它的频率,对每个文件进行排名。每项发现都会指出其背后的文件和行号,并且提交的基线会在 CI 中对整个结果集进行单向棘轮控制,因此构建失败是因为*本次提交*引入了恶化,而不是因为某个大家都无共识的分数。
- **零运行时依赖。** Prism 随 Ruby 3.4+ 一起提供;无需额外安装。
- **读取 AST,绝不读取字符串。** 每个信号都来自解析树。注释和字符串字面量是不可见的。
- **三种分析器,可自由选择退出。** 耦合度、复杂度和重复度默认同时运行;使用 `--skip` 可以跳过任何一项。
- **排名,而非评级。** 热点汇总按成本 × 变更频率对文件进行排序 —— 这是一个工作队列,而不是在所有健康代码库上都千篇一律的字母评级。
- **发现,而不仅仅是一个仪表板。** 循环依赖、SDP 违规、复杂度热点和克隆集群 —— 每一项都由文件级证据和通俗易懂的修复方法支持。
- **专为 CI 设计。** 根据基线对边界和发现进行单向棘轮控制,因此不需要从零开始。或者直接使用 `--fail-on` 进行拦截。
让 `billing` 和 `shipping` 开始相互引用,hashira 就会指出循环依赖以及切断成本最低的边:
```
$ hashira app
package TC Ca Ce I Cyc
----------------------------------------
billing 1 1 1 0.50 YES
shipping 1 1 1 0.50 YES
Findings (2):
cycle: billing can reach itself: billing -> shipping -> billing — any change
may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
· billing/client.rb:3: Shipping::Rate
· shipping/rate.rb:3: Billing::Client
```
一个健康的项目会报告 `Findings (0): none ✓ — structure is healthy`。
## 目录
[安装说明](#install) · [快速开始](#getting-started) · [耦合度:如何解读这些数字](#coupling-how-to-read-the-numbers) · [Rails 应用](#rails-apps) · [认知复杂度](#cognitive-complexity) · [重复度](#duplication) · [热点](#hotspots) · [工作原理](#how-it-works) · [CI](#ci) · [其他格式](#other-formats) · [为什么使用认知复杂度](#why-cognitive-complexity) · [为什么不使用 A、D 或区域评级](#why-no-a-d-or-zones)
## 安装说明
hashira 是一个命令行工具。全局安装它:
```
gem install hashira
```
或者将其添加到项目中并通过 Bundler 运行:
```
# Gemfile
gem "hashira", group: :development
```
```
bundle install
bundle exec hashira
```
需要 Ruby 3.4 或更高版本。
## 快速开始
将 hashira 指向你的代码,或者在不带参数的情况下运行它以自动检测 `lib/`。单文件夹包装链会被自动下钻,因此 `hashira`、`hashira lib` 和 `hashira lib/gem/core` 最终会落在相同的包边界上:
```
hashira # auto-detects lib/
hashira lib/myapp # or point it at a directory
hashira app lib # or several — one shared graph
hashira --skip complexity,duplication # coupling only
hashira --skip coupling # complexity + duplication
```
完整的文本报告包含耦合度表、复杂度表、热点汇总和各项发现(包括任何重复集群)。以下是针对 hashira 自身源码的报告:
```
$ hashira
Package (layer) metrics for lib/hashira (9 packages, 75 files)
package TC Ca Ce I Cyc
----------------------------------------
analysis 14 3 0 0.00 -
diagram 3 1 0 0.00 -
hotspots 1 1 0 0.00 -
duplication 14 2 1 0.33 -
report 8 2 1 0.33 -
complexity 7 1 1 0.50 -
(root) 3 2 4 0.67 -
ci 8 1 2 0.67 -
cli 6 0 4 1.00 -
Legend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),
I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)
Dependencies (DependsUpon(refs) -> | <- UsedBy):
(root) -> analysis(4), complexity(1), duplication(1), hotspots(1) <- ci, cli
duplication -> analysis(3) <- (root), report
...
Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends):
method Cog Calls Loc
-------------------------------------------------------------
Hashira::Report::Text#print 3 7 report/text.rb:11
Hashira::Analysis::CycleSearch#cycle? 3 3 analysis/cycle_search.rb:19
Hashira::CLI::CommandLine#usage_options 3 5 cli/command_line.rb:20
Hashira::Duplication::Delta#kind 3 6 duplication/delta.rb:21
...
Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides):
class Cog Methods Peak
------------------------------------------------------
Hashira::CLI::CommandLine 16 15 3
Hashira::Analysis::CycleSearch 8 5 3
...
Hotspots — cost × churn (where refactoring pays the most):
file Cog Dup Churn Rank
-------------------------------------------------------------------------
cli/run.rb 1 36 2 74
cli/command_line.rb 16 0 3 48
pipeline.rb 7 0 3 21
...
Findings (0):
none ✓ — structure is healthy
```
## 耦合度:如何解读这些数字
目标目录下的每个文件夹都是一个**包**。对于每一个包:
- **TC** — 它定义了多少个类/模块。
- **Ca** — 有多少个包依赖*它*(向心,传入)。
- **Ce** — 它依赖*多少个*包(离心,传出)。
- **I** — 不稳定性,`Ce / (Ce + Ca)`,范围从 0 到 1。
**I = 0**:所有人都依赖它,而它不依赖任何人。这是基础,修改成本极高。**I = 1**:没有人依赖它,因此可以随意修改。这二者本身没有绝对的好坏之分;CLI 层*应该*处于 1.00,而核心领域层应接近 0.00。各项发现针对的是指向错误方向的箭头:
- **SDP 违规** — 一个稳定的包依赖于一个较不稳定的包,这违背了稳定依赖原则(“沿着稳定性的方向进行依赖”),这是 Robert C. Martin 的[包设计原则](https://en.wikipedia.org/wiki/Package_principles)之一。
- **循环依赖** — 包之间相互依赖形成循环。
每项发现都包含文件级别的证据;对于循环依赖,会提供最短的循环路径及其最轻量的边。一项发现对你的架构设计意味着什么,由你自己决定。
## Rails 应用
Rails 的分层文件夹是框架布局,而非架构:模型总是会触及任务和邮件发送器,因此 `app/` 下的文件夹包会将这些惯例报告为发现项。当被分析的目录包含 `config/application.rb`(Rails 根目录)或位于其旁边(即它的 `app` 文件夹)时,hashira 会切换到**命名空间打包模式**:类型根据顶层常量(`Billing`、`Ci`、`User`)跨文件夹分层进行分组,边连接不同的领域,而发现项回答了一个 Rails 单体应用真正面临的问题 —— `Billing` 是否触及了 `Ci`?
```
$ hashira app
package TC Ca Ce I Cyc
----------------------------------------
Account 26 21 18 0.46 YES
Billing 116 12 11 0.48 YES
Ci 107 9 16 0.64 YES
...
cycle: Account can reach itself: Account -> User -> Account — any change
may ripple back around. The lightest edge on this cycle is Account -> User (1 ref).
· models/account.rb:36: User
· models/user/signup.rb:32: Account
```
在命名空间打包模式下,对应用自定义的 `Application*` 基类(如 `ApplicationRecord`、`ApplicationJob` 等)的引用会被跳过,视为框架底层管道;`--package-by folder` 会保留它们,从而使传统的分层视图保持完整。常量解析在任何地方都遵循 Ruby 的词法嵌套规则 —— `class User` 中的裸露的 `Authentication` 指的是 `User::Authentication`,而不是另一个包中同名的顶层常量 —— 这在 Rails 应用中最为关键,因为嵌套的 concern 通常会遮蔽顶层的名称。
你可以在任何地方强制使用任一分组模式:
```
hashira app --package-by folder # layer view, even in a Rails app
hashira lib/gem --package-by namespace
```
## 认知复杂度
hashira 使用**认知复杂度**对每个方法进行评分,而不是 ABC 或调用次数指标。其核心在于根据方法的*阅读*难度进行排名,而不是根据它们发送了多少条消息:
- **Cog** — 认知复杂度得分。连续平铺的调用序列成本为零;每一层嵌套都会增加其内部代码的成本;无论有多少个分支,一个 `case` 只计一次;一连串相同的布尔运算符只计一次,但混合使用 `&&`/`||` 会导致成本增加;`elsif`/`else` 会保持平铺,而不会导致成本累加。
- **Calls** — 消息发送的数量,与 Cog 并排显示。这就是调用次数指标排名的依据;当 Cog 和 Calls 不一致时,Cog 是更真实反映情况的指标。
- **类级汇总** — 一个类的总复杂度及其方法数量。如果只看方法级别的得分,当你把一个大方法拆分成五个小方法时,得分就会消失;但类的总复杂度不会,因此这种汇总方式能识破这种钻空子的行为。
超过阈值的方法将成为 `complexity`(复杂度)发现项,每一项都包含得分来源的详细分类以及重构建议:
```
complexity: Shop::Checkout::Pricing#total — cognitive 10, 12 calls
(checkout/pricing.rb:4). flatten the branching — guard clauses, early returns, or polymorphism.
· if +8 (lines 6, 7, 8, 12)
· else +1 (line 9)
· boolean +1 (line 12)
```
## 重复度
将相同的代码复制到三个文件中,hashira 会在一次发现中找出整个家族 —— 而不是三对 —— 并告诉你它们之间的差异:
```
Findings (1):
duplication: 3 similar fragments (mass 45) — differs only in literal values —
extract a method, pass them as arguments.
· reports/orders.rb:1-11
· reports/payouts.rb:1-11
· reports/refunds.rb:1-11
```
它只读取 `.rb` 文件,因此存在于模板中的重复代码不在检测范围内。它在 Ruby 内部的作用包括:
- **默认检测近似克隆。** 每个代码片段都被简化为其节点类型序列,按其最罕见的类型建立索引,并通过真正的最长公共子序列检查来验证候选对。这能找出精确结构哈希无法检测到的 Type-3 克隆 —— 即带有重命名变量或额外一行的副本。它始终处于开启状态,且匹配结果带有一个分数,而不仅仅是一个标签。
- **滑动窗口,小到单个语句。** 会考虑每一个连续的语句序列,因此埋藏在一个较大方法内部的重复代码段也会被捕获,而不仅仅是整个方法体。两个由于逐行修改而产生差异的同级 controller 会在这里且仅在这里匹配:它们任何一个的子树都不是另一个的克隆。此外,单个语句本身也可以是一个克隆 —— view helper 中逐字重复的块体就是一个单一表达式。
- **还包括整个方法、`when` 分支和 `rescue` 子句。** 单行方法根本没有连续的语句序列;如果不包含这些,它们将是不可见的。
- **列表不是克隆。** 一连串结构相同的语句 —— 一个 require 块、一个 routes 文件、一列注册语句 —— 会被跳过,因此从其中截取的滑动窗口不会在每个偏移量上都报告匹配。
- **是集群,而不是对。** 同一代码的所有副本都会折叠成一个包含 N 个位置的单一发现项,因此报告读起来像是“一次性修复它”,而不是一堆成对匹配。
- **它会告诉你如何修复。** hashira 会对副本进行 diff,并对差异进行分类:仅字面量不同 → 提取一个方法,并将它们作为参数传递;仅接收者不同 → 提取一个接收它的方法,或使用多态;常量不同 → 将其参数化;控制流本身不同 → 提取公共核心,但需要手动验证(标记为较低置信度)。
- **从代码库本身控制噪声。** 到处重复出现的结构是 Ruby 的惯用语法,而不是重复代码,因此随着某种结构变得越来越常见,其质量下限也会相应提高。罕见的 token 类型会驱动匹配过程,而常见的则不会。当两个位置除了结构相同之外没有任何共同点时,下限会再次提高:`each_cons(2).min_by { }` 和 `combination(2).select { }` 巧合地成为相同的语法树,而一个没有共同名称的匹配必须大得多才能代表真正的重复。
- **变更频率叠加。** 当 git 可用时,如果两个克隆所在的文件都频繁更改,它们就会被特别标出 —— 因为这正是其中一份副本被修复,而另一份默默产生偏差的高发地带。当 git 不存在时,此功能会自动静默;且该过程不需要任何配置。
## 热点
三种分析器分别回答不同的问题。汇总报告将它们按文件结合起来,并加入了那个 AST 中没有的信号 —— 即文件实际发生变更的频率 —— 因为你无需承担的成本就不值得去偿还:
```
Hotspots — cost × churn (where refactoring pays the most):
file Cog Dup Churn Rank
-------------------------------------------------------------------------
controllers/orders/refunds_controller.rb 0 67 4 268
controllers/orders/returns_controller.rb 0 67 4 268
models/invoice.rb 8 34 3 126
models/shipping/label.rb 9 100 1 109
controllers/orders_controller.rb 8 0 7 56
```
把它当作一个工作队列来解读:第一行是花一天时间重构收益最大的地方。包含克隆代码的文件会按克隆点数量进行计费,因此同时包含两份副本的文件需支付双倍成本。变更频率的最低值为 1,因此即使是没有 git 历史的代码库,依然会按成本进行排名。
刻意不进行评级。健康代码库上的字母评级都是重复的同一个字母 —— 它完全不能告诉你应该先打开哪个文件。
## 工作原理
**耦合度。** 当包 A 中的文件引用了包 B 声明的常量时,就存在一条依赖边 A→B。声明是从 AST 中读取的;字符串和注释是不可见的。只有当类型直接在其主体中定义了方法时,该类型才计入 TC;纯粹的命名空间包装器不计入。包共享的命名空间前缀会被推断出来(如 `App`,或者在分析嵌套子树时为 `App::Core`),因此 `App::Alpha` 和 `Alpha` 会解析到同一个包。解析基于最长的常量路径,因此跨包镜像的命名空间(如模型中的 `Admin::Account`,controller 中的 `Admin::AccountsController`)会将每个引用发送到正确的包;在唯一一个包中声明的裸名会在那里解析,而多个包声称拥有的名称则什么都不会解析,绝不进行猜测。每条边都带有一个**权重**:支持它的常量引用数量。当存在同级的文件夹 `x/` 时,根级文件 `x.rb` 会合并到包 `x` 中;顶层的其他所有内容都会归入 `(root)`。
**复杂度。** 每个方法体都会被遍历一次,并根据上述认知复杂度规则进行评分。
**重复度。** 候选项是每一个包含 1 到 12 条同级语句的滑动窗口,加上每一个完整的方法、`when` 分支和 `rescue` 子句。结构完全相同的连续语句序列会作为列表被跳过。每个候选项都会进行结构性哈希处理,并同时进行精确匹配和近似匹配 —— 在进行真正的比较之前,最长公共子序列的线性时间界限会先拒绝掉不匹配的对 —— 然后联合成集群,并精简为最大且不重叠的集群。所有三个分析器共享对你源码的一次解析,因此同时运行它们的成本并不比解析一次高。
**热点。** 每个文件都会被计入其方法的认知复杂度得分以及它所包含的每个克隆点的质量,然后乘以触及它的 commit 数量。Git 只会在需要变更频率数据时,被懒加载地询问一次。
## CI
`--fail-on` 是一种直接粗暴的工具:当存在某种发现项时,直接让构建失败。它只在初始状态干净的代码库上有效。
```
hashira --fail-on cycles,sdp,complexity,duplication # any subset, comma-separated
```
棘轮机制(ratchet)是你今天就可以采用的方案。提交关于当前真实状态的基线 —— 存在哪些边,存在哪些发现项 —— 当这个集合*增加*时,构建就会失败。它从不追问代码是好是坏,只关心本次提交是否使其恶化,这才是一个构建系统真正能回答的问题:
```
hashira --update-baseline # record today's edges and findings
hashira --ratchet # fail if either set grew
hashira --ratchet --baseline PATH
```
回归会连同其引入的证据一起完整打印出来:
```
$ hashira --ratchet
NEW FINDING:
duplication: 2 similar fragments (mass 44) — extract the shared shape and pass what differs as parameters.
· billing/refund.rb:1-11
· orders/checkout.rb:1-11
Ratchet FAILED. Either fix what regressed, or — if it is deliberate —
record the decision: update the baseline, or accept it with a reason.
```
改进也会导致构建失败,并且会以欢快的语气告知这一点 —— 未被记录的提升很容易在下一次提交中被悄悄撤销。重新运行 `--update-baseline` 即可将其锁定。
### 设计上的接受方式
任何刻意为之的项都会带着一个理由放入基线中。它会从报告和拦截规则中移出,但会保留一行提醒,说明为什么允许它存在:
```
"accepted": [
{"kind": "sdp_violation", "package": "models", "reason": "config is generated, churn is harmless"},
{"kind": "complexity", "package": "Legacy::Importer#run", "reason": "vendored, rewrite scheduled"},
{"kind": "duplication", "digest": "8bbddea787bc", "reason": "generated adapters, regenerated together"}
]
```
循环依赖、SDP 违规和复杂度都通过 `package` 来标识 —— 可以是包名或方法名,两者都很稳定。但克隆没有一个稳定的名称:它的规范标识是一个行号,而只要它上面的任何内容发生变动,行号就会改变。因此,克隆改用 `digest` 来接受,这是其结构本身的指纹 —— 从 `hashira --json` 中读取。即使克隆在文件中向下移动,它依然有效,而当克隆真正发生改变时,它就会停止匹配。
那句理由是任何工具都无法计算的部分。一个没有逃逸阀门的棘轮机制,在它第一次阻碍发布的那个星期五就会被关掉;而一个只需写下一句理由的机制,会将每一次破例变成一个经过某人审查的决定。
## 其他格式
```
hashira --json # machine format: findings (with digests), accepted, packages,
# edges, complexity, duplication, hotspots
hashira --format dot # Graphviz digraph
hashira --format mermaid # Mermaid diagram
```
## 为什么使用认知复杂度
较早期的 Ruby 复杂度指标大约对每次消息发送收取一分,并乘以嵌套深度,因此得分更多地反映了*你调用了多少个方法*,而不是代码本身有多难懂。一个平铺的、调用了二十个协作对象的方法,其得分会高于一个包含深层条件判断和混合布尔逻辑的、真正令人费解的方法。
认知复杂度的设计理念恰恰相反:直线型代码无论多长都是零成本;嵌套会产生累加成本;而像 `case` 这样的平铺结构成本很低,因为跳转表读起来很简单。hashira 特意将调用次数显示在得分旁边,就是为了让你能直观地看到两者不一致的地方。
这种差距在 Rails 中表现得最为明显。如果按消息发送数量对 Rails 应用进行排名,排在最前面的会是类主体 —— `Invoice`、`Order::Pagination`、`Membership` —— 因为一整列 `has_many` 和 `validates` 声明就是一整列消息发送。类主体不是方法,因此认知复杂度将它们评为零分,并对实际包含分支逻辑的代码进行排名。类级汇总功能随后能确保在将某个热点拆分成五个微小方法后,依然保持其可见性,同时不会让一整墙的 DSL 调用主导总得分。
## 为什么不使用 A、D 或区域评级
经典的包指标工具还会度量抽象程度(A)、偏离主序列的距离(D)以及痛苦/无用区域。这些指标假设你是通过正式接口进行解耦的。而惯用的 Ruby 是通过鸭子类型进行解耦,因此任何对抽象程度的代理衡量都会被钉死在 ~0,所谓的“区域”判定只不过是在重复 I 的结论而已。故而有意跳过。
## 贡献
欢迎在
[github.com/giacope/hashira](https://github.com/giacope/hashira) 提交 Bug 报告和 Pull Request。请参阅
[CONTRIBUTING.md](CONTRIBUTING.md) 和[行为准则](CODE_OF_CONDUCT.md)。
## 许可证
[MIT](LICENSE.txt)
标签:Ruby, 技术债务, 文档结构分析, 知识库, 错误基检测, 静态代码分析