@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
package/src/config.ts
CHANGED
|
@@ -3,6 +3,22 @@ import { join, resolve } from 'node:path'
|
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import type { DriftConfig } from './types.js'
|
|
5
5
|
|
|
6
|
+
function normalizeLegacyConfig(config: DriftConfig): DriftConfig {
|
|
7
|
+
if (config.modules !== undefined) {
|
|
8
|
+
return config
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const legacyModules = config.moduleBoundaries ?? config.boundaries
|
|
12
|
+
if (!legacyModules || legacyModules.length === 0) {
|
|
13
|
+
return config
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...config,
|
|
18
|
+
modules: legacyModules,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* Load drift.config.ts / .js / .json from the given project root.
|
|
8
24
|
* Returns undefined if no config file is found.
|
|
@@ -27,7 +43,8 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
|
|
|
27
43
|
|
|
28
44
|
if (ext === 'json') {
|
|
29
45
|
const { readFileSync } = await import('node:fs')
|
|
30
|
-
|
|
46
|
+
const rawConfig = JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
|
|
47
|
+
return normalizeLegacyConfig(rawConfig)
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
// .ts / .js — dynamic import via file URL
|
|
@@ -35,7 +52,7 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
|
|
|
35
52
|
const mod = await import(fileUrl)
|
|
36
53
|
const config: DriftConfig = mod.default ?? mod
|
|
37
54
|
|
|
38
|
-
return config
|
|
55
|
+
return normalizeLegacyConfig(config)
|
|
39
56
|
} catch { // drift-ignore
|
|
40
57
|
// drift-ignore: catch-swallow — config is optional; load failure is non-fatal
|
|
41
58
|
}
|
package/src/diff.ts
CHANGED
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
|
|
2
2
|
|
|
3
|
+
function normalizePath(filePath: string): string {
|
|
4
|
+
return filePath.replace(/\\/g, '/')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeIssueText(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\r\n/g, '\n')
|
|
10
|
+
.replace(/\r/g, '\n')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SNIPPET_PREFIX_LENGTH = 80
|
|
16
|
+
|
|
17
|
+
interface IssueMatchState {
|
|
18
|
+
matchedBaseIndexes: Set<number>
|
|
19
|
+
matchedCurrentIndexes: Set<number>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function strictIssueKey(i: DriftIssue): string {
|
|
23
|
+
return `${i.rule}:${i.line}:${i.column}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizedIssueKey(i: DriftIssue): string {
|
|
27
|
+
const normalizedMessage = normalizeIssueText(i.message)
|
|
28
|
+
const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, SNIPPET_PREFIX_LENGTH)
|
|
29
|
+
return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildIssueIndex(
|
|
33
|
+
issues: DriftIssue[],
|
|
34
|
+
getKey: (issue: DriftIssue) => string,
|
|
35
|
+
skip?: Set<number>,
|
|
36
|
+
): Map<string, number[]> {
|
|
37
|
+
const index = new Map<string, number[]>()
|
|
38
|
+
for (const [idx, issue] of issues.entries()) {
|
|
39
|
+
if (skip?.has(idx)) continue
|
|
40
|
+
const key = getKey(issue)
|
|
41
|
+
const bucket = index.get(key)
|
|
42
|
+
if (bucket) bucket.push(idx)
|
|
43
|
+
else index.set(key, [idx])
|
|
44
|
+
}
|
|
45
|
+
return index
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchIssues(
|
|
49
|
+
currentIssues: DriftIssue[],
|
|
50
|
+
index: Map<string, number[]>,
|
|
51
|
+
state: IssueMatchState,
|
|
52
|
+
getKey: (issue: DriftIssue) => string,
|
|
53
|
+
): void {
|
|
54
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
55
|
+
if (state.matchedCurrentIndexes.has(currentIndex)) continue
|
|
56
|
+
const bucket = index.get(getKey(issue))
|
|
57
|
+
if (!bucket || bucket.length === 0) continue
|
|
58
|
+
|
|
59
|
+
const matchedIndex = bucket.shift()
|
|
60
|
+
if (matchedIndex === undefined) continue
|
|
61
|
+
|
|
62
|
+
state.matchedBaseIndexes.add(matchedIndex)
|
|
63
|
+
state.matchedCurrentIndexes.add(currentIndex)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
3
67
|
/**
|
|
4
68
|
* Compute the diff between two DriftReports.
|
|
5
69
|
*
|
|
6
|
-
* Issues are matched
|
|
70
|
+
* Issues are matched in two passes:
|
|
71
|
+
* 1) strict location key (rule + line + column)
|
|
72
|
+
* 2) normalized content key (rule + severity + line + message + snippet)
|
|
73
|
+
*
|
|
74
|
+
* This keeps deterministic matching while preventing false churn caused by
|
|
75
|
+
* cross-platform line ending changes and small column offset noise.
|
|
7
76
|
* A "new" issue exists in `current` but not in `base`.
|
|
8
77
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
78
|
*/
|
|
@@ -19,13 +88,18 @@ function computeFileDiff(
|
|
|
19
88
|
const baseIssues = baseFile?.issues ?? []
|
|
20
89
|
const currentIssues = currentFile?.issues ?? []
|
|
21
90
|
|
|
22
|
-
const
|
|
91
|
+
const matchedBaseIndexes = new Set<number>()
|
|
92
|
+
const matchedCurrentIndexes = new Set<number>()
|
|
93
|
+
const matchState = { matchedBaseIndexes, matchedCurrentIndexes }
|
|
94
|
+
|
|
95
|
+
const baseStrictIndex = buildIssueIndex(baseIssues, strictIssueKey)
|
|
96
|
+
matchIssues(currentIssues, baseStrictIndex, matchState, strictIssueKey)
|
|
23
97
|
|
|
24
|
-
const
|
|
25
|
-
|
|
98
|
+
const baseNormalizedIndex = buildIssueIndex(baseIssues, normalizedIssueKey, matchedBaseIndexes)
|
|
99
|
+
matchIssues(currentIssues, baseNormalizedIndex, matchState, normalizedIssueKey)
|
|
26
100
|
|
|
27
|
-
const newIssues = currentIssues.filter(
|
|
28
|
-
const resolvedIssues = baseIssues.filter(
|
|
101
|
+
const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
|
|
102
|
+
const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
|
|
29
103
|
|
|
30
104
|
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
31
105
|
return {
|
|
@@ -48,12 +122,12 @@ export function computeDiff(
|
|
|
48
122
|
): DriftDiff {
|
|
49
123
|
const fileDiffs: FileDiff[] = []
|
|
50
124
|
|
|
51
|
-
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
52
|
-
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
125
|
+
const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]))
|
|
126
|
+
const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]))
|
|
53
127
|
|
|
54
128
|
const allPaths = new Set([
|
|
55
|
-
...base.files.map(f => f.path),
|
|
56
|
-
...current.files.map(f => f.path),
|
|
129
|
+
...base.files.map(f => normalizePath(f.path)),
|
|
130
|
+
...current.files.map(f => normalizePath(f.path)),
|
|
57
131
|
])
|
|
58
132
|
|
|
59
133
|
for (const filePath of allPaths) {
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import kleur from 'kleur'
|
|
4
|
+
|
|
5
|
+
export interface DoctorOptions {
|
|
6
|
+
json?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DoctorReport {
|
|
10
|
+
targetPath: string
|
|
11
|
+
node: {
|
|
12
|
+
version: string
|
|
13
|
+
major: number
|
|
14
|
+
supported: boolean
|
|
15
|
+
}
|
|
16
|
+
project: {
|
|
17
|
+
packageJsonFound: boolean
|
|
18
|
+
esm: boolean
|
|
19
|
+
tsconfigFound: boolean
|
|
20
|
+
sourceFilesCount: number
|
|
21
|
+
lowMemorySuggested: boolean
|
|
22
|
+
driftConfigFile: string | null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
|
|
27
|
+
const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'coverage'])
|
|
28
|
+
const DECIMAL_RADIX = 10
|
|
29
|
+
const MIN_SUPPORTED_NODE_MAJOR = 18
|
|
30
|
+
const LOW_MEMORY_SOURCE_FILE_THRESHOLD = 500
|
|
31
|
+
const DRIFT_CONFIG_CANDIDATES = [
|
|
32
|
+
'drift.config.ts',
|
|
33
|
+
'drift.config.js',
|
|
34
|
+
'drift.config.mjs',
|
|
35
|
+
'drift.config.cjs',
|
|
36
|
+
'drift.config.json',
|
|
37
|
+
] as const
|
|
38
|
+
|
|
39
|
+
function parseNodeMajor(version: string): number {
|
|
40
|
+
const parsed = Number.parseInt(version.replace(/^v/, '').split('.')[0] ?? '0', DECIMAL_RADIX)
|
|
41
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectDriftConfig(projectPath: string): string | null {
|
|
45
|
+
for (const candidate of DRIFT_CONFIG_CANDIDATES) {
|
|
46
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
47
|
+
return candidate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function countSourceFiles(projectPath: string): number {
|
|
54
|
+
let total = 0
|
|
55
|
+
const stack = [projectPath]
|
|
56
|
+
|
|
57
|
+
while (stack.length > 0) {
|
|
58
|
+
const currentDir = stack.pop()
|
|
59
|
+
if (!currentDir) continue
|
|
60
|
+
|
|
61
|
+
const entries = readdirSync(currentDir, { withFileTypes: true })
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
|
|
64
|
+
stack.push(join(currentDir, entry.name))
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!entry.isFile()) continue
|
|
73
|
+
|
|
74
|
+
const lastDot = entry.name.lastIndexOf('.')
|
|
75
|
+
if (lastDot === -1) continue
|
|
76
|
+
|
|
77
|
+
const extension = entry.name.slice(lastDot)
|
|
78
|
+
if (SOURCE_EXTENSIONS.has(extension)) {
|
|
79
|
+
total += 1
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return total
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildDoctorReport(projectPath: string): DoctorReport {
|
|
88
|
+
const nodeMajor = parseNodeMajor(process.version)
|
|
89
|
+
const packageJsonPath = join(projectPath, 'package.json')
|
|
90
|
+
const packageJsonFound = existsSync(packageJsonPath)
|
|
91
|
+
|
|
92
|
+
let esm = false
|
|
93
|
+
if (packageJsonFound) {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }
|
|
95
|
+
esm = parsed.type === 'module'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sourceFilesCount = countSourceFiles(projectPath)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
targetPath: projectPath,
|
|
102
|
+
node: {
|
|
103
|
+
version: process.version,
|
|
104
|
+
major: nodeMajor,
|
|
105
|
+
supported: nodeMajor >= MIN_SUPPORTED_NODE_MAJOR,
|
|
106
|
+
},
|
|
107
|
+
project: {
|
|
108
|
+
packageJsonFound,
|
|
109
|
+
esm,
|
|
110
|
+
tsconfigFound: existsSync(join(projectPath, 'tsconfig.json')),
|
|
111
|
+
sourceFilesCount,
|
|
112
|
+
lowMemorySuggested: sourceFilesCount > LOW_MEMORY_SOURCE_FILE_THRESHOLD,
|
|
113
|
+
driftConfigFile: detectDriftConfig(projectPath),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printConsoleReport(report: DoctorReport): void {
|
|
119
|
+
const icons = {
|
|
120
|
+
check: kleur.green('✓'),
|
|
121
|
+
warn: kleur.yellow('⚠'),
|
|
122
|
+
error: kleur.red('✗'),
|
|
123
|
+
info: kleur.cyan('ℹ'),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
process.stdout.write('\n')
|
|
127
|
+
process.stdout.write(`${kleur.bold().white('drift doctor')} ${kleur.gray('- environment diagnostics')}\n\n`)
|
|
128
|
+
|
|
129
|
+
const nodeStatus = report.node.supported
|
|
130
|
+
? `${icons.check} ${kleur.green('Node runtime supported')}`
|
|
131
|
+
: `${icons.warn} ${kleur.yellow('Node runtime below recommended minimum (>=18)')}`
|
|
132
|
+
process.stdout.write(`${nodeStatus} ${kleur.gray(`(${report.node.version})`)}\n`)
|
|
133
|
+
|
|
134
|
+
if (report.project.packageJsonFound) {
|
|
135
|
+
process.stdout.write(`${icons.check} package.json found\n`)
|
|
136
|
+
process.stdout.write(`${icons.info} ESM mode: ${report.project.esm ? kleur.green('yes') : kleur.yellow('no')}\n`)
|
|
137
|
+
} else {
|
|
138
|
+
process.stdout.write(`${icons.warn} package.json not found\n`)
|
|
139
|
+
process.stdout.write(`${icons.info} ESM mode: ${kleur.gray('unknown')}\n`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (report.project.tsconfigFound) {
|
|
143
|
+
process.stdout.write(`${icons.check} tsconfig.json found\n`)
|
|
144
|
+
} else {
|
|
145
|
+
process.stdout.write(`${icons.warn} tsconfig.json not found\n`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
process.stdout.write(`${icons.info} Source files (.ts/.tsx/.js/.jsx): ${report.project.sourceFilesCount}\n`)
|
|
149
|
+
|
|
150
|
+
if (report.project.lowMemorySuggested) {
|
|
151
|
+
process.stdout.write(`${icons.warn} Large codebase detected, consider ${kleur.bold('--low-memory')}\n`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (report.project.driftConfigFile) {
|
|
155
|
+
process.stdout.write(`${icons.check} Drift config: ${report.project.driftConfigFile}\n`)
|
|
156
|
+
} else {
|
|
157
|
+
process.stdout.write(`${icons.warn} Drift config not found (drift.config.*)\n`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
process.stdout.write('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runDoctor(projectPath: string, options?: DoctorOptions): Promise<number> {
|
|
164
|
+
const report = buildDoctorReport(projectPath)
|
|
165
|
+
|
|
166
|
+
if (options?.json) {
|
|
167
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
168
|
+
} else {
|
|
169
|
+
printConsoleReport(report)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return 0
|
|
173
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const UNIFIED_FORMAT_VALUES = ['console', 'json', 'markdown', 'ai', 'sarif'] as const
|
|
2
|
+
|
|
3
|
+
type UnifiedOutputFormat = (typeof UNIFIED_FORMAT_VALUES)[number]
|
|
4
|
+
|
|
5
|
+
type LegacyAlias = {
|
|
6
|
+
flag: string
|
|
7
|
+
used?: boolean
|
|
8
|
+
mapsTo: UnifiedOutputFormat
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ResolveOutputFormatOptions {
|
|
12
|
+
command: string
|
|
13
|
+
format?: string
|
|
14
|
+
supported: readonly UnifiedOutputFormat[]
|
|
15
|
+
legacyAliases?: LegacyAlias[]
|
|
16
|
+
onWarning?: (message: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertSupportedFormatValue(command: string, format: string): asserts format is UnifiedOutputFormat {
|
|
20
|
+
if ((UNIFIED_FORMAT_VALUES as readonly string[]).includes(format)) return
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid --format '${format}' for '${command}'. Allowed values: ${UNIFIED_FORMAT_VALUES.join(', ')}.`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwUnsupportedFormat(command: string, selected: UnifiedOutputFormat, supported: readonly UnifiedOutputFormat[]): never {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Format '${selected}' is not supported for '${command}'. Supported formats: ${supported.join(', ')}.`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeLegacyFormatSelection(command: string, selectedLegacyFormats: UnifiedOutputFormat[]): UnifiedOutputFormat | undefined {
|
|
33
|
+
if (selectedLegacyFormats.length === 0) return undefined
|
|
34
|
+
|
|
35
|
+
const uniqueFormats = [...new Set(selectedLegacyFormats)]
|
|
36
|
+
if (uniqueFormats.length > 1) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Conflicting legacy format flags for '${command}': ${uniqueFormats.join(' vs ')}. Use a single format option.`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return uniqueFormats[0]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveOutputFormat(options: ResolveOutputFormatOptions): UnifiedOutputFormat {
|
|
46
|
+
const { command, format, supported, onWarning } = options
|
|
47
|
+
const legacyAliases = options.legacyAliases ?? []
|
|
48
|
+
|
|
49
|
+
for (const alias of legacyAliases) {
|
|
50
|
+
if (!alias.used) continue
|
|
51
|
+
onWarning?.(`Warning: --${alias.flag} is deprecated for '${command}'. Use --format ${alias.mapsTo} instead.`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const selectedLegacyFormat = normalizeLegacyFormatSelection(
|
|
55
|
+
command,
|
|
56
|
+
legacyAliases.filter((alias) => alias.used).map((alias) => alias.mapsTo),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const selectedFormat = format?.trim()
|
|
60
|
+
if (selectedFormat) {
|
|
61
|
+
assertSupportedFormatValue(command, selectedFormat)
|
|
62
|
+
if (selectedLegacyFormat && selectedLegacyFormat !== selectedFormat) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Conflicting format flags for '${command}': --format ${selectedFormat} and legacy alias for ${selectedLegacyFormat}.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!supported.includes(selectedFormat)) {
|
|
69
|
+
throwUnsupportedFormat(command, selectedFormat, supported)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return selectedFormat
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resolvedFromLegacy = selectedLegacyFormat ?? 'console'
|
|
76
|
+
if (!supported.includes(resolvedFromLegacy)) {
|
|
77
|
+
throwUnsupportedFormat(command, resolvedFromLegacy, supported)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return resolvedFromLegacy
|
|
81
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -63,6 +63,18 @@ function extractFile(projectPath: string, ref: string, filePath: string, tempDir
|
|
|
63
63
|
writeFileSync(destPath, content, 'utf-8')
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function extractArchiveAtRef(projectPath: string, ref: string, tempDir: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
execSync(
|
|
69
|
+
`git archive --format=tar ${ref} | tar -x -C "${tempDir}"`,
|
|
70
|
+
{ cwd: projectPath, stdio: 'pipe' }
|
|
71
|
+
)
|
|
72
|
+
return true
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
67
79
|
verifyGitRepo(projectPath)
|
|
68
80
|
verifyRefExists(projectPath, ref)
|
|
@@ -76,6 +88,10 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
|
76
88
|
const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
|
|
77
89
|
mkdirSync(tempDir, { recursive: true })
|
|
78
90
|
|
|
91
|
+
if (extractArchiveAtRef(projectPath, ref, tempDir)) {
|
|
92
|
+
return tempDir
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
for (const filePath of tsFiles) {
|
|
80
96
|
extractFile(projectPath, ref, filePath, tempDir)
|
|
81
97
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DriftAnalysisOptions, DriftDiff, DriftIssue, DriftReport } from './types.js'
|
|
2
|
+
|
|
3
|
+
export type IssueSeverity = DriftIssue['severity']
|
|
4
|
+
|
|
5
|
+
export interface GuardBaseline {
|
|
6
|
+
score?: number
|
|
7
|
+
totalIssues?: number
|
|
8
|
+
errors?: number
|
|
9
|
+
warnings?: number
|
|
10
|
+
infos?: number
|
|
11
|
+
bySeverity?: Partial<Record<IssueSeverity, number>>
|
|
12
|
+
summary?: {
|
|
13
|
+
errors?: number
|
|
14
|
+
warnings?: number
|
|
15
|
+
infos?: number
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GuardThresholds {
|
|
20
|
+
error?: number
|
|
21
|
+
warning?: number
|
|
22
|
+
info?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GuardOptions {
|
|
26
|
+
baseRef?: string
|
|
27
|
+
baselinePath?: string
|
|
28
|
+
baseline?: GuardBaseline
|
|
29
|
+
budget?: number
|
|
30
|
+
bySeverity?: GuardThresholds
|
|
31
|
+
analysis?: DriftAnalysisOptions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GuardMetrics {
|
|
35
|
+
scoreDelta: number
|
|
36
|
+
totalIssuesDelta: number
|
|
37
|
+
severityDelta: Record<IssueSeverity, number>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GuardCheck {
|
|
41
|
+
id: string
|
|
42
|
+
passed: boolean
|
|
43
|
+
actual: number
|
|
44
|
+
limit: number
|
|
45
|
+
message: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GuardEvaluation {
|
|
49
|
+
passed: boolean
|
|
50
|
+
checks: GuardCheck[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GuardResult {
|
|
54
|
+
scannedAt: string
|
|
55
|
+
projectPath: string
|
|
56
|
+
mode: 'diff' | 'baseline'
|
|
57
|
+
passed: boolean
|
|
58
|
+
baseRef?: string
|
|
59
|
+
baselinePath?: string
|
|
60
|
+
metrics: GuardMetrics
|
|
61
|
+
checks: GuardCheck[]
|
|
62
|
+
current: DriftReport
|
|
63
|
+
diff?: DriftDiff
|
|
64
|
+
}
|