@eduardbar/drift 1.3.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.
Files changed (168) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/review-pr.yml +34 -41
  7. package/AGENTS.md +75 -251
  8. package/CHANGELOG.md +28 -0
  9. package/README.md +148 -41
  10. package/dist/benchmark.d.ts +1 -1
  11. package/dist/benchmark.js +71 -52
  12. package/dist/cli.js +243 -8
  13. package/dist/config.js +16 -2
  14. package/dist/diff.js +42 -50
  15. package/dist/doctor.d.ts +5 -0
  16. package/dist/doctor.js +133 -0
  17. package/dist/format.d.ts +17 -0
  18. package/dist/format.js +45 -0
  19. package/dist/guard-types.d.ts +57 -0
  20. package/dist/guard-types.js +2 -0
  21. package/dist/guard.d.ts +14 -0
  22. package/dist/guard.js +239 -0
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.js +4 -1
  25. package/dist/init.d.ts +15 -0
  26. package/dist/init.js +273 -0
  27. package/dist/map-cycles.d.ts +2 -0
  28. package/dist/map-cycles.js +34 -0
  29. package/dist/map-svg.d.ts +19 -0
  30. package/dist/map-svg.js +97 -0
  31. package/dist/map.js +78 -138
  32. package/dist/metrics.js +70 -55
  33. package/dist/output-metadata.d.ts +13 -0
  34. package/dist/output-metadata.js +17 -0
  35. package/dist/plugins-capabilities.d.ts +4 -0
  36. package/dist/plugins-capabilities.js +21 -0
  37. package/dist/plugins-messages.d.ts +10 -0
  38. package/dist/plugins-messages.js +16 -0
  39. package/dist/plugins-rules.d.ts +9 -0
  40. package/dist/plugins-rules.js +137 -0
  41. package/dist/plugins.d.ts +1 -1
  42. package/dist/plugins.js +45 -142
  43. package/dist/reporter-constants.d.ts +16 -0
  44. package/dist/reporter-constants.js +39 -0
  45. package/dist/reporter.d.ts +3 -3
  46. package/dist/reporter.js +35 -55
  47. package/dist/review.d.ts +2 -1
  48. package/dist/review.js +2 -1
  49. package/dist/rules/phase3-configurable.js +23 -15
  50. package/dist/saas/constants.d.ts +15 -0
  51. package/dist/saas/constants.js +48 -0
  52. package/dist/saas/dashboard.d.ts +8 -0
  53. package/dist/saas/dashboard.js +132 -0
  54. package/dist/saas/errors.d.ts +19 -0
  55. package/dist/saas/errors.js +37 -0
  56. package/dist/saas/helpers.d.ts +21 -0
  57. package/dist/saas/helpers.js +110 -0
  58. package/dist/saas/ingest.d.ts +3 -0
  59. package/dist/saas/ingest.js +249 -0
  60. package/dist/saas/organization.d.ts +5 -0
  61. package/dist/saas/organization.js +82 -0
  62. package/dist/saas/plan-change.d.ts +10 -0
  63. package/dist/saas/plan-change.js +15 -0
  64. package/dist/saas/store.d.ts +21 -0
  65. package/dist/saas/store.js +159 -0
  66. package/dist/saas/types.d.ts +191 -0
  67. package/dist/saas/types.js +2 -0
  68. package/dist/saas.d.ts +8 -218
  69. package/dist/saas.js +7 -761
  70. package/dist/sarif.d.ts +74 -0
  71. package/dist/sarif.js +122 -0
  72. package/dist/trust-advanced.d.ts +14 -0
  73. package/dist/trust-advanced.js +65 -0
  74. package/dist/trust-kpi-fs.d.ts +3 -0
  75. package/dist/trust-kpi-fs.js +141 -0
  76. package/dist/trust-kpi-parse.d.ts +7 -0
  77. package/dist/trust-kpi-parse.js +186 -0
  78. package/dist/trust-kpi-types.d.ts +16 -0
  79. package/dist/trust-kpi-types.js +2 -0
  80. package/dist/trust-kpi.d.ts +1 -3
  81. package/dist/trust-kpi.js +6 -266
  82. package/dist/trust-policy.d.ts +32 -0
  83. package/dist/trust-policy.js +160 -0
  84. package/dist/trust-render.d.ts +9 -0
  85. package/dist/trust-render.js +54 -0
  86. package/dist/trust-scoring.d.ts +9 -0
  87. package/dist/trust-scoring.js +208 -0
  88. package/dist/trust.d.ts +4 -32
  89. package/dist/trust.js +29 -432
  90. package/dist/types/app.d.ts +30 -0
  91. package/dist/types/app.js +2 -0
  92. package/dist/types/config.d.ts +25 -0
  93. package/dist/types/config.js +2 -0
  94. package/dist/types/core.d.ts +100 -0
  95. package/dist/types/core.js +2 -0
  96. package/dist/types/diff.d.ts +55 -0
  97. package/dist/types/diff.js +2 -0
  98. package/dist/types/plugin.d.ts +41 -0
  99. package/dist/types/plugin.js +2 -0
  100. package/dist/types/trust.d.ts +120 -0
  101. package/dist/types/trust.js +2 -0
  102. package/dist/types.d.ts +8 -365
  103. package/docs/release-notes-draft.md +40 -0
  104. package/docs/rules-catalog.md +49 -0
  105. package/docs/trust-core-release-checklist.md +37 -5
  106. package/package.json +3 -2
  107. package/packages/vscode-drift/src/code-actions.ts +1 -1
  108. package/schemas/drift-ai-output.v1.json +162 -0
  109. package/schemas/drift-report.v1.json +151 -0
  110. package/schemas/drift-trust.v1.json +131 -0
  111. package/scripts/smoke-repo.mjs +394 -0
  112. package/src/benchmark.ts +75 -53
  113. package/src/cli.ts +285 -13
  114. package/src/config.ts +19 -2
  115. package/src/diff.ts +57 -48
  116. package/src/doctor.ts +173 -0
  117. package/src/format.ts +81 -0
  118. package/src/guard-types.ts +64 -0
  119. package/src/guard.ts +324 -0
  120. package/src/index.ts +35 -0
  121. package/src/init.ts +298 -0
  122. package/src/map-cycles.ts +38 -0
  123. package/src/map-svg.ts +124 -0
  124. package/src/map.ts +111 -142
  125. package/src/metrics.ts +78 -59
  126. package/src/output-metadata.ts +30 -0
  127. package/src/plugins-capabilities.ts +36 -0
  128. package/src/plugins-messages.ts +35 -0
  129. package/src/plugins-rules.ts +296 -0
  130. package/src/plugins.ts +76 -283
  131. package/src/reporter-constants.ts +46 -0
  132. package/src/reporter.ts +64 -65
  133. package/src/review.ts +4 -2
  134. package/src/rules/phase3-configurable.ts +39 -26
  135. package/src/saas/constants.ts +56 -0
  136. package/src/saas/dashboard.ts +172 -0
  137. package/src/saas/errors.ts +45 -0
  138. package/src/saas/helpers.ts +140 -0
  139. package/src/saas/ingest.ts +278 -0
  140. package/src/saas/organization.ts +99 -0
  141. package/src/saas/plan-change.ts +19 -0
  142. package/src/saas/store.ts +172 -0
  143. package/src/saas/types.ts +216 -0
  144. package/src/saas.ts +49 -1031
  145. package/src/sarif.ts +232 -0
  146. package/src/trust-advanced.ts +99 -0
  147. package/src/trust-kpi-fs.ts +169 -0
  148. package/src/trust-kpi-parse.ts +219 -0
  149. package/src/trust-kpi-types.ts +19 -0
  150. package/src/trust-kpi.ts +8 -316
  151. package/src/trust-policy.ts +246 -0
  152. package/src/trust-render.ts +61 -0
  153. package/src/trust-scoring.ts +231 -0
  154. package/src/trust.ts +62 -576
  155. package/src/types/app.ts +30 -0
  156. package/src/types/config.ts +27 -0
  157. package/src/types/core.ts +105 -0
  158. package/src/types/diff.ts +61 -0
  159. package/src/types/plugin.ts +46 -0
  160. package/src/types/trust.ts +134 -0
  161. package/src/types.ts +78 -409
  162. package/tests/cli-sarif.test.ts +92 -0
  163. package/tests/format.test.ts +157 -0
  164. package/tests/new-features.test.ts +10 -2
  165. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  166. package/tests/sarif.test.ts +160 -0
  167. package/tests/trust-kpi.test.ts +31 -4
  168. package/tests/trust.test.ts +18 -0
package/src/init.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { analyzeProject } from './analyzer.js'
4
+ import { buildReport } from './reporter.js'
5
+ import { loadConfig } from './config.js'
6
+ import { scoreToGrade } from './utils.js'
7
+
8
+ interface InitOptions {
9
+ preset?: string
10
+ ci?: boolean
11
+ baseline?: boolean
12
+ }
13
+
14
+ export const INIT_PRESETS = ['node-backend', 'react-app', 'hexagonal', 'monorepo'] as const
15
+ type InitPreset = (typeof INIT_PRESETS)[number]
16
+
17
+ type InitBaselineGrade = 'CLEAN' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
18
+
19
+ const CONFIG_PRESET_CONTENT: Record<InitPreset, string> = {
20
+ 'node-backend': `import type { DriftConfig } from '@eduardbar/drift'
21
+
22
+ export default {
23
+ layers: [
24
+ {
25
+ name: 'api',
26
+ patterns: ['src/routes/**', 'src/controllers/**'],
27
+ canImportFrom: ['services', 'middleware', 'types'],
28
+ },
29
+ {
30
+ name: 'services',
31
+ patterns: ['src/services/**'],
32
+ canImportFrom: ['db', 'types'],
33
+ },
34
+ {
35
+ name: 'db',
36
+ patterns: ['src/db/**', 'src/models/**'],
37
+ canImportFrom: ['types'],
38
+ },
39
+ {
40
+ name: 'types',
41
+ patterns: ['src/types/**'],
42
+ canImportFrom: [],
43
+ },
44
+ ],
45
+ } satisfies DriftConfig
46
+ `,
47
+ 'react-app': `import type { DriftConfig } from '@eduardbar/drift'
48
+
49
+ export default {
50
+ layers: [
51
+ {
52
+ name: 'pages',
53
+ patterns: ['src/pages/**', 'src/app/**'],
54
+ canImportFrom: ['components', 'hooks', 'services', 'types'],
55
+ },
56
+ {
57
+ name: 'components',
58
+ patterns: ['src/components/**'],
59
+ canImportFrom: ['hooks', 'types'],
60
+ },
61
+ {
62
+ name: 'hooks',
63
+ patterns: ['src/hooks/**'],
64
+ canImportFrom: ['services', 'types'],
65
+ },
66
+ {
67
+ name: 'services',
68
+ patterns: ['src/services/**', 'src/api/**'],
69
+ canImportFrom: ['types'],
70
+ },
71
+ {
72
+ name: 'types',
73
+ patterns: ['src/types/**'],
74
+ canImportFrom: [],
75
+ },
76
+ ],
77
+ } satisfies DriftConfig
78
+ `,
79
+ hexagonal: `import type { DriftConfig } from '@eduardbar/drift'
80
+
81
+ export default {
82
+ layers: [
83
+ {
84
+ name: 'adapters',
85
+ patterns: ['src/adapters/**', 'src/infrastructure/**'],
86
+ canImportFrom: ['application', 'domain'],
87
+ },
88
+ {
89
+ name: 'application',
90
+ patterns: ['src/application/**', 'src/use-cases/**'],
91
+ canImportFrom: ['domain'],
92
+ },
93
+ {
94
+ name: 'domain',
95
+ patterns: ['src/domain/**'],
96
+ canImportFrom: [],
97
+ },
98
+ ],
99
+ } satisfies DriftConfig
100
+ `,
101
+ monorepo: `import type { DriftConfig } from '@eduardbar/drift'
102
+
103
+ export default {
104
+ modules: [
105
+ {
106
+ name: 'shared',
107
+ root: 'packages/shared',
108
+ allowedExternalImports: [],
109
+ },
110
+ {
111
+ name: 'api',
112
+ root: 'packages/api',
113
+ allowedExternalImports: ['@myorg/shared'],
114
+ },
115
+ {
116
+ name: 'web',
117
+ root: 'packages/web',
118
+ allowedExternalImports: ['@myorg/shared'],
119
+ },
120
+ ],
121
+ } satisfies DriftConfig
122
+ `,
123
+ }
124
+
125
+ const GITHUB_WORKFLOW_TEMPLATE = `name: drift PR Review
126
+
127
+ on:
128
+ pull_request:
129
+ branches: [main, master, develop]
130
+
131
+ permissions:
132
+ contents: read
133
+ pull-requests: write
134
+
135
+ jobs:
136
+ drift-review:
137
+ runs-on: ubuntu-latest
138
+ steps:
139
+ - uses: actions/checkout@v4
140
+ with:
141
+ fetch-depth: 0
142
+
143
+ - uses: actions/setup-node@v4
144
+ with:
145
+ node-version: 18
146
+
147
+ - name: Install drift
148
+ run: npm install -g @eduardbar/drift
149
+
150
+ - name: Run drift review
151
+ id: drift
152
+ run: |
153
+ npx drift review --base origin/\${{ github.base_ref }} --comment > drift-comment.md
154
+ echo "score=$(cat drift-comment.md | grep 'Score:' | awk '{print $2}')" >> $GITHUB_OUTPUT
155
+
156
+ - name: Comment PR
157
+ uses: actions/github-script@v7
158
+ with:
159
+ script: |
160
+ const fs = require('fs')
161
+ const comment = fs.readFileSync('drift-comment.md', 'utf8')
162
+
163
+ const { data: comments } = await github.rest.issues.listComments({
164
+ owner: context.repo.owner,
165
+ repo: context.repo.repo,
166
+ issue_number: context.issue.number,
167
+ })
168
+
169
+ const botComment = comments.find(c =>
170
+ c.user?.type === 'Bot' && c.body?.includes('<!-- drift-review -->')
171
+ )
172
+
173
+ const body = '<!-- drift-review -->\\n\\n' + comment
174
+
175
+ if (botComment) {
176
+ await github.rest.issues.updateComment({
177
+ owner: context.repo.owner,
178
+ repo: context.repo.repo,
179
+ comment_id: botComment.id,
180
+ body,
181
+ })
182
+ } else {
183
+ await github.rest.issues.createComment({
184
+ owner: context.repo.owner,
185
+ repo: context.repo.repo,
186
+ issue_number: context.issue.number,
187
+ body,
188
+ })
189
+ }
190
+ `
191
+
192
+ function mapScoreToBaselineGrade(score: number): InitBaselineGrade {
193
+ const { label } = scoreToGrade(score)
194
+
195
+ if (label === 'clean') return 'CLEAN'
196
+ if (label === 'low') return 'LOW'
197
+ if (label === 'moderate') return 'MEDIUM'
198
+ if (label === 'high') return 'HIGH'
199
+
200
+ return 'CRITICAL'
201
+ }
202
+
203
+ /**
204
+ * Initialize drift configuration with optional presets and scaffolding.
205
+ *
206
+ * @param projectRoot - Absolute path to project root
207
+ * @param options - Init options from CLI
208
+ */
209
+ export async function runInit(projectRoot: string, options: InitOptions): Promise<void> {
210
+ const tasks: string[] = []
211
+
212
+ maybeWritePresetConfig(projectRoot, options.preset, tasks)
213
+ maybeWriteCiWorkflow(projectRoot, options.ci, tasks)
214
+ await maybeWriteBaseline(projectRoot, options.baseline, tasks)
215
+
216
+ if (tasks.length === 0) {
217
+ process.stdout.write('\n No actions taken. Use --preset, --ci, or --baseline flags.\n\n')
218
+ } else {
219
+ process.stdout.write('\n drift init complete:\n\n')
220
+ for (const task of tasks) {
221
+ process.stdout.write(` ${task}\n`)
222
+ }
223
+ process.stdout.write('\n')
224
+ }
225
+ }
226
+
227
+ function isInitPreset(value: string): value is InitPreset {
228
+ return INIT_PRESETS.includes(value as InitPreset)
229
+ }
230
+
231
+ function maybeWritePresetConfig(projectRoot: string, preset: string | undefined, tasks: string[]): void {
232
+ if (!preset) return
233
+
234
+ if (!isInitPreset(preset)) {
235
+ throw new Error(`Invalid preset '${preset}'. Use one of: ${INIT_PRESETS.join(', ')}`)
236
+ }
237
+
238
+ const configPath = join(projectRoot, 'drift.config.ts')
239
+ if (existsSync(configPath)) {
240
+ process.stderr.write(` ⚠️ drift.config.ts already exists, skipping config generation\n`)
241
+ return
242
+ }
243
+
244
+ writeFileSync(configPath, generateConfigPreset(preset), 'utf8')
245
+ tasks.push('✅ Generated drift.config.ts')
246
+ }
247
+
248
+ function maybeWriteCiWorkflow(projectRoot: string, ci: boolean | undefined, tasks: string[]): void {
249
+ if (!ci) return
250
+
251
+ const workflowDir = join(projectRoot, '.github', 'workflows')
252
+ const workflowPath = join(workflowDir, 'drift-review.yml')
253
+ if (existsSync(workflowPath)) {
254
+ process.stderr.write(` ⚠️ .github/workflows/drift-review.yml already exists, skipping workflow generation\n`)
255
+ return
256
+ }
257
+
258
+ if (!existsSync(workflowDir)) {
259
+ mkdirSync(workflowDir, { recursive: true })
260
+ }
261
+
262
+ writeFileSync(workflowPath, generateGitHubWorkflow(), 'utf8')
263
+ tasks.push('✅ Generated .github/workflows/drift-review.yml')
264
+ }
265
+
266
+ async function maybeWriteBaseline(projectRoot: string, baseline: boolean | undefined, tasks: string[]): Promise<void> {
267
+ if (!baseline) return
268
+
269
+ const baselinePath = join(projectRoot, 'drift-baseline.json')
270
+ if (existsSync(baselinePath)) {
271
+ process.stderr.write(` ⚠️ drift-baseline.json already exists, skipping baseline creation\n`)
272
+ return
273
+ }
274
+
275
+ process.stderr.write(' Scanning project to create baseline...\n')
276
+ const config = await loadConfig(projectRoot)
277
+ const files = analyzeProject(projectRoot, config)
278
+ const report = buildReport(projectRoot, files)
279
+
280
+ const baselineSnapshot = {
281
+ createdAt: new Date().toISOString(),
282
+ score: report.totalScore,
283
+ grade: mapScoreToBaselineGrade(report.totalScore),
284
+ totalIssues: report.totalIssues,
285
+ files: report.files.length,
286
+ }
287
+
288
+ writeFileSync(baselinePath, JSON.stringify(baselineSnapshot, null, 2), 'utf8')
289
+ tasks.push(`✅ Created drift-baseline.json (score: ${report.totalScore}/100, grade: ${baselineSnapshot.grade})`)
290
+ }
291
+
292
+ function generateConfigPreset(preset: InitPreset): string {
293
+ return CONFIG_PRESET_CONTENT[preset]
294
+ }
295
+
296
+ function generateGitHubWorkflow(): string {
297
+ return GITHUB_WORKFLOW_TEMPLATE
298
+ }
@@ -0,0 +1,38 @@
1
+ export function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
2
+ const visited = new Set<string>()
3
+ const inStack = new Set<string>()
4
+ const stack: string[] = []
5
+ const cycleEdges = new Set<string>()
6
+
7
+ function dfs(node: string): void {
8
+ visited.add(node)
9
+ inStack.add(node)
10
+ stack.push(node)
11
+
12
+ for (const neighbor of adjacency.get(node) ?? []) {
13
+ if (!visited.has(neighbor)) {
14
+ dfs(neighbor)
15
+ continue
16
+ }
17
+
18
+ if (!inStack.has(neighbor)) continue
19
+
20
+ const startIndex = stack.indexOf(neighbor)
21
+ if (startIndex >= 0) {
22
+ for (let i = startIndex; i < stack.length - 1; i++) {
23
+ cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
24
+ }
25
+ }
26
+ cycleEdges.add(`${node}->${neighbor}`)
27
+ }
28
+
29
+ stack.pop()
30
+ inStack.delete(node)
31
+ }
32
+
33
+ for (const node of adjacency.keys()) {
34
+ if (!visited.has(node)) dfs(node)
35
+ }
36
+
37
+ return cycleEdges
38
+ }
package/src/map-svg.ts ADDED
@@ -0,0 +1,124 @@
1
+ const SVG_WIDTH = 960
2
+ const ROW_HEIGHT = 90
3
+ const MIN_CANVAS_HEIGHT = 180
4
+ const BOTTOM_PADDING = 120
5
+ const BOX_WIDTH = 240
6
+ const BOX_HEIGHT = 50
7
+ const BOX_LEFT = 100
8
+ const BOX_TOP_OFFSET = 60
9
+
10
+ const NORMAL_EDGE_WIDTH = 2
11
+ const HIGHLIGHT_EDGE_WIDTH = 3
12
+ const EDGE_LABEL_Y_OFFSET = 4
13
+
14
+ const NODE_TITLE_X_OFFSET = 12
15
+ const NODE_TITLE_Y_OFFSET = 22
16
+ const NODE_META_X_OFFSET = 12
17
+ const NODE_META_Y_OFFSET = 38
18
+
19
+ const LEGEND_CYCLE_START_X = 520
20
+ const LEGEND_CYCLE_END_X = 560
21
+ const LEGEND_CYCLE_LABEL_X = 567
22
+ const LEGEND_VIOLATION_START_X = 630
23
+ const LEGEND_VIOLATION_END_X = 670
24
+ const LEGEND_VIOLATION_LABEL_X = 677
25
+ const LEGEND_LINE_Y = 66
26
+ const LEGEND_LABEL_Y = 69
27
+
28
+ interface LayerNode {
29
+ name: string
30
+ files: Set<string>
31
+ }
32
+
33
+ interface MapEdge {
34
+ key: string
35
+ from: string
36
+ to: string
37
+ count: number
38
+ kind: 'normal' | 'cycle' | 'violation'
39
+ }
40
+
41
+ function esc(value: string): string {
42
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
43
+ }
44
+
45
+ function buildSvgLayout(layers: Map<string, LayerNode>): { width: number; height: number; boxes: Array<LayerNode & { x: number; y: number }> } {
46
+ const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
47
+ const height = Math.max(MIN_CANVAS_HEIGHT, layerList.length * ROW_HEIGHT + BOTTOM_PADDING)
48
+ const boxes = layerList.map((layer, index) => ({
49
+ ...layer,
50
+ x: BOX_LEFT,
51
+ y: BOX_TOP_OFFSET + index * ROW_HEIGHT,
52
+ }))
53
+
54
+ return {
55
+ width: SVG_WIDTH,
56
+ height,
57
+ boxes,
58
+ }
59
+ }
60
+
61
+ function edgeStroke(kind: MapEdge['kind']): string {
62
+ if (kind === 'violation') return '#ef4444'
63
+ if (kind === 'cycle') return '#f59e0b'
64
+ return '#64748b'
65
+ }
66
+
67
+ function edgeStrokeWidth(kind: MapEdge['kind']): number {
68
+ return kind === 'normal' ? NORMAL_EDGE_WIDTH : HIGHLIGHT_EDGE_WIDTH
69
+ }
70
+
71
+ function renderEdges(edgeList: MapEdge[], boxByName: Map<string, LayerNode & { x: number; y: number }>): string {
72
+ return edgeList.map((edge) => {
73
+ const a = boxByName.get(edge.from)
74
+ const b = boxByName.get(edge.to)
75
+ if (!a || !b) return ''
76
+ const startX = a.x + BOX_WIDTH
77
+ const startY = a.y + BOX_HEIGHT / 2
78
+ const endX = b.x
79
+ const endY = b.y + BOX_HEIGHT / 2
80
+ const stroke = edgeStroke(edge.kind)
81
+ const widthPx = edgeStrokeWidth(edge.kind)
82
+ return `
83
+ <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}" />
84
+ <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - EDGE_LABEL_Y_OFFSET}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
85
+ }).join('')
86
+ }
87
+
88
+ function renderNodes(boxes: Array<LayerNode & { x: number; y: number }>): string {
89
+ return boxes.map((box) => `
90
+ <g>
91
+ <rect x="${box.x}" y="${box.y}" width="${BOX_WIDTH}" height="${BOX_HEIGHT}" rx="8" fill="#0f172a" stroke="#334155" />
92
+ <text x="${box.x + NODE_TITLE_X_OFFSET}" y="${box.y + NODE_TITLE_Y_OFFSET}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
93
+ <text x="${box.x + NODE_META_X_OFFSET}" y="${box.y + NODE_META_Y_OFFSET}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
94
+ </g>`).join('')
95
+ }
96
+
97
+ export function renderArchitectureSvg(input: {
98
+ layers: Map<string, LayerNode>
99
+ edgeList: MapEdge[]
100
+ cycleCount: number
101
+ violationCount: number
102
+ }): string {
103
+ const { width, height, boxes } = buildSvgLayout(input.layers)
104
+ const boxByName = new Map(boxes.map((box) => [box.name, box]))
105
+
106
+ const lines = renderEdges(input.edgeList, boxByName)
107
+ const nodes = renderNodes(boxes)
108
+
109
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
110
+ <defs>
111
+ <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
112
+ <path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
113
+ </marker>
114
+ </defs>
115
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
116
+ <text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
117
+ <text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
118
+ <text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${input.cycleCount} | Layer violations: ${input.violationCount}</text>
119
+ <line x1="${LEGEND_CYCLE_START_X}" y1="${LEGEND_LINE_Y}" x2="${LEGEND_CYCLE_END_X}" y2="${LEGEND_LINE_Y}" stroke="#f59e0b" stroke-width="${HIGHLIGHT_EDGE_WIDTH}" /><text x="${LEGEND_CYCLE_LABEL_X}" y="${LEGEND_LABEL_Y}" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
120
+ <line x1="${LEGEND_VIOLATION_START_X}" y1="${LEGEND_LINE_Y}" x2="${LEGEND_VIOLATION_END_X}" y2="${LEGEND_LINE_Y}" stroke="#ef4444" stroke-width="${HIGHLIGHT_EDGE_WIDTH}" /><text x="${LEGEND_VIOLATION_LABEL_X}" y="${LEGEND_LABEL_Y}" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
121
+ ${lines}
122
+ ${nodes}
123
+ </svg>`
124
+ }