@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,695 @@
1
+ /**
2
+ * Clean Architecture boundary / dependency-direction / layer-separation
3
+ * scoring — self-contained, language-independent, same discipline as
4
+ * `package-metrics.mjs` (plain `node:fs` + regex import/path parsing, NO
5
+ * AST, NO ts-morph). Deliberately does not import `language-plugin.mjs` or
6
+ * `plugins/typescript.mjs` — those are owned by parallel work tonight, and
7
+ * Clean Architecture's boundary rule ("dependencies point inward only") is
8
+ * fundamentally a FILE-PATH + IMPORT-TARGET question, which a regex/path
9
+ * scan answers exactly as well as an AST would.
10
+ *
11
+ * Only cross-file import it takes is `extractImportSpecifiers` and
12
+ * `findCycles` from `./package-metrics.mjs` (its own already-exported public
13
+ * API, read-only reuse — no edit to that file) — same-tree specifier
14
+ * extraction and the same real ADP cycle-walk algorithm, now applied to
15
+ * LAYERS instead of PACKAGES.
16
+ *
17
+ * ---- Source: OnSightTeam/architecture-toolkit (MIT) ----
18
+ *
19
+ * Fetched in full via raw.githubusercontent.com 2026-08-20 (not summarized,
20
+ * not guessed) and read end-to-end:
21
+ * - src/agents/architecture-reviewer/tools/dependency-rule-validator.ts (213 lines)
22
+ * - src/agents/architecture-reviewer/tools/boundary-analysis-validator.ts (169 lines)
23
+ * - src/agents/architecture-reviewer/tools/layer-separation-validator.ts (145 lines)
24
+ *
25
+ * architecture-toolkit's OWN `.claude/skills/review-arch.md` shells out to
26
+ * `node dist/cli.js --agents=architecture <paths>` — a fully deterministic
27
+ * CLI, no LLM judgment step at all for this domain in the source project.
28
+ * This file mirrors that: every check below is a regex/path fact, not a
29
+ * dispatched opinion.
30
+ *
31
+ * Every rule function cites the exact toolkit file:line(s) it ports or
32
+ * adapts. Two things are DELIBERATELY NOT ported, because they are honestly
33
+ * broken in the source, the same standard `package-metrics.mjs` already
34
+ * applied to a different architecture-toolkit file:
35
+ * - `dependency-rule-validator.ts`'s own `checkCircularDependencies`
36
+ * (lines 119-146) is not a cycle walk at all — it flags "3+ levels of
37
+ * `../../..`" as a *proxy* for circularity, which is neither necessary
38
+ * nor sufficient (a deeply-relative import can be perfectly acyclic; a
39
+ * one-hop `./foo` can be one edge of a real 2-node cycle). This file's
40
+ * `circularLayerDependencyFindings` instead builds a REAL layer-level
41
+ * import graph from resolved imports and runs `findCycles` — an actual
42
+ * graph walk, not a path-depth guess.
43
+ * - Layer detection for an UNRESOLVED import (a bare specifier, or a
44
+ * relative import that doesn't resolve to any scanned file) falls back
45
+ * to `detectLayerFromImport`'s keyword-substring match
46
+ * (dependency-rule-validator.ts:177-193) — ported as-is, but flagged
47
+ * `confidence: 'low'` in the finding, never conflated with a RESOLVED
48
+ * file's real classified layer (`confidence: 'high'`). The toolkit does
49
+ * not make this distinction; this file does, because the two are very
50
+ * different strengths of evidence.
51
+ *
52
+ * ── Layer classification — configurable, not hardcoded ─────────────────
53
+ * `DEFAULT_LAYERS` below encodes Clean Architecture's four rings
54
+ * (Entities > UseCases > InterfaceAdapters > Frameworks, `level` descending
55
+ * = more inner/protected) as a glob-per-layer list, exactly the shape
56
+ * `solid-score.mjs`'s `boundaries` config already established for a
57
+ * DIFFERENT check (`{orchestrator, requiredPorts}`) — same config-loading
58
+ * discipline (`--config`, `configPath`/`configLoaded` reported in output),
59
+ * separate config section (`layers:`) because it answers a different
60
+ * question. A repo names its folders however it wants; `layers` in
61
+ * `.architecture-score.yml` overrides `DEFAULT_LAYERS` entirely.
62
+ *
63
+ * Two classification passes, in order, cited separately in every finding:
64
+ * 1. PATH match against the layer's `globs` — `confidence: 'high'` basis.
65
+ * 2. Class/interface-NAME regex fallback (`nameHints`), ported near-
66
+ * verbatim from `detectLayer`'s code-regex half
67
+ * (dependency-rule-validator.ts:164-172) — `confidence: 'medium'`
68
+ * basis, since a name convention is a weaker signal than a declared
69
+ * folder boundary.
70
+ * A file matching neither is `layer: null` — reported as unclassified, not
71
+ * silently defaulted to "Frameworks" (the toolkit's own `detectLayer`
72
+ * defaults unmatched files to `Frameworks` at line 174; this file does NOT
73
+ * inherit that default, because "we don't know" and "this is definitely the
74
+ * outermost ring" are different claims, and defaulting to a specific answer
75
+ * would fabricate confidence a config-driven classifier does not have).
76
+ */
77
+
78
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
79
+ import path from 'node:path';
80
+
81
+ import { extractImportSpecifiers, findCycles } from './package-metrics.mjs';
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // File walking — sorted, for determinism (ATF golden-record requirement)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.turbo', '.next']);
88
+ const SOURCE_EXT = ['.mjs', '.js', '.cjs', '.ts', '.tsx', '.jsx'];
89
+ const SOURCE_EXT_SET = new Set(SOURCE_EXT);
90
+
91
+ /**
92
+ * @returns {string[]} absolute paths of every source file under dir,
93
+ * recursively, in a STABLE sort order (directory entries sorted by name
94
+ * before recursing) — `readdirSync` order is not guaranteed identical
95
+ * across filesystems/OSes, and this scorer's JSON output must be
96
+ * byte-identical across two runs on the same unchanged input.
97
+ */
98
+ export function walkSourceFiles(dir) {
99
+ const out = [];
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(dir, { withFileTypes: true });
103
+ } catch {
104
+ return out;
105
+ }
106
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
107
+ for (const entry of entries) {
108
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
109
+ const full = path.join(dir, entry.name);
110
+ if (entry.isDirectory()) {
111
+ if (SKIP_DIRS.has(entry.name)) continue;
112
+ out.push(...walkSourceFiles(full));
113
+ } else if (entry.isFile() && SOURCE_EXT_SET.has(path.extname(entry.name))) {
114
+ out.push(full);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Layer classification
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Clean Architecture's four rings. `level` descending = more inner
126
+ * (protected); the Dependency Rule violation test is
127
+ * `targetLayer.level < currentLayer.level` — a current file depending on a
128
+ * STRICTLY MORE OUTER layer — exact match to
129
+ * `dependency-rule-validator.ts`'s `layerHierarchy` (lines 16-21) and its
130
+ * `targetLayerLevel < currentLayerLevel` test (line 79).
131
+ *
132
+ * `nameHints` are the class/interface-name regex fallback, ported from
133
+ * `detectLayer`'s code-regex half (dependency-rule-validator.ts:164-172).
134
+ */
135
+ export const DEFAULT_LAYERS = [
136
+ {
137
+ name: 'Entities',
138
+ level: 4,
139
+ globs: ['**/entities/**', '**/entity/**', '**/domain/**'],
140
+ nameHints: [/\bclass\s+\w+Entity\b/i, /\binterface\s+\w+Entity\b/i],
141
+ },
142
+ {
143
+ name: 'UseCases',
144
+ level: 3,
145
+ globs: ['**/use-cases/**', '**/usecases/**', '**/use-case/**', '**/usecase/**', '**/application/**'],
146
+ nameHints: [/\bclass\s+\w+UseCase\b/i, /\bclass\s+\w+Interactor\b/i],
147
+ },
148
+ {
149
+ name: 'InterfaceAdapters',
150
+ level: 2,
151
+ globs: ['**/adapters/**', '**/adapter/**', '**/controllers/**', '**/controller/**', '**/presenters/**', '**/gateways/**'],
152
+ nameHints: [/\bclass\s+\w+Controller\b/i, /\bclass\s+\w+Presenter\b/i],
153
+ },
154
+ {
155
+ name: 'Frameworks',
156
+ level: 1,
157
+ globs: ['**/frameworks/**', '**/framework/**', '**/infrastructure/**', '**/infra/**'],
158
+ nameHints: [],
159
+ },
160
+ ];
161
+
162
+ /**
163
+ * Framework/library indicators. The first block (through `morgan`) is
164
+ * `frameworkIndicators` VERBATIM from `dependency-rule-validator.ts:23-28`
165
+ * — including its two generic-word entries (`http`, `fetch`, `request`),
166
+ * which is exactly why THIS file labels findings from those three at
167
+ * `confidence: 'medium'` rather than `'high'` (see
168
+ * `frameworkCouplingFindings`) — a local variable literally named `request`
169
+ * or an import of Node's builtin `node:http` both match, and the toolkit's
170
+ * own substring check cannot tell the difference. The second block is a
171
+ * disclosed EXTENSION, not part of the cited source — added per this task's
172
+ * instruction to build "a real, cited list, not a guess."
173
+ */
174
+ export const FRAMEWORK_INDICATORS_TOOLKIT = [
175
+ 'express', 'fastify', 'nest', 'react', 'vue', 'angular',
176
+ 'axios', 'fetch', 'http', 'request',
177
+ 'mongoose', 'typeorm', 'prisma', 'sequelize',
178
+ 'winston', 'pino', 'morgan',
179
+ ];
180
+ export const FRAMEWORK_INDICATORS_EXTENDED = [
181
+ 'next', 'aws-sdk', '@aws-sdk/', 'pg', 'redis', 'ioredis', 'koa', 'graphql', 'apollo-server',
182
+ ];
183
+ export const FRAMEWORK_INDICATORS_GENERIC = new Set(['http', 'fetch', 'request']);
184
+ export const FRAMEWORK_INDICATORS = [...FRAMEWORK_INDICATORS_TOOLKIT, ...FRAMEWORK_INDICATORS_EXTENDED];
185
+
186
+ const LEAD_STAR_TOKEN = 'LEADSTAR'; // '**/' — zero or more LEADING path segments
187
+ const TRAIL_STAR_TOKEN = 'TRAILSTAR'; // '/**' — zero or more TRAILING path segments
188
+ const MID_STAR_TOKEN = 'MIDSTAR'; // bare '**' elsewhere
189
+
190
+ /**
191
+ * glob->RegExp, anchored on the FULL relative path (`^...$`).
192
+ *
193
+ * Started as a copy of `solid-score.mjs`'s `globToRegExp` (naive
194
+ * `**` -> `.*` substitution, wrapped in `(^|/)...(/|$)`), and this file's
195
+ * own POSITIVE CONTROL fixture (see task report) caught it as WRONG for a
196
+ * layer glob like `**` + `/frameworks/` + `**` against a TOP-LEVEL scanned
197
+ * path (`frameworks/db-client.mjs`, no parent directory): the naive
198
+ * translation is `.*` + `/frameworks/` + `.*`, which requires a literal `/`
199
+ * immediately before `frameworks` — a path with nothing before `frameworks`
200
+ * at all has no such slash to match, so it silently misclassified a real
201
+ * Frameworks-layer file as unclassified. `solid-score.mjs` never surfaced
202
+ * this because every glob it evaluates (test dirs, `node_modules`, …) is an
203
+ * EXCLUDE pattern normally matched against nested repo paths, not asserted
204
+ * to match a bare top-level segment — this file's layer globs regularly are.
205
+ *
206
+ * Fix: a leading `**` + slash and a trailing slash + `**` are each their own
207
+ * token, translated to an optional-leading-segments group and an
208
+ * optional-trailing-segments group respectively (see LEAD_STAR_TOKEN /
209
+ * TRAIL_STAR_TOKEN below) — instead of both collapsing through the same
210
+ * bare wildcard-to-any-chars substitution. A bare `**` elsewhere still
211
+ * becomes "match any characters."
212
+ */
213
+ function globToRegExp(glob) {
214
+ const specialChars = ['.', '+', '^', '$', '{', '}', '(', ')', '|', '[', ']'];
215
+ let escaped = glob;
216
+ for (const ch of specialChars) escaped = escaped.split(ch).join(`\\${ch}`);
217
+ let out = escaped
218
+ .split('**/').join(LEAD_STAR_TOKEN)
219
+ .split('/**').join(TRAIL_STAR_TOKEN)
220
+ .split('**').join(MID_STAR_TOKEN)
221
+ .split('*').join('[^/]*');
222
+ out = out
223
+ .split(LEAD_STAR_TOKEN).join('(?:.*/)?')
224
+ .split(TRAIL_STAR_TOKEN).join('(?:/.*)?')
225
+ .split(MID_STAR_TOKEN).join('.*');
226
+ return new RegExp(`^${out}$`);
227
+ }
228
+
229
+ /** Bare keyword per glob (strip `**`, `*`, slashes) — for the unresolved-import fallback below. */
230
+ function layerKeywords(layer) {
231
+ return layer.globs
232
+ .map((g) => g.replace(/\*+/g, '').replace(/\//g, '').toLowerCase())
233
+ .filter(Boolean);
234
+ }
235
+
236
+ /**
237
+ * @param {string} relPath posix-slash relative path
238
+ * @param {{name:string, level:number, globs:string[]}[]} layers
239
+ * @returns {{name:string, level:number}|null}
240
+ */
241
+ export function classifyLayerByPath(relPath, layers) {
242
+ for (const layer of layers) {
243
+ for (const glob of layer.globs) {
244
+ if (globToRegExp(glob).test(relPath)) return { name: layer.name, level: layer.level };
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * @param {string} sourceText
252
+ * @param {{name:string, level:number, nameHints?:RegExp[]}[]} layers
253
+ * @returns {{name:string, level:number}|null}
254
+ */
255
+ export function classifyLayerByNameHint(sourceText, layers) {
256
+ for (const layer of layers) {
257
+ for (const hint of layer.nameHints ?? []) {
258
+ if (hint.test(sourceText)) return { name: layer.name, level: layer.level };
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * @returns {{layer:{name:string,level:number}|null, basis:'path'|'name-hint'|'unclassified'}}
266
+ */
267
+ export function classifyFile(relPath, sourceText, layers) {
268
+ const byPath = classifyLayerByPath(relPath, layers);
269
+ if (byPath) return { layer: byPath, basis: 'path' };
270
+ const byName = classifyLayerByNameHint(sourceText, layers);
271
+ if (byName) return { layer: byName, basis: 'name-hint' };
272
+ return { layer: null, basis: 'unclassified' };
273
+ }
274
+
275
+ /**
276
+ * Keyword-substring layer guess for an import specifier that did NOT
277
+ * resolve to any scanned file — ported from `detectLayerFromImport`
278
+ * (dependency-rule-validator.ts:177-193). Always `confidence: 'low'` at the
279
+ * call site; this is a weaker signal than a resolved file's real classified
280
+ * layer and must never be reported with equal certainty.
281
+ */
282
+ export function classifyLayerBySpecifierKeyword(specifier, layers) {
283
+ const low = specifier.toLowerCase();
284
+ for (const layer of layers) {
285
+ for (const kw of layerKeywords(layer)) {
286
+ if (kw && low.includes(kw)) return { name: layer.name, level: layer.level };
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Import resolution to an actual scanned file
294
+ // ---------------------------------------------------------------------------
295
+
296
+ function normalize(p) {
297
+ return path.resolve(p).replace(/\\/g, '/').toLowerCase();
298
+ }
299
+
300
+ function stripExt(p) {
301
+ const ext = path.extname(p);
302
+ return SOURCE_EXT_SET.has(ext) ? p.slice(0, -ext.length) : p;
303
+ }
304
+
305
+ /**
306
+ * Resolve a relative import specifier to a scanned file record, if any.
307
+ * Tries the literal path, each source extension, and each extension under
308
+ * an `/index` suffix — the standard Node/bundler resolution shape, done
309
+ * against the KNOWN scanned-file set rather than the real filesystem (a
310
+ * scan target is routinely a subtree, and imports legitimately point
311
+ * outside it — those are correctly reported as "unresolved", not chased
312
+ * onto disk).
313
+ *
314
+ * @param {string} specifier
315
+ * @param {string} fromFile absolute path of the importing file
316
+ * @param {Map<string, object>} byNoExt normalize(stripExt(absPath)) -> record
317
+ * @returns {object|null} the target file record, or null if unresolved
318
+ */
319
+ export function resolveRelativeImport(specifier, fromFile, byNoExt) {
320
+ if (!specifier.startsWith('.')) return null;
321
+ const resolvedBase = path.resolve(path.dirname(fromFile), specifier);
322
+ const candidates = [resolvedBase, path.join(resolvedBase, 'index')];
323
+ for (const c of candidates) {
324
+ const hit = byNoExt.get(normalize(stripExt(c)));
325
+ if (hit) return hit;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Graph construction
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /**
335
+ * @typedef {object} FileRecord
336
+ * @property {string} absPath
337
+ * @property {string} relPath posix-slash, relative to scan root
338
+ * @property {string} text
339
+ * @property {{name:string, level:number}|null} layer
340
+ * @property {'path'|'name-hint'|'unclassified'} layerBasis
341
+ * @property {string[]} imports raw specifiers, in extraction order
342
+ */
343
+
344
+ /**
345
+ * @param {string[]} files absolute paths
346
+ * @param {string} root absolute scan root (for relPath)
347
+ * @param {{layers: object[]}} config
348
+ * @returns {{records: FileRecord[], byNoExt: Map<string,FileRecord>}}
349
+ */
350
+ export function buildFileGraph(files, root, config) {
351
+ const layers = config.layers ?? DEFAULT_LAYERS;
352
+ const records = [];
353
+ for (const f of files) {
354
+ let text;
355
+ try {
356
+ text = readFileSync(f, 'utf8');
357
+ } catch {
358
+ continue;
359
+ }
360
+ const relPath = path.relative(root, f).split(path.sep).join('/');
361
+ const { layer, basis } = classifyFile(relPath, text, layers);
362
+ records.push({ absPath: f, relPath, text, layer, layerBasis: basis, imports: extractImportSpecifiers(text) });
363
+ }
364
+ const byNoExt = new Map();
365
+ for (const r of records) byNoExt.set(normalize(stripExt(r.absPath)), r);
366
+ return { records, byNoExt };
367
+ }
368
+
369
+ /**
370
+ * Layer-level edge set built ONLY from RESOLVED imports (a relative
371
+ * specifier that lands on a real scanned file with a known layer) — the
372
+ * high-confidence half of layer classification. This is deliberately a
373
+ * narrower graph than "every import that looks like it points at a layer";
374
+ * `circularLayerDependencyFindings` needs real edges to run a real cycle
375
+ * walk on, not keyword guesses.
376
+ *
377
+ * @returns {Map<string, Set<string>>} layerName -> set of layerNames it depends on
378
+ */
379
+ export function buildLayerEdges(records, byNoExt, layers) {
380
+ const edges = new Map(layers.map((l) => [l.name, new Set()]));
381
+ for (const r of records) {
382
+ if (!r.layer) continue;
383
+ for (const spec of r.imports) {
384
+ const target = resolveRelativeImport(spec, r.absPath, byNoExt);
385
+ if (!target || !target.layer) continue;
386
+ if (target.layer.name === r.layer.name) continue; // intra-layer coupling is not a layer-graph edge
387
+ edges.get(r.layer.name)?.add(target.layer.name);
388
+ }
389
+ }
390
+ return edges;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Rule 1 — Dependency direction (inward-only)
395
+ // Ported from dependency-rule-validator.ts:61-117 `checkDependencyDirection`
396
+ // ---------------------------------------------------------------------------
397
+
398
+ export function dependencyDirectionFindings(record, byNoExt, layers) {
399
+ const findings = [];
400
+ if (!record.layer) return { ruleId: 'dependency-direction', findings, source: 'dependency-rule-validator.ts:61-117' };
401
+ for (const spec of record.imports) {
402
+ const resolved = resolveRelativeImport(spec, record.absPath, byNoExt);
403
+ let targetLayer = null;
404
+ let confidence = null;
405
+ if (resolved?.layer) {
406
+ targetLayer = resolved.layer;
407
+ confidence = 'high';
408
+ } else if (!spec.startsWith('.')) {
409
+ const guessed = classifyLayerBySpecifierKeyword(spec, layers);
410
+ if (guessed) {
411
+ targetLayer = guessed;
412
+ confidence = 'low';
413
+ }
414
+ }
415
+ if (!targetLayer) continue;
416
+ if (targetLayer.level < record.layer.level) {
417
+ findings.push({
418
+ location: record.relPath,
419
+ detail: `${record.layer.name} layer depends on outer ${targetLayer.name} layer via '${spec}' (violates the Dependency Rule — dependencies must point inward only)`,
420
+ severity: 'critical',
421
+ confidence,
422
+ });
423
+ }
424
+ }
425
+ return { ruleId: 'dependency-direction', findings, source: 'dependency-rule-validator.ts:61-117' };
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Rule 2 — Framework coupling
430
+ // Ported from dependency-rule-validator.ts:98-113
431
+ // ---------------------------------------------------------------------------
432
+
433
+ export function frameworkCouplingFindings(record) {
434
+ const findings = [];
435
+ if (!record.layer || record.layer.name === 'Frameworks') return { ruleId: 'framework-coupling', findings, source: 'dependency-rule-validator.ts:98-113' };
436
+ for (const spec of record.imports) {
437
+ const low = spec.toLowerCase();
438
+ const hit = FRAMEWORK_INDICATORS.find((ind) => low.includes(ind));
439
+ if (!hit) continue;
440
+ findings.push({
441
+ location: record.relPath,
442
+ detail: `${record.layer.name} layer directly imports framework/library '${spec}' (matched indicator '${hit}')`,
443
+ // Exact ternary from dependency-rule-validator.ts:105 — critical for
444
+ // Entities, high for every other non-Frameworks layer.
445
+ severity: record.layer.name === 'Entities' ? 'critical' : 'high',
446
+ confidence: FRAMEWORK_INDICATORS_GENERIC.has(hit) ? 'medium' : 'high',
447
+ });
448
+ }
449
+ return { ruleId: 'framework-coupling', findings, source: 'dependency-rule-validator.ts:98-113' };
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Rule 3 — Missing abstraction (five sub-checks, one ruleId, same pattern as
454
+ // clean-code-scoring.mjs's G9 "two independent halves")
455
+ // ---------------------------------------------------------------------------
456
+
457
+ const RE_USECASE_CLASS = /class\s+\w+(UseCase|Interactor)/i;
458
+ const RE_CONCRETE_REPO = /new\s+\w+(Repository|Gateway|DataSource)/i;
459
+ const RE_CONTROLLER_CLASS = /class\s+\w+Controller/i;
460
+ const RE_PORT_INTERFACE = /interface\s+\w+(UseCase|Port|Input)/i;
461
+ const RE_NEW_USECASE = /new\s+\w+UseCase/i;
462
+ const RE_HTTP_LEAK = /request\.|req\./i;
463
+ const RE_ENTITY_LEAK = /return\s+.*Entity|:\s+.*Entity\[|Promise<.*Entity>/i;
464
+ const DB_PATTERNS = [/\bdb\./i, /\bdatabase\./i, /\bmongodb\./i, /\bprisma\./i, /SELECT\s+.*\s+FROM/i, /INSERT\s+INTO/i, /UPDATE\s+.*\s+SET/i];
465
+ const RE_USECASE_SERVICE_CLASS = /class\s+\w+(UseCase|Interactor|Service)/i;
466
+
467
+ /**
468
+ * @param {FileRecord} record
469
+ */
470
+ export function missingAbstractionFindings(record) {
471
+ const findings = [];
472
+ const text = record.text;
473
+
474
+ // 3a — boundary-analysis-validator.ts:44-63
475
+ if (RE_USECASE_CLASS.test(text) && RE_CONCRETE_REPO.test(text)) {
476
+ findings.push({
477
+ location: record.relPath,
478
+ kind: 'missing-repository-interface',
479
+ detail: 'Use Case directly instantiates a concrete Repository/Gateway/DataSource (missing boundary interface)',
480
+ severity: 'high',
481
+ confidence: 'high',
482
+ });
483
+ }
484
+
485
+ // 3b — boundary-analysis-validator.ts:65-82
486
+ if (RE_CONTROLLER_CLASS.test(text) && !RE_PORT_INTERFACE.test(text) && RE_NEW_USECASE.test(text)) {
487
+ findings.push({
488
+ location: record.relPath,
489
+ kind: 'missing-input-port',
490
+ detail: 'Controller directly instantiates a Use Case with no input-port interface declared in this file',
491
+ severity: 'medium',
492
+ confidence: 'high',
493
+ });
494
+ }
495
+
496
+ // 3c — boundary-analysis-validator.ts:108-124
497
+ if (RE_HTTP_LEAK.test(text) && RE_USECASE_CLASS.test(text)) {
498
+ findings.push({
499
+ location: record.relPath,
500
+ kind: 'http-request-in-usecase',
501
+ detail: "HTTP request object ('request.'/'req.') referenced in a Use Case/Interactor file — framework detail leaked into the Use Case layer",
502
+ severity: 'critical',
503
+ confidence: 'medium', // whole-file text match, not scoped to the UseCase class body specifically
504
+ });
505
+ }
506
+
507
+ // 3d — layer-separation-validator.ts:83-115
508
+ const hasDb = DB_PATTERNS.some((re) => re.test(text));
509
+ if (hasDb && RE_USECASE_SERVICE_CLASS.test(text)) {
510
+ findings.push({
511
+ location: record.relPath,
512
+ kind: 'direct-db-access-in-usecase',
513
+ detail: 'Use Case/Interactor/Service accesses the database directly (db./database./mongodb./prisma./raw SQL) instead of through a repository interface',
514
+ severity: 'critical',
515
+ confidence: 'medium',
516
+ });
517
+ }
518
+
519
+ // 3e — boundary-analysis-validator.ts:87-106
520
+ if (RE_ENTITY_LEAK.test(text) && RE_CONTROLLER_CLASS.test(text)) {
521
+ findings.push({
522
+ location: record.relPath,
523
+ kind: 'data-structure-leak',
524
+ detail: 'Controller returns/types a value as an Entity — an internal data structure crossing the boundary untranslated; map to a DTO at the boundary instead',
525
+ severity: 'critical',
526
+ confidence: 'low', // `return .*Entity` / `: .*Entity[` / `Promise<.*Entity>` is a broad textual pattern — real false-positive risk (e.g. an unrelated class literally named `...Entity` used only as a local value)
527
+ });
528
+ }
529
+
530
+ return { ruleId: 'missing-abstraction', findings, source: 'boundary-analysis-validator.ts:44-124, layer-separation-validator.ts:83-115' };
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Rule 4 (bonus) — Mixed business/infrastructure concerns
535
+ // Ported from layer-separation-validator.ts:54-81
536
+ // ---------------------------------------------------------------------------
537
+
538
+ const BUSINESS_RULE_INDICATORS = ['validate', 'calculate', 'process', 'execute', 'apply', 'business', 'rule', 'policy', 'workflow'];
539
+ const INFRASTRUCTURE_CONCERNS = ['database', 'http', 'file', 'cache', 'queue', 'email', 'logger', 'metrics', 'config', 'environment'];
540
+
541
+ export function mixedConcernsFindings(record) {
542
+ const findings = [];
543
+ const text = record.text;
544
+ const hasBusiness = BUSINESS_RULE_INDICATORS.some((w) => new RegExp(`\\b${w}\\w*\\b`, 'i').test(text));
545
+ const hasInfra = INFRASTRUCTURE_CONCERNS.some((w) => new RegExp(`\\b${w}\\w*\\b`, 'i').test(text));
546
+ if (hasBusiness && hasInfra) {
547
+ findings.push({
548
+ location: record.relPath,
549
+ detail: 'Business-rule vocabulary (validate/calculate/process/…) and infrastructure vocabulary (database/http/cache/…) both appear in this file — likely business logic mixed with infrastructure concerns',
550
+ severity: 'high',
551
+ // whole-file word-list co-occurrence, same heuristic risk N2 already
552
+ // discloses in clean-code-scoring.mjs — a file legitimately discussing
553
+ // both (e.g. this very scorer's own doc comments) will false-positive.
554
+ confidence: 'low',
555
+ });
556
+ }
557
+ return { ruleId: 'mixed-concerns', findings, source: 'layer-separation-validator.ts:54-81' };
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // Rule 5 (bonus) — UI/business-logic mixing
562
+ // Ported from layer-separation-validator.ts:117-143
563
+ // ---------------------------------------------------------------------------
564
+
565
+ const UI_INDICATORS = ['component', 'render', 'jsx', 'tsx', 'props', 'state', 'onclick'];
566
+ const RE_COMPLEX_BUSINESS_BODY = /\b(calculate|validate|process)\w*\([^)]*\)\s*{[^}]{100,}/i;
567
+
568
+ export function uiBusinessLogicMixingFindings(record) {
569
+ const findings = [];
570
+ const text = record.text;
571
+ const hasUI = UI_INDICATORS.some((w) => new RegExp(`\\b${w}\\b`, 'i').test(text));
572
+ if (hasUI && RE_COMPLEX_BUSINESS_BODY.test(text)) {
573
+ findings.push({
574
+ location: record.relPath,
575
+ detail: 'A calculate/validate/process function with a 100+ character body appears alongside UI indicators (component/render/jsx/props/…) — complex business logic embedded in a UI component',
576
+ severity: 'medium',
577
+ confidence: 'medium',
578
+ });
579
+ }
580
+ return { ruleId: 'ui-business-logic-mixing', findings, source: 'layer-separation-validator.ts:117-143' };
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Rule 6 (bonus) — Mixed architectural layers in one file (3+)
585
+ // Ported from boundary-analysis-validator.ts:129-167
586
+ // ---------------------------------------------------------------------------
587
+
588
+ export function mixedLayerImportsFindings(record, layers) {
589
+ const findings = [];
590
+ const layersFound = new Set();
591
+ for (const spec of record.imports) {
592
+ const guess = classifyLayerBySpecifierKeyword(spec, layers);
593
+ if (guess) layersFound.add(guess.name);
594
+ }
595
+ if (layersFound.size >= 3) {
596
+ findings.push({
597
+ location: record.relPath,
598
+ detail: `File imports from ${layersFound.size} different architectural layers (${[...layersFound].sort().join(', ')}) — tight coupling across layer boundaries`,
599
+ severity: 'medium',
600
+ confidence: 'medium', // keyword-in-specifier layer guess, same basis as the low-confidence half of dependency-direction
601
+ });
602
+ }
603
+ return { ruleId: 'mixed-layer-imports', findings, source: 'boundary-analysis-validator.ts:129-167' };
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Rule 7 — Circular layer dependencies (REAL cycle walk, not the toolkit's
608
+ // broken `../../..` depth proxy — see file header)
609
+ // ---------------------------------------------------------------------------
610
+
611
+ /**
612
+ * Canonicalize a cycle path (array of layer names ending back at the start)
613
+ * by rotating to start at the lexicographically smallest layer name, so the
614
+ * same physical cycle discovered from two different `findCycles(start)`
615
+ * calls dedupes to one entry — required for deterministic, non-duplicated
616
+ * JSON output.
617
+ */
618
+ function canonicalizeCycle(cyclePath) {
619
+ const ring = cyclePath.slice(0, -1); // drop the repeated closing node
620
+ let minIdx = 0;
621
+ for (let i = 1; i < ring.length; i++) if (ring[i] < ring[minIdx]) minIdx = i;
622
+ const rotated = [...ring.slice(minIdx), ...ring.slice(0, minIdx)];
623
+ return [...rotated, rotated[0]];
624
+ }
625
+
626
+ export function circularLayerDependencyFindings(layerEdges) {
627
+ const seen = new Set();
628
+ const cycles = [];
629
+ for (const layerName of [...layerEdges.keys()].sort()) {
630
+ for (const c of findCycles(layerEdges, layerName)) {
631
+ const canon = canonicalizeCycle(c);
632
+ const key = canon.join('>');
633
+ if (seen.has(key)) continue;
634
+ seen.add(key);
635
+ cycles.push(canon);
636
+ }
637
+ }
638
+ cycles.sort((a, b) => a.join('>').localeCompare(b.join('>')));
639
+ const findings = cycles.map((c) => ({
640
+ location: c.join(' -> '),
641
+ detail: `Real cycle in the layer-import graph: ${c.join(' -> ')} — built from RESOLVED file imports only (buildLayerEdges), then walked with package-metrics.mjs's findCycles (the same ADP algorithm, applied to layers instead of packages)`,
642
+ severity: 'high',
643
+ confidence: 'high',
644
+ }));
645
+ return { ruleId: 'circular-layer-dependency', findings, source: 'package-metrics.mjs findCycles, adapted to layers (dependency-rule-validator.ts:119-146 is NOT reused — see file header)' };
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Aggregate per-file score + whole-scan score
650
+ // ---------------------------------------------------------------------------
651
+
652
+ /**
653
+ * @param {FileRecord} record
654
+ * @param {Map<string,FileRecord>} byNoExt
655
+ * @param {object[]} layers
656
+ */
657
+ export function architectureScoreFile(record, byNoExt, layers) {
658
+ const rules = {
659
+ dependencyDirection: dependencyDirectionFindings(record, byNoExt, layers),
660
+ frameworkCoupling: frameworkCouplingFindings(record),
661
+ missingAbstraction: missingAbstractionFindings(record),
662
+ mixedConcerns: mixedConcernsFindings(record),
663
+ uiBusinessLogicMixing: uiBusinessLogicMixingFindings(record),
664
+ mixedLayerImports: mixedLayerImportsFindings(record, layers),
665
+ };
666
+ const totalFindings = Object.values(rules).reduce((n, r) => n + r.findings.length, 0);
667
+ return {
668
+ file: record.relPath,
669
+ layer: record.layer?.name ?? null,
670
+ layerBasis: record.layerBasis,
671
+ rules,
672
+ totalFindings,
673
+ };
674
+ }
675
+
676
+ /**
677
+ * @param {string[]} files absolute paths
678
+ * @param {string} root absolute scan root
679
+ * @param {{layers?: object[]}} config
680
+ */
681
+ export function architectureScoreAll(files, root, config = {}) {
682
+ const layers = config.layers ?? DEFAULT_LAYERS;
683
+ const { records, byNoExt } = buildFileGraph(files, root, { layers });
684
+ const sortedRecords = [...records].sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
685
+ const results = sortedRecords.map((r) => architectureScoreFile(r, byNoExt, layers));
686
+ const layerEdges = buildLayerEdges(records, byNoExt, layers);
687
+ const circular = circularLayerDependencyFindings(layerEdges);
688
+ const unclassifiedFiles = sortedRecords.filter((r) => !r.layer).map((r) => r.relPath);
689
+ return {
690
+ results,
691
+ circularLayerDependency: circular,
692
+ unclassifiedFiles,
693
+ layers: layers.map((l) => ({ name: l.name, level: l.level, globs: l.globs })),
694
+ };
695
+ }