@eduardbar/drift 1.0.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/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +3 -1
- package/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +179 -6
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +34 -0
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +206 -7
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +31 -1
- package/src/map.ts +219 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
1
2
|
import * as crypto from 'node:crypto';
|
|
2
3
|
import { SyntaxKind, } from 'ts-morph';
|
|
3
4
|
/** Normalize a function body to a canonical string (Type-2 clone detection).
|
|
@@ -5,21 +6,17 @@ import { SyntaxKind, } from 'ts-morph';
|
|
|
5
6
|
* with canonical tokens so that two functions with identical logic but
|
|
6
7
|
* different identifiers produce the same fingerprint.
|
|
7
8
|
*/
|
|
8
|
-
|
|
9
|
-
// Build a substitution map: localName → canonical token
|
|
9
|
+
function buildSubstitutionMap(fn) {
|
|
10
10
|
const subst = new Map();
|
|
11
|
-
// Map parameters first
|
|
12
11
|
for (const [i, param] of fn.getParameters().entries()) {
|
|
13
12
|
const name = param.getName();
|
|
14
13
|
if (name && name !== '_')
|
|
15
14
|
subst.set(name, `P${i}`);
|
|
16
15
|
}
|
|
17
|
-
// Map locally declared variables (VariableDeclaration)
|
|
18
16
|
let varIdx = 0;
|
|
19
17
|
fn.forEachDescendant(node => {
|
|
20
18
|
if (node.getKind() === SyntaxKind.VariableDeclaration) {
|
|
21
19
|
const nameNode = node.getNameNode();
|
|
22
|
-
// Support destructuring — getNameNode() may be a BindingPattern
|
|
23
20
|
if (nameNode.getKind() === SyntaxKind.Identifier) {
|
|
24
21
|
const name = nameNode.getText();
|
|
25
22
|
if (!subst.has(name))
|
|
@@ -27,35 +24,39 @@ export function normalizeFunctionBody(fn) {
|
|
|
27
24
|
}
|
|
28
25
|
}
|
|
29
26
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return 'NL';
|
|
39
|
-
case SyntaxKind.StringLiteral:
|
|
40
|
-
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
41
|
-
return 'SL';
|
|
42
|
-
case SyntaxKind.TrueKeyword:
|
|
43
|
-
return 'TRUE';
|
|
44
|
-
case SyntaxKind.FalseKeyword:
|
|
45
|
-
return 'FALSE';
|
|
46
|
-
case SyntaxKind.NullKeyword:
|
|
47
|
-
return 'NULL';
|
|
27
|
+
return subst;
|
|
28
|
+
}
|
|
29
|
+
function serializeNode(node, subst) {
|
|
30
|
+
const kind = node.getKindName();
|
|
31
|
+
switch (node.getKind()) {
|
|
32
|
+
case SyntaxKind.Identifier: {
|
|
33
|
+
const text = node.getText();
|
|
34
|
+
return subst.get(text) ?? text;
|
|
48
35
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
case SyntaxKind.NumericLiteral:
|
|
37
|
+
return 'NL';
|
|
38
|
+
case SyntaxKind.StringLiteral:
|
|
39
|
+
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
40
|
+
return 'SL';
|
|
41
|
+
case SyntaxKind.TrueKeyword:
|
|
42
|
+
return 'TRUE';
|
|
43
|
+
case SyntaxKind.FalseKeyword:
|
|
44
|
+
return 'FALSE';
|
|
45
|
+
case SyntaxKind.NullKeyword:
|
|
46
|
+
return 'NULL';
|
|
54
47
|
}
|
|
48
|
+
const children = node.getChildren();
|
|
49
|
+
if (children.length === 0)
|
|
50
|
+
return kind;
|
|
51
|
+
const childStr = children.map(c => serializeNode(c, subst)).join('|');
|
|
52
|
+
return `${kind}(${childStr})`;
|
|
53
|
+
}
|
|
54
|
+
export function normalizeFunctionBody(fn) {
|
|
55
|
+
const subst = buildSubstitutionMap(fn);
|
|
55
56
|
const body = fn.getBody();
|
|
56
57
|
if (!body)
|
|
57
58
|
return '';
|
|
58
|
-
return serializeNode(body);
|
|
59
|
+
return serializeNode(body, subst);
|
|
59
60
|
}
|
|
60
61
|
/** Return a SHA-256 fingerprint for a function body (normalized). */
|
|
61
62
|
export function fingerprintFunction(fn) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
export function detectPromiseStyleMix(file) {
|
|
3
|
+
const text = file.getFullText();
|
|
4
|
+
const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
|
|
5
|
+
const name = node.getName();
|
|
6
|
+
return name === 'then' || name === 'catch';
|
|
7
|
+
});
|
|
8
|
+
const hasAsync = file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
|
|
9
|
+
/\bawait\b/.test(text);
|
|
10
|
+
if (hasThen && hasAsync) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: 'promise-style-mix',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
|
|
16
|
+
line: 1,
|
|
17
|
+
column: 1,
|
|
18
|
+
snippet: `// mixed promise styles detected`,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=promise.js.map
|
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
|
package/dist/saas.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
const STORE_VERSION = 1;
|
|
4
|
+
const ACTIVE_WINDOW_DAYS = 30;
|
|
5
|
+
export const DEFAULT_SAAS_POLICY = {
|
|
6
|
+
freeUserThreshold: 7500,
|
|
7
|
+
maxRunsPerWorkspacePerMonth: 500,
|
|
8
|
+
maxReposPerWorkspace: 20,
|
|
9
|
+
retentionDays: 90,
|
|
10
|
+
};
|
|
11
|
+
export function resolveSaasPolicy(policy) {
|
|
12
|
+
return {
|
|
13
|
+
...DEFAULT_SAAS_POLICY,
|
|
14
|
+
...(policy ?? {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function defaultSaasStorePath(root = '.') {
|
|
18
|
+
return resolve(root, '.drift-cloud', 'store.json');
|
|
19
|
+
}
|
|
20
|
+
function ensureStoreFile(storeFile, policy) {
|
|
21
|
+
const dir = dirname(storeFile);
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
if (!existsSync(storeFile)) {
|
|
25
|
+
const initial = createEmptyStore(policy);
|
|
26
|
+
writeFileSync(storeFile, JSON.stringify(initial, null, 2), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function createEmptyStore(policy) {
|
|
30
|
+
return {
|
|
31
|
+
version: STORE_VERSION,
|
|
32
|
+
policy: resolveSaasPolicy(policy),
|
|
33
|
+
users: {},
|
|
34
|
+
workspaces: {},
|
|
35
|
+
repos: {},
|
|
36
|
+
snapshots: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function monthKey(isoDate) {
|
|
40
|
+
const date = new Date(isoDate);
|
|
41
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
42
|
+
return `${date.getUTCFullYear()}-${month}`;
|
|
43
|
+
}
|
|
44
|
+
function daysAgo(days) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
return now - days * 24 * 60 * 60 * 1000;
|
|
47
|
+
}
|
|
48
|
+
function applyRetention(store) {
|
|
49
|
+
const cutoff = daysAgo(store.policy.retentionDays);
|
|
50
|
+
store.snapshots = store.snapshots.filter((snapshot) => {
|
|
51
|
+
return new Date(snapshot.createdAt).getTime() >= cutoff;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function saveStore(storeFile, store) {
|
|
55
|
+
writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
function loadStoreInternal(storeFile, policy) {
|
|
58
|
+
ensureStoreFile(storeFile, policy);
|
|
59
|
+
const raw = readFileSync(storeFile, 'utf8');
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
const merged = createEmptyStore(parsed.policy);
|
|
62
|
+
merged.version = parsed.version ?? STORE_VERSION;
|
|
63
|
+
merged.users = parsed.users ?? {};
|
|
64
|
+
merged.workspaces = parsed.workspaces ?? {};
|
|
65
|
+
merged.repos = parsed.repos ?? {};
|
|
66
|
+
merged.snapshots = parsed.snapshots ?? [];
|
|
67
|
+
merged.policy = resolveSaasPolicy({ ...merged.policy, ...policy });
|
|
68
|
+
applyRetention(merged);
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
71
|
+
function isWorkspaceActive(workspace) {
|
|
72
|
+
return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
|
|
73
|
+
}
|
|
74
|
+
function isRepoActive(repo) {
|
|
75
|
+
return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
|
|
76
|
+
}
|
|
77
|
+
function assertGuardrails(store, options, nowIso) {
|
|
78
|
+
const usersRegistered = Object.keys(store.users).length;
|
|
79
|
+
const isFreePhase = usersRegistered < store.policy.freeUserThreshold;
|
|
80
|
+
if (!isFreePhase)
|
|
81
|
+
return;
|
|
82
|
+
if (!store.users[options.userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
|
|
83
|
+
throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`);
|
|
84
|
+
}
|
|
85
|
+
const workspace = store.workspaces[options.workspaceId];
|
|
86
|
+
const repoName = options.repoName ?? 'default';
|
|
87
|
+
const repoId = `${options.workspaceId}:${repoName}`;
|
|
88
|
+
const repoExists = Boolean(store.repos[repoId]);
|
|
89
|
+
const repoCount = workspace?.repoIds.length ?? 0;
|
|
90
|
+
if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
|
|
91
|
+
throw new Error(`Workspace '${options.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`);
|
|
92
|
+
}
|
|
93
|
+
const currentMonth = monthKey(nowIso);
|
|
94
|
+
const runsThisMonth = store.snapshots.filter((snapshot) => {
|
|
95
|
+
return snapshot.workspaceId === options.workspaceId && monthKey(snapshot.createdAt) === currentMonth;
|
|
96
|
+
}).length;
|
|
97
|
+
if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
|
|
98
|
+
throw new Error(`Workspace '${options.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function ingestSnapshotFromReport(report, options) {
|
|
102
|
+
const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
|
|
103
|
+
const store = loadStoreInternal(storeFile, options.policy);
|
|
104
|
+
const nowIso = new Date().toISOString();
|
|
105
|
+
assertGuardrails(store, options, nowIso);
|
|
106
|
+
const user = store.users[options.userId];
|
|
107
|
+
if (user) {
|
|
108
|
+
user.lastSeenAt = nowIso;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
store.users[options.userId] = {
|
|
112
|
+
id: options.userId,
|
|
113
|
+
createdAt: nowIso,
|
|
114
|
+
lastSeenAt: nowIso,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const workspace = store.workspaces[options.workspaceId];
|
|
118
|
+
if (workspace) {
|
|
119
|
+
workspace.lastSeenAt = nowIso;
|
|
120
|
+
if (!workspace.userIds.includes(options.userId))
|
|
121
|
+
workspace.userIds.push(options.userId);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
store.workspaces[options.workspaceId] = {
|
|
125
|
+
id: options.workspaceId,
|
|
126
|
+
createdAt: nowIso,
|
|
127
|
+
lastSeenAt: nowIso,
|
|
128
|
+
userIds: [options.userId],
|
|
129
|
+
repoIds: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const repoName = options.repoName ?? 'default';
|
|
133
|
+
const repoId = `${options.workspaceId}:${repoName}`;
|
|
134
|
+
const repo = store.repos[repoId];
|
|
135
|
+
if (repo) {
|
|
136
|
+
repo.lastSeenAt = nowIso;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
store.repos[repoId] = {
|
|
140
|
+
id: repoId,
|
|
141
|
+
workspaceId: options.workspaceId,
|
|
142
|
+
name: repoName,
|
|
143
|
+
createdAt: nowIso,
|
|
144
|
+
lastSeenAt: nowIso,
|
|
145
|
+
};
|
|
146
|
+
const ws = store.workspaces[options.workspaceId];
|
|
147
|
+
if (!ws.repoIds.includes(repoId))
|
|
148
|
+
ws.repoIds.push(repoId);
|
|
149
|
+
}
|
|
150
|
+
const snapshot = {
|
|
151
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
|
|
152
|
+
createdAt: nowIso,
|
|
153
|
+
scannedAt: report.scannedAt,
|
|
154
|
+
workspaceId: options.workspaceId,
|
|
155
|
+
userId: options.userId,
|
|
156
|
+
repoId,
|
|
157
|
+
repoName,
|
|
158
|
+
targetPath: report.targetPath,
|
|
159
|
+
totalScore: report.totalScore,
|
|
160
|
+
totalIssues: report.totalIssues,
|
|
161
|
+
totalFiles: report.totalFiles,
|
|
162
|
+
summary: {
|
|
163
|
+
errors: report.summary.errors,
|
|
164
|
+
warnings: report.summary.warnings,
|
|
165
|
+
infos: report.summary.infos,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
store.snapshots.push(snapshot);
|
|
169
|
+
applyRetention(store);
|
|
170
|
+
saveStore(storeFile, store);
|
|
171
|
+
return snapshot;
|
|
172
|
+
}
|
|
173
|
+
export function getSaasSummary(options) {
|
|
174
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
|
|
175
|
+
const store = loadStoreInternal(storeFile, options?.policy);
|
|
176
|
+
saveStore(storeFile, store);
|
|
177
|
+
const usersRegistered = Object.keys(store.users).length;
|
|
178
|
+
const workspacesActive = Object.values(store.workspaces).filter(isWorkspaceActive).length;
|
|
179
|
+
const reposActive = Object.values(store.repos).filter(isRepoActive).length;
|
|
180
|
+
const runsPerMonth = {};
|
|
181
|
+
for (const snapshot of store.snapshots) {
|
|
182
|
+
const key = monthKey(snapshot.createdAt);
|
|
183
|
+
runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1;
|
|
184
|
+
}
|
|
185
|
+
const thresholdReached = usersRegistered >= store.policy.freeUserThreshold;
|
|
186
|
+
return {
|
|
187
|
+
policy: store.policy,
|
|
188
|
+
usersRegistered,
|
|
189
|
+
workspacesActive,
|
|
190
|
+
reposActive,
|
|
191
|
+
runsPerMonth,
|
|
192
|
+
totalSnapshots: store.snapshots.length,
|
|
193
|
+
phase: thresholdReached ? 'paid' : 'free',
|
|
194
|
+
thresholdReached,
|
|
195
|
+
freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function escapeHtml(value) {
|
|
199
|
+
return value
|
|
200
|
+
.replaceAll('&', '&')
|
|
201
|
+
.replaceAll('<', '<')
|
|
202
|
+
.replaceAll('>', '>')
|
|
203
|
+
.replaceAll('"', '"')
|
|
204
|
+
.replaceAll("'", ''');
|
|
205
|
+
}
|
|
206
|
+
export function generateSaasDashboardHtml(options) {
|
|
207
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
|
|
208
|
+
const store = loadStoreInternal(storeFile, options?.policy);
|
|
209
|
+
const summary = getSaasSummary(options);
|
|
210
|
+
const workspaceStats = Object.values(store.workspaces)
|
|
211
|
+
.map((workspace) => {
|
|
212
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.workspaceId === workspace.id);
|
|
213
|
+
const runs = snapshots.length;
|
|
214
|
+
const avgScore = runs === 0
|
|
215
|
+
? 0
|
|
216
|
+
: Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs);
|
|
217
|
+
const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a';
|
|
218
|
+
return {
|
|
219
|
+
id: workspace.id,
|
|
220
|
+
runs,
|
|
221
|
+
avgScore,
|
|
222
|
+
lastRun,
|
|
223
|
+
};
|
|
224
|
+
})
|
|
225
|
+
.sort((a, b) => b.avgScore - a.avgScore);
|
|
226
|
+
const repoStats = Object.values(store.repos)
|
|
227
|
+
.map((repo) => {
|
|
228
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id);
|
|
229
|
+
const runs = snapshots.length;
|
|
230
|
+
const avgScore = runs === 0
|
|
231
|
+
? 0
|
|
232
|
+
: Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs);
|
|
233
|
+
return {
|
|
234
|
+
workspaceId: repo.workspaceId,
|
|
235
|
+
name: repo.name,
|
|
236
|
+
runs,
|
|
237
|
+
avgScore,
|
|
238
|
+
};
|
|
239
|
+
})
|
|
240
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
241
|
+
.slice(0, 15);
|
|
242
|
+
const runsRows = Object.entries(summary.runsPerMonth)
|
|
243
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
244
|
+
.map(([month, count]) => {
|
|
245
|
+
const width = Math.max(8, count * 8);
|
|
246
|
+
return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`;
|
|
247
|
+
})
|
|
248
|
+
.join('');
|
|
249
|
+
const workspaceRows = workspaceStats
|
|
250
|
+
.map((workspace) => `<tr><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
|
|
251
|
+
.join('');
|
|
252
|
+
const repoRows = repoStats
|
|
253
|
+
.map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
|
|
254
|
+
.join('');
|
|
255
|
+
return `<!doctype html>
|
|
256
|
+
<html lang="en">
|
|
257
|
+
<head>
|
|
258
|
+
<meta charset="utf-8" />
|
|
259
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
260
|
+
<title>drift cloud dashboard</title>
|
|
261
|
+
<style>
|
|
262
|
+
:root { color-scheme: light; }
|
|
263
|
+
body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; }
|
|
264
|
+
main { max-width: 980px; margin: 0 auto; padding: 24px; }
|
|
265
|
+
h1 { margin: 0 0 6px; }
|
|
266
|
+
p.meta { margin: 0 0 20px; color: #475569; }
|
|
267
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
268
|
+
.card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; }
|
|
269
|
+
.card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
270
|
+
.card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
|
|
271
|
+
table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; }
|
|
272
|
+
th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; }
|
|
273
|
+
th { background: #eef2f9; }
|
|
274
|
+
.section { margin-top: 18px; }
|
|
275
|
+
.bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; }
|
|
276
|
+
.pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
|
|
277
|
+
.pill.free { background: #dcfce7; color: #166534; }
|
|
278
|
+
.pill.paid { background: #fee2e2; color: #991b1b; }
|
|
279
|
+
</style>
|
|
280
|
+
</head>
|
|
281
|
+
<body>
|
|
282
|
+
<main>
|
|
283
|
+
<h1>drift cloud dashboard</h1>
|
|
284
|
+
<p class="meta">Store: ${escapeHtml(storeFile)}</p>
|
|
285
|
+
<div class="cards">
|
|
286
|
+
<div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${summary.phase}">${summary.phase.toUpperCase()}</span></div></div>
|
|
287
|
+
<div class="card"><div class="label">Users</div><div class="value">${summary.usersRegistered}</div></div>
|
|
288
|
+
<div class="card"><div class="label">Active Workspaces</div><div class="value">${summary.workspacesActive}</div></div>
|
|
289
|
+
<div class="card"><div class="label">Active Repos</div><div class="value">${summary.reposActive}</div></div>
|
|
290
|
+
<div class="card"><div class="label">Snapshots</div><div class="value">${summary.totalSnapshots}</div></div>
|
|
291
|
+
<div class="card"><div class="label">Free Seats Left</div><div class="value">${summary.freeUsersRemaining}</div></div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<section class="section">
|
|
295
|
+
<h2>Runs Per Month</h2>
|
|
296
|
+
<table>
|
|
297
|
+
<thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead>
|
|
298
|
+
<tbody>${runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody>
|
|
299
|
+
</table>
|
|
300
|
+
</section>
|
|
301
|
+
|
|
302
|
+
<section class="section">
|
|
303
|
+
<h2>Workspace Hotspots</h2>
|
|
304
|
+
<table>
|
|
305
|
+
<thead><tr><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
|
|
306
|
+
<tbody>${workspaceRows || '<tr><td colspan="4">No workspace data</td></tr>'}</tbody>
|
|
307
|
+
</table>
|
|
308
|
+
</section>
|
|
309
|
+
|
|
310
|
+
<section class="section">
|
|
311
|
+
<h2>Repo Hotspots</h2>
|
|
312
|
+
<table>
|
|
313
|
+
<thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead>
|
|
314
|
+
<tbody>${repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody>
|
|
315
|
+
</table>
|
|
316
|
+
</section>
|
|
317
|
+
</main>
|
|
318
|
+
</body>
|
|
319
|
+
</html>`;
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=saas.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DriftReport } from './types.js';
|
|
2
|
+
export interface SnapshotEntry {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
label: string;
|
|
5
|
+
score: number;
|
|
6
|
+
grade: string;
|
|
7
|
+
totalIssues: number;
|
|
8
|
+
files: number;
|
|
9
|
+
byRule: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
export interface SnapshotHistory {
|
|
12
|
+
project: string;
|
|
13
|
+
snapshots: SnapshotEntry[];
|
|
14
|
+
}
|
|
15
|
+
export declare function loadHistory(targetPath: string): SnapshotHistory;
|
|
16
|
+
export declare function saveSnapshot(targetPath: string, report: DriftReport, label?: string): SnapshotEntry;
|
|
17
|
+
export declare function printHistory(history: SnapshotHistory): void;
|
|
18
|
+
export declare function printSnapshotDiff(history: SnapshotHistory, currentScore: number): void;
|
|
19
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { scoreToGradeText } from './utils.js';
|
|
5
|
+
const HISTORY_FILE = 'drift-history.json';
|
|
6
|
+
const HEADER_PAD = {
|
|
7
|
+
INDEX: 4,
|
|
8
|
+
DATE: 26,
|
|
9
|
+
LABEL: 20,
|
|
10
|
+
SCORE: 8,
|
|
11
|
+
GRADE: 12,
|
|
12
|
+
ISSUES: 8,
|
|
13
|
+
DELTA: 6,
|
|
14
|
+
};
|
|
15
|
+
const GRADE_THRESHOLDS = {
|
|
16
|
+
LOW: 20,
|
|
17
|
+
MODERATE: 45,
|
|
18
|
+
HIGH: 70,
|
|
19
|
+
};
|
|
20
|
+
export function loadHistory(targetPath) {
|
|
21
|
+
const filePath = path.join(targetPath, HISTORY_FILE);
|
|
22
|
+
if (fs.existsSync(filePath)) {
|
|
23
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
return { project: targetPath, snapshots: [] };
|
|
26
|
+
}
|
|
27
|
+
export function saveSnapshot(targetPath, report, label) {
|
|
28
|
+
const history = loadHistory(targetPath);
|
|
29
|
+
const entry = {
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
label: label ?? '',
|
|
32
|
+
score: report.totalScore,
|
|
33
|
+
grade: scoreToGradeText(report.totalScore).label.toUpperCase(),
|
|
34
|
+
totalIssues: report.totalIssues,
|
|
35
|
+
files: report.totalFiles,
|
|
36
|
+
byRule: { ...report.summary.byRule },
|
|
37
|
+
};
|
|
38
|
+
history.snapshots.push(entry);
|
|
39
|
+
const filePath = path.join(targetPath, HISTORY_FILE);
|
|
40
|
+
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8');
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
function formatDelta(current, prev) {
|
|
44
|
+
if (!prev)
|
|
45
|
+
return '—';
|
|
46
|
+
const delta = current.score - prev.score;
|
|
47
|
+
if (delta > 0)
|
|
48
|
+
return kleur.red(`+${delta}`);
|
|
49
|
+
if (delta < 0)
|
|
50
|
+
return kleur.green(String(delta));
|
|
51
|
+
return kleur.gray('0');
|
|
52
|
+
}
|
|
53
|
+
export function printHistory(history) {
|
|
54
|
+
const { snapshots } = history;
|
|
55
|
+
if (snapshots.length === 0) {
|
|
56
|
+
process.stdout.write('\n No snapshots recorded yet.\n\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write('\n');
|
|
60
|
+
process.stdout.write(kleur.bold(` ${'#'.padEnd(HEADER_PAD.INDEX)} ${'Date'.padEnd(HEADER_PAD.DATE)} ${'Label'.padEnd(HEADER_PAD.LABEL)} ${'Score'.padEnd(HEADER_PAD.SCORE)} ${'Grade'.padEnd(HEADER_PAD.GRADE)} ${'Issues'.padEnd(HEADER_PAD.ISSUES)} ${'Delta'}\n`));
|
|
61
|
+
process.stdout.write(` ${'─'.repeat(HEADER_PAD.INDEX)} ${'─'.repeat(HEADER_PAD.DATE)} ${'─'.repeat(HEADER_PAD.LABEL)} ${'─'.repeat(HEADER_PAD.SCORE)} ${'─'.repeat(HEADER_PAD.GRADE)} ${'─'.repeat(HEADER_PAD.ISSUES)} ${'─'.repeat(HEADER_PAD.DELTA)}\n`);
|
|
62
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
63
|
+
const s = snapshots[i];
|
|
64
|
+
const date = new Date(s.timestamp).toLocaleString('en-US', {
|
|
65
|
+
year: 'numeric',
|
|
66
|
+
month: 'short',
|
|
67
|
+
day: '2-digit',
|
|
68
|
+
hour: '2-digit',
|
|
69
|
+
minute: '2-digit',
|
|
70
|
+
});
|
|
71
|
+
const deltaStr = formatDelta(s, i > 0 ? snapshots[i - 1] : null);
|
|
72
|
+
const gradeColored = colorGrade(s.grade, s.score);
|
|
73
|
+
process.stdout.write(` ${String(i + 1).padEnd(HEADER_PAD.INDEX)} ${date.padEnd(HEADER_PAD.DATE)} ${(s.label || '—').padEnd(HEADER_PAD.LABEL)} ${String(s.score).padEnd(HEADER_PAD.SCORE)} ${gradeColored.padEnd(HEADER_PAD.GRADE)} ${String(s.totalIssues).padEnd(HEADER_PAD.ISSUES)} ${deltaStr}\n`);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write('\n');
|
|
76
|
+
}
|
|
77
|
+
export function printSnapshotDiff(history, currentScore) {
|
|
78
|
+
const { snapshots } = history;
|
|
79
|
+
if (snapshots.length === 0) {
|
|
80
|
+
process.stdout.write('\n No previous snapshot to compare against.\n\n');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const last = snapshots[snapshots.length - 1];
|
|
84
|
+
const delta = currentScore - last.score;
|
|
85
|
+
const lastDate = new Date(last.timestamp).toLocaleString('en-US', {
|
|
86
|
+
year: 'numeric',
|
|
87
|
+
month: 'short',
|
|
88
|
+
day: '2-digit',
|
|
89
|
+
hour: '2-digit',
|
|
90
|
+
minute: '2-digit',
|
|
91
|
+
});
|
|
92
|
+
const lastLabel = last.label ? ` (${last.label})` : '';
|
|
93
|
+
process.stdout.write('\n');
|
|
94
|
+
process.stdout.write(` Last snapshot: ${kleur.bold(lastDate)}${lastLabel} — score ${kleur.bold(String(last.score))}\n`);
|
|
95
|
+
process.stdout.write(` Current score: ${kleur.bold(String(currentScore))}\n`);
|
|
96
|
+
process.stdout.write('\n');
|
|
97
|
+
if (delta > 0) {
|
|
98
|
+
process.stdout.write(` Delta: ${kleur.bold().red(`+${delta}`)} — technical debt increased\n`);
|
|
99
|
+
}
|
|
100
|
+
else if (delta < 0) {
|
|
101
|
+
process.stdout.write(` Delta: ${kleur.bold().green(String(delta))} — technical debt decreased\n`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
process.stdout.write(` Delta: ${kleur.gray('0')} — no change since last snapshot\n`);
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write('\n');
|
|
107
|
+
}
|
|
108
|
+
function colorGrade(grade, score) {
|
|
109
|
+
if (score === 0)
|
|
110
|
+
return kleur.green(grade);
|
|
111
|
+
if (score < GRADE_THRESHOLDS.LOW)
|
|
112
|
+
return kleur.green(grade);
|
|
113
|
+
if (score < GRADE_THRESHOLDS.MODERATE)
|
|
114
|
+
return kleur.yellow(grade);
|
|
115
|
+
if (score < GRADE_THRESHOLDS.HIGH)
|
|
116
|
+
return kleur.red(grade);
|
|
117
|
+
return kleur.bold().red(grade);
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=snapshot.js.map
|