@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,258 @@
1
+ /**
2
+ * Clean Code scoring — pure functions over a `NormalizedUnit`
3
+ * (see language-plugin.mjs), same discipline as solid-scoring.mjs: no
4
+ * ts-morph, no language-specific parser. Every fact these rules read was
5
+ * computed once, in `lib/plugins/typescript.mjs`, from the real AST.
6
+ *
7
+ * Detection logic (thresholds, patterns, what counts as a violation) is
8
+ * ported/adapted from architecture-toolkit's REAL implementation —
9
+ * github.com/OnSightTeam/architecture-toolkit (MIT), specifically
10
+ * `src/agents/clean-code-analyzer/tools/{naming,function,code-smell}-validator.ts`
11
+ * — reuse of their real detection logic explicitly approved by the operator
12
+ * mid-task, 2026-08-20. Their checks run whole-file text regexes with an
13
+ * occurrence-count threshold to suppress false positives (e.g. "flag only if
14
+ * more than 3 single-letter assignments appear"); ours walks the real AST
15
+ * per declared binding, so context is known directly (a for-loop counter vs.
16
+ * a badly-named field, a magic number vs. a named const) and no threshold is
17
+ * needed to separate signal from regex noise — every real occurrence is its
18
+ * own finding. Each rule function below cites the specific architecture-
19
+ * toolkit file:line it corroborates or adapts.
20
+ *
21
+ * Rules NOT implemented here (N3, N5, N6, C1-C5, G5, G14, G16, G28) are
22
+ * intentionally absent — see skills/clean-code-analyzer/SKILL.md for why
23
+ * each one stays a dispatched-judgment call instead of a fake mechanical
24
+ * check.
25
+ */
26
+
27
+ export const NOT_IMPLEMENTED = ['N3', 'N5', 'N6', 'C1', 'C2', 'C3', 'C4', 'C5', 'G5', 'G14', 'G16', 'G28'];
28
+
29
+ function loc(unit, memberName, line) {
30
+ return line === undefined ? `${unit.name}#${memberName}` : `${unit.name}#${memberName}:${line}`;
31
+ }
32
+
33
+ // ── N1 — single-letter / cryptic variable names ────────────────────────────
34
+ // architecture-toolkit's real check: naming-validator.ts:46-47 flags
35
+ // single-letter assignments (`\b[a-z]\s*=`) once there are MORE THAN 3 in
36
+ // the whole file, and naming-validator.ts:63-64 does the same for two-letter
37
+ // names at a threshold of 5 — both exist only to suppress the regex's own
38
+ // false-positive rate (any `x = 5` matches, including inside strings). Our
39
+ // AST version reads the real declared-binding name directly, so every
40
+ // genuine one-or-two-letter local (outside the loop-counter/well-known
41
+ // exceptions the task specifies) is reported on its own — no threshold.
42
+ const LOOP_COUNTER_WHITELIST = new Set(['i', 'j', 'k']);
43
+ const SHORT_NAME_WHITELIST = new Set(['fn', 'cb', 'ok', 'id', 'db', 'ui', 'io']);
44
+
45
+ export function n1CrypticNames(unit) {
46
+ const findings = [];
47
+ for (const m of unit.members) {
48
+ for (const d of m.declaredNames ?? []) {
49
+ if (d.name.length === 1 && !LOOP_COUNTER_WHITELIST.has(d.name)) {
50
+ findings.push({ location: loc(unit, m.name, d.line), detail: `single-letter name '${d.name}' (not a conventional loop counter)` });
51
+ } else if (d.name.length === 2 && !SHORT_NAME_WHITELIST.has(d.name.toLowerCase())) {
52
+ findings.push({ location: loc(unit, m.name, d.line), detail: `cryptic two-letter name '${d.name}'` });
53
+ }
54
+ }
55
+ }
56
+ return { ruleId: 'N1', findings, confidence: 'high' };
57
+ }
58
+
59
+ // ── N2 — meaningless distinctions (heuristic, low confidence) ─────────────
60
+ // architecture-toolkit's real check at naming-validator.ts:98-99 flags
61
+ // number-suffixed names (`\w+\d+\s*=`, e.g. name1/name2) at a >2 occurrence
62
+ // threshold — that pattern (data1/data2) is adapted directly, per-occurrence,
63
+ // no threshold. Its co-located data/info regex (naming-validator.ts:82) is
64
+ // whole-file text co-occurrence and doesn't translate to a per-binding AST
65
+ // check, so it's replaced here with a small noise-word set applied to the
66
+ // SAME declared-binding facts N1 uses.
67
+ const NOISE_WORDS = new Set(['data', 'info', 'temp', 'tmp', 'foo', 'bar', 'val', 'obj', 'thing']);
68
+ const NUMERIC_SUFFIX_RE = /^[A-Za-z_$][A-Za-z0-9_$]*[0-9]+$/;
69
+
70
+ export function n2MeaninglessNames(unit) {
71
+ const findings = [];
72
+ for (const m of unit.members) {
73
+ for (const d of m.declaredNames ?? []) {
74
+ if (NOISE_WORDS.has(d.name.toLowerCase())) {
75
+ findings.push({ location: loc(unit, m.name, d.line), detail: `heuristic: noise-word name '${d.name}' carries no distinguishing meaning` });
76
+ } else if (NUMERIC_SUFFIX_RE.test(d.name)) {
77
+ findings.push({ location: loc(unit, m.name, d.line), detail: `heuristic: numeric-suffix name '${d.name}' (data1/data2 pattern) — name by role, not sequence` });
78
+ }
79
+ }
80
+ }
81
+ return { ruleId: 'N2', findings, confidence: 'low' };
82
+ }
83
+
84
+ // ── N4 — magic numbers ─────────────────────────────────────────────────────
85
+ // architecture-toolkit's real check (naming-validator.ts:139, `\b\d{2,}\b`
86
+ // at a >3-occurrence threshold) is whole-file text and blind to declaration
87
+ // context — it cannot tell a bare `86400` from a `const DAY_MS = 86400`.
88
+ // Our AST version reads `magicNumbers` computed in typescript.mjs, which
89
+ // already excludes 0/1/-1 and any literal that IS the direct initializer of
90
+ // a `const` or an enum member — the exact context distinction a regex can't
91
+ // make. Findings are the raw fact list; this function just packages them.
92
+ export function n4MagicNumbers(unit) {
93
+ const findings = [];
94
+ for (const m of unit.members) {
95
+ for (const n of m.magicNumbers ?? []) {
96
+ findings.push({ location: loc(unit, m.name, n.line), detail: `magic number ${n.value} used outside a const/enum declaration` });
97
+ }
98
+ }
99
+ return { ruleId: 'N4', findings, confidence: 'high' };
100
+ }
101
+
102
+ // ── N7 — generic class/function names ──────────────────────────────────────
103
+ // architecture-toolkit's real check at naming-validator.ts:219
104
+ // (`/class\s+(Manager|Processor|Data|Info)\b/`) matches only the WHOLE class
105
+ // name, prefix position, and only 4 words. Our task spec's word list is
106
+ // {Manager, Handler, Processor, Helper, Util} and explicitly wants the SOLE
107
+ // SUFFIX form too (e.g. `UserDataManager`), so this extends their mechanism
108
+ // (name-against-known-set) rather than reusing the regex verbatim.
109
+ const GENERIC_NAME_WORDS = ['Manager', 'Handler', 'Processor', 'Helper', 'Util'];
110
+ function isGenericName(name) {
111
+ return GENERIC_NAME_WORDS.some((w) => name === w || name.endsWith(w));
112
+ }
113
+
114
+ export function n7GenericNames(unit) {
115
+ const findings = [];
116
+ if (unit.kind === 'class' && isGenericName(unit.name)) {
117
+ findings.push({ location: unit.name, detail: `generic class name '${unit.name}' (whole name or sole suffix is a generic word)` });
118
+ }
119
+ for (const m of unit.members) {
120
+ if (isGenericName(m.name)) {
121
+ findings.push({ location: loc(unit, m.name), detail: `generic function/method name '${m.name}' (whole name or sole suffix is a generic word)` });
122
+ }
123
+ }
124
+ return { ruleId: 'N7', findings, confidence: 'high' };
125
+ }
126
+
127
+ // ── F1 — long methods (over 20 statements) ─────────────────────────────────
128
+ // Corroborated, not just copied: architecture-toolkit's real threshold at
129
+ // function-validator.ts:52 (`if (avgLinesPerFunction > 20)`) independently
130
+ // lands on the SAME number our task spec names, for lines-per-function
131
+ // rather than statement count. Their metric is an average over the whole
132
+ // file (blind to which specific function is long); ours is per-member and
133
+ // counts real statement nodes (flattened across nesting), so a 21-statement
134
+ // method is named directly instead of averaged away by short neighbors.
135
+ export function f1LongMethods(unit) {
136
+ const findings = [];
137
+ for (const m of unit.members) {
138
+ if ((m.statementCount ?? 0) > 20) {
139
+ findings.push({ location: loc(unit, m.name), detail: `${m.statementCount} statements (over 20)` });
140
+ }
141
+ }
142
+ return { ruleId: 'F1', findings, confidence: 'high' };
143
+ }
144
+
145
+ // ── F2 — too many parameters (over 3) ──────────────────────────────────────
146
+ // Exact match to architecture-toolkit's real threshold at
147
+ // function-validator.ts:82 (`if (paramCount > 3)`). `paramCount` is already
148
+ // part of the base NormalizedMember contract (solid-scoring.mjs's ISP reads
149
+ // the same field) — no new fact needed here.
150
+ export function f2TooManyParams(unit) {
151
+ const findings = [];
152
+ for (const m of unit.members) {
153
+ if (m.paramCount > 3) {
154
+ findings.push({ location: loc(unit, m.name), detail: `${m.paramCount} parameters (over 3)` });
155
+ }
156
+ }
157
+ return { ruleId: 'F2', findings, confidence: 'high' };
158
+ }
159
+
160
+ // ── E1 — empty catch blocks ─────────────────────────────────────────────────
161
+ // Exact match to architecture-toolkit's real pattern at
162
+ // code-smell-validator.ts:160 (`/catch\s*\([^)]+\)\s*{\s*}/i`). AST form is
163
+ // strictly stronger: a comment-only catch block (`catch(e) { /* ignore */ }`)
164
+ // is exactly as silent as a truly empty one but does NOT match their regex
165
+ // (comment text isn't whitespace); the AST's statement count is 0 either way.
166
+ export function e1EmptyCatchBlocks(unit) {
167
+ const findings = [];
168
+ for (const m of unit.members) {
169
+ for (const c of m.emptyCatches ?? []) {
170
+ findings.push({ location: loc(unit, m.name, c.line), detail: 'empty catch block — exception swallowed silently' });
171
+ }
172
+ }
173
+ return { ruleId: 'E1', findings, confidence: 'high' };
174
+ }
175
+
176
+ // ── E2 — unguarded risky operations (missing try/catch) ────────────────────
177
+ // New rule, not ported from architecture-toolkit -- confirmed no E2/E3/E4
178
+ // exist in their source or in this repo's own clean-code-analyzer/SKILL.md
179
+ // "not implemented" table before adding this (this file's own header
180
+ // requires that check before claiming coverage). Built to close ATF's
181
+ // R3 "error-handling coverage" gap (LADDER-ARCHITECTURE.md, 2026-08-22) --
182
+ // E1 already caught the swallowed-exception half; this catches the OTHER
183
+ // real defect in the same chapter, an operation with no error handling at
184
+ // all. Conservative on purpose: `await` and a small named list of
185
+ // known-throwing sync calls, not every function call.
186
+ export function e2UnguardedRiskyOps(unit) {
187
+ const findings = [];
188
+ for (const m of unit.members) {
189
+ for (const op of m.unguardedRiskyOps ?? []) {
190
+ findings.push({ location: loc(unit, m.name, op.line), detail: `unguarded ${op.kind} — no enclosing try/catch` });
191
+ }
192
+ }
193
+ return { ruleId: 'E2', findings, confidence: 'high' };
194
+ }
195
+
196
+ // ── G9 — dead code (two independent halves) ────────────────────────────────
197
+ // architecture-toolkit's ACTUAL G9 (code-smell-validator.ts:115,
198
+ // `/if\s*\(\s*false\s*\)|if\s*\(\s*true\s*\)/`) is constant-conditional
199
+ // UNREACHABLE code — a different sub-smell than the unused-EXPORT dead code
200
+ // this repo's own clean-code-analyzer/SKILL.md already specced under the
201
+ // same G9 id. Both are legitimate readings of Clean Code's G9 "Dead Code"
202
+ // chapter, so this ships BOTH:
203
+ // - unreachable half: `deadConditionals` (if(true)/if(false)/while(false)),
204
+ // computed per-member in typescript.mjs, always measured.
205
+ // - unused-export half: `deadExportsFacts`, computed by the OPTIONAL
206
+ // plugin.deadExportsOf(filePath, projectFilePaths) — a REAL cross-file
207
+ // reference-graph walk (ts-morph findReferencesAsNodes), not a text grep.
208
+ // Pass `[]` (the default) when that scan hasn't been run; this function
209
+ // never treats "wasn't scanned" as "found nothing" — confidence drops to
210
+ // 'medium' and says so, rather than silently reporting zero findings for
211
+ // unmeasured evidence.
212
+ //
213
+ // POSITIVE CONTROL is the caller's responsibility (see
214
+ // scripts/clean-code-score.mjs): before trusting any `referenceCount === 0`
215
+ // finding from a `deadExportsOf` scan, confirm a KNOWN-used export in the
216
+ // same scan comes back non-zero. A scan that returns zero for everything is
217
+ // broken, not a clean project.
218
+ export function g9DeadCode(unit, deadExportsFacts = []) {
219
+ const findings = [];
220
+ for (const m of unit.members) {
221
+ for (const dc of m.deadConditionals ?? []) {
222
+ findings.push({ location: loc(unit, m.name, dc.line), detail: `unreachable code — constant-conditional '${dc.kind}' never takes the live branch` });
223
+ }
224
+ }
225
+ for (const f of deadExportsFacts) {
226
+ if (f.referenceCount === 0) {
227
+ findings.push({ location: loc(unit, f.name, f.line), detail: `exported '${f.name}' has zero reference sites anywhere in the scanned project — dead export` });
228
+ }
229
+ // referenceCount === -1 ("declaration kind unsupported by the reference
230
+ // finder") is deliberately NOT reported as a finding — see DeadExportFact
231
+ // in language-plugin.mjs. It is neither used nor unused; it is unmeasured.
232
+ }
233
+ return { ruleId: 'G9', findings, confidence: deadExportsFacts.length ? 'high' : 'medium' };
234
+ }
235
+
236
+ /**
237
+ * @param {import('./language-plugin.mjs').NormalizedUnit} unit
238
+ * @param {import('./language-plugin.mjs').DeadExportFact[]|null} deadExportsFacts
239
+ * Pass the result of `plugin.deadExportsOf(filePath, projectFilePaths)` when
240
+ * available; omit (or pass `null`) to score everything except G9's
241
+ * unused-export half, which then reports at 'medium' confidence using only
242
+ * its unreachable-code half.
243
+ */
244
+ export function cleanCodeScore(unit, deadExportsFacts = null) {
245
+ const rules = {
246
+ n1: n1CrypticNames(unit),
247
+ n2: n2MeaninglessNames(unit),
248
+ n4: n4MagicNumbers(unit),
249
+ n7: n7GenericNames(unit),
250
+ f1: f1LongMethods(unit),
251
+ f2: f2TooManyParams(unit),
252
+ e1: e1EmptyCatchBlocks(unit),
253
+ e2: e2UnguardedRiskyOps(unit),
254
+ g9: g9DeadCode(unit, deadExportsFacts ?? []),
255
+ };
256
+ const totalFindings = Object.values(rules).reduce((n, r) => n + r.findings.length, 0);
257
+ return { unit: unit.name, kind: unit.kind, rules, totalFindings, notImplemented: NOT_IMPLEMENTED };
258
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * G5 — Duplicate Code detection via token-shingle Rabin-Karp matching.
3
+ *
4
+ * This is the rule the original Clean Code port (2026-08-20, commit e159ada)
5
+ * incorrectly filed as "needs judgment, not mechanical" — see
6
+ * clean-code-scoring.mjs's own header, and skills/clean-code-analyzer/SKILL.md's
7
+ * "Not implemented" table, row G5: "a per-unit, per-file scorer is the wrong
8
+ * shape for a cross-file structural-clone problem." That reasoning is correct
9
+ * about WHY clean-code-scoring.mjs's per-unit shape can't do this — it is
10
+ * wrong that the problem itself needs judgment. It has a well-established
11
+ * deterministic solution: token-based clone detection via Rabin-Karp rolling
12
+ * hash, the same algorithm jscpd (github.com/kucherenko/jscpd) and PMD's CPD
13
+ * (pmd.github.io/pmd/pmd_userdocs_cpd.html) both use in production, 20+ years
14
+ * of prior art. This file is a real, independent implementation of that
15
+ * algorithm — not a wrapper around either tool — operating at REPO scope
16
+ * (across every file passed in), not per-file, which is why it lives here as
17
+ * its own module rather than as an eighth rule inside clean-code-scoring.mjs's
18
+ * per-unit contract.
19
+ *
20
+ * No ts-morph, no NormalizedUnit dependency — plain text tokenization, same
21
+ * discipline as package-metrics.mjs and architecture-scoring.mjs. Works on
22
+ * any language whose comments/whitespace can be stripped by a language-aware
23
+ * comment-stripping table (below); token SHAPE (identifiers, literals,
24
+ * operators) is language-agnostic once comments are stripped.
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs';
28
+
29
+ /** Comment-stripping regexes, keyed by extension. Line + block comments only —
30
+ * string/template literals are deliberately NOT stripped, since a literal's
31
+ * content is real duplicated text if it repeats. */
32
+ const COMMENT_STRIP = {
33
+ '.js': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
34
+ '.mjs': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
35
+ '.cjs': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
36
+ '.ts': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
37
+ '.tsx': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
38
+ '.jsx': [/\/\/.*$/gm, /\/\*[\s\S]*?\*\//g],
39
+ '.py': [/#.*$/gm],
40
+ };
41
+
42
+ /** Token pattern: identifiers/keywords, numbers, string literals (as one
43
+ * opaque token — content ignored, only "a string literal was here" matters,
44
+ * matching CPD's own "ignore literals" default OFF-by-default behavior; we
45
+ * keep literal content since a repeated literal string IS real duplication),
46
+ * and single-char operators/punctuation. */
47
+ const TOKEN_RE = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|[A-Za-z_$][A-Za-z0-9_$]*|\d+(?:\.\d+)?|[{}()[\];,.<>=+\-*/%!&|^~?:]/g;
48
+
49
+ /**
50
+ * Tokenize one file's text into {token, line}[] — comments stripped, string
51
+ * literals collapsed to their raw text (not a placeholder — real content
52
+ * still counts as duplication if it repeats), whitespace/newlines used only
53
+ * to track line numbers, not as tokens themselves.
54
+ */
55
+ export function tokenizeSource(text, ext) {
56
+ const strips = COMMENT_STRIP[ext] || [];
57
+ let stripped = text;
58
+ for (const re of strips) stripped = stripped.replace(re, (m) => m.replace(/[^\n]/g, ' '));
59
+
60
+ const tokens = [];
61
+ let line = 1;
62
+ let lastIndex = 0;
63
+ TOKEN_RE.lastIndex = 0;
64
+ let match;
65
+ while ((match = TOKEN_RE.exec(stripped)) !== null) {
66
+ // count newlines between lastIndex and match.index to keep line tracking accurate
67
+ for (let i = lastIndex; i < match.index; i++) if (stripped[i] === '\n') line++;
68
+ lastIndex = match.index;
69
+ tokens.push({ token: match[0], line });
70
+ }
71
+ return tokens;
72
+ }
73
+
74
+ /**
75
+ * Rabin-Karp rolling hash over a token-shingle window of length k.
76
+ * Base/modulus chosen to keep hashes in safe-integer range for k up to ~200.
77
+ */
78
+ const BASE = 257n;
79
+ const MOD = 1_000_000_007n;
80
+
81
+ function hashWindow(tokens, start, k) {
82
+ let h = 0n;
83
+ for (let i = 0; i < k; i++) {
84
+ h = (h * BASE + BigInt(simpleStringHash(tokens[start + i].token))) % MOD;
85
+ }
86
+ return h.toString();
87
+ }
88
+
89
+ function simpleStringHash(s) {
90
+ let h = 0;
91
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
92
+ return h;
93
+ }
94
+
95
+ /**
96
+ * Rolling update: given the hash of window [start, start+k), compute the
97
+ * hash of window [start+1, start+1+k) in O(1) using the outgoing/incoming
98
+ * token — the actual Rabin-Karp technique (not re-hashing the whole window
99
+ * each slide, which would make this O(n*k) instead of O(n)).
100
+ */
101
+ function rollHash(prevHash, outgoingTok, incomingTok, k) {
102
+ const highOrder = powMod(BASE, BigInt(k - 1));
103
+ let h = (prevHash - BigInt(simpleStringHash(outgoingTok)) * highOrder % MOD + MOD * MOD) % MOD;
104
+ h = (h * BASE + BigInt(simpleStringHash(incomingTok))) % MOD;
105
+ return h;
106
+ }
107
+
108
+ function powMod(base, exp) {
109
+ let r = 1n, b = base % MOD, e = exp;
110
+ while (e > 0n) {
111
+ if (e & 1n) r = (r * b) % MOD;
112
+ b = (b * b) % MOD;
113
+ e >>= 1n;
114
+ }
115
+ return r;
116
+ }
117
+
118
+ /**
119
+ * Find duplicate token-shingle windows of length >= minTokens across all
120
+ * supplied files. Matches jscpd/CPD's default shape: a "duplicate" is
121
+ * >= minTokens contiguous matching tokens appearing in 2+ distinct
122
+ * (file, position) locations, with overlapping windows from the SAME
123
+ * location merged into one finding rather than reported once per slide.
124
+ *
125
+ * @param {{file: string, text: string, ext: string}[]} files
126
+ * @param {number} minTokens - default 50, matches CPD's default token threshold
127
+ * @returns {{duplicates: Array<{tokenCount: number, occurrences: Array<{file:string, startLine:number, endLine:number}>}>}}
128
+ */
129
+ export function findDuplicates(files, minTokens = 50) {
130
+ const fileTokens = files.map((f) => ({
131
+ file: f.file,
132
+ tokens: tokenizeSource(f.text, f.ext),
133
+ })).filter((f) => f.tokens.length >= minTokens);
134
+
135
+ // hash -> [{fileIdx, start}]
136
+ const hashIndex = new Map();
137
+
138
+ for (let fi = 0; fi < fileTokens.length; fi++) {
139
+ const { tokens } = fileTokens[fi];
140
+ if (tokens.length < minTokens) continue;
141
+ let h = hashWindow(tokens, 0, minTokens);
142
+ recordHash(hashIndex, h, fi, 0);
143
+ for (let start = 1; start <= tokens.length - minTokens; start++) {
144
+ h = rollHash(BigInt(h), tokens[start - 1].token, tokens[start + minTokens - 1].token, minTokens).toString();
145
+ recordHash(hashIndex, h, fi, start);
146
+ }
147
+ }
148
+
149
+ // Every hash bucket with >=2 occurrences is a matching WINDOW pair, not a
150
+ // duplicate BLOCK yet — a real duplicated region of length L > minTokens
151
+ // produces (L - minTokens + 1) consecutive matching window-pairs, one per
152
+ // slide position, all at the SAME offset between the two locations. The
153
+ // real algorithm (same technique CPD/jscpd use) is to pair up occurrences,
154
+ // group pairs by (fileA, fileB, start diff constant), then merge
155
+ // consecutive starts within each group into ONE maximal match — that
156
+ // collapses N sliding-window hits into 1 real finding.
157
+ //
158
+ // pairKey -> { fileA, fileB, starts: Set<number> } where starts holds the
159
+ // fileA-side start of every matching window against a fixed fileB anchor
160
+ // at the SAME relative offset.
161
+ const pairGroups = new Map();
162
+ for (const [, occ] of hashIndex) {
163
+ if (occ.length < 2) continue;
164
+ const sorted = occ.slice().sort((a, b) => a.fileIdx - b.fileIdx || a.start - b.start);
165
+ for (let i = 0; i < sorted.length; i++) {
166
+ for (let j = i + 1; j < sorted.length; j++) {
167
+ const a = sorted[i], b = sorted[j];
168
+ if (a.fileIdx === b.fileIdx && a.start === b.start) continue;
169
+ const key = `${a.fileIdx}:${b.fileIdx}:${b.start - a.start}`;
170
+ if (!pairGroups.has(key)) pairGroups.set(key, { fileA: a.fileIdx, fileB: b.fileIdx, delta: b.start - a.start, starts: new Set() });
171
+ pairGroups.get(key).starts.add(a.start);
172
+ }
173
+ }
174
+ }
175
+
176
+ // Within each (fileA, fileB, delta) group, merge consecutive fileA starts
177
+ // into maximal runs. A run [s, e] of consecutive starts (step 1) means a
178
+ // real duplicated block spanning tokens [s, e + minTokens - 1].
179
+ const rawFindings = [];
180
+ for (const { fileA, fileB, delta, starts } of pairGroups.values()) {
181
+ const sortedStarts = [...starts].sort((a, b) => a - b);
182
+ let runStart = sortedStarts[0];
183
+ let prev = sortedStarts[0];
184
+ const flush = (end) => {
185
+ // Skip a same-file, zero-offset-adjacent run entirely inside itself
186
+ // (fileA === fileB, delta < minTokens means the two "occurrences" are
187
+ // really the same physical block overlapping its own rolling window,
188
+ // not a second copy).
189
+ if (fileA === fileB && Math.abs(delta) < minTokens) return;
190
+ rawFindings.push({ fileA, fileB, aStart: runStart, aEnd: end, bStart: runStart + delta, bEnd: end + delta });
191
+ };
192
+ for (let i = 1; i < sortedStarts.length; i++) {
193
+ if (sortedStarts[i] === prev + 1) {
194
+ prev = sortedStarts[i];
195
+ continue;
196
+ }
197
+ flush(prev);
198
+ runStart = sortedStarts[i];
199
+ prev = sortedStarts[i];
200
+ }
201
+ flush(prev);
202
+ }
203
+
204
+ // Dedupe (a finding and its mirror-image (fileB,fileA,-delta) are the same
205
+ // physical duplicate reported twice) and convert to file:line.
206
+ const seen = new Set();
207
+ const duplicates = [];
208
+ for (const f of rawFindings) {
209
+ const tokenCount = f.aEnd - f.aStart + minTokens;
210
+ const locA = { fileIdx: f.fileA, start: f.aStart, end: f.aEnd + minTokens - 1 };
211
+ const locB = { fileIdx: f.fileB, start: f.bStart, end: f.bEnd + minTokens - 1 };
212
+ const sig = [locA, locB].map((l) => `${l.fileIdx}:${l.start}:${l.end}`).sort().join('|');
213
+ if (seen.has(sig)) continue;
214
+ seen.add(sig);
215
+ const occurrences = [locA, locB].map((l) => {
216
+ const { file, tokens } = fileTokens[l.fileIdx];
217
+ return { file, startLine: tokens[l.start].line, endLine: tokens[Math.min(l.end, tokens.length - 1)].line };
218
+ });
219
+ duplicates.push({ tokenCount, occurrences });
220
+ }
221
+
222
+ // Sort deterministically: by first occurrence file, then line — required
223
+ // for ATF golden-capture (byte-identical output across runs).
224
+ for (const d of duplicates) {
225
+ d.occurrences.sort((a, b) => a.file.localeCompare(b.file) || a.startLine - b.startLine);
226
+ }
227
+ duplicates.sort((a, b) => {
228
+ const fa = a.occurrences[0], fb = b.occurrences[0];
229
+ return fa.file.localeCompare(fb.file) || fa.startLine - fb.startLine || b.tokenCount - a.tokenCount;
230
+ });
231
+
232
+ return { duplicates, filesScanned: fileTokens.length, minTokens };
233
+ }
234
+
235
+ function recordHash(index, hash, fileIdx, start) {
236
+ if (!index.has(hash)) index.set(hash, []);
237
+ index.get(hash).push({ fileIdx, start });
238
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The language-plugin contract for solid-score.
3
+ *
4
+ * Everything downstream of this file — the five SOLID scorers and the Clean
5
+ * Architecture boundary check — reads ONLY a `NormalizedUnit`. No scoring
6
+ * function may import ts-morph, a Python AST module, or any language-specific
7
+ * parser. That boundary is the whole point: the first version of this tool
8
+ * had `srp()`/`ocp()`/`lsp()`/`isp()`/`dip()` walking ts-morph `SyntaxKind`
9
+ * nodes directly, which is the exact god-object-orchestrator shape this
10
+ * scorer exists to catch, just relocated into the scorer itself. A Python
11
+ * plugin must be addable by writing ONE new file that implements
12
+ * `extractUnits`, touching nothing under scoring/.
13
+ *
14
+ * @typedef {object} NormalizedMember
15
+ * @property {string} name
16
+ * @property {number} paramCount
17
+ * @property {string[]} fieldAccess - names of instance-scope fields this member reads/writes (e.g. `this.x`, `self.x`)
18
+ * @property {string[]} calls - names this member calls (bare names, receiver stripped)
19
+ * @property {number} branchHits - switch-cases + instanceof/typeof-equivalent checks + if-else-if chain links
20
+ * @property {boolean} isPublic
21
+ * @property {object|null} override - present only if this member overrides a base-class member
22
+ * @property {number} override.baseParamCount
23
+ * @property {boolean} override.callsSuper
24
+ * @property {string|null} override.returnType
25
+ * @property {string|null} override.baseReturnType
26
+ * @property {number} statementCount - total statement-kind descendants (flattened, all nesting depths) — clean-code F1 (long method)
27
+ * @property {{name: string, line: number}[]} declaredNames - parameter + local `let`/`const`/`var` bindings with simple (non-destructured) names — clean-code N1/N2 (naming)
28
+ * @property {{value: number, line: number}[]} magicNumbers - numeric literals other than 0/1/-1 that are NOT the direct initializer of a `const` variable declaration or an enum member — clean-code N4
29
+ * @property {{line: number}[]} emptyCatches - `catch` blocks with zero statements — clean-code E1
30
+ * @property {{line: number, kind: 'if-true'|'if-false'|'while-false'}[]} deadConditionals - `if(true)`/`if(false)`/`while(false)` constant-conditional branches — clean-code G9 (unreachable-code half)
31
+ * @property {{line: number, kind: string}[]} unguardedRiskyOps - `await` expressions and named risky sync calls (JSON.parse, fs.readFileSync/writeFileSync) with no enclosing try-block — clean-code E2 (new rule, not ported from architecture-toolkit). ADDED for the ATF ladder's R3 error-handling-coverage gap (2026-08-22), additive-only.
32
+ * @property {{text: string, line: number}[]} statementTexts - whitespace-normalized text of every statement-kind descendant (same STATEMENT_KINDS as statementCount), filtered to text longer than 10 chars — refactoring consolidate-duplicate-code. ADDED for refactoring-scoring.mjs (2026-08-20), additive-only.
33
+ * @property {{line: number}[]} nullChecks - `if` statements whose condition contains a top-level `=== null` / `!== null` comparison, one entry per matching `if` (deduplicated per-if, not per-comparison) — refactoring null-object-transform. ADDED for refactoring-scoring.mjs (2026-08-20), additive-only.
34
+ * @property {{line: number, hasBehaviorCall: boolean, hasTypeCreation: boolean}[]} switchStatements - every `switch` statement, flagged for behavior-dispatch shape (case bodies call something matching /calculate|process|validate|format/i — refactoring strategy-transform) and type-based-construction shape (discriminant text contains "type" AND the switch body contains a `new X()` — refactoring factory-transform). ADDED for refactoring-scoring.mjs (2026-08-20), additive-only.
35
+ * @property {{line: number, length: number}[]} complexConditionals - `if` statement conditions whose source text is >= 50 chars — refactoring decompose-conditional. ADDED for refactoring-scoring.mjs (2026-08-20), additive-only.
36
+ * @property {number|null} switchBehaviorCallLine - line of the first `switch` statement in this member whose text matches /calculate|process|execute|validate|format/i — pattern-advisor Strategy signal (behavioral-pattern-analyzer.ts:44's exact word list; a superset of `switchStatements[].hasBehaviorCall`'s list above, which omits "execute" and is ported from a different toolkit file — kept as a separate fact rather than edited in place). `null` if no switch in this member matches. ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
37
+ * @property {string[]} constructorNewCallTargets - ALL `new X(...)` target names anywhere in this member's body, UNFILTERED (includes stdlib) — pattern-advisor Factory Method "scattered instantiation" signal (creational-pattern-analyzer.ts:68-83: >5 total, >3 unique). Distinct from `concreteInstantiations` on NormalizedUnit, which is local-classes-only and would under-count this check. ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
38
+ * @property {number|null} conditionalFeatureCallLine - line of the first `if` statement in this member whose THEN block calls a function whose bare name matches /wrap|add|extend|enhance/i — pattern-advisor Decorator signal (structural-pattern-analyzer.ts:39-43). `null` if none. ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
39
+ * @property {number} deepChainCallCount - count of call expressions shaped `a.b.c(...)` (callee is a property access whose own receiver is itself a property access) — pattern-advisor Facade signal (structural-pattern-analyzer.ts:104-105, threshold >5). ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
40
+ * @property {string[]} calleeNames - bare trailing call name (receiver stripped entirely, not just `this.`) for every call expression in this member — pattern-advisor Adapter/Observer/Command/Template Method keyword scans. Distinct from `calls` above, which keeps `this.`-stripped but otherwise full receiver-qualified text. ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
41
+ *
42
+ * @typedef {object} NormalizedUnit
43
+ * @property {string} name
44
+ * @property {'class'|'module'} kind
45
+ * @property {NormalizedMember[]} members
46
+ * @property {boolean} hasBaseClass - true if this unit extends/inherits from something
47
+ * @property {number} concreteInstantiations - count of `new <LocalClass>()`-equivalent constructions of project-local types
48
+ * @property {number} totalDependencies - concreteInstantiations + count of distinct imported/injected names used
49
+ * @property {string[]} staticPropertyNames - names of static properties declared directly on this class; always `[]` for a module — pattern-advisor Singleton signal (creational-pattern-analyzer.ts:124-146). ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
50
+ * @property {boolean} hasGetInstanceMethod - true if any member (class method or top-level module function) is named exactly 'getInstance' — pattern-advisor Singleton signal, paired with `staticPropertyNames`. ADDED for pattern-scoring.mjs (2026-08-20), additive-only.
51
+ *
52
+ * @typedef {object} DeadExportFact
53
+ * @property {string} name
54
+ * @property {number} line
55
+ * @property {number} referenceCount - reference sites found anywhere in the scanned project, EXCLUDING the declaration's own name occurrence. -1 means "declaration kind not supported by the reference finder", never treat -1 as zero.
56
+ * @property {string} kind - ts-morph declaration kind name, for diagnostics
57
+ *
58
+ * @typedef {object} LanguagePlugin
59
+ * @property {string} id
60
+ * @property {(filePath: string) => boolean} canHandle
61
+ * @property {(filePath: string, sourceText?: string) => NormalizedUnit[]} extractUnits
62
+ * @property {(filePath: string, sourceText?: string) => string[]} importsOf - module specifiers this file imports, for the boundary check
63
+ * @property {(filePath: string, projectFilePaths?: string[]) => DeadExportFact[]} [deadExportsOf] - OPTIONAL. Cross-file reference count per exported symbol in `filePath`, resolved against the full set of `projectFilePaths` (a real reference-graph walk, not a text grep). Absent on a plugin that hasn't implemented it — a caller MUST treat a missing method as "G9 export-usage half unmeasured", never as "zero dead exports".
64
+ * @property {(filePath: string, exportName: string, projectFilePaths?: string[]) => {referenceCount: number, files: string[], kind: string|null}} [referenceSitesOf] - OPTIONAL. ADDED for refactoring-scoring.mjs (2026-08-20), additive-only. Same `findReferencesAsNodes()` walk as `deadExportsOf`, targeted at ONE named export, returning both the count and the deduplicated list of file paths that reference it (declaration's own occurrence excluded) — refactoring effort estimation's call-site-count and package-boundary-crossing criteria. `referenceCount: -1` means "declaration kind unsupported by the reference finder", never treat -1 as zero. Absent on a plugin that hasn't implemented it — a caller MUST treat a missing method as "effort unmeasured", never as "0 call sites".
65
+ */
66
+
67
+ /** @type {LanguagePlugin[]} */
68
+ const REGISTRY = [];
69
+
70
+ /** @param {LanguagePlugin} plugin */
71
+ export function registerPlugin(plugin) {
72
+ REGISTRY.push(plugin);
73
+ }
74
+
75
+ /** @returns {LanguagePlugin|null} */
76
+ export function pluginFor(filePath) {
77
+ return REGISTRY.find((p) => p.canHandle(filePath)) ?? null;
78
+ }
79
+
80
+ export function registeredPlugins() {
81
+ return [...REGISTRY];
82
+ }