@eduardbar/drift 1.3.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/review-pr.yml +34 -41
- package/AGENTS.md +75 -251
- package/CHANGELOG.md +28 -0
- package/README.md +148 -41
- package/dist/benchmark.d.ts +1 -1
- package/dist/benchmark.js +71 -52
- package/dist/cli.js +243 -8
- package/dist/config.js +16 -2
- package/dist/diff.js +42 -50
- 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/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 +10 -3
- package/dist/index.js +4 -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 +1 -1
- package/dist/plugins.js +45 -142
- 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 +2 -1
- package/dist/rules/phase3-configurable.js +23 -15
- 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 -218
- package/dist/saas.js +7 -761
- 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 +1 -3
- package/dist/trust-kpi.js +6 -266
- 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 +4 -32
- package/dist/trust.js +29 -432
- 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 -365
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +37 -5
- package/package.json +3 -2
- 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/benchmark.ts +75 -53
- package/src/cli.ts +285 -13
- package/src/config.ts +19 -2
- package/src/diff.ts +57 -48
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +35 -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 +76 -283
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +4 -2
- package/src/rules/phase3-configurable.ts +39 -26
- 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 -1031
- 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 +8 -316
- 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 +62 -576
- 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 -409
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +10 -2
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +31 -4
- package/tests/trust.test.ts +18 -0
package/src/sarif.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { DriftIssue, DriftReport } from './types.js'
|
|
3
|
+
import type { DriftDiff } from './types.js'
|
|
4
|
+
import { RULE_WEIGHTS } from './analyzer.js'
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url)
|
|
7
|
+
const { version: VERSION } = require('../package.json') as { version: string }
|
|
8
|
+
|
|
9
|
+
const HTTPS_SCHEME = 'https:'
|
|
10
|
+
const URL_SEPARATOR = '//'
|
|
11
|
+
const SARIF_SCHEMA_HOST_AND_PATH = 'json.schemastore.org/sarif-2.1.0.json'
|
|
12
|
+
const DRIFT_INFORMATION_HOST_AND_PATH = 'github.com/eduardbar/drift'
|
|
13
|
+
const SARIF_SCHEMA_URL = `${HTTPS_SCHEME}${URL_SEPARATOR}${SARIF_SCHEMA_HOST_AND_PATH}`
|
|
14
|
+
const DRIFT_INFORMATION_URI = `${HTTPS_SCHEME}${URL_SEPARATOR}${DRIFT_INFORMATION_HOST_AND_PATH}`
|
|
15
|
+
|
|
16
|
+
export type SarifLevel = 'error' | 'warning' | 'note'
|
|
17
|
+
|
|
18
|
+
export interface DriftSarifRule {
|
|
19
|
+
id: string
|
|
20
|
+
name?: string
|
|
21
|
+
shortDescription?: {
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
defaultConfiguration?: {
|
|
25
|
+
level: SarifLevel
|
|
26
|
+
}
|
|
27
|
+
properties?: {
|
|
28
|
+
weight?: number
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DriftSarifResult {
|
|
33
|
+
ruleId: string
|
|
34
|
+
level: SarifLevel
|
|
35
|
+
message: {
|
|
36
|
+
text: string
|
|
37
|
+
}
|
|
38
|
+
locations: Array<{
|
|
39
|
+
physicalLocation: {
|
|
40
|
+
artifactLocation: {
|
|
41
|
+
uri: string
|
|
42
|
+
}
|
|
43
|
+
region: {
|
|
44
|
+
startLine: number
|
|
45
|
+
startColumn: number
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}>
|
|
49
|
+
properties?: {
|
|
50
|
+
weight?: number
|
|
51
|
+
fileScore?: number
|
|
52
|
+
driftSeverity: DriftIssue['severity']
|
|
53
|
+
baseRef?: string
|
|
54
|
+
scoreBefore?: number
|
|
55
|
+
scoreAfter?: number
|
|
56
|
+
scoreDelta?: number
|
|
57
|
+
changeType?: 'new-issue'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DriftSarifRun {
|
|
62
|
+
tool: {
|
|
63
|
+
driver: {
|
|
64
|
+
name: string
|
|
65
|
+
version: string
|
|
66
|
+
informationUri: string
|
|
67
|
+
rules: DriftSarifRule[]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
results: DriftSarifResult[]
|
|
71
|
+
properties: {
|
|
72
|
+
scannedAt: string
|
|
73
|
+
targetPath: string
|
|
74
|
+
totalIssues: number
|
|
75
|
+
totalScore: number
|
|
76
|
+
totalFiles: number
|
|
77
|
+
baseRef?: string
|
|
78
|
+
totalDelta?: number
|
|
79
|
+
newIssuesCount?: number
|
|
80
|
+
resolvedIssuesCount?: number
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface DriftSarifLog {
|
|
85
|
+
$schema: string
|
|
86
|
+
version: '2.1.0'
|
|
87
|
+
runs: DriftSarifRun[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SarifRunMetrics {
|
|
91
|
+
scannedAt: string
|
|
92
|
+
targetPath: string
|
|
93
|
+
totalIssues: number
|
|
94
|
+
totalScore: number
|
|
95
|
+
totalFiles: number
|
|
96
|
+
baseRef?: string
|
|
97
|
+
totalDelta?: number
|
|
98
|
+
newIssuesCount?: number
|
|
99
|
+
resolvedIssuesCount?: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mapSeverityToSarifLevel(severity: DriftIssue['severity']): SarifLevel {
|
|
103
|
+
switch (severity) {
|
|
104
|
+
case 'error':
|
|
105
|
+
return 'error'
|
|
106
|
+
case 'warning':
|
|
107
|
+
return 'warning'
|
|
108
|
+
default:
|
|
109
|
+
return 'note'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeArtifactUri(filePath: string): string {
|
|
114
|
+
return filePath.replaceAll('\\', '/')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toSarifResult(
|
|
118
|
+
filePath: string,
|
|
119
|
+
fileScore: number,
|
|
120
|
+
issue: DriftIssue,
|
|
121
|
+
extraProperties?: Omit<NonNullable<DriftSarifResult['properties']>, 'weight' | 'fileScore' | 'driftSeverity'>,
|
|
122
|
+
): DriftSarifResult {
|
|
123
|
+
const line = Math.max(issue.line, 1)
|
|
124
|
+
const column = Math.max(issue.column, 1)
|
|
125
|
+
const weight = RULE_WEIGHTS[issue.rule]?.weight
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ruleId: issue.rule,
|
|
129
|
+
level: mapSeverityToSarifLevel(issue.severity),
|
|
130
|
+
message: {
|
|
131
|
+
text: issue.message,
|
|
132
|
+
},
|
|
133
|
+
locations: [{
|
|
134
|
+
physicalLocation: {
|
|
135
|
+
artifactLocation: {
|
|
136
|
+
uri: normalizeArtifactUri(filePath),
|
|
137
|
+
},
|
|
138
|
+
region: {
|
|
139
|
+
startLine: line,
|
|
140
|
+
startColumn: column,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}],
|
|
144
|
+
properties: {
|
|
145
|
+
weight,
|
|
146
|
+
fileScore,
|
|
147
|
+
driftSeverity: issue.severity,
|
|
148
|
+
...extraProperties,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildRules(results: DriftSarifResult[]): DriftSarifRule[] {
|
|
154
|
+
const byRule = new Map<string, DriftSarifRule>()
|
|
155
|
+
|
|
156
|
+
for (const result of results) {
|
|
157
|
+
if (byRule.has(result.ruleId)) continue
|
|
158
|
+
|
|
159
|
+
byRule.set(result.ruleId, {
|
|
160
|
+
id: result.ruleId,
|
|
161
|
+
name: result.ruleId,
|
|
162
|
+
shortDescription: {
|
|
163
|
+
text: `drift rule: ${result.ruleId}`,
|
|
164
|
+
},
|
|
165
|
+
defaultConfiguration: {
|
|
166
|
+
level: result.level,
|
|
167
|
+
},
|
|
168
|
+
properties: {
|
|
169
|
+
weight: result.properties?.weight,
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return [...byRule.values()]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildSarifLog(results: DriftSarifResult[], metrics: SarifRunMetrics): DriftSarifLog {
|
|
178
|
+
return {
|
|
179
|
+
$schema: SARIF_SCHEMA_URL,
|
|
180
|
+
version: '2.1.0',
|
|
181
|
+
runs: [{
|
|
182
|
+
tool: {
|
|
183
|
+
driver: {
|
|
184
|
+
name: 'drift',
|
|
185
|
+
version: VERSION,
|
|
186
|
+
informationUri: DRIFT_INFORMATION_URI,
|
|
187
|
+
rules: buildRules(results),
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
results,
|
|
191
|
+
properties: metrics,
|
|
192
|
+
}],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function toSarif(report: DriftReport): DriftSarifLog {
|
|
197
|
+
const results = report.files.flatMap((file) => file.issues.map((issue) => toSarifResult(file.path, file.score, issue)))
|
|
198
|
+
|
|
199
|
+
return buildSarifLog(results, {
|
|
200
|
+
scannedAt: report.scannedAt,
|
|
201
|
+
targetPath: report.targetPath,
|
|
202
|
+
totalIssues: report.totalIssues,
|
|
203
|
+
totalScore: report.totalScore,
|
|
204
|
+
totalFiles: report.totalFiles,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function diffToSarif(diff: DriftDiff): DriftSarifLog {
|
|
209
|
+
const results = diff.files.flatMap((file) =>
|
|
210
|
+
file.newIssues.map((issue) =>
|
|
211
|
+
toSarifResult(file.path, file.scoreAfter, issue, {
|
|
212
|
+
baseRef: diff.baseRef,
|
|
213
|
+
scoreBefore: file.scoreBefore,
|
|
214
|
+
scoreAfter: file.scoreAfter,
|
|
215
|
+
scoreDelta: file.scoreDelta,
|
|
216
|
+
changeType: 'new-issue',
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return buildSarifLog(results, {
|
|
222
|
+
scannedAt: diff.scannedAt,
|
|
223
|
+
targetPath: diff.projectPath,
|
|
224
|
+
totalIssues: diff.newIssuesCount,
|
|
225
|
+
totalScore: diff.totalScoreAfter,
|
|
226
|
+
totalFiles: diff.files.length,
|
|
227
|
+
baseRef: diff.baseRef,
|
|
228
|
+
totalDelta: diff.totalDelta,
|
|
229
|
+
newIssuesCount: diff.newIssuesCount,
|
|
230
|
+
resolvedIssuesCount: diff.resolvedIssuesCount,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { DriftReport, DriftTrustReport, TrustAdvancedComparison, TrustDiffContext, TrustFixPriority } from './types.js'
|
|
2
|
+
import type { SnapshotEntry } from './snapshot.js'
|
|
3
|
+
|
|
4
|
+
const SYSTEMIC_GUIDANCE_LIMIT = 2
|
|
5
|
+
const TEAM_GUIDANCE_LIMIT = 3
|
|
6
|
+
|
|
7
|
+
function buildComparisonFromPreviousTrust(
|
|
8
|
+
trustScore: number,
|
|
9
|
+
previousTrust: Partial<DriftTrustReport> | undefined,
|
|
10
|
+
): TrustAdvancedComparison | undefined {
|
|
11
|
+
if (!previousTrust || typeof previousTrust.trust_score !== 'number') return undefined
|
|
12
|
+
|
|
13
|
+
const trustDelta = trustScore - previousTrust.trust_score
|
|
14
|
+
const trend = trustDelta > 0 ? 'improving' : trustDelta < 0 ? 'regressing' : 'stable'
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
source: 'previous-trust-json',
|
|
18
|
+
trend,
|
|
19
|
+
summary: `Trust moved ${trustDelta >= 0 ? '+' : ''}${trustDelta} vs provided previous trust JSON.`,
|
|
20
|
+
trust_delta: trustDelta,
|
|
21
|
+
previous_trust_score: previousTrust.trust_score,
|
|
22
|
+
previous_merge_risk: previousTrust.merge_risk,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildComparisonFromSnapshotHistory(
|
|
27
|
+
report: DriftReport,
|
|
28
|
+
snapshots: SnapshotEntry[] | undefined,
|
|
29
|
+
): TrustAdvancedComparison | undefined {
|
|
30
|
+
const lastSnapshot = snapshots && snapshots.length > 0 ? snapshots[snapshots.length - 1] : undefined
|
|
31
|
+
if (!lastSnapshot) return undefined
|
|
32
|
+
|
|
33
|
+
const snapshotScoreDelta = report.totalScore - lastSnapshot.score
|
|
34
|
+
const trend = snapshotScoreDelta < 0 ? 'improving' : snapshotScoreDelta > 0 ? 'regressing' : 'stable'
|
|
35
|
+
const snapshotContext = lastSnapshot.label
|
|
36
|
+
? `${lastSnapshot.timestamp} (${lastSnapshot.label})`
|
|
37
|
+
: lastSnapshot.timestamp
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
source: 'snapshot-history',
|
|
41
|
+
trend,
|
|
42
|
+
summary: `Drift score moved ${snapshotScoreDelta >= 0 ? '+' : ''}${snapshotScoreDelta} vs snapshot ${snapshotContext}.`,
|
|
43
|
+
snapshot_score_delta: snapshotScoreDelta,
|
|
44
|
+
snapshot_label: lastSnapshot.label || undefined,
|
|
45
|
+
snapshot_timestamp: lastSnapshot.timestamp,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildTeamGuidance(
|
|
50
|
+
priorities: TrustFixPriority[],
|
|
51
|
+
comparison: TrustAdvancedComparison | undefined,
|
|
52
|
+
diffContext: TrustDiffContext | undefined,
|
|
53
|
+
): string[] {
|
|
54
|
+
const systemicTargets = priorities
|
|
55
|
+
.filter((priority) => priority.systemic)
|
|
56
|
+
.slice(0, SYSTEMIC_GUIDANCE_LIMIT)
|
|
57
|
+
.map((priority) => `${priority.rule} (x${priority.occurrences})`)
|
|
58
|
+
|
|
59
|
+
const guidance: string[] = []
|
|
60
|
+
if (systemicTargets.length > 0) {
|
|
61
|
+
guidance.push(`Start with systemic rules: ${systemicTargets.join(', ')}.`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (comparison?.trend === 'regressing') {
|
|
65
|
+
guidance.push('Trend regressed; freeze net-new debt in CI and assign owners per systemic rule.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (diffContext && diffContext.newIssues > 0) {
|
|
69
|
+
guidance.push(`Block net-new issue growth first (+${diffContext.newIssues} new issue(s) in diff context).`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (guidance.length === 0) {
|
|
73
|
+
guidance.push('Maintain current baseline and schedule periodic systemic debt cleanup by rule ownership.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return guidance.slice(0, TEAM_GUIDANCE_LIMIT)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildAdvancedContext(input: {
|
|
80
|
+
report: DriftReport
|
|
81
|
+
advancedOptions: {
|
|
82
|
+
enabled?: boolean
|
|
83
|
+
previousTrust?: Partial<DriftTrustReport>
|
|
84
|
+
snapshots?: SnapshotEntry[]
|
|
85
|
+
} | undefined
|
|
86
|
+
trustScore: number
|
|
87
|
+
fixPriorities: TrustFixPriority[]
|
|
88
|
+
diffContext: TrustDiffContext | undefined
|
|
89
|
+
}): DriftTrustReport['advanced_context'] {
|
|
90
|
+
if (input.advancedOptions?.enabled !== true) return undefined
|
|
91
|
+
|
|
92
|
+
const comparison = buildComparisonFromPreviousTrust(input.trustScore, input.advancedOptions.previousTrust)
|
|
93
|
+
?? buildComparisonFromSnapshotHistory(input.report, input.advancedOptions.snapshots)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
comparison,
|
|
97
|
+
team_guidance: buildTeamGuidance(input.fixPriorities, comparison, input.diffContext),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import type { TrustKpiDiagnostic } from './types.js'
|
|
4
|
+
import type { DiscoverResult } from './trust-kpi-types.js'
|
|
5
|
+
|
|
6
|
+
const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'build'])
|
|
7
|
+
|
|
8
|
+
function toPosixPath(path: string): string {
|
|
9
|
+
return path.replace(/\\/g, '/')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function processDirectoryEntry(current: string, entry: string, stack: string[], out: string[]): void {
|
|
13
|
+
const fullPath = resolve(current, entry)
|
|
14
|
+
const info = statSync(fullPath)
|
|
15
|
+
if (!info.isDirectory()) {
|
|
16
|
+
out.push(fullPath)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (IGNORED_DIRECTORIES.has(entry)) return
|
|
21
|
+
stack.push(fullPath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function listFilesRecursively(root: string): string[] {
|
|
25
|
+
if (!existsSync(root)) return []
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
const stack = [root]
|
|
28
|
+
|
|
29
|
+
while (stack.length > 0) {
|
|
30
|
+
const current = stack.pop()!
|
|
31
|
+
for (const entry of readdirSync(current)) {
|
|
32
|
+
processDirectoryEntry(current, entry, stack, out)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isGlobPattern(input: string): boolean {
|
|
40
|
+
return /[*?[\]{}]/.test(input)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeRegex(char: string): string {
|
|
44
|
+
return /[\\^$+?.()|{}\[\]]/.test(char) ? `\\${char}` : char
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function globToRegex(pattern: string): RegExp {
|
|
48
|
+
const normalized = toPosixPath(pattern)
|
|
49
|
+
let expression = '^'
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
52
|
+
const char = normalized[index]
|
|
53
|
+
const nextChar = normalized[index + 1]
|
|
54
|
+
const nextNextChar = normalized[index + 2]
|
|
55
|
+
|
|
56
|
+
if (char === '*' && nextChar === '*') {
|
|
57
|
+
if (nextNextChar === '/') {
|
|
58
|
+
expression += '(?:.*/)?'
|
|
59
|
+
index += 2
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
expression += '.*'
|
|
63
|
+
index += 1
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (char === '*') {
|
|
68
|
+
expression += '[^/]*'
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (char === '?') {
|
|
73
|
+
expression += '[^/]'
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expression += escapeRegex(char)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
expression += '$'
|
|
81
|
+
return new RegExp(expression)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function globBaseDir(pattern: string): string {
|
|
85
|
+
const normalized = toPosixPath(pattern)
|
|
86
|
+
const wildcardIndex = normalized.search(/[*?[\]{}]/)
|
|
87
|
+
|
|
88
|
+
if (wildcardIndex < 0) return dirname(pattern)
|
|
89
|
+
|
|
90
|
+
const prefix = normalized.slice(0, wildcardIndex)
|
|
91
|
+
const slashIndex = prefix.lastIndexOf('/')
|
|
92
|
+
|
|
93
|
+
if (slashIndex < 0) return '.'
|
|
94
|
+
if (slashIndex === 0) return '/'
|
|
95
|
+
|
|
96
|
+
return prefix.slice(0, slashIndex)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function discoverFromGlob(source: string, cwd: string): DiscoverResult {
|
|
100
|
+
const diagnostics: TrustKpiDiagnostic[] = []
|
|
101
|
+
const absolutePattern = isAbsolute(source) ? source : resolve(cwd, source)
|
|
102
|
+
const regex = globToRegex(toPosixPath(absolutePattern))
|
|
103
|
+
const base = resolve(cwd, globBaseDir(source))
|
|
104
|
+
|
|
105
|
+
if (!existsSync(base)) {
|
|
106
|
+
diagnostics.push({
|
|
107
|
+
level: 'error',
|
|
108
|
+
code: 'path-not-found',
|
|
109
|
+
message: `Glob base path does not exist: ${base}`,
|
|
110
|
+
})
|
|
111
|
+
return { files: [], diagnostics }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matched = listFilesRecursively(base)
|
|
115
|
+
.filter((filePath) => regex.test(toPosixPath(filePath)))
|
|
116
|
+
.filter((filePath) => filePath.toLowerCase().endsWith('.json'))
|
|
117
|
+
.sort((a, b) => a.localeCompare(b))
|
|
118
|
+
|
|
119
|
+
return { files: matched, diagnostics }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function discoverFromPath(source: string, cwd: string): DiscoverResult {
|
|
123
|
+
const diagnostics: TrustKpiDiagnostic[] = []
|
|
124
|
+
const absolute = isAbsolute(source) ? source : resolve(cwd, source)
|
|
125
|
+
|
|
126
|
+
if (!existsSync(absolute)) {
|
|
127
|
+
diagnostics.push({
|
|
128
|
+
level: 'error',
|
|
129
|
+
code: 'path-not-found',
|
|
130
|
+
message: `Path does not exist: ${absolute}`,
|
|
131
|
+
})
|
|
132
|
+
return { files: [], diagnostics }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const info = statSync(absolute)
|
|
136
|
+
if (info.isDirectory()) {
|
|
137
|
+
const files = listFilesRecursively(absolute)
|
|
138
|
+
.filter((filePath) => filePath.toLowerCase().endsWith('.json'))
|
|
139
|
+
.sort((a, b) => a.localeCompare(b))
|
|
140
|
+
return { files, diagnostics }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (info.isFile()) {
|
|
144
|
+
if (!absolute.toLowerCase().endsWith('.json')) {
|
|
145
|
+
diagnostics.push({
|
|
146
|
+
level: 'warning',
|
|
147
|
+
code: 'path-not-supported',
|
|
148
|
+
file: absolute,
|
|
149
|
+
message: 'Input file is not JSON; attempting to parse anyway',
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return { files: [absolute], diagnostics }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
diagnostics.push({
|
|
156
|
+
level: 'error',
|
|
157
|
+
code: 'path-not-supported',
|
|
158
|
+
message: `Path is neither a file nor directory: ${absolute}`,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return { files: [], diagnostics }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function discoverTrustJsonFiles(input: string, cwd: string): DiscoverResult {
|
|
165
|
+
const source = input.trim() || '.'
|
|
166
|
+
return isGlobPattern(source)
|
|
167
|
+
? discoverFromGlob(source, cwd)
|
|
168
|
+
: discoverFromPath(source, cwd)
|
|
169
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { MERGE_RISK_ORDER, normalizeMergeRiskLevel } from './trust.js'
|
|
3
|
+
import type { MergeRiskLevel, TrustDiffContext, TrustKpiDiagnostic } from './types.js'
|
|
4
|
+
import type { DiffStatus, ParsedTrustArtifact } from './trust-kpi-types.js'
|
|
5
|
+
|
|
6
|
+
const DIFF_STATUS_VALUES = new Set(['improved', 'regressed', 'neutral'])
|
|
7
|
+
|
|
8
|
+
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === 'object' && value !== null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createDiffContextWarning(message: string): { diagnostic: TrustKpiDiagnostic } {
|
|
13
|
+
return {
|
|
14
|
+
diagnostic: {
|
|
15
|
+
level: 'warning',
|
|
16
|
+
code: 'invalid-diff-context',
|
|
17
|
+
message,
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getFiniteNumber(raw: Record<string, unknown>, key: string): number | null {
|
|
23
|
+
const value = raw[key]
|
|
24
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveDiffStatus(rawStatus: unknown, scoreDelta: number): DiffStatus {
|
|
28
|
+
if (typeof rawStatus === 'string' && DIFF_STATUS_VALUES.has(rawStatus)) {
|
|
29
|
+
return rawStatus as DiffStatus
|
|
30
|
+
}
|
|
31
|
+
if (scoreDelta < 0) return 'improved'
|
|
32
|
+
if (scoreDelta > 0) return 'regressed'
|
|
33
|
+
return 'neutral'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseDiffContextBase(raw: Record<string, unknown>): {
|
|
37
|
+
baseRef: string
|
|
38
|
+
scoreDelta: number | null
|
|
39
|
+
newIssues: number | null
|
|
40
|
+
resolvedIssues: number | null
|
|
41
|
+
filesChanged: number
|
|
42
|
+
penalty: number
|
|
43
|
+
bonus: number
|
|
44
|
+
netImpact: number
|
|
45
|
+
} {
|
|
46
|
+
return {
|
|
47
|
+
baseRef: typeof raw.baseRef === 'string' ? raw.baseRef : 'unknown',
|
|
48
|
+
scoreDelta: getFiniteNumber(raw, 'scoreDelta'),
|
|
49
|
+
newIssues: getFiniteNumber(raw, 'newIssues'),
|
|
50
|
+
resolvedIssues: getFiniteNumber(raw, 'resolvedIssues'),
|
|
51
|
+
filesChanged: getFiniteNumber(raw, 'filesChanged') ?? 0,
|
|
52
|
+
penalty: getFiniteNumber(raw, 'penalty') ?? 0,
|
|
53
|
+
bonus: getFiniteNumber(raw, 'bonus') ?? 0,
|
|
54
|
+
netImpact: getFiniteNumber(raw, 'netImpact') ?? 0,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeDiffContext(raw: unknown): { diffContext?: TrustDiffContext; diagnostic?: TrustKpiDiagnostic } {
|
|
59
|
+
if (!isObjectLike(raw)) {
|
|
60
|
+
return createDiffContextWarning('diff_context is present but malformed; skipping diff trend fields for this artifact')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parsed = parseDiffContextBase(raw)
|
|
64
|
+
|
|
65
|
+
if (parsed.scoreDelta == null || parsed.newIssues == null || parsed.resolvedIssues == null) {
|
|
66
|
+
return createDiffContextWarning('diff_context is missing numeric scoreDelta/newIssues/resolvedIssues; skipping diff trend fields for this artifact')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const normalizedStatus = resolveDiffStatus(raw.status, parsed.scoreDelta)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
diffContext: {
|
|
73
|
+
baseRef: parsed.baseRef,
|
|
74
|
+
status: normalizedStatus,
|
|
75
|
+
scoreDelta: parsed.scoreDelta,
|
|
76
|
+
newIssues: parsed.newIssues,
|
|
77
|
+
resolvedIssues: parsed.resolvedIssues,
|
|
78
|
+
filesChanged: parsed.filesChanged,
|
|
79
|
+
penalty: parsed.penalty,
|
|
80
|
+
bonus: parsed.bonus,
|
|
81
|
+
netImpact: parsed.netImpact,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readJsonFile(filePath: string): { parsed?: unknown; diagnostics: TrustKpiDiagnostic[] } {
|
|
87
|
+
let rawContent = ''
|
|
88
|
+
try {
|
|
89
|
+
rawContent = readFileSync(filePath, 'utf8')
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
diagnostics: [{
|
|
93
|
+
level: 'error',
|
|
94
|
+
code: 'read-failed',
|
|
95
|
+
file: filePath,
|
|
96
|
+
message: error instanceof Error ? error.message : String(error),
|
|
97
|
+
}],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
return { parsed: JSON.parse(rawContent), diagnostics: [] }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return {
|
|
105
|
+
diagnostics: [{
|
|
106
|
+
level: 'error',
|
|
107
|
+
code: 'parse-failed',
|
|
108
|
+
file: filePath,
|
|
109
|
+
message: error instanceof Error ? error.message : String(error),
|
|
110
|
+
}],
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeArtifactShape(parsed: unknown, filePath: string): { artifact?: Record<string, unknown>; diagnostics: TrustKpiDiagnostic[] } {
|
|
116
|
+
if (isObjectLike(parsed)) {
|
|
117
|
+
const rawSchema = parsed.$schema
|
|
118
|
+
if (rawSchema !== undefined && rawSchema !== 'schemas/drift-trust.v1.json') {
|
|
119
|
+
return {
|
|
120
|
+
diagnostics: [{
|
|
121
|
+
level: 'error',
|
|
122
|
+
code: 'invalid-shape',
|
|
123
|
+
file: filePath,
|
|
124
|
+
message: 'Invalid $schema for trust artifact (expected schemas/drift-trust.v1.json)',
|
|
125
|
+
}],
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { artifact: parsed, diagnostics: [] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
diagnostics: [{
|
|
134
|
+
level: 'error',
|
|
135
|
+
code: 'invalid-shape',
|
|
136
|
+
file: filePath,
|
|
137
|
+
message: 'Trust artifact must be a JSON object',
|
|
138
|
+
}],
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseTrustScore(raw: Record<string, unknown>, filePath: string): { trustScore?: number; diagnostics: TrustKpiDiagnostic[] } {
|
|
143
|
+
const trustScore = raw.trust_score
|
|
144
|
+
if (typeof trustScore === 'number' && Number.isFinite(trustScore)) {
|
|
145
|
+
return { trustScore, diagnostics: [] }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
diagnostics: [{
|
|
150
|
+
level: 'error',
|
|
151
|
+
code: 'invalid-shape',
|
|
152
|
+
file: filePath,
|
|
153
|
+
message: 'Missing numeric trust_score',
|
|
154
|
+
}],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseMergeRisk(raw: Record<string, unknown>, filePath: string): { mergeRisk?: MergeRiskLevel; diagnostics: TrustKpiDiagnostic[] } {
|
|
159
|
+
const mergeRisk = typeof raw.merge_risk === 'string'
|
|
160
|
+
? normalizeMergeRiskLevel(raw.merge_risk)
|
|
161
|
+
: undefined
|
|
162
|
+
|
|
163
|
+
if (mergeRisk) {
|
|
164
|
+
return { mergeRisk, diagnostics: [] }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
diagnostics: [{
|
|
169
|
+
level: 'error',
|
|
170
|
+
code: 'invalid-shape',
|
|
171
|
+
file: filePath,
|
|
172
|
+
message: `Missing/invalid merge_risk (expected one of ${MERGE_RISK_ORDER.join(', ')})`,
|
|
173
|
+
}],
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseTrustArtifact(filePath: string): { record?: ParsedTrustArtifact; diagnostics: TrustKpiDiagnostic[] } {
|
|
178
|
+
const readResult = readJsonFile(filePath)
|
|
179
|
+
if (readResult.diagnostics.length > 0) {
|
|
180
|
+
return { diagnostics: readResult.diagnostics }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const shape = normalizeArtifactShape(readResult.parsed, filePath)
|
|
184
|
+
if (!shape.artifact) {
|
|
185
|
+
return { diagnostics: shape.diagnostics }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const trustScoreResult = parseTrustScore(shape.artifact, filePath)
|
|
189
|
+
if (trustScoreResult.trustScore === undefined) {
|
|
190
|
+
return { diagnostics: trustScoreResult.diagnostics }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const mergeRiskResult = parseMergeRisk(shape.artifact, filePath)
|
|
194
|
+
if (!mergeRiskResult.mergeRisk) {
|
|
195
|
+
return { diagnostics: mergeRiskResult.diagnostics }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const diagnostics: TrustKpiDiagnostic[] = []
|
|
199
|
+
let diffContext: TrustDiffContext | undefined
|
|
200
|
+
|
|
201
|
+
if (shape.artifact.diff_context !== undefined) {
|
|
202
|
+
const normalized = normalizeDiffContext(shape.artifact.diff_context)
|
|
203
|
+
if (normalized.diagnostic) {
|
|
204
|
+
diagnostics.push({ ...normalized.diagnostic, file: filePath })
|
|
205
|
+
} else {
|
|
206
|
+
diffContext = normalized.diffContext
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
record: {
|
|
212
|
+
filePath,
|
|
213
|
+
trustScore: trustScoreResult.trustScore,
|
|
214
|
+
mergeRisk: mergeRiskResult.mergeRisk,
|
|
215
|
+
diffContext,
|
|
216
|
+
},
|
|
217
|
+
diagnostics,
|
|
218
|
+
}
|
|
219
|
+
}
|