@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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +284 -1
  2. package/VALIDATOR-ARCHITECTURE.md +534 -0
  3. package/commands/analyze-tests.md +11 -0
  4. package/commands/check-clean-code.md +11 -0
  5. package/commands/check-packages.md +10 -0
  6. package/commands/compare-compliance.md +14 -0
  7. package/commands/full-analysis.md +50 -0
  8. package/commands/get-refactoring-plan.md +13 -0
  9. package/commands/quick-check.md +13 -0
  10. package/commands/recover.md +149 -0
  11. package/commands/review-arch.md +12 -0
  12. package/commands/review.md +12 -113
  13. package/commands/suggest-patterns.md +11 -0
  14. package/commands/validate-solid.md +11 -0
  15. package/package.json +14 -2
  16. package/scripts/architecture-score.mjs +157 -0
  17. package/scripts/clean-code-score.mjs +177 -0
  18. package/scripts/duplication-score.mjs +66 -0
  19. package/scripts/lib/architecture-scoring.mjs +695 -0
  20. package/scripts/lib/clean-code-scoring.mjs +258 -0
  21. package/scripts/lib/duplication-scoring.mjs +238 -0
  22. package/scripts/lib/language-plugin.mjs +82 -0
  23. package/scripts/lib/package-metrics.mjs +439 -0
  24. package/scripts/lib/pattern-scoring.mjs +351 -0
  25. package/scripts/lib/plugins/treesitter.mjs +1182 -0
  26. package/scripts/lib/plugins/typescript.mjs +672 -0
  27. package/scripts/lib/refactoring-scoring.mjs +307 -0
  28. package/scripts/lib/solid-scoring.mjs +101 -0
  29. package/scripts/lib/test-smell-scoring.mjs +581 -0
  30. package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
  31. package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
  32. package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
  33. package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
  34. package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
  35. package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
  36. package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
  37. package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
  38. package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
  39. package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
  40. package/scripts/package-metrics-cli.mjs +112 -0
  41. package/scripts/pattern-score.mjs +143 -0
  42. package/scripts/refactoring-score.mjs +253 -0
  43. package/scripts/solid-score.mjs +337 -0
  44. package/skills/architecture-reviewer/SKILL.md +287 -0
  45. package/skills/clean-code-analyzer/SKILL.md +147 -0
  46. package/skills/package-design/SKILL.md +118 -0
  47. package/skills/pattern-advisor/SKILL.md +237 -0
  48. package/skills/pattern-refactoring-guide/SKILL.md +262 -0
  49. package/skills/review/SKILL.md +29 -0
  50. package/skills/solid-validator/SKILL.md +92 -0
  51. package/skills/testing-strategy/SKILL.md +132 -0
  52. package/tests/lib/architecture-scoring.test.mjs +335 -0
  53. package/tests/lib/clean-code-scoring.test.mjs +241 -0
  54. package/tests/lib/duplication-scoring.test.mjs +144 -0
  55. package/tests/lib/fixtures.mjs +58 -0
  56. package/tests/lib/package-metrics.test.mjs +241 -0
  57. package/tests/lib/pattern-scoring.test.mjs +251 -0
  58. package/tests/lib/refactoring-scoring.test.mjs +264 -0
  59. package/tests/lib/solid-scoring.test.mjs +291 -0
  60. 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
+ }