@lifeaitools/rdc-skills 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +284 -1
- package/VALIDATOR-ARCHITECTURE.md +534 -0
- package/commands/analyze-tests.md +11 -0
- package/commands/check-clean-code.md +11 -0
- package/commands/check-packages.md +10 -0
- package/commands/compare-compliance.md +14 -0
- package/commands/full-analysis.md +50 -0
- package/commands/get-refactoring-plan.md +13 -0
- package/commands/quick-check.md +13 -0
- package/commands/recover.md +149 -0
- package/commands/review-arch.md +12 -0
- package/commands/review.md +12 -113
- package/commands/suggest-patterns.md +11 -0
- package/commands/validate-solid.md +11 -0
- package/package.json +14 -2
- package/scripts/architecture-score.mjs +157 -0
- package/scripts/clean-code-score.mjs +177 -0
- package/scripts/duplication-score.mjs +66 -0
- package/scripts/lib/architecture-scoring.mjs +695 -0
- package/scripts/lib/clean-code-scoring.mjs +258 -0
- package/scripts/lib/duplication-scoring.mjs +238 -0
- package/scripts/lib/language-plugin.mjs +82 -0
- package/scripts/lib/package-metrics.mjs +439 -0
- package/scripts/lib/pattern-scoring.mjs +351 -0
- package/scripts/lib/plugins/treesitter.mjs +1182 -0
- package/scripts/lib/plugins/typescript.mjs +672 -0
- package/scripts/lib/refactoring-scoring.mjs +307 -0
- package/scripts/lib/solid-scoring.mjs +101 -0
- package/scripts/lib/test-smell-scoring.mjs +581 -0
- package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
- package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
- package/scripts/package-metrics-cli.mjs +112 -0
- package/scripts/pattern-score.mjs +143 -0
- package/scripts/refactoring-score.mjs +253 -0
- package/scripts/solid-score.mjs +337 -0
- package/skills/architecture-reviewer/SKILL.md +287 -0
- package/skills/clean-code-analyzer/SKILL.md +147 -0
- package/skills/package-design/SKILL.md +118 -0
- package/skills/pattern-advisor/SKILL.md +237 -0
- package/skills/pattern-refactoring-guide/SKILL.md +262 -0
- package/skills/review/SKILL.md +29 -0
- package/skills/solid-validator/SKILL.md +92 -0
- package/skills/testing-strategy/SKILL.md +132 -0
- package/tests/lib/architecture-scoring.test.mjs +335 -0
- package/tests/lib/clean-code-scoring.test.mjs +241 -0
- package/tests/lib/duplication-scoring.test.mjs +144 -0
- package/tests/lib/fixtures.mjs +58 -0
- package/tests/lib/package-metrics.test.mjs +241 -0
- package/tests/lib/pattern-scoring.test.mjs +251 -0
- package/tests/lib/refactoring-scoring.test.mjs +264 -0
- package/tests/lib/solid-scoring.test.mjs +291 -0
- package/tests/lib/test-smell-scoring.test.mjs +281 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refactoring detection — pure functions over a `NormalizedUnit`
|
|
3
|
+
* (see language-plugin.mjs), same discipline as clean-code-scoring.mjs and
|
|
4
|
+
* solid-scoring.mjs: no ts-morph, no language-specific parser. Every fact
|
|
5
|
+
* these rules read was computed once, in `lib/plugins/typescript.mjs`, from
|
|
6
|
+
* the real AST.
|
|
7
|
+
*
|
|
8
|
+
* Detection thresholds are ported/corroborated from architecture-toolkit's
|
|
9
|
+
* REAL implementation — github.com/OnSightTeam/architecture-toolkit (MIT),
|
|
10
|
+
* `src/agents/pattern-refactoring-guide/tools/{refactoring-analyzer,
|
|
11
|
+
* code-smell-refactoring-guide,pattern-transformation-guide}.ts` — reuse of
|
|
12
|
+
* their real detection logic explicitly approved by the operator mid-task,
|
|
13
|
+
* 2026-08-20. Their checks run whole-file text regexes (no AST); ours walk
|
|
14
|
+
* the real AST per unit/member, so every finding carries a real file:line,
|
|
15
|
+
* not a file-wide count.
|
|
16
|
+
*
|
|
17
|
+
* IMPORTANT — two thresholds here are DELIBERATELY DIFFERENT from this
|
|
18
|
+
* repo's own clean-code-scoring.mjs, even though they measure the same
|
|
19
|
+
* underlying fact. They are NOT merged/deduped with the clean-code rules:
|
|
20
|
+
* - extract-method here fires at statementCount > 25 (this file), vs.
|
|
21
|
+
* clean-code's F1 at statementCount > 20 (clean-code-scoring.mjs's
|
|
22
|
+
* f1LongMethods). architecture-toolkit's own real threshold
|
|
23
|
+
* (refactoring-analyzer.ts:49, `if (lines.length > 25)`) is lines-per-
|
|
24
|
+
* function, not statements — 25 is the toolkit's real number for THIS
|
|
25
|
+
* domain (a refactoring recommendation), 20 is this repo's own number
|
|
26
|
+
* for clean-code's F1. Both stay live, cited separately.
|
|
27
|
+
* - introduce-parameter-object here fires at paramCount > 4, vs.
|
|
28
|
+
* clean-code's F2 at paramCount > 3 (clean-code-scoring.mjs's
|
|
29
|
+
* f2TooManyParams). architecture-toolkit's real threshold
|
|
30
|
+
* (refactoring-analyzer.ts:171, `if (params.length > 4)`) is 4; F2's is
|
|
31
|
+
* the repo's own 3. A member can be flagged by clean-code's F2 (>3) and
|
|
32
|
+
* NOT yet reach the refactoring-actionable threshold (>4) — that gap is
|
|
33
|
+
* intentional, not an inconsistency to fix.
|
|
34
|
+
*
|
|
35
|
+
* replace-magic-number deliberately REUSES clean-code's N4 `magicNumbers`
|
|
36
|
+
* fact rather than recomputing it — same underlying numbers, this domain
|
|
37
|
+
* just reframes the OUTPUT as a refactoring recommendation (a concrete
|
|
38
|
+
* "extract these into named constants" plan) instead of N4's per-occurrence
|
|
39
|
+
* finding, once the unit's total count crosses architecture-toolkit's real
|
|
40
|
+
* file-level threshold (refactoring-analyzer.ts:224-226, `magicNumbers.length
|
|
41
|
+
* > 5`).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ── extract-method ──────────────────────────────────────────────────────
|
|
45
|
+
// architecture-toolkit's real threshold: refactoring-analyzer.ts:49
|
|
46
|
+
// (`if (lines.length > 25)`) — lines-per-function-body, whole-file regex
|
|
47
|
+
// match. Ours: statementCount (already on the contract, computed once for
|
|
48
|
+
// clean-code's F1 at a DIFFERENT threshold — see file header) > 25.
|
|
49
|
+
export function extractMethodOpportunities(unit) {
|
|
50
|
+
const findings = [];
|
|
51
|
+
for (const m of unit.members) {
|
|
52
|
+
if ((m.statementCount ?? 0) > 25) {
|
|
53
|
+
findings.push({
|
|
54
|
+
type: 'extract-method',
|
|
55
|
+
location: `${unit.name}#${m.name}`,
|
|
56
|
+
detail: `${m.statementCount} statements (over 25) — candidate for Extract Method`,
|
|
57
|
+
effortCriterion: 'single-file, mechanical extraction — Low unless call-site scan says otherwise',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { refactoringType: 'extract-method', findings, confidence: 'high' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── extract-class ───────────────────────────────────────────────────────
|
|
65
|
+
// architecture-toolkit's real threshold: refactoring-analyzer.ts:108
|
|
66
|
+
// (`if (methods > 15)`) — method count per class, whole-file regex match.
|
|
67
|
+
// Ours: unit.members.length (the same fact this repo's own SRP
|
|
68
|
+
// connected-component analysis in solid-scoring.mjs already reads) > 15,
|
|
69
|
+
// scoped to class units only (a module with >15 top-level functions is a
|
|
70
|
+
// different smell — god-module, not god-class — and is out of scope here).
|
|
71
|
+
export function extractClassOpportunities(unit) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
if (unit.kind === 'class' && unit.members.length > 15) {
|
|
74
|
+
findings.push({
|
|
75
|
+
type: 'extract-class',
|
|
76
|
+
location: unit.name,
|
|
77
|
+
detail: `${unit.members.length} methods (over 15) — violates Single Responsibility, candidate for Extract Class`,
|
|
78
|
+
effortCriterion: 'package-boundary crossing likely — check call-site scan; toolkit\'s own precedent is High',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { refactoringType: 'extract-class', findings, confidence: 'high' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── introduce-parameter-object ──────────────────────────────────────────
|
|
85
|
+
// architecture-toolkit's real threshold: refactoring-analyzer.ts:171
|
|
86
|
+
// (`if (params.length > 4)`). `paramCount` is already part of the base
|
|
87
|
+
// NormalizedMember contract (clean-code's F2 reads the same field at a
|
|
88
|
+
// DIFFERENT threshold, >3 — see file header).
|
|
89
|
+
export function introduceParameterObjectOpportunities(unit) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
for (const m of unit.members) {
|
|
92
|
+
if (m.paramCount > 4) {
|
|
93
|
+
findings.push({
|
|
94
|
+
type: 'introduce-parameter-object',
|
|
95
|
+
location: `${unit.name}#${m.name}`,
|
|
96
|
+
detail: `${m.paramCount} parameters (over 4) — candidate for Introduce Parameter Object`,
|
|
97
|
+
effortCriterion: 'single-file, mechanical — Low unless call-site scan says otherwise',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { refactoringType: 'introduce-parameter-object', findings, confidence: 'high' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── replace-magic-number ────────────────────────────────────────────────
|
|
105
|
+
// architecture-toolkit's real threshold: refactoring-analyzer.ts:224-226
|
|
106
|
+
// (`magicNumbers.length > 5`, whole file). We reuse N4's `magicNumbers` fact
|
|
107
|
+
// (magic-number occurrences already excluding 0/1/-1 and const/enum
|
|
108
|
+
// initializers) but AGGREGATE PER UNIT — not per member, matching the
|
|
109
|
+
// toolkit's own whole-file scope — and only recommend the refactor once the
|
|
110
|
+
// unit's total crosses 5. Below that, N4 (clean-code-scoring.mjs) still
|
|
111
|
+
// flags each occurrence individually; this domain only fires once there are
|
|
112
|
+
// "enough" to justify a consolidation pass, per the toolkit's real number.
|
|
113
|
+
export function replaceMagicNumberOpportunities(unit) {
|
|
114
|
+
const all = unit.members.flatMap((m) => (m.magicNumbers ?? []).map((n) => ({ member: m.name, ...n })));
|
|
115
|
+
if (all.length <= 5) return { refactoringType: 'replace-magic-number', findings: [], confidence: 'high' };
|
|
116
|
+
const findings = all.map((n) => ({
|
|
117
|
+
type: 'replace-magic-number',
|
|
118
|
+
location: `${unit.name}#${n.member}:${n.line}`,
|
|
119
|
+
detail: `magic number ${n.value} — one of ${all.length} in this unit (over 5), candidate for a named constant`,
|
|
120
|
+
effortCriterion: 'single-file, mechanical (extract-constant) — Low',
|
|
121
|
+
}));
|
|
122
|
+
return { refactoringType: 'replace-magic-number', findings, confidence: 'high' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── consolidate-duplicate-code ──────────────────────────────────────────
|
|
126
|
+
// architecture-toolkit's real thresholds: code-smell-refactoring-guide.ts:41
|
|
127
|
+
// (lines trimmed to >10 chars), :49 (`count > 3` per pattern),
|
|
128
|
+
// :51 (`significantDuplication.length > 3` distinct patterns) — all
|
|
129
|
+
// whole-file line-text repetition. Ours: groups `statementTexts` (a new
|
|
130
|
+
// fact — normalized per-STATEMENT-NODE text, same >10-char filter) across
|
|
131
|
+
// ALL members of the unit, same two-level threshold (each group repeated
|
|
132
|
+
// >3 times, AND more than 3 such groups) before firing.
|
|
133
|
+
export function consolidateDuplicateCodeOpportunities(unit) {
|
|
134
|
+
const byText = new Map();
|
|
135
|
+
for (const m of unit.members) {
|
|
136
|
+
for (const s of m.statementTexts ?? []) {
|
|
137
|
+
if (!byText.has(s.text)) byText.set(s.text, []);
|
|
138
|
+
byText.get(s.text).push({ member: m.name, line: s.line });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const duplicateGroups = [...byText.entries()].filter(([, occurrences]) => occurrences.length > 3);
|
|
142
|
+
if (duplicateGroups.length <= 3) return { refactoringType: 'consolidate-duplicate-code', findings: [], confidence: 'medium' };
|
|
143
|
+
const findings = duplicateGroups.map(([text, occurrences]) => ({
|
|
144
|
+
type: 'consolidate-duplicate-code',
|
|
145
|
+
location: occurrences.map((o) => `${unit.name}#${o.member}:${o.line}`).join(', '),
|
|
146
|
+
detail: `statement repeated ${occurrences.length}x (one of ${duplicateGroups.length} duplicate patterns, over 3) — candidate for Consolidate Duplicate Code: '${text.slice(0, 80)}${text.length > 80 ? '…' : ''}'`,
|
|
147
|
+
effortCriterion: '4-15 call sites within the unit — Medium, unless the shared logic must also serve callers outside this package',
|
|
148
|
+
}));
|
|
149
|
+
return { refactoringType: 'consolidate-duplicate-code', findings, confidence: 'medium' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── decompose-conditional ───────────────────────────────────────────────
|
|
153
|
+
// architecture-toolkit's real threshold: code-smell-refactoring-guide.ts:111
|
|
154
|
+
// (`/if\s*\([^)]{50,}\)/g`, whole file), :113 (`.length > 2`). Ours:
|
|
155
|
+
// `complexConditionals` (a new fact — real `if`-condition text length),
|
|
156
|
+
// aggregated per unit, same >2-count threshold.
|
|
157
|
+
export function decomposeConditionalOpportunities(unit) {
|
|
158
|
+
const all = unit.members.flatMap((m) => (m.complexConditionals ?? []).map((c) => ({ member: m.name, ...c })));
|
|
159
|
+
if (all.length <= 2) return { refactoringType: 'decompose-conditional', findings: [], confidence: 'high' };
|
|
160
|
+
const findings = all.map((c) => ({
|
|
161
|
+
type: 'decompose-conditional',
|
|
162
|
+
location: `${unit.name}#${c.member}:${c.line}`,
|
|
163
|
+
detail: `complex conditional, ${c.length} chars (over 50) — one of ${all.length} in this unit (over 2), candidate for Decompose Conditional`,
|
|
164
|
+
effortCriterion: 'single-file, mechanical (extract-condition-to-named-method) — Low',
|
|
165
|
+
}));
|
|
166
|
+
return { refactoringType: 'decompose-conditional', findings, confidence: 'high' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── strategy-transform ──────────────────────────────────────────────────
|
|
170
|
+
// architecture-toolkit's real pattern: pattern-transformation-guide.ts:42
|
|
171
|
+
// (`switch(...) { ... (calculate|process|validate|format) ... }`, whole
|
|
172
|
+
// file, boolean trigger — no count threshold). Ours: per real
|
|
173
|
+
// SwitchStatement (`switchStatements` fact), same word list.
|
|
174
|
+
export function strategyTransformOpportunities(unit) {
|
|
175
|
+
const findings = [];
|
|
176
|
+
for (const m of unit.members) {
|
|
177
|
+
for (const sw of m.switchStatements ?? []) {
|
|
178
|
+
if (sw.hasBehaviorCall) {
|
|
179
|
+
findings.push({
|
|
180
|
+
type: 'strategy-transform',
|
|
181
|
+
location: `${unit.name}#${m.name}:${sw.line}`,
|
|
182
|
+
detail: 'switch statement dispatches behavior (calculate/process/validate/format) — candidate for Strategy pattern',
|
|
183
|
+
effortCriterion: 'new Strategy classes, call sites local to this unit — Medium',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { refactoringType: 'strategy-transform', findings, confidence: 'medium' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── factory-transform ───────────────────────────────────────────────────
|
|
192
|
+
// architecture-toolkit's real pattern: pattern-transformation-guide.ts:100
|
|
193
|
+
// (`switch(...type...) { ... new ... }`, whole file, boolean trigger).
|
|
194
|
+
// Ours: per real SwitchStatement, discriminant text contains "type" AND the
|
|
195
|
+
// switch body contains a `new X()`.
|
|
196
|
+
export function factoryTransformOpportunities(unit) {
|
|
197
|
+
const findings = [];
|
|
198
|
+
for (const m of unit.members) {
|
|
199
|
+
for (const sw of m.switchStatements ?? []) {
|
|
200
|
+
if (sw.hasTypeCreation) {
|
|
201
|
+
findings.push({
|
|
202
|
+
type: 'factory-transform',
|
|
203
|
+
location: `${unit.name}#${m.name}:${sw.line}`,
|
|
204
|
+
detail: 'switch statement creates objects by type — candidate for Factory Method pattern',
|
|
205
|
+
effortCriterion: 'new Factory class, call sites local to this unit — Medium',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { refactoringType: 'factory-transform', findings, confidence: 'medium' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── null-object-transform ───────────────────────────────────────────────
|
|
214
|
+
// architecture-toolkit's real threshold: pattern-transformation-guide.ts:160
|
|
215
|
+
// (`nullChecks > 5`, whole file). Ours: `nullChecks` fact (real `if`
|
|
216
|
+
// conditions containing `=== null` / `!== null`), aggregated per unit, same
|
|
217
|
+
// threshold.
|
|
218
|
+
export function nullObjectTransformOpportunities(unit) {
|
|
219
|
+
const all = unit.members.flatMap((m) => (m.nullChecks ?? []).map((n) => ({ member: m.name, ...n })));
|
|
220
|
+
if (all.length <= 5) return { refactoringType: 'null-object-transform', findings: [], confidence: 'high' };
|
|
221
|
+
const findings = all.map((n) => ({
|
|
222
|
+
type: 'null-object-transform',
|
|
223
|
+
location: `${unit.name}#${n.member}:${n.line}`,
|
|
224
|
+
detail: `null check — one of ${all.length} in this unit (over 5), candidate for Null Object pattern`,
|
|
225
|
+
effortCriterion: 'new Null Object class, call sites local to this unit — Medium',
|
|
226
|
+
}));
|
|
227
|
+
return { refactoringType: 'null-object-transform', findings, confidence: 'high' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Effort estimation — low/medium/high, per the criteria table in
|
|
232
|
+
* skills/pattern-refactoring-guide/SKILL.md (adapted from architecture-
|
|
233
|
+
* toolkit's own `estimatedEffort` field values, corroborated file:line in
|
|
234
|
+
* that SKILL.md). Requires a real cross-file reference-graph walk for the
|
|
235
|
+
* call-site-count and package-boundary criteria — reuses the SAME mechanism
|
|
236
|
+
* clean-code-scoring.mjs's G9 dead-export check already uses
|
|
237
|
+
* (`plugin.referenceSitesOf`, built on the identical `findReferencesAsNodes`
|
|
238
|
+
* walk as `deadExportsOf`), gated behind the same positive-control
|
|
239
|
+
* discipline the caller (refactoring-score.mjs) is responsible for running
|
|
240
|
+
* BEFORE trusting any referenceCount from it — see
|
|
241
|
+
* .claude/rules/prove-absence-positive-control.md.
|
|
242
|
+
*
|
|
243
|
+
* The "cross-cutting invariant" criterion from the SKILL.md table (event
|
|
244
|
+
* ordering, transactional/append-only integrity, freeze-after-mutation
|
|
245
|
+
* semantics) is NOT mechanically detectable from AST facts alone — it stays
|
|
246
|
+
* a human judgment call. This function reports `invariantCheckRequired:
|
|
247
|
+
* true` on every HIGH-adjacent (package-boundary-crossing OR >15-call-site)
|
|
248
|
+
* result as a reminder, but never claims to have evaluated it.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} params
|
|
251
|
+
* @param {string} params.unitPackage - the target unit's own top-level
|
|
252
|
+
* package/app segment (e.g. `packages/core`, `apps/prt`), used to test
|
|
253
|
+
* package-boundary crossing against each reference site's file path.
|
|
254
|
+
* @param {{referenceCount: number, files: string[], kind: string|null}|null} params.referenceSites -
|
|
255
|
+
* result of `plugin.referenceSitesOf(filePath, unitName, projectFilePaths)`,
|
|
256
|
+
* or `null` if the scan was skipped/unavailable/failed its positive control.
|
|
257
|
+
*/
|
|
258
|
+
export function estimateEffort({ unitPackage, referenceSites }) {
|
|
259
|
+
if (!referenceSites || referenceSites.referenceCount < 0) {
|
|
260
|
+
return { effort: null, confidence: 'unmeasured', criterion: 'call-site scan unavailable or unit not found in it — effort cannot be estimated mechanically' };
|
|
261
|
+
}
|
|
262
|
+
const { referenceCount, files } = referenceSites;
|
|
263
|
+
const crossesBoundary = unitPackage ? files.some((f) => !topLevelPackageOf(f, unitPackage)) : false;
|
|
264
|
+
if (crossesBoundary) {
|
|
265
|
+
return { effort: 'high', confidence: 'high', criterion: 'crosses a package boundary', callSites: referenceCount, invariantCheckRequired: true };
|
|
266
|
+
}
|
|
267
|
+
if (referenceCount > 15) {
|
|
268
|
+
return { effort: 'high', confidence: 'high', criterion: '>15 call sites', callSites: referenceCount, invariantCheckRequired: true };
|
|
269
|
+
}
|
|
270
|
+
if (referenceCount >= 4) {
|
|
271
|
+
return { effort: 'medium', confidence: 'high', criterion: '4-15 call sites, same package', callSites: referenceCount };
|
|
272
|
+
}
|
|
273
|
+
return { effort: 'low', confidence: 'high', criterion: '≤3 call sites, no package-boundary crossing', callSites: referenceCount };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// True when `filePath` shares `unitPackage`'s top-level package/app segment
|
|
277
|
+
// (e.g. unitPackage = "packages/core" matches any filePath containing
|
|
278
|
+
// "/packages/core/").
|
|
279
|
+
function topLevelPackageOf(filePath, unitPackage) {
|
|
280
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
281
|
+
return norm.includes(`/${unitPackage}/`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ALL_RULES = [
|
|
285
|
+
extractMethodOpportunities,
|
|
286
|
+
extractClassOpportunities,
|
|
287
|
+
introduceParameterObjectOpportunities,
|
|
288
|
+
replaceMagicNumberOpportunities,
|
|
289
|
+
consolidateDuplicateCodeOpportunities,
|
|
290
|
+
decomposeConditionalOpportunities,
|
|
291
|
+
strategyTransformOpportunities,
|
|
292
|
+
factoryTransformOpportunities,
|
|
293
|
+
nullObjectTransformOpportunities,
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {import('./language-plugin.mjs').NormalizedUnit} unit
|
|
298
|
+
*/
|
|
299
|
+
export function refactoringScore(unit) {
|
|
300
|
+
const rules = {};
|
|
301
|
+
for (const fn of ALL_RULES) {
|
|
302
|
+
const result = fn(unit);
|
|
303
|
+
rules[result.refactoringType] = result;
|
|
304
|
+
}
|
|
305
|
+
const totalFindings = Object.values(rules).reduce((n, r) => n + r.findings.length, 0);
|
|
306
|
+
return { unit: unit.name, kind: unit.kind, rules, totalFindings };
|
|
307
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOLID scoring — pure functions over a `NormalizedUnit` (see language-plugin.mjs).
|
|
3
|
+
*
|
|
4
|
+
* No import of ts-morph, no Python bridge, no language-specific parser. If a
|
|
5
|
+
* new metric needs a fact this file doesn't have, the fix is to add the fact
|
|
6
|
+
* to `NormalizedUnit` and every plugin that produces it — never to reach past
|
|
7
|
+
* the contract for one language's convenience.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function srp(unit) {
|
|
11
|
+
const members = unit.members;
|
|
12
|
+
// Zero members is a real, measured fact now that the TS plugin visits
|
|
13
|
+
// constructors/accessors/arrow-property methods, not just cls.getMethods().
|
|
14
|
+
// Before that fix, "no members" usually meant "the scanner didn't look
|
|
15
|
+
// inside this class", not "this class has none" — a class with 3
|
|
16
|
+
// constructor-injected deps and 3 arrow methods scored 100 at 'high'
|
|
17
|
+
// confidence while genuinely unmeasured. `confidence: 'none'` marks that
|
|
18
|
+
// state so scoreUnit can exclude it from the weighted total instead of
|
|
19
|
+
// reporting a perfect score for evidence that was never gathered.
|
|
20
|
+
if (members.length === 0) return { score: 100, confidence: 'none', detail: 'no members found — unmeasured, not clean' };
|
|
21
|
+
if (members.length === 1) return { score: 100, confidence: 'high', detail: '1 member' };
|
|
22
|
+
|
|
23
|
+
const parent = members.map((_, i) => i);
|
|
24
|
+
const find = (x) => (parent[x] === x ? x : (parent[x] = find(parent[x])));
|
|
25
|
+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < members.length; i++) {
|
|
28
|
+
for (let j = i + 1; j < members.length; j++) {
|
|
29
|
+
const sharedField = members[i].fieldAccess.some((f) => members[j].fieldAccess.includes(f));
|
|
30
|
+
const callsEachOther = members[j].calls.includes(members[i].name) || members[i].calls.includes(members[j].name);
|
|
31
|
+
if (sharedField || callsEachOther) union(i, j);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const components = new Set(members.map((_, i) => find(i))).size;
|
|
35
|
+
const score = components === 1 ? 100 : components === 2 ? 70 : components === 3 ? 40 : 10;
|
|
36
|
+
return { score, confidence: 'high', detail: `${components} connected component(s) across ${members.length} member(s)` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ocp(unit) {
|
|
40
|
+
const members = unit.members;
|
|
41
|
+
if (!members.length) return { score: 100, confidence: 'none', detail: 'no members found — unmeasured, not clean' };
|
|
42
|
+
const hits = members.reduce((n, m) => n + m.branchHits, 0);
|
|
43
|
+
const density = hits / members.length;
|
|
44
|
+
const score = Math.max(0, Math.round(100 - density * 25));
|
|
45
|
+
return { score, confidence: 'low', detail: `${hits} branch/type-check hit(s) across ${members.length} member(s), density ${density.toFixed(2)}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function lsp(unit) {
|
|
49
|
+
if (!unit.hasBaseClass) return { score: 100, confidence: 'low-medium', detail: 'no base class — nothing to violate' };
|
|
50
|
+
const overridden = unit.members.filter((m) => m.override);
|
|
51
|
+
if (!overridden.length) return { score: 100, confidence: 'low-medium', detail: 'no overridden methods' };
|
|
52
|
+
|
|
53
|
+
let drift = 0;
|
|
54
|
+
for (const m of overridden) {
|
|
55
|
+
const o = m.override;
|
|
56
|
+
if (m.paramCount !== o.baseParamCount) drift++;
|
|
57
|
+
if (!o.callsSuper) drift++;
|
|
58
|
+
if (o.returnType && o.baseReturnType && o.returnType !== o.baseReturnType) drift++;
|
|
59
|
+
}
|
|
60
|
+
const score = Math.max(0, Math.round(100 - (drift / (overridden.length * 3)) * 100));
|
|
61
|
+
return { score, confidence: 'low-medium', detail: `${drift} drift signal(s) across ${overridden.length} overridden member(s)` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isp(unit) {
|
|
65
|
+
if (!unit.members.length) return { score: 100, confidence: 'none', detail: 'no members found — unmeasured, not clean' };
|
|
66
|
+
const publicMembers = unit.members.filter((m) => m.isPublic);
|
|
67
|
+
if (!publicMembers.length) return { score: 100, confidence: 'medium-high', detail: 'no public members' };
|
|
68
|
+
const avgParams = publicMembers.reduce((n, m) => n + m.paramCount, 0) / publicMembers.length;
|
|
69
|
+
const countScore = publicMembers.length <= 5 ? 100 : publicMembers.length <= 10 ? 75 : publicMembers.length <= 20 ? 45 : 15;
|
|
70
|
+
const paramScore = avgParams <= 2 ? 100 : avgParams <= 4 ? 75 : 40;
|
|
71
|
+
const score = Math.round((countScore + paramScore) / 2);
|
|
72
|
+
return { score, confidence: 'medium-high', detail: `${publicMembers.length} public member(s), avg ${avgParams.toFixed(1)} param(s)` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function dip(unit) {
|
|
76
|
+
if (!unit.totalDependencies) return { score: 100, confidence: 'high', detail: 'no dependencies' };
|
|
77
|
+
const ratio = unit.concreteInstantiations / unit.totalDependencies;
|
|
78
|
+
const score = Math.max(0, Math.round(100 - ratio * 100));
|
|
79
|
+
return { score, confidence: 'high', detail: `${unit.concreteInstantiations} concrete instantiation(s) of ${unit.totalDependencies} total dependenc(y/ies)` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {import('./language-plugin.mjs').NormalizedUnit} unit
|
|
84
|
+
*
|
|
85
|
+
* A criterion at `confidence: 'none'` was never actually measured (an empty
|
|
86
|
+
* unit — nothing the plugin could find to look inside). Folding its default
|
|
87
|
+
* 100 into the weighted total the same as a real 'high'-confidence 100
|
|
88
|
+
* reports a meaningless number as if it were evidence. It is excluded and
|
|
89
|
+
* the remaining weights renormalized instead. `total` is `null` — not 0,
|
|
90
|
+
* not 100 — when EVERY criterion is unmeasured; a caller must not treat
|
|
91
|
+
* `null` as a passing or failing number.
|
|
92
|
+
*/
|
|
93
|
+
export function scoreUnit(unit, weights) {
|
|
94
|
+
const criteria = { srp: srp(unit), ocp: ocp(unit), lsp: lsp(unit), isp: isp(unit), dip: dip(unit) };
|
|
95
|
+
const measured = Object.entries(weights).filter(([k]) => criteria[k].confidence !== 'none');
|
|
96
|
+
const measuredWeight = measured.reduce((sum, [, w]) => sum + w, 0);
|
|
97
|
+
const total = measuredWeight === 0
|
|
98
|
+
? null
|
|
99
|
+
: Math.round((measured.reduce((sum, [k, w]) => sum + criteria[k].score * w, 0) / measuredWeight) * 10) / 10;
|
|
100
|
+
return { unit: unit.name, kind: unit.kind, criteria, total, unmeasured: Object.keys(weights).filter((k) => criteria[k].confidence === 'none') };
|
|
101
|
+
}
|