@eduardbar/drift 1.1.0 → 1.3.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/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +153 -0
- package/AGENTS.md +6 -0
- package/README.md +192 -4
- 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 +185 -0
- package/dist/cli.js +509 -23
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +177 -28
- package/dist/printer.js +4 -0
- package/dist/review.js +2 -2
- 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/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas.d.ts +219 -0
- package/dist/saas.js +762 -0
- package/dist/trust-kpi.d.ts +9 -0
- package/dist/trust-kpi.js +445 -0
- package/dist/trust.d.ts +65 -0
- package/dist/trust.js +571 -0
- package/dist/types.d.ts +160 -0
- package/docs/PRD.md +199 -172
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +628 -36
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +63 -0
- package/src/map.ts +112 -10
- package/src/plugins.ts +354 -26
- package/src/printer.ts +4 -0
- package/src/review.ts +2 -2
- 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/shared.ts +31 -3
- package/src/saas.ts +1031 -0
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +177 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +98 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +464 -0
- package/tests/trust-kpi.test.ts +120 -0
- package/tests/trust.test.ts +584 -0
package/src/diff.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
|
|
2
2
|
|
|
3
|
+
function normalizePath(filePath: string): string {
|
|
4
|
+
return filePath.replace(/\\/g, '/')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeIssueText(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\r\n/g, '\n')
|
|
10
|
+
.replace(/\r/g, '\n')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
/**
|
|
4
16
|
* Compute the diff between two DriftReports.
|
|
5
17
|
*
|
|
6
|
-
* Issues are matched
|
|
18
|
+
* Issues are matched in two passes:
|
|
19
|
+
* 1) strict location key (rule + line + column)
|
|
20
|
+
* 2) normalized content key (rule + severity + line + message + snippet)
|
|
21
|
+
*
|
|
22
|
+
* This keeps deterministic matching while preventing false churn caused by
|
|
23
|
+
* cross-platform line ending changes and small column offset noise.
|
|
7
24
|
* A "new" issue exists in `current` but not in `base`.
|
|
8
25
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
26
|
*/
|
|
@@ -19,13 +36,61 @@ function computeFileDiff(
|
|
|
19
36
|
const baseIssues = baseFile?.issues ?? []
|
|
20
37
|
const currentIssues = currentFile?.issues ?? []
|
|
21
38
|
|
|
22
|
-
const
|
|
39
|
+
const strictIssueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
+
const normalizedIssueKey = (i: DriftIssue) => {
|
|
41
|
+
const normalizedMessage = normalizeIssueText(i.message)
|
|
42
|
+
const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, 80)
|
|
43
|
+
return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const matchedBaseIndexes = new Set<number>()
|
|
47
|
+
const matchedCurrentIndexes = new Set<number>()
|
|
48
|
+
|
|
49
|
+
const baseStrictIndex = new Map<string, number[]>()
|
|
50
|
+
for (const [index, issue] of baseIssues.entries()) {
|
|
51
|
+
const key = strictIssueKey(issue)
|
|
52
|
+
const bucket = baseStrictIndex.get(key)
|
|
53
|
+
if (bucket) bucket.push(index)
|
|
54
|
+
else baseStrictIndex.set(key, [index])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
58
|
+
const key = strictIssueKey(issue)
|
|
59
|
+
const bucket = baseStrictIndex.get(key)
|
|
60
|
+
if (!bucket || bucket.length === 0) continue
|
|
23
61
|
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
const matchedIndex = bucket.shift()
|
|
63
|
+
if (matchedIndex === undefined) continue
|
|
64
|
+
|
|
65
|
+
matchedBaseIndexes.add(matchedIndex)
|
|
66
|
+
matchedCurrentIndexes.add(currentIndex)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const baseNormalizedIndex = new Map<string, number[]>()
|
|
70
|
+
for (const [index, issue] of baseIssues.entries()) {
|
|
71
|
+
if (matchedBaseIndexes.has(index)) continue
|
|
72
|
+
const key = normalizedIssueKey(issue)
|
|
73
|
+
const bucket = baseNormalizedIndex.get(key)
|
|
74
|
+
if (bucket) bucket.push(index)
|
|
75
|
+
else baseNormalizedIndex.set(key, [index])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
79
|
+
if (matchedCurrentIndexes.has(currentIndex)) continue
|
|
80
|
+
|
|
81
|
+
const key = normalizedIssueKey(issue)
|
|
82
|
+
const bucket = baseNormalizedIndex.get(key)
|
|
83
|
+
if (!bucket || bucket.length === 0) continue
|
|
84
|
+
|
|
85
|
+
const matchedIndex = bucket.shift()
|
|
86
|
+
if (matchedIndex === undefined) continue
|
|
87
|
+
|
|
88
|
+
matchedBaseIndexes.add(matchedIndex)
|
|
89
|
+
matchedCurrentIndexes.add(currentIndex)
|
|
90
|
+
}
|
|
26
91
|
|
|
27
|
-
const newIssues = currentIssues.filter(
|
|
28
|
-
const resolvedIssues = baseIssues.filter(
|
|
92
|
+
const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
|
|
93
|
+
const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
|
|
29
94
|
|
|
30
95
|
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
31
96
|
return {
|
|
@@ -48,12 +113,12 @@ export function computeDiff(
|
|
|
48
113
|
): DriftDiff {
|
|
49
114
|
const fileDiffs: FileDiff[] = []
|
|
50
115
|
|
|
51
|
-
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
52
|
-
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
116
|
+
const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]))
|
|
117
|
+
const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]))
|
|
53
118
|
|
|
54
119
|
const allPaths = new Set([
|
|
55
|
-
...base.files.map(f => f.path),
|
|
56
|
-
...current.files.map(f => f.path),
|
|
120
|
+
...base.files.map(f => normalizePath(f.path)),
|
|
121
|
+
...current.files.map(f => normalizePath(f.path)),
|
|
57
122
|
])
|
|
58
123
|
|
|
59
124
|
for (const filePath of allPaths) {
|
package/src/git.ts
CHANGED
|
@@ -63,6 +63,18 @@ function extractFile(projectPath: string, ref: string, filePath: string, tempDir
|
|
|
63
63
|
writeFileSync(destPath, content, 'utf-8')
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function extractArchiveAtRef(projectPath: string, ref: string, tempDir: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
execSync(
|
|
69
|
+
`git archive --format=tar ${ref} | tar -x -C "${tempDir}"`,
|
|
70
|
+
{ cwd: projectPath, stdio: 'pipe' }
|
|
71
|
+
)
|
|
72
|
+
return true
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
67
79
|
verifyGitRepo(projectPath)
|
|
68
80
|
verifyRefExists(projectPath, ref)
|
|
@@ -76,6 +88,10 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
|
76
88
|
const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
|
|
77
89
|
mkdirSync(tempDir, { recursive: true })
|
|
78
90
|
|
|
91
|
+
if (extractArchiveAtRef(projectPath, ref, tempDir)) {
|
|
92
|
+
return tempDir
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
for (const filePath of tsFiles) {
|
|
80
96
|
extractFile(projectPath, ref, filePath, tempDir)
|
|
81
97
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,22 @@ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
|
|
|
2
2
|
export { buildReport, formatMarkdown } from './reporter.js'
|
|
3
3
|
export { computeDiff } from './diff.js'
|
|
4
4
|
export { generateReview, formatReviewMarkdown } from './review.js'
|
|
5
|
+
export {
|
|
6
|
+
buildTrustReport,
|
|
7
|
+
formatTrustConsole,
|
|
8
|
+
formatTrustMarkdown,
|
|
9
|
+
formatTrustJson,
|
|
10
|
+
shouldFailByMaxRisk,
|
|
11
|
+
shouldFailTrustGate,
|
|
12
|
+
normalizeMergeRiskLevel,
|
|
13
|
+
MERGE_RISK_ORDER,
|
|
14
|
+
} from './trust.js'
|
|
15
|
+
export {
|
|
16
|
+
computeTrustKpis,
|
|
17
|
+
computeTrustKpisFromReports,
|
|
18
|
+
formatTrustKpiConsole,
|
|
19
|
+
formatTrustKpiJson,
|
|
20
|
+
} from './trust-kpi.js'
|
|
5
21
|
export { generateArchitectureMap, generateArchitectureSvg } from './map.js'
|
|
6
22
|
export type {
|
|
7
23
|
DriftReport,
|
|
@@ -12,8 +28,55 @@ export type {
|
|
|
12
28
|
DriftConfig,
|
|
13
29
|
RepoQualityScore,
|
|
14
30
|
MaintenanceRiskMetrics,
|
|
31
|
+
DriftTrustReport,
|
|
32
|
+
TrustReason,
|
|
33
|
+
TrustFixPriority,
|
|
34
|
+
TrustDiffContext,
|
|
35
|
+
TrustKpiReport,
|
|
36
|
+
TrustKpiDiagnostic,
|
|
37
|
+
TrustDiffTrendSummary,
|
|
38
|
+
TrustScoreStats,
|
|
39
|
+
MergeRiskLevel,
|
|
15
40
|
DriftPlugin,
|
|
16
41
|
DriftPluginRule,
|
|
17
42
|
} from './types.js'
|
|
18
43
|
export { loadHistory, saveSnapshot } from './snapshot.js'
|
|
19
44
|
export type { SnapshotEntry, SnapshotHistory } from './snapshot.js'
|
|
45
|
+
export {
|
|
46
|
+
DEFAULT_SAAS_POLICY,
|
|
47
|
+
defaultSaasStorePath,
|
|
48
|
+
resolveSaasPolicy,
|
|
49
|
+
SaasActorRequiredError,
|
|
50
|
+
SaasPermissionError,
|
|
51
|
+
getRequiredRoleForOperation,
|
|
52
|
+
assertSaasPermission,
|
|
53
|
+
getSaasEffectiveLimits,
|
|
54
|
+
getOrganizationEffectiveLimits,
|
|
55
|
+
changeOrganizationPlan,
|
|
56
|
+
listOrganizationPlanChanges,
|
|
57
|
+
getOrganizationUsageSnapshot,
|
|
58
|
+
ingestSnapshotFromReport,
|
|
59
|
+
listSaasSnapshots,
|
|
60
|
+
getSaasSummary,
|
|
61
|
+
generateSaasDashboardHtml,
|
|
62
|
+
} from './saas.js'
|
|
63
|
+
export type {
|
|
64
|
+
SaasRole,
|
|
65
|
+
SaasPlan,
|
|
66
|
+
SaasPolicy,
|
|
67
|
+
SaasPolicyOverrides,
|
|
68
|
+
SaasStore,
|
|
69
|
+
SaasSummary,
|
|
70
|
+
SaasSnapshot,
|
|
71
|
+
SaasQueryOptions,
|
|
72
|
+
IngestOptions,
|
|
73
|
+
SaasPlanChange,
|
|
74
|
+
SaasOperation,
|
|
75
|
+
SaasPermissionContext,
|
|
76
|
+
SaasPermissionResult,
|
|
77
|
+
SaasEffectiveLimits,
|
|
78
|
+
SaasOrganizationUsageSnapshot,
|
|
79
|
+
ChangeOrganizationPlanOptions,
|
|
80
|
+
SaasUsageQueryOptions,
|
|
81
|
+
SaasPlanChangeQueryOptions,
|
|
82
|
+
} from './saas.js'
|
package/src/map.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs'
|
|
2
2
|
import { resolve, relative } from 'node:path'
|
|
3
3
|
import { Project } from 'ts-morph'
|
|
4
|
+
import type { DriftConfig } from './types.js'
|
|
5
|
+
import { detectLayerViolations } from './rules/phase3-arch.js'
|
|
6
|
+
import { RULE_WEIGHTS } from './analyzer.js'
|
|
4
7
|
|
|
5
8
|
interface LayerNode {
|
|
6
9
|
name: string
|
|
7
10
|
files: Set<string>
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
interface MapEdge {
|
|
14
|
+
key: string
|
|
15
|
+
from: string
|
|
16
|
+
to: string
|
|
17
|
+
count: number
|
|
18
|
+
kind: 'normal' | 'cycle' | 'violation'
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function detectLayer(relPath: string): string {
|
|
11
22
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
12
23
|
const first = normalized.split('/')[0] || 'root'
|
|
@@ -17,7 +28,7 @@ function esc(value: string): string {
|
|
|
17
28
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
export function generateArchitectureSvg(targetPath: string): string {
|
|
31
|
+
export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
|
|
21
32
|
const project = new Project({
|
|
22
33
|
skipAddingFilesFromTsConfig: true,
|
|
23
34
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -36,24 +47,65 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
36
47
|
|
|
37
48
|
const layers = new Map<string, LayerNode>()
|
|
38
49
|
const edges = new Map<string, number>()
|
|
50
|
+
const layerAdjacency = new Map<string, Set<string>>()
|
|
51
|
+
const fileImportGraph = new Map<string, Set<string>>()
|
|
39
52
|
|
|
40
53
|
for (const file of project.getSourceFiles()) {
|
|
41
|
-
const
|
|
54
|
+
const filePath = file.getFilePath()
|
|
55
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/')
|
|
42
56
|
const layerName = detectLayer(rel)
|
|
43
57
|
if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
|
|
44
58
|
layers.get(layerName)!.files.add(rel)
|
|
59
|
+
if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
|
|
45
60
|
|
|
46
61
|
for (const decl of file.getImportDeclarations()) {
|
|
47
62
|
const imported = decl.getModuleSpecifierSourceFile()
|
|
48
63
|
if (!imported) continue
|
|
64
|
+
fileImportGraph.get(filePath)!.add(imported.getFilePath())
|
|
49
65
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
|
|
50
66
|
const importedLayer = detectLayer(importedRel)
|
|
51
67
|
if (importedLayer === layerName) continue
|
|
52
68
|
const key = `${layerName}->${importedLayer}`
|
|
53
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)
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
const cycleEdges = detectCycleEdges(layerAdjacency)
|
|
76
|
+
const violationEdges = new Set<string>()
|
|
77
|
+
|
|
78
|
+
if (config?.layers && config.layers.length > 0) {
|
|
79
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
|
|
80
|
+
for (const issues of violations.values()) {
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
|
|
83
|
+
if (!match) continue
|
|
84
|
+
const from = match[1]
|
|
85
|
+
const to = match[2]
|
|
86
|
+
violationEdges.add(`${from}->${to}`)
|
|
87
|
+
if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
|
|
88
|
+
if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
|
|
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
|
+
|
|
57
109
|
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
58
110
|
const width = 960
|
|
59
111
|
const rowHeight = 90
|
|
@@ -73,18 +125,23 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
73
125
|
|
|
74
126
|
const boxByName = new Map(boxes.map((box) => [box.name, box]))
|
|
75
127
|
|
|
76
|
-
const lines =
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const b = boxByName.get(to)
|
|
128
|
+
const lines = edgeList.map((edge) => {
|
|
129
|
+
const a = boxByName.get(edge.from)
|
|
130
|
+
const b = boxByName.get(edge.to)
|
|
80
131
|
if (!a || !b) return ''
|
|
81
132
|
const startX = a.x + boxWidth
|
|
82
133
|
const startY = a.y + boxHeight / 2
|
|
83
134
|
const endX = b.x
|
|
84
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
|
|
85
142
|
return `
|
|
86
|
-
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="
|
|
87
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`
|
|
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>`
|
|
88
145
|
}).join('')
|
|
89
146
|
|
|
90
147
|
const nodes = boxes.map((box) => `
|
|
@@ -94,6 +151,9 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
94
151
|
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
95
152
|
</g>`).join('')
|
|
96
153
|
|
|
154
|
+
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
|
|
155
|
+
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
|
|
156
|
+
|
|
97
157
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
98
158
|
<defs>
|
|
99
159
|
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
@@ -103,14 +163,56 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
103
163
|
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
104
164
|
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
105
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>
|
|
106
169
|
${lines}
|
|
107
170
|
${nodes}
|
|
108
171
|
</svg>`
|
|
109
172
|
}
|
|
110
173
|
|
|
111
|
-
|
|
174
|
+
function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
|
|
175
|
+
const visited = new Set<string>()
|
|
176
|
+
const inStack = new Set<string>()
|
|
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
|
+
}
|
|
209
|
+
|
|
210
|
+
return cycleEdges
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
|
|
112
214
|
const resolvedTarget = resolve(targetPath)
|
|
113
|
-
const svg = generateArchitectureSvg(resolvedTarget)
|
|
215
|
+
const svg = generateArchitectureSvg(resolvedTarget, config)
|
|
114
216
|
const outPath = resolve(outputFile)
|
|
115
217
|
writeFileSync(outPath, svg, 'utf8')
|
|
116
218
|
return outPath
|