@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/dist/init.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
export const INIT_PRESETS = ['node-backend', 'react-app', 'hexagonal', 'monorepo'];
|
|
8
|
+
const CONFIG_PRESET_CONTENT = {
|
|
9
|
+
'node-backend': `import type { DriftConfig } from '@eduardbar/drift'
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
layers: [
|
|
13
|
+
{
|
|
14
|
+
name: 'api',
|
|
15
|
+
patterns: ['src/routes/**', 'src/controllers/**'],
|
|
16
|
+
canImportFrom: ['services', 'middleware', 'types'],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'services',
|
|
20
|
+
patterns: ['src/services/**'],
|
|
21
|
+
canImportFrom: ['db', 'types'],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'db',
|
|
25
|
+
patterns: ['src/db/**', 'src/models/**'],
|
|
26
|
+
canImportFrom: ['types'],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'types',
|
|
30
|
+
patterns: ['src/types/**'],
|
|
31
|
+
canImportFrom: [],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
} satisfies DriftConfig
|
|
35
|
+
`,
|
|
36
|
+
'react-app': `import type { DriftConfig } from '@eduardbar/drift'
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
layers: [
|
|
40
|
+
{
|
|
41
|
+
name: 'pages',
|
|
42
|
+
patterns: ['src/pages/**', 'src/app/**'],
|
|
43
|
+
canImportFrom: ['components', 'hooks', 'services', 'types'],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'components',
|
|
47
|
+
patterns: ['src/components/**'],
|
|
48
|
+
canImportFrom: ['hooks', 'types'],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'hooks',
|
|
52
|
+
patterns: ['src/hooks/**'],
|
|
53
|
+
canImportFrom: ['services', 'types'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'services',
|
|
57
|
+
patterns: ['src/services/**', 'src/api/**'],
|
|
58
|
+
canImportFrom: ['types'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'types',
|
|
62
|
+
patterns: ['src/types/**'],
|
|
63
|
+
canImportFrom: [],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
} satisfies DriftConfig
|
|
67
|
+
`,
|
|
68
|
+
hexagonal: `import type { DriftConfig } from '@eduardbar/drift'
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
layers: [
|
|
72
|
+
{
|
|
73
|
+
name: 'adapters',
|
|
74
|
+
patterns: ['src/adapters/**', 'src/infrastructure/**'],
|
|
75
|
+
canImportFrom: ['application', 'domain'],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'application',
|
|
79
|
+
patterns: ['src/application/**', 'src/use-cases/**'],
|
|
80
|
+
canImportFrom: ['domain'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'domain',
|
|
84
|
+
patterns: ['src/domain/**'],
|
|
85
|
+
canImportFrom: [],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
} satisfies DriftConfig
|
|
89
|
+
`,
|
|
90
|
+
monorepo: `import type { DriftConfig } from '@eduardbar/drift'
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
modules: [
|
|
94
|
+
{
|
|
95
|
+
name: 'shared',
|
|
96
|
+
root: 'packages/shared',
|
|
97
|
+
allowedExternalImports: [],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'api',
|
|
101
|
+
root: 'packages/api',
|
|
102
|
+
allowedExternalImports: ['@myorg/shared'],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'web',
|
|
106
|
+
root: 'packages/web',
|
|
107
|
+
allowedExternalImports: ['@myorg/shared'],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
} satisfies DriftConfig
|
|
111
|
+
`,
|
|
112
|
+
};
|
|
113
|
+
const GITHUB_WORKFLOW_TEMPLATE = `name: drift PR Review
|
|
114
|
+
|
|
115
|
+
on:
|
|
116
|
+
pull_request:
|
|
117
|
+
branches: [main, master, develop]
|
|
118
|
+
|
|
119
|
+
permissions:
|
|
120
|
+
contents: read
|
|
121
|
+
pull-requests: write
|
|
122
|
+
|
|
123
|
+
jobs:
|
|
124
|
+
drift-review:
|
|
125
|
+
runs-on: ubuntu-latest
|
|
126
|
+
steps:
|
|
127
|
+
- uses: actions/checkout@v4
|
|
128
|
+
with:
|
|
129
|
+
fetch-depth: 0
|
|
130
|
+
|
|
131
|
+
- uses: actions/setup-node@v4
|
|
132
|
+
with:
|
|
133
|
+
node-version: 18
|
|
134
|
+
|
|
135
|
+
- name: Install drift
|
|
136
|
+
run: npm install -g @eduardbar/drift
|
|
137
|
+
|
|
138
|
+
- name: Run drift review
|
|
139
|
+
id: drift
|
|
140
|
+
run: |
|
|
141
|
+
npx drift review --base origin/\${{ github.base_ref }} --comment > drift-comment.md
|
|
142
|
+
echo "score=$(cat drift-comment.md | grep 'Score:' | awk '{print $2}')" >> $GITHUB_OUTPUT
|
|
143
|
+
|
|
144
|
+
- name: Comment PR
|
|
145
|
+
uses: actions/github-script@v7
|
|
146
|
+
with:
|
|
147
|
+
script: |
|
|
148
|
+
const fs = require('fs')
|
|
149
|
+
const comment = fs.readFileSync('drift-comment.md', 'utf8')
|
|
150
|
+
|
|
151
|
+
const { data: comments } = await github.rest.issues.listComments({
|
|
152
|
+
owner: context.repo.owner,
|
|
153
|
+
repo: context.repo.repo,
|
|
154
|
+
issue_number: context.issue.number,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const botComment = comments.find(c =>
|
|
158
|
+
c.user?.type === 'Bot' && c.body?.includes('<!-- drift-review -->')
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const body = '<!-- drift-review -->\\n\\n' + comment
|
|
162
|
+
|
|
163
|
+
if (botComment) {
|
|
164
|
+
await github.rest.issues.updateComment({
|
|
165
|
+
owner: context.repo.owner,
|
|
166
|
+
repo: context.repo.repo,
|
|
167
|
+
comment_id: botComment.id,
|
|
168
|
+
body,
|
|
169
|
+
})
|
|
170
|
+
} else {
|
|
171
|
+
await github.rest.issues.createComment({
|
|
172
|
+
owner: context.repo.owner,
|
|
173
|
+
repo: context.repo.repo,
|
|
174
|
+
issue_number: context.issue.number,
|
|
175
|
+
body,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
`;
|
|
179
|
+
function mapScoreToBaselineGrade(score) {
|
|
180
|
+
const { label } = scoreToGrade(score);
|
|
181
|
+
if (label === 'clean')
|
|
182
|
+
return 'CLEAN';
|
|
183
|
+
if (label === 'low')
|
|
184
|
+
return 'LOW';
|
|
185
|
+
if (label === 'moderate')
|
|
186
|
+
return 'MEDIUM';
|
|
187
|
+
if (label === 'high')
|
|
188
|
+
return 'HIGH';
|
|
189
|
+
return 'CRITICAL';
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Initialize drift configuration with optional presets and scaffolding.
|
|
193
|
+
*
|
|
194
|
+
* @param projectRoot - Absolute path to project root
|
|
195
|
+
* @param options - Init options from CLI
|
|
196
|
+
*/
|
|
197
|
+
export async function runInit(projectRoot, options) {
|
|
198
|
+
const tasks = [];
|
|
199
|
+
maybeWritePresetConfig(projectRoot, options.preset, tasks);
|
|
200
|
+
maybeWriteCiWorkflow(projectRoot, options.ci, tasks);
|
|
201
|
+
await maybeWriteBaseline(projectRoot, options.baseline, tasks);
|
|
202
|
+
if (tasks.length === 0) {
|
|
203
|
+
process.stdout.write('\n No actions taken. Use --preset, --ci, or --baseline flags.\n\n');
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
process.stdout.write('\n drift init complete:\n\n');
|
|
207
|
+
for (const task of tasks) {
|
|
208
|
+
process.stdout.write(` ${task}\n`);
|
|
209
|
+
}
|
|
210
|
+
process.stdout.write('\n');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function isInitPreset(value) {
|
|
214
|
+
return INIT_PRESETS.includes(value);
|
|
215
|
+
}
|
|
216
|
+
function maybeWritePresetConfig(projectRoot, preset, tasks) {
|
|
217
|
+
if (!preset)
|
|
218
|
+
return;
|
|
219
|
+
if (!isInitPreset(preset)) {
|
|
220
|
+
throw new Error(`Invalid preset '${preset}'. Use one of: ${INIT_PRESETS.join(', ')}`);
|
|
221
|
+
}
|
|
222
|
+
const configPath = join(projectRoot, 'drift.config.ts');
|
|
223
|
+
if (existsSync(configPath)) {
|
|
224
|
+
process.stderr.write(` ⚠️ drift.config.ts already exists, skipping config generation\n`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
writeFileSync(configPath, generateConfigPreset(preset), 'utf8');
|
|
228
|
+
tasks.push('✅ Generated drift.config.ts');
|
|
229
|
+
}
|
|
230
|
+
function maybeWriteCiWorkflow(projectRoot, ci, tasks) {
|
|
231
|
+
if (!ci)
|
|
232
|
+
return;
|
|
233
|
+
const workflowDir = join(projectRoot, '.github', 'workflows');
|
|
234
|
+
const workflowPath = join(workflowDir, 'drift-review.yml');
|
|
235
|
+
if (existsSync(workflowPath)) {
|
|
236
|
+
process.stderr.write(` ⚠️ .github/workflows/drift-review.yml already exists, skipping workflow generation\n`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!existsSync(workflowDir)) {
|
|
240
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
writeFileSync(workflowPath, generateGitHubWorkflow(), 'utf8');
|
|
243
|
+
tasks.push('✅ Generated .github/workflows/drift-review.yml');
|
|
244
|
+
}
|
|
245
|
+
async function maybeWriteBaseline(projectRoot, baseline, tasks) {
|
|
246
|
+
if (!baseline)
|
|
247
|
+
return;
|
|
248
|
+
const baselinePath = join(projectRoot, 'drift-baseline.json');
|
|
249
|
+
if (existsSync(baselinePath)) {
|
|
250
|
+
process.stderr.write(` ⚠️ drift-baseline.json already exists, skipping baseline creation\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
process.stderr.write(' Scanning project to create baseline...\n');
|
|
254
|
+
const config = await loadConfig(projectRoot);
|
|
255
|
+
const files = analyzeProject(projectRoot, config);
|
|
256
|
+
const report = buildReport(projectRoot, files);
|
|
257
|
+
const baselineSnapshot = {
|
|
258
|
+
createdAt: new Date().toISOString(),
|
|
259
|
+
score: report.totalScore,
|
|
260
|
+
grade: mapScoreToBaselineGrade(report.totalScore),
|
|
261
|
+
totalIssues: report.totalIssues,
|
|
262
|
+
files: report.files.length,
|
|
263
|
+
};
|
|
264
|
+
writeFileSync(baselinePath, JSON.stringify(baselineSnapshot, null, 2), 'utf8');
|
|
265
|
+
tasks.push(`✅ Created drift-baseline.json (score: ${report.totalScore}/100, grade: ${baselineSnapshot.grade})`);
|
|
266
|
+
}
|
|
267
|
+
function generateConfigPreset(preset) {
|
|
268
|
+
return CONFIG_PRESET_CONTENT[preset];
|
|
269
|
+
}
|
|
270
|
+
function generateGitHubWorkflow() {
|
|
271
|
+
return GITHUB_WORKFLOW_TEMPLATE;
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function detectCycleEdges(adjacency) {
|
|
2
|
+
const visited = new Set();
|
|
3
|
+
const inStack = new Set();
|
|
4
|
+
const stack = [];
|
|
5
|
+
const cycleEdges = new Set();
|
|
6
|
+
function dfs(node) {
|
|
7
|
+
visited.add(node);
|
|
8
|
+
inStack.add(node);
|
|
9
|
+
stack.push(node);
|
|
10
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
11
|
+
if (!visited.has(neighbor)) {
|
|
12
|
+
dfs(neighbor);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (!inStack.has(neighbor))
|
|
16
|
+
continue;
|
|
17
|
+
const startIndex = stack.indexOf(neighbor);
|
|
18
|
+
if (startIndex >= 0) {
|
|
19
|
+
for (let i = startIndex; i < stack.length - 1; i++) {
|
|
20
|
+
cycleEdges.add(`${stack[i]}->${stack[i + 1]}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
cycleEdges.add(`${node}->${neighbor}`);
|
|
24
|
+
}
|
|
25
|
+
stack.pop();
|
|
26
|
+
inStack.delete(node);
|
|
27
|
+
}
|
|
28
|
+
for (const node of adjacency.keys()) {
|
|
29
|
+
if (!visited.has(node))
|
|
30
|
+
dfs(node);
|
|
31
|
+
}
|
|
32
|
+
return cycleEdges;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=map-cycles.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface LayerNode {
|
|
2
|
+
name: string;
|
|
3
|
+
files: Set<string>;
|
|
4
|
+
}
|
|
5
|
+
interface MapEdge {
|
|
6
|
+
key: string;
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
count: number;
|
|
10
|
+
kind: 'normal' | 'cycle' | 'violation';
|
|
11
|
+
}
|
|
12
|
+
export declare function renderArchitectureSvg(input: {
|
|
13
|
+
layers: Map<string, LayerNode>;
|
|
14
|
+
edgeList: MapEdge[];
|
|
15
|
+
cycleCount: number;
|
|
16
|
+
violationCount: number;
|
|
17
|
+
}): string;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=map-svg.d.ts.map
|
package/dist/map-svg.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
const NORMAL_EDGE_WIDTH = 2;
|
|
10
|
+
const HIGHLIGHT_EDGE_WIDTH = 3;
|
|
11
|
+
const EDGE_LABEL_Y_OFFSET = 4;
|
|
12
|
+
const NODE_TITLE_X_OFFSET = 12;
|
|
13
|
+
const NODE_TITLE_Y_OFFSET = 22;
|
|
14
|
+
const NODE_META_X_OFFSET = 12;
|
|
15
|
+
const NODE_META_Y_OFFSET = 38;
|
|
16
|
+
const LEGEND_CYCLE_START_X = 520;
|
|
17
|
+
const LEGEND_CYCLE_END_X = 560;
|
|
18
|
+
const LEGEND_CYCLE_LABEL_X = 567;
|
|
19
|
+
const LEGEND_VIOLATION_START_X = 630;
|
|
20
|
+
const LEGEND_VIOLATION_END_X = 670;
|
|
21
|
+
const LEGEND_VIOLATION_LABEL_X = 677;
|
|
22
|
+
const LEGEND_LINE_Y = 66;
|
|
23
|
+
const LEGEND_LABEL_Y = 69;
|
|
24
|
+
function esc(value) {
|
|
25
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
26
|
+
}
|
|
27
|
+
function buildSvgLayout(layers) {
|
|
28
|
+
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
29
|
+
const height = Math.max(MIN_CANVAS_HEIGHT, layerList.length * ROW_HEIGHT + BOTTOM_PADDING);
|
|
30
|
+
const boxes = layerList.map((layer, index) => ({
|
|
31
|
+
...layer,
|
|
32
|
+
x: BOX_LEFT,
|
|
33
|
+
y: BOX_TOP_OFFSET + index * ROW_HEIGHT,
|
|
34
|
+
}));
|
|
35
|
+
return {
|
|
36
|
+
width: SVG_WIDTH,
|
|
37
|
+
height,
|
|
38
|
+
boxes,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function edgeStroke(kind) {
|
|
42
|
+
if (kind === 'violation')
|
|
43
|
+
return '#ef4444';
|
|
44
|
+
if (kind === 'cycle')
|
|
45
|
+
return '#f59e0b';
|
|
46
|
+
return '#64748b';
|
|
47
|
+
}
|
|
48
|
+
function edgeStrokeWidth(kind) {
|
|
49
|
+
return kind === 'normal' ? NORMAL_EDGE_WIDTH : HIGHLIGHT_EDGE_WIDTH;
|
|
50
|
+
}
|
|
51
|
+
function renderEdges(edgeList, boxByName) {
|
|
52
|
+
return edgeList.map((edge) => {
|
|
53
|
+
const a = boxByName.get(edge.from);
|
|
54
|
+
const b = boxByName.get(edge.to);
|
|
55
|
+
if (!a || !b)
|
|
56
|
+
return '';
|
|
57
|
+
const startX = a.x + BOX_WIDTH;
|
|
58
|
+
const startY = a.y + BOX_HEIGHT / 2;
|
|
59
|
+
const endX = b.x;
|
|
60
|
+
const endY = b.y + BOX_HEIGHT / 2;
|
|
61
|
+
const stroke = edgeStroke(edge.kind);
|
|
62
|
+
const widthPx = edgeStrokeWidth(edge.kind);
|
|
63
|
+
return `
|
|
64
|
+
<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}" />
|
|
65
|
+
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - EDGE_LABEL_Y_OFFSET}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`;
|
|
66
|
+
}).join('');
|
|
67
|
+
}
|
|
68
|
+
function renderNodes(boxes) {
|
|
69
|
+
return boxes.map((box) => `
|
|
70
|
+
<g>
|
|
71
|
+
<rect x="${box.x}" y="${box.y}" width="${BOX_WIDTH}" height="${BOX_HEIGHT}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
72
|
+
<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>
|
|
73
|
+
<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>
|
|
74
|
+
</g>`).join('');
|
|
75
|
+
}
|
|
76
|
+
export function renderArchitectureSvg(input) {
|
|
77
|
+
const { width, height, boxes } = buildSvgLayout(input.layers);
|
|
78
|
+
const boxByName = new Map(boxes.map((box) => [box.name, box]));
|
|
79
|
+
const lines = renderEdges(input.edgeList, boxByName);
|
|
80
|
+
const nodes = renderNodes(boxes);
|
|
81
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
82
|
+
<defs>
|
|
83
|
+
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
84
|
+
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
85
|
+
</marker>
|
|
86
|
+
</defs>
|
|
87
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
88
|
+
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
89
|
+
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
90
|
+
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${input.cycleCount} | Layer violations: ${input.violationCount}</text>
|
|
91
|
+
<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>
|
|
92
|
+
<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>
|
|
93
|
+
${lines}
|
|
94
|
+
${nodes}
|
|
95
|
+
</svg>`;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=map-svg.js.map
|
package/dist/map.js
CHANGED
|
@@ -3,15 +3,54 @@ import { resolve, relative } from 'node:path';
|
|
|
3
3
|
import { Project } from 'ts-morph';
|
|
4
4
|
import { detectLayerViolations } from './rules/phase3-arch.js';
|
|
5
5
|
import { RULE_WEIGHTS } from './analyzer.js';
|
|
6
|
+
import { detectCycleEdges } from './map-cycles.js';
|
|
7
|
+
import { renderArchitectureSvg } from './map-svg.js';
|
|
6
8
|
function detectLayer(relPath) {
|
|
7
9
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
8
10
|
const first = normalized.split('/')[0] || 'root';
|
|
9
11
|
return first;
|
|
10
12
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
+
function appendFileLayerContext(filePath, targetPath, layers, fileImportGraph) {
|
|
14
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/');
|
|
15
|
+
const layerName = detectLayer(rel);
|
|
16
|
+
if (!layers.has(layerName)) {
|
|
17
|
+
layers.set(layerName, { name: layerName, files: new Set() });
|
|
18
|
+
}
|
|
19
|
+
layers.get(layerName).files.add(rel);
|
|
20
|
+
if (!fileImportGraph.has(filePath)) {
|
|
21
|
+
fileImportGraph.set(filePath, new Set());
|
|
22
|
+
}
|
|
23
|
+
return { rel, layerName };
|
|
13
24
|
}
|
|
14
|
-
|
|
25
|
+
function registerImportEdge(layerName, importedLayer, edges, layerAdjacency) {
|
|
26
|
+
if (importedLayer === layerName)
|
|
27
|
+
return;
|
|
28
|
+
const key = `${layerName}->${importedLayer}`;
|
|
29
|
+
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
30
|
+
if (!layerAdjacency.has(layerName)) {
|
|
31
|
+
layerAdjacency.set(layerName, new Set());
|
|
32
|
+
}
|
|
33
|
+
layerAdjacency.get(layerName).add(importedLayer);
|
|
34
|
+
}
|
|
35
|
+
function buildEdgeList(edges, cycleEdges, violationEdges) {
|
|
36
|
+
const edgeList = [...edges.entries()].map(([key, count]) => {
|
|
37
|
+
const [from, to] = key.split('->');
|
|
38
|
+
const kind = violationEdges.has(key)
|
|
39
|
+
? 'violation'
|
|
40
|
+
: cycleEdges.has(key)
|
|
41
|
+
? 'cycle'
|
|
42
|
+
: 'normal';
|
|
43
|
+
return { key, from, to, count, kind };
|
|
44
|
+
});
|
|
45
|
+
for (const key of violationEdges) {
|
|
46
|
+
if (edges.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
const [from, to] = key.split('->');
|
|
49
|
+
edgeList.push({ key, from, to, count: 1, kind: 'violation' });
|
|
50
|
+
}
|
|
51
|
+
return edgeList;
|
|
52
|
+
}
|
|
53
|
+
function createArchitectureProject(targetPath) {
|
|
15
54
|
const project = new Project({
|
|
16
55
|
skipAddingFilesFromTsConfig: true,
|
|
17
56
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -26,19 +65,16 @@ export function generateArchitectureSvg(targetPath, config) {
|
|
|
26
65
|
`!${targetPath}/**/.next/**`,
|
|
27
66
|
`!${targetPath}/**/*.d.ts`,
|
|
28
67
|
]);
|
|
68
|
+
return project;
|
|
69
|
+
}
|
|
70
|
+
function collectArchitectureGraph(project, targetPath) {
|
|
29
71
|
const layers = new Map();
|
|
30
72
|
const edges = new Map();
|
|
31
73
|
const layerAdjacency = new Map();
|
|
32
74
|
const fileImportGraph = new Map();
|
|
33
75
|
for (const file of project.getSourceFiles()) {
|
|
34
76
|
const filePath = file.getFilePath();
|
|
35
|
-
const
|
|
36
|
-
const layerName = detectLayer(rel);
|
|
37
|
-
if (!layers.has(layerName))
|
|
38
|
-
layers.set(layerName, { name: layerName, files: new Set() });
|
|
39
|
-
layers.get(layerName).files.add(rel);
|
|
40
|
-
if (!fileImportGraph.has(filePath))
|
|
41
|
-
fileImportGraph.set(filePath, new Set());
|
|
77
|
+
const { layerName } = appendFileLayerContext(filePath, targetPath, layers, fileImportGraph);
|
|
42
78
|
for (const decl of file.getImportDeclarations()) {
|
|
43
79
|
const imported = decl.getModuleSpecifierSourceFile();
|
|
44
80
|
if (!imported)
|
|
@@ -46,140 +82,44 @@ export function generateArchitectureSvg(targetPath, config) {
|
|
|
46
82
|
fileImportGraph.get(filePath).add(imported.getFilePath());
|
|
47
83
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/');
|
|
48
84
|
const importedLayer = detectLayer(importedRel);
|
|
49
|
-
|
|
50
|
-
continue;
|
|
51
|
-
const key = `${layerName}->${importedLayer}`;
|
|
52
|
-
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
53
|
-
if (!layerAdjacency.has(layerName))
|
|
54
|
-
layerAdjacency.set(layerName, new Set());
|
|
55
|
-
layerAdjacency.get(layerName).add(importedLayer);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const cycleEdges = detectCycleEdges(layerAdjacency);
|
|
59
|
-
const violationEdges = new Set();
|
|
60
|
-
if (config?.layers && config.layers.length > 0) {
|
|
61
|
-
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS);
|
|
62
|
-
for (const issues of violations.values()) {
|
|
63
|
-
for (const issue of issues) {
|
|
64
|
-
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/);
|
|
65
|
-
if (!match)
|
|
66
|
-
continue;
|
|
67
|
-
const from = match[1];
|
|
68
|
-
const to = match[2];
|
|
69
|
-
violationEdges.add(`${from}->${to}`);
|
|
70
|
-
if (!layers.has(from))
|
|
71
|
-
layers.set(from, { name: from, files: new Set() });
|
|
72
|
-
if (!layers.has(to))
|
|
73
|
-
layers.set(to, { name: to, files: new Set() });
|
|
74
|
-
}
|
|
85
|
+
registerImportEdge(layerName, importedLayer, edges, layerAdjacency);
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
|
-
|
|
78
|
-
const [from, to] = key.split('->');
|
|
79
|
-
const kind = violationEdges.has(key)
|
|
80
|
-
? 'violation'
|
|
81
|
-
: cycleEdges.has(key)
|
|
82
|
-
? 'cycle'
|
|
83
|
-
: 'normal';
|
|
84
|
-
return { key, from, to, count, kind };
|
|
85
|
-
});
|
|
86
|
-
for (const key of violationEdges) {
|
|
87
|
-
if (edges.has(key))
|
|
88
|
-
continue;
|
|
89
|
-
const [from, to] = key.split('->');
|
|
90
|
-
edgeList.push({ key, from, to, count: 1, kind: 'violation' });
|
|
91
|
-
}
|
|
92
|
-
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
93
|
-
const width = 960;
|
|
94
|
-
const rowHeight = 90;
|
|
95
|
-
const height = Math.max(180, layerList.length * rowHeight + 120);
|
|
96
|
-
const boxWidth = 240;
|
|
97
|
-
const boxHeight = 50;
|
|
98
|
-
const left = 100;
|
|
99
|
-
const boxes = layerList.map((layer, index) => {
|
|
100
|
-
const y = 60 + index * rowHeight;
|
|
101
|
-
return {
|
|
102
|
-
...layer,
|
|
103
|
-
x: left,
|
|
104
|
-
y,
|
|
105
|
-
};
|
|
106
|
-
});
|
|
107
|
-
const boxByName = new Map(boxes.map((box) => [box.name, box]));
|
|
108
|
-
const lines = edgeList.map((edge) => {
|
|
109
|
-
const a = boxByName.get(edge.from);
|
|
110
|
-
const b = boxByName.get(edge.to);
|
|
111
|
-
if (!a || !b)
|
|
112
|
-
return '';
|
|
113
|
-
const startX = a.x + boxWidth;
|
|
114
|
-
const startY = a.y + boxHeight / 2;
|
|
115
|
-
const endX = b.x;
|
|
116
|
-
const endY = b.y + boxHeight / 2;
|
|
117
|
-
const stroke = edge.kind === 'violation'
|
|
118
|
-
? '#ef4444'
|
|
119
|
-
: edge.kind === 'cycle'
|
|
120
|
-
? '#f59e0b'
|
|
121
|
-
: '#64748b';
|
|
122
|
-
const widthPx = edge.kind === 'normal' ? 2 : 3;
|
|
123
|
-
return `
|
|
124
|
-
<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}" />
|
|
125
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`;
|
|
126
|
-
}).join('');
|
|
127
|
-
const nodes = boxes.map((box) => `
|
|
128
|
-
<g>
|
|
129
|
-
<rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
130
|
-
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
131
|
-
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
132
|
-
</g>`).join('');
|
|
133
|
-
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length;
|
|
134
|
-
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length;
|
|
135
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
136
|
-
<defs>
|
|
137
|
-
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
138
|
-
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
139
|
-
</marker>
|
|
140
|
-
</defs>
|
|
141
|
-
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
142
|
-
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
143
|
-
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
144
|
-
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
|
|
145
|
-
<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>
|
|
146
|
-
<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>
|
|
147
|
-
${lines}
|
|
148
|
-
${nodes}
|
|
149
|
-
</svg>`;
|
|
88
|
+
return { layers, edges, layerAdjacency, fileImportGraph };
|
|
150
89
|
}
|
|
151
|
-
function
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
for (const neighbor of adjacency.get(node) ?? []) {
|
|
161
|
-
if (!visited.has(neighbor)) {
|
|
162
|
-
dfs(neighbor);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (!inStack.has(neighbor))
|
|
90
|
+
function collectViolationEdges(config, fileImportGraph, targetPath, layers) {
|
|
91
|
+
const violationEdges = new Set();
|
|
92
|
+
if (!config?.layers?.length)
|
|
93
|
+
return violationEdges;
|
|
94
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS);
|
|
95
|
+
for (const issues of violations.values()) {
|
|
96
|
+
for (const issue of issues) {
|
|
97
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/);
|
|
98
|
+
if (!match)
|
|
166
99
|
continue;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
100
|
+
const from = match[1];
|
|
101
|
+
const to = match[2];
|
|
102
|
+
violationEdges.add(`${from}->${to}`);
|
|
103
|
+
if (!layers.has(from))
|
|
104
|
+
layers.set(from, { name: from, files: new Set() });
|
|
105
|
+
if (!layers.has(to))
|
|
106
|
+
layers.set(to, { name: to, files: new Set() });
|
|
174
107
|
}
|
|
175
|
-
stack.pop();
|
|
176
|
-
inStack.delete(node);
|
|
177
|
-
}
|
|
178
|
-
for (const node of adjacency.keys()) {
|
|
179
|
-
if (!visited.has(node))
|
|
180
|
-
dfs(node);
|
|
181
108
|
}
|
|
182
|
-
return
|
|
109
|
+
return violationEdges;
|
|
110
|
+
}
|
|
111
|
+
export function generateArchitectureSvg(targetPath, config) {
|
|
112
|
+
const project = createArchitectureProject(targetPath);
|
|
113
|
+
const { layers, edges, layerAdjacency, fileImportGraph } = collectArchitectureGraph(project, targetPath);
|
|
114
|
+
const cycleEdges = detectCycleEdges(layerAdjacency);
|
|
115
|
+
const violationEdges = collectViolationEdges(config, fileImportGraph, targetPath, layers);
|
|
116
|
+
const edgeList = buildEdgeList(edges, cycleEdges, violationEdges);
|
|
117
|
+
return renderArchitectureSvg({
|
|
118
|
+
layers,
|
|
119
|
+
edgeList,
|
|
120
|
+
cycleCount: edgeList.filter((edge) => edge.kind === 'cycle').length,
|
|
121
|
+
violationCount: edgeList.filter((edge) => edge.kind === 'violation').length,
|
|
122
|
+
});
|
|
183
123
|
}
|
|
184
124
|
export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg', config) {
|
|
185
125
|
const resolvedTarget = resolve(targetPath);
|