@chemx/create-chemx 26.9.12-93 → 26.9.13-383

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 (54) hide show
  1. package/cli/audit/ai-slop-detector.js +32 -48
  2. package/cli/audit/ast-visitors.js +10 -13
  3. package/cli/audit/extended-visitors.js +10 -4
  4. package/cli/audit/history.js +47 -15
  5. package/cli/audit/pattern-detector.js +182 -0
  6. package/cli/audit/prompts.js +165 -71
  7. package/cli/audit/reporter-banner.js +4 -16
  8. package/cli/audit/reporter-grades.js +28 -16
  9. package/cli/audit/reporter-grouping.d.ts +6 -0
  10. package/cli/audit/reporter-grouping.js +48 -5
  11. package/cli/audit/reporter-markdown.js +31 -16
  12. package/cli/audit/reporter-sections.d.ts +1 -0
  13. package/cli/audit/reporter-sections.js +83 -13
  14. package/cli/audit/reporter-utils.js +116 -1
  15. package/cli/audit/reporter.d.ts +13 -4
  16. package/cli/audit/reporter.js +34 -5
  17. package/cli/audit/roadmap.js +236 -0
  18. package/cli/audit/rules-predicates.d.ts +22 -0
  19. package/cli/audit/rules-predicates.js +139 -0
  20. package/cli/audit/rules.js +6 -2
  21. package/cli/audit/social-gh.js +2 -6
  22. package/cli/audit/social-publisher.js +1 -1
  23. package/cli/audit/social.js +25 -9
  24. package/cli/audit/types.d.ts +52 -5
  25. package/cli/audit.js +89 -14
  26. package/cli/badge.js +9 -2
  27. package/cli/build/detector.js +68 -0
  28. package/cli/build/executor.js +58 -0
  29. package/cli/build/grouper.js +78 -0
  30. package/cli/build/parser-matchers.js +116 -0
  31. package/cli/build/parser.js +80 -0
  32. package/cli/build/reporter.js +66 -0
  33. package/cli/build/types.d.ts +58 -0
  34. package/cli/build/types.js +1 -0
  35. package/cli/build.d.ts +16 -0
  36. package/cli/build.js +59 -0
  37. package/cli/generator.js +4 -1
  38. package/cli/help.js +14 -0
  39. package/cli/index.js +43 -15
  40. package/cli/installer-templates.js +43 -4
  41. package/cli/license.js +59 -6
  42. package/cli/navigator-actions.js +114 -90
  43. package/cli/navigator-banner.js +4 -2
  44. package/cli/navigator-grades.js +28 -0
  45. package/cli/navigator-menu.js +32 -13
  46. package/cli/navigator-share.js +1 -1
  47. package/cli/navigator.d.ts +10 -1
  48. package/cli/navigator.js +5 -3
  49. package/cli/scaffold.js +24 -9
  50. package/cli/terminal.js +6 -5
  51. package/cli/theme.d.ts +41 -0
  52. package/cli/theme.js +84 -0
  53. package/docs/CHANGELOG.md +68 -0
  54. package/package.json +3 -2
@@ -1,5 +1,12 @@
1
1
  import * as t from '@babel/types';
2
2
  import { RULE_REGISTRY } from './rules-registry.js';
3
+ import {
4
+ isComponentPath,
5
+ isCodeLine,
6
+ isShallowCatchBody,
7
+ hasAnyTypeAnnotation,
8
+ isRedundantPassthroughReturn
9
+ } from './rules-predicates.js';
3
10
 
4
11
  const RESIDUE_PATTERNS = [
5
12
  ['hope this', 'helps'].join(' '),
@@ -95,7 +102,7 @@ export const checkSlopTextPatterns = (content, lines, relativePath, violations)
95
102
  let nextIdx = idx + 1;
96
103
  while (nextIdx < lines.length) {
97
104
  const nextTrim = lines[nextIdx].trim();
98
- if (nextTrim && !nextTrim.startsWith('//') && !nextTrim.startsWith('/*')) {
105
+ if (isCodeLine(nextTrim)) {
99
106
  const nextWords = new Set(normalizeWords(nextTrim));
100
107
  let matchCount = 0;
101
108
  for (const cw of commentWords) {
@@ -126,25 +133,12 @@ export const checkSlopTextPatterns = (content, lines, relativePath, violations)
126
133
  };
127
134
 
128
135
  export const createAiSlopVisitors = ({ relativePath, violations }) => {
129
- const isComponentFile = (
130
- relativePath.includes('molecules') ||
131
- relativePath.includes('components') ||
132
- relativePath.includes('/m-') ||
133
- relativePath.includes('/views/') ||
134
- relativePath.includes('/pages/')
135
- );
136
+ const isComponentFile = isComponentPath(relativePath);
136
137
 
137
138
  return {
138
139
  CatchClause(astPath) {
139
140
  const body = astPath.node.body?.body || [];
140
- const isShallowCatch = (
141
- body.length === 0 ||
142
- (body.length === 1 &&
143
- t.isExpressionStatement(body[0]) &&
144
- t.isCallExpression(body[0].expression) &&
145
- t.isMemberExpression(body[0].expression.callee) &&
146
- t.isIdentifier(body[0].expression.callee.object, { name: 'console' }))
147
- );
141
+ const isShallowCatch = isShallowCatchBody(body, t);
148
142
 
149
143
  if (isShallowCatch) {
150
144
  const line = astPath.node.loc?.start.line || 1;
@@ -162,23 +156,20 @@ export const createAiSlopVisitors = ({ relativePath, violations }) => {
162
156
  });
163
157
  }
164
158
 
165
- const param = astPath.node.param;
166
- if (param && t.isIdentifier(param) && param.typeAnnotation) {
167
- if (t.isTSTypeAnnotation(param.typeAnnotation) && t.isTSAnyKeyword(param.typeAnnotation.typeAnnotation)) {
168
- const line = astPath.node.loc?.start.line || 1;
169
- const meta = RULE_REGISTRY.AI_SLOP_LAZY_ANY;
170
- violations.push({
171
- filePath: relativePath,
172
- line,
173
- column: astPath.node.loc?.start.column || 1,
174
- hazard: 'Lazy any catch parameter widening detected',
175
- rule: 'AI_SLOP_LAZY_ANY',
176
- severity: meta.severity,
177
- pillar: meta.pillar,
178
- directive: meta.directive,
179
- isAiSlop: true
180
- });
181
- }
159
+ if (hasAnyTypeAnnotation(astPath.node.param, t)) {
160
+ const line = astPath.node.loc?.start.line || 1;
161
+ const meta = RULE_REGISTRY.AI_SLOP_LAZY_ANY;
162
+ violations.push({
163
+ filePath: relativePath,
164
+ line,
165
+ column: astPath.node.loc?.start.column || 1,
166
+ hazard: 'Lazy any catch parameter widening detected',
167
+ rule: 'AI_SLOP_LAZY_ANY',
168
+ severity: meta.severity,
169
+ pillar: meta.pillar,
170
+ directive: meta.directive,
171
+ isAiSlop: true
172
+ });
182
173
  }
183
174
  },
184
175
 
@@ -188,14 +179,7 @@ export const createAiSlopVisitors = ({ relativePath, violations }) => {
188
179
  for (let i = 0; i < stmts.length - 1; i++) {
189
180
  const curr = stmts[i];
190
181
  const next = stmts[i + 1];
191
- if (
192
- t.isVariableDeclaration(curr) &&
193
- curr.declarations.length === 1 &&
194
- t.isIdentifier(curr.declarations[0].id) &&
195
- t.isReturnStatement(next) &&
196
- t.isIdentifier(next.argument) &&
197
- curr.declarations[0].id.name === next.argument.name
198
- ) {
182
+ if (isRedundantPassthroughReturn(curr, next, t)) {
199
183
  const varName = curr.declarations[0].id.name;
200
184
  const line = curr.loc?.start.line || 1;
201
185
  const meta = RULE_REGISTRY.AI_SLOP_REDUNDANT_PASSTHROUGH;
@@ -238,12 +222,13 @@ export const createAiSlopVisitors = ({ relativePath, violations }) => {
238
222
  VariableDeclarator(astPath) {
239
223
  if (!isComponentFile) return;
240
224
  const idName = astPath.node.id?.name?.toLowerCase();
241
- if (
242
- idName &&
243
- REINVENTED_UTILS.has(idName) &&
244
- (t.isArrowFunctionExpression(astPath.node.init) || t.isFunctionExpression(astPath.node.init))
245
- ) {
246
- const line = astPath.node.loc?.start.line || 1;
225
+ if (!idName || !REINVENTED_UTILS.has(idName)) return;
226
+
227
+ const initNode = astPath.node.init;
228
+ const isFunction = t.isArrowFunctionExpression(initNode) || t.isFunctionExpression(initNode);
229
+ if (!isFunction) return;
230
+
231
+ const line = astPath.node.loc?.start.line || 1;
247
232
  const meta = RULE_REGISTRY.AI_SLOP_UTILITY_REINVENTION;
248
233
  violations.push({
249
234
  filePath: relativePath,
@@ -256,7 +241,6 @@ export const createAiSlopVisitors = ({ relativePath, violations }) => {
256
241
  directive: meta.directive,
257
242
  isAiSlop: true
258
243
  });
259
- }
260
244
  }
261
245
  };
262
246
  };
@@ -1,14 +1,17 @@
1
1
  import * as t from '@babel/types';
2
2
  import { RULE_REGISTRY } from './rules-registry.js';
3
3
  import { countLogicalOperators } from './rules-helpers.js';
4
+ import {
5
+ isCustomHookFunction,
6
+ resolveStartLine,
7
+ isZeroDelayTimeout,
8
+ isUnguardedConsoleCall
9
+ } from './rules-predicates.js';
4
10
 
5
11
  export const createAstVisitors = ({ relativePath, violations }) => {
6
12
  return {
7
13
  Function(astPath) {
8
- const isCustomHook = (
9
- (astPath.node.id && /^use[A-Z0-9]/.test(astPath.node.id.name)) ||
10
- (astPath.parentPath?.node?.id && /^use[A-Z0-9]/.test(astPath.parentPath.node.id.name))
11
- );
14
+ const isCustomHook = isCustomHookFunction(astPath);
12
15
 
13
16
  // Pillar 3: Hook Saturation
14
17
  let hookCount = 0;
@@ -70,7 +73,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
70
73
  if (t.isLogicalExpression(expr) || t.isUnaryExpression(expr)) {
71
74
  const opCount = countLogicalOperators(expr);
72
75
  if (opCount > 2) {
73
- const line = expr.loc?.start.line || astPath.node.loc?.start.line || 1;
76
+ const line = resolveStartLine(expr, astPath.node, 1);
74
77
  const meta = RULE_REGISTRY.CONTROL_FLOW_INLINE_BOOLEAN;
75
78
  violations.push({
76
79
  filePath: relativePath,
@@ -154,7 +157,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
154
157
  const delayArg = args[1];
155
158
 
156
159
  // Render-hack check: setTimeout(fn, 0)
157
- if (callee.name === 'setTimeout' && delayArg && t.isNumericLiteral(delayArg) && delayArg.value === 0) {
160
+ if (isZeroDelayTimeout(callee, delayArg, t)) {
158
161
  const line = astPath.node.loc?.start.line || 1;
159
162
  const meta = RULE_REGISTRY.RENDER_HACK_TIMEOUT;
160
163
  violations.push({
@@ -198,13 +201,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
198
201
  }
199
202
 
200
203
  // Unguarded Logging
201
- if (
202
- t.isMemberExpression(callee) &&
203
- t.isIdentifier(callee.object) &&
204
- callee.object.name === 'console' &&
205
- t.isIdentifier(callee.property) &&
206
- ['log', 'info', 'warn'].includes(callee.property.name)
207
- ) {
204
+ if (isUnguardedConsoleCall(callee, t)) {
208
205
  const line = astPath.node.loc?.start.line || 1;
209
206
  const meta = RULE_REGISTRY.UNGUARDED_LOGGING;
210
207
  violations.push({
@@ -3,6 +3,11 @@ import path from 'node:path';
3
3
  import * as t from '@babel/types';
4
4
  import { RULE_REGISTRY } from './rules-registry.js';
5
5
  import { toResultSync } from './rules-helpers.js';
6
+ import {
7
+ isComponentExtension,
8
+ isTemplateExtension,
9
+ isCommentLine
10
+ } from './rules-predicates.js';
6
11
 
7
12
  const BARE_BOOLEANS = new Set([
8
13
  'loading', 'valid', 'active', 'visible', 'open', 'disabled',
@@ -37,11 +42,12 @@ const checkMoleculeCoLocatedTest = (filePath, relativePath, violations) => {
37
42
 
38
43
  // Stage 1: Atomic Concept Declarations
39
44
  const isMoleculePath = relativePath.includes('molecules') || baseName.startsWith('m-');
40
- const isComponentExt = ext === '.vue' || ext === '.tsx' || ext === '.jsx';
45
+ const isComponentExt = isComponentExtension(ext);
41
46
  const isTestOrSpecFile = baseName.includes('.spec.') || baseName.includes('.test.');
42
47
 
43
48
  // Stage 2: Unified Decision Variable & Early Guard Clause
44
- const isMoleculeComponent = isMoleculePath && isComponentExt && !isTestOrSpecFile;
49
+ const isCandidateComponent = isMoleculePath && isComponentExt;
50
+ const isMoleculeComponent = isCandidateComponent && !isTestOrSpecFile;
45
51
  if (!isMoleculeComponent) return;
46
52
 
47
53
  const dir = path.dirname(filePath);
@@ -75,12 +81,12 @@ export const checkExtendedTextPatterns = (content, lines, relativePath, filePath
75
81
 
76
82
  const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath);
77
83
  const ext = path.extname(filePath);
78
- const isTemplateFile = ext === '.vue' || ext === '.html' || ext === '.svelte';
84
+ const isTemplateFile = isTemplateExtension(ext);
79
85
 
80
86
  lines.forEach((lineText, idx) => {
81
87
  const lineNum = idx + 1;
82
88
  const trimmed = lineText.trim();
83
- const isComment = trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
89
+ const isComment = isCommentLine(trimmed);
84
90
 
85
91
  // Pillar 9: Hardcoded Secret Detection
86
92
  const hasEnvReference = trimmed.includes('process.env') || trimmed.includes('import.meta.env');
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { groupViolationsBySeverity } from './reporter.js';
4
+ import { hasMatchingAuditMetrics } from './rules-predicates.js';
4
5
 
5
6
  const RESET = '\x1b[0m';
6
7
  const BOLD = '\x1b[1m';
@@ -126,18 +127,39 @@ export const getAuditHistory = (cwd = process.cwd()) => {
126
127
  };
127
128
 
128
129
  export const getAuditBaseline = (cwd = process.cwd()) => {
130
+ const history = getAuditHistory(cwd);
129
131
  const baselinePath = path.resolve(cwd, '.chemx', 'baseline.json');
132
+ let explicitBaseline = null;
130
133
  if (fs.existsSync(baselinePath)) {
131
134
  try {
132
135
  const raw = fs.readFileSync(baselinePath, 'utf-8');
133
- return JSON.parse(raw);
136
+ explicitBaseline = JSON.parse(raw);
134
137
  } catch (err) {
135
138
  const error = err instanceof Error ? err : new Error(String(err));
136
- return null;
139
+ explicitBaseline = null;
137
140
  }
138
141
  }
139
- const history = getAuditHistory(cwd);
140
- return history.length > 0 ? history[0] : null;
142
+
143
+ if (history.length === 0) {
144
+ return explicitBaseline;
145
+ }
146
+
147
+ // Find the lowest scoring snapshot in history (the true debt floor)
148
+ let lowestSnapshot = history[0];
149
+ for (const s of history) {
150
+ const currentScore = s.health?.score ?? 100;
151
+ const lowestScore = lowestSnapshot.health?.score ?? 100;
152
+ if (currentScore < lowestScore) {
153
+ lowestSnapshot = s;
154
+ }
155
+ }
156
+
157
+ if (!explicitBaseline) return lowestSnapshot;
158
+
159
+ // Use the lowest score between explicit baseline and history floor
160
+ const explicitScore = explicitBaseline.health?.score ?? 100;
161
+ const minScore = lowestSnapshot.health?.score ?? 100;
162
+ return minScore < explicitScore ? lowestSnapshot : explicitBaseline;
141
163
  };
142
164
 
143
165
  export const setAuditBaseline = (snapshot, cwd = process.cwd()) => {
@@ -156,10 +178,7 @@ export const saveAuditSnapshot = (report, cwd = process.cwd()) => {
156
178
  if (history.length > 0) {
157
179
  const last = history[history.length - 1];
158
180
  const timeDiff = Math.abs(Date.now() - new Date(last.timestamp).getTime());
159
- const isSameMetrics =
160
- last.health?.score === snapshot.health?.score &&
161
- last.metrics?.totalLoc === snapshot.metrics?.totalLoc &&
162
- last.violations?.total === snapshot.violations?.total;
181
+ const isSameMetrics = hasMatchingAuditMetrics(last, snapshot);
163
182
 
164
183
  if (timeDiff < 5000 && isSameMetrics) {
165
184
  return {
@@ -179,10 +198,14 @@ export const saveAuditSnapshot = (report, cwd = process.cwd()) => {
179
198
  const historyPath = path.resolve(cwd, '.chemx', 'history.json');
180
199
  fs.writeFileSync(historyPath, JSON.stringify(trimmed, null, 2), 'utf-8');
181
200
 
182
- // Auto-establish first run as baseline if none exists
201
+ // Auto-establish first run or lower score as baseline floor
183
202
  const baselinePath = path.resolve(cwd, '.chemx', 'baseline.json');
184
203
  let isNewBaseline = false;
185
- if (!fs.existsSync(baselinePath)) {
204
+ const currentBaseline = getAuditBaseline(cwd);
205
+ const currentScore = snapshot.health?.score ?? 100;
206
+ const baselineScore = currentBaseline?.health?.score ?? 101;
207
+
208
+ if (!currentBaseline || currentScore < baselineScore) {
186
209
  fs.writeFileSync(baselinePath, JSON.stringify(snapshot, null, 2), 'utf-8');
187
210
  isNewBaseline = true;
188
211
  }
@@ -275,7 +298,8 @@ export const calculateTransformationDelta = (beforeSnapshot, afterSnapshot) => {
275
298
  };
276
299
  };
277
300
 
278
- export const formatTransformationTerminal = (beforeSnapshot, afterSnapshot) => {
301
+ export const formatTransformationTerminal = (beforeSnapshot, afterSnapshot, options = {}) => {
302
+ const { isStepDelta = false } = options;
279
303
  const delta = calculateTransformationDelta(beforeSnapshot, afterSnapshot);
280
304
  const lines = [];
281
305
 
@@ -295,18 +319,26 @@ export const formatTransformationTerminal = (beforeSnapshot, afterSnapshot) => {
295
319
  return `${color}${BOLD}${sign}${perUnit}${RESET}`;
296
320
  };
297
321
 
322
+ const title = isStepDelta
323
+ ? 'STEP PROGRESSION : INCREMENTAL CHECKPOINT DELTA'
324
+ : 'ARCHITECTURAL TRANSFORMATION : BEFORE & AFTER PROGRESSION';
325
+ const subtitle = isStepDelta
326
+ ? 'Comparing immediately preceding audit vs. latest refactored audit'
327
+ : 'Comparing baseline floor snapshot vs. latest refactored audit';
328
+ const beforeLabel = isStepDelta ? 'Previous Audit:' : 'Baseline Floor:';
329
+
298
330
  lines.push('');
299
331
  lines.push(`${CYAN}======================================================================${RESET}`);
300
- lines.push(`${BOLD}${CYAN} ARCHITECTURAL TRANSFORMATION : BEFORE & AFTER PROGRESSION${RESET}`);
301
- lines.push(`${DIM} Comparing baseline snapshot vs. latest refactored audit${RESET}`);
332
+ lines.push(`${BOLD}${CYAN} ${title}${RESET}`);
333
+ lines.push(`${DIM} ${subtitle}${RESET}`);
302
334
  lines.push(`${CYAN}======================================================================${RESET}`);
303
335
  lines.push('');
304
336
 
305
337
  const dateBefore = new Date(beforeSnapshot.timestamp).toLocaleDateString();
306
338
  const dateAfter = new Date(afterSnapshot.timestamp).toLocaleDateString();
307
339
 
308
- lines.push(` ${BOLD}Baseline Audit:${RESET} ${DIM}${dateBefore} [${beforeSnapshot.id}]${RESET}`);
309
- lines.push(` ${BOLD}Latest Audit:${RESET} ${GREEN}${dateAfter} [${afterSnapshot.id}]${RESET}`);
340
+ lines.push(` ${BOLD}${beforeLabel.padEnd(18)}${RESET} ${DIM}${dateBefore} [${beforeSnapshot.id}]${RESET}`);
341
+ lines.push(` ${BOLD}${'Latest Audit:'.padEnd(18)}${RESET} ${GREEN}${dateAfter} [${afterSnapshot.id}]${RESET}`);
310
342
  lines.push('');
311
343
 
312
344
  lines.push(`${BOLD} METRIC COMPARISON TABLE${RESET}`);
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Chemical X Protocol: Structural Pattern Harmonization Detector
3
+ * Single-pass AST fingerprinting to discover cross-file clones and extraction candidates.
4
+ */
5
+
6
+ const MIN_UNION_MEMBERS = 3;
7
+ const MIN_CHILD_NODES = 2;
8
+
9
+ export const createPatternRegistry = () => {
10
+ const buckets = new Map();
11
+
12
+ const record = (type, signature, location) => {
13
+ if (!signature) return;
14
+ const key = `${type}::${signature}`;
15
+ let bucket = buckets.get(key);
16
+ if (!bucket) {
17
+ bucket = {
18
+ type,
19
+ signature,
20
+ detail: location.detail || signature,
21
+ occurrences: []
22
+ };
23
+ buckets.set(key, bucket);
24
+ }
25
+ bucket.occurrences.push({
26
+ filePath: location.filePath,
27
+ line: location.line || 1,
28
+ column: location.column || 1
29
+ });
30
+ };
31
+
32
+ const resolveHarmonizationCandidates = (hotspots = []) => {
33
+ const hotspotFiles = new Set(hotspots.map((h) => h.filePath));
34
+ const candidates = [];
35
+
36
+ for (const [key, bucket] of buckets.entries()) {
37
+ const uniqueFiles = Array.from(new Set(bucket.occurrences.map((o) => o.filePath)));
38
+ if (uniqueFiles.length < 2) continue;
39
+
40
+ const hasHotspot = uniqueFiles.some((f) => hotspotFiles.has(f));
41
+ const impactScore = uniqueFiles.length * (hasHotspot ? 3 : 1.5) + bucket.occurrences.length;
42
+
43
+ let label = 'Recurring Pattern';
44
+ let suggestedCapsule = 'm-shared-capsule';
45
+ let recommendation = 'Extract canonical capsule before decomposing consumers';
46
+
47
+ if (bucket.type === 'STATE_UNION') {
48
+ label = `Shared State Machine (${bucket.detail})`;
49
+ suggestedCapsule = 'types/state.d.ts';
50
+ recommendation = 'Extract shared discriminated union into domain types to eliminate duplicate definitions';
51
+ } else if (bucket.type === 'UI_STRUCTURE') {
52
+ label = `Shared UI Layout Structure (${bucket.detail})`;
53
+ suggestedCapsule = 'm-feature-card';
54
+ recommendation = 'Extract canonical molecule capsule before slicing consumer monoliths';
55
+ } else if (bucket.type === 'PREDICATE_LOGIC') {
56
+ label = `Duplicated Boolean Predicate Topology (${bucket.detail})`;
57
+ suggestedCapsule = 'usePredicateFilter.ts';
58
+ recommendation = 'Extract named predicate callback or higher-order filter function';
59
+ } else if (bucket.type === 'HOOK_SIGNATURE') {
60
+ label = `Parallel State Controller Return (${bucket.detail})`;
61
+ suggestedCapsule = 'useSharedController.ts';
62
+ recommendation = 'Unify ad-hoc local state returns into a canonical domain composable';
63
+ }
64
+
65
+ candidates.push({
66
+ id: key,
67
+ type: bucket.type,
68
+ label,
69
+ detail: bucket.detail,
70
+ suggestedCapsule,
71
+ recommendation,
72
+ fileCount: uniqueFiles.length,
73
+ totalHits: bucket.occurrences.length,
74
+ hasHotspot,
75
+ impactScore,
76
+ occurrences: bucket.occurrences
77
+ });
78
+ }
79
+
80
+ return candidates.sort((a, b) => b.impactScore - a.impactScore).slice(0, 12);
81
+ };
82
+
83
+ return { record, resolveHarmonizationCandidates };
84
+ };
85
+
86
+ export const createPatternVisitors = (registry, relativePath) => {
87
+ if (!registry) return {};
88
+
89
+ const getJsxTagName = (node) => {
90
+ if (!node || !node.openingElement) return null;
91
+ const nameNode = node.openingElement.name;
92
+ if (nameNode.type === 'JSXIdentifier') return nameNode.name;
93
+ if (nameNode.type === 'JSXMemberExpression') return `${nameNode.object.name}.${nameNode.property.name}`;
94
+ return null;
95
+ };
96
+
97
+ const getJsxHierarchy = (node, depth = 0) => {
98
+ if (depth > 2 || !node || node.type !== 'JSXElement') return '';
99
+ const tag = getJsxTagName(node);
100
+ if (!tag) return '';
101
+
102
+ const elementChildren = (node.children || [])
103
+ .filter((c) => c.type === 'JSXElement')
104
+ .map((c) => getJsxHierarchy(c, depth + 1))
105
+ .filter(Boolean);
106
+
107
+ if (elementChildren.length === 0) return tag;
108
+ return `${tag}>(${elementChildren.join('+')})`;
109
+ };
110
+
111
+ return {
112
+ TSTypeAliasDeclaration(path) {
113
+ const typeAnnotation = path.node.typeAnnotation;
114
+ if (typeAnnotation && typeAnnotation.type === 'TSUnionType') {
115
+ const literals = (typeAnnotation.types || [])
116
+ .filter((t) => t.type === 'TSLiteralType' && typeof t.literal?.value === 'string')
117
+ .map((t) => `'${t.literal.value}'`);
118
+
119
+ if (literals.length >= MIN_UNION_MEMBERS) {
120
+ const sorted = Array.from(new Set(literals)).sort().join(' | ');
121
+ registry.record('STATE_UNION', sorted, {
122
+ filePath: relativePath,
123
+ line: path.node.loc?.start?.line,
124
+ detail: sorted
125
+ });
126
+ }
127
+ }
128
+ },
129
+
130
+ JSXElement(path) {
131
+ const directElementChildren = (path.node.children || []).filter((c) => c.type === 'JSXElement');
132
+ if (directElementChildren.length >= MIN_CHILD_NODES) {
133
+ const hierarchy = getJsxHierarchy(path.node);
134
+ if (hierarchy && hierarchy.includes('>')) {
135
+ registry.record('UI_STRUCTURE', hierarchy, {
136
+ filePath: relativePath,
137
+ line: path.node.loc?.start?.line,
138
+ detail: hierarchy
139
+ });
140
+ }
141
+ }
142
+ },
143
+
144
+ LogicalExpression(path) {
145
+ let clauseCount = 1;
146
+ let curr = path.node;
147
+ while (curr && curr.type === 'LogicalExpression') {
148
+ clauseCount++;
149
+ curr = curr.left;
150
+ }
151
+ if (clauseCount >= 3) {
152
+ const sig = `CLAUSES_${clauseCount}_OP_${path.node.operator}`;
153
+ registry.record('PREDICATE_LOGIC', sig, {
154
+ filePath: relativePath,
155
+ line: path.node.loc?.start?.line,
156
+ detail: `${clauseCount}-clause ${path.node.operator} condition`
157
+ });
158
+ }
159
+ },
160
+
161
+ ReturnStatement(path) {
162
+ const functionParent = path.getFunctionParent();
163
+ const fnName = functionParent?.node?.id?.name;
164
+ const isHook = fnName && /^use[A-Z]/.test(fnName);
165
+
166
+ if (isHook && path.node.argument && path.node.argument.type === 'ObjectExpression') {
167
+ const props = (path.node.argument.properties || [])
168
+ .filter((p) => p.type === 'ObjectProperty' && p.key?.name)
169
+ .map((p) => p.key.name);
170
+
171
+ if (props.length >= 3) {
172
+ const sorted = props.sort().join(',');
173
+ registry.record('HOOK_SIGNATURE', sorted, {
174
+ filePath: relativePath,
175
+ line: path.node.loc?.start?.line,
176
+ detail: `{ ${sorted.replace(/,/g, ', ')} }`
177
+ });
178
+ }
179
+ }
180
+ }
181
+ };
182
+ };