@eduardbar/drift 1.1.0 → 1.2.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/review-pr.yml +61 -0
- package/README.md +39 -1
- package/dist/cli.js +97 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/types.d.ts +6 -0
- package/docs/PRD.md +125 -176
- package/package.json +1 -1
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/cli.ts +112 -3
- package/src/index.ts +15 -0
- package/src/map.ts +112 -10
- package/src/saas.ts +433 -0
- package/src/types.ts +6 -0
- package/tests/new-features.test.ts +27 -0
- package/tests/saas-foundation.test.ts +107 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Drift PR Review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize, reopened]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
drift-review:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- name: Setup Node.js
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: '20'
|
|
24
|
+
cache: 'npm'
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm ci
|
|
28
|
+
|
|
29
|
+
- name: Generate drift review markdown
|
|
30
|
+
run: npx @eduardbar/drift review --base "origin/${{ github.base_ref }}" --comment > drift-review.md
|
|
31
|
+
|
|
32
|
+
- name: Post or update PR comment (non-fork only)
|
|
33
|
+
if: github.event.pull_request.head.repo.fork == false
|
|
34
|
+
env:
|
|
35
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
36
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
37
|
+
REPO: ${{ github.repository }}
|
|
38
|
+
run: |
|
|
39
|
+
COMMENT_BODY="<!-- drift-review -->"
|
|
40
|
+
COMMENT_BODY+=$'\n'
|
|
41
|
+
COMMENT_BODY+="$(cat drift-review.md)"
|
|
42
|
+
|
|
43
|
+
EXISTING_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("<!-- drift-review -->")) | .id' | sed -n '1p')
|
|
44
|
+
|
|
45
|
+
if [ -n "$EXISTING_ID" ]; then
|
|
46
|
+
gh api -X PATCH "repos/$REPO/issues/comments/$EXISTING_ID" -f "body=$COMMENT_BODY"
|
|
47
|
+
else
|
|
48
|
+
gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f "body=$COMMENT_BODY"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
- name: Fallback summary for fork PRs
|
|
52
|
+
if: github.event.pull_request.head.repo.fork == true
|
|
53
|
+
run: |
|
|
54
|
+
{
|
|
55
|
+
echo "## drift review"
|
|
56
|
+
echo
|
|
57
|
+
cat drift-review.md
|
|
58
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
59
|
+
|
|
60
|
+
- name: Enforce drift threshold
|
|
61
|
+
run: npx @eduardbar/drift review --base "origin/${{ github.base_ref }}" --fail-on 5 --comment > /dev/null
|
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ drift review --base origin/main --fail-on 5
|
|
|
150
150
|
|
|
151
151
|
### `drift map [path]`
|
|
152
152
|
|
|
153
|
-
Generate an `architecture.svg` map with inferred layer dependencies.
|
|
153
|
+
Generate an `architecture.svg` map with inferred layer dependencies. When layer config is present, the SVG also highlights cycle edges and layer violations.
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
156
|
drift map
|
|
@@ -162,6 +162,11 @@ drift map ./src --output docs/architecture.svg
|
|
|
162
162
|
|------|-------------|
|
|
163
163
|
| `--output <file>` | Output path for the SVG file (default: `architecture.svg`) |
|
|
164
164
|
|
|
165
|
+
Edge legend in SVG:
|
|
166
|
+
- Gray: normal dependency
|
|
167
|
+
- Orange: cycle edge
|
|
168
|
+
- Red: layer violation edge
|
|
169
|
+
|
|
165
170
|
---
|
|
166
171
|
|
|
167
172
|
### `drift report [path]`
|
|
@@ -270,6 +275,7 @@ Auto-fix safe issues with explicit preview/write modes.
|
|
|
270
275
|
```bash
|
|
271
276
|
drift fix ./src --preview
|
|
272
277
|
drift fix ./src --write
|
|
278
|
+
drift fix ./src --write --yes
|
|
273
279
|
drift fix ./src --dry-run # alias of --preview
|
|
274
280
|
```
|
|
275
281
|
|
|
@@ -278,6 +284,30 @@ drift fix ./src --dry-run # alias of --preview
|
|
|
278
284
|
| `--preview` | Preview before/after without writing files |
|
|
279
285
|
| `--write` | Apply fixes to disk |
|
|
280
286
|
| `--dry-run` | Backward-compatible alias for preview mode |
|
|
287
|
+
| `--yes` | Skip interactive confirmation for write mode |
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### `drift cloud`
|
|
292
|
+
|
|
293
|
+
Local SaaS foundations backed by `.drift-cloud/store.json`.
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
drift cloud ingest ./src --workspace acme --user u-123 --repo webapp
|
|
297
|
+
drift cloud summary
|
|
298
|
+
drift cloud summary --json
|
|
299
|
+
drift cloud dashboard --output drift-cloud-dashboard.html
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Subcommands:**
|
|
303
|
+
|
|
304
|
+
| Command | Description |
|
|
305
|
+
|---------|-------------|
|
|
306
|
+
| `drift cloud ingest [path] --workspace <id> --user <id> [--repo <name>] [--store <file>]` | Scans the path and stores one SaaS snapshot |
|
|
307
|
+
| `drift cloud summary [--json] [--store <file>]` | Shows users/workspaces/repos usage and runs per month |
|
|
308
|
+
| `drift cloud dashboard [--output <file>] [--store <file>]` | Generates an HTML dashboard with trends and hotspots |
|
|
309
|
+
|
|
310
|
+
`drift cloud` ships with a free-until-7,500 strategy and configurable guardrails for the free phase: max runs per workspace per month, max repos per workspace, and retention window.
|
|
281
311
|
|
|
282
312
|
---
|
|
283
313
|
|
|
@@ -412,6 +442,14 @@ jobs:
|
|
|
412
442
|
|
|
413
443
|
`drift ci` emits `::error` and `::warning` annotations that appear inline in the PR diff and writes a formatted summary to `$GITHUB_STEP_SUMMARY`. Use this when you want visibility beyond a pass/fail exit code.
|
|
414
444
|
|
|
445
|
+
### Auto PR comment with `drift review`
|
|
446
|
+
|
|
447
|
+
The repository includes `.github/workflows/review-pr.yml`, which:
|
|
448
|
+
- generates a PR-ready markdown comment from `drift review --comment`
|
|
449
|
+
- updates a single sticky comment (`<!-- drift-review -->`) on non-fork PRs
|
|
450
|
+
- falls back to `$GITHUB_STEP_SUMMARY` for fork PRs
|
|
451
|
+
- enforces a score delta threshold with `--fail-on`
|
|
452
|
+
|
|
415
453
|
---
|
|
416
454
|
|
|
417
455
|
## drift-ignore
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// drift-ignore-file
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { writeFileSync } from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
5
|
+
import { basename, resolve } from 'node:path';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
+
import { createInterface } from 'node:readline/promises';
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
7
9
|
const require = createRequire(import.meta.url);
|
|
8
10
|
const { version: VERSION } = require('../package.json');
|
|
9
11
|
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
|
|
@@ -19,6 +21,7 @@ import { applyFixes } from './fix.js';
|
|
|
19
21
|
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
|
|
20
22
|
import { generateReview } from './review.js';
|
|
21
23
|
import { generateArchitectureMap } from './map.js';
|
|
24
|
+
import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js';
|
|
22
25
|
const program = new Command();
|
|
23
26
|
program
|
|
24
27
|
.name('drift')
|
|
@@ -140,7 +143,8 @@ program
|
|
|
140
143
|
.action(async (targetPath, options) => {
|
|
141
144
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
142
145
|
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`);
|
|
143
|
-
const
|
|
146
|
+
const config = await loadConfig(resolvedPath);
|
|
147
|
+
const out = generateArchitectureMap(resolvedPath, options.output, config);
|
|
144
148
|
process.stderr.write(` Architecture map saved to ${out}\n\n`);
|
|
145
149
|
});
|
|
146
150
|
program
|
|
@@ -232,11 +236,33 @@ program
|
|
|
232
236
|
.option('--preview', 'Preview changes without writing files')
|
|
233
237
|
.option('--write', 'Write fixes to disk')
|
|
234
238
|
.option('--dry-run', 'Show what would change without writing files')
|
|
239
|
+
.option('-y, --yes', 'Skip interactive confirmation for --write')
|
|
235
240
|
.action(async (targetPath, options) => {
|
|
236
241
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
237
242
|
const config = await loadConfig(resolvedPath);
|
|
238
243
|
const previewMode = Boolean(options.preview || options.dryRun);
|
|
239
244
|
const writeMode = options.write ?? !previewMode;
|
|
245
|
+
if (writeMode && !options.yes) {
|
|
246
|
+
const previewResults = await applyFixes(resolvedPath, config, {
|
|
247
|
+
rule: options.rule,
|
|
248
|
+
dryRun: true,
|
|
249
|
+
preview: true,
|
|
250
|
+
write: false,
|
|
251
|
+
});
|
|
252
|
+
if (previewResults.length === 0) {
|
|
253
|
+
console.log('No fixable issues found.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const files = new Set(previewResults.map((result) => result.file)).size;
|
|
257
|
+
const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `;
|
|
258
|
+
const rl = createInterface({ input, output });
|
|
259
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase();
|
|
260
|
+
rl.close();
|
|
261
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
262
|
+
console.log('Aborted. No files were modified.');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
240
266
|
const results = await applyFixes(resolvedPath, config, {
|
|
241
267
|
rule: options.rule,
|
|
242
268
|
dryRun: previewMode,
|
|
@@ -305,5 +331,74 @@ program
|
|
|
305
331
|
process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
|
|
306
332
|
process.stdout.write(` Saved to drift-history.json\n\n`);
|
|
307
333
|
});
|
|
334
|
+
const cloud = program
|
|
335
|
+
.command('cloud')
|
|
336
|
+
.description('Local SaaS foundations: ingest, summary, and dashboard');
|
|
337
|
+
cloud
|
|
338
|
+
.command('ingest [path]')
|
|
339
|
+
.description('Scan path, build report, and store cloud snapshot')
|
|
340
|
+
.requiredOption('--workspace <id>', 'Workspace id')
|
|
341
|
+
.requiredOption('--user <id>', 'User id')
|
|
342
|
+
.option('--repo <name>', 'Repo name (default: basename of scanned path)')
|
|
343
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
344
|
+
.action(async (targetPath, options) => {
|
|
345
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
346
|
+
process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
|
|
347
|
+
const config = await loadConfig(resolvedPath);
|
|
348
|
+
const files = analyzeProject(resolvedPath, config);
|
|
349
|
+
const report = buildReport(resolvedPath, files);
|
|
350
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
351
|
+
workspaceId: options.workspace,
|
|
352
|
+
userId: options.user,
|
|
353
|
+
repoName: options.repo ?? basename(resolvedPath),
|
|
354
|
+
storeFile: options.store,
|
|
355
|
+
policy: config?.saas,
|
|
356
|
+
});
|
|
357
|
+
process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
|
|
358
|
+
process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
|
|
359
|
+
process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
|
|
360
|
+
});
|
|
361
|
+
cloud
|
|
362
|
+
.command('summary')
|
|
363
|
+
.description('Show SaaS usage metrics and free threshold status')
|
|
364
|
+
.option('--json', 'Output raw JSON summary')
|
|
365
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
366
|
+
.action((options) => {
|
|
367
|
+
const summary = getSaasSummary({ storeFile: options.store });
|
|
368
|
+
if (options.json) {
|
|
369
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
process.stdout.write('\n');
|
|
373
|
+
process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
|
|
374
|
+
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
|
|
375
|
+
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
|
|
376
|
+
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
|
|
377
|
+
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
|
|
378
|
+
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
|
|
379
|
+
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
|
|
380
|
+
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
|
|
381
|
+
process.stdout.write('Runs per month:\n');
|
|
382
|
+
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
|
|
383
|
+
if (monthly.length === 0) {
|
|
384
|
+
process.stdout.write(' - none\n\n');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const [month, runs] of monthly) {
|
|
388
|
+
process.stdout.write(` - ${month}: ${runs}\n`);
|
|
389
|
+
}
|
|
390
|
+
process.stdout.write('\n');
|
|
391
|
+
});
|
|
392
|
+
cloud
|
|
393
|
+
.command('dashboard')
|
|
394
|
+
.description('Generate an HTML dashboard with trends and hotspots')
|
|
395
|
+
.option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
|
|
396
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
397
|
+
.action((options) => {
|
|
398
|
+
const html = generateSaasDashboardHtml({ storeFile: options.store });
|
|
399
|
+
const outPath = resolve(options.output);
|
|
400
|
+
writeFileSync(outPath, html, 'utf8');
|
|
401
|
+
process.stdout.write(`Dashboard saved to ${outPath}\n`);
|
|
402
|
+
});
|
|
308
403
|
program.parse();
|
|
309
404
|
//# sourceMappingURL=cli.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,6 @@ export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
|
|
|
6
6
|
export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig, RepoQualityScore, MaintenanceRiskMetrics, DriftPlugin, DriftPluginRule, } from './types.js';
|
|
7
7
|
export { loadHistory, saveSnapshot } from './snapshot.js';
|
|
8
8
|
export type { SnapshotEntry, SnapshotHistory } from './snapshot.js';
|
|
9
|
+
export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
|
|
10
|
+
export type { SaasPolicy, SaasStore, SaasSummary, SaasSnapshot, IngestOptions, } from './saas.js';
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { computeDiff } from './diff.js';
|
|
|
4
4
|
export { generateReview, formatReviewMarkdown } from './review.js';
|
|
5
5
|
export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
|
|
6
6
|
export { loadHistory, saveSnapshot } from './snapshot.js';
|
|
7
|
+
export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
|
|
7
8
|
//# sourceMappingURL=index.js.map
|
package/dist/map.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function
|
|
1
|
+
import type { DriftConfig } from './types.js';
|
|
2
|
+
export declare function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string;
|
|
3
|
+
export declare function generateArchitectureMap(targetPath: string, outputFile?: string, config?: DriftConfig): string;
|
|
3
4
|
//# sourceMappingURL=map.d.ts.map
|
package/dist/map.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { resolve, relative } from 'node:path';
|
|
3
3
|
import { Project } from 'ts-morph';
|
|
4
|
+
import { detectLayerViolations } from './rules/phase3-arch.js';
|
|
5
|
+
import { RULE_WEIGHTS } from './analyzer.js';
|
|
4
6
|
function detectLayer(relPath) {
|
|
5
7
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
6
8
|
const first = normalized.split('/')[0] || 'root';
|
|
@@ -9,7 +11,7 @@ function detectLayer(relPath) {
|
|
|
9
11
|
function esc(value) {
|
|
10
12
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
13
|
}
|
|
12
|
-
export function generateArchitectureSvg(targetPath) {
|
|
14
|
+
export function generateArchitectureSvg(targetPath, config) {
|
|
13
15
|
const project = new Project({
|
|
14
16
|
skipAddingFilesFromTsConfig: true,
|
|
15
17
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -26,24 +28,67 @@ export function generateArchitectureSvg(targetPath) {
|
|
|
26
28
|
]);
|
|
27
29
|
const layers = new Map();
|
|
28
30
|
const edges = new Map();
|
|
31
|
+
const layerAdjacency = new Map();
|
|
32
|
+
const fileImportGraph = new Map();
|
|
29
33
|
for (const file of project.getSourceFiles()) {
|
|
30
|
-
const
|
|
34
|
+
const filePath = file.getFilePath();
|
|
35
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/');
|
|
31
36
|
const layerName = detectLayer(rel);
|
|
32
37
|
if (!layers.has(layerName))
|
|
33
38
|
layers.set(layerName, { name: layerName, files: new Set() });
|
|
34
39
|
layers.get(layerName).files.add(rel);
|
|
40
|
+
if (!fileImportGraph.has(filePath))
|
|
41
|
+
fileImportGraph.set(filePath, new Set());
|
|
35
42
|
for (const decl of file.getImportDeclarations()) {
|
|
36
43
|
const imported = decl.getModuleSpecifierSourceFile();
|
|
37
44
|
if (!imported)
|
|
38
45
|
continue;
|
|
46
|
+
fileImportGraph.get(filePath).add(imported.getFilePath());
|
|
39
47
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/');
|
|
40
48
|
const importedLayer = detectLayer(importedRel);
|
|
41
49
|
if (importedLayer === layerName)
|
|
42
50
|
continue;
|
|
43
51
|
const key = `${layerName}->${importedLayer}`;
|
|
44
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);
|
|
45
56
|
}
|
|
46
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
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const edgeList = [...edges.entries()].map(([key, count]) => {
|
|
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
|
+
}
|
|
47
92
|
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
48
93
|
const width = 960;
|
|
49
94
|
const rowHeight = 90;
|
|
@@ -60,19 +105,24 @@ export function generateArchitectureSvg(targetPath) {
|
|
|
60
105
|
};
|
|
61
106
|
});
|
|
62
107
|
const boxByName = new Map(boxes.map((box) => [box.name, box]));
|
|
63
|
-
const lines =
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const b = boxByName.get(to);
|
|
108
|
+
const lines = edgeList.map((edge) => {
|
|
109
|
+
const a = boxByName.get(edge.from);
|
|
110
|
+
const b = boxByName.get(edge.to);
|
|
67
111
|
if (!a || !b)
|
|
68
112
|
return '';
|
|
69
113
|
const startX = a.x + boxWidth;
|
|
70
114
|
const startY = a.y + boxHeight / 2;
|
|
71
115
|
const endX = b.x;
|
|
72
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;
|
|
73
123
|
return `
|
|
74
|
-
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="
|
|
75
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`;
|
|
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>`;
|
|
76
126
|
}).join('');
|
|
77
127
|
const nodes = boxes.map((box) => `
|
|
78
128
|
<g>
|
|
@@ -80,6 +130,8 @@ export function generateArchitectureSvg(targetPath) {
|
|
|
80
130
|
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
81
131
|
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
82
132
|
</g>`).join('');
|
|
133
|
+
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length;
|
|
134
|
+
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length;
|
|
83
135
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
84
136
|
<defs>
|
|
85
137
|
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
@@ -89,13 +141,49 @@ export function generateArchitectureSvg(targetPath) {
|
|
|
89
141
|
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
90
142
|
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
91
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>
|
|
92
147
|
${lines}
|
|
93
148
|
${nodes}
|
|
94
149
|
</svg>`;
|
|
95
150
|
}
|
|
96
|
-
|
|
151
|
+
function detectCycleEdges(adjacency) {
|
|
152
|
+
const visited = new Set();
|
|
153
|
+
const inStack = new Set();
|
|
154
|
+
const stack = [];
|
|
155
|
+
const cycleEdges = new Set();
|
|
156
|
+
function dfs(node) {
|
|
157
|
+
visited.add(node);
|
|
158
|
+
inStack.add(node);
|
|
159
|
+
stack.push(node);
|
|
160
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
161
|
+
if (!visited.has(neighbor)) {
|
|
162
|
+
dfs(neighbor);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!inStack.has(neighbor))
|
|
166
|
+
continue;
|
|
167
|
+
const startIndex = stack.indexOf(neighbor);
|
|
168
|
+
if (startIndex >= 0) {
|
|
169
|
+
for (let i = startIndex; i < stack.length - 1; i++) {
|
|
170
|
+
cycleEdges.add(`${stack[i]}->${stack[i + 1]}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
cycleEdges.add(`${node}->${neighbor}`);
|
|
174
|
+
}
|
|
175
|
+
stack.pop();
|
|
176
|
+
inStack.delete(node);
|
|
177
|
+
}
|
|
178
|
+
for (const node of adjacency.keys()) {
|
|
179
|
+
if (!visited.has(node))
|
|
180
|
+
dfs(node);
|
|
181
|
+
}
|
|
182
|
+
return cycleEdges;
|
|
183
|
+
}
|
|
184
|
+
export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg', config) {
|
|
97
185
|
const resolvedTarget = resolve(targetPath);
|
|
98
|
-
const svg = generateArchitectureSvg(resolvedTarget);
|
|
186
|
+
const svg = generateArchitectureSvg(resolvedTarget, config);
|
|
99
187
|
const outPath = resolve(outputFile);
|
|
100
188
|
writeFileSync(outPath, svg, 'utf8');
|
|
101
189
|
return outPath;
|
package/dist/saas.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { DriftReport, DriftConfig } from './types.js';
|
|
2
|
+
export interface SaasPolicy {
|
|
3
|
+
freeUserThreshold: number;
|
|
4
|
+
maxRunsPerWorkspacePerMonth: number;
|
|
5
|
+
maxReposPerWorkspace: number;
|
|
6
|
+
retentionDays: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SaasUser {
|
|
9
|
+
id: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
lastSeenAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SaasWorkspace {
|
|
14
|
+
id: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
lastSeenAt: string;
|
|
17
|
+
userIds: string[];
|
|
18
|
+
repoIds: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface SaasRepo {
|
|
21
|
+
id: string;
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
lastSeenAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SaasSnapshot {
|
|
28
|
+
id: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
scannedAt: string;
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
repoId: string;
|
|
34
|
+
repoName: string;
|
|
35
|
+
targetPath: string;
|
|
36
|
+
totalScore: number;
|
|
37
|
+
totalIssues: number;
|
|
38
|
+
totalFiles: number;
|
|
39
|
+
summary: {
|
|
40
|
+
errors: number;
|
|
41
|
+
warnings: number;
|
|
42
|
+
infos: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface SaasStore {
|
|
46
|
+
version: number;
|
|
47
|
+
policy: SaasPolicy;
|
|
48
|
+
users: Record<string, SaasUser>;
|
|
49
|
+
workspaces: Record<string, SaasWorkspace>;
|
|
50
|
+
repos: Record<string, SaasRepo>;
|
|
51
|
+
snapshots: SaasSnapshot[];
|
|
52
|
+
}
|
|
53
|
+
export interface SaasSummary {
|
|
54
|
+
policy: SaasPolicy;
|
|
55
|
+
usersRegistered: number;
|
|
56
|
+
workspacesActive: number;
|
|
57
|
+
reposActive: number;
|
|
58
|
+
runsPerMonth: Record<string, number>;
|
|
59
|
+
totalSnapshots: number;
|
|
60
|
+
phase: 'free' | 'paid';
|
|
61
|
+
thresholdReached: boolean;
|
|
62
|
+
freeUsersRemaining: number;
|
|
63
|
+
}
|
|
64
|
+
export interface IngestOptions {
|
|
65
|
+
workspaceId: string;
|
|
66
|
+
userId: string;
|
|
67
|
+
repoName?: string;
|
|
68
|
+
storeFile?: string;
|
|
69
|
+
policy?: Partial<SaasPolicy>;
|
|
70
|
+
}
|
|
71
|
+
export declare const DEFAULT_SAAS_POLICY: SaasPolicy;
|
|
72
|
+
export declare function resolveSaasPolicy(policy?: Partial<SaasPolicy> | DriftConfig['saas']): SaasPolicy;
|
|
73
|
+
export declare function defaultSaasStorePath(root?: string): string;
|
|
74
|
+
export declare function ingestSnapshotFromReport(report: DriftReport, options: IngestOptions): SaasSnapshot;
|
|
75
|
+
export declare function getSaasSummary(options?: {
|
|
76
|
+
storeFile?: string;
|
|
77
|
+
policy?: Partial<SaasPolicy>;
|
|
78
|
+
}): SaasSummary;
|
|
79
|
+
export declare function generateSaasDashboardHtml(options?: {
|
|
80
|
+
storeFile?: string;
|
|
81
|
+
policy?: Partial<SaasPolicy>;
|
|
82
|
+
}): string;
|
|
83
|
+
//# sourceMappingURL=saas.d.ts.map
|