@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,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package Design metrics — Robert C. Martin's package-level cohesion/coupling
|
|
3
|
+
* suite, operating on PACKAGES (a directory with its own `package.json`, or
|
|
4
|
+
* a declared module boundary passed in explicitly), not files/classes.
|
|
5
|
+
*
|
|
6
|
+
* `solid-validator`'s five scorers (SRP/OCP/LSP/ISP/DIP, driven by
|
|
7
|
+
* `scripts/lib/language-plugin.mjs` + `scripts/lib/plugins/typescript.mjs`)
|
|
8
|
+
* already cover the file/class level. This file is one level up: the
|
|
9
|
+
* package-dependency GRAPH across a set of sibling packages.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately independent of the ts-morph plugin (both being edited
|
|
12
|
+
* concurrently by another agent tonight — no import from either file here).
|
|
13
|
+
* Import extraction is a lightweight regex/text scan over plain source via
|
|
14
|
+
* `node:fs`, not an AST. That is a real, disclosed limitation — see the
|
|
15
|
+
* per-function notes below for exactly what it can and cannot see.
|
|
16
|
+
*
|
|
17
|
+
* ---- Reference check: OnSightTeam/architecture-toolkit (MIT) ----
|
|
18
|
+
*
|
|
19
|
+
* Per operator instruction, checked this implementation's formulas against
|
|
20
|
+
* github.com/OnSightTeam/architecture-toolkit's
|
|
21
|
+
* `src/agents/package-design/tools/{stability-metrics-calculator,
|
|
22
|
+
* package-coupling-analyzer}.ts` (fetched via `gh api` / raw.githubusercontent,
|
|
23
|
+
* MIT per its package.json `license` field and README `## License` section).
|
|
24
|
+
*
|
|
25
|
+
* Confirmed independently, not copied — both are short enough that there is
|
|
26
|
+
* nothing to adapt, only to check against:
|
|
27
|
+
* - D = |A + I − 1| — stability-metrics-calculator.ts:29
|
|
28
|
+
* (`Math.abs(abstractness + stability - 1)`, where their local variable
|
|
29
|
+
* named `stability` is computed as efferent/(efferent+afferent), i.e.
|
|
30
|
+
* Martin's INSTABILITY, not stability — same formula as `distanceFromMainSequence`
|
|
31
|
+
* below, matches Martin's own definition).
|
|
32
|
+
* - Zone-of-Pain / Zone-of-Uselessness split at instability<0.5 && A<0.5 —
|
|
33
|
+
* stability-metrics-calculator.ts:94-96 and package-coupling-analyzer.ts:127
|
|
34
|
+
* — matches the `zone()` classification below.
|
|
35
|
+
*
|
|
36
|
+
* NOT reused — both are genuinely broken, confirmed by reading, not assumed:
|
|
37
|
+
* - Their `countAfferentCoupling` (stability-metrics-calculator.ts:60-75)
|
|
38
|
+
* `continue`s on every file whose path contains the target package name,
|
|
39
|
+
* then re-tests the identical condition on what's left — that branch is
|
|
40
|
+
* dead code. Ca is always 0 there, so I is always 1 whenever Ce>0. Not a
|
|
41
|
+
* real afferent count.
|
|
42
|
+
* - Their ADP cycle walk's `extractDependencies` (package-coupling-analyzer.ts:154-176)
|
|
43
|
+
* keys the whole dependency map off `this.getPackageName('')`, which
|
|
44
|
+
* always resolves to the literal string `'root'` (see its
|
|
45
|
+
* `getPackageName`, ...:149-152, on an empty path) — so cross-file cycle
|
|
46
|
+
* detection never actually walks a real multi-package graph; it only
|
|
47
|
+
* ever inspects the current file's own single-hop relative imports in
|
|
48
|
+
* isolation.
|
|
49
|
+
* - Their abstractness scan counts EVERY `class`/`interface` declaration in
|
|
50
|
+
* a file, exported or not, and has no notion of `type` aliases at all.
|
|
51
|
+
*
|
|
52
|
+
* `buildImportGraph` below resolves relative and bare-specifier imports to
|
|
53
|
+
* actual sibling package directories across the WHOLE package tree (not one
|
|
54
|
+
* file read in isolation), so Ca/Ce and `cycles` here are real multi-file,
|
|
55
|
+
* multi-package graph facts, dogfooded against `rdc-harness/packages/*`
|
|
56
|
+
* (21 packages, cross-checked by hand against
|
|
57
|
+
* `grep -rn "from '\.\./\.\./"` — see the skill doc / task report for the
|
|
58
|
+
* full positive-control table).
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
62
|
+
import path from 'node:path';
|
|
63
|
+
|
|
64
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.turbo', '.next']);
|
|
65
|
+
const SOURCE_EXT = new Set(['.mjs', '.js', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// File walking
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** @returns {string[]} absolute paths of every source file under dir, recursively */
|
|
72
|
+
function walkSourceFiles(dir) {
|
|
73
|
+
const out = [];
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
82
|
+
const full = path.join(dir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
85
|
+
out.push(...walkSourceFiles(full));
|
|
86
|
+
} else if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name))) {
|
|
87
|
+
out.push(full);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Import/export extraction — plain regex, no AST.
|
|
95
|
+
//
|
|
96
|
+
// Deliberately misses: type-only imports written with unusual whitespace
|
|
97
|
+
// gymnastics, re-exports hidden behind a computed/template specifier,
|
|
98
|
+
// anything generated at runtime (`require(someVar)`). Good enough for the
|
|
99
|
+
// overwhelming majority of ESM/CJS import statements, which is the honest
|
|
100
|
+
// ceiling of a regex approach — this is disclosed, not hidden.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const IMPORT_FROM_RE = /\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s+['"]([^'"]+)['"]/g;
|
|
104
|
+
const IMPORT_BARE_RE = /\bimport\s+['"]([^'"]+)['"]/g;
|
|
105
|
+
const EXPORT_FROM_RE = /\bexport\s+(?:type\s+)?(?:\*|\{[^}]*\})\s*from\s+['"]([^'"]+)['"]/g;
|
|
106
|
+
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
107
|
+
const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
108
|
+
|
|
109
|
+
function stripComments(text) {
|
|
110
|
+
// Block comments, then line comments. Simple on purpose: this only needs
|
|
111
|
+
// to avoid matching an import specifier that appears inside a comment
|
|
112
|
+
// (e.g. this file's own doc-comment above, which quotes real import
|
|
113
|
+
// syntax) — it is not a tokenizer.
|
|
114
|
+
return text
|
|
115
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
|
|
116
|
+
.replace(/(^|[^:"'])\/\/.*$/gm, '$1');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} sourceText
|
|
121
|
+
* @returns {string[]} raw module specifiers this file imports/requires
|
|
122
|
+
*/
|
|
123
|
+
export function extractImportSpecifiers(sourceText) {
|
|
124
|
+
const clean = stripComments(sourceText);
|
|
125
|
+
const specs = new Set();
|
|
126
|
+
for (const re of [IMPORT_FROM_RE, IMPORT_BARE_RE, EXPORT_FROM_RE, REQUIRE_RE, DYNAMIC_IMPORT_RE]) {
|
|
127
|
+
re.lastIndex = 0;
|
|
128
|
+
let m;
|
|
129
|
+
while ((m = re.exec(clean))) specs.add(m[1]);
|
|
130
|
+
}
|
|
131
|
+
return [...specs];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* True for a file whose declaration-site exports should NOT count toward
|
|
136
|
+
* package abstractness. Test files routinely embed source-as-STRING fixtures
|
|
137
|
+
* (`` `export interface Page { ... }` `` as a template-literal test input,
|
|
138
|
+
* not a real declaration of the package under test) that a regex scanner
|
|
139
|
+
* cannot distinguish from real code without a full lexer — confirmed live:
|
|
140
|
+
* `rdc-harness/packages/e2e/test/breadth-lifecycle.test.mjs` embeds exactly
|
|
141
|
+
* that fixture and was originally mis-measured as A=0.105 "measured" for a
|
|
142
|
+
* plain-.mjs package with zero real type declarations. Test files also
|
|
143
|
+
* aren't the package's public contract in Martin's sense regardless of the
|
|
144
|
+
* string-literal risk, so excluding them from THIS scan (not from the
|
|
145
|
+
* Ca/Ce import graph, which legitimately counts test-time coupling too) is
|
|
146
|
+
* correct on both grounds, not just a patch for the false positive.
|
|
147
|
+
*/
|
|
148
|
+
function isTestFile(filePath) {
|
|
149
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
150
|
+
if (/\/(test|tests|__tests__)\//.test(norm)) return true;
|
|
151
|
+
return /\.(test|spec)\.[a-z]+$/.test(norm);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Declaration-site exports only (`export interface Foo`, `export const Foo = ...`).
|
|
155
|
+
// `export { a, b }` re-export lists are intentionally NOT counted here — they
|
|
156
|
+
// name existing declarations rather than introducing new ones, and counting
|
|
157
|
+
// both would double-count the same declaration under two different exports.
|
|
158
|
+
const EXPORT_DECL_RE =
|
|
159
|
+
/\bexport\s+(?:default\s+)?(?:declare\s+)?(abstract\s+class|interface|type|class|function\s*\*?|const|let|var|enum)\s+([A-Za-z0-9_$]+)/g;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} sourceText
|
|
163
|
+
* @returns {{ abstract: number, concrete: number }}
|
|
164
|
+
*
|
|
165
|
+
* NOTE: this does NOT decide measurability. A `.ts` file with zero
|
|
166
|
+
* interfaces/types (all classes/functions/consts) is a real, honest A=0 —
|
|
167
|
+
* "fully concrete" is a legitimate measurement, not an absence of one.
|
|
168
|
+
* Measurability is decided in `buildImportGraph` from the file EXTENSION
|
|
169
|
+
* (does this package contain any `.ts`/`.tsx` source at all), never from
|
|
170
|
+
* whether abstract-shaped keywords happen to appear in it. Gating on
|
|
171
|
+
* keyword presence was tried first and was wrong: it reported a genuine
|
|
172
|
+
* all-concrete TypeScript package (fixture `stable/src/index.ts`, three
|
|
173
|
+
* exported classes/consts, zero interfaces) as "no-type-syntax"/null
|
|
174
|
+
* instead of the correct A=0 — caught by the synthetic Zone-of-Pain/
|
|
175
|
+
* Zone-of-Uselessness fixture run during dogfooding, see the task report.
|
|
176
|
+
*/
|
|
177
|
+
export function exportedDeclarationCounts(sourceText) {
|
|
178
|
+
const clean = stripComments(sourceText);
|
|
179
|
+
let abstract = 0;
|
|
180
|
+
let concrete = 0;
|
|
181
|
+
EXPORT_DECL_RE.lastIndex = 0;
|
|
182
|
+
let m;
|
|
183
|
+
while ((m = EXPORT_DECL_RE.exec(clean))) {
|
|
184
|
+
const kind = m[1].trim().replace(/\s*\*$/, '');
|
|
185
|
+
if (kind === 'interface' || kind === 'type') abstract++;
|
|
186
|
+
else concrete++;
|
|
187
|
+
}
|
|
188
|
+
return { abstract, concrete };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Package identity
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} dir absolute path to a package directory
|
|
197
|
+
* @returns {{ dir: string, name: string, hasPackageJson: boolean }}
|
|
198
|
+
*/
|
|
199
|
+
function packageIdentity(dir) {
|
|
200
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
201
|
+
if (existsSync(pkgJsonPath)) {
|
|
202
|
+
try {
|
|
203
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
204
|
+
if (pkg.name) return { dir, name: pkg.name, hasPackageJson: true };
|
|
205
|
+
} catch {
|
|
206
|
+
// fall through to dirname
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { dir, name: path.basename(dir), hasPackageJson: existsSync(pkgJsonPath) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalize(p) {
|
|
213
|
+
return path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a module specifier found in `fromFile` to a sibling package, if it
|
|
218
|
+
* points at one.
|
|
219
|
+
*
|
|
220
|
+
* @returns {string|null} the target package's declared name, or null if the
|
|
221
|
+
* specifier doesn't resolve to any of `packages` (npm dependency, node
|
|
222
|
+
* builtin, or an intra-package relative import).
|
|
223
|
+
*/
|
|
224
|
+
function resolveToPackage(specifier, fromFile, packages, nameToIndex, dirsSortedByLenDesc) {
|
|
225
|
+
if (specifier.startsWith('.')) {
|
|
226
|
+
const resolved = normalize(path.resolve(path.dirname(fromFile), specifier));
|
|
227
|
+
for (const pkg of dirsSortedByLenDesc) {
|
|
228
|
+
const pdir = normalize(pkg.dir);
|
|
229
|
+
if (resolved === pdir || resolved.startsWith(pdir + '/')) return pkg.name;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
// Bare specifier: exact package-name match (`@scope/name`) or, as a
|
|
234
|
+
// fallback, a bare dirname match (`name`) — some monorepos import by
|
|
235
|
+
// dirname via a workspace alias rather than the declared package.json name.
|
|
236
|
+
if (nameToIndex.has(specifier)) return packages[nameToIndex.get(specifier)].name;
|
|
237
|
+
const byDirname = packages.find((p) => path.basename(p.dir) === specifier);
|
|
238
|
+
return byDirname ? byDirname.name : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Import graph across the whole sibling set
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string[]} packageDirs absolute paths
|
|
247
|
+
* @returns {{
|
|
248
|
+
* packages: {dir:string,name:string,hasPackageJson:boolean}[],
|
|
249
|
+
* ceEdges: Map<string, Set<string>>, // pkgName -> set of pkgNames it imports from
|
|
250
|
+
* caEdges: Map<string, Set<string>>, // pkgName -> set of pkgNames that import it
|
|
251
|
+
* abstractness: Map<string, {abstract:number, concrete:number, hasTsFile:boolean}>,
|
|
252
|
+
* }}
|
|
253
|
+
*/
|
|
254
|
+
export function buildImportGraph(packageDirs) {
|
|
255
|
+
const packages = packageDirs.map(packageIdentity);
|
|
256
|
+
const dirsSortedByLenDesc = [...packages].sort((a, b) => b.dir.length - a.dir.length);
|
|
257
|
+
const nameToIndex = new Map(packages.map((p, i) => [p.name, i]));
|
|
258
|
+
|
|
259
|
+
const ceEdges = new Map(packages.map((p) => [p.name, new Set()]));
|
|
260
|
+
const caEdges = new Map(packages.map((p) => [p.name, new Set()]));
|
|
261
|
+
const abstractness = new Map(
|
|
262
|
+
packages.map((p) => [p.name, { abstract: 0, concrete: 0, hasTsFile: false }]),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
for (const pkg of packages) {
|
|
266
|
+
const files = walkSourceFiles(pkg.dir);
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
let text;
|
|
269
|
+
try {
|
|
270
|
+
text = readFileSync(file, 'utf8');
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const specs = extractImportSpecifiers(text);
|
|
276
|
+
for (const spec of specs) {
|
|
277
|
+
const targetName = resolveToPackage(spec, file, packages, nameToIndex, dirsSortedByLenDesc);
|
|
278
|
+
if (!targetName || targetName === pkg.name) continue;
|
|
279
|
+
ceEdges.get(pkg.name).add(targetName);
|
|
280
|
+
caEdges.get(targetName).add(pkg.name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (isTestFile(file)) continue; // Ca/Ce above already counted this file's real edges
|
|
284
|
+
|
|
285
|
+
const agg = abstractness.get(pkg.name);
|
|
286
|
+
// Measurability is decided by EXTENSION — a `.ts`/`.tsx` file is real
|
|
287
|
+
// TypeScript regardless of whether it happens to declare any
|
|
288
|
+
// interfaces/types. See exportedDeclarationCounts' doc comment for why
|
|
289
|
+
// keyword-sniffing was tried and rejected.
|
|
290
|
+
if (/\.tsx?$/.test(file)) agg.hasTsFile = true;
|
|
291
|
+
|
|
292
|
+
const decl = exportedDeclarationCounts(text);
|
|
293
|
+
agg.abstract += decl.abstract;
|
|
294
|
+
agg.concrete += decl.concrete;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { packages, ceEdges, caEdges, abstractness };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// ADP — Acyclic Dependencies Principle: real cycle detection over the graph
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Find every simple cycle that passes through `startName`, as an actual
|
|
307
|
+
* package-name path (not just "a cycle exists").
|
|
308
|
+
*
|
|
309
|
+
* @param {Map<string, Set<string>>} ceEdges
|
|
310
|
+
* @param {string} startName
|
|
311
|
+
* @returns {string[][]} each entry is a cycle path, e.g. ['a','b','c','a']
|
|
312
|
+
*/
|
|
313
|
+
export function findCycles(ceEdges, startName) {
|
|
314
|
+
const cycles = [];
|
|
315
|
+
const stack = [];
|
|
316
|
+
const onStack = new Set();
|
|
317
|
+
const seenCyclesKey = new Set();
|
|
318
|
+
|
|
319
|
+
function dfs(node) {
|
|
320
|
+
stack.push(node);
|
|
321
|
+
onStack.add(node);
|
|
322
|
+
for (const next of ceEdges.get(node) ?? []) {
|
|
323
|
+
if (next === startName) {
|
|
324
|
+
const path = [...stack, startName];
|
|
325
|
+
const key = path.join('>');
|
|
326
|
+
if (!seenCyclesKey.has(key)) {
|
|
327
|
+
seenCyclesKey.add(key);
|
|
328
|
+
cycles.push(path);
|
|
329
|
+
}
|
|
330
|
+
} else if (!onStack.has(next)) {
|
|
331
|
+
dfs(next);
|
|
332
|
+
}
|
|
333
|
+
// if `next` is on the stack but isn't startName, that's a cycle NOT
|
|
334
|
+
// involving startName — out of scope for "cycles involving this
|
|
335
|
+
// package", left for a whole-graph sweep if ever needed.
|
|
336
|
+
}
|
|
337
|
+
stack.pop();
|
|
338
|
+
onStack.delete(node);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
dfs(startName);
|
|
342
|
+
return cycles;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Public API
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* @typedef {object} PackageMetricsResult
|
|
351
|
+
* @property {string} name
|
|
352
|
+
* @property {number} ca - count of OTHER packages that import from this one
|
|
353
|
+
* @property {number} ce - count of OTHER packages this one imports from
|
|
354
|
+
* @property {number|null} instability - Ce/(Ca+Ce); null if Ca+Ce===0 (isolated, no coupling data)
|
|
355
|
+
* @property {number|null} abstractness - exported interface+type / exported total; null if unmeasurable
|
|
356
|
+
* @property {'measured'|'no-type-syntax'|'no-exported-declarations'} abstractnessBasis
|
|
357
|
+
* @property {number|null} distanceFromMainSequence - |A+I-1|; null if either input is null
|
|
358
|
+
* @property {string[][]} cycles - real cycle paths through this package (ADP violations)
|
|
359
|
+
* @property {'main-sequence'|'zone-of-pain'|'zone-of-uselessness'|'off-main-sequence'|'unmeasurable'} zone
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @param {{ packageDir: string, allPackageDirs: string[] }} args
|
|
364
|
+
* @returns {PackageMetricsResult}
|
|
365
|
+
*/
|
|
366
|
+
export function packageMetrics({ packageDir, allPackageDirs }) {
|
|
367
|
+
const dirs = allPackageDirs.includes(packageDir) ? allPackageDirs : [...allPackageDirs, packageDir];
|
|
368
|
+
const graph = graphCache(dirs);
|
|
369
|
+
return metricsFor(graph, packageIdentity(packageDir).name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Cache the graph per unique dir-set within a process — the CLI computes
|
|
373
|
+
// metrics for every sibling package in one run and would otherwise rebuild
|
|
374
|
+
// the identical graph N times.
|
|
375
|
+
const _graphCacheStore = new Map();
|
|
376
|
+
function graphCache(dirs) {
|
|
377
|
+
const key = [...dirs].map((d) => normalize(d)).sort().join('|');
|
|
378
|
+
if (!_graphCacheStore.has(key)) _graphCacheStore.set(key, buildImportGraph(dirs));
|
|
379
|
+
return _graphCacheStore.get(key);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function metricsFor(graph, pkgName) {
|
|
383
|
+
const ce = graph.ceEdges.get(pkgName)?.size ?? 0;
|
|
384
|
+
const ca = graph.caEdges.get(pkgName)?.size ?? 0;
|
|
385
|
+
const total = ca + ce;
|
|
386
|
+
const instability = total === 0 ? null : ce / total;
|
|
387
|
+
|
|
388
|
+
const decl = graph.abstractness.get(pkgName) ?? { abstract: 0, concrete: 0, hasTsFile: false };
|
|
389
|
+
let abstractness = null;
|
|
390
|
+
let abstractnessBasis = 'no-type-syntax';
|
|
391
|
+
if (decl.hasTsFile) {
|
|
392
|
+
const declTotal = decl.abstract + decl.concrete;
|
|
393
|
+
if (declTotal === 0) {
|
|
394
|
+
abstractnessBasis = 'no-exported-declarations';
|
|
395
|
+
} else {
|
|
396
|
+
abstractness = decl.abstract / declTotal;
|
|
397
|
+
abstractnessBasis = 'measured';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const distanceFromMainSequence =
|
|
402
|
+
instability === null || abstractness === null ? null : Math.abs(abstractness + instability - 1);
|
|
403
|
+
|
|
404
|
+
const cycles = findCycles(graph.ceEdges, pkgName);
|
|
405
|
+
|
|
406
|
+
let zone;
|
|
407
|
+
if (distanceFromMainSequence === null) {
|
|
408
|
+
zone = 'unmeasurable';
|
|
409
|
+
} else if (distanceFromMainSequence <= 0.5) {
|
|
410
|
+
zone = 'main-sequence';
|
|
411
|
+
} else if (instability < 0.5 && abstractness < 0.5) {
|
|
412
|
+
zone = 'zone-of-pain';
|
|
413
|
+
} else if (instability > 0.5 && abstractness > 0.5) {
|
|
414
|
+
zone = 'zone-of-uselessness';
|
|
415
|
+
} else {
|
|
416
|
+
zone = 'off-main-sequence';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
name: pkgName,
|
|
421
|
+
ca,
|
|
422
|
+
ce,
|
|
423
|
+
instability,
|
|
424
|
+
abstractness,
|
|
425
|
+
abstractnessBasis,
|
|
426
|
+
distanceFromMainSequence,
|
|
427
|
+
cycles,
|
|
428
|
+
zone,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {string[]} packageDirs absolute paths to every sibling package
|
|
434
|
+
* @returns {PackageMetricsResult[]}
|
|
435
|
+
*/
|
|
436
|
+
export function packageMetricsAll(packageDirs) {
|
|
437
|
+
const graph = graphCache(packageDirs);
|
|
438
|
+
return graph.packages.map((p) => metricsFor(graph, p.name));
|
|
439
|
+
}
|