@archora/core 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { parse as parseSfc } from '@vue/compiler-sfc';
|
|
2
|
+
import type { ParsedFile } from '../types';
|
|
3
|
+
import type { TsParser } from './tsParser';
|
|
4
|
+
|
|
5
|
+
export interface VueParseInput {
|
|
6
|
+
relPath: string;
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface VueParser {
|
|
11
|
+
parse(input: VueParseInput): ParsedFile;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VUE_BUILTIN_TAGS: ReadonlySet<string> = new Set([
|
|
15
|
+
'Transition',
|
|
16
|
+
'TransitionGroup',
|
|
17
|
+
'KeepAlive',
|
|
18
|
+
'Suspense',
|
|
19
|
+
'Teleport',
|
|
20
|
+
'Component',
|
|
21
|
+
'Slot',
|
|
22
|
+
'RouterView',
|
|
23
|
+
'RouterLink',
|
|
24
|
+
'NuxtLink',
|
|
25
|
+
'NuxtPage',
|
|
26
|
+
'NuxtLayout',
|
|
27
|
+
'ClientOnly',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function createVueParser(tsParser: TsParser): VueParser {
|
|
31
|
+
return {
|
|
32
|
+
parse(input: VueParseInput): ParsedFile {
|
|
33
|
+
const { descriptor } = parseSfc(input.content, { filename: input.relPath });
|
|
34
|
+
const setup = descriptor.scriptSetup;
|
|
35
|
+
const script = descriptor.script;
|
|
36
|
+
const code = `${setup?.content ?? ''}\n${script?.content ?? ''}`;
|
|
37
|
+
const parsed = tsParser.parse({
|
|
38
|
+
relPath: input.relPath,
|
|
39
|
+
content: code,
|
|
40
|
+
language: 'vue',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const templateRefs = descriptor.template?.ast
|
|
44
|
+
? extractTemplateComponentTags(descriptor.template.ast as unknown as TemplateNodeLike)
|
|
45
|
+
: [];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
...parsed,
|
|
49
|
+
loc: countLines(input.content),
|
|
50
|
+
language: 'vue',
|
|
51
|
+
...(templateRefs.length > 0 ? { templateRefs } : {}),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractTemplateComponentTags(root: TemplateNodeLike): string[] {
|
|
58
|
+
const found = new Set<string>();
|
|
59
|
+
visit(root.children);
|
|
60
|
+
return [...found];
|
|
61
|
+
|
|
62
|
+
function visit(children: ReadonlyArray<unknown> | undefined): void {
|
|
63
|
+
if (!children) return;
|
|
64
|
+
for (const child of children) {
|
|
65
|
+
if (!isElementNode(child)) continue;
|
|
66
|
+
if (child.type === 1 && child.tagType === 1 && typeof child.tag === 'string') {
|
|
67
|
+
const name = normalizeTag(child.tag);
|
|
68
|
+
if (name && !VUE_BUILTIN_TAGS.has(name)) found.add(name);
|
|
69
|
+
}
|
|
70
|
+
visit(child.children);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface TemplateNodeLike {
|
|
76
|
+
children?: ReadonlyArray<unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ElementNodeLike {
|
|
80
|
+
type: number;
|
|
81
|
+
tag?: string;
|
|
82
|
+
tagType?: number;
|
|
83
|
+
children?: ReadonlyArray<unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isElementNode(node: unknown): node is ElementNodeLike {
|
|
87
|
+
return (
|
|
88
|
+
typeof node === 'object' &&
|
|
89
|
+
node !== null &&
|
|
90
|
+
typeof (node as { type?: unknown }).type === 'number'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeTag(tag: string): string | null {
|
|
95
|
+
if (tag.length === 0) return null;
|
|
96
|
+
if (!tag.includes('-')) return tag;
|
|
97
|
+
return tag
|
|
98
|
+
.split('-')
|
|
99
|
+
.filter((p) => p.length > 0)
|
|
100
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
101
|
+
.join('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function countLines(content: string): number {
|
|
105
|
+
if (content.length === 0) return 0;
|
|
106
|
+
let n = 1;
|
|
107
|
+
for (let i = 0; i < content.length; i++) if (content[i] === '\n') n++;
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Cycle,
|
|
3
|
+
CycleFeedbackEdge,
|
|
4
|
+
DependencyEdge,
|
|
5
|
+
LayerViolation,
|
|
6
|
+
ModuleId,
|
|
7
|
+
ModuleMetrics,
|
|
8
|
+
ModuleNode,
|
|
9
|
+
Recommendation,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { detectLayer } from './layers';
|
|
12
|
+
import { countBrokenCycles, feedbackArcSet, parseEdgeKey } from './feedbackArcSet';
|
|
13
|
+
import { classifyCyclePattern, type CyclePattern } from './cyclePatterns';
|
|
14
|
+
import type { TypeOnlyCandidate } from './typeOnlyCandidates';
|
|
15
|
+
import type { ContractViolation } from './contracts';
|
|
16
|
+
import type { BundleBloat } from './bundle/types';
|
|
17
|
+
import type { TemporalCoupling } from '../git/types';
|
|
18
|
+
import { displayShortId } from './displayId';
|
|
19
|
+
|
|
20
|
+
// heuristic recommendations. structured output (kind + params); UI does the i18n.
|
|
21
|
+
const MEMBERS_PREVIEW = 8;
|
|
22
|
+
|
|
23
|
+
export function computeRecommendations(inputs: {
|
|
24
|
+
modules: ModuleNode[];
|
|
25
|
+
edges: DependencyEdge[];
|
|
26
|
+
metrics: Record<ModuleId, ModuleMetrics>;
|
|
27
|
+
cycles: Cycle[];
|
|
28
|
+
layerViolations: LayerViolation[];
|
|
29
|
+
hotZones: ModuleId[];
|
|
30
|
+
/** entry-point ids; clusters containing any entry are skipped */
|
|
31
|
+
entries?: ModuleId[];
|
|
32
|
+
/** Optional type-only candidates produced post-FAS by `findTypeOnlyCandidates`. */
|
|
33
|
+
typeOnlyCandidates?: TypeOnlyCandidate[];
|
|
34
|
+
/**
|
|
35
|
+
* Architectural contract violations from the configured `contracts` block.
|
|
36
|
+
* One `'contract-violation'` recommendation is emitted per violation,
|
|
37
|
+
* weighted by severity. Optional - when omitted no contract recs surface.
|
|
38
|
+
*/
|
|
39
|
+
contractViolations?: ContractViolation[];
|
|
40
|
+
/**
|
|
41
|
+
* Bundle bloat issues. One `bundle-bloat` recommendation per
|
|
42
|
+
* issue, weighted by severity.
|
|
43
|
+
*/
|
|
44
|
+
bundleBloat?: BundleBloat[];
|
|
45
|
+
/**
|
|
46
|
+
* Temporal couplings. Only the subset already filtered by
|
|
47
|
+
* the detector lands here — we further narrow to "hidden" couplings
|
|
48
|
+
* (no static edge between the pair), since visible ones duplicate signal
|
|
49
|
+
* the dependency graph already exposes.
|
|
50
|
+
*/
|
|
51
|
+
temporalCoupling?: TemporalCoupling[];
|
|
52
|
+
}): Recommendation[] {
|
|
53
|
+
const { modules, edges, metrics, cycles, layerViolations, hotZones } = inputs;
|
|
54
|
+
const entries = new Set(inputs.entries ?? []);
|
|
55
|
+
const out: Recommendation[] = [];
|
|
56
|
+
const moduleById = new Map(modules.map((m) => [m.id, m]));
|
|
57
|
+
const hot = new Set(hotZones);
|
|
58
|
+
|
|
59
|
+
// 1. split god-modules: top 5% fan-in AND >300 LOC
|
|
60
|
+
const fanInValues = modules.map((m) => metrics[m.id]?.fanIn ?? 0).sort((a, b) => a - b);
|
|
61
|
+
const fanInP95 = fanInValues[Math.floor(fanInValues.length * 0.95)] ?? 0;
|
|
62
|
+
for (const m of modules) {
|
|
63
|
+
if (m.isInfra) continue;
|
|
64
|
+
const mt = metrics[m.id];
|
|
65
|
+
if (!mt) continue;
|
|
66
|
+
if (mt.fanIn >= fanInP95 && mt.fanIn >= 8 && m.loc >= 300) {
|
|
67
|
+
out.push({
|
|
68
|
+
id: `god:${m.id}`,
|
|
69
|
+
kind: 'split-god-module',
|
|
70
|
+
modules: [m.id],
|
|
71
|
+
params: {
|
|
72
|
+
name: shortId(m.id),
|
|
73
|
+
fanIn: mt.fanIn,
|
|
74
|
+
loc: m.loc,
|
|
75
|
+
},
|
|
76
|
+
weight: Math.min(1, mt.fanIn / 30 + m.loc / 1000),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. unused utilities: kind=util with fanIn==0 (skip entries/integration)
|
|
82
|
+
for (const m of modules) {
|
|
83
|
+
if (m.isInfra) continue;
|
|
84
|
+
if (m.kind !== 'util') continue;
|
|
85
|
+
if (entries.has(m.id)) continue;
|
|
86
|
+
const mt = metrics[m.id];
|
|
87
|
+
if (!mt) continue;
|
|
88
|
+
if (mt.fanIn !== 0) continue;
|
|
89
|
+
out.push({
|
|
90
|
+
id: `unused:${m.id}`,
|
|
91
|
+
kind: 'unused-utility',
|
|
92
|
+
modules: [m.id],
|
|
93
|
+
params: { name: shortId(m.id) },
|
|
94
|
+
weight: 0.4,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. cycle-break-cluster: FAS + pattern + counterfactual per SCC.
|
|
99
|
+
// Replaces the old single-edge `cycle-break-candidate` heuristic, which
|
|
100
|
+
// on dense SCCs with a barrel-like hub picks an arbitrary outgoing edge
|
|
101
|
+
// that breaks 1 of N parallel cycles - misleading on real codebases.
|
|
102
|
+
for (const c of cycles) {
|
|
103
|
+
const fas = feedbackArcSet(c.modules, edges);
|
|
104
|
+
if (fas.feedback.size === 0) continue;
|
|
105
|
+
const pattern = classifyCyclePattern({
|
|
106
|
+
scc: c.modules,
|
|
107
|
+
internalEdges: fas.internal,
|
|
108
|
+
feedback: fas.feedback,
|
|
109
|
+
});
|
|
110
|
+
const broken = countBrokenCycles(c.modules, fas.internal, fas.feedback);
|
|
111
|
+
const feedbackEdges: CycleFeedbackEdge[] = [...fas.feedback]
|
|
112
|
+
.map((k) => {
|
|
113
|
+
const { from, to } = parseEdgeKey(k);
|
|
114
|
+
const stats = broken.byEdge.get(k);
|
|
115
|
+
return {
|
|
116
|
+
from,
|
|
117
|
+
to,
|
|
118
|
+
broken: stats?.broken ?? 0,
|
|
119
|
+
partial: stats?.partial ?? broken.totalPartial,
|
|
120
|
+
};
|
|
121
|
+
})
|
|
122
|
+
.sort((a, b) => b.broken - a.broken || a.from.localeCompare(b.from));
|
|
123
|
+
|
|
124
|
+
// cycle.id already has the `cycle:` prefix; do not double it here.
|
|
125
|
+
out.push({
|
|
126
|
+
id: c.id.startsWith('cycle:') ? c.id : `cycle:${c.id}`,
|
|
127
|
+
kind: 'cycle-break-cluster',
|
|
128
|
+
modules: [...c.modules],
|
|
129
|
+
params: cyclePatternParams(pattern, c, feedbackEdges, broken.totalCycles),
|
|
130
|
+
weight: cycleWeight(pattern, c, feedbackEdges.length),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3b. type-only candidates surface separately so they can outrank the
|
|
135
|
+
// architectural advice (cheapest possible fix - a single import edit).
|
|
136
|
+
for (const tc of inputs.typeOnlyCandidates ?? []) {
|
|
137
|
+
out.push({
|
|
138
|
+
id: `typeonly:${tc.from}\u0001${tc.to}`,
|
|
139
|
+
kind: 'type-only-candidate',
|
|
140
|
+
modules: [tc.from, tc.to],
|
|
141
|
+
params: {
|
|
142
|
+
from: shortId(tc.from),
|
|
143
|
+
to: shortId(tc.to),
|
|
144
|
+
specifier: tc.specifier,
|
|
145
|
+
bindings: tc.bindings.join(', '),
|
|
146
|
+
},
|
|
147
|
+
weight: 0.7,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 4. misplaced-by-layer: 70%+ of dependents live in a different layer
|
|
152
|
+
const dependents = new Map<ModuleId, ModuleId[]>();
|
|
153
|
+
for (const e of edges) {
|
|
154
|
+
if (e.kind === 'type-only') continue;
|
|
155
|
+
if (!dependents.has(e.to)) dependents.set(e.to, []);
|
|
156
|
+
dependents.get(e.to)!.push(e.from);
|
|
157
|
+
}
|
|
158
|
+
for (const m of modules) {
|
|
159
|
+
if (m.isInfra) continue;
|
|
160
|
+
const layer = detectLayer(m.id);
|
|
161
|
+
if (layer === 'unknown') continue;
|
|
162
|
+
const incoming = dependents.get(m.id) ?? [];
|
|
163
|
+
if (incoming.length < 5) continue;
|
|
164
|
+
const layerCounts = new Map<string, number>();
|
|
165
|
+
for (const id of incoming) {
|
|
166
|
+
const l = detectLayer(id);
|
|
167
|
+
layerCounts.set(l, (layerCounts.get(l) ?? 0) + 1);
|
|
168
|
+
}
|
|
169
|
+
let majorityLayer: string | null = null;
|
|
170
|
+
let majorityCount = 0;
|
|
171
|
+
for (const [l, n] of layerCounts) {
|
|
172
|
+
if (l === 'unknown') continue;
|
|
173
|
+
if (n > majorityCount) {
|
|
174
|
+
majorityCount = n;
|
|
175
|
+
majorityLayer = l;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (majorityLayer && majorityLayer !== layer && majorityCount >= incoming.length * 0.7) {
|
|
179
|
+
out.push({
|
|
180
|
+
id: `layer:${m.id}`,
|
|
181
|
+
kind: 'misplaced-by-layer',
|
|
182
|
+
modules: [m.id],
|
|
183
|
+
params: {
|
|
184
|
+
name: shortId(m.id),
|
|
185
|
+
currentLayer: layer,
|
|
186
|
+
targetLayer: majorityLayer,
|
|
187
|
+
majority: majorityCount,
|
|
188
|
+
total: incoming.length,
|
|
189
|
+
},
|
|
190
|
+
weight: 0.5 + 0.3 * (majorityCount / incoming.length),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. isolated clusters: 5+ modules disconnected from rest (skip if has entry)
|
|
196
|
+
const clusters = findIsolatedClusters(modules, edges);
|
|
197
|
+
for (const cluster of clusters) {
|
|
198
|
+
if (cluster.length < 5) continue;
|
|
199
|
+
if (cluster.some((id) => moduleById.get(id)?.isInfra)) continue;
|
|
200
|
+
if (cluster.some((id) => entries.has(id))) continue;
|
|
201
|
+
if (cluster.length > modules.length * 0.5) continue;
|
|
202
|
+
const sample = cluster.slice(0, 3).map(shortId).join(', ');
|
|
203
|
+
out.push({
|
|
204
|
+
id: `isolated:${cluster[0]!}`,
|
|
205
|
+
kind: 'isolated-cluster',
|
|
206
|
+
modules: cluster,
|
|
207
|
+
params: { count: cluster.length, sample },
|
|
208
|
+
weight: 0.45,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// top 1-3 modules causing the most layer violations
|
|
213
|
+
const violationByModule = new Map<ModuleId, number>();
|
|
214
|
+
for (const v of layerViolations) {
|
|
215
|
+
violationByModule.set(
|
|
216
|
+
v.from,
|
|
217
|
+
(violationByModule.get(v.from) ?? 0) + (v.severity === 'error' ? 2 : 1),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const topViolators = [...violationByModule.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
221
|
+
for (const [id, count] of topViolators) {
|
|
222
|
+
if (count < 2) continue;
|
|
223
|
+
out.push({
|
|
224
|
+
id: `vhot:${id}`,
|
|
225
|
+
kind: 'misplaced-by-layer',
|
|
226
|
+
modules: [id],
|
|
227
|
+
params: { name: shortId(id), count, asViolator: 1 },
|
|
228
|
+
weight: 0.6 + Math.min(0.3, count / 20),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 6. contract violations: one rec per violation, weighted by severity. We
|
|
233
|
+
// don't aggregate per-rule on purpose - each violation points at a concrete
|
|
234
|
+
// edge or module that the user can act on; merging would lose the
|
|
235
|
+
// drill-down value. Cap per-rule emission at 20 to keep huge bulk-imports
|
|
236
|
+
// from drowning the recommendations list.
|
|
237
|
+
if (inputs.contractViolations && inputs.contractViolations.length > 0) {
|
|
238
|
+
const perRuleCount = new Map<string, number>();
|
|
239
|
+
for (const v of inputs.contractViolations) {
|
|
240
|
+
const seen = perRuleCount.get(v.ruleName) ?? 0;
|
|
241
|
+
if (seen >= 20) continue;
|
|
242
|
+
perRuleCount.set(v.ruleName, seen + 1);
|
|
243
|
+
out.push({
|
|
244
|
+
id: `contract:${v.id}`,
|
|
245
|
+
kind: 'contract-violation',
|
|
246
|
+
modules: v.modules,
|
|
247
|
+
params: contractParams(v),
|
|
248
|
+
weight: v.severity === 'error' ? 0.9 : 0.5,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 8. temporal coupling: only the *hidden* couplings — pairs
|
|
254
|
+
// that change together a lot AND have no static edge. The dependency
|
|
255
|
+
// graph already shows the rest. Weight scales with score: a 0.9 score
|
|
256
|
+
// pair ranks alongside a god-module rec, a 0.5 pair sits below cycle
|
|
257
|
+
// breaks. Cap at 8 to keep the panel readable on big histories.
|
|
258
|
+
if (inputs.temporalCoupling && inputs.temporalCoupling.length > 0) {
|
|
259
|
+
const hidden = inputs.temporalCoupling.filter((c) => c.hidden).slice(0, 8);
|
|
260
|
+
for (const c of hidden) {
|
|
261
|
+
out.push({
|
|
262
|
+
id: `temporal:${c.a}\x00${c.b}`,
|
|
263
|
+
kind: 'temporal-coupling',
|
|
264
|
+
modules: [c.a, c.b],
|
|
265
|
+
params: {
|
|
266
|
+
a: c.a,
|
|
267
|
+
b: c.b,
|
|
268
|
+
aShort: displayShortId(c.a),
|
|
269
|
+
bShort: displayShortId(c.b),
|
|
270
|
+
coOccurrences: c.coOccurrences,
|
|
271
|
+
// Round to 2 decimals so i18n templates render `0.83` not `0.8333…`.
|
|
272
|
+
score: Math.round(c.score * 100) / 100,
|
|
273
|
+
},
|
|
274
|
+
// 0.5 .. 0.85 — same band as misplaced-by-layer / cycle-break candidates.
|
|
275
|
+
weight: 0.5 + Math.min(0.35, c.score * 0.4),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 7. bundle bloat: one rec per issue. Weighted so high-sev
|
|
281
|
+
// duplicates / heavy chunks outrank most heuristic recs.
|
|
282
|
+
if (inputs.bundleBloat && inputs.bundleBloat.length > 0) {
|
|
283
|
+
for (const b of inputs.bundleBloat) {
|
|
284
|
+
out.push({
|
|
285
|
+
id: `bundle:${b.id}`,
|
|
286
|
+
kind: 'bundle-bloat',
|
|
287
|
+
modules: b.modules,
|
|
288
|
+
params: bundleParams(b),
|
|
289
|
+
weight: bundleWeight(b),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const byId = new Map<string, Recommendation>();
|
|
295
|
+
for (const r of out) byId.set(r.id, r);
|
|
296
|
+
const final = [...byId.values()].sort((a, b) => b.weight - a.weight);
|
|
297
|
+
|
|
298
|
+
// hot-zone amplifier
|
|
299
|
+
for (const r of final) {
|
|
300
|
+
if (r.modules.some((id) => hot.has(id))) r.weight = Math.min(1, r.weight + 0.1);
|
|
301
|
+
}
|
|
302
|
+
final.sort((a, b) => b.weight - a.weight);
|
|
303
|
+
|
|
304
|
+
return final.slice(0, 20);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function contractParams(v: ContractViolation): Recommendation['params'] {
|
|
308
|
+
const p: Record<string, string | number> = {
|
|
309
|
+
kind: v.kind,
|
|
310
|
+
rule: v.ruleName,
|
|
311
|
+
severity: v.severity,
|
|
312
|
+
message: v.message,
|
|
313
|
+
};
|
|
314
|
+
if (v.description) p['description'] = v.description;
|
|
315
|
+
if (v.detail) {
|
|
316
|
+
p['metric'] = v.detail.metric;
|
|
317
|
+
p['value'] = v.detail.value;
|
|
318
|
+
p['limit'] = v.detail.limit;
|
|
319
|
+
}
|
|
320
|
+
if (v.edge) {
|
|
321
|
+
p['from'] = v.edge.from;
|
|
322
|
+
p['to'] = v.edge.to;
|
|
323
|
+
p['specifier'] = v.edge.specifier;
|
|
324
|
+
}
|
|
325
|
+
return p;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function cyclePatternParams(
|
|
329
|
+
pattern: CyclePattern,
|
|
330
|
+
cycle: Cycle,
|
|
331
|
+
feedbackEdges: readonly CycleFeedbackEdge[],
|
|
332
|
+
totalCycles: number,
|
|
333
|
+
): Recommendation['params'] {
|
|
334
|
+
const base: Recommendation['params'] = {
|
|
335
|
+
pattern: pattern.kind,
|
|
336
|
+
severity: cycle.severity,
|
|
337
|
+
sccLength: cycle.length,
|
|
338
|
+
feedbackCount: feedbackEdges.length,
|
|
339
|
+
totalCycles, // -1 = partial
|
|
340
|
+
feedbackEdges,
|
|
341
|
+
// preview for the title — full SCC is in `modules[]` on the recommendation
|
|
342
|
+
members: cycle.modules.slice(0, MEMBERS_PREVIEW).map(shortId).join(', '),
|
|
343
|
+
};
|
|
344
|
+
switch (pattern.kind) {
|
|
345
|
+
case 'mutual-pair':
|
|
346
|
+
return { ...base, a: shortId(pattern.a), b: shortId(pattern.b) };
|
|
347
|
+
case 'barrel-cycle':
|
|
348
|
+
return {
|
|
349
|
+
...base,
|
|
350
|
+
barrel: shortId(pattern.barrel),
|
|
351
|
+
sibling: shortId(pattern.sibling),
|
|
352
|
+
};
|
|
353
|
+
case 'hub-feedback':
|
|
354
|
+
return {
|
|
355
|
+
...base,
|
|
356
|
+
hub: shortId(pattern.hub),
|
|
357
|
+
incomingCount: pattern.incomingCount,
|
|
358
|
+
valueImports: pattern.valueImports,
|
|
359
|
+
};
|
|
360
|
+
case 'long-chain': {
|
|
361
|
+
const { from, to } = parseEdgeKey(pattern.bridge);
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
364
|
+
chainLength: pattern.length,
|
|
365
|
+
bridgeFrom: shortId(from),
|
|
366
|
+
bridgeTo: shortId(to),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
case 'mixed':
|
|
370
|
+
return base;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cycleWeight(pattern: CyclePattern, cycle: Cycle, feedbackCount: number): number {
|
|
375
|
+
// direct cycles (2-cycle) are easier to fix and more obvious; weight them
|
|
376
|
+
// higher. hub-feedback over a sizable scc indicates a real architectural
|
|
377
|
+
// smell, also worth surfacing.
|
|
378
|
+
const base = cycle.severity === 'direct' ? 0.85 : 0.6;
|
|
379
|
+
if (pattern.kind === 'hub-feedback' && feedbackCount >= 3) return Math.min(0.95, base + 0.1);
|
|
380
|
+
if (pattern.kind === 'barrel-cycle') return Math.min(0.9, base + 0.05);
|
|
381
|
+
return base;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const shortId = displayShortId;
|
|
385
|
+
|
|
386
|
+
function bundleParams(b: BundleBloat): Recommendation['params'] {
|
|
387
|
+
const p: Record<string, string | number> = {
|
|
388
|
+
subkind: b.kind,
|
|
389
|
+
severity: b.severity,
|
|
390
|
+
message: b.message,
|
|
391
|
+
chunks: b.chunks.join(', '),
|
|
392
|
+
};
|
|
393
|
+
if (b.detail?.sizeBytes !== undefined) p['sizeBytes'] = b.detail.sizeBytes;
|
|
394
|
+
if (b.detail?.chunkCount !== undefined) p['chunkCount'] = b.detail.chunkCount;
|
|
395
|
+
if (b.detail?.sharePercent !== undefined) p['sharePercent'] = b.detail.sharePercent;
|
|
396
|
+
return p;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function bundleWeight(b: BundleBloat): number {
|
|
400
|
+
// duplicates feel more actionable than just-large chunks; weight them up.
|
|
401
|
+
const base = b.kind === 'duplicate' ? 0.85 : b.kind === 'heavy-chunk' ? 0.7 : 0.6;
|
|
402
|
+
const bump = b.severity === 'high' ? 0.1 : b.severity === 'medium' ? 0.05 : 0;
|
|
403
|
+
return Math.min(0.95, base + bump);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function findIsolatedClusters(modules: ModuleNode[], edges: DependencyEdge[]): ModuleId[][] {
|
|
407
|
+
const adj = new Map<ModuleId, Set<ModuleId>>();
|
|
408
|
+
for (const m of modules) adj.set(m.id, new Set());
|
|
409
|
+
for (const e of edges) {
|
|
410
|
+
if (e.kind === 'type-only') continue;
|
|
411
|
+
adj.get(e.from)?.add(e.to);
|
|
412
|
+
adj.get(e.to)?.add(e.from);
|
|
413
|
+
}
|
|
414
|
+
const visited = new Set<ModuleId>();
|
|
415
|
+
const clusters: ModuleId[][] = [];
|
|
416
|
+
for (const m of modules) {
|
|
417
|
+
if (visited.has(m.id)) continue;
|
|
418
|
+
const cluster: ModuleId[] = [];
|
|
419
|
+
const stack = [m.id];
|
|
420
|
+
while (stack.length) {
|
|
421
|
+
const id = stack.pop()!;
|
|
422
|
+
if (visited.has(id)) continue;
|
|
423
|
+
visited.add(id);
|
|
424
|
+
cluster.push(id);
|
|
425
|
+
const neighbors = adj.get(id);
|
|
426
|
+
if (!neighbors) continue;
|
|
427
|
+
for (const n of neighbors) if (!visited.has(n)) stack.push(n);
|
|
428
|
+
}
|
|
429
|
+
clusters.push(cluster);
|
|
430
|
+
}
|
|
431
|
+
return clusters.sort((a, b) => a.length - b.length);
|
|
432
|
+
}
|