@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.
@@ -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 out = generateArchitectureMap(resolvedPath, options.output);
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
- export declare function generateArchitectureSvg(targetPath: string): string;
2
- export declare function generateArchitectureMap(targetPath: string, outputFile?: string): string;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/');
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 = [...edges.entries()].map(([key, count]) => {
64
- const [from, to] = key.split('->');
65
- const a = boxByName.get(from);
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="#64748b" stroke-width="2" marker-end="url(#arrow)" />
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
- export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg') {
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