@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,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
|
+
}
|