@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,672 @@
1
+ /**
2
+ * TypeScript/JavaScript language plugin — the Day-1 implementation of the
3
+ * language-plugin contract (see ../language-plugin.mjs). This is the ONLY
4
+ * file in the scorer allowed to import ts-morph or reason about its AST.
5
+ */
6
+
7
+ import { Project, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
8
+
9
+ const STDLIB_WHITELIST = new Set([
10
+ 'Map', 'Set', 'WeakMap', 'WeakSet', 'Array', 'Object', 'Date', 'Error',
11
+ 'TypeError', 'RangeError', 'RegExp', 'Promise', 'URL', 'URLSearchParams',
12
+ 'AbortController', 'Buffer', 'Headers', 'Request', 'Response',
13
+ ]);
14
+
15
+ function fieldsOf(m) {
16
+ return m.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
17
+ .filter((p) => p.getExpression().getKind() === SyntaxKind.ThisKeyword)
18
+ .map((p) => p.getName());
19
+ }
20
+
21
+ function callsOf(m) {
22
+ return m.getDescendantsOfKind(SyntaxKind.CallExpression)
23
+ .map((c) => c.getExpression().getText().replace(/^this\./, ''));
24
+ }
25
+
26
+ function branchHitsOf(m) {
27
+ let n = 0;
28
+ n += m.getDescendantsOfKind(SyntaxKind.SwitchStatement)
29
+ .reduce((s, sw) => s + sw.getClauses().filter((c) => c.getKind() === SyntaxKind.CaseClause).length, 0);
30
+ n += m.getDescendantsOfKind(SyntaxKind.BinaryExpression).filter((b) => b.getOperatorToken().getText() === 'instanceof').length;
31
+ n += m.getDescendantsOfKind(SyntaxKind.TypeOfExpression).length;
32
+ n += m.getDescendantsOfKind(SyntaxKind.IfStatement).filter((s) => s.getElseStatement()?.getKind() === SyntaxKind.IfStatement).length;
33
+ return n;
34
+ }
35
+
36
+ // ── Clean Code facts (N1/N2/N4/F1/E1/G9's unreachable-code half) ──────────
37
+ // Real detection logic ported from architecture-toolkit's src/agents/
38
+ // clean-code-analyzer/tools (MIT, github.com/OnSightTeam/architecture-toolkit,
39
+ // explicit reuse approval from the operator) — its own checks run whole-file
40
+ // text regexes against `code.match(...)` with an occurrence-count threshold,
41
+ // which is why it needs ">3 single-letter assignments" as noise suppression
42
+ // instead of just flagging one. Ours walks the real AST per declared binding,
43
+ // so context (a for-loop counter, a const-declared magic number) is known
44
+ // directly and every occurrence is its own finding — no threshold needed to
45
+ // separate signal from regex false-positives.
46
+
47
+ const STATEMENT_KINDS = [
48
+ SyntaxKind.ExpressionStatement, SyntaxKind.VariableStatement, SyntaxKind.IfStatement,
49
+ SyntaxKind.ForStatement, SyntaxKind.ForInStatement, SyntaxKind.ForOfStatement,
50
+ SyntaxKind.WhileStatement, SyntaxKind.DoStatement, SyntaxKind.SwitchStatement,
51
+ SyntaxKind.ReturnStatement, SyntaxKind.ThrowStatement, SyntaxKind.TryStatement,
52
+ SyntaxKind.BreakStatement, SyntaxKind.ContinueStatement, SyntaxKind.LabeledStatement,
53
+ ];
54
+ // F1 (long method, threshold 20) — architecture-toolkit's own real threshold
55
+ // at src/agents/clean-code-analyzer/tools/function-validator.ts:52
56
+ // (`if (avgLinesPerFunction > 20)`) independently lands on the same number
57
+ // our task spec names; corroborated, not just copied.
58
+ function statementCountOf(m) {
59
+ return STATEMENT_KINDS.reduce((n, k) => n + m.getDescendantsOfKind(k).length, 0);
60
+ }
61
+
62
+ // N1/N2 raw material — every simple (non-destructured) local binding name:
63
+ // parameters plus `let`/`const`/`var` declarations, with its declaration
64
+ // line. Destructuring patterns are skipped (getName() on a binding pattern
65
+ // isn't a single identifier) rather than mis-flagged.
66
+ function declaredNamesOf(paramsNode, bodyNode) {
67
+ const names = [];
68
+ for (const p of paramsNode.getParameters()) {
69
+ if (p.getNameNode().getKind() === SyntaxKind.Identifier) names.push({ name: p.getName(), line: p.getStartLineNumber() });
70
+ }
71
+ for (const decl of bodyNode.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
72
+ if (decl.getNameNode().getKind() === SyntaxKind.Identifier) names.push({ name: decl.getName(), line: decl.getStartLineNumber() });
73
+ }
74
+ return names;
75
+ }
76
+
77
+ // N4 magic numbers — numeric literal other than 0/1/-1, excluded only when
78
+ // it is the DIRECT initializer of a `const` variable declaration or an enum
79
+ // member (the two AST-visible "this number has already been named" shapes).
80
+ function magicNumbersOf(m) {
81
+ const found = [];
82
+ for (const lit of m.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
83
+ let node = lit;
84
+ let value = Number(lit.getText());
85
+ const parent = lit.getParent();
86
+ if (parent?.getKind() === SyntaxKind.PrefixUnaryExpression && parent.getOperatorToken() === SyntaxKind.MinusToken) {
87
+ value = -value;
88
+ node = parent;
89
+ }
90
+ if (value === 0 || value === 1 || value === -1) continue;
91
+
92
+ const initParent = node.getParent();
93
+ let excluded = false;
94
+ if (initParent?.getKind() === SyntaxKind.VariableDeclaration && initParent.getInitializer() === node) {
95
+ const declList = initParent.getParent();
96
+ if (declList?.getKind() === SyntaxKind.VariableDeclarationList && declList.getDeclarationKind() === VariableDeclarationKind.Const) excluded = true;
97
+ }
98
+ if (initParent?.getKind() === SyntaxKind.EnumMember) excluded = true;
99
+ if (excluded) continue;
100
+
101
+ found.push({ value, line: lit.getStartLineNumber() });
102
+ }
103
+ return found;
104
+ }
105
+
106
+ // E1 empty catch blocks — real pattern is architecture-toolkit's
107
+ // src/agents/clean-code-analyzer/tools/code-smell-validator.ts:160
108
+ // (`/catch\s*\([^)]+\)\s*{\s*}/i`). AST form: zero statements in the block —
109
+ // strictly stronger than their regex, which a `catch(e) { /* ignored */ }`
110
+ // comment-only block would NOT match (comment text isn't whitespace) but is
111
+ // exactly as empty in intent.
112
+ function emptyCatchesOf(m) {
113
+ const found = [];
114
+ for (const cc of m.getDescendantsOfKind(SyntaxKind.CatchClause)) {
115
+ if (cc.getBlock().getStatements().length === 0) found.push({ line: cc.getStartLineNumber() });
116
+ }
117
+ return found;
118
+ }
119
+
120
+ // E2 unguarded risky operations — new rule, not ported from architecture-
121
+ // toolkit (checked: no E2/E3/E4 exist anywhere in that source or in this
122
+ // repo's own clean-code-analyzer/SKILL.md "not implemented" table — this is
123
+ // genuinely new scope, not previously deferred with a documented reason).
124
+ // Conservative by design: only `await` expressions and a small named list of
125
+ // known-throwing sync calls (JSON.parse, fs.readFileSync/writeFileSync)
126
+ // count as "risky" — flagging every function call as risky would swamp
127
+ // real findings in noise. "Guarded" means textually inside a TryStatement's
128
+ // TRY block specifically (not its catch/finally) — computed by walking each
129
+ // try block's own descendants first, so a risky op inside a catch/finally
130
+ // of one try but not wrapped by any try of its own still reports correctly.
131
+ const RISKY_SYNC_CALL_NAMES = ['JSON.parse', 'readFileSync', 'writeFileSync'];
132
+ function unguardedRiskyOpsOf(m) {
133
+ const guarded = new Set();
134
+ for (const tryStmt of m.getDescendantsOfKind(SyntaxKind.TryStatement)) {
135
+ const tryBlock = tryStmt.getTryBlock();
136
+ for (const node of [
137
+ ...tryBlock.getDescendantsOfKind(SyntaxKind.AwaitExpression),
138
+ ...tryBlock.getDescendantsOfKind(SyntaxKind.CallExpression),
139
+ ]) {
140
+ guarded.add(node);
141
+ }
142
+ }
143
+ const found = [];
144
+ for (const awaitExpr of m.getDescendantsOfKind(SyntaxKind.AwaitExpression)) {
145
+ if (!guarded.has(awaitExpr)) found.push({ line: awaitExpr.getStartLineNumber(), kind: 'await' });
146
+ }
147
+ for (const call of m.getDescendantsOfKind(SyntaxKind.CallExpression)) {
148
+ if (guarded.has(call)) continue;
149
+ const exprText = call.getExpression().getText();
150
+ if (RISKY_SYNC_CALL_NAMES.some((name) => exprText === name || exprText.endsWith(`.${name}`))) {
151
+ found.push({ line: call.getStartLineNumber(), kind: exprText });
152
+ }
153
+ }
154
+ return found;
155
+ }
156
+
157
+ // G9 unreachable-code half — architecture-toolkit's actual G9 implementation
158
+ // (src/agents/clean-code-analyzer/tools/code-smell-validator.ts:115,
159
+ // `/if\s*\(\s*false\s*\)|if\s*\(\s*true\s*\)/`) is constant-conditional dead
160
+ // code, NOT unused-export dead code — both are legitimate readings of
161
+ // Clean Code's G9 "Dead Code". We ship both: this AST half plus the
162
+ // cross-file export-usage half in `deadExportsOf` below.
163
+ function deadConditionalsOf(m) {
164
+ const found = [];
165
+ for (const ifStmt of m.getDescendantsOfKind(SyntaxKind.IfStatement)) {
166
+ const kind = ifStmt.getExpression().getKind();
167
+ if (kind === SyntaxKind.TrueKeyword) found.push({ line: ifStmt.getStartLineNumber(), kind: 'if-true' });
168
+ else if (kind === SyntaxKind.FalseKeyword) found.push({ line: ifStmt.getStartLineNumber(), kind: 'if-false' });
169
+ }
170
+ for (const whileStmt of m.getDescendantsOfKind(SyntaxKind.WhileStatement)) {
171
+ if (whileStmt.getExpression().getKind() === SyntaxKind.FalseKeyword) found.push({ line: whileStmt.getStartLineNumber(), kind: 'while-false' });
172
+ }
173
+ return found;
174
+ }
175
+
176
+ // ── Refactoring facts (extract-method/class/param-object/magic-number,
177
+ // consolidate-duplicate-code, decompose-conditional, strategy/factory/
178
+ // null-object transforms) — ADDED for refactoring-scoring.mjs (2026-08-20),
179
+ // additive-only. Real detection logic ported from architecture-toolkit's
180
+ // src/agents/pattern-refactoring-guide/tools/{refactoring-analyzer,
181
+ // code-smell-refactoring-guide,pattern-transformation-guide}.ts (MIT,
182
+ // github.com/OnSightTeam/architecture-toolkit) — same discipline as the
183
+ // Clean Code facts above: their checks are whole-file text regexes, ours
184
+ // walk the real AST so context is known directly. extract-method/extract-
185
+ // class/introduce-parameter-object reuse statementCount/members.length/
186
+ // paramCount already on the contract; magic-number reuses `magicNumbers`.
187
+ // Only the facts below are genuinely new.
188
+
189
+ // consolidate-duplicate-code raw material — architecture-toolkit's real
190
+ // check (code-smell-refactoring-guide.ts:41,49) is whole-FILE line-text
191
+ // repetition (trimmed line length > 10 chars, repeated > 3 times, > 3 such
192
+ // patterns). Ours captures the same shape (normalized statement text, same
193
+ // length filter) per real statement NODE instead of per raw source line, so
194
+ // a duplicate spread across multiple physical lines by formatting still
195
+ // matches. Grouping/threshold logic lives in refactoring-scoring.mjs, which
196
+ // is the pure-function layer — this only supplies the raw per-member facts.
197
+ function statementTextsOf(m) {
198
+ const found = [];
199
+ for (const k of STATEMENT_KINDS) {
200
+ for (const node of m.getDescendantsOfKind(k)) {
201
+ const text = node.getText().replace(/\s+/g, ' ').trim();
202
+ if (text.length > 10) found.push({ text, line: node.getStartLineNumber() });
203
+ }
204
+ }
205
+ return found;
206
+ }
207
+
208
+ // null-object-transform raw material — architecture-toolkit's real pattern
209
+ // (pattern-transformation-guide.ts:158, `/if\s*\(\s*\w+\s*[!=]==\s*null/g`)
210
+ // is a whole-file regex counting occurrences of the text shape. AST form:
211
+ // an `if` whose condition contains a top-level `===`/`!==` comparison
212
+ // against the `null` keyword, counted once per `if` (matches the regex's
213
+ // per-match count for the common one-comparison-per-if case).
214
+ function nullChecksOf(m) {
215
+ const found = [];
216
+ for (const ifStmt of m.getDescendantsOfKind(SyntaxKind.IfStatement)) {
217
+ const expr = ifStmt.getExpression();
218
+ // Walk every BinaryExpression in the condition, not just a lone
219
+ // top-level one — a chained `a === null && b === null` condition's own
220
+ // top-level node is the `&&` BinaryExpression, so a self-or-descendants
221
+ // walk is required to reach the `=== null` comparisons nested inside it.
222
+ const bins = [expr, ...expr.getDescendantsOfKind(SyntaxKind.BinaryExpression)].filter((n) => n.getKind() === SyntaxKind.BinaryExpression);
223
+ for (const b of bins) {
224
+ const op = b.getOperatorToken().getText();
225
+ if (op !== '===' && op !== '!==') continue;
226
+ if (b.getLeft().getKind() === SyntaxKind.NullKeyword || b.getRight().getKind() === SyntaxKind.NullKeyword) {
227
+ found.push({ line: ifStmt.getStartLineNumber() });
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ return found;
233
+ }
234
+
235
+ // strategy-transform / factory-transform raw material — architecture-
236
+ // toolkit's real patterns are whole-file regexes: switch-on-behavior
237
+ // (pattern-transformation-guide.ts:42, `/switch\s*\([^)]*\)\s*{[^}]*
238
+ // (calculate|process|validate|format)/i`) and switch-on-type-creating
239
+ // (`:100`, `/switch\s*\([^)]*type[^)]*\)\s*{[^}]*new\s+/i`). AST form: per
240
+ // real SwitchStatement node, test the same word list against the switch's
241
+ // own text (not the whole file), so a match is attributable to a specific
242
+ // switch instead of "somewhere in this file".
243
+ const STRATEGY_BEHAVIOR_RE = /(calculate|process|validate|format)/i;
244
+ function switchStatementsOf(m) {
245
+ const found = [];
246
+ for (const sw of m.getDescendantsOfKind(SyntaxKind.SwitchStatement)) {
247
+ const discriminantText = sw.getExpression().getText();
248
+ const swText = sw.getText();
249
+ found.push({
250
+ line: sw.getStartLineNumber(),
251
+ hasBehaviorCall: STRATEGY_BEHAVIOR_RE.test(swText),
252
+ hasTypeCreation: /type/i.test(discriminantText) && /\bnew\s+/.test(swText),
253
+ });
254
+ }
255
+ return found;
256
+ }
257
+
258
+ // decompose-conditional raw material — architecture-toolkit's real pattern
259
+ // (code-smell-refactoring-guide.ts:111, `/if\s*\([^)]{50,}\)/g`) is a
260
+ // whole-file regex on parenthesized condition length. AST form: the real
261
+ // `if` condition expression's own source text length, per `if`.
262
+ function complexConditionalsOf(m) {
263
+ const found = [];
264
+ for (const ifStmt of m.getDescendantsOfKind(SyntaxKind.IfStatement)) {
265
+ const condText = ifStmt.getExpression().getText();
266
+ if (condText.length >= 50) found.push({ line: ifStmt.getStartLineNumber(), length: condText.length });
267
+ }
268
+ return found;
269
+ }
270
+
271
+ // ── pattern-advisor facts (Factory Method/Builder/Singleton/Decorator/
272
+ // Adapter/Facade/Strategy/Observer/Command/Template Method) — ADDED for
273
+ // pattern-scoring.mjs (2026-08-20), additive-only. Real detection heuristics
274
+ // ported from architecture-toolkit's src/agents/pattern-advisor/tools/
275
+ // {creational,structural,behavioral}-pattern-analyzer.ts (MIT,
276
+ // github.com/OnSightTeam/architecture-toolkit, raw source fetched and read in
277
+ // full this task) — same discipline as every other fact block in this file:
278
+ // their checks are whole-file text regexes with no scoping to which
279
+ // switch/if/call the signal came from; ours walk the real AST per node.
280
+ //
281
+ // Factory Method's switch-on-type-constructs-new signal
282
+ // (creational-pattern-analyzer.ts:43-44, `/switch\s*\([^)]*type[^)]*\)\s*{
283
+ // [^}]*new\s+/i`) is the IDENTICAL regex shape already captured by
284
+ // `switchStatements[].hasTypeCreation` above (added for
285
+ // refactoring-scoring.mjs's factory-transform, itself ported from
286
+ // pattern-transformation-guide.ts:100 — the same regex, different toolkit
287
+ // tool) — pattern-scoring.mjs reuses that existing field directly rather
288
+ // than duplicating the switch walk. Strategy's behavior-call word list
289
+ // (behavioral-pattern-analyzer.ts:44) is `calculate|process|execute|
290
+ // validate|format` — a SUPERSET of `switchStatements[].hasBehaviorCall`'s
291
+ // list (`calculate|process|validate|format`, no "execute", ported from a
292
+ // different toolkit file, pattern-transformation-guide.ts:42) — rather than
293
+ // editing that shared field's regex (refactoring-scoring.mjs already
294
+ // consumes it), `switchBehaviorCallLine` below is a small separate fact with
295
+ // the exact word list this task's source citation requires.
296
+ const PATTERN_ADVISOR_BEHAVIOR_RE = /(calculate|process|execute|validate|format)/i;
297
+ function switchBehaviorCallLineOf(m) {
298
+ for (const sw of m.getDescendantsOfKind(SyntaxKind.SwitchStatement)) {
299
+ if (PATTERN_ADVISOR_BEHAVIOR_RE.test(sw.getText())) return sw.getStartLineNumber();
300
+ }
301
+ return null;
302
+ }
303
+
304
+ // Factory Method's "scattered instantiation" signal
305
+ // (creational-pattern-analyzer.ts:68-83): >5 total `new` calls, >3 unique
306
+ // constructor names. UNFILTERED (includes stdlib targets), matching the
307
+ // original's blind regex — deliberately NOT the same as `concreteInstantiations`
308
+ // (computed elsewhere in this file), which counts only project-local classes
309
+ // for DIP and would under-count this check.
310
+ function constructorNewCallTargetsOf(m) {
311
+ return m.getDescendantsOfKind(SyntaxKind.NewExpression).map((n) => n.getExpression().getText());
312
+ }
313
+
314
+ // Decorator's conditional-feature-addition signal
315
+ // (structural-pattern-analyzer.ts:39-43, `/if\s*\([^)]*\)\s*{[^}]*(wrap|add|
316
+ // extend|enhance)/i`): an if-statement whose THEN block calls a function
317
+ // named wrap/add/extend/enhance. AST form scopes the keyword search to the
318
+ // if's own consequent block, not "anywhere after an if in the file".
319
+ const FEATURE_CALL_RE = /(wrap|add|extend|enhance)/i;
320
+ function trailingCallName(callExpr) {
321
+ const expr = callExpr.getExpression();
322
+ return expr.getKind() === SyntaxKind.PropertyAccessExpression ? expr.getName() : expr.getText().replace(/^this\./, '');
323
+ }
324
+ function conditionalFeatureCallLineOf(m) {
325
+ for (const ifStmt of m.getDescendantsOfKind(SyntaxKind.IfStatement)) {
326
+ const then = ifStmt.getThenStatement();
327
+ if (!then) continue;
328
+ if (then.getDescendantsOfKind(SyntaxKind.CallExpression).some((c) => FEATURE_CALL_RE.test(trailingCallName(c)))) {
329
+ return ifStmt.getStartLineNumber();
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // Facade's complex-subsystem-interaction signal
336
+ // (structural-pattern-analyzer.ts:104-105, `code.match(/\w+\.\w+\.\w+\(/g)`,
337
+ // threshold >5): a call shaped `a.b.c(...)` — the callee is a property
338
+ // access whose OWN receiver is itself a property access (two dots before the
339
+ // paren). AST form counts real call expressions of that shape instead of a
340
+ // regex that also matches inside strings/comments.
341
+ function deepChainCallCountOf(m) {
342
+ let n = 0;
343
+ for (const c of m.getDescendantsOfKind(SyntaxKind.CallExpression)) {
344
+ const expr = c.getExpression();
345
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression && expr.getExpression().getKind() === SyntaxKind.PropertyAccessExpression) n++;
346
+ }
347
+ return n;
348
+ }
349
+
350
+ // General-purpose bare trailing call name for every call in this member —
351
+ // used by pattern-scoring.mjs for Adapter (convert/transform/adapt),
352
+ // Observer (notify/update/inform/broadcast), Command (undo/redo/history/
353
+ // queue/execute), and Template Method (initialize/process/cleanup) keyword
354
+ // scans. Distinct from the existing `calls` fact (which keeps the
355
+ // `this.`-stripped but otherwise full receiver-qualified text, e.g.
356
+ // "obj.notify") — these callers need just the trailing method/function name
357
+ // regardless of receiver, matching the original regexes'
358
+ // `\.(notify|update|...)\w*\(` shape (any receiver, bare trailing name).
359
+ function calleeNamesOf(m) {
360
+ return m.getDescendantsOfKind(SyntaxKind.CallExpression).map((c) => trailingCallName(c));
361
+ }
362
+
363
+ /**
364
+ * `cls.getMethods()` alone misses constructors, getters/setters, and
365
+ * arrow-function property members ("class ArrowGod { greet = () => {} }") —
366
+ * a mainstream TS/JS style. A class scored on methods alone with none
367
+ * present reads as `members.length === 0`, which scores 100 at 'high'
368
+ * confidence on SRP/ISP/DIP: a silent perfect score on a class the scorer
369
+ * never actually looked inside. Reviewer-confirmed live: an ArrowGod fixture
370
+ * with 3 constructor-injected deps and 3 arrow methods scored 100/100.
371
+ *
372
+ * @returns {{name: string, paramsNode: object, bodyNode: object, isPublicNode: object|null}[]}
373
+ */
374
+ function memberEntries(cls) {
375
+ const entries = [];
376
+ for (const c of cls.getConstructors()) entries.push({ name: 'constructor', paramsNode: c, bodyNode: c, isPublicNode: null });
377
+ for (const m of cls.getMethods()) entries.push({ name: m.getName(), paramsNode: m, bodyNode: m, isPublicNode: m });
378
+ for (const g of cls.getGetAccessors()) entries.push({ name: g.getName(), paramsNode: g, bodyNode: g, isPublicNode: g });
379
+ for (const s of cls.getSetAccessors()) entries.push({ name: s.getName(), paramsNode: s, bodyNode: s, isPublicNode: s });
380
+ for (const p of cls.getProperties()) {
381
+ const init = p.getInitializer();
382
+ if (init && (init.getKind() === SyntaxKind.ArrowFunction || init.getKind() === SyntaxKind.FunctionExpression)) {
383
+ entries.push({ name: p.getName(), paramsNode: init, bodyNode: init, isPublicNode: p });
384
+ }
385
+ }
386
+ return entries;
387
+ }
388
+
389
+ function normalizedMember({ name, paramsNode, bodyNode, isPublicNode }, { baseMethods = null } = {}) {
390
+ const isPublic = isPublicNode
391
+ ? !(isPublicNode.hasModifier?.(SyntaxKind.PrivateKeyword) || isPublicNode.hasModifier?.(SyntaxKind.ProtectedKeyword))
392
+ && !(name ?? '').startsWith('#')
393
+ : false; // constructor: not counted toward ISP's public behavioral surface
394
+
395
+ const base = name && baseMethods ? baseMethods.get(name) : null;
396
+ const override = base ? {
397
+ baseParamCount: base.getParameters().length,
398
+ // `super(...)` is a CallExpression whose own expression IS the super
399
+ // keyword; `super.method()` is a CallExpression whose expression is a
400
+ // PropertyAccessExpression on the super keyword. The original check
401
+ // matched only the first form, so `super.method(...)` — the ONLY form
402
+ // that appears in a real method override — never matched, penalizing
403
+ // every correctly-written override by one third of its LSP score.
404
+ callsSuper: bodyNode.getDescendantsOfKind(SyntaxKind.CallExpression).some((c) => {
405
+ const expr = c.getExpression();
406
+ if (expr.getKind() === SyntaxKind.SuperKeyword) return true;
407
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
408
+ return expr.getExpression().getKind() === SyntaxKind.SuperKeyword;
409
+ }
410
+ return false;
411
+ }),
412
+ returnType: bodyNode.getReturnTypeNode?.()?.getText() ?? null,
413
+ baseReturnType: base.getReturnTypeNode?.()?.getText() ?? null,
414
+ } : null;
415
+
416
+ return {
417
+ name: name ?? '(anonymous)',
418
+ paramCount: paramsNode.getParameters().length,
419
+ fieldAccess: fieldsOf(bodyNode),
420
+ calls: callsOf(bodyNode),
421
+ branchHits: branchHitsOf(bodyNode),
422
+ isPublic,
423
+ override,
424
+ statementCount: statementCountOf(bodyNode),
425
+ declaredNames: declaredNamesOf(paramsNode, bodyNode),
426
+ magicNumbers: magicNumbersOf(bodyNode),
427
+ emptyCatches: emptyCatchesOf(bodyNode),
428
+ deadConditionals: deadConditionalsOf(bodyNode),
429
+ unguardedRiskyOps: unguardedRiskyOpsOf(bodyNode),
430
+ // ADDED for refactoring-scoring.mjs (2026-08-20), additive-only:
431
+ statementTexts: statementTextsOf(bodyNode),
432
+ nullChecks: nullChecksOf(bodyNode),
433
+ switchStatements: switchStatementsOf(bodyNode),
434
+ complexConditionals: complexConditionalsOf(bodyNode),
435
+ // ADDED for pattern-scoring.mjs (2026-08-20), additive-only:
436
+ switchBehaviorCallLine: switchBehaviorCallLineOf(bodyNode),
437
+ constructorNewCallTargets: constructorNewCallTargetsOf(bodyNode),
438
+ conditionalFeatureCallLine: conditionalFeatureCallLineOf(bodyNode),
439
+ deepChainCallCount: deepChainCallCountOf(bodyNode),
440
+ calleeNames: calleeNamesOf(bodyNode),
441
+ };
442
+ }
443
+
444
+ /**
445
+ * @param {object|null} constructorNode the class's constructor, if it has one —
446
+ * a constructor-injected dependency is the actual subject of DIP and was
447
+ * never counted before because constructors were never visited at all.
448
+ */
449
+ function concreteDependencyCounts(scopeNode, sourceFile, localClassNames, constructorNode = null) {
450
+ const newExprs = scopeNode.getDescendantsOfKind(SyntaxKind.NewExpression);
451
+ let concrete = 0;
452
+ for (const n of newExprs) {
453
+ const name = n.getExpression().getText();
454
+ if (STDLIB_WHITELIST.has(name)) continue;
455
+ if (localClassNames.has(name)) concrete++;
456
+ }
457
+ const imports = sourceFile.getImportDeclarations().flatMap((d) => d.getNamedImports().map((i) => i.getName()));
458
+ const injected = constructorNode
459
+ ? constructorNode.getParameters().filter((p) => {
460
+ const t = p.getTypeNode()?.getText();
461
+ return t && !['string', 'number', 'boolean', 'any', 'unknown'].includes(t);
462
+ }).length
463
+ : 0;
464
+ return { concreteInstantiations: concrete, totalDependencies: concrete + imports.length + injected };
465
+ }
466
+
467
+ function unitsFromSourceFile(sourceFile) {
468
+ const localClassNames = new Set(
469
+ sourceFile.getProject().getSourceFiles().flatMap((sf) => sf.getClasses()).map((c) => c.getName()).filter(Boolean),
470
+ );
471
+
472
+ const classes = sourceFile.getClasses();
473
+ if (classes.length) {
474
+ return classes.map((cls) => {
475
+ const heritage = cls.getExtends();
476
+ const baseName = heritage?.getExpression().getText();
477
+ const baseDecl = baseName
478
+ ? sourceFile.getProject().getSourceFiles().flatMap((sf) => sf.getClasses()).find((c) => c.getName() === baseName)
479
+ : null;
480
+ const baseMethods = baseDecl ? new Map(baseDecl.getMethods().map((m) => [m.getName(), m])) : null;
481
+
482
+ const members = memberEntries(cls).map((e) => normalizedMember(e, { baseMethods }));
483
+ const [ctor] = cls.getConstructors();
484
+ const dep = concreteDependencyCounts(cls, sourceFile, localClassNames, ctor ?? null);
485
+ // Singleton signal (creational-pattern-analyzer.ts:124-146,
486
+ // `/private\s+static\s+instance|getInstance\s*\(\)/i`) — ADDED for
487
+ // pattern-scoring.mjs (2026-08-20), additive-only. A real static
488
+ // property (not a text match) plus a real method named 'getInstance'.
489
+ const staticPropertyNames = cls.getProperties().filter((p) => p.isStatic()).map((p) => p.getName());
490
+ return {
491
+ name: cls.getName() ?? '(anonymous)', kind: 'class', members,
492
+ hasBaseClass: Boolean(heritage), ...dep,
493
+ staticPropertyNames,
494
+ hasGetInstanceMethod: members.some((mm) => mm.name === 'getInstance'),
495
+ };
496
+ });
497
+ }
498
+
499
+ // ALL top-level functions, not just exported ones. A library module's
500
+ // unexported helpers are implementation detail another module can't see —
501
+ // filtering to exports made sense there. A SCRIPT (tools/*.mjs, a CLI) has
502
+ // no consumers importing it at all; its real logic routinely lives in
503
+ // unexported helpers plus an unexported main(). The old filter scored these
504
+ // as `[]` — not a low score, no measurement whatsoever — for every script
505
+ // in a codebase. Confirmed live: 5 of 6 files in rdc-harness's tools/
506
+ // scored zero units this way; only the one file with an exported function
507
+ // was measured at all.
508
+ const fns = sourceFile.getFunctions();
509
+
510
+ // Top-level `const x = (...) => {}` / `const x = function() {}` — a
511
+ // FunctionDeclaration query alone never sees these; same blind spot as
512
+ // class arrow-property methods, one level up. `tools/ladder.mjs`'s entire
513
+ // helper surface (`arg`, `flagPresent`, …) is written this way and scored
514
+ // zero units without this.
515
+ const arrowFns = [];
516
+ for (const stmt of sourceFile.getVariableStatements()) {
517
+ for (const decl of stmt.getDeclarations()) {
518
+ const init = decl.getInitializer();
519
+ if (init && (init.getKind() === SyntaxKind.ArrowFunction || init.getKind() === SyntaxKind.FunctionExpression)) {
520
+ arrowFns.push({ name: decl.getName(), node: init, exported: stmt.isExported() });
521
+ }
522
+ }
523
+ }
524
+
525
+ if (!fns.length && !arrowFns.length) return [];
526
+ const members = [
527
+ ...fns.map((f) => normalizedMember({
528
+ name: f.getName() ?? '(anonymous)', paramsNode: f, bodyNode: f,
529
+ // ISP still means "public surface" — an exported function is genuinely
530
+ // public API; an unexported script helper is not, even though it's
531
+ // scored for SRP/OCP/LSP/DIP the same as everything else.
532
+ isPublicNode: f.isExported() ? f : null,
533
+ })),
534
+ ...arrowFns.map(({ name, node, exported }) => normalizedMember({
535
+ name, paramsNode: node, bodyNode: node, isPublicNode: exported ? node : null,
536
+ })),
537
+ ];
538
+ const dep = concreteDependencyCounts(sourceFile, sourceFile, localClassNames);
539
+ // ADDED for pattern-scoring.mjs (2026-08-20), additive-only — a module has
540
+ // no static properties; hasGetInstanceMethod still checked for a top-level
541
+ // `getInstance` function, the module-shaped analog of the class case above.
542
+ return [{
543
+ name: sourceFile.getBaseName(), kind: 'module', members, hasBaseClass: false, ...dep,
544
+ staticPropertyNames: [],
545
+ hasGetInstanceMethod: members.some((mm) => mm.name === 'getInstance'),
546
+ }];
547
+ }
548
+
549
+ const projectCache = new Map();
550
+ // One shared project per process keeps cross-file base-class resolution (and,
551
+ // below, cross-file dead-export reference resolution) working without
552
+ // re-parsing the whole tree per file.
553
+ function sharedProject() {
554
+ if (!projectCache.has('shared')) {
555
+ // `allowJs: true` is REQUIRED for the type-checker to bind exports on a
556
+ // .mjs/.js file at all — without it `sourceFile.getExportedDeclarations()`
557
+ // / `.getExportSymbols()` silently return an empty result for every plain
558
+ // JS file (confirmed live: every export in this very package's own .mjs
559
+ // files scanned as zero until this option was added). Every OTHER
560
+ // extraction path in this file (extractUnits, importsOf) is purely
561
+ // syntactic — `f.isExported()`, `getFunctions()`, `getImportDeclarations()`
562
+ // — and never needed the checker, which is why this bug shipped invisibly
563
+ // until deadExportsOf (the first checker-backed feature) was added.
564
+ projectCache.set('shared', new Project({ skipAddingFilesFromTsConfig: true, compilerOptions: { allowJs: true } }));
565
+ }
566
+ return projectCache.get('shared');
567
+ }
568
+ function projectFor(filePath) {
569
+ const project = sharedProject();
570
+ const existing = project.getSourceFile(filePath);
571
+ return existing ? { project, sourceFile: existing } : { project, sourceFile: project.addSourceFileAtPath(filePath) };
572
+ }
573
+
574
+ /**
575
+ * G9 export-usage half. A REAL reference-graph walk via ts-morph's own
576
+ * `findReferencesAsNodes()` (language-service-backed, resolves imports/
577
+ * re-exports/aliases) — not a text grep, which would count a same-named
578
+ * local variable in an unrelated file as a "use" and miss a renamed import.
579
+ *
580
+ * `projectFilePaths` MUST include every file a real usage could live in
581
+ * (tests included — a symbol only called from a test is still used). A
582
+ * name matching zero files in that set is reported at `referenceCount: 0`,
583
+ * same as a name with no callers at all — the caller is responsible for
584
+ * having actually scanned the whole project, per the positive-control rule
585
+ * in clean-code-scoring.mjs's dead-code check.
586
+ */
587
+ export function deadExportsOf(filePath, projectFilePaths = []) {
588
+ const project = sharedProject();
589
+ for (const p of new Set([filePath, ...projectFilePaths])) {
590
+ if (!project.getSourceFile(p)) {
591
+ try { project.addSourceFileAtPath(p); } catch { /* unreadable/binary — not a TS/JS file, skip */ }
592
+ }
593
+ }
594
+ const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath);
595
+ const results = [];
596
+ for (const [name, decls] of sourceFile.getExportedDeclarations()) {
597
+ const decl = decls[0];
598
+ if (!decl) continue;
599
+ const referable = typeof decl.findReferencesAsNodes === 'function' ? decl : (decl.getNameNode?.() ?? null);
600
+ if (!referable || typeof referable.findReferencesAsNodes !== 'function') {
601
+ results.push({ name, line: decl.getStartLineNumber(), referenceCount: -1, kind: decl.getKindName() });
602
+ continue;
603
+ }
604
+ let refs = [];
605
+ try { refs = referable.findReferencesAsNodes(); } catch { refs = []; }
606
+ // `findReferencesAsNodes()` includes the declaration's own name
607
+ // occurrence — exclude exactly that node (same file, same start
608
+ // position) so a symbol with genuinely zero callers reads as 0, not 1.
609
+ const usageRefs = refs.filter((r) => !(r.getSourceFile() === sourceFile && r.getStart() === referable.getStart()));
610
+ results.push({ name, line: decl.getStartLineNumber(), referenceCount: usageRefs.length, kind: decl.getKindName() });
611
+ }
612
+ return results;
613
+ }
614
+
615
+ /**
616
+ * refactoring effort estimation's call-site-count / package-boundary
617
+ * criteria — ADDED for refactoring-scoring.mjs (2026-08-20), additive-only.
618
+ * Same `findReferencesAsNodes()` mechanism as `deadExportsOf` above (same
619
+ * shared project, same declaration-node-vs-name-node resolution, same
620
+ * "exclude the declaration's own occurrence" dedup) — deliberately NOT a
621
+ * second implementation, just targeted at one named export and returning
622
+ * file paths alongside the count so a caller can test package-boundary
623
+ * crossing without a second AST walk.
624
+ */
625
+ export function referenceSitesOf(filePath, exportName, projectFilePaths = []) {
626
+ const project = sharedProject();
627
+ for (const p of new Set([filePath, ...projectFilePaths])) {
628
+ if (!project.getSourceFile(p)) {
629
+ try { project.addSourceFileAtPath(p); } catch { /* unreadable/binary — not a TS/JS file, skip */ }
630
+ }
631
+ }
632
+ const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath);
633
+ const decls = sourceFile.getExportedDeclarations().get(exportName);
634
+ const decl = decls?.[0];
635
+ if (!decl) return { referenceCount: -1, files: [], kind: null };
636
+ const referable = typeof decl.findReferencesAsNodes === 'function' ? decl : (decl.getNameNode?.() ?? null);
637
+ if (!referable || typeof referable.findReferencesAsNodes !== 'function') {
638
+ return { referenceCount: -1, files: [], kind: decl.getKindName() };
639
+ }
640
+ let refs = [];
641
+ try { refs = referable.findReferencesAsNodes(); } catch { refs = []; }
642
+ const usageRefs = refs.filter((r) => !(r.getSourceFile() === sourceFile && r.getStart() === referable.getStart()));
643
+ const files = [...new Set(usageRefs.map((r) => r.getSourceFile().getFilePath()))];
644
+ return { referenceCount: usageRefs.length, files, kind: decl.getKindName() };
645
+ }
646
+
647
+ export const typescriptPlugin = {
648
+ id: 'typescript',
649
+ canHandle: (filePath) => /\.(mjs|ts|tsx|js|cjs)$/.test(filePath),
650
+ extractUnits(filePath, sourceText) {
651
+ if (sourceText !== undefined) {
652
+ // Ephemeral parse (e.g. a file's content at a git ref) — its own
653
+ // one-shot project, never mixed into the shared cross-file project.
654
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
655
+ const sourceFile = project.createSourceFile(filePath.replace(/^[A-Za-z]:/, ''), sourceText);
656
+ return unitsFromSourceFile(sourceFile);
657
+ }
658
+ const { sourceFile } = projectFor(filePath);
659
+ return unitsFromSourceFile(sourceFile);
660
+ },
661
+ importsOf(filePath, sourceText) {
662
+ const { sourceFile } = sourceText !== undefined
663
+ ? { sourceFile: (() => {
664
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
665
+ return project.createSourceFile(filePath.replace(/^[A-Za-z]:/, ''), sourceText);
666
+ })() }
667
+ : projectFor(filePath);
668
+ return sourceFile.getImportDeclarations().map((d) => d.getModuleSpecifierValue());
669
+ },
670
+ deadExportsOf,
671
+ referenceSitesOf, // ADDED for refactoring-scoring.mjs (2026-08-20), additive-only
672
+ };