@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,335 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
walkSourceFiles, DEFAULT_LAYERS, classifyLayerByPath, classifyLayerByNameHint,
|
|
9
|
+
classifyFile, classifyLayerBySpecifierKeyword, resolveRelativeImport,
|
|
10
|
+
buildFileGraph, buildLayerEdges, dependencyDirectionFindings,
|
|
11
|
+
frameworkCouplingFindings, missingAbstractionFindings, mixedConcernsFindings,
|
|
12
|
+
uiBusinessLogicMixingFindings, mixedLayerImportsFindings,
|
|
13
|
+
circularLayerDependencyFindings, architectureScoreFile, architectureScoreAll,
|
|
14
|
+
} from '../../scripts/lib/architecture-scoring.mjs';
|
|
15
|
+
|
|
16
|
+
function makeRecord(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
absPath: '/repo/src/file.ts',
|
|
19
|
+
relPath: 'src/file.ts',
|
|
20
|
+
text: '',
|
|
21
|
+
layer: null,
|
|
22
|
+
layerBasis: 'unclassified',
|
|
23
|
+
imports: [],
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withTmpRoot(t) {
|
|
29
|
+
const root = mkdtempSync(path.join(tmpdir(), 'arch-score-'));
|
|
30
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeFiles(root, files) {
|
|
35
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
36
|
+
const full = path.join(root, rel);
|
|
37
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
38
|
+
writeFileSync(full, content, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── empty / trivial input ───────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
test('all rule functions on an unclassified, empty-text record return zero findings, never throw', () => {
|
|
45
|
+
const r = makeRecord();
|
|
46
|
+
assert.deepEqual(dependencyDirectionFindings(r, new Map(), DEFAULT_LAYERS).findings, []);
|
|
47
|
+
assert.deepEqual(frameworkCouplingFindings(r).findings, []);
|
|
48
|
+
assert.deepEqual(missingAbstractionFindings(r).findings, []);
|
|
49
|
+
assert.deepEqual(mixedConcernsFindings(r).findings, []);
|
|
50
|
+
assert.deepEqual(uiBusinessLogicMixingFindings(r).findings, []);
|
|
51
|
+
assert.deepEqual(mixedLayerImportsFindings(r, DEFAULT_LAYERS).findings, []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('circularLayerDependencyFindings: an empty edge map produces zero findings', () => {
|
|
55
|
+
assert.deepEqual(circularLayerDependencyFindings(new Map()).findings, []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── dependency-direction ─────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
test('dependency-direction: known violation — an unresolved bare specifier keyword-matched to an outer layer fires at low confidence', () => {
|
|
61
|
+
const r = makeRecord({ layer: { name: 'Entities', level: 4 }, imports: ['my-infra-client'] });
|
|
62
|
+
const result = dependencyDirectionFindings(r, new Map(), DEFAULT_LAYERS);
|
|
63
|
+
assert.equal(result.findings.length, 1);
|
|
64
|
+
assert.equal(result.findings[0].confidence, 'low');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('dependency-direction: known-clean — an import that resolves to no known layer does not fire', () => {
|
|
68
|
+
const r = makeRecord({ layer: { name: 'Entities', level: 4 }, imports: ['lodash'] });
|
|
69
|
+
assert.deepEqual(dependencyDirectionFindings(r, new Map(), DEFAULT_LAYERS).findings, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('dependency-direction: an unclassified record (layer null) never fires regardless of imports', () => {
|
|
73
|
+
const r = makeRecord({ layer: null, imports: ['my-infra-client'] });
|
|
74
|
+
assert.deepEqual(dependencyDirectionFindings(r, new Map(), DEFAULT_LAYERS).findings, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('dependency-direction: known violation — a RESOLVED relative import to an outer layer fires at high confidence (real file graph)', (t) => {
|
|
78
|
+
const root = withTmpRoot(t);
|
|
79
|
+
writeFiles(root, {
|
|
80
|
+
'frameworks/db-client.mjs': `export const db = {};`,
|
|
81
|
+
'usecases/create-user.ts': `import { db } from '../frameworks/db-client';\nexport class CreateUserUseCase {}`,
|
|
82
|
+
});
|
|
83
|
+
const files = walkSourceFiles(root);
|
|
84
|
+
const { records, byNoExt } = buildFileGraph(files, root, { layers: DEFAULT_LAYERS });
|
|
85
|
+
const useCaseRecord = records.find((r) => r.relPath.includes('create-user'));
|
|
86
|
+
assert.ok(useCaseRecord.layer, 'usecases/create-user.ts must classify to a real layer');
|
|
87
|
+
const result = dependencyDirectionFindings(useCaseRecord, byNoExt, DEFAULT_LAYERS);
|
|
88
|
+
assert.equal(result.findings.length, 1);
|
|
89
|
+
assert.equal(result.findings[0].confidence, 'high');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── framework-coupling ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test('framework-coupling: known violation — Entities layer importing a framework is critical severity, high confidence', () => {
|
|
95
|
+
const r = makeRecord({ layer: { name: 'Entities', level: 4 }, imports: ['express'] });
|
|
96
|
+
const result = frameworkCouplingFindings(r);
|
|
97
|
+
assert.equal(result.findings.length, 1);
|
|
98
|
+
assert.equal(result.findings[0].severity, 'critical');
|
|
99
|
+
assert.equal(result.findings[0].confidence, 'high');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('framework-coupling: a non-Entities layer importing a framework is high severity, not critical', () => {
|
|
103
|
+
const r = makeRecord({ layer: { name: 'UseCases', level: 3 }, imports: ['axios'] });
|
|
104
|
+
const result = frameworkCouplingFindings(r);
|
|
105
|
+
assert.equal(result.findings[0].severity, 'high');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('framework-coupling: a generic indicator (http/fetch/request) is confidence medium, not high', () => {
|
|
109
|
+
const r = makeRecord({ layer: { name: 'UseCases', level: 3 }, imports: ['http'] });
|
|
110
|
+
const result = frameworkCouplingFindings(r);
|
|
111
|
+
assert.equal(result.findings[0].confidence, 'medium');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('framework-coupling: known-clean — the Frameworks layer itself is exempt regardless of imports', () => {
|
|
115
|
+
const r = makeRecord({ layer: { name: 'Frameworks', level: 1 }, imports: ['express', 'mongoose'] });
|
|
116
|
+
assert.deepEqual(frameworkCouplingFindings(r).findings, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('framework-coupling: known-clean — no framework-indicator import does not fire', () => {
|
|
120
|
+
const r = makeRecord({ layer: { name: 'UseCases', level: 3 }, imports: ['./local-module'] });
|
|
121
|
+
assert.deepEqual(frameworkCouplingFindings(r).findings, []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── missing-abstraction — five independent sub-checks ─────────────────────
|
|
125
|
+
|
|
126
|
+
test('missing-abstraction 3a: Use Case directly instantiating a concrete Repository fires', () => {
|
|
127
|
+
const r = makeRecord({ text: `class OrderUseCase { run() { const repo = new OrderRepository(); } }` });
|
|
128
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
129
|
+
assert.ok(findings.some((f) => f.kind === 'missing-repository-interface'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('missing-abstraction 3b: Controller instantiating a Use Case with no input-port interface fires', () => {
|
|
133
|
+
const r = makeRecord({ text: `class OrderController { constructor() { this.uc = new OrderUseCase(); } }` });
|
|
134
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
135
|
+
assert.ok(findings.some((f) => f.kind === 'missing-input-port'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('missing-abstraction 3b: known-clean — a declared input-port interface suppresses the finding', () => {
|
|
139
|
+
const r = makeRecord({ text: `interface OrderUseCase {} class OrderController { constructor() { this.uc = new OrderUseCase(); } }` });
|
|
140
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
141
|
+
assert.ok(!findings.some((f) => f.kind === 'missing-input-port'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('missing-abstraction 3c: an HTTP request object referenced inside a Use Case fires', () => {
|
|
145
|
+
const r = makeRecord({ text: `class PayOrderUseCase { run(req) { return req.body; } }` });
|
|
146
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
147
|
+
assert.ok(findings.some((f) => f.kind === 'http-request-in-usecase'));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('missing-abstraction 3d: direct database access inside a Service fires', () => {
|
|
151
|
+
const r = makeRecord({ text: `class ReportService { run() { return db.query('SELECT * FROM orders'); } }` });
|
|
152
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
153
|
+
assert.ok(findings.some((f) => f.kind === 'direct-db-access-in-usecase'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('missing-abstraction 3e: a Controller returning a raw Entity fires', () => {
|
|
157
|
+
const r = makeRecord({ text: `class UserController { get() { return userEntity; } }` });
|
|
158
|
+
const findings = missingAbstractionFindings(r).findings;
|
|
159
|
+
assert.ok(findings.some((f) => f.kind === 'data-structure-leak'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('missing-abstraction: known-clean — plain text with none of the five patterns fires nothing', () => {
|
|
163
|
+
const r = makeRecord({ text: `export function add(a, b) { return a + b; }` });
|
|
164
|
+
assert.deepEqual(missingAbstractionFindings(r).findings, []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── mixed-concerns (business + infra vocabulary co-occurrence) ───────────
|
|
168
|
+
|
|
169
|
+
test('mixed-concerns: known violation — business and infrastructure vocabulary both present fires', () => {
|
|
170
|
+
const r = makeRecord({ text: `function validateOrder(order) { database.save(order); }` });
|
|
171
|
+
assert.equal(mixedConcernsFindings(r).findings.length, 1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('mixed-concerns: known-clean — only business vocabulary present does not fire', () => {
|
|
175
|
+
const r = makeRecord({ text: `function validateOrder(order) { return order.total > 0; }` });
|
|
176
|
+
assert.deepEqual(mixedConcernsFindings(r).findings, []);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── ui-business-logic-mixing (UI indicator + 100+ char business body) ────
|
|
180
|
+
|
|
181
|
+
test('ui-business-logic-mixing: known violation — a UI indicator plus a 100+ char calculate/validate/process body fires', () => {
|
|
182
|
+
const longBody = 'x'.repeat(120);
|
|
183
|
+
const r = makeRecord({ text: `function Component() { function calculateFoo() { ${longBody} } }` });
|
|
184
|
+
assert.equal(uiBusinessLogicMixingFindings(r).findings.length, 1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('ui-business-logic-mixing: boundary — a short (<100 char) business body with a UI indicator does not fire', () => {
|
|
188
|
+
const r = makeRecord({ text: `function Component() { function calculateFoo() { return 1; } }` });
|
|
189
|
+
assert.deepEqual(uiBusinessLogicMixingFindings(r).findings, []);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('ui-business-logic-mixing: known-clean — a long business body with NO UI indicator does not fire', () => {
|
|
193
|
+
const longBody = 'x'.repeat(120);
|
|
194
|
+
const r = makeRecord({ text: `function calculateFoo() { ${longBody} }` });
|
|
195
|
+
assert.deepEqual(uiBusinessLogicMixingFindings(r).findings, []);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── mixed-layer-imports (3+ distinct layers guessed from bare specifiers) ─
|
|
199
|
+
|
|
200
|
+
test('mixed-layer-imports: known violation — imports keyword-matching 3 distinct layers fires', () => {
|
|
201
|
+
const r = makeRecord({ imports: ['my-domain-thing', 'my-usecases-thing', 'my-controllers-thing'] });
|
|
202
|
+
assert.equal(mixedLayerImportsFindings(r, DEFAULT_LAYERS).findings.length, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('mixed-layer-imports: boundary — exactly 2 distinct layers does not fire', () => {
|
|
206
|
+
const r = makeRecord({ imports: ['my-domain-thing', 'my-usecases-thing'] });
|
|
207
|
+
assert.deepEqual(mixedLayerImportsFindings(r, DEFAULT_LAYERS).findings, []);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── circular-layer-dependency — real cycle walk over layer edges ─────────
|
|
211
|
+
|
|
212
|
+
test('circularLayerDependencyFindings: a real 2-layer cycle is found and canonicalized', () => {
|
|
213
|
+
const edges = new Map([
|
|
214
|
+
['Entities', new Set(['UseCases'])],
|
|
215
|
+
['UseCases', new Set(['Entities'])],
|
|
216
|
+
]);
|
|
217
|
+
const result = circularLayerDependencyFindings(edges);
|
|
218
|
+
assert.equal(result.findings.length, 1);
|
|
219
|
+
assert.equal(result.findings[0].location, 'Entities -> UseCases -> Entities');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('circularLayerDependencyFindings: an acyclic layer graph fires nothing', () => {
|
|
223
|
+
const edges = new Map([
|
|
224
|
+
['UseCases', new Set(['Entities'])],
|
|
225
|
+
['Entities', new Set()],
|
|
226
|
+
]);
|
|
227
|
+
assert.deepEqual(circularLayerDependencyFindings(edges).findings, []);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── layer classification — path glob, name-hint fallback, unclassified ───
|
|
231
|
+
|
|
232
|
+
test('classifyLayerByPath: a TOP-LEVEL frameworks/ path (no parent directory) still classifies as Frameworks', () => {
|
|
233
|
+
// Regression fixture for the documented globToRegExp fix: the naive
|
|
234
|
+
// '**' -> '.*' translation required a literal '/' before 'frameworks',
|
|
235
|
+
// which a top-level scanned path has none of.
|
|
236
|
+
const layer = classifyLayerByPath('frameworks/db-client.mjs', DEFAULT_LAYERS);
|
|
237
|
+
assert.equal(layer.name, 'Frameworks');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('classifyLayerByPath: a nested entities/ path classifies as Entities', () => {
|
|
241
|
+
const layer = classifyLayerByPath('src/domain/entities/user.ts', DEFAULT_LAYERS);
|
|
242
|
+
assert.equal(layer.name, 'Entities');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('classifyLayerByPath: a path matching no glob returns null', () => {
|
|
246
|
+
assert.equal(classifyLayerByPath('random/unrelated.ts', DEFAULT_LAYERS), null);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('classifyLayerByNameHint: a UseCase-suffixed class name classifies as UseCases via name-hint', () => {
|
|
250
|
+
const layer = classifyLayerByNameHint('export class CreateOrderUseCase {}', DEFAULT_LAYERS);
|
|
251
|
+
assert.equal(layer.name, 'UseCases');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('classifyFile: path match takes precedence over a name-hint (basis "path")', () => {
|
|
255
|
+
const r = classifyFile('entities/user.ts', 'export class CreateOrderUseCase {}', DEFAULT_LAYERS);
|
|
256
|
+
assert.equal(r.basis, 'path');
|
|
257
|
+
assert.equal(r.layer.name, 'Entities');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('classifyFile: falls back to name-hint when the path matches no glob (basis "name-hint")', () => {
|
|
261
|
+
const r = classifyFile('random/order.ts', 'export class CreateOrderUseCase {}', DEFAULT_LAYERS);
|
|
262
|
+
assert.equal(r.basis, 'name-hint');
|
|
263
|
+
assert.equal(r.layer.name, 'UseCases');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('classifyFile: neither path nor name-hint match returns unclassified, not a fabricated default layer', () => {
|
|
267
|
+
const r = classifyFile('random/misc.ts', 'export function helper() {}', DEFAULT_LAYERS);
|
|
268
|
+
assert.equal(r.basis, 'unclassified');
|
|
269
|
+
assert.equal(r.layer, null);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('classifyLayerBySpecifierKeyword: a keyword-matching bare specifier resolves at low confidence (caller responsibility)', () => {
|
|
273
|
+
const layer = classifyLayerBySpecifierKeyword('some-domain-lib', DEFAULT_LAYERS);
|
|
274
|
+
assert.equal(layer.name, 'Entities');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('classifyLayerBySpecifierKeyword: a non-matching specifier returns null', () => {
|
|
278
|
+
assert.equal(classifyLayerBySpecifierKeyword('lodash', DEFAULT_LAYERS), null);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── real-file integration: walkSourceFiles, buildFileGraph, resolveRelativeImport, architectureScoreAll ──
|
|
282
|
+
|
|
283
|
+
test('walkSourceFiles: an empty directory returns zero files, never throws', (t) => {
|
|
284
|
+
const root = withTmpRoot(t);
|
|
285
|
+
assert.deepEqual(walkSourceFiles(root), []);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('walkSourceFiles: skips node_modules and non-source extensions, includes real source files, sorted', (t) => {
|
|
289
|
+
const root = withTmpRoot(t);
|
|
290
|
+
writeFiles(root, {
|
|
291
|
+
'a.mjs': '',
|
|
292
|
+
'b.ts': '',
|
|
293
|
+
'README.md': '',
|
|
294
|
+
'node_modules/dep/index.mjs': '',
|
|
295
|
+
});
|
|
296
|
+
const files = walkSourceFiles(root).map((f) => path.relative(root, f).split(path.sep).join('/'));
|
|
297
|
+
assert.deepEqual(files.sort(), ['a.mjs', 'b.ts']);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('resolveRelativeImport: resolves a relative specifier to its extension-bearing target file record', (t) => {
|
|
301
|
+
const root = withTmpRoot(t);
|
|
302
|
+
writeFiles(root, {
|
|
303
|
+
'a/index.ts': `import { b } from '../b/thing';`,
|
|
304
|
+
'b/thing.ts': `export const b = 1;`,
|
|
305
|
+
});
|
|
306
|
+
const files = walkSourceFiles(root);
|
|
307
|
+
const { byNoExt } = buildFileGraph(files, root, { layers: DEFAULT_LAYERS });
|
|
308
|
+
const fromFile = path.join(root, 'a/index.ts');
|
|
309
|
+
const resolved = resolveRelativeImport('../b/thing', fromFile, byNoExt);
|
|
310
|
+
assert.ok(resolved);
|
|
311
|
+
assert.match(resolved.relPath.replace(/\\/g, '/'), /b\/thing\.ts$/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('resolveRelativeImport: a specifier that resolves to nothing scanned returns null', (t) => {
|
|
315
|
+
const root = withTmpRoot(t);
|
|
316
|
+
writeFiles(root, { 'a/index.ts': '' });
|
|
317
|
+
const files = walkSourceFiles(root);
|
|
318
|
+
const { byNoExt } = buildFileGraph(files, root, { layers: DEFAULT_LAYERS });
|
|
319
|
+
const fromFile = path.join(root, 'a/index.ts');
|
|
320
|
+
assert.equal(resolveRelativeImport('../nowhere', fromFile, byNoExt), null);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('architectureScoreAll: end-to-end — surfaces a real dependency-direction violation and reports unclassifiedFiles', (t) => {
|
|
324
|
+
const root = withTmpRoot(t);
|
|
325
|
+
writeFiles(root, {
|
|
326
|
+
'frameworks/db-client.mjs': `export const db = {};`,
|
|
327
|
+
'usecases/create-user.ts': `import { db } from '../frameworks/db-client';\nexport class CreateUserUseCase {}`,
|
|
328
|
+
'misc/unrelated.mjs': `export const noop = () => {};`,
|
|
329
|
+
});
|
|
330
|
+
const files = walkSourceFiles(root);
|
|
331
|
+
const result = architectureScoreAll(files, root, { layers: DEFAULT_LAYERS });
|
|
332
|
+
const useCaseResult = result.results.find((r) => r.file.includes('create-user'));
|
|
333
|
+
assert.equal(useCaseResult.rules.dependencyDirection.findings.length, 1);
|
|
334
|
+
assert.ok(result.unclassifiedFiles.some((f) => f.includes('unrelated')));
|
|
335
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
n1CrypticNames, n2MeaninglessNames, n4MagicNumbers, n7GenericNames,
|
|
6
|
+
f1LongMethods, f2TooManyParams, e1EmptyCatchBlocks, e2UnguardedRiskyOps, g9DeadCode,
|
|
7
|
+
cleanCodeScore, NOT_IMPLEMENTED,
|
|
8
|
+
} from '../../scripts/lib/clean-code-scoring.mjs';
|
|
9
|
+
import { makeMember, makeUnit } from './fixtures.mjs';
|
|
10
|
+
|
|
11
|
+
// ── empty / trivial input ───────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
test('cleanCodeScore: zero members produces zero findings for every rule, never throws', () => {
|
|
14
|
+
const unit = makeUnit({ members: [] });
|
|
15
|
+
const r = cleanCodeScore(unit);
|
|
16
|
+
assert.equal(r.totalFindings, 0);
|
|
17
|
+
for (const rule of Object.values(r.rules)) assert.deepEqual(rule.findings, []);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('cleanCodeScore: a single trivial member with no smells produces zero findings', () => {
|
|
21
|
+
const unit = makeUnit({ members: [makeMember()] });
|
|
22
|
+
const r = cleanCodeScore(unit);
|
|
23
|
+
assert.equal(r.totalFindings, 0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('NOT_IMPLEMENTED lists the rules intentionally left as judgment calls', () => {
|
|
27
|
+
assert.deepEqual(NOT_IMPLEMENTED, ['N3', 'N5', 'N6', 'C1', 'C2', 'C3', 'C4', 'C5', 'G5', 'G14', 'G16', 'G28']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── N1 — cryptic names ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
test('N1: single-letter non-loop-counter name fires', () => {
|
|
33
|
+
const unit = makeUnit({ members: [makeMember({ declaredNames: [{ name: 'x', line: 5 }] })] });
|
|
34
|
+
const r = n1CrypticNames(unit);
|
|
35
|
+
assert.equal(r.findings.length, 1);
|
|
36
|
+
assert.match(r.findings[0].location, /:5$/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('N1: loop-counter whitelist (i/j/k) does not fire', () => {
|
|
40
|
+
const unit = makeUnit({ members: [makeMember({ declaredNames: [{ name: 'i', line: 5 }, { name: 'j', line: 6 }, { name: 'k', line: 7 }] })] });
|
|
41
|
+
assert.deepEqual(n1CrypticNames(unit).findings, []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('N1: cryptic two-letter name fires; short-name whitelist (fn/cb/ok/id/db/ui/io) does not', () => {
|
|
45
|
+
const unit = makeUnit({
|
|
46
|
+
members: [makeMember({ declaredNames: [{ name: 'xy', line: 1 }, { name: 'fn', line: 2 }, { name: 'id', line: 3 }] })],
|
|
47
|
+
});
|
|
48
|
+
const r = n1CrypticNames(unit);
|
|
49
|
+
assert.equal(r.findings.length, 1);
|
|
50
|
+
assert.match(r.findings[0].detail, /xy/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── N2 — meaningless / noise-word / numeric-suffix names ────────────────
|
|
54
|
+
|
|
55
|
+
test('N2: noise-word name (data/info/temp/...) fires', () => {
|
|
56
|
+
const unit = makeUnit({ members: [makeMember({ declaredNames: [{ name: 'data', line: 1 }] })] });
|
|
57
|
+
assert.equal(n2MeaninglessNames(unit).findings.length, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('N2: numeric-suffix name (data1/data2 pattern) fires', () => {
|
|
61
|
+
const unit = makeUnit({ members: [makeMember({ declaredNames: [{ name: 'value2', line: 1 }] })] });
|
|
62
|
+
const r = n2MeaninglessNames(unit);
|
|
63
|
+
assert.equal(r.findings.length, 1);
|
|
64
|
+
assert.match(r.findings[0].detail, /numeric-suffix/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('N2: a well-named binding does not fire', () => {
|
|
68
|
+
const unit = makeUnit({ members: [makeMember({ declaredNames: [{ name: 'customerId', line: 1 }] })] });
|
|
69
|
+
assert.deepEqual(n2MeaninglessNames(unit).findings, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── N4 — magic numbers ───────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test('N4: a magic-number fact fires one finding per occurrence', () => {
|
|
75
|
+
const unit = makeUnit({ members: [makeMember({ magicNumbers: [{ value: 86400, line: 10 }, { value: 42, line: 12 }] })] });
|
|
76
|
+
assert.equal(n4MagicNumbers(unit).findings.length, 2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('N4: no magic numbers does not fire', () => {
|
|
80
|
+
const unit = makeUnit({ members: [makeMember({ magicNumbers: [] })] });
|
|
81
|
+
assert.deepEqual(n4MagicNumbers(unit).findings, []);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── N7 — generic class/function names ────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
test('N7: generic class name (whole word) fires on the unit itself', () => {
|
|
87
|
+
const unit = makeUnit({ name: 'Manager', kind: 'class', members: [] });
|
|
88
|
+
const r = n7GenericNames(unit);
|
|
89
|
+
assert.equal(r.findings.length, 1);
|
|
90
|
+
assert.equal(r.findings[0].location, 'Manager');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('N7: generic suffix class name (UserDataManager) fires', () => {
|
|
94
|
+
const unit = makeUnit({ name: 'UserDataManager', kind: 'class', members: [] });
|
|
95
|
+
assert.equal(n7GenericNames(unit).findings.length, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('N7: generic member/function name fires independent of the unit name', () => {
|
|
99
|
+
const unit = makeUnit({ name: 'Widget', members: [makeMember({ name: 'processHelper' })] });
|
|
100
|
+
const r = n7GenericNames(unit);
|
|
101
|
+
assert.equal(r.findings.length, 1);
|
|
102
|
+
assert.match(r.findings[0].detail, /processHelper/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('N7: a descriptive, non-generic name does not fire', () => {
|
|
106
|
+
const unit = makeUnit({ name: 'InvoiceReconciler', members: [makeMember({ name: 'calculateTotal' })] });
|
|
107
|
+
assert.deepEqual(n7GenericNames(unit).findings, []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── F1 — long methods (>20 statements) ───────────────────────────────────
|
|
111
|
+
|
|
112
|
+
test('F1: known violation — 21 statements fires', () => {
|
|
113
|
+
const unit = makeUnit({ members: [makeMember({ statementCount: 21 })] });
|
|
114
|
+
assert.equal(f1LongMethods(unit).findings.length, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('F1: boundary — exactly 20 statements does not fire', () => {
|
|
118
|
+
const unit = makeUnit({ members: [makeMember({ statementCount: 20 })] });
|
|
119
|
+
assert.deepEqual(f1LongMethods(unit).findings, []);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('F1: boundary — 21 is the first statement count that fires', () => {
|
|
123
|
+
const at20 = f1LongMethods(makeUnit({ members: [makeMember({ statementCount: 20 })] }));
|
|
124
|
+
const at21 = f1LongMethods(makeUnit({ members: [makeMember({ statementCount: 21 })] }));
|
|
125
|
+
assert.equal(at20.findings.length, 0);
|
|
126
|
+
assert.equal(at21.findings.length, 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── F2 — too many parameters (>3) ────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
test('F2: known violation — 4 parameters fires', () => {
|
|
132
|
+
const unit = makeUnit({ members: [makeMember({ paramCount: 4 })] });
|
|
133
|
+
assert.equal(f2TooManyParams(unit).findings.length, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('F2: boundary — exactly 3 parameters does not fire', () => {
|
|
137
|
+
const unit = makeUnit({ members: [makeMember({ paramCount: 3 })] });
|
|
138
|
+
assert.deepEqual(f2TooManyParams(unit).findings, []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── E1 — empty catch blocks ───────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test('E1: an empty-catch fact fires', () => {
|
|
144
|
+
const unit = makeUnit({ members: [makeMember({ emptyCatches: [{ line: 8 }] })] });
|
|
145
|
+
const r = e1EmptyCatchBlocks(unit);
|
|
146
|
+
assert.equal(r.findings.length, 1);
|
|
147
|
+
assert.match(r.findings[0].location, /:8$/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('E1: no empty catches does not fire', () => {
|
|
151
|
+
const unit = makeUnit({ members: [makeMember({ emptyCatches: [] })] });
|
|
152
|
+
assert.deepEqual(e1EmptyCatchBlocks(unit).findings, []);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── E2 — unguarded risky operations ────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
test('E2: an unguarded-await fact fires', () => {
|
|
158
|
+
const unit = makeUnit({ members: [makeMember({ unguardedRiskyOps: [{ line: 10, kind: 'await' }] })] });
|
|
159
|
+
const r = e2UnguardedRiskyOps(unit);
|
|
160
|
+
assert.equal(r.findings.length, 1);
|
|
161
|
+
assert.match(r.findings[0].location, /:10$/);
|
|
162
|
+
assert.match(r.findings[0].detail, /unguarded await/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('E2: an unguarded risky sync call fact fires', () => {
|
|
166
|
+
const unit = makeUnit({ members: [makeMember({ unguardedRiskyOps: [{ line: 3, kind: 'JSON.parse' }] })] });
|
|
167
|
+
const r = e2UnguardedRiskyOps(unit);
|
|
168
|
+
assert.equal(r.findings.length, 1);
|
|
169
|
+
assert.match(r.findings[0].detail, /unguarded JSON\.parse/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('E2: no unguarded risky ops does not fire', () => {
|
|
173
|
+
const unit = makeUnit({ members: [makeMember({ unguardedRiskyOps: [] })] });
|
|
174
|
+
assert.deepEqual(e2UnguardedRiskyOps(unit).findings, []);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('E2: missing unguardedRiskyOps fact (older plugin) does not throw, reports zero', () => {
|
|
178
|
+
const unit = makeUnit({ members: [makeMember({})] });
|
|
179
|
+
assert.deepEqual(e2UnguardedRiskyOps(unit).findings, []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── G9 — dead code (unreachable half + unused-export half) ───────────────
|
|
183
|
+
|
|
184
|
+
test('G9: unreachable-conditional half fires per dead-conditional fact', () => {
|
|
185
|
+
const unit = makeUnit({ members: [makeMember({ deadConditionals: [{ line: 4, kind: 'if-false' }] })] });
|
|
186
|
+
const r = g9DeadCode(unit);
|
|
187
|
+
assert.equal(r.findings.length, 1);
|
|
188
|
+
assert.equal(r.confidence, 'medium'); // no deadExportsFacts supplied
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('G9: unused-export half fires when referenceCount is exactly 0', () => {
|
|
192
|
+
const unit = makeUnit({ members: [] });
|
|
193
|
+
const r = g9DeadCode(unit, [{ name: 'unusedThing', line: 3, referenceCount: 0, kind: 'FunctionDeclaration' }]);
|
|
194
|
+
assert.equal(r.findings.length, 1);
|
|
195
|
+
assert.equal(r.confidence, 'high'); // deadExportsFacts supplied
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('G9: referenceCount -1 (unsupported declaration kind) is unmeasured, never reported as a finding', () => {
|
|
199
|
+
const unit = makeUnit({ members: [] });
|
|
200
|
+
const r = g9DeadCode(unit, [{ name: 'thing', line: 3, referenceCount: -1, kind: 'WeirdKind' }]);
|
|
201
|
+
assert.deepEqual(r.findings, []);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('G9: a positively-referenced export does not fire (mirror of the dead-export case)', () => {
|
|
205
|
+
const unit = makeUnit({ members: [] });
|
|
206
|
+
const r = g9DeadCode(unit, [{ name: 'usedThing', line: 3, referenceCount: 5, kind: 'FunctionDeclaration' }]);
|
|
207
|
+
assert.deepEqual(r.findings, []);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('G9: no dead conditionals and no export facts does not fire, confidence stays medium', () => {
|
|
211
|
+
const unit = makeUnit({ members: [makeMember({ deadConditionals: [] })] });
|
|
212
|
+
const r = g9DeadCode(unit);
|
|
213
|
+
assert.deepEqual(r.findings, []);
|
|
214
|
+
assert.equal(r.confidence, 'medium');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── cleanCodeScore — full aggregate over all 8 rules at once ─────────────
|
|
218
|
+
|
|
219
|
+
test('cleanCodeScore: a unit with one violation per pre-existing rule fires all 8 and totals correctly (E2 untouched here, tested separately)', () => {
|
|
220
|
+
const unit = makeUnit({
|
|
221
|
+
name: 'Manager',
|
|
222
|
+
members: [makeMember({
|
|
223
|
+
name: 'processHelper',
|
|
224
|
+
declaredNames: [{ name: 'x', line: 1 }],
|
|
225
|
+
magicNumbers: [{ value: 99, line: 2 }],
|
|
226
|
+
statementCount: 21,
|
|
227
|
+
paramCount: 4,
|
|
228
|
+
emptyCatches: [{ line: 3 }],
|
|
229
|
+
deadConditionals: [{ line: 4, kind: 'if-true' }],
|
|
230
|
+
})],
|
|
231
|
+
});
|
|
232
|
+
const r = cleanCodeScore(unit);
|
|
233
|
+
assert.equal(r.rules.n1.findings.length, 1);
|
|
234
|
+
assert.equal(r.rules.n4.findings.length, 1);
|
|
235
|
+
assert.equal(r.rules.n7.findings.length, 2); // both unit name AND member name are generic
|
|
236
|
+
assert.equal(r.rules.f1.findings.length, 1);
|
|
237
|
+
assert.equal(r.rules.f2.findings.length, 1);
|
|
238
|
+
assert.equal(r.rules.e1.findings.length, 1);
|
|
239
|
+
assert.equal(r.rules.g9.findings.length, 1);
|
|
240
|
+
assert.equal(r.totalFindings, 8);
|
|
241
|
+
});
|