@eduardbar/drift 1.1.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.
Files changed (66) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. 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 by (rule + line + column) as a unique key within a file.
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 issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
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
- const baseKeys = new Set(baseIssues.map(issueKey))
25
- const currentKeys = new Set(currentIssues.map(issueKey))
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(i => !baseKeys.has(issueKey(i)))
28
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
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,8 +28,55 @@ 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'
18
43
  export { loadHistory, saveSnapshot } from './snapshot.js'
19
44
  export type { SnapshotEntry, SnapshotHistory } from './snapshot.js'
45
+ export {
46
+ DEFAULT_SAAS_POLICY,
47
+ defaultSaasStorePath,
48
+ resolveSaasPolicy,
49
+ SaasActorRequiredError,
50
+ SaasPermissionError,
51
+ getRequiredRoleForOperation,
52
+ assertSaasPermission,
53
+ getSaasEffectiveLimits,
54
+ getOrganizationEffectiveLimits,
55
+ changeOrganizationPlan,
56
+ listOrganizationPlanChanges,
57
+ getOrganizationUsageSnapshot,
58
+ ingestSnapshotFromReport,
59
+ listSaasSnapshots,
60
+ getSaasSummary,
61
+ generateSaasDashboardHtml,
62
+ } from './saas.js'
63
+ export type {
64
+ SaasRole,
65
+ SaasPlan,
66
+ SaasPolicy,
67
+ SaasPolicyOverrides,
68
+ SaasStore,
69
+ SaasSummary,
70
+ SaasSnapshot,
71
+ SaasQueryOptions,
72
+ IngestOptions,
73
+ SaasPlanChange,
74
+ SaasOperation,
75
+ SaasPermissionContext,
76
+ SaasPermissionResult,
77
+ SaasEffectiveLimits,
78
+ SaasOrganizationUsageSnapshot,
79
+ ChangeOrganizationPlanOptions,
80
+ SaasUsageQueryOptions,
81
+ SaasPlanChangeQueryOptions,
82
+ } from './saas.js'
package/src/map.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  import { writeFileSync } from 'node:fs'
2
2
  import { resolve, relative } from 'node:path'
3
3
  import { Project } from 'ts-morph'
4
+ import type { DriftConfig } from './types.js'
5
+ import { detectLayerViolations } from './rules/phase3-arch.js'
6
+ import { RULE_WEIGHTS } from './analyzer.js'
4
7
 
5
8
  interface LayerNode {
6
9
  name: string
7
10
  files: Set<string>
8
11
  }
9
12
 
13
+ interface MapEdge {
14
+ key: string
15
+ from: string
16
+ to: string
17
+ count: number
18
+ kind: 'normal' | 'cycle' | 'violation'
19
+ }
20
+
10
21
  function detectLayer(relPath: string): string {
11
22
  const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
12
23
  const first = normalized.split('/')[0] || 'root'
@@ -17,7 +28,7 @@ function esc(value: string): string {
17
28
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
18
29
  }
19
30
 
20
- export function generateArchitectureSvg(targetPath: string): string {
31
+ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
21
32
  const project = new Project({
22
33
  skipAddingFilesFromTsConfig: true,
23
34
  compilerOptions: { allowJs: true, jsx: 1 },
@@ -36,24 +47,65 @@ export function generateArchitectureSvg(targetPath: string): string {
36
47
 
37
48
  const layers = new Map<string, LayerNode>()
38
49
  const edges = new Map<string, number>()
50
+ const layerAdjacency = new Map<string, Set<string>>()
51
+ const fileImportGraph = new Map<string, Set<string>>()
39
52
 
40
53
  for (const file of project.getSourceFiles()) {
41
- const rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/')
54
+ const filePath = file.getFilePath()
55
+ const rel = relative(targetPath, filePath).replace(/\\/g, '/')
42
56
  const layerName = detectLayer(rel)
43
57
  if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
44
58
  layers.get(layerName)!.files.add(rel)
59
+ if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
45
60
 
46
61
  for (const decl of file.getImportDeclarations()) {
47
62
  const imported = decl.getModuleSpecifierSourceFile()
48
63
  if (!imported) continue
64
+ fileImportGraph.get(filePath)!.add(imported.getFilePath())
49
65
  const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
50
66
  const importedLayer = detectLayer(importedRel)
51
67
  if (importedLayer === layerName) continue
52
68
  const key = `${layerName}->${importedLayer}`
53
69
  edges.set(key, (edges.get(key) ?? 0) + 1)
70
+ if (!layerAdjacency.has(layerName)) layerAdjacency.set(layerName, new Set())
71
+ layerAdjacency.get(layerName)!.add(importedLayer)
54
72
  }
55
73
  }
56
74
 
75
+ const cycleEdges = detectCycleEdges(layerAdjacency)
76
+ const violationEdges = new Set<string>()
77
+
78
+ if (config?.layers && config.layers.length > 0) {
79
+ const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
80
+ for (const issues of violations.values()) {
81
+ for (const issue of issues) {
82
+ const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
83
+ if (!match) continue
84
+ const from = match[1]
85
+ const to = match[2]
86
+ violationEdges.add(`${from}->${to}`)
87
+ if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
88
+ if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
89
+ }
90
+ }
91
+ }
92
+
93
+ const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
94
+ const [from, to] = key.split('->')
95
+ const kind = violationEdges.has(key)
96
+ ? 'violation'
97
+ : cycleEdges.has(key)
98
+ ? 'cycle'
99
+ : 'normal'
100
+ return { key, from, to, count, kind }
101
+ })
102
+
103
+ for (const key of violationEdges) {
104
+ if (edges.has(key)) continue
105
+ const [from, to] = key.split('->')
106
+ edgeList.push({ key, from, to, count: 1, kind: 'violation' })
107
+ }
108
+
57
109
  const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
58
110
  const width = 960
59
111
  const rowHeight = 90
@@ -73,18 +125,23 @@ export function generateArchitectureSvg(targetPath: string): string {
73
125
 
74
126
  const boxByName = new Map(boxes.map((box) => [box.name, box]))
75
127
 
76
- const lines = [...edges.entries()].map(([key, count]) => {
77
- const [from, to] = key.split('->')
78
- const a = boxByName.get(from)
79
- const b = boxByName.get(to)
128
+ const lines = edgeList.map((edge) => {
129
+ const a = boxByName.get(edge.from)
130
+ const b = boxByName.get(edge.to)
80
131
  if (!a || !b) return ''
81
132
  const startX = a.x + boxWidth
82
133
  const startY = a.y + boxHeight / 2
83
134
  const endX = b.x
84
135
  const endY = b.y + boxHeight / 2
136
+ const stroke = edge.kind === 'violation'
137
+ ? '#ef4444'
138
+ : edge.kind === 'cycle'
139
+ ? '#f59e0b'
140
+ : '#64748b'
141
+ const widthPx = edge.kind === 'normal' ? 2 : 3
85
142
  return `
86
- <line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
87
- <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`
143
+ <line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
144
+ <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
88
145
  }).join('')
89
146
 
90
147
  const nodes = boxes.map((box) => `
@@ -94,6 +151,9 @@ export function generateArchitectureSvg(targetPath: string): string {
94
151
  <text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
95
152
  </g>`).join('')
96
153
 
154
+ const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
155
+ const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
156
+
97
157
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
98
158
  <defs>
99
159
  <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
@@ -103,14 +163,56 @@ export function generateArchitectureSvg(targetPath: string): string {
103
163
  <rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
104
164
  <text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
105
165
  <text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
166
+ <text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
167
+ <line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
168
+ <line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
106
169
  ${lines}
107
170
  ${nodes}
108
171
  </svg>`
109
172
  }
110
173
 
111
- export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg'): string {
174
+ function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
175
+ const visited = new Set<string>()
176
+ const inStack = new Set<string>()
177
+ const stack: string[] = []
178
+ const cycleEdges = new Set<string>()
179
+
180
+ function dfs(node: string): void {
181
+ visited.add(node)
182
+ inStack.add(node)
183
+ stack.push(node)
184
+
185
+ for (const neighbor of adjacency.get(node) ?? []) {
186
+ if (!visited.has(neighbor)) {
187
+ dfs(neighbor)
188
+ continue
189
+ }
190
+
191
+ if (!inStack.has(neighbor)) continue
192
+
193
+ const startIndex = stack.indexOf(neighbor)
194
+ if (startIndex >= 0) {
195
+ for (let i = startIndex; i < stack.length - 1; i++) {
196
+ cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
197
+ }
198
+ }
199
+ cycleEdges.add(`${node}->${neighbor}`)
200
+ }
201
+
202
+ stack.pop()
203
+ inStack.delete(node)
204
+ }
205
+
206
+ for (const node of adjacency.keys()) {
207
+ if (!visited.has(node)) dfs(node)
208
+ }
209
+
210
+ return cycleEdges
211
+ }
212
+
213
+ export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
112
214
  const resolvedTarget = resolve(targetPath)
113
- const svg = generateArchitectureSvg(resolvedTarget)
215
+ const svg = generateArchitectureSvg(resolvedTarget, config)
114
216
  const outPath = resolve(outputFile)
115
217
  writeFileSync(outPath, svg, 'utf8')
116
218
  return outPath