@eduardbar/drift 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +98 -6
- package/AGENTS.md +6 -0
- package/README.md +160 -10
- 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 +185 -0
- package/dist/cli.js +453 -62
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +177 -28
- package/dist/printer.js +4 -0
- package/dist/review.js +2 -2
- 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/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas.d.ts +143 -7
- package/dist/saas.js +478 -37
- package/dist/trust-kpi.d.ts +9 -0
- package/dist/trust-kpi.js +445 -0
- package/dist/trust.d.ts +65 -0
- package/dist/trust.js +571 -0
- package/dist/types.d.ts +154 -0
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +562 -79
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +48 -0
- package/src/plugins.ts +354 -26
- package/src/printer.ts +4 -0
- package/src/review.ts +2 -2
- 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/shared.ts +31 -3
- package/src/saas.ts +641 -43
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +171 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +71 -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/trust-kpi.test.ts +120 -0
- package/tests/trust.test.ts +584 -0
package/src/diff.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
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
|
+
|
|
3
15
|
/**
|
|
4
16
|
* Compute the diff between two DriftReports.
|
|
5
17
|
*
|
|
6
|
-
* Issues are matched
|
|
18
|
+
* Issues are matched in two passes:
|
|
19
|
+
* 1) strict location key (rule + line + column)
|
|
20
|
+
* 2) normalized content key (rule + severity + line + message + snippet)
|
|
21
|
+
*
|
|
22
|
+
* This keeps deterministic matching while preventing false churn caused by
|
|
23
|
+
* cross-platform line ending changes and small column offset noise.
|
|
7
24
|
* A "new" issue exists in `current` but not in `base`.
|
|
8
25
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
26
|
*/
|
|
@@ -19,13 +36,61 @@ function computeFileDiff(
|
|
|
19
36
|
const baseIssues = baseFile?.issues ?? []
|
|
20
37
|
const currentIssues = currentFile?.issues ?? []
|
|
21
38
|
|
|
22
|
-
const
|
|
39
|
+
const strictIssueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
+
const normalizedIssueKey = (i: DriftIssue) => {
|
|
41
|
+
const normalizedMessage = normalizeIssueText(i.message)
|
|
42
|
+
const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, 80)
|
|
43
|
+
return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const matchedBaseIndexes = new Set<number>()
|
|
47
|
+
const matchedCurrentIndexes = new Set<number>()
|
|
48
|
+
|
|
49
|
+
const baseStrictIndex = new Map<string, number[]>()
|
|
50
|
+
for (const [index, issue] of baseIssues.entries()) {
|
|
51
|
+
const key = strictIssueKey(issue)
|
|
52
|
+
const bucket = baseStrictIndex.get(key)
|
|
53
|
+
if (bucket) bucket.push(index)
|
|
54
|
+
else baseStrictIndex.set(key, [index])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
58
|
+
const key = strictIssueKey(issue)
|
|
59
|
+
const bucket = baseStrictIndex.get(key)
|
|
60
|
+
if (!bucket || bucket.length === 0) continue
|
|
23
61
|
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
const matchedIndex = bucket.shift()
|
|
63
|
+
if (matchedIndex === undefined) continue
|
|
64
|
+
|
|
65
|
+
matchedBaseIndexes.add(matchedIndex)
|
|
66
|
+
matchedCurrentIndexes.add(currentIndex)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const baseNormalizedIndex = new Map<string, number[]>()
|
|
70
|
+
for (const [index, issue] of baseIssues.entries()) {
|
|
71
|
+
if (matchedBaseIndexes.has(index)) continue
|
|
72
|
+
const key = normalizedIssueKey(issue)
|
|
73
|
+
const bucket = baseNormalizedIndex.get(key)
|
|
74
|
+
if (bucket) bucket.push(index)
|
|
75
|
+
else baseNormalizedIndex.set(key, [index])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [currentIndex, issue] of currentIssues.entries()) {
|
|
79
|
+
if (matchedCurrentIndexes.has(currentIndex)) continue
|
|
80
|
+
|
|
81
|
+
const key = normalizedIssueKey(issue)
|
|
82
|
+
const bucket = baseNormalizedIndex.get(key)
|
|
83
|
+
if (!bucket || bucket.length === 0) continue
|
|
84
|
+
|
|
85
|
+
const matchedIndex = bucket.shift()
|
|
86
|
+
if (matchedIndex === undefined) continue
|
|
87
|
+
|
|
88
|
+
matchedBaseIndexes.add(matchedIndex)
|
|
89
|
+
matchedCurrentIndexes.add(currentIndex)
|
|
90
|
+
}
|
|
26
91
|
|
|
27
|
-
const newIssues = currentIssues.filter(
|
|
28
|
-
const resolvedIssues = baseIssues.filter(
|
|
92
|
+
const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
|
|
93
|
+
const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
|
|
29
94
|
|
|
30
95
|
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
31
96
|
return {
|
|
@@ -48,12 +113,12 @@ export function computeDiff(
|
|
|
48
113
|
): DriftDiff {
|
|
49
114
|
const fileDiffs: FileDiff[] = []
|
|
50
115
|
|
|
51
|
-
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
52
|
-
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
116
|
+
const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]))
|
|
117
|
+
const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]))
|
|
53
118
|
|
|
54
119
|
const allPaths = new Set([
|
|
55
|
-
...base.files.map(f => f.path),
|
|
56
|
-
...current.files.map(f => f.path),
|
|
120
|
+
...base.files.map(f => normalizePath(f.path)),
|
|
121
|
+
...current.files.map(f => normalizePath(f.path)),
|
|
57
122
|
])
|
|
58
123
|
|
|
59
124
|
for (const filePath of allPaths) {
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,22 @@ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
|
|
|
2
2
|
export { buildReport, formatMarkdown } from './reporter.js'
|
|
3
3
|
export { computeDiff } from './diff.js'
|
|
4
4
|
export { generateReview, formatReviewMarkdown } from './review.js'
|
|
5
|
+
export {
|
|
6
|
+
buildTrustReport,
|
|
7
|
+
formatTrustConsole,
|
|
8
|
+
formatTrustMarkdown,
|
|
9
|
+
formatTrustJson,
|
|
10
|
+
shouldFailByMaxRisk,
|
|
11
|
+
shouldFailTrustGate,
|
|
12
|
+
normalizeMergeRiskLevel,
|
|
13
|
+
MERGE_RISK_ORDER,
|
|
14
|
+
} from './trust.js'
|
|
15
|
+
export {
|
|
16
|
+
computeTrustKpis,
|
|
17
|
+
computeTrustKpisFromReports,
|
|
18
|
+
formatTrustKpiConsole,
|
|
19
|
+
formatTrustKpiJson,
|
|
20
|
+
} from './trust-kpi.js'
|
|
5
21
|
export { generateArchitectureMap, generateArchitectureSvg } from './map.js'
|
|
6
22
|
export type {
|
|
7
23
|
DriftReport,
|
|
@@ -12,6 +28,15 @@ export type {
|
|
|
12
28
|
DriftConfig,
|
|
13
29
|
RepoQualityScore,
|
|
14
30
|
MaintenanceRiskMetrics,
|
|
31
|
+
DriftTrustReport,
|
|
32
|
+
TrustReason,
|
|
33
|
+
TrustFixPriority,
|
|
34
|
+
TrustDiffContext,
|
|
35
|
+
TrustKpiReport,
|
|
36
|
+
TrustKpiDiagnostic,
|
|
37
|
+
TrustDiffTrendSummary,
|
|
38
|
+
TrustScoreStats,
|
|
39
|
+
MergeRiskLevel,
|
|
15
40
|
DriftPlugin,
|
|
16
41
|
DriftPluginRule,
|
|
17
42
|
} from './types.js'
|
|
@@ -21,14 +46,37 @@ export {
|
|
|
21
46
|
DEFAULT_SAAS_POLICY,
|
|
22
47
|
defaultSaasStorePath,
|
|
23
48
|
resolveSaasPolicy,
|
|
49
|
+
SaasActorRequiredError,
|
|
50
|
+
SaasPermissionError,
|
|
51
|
+
getRequiredRoleForOperation,
|
|
52
|
+
assertSaasPermission,
|
|
53
|
+
getSaasEffectiveLimits,
|
|
54
|
+
getOrganizationEffectiveLimits,
|
|
55
|
+
changeOrganizationPlan,
|
|
56
|
+
listOrganizationPlanChanges,
|
|
57
|
+
getOrganizationUsageSnapshot,
|
|
24
58
|
ingestSnapshotFromReport,
|
|
59
|
+
listSaasSnapshots,
|
|
25
60
|
getSaasSummary,
|
|
26
61
|
generateSaasDashboardHtml,
|
|
27
62
|
} from './saas.js'
|
|
28
63
|
export type {
|
|
64
|
+
SaasRole,
|
|
65
|
+
SaasPlan,
|
|
29
66
|
SaasPolicy,
|
|
67
|
+
SaasPolicyOverrides,
|
|
30
68
|
SaasStore,
|
|
31
69
|
SaasSummary,
|
|
32
70
|
SaasSnapshot,
|
|
71
|
+
SaasQueryOptions,
|
|
33
72
|
IngestOptions,
|
|
73
|
+
SaasPlanChange,
|
|
74
|
+
SaasOperation,
|
|
75
|
+
SaasPermissionContext,
|
|
76
|
+
SaasPermissionResult,
|
|
77
|
+
SaasEffectiveLimits,
|
|
78
|
+
SaasOrganizationUsageSnapshot,
|
|
79
|
+
ChangeOrganizationPlanOptions,
|
|
80
|
+
SaasUsageQueryOptions,
|
|
81
|
+
SaasPlanChangeQueryOptions,
|
|
34
82
|
} from './saas.js'
|
package/src/plugins.ts
CHANGED
|
@@ -1,30 +1,353 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path'
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
|
-
import type { DriftPlugin, PluginLoadError, LoadedPlugin } from './types.js'
|
|
4
|
+
import type { DriftIssue, DriftPlugin, DriftPluginRule, PluginLoadError, PluginLoadWarning, LoadedPlugin } from './types.js'
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url)
|
|
7
|
+
const VALID_SEVERITIES: DriftIssue['severity'][] = ['error', 'warning', 'info']
|
|
8
|
+
const SUPPORTED_PLUGIN_API_VERSION = 1
|
|
9
|
+
const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return candidate.rules.every((rule) =>
|
|
14
|
-
rule &&
|
|
15
|
-
typeof rule === 'object' &&
|
|
16
|
-
typeof rule.name === 'string' &&
|
|
17
|
-
typeof rule.detect === 'function'
|
|
18
|
-
)
|
|
11
|
+
type PluginCandidate = {
|
|
12
|
+
name?: unknown
|
|
13
|
+
apiVersion?: unknown
|
|
14
|
+
capabilities?: unknown
|
|
15
|
+
rules?: unknown
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
type RuleCandidate = {
|
|
19
|
+
id?: unknown
|
|
20
|
+
name?: unknown
|
|
21
|
+
severity?: unknown
|
|
22
|
+
weight?: unknown
|
|
23
|
+
detect?: unknown
|
|
24
|
+
fix?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizePluginExport(mod: unknown): unknown {
|
|
23
28
|
if (mod && typeof mod === 'object' && 'default' in mod) {
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
return (mod as { default?: unknown }).default ?? mod
|
|
30
|
+
}
|
|
31
|
+
return mod
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pushError(
|
|
35
|
+
errors: PluginLoadError[],
|
|
36
|
+
pluginId: string,
|
|
37
|
+
message: string,
|
|
38
|
+
options?: { pluginName?: string; ruleId?: string; code?: string },
|
|
39
|
+
): void {
|
|
40
|
+
errors.push({
|
|
41
|
+
pluginId,
|
|
42
|
+
pluginName: options?.pluginName,
|
|
43
|
+
ruleId: options?.ruleId,
|
|
44
|
+
code: options?.code,
|
|
45
|
+
message,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pushWarning(
|
|
50
|
+
warnings: PluginLoadWarning[],
|
|
51
|
+
pluginId: string,
|
|
52
|
+
message: string,
|
|
53
|
+
options?: { pluginName?: string; ruleId?: string; code?: string },
|
|
54
|
+
): void {
|
|
55
|
+
warnings.push({
|
|
56
|
+
pluginId,
|
|
57
|
+
pluginName: options?.pluginName,
|
|
58
|
+
ruleId: options?.ruleId,
|
|
59
|
+
code: options?.code,
|
|
60
|
+
message,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeRule(
|
|
65
|
+
pluginId: string,
|
|
66
|
+
pluginName: string,
|
|
67
|
+
rawRule: RuleCandidate,
|
|
68
|
+
ruleIndex: number,
|
|
69
|
+
options: { strictRuleId: boolean },
|
|
70
|
+
errors: PluginLoadError[],
|
|
71
|
+
warnings: PluginLoadWarning[],
|
|
72
|
+
): DriftPluginRule | undefined {
|
|
73
|
+
const rawRuleId = typeof rawRule.id === 'string'
|
|
74
|
+
? rawRule.id.trim()
|
|
75
|
+
: typeof rawRule.name === 'string'
|
|
76
|
+
? rawRule.name.trim()
|
|
77
|
+
: ''
|
|
78
|
+
|
|
79
|
+
const ruleLabel = rawRuleId || `rule#${ruleIndex + 1}`
|
|
80
|
+
|
|
81
|
+
if (!rawRuleId) {
|
|
82
|
+
pushError(
|
|
83
|
+
errors,
|
|
84
|
+
pluginId,
|
|
85
|
+
`Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`,
|
|
86
|
+
{ pluginName, code: 'plugin-rule-id-missing' },
|
|
87
|
+
)
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof rawRule.detect !== 'function') {
|
|
92
|
+
pushError(
|
|
93
|
+
errors,
|
|
94
|
+
pluginId,
|
|
95
|
+
`Rule '${rawRuleId}' is invalid. Expected 'detect(file, context)' function.`,
|
|
96
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-invalid' },
|
|
97
|
+
)
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (rawRule.detect.length > 2) {
|
|
102
|
+
pushWarning(
|
|
103
|
+
warnings,
|
|
104
|
+
pluginId,
|
|
105
|
+
`Rule '${rawRuleId}' detect() declares ${rawRule.detect.length} parameters. Expected 1-2 parameters (file, context).`,
|
|
106
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-arity' },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!RULE_ID_REQUIRED.test(rawRuleId)) {
|
|
111
|
+
if (options.strictRuleId) {
|
|
112
|
+
pushError(
|
|
113
|
+
errors,
|
|
114
|
+
pluginId,
|
|
115
|
+
`Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`,
|
|
116
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' },
|
|
117
|
+
)
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pushWarning(
|
|
122
|
+
warnings,
|
|
123
|
+
pluginId,
|
|
124
|
+
`Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
125
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' },
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let severity: DriftIssue['severity'] | undefined
|
|
130
|
+
if (rawRule.severity !== undefined) {
|
|
131
|
+
if (typeof rawRule.severity === 'string' && VALID_SEVERITIES.includes(rawRule.severity as DriftIssue['severity'])) {
|
|
132
|
+
severity = rawRule.severity as DriftIssue['severity']
|
|
133
|
+
} else {
|
|
134
|
+
pushError(
|
|
135
|
+
errors,
|
|
136
|
+
pluginId,
|
|
137
|
+
`Rule '${rawRuleId}' has invalid severity '${String(rawRule.severity)}'. Allowed: error, warning, info.`,
|
|
138
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-severity-invalid' },
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let weight: number | undefined
|
|
144
|
+
if (rawRule.weight !== undefined) {
|
|
145
|
+
if (typeof rawRule.weight === 'number' && Number.isFinite(rawRule.weight) && rawRule.weight >= 0 && rawRule.weight <= 100) {
|
|
146
|
+
weight = rawRule.weight
|
|
147
|
+
} else {
|
|
148
|
+
pushError(
|
|
149
|
+
errors,
|
|
150
|
+
pluginId,
|
|
151
|
+
`Rule '${rawRuleId}' has invalid weight '${String(rawRule.weight)}'. Expected a finite number between 0 and 100.`,
|
|
152
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-weight-invalid' },
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let fix: DriftPluginRule['fix'] | undefined
|
|
158
|
+
if (rawRule.fix !== undefined) {
|
|
159
|
+
if (typeof rawRule.fix === 'function') {
|
|
160
|
+
fix = rawRule.fix as DriftPluginRule['fix']
|
|
161
|
+
if (rawRule.fix.length > 3) {
|
|
162
|
+
pushWarning(
|
|
163
|
+
warnings,
|
|
164
|
+
pluginId,
|
|
165
|
+
`Rule '${rawRuleId}' fix() declares ${rawRule.fix.length} parameters. Expected up to 3 (issue, file, context).`,
|
|
166
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-arity' },
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
pushError(
|
|
171
|
+
errors,
|
|
172
|
+
pluginId,
|
|
173
|
+
`Rule '${rawRuleId}' has invalid fix. Expected a function when provided.`,
|
|
174
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-invalid' },
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id: rawRuleId,
|
|
181
|
+
name: rawRuleId,
|
|
182
|
+
detect: rawRule.detect as DriftPluginRule['detect'],
|
|
183
|
+
severity,
|
|
184
|
+
weight,
|
|
185
|
+
fix,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validatePluginContract(
|
|
190
|
+
pluginId: string,
|
|
191
|
+
candidate: unknown,
|
|
192
|
+
): {
|
|
193
|
+
plugin?: DriftPlugin
|
|
194
|
+
errors: PluginLoadError[]
|
|
195
|
+
warnings: PluginLoadWarning[]
|
|
196
|
+
} {
|
|
197
|
+
const errors: PluginLoadError[] = []
|
|
198
|
+
const warnings: PluginLoadWarning[] = []
|
|
199
|
+
|
|
200
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
201
|
+
pushError(
|
|
202
|
+
errors,
|
|
203
|
+
pluginId,
|
|
204
|
+
`Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`,
|
|
205
|
+
{ code: 'plugin-shape-invalid' },
|
|
206
|
+
)
|
|
207
|
+
return { errors, warnings }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const plugin = candidate as PluginCandidate
|
|
211
|
+
const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : ''
|
|
212
|
+
if (!pluginName) {
|
|
213
|
+
pushError(
|
|
214
|
+
errors,
|
|
215
|
+
pluginId,
|
|
216
|
+
`Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`,
|
|
217
|
+
{ code: 'plugin-name-missing' },
|
|
218
|
+
)
|
|
219
|
+
return { errors, warnings }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hasExplicitApiVersion = plugin.apiVersion !== undefined
|
|
223
|
+
const isLegacyPlugin = !hasExplicitApiVersion
|
|
224
|
+
|
|
225
|
+
if (isLegacyPlugin) {
|
|
226
|
+
pushWarning(
|
|
227
|
+
warnings,
|
|
228
|
+
pluginId,
|
|
229
|
+
`Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
230
|
+
{ pluginName, code: 'plugin-api-version-implicit' },
|
|
231
|
+
)
|
|
232
|
+
} else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
|
|
233
|
+
pushError(
|
|
234
|
+
errors,
|
|
235
|
+
pluginId,
|
|
236
|
+
`Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`,
|
|
237
|
+
{ pluginName, code: 'plugin-api-version-invalid' },
|
|
238
|
+
)
|
|
239
|
+
return { errors, warnings }
|
|
240
|
+
} else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
|
|
241
|
+
pushError(
|
|
242
|
+
errors,
|
|
243
|
+
pluginId,
|
|
244
|
+
`Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
245
|
+
{ pluginName, code: 'plugin-api-version-unsupported' },
|
|
246
|
+
)
|
|
247
|
+
return { errors, warnings }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let capabilities: DriftPlugin['capabilities'] | undefined
|
|
251
|
+
if (plugin.capabilities !== undefined) {
|
|
252
|
+
if (!plugin.capabilities || typeof plugin.capabilities !== 'object' || Array.isArray(plugin.capabilities)) {
|
|
253
|
+
pushError(
|
|
254
|
+
errors,
|
|
255
|
+
pluginId,
|
|
256
|
+
`Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`,
|
|
257
|
+
{ pluginName, code: 'plugin-capabilities-invalid' },
|
|
258
|
+
)
|
|
259
|
+
return { errors, warnings }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries = Object.entries(plugin.capabilities as Record<string, unknown>)
|
|
263
|
+
for (const [capabilityKey, capabilityValue] of entries) {
|
|
264
|
+
const capabilityType = typeof capabilityValue
|
|
265
|
+
if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
|
|
266
|
+
pushError(
|
|
267
|
+
errors,
|
|
268
|
+
pluginId,
|
|
269
|
+
`Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`,
|
|
270
|
+
{ pluginName, code: 'plugin-capabilities-value-invalid' },
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (errors.length > 0) {
|
|
276
|
+
return { errors, warnings }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
capabilities = plugin.capabilities as DriftPlugin['capabilities']
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!Array.isArray(plugin.rules)) {
|
|
283
|
+
pushError(
|
|
284
|
+
errors,
|
|
285
|
+
pluginId,
|
|
286
|
+
`Invalid plugin '${pluginName}'. Expected 'rules' to be an array.`,
|
|
287
|
+
{ pluginName, code: 'plugin-rules-not-array' },
|
|
288
|
+
)
|
|
289
|
+
return { errors, warnings }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const normalizedRules: DriftPluginRule[] = []
|
|
293
|
+
const seenRuleIds = new Set<string>()
|
|
294
|
+
|
|
295
|
+
for (const [ruleIndex, rawRule] of plugin.rules.entries()) {
|
|
296
|
+
if (!rawRule || typeof rawRule !== 'object') {
|
|
297
|
+
pushError(
|
|
298
|
+
errors,
|
|
299
|
+
pluginId,
|
|
300
|
+
`Invalid rule at index ${ruleIndex} in plugin '${pluginName}'. Expected an object.`,
|
|
301
|
+
{ pluginName, code: 'plugin-rule-shape-invalid' },
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const normalized = normalizeRule(
|
|
307
|
+
pluginId,
|
|
308
|
+
pluginName,
|
|
309
|
+
rawRule as RuleCandidate,
|
|
310
|
+
ruleIndex,
|
|
311
|
+
{ strictRuleId: !isLegacyPlugin },
|
|
312
|
+
errors,
|
|
313
|
+
warnings,
|
|
314
|
+
)
|
|
315
|
+
if (normalized) {
|
|
316
|
+
if (seenRuleIds.has(normalized.id ?? normalized.name)) {
|
|
317
|
+
pushError(
|
|
318
|
+
errors,
|
|
319
|
+
pluginId,
|
|
320
|
+
`Plugin '${pluginName}' defines duplicate rule id '${normalized.id ?? normalized.name}'. Rule ids must be unique within a plugin.`,
|
|
321
|
+
{ pluginName, ruleId: normalized.id ?? normalized.name, code: 'plugin-rule-id-duplicate' },
|
|
322
|
+
)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
seenRuleIds.add(normalized.id ?? normalized.name)
|
|
327
|
+
normalizedRules.push(normalized)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (normalizedRules.length === 0) {
|
|
332
|
+
pushError(
|
|
333
|
+
errors,
|
|
334
|
+
pluginId,
|
|
335
|
+
`Plugin '${pluginName}' has no valid rules after validation.`,
|
|
336
|
+
{ pluginName, code: 'plugin-rules-empty' },
|
|
337
|
+
)
|
|
338
|
+
return { errors, warnings }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
plugin: {
|
|
343
|
+
name: pluginName,
|
|
344
|
+
apiVersion: hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
|
|
345
|
+
capabilities,
|
|
346
|
+
rules: normalizedRules,
|
|
347
|
+
},
|
|
348
|
+
errors,
|
|
349
|
+
warnings,
|
|
26
350
|
}
|
|
27
|
-
return undefined
|
|
28
351
|
}
|
|
29
352
|
|
|
30
353
|
function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
@@ -43,34 +366,39 @@ function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
|
43
366
|
export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
|
|
44
367
|
plugins: LoadedPlugin[]
|
|
45
368
|
errors: PluginLoadError[]
|
|
369
|
+
warnings: PluginLoadWarning[]
|
|
46
370
|
} {
|
|
47
371
|
if (!pluginIds || pluginIds.length === 0) {
|
|
48
|
-
return { plugins: [], errors: [] }
|
|
372
|
+
return { plugins: [], errors: [], warnings: [] }
|
|
49
373
|
}
|
|
50
374
|
|
|
51
375
|
const loaded: LoadedPlugin[] = []
|
|
52
376
|
const errors: PluginLoadError[] = []
|
|
377
|
+
const warnings: PluginLoadWarning[] = []
|
|
53
378
|
|
|
54
379
|
for (const pluginId of pluginIds) {
|
|
55
380
|
const resolved = resolvePluginSpecifier(projectRoot, pluginId)
|
|
56
381
|
try {
|
|
57
382
|
const mod = require(resolved)
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
383
|
+
const normalized = normalizePluginExport(mod)
|
|
384
|
+
const validation = validatePluginContract(pluginId, normalized)
|
|
385
|
+
|
|
386
|
+
errors.push(...validation.errors)
|
|
387
|
+
warnings.push(...validation.warnings)
|
|
388
|
+
|
|
389
|
+
if (!validation.plugin) {
|
|
64
390
|
continue
|
|
65
391
|
}
|
|
66
|
-
|
|
392
|
+
|
|
393
|
+
loaded.push({ id: pluginId, plugin: validation.plugin })
|
|
67
394
|
} catch (error) {
|
|
68
395
|
errors.push({
|
|
69
396
|
pluginId,
|
|
397
|
+
code: 'plugin-load-failed',
|
|
70
398
|
message: error instanceof Error ? error.message : String(error),
|
|
71
399
|
})
|
|
72
400
|
}
|
|
73
401
|
}
|
|
74
402
|
|
|
75
|
-
return { plugins: loaded, errors }
|
|
403
|
+
return { plugins: loaded, errors, warnings }
|
|
76
404
|
}
|
package/src/printer.ts
CHANGED
|
@@ -133,6 +133,10 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
133
133
|
'Fix or remove the failing plugin in drift.config.*',
|
|
134
134
|
'Validate plugin contract: export { name, rules[] } and detector functions',
|
|
135
135
|
],
|
|
136
|
+
'plugin-warning': [
|
|
137
|
+
'Review plugin validation warnings and align with the recommended contract',
|
|
138
|
+
'Use explicit rule ids and valid severity/weight values',
|
|
139
|
+
],
|
|
136
140
|
}
|
|
137
141
|
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
138
142
|
}
|
package/src/review.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { relative, resolve } from 'node:path'
|
|
2
2
|
import { analyzeProject } from './analyzer.js'
|
|
3
3
|
import { loadConfig } from './config.js'
|
|
4
4
|
import { buildReport } from './reporter.js'
|
|
@@ -66,7 +66,7 @@ export async function generateReview(projectPath: string, baseRef: string): Prom
|
|
|
66
66
|
...baseReport,
|
|
67
67
|
files: baseReport.files.map((file) => ({
|
|
68
68
|
...file,
|
|
69
|
-
path:
|
|
69
|
+
path: resolve(resolvedPath, relative(tempDir!, file.path)),
|
|
70
70
|
})),
|
|
71
71
|
}
|
|
72
72
|
|
package/src/rules/comments.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SourceFile } from 'ts-morph'
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
|
-
import { hasIgnoreComment } from './shared.js'
|
|
3
|
+
import { hasIgnoreComment, getFileLines } from './shared.js'
|
|
4
4
|
|
|
5
5
|
const TRIVIAL_COMMENT_PATTERNS = [
|
|
6
6
|
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
@@ -41,7 +41,7 @@ function checkLineForContradiction(
|
|
|
41
41
|
|
|
42
42
|
export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
|
|
43
43
|
const issues: DriftIssue[] = []
|
|
44
|
-
const lines = file
|
|
44
|
+
const lines = getFileLines(file)
|
|
45
45
|
|
|
46
46
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
47
47
|
const commentLine = lines[i].trim()
|