@eduardbar/drift 1.2.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/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- 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/git.js +12 -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 +12 -3
- package/dist/index.js +6 -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 +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- 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 +4 -3
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- 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 -82
- package/dist/saas.js +7 -320
- 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 +7 -0
- package/dist/trust-kpi.js +185 -0
- 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 +37 -0
- package/dist/trust.js +168 -0
- 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 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- 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/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -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 +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- 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 -433
- 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 +210 -0
- 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 +260 -0
- 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 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +358 -1
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computeDiff } from '../src/diff.js'
|
|
3
|
+
import type { DriftIssue, DriftReport } from '../src/types.js'
|
|
4
|
+
|
|
5
|
+
function createReport(pathPrefix: string): DriftReport {
|
|
6
|
+
return {
|
|
7
|
+
scannedAt: new Date().toISOString(),
|
|
8
|
+
targetPath: pathPrefix,
|
|
9
|
+
files: [
|
|
10
|
+
{
|
|
11
|
+
path: `${pathPrefix}/src/a.ts`,
|
|
12
|
+
score: 10,
|
|
13
|
+
issues: [
|
|
14
|
+
{
|
|
15
|
+
rule: 'magic-number',
|
|
16
|
+
severity: 'info',
|
|
17
|
+
message: 'Magic number 42 used directly in logic. Extract to a named constant.',
|
|
18
|
+
line: 4,
|
|
19
|
+
column: 10,
|
|
20
|
+
snippet: 'const answer = 42',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
totalIssues: 1,
|
|
26
|
+
totalScore: 10,
|
|
27
|
+
totalFiles: 1,
|
|
28
|
+
summary: {
|
|
29
|
+
errors: 0,
|
|
30
|
+
warnings: 0,
|
|
31
|
+
infos: 1,
|
|
32
|
+
byRule: {
|
|
33
|
+
'magic-number': 1,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
quality: {
|
|
37
|
+
overall: 90,
|
|
38
|
+
dimensions: {
|
|
39
|
+
architecture: 100,
|
|
40
|
+
complexity: 90,
|
|
41
|
+
'ai-patterns': 90,
|
|
42
|
+
testing: 100,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
maintenanceRisk: {
|
|
46
|
+
score: 10,
|
|
47
|
+
level: 'low',
|
|
48
|
+
hotspots: [],
|
|
49
|
+
signals: {
|
|
50
|
+
highComplexityFiles: 0,
|
|
51
|
+
filesWithoutNearbyTests: 0,
|
|
52
|
+
frequentChangeFiles: 0,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withIssues(report: DriftReport, issues: DriftIssue[]): DriftReport {
|
|
59
|
+
return {
|
|
60
|
+
...report,
|
|
61
|
+
files: report.files.map((file, index) => {
|
|
62
|
+
if (index !== 0) return file
|
|
63
|
+
return {
|
|
64
|
+
...file,
|
|
65
|
+
issues,
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
totalIssues: issues.length,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('computeDiff', () => {
|
|
73
|
+
it('treats slash and backslash file paths as the same file', () => {
|
|
74
|
+
const base = createReport('C:/repo')
|
|
75
|
+
const current = createReport('C:\\repo')
|
|
76
|
+
|
|
77
|
+
const diff = computeDiff(base, current, 'origin/main')
|
|
78
|
+
|
|
79
|
+
expect(diff.files).toHaveLength(0)
|
|
80
|
+
expect(diff.newIssuesCount).toBe(0)
|
|
81
|
+
expect(diff.resolvedIssuesCount).toBe(0)
|
|
82
|
+
expect(diff.totalDelta).toBe(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not create churn for LF vs CRLF snippets and column noise', () => {
|
|
86
|
+
const base = createReport('C:/repo')
|
|
87
|
+
const current = withIssues(createReport('C:/repo'), [
|
|
88
|
+
{
|
|
89
|
+
...base.files[0].issues[0],
|
|
90
|
+
column: 12,
|
|
91
|
+
snippet: 'const answer = 42\r\n',
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
const diff = computeDiff(base, current, 'origin/main')
|
|
96
|
+
|
|
97
|
+
expect(diff.files).toHaveLength(0)
|
|
98
|
+
expect(diff.newIssuesCount).toBe(0)
|
|
99
|
+
expect(diff.resolvedIssuesCount).toBe(0)
|
|
100
|
+
expect(diff.totalDelta).toBe(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('still detects genuinely new issues', () => {
|
|
104
|
+
const base = createReport('C:/repo')
|
|
105
|
+
const current = withIssues(createReport('C:/repo'), [
|
|
106
|
+
...base.files[0].issues,
|
|
107
|
+
{
|
|
108
|
+
rule: 'any-abuse',
|
|
109
|
+
severity: 'warning',
|
|
110
|
+
message: 'Avoid using any type. Use a specific type or unknown and narrow it safely.',
|
|
111
|
+
line: 6,
|
|
112
|
+
column: 9,
|
|
113
|
+
snippet: 'const value: any = source',
|
|
114
|
+
},
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
const diff = computeDiff(base, current, 'origin/main')
|
|
118
|
+
|
|
119
|
+
expect(diff.files).toHaveLength(1)
|
|
120
|
+
expect(diff.newIssuesCount).toBe(1)
|
|
121
|
+
expect(diff.resolvedIssuesCount).toBe(0)
|
|
122
|
+
expect(diff.files[0]?.newIssues[0]?.rule).toBe('any-abuse')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { resolveOutputFormat } from '../src/format.js'
|
|
3
|
+
|
|
4
|
+
describe('resolveOutputFormat', () => {
|
|
5
|
+
it('defaults to console when no format flags are passed', () => {
|
|
6
|
+
const format = resolveOutputFormat({
|
|
7
|
+
command: 'scan',
|
|
8
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
expect(format).toBe('console')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('resolves from --format when supported', () => {
|
|
15
|
+
const format = resolveOutputFormat({
|
|
16
|
+
command: 'trust',
|
|
17
|
+
format: 'markdown',
|
|
18
|
+
supported: ['console', 'json', 'markdown'],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(format).toBe('markdown')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('maps legacy aliases and emits deprecation warnings', () => {
|
|
25
|
+
const onWarning = vi.fn()
|
|
26
|
+
|
|
27
|
+
const format = resolveOutputFormat({
|
|
28
|
+
command: 'review',
|
|
29
|
+
supported: ['console', 'json', 'markdown'],
|
|
30
|
+
legacyAliases: [{ flag: 'comment', used: true, mapsTo: 'markdown' }],
|
|
31
|
+
onWarning,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(format).toBe('markdown')
|
|
35
|
+
expect(onWarning).toHaveBeenCalledWith("Warning: --comment is deprecated for 'review'. Use --format markdown instead.")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('covers legacy alias mapping per phase-1.4 command', () => {
|
|
39
|
+
expect(
|
|
40
|
+
resolveOutputFormat({
|
|
41
|
+
command: 'scan',
|
|
42
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
43
|
+
legacyAliases: [{ flag: 'ai', used: true, mapsTo: 'ai' }],
|
|
44
|
+
}),
|
|
45
|
+
).toBe('ai')
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
resolveOutputFormat({
|
|
49
|
+
command: 'trust',
|
|
50
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
51
|
+
legacyAliases: [{ flag: 'markdown', used: true, mapsTo: 'markdown' }],
|
|
52
|
+
}),
|
|
53
|
+
).toBe('markdown')
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
resolveOutputFormat({
|
|
57
|
+
command: 'diff',
|
|
58
|
+
supported: ['console', 'json', 'sarif'],
|
|
59
|
+
legacyAliases: [{ flag: 'json', used: true, mapsTo: 'json' }],
|
|
60
|
+
}),
|
|
61
|
+
).toBe('json')
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
resolveOutputFormat({
|
|
65
|
+
command: 'ci',
|
|
66
|
+
supported: ['console', 'json', 'sarif'],
|
|
67
|
+
legacyAliases: [{ flag: 'json', used: true, mapsTo: 'json' }],
|
|
68
|
+
}),
|
|
69
|
+
).toBe('json')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('allows sarif when command supports it', () => {
|
|
73
|
+
expect(
|
|
74
|
+
resolveOutputFormat({
|
|
75
|
+
command: 'scan',
|
|
76
|
+
format: 'sarif',
|
|
77
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
78
|
+
}),
|
|
79
|
+
).toBe('sarif')
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
resolveOutputFormat({
|
|
83
|
+
command: 'ci',
|
|
84
|
+
format: 'sarif',
|
|
85
|
+
supported: ['console', 'json', 'sarif'],
|
|
86
|
+
}),
|
|
87
|
+
).toBe('sarif')
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
resolveOutputFormat({
|
|
91
|
+
command: 'diff',
|
|
92
|
+
format: 'sarif',
|
|
93
|
+
supported: ['console', 'json', 'sarif'],
|
|
94
|
+
}),
|
|
95
|
+
).toBe('sarif')
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
resolveOutputFormat({
|
|
99
|
+
command: 'review',
|
|
100
|
+
format: 'sarif',
|
|
101
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
102
|
+
}),
|
|
103
|
+
).toBe('sarif')
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
resolveOutputFormat({
|
|
107
|
+
command: 'trust',
|
|
108
|
+
format: 'sarif',
|
|
109
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
110
|
+
}),
|
|
111
|
+
).toBe('sarif')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('fails on unsupported format per command', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
resolveOutputFormat({
|
|
117
|
+
command: 'diff',
|
|
118
|
+
format: 'markdown',
|
|
119
|
+
supported: ['console', 'json', 'sarif'],
|
|
120
|
+
}),
|
|
121
|
+
).toThrow("Format 'markdown' is not supported for 'diff'. Supported formats: console, json, sarif.")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('fails when sarif is not supported by the command', () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
resolveOutputFormat({
|
|
127
|
+
command: 'guard',
|
|
128
|
+
format: 'sarif',
|
|
129
|
+
supported: ['console', 'json'],
|
|
130
|
+
}),
|
|
131
|
+
).toThrow("Format 'sarif' is not supported for 'guard'. Supported formats: console, json.")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('fails when legacy aliases conflict', () => {
|
|
135
|
+
expect(() =>
|
|
136
|
+
resolveOutputFormat({
|
|
137
|
+
command: 'scan',
|
|
138
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
139
|
+
legacyAliases: [
|
|
140
|
+
{ flag: 'json', used: true, mapsTo: 'json' },
|
|
141
|
+
{ flag: 'ai', used: true, mapsTo: 'ai' },
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
).toThrow("Conflicting legacy format flags for 'scan': json vs ai. Use a single format option.")
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('fails when --format conflicts with a legacy alias', () => {
|
|
148
|
+
expect(() =>
|
|
149
|
+
resolveOutputFormat({
|
|
150
|
+
command: 'trust',
|
|
151
|
+
format: 'json',
|
|
152
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
153
|
+
legacyAliases: [{ flag: 'markdown', used: true, mapsTo: 'markdown' }],
|
|
154
|
+
}),
|
|
155
|
+
).toThrow("Conflicting format flags for 'trust': --format json and legacy alias for markdown.")
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -4,10 +4,12 @@ import { tmpdir } from 'node:os'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { analyzeProject } from '../src/analyzer.js'
|
|
6
6
|
import { buildReport, formatAIOutput } from '../src/reporter.js'
|
|
7
|
-
import { formatReviewMarkdown
|
|
7
|
+
import { formatReviewMarkdown } from '../src/review.js'
|
|
8
8
|
import { generateArchitectureSvg } from '../src/map.js'
|
|
9
9
|
import { applyFixes } from '../src/fix.js'
|
|
10
10
|
|
|
11
|
+
type DriftReview = Parameters<typeof formatReviewMarkdown>[0]
|
|
12
|
+
|
|
11
13
|
describe('new feature MVP', () => {
|
|
12
14
|
let tmpDir = ''
|
|
13
15
|
|
|
@@ -36,6 +38,12 @@ describe('new feature MVP', () => {
|
|
|
36
38
|
expect(Array.isArray(ai.files_suspected)).toBe(true)
|
|
37
39
|
expect(ai.maintenance_risk).toBeDefined()
|
|
38
40
|
expect(ai.quality).toBeDefined()
|
|
41
|
+
expect(ai.$schema).toBe('schemas/drift-ai-output.v1.json')
|
|
42
|
+
expect(typeof ai.toolVersion).toBe('string')
|
|
43
|
+
expect(ai.toolVersion.length).toBeGreaterThan(0)
|
|
44
|
+
expect(report.$schema).toBe('schemas/drift-report.v1.json')
|
|
45
|
+
expect(typeof report.toolVersion).toBe('string')
|
|
46
|
+
expect(report.toolVersion.length).toBeGreaterThan(0)
|
|
39
47
|
})
|
|
40
48
|
|
|
41
49
|
it('formats review markdown for PR comments', () => {
|
|
@@ -177,4 +185,75 @@ describe('new feature MVP', () => {
|
|
|
177
185
|
expect(write.length).toBeGreaterThan(0)
|
|
178
186
|
expect(readFileSync(file, 'utf8')).not.toContain('console.log')
|
|
179
187
|
})
|
|
188
|
+
|
|
189
|
+
it('supports low-memory chunked analysis with cross-file rules', () => {
|
|
190
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'drift-low-memory-'))
|
|
191
|
+
writeFileSync(join(tmpDir, 'a.ts'), "import { b } from './b.js'\nexport const a = b\n")
|
|
192
|
+
writeFileSync(join(tmpDir, 'b.ts'), "import { a } from './a.js'\nexport const b = a\n")
|
|
193
|
+
|
|
194
|
+
const fullRules = new Set(analyzeProject(tmpDir).flatMap((report) => report.issues.map((issue) => issue.rule)))
|
|
195
|
+
const lowMemoryRules = new Set(
|
|
196
|
+
analyzeProject(tmpDir, undefined, { lowMemory: true, chunkSize: 1, includeSemanticDuplication: true })
|
|
197
|
+
.flatMap((report) => report.issues.map((issue) => issue.rule)),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
expect(fullRules.has('circular-dependency')).toBe(true)
|
|
201
|
+
expect(lowMemoryRules.has('circular-dependency')).toBe(true)
|
|
202
|
+
}, 15000)
|
|
203
|
+
|
|
204
|
+
it('adds diagnostics when max file size guardrail skips files', () => {
|
|
205
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'drift-max-file-size-'))
|
|
206
|
+
writeFileSync(join(tmpDir, 'small.ts'), 'export const ok = 1\n')
|
|
207
|
+
writeFileSync(join(tmpDir, 'big.ts'), `export const payload = '${'x'.repeat(5000)}'\n`)
|
|
208
|
+
|
|
209
|
+
const reports = analyzeProject(tmpDir, undefined, { maxFileSizeKb: 1 })
|
|
210
|
+
const skipIssues = reports.flatMap((report) => report.issues.filter((issue) => issue.rule === 'analysis-skip-file-size'))
|
|
211
|
+
|
|
212
|
+
expect(skipIssues.length).toBeGreaterThan(0)
|
|
213
|
+
expect(skipIssues[0].message).toContain('maxFileSizeKb')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('adds diagnostics when max files guardrail skips files', () => {
|
|
217
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'drift-max-files-'))
|
|
218
|
+
writeFileSync(join(tmpDir, 'a.ts'), 'export const a = 1\n')
|
|
219
|
+
writeFileSync(join(tmpDir, 'b.ts'), 'export const b = 2\n')
|
|
220
|
+
writeFileSync(join(tmpDir, 'c.ts'), 'export const c = 3\n')
|
|
221
|
+
|
|
222
|
+
const reports = analyzeProject(tmpDir, undefined, { maxFiles: 1 })
|
|
223
|
+
const skipped = reports.flatMap((report) => report.issues.filter((issue) => issue.rule === 'analysis-skip-max-files'))
|
|
224
|
+
|
|
225
|
+
expect(skipped).toHaveLength(2)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('disables semantic duplication by default in low-memory mode but keeps opt-in', () => {
|
|
229
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'drift-low-memory-semantic-'))
|
|
230
|
+
const functionA = [
|
|
231
|
+
'export function same(x: number): number {',
|
|
232
|
+
' const a = x + 1',
|
|
233
|
+
' const b = a * 2',
|
|
234
|
+
' const c = b - 3',
|
|
235
|
+
' const d = c + 4',
|
|
236
|
+
' const e = d * 5',
|
|
237
|
+
' const f = e - 6',
|
|
238
|
+
' const g = f + 7',
|
|
239
|
+
' return g',
|
|
240
|
+
'}',
|
|
241
|
+
].join('\n')
|
|
242
|
+
const functionB = functionA
|
|
243
|
+
.replace('same', 'same2')
|
|
244
|
+
.replace(/\bx\b/g, 'n')
|
|
245
|
+
|
|
246
|
+
writeFileSync(join(tmpDir, 'a.ts'), `${functionA}\n`)
|
|
247
|
+
writeFileSync(join(tmpDir, 'b.ts'), `${functionB}\n`)
|
|
248
|
+
|
|
249
|
+
const lowMemoryDefault = analyzeProject(tmpDir, undefined, { lowMemory: true })
|
|
250
|
+
.flatMap((report) => report.issues.map((issue) => issue.rule))
|
|
251
|
+
const lowMemoryWithSemantic = analyzeProject(tmpDir, undefined, {
|
|
252
|
+
lowMemory: true,
|
|
253
|
+
includeSemanticDuplication: true,
|
|
254
|
+
}).flatMap((report) => report.issues.map((issue) => issue.rule))
|
|
255
|
+
|
|
256
|
+
expect(lowMemoryDefault).not.toContain('semantic-duplication')
|
|
257
|
+
expect(lowMemoryWithSemantic).toContain('semantic-duplication')
|
|
258
|
+
})
|
|
180
259
|
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { runDoctor } from '../src/doctor.js'
|
|
6
|
+
import { runInit } from '../src/init.js'
|
|
7
|
+
import { evaluateGuard, runGuard } from '../src/guard.js'
|
|
8
|
+
import { analyzeProject } from '../src/analyzer.js'
|
|
9
|
+
import { buildReport } from '../src/reporter.js'
|
|
10
|
+
|
|
11
|
+
function createTempDir(prefix: string): string {
|
|
12
|
+
return mkdtempSync(join(tmpdir(), prefix))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('phase 1: doctor/init/guard', () => {
|
|
16
|
+
const tempDirs: string[] = []
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks()
|
|
20
|
+
for (const dir of tempDirs) {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true })
|
|
22
|
+
}
|
|
23
|
+
tempDirs.length = 0
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('runDoctor', () => {
|
|
27
|
+
it('prints a basic diagnostic report and exits with code 0', async () => {
|
|
28
|
+
const projectDir = createTempDir('drift-doctor-basic-')
|
|
29
|
+
tempDirs.push(projectDir)
|
|
30
|
+
writeFileSync(join(projectDir, 'index.ts'), 'export const value = 1\n')
|
|
31
|
+
|
|
32
|
+
const output: string[] = []
|
|
33
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
34
|
+
output.push(String(chunk))
|
|
35
|
+
return true
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const exitCode = await runDoctor(projectDir)
|
|
39
|
+
const text = output.join('')
|
|
40
|
+
|
|
41
|
+
expect(exitCode).toBe(0)
|
|
42
|
+
expect(text).toContain('drift doctor')
|
|
43
|
+
expect(text).toContain('Source files (.ts/.tsx/.js/.jsx): 1')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('prints valid JSON output with expected shape', async () => {
|
|
47
|
+
const projectDir = createTempDir('drift-doctor-json-')
|
|
48
|
+
tempDirs.push(projectDir)
|
|
49
|
+
mkdirSync(join(projectDir, 'src'))
|
|
50
|
+
writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2))
|
|
51
|
+
writeFileSync(join(projectDir, 'tsconfig.json'), '{"compilerOptions":{}}\n')
|
|
52
|
+
writeFileSync(join(projectDir, 'drift.config.ts'), 'export default {}\n')
|
|
53
|
+
writeFileSync(join(projectDir, 'src', 'app.ts'), 'export const answer = 42\n')
|
|
54
|
+
|
|
55
|
+
const output: string[] = []
|
|
56
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
57
|
+
output.push(String(chunk))
|
|
58
|
+
return true
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await runDoctor(projectDir, { json: true })
|
|
62
|
+
const report = JSON.parse(output.join('')) as {
|
|
63
|
+
targetPath: string
|
|
64
|
+
node: { version: string; major: number; supported: boolean }
|
|
65
|
+
project: {
|
|
66
|
+
packageJsonFound: boolean
|
|
67
|
+
esm: boolean
|
|
68
|
+
tsconfigFound: boolean
|
|
69
|
+
sourceFilesCount: number
|
|
70
|
+
lowMemorySuggested: boolean
|
|
71
|
+
driftConfigFile: string | null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
expect(report.targetPath).toBe(projectDir)
|
|
76
|
+
expect(typeof report.node.version).toBe('string')
|
|
77
|
+
expect(typeof report.node.major).toBe('number')
|
|
78
|
+
expect(typeof report.node.supported).toBe('boolean')
|
|
79
|
+
expect(report.project.packageJsonFound).toBe(true)
|
|
80
|
+
expect(report.project.esm).toBe(true)
|
|
81
|
+
expect(report.project.tsconfigFound).toBe(true)
|
|
82
|
+
expect(report.project.sourceFilesCount).toBe(2)
|
|
83
|
+
expect(report.project.driftConfigFile).toBe('drift.config.ts')
|
|
84
|
+
expect(typeof report.project.lowMemorySuggested).toBe('boolean')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('runInit', () => {
|
|
89
|
+
it('creates drift.config.ts when using a valid preset', async () => {
|
|
90
|
+
const projectDir = createTempDir('drift-init-preset-')
|
|
91
|
+
tempDirs.push(projectDir)
|
|
92
|
+
|
|
93
|
+
await runInit(projectDir, { preset: 'node-backend' })
|
|
94
|
+
|
|
95
|
+
const generated = readFileSync(join(projectDir, 'drift.config.ts'), 'utf8')
|
|
96
|
+
expect(generated).toContain('satisfies DriftConfig')
|
|
97
|
+
expect(generated).toContain("name: 'api'")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('throws on invalid preset', async () => {
|
|
101
|
+
const projectDir = createTempDir('drift-init-invalid-')
|
|
102
|
+
tempDirs.push(projectDir)
|
|
103
|
+
|
|
104
|
+
await expect(runInit(projectDir, { preset: 'invalid-preset' })).rejects.toThrow("Invalid preset 'invalid-preset'")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('creates ci workflow when --ci flag is enabled', async () => {
|
|
108
|
+
const projectDir = createTempDir('drift-init-ci-')
|
|
109
|
+
tempDirs.push(projectDir)
|
|
110
|
+
|
|
111
|
+
await runInit(projectDir, { ci: true })
|
|
112
|
+
|
|
113
|
+
const workflowPath = join(projectDir, '.github', 'workflows', 'drift-review.yml')
|
|
114
|
+
const workflow = readFileSync(workflowPath, 'utf8')
|
|
115
|
+
expect(workflow).toContain('name: drift PR Review')
|
|
116
|
+
expect(workflow).toContain('npx drift review --base')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('prints no-actions message when no flags are provided', async () => {
|
|
120
|
+
const projectDir = createTempDir('drift-init-empty-')
|
|
121
|
+
tempDirs.push(projectDir)
|
|
122
|
+
|
|
123
|
+
const output: string[] = []
|
|
124
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
125
|
+
output.push(String(chunk))
|
|
126
|
+
return true
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await runInit(projectDir, {})
|
|
130
|
+
|
|
131
|
+
expect(output.join('')).toContain('No actions taken. Use --preset, --ci, or --baseline flags.')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('guard', () => {
|
|
136
|
+
it('evaluateGuard applies no-regression, budget and severity checks', () => {
|
|
137
|
+
const evaluation = evaluateGuard({
|
|
138
|
+
metrics: {
|
|
139
|
+
scoreDelta: 3,
|
|
140
|
+
totalIssuesDelta: 1,
|
|
141
|
+
severityDelta: { error: 1, warning: 0, info: 0 },
|
|
142
|
+
},
|
|
143
|
+
budget: 2,
|
|
144
|
+
bySeverity: { error: 0, warning: 1, info: 0 },
|
|
145
|
+
enforceNoRegression: {
|
|
146
|
+
score: true,
|
|
147
|
+
totalIssues: true,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(evaluation.passed).toBe(false)
|
|
152
|
+
expect(evaluation.checks.some((check) => check.id === 'no-regression-score' && !check.passed)).toBe(true)
|
|
153
|
+
expect(evaluation.checks.some((check) => check.id === 'no-regression-total-issues' && !check.passed)).toBe(true)
|
|
154
|
+
expect(evaluation.checks.some((check) => check.id === 'budget-total-delta' && !check.passed)).toBe(true)
|
|
155
|
+
expect(evaluation.checks.some((check) => check.id === 'severity-error' && !check.passed)).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('runs in baseline mode with inline baseline fixture', async () => {
|
|
159
|
+
const projectDir = createTempDir('drift-guard-baseline-')
|
|
160
|
+
tempDirs.push(projectDir)
|
|
161
|
+
writeFileSync(join(projectDir, 'main.ts'), 'export const value: any = 42\n')
|
|
162
|
+
|
|
163
|
+
const reports = analyzeProject(projectDir)
|
|
164
|
+
const current = buildReport(projectDir, reports)
|
|
165
|
+
|
|
166
|
+
const result = await runGuard(projectDir, {
|
|
167
|
+
baseline: {
|
|
168
|
+
score: current.totalScore,
|
|
169
|
+
totalIssues: current.totalIssues,
|
|
170
|
+
bySeverity: {
|
|
171
|
+
error: current.summary.errors,
|
|
172
|
+
warning: current.summary.warnings,
|
|
173
|
+
info: current.summary.infos,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
budget: 0,
|
|
177
|
+
bySeverity: { error: 0, warning: 0, info: 0 },
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(result.mode).toBe('baseline')
|
|
181
|
+
expect(result.passed).toBe(true)
|
|
182
|
+
expect(result.metrics.scoreDelta).toBe(0)
|
|
183
|
+
expect(result.metrics.totalIssuesDelta).toBe(0)
|
|
184
|
+
expect(result.metrics.severityDelta).toEqual({ error: 0, warning: 0, info: 0 })
|
|
185
|
+
expect(result.checks.some((check) => check.id === 'no-regression-score')).toBe(true)
|
|
186
|
+
expect(result.checks.some((check) => check.id === 'no-regression-total-issues')).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('throws when guard has no baseRef and no baseline', async () => {
|
|
190
|
+
const projectDir = createTempDir('drift-guard-missing-anchor-')
|
|
191
|
+
tempDirs.push(projectDir)
|
|
192
|
+
writeFileSync(join(projectDir, 'main.ts'), 'export const value = 1\n')
|
|
193
|
+
|
|
194
|
+
await expect(runGuard(projectDir, {})).rejects.toThrow(
|
|
195
|
+
'Guard requires a comparison point: provide baseRef or a baseline (inline or file).',
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
})
|