@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.
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,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * solid-score — SOLID + Clean Architecture scoring, language-plugin-based.
4
+ *
5
+ * This CLI is deliberately language-agnostic. It owns: argv parsing, config
6
+ * loading, git-diff orchestration, the boundary-rule check, and output
7
+ * formatting. It does NOT know what an AST node is — every fact about source
8
+ * code comes from whichever plugin in `lib/plugins/` claims the file
9
+ * (see lib/language-plugin.mjs for the contract). Day 1 ships one plugin
10
+ * (TypeScript/JavaScript, via ts-morph). Adding Python later means writing
11
+ * `lib/plugins/python.mjs` against the same contract — nothing in this file
12
+ * changes.
13
+ *
14
+ * It does not persist a baseline file. `--diff <ref>` scores each changed
15
+ * unit TWICE — once at <ref> via `git show`, once against the CURRENT
16
+ * WORKING TREE (uncommitted and untracked changes included, not just what's
17
+ * been committed) — and gates on the delta. `git diff --name-only <ref>`
18
+ * (no `...HEAD`) compares the ref directly against the working directory,
19
+ * which also means the "before" side (`git show <ref>:<path>`) and the file
20
+ * list are read from the exact same commit — no separate merge-base to fall
21
+ * out of sync with it.
22
+ *
23
+ * A SEPARATE, non-SOLID check rides alongside the five letters: Clean
24
+ * Architecture's dependency rule — an orchestrator module declared to own a
25
+ * set of ports must actually import and delegate to them, not reimplement
26
+ * their logic inline. Dogfooded proof this matters: `rdc-harness`'s `Harness`
27
+ * god-object scores 68.5/100 on the plain weighted SOLID sum (DIP alone
28
+ * doesn't catch it — a class with almost no dependencies of ANY kind scores
29
+ * fine on concrete-instantiation ratio) but fails the boundary check outright
30
+ * on all six of its declared ports.
31
+ *
32
+ * A per-consumer-repo config lives at `<repo>/.solid-score.yml` — this
33
+ * package ships no default boundary rules of its own; each repo declares
34
+ * its own orchestrator/port pairs.
35
+ *
36
+ * Usage:
37
+ * node solid-score.mjs <path> [--config <file>] [--diff <ref>]
38
+ * [--format text|json] [--parser tree-sitter|ts-morph]
39
+ *
40
+ * `--parser` selects the AST backend (default `tree-sitter`, see the
41
+ * PARSER_PLUGINS block below for why).
42
+ */
43
+
44
+ import { execFileSync } from 'node:child_process';
45
+ import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
46
+ import { join, dirname, relative, resolve, sep } from 'node:path';
47
+ import { pathToFileURL } from 'node:url';
48
+ import { parse as parseYaml } from 'yaml';
49
+
50
+ import { registerPlugin, pluginFor } from './lib/language-plugin.mjs';
51
+ import { typescriptPlugin } from './lib/plugins/typescript.mjs';
52
+ import { treesitterPlugin } from './lib/plugins/treesitter.mjs';
53
+ import { scoreUnit } from './lib/solid-scoring.mjs';
54
+
55
+ // AST backend selection — `--parser tree-sitter|ts-morph`, default
56
+ // `tree-sitter` per direct operator instruction (Dave, 2026-08-20): the
57
+ // fleet's own standalone, in-process, multi-language tree-sitter parser
58
+ // (ported from CodeFlow's `packages/codeflow-parser/src/nativeParser.ts`,
59
+ // see scripts/lib/plugins/treesitter.mjs's own header for the full port
60
+ // citation) replaces the third-party TS-only ts-morph backend as the
61
+ // default for this CLI. `--parser ts-morph` stays available as an escape
62
+ // hatch/regression-comparison lever, not a silent removal — ts-morph
63
+ // remains the backend the other 3 ts-morph tools (clean-code-score,
64
+ // pattern-score, refactoring-score) use this pass; only SOLID moves.
65
+ const PARSER_PLUGINS = { 'tree-sitter': treesitterPlugin, 'ts-morph': typescriptPlugin };
66
+
67
+ const parserArgIndex = process.argv.indexOf('--parser');
68
+ const parserName = parserArgIndex !== -1 ? process.argv[parserArgIndex + 1] : 'tree-sitter';
69
+ if (!PARSER_PLUGINS[parserName]) {
70
+ throw new Error(`--parser must be one of ${Object.keys(PARSER_PLUGINS).join('|')}, got "${parserName}"`);
71
+ }
72
+ // Register only the SELECTED plugin — typescriptPlugin and treesitterPlugin
73
+ // both claim the identical file-extension set (canHandle), so registering
74
+ // both would make `pluginFor()` always resolve to whichever was registered
75
+ // first regardless of `--parser`, silently ignoring the flag.
76
+ registerPlugin(PARSER_PLUGINS[parserName]);
77
+ // A future Python plugin registers here too — nothing else in this file changes.
78
+
79
+ export const DEFAULT_CONFIG = {
80
+ weights: { srp: 0.20, ocp: 0.15, lsp: 0.15, isp: 0.20, dip: 0.30 },
81
+ thresholds: null, // not implemented — see skills/solid-validator/SKILL.md "Known gaps"; left null so a config setting it is visibly ignored rather than silently accepted
82
+ diff: { maxDecrease: 0, newUnitMin: 0 },
83
+ exclude: ['**/test/**', '**/*.test.*', '**/node_modules/**'],
84
+ boundaries: [], // [{ orchestrator: glob, requiredPorts: [glob, ...] }]
85
+ };
86
+
87
+ /**
88
+ * A config path that was explicitly PASSED and does not exist is a user
89
+ * error, not "use defaults" — an absent `--config` flag (no path at all)
90
+ * legitimately means "no config, use defaults". Before this fix the two
91
+ * were indistinguishable: a typo'd path silently produced `boundaries: []`
92
+ * and a clean exit 0, and every SKILL.md-documented invocation of this tool
93
+ * pointed at `.solid-score.yml`, a filename that existed in no repo tonight
94
+ * — so the Clean Architecture gate ran with zero configured rules on every
95
+ * documented call site.
96
+ */
97
+ export function loadConfig(path) {
98
+ if (!path) return { config: structuredClone(DEFAULT_CONFIG), configPath: null, configLoaded: false };
99
+ if (!existsSync(path)) throw new Error(`--config ${path} does not exist`);
100
+ const raw = parseYaml(readFileSync(path, 'utf8'));
101
+ const config = {
102
+ ...structuredClone(DEFAULT_CONFIG), ...raw,
103
+ weights: { ...DEFAULT_CONFIG.weights, ...(raw.weights ?? {}) },
104
+ diff: { ...DEFAULT_CONFIG.diff, ...(raw.diff ?? {}) },
105
+ };
106
+ return { config, configPath: path, configLoaded: true };
107
+ }
108
+
109
+ const GLOB_STAR_TOKEN = '\u0001GLOBSTAR\u0001';
110
+
111
+ /**
112
+ * Real glob-to-regex, not a globstar-strip substring check — a single `*`
113
+ * in a user config previously survived as a literal character and matched
114
+ * nothing. Built with string split/join rather than regex-literal replace
115
+ * to sidestep a tool encoding issue that corrupted a prior version of this
116
+ * function with embedded NUL bytes at the globstar/space substitution
117
+ * points — confirmed byte-for-byte with a raw scan before rewriting.
118
+ */
119
+ function globToRegExp(glob) {
120
+ const specialChars = ['.', '+', '^', '$', '{', '}', '(', ')', '|', '[', ']'];
121
+ let escaped = glob;
122
+ for (const ch of specialChars) escaped = escaped.split(ch).join(`\\${ch}`);
123
+ const withGlobstar = escaped.split('**').join(GLOB_STAR_TOKEN);
124
+ const withStar = withGlobstar.split('*').join('[^/]*');
125
+ const final = withStar.split(GLOB_STAR_TOKEN).join('.*');
126
+ return new RegExp(`(^|/)${final}$|(^|/)${final}(/|$)`);
127
+ }
128
+
129
+ function isExcluded(relPath, exclude) {
130
+ return exclude.some((p) => globToRegExp(p).test(relPath));
131
+ }
132
+
133
+ /** Recursive file walk — no glob library, so no more languages than plugins to add here either. */
134
+ function walk(dir, exclude, out = []) {
135
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
136
+ const full = join(dir, entry.name);
137
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
138
+ if (entry.isDirectory()) walk(full, exclude, out);
139
+ else if (!isExcluded(full.split(sep).join('/'), exclude)) out.push(full);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /** Score a file's text as it existed at a given git ref, without touching the working tree. */
145
+ function scoreAtRef(repoRoot, filePath, ref, weights) {
146
+ const relPath = relative(repoRoot, filePath).split('\\').join('/');
147
+ let text;
148
+ try {
149
+ text = execFileSync('git', ['show', `${ref}:${relPath}`], { cwd: repoRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
150
+ } catch (err) {
151
+ // `git show` fails both when the file is genuinely new at `ref` AND for
152
+ // unrelated reasons (a path-form mismatch, a bad ref). Collapsing both
153
+ // into "it's new" hides the second case — the exact class of silent
154
+ // path-form bug found three times in this file already tonight. Only a
155
+ // "does not exist" message is treated as new; anything else is rethrown.
156
+ const msg = String(err.stderr ?? err.message ?? '');
157
+ if (msg.includes('does not exist in') || msg.includes('fatal: path') && msg.includes('does not exist')) return null;
158
+ throw err;
159
+ }
160
+ const plugin = pluginFor(filePath);
161
+ if (!plugin) return null;
162
+ return plugin.extractUnits(filePath, text).map((u) => scoreUnit(u, weights));
163
+ }
164
+
165
+ /**
166
+ * Does `orchestrator` import from every `requiredPorts` glob it's declared
167
+ * to own? Both fields are documented as globs; a rule that matches zero
168
+ * files is a config error (typo'd path, moved file) and MUST be reported —
169
+ * silently passing zero matches makes a misconfigured rule indistinguishable
170
+ * from a satisfied one.
171
+ */
172
+ function checkBoundaries(files, boundaries, repoRoot) {
173
+ const findings = [];
174
+ const misconfigured = [];
175
+ for (const rule of boundaries) {
176
+ const orchestratorRe = globToRegExp(rule.orchestrator.split('\\').join('/'));
177
+ const matched = files.filter((f) => orchestratorRe.test(relative(repoRoot, f).split('\\').join('/')));
178
+ if (!matched.length) { misconfigured.push({ rule: rule.orchestrator, reason: 'matched zero files in the scanned set' }); continue; }
179
+ for (const f of matched) {
180
+ const plugin = pluginFor(f);
181
+ if (!plugin) continue;
182
+ const importPaths = plugin.importsOf(f);
183
+ for (const port of rule.requiredPorts) {
184
+ const portRe = globToRegExp(port);
185
+ const substringFallback = port.split('**')[0].replace(/^\.\//, '');
186
+ findings.push({ file: relative(repoRoot, f), requiredPort: port, satisfied: importPaths.some((p) => portRe.test(p) || p.includes(substringFallback)) });
187
+ }
188
+ }
189
+ }
190
+ return { findings, misconfigured };
191
+ }
192
+
193
+ // ── CLI ──────────────────────────────────────────────────────────────────
194
+
195
+ function arg(name, fallback = null) { const i = process.argv.indexOf(name); return i !== -1 ? process.argv[i + 1] : fallback; }
196
+
197
+ /**
198
+ * Bash-tool / MSYS argv can hand this process a POSIX-shaped path
199
+ * (`/c/Dev/...`) even though this is native Windows node. The filesystem
200
+ * layer only resolves Windows-shaped paths — a POSIX path fails silently
201
+ * upstream of any error message. Normalize before use.
202
+ */
203
+ function normalizePath(p) {
204
+ const m = /^\/([A-Za-z])\/(.*)$/.exec(p);
205
+ const windowsShaped = m ? `${m[1].toUpperCase()}:/${m[2]}` : p;
206
+ // A relative arg ("packages/core/src/index.mjs") must become absolute
207
+ // BEFORE any downstream comparison — the boundary check always builds its
208
+ // side of the match from repoRoot-absolute paths, so a relative target
209
+ // silently matches nothing.
210
+ const abs = resolve(process.cwd(), windowsShaped);
211
+ // realpathSync so a path reached through a symlink/junction compares
212
+ // equal to repoRoot (derived from `git rev-parse`, which reports the real
213
+ // path) — the same symlink-vs-original mismatch that broke the isMain
214
+ // check under `npm link` also applies to any target path reached that way.
215
+ try { return realpathSync(abs); } catch { return abs; }
216
+ }
217
+
218
+ async function main() {
219
+ const rawTarget = process.argv[2]?.startsWith('--') ? process.cwd() : (process.argv[2] ?? process.cwd());
220
+ const targetPath = normalizePath(rawTarget);
221
+ const configArg = arg('--config');
222
+ const { config, configPath, configLoaded } = loadConfig(configArg ? normalizePath(configArg) : null);
223
+ const diffRef = arg('--diff');
224
+ const format = arg('--format', 'text');
225
+
226
+ if (!existsSync(targetPath)) throw new Error(`target path does not exist: ${targetPath}`);
227
+ const isFile = statSync(targetPath).isFile();
228
+
229
+ // A plain (non-diff) score does not need a git repo at all — only resolve
230
+ // repoRoot when it's actually needed (diff mode, or a boundary check that
231
+ // needs repo-root-relative paths).
232
+ const needsGit = Boolean(diffRef) || (config.boundaries ?? []).length > 0;
233
+ let repoRoot = isFile ? dirname(targetPath) : targetPath;
234
+ if (needsGit) {
235
+ const cwdForGit = isFile ? dirname(targetPath) : targetPath;
236
+ repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: cwdForGit, encoding: 'utf8' }).trim();
237
+ }
238
+
239
+ let files;
240
+ if (diffRef) {
241
+ // `git diff --name-only <ref> -- <path>` (no `...HEAD`) compares `<ref>`
242
+ // directly against the WORKING TREE — committed history AND uncommitted
243
+ // changes both show up. Untracked new files show up in neither `git
244
+ // diff` form, so they're unioned in separately. Before this fix, only
245
+ // `<ref>...HEAD` (committed history alone) was scored — a dirty tree
246
+ // with real regressions scored zero files and exited 0.
247
+ const committed = execFileSync('git', ['diff', '--name-only', diffRef, '--', targetPath], { cwd: repoRoot, encoding: 'utf8' })
248
+ .split('\n').filter(Boolean);
249
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--', targetPath], { cwd: repoRoot, encoding: 'utf8' })
250
+ .split('\n').filter(Boolean);
251
+ files = [...new Set([...committed, ...untracked])]
252
+ .map((f) => join(repoRoot, f))
253
+ .filter((f) => !isExcluded(f.split(sep).join('/'), config.exclude));
254
+ } else if (isFile) {
255
+ files = [targetPath];
256
+ } else {
257
+ files = walk(targetPath, config.exclude);
258
+ }
259
+
260
+ const results = [];
261
+ const regressions = [];
262
+ const unresolvedLanguages = [];
263
+
264
+ for (const f of files) {
265
+ const plugin = pluginFor(f);
266
+ if (!plugin) { unresolvedLanguages.push(f); continue; } // reachable now — files are no longer pre-filtered by pluginFor before reaching this loop
267
+ const current = plugin.extractUnits(f).map((u) => scoreUnit(u, config.weights));
268
+
269
+ if (diffRef) {
270
+ const before = scoreAtRef(repoRoot, f, diffRef, config.weights);
271
+ for (const unit of current) {
272
+ if (unit.total === null) continue; // fully unmeasured — nothing to compare
273
+ const prior = before?.find((b) => b.unit === unit.unit);
274
+ if (!prior || prior.total === null) {
275
+ if (unit.total < config.diff.newUnitMin) regressions.push({ file: f, unit: unit.unit, reason: 'new_unit_below_min', total: unit.total, min: config.diff.newUnitMin });
276
+ } else if (prior.total - unit.total > config.diff.maxDecrease) {
277
+ regressions.push({ file: f, unit: unit.unit, reason: 'regression', before: prior.total, after: unit.total });
278
+ }
279
+ }
280
+ }
281
+ results.push({ file: relative(repoRoot, f).split('\\').join('/'), units: current });
282
+ }
283
+
284
+ const { findings: boundaryFindings, misconfigured: boundaryMisconfigured } = checkBoundaries(files, config.boundaries ?? [], repoRoot);
285
+ const boundaryViolations = boundaryFindings.filter((f) => !f.satisfied);
286
+
287
+ const output = {
288
+ results, regressions, boundaryFindings, boundaryViolations, boundaryMisconfigured, unresolvedLanguages,
289
+ config: { weights: config.weights, diff: config.diff, boundaryRuleCount: (config.boundaries ?? []).length },
290
+ configPath, configLoaded,
291
+ parser: parserName,
292
+ };
293
+
294
+ if (format === 'json') {
295
+ console.log(JSON.stringify(output, null, 2));
296
+ } else {
297
+ for (const r of results) {
298
+ for (const u of r.units) {
299
+ console.log(`${r.file} :: ${u.unit} (${u.kind}) total=${u.total ?? 'UNMEASURED'}`);
300
+ for (const [k, v] of Object.entries(u.criteria)) console.log(` ${k.toUpperCase()}: ${v.score.toString().padStart(3)} [${v.confidence}] ${v.detail}`);
301
+ }
302
+ }
303
+ if (boundaryViolations.length) {
304
+ console.log('\nCLEAN ARCHITECTURE BOUNDARY VIOLATIONS:');
305
+ for (const v of boundaryViolations) console.log(` ${v.file}: missing required port import '${v.requiredPort}'`);
306
+ }
307
+ if (boundaryMisconfigured.length) {
308
+ console.log('\nBOUNDARY RULES MATCHING ZERO FILES (config error, not a pass):');
309
+ for (const m of boundaryMisconfigured) console.log(` orchestrator '${m.rule}' — ${m.reason}`);
310
+ }
311
+ if (regressions.length) {
312
+ console.log('\nREGRESSIONS:');
313
+ for (const r of regressions) console.log(` ${r.file} :: ${r.unit} — ${r.reason} ${JSON.stringify(r)}`);
314
+ }
315
+ if (unresolvedLanguages.length) {
316
+ console.log(`\n${unresolvedLanguages.length} file(s) matched no registered language plugin — skipped, not silently passed:`);
317
+ for (const f of unresolvedLanguages) console.log(` ${relative(repoRoot, f)}`);
318
+ }
319
+ if ((config.boundaries ?? []).length === 0) {
320
+ console.log(configLoaded ? '\nNo boundary rules configured — Clean Architecture check has nothing to verify.' : '\nNo --config given — running with defaults, zero boundary rules. Pass --config <repo>/.solid-score.yml to enable the Clean Architecture check.');
321
+ }
322
+ }
323
+
324
+ const failed = regressions.length > 0 || boundaryViolations.length > 0 || boundaryMisconfigured.length > 0;
325
+ process.exit(failed ? 1 : 0);
326
+ }
327
+
328
+ // `import.meta.url` resolves through symlinks to the real path; a plain
329
+ // `pathToFileURL(process.argv[1])` does not. Under `npm link` (a symlinked
330
+ // global bin — exactly how this tool got installed) the two disagreed and
331
+ // main() silently never ran, on every invocation, with exit 0 and no output.
332
+ // realpathSync on both sides closes that gap.
333
+ function realFileURL(p) {
334
+ try { return pathToFileURL(realpathSync(p)).href; } catch { return pathToFileURL(p).href; }
335
+ }
336
+ const isMain = process.argv[1] && import.meta.url === realFileURL(process.argv[1]);
337
+ if (isMain) main().catch((err) => { console.error(err); process.exit(2); });
@@ -0,0 +1,287 @@
1
+ ---
2
+ name: architecture-reviewer
3
+ description: >-
4
+ Usage `rdc:architecture-reviewer <path> [--config <file>] [--diff <ref>]` —
5
+ Clean Architecture boundary/dependency-direction/layer-separation review.
6
+ Runs TWO mechanical scorers first — `rdc-architecture-score` (layer
7
+ classification, inward-dependency rule, framework coupling, missing
8
+ repository/port abstractions, HTTP/DB leaks into Use Cases, circular
9
+ layer dependencies) and `rdc-solid-score`'s orchestrator/port boundary
10
+ check — then dispatches judgment-level review for the ONE thing neither
11
+ can see: whether an abstraction boundary makes architectural SENSE for
12
+ this domain, not just whether it has the right shape. Call from
13
+ rdc:review step 8b+, or standalone before merging a new package/module.
14
+ ---
15
+
16
+ > **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`
17
+ > Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.
18
+ > One checklist upfront, updated in place, shown again at end with a 1-line verdict.
19
+
20
+ # architecture-reviewer — Layering & Dependency-Direction Review
21
+
22
+ ## Why this is now mostly mechanical, not judgment-dispatch
23
+
24
+ This skill used to run ONE mechanical check (`rdc-solid-score`'s named
25
+ orchestrator/port boundary rule) and push everything else — framework
26
+ imports leaking into inner layers, DB/HTTP access inside Use Cases, missing
27
+ repository interfaces, layer-mixing, circular layer dependencies — into a
28
+ dispatched judgment pass, on the theory that "mechanical facts don't exist
29
+ yet for this domain."
30
+
31
+ That premise was checked against the actual source it was adapted from and
32
+ found wrong. `architecture-toolkit`'s own
33
+ [`.claude/skills/review-arch.md`](https://github.com/OnSightTeam/architecture-toolkit)
34
+ does not dispatch judgment for any of this — it shells out to
35
+ `node dist/cli.js --agents=architecture <paths>`, a fully deterministic CLI.
36
+ There is no LLM judgment step in the source project for Clean Architecture
37
+ boundary/dependency/layer detection at all: it is glob-based layer
38
+ classification plus regex import/text matching, because that is what the
39
+ Dependency Rule actually needs — WHICH FILE, WHICH FOLDER, WHICH IMPORT
40
+ TARGET — not an AST, and not an opinion.
41
+
42
+ `scripts/lib/architecture-scoring.mjs` ports that detection logic (see its
43
+ header for full provenance — three files fetched and read in full from
44
+ `github.com/OnSightTeam/architecture-toolkit`, MIT). It runs as
45
+ `rdc-architecture-score` alongside the pre-existing `rdc-solid-score`
46
+ boundary check, which is a DIFFERENT, complementary mechanism (an
47
+ explicitly-declared `{orchestrator, requiredPorts}` rule per repo, not
48
+ layer classification) and stays exactly as it was.
49
+
50
+ What is genuinely LEFT as judgment — the one thing neither scorer can do —
51
+ is deciding whether an abstraction boundary makes architectural SENSE for
52
+ this specific domain: a `Harness`-shaped class that reimplements logic its
53
+ own sibling packages already own, a `shared`/`utils` module quietly
54
+ accumulating business logic, a port whose shape leaks an implementation
55
+ detail, a package importing more siblings than its stated job justifies.
56
+ None of those are a shape a glob/regex scanner can match — they require
57
+ reading INTENT against the code that implements it. That is Step 2 below,
58
+ and it is now the ONLY judgment step.
59
+
60
+ ## When to Use
61
+
62
+ - Any PR that adds or restructures a package boundary
63
+ - Before merging a new orchestrator/use-case class
64
+ - Called from `rdc:review` step 8b+ alongside the SOLID score gate
65
+ - When a reviewer suspects "this could have just called the existing thing"
66
+
67
+ ## Arguments
68
+
69
+ - `rdc:architecture-reviewer <path>` — full review of the target
70
+ - `rdc:architecture-reviewer <path> --config <file>` — non-default layer
71
+ classification (see `lib/architecture-scoring.mjs`'s `DEFAULT_LAYERS` for
72
+ the shape; a repo that doesn't name its folders `entities`/`use-cases`/
73
+ `adapters`/`frameworks` needs its own `.architecture-score.yml`)
74
+ - `rdc:architecture-reviewer <path> --diff <ref>` — new/changed code only
75
+ (scope the file list to the diff before invoking either scorer)
76
+
77
+ ## Mechanical Rule Catalog — `rdc-architecture-score`
78
+
79
+ Every row cites the exact `architecture-toolkit` file:line it is ported or
80
+ adapted from (full citations live in `lib/architecture-scoring.mjs`'s
81
+ per-rule comments — read them before trusting a finding's severity).
82
+
83
+ | Rule ID | What it detects | Severity | Confidence basis |
84
+ |---|---|---|---|
85
+ | `dependency-direction` | An inner-layer file imports a more-outer layer (Entities importing UseCases, UseCases importing Frameworks, etc.) | Critical | `high` when the import RESOLVES to a real scanned file with a known layer; `low` when only the import specifier text is keyword-matched (the toolkit's own, weaker, method) |
86
+ | `framework-coupling` | A non-Frameworks-layer file imports a known framework/library (express, react, mongoose, prisma, aws-sdk, pg, …) | Critical (Entities) / High (everything else non-Frameworks) | `high`, except `medium` for the three generic-word indicators (`http`, `fetch`, `request`) the toolkit's own list carries, which real false-positive on an unrelated local variable or Node's builtin `node:http` |
87
+ | `missing-abstraction` (5 subtypes) | `missing-repository-interface` (UseCase `new`s a concrete Repository/Gateway/DataSource) · `missing-input-port` (Controller `new`s a concrete UseCase, no Port interface declared) · `http-request-in-usecase` (`request.`/`req.` referenced in a UseCase file) · `direct-db-access-in-usecase` (`db.`/raw SQL in a UseCase/Interactor/Service) · `data-structure-leak` (a Controller returns/types a value as an Entity) | High/Critical per subtype | `high` for the first three (unambiguous regex shape); `medium`/`low` for db-access and data-structure-leak (broader textual patterns, real false-positive risk — disclosed per-subtype) |
88
+ | `mixed-concerns` (bonus, beyond the task's 4 required categories) | Business-rule vocabulary and infrastructure vocabulary both appear in one file | High | `low` — whole-file word-list co-occurrence, same heuristic class as `clean-code-analyzer`'s N2 |
89
+ | `ui-business-logic-mixing` (bonus) | A 100+ char `calculate`/`validate`/`process` function body alongside UI indicators | Medium | `medium` |
90
+ | `mixed-layer-imports` (bonus) | A file imports from 3+ distinct layers | Medium | `medium` |
91
+ | `circular-layer-dependency` | A REAL cycle in the layer-import graph, built from RESOLVED file imports and walked with the SAME ADP algorithm `package-metrics.mjs` already uses for package-level cycles (`findCycles`, reused not reimplemented) — this is STRONGER than `architecture-toolkit`'s own circular check, which is a `"../../.."`-depth proxy that is neither necessary nor sufficient for an actual cycle (see `lib/architecture-scoring.mjs`'s header for why that half was deliberately NOT ported) | High | `high` — built only from resolved edges, not keyword guesses |
92
+
93
+ **Layer classification is config-driven, not hardcoded.** `DEFAULT_LAYERS`
94
+ covers the canonical four rings (`entities`/`domain`, `use-cases`/
95
+ `usecases`/`application`, `adapters`/`controllers`/`presenters`/`gateways`,
96
+ `frameworks`/`infrastructure`) by path glob, with a class/interface-name
97
+ regex fallback (ported from the toolkit's own `detectLayer`). A file
98
+ matching NEITHER is reported `layer: null` / unclassified — it is never
99
+ silently defaulted to any specific layer (the toolkit's own `detectLayer`
100
+ defaults an unmatched file to `Frameworks`; this port deliberately does
101
+ not, because "we don't know" and "this is definitely the outermost ring"
102
+ are different claims). A repo with different folder names supplies its own
103
+ `layers:` in `--config <file>`.
104
+
105
+ ## Procedure
106
+
107
+ 1. **Run both mechanical scorers first — cheap, exhaustive, never skip:**
108
+ ```bash
109
+ rdc-architecture-score <path> [--config <file>] --format json
110
+ rdc-solid-score <path> --diff <ref> --config <repo>/.solid-score.yml --format json
111
+ ```
112
+ Read `results[].rules` from `rdc-architecture-score` for every finding
113
+ above; read `boundaryFindings` from `rdc-solid-score` for the
114
+ orchestrator/port rule — a DIFFERENT, complementary check (an
115
+ explicitly-declared `{orchestrator, requiredPorts}` rule per repo, not
116
+ layer classification). Report `unclassifiedFiles` honestly — a file
117
+ with no glob/name-hint match got NO layer-aware findings run against it,
118
+ which is not the same as "clean."
119
+
120
+ If the target repo has no `.solid-score.yml` `boundaries` section, or no
121
+ `.architecture-score.yml` `layers` override where the default four rings
122
+ don't apply, say so plainly — a review that silently skipped a
123
+ configurable check is incomplete, not clean.
124
+
125
+ 2. **Dispatch the ONE remaining judgment step — architectural SENSE, not
126
+ shape:**
127
+
128
+ ```
129
+ Agent({
130
+ subagent_type: "pr-review-toolkit:code-reviewer",
131
+ description: "architecture-reviewer judgment pass",
132
+ prompt: "Review `git diff <ref>...HEAD` (or the full target if no
133
+ --diff) for architectural boundary choices that are
134
+ technically LEGAL — no mechanical rule catches them — but
135
+ conceptually WRONG for this domain. The mechanical scorers
136
+ (rdc-architecture-score, rdc-solid-score) already caught
137
+ layer-direction violations, framework coupling, missing
138
+ repository/port abstractions, HTTP/DB leaks into Use Cases,
139
+ layer-mixing, and circular layer dependencies — do NOT
140
+ re-litigate those. Check ONLY these four, which require
141
+ reading INTENT against the implementation, not matching a
142
+ shape:
143
+ (1) a module that reimplements logic a sibling package
144
+ already owns instead of importing and delegating to it — the
145
+ `Harness`-shaped failure: a class with almost no external
146
+ dependencies of ANY kind can score fine on a generic
147
+ DIP/coupling metric while still being exactly this;
148
+ (2) a 'shared'/'common'/'utils' module accumulating actual
149
+ business logic rather than genuinely-shared primitives;
150
+ (3) a port/interface whose own shape leaks a concrete
151
+ implementation detail (a parameter that only makes sense for
152
+ one adapter);
153
+ (4) a new package importing more siblings than its stated
154
+ responsibility justifies.
155
+ Report each finding with severity (critical/high/medium/low),
156
+ file:line, the concrete violation, and what delegation/
157
+ boundary should exist instead. Return
158
+ ARCHITECTURE_REVIEW_COMPLETE with:
159
+ { critical_count, high_count, medium_count, low_count,
160
+ findings: [{severity, file:line, issue, suggested_boundary}] }."
161
+ })
162
+ ```
163
+
164
+ 3. **Merge all three into one report.** The two mechanical scorers'
165
+ findings are certain (a named rule failed, or a real graph cycle
166
+ exists); the judgment findings carry the subagent's confidence — do
167
+ not present them with equal certainty. Within the mechanical findings
168
+ themselves, honor each finding's own `confidence` field (see the
169
+ catalog table) — a `low`-confidence mechanical finding is real evidence
170
+ worth a look, not a certainty either.
171
+
172
+ 4. **Severity gate**, same shape as `rdc:review` step 8b:
173
+ - Any `critical`/`high` mechanical finding, `rdc-solid-score` boundary
174
+ violation, or `critical`/`high` judgment finding → verdict cannot be
175
+ CLEAN.
176
+ - `medium`/`low` findings from either source → recorded, verdict can
177
+ still be CLEAN.
178
+ - Zero findings across all three → `ARCHITECTURE_REVIEW: CLEAN`.
179
+
180
+ 5. **Report:**
181
+ ```
182
+ ## Architecture Review
183
+ ### Mechanical — rdc-architecture-score (layer/dependency/boundary)
184
+ | Rule | File | Severity | Confidence | Detail |
185
+ ### Mechanical — rdc-solid-score (orchestrator/port boundary)
186
+ | File | Required port | Satisfied |
187
+ ### Unclassified files (no layer-aware rule ran)
188
+ ### Judgment findings — architectural sense
189
+ | Severity | File:Line | Issue | Suggested boundary |
190
+ ## Verdict: CLEAN / HAS ISSUES
191
+ ```
192
+
193
+ ## Rules
194
+
195
+ - Never skip step 1 to save time — both scorers are cheap and certain, and
196
+ each has independently dogfooded a real finding (see below).
197
+ - The judgment pass is advisory-plus-severity, not a rubber stamp on the
198
+ mechanical passes; a clean mechanical result with high-severity judgment
199
+ findings is still HAS ISSUES.
200
+ - Do not report a mechanical finding without its `confidence` field — a
201
+ `low`-confidence heuristic hit (e.g. `data-structure-leak`'s broad
202
+ `Entity`-substring match) is worth a look, never a certainty.
203
+ - If neither the target repo nor `--diff` is given, refuse rather than
204
+ guess scope — an architecture review with an unstated scope is
205
+ unfalsifiable.
206
+
207
+ ## Dogfood evidence — real, not hypothetical
208
+
209
+ Two DIFFERENT real targets confirm the mechanism works and is honest about
210
+ its own limits:
211
+
212
+ **A constructed positive-control fixture** (per
213
+ `.claude/rules/prove-absence-positive-control.md`) — a 4-file Clean
214
+ Architecture layout with a KNOWN violation of each required category —
215
+ correctly produced ALL of: 2 `dependency-direction` criticals (Entities and
216
+ UseCases each importing the Frameworks-layer file), 1 `framework-coupling`
217
+ critical (`express` imported into Entities), 5 `missing-abstraction`
218
+ findings across all 5 subtypes, and a real `circular-layer-dependency`
219
+ finding (`Frameworks -> UseCases -> Frameworks`, from two files that
220
+ genuinely import each other). This run also caught a real bug in the
221
+ scorer's own glob matcher — `**/frameworks/**` failed to match a TOP-LEVEL
222
+ `frameworks/db-client.mjs` path (no parent directory to supply the literal
223
+ slash the naive translation required) — fixed before ship, see
224
+ `lib/architecture-scoring.mjs`'s `globToRegExp` header for the full story.
225
+
226
+ **Two real fleets that do NOT use Clean-Architecture folder/class-naming
227
+ conventions** (`rdc-harness/packages`, 85 files; this repo's own `scripts/`,
228
+ 36 files) correctly report ZERO `dependency-direction`/`framework-coupling`/
229
+ `missing-abstraction` findings — confirmed as a GENUINE absence, not a
230
+ broken scanner, by an independent `grep -rE 'class\s+\w+(UseCase|Interactor|
231
+ Controller)'` across both trees (zero hits, matching the scorer's own
232
+ report). Both correctly report every scanned file as `unclassified` — real
233
+ honesty, not a silent false-positive from defaulting to a layer. The
234
+ layer-independent `mixed-concerns` heuristic still fires (34/54 hits
235
+ respectively) at its disclosed `low` confidence, the same character as
236
+ `clean-code-analyzer`'s N2 — worth a look, not a certainty.
237
+
238
+ ## Worked Example — `rdc-harness`'s `Harness` class, THREE sources now
239
+
240
+ Real target, read in full: [`C:/Dev/rdc-harness/packages/core/src/index.mjs`](file:///C:/Dev/rdc-harness/packages/core/src/index.mjs)
241
+ (398 lines, one exported class). This is now a THREE-source report, and it
242
+ is a genuinely instructive case: `Harness`'s failure is NOT a Clean
243
+ Architecture layer violation (the file uses no `entities`/`use-cases`/
244
+ `adapters`/`frameworks` folder or class-naming convention at all — that's
245
+ a different codebase style, package-oriented rather than layer-oriented),
246
+ so `rdc-architecture-score` correctly abstains rather than fabricating a
247
+ layer-based finding:
248
+
249
+ ```
250
+ ## Architecture Review — rdc-harness/packages/core/src/index.mjs
251
+
252
+ ### Mechanical — rdc-architecture-score
253
+ UNCLASSIFIED (no layer glob or name-hint matched `index.mjs`) — zero
254
+ layer-aware findings ran. This is an honest abstention, not a clean result:
255
+ `Harness`'s failure mode is package-level (see below), not layer-level.
256
+
257
+ ### Mechanical — rdc-solid-score (orchestrator/port boundary)
258
+ | File | Required port | Satisfied |
259
+ | index.mjs (Harness) | 6/6 ports (transaction, delivery, deploy, orchestration,
260
+ boundary, work — one per sibling package) | false — 0/6 satisfied |
261
+
262
+ ### Judgment findings — architectural sense
263
+ | Severity | File:Line | Issue | Suggested boundary |
264
+ | high (judgment #1) | index.mjs:49-394 (whole class) | `Harness` reimplements
265
+ transaction handling (`#block`, `transaction.blocked` events), delivery
266
+ (`shipDev`), deploy (`deploy`, `requestProduction`, `recordDecision`), and
267
+ orchestration (`createRun`, `createTarget`) all in one class — the exact
268
+ `Harness`-shaped failure: 21 sibling packages exist (`packages/transaction`,
269
+ `packages/delivery`, `packages/deploy`, `packages/orchestration`,
270
+ `packages/boundary`, …) and `Harness` currently imports from none of them;
271
+ it reimplements their responsibilities inline instead of delegating. It
272
+ would still pass a generic SOLID/coupling metric because its only external
273
+ imports are `node:crypto` and `node:fs` — a narrow dependency *count*
274
+ hiding a wide responsibility *surface*. | Split into a thin `Harness`
275
+ facade that composes injected ports: `TransactionPort`, `DeliveryPort`,
276
+ `DeployPort`, `OrchestrationPort`, each backed by its already-existing
277
+ sibling package. |
278
+
279
+ ## Verdict: HAS ISSUES (0/6 ports satisfied, 1 high judgment finding)
280
+ ```
281
+
282
+ This is a real result, not a hypothetical: `Harness` genuinely has zero
283
+ imports from any of its 21 sibling packages (confirmed via
284
+ `grep -r "from '.*packages/(transaction|delivery|deploy|orchestration|boundary)"`
285
+ against `index.mjs` — zero hits), `rdc-solid-score` genuinely reports 0/6
286
+ ports satisfied, and `rdc-architecture-score` genuinely reports the file
287
+ unclassified — three independently-verified facts, not one narrative.