@eduardbar/drift 1.2.0 → 1.4.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/.gga +50 -0
- package/.github/actions/drift-review/README.md +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/git.js +12 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +13 -0
- package/dist/output-metadata.js +17 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +4 -3
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -82
- package/dist/saas.js +7 -320
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +7 -0
- package/dist/trust-kpi.js +185 -0
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +37 -0
- package/dist/trust.js +168 -0
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +30 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -433
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +210 -0
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +260 -0
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +78 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +358 -1
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -0
package/src/map.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { Project } from 'ts-morph'
|
|
|
4
4
|
import type { DriftConfig } from './types.js'
|
|
5
5
|
import { detectLayerViolations } from './rules/phase3-arch.js'
|
|
6
6
|
import { RULE_WEIGHTS } from './analyzer.js'
|
|
7
|
+
import { detectCycleEdges } from './map-cycles.js'
|
|
8
|
+
import { renderArchitectureSvg } from './map-svg.js'
|
|
7
9
|
|
|
8
10
|
interface LayerNode {
|
|
9
11
|
name: string
|
|
@@ -24,11 +26,68 @@ function detectLayer(relPath: string): string {
|
|
|
24
26
|
return first
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
+
function appendFileLayerContext(
|
|
30
|
+
filePath: string,
|
|
31
|
+
targetPath: string,
|
|
32
|
+
layers: Map<string, LayerNode>,
|
|
33
|
+
fileImportGraph: Map<string, Set<string>>,
|
|
34
|
+
): { rel: string; layerName: string } {
|
|
35
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/')
|
|
36
|
+
const layerName = detectLayer(rel)
|
|
37
|
+
|
|
38
|
+
if (!layers.has(layerName)) {
|
|
39
|
+
layers.set(layerName, { name: layerName, files: new Set() })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
layers.get(layerName)!.files.add(rel)
|
|
43
|
+
|
|
44
|
+
if (!fileImportGraph.has(filePath)) {
|
|
45
|
+
fileImportGraph.set(filePath, new Set())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { rel, layerName }
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
function registerImportEdge(
|
|
52
|
+
layerName: string,
|
|
53
|
+
importedLayer: string,
|
|
54
|
+
edges: Map<string, number>,
|
|
55
|
+
layerAdjacency: Map<string, Set<string>>,
|
|
56
|
+
): void {
|
|
57
|
+
if (importedLayer === layerName) return
|
|
58
|
+
|
|
59
|
+
const key = `${layerName}->${importedLayer}`
|
|
60
|
+
edges.set(key, (edges.get(key) ?? 0) + 1)
|
|
61
|
+
|
|
62
|
+
if (!layerAdjacency.has(layerName)) {
|
|
63
|
+
layerAdjacency.set(layerName, new Set())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
layerAdjacency.get(layerName)!.add(importedLayer)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildEdgeList(edges: Map<string, number>, cycleEdges: Set<string>, violationEdges: Set<string>): MapEdge[] {
|
|
70
|
+
const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
|
|
71
|
+
const [from, to] = key.split('->')
|
|
72
|
+
const kind = violationEdges.has(key)
|
|
73
|
+
? 'violation'
|
|
74
|
+
: cycleEdges.has(key)
|
|
75
|
+
? 'cycle'
|
|
76
|
+
: 'normal'
|
|
77
|
+
return { key, from, to, count, kind }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
for (const key of violationEdges) {
|
|
81
|
+
if (edges.has(key)) continue
|
|
82
|
+
const [from, to] = key.split('->')
|
|
83
|
+
edgeList.push({ key, from, to, count: 1, kind: 'violation' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return edgeList
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
function createArchitectureProject(targetPath: string): Project {
|
|
32
91
|
const project = new Project({
|
|
33
92
|
skipAddingFilesFromTsConfig: true,
|
|
34
93
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -45,6 +104,18 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
|
|
|
45
104
|
`!${targetPath}/**/*.d.ts`,
|
|
46
105
|
])
|
|
47
106
|
|
|
107
|
+
return project
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectArchitectureGraph(
|
|
111
|
+
project: Project,
|
|
112
|
+
targetPath: string,
|
|
113
|
+
): {
|
|
114
|
+
layers: Map<string, LayerNode>
|
|
115
|
+
edges: Map<string, number>
|
|
116
|
+
layerAdjacency: Map<string, Set<string>>
|
|
117
|
+
fileImportGraph: Map<string, Set<string>>
|
|
118
|
+
} {
|
|
48
119
|
const layers = new Map<string, LayerNode>()
|
|
49
120
|
const edges = new Map<string, number>()
|
|
50
121
|
const layerAdjacency = new Map<string, Set<string>>()
|
|
@@ -52,11 +123,7 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
|
|
|
52
123
|
|
|
53
124
|
for (const file of project.getSourceFiles()) {
|
|
54
125
|
const filePath = file.getFilePath()
|
|
55
|
-
const
|
|
56
|
-
const layerName = detectLayer(rel)
|
|
57
|
-
if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
|
|
58
|
-
layers.get(layerName)!.files.add(rel)
|
|
59
|
-
if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
|
|
126
|
+
const { layerName } = appendFileLayerContext(filePath, targetPath, layers, fileImportGraph)
|
|
60
127
|
|
|
61
128
|
for (const decl of file.getImportDeclarations()) {
|
|
62
129
|
const imported = decl.getModuleSpecifierSourceFile()
|
|
@@ -64,150 +131,52 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
|
|
|
64
131
|
fileImportGraph.get(filePath)!.add(imported.getFilePath())
|
|
65
132
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
|
|
66
133
|
const importedLayer = detectLayer(importedRel)
|
|
67
|
-
|
|
68
|
-
const key = `${layerName}->${importedLayer}`
|
|
69
|
-
edges.set(key, (edges.get(key) ?? 0) + 1)
|
|
70
|
-
if (!layerAdjacency.has(layerName)) layerAdjacency.set(layerName, new Set())
|
|
71
|
-
layerAdjacency.get(layerName)!.add(importedLayer)
|
|
134
|
+
registerImportEdge(layerName, importedLayer, edges, layerAdjacency)
|
|
72
135
|
}
|
|
73
136
|
}
|
|
74
137
|
|
|
75
|
-
|
|
76
|
-
|
|
138
|
+
return { layers, edges, layerAdjacency, fileImportGraph }
|
|
139
|
+
}
|
|
77
140
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
function collectViolationEdges(
|
|
142
|
+
config: DriftConfig | undefined,
|
|
143
|
+
fileImportGraph: Map<string, Set<string>>,
|
|
144
|
+
targetPath: string,
|
|
145
|
+
layers: Map<string, LayerNode>,
|
|
146
|
+
): Set<string> {
|
|
147
|
+
const violationEdges = new Set<string>()
|
|
148
|
+
if (!config?.layers?.length) return violationEdges
|
|
149
|
+
|
|
150
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
|
|
151
|
+
for (const issues of violations.values()) {
|
|
152
|
+
for (const issue of issues) {
|
|
153
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
|
|
154
|
+
if (!match) continue
|
|
155
|
+
const from = match[1]
|
|
156
|
+
const to = match[2]
|
|
157
|
+
violationEdges.add(`${from}->${to}`)
|
|
158
|
+
if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
|
|
159
|
+
if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
|
|
90
160
|
}
|
|
91
161
|
}
|
|
92
162
|
|
|
93
|
-
|
|
94
|
-
const [from, to] = key.split('->')
|
|
95
|
-
const kind = violationEdges.has(key)
|
|
96
|
-
? 'violation'
|
|
97
|
-
: cycleEdges.has(key)
|
|
98
|
-
? 'cycle'
|
|
99
|
-
: 'normal'
|
|
100
|
-
return { key, from, to, count, kind }
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
for (const key of violationEdges) {
|
|
104
|
-
if (edges.has(key)) continue
|
|
105
|
-
const [from, to] = key.split('->')
|
|
106
|
-
edgeList.push({ key, from, to, count: 1, kind: 'violation' })
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
110
|
-
const width = 960
|
|
111
|
-
const rowHeight = 90
|
|
112
|
-
const height = Math.max(180, layerList.length * rowHeight + 120)
|
|
113
|
-
const boxWidth = 240
|
|
114
|
-
const boxHeight = 50
|
|
115
|
-
const left = 100
|
|
116
|
-
|
|
117
|
-
const boxes = layerList.map((layer, index) => {
|
|
118
|
-
const y = 60 + index * rowHeight
|
|
119
|
-
return {
|
|
120
|
-
...layer,
|
|
121
|
-
x: left,
|
|
122
|
-
y,
|
|
123
|
-
}
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
const boxByName = new Map(boxes.map((box) => [box.name, box]))
|
|
127
|
-
|
|
128
|
-
const lines = edgeList.map((edge) => {
|
|
129
|
-
const a = boxByName.get(edge.from)
|
|
130
|
-
const b = boxByName.get(edge.to)
|
|
131
|
-
if (!a || !b) return ''
|
|
132
|
-
const startX = a.x + boxWidth
|
|
133
|
-
const startY = a.y + boxHeight / 2
|
|
134
|
-
const endX = b.x
|
|
135
|
-
const endY = b.y + boxHeight / 2
|
|
136
|
-
const stroke = edge.kind === 'violation'
|
|
137
|
-
? '#ef4444'
|
|
138
|
-
: edge.kind === 'cycle'
|
|
139
|
-
? '#f59e0b'
|
|
140
|
-
: '#64748b'
|
|
141
|
-
const widthPx = edge.kind === 'normal' ? 2 : 3
|
|
142
|
-
return `
|
|
143
|
-
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
|
|
144
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
|
|
145
|
-
}).join('')
|
|
146
|
-
|
|
147
|
-
const nodes = boxes.map((box) => `
|
|
148
|
-
<g>
|
|
149
|
-
<rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
150
|
-
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
151
|
-
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
152
|
-
</g>`).join('')
|
|
153
|
-
|
|
154
|
-
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
|
|
155
|
-
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
|
|
156
|
-
|
|
157
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
158
|
-
<defs>
|
|
159
|
-
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
160
|
-
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
161
|
-
</marker>
|
|
162
|
-
</defs>
|
|
163
|
-
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
164
|
-
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
165
|
-
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
166
|
-
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
|
|
167
|
-
<line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
|
|
168
|
-
<line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
|
|
169
|
-
${lines}
|
|
170
|
-
${nodes}
|
|
171
|
-
</svg>`
|
|
163
|
+
return violationEdges
|
|
172
164
|
}
|
|
173
165
|
|
|
174
|
-
function
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const stack: string[] = []
|
|
178
|
-
const cycleEdges = new Set<string>()
|
|
179
|
-
|
|
180
|
-
function dfs(node: string): void {
|
|
181
|
-
visited.add(node)
|
|
182
|
-
inStack.add(node)
|
|
183
|
-
stack.push(node)
|
|
184
|
-
|
|
185
|
-
for (const neighbor of adjacency.get(node) ?? []) {
|
|
186
|
-
if (!visited.has(neighbor)) {
|
|
187
|
-
dfs(neighbor)
|
|
188
|
-
continue
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (!inStack.has(neighbor)) continue
|
|
192
|
-
|
|
193
|
-
const startIndex = stack.indexOf(neighbor)
|
|
194
|
-
if (startIndex >= 0) {
|
|
195
|
-
for (let i = startIndex; i < stack.length - 1; i++) {
|
|
196
|
-
cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
cycleEdges.add(`${node}->${neighbor}`)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
stack.pop()
|
|
203
|
-
inStack.delete(node)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
for (const node of adjacency.keys()) {
|
|
207
|
-
if (!visited.has(node)) dfs(node)
|
|
208
|
-
}
|
|
166
|
+
export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
|
|
167
|
+
const project = createArchitectureProject(targetPath)
|
|
168
|
+
const { layers, edges, layerAdjacency, fileImportGraph } = collectArchitectureGraph(project, targetPath)
|
|
209
169
|
|
|
210
|
-
|
|
170
|
+
const cycleEdges = detectCycleEdges(layerAdjacency)
|
|
171
|
+
const violationEdges = collectViolationEdges(config, fileImportGraph, targetPath, layers)
|
|
172
|
+
|
|
173
|
+
const edgeList = buildEdgeList(edges, cycleEdges, violationEdges)
|
|
174
|
+
return renderArchitectureSvg({
|
|
175
|
+
layers,
|
|
176
|
+
edgeList,
|
|
177
|
+
cycleCount: edgeList.filter((edge) => edge.kind === 'cycle').length,
|
|
178
|
+
violationCount: edgeList.filter((edge) => edge.kind === 'violation').length,
|
|
179
|
+
})
|
|
211
180
|
}
|
|
212
181
|
|
|
213
182
|
export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
|
package/src/metrics.ts
CHANGED
|
@@ -30,6 +30,20 @@ const AI_RULES = new Set([
|
|
|
30
30
|
'ai-code-smell',
|
|
31
31
|
])
|
|
32
32
|
|
|
33
|
+
const ISSUE_WEIGHT_PER_FILE = 20
|
|
34
|
+
const DIMENSION_COUNT = 4
|
|
35
|
+
const MAX_COMPLEXITY_RISK = 40
|
|
36
|
+
const COMPLEXITY_RISK_PER_ISSUE = 10
|
|
37
|
+
const MISSING_TESTS_RISK = 25
|
|
38
|
+
const FREQUENT_CHANGE_THRESHOLD = 8
|
|
39
|
+
const FREQUENT_CHANGE_RISK = 20
|
|
40
|
+
const HIGH_DRIFT_THRESHOLD = 50
|
|
41
|
+
const HIGH_DRIFT_RISK = 15
|
|
42
|
+
const HOTSPOT_LIMIT = 10
|
|
43
|
+
const LEVEL_CRITICAL_THRESHOLD = 75
|
|
44
|
+
const LEVEL_HIGH_THRESHOLD = 55
|
|
45
|
+
const LEVEL_MEDIUM_THRESHOLD = 30
|
|
46
|
+
|
|
33
47
|
function clamp(value: number, min: number, max: number): number {
|
|
34
48
|
return Math.max(min, Math.min(max, value))
|
|
35
49
|
}
|
|
@@ -38,20 +52,23 @@ function listFilesRecursively(root: string): string[] {
|
|
|
38
52
|
if (!existsSync(root)) return []
|
|
39
53
|
const out: string[] = []
|
|
40
54
|
const stack = [root]
|
|
55
|
+
|
|
56
|
+
const shouldSkipDirectory = (name: string): boolean =>
|
|
57
|
+
name === 'node_modules' || name === 'dist' || name === '.git' || name === '.next' || name === 'build'
|
|
58
|
+
|
|
41
59
|
while (stack.length > 0) {
|
|
42
60
|
const current = stack.pop()!
|
|
43
61
|
const entries = readdirSync(current)
|
|
44
62
|
for (const entry of entries) {
|
|
45
63
|
const full = join(current, entry)
|
|
46
64
|
const stat = statSync(full)
|
|
47
|
-
if (stat.isDirectory()) {
|
|
48
|
-
if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
|
|
49
|
-
continue
|
|
50
|
-
}
|
|
51
|
-
stack.push(full)
|
|
52
|
-
} else {
|
|
65
|
+
if (!stat.isDirectory()) {
|
|
53
66
|
out.push(full)
|
|
67
|
+
continue
|
|
54
68
|
}
|
|
69
|
+
|
|
70
|
+
if (shouldSkipDirectory(entry)) continue
|
|
71
|
+
stack.push(full)
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
return out
|
|
@@ -88,7 +105,55 @@ function getCommitTouchCount(targetPath: string, filePath: string): number {
|
|
|
88
105
|
function qualityFromIssues(totalFiles: number, issues: DriftIssue[], rules: Set<string>): number {
|
|
89
106
|
const count = issues.filter((issue) => rules.has(issue.rule)).length
|
|
90
107
|
if (totalFiles === 0) return 100
|
|
91
|
-
return clamp(100 - Math.round((count / totalFiles) *
|
|
108
|
+
return clamp(100 - Math.round((count / totalFiles) * ISSUE_WEIGHT_PER_FILE), 0, 100)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function riskLevelFromScore(score: number): MaintenanceRiskMetrics['level'] {
|
|
112
|
+
if (score >= LEVEL_CRITICAL_THRESHOLD) return 'critical'
|
|
113
|
+
if (score >= LEVEL_HIGH_THRESHOLD) return 'high'
|
|
114
|
+
if (score >= LEVEL_MEDIUM_THRESHOLD) return 'medium'
|
|
115
|
+
return 'low'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function evaluateHotspot(targetPath: string, file: FileReport): RiskHotspot {
|
|
119
|
+
const complexityIssues = file.issues.filter((issue) =>
|
|
120
|
+
issue.rule === 'high-complexity' ||
|
|
121
|
+
issue.rule === 'deep-nesting' ||
|
|
122
|
+
issue.rule === 'large-function' ||
|
|
123
|
+
issue.rule === 'max-function-lines'
|
|
124
|
+
).length
|
|
125
|
+
|
|
126
|
+
const changeFrequency = getCommitTouchCount(targetPath, file.path)
|
|
127
|
+
const hasTests = hasNearbyTest(targetPath, file.path)
|
|
128
|
+
const reasons: string[] = []
|
|
129
|
+
let risk = 0
|
|
130
|
+
|
|
131
|
+
if (complexityIssues > 0) {
|
|
132
|
+
risk += Math.min(MAX_COMPLEXITY_RISK, complexityIssues * COMPLEXITY_RISK_PER_ISSUE)
|
|
133
|
+
reasons.push('high complexity signals')
|
|
134
|
+
}
|
|
135
|
+
if (!hasTests) {
|
|
136
|
+
risk += MISSING_TESTS_RISK
|
|
137
|
+
reasons.push('no nearby tests')
|
|
138
|
+
}
|
|
139
|
+
if (changeFrequency >= FREQUENT_CHANGE_THRESHOLD) {
|
|
140
|
+
risk += FREQUENT_CHANGE_RISK
|
|
141
|
+
reasons.push('frequently changed file')
|
|
142
|
+
}
|
|
143
|
+
if (file.score >= HIGH_DRIFT_THRESHOLD) {
|
|
144
|
+
risk += HIGH_DRIFT_RISK
|
|
145
|
+
reasons.push('high drift score')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
file: file.path,
|
|
150
|
+
driftScore: file.score,
|
|
151
|
+
complexityIssues,
|
|
152
|
+
hasNearbyTests: hasTests,
|
|
153
|
+
changeFrequency,
|
|
154
|
+
risk: clamp(risk, 0, 100),
|
|
155
|
+
reasons,
|
|
156
|
+
}
|
|
92
157
|
}
|
|
93
158
|
|
|
94
159
|
export function computeRepoQuality(targetPath: string, files: FileReport[]): RepoQualityScore {
|
|
@@ -119,73 +184,27 @@ export function computeRepoQuality(targetPath: string, files: FileReport[]): Rep
|
|
|
119
184
|
dimensions.complexity +
|
|
120
185
|
dimensions['ai-patterns'] +
|
|
121
186
|
dimensions.testing
|
|
122
|
-
) /
|
|
187
|
+
) / DIMENSION_COUNT)
|
|
123
188
|
|
|
124
189
|
return { overall, dimensions }
|
|
125
190
|
}
|
|
126
191
|
|
|
127
192
|
export function computeMaintenanceRisk(report: DriftReport): MaintenanceRiskMetrics {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
.map((file) => {
|
|
131
|
-
const complexityIssues = file.issues.filter((issue) =>
|
|
132
|
-
issue.rule === 'high-complexity' ||
|
|
133
|
-
issue.rule === 'deep-nesting' ||
|
|
134
|
-
issue.rule === 'large-function' ||
|
|
135
|
-
issue.rule === 'max-function-lines'
|
|
136
|
-
).length
|
|
137
|
-
|
|
138
|
-
const changeFrequency = getCommitTouchCount(report.targetPath, file.path)
|
|
139
|
-
const hasTests = hasNearbyTest(report.targetPath, file.path)
|
|
140
|
-
const reasons: string[] = []
|
|
141
|
-
let risk = 0
|
|
142
|
-
|
|
143
|
-
if (complexityIssues > 0) {
|
|
144
|
-
risk += Math.min(40, complexityIssues * 10)
|
|
145
|
-
reasons.push('high complexity signals')
|
|
146
|
-
}
|
|
147
|
-
if (!hasTests) {
|
|
148
|
-
risk += 25
|
|
149
|
-
reasons.push('no nearby tests')
|
|
150
|
-
}
|
|
151
|
-
if (changeFrequency >= 8) {
|
|
152
|
-
risk += 20
|
|
153
|
-
reasons.push('frequently changed file')
|
|
154
|
-
}
|
|
155
|
-
if (file.score >= 50) {
|
|
156
|
-
risk += 15
|
|
157
|
-
reasons.push('high drift score')
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
file: file.path,
|
|
162
|
-
driftScore: file.score,
|
|
163
|
-
complexityIssues,
|
|
164
|
-
hasNearbyTests: hasTests,
|
|
165
|
-
changeFrequency,
|
|
166
|
-
risk: clamp(risk, 0, 100),
|
|
167
|
-
reasons,
|
|
168
|
-
}
|
|
169
|
-
})
|
|
193
|
+
const hotspots: RiskHotspot[] = report.files
|
|
194
|
+
.map((file) => evaluateHotspot(report.targetPath, file))
|
|
170
195
|
.filter((hotspot) => hotspot.risk > 0)
|
|
171
196
|
.sort((a, b) => b.risk - a.risk)
|
|
172
|
-
.slice(0,
|
|
197
|
+
.slice(0, HOTSPOT_LIMIT)
|
|
173
198
|
|
|
174
199
|
const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length
|
|
175
200
|
const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length
|
|
176
|
-
const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >=
|
|
201
|
+
const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= FREQUENT_CHANGE_THRESHOLD).length
|
|
177
202
|
|
|
178
203
|
const score = hotspots.length === 0
|
|
179
204
|
? 0
|
|
180
205
|
: Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length)
|
|
181
206
|
|
|
182
|
-
const level = score
|
|
183
|
-
? 'critical'
|
|
184
|
-
: score >= 55
|
|
185
|
-
? 'high'
|
|
186
|
-
: score >= 30
|
|
187
|
-
? 'medium'
|
|
188
|
-
: 'low'
|
|
207
|
+
const level = riskLevelFromScore(score)
|
|
189
208
|
|
|
190
209
|
return {
|
|
191
210
|
score,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url)
|
|
4
|
+
const { version } = require('../package.json') as { version: string }
|
|
5
|
+
|
|
6
|
+
const TOOL_VERSION = version
|
|
7
|
+
|
|
8
|
+
export const OUTPUT_SCHEMA = {
|
|
9
|
+
report: 'schemas/drift-report.v1.json',
|
|
10
|
+
trust: 'schemas/drift-trust.v1.json',
|
|
11
|
+
ai: 'schemas/drift-ai-output.v1.json',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
type OutputMetadata = {
|
|
15
|
+
$schema: string
|
|
16
|
+
toolVersion: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type JsonOutputWithMetadata<T extends object> = T & OutputMetadata
|
|
20
|
+
|
|
21
|
+
export function withOutputMetadata<T extends object>(
|
|
22
|
+
payload: T,
|
|
23
|
+
schema: string,
|
|
24
|
+
): JsonOutputWithMetadata<T> {
|
|
25
|
+
return {
|
|
26
|
+
...payload,
|
|
27
|
+
$schema: schema,
|
|
28
|
+
toolVersion: TOOL_VERSION,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DriftPlugin } from './types.js'
|
|
2
|
+
import type { PluginValidationContext } from './plugins-rules.js'
|
|
3
|
+
import { pushError } from './plugins-messages.js'
|
|
4
|
+
|
|
5
|
+
export function validateCapabilities(
|
|
6
|
+
capabilitiesCandidate: unknown,
|
|
7
|
+
context: PluginValidationContext,
|
|
8
|
+
): DriftPlugin['capabilities'] | undefined {
|
|
9
|
+
const { pluginId, pluginName, errors } = context
|
|
10
|
+
if (capabilitiesCandidate === undefined) return undefined
|
|
11
|
+
if (!capabilitiesCandidate || typeof capabilitiesCandidate !== 'object' || Array.isArray(capabilitiesCandidate)) {
|
|
12
|
+
pushError(
|
|
13
|
+
errors,
|
|
14
|
+
pluginId,
|
|
15
|
+
`Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`,
|
|
16
|
+
{ pluginName, code: 'plugin-capabilities-invalid' },
|
|
17
|
+
)
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const entries = Object.entries(capabilitiesCandidate as Record<string, unknown>)
|
|
22
|
+
for (const [capabilityKey, capabilityValue] of entries) {
|
|
23
|
+
const capabilityType = typeof capabilityValue
|
|
24
|
+
if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
|
|
25
|
+
pushError(
|
|
26
|
+
errors,
|
|
27
|
+
pluginId,
|
|
28
|
+
`Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`,
|
|
29
|
+
{ pluginName, code: 'plugin-capabilities-value-invalid' },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (errors.length > 0) return undefined
|
|
35
|
+
return capabilitiesCandidate as DriftPlugin['capabilities']
|
|
36
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { PluginLoadError, PluginLoadWarning } from './types.js'
|
|
2
|
+
|
|
3
|
+
type PluginMessageOptions = { pluginName?: string; ruleId?: string; code?: string }
|
|
4
|
+
|
|
5
|
+
function pushLoadMessage(
|
|
6
|
+
pluginId: string,
|
|
7
|
+
message: string,
|
|
8
|
+
options?: PluginMessageOptions,
|
|
9
|
+
): PluginLoadError | PluginLoadWarning {
|
|
10
|
+
return {
|
|
11
|
+
pluginId,
|
|
12
|
+
pluginName: options?.pluginName,
|
|
13
|
+
ruleId: options?.ruleId,
|
|
14
|
+
code: options?.code,
|
|
15
|
+
message,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pushError(
|
|
20
|
+
errors: PluginLoadError[],
|
|
21
|
+
pluginId: string,
|
|
22
|
+
message: string,
|
|
23
|
+
options?: PluginMessageOptions,
|
|
24
|
+
): void {
|
|
25
|
+
errors.push(pushLoadMessage(pluginId, message, options))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pushWarning(
|
|
29
|
+
warnings: PluginLoadWarning[],
|
|
30
|
+
pluginId: string,
|
|
31
|
+
message: string,
|
|
32
|
+
options?: PluginMessageOptions,
|
|
33
|
+
): void {
|
|
34
|
+
warnings.push(pushLoadMessage(pluginId, message, options))
|
|
35
|
+
}
|