@lifeaitools/rdc-skills 0.34.1 → 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,144 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { tokenizeSource, findDuplicates } from '../../scripts/lib/duplication-scoring.mjs';
|
|
5
|
+
|
|
6
|
+
/** N distinct single-token identifiers, space-separated -> exactly N real tokens. */
|
|
7
|
+
function tokenBlock(n, prefix = 'shared') {
|
|
8
|
+
return Array.from({ length: n }, (_, i) => `${prefix}${i}`).join(' ');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── empty / trivial input ───────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
test('tokenizeSource: empty text returns zero tokens, never throws', () => {
|
|
14
|
+
assert.deepEqual(tokenizeSource('', '.mjs'), []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('findDuplicates: zero files returns zero duplicates, never throws', () => {
|
|
18
|
+
const r = findDuplicates([]);
|
|
19
|
+
assert.deepEqual(r.duplicates, []);
|
|
20
|
+
assert.equal(r.filesScanned, 0);
|
|
21
|
+
assert.equal(r.minTokens, 50); // documented default
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('findDuplicates: a single trivial file (below minTokens) produces zero duplicates', () => {
|
|
25
|
+
const r = findDuplicates([{ file: 'a.mjs', text: 'const x = 1;', ext: '.mjs' }], 50);
|
|
26
|
+
assert.deepEqual(r.duplicates, []);
|
|
27
|
+
assert.equal(r.filesScanned, 0); // filtered out: fewer tokens than minTokens
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── tokenization — comment stripping, string literals, line tracking ────
|
|
31
|
+
|
|
32
|
+
test('tokenizeSource: // line comments (.js/.mjs) are stripped, not tokenized', () => {
|
|
33
|
+
const text = `// ${tokenBlock(3)}\n${tokenBlock(3, 'real')}`;
|
|
34
|
+
const tokens = tokenizeSource(text, '.js');
|
|
35
|
+
assert.equal(tokens.length, 3);
|
|
36
|
+
assert.equal(tokens[0].token, 'real0');
|
|
37
|
+
assert.equal(tokens[0].line, 2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('tokenizeSource: /* block comments */ (.ts) are stripped, not tokenized', () => {
|
|
41
|
+
const text = `/* ${tokenBlock(3)} */\n${tokenBlock(3, 'real')}`;
|
|
42
|
+
const tokens = tokenizeSource(text, '.ts');
|
|
43
|
+
assert.equal(tokens.length, 3);
|
|
44
|
+
assert.equal(tokens[0].token, 'real0');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('tokenizeSource: # comments (.py) are stripped using the Python comment table', () => {
|
|
48
|
+
const text = `# ${tokenBlock(3)}\n${tokenBlock(3, 'real')}`;
|
|
49
|
+
const tokens = tokenizeSource(text, '.py');
|
|
50
|
+
assert.equal(tokens.length, 3);
|
|
51
|
+
assert.equal(tokens[0].token, 'real0');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('tokenizeSource: an unknown extension applies no comment stripping (raw tokenization)', () => {
|
|
55
|
+
const tokens = tokenizeSource('a b c', '.xyz');
|
|
56
|
+
assert.equal(tokens.length, 3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('tokenizeSource: a string literal collapses to ONE token, content preserved (not a placeholder)', () => {
|
|
60
|
+
const tokens = tokenizeSource(`"hello world"`, '.mjs');
|
|
61
|
+
assert.equal(tokens.length, 1);
|
|
62
|
+
assert.equal(tokens[0].token, '"hello world"');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('tokenizeSource: line numbers track correctly across a multi-line file', () => {
|
|
66
|
+
const tokens = tokenizeSource('a\nb\nc', '.mjs');
|
|
67
|
+
assert.deepEqual(tokens.map((t) => t.line), [1, 2, 3]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── findDuplicates — cross-file duplicate detection, boundary at minTokens ─
|
|
71
|
+
|
|
72
|
+
test('findDuplicates: known violation — an identical 10-token block shared across two files fires one finding', () => {
|
|
73
|
+
const shared = tokenBlock(10);
|
|
74
|
+
const files = [
|
|
75
|
+
{ file: 'a.mjs', text: `${shared} uniqueA0 uniqueA1 uniqueA2 uniqueA3 uniqueA4 uniqueA5`, ext: '.mjs' },
|
|
76
|
+
{ file: 'b.mjs', text: `${shared} uniqueB0 uniqueB1 uniqueB2 uniqueB3 uniqueB4 uniqueB5`, ext: '.mjs' },
|
|
77
|
+
];
|
|
78
|
+
const r = findDuplicates(files, 10);
|
|
79
|
+
assert.equal(r.duplicates.length, 1);
|
|
80
|
+
assert.equal(r.duplicates[0].tokenCount, 10);
|
|
81
|
+
assert.equal(r.duplicates[0].occurrences.length, 2);
|
|
82
|
+
const filesFound = r.duplicates[0].occurrences.map((o) => o.file).sort();
|
|
83
|
+
assert.deepEqual(filesFound, ['a.mjs', 'b.mjs']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('findDuplicates: boundary — a shared block of exactly minTokens-1 (9) tokens does not fire', () => {
|
|
87
|
+
const shared = tokenBlock(9);
|
|
88
|
+
const files = [
|
|
89
|
+
{ file: 'a.mjs', text: `${shared} uniqueA0 uniqueA1 uniqueA2 uniqueA3 uniqueA4 uniqueA5 uniqueA6`, ext: '.mjs' },
|
|
90
|
+
{ file: 'b.mjs', text: `${shared} uniqueB0 uniqueB1 uniqueB2 uniqueB3 uniqueB4 uniqueB5 uniqueB6`, ext: '.mjs' },
|
|
91
|
+
];
|
|
92
|
+
const r = findDuplicates(files, 10);
|
|
93
|
+
assert.deepEqual(r.duplicates, []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('findDuplicates: known-clean — two files with no shared minTokens-length window fire nothing', () => {
|
|
97
|
+
const files = [
|
|
98
|
+
{ file: 'a.mjs', text: tokenBlock(15, 'alpha'), ext: '.mjs' },
|
|
99
|
+
{ file: 'b.mjs', text: tokenBlock(15, 'beta'), ext: '.mjs' },
|
|
100
|
+
];
|
|
101
|
+
assert.deepEqual(findDuplicates(files, 10).duplicates, []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('findDuplicates: a duplicate block LONGER than minTokens is merged into one maximal finding, not one per slide', () => {
|
|
105
|
+
const shared = tokenBlock(20); // 20 shared tokens, minTokens=10 -> 11 sliding-window hits, must merge to 1
|
|
106
|
+
const files = [
|
|
107
|
+
{ file: 'a.mjs', text: `${shared} uniqueA0 uniqueA1 uniqueA2 uniqueA3 uniqueA4 uniqueA5`, ext: '.mjs' },
|
|
108
|
+
{ file: 'b.mjs', text: `${shared} uniqueB0 uniqueB1 uniqueB2 uniqueB3 uniqueB4 uniqueB5`, ext: '.mjs' },
|
|
109
|
+
];
|
|
110
|
+
const r = findDuplicates(files, 10);
|
|
111
|
+
assert.equal(r.duplicates.length, 1);
|
|
112
|
+
assert.equal(r.duplicates[0].tokenCount, 20);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('findDuplicates: a genuine SAME-FILE duplicate (well-separated occurrences) is still detected', () => {
|
|
116
|
+
const shared = tokenBlock(10);
|
|
117
|
+
const padding = tokenBlock(15, 'pad'); // separates the two occurrences by 25 tokens, well over minTokens
|
|
118
|
+
const files = [{ file: 'a.mjs', text: `${shared} ${padding} ${shared}`, ext: '.mjs' }];
|
|
119
|
+
const r = findDuplicates(files, 10);
|
|
120
|
+
assert.equal(r.duplicates.length, 1);
|
|
121
|
+
assert.equal(r.duplicates[0].occurrences.length, 2);
|
|
122
|
+
assert.ok(r.duplicates[0].occurrences.every((o) => o.file === 'a.mjs'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('findDuplicates: a single occurrence with no real repetition produces no self-referential phantom duplicate', () => {
|
|
126
|
+
const files = [{ file: 'a.mjs', text: tokenBlock(30, 'unique'), ext: '.mjs' }];
|
|
127
|
+
assert.deepEqual(findDuplicates(files, 10).duplicates, []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('findDuplicates: string-literal content that repeats verbatim across files IS real duplication (not collapsed to a placeholder)', () => {
|
|
131
|
+
const sharedLiteral = Array.from({ length: 10 }, (_, i) => `"literal text number ${i}"`).join(' ');
|
|
132
|
+
const files = [
|
|
133
|
+
{ file: 'a.mjs', text: `${sharedLiteral} tailA0 tailA1 tailA2 tailA3 tailA4 tailA5`, ext: '.mjs' },
|
|
134
|
+
{ file: 'b.mjs', text: `${sharedLiteral} tailB0 tailB1 tailB2 tailB3 tailB4 tailB5`, ext: '.mjs' },
|
|
135
|
+
];
|
|
136
|
+
const r = findDuplicates(files, 10);
|
|
137
|
+
assert.equal(r.duplicates.length, 1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('findDuplicates: minTokens is respected as a real parameter, not hardcoded to the default 50', () => {
|
|
141
|
+
const r = findDuplicates([{ file: 'a.mjs', text: tokenBlock(20), ext: '.mjs' }], 30);
|
|
142
|
+
assert.equal(r.filesScanned, 0); // 20 tokens < minTokens(30) -> filtered before scanning
|
|
143
|
+
assert.equal(r.minTokens, 30);
|
|
144
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared literal fixtures for scripts/lib/*.mjs unit tests.
|
|
3
|
+
*
|
|
4
|
+
* `makeMember` / `makeUnit` construct minimal, fully-shaped
|
|
5
|
+
* `NormalizedMember` / `NormalizedUnit` objects per the contract documented
|
|
6
|
+
* in scripts/lib/language-plugin.mjs's JSDoc — every field the contract
|
|
7
|
+
* declares is present with a safe default, so a scoring function's `?? []`
|
|
8
|
+
* guards are never exercising an accidentally-missing field instead of the
|
|
9
|
+
* real default. Individual tests override only the fields relevant to the
|
|
10
|
+
* rule under test.
|
|
11
|
+
*
|
|
12
|
+
* These are hand-built literal objects passed DIRECTLY to the pure scoring
|
|
13
|
+
* functions — no ts-morph, no real parse. This is the correct unit-test
|
|
14
|
+
* boundary: it tests the scoring LOGIC in isolation from the AST layer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @returns {import('../../scripts/lib/language-plugin.mjs').NormalizedMember} */
|
|
18
|
+
export function makeMember(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
name: 'method',
|
|
21
|
+
paramCount: 0,
|
|
22
|
+
fieldAccess: [],
|
|
23
|
+
calls: [],
|
|
24
|
+
branchHits: 0,
|
|
25
|
+
isPublic: true,
|
|
26
|
+
override: null,
|
|
27
|
+
statementCount: 0,
|
|
28
|
+
declaredNames: [],
|
|
29
|
+
magicNumbers: [],
|
|
30
|
+
emptyCatches: [],
|
|
31
|
+
deadConditionals: [],
|
|
32
|
+
statementTexts: [],
|
|
33
|
+
nullChecks: [],
|
|
34
|
+
switchStatements: [],
|
|
35
|
+
complexConditionals: [],
|
|
36
|
+
switchBehaviorCallLine: null,
|
|
37
|
+
constructorNewCallTargets: [],
|
|
38
|
+
conditionalFeatureCallLine: null,
|
|
39
|
+
deepChainCallCount: 0,
|
|
40
|
+
calleeNames: [],
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {import('../../scripts/lib/language-plugin.mjs').NormalizedUnit} */
|
|
46
|
+
export function makeUnit(overrides = {}) {
|
|
47
|
+
return {
|
|
48
|
+
name: 'Unit',
|
|
49
|
+
kind: 'class',
|
|
50
|
+
members: [],
|
|
51
|
+
hasBaseClass: false,
|
|
52
|
+
concreteInstantiations: 0,
|
|
53
|
+
totalDependencies: 0,
|
|
54
|
+
staticPropertyNames: [],
|
|
55
|
+
hasGetInstanceMethod: false,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
extractImportSpecifiers, exportedDeclarationCounts, buildImportGraph,
|
|
9
|
+
findCycles, packageMetrics, packageMetricsAll,
|
|
10
|
+
} from '../../scripts/lib/package-metrics.mjs';
|
|
11
|
+
|
|
12
|
+
/** Create a package dir under a fresh tmp root: package.json + files map (relPath -> content). */
|
|
13
|
+
function makePkg(root, name, files) {
|
|
14
|
+
const dir = path.join(root, name);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name }), 'utf8');
|
|
17
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
18
|
+
const full = path.join(dir, rel);
|
|
19
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
20
|
+
writeFileSync(full, content, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withTmpRoot(t) {
|
|
26
|
+
const root = mkdtempSync(path.join(tmpdir(), 'pkg-metrics-'));
|
|
27
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── empty / trivial input ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('buildImportGraph: zero package dirs returns empty structures, never throws', () => {
|
|
34
|
+
const g = buildImportGraph([]);
|
|
35
|
+
assert.deepEqual(g.packages, []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('packageMetricsAll: zero package dirs returns an empty array', () => {
|
|
39
|
+
assert.deepEqual(packageMetricsAll([]), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('extractImportSpecifiers: empty source returns no specifiers', () => {
|
|
43
|
+
assert.deepEqual(extractImportSpecifiers(''), []);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('exportedDeclarationCounts: empty source returns zero/zero, not NaN', () => {
|
|
47
|
+
assert.deepEqual(exportedDeclarationCounts(''), { abstract: 0, concrete: 0 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── extractImportSpecifiers — every import shape it claims to handle ────
|
|
51
|
+
|
|
52
|
+
test('extractImportSpecifiers: static named import, bare import, export-from, require, dynamic import all extracted', () => {
|
|
53
|
+
const src = `
|
|
54
|
+
import { a } from 'pkg-static';
|
|
55
|
+
import 'pkg-bare';
|
|
56
|
+
export { b } from 'pkg-export-from';
|
|
57
|
+
const c = require('pkg-require');
|
|
58
|
+
const d = import('pkg-dynamic');
|
|
59
|
+
`;
|
|
60
|
+
const specs = extractImportSpecifiers(src).sort();
|
|
61
|
+
assert.deepEqual(specs, ['pkg-bare', 'pkg-dynamic', 'pkg-export-from', 'pkg-require', 'pkg-static'].sort());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('extractImportSpecifiers: a specifier that only appears inside a comment is NOT extracted', () => {
|
|
65
|
+
const src = `
|
|
66
|
+
// import { x } from 'pkg-in-line-comment';
|
|
67
|
+
/* import { y } from 'pkg-in-block-comment'; */
|
|
68
|
+
import { z } from 'pkg-real';
|
|
69
|
+
`;
|
|
70
|
+
assert.deepEqual(extractImportSpecifiers(src), ['pkg-real']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('extractImportSpecifiers: duplicate imports of the same specifier dedupe to one entry', () => {
|
|
74
|
+
const src = `import { a } from 'dup'; import { b } from 'dup';`;
|
|
75
|
+
assert.deepEqual(extractImportSpecifiers(src), ['dup']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── exportedDeclarationCounts — abstract vs concrete classification ─────
|
|
79
|
+
|
|
80
|
+
test('exportedDeclarationCounts: interface and type count as abstract', () => {
|
|
81
|
+
const src = `export interface Foo {} export type Bar = string;`;
|
|
82
|
+
assert.deepEqual(exportedDeclarationCounts(src), { abstract: 2, concrete: 0 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('exportedDeclarationCounts: class, function, const, enum count as concrete', () => {
|
|
86
|
+
const src = `
|
|
87
|
+
export class Alpha {}
|
|
88
|
+
export function beta() {}
|
|
89
|
+
export const gamma = 1;
|
|
90
|
+
export enum Delta { A }
|
|
91
|
+
`;
|
|
92
|
+
assert.deepEqual(exportedDeclarationCounts(src), { abstract: 0, concrete: 4 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('exportedDeclarationCounts: a declaration inside a comment is not counted', () => {
|
|
96
|
+
const src = `// export interface Ghost {}\nexport const real = 1;`;
|
|
97
|
+
assert.deepEqual(exportedDeclarationCounts(src), { abstract: 0, concrete: 1 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── findCycles — pure graph walk, no fs ──────────────────────────────────
|
|
101
|
+
|
|
102
|
+
test('findCycles: a real 3-node cycle is found as a path back to the start', () => {
|
|
103
|
+
const edges = new Map([
|
|
104
|
+
['a', new Set(['b'])],
|
|
105
|
+
['b', new Set(['c'])],
|
|
106
|
+
['c', new Set(['a'])],
|
|
107
|
+
]);
|
|
108
|
+
const cycles = findCycles(edges, 'a');
|
|
109
|
+
assert.equal(cycles.length, 1);
|
|
110
|
+
assert.deepEqual(cycles[0], ['a', 'b', 'c', 'a']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('findCycles: an acyclic graph returns no cycles', () => {
|
|
114
|
+
const edges = new Map([
|
|
115
|
+
['a', new Set(['b'])],
|
|
116
|
+
['b', new Set(['c'])],
|
|
117
|
+
['c', new Set()],
|
|
118
|
+
]);
|
|
119
|
+
assert.deepEqual(findCycles(edges, 'a'), []);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── buildImportGraph / packageMetrics — real tmp-file fixtures ──────────
|
|
123
|
+
|
|
124
|
+
test('packageMetrics: Ca/Ce reflect the real cross-package import graph, and a mutual import is a real detected cycle', (t) => {
|
|
125
|
+
const root = withTmpRoot(t);
|
|
126
|
+
const a = makePkg(root, 'pkg-a', { 'index.mjs': `import { b } from 'pkg-b';` });
|
|
127
|
+
const b = makePkg(root, 'pkg-b', { 'index.mjs': `import { a } from 'pkg-a';` });
|
|
128
|
+
const dirs = [a, b];
|
|
129
|
+
|
|
130
|
+
const ma = packageMetrics({ packageDir: a, allPackageDirs: dirs });
|
|
131
|
+
assert.equal(ma.ca, 1); // pkg-b imports pkg-a
|
|
132
|
+
assert.equal(ma.ce, 1); // pkg-a imports pkg-b
|
|
133
|
+
assert.equal(ma.instability, 0.5);
|
|
134
|
+
assert.equal(ma.cycles.length, 1);
|
|
135
|
+
assert.deepEqual(ma.cycles[0], ['pkg-a', 'pkg-b', 'pkg-a']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('packageMetrics: an isolated package (no imports, nothing imports it) has null instability and zone "unmeasurable"', (t) => {
|
|
139
|
+
const root = withTmpRoot(t);
|
|
140
|
+
const iso = makePkg(root, 'pkg-iso', { 'index.mjs': `export const x = 1;` });
|
|
141
|
+
const m = packageMetrics({ packageDir: iso, allPackageDirs: [iso] });
|
|
142
|
+
assert.equal(m.ca, 0);
|
|
143
|
+
assert.equal(m.ce, 0);
|
|
144
|
+
assert.equal(m.instability, null);
|
|
145
|
+
assert.equal(m.zone, 'unmeasurable');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('packageMetrics: abstractnessBasis "no-type-syntax" for a package with only .mjs source', (t) => {
|
|
149
|
+
const root = withTmpRoot(t);
|
|
150
|
+
const dir = makePkg(root, 'pkg-plain-js', { 'index.mjs': `export const x = 1;` });
|
|
151
|
+
const m = packageMetrics({ packageDir: dir, allPackageDirs: [dir] });
|
|
152
|
+
assert.equal(m.abstractnessBasis, 'no-type-syntax');
|
|
153
|
+
assert.equal(m.abstractness, null);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('packageMetrics: abstractnessBasis "no-exported-declarations" for a .ts file with zero exports', (t) => {
|
|
157
|
+
const root = withTmpRoot(t);
|
|
158
|
+
const dir = makePkg(root, 'pkg-empty-ts', { 'src/index.ts': `const internal = 1;` });
|
|
159
|
+
const m = packageMetrics({ packageDir: dir, allPackageDirs: [dir] });
|
|
160
|
+
assert.equal(m.abstractnessBasis, 'no-exported-declarations');
|
|
161
|
+
assert.equal(m.abstractness, null);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('packageMetrics: abstractnessBasis "measured" computes a real interface/total ratio', (t) => {
|
|
165
|
+
const root = withTmpRoot(t);
|
|
166
|
+
const dir = makePkg(root, 'pkg-measured', {
|
|
167
|
+
'src/index.ts': `export interface Foo {}\nexport class Bar {}\nexport const baz = 1;`,
|
|
168
|
+
});
|
|
169
|
+
const m = packageMetrics({ packageDir: dir, allPackageDirs: [dir] });
|
|
170
|
+
assert.equal(m.abstractnessBasis, 'measured');
|
|
171
|
+
assert.equal(m.abstractness, 1 / 3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('packageMetrics: a test file\'s embedded string-literal "export interface" fixture does NOT count toward abstractness', (t) => {
|
|
175
|
+
const root = withTmpRoot(t);
|
|
176
|
+
const dir = makePkg(root, 'pkg-with-test', {
|
|
177
|
+
'src/index.mjs': `export const real = 1;`,
|
|
178
|
+
'test/fixture.test.ts': `export interface Page {}`, // a real .ts declaration, but inside a test file
|
|
179
|
+
});
|
|
180
|
+
const m = packageMetrics({ packageDir: dir, allPackageDirs: [dir] });
|
|
181
|
+
// the test .ts file must NOT flip hasTsFile / contribute declarations —
|
|
182
|
+
// this package is genuinely all-.mjs source once test files are excluded
|
|
183
|
+
assert.equal(m.abstractnessBasis, 'no-type-syntax');
|
|
184
|
+
assert.equal(m.abstractness, null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('packageMetrics: zone "main-sequence" when instability and abstractness both sit at 0.5 (distance 0)', (t) => {
|
|
188
|
+
const root = withTmpRoot(t);
|
|
189
|
+
// pkg-g imports pkg-h (ce=1 for g); pkg-i imports pkg-g (ca=1 for g) -> instability(g) = 1/(1+1) = 0.5
|
|
190
|
+
const g = makePkg(root, 'pkg-g', { 'src/index.ts': `import { h } from 'pkg-h';\nexport interface X {}\nexport class Y {}` }); // abstractness 0.5
|
|
191
|
+
const h = makePkg(root, 'pkg-h', { 'index.mjs': `export const x = 1;` });
|
|
192
|
+
const i = makePkg(root, 'pkg-i', { 'index.mjs': `import { g } from 'pkg-g';` });
|
|
193
|
+
const dirs = [g, h, i];
|
|
194
|
+
const m = packageMetrics({ packageDir: g, allPackageDirs: dirs });
|
|
195
|
+
assert.equal(m.instability, 0.5);
|
|
196
|
+
assert.equal(m.abstractness, 0.5);
|
|
197
|
+
assert.equal(m.distanceFromMainSequence, 0);
|
|
198
|
+
assert.equal(m.zone, 'main-sequence');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('packageMetrics: zone "zone-of-pain" — low instability AND low abstractness, far off the main sequence', (t) => {
|
|
202
|
+
const root = withTmpRoot(t);
|
|
203
|
+
const k = makePkg(root, 'pkg-k', { 'src/index.ts': `export class OnlyConcrete {}` }); // abstractness 0
|
|
204
|
+
const l = makePkg(root, 'pkg-l', { 'index.mjs': `import { k } from 'pkg-k';` }); // pkg-l imports pkg-k -> ca(k)=1, ce(k)=0
|
|
205
|
+
const m = packageMetrics({ packageDir: k, allPackageDirs: [k, l] });
|
|
206
|
+
assert.equal(m.instability, 0); // ce=0, ca=1 -> 0/1
|
|
207
|
+
assert.equal(m.abstractness, 0);
|
|
208
|
+
assert.equal(m.zone, 'zone-of-pain');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('packageMetrics: zone "zone-of-uselessness" — high instability AND high abstractness, far off the main sequence', (t) => {
|
|
212
|
+
const root = withTmpRoot(t);
|
|
213
|
+
const n = makePkg(root, 'pkg-n', { 'index.mjs': `export const x = 1;` });
|
|
214
|
+
const m2 = makePkg(root, 'pkg-m', { 'src/index.ts': `import { n } from 'pkg-n';\nexport interface OnlyAbstract {}` }); // abstractness 1, ce=1, ca=0
|
|
215
|
+
const dirs = [n, m2];
|
|
216
|
+
const m = packageMetrics({ packageDir: m2, allPackageDirs: dirs });
|
|
217
|
+
assert.equal(m.instability, 1); // ce=1, ca=0 -> 1/1
|
|
218
|
+
assert.equal(m.abstractness, 1);
|
|
219
|
+
assert.equal(m.zone, 'zone-of-uselessness');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Note: the fourth branch, "off-main-sequence", is unreachable within the
|
|
223
|
+
// valid metric domain. Both instability and abstractness are true ratios of
|
|
224
|
+
// non-negative counts and therefore always lie in [0,1]. distance = |A+I-1|
|
|
225
|
+
// exceeds 0.5 only when A+I < 0.5 or A+I > 1.5; given A,I in [0,1], the first
|
|
226
|
+
// forces BOTH A<0.5 and I<0.5 (zone-of-pain's own condition), and the second
|
|
227
|
+
// forces BOTH A>0.5 and I>0.5 (zone-of-uselessness's own condition) — so
|
|
228
|
+
// every input that clears the >0.5 distance gate necessarily also satisfies
|
|
229
|
+
// one of the two named zone conditions first. No literal fixture can reach
|
|
230
|
+
// the `else` branch; this is a structural proof, not a coverage gap.
|
|
231
|
+
|
|
232
|
+
test('packageMetricsAll: computes metrics for every sibling package in one graph build', (t) => {
|
|
233
|
+
const root = withTmpRoot(t);
|
|
234
|
+
const a = makePkg(root, 'pkg-all-a', { 'index.mjs': `import { b } from 'pkg-all-b';` });
|
|
235
|
+
const b = makePkg(root, 'pkg-all-b', { 'index.mjs': `export const x = 1;` });
|
|
236
|
+
const results = packageMetricsAll([a, b]);
|
|
237
|
+
assert.equal(results.length, 2);
|
|
238
|
+
const byName = Object.fromEntries(results.map((r) => [r.name, r]));
|
|
239
|
+
assert.equal(byName['pkg-all-a'].ce, 1);
|
|
240
|
+
assert.equal(byName['pkg-all-b'].ca, 1);
|
|
241
|
+
});
|