@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.
Files changed (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. package/tests/trust.test.ts +602 -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
+ }