@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.
- package/cli/audit/ai-slop-detector.js +32 -48
- package/cli/audit/ast-visitors.js +10 -13
- package/cli/audit/extended-visitors.js +10 -4
- package/cli/audit/history.js +47 -15
- package/cli/audit/pattern-detector.js +182 -0
- package/cli/audit/prompts.js +165 -71
- package/cli/audit/reporter-banner.js +4 -16
- package/cli/audit/reporter-grades.js +28 -16
- package/cli/audit/reporter-grouping.d.ts +6 -0
- package/cli/audit/reporter-grouping.js +48 -5
- package/cli/audit/reporter-markdown.js +31 -16
- package/cli/audit/reporter-sections.d.ts +1 -0
- package/cli/audit/reporter-sections.js +83 -13
- package/cli/audit/reporter-utils.js +116 -1
- package/cli/audit/reporter.d.ts +13 -4
- package/cli/audit/reporter.js +34 -5
- package/cli/audit/roadmap.js +236 -0
- package/cli/audit/rules-predicates.d.ts +22 -0
- package/cli/audit/rules-predicates.js +139 -0
- package/cli/audit/rules.js +6 -2
- package/cli/audit/social-gh.js +2 -6
- package/cli/audit/social-publisher.js +1 -1
- package/cli/audit/social.js +25 -9
- package/cli/audit/types.d.ts +52 -5
- package/cli/audit.js +89 -14
- package/cli/badge.js +9 -2
- package/cli/build/detector.js +68 -0
- package/cli/build/executor.js +58 -0
- package/cli/build/grouper.js +78 -0
- package/cli/build/parser-matchers.js +116 -0
- package/cli/build/parser.js +80 -0
- package/cli/build/reporter.js +66 -0
- package/cli/build/types.d.ts +58 -0
- package/cli/build/types.js +1 -0
- package/cli/build.d.ts +16 -0
- package/cli/build.js +59 -0
- package/cli/generator.js +4 -1
- package/cli/help.js +14 -0
- package/cli/index.js +43 -15
- package/cli/installer-templates.js +43 -4
- package/cli/license.js +59 -6
- package/cli/navigator-actions.js +114 -90
- package/cli/navigator-banner.js +4 -2
- package/cli/navigator-grades.js +28 -0
- package/cli/navigator-menu.js +32 -13
- package/cli/navigator-share.js +1 -1
- package/cli/navigator.d.ts +10 -1
- package/cli/navigator.js +5 -3
- package/cli/scaffold.js +24 -9
- package/cli/terminal.js +6 -5
- package/cli/theme.d.ts +41 -0
- package/cli/theme.js +84 -0
- package/docs/CHANGELOG.md +68 -0
- 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 (
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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');
|
package/cli/audit/history.js
CHANGED
|
@@ -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
|
-
|
|
136
|
+
explicitBaseline = JSON.parse(raw);
|
|
134
137
|
} catch (err) {
|
|
135
138
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
136
|
-
|
|
139
|
+
explicitBaseline = null;
|
|
137
140
|
}
|
|
138
141
|
}
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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}
|
|
301
|
-
lines.push(`${DIM}
|
|
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}
|
|
309
|
-
lines.push(` ${BOLD}Latest Audit
|
|
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
|
+
};
|