@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.
- 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/review-pr.yml +34 -41
- package/AGENTS.md +75 -251
- package/CHANGELOG.md +28 -0
- package/README.md +148 -41
- package/dist/benchmark.d.ts +1 -1
- package/dist/benchmark.js +71 -52
- package/dist/cli.js +243 -8
- package/dist/config.js +16 -2
- package/dist/diff.js +42 -50
- 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/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 +10 -3
- package/dist/index.js +4 -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 +1 -1
- package/dist/plugins.js +45 -142
- 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 +2 -1
- package/dist/rules/phase3-configurable.js +23 -15
- 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 -218
- package/dist/saas.js +7 -761
- 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 +1 -3
- package/dist/trust-kpi.js +6 -266
- 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 +4 -32
- package/dist/trust.js +29 -432
- 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 -365
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +37 -5
- package/package.json +3 -2
- 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/benchmark.ts +75 -53
- package/src/cli.ts +285 -13
- package/src/config.ts +19 -2
- package/src/diff.ts +57 -48
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +35 -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 +76 -283
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +4 -2
- package/src/rules/phase3-configurable.ts +39 -26
- 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 -1031
- 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 +8 -316
- 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 +62 -576
- 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 -409
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +10 -2
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +31 -4
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|