@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/dist/diff.js CHANGED
@@ -1,7 +1,22 @@
1
+ function normalizePath(filePath) {
2
+ return filePath.replace(/\\/g, '/');
3
+ }
4
+ function normalizeIssueText(value) {
5
+ return value
6
+ .replace(/\r\n/g, '\n')
7
+ .replace(/\r/g, '\n')
8
+ .replace(/\s+/g, ' ')
9
+ .trim();
10
+ }
1
11
  /**
2
12
  * Compute the diff between two DriftReports.
3
13
  *
4
- * Issues are matched by (rule + line + column) as a unique key within a file.
14
+ * Issues are matched in two passes:
15
+ * 1) strict location key (rule + line + column)
16
+ * 2) normalized content key (rule + severity + line + message + snippet)
17
+ *
18
+ * This keeps deterministic matching while preventing false churn caused by
19
+ * cross-platform line ending changes and small column offset noise.
5
20
  * A "new" issue exists in `current` but not in `base`.
6
21
  * A "resolved" issue exists in `base` but not in `current`.
7
22
  */
@@ -11,11 +26,60 @@ function computeFileDiff(filePath, baseFile, currentFile) {
11
26
  const scoreDelta = scoreAfter - scoreBefore;
12
27
  const baseIssues = baseFile?.issues ?? [];
13
28
  const currentIssues = currentFile?.issues ?? [];
14
- const issueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
15
- const baseKeys = new Set(baseIssues.map(issueKey));
16
- const currentKeys = new Set(currentIssues.map(issueKey));
17
- const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)));
18
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)));
29
+ const strictIssueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
30
+ const normalizedIssueKey = (i) => {
31
+ const normalizedMessage = normalizeIssueText(i.message);
32
+ const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, 80);
33
+ return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`;
34
+ };
35
+ const matchedBaseIndexes = new Set();
36
+ const matchedCurrentIndexes = new Set();
37
+ const baseStrictIndex = new Map();
38
+ for (const [index, issue] of baseIssues.entries()) {
39
+ const key = strictIssueKey(issue);
40
+ const bucket = baseStrictIndex.get(key);
41
+ if (bucket)
42
+ bucket.push(index);
43
+ else
44
+ baseStrictIndex.set(key, [index]);
45
+ }
46
+ for (const [currentIndex, issue] of currentIssues.entries()) {
47
+ const key = strictIssueKey(issue);
48
+ const bucket = baseStrictIndex.get(key);
49
+ if (!bucket || bucket.length === 0)
50
+ continue;
51
+ const matchedIndex = bucket.shift();
52
+ if (matchedIndex === undefined)
53
+ continue;
54
+ matchedBaseIndexes.add(matchedIndex);
55
+ matchedCurrentIndexes.add(currentIndex);
56
+ }
57
+ const baseNormalizedIndex = new Map();
58
+ for (const [index, issue] of baseIssues.entries()) {
59
+ if (matchedBaseIndexes.has(index))
60
+ continue;
61
+ const key = normalizedIssueKey(issue);
62
+ const bucket = baseNormalizedIndex.get(key);
63
+ if (bucket)
64
+ bucket.push(index);
65
+ else
66
+ baseNormalizedIndex.set(key, [index]);
67
+ }
68
+ for (const [currentIndex, issue] of currentIssues.entries()) {
69
+ if (matchedCurrentIndexes.has(currentIndex))
70
+ continue;
71
+ const key = normalizedIssueKey(issue);
72
+ const bucket = baseNormalizedIndex.get(key);
73
+ if (!bucket || bucket.length === 0)
74
+ continue;
75
+ const matchedIndex = bucket.shift();
76
+ if (matchedIndex === undefined)
77
+ continue;
78
+ matchedBaseIndexes.add(matchedIndex);
79
+ matchedCurrentIndexes.add(currentIndex);
80
+ }
81
+ const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index));
82
+ const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index));
19
83
  if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
20
84
  return {
21
85
  path: filePath,
@@ -30,11 +94,11 @@ function computeFileDiff(filePath, baseFile, currentFile) {
30
94
  }
31
95
  export function computeDiff(base, current, baseRef) {
32
96
  const fileDiffs = [];
33
- const baseByPath = new Map(base.files.map(f => [f.path, f]));
34
- const currentByPath = new Map(current.files.map(f => [f.path, f]));
97
+ const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]));
98
+ const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]));
35
99
  const allPaths = new Set([
36
- ...base.files.map(f => f.path),
37
- ...current.files.map(f => f.path),
100
+ ...base.files.map(f => normalizePath(f.path)),
101
+ ...current.files.map(f => normalizePath(f.path)),
38
102
  ]);
39
103
  for (const filePath of allPaths) {
40
104
  const baseFile = baseByPath.get(filePath);
package/dist/git.js CHANGED
@@ -54,6 +54,15 @@ function extractFile(projectPath, ref, filePath, tempDir) {
54
54
  mkdirSync(destDir, { recursive: true });
55
55
  writeFileSync(destPath, content, 'utf-8');
56
56
  }
57
+ function extractArchiveAtRef(projectPath, ref, tempDir) {
58
+ try {
59
+ execSync(`git archive --format=tar ${ref} | tar -x -C "${tempDir}"`, { cwd: projectPath, stdio: 'pipe' });
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
57
66
  export function extractFilesAtRef(projectPath, ref) {
58
67
  verifyGitRepo(projectPath);
59
68
  verifyRefExists(projectPath, ref);
@@ -63,6 +72,9 @@ export function extractFilesAtRef(projectPath, ref) {
63
72
  }
64
73
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`);
65
74
  mkdirSync(tempDir, { recursive: true });
75
+ if (extractArchiveAtRef(projectPath, ref, tempDir)) {
76
+ return tempDir;
77
+ }
66
78
  for (const filePath of tsFiles) {
67
79
  extractFile(projectPath, ref, filePath, tempDir);
68
80
  }
package/dist/index.d.ts CHANGED
@@ -2,8 +2,12 @@ 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 { buildTrustReport, formatTrustConsole, formatTrustMarkdown, formatTrustJson, shouldFailByMaxRisk, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, } from './trust.js';
6
+ export { computeTrustKpis, computeTrustKpisFromReports, formatTrustKpiConsole, formatTrustKpiJson, } from './trust-kpi.js';
5
7
  export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
6
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig, RepoQualityScore, MaintenanceRiskMetrics, DriftPlugin, DriftPluginRule, } from './types.js';
8
+ export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig, RepoQualityScore, MaintenanceRiskMetrics, DriftTrustReport, TrustReason, TrustFixPriority, TrustDiffContext, TrustKpiReport, TrustKpiDiagnostic, TrustDiffTrendSummary, TrustScoreStats, MergeRiskLevel, DriftPlugin, DriftPluginRule, } from './types.js';
7
9
  export { loadHistory, saveSnapshot } from './snapshot.js';
8
10
  export type { SnapshotEntry, SnapshotHistory } from './snapshot.js';
11
+ export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, SaasActorRequiredError, SaasPermissionError, getRequiredRoleForOperation, assertSaasPermission, getSaasEffectiveLimits, getOrganizationEffectiveLimits, changeOrganizationPlan, listOrganizationPlanChanges, getOrganizationUsageSnapshot, ingestSnapshotFromReport, listSaasSnapshots, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
12
+ export type { SaasRole, SaasPlan, SaasPolicy, SaasPolicyOverrides, SaasStore, SaasSummary, SaasSnapshot, SaasQueryOptions, IngestOptions, SaasPlanChange, SaasOperation, SaasPermissionContext, SaasPermissionResult, SaasEffectiveLimits, SaasOrganizationUsageSnapshot, ChangeOrganizationPlanOptions, SaasUsageQueryOptions, SaasPlanChangeQueryOptions, } from './saas.js';
9
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ 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 { buildTrustReport, formatTrustConsole, formatTrustMarkdown, formatTrustJson, shouldFailByMaxRisk, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, } from './trust.js';
6
+ export { computeTrustKpis, computeTrustKpisFromReports, formatTrustKpiConsole, formatTrustKpiJson, } from './trust-kpi.js';
5
7
  export { generateArchitectureMap, generateArchitectureSvg } from './map.js';
6
8
  export { loadHistory, saveSnapshot } from './snapshot.js';
9
+ export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, SaasActorRequiredError, SaasPermissionError, getRequiredRoleForOperation, assertSaasPermission, getSaasEffectiveLimits, getOrganizationEffectiveLimits, changeOrganizationPlan, listOrganizationPlanChanges, getOrganizationUsageSnapshot, ingestSnapshotFromReport, listSaasSnapshots, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
7
10
  //# sourceMappingURL=index.js.map
package/dist/map.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export declare function generateArchitectureSvg(targetPath: string): string;
2
- export declare function generateArchitectureMap(targetPath: string, outputFile?: string): string;
1
+ import type { DriftConfig } from './types.js';
2
+ export declare function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string;
3
+ export declare function generateArchitectureMap(targetPath: string, outputFile?: string, config?: DriftConfig): string;
3
4
  //# sourceMappingURL=map.d.ts.map
package/dist/map.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { resolve, relative } from 'node:path';
3
3
  import { Project } from 'ts-morph';
4
+ import { detectLayerViolations } from './rules/phase3-arch.js';
5
+ import { RULE_WEIGHTS } from './analyzer.js';
4
6
  function detectLayer(relPath) {
5
7
  const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
6
8
  const first = normalized.split('/')[0] || 'root';
@@ -9,7 +11,7 @@ function detectLayer(relPath) {
9
11
  function esc(value) {
10
12
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
13
  }
12
- export function generateArchitectureSvg(targetPath) {
14
+ export function generateArchitectureSvg(targetPath, config) {
13
15
  const project = new Project({
14
16
  skipAddingFilesFromTsConfig: true,
15
17
  compilerOptions: { allowJs: true, jsx: 1 },
@@ -26,24 +28,67 @@ export function generateArchitectureSvg(targetPath) {
26
28
  ]);
27
29
  const layers = new Map();
28
30
  const edges = new Map();
31
+ const layerAdjacency = new Map();
32
+ const fileImportGraph = new Map();
29
33
  for (const file of project.getSourceFiles()) {
30
- const rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/');
34
+ const filePath = file.getFilePath();
35
+ const rel = relative(targetPath, filePath).replace(/\\/g, '/');
31
36
  const layerName = detectLayer(rel);
32
37
  if (!layers.has(layerName))
33
38
  layers.set(layerName, { name: layerName, files: new Set() });
34
39
  layers.get(layerName).files.add(rel);
40
+ if (!fileImportGraph.has(filePath))
41
+ fileImportGraph.set(filePath, new Set());
35
42
  for (const decl of file.getImportDeclarations()) {
36
43
  const imported = decl.getModuleSpecifierSourceFile();
37
44
  if (!imported)
38
45
  continue;
46
+ fileImportGraph.get(filePath).add(imported.getFilePath());
39
47
  const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/');
40
48
  const importedLayer = detectLayer(importedRel);
41
49
  if (importedLayer === layerName)
42
50
  continue;
43
51
  const key = `${layerName}->${importedLayer}`;
44
52
  edges.set(key, (edges.get(key) ?? 0) + 1);
53
+ if (!layerAdjacency.has(layerName))
54
+ layerAdjacency.set(layerName, new Set());
55
+ layerAdjacency.get(layerName).add(importedLayer);
45
56
  }
46
57
  }
58
+ const cycleEdges = detectCycleEdges(layerAdjacency);
59
+ const violationEdges = new Set();
60
+ if (config?.layers && config.layers.length > 0) {
61
+ const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS);
62
+ for (const issues of violations.values()) {
63
+ for (const issue of issues) {
64
+ const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/);
65
+ if (!match)
66
+ continue;
67
+ const from = match[1];
68
+ const to = match[2];
69
+ violationEdges.add(`${from}->${to}`);
70
+ if (!layers.has(from))
71
+ layers.set(from, { name: from, files: new Set() });
72
+ if (!layers.has(to))
73
+ layers.set(to, { name: to, files: new Set() });
74
+ }
75
+ }
76
+ }
77
+ const edgeList = [...edges.entries()].map(([key, count]) => {
78
+ const [from, to] = key.split('->');
79
+ const kind = violationEdges.has(key)
80
+ ? 'violation'
81
+ : cycleEdges.has(key)
82
+ ? 'cycle'
83
+ : 'normal';
84
+ return { key, from, to, count, kind };
85
+ });
86
+ for (const key of violationEdges) {
87
+ if (edges.has(key))
88
+ continue;
89
+ const [from, to] = key.split('->');
90
+ edgeList.push({ key, from, to, count: 1, kind: 'violation' });
91
+ }
47
92
  const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
48
93
  const width = 960;
49
94
  const rowHeight = 90;
@@ -60,19 +105,24 @@ export function generateArchitectureSvg(targetPath) {
60
105
  };
61
106
  });
62
107
  const boxByName = new Map(boxes.map((box) => [box.name, box]));
63
- const lines = [...edges.entries()].map(([key, count]) => {
64
- const [from, to] = key.split('->');
65
- const a = boxByName.get(from);
66
- const b = boxByName.get(to);
108
+ const lines = edgeList.map((edge) => {
109
+ const a = boxByName.get(edge.from);
110
+ const b = boxByName.get(edge.to);
67
111
  if (!a || !b)
68
112
  return '';
69
113
  const startX = a.x + boxWidth;
70
114
  const startY = a.y + boxHeight / 2;
71
115
  const endX = b.x;
72
116
  const endY = b.y + boxHeight / 2;
117
+ const stroke = edge.kind === 'violation'
118
+ ? '#ef4444'
119
+ : edge.kind === 'cycle'
120
+ ? '#f59e0b'
121
+ : '#64748b';
122
+ const widthPx = edge.kind === 'normal' ? 2 : 3;
73
123
  return `
74
- <line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
75
- <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`;
124
+ <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}" />
125
+ <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`;
76
126
  }).join('');
77
127
  const nodes = boxes.map((box) => `
78
128
  <g>
@@ -80,6 +130,8 @@ export function generateArchitectureSvg(targetPath) {
80
130
  <text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
81
131
  <text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
82
132
  </g>`).join('');
133
+ const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length;
134
+ const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length;
83
135
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
84
136
  <defs>
85
137
  <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
@@ -89,13 +141,49 @@ export function generateArchitectureSvg(targetPath) {
89
141
  <rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
90
142
  <text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
91
143
  <text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
144
+ <text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
145
+ <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>
146
+ <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>
92
147
  ${lines}
93
148
  ${nodes}
94
149
  </svg>`;
95
150
  }
96
- export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg') {
151
+ function detectCycleEdges(adjacency) {
152
+ const visited = new Set();
153
+ const inStack = new Set();
154
+ const stack = [];
155
+ const cycleEdges = new Set();
156
+ function dfs(node) {
157
+ visited.add(node);
158
+ inStack.add(node);
159
+ stack.push(node);
160
+ for (const neighbor of adjacency.get(node) ?? []) {
161
+ if (!visited.has(neighbor)) {
162
+ dfs(neighbor);
163
+ continue;
164
+ }
165
+ if (!inStack.has(neighbor))
166
+ continue;
167
+ const startIndex = stack.indexOf(neighbor);
168
+ if (startIndex >= 0) {
169
+ for (let i = startIndex; i < stack.length - 1; i++) {
170
+ cycleEdges.add(`${stack[i]}->${stack[i + 1]}`);
171
+ }
172
+ }
173
+ cycleEdges.add(`${node}->${neighbor}`);
174
+ }
175
+ stack.pop();
176
+ inStack.delete(node);
177
+ }
178
+ for (const node of adjacency.keys()) {
179
+ if (!visited.has(node))
180
+ dfs(node);
181
+ }
182
+ return cycleEdges;
183
+ }
184
+ export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg', config) {
97
185
  const resolvedTarget = resolve(targetPath);
98
- const svg = generateArchitectureSvg(resolvedTarget);
186
+ const svg = generateArchitectureSvg(resolvedTarget, config);
99
187
  const outPath = resolve(outputFile);
100
188
  writeFileSync(outPath, svg, 'utf8');
101
189
  return outPath;
package/dist/plugins.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { PluginLoadError, LoadedPlugin } from './types.js';
1
+ import type { PluginLoadError, PluginLoadWarning, LoadedPlugin } from './types.js';
2
2
  export declare function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
3
3
  plugins: LoadedPlugin[];
4
4
  errors: PluginLoadError[];
5
+ warnings: PluginLoadWarning[];
5
6
  };
6
7
  //# sourceMappingURL=plugins.d.ts.map
package/dist/plugins.js CHANGED
@@ -2,28 +2,176 @@ import { existsSync } from 'node:fs';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  const require = createRequire(import.meta.url);
5
- function isPluginShape(value) {
6
- if (!value || typeof value !== 'object')
7
- return false;
8
- const candidate = value;
9
- if (typeof candidate.name !== 'string')
10
- return false;
11
- if (!Array.isArray(candidate.rules))
12
- return false;
13
- return candidate.rules.every((rule) => rule &&
14
- typeof rule === 'object' &&
15
- typeof rule.name === 'string' &&
16
- typeof rule.detect === 'function');
17
- }
5
+ const VALID_SEVERITIES = ['error', 'warning', 'info'];
6
+ const SUPPORTED_PLUGIN_API_VERSION = 1;
7
+ const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/;
18
8
  function normalizePluginExport(mod) {
19
- if (isPluginShape(mod))
20
- return mod;
21
9
  if (mod && typeof mod === 'object' && 'default' in mod) {
22
- const maybeDefault = mod.default;
23
- if (isPluginShape(maybeDefault))
24
- return maybeDefault;
10
+ return mod.default ?? mod;
11
+ }
12
+ return mod;
13
+ }
14
+ function pushError(errors, pluginId, message, options) {
15
+ errors.push({
16
+ pluginId,
17
+ pluginName: options?.pluginName,
18
+ ruleId: options?.ruleId,
19
+ code: options?.code,
20
+ message,
21
+ });
22
+ }
23
+ function pushWarning(warnings, pluginId, message, options) {
24
+ warnings.push({
25
+ pluginId,
26
+ pluginName: options?.pluginName,
27
+ ruleId: options?.ruleId,
28
+ code: options?.code,
29
+ message,
30
+ });
31
+ }
32
+ function normalizeRule(pluginId, pluginName, rawRule, ruleIndex, options, errors, warnings) {
33
+ const rawRuleId = typeof rawRule.id === 'string'
34
+ ? rawRule.id.trim()
35
+ : typeof rawRule.name === 'string'
36
+ ? rawRule.name.trim()
37
+ : '';
38
+ const ruleLabel = rawRuleId || `rule#${ruleIndex + 1}`;
39
+ if (!rawRuleId) {
40
+ pushError(errors, pluginId, `Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`, { pluginName, code: 'plugin-rule-id-missing' });
41
+ return undefined;
42
+ }
43
+ if (typeof rawRule.detect !== 'function') {
44
+ pushError(errors, pluginId, `Rule '${rawRuleId}' is invalid. Expected 'detect(file, context)' function.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-invalid' });
45
+ return undefined;
46
+ }
47
+ if (rawRule.detect.length > 2) {
48
+ pushWarning(warnings, pluginId, `Rule '${rawRuleId}' detect() declares ${rawRule.detect.length} parameters. Expected 1-2 parameters (file, context).`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-arity' });
49
+ }
50
+ if (!RULE_ID_REQUIRED.test(rawRuleId)) {
51
+ if (options.strictRuleId) {
52
+ pushError(errors, pluginId, `Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' });
53
+ return undefined;
54
+ }
55
+ pushWarning(warnings, pluginId, `Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' });
56
+ }
57
+ let severity;
58
+ if (rawRule.severity !== undefined) {
59
+ if (typeof rawRule.severity === 'string' && VALID_SEVERITIES.includes(rawRule.severity)) {
60
+ severity = rawRule.severity;
61
+ }
62
+ else {
63
+ pushError(errors, pluginId, `Rule '${rawRuleId}' has invalid severity '${String(rawRule.severity)}'. Allowed: error, warning, info.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-severity-invalid' });
64
+ }
65
+ }
66
+ let weight;
67
+ if (rawRule.weight !== undefined) {
68
+ if (typeof rawRule.weight === 'number' && Number.isFinite(rawRule.weight) && rawRule.weight >= 0 && rawRule.weight <= 100) {
69
+ weight = rawRule.weight;
70
+ }
71
+ else {
72
+ pushError(errors, pluginId, `Rule '${rawRuleId}' has invalid weight '${String(rawRule.weight)}'. Expected a finite number between 0 and 100.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-weight-invalid' });
73
+ }
74
+ }
75
+ let fix;
76
+ if (rawRule.fix !== undefined) {
77
+ if (typeof rawRule.fix === 'function') {
78
+ fix = rawRule.fix;
79
+ if (rawRule.fix.length > 3) {
80
+ pushWarning(warnings, pluginId, `Rule '${rawRuleId}' fix() declares ${rawRule.fix.length} parameters. Expected up to 3 (issue, file, context).`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-arity' });
81
+ }
82
+ }
83
+ else {
84
+ pushError(errors, pluginId, `Rule '${rawRuleId}' has invalid fix. Expected a function when provided.`, { pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-invalid' });
85
+ }
86
+ }
87
+ return {
88
+ id: rawRuleId,
89
+ name: rawRuleId,
90
+ detect: rawRule.detect,
91
+ severity,
92
+ weight,
93
+ fix,
94
+ };
95
+ }
96
+ function validatePluginContract(pluginId, candidate) {
97
+ const errors = [];
98
+ const warnings = [];
99
+ if (!candidate || typeof candidate !== 'object') {
100
+ pushError(errors, pluginId, `Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`, { code: 'plugin-shape-invalid' });
101
+ return { errors, warnings };
102
+ }
103
+ const plugin = candidate;
104
+ const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : '';
105
+ if (!pluginName) {
106
+ pushError(errors, pluginId, `Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`, { code: 'plugin-name-missing' });
107
+ return { errors, warnings };
108
+ }
109
+ const hasExplicitApiVersion = plugin.apiVersion !== undefined;
110
+ const isLegacyPlugin = !hasExplicitApiVersion;
111
+ if (isLegacyPlugin) {
112
+ pushWarning(warnings, pluginId, `Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`, { pluginName, code: 'plugin-api-version-implicit' });
113
+ }
114
+ else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
115
+ pushError(errors, pluginId, `Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`, { pluginName, code: 'plugin-api-version-invalid' });
116
+ return { errors, warnings };
117
+ }
118
+ else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
119
+ pushError(errors, pluginId, `Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`, { pluginName, code: 'plugin-api-version-unsupported' });
120
+ return { errors, warnings };
121
+ }
122
+ let capabilities;
123
+ if (plugin.capabilities !== undefined) {
124
+ if (!plugin.capabilities || typeof plugin.capabilities !== 'object' || Array.isArray(plugin.capabilities)) {
125
+ pushError(errors, pluginId, `Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`, { pluginName, code: 'plugin-capabilities-invalid' });
126
+ return { errors, warnings };
127
+ }
128
+ const entries = Object.entries(plugin.capabilities);
129
+ for (const [capabilityKey, capabilityValue] of entries) {
130
+ const capabilityType = typeof capabilityValue;
131
+ if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
132
+ pushError(errors, pluginId, `Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`, { pluginName, code: 'plugin-capabilities-value-invalid' });
133
+ }
134
+ }
135
+ if (errors.length > 0) {
136
+ return { errors, warnings };
137
+ }
138
+ capabilities = plugin.capabilities;
139
+ }
140
+ if (!Array.isArray(plugin.rules)) {
141
+ pushError(errors, pluginId, `Invalid plugin '${pluginName}'. Expected 'rules' to be an array.`, { pluginName, code: 'plugin-rules-not-array' });
142
+ return { errors, warnings };
143
+ }
144
+ const normalizedRules = [];
145
+ const seenRuleIds = new Set();
146
+ for (const [ruleIndex, rawRule] of plugin.rules.entries()) {
147
+ if (!rawRule || typeof rawRule !== 'object') {
148
+ pushError(errors, pluginId, `Invalid rule at index ${ruleIndex} in plugin '${pluginName}'. Expected an object.`, { pluginName, code: 'plugin-rule-shape-invalid' });
149
+ continue;
150
+ }
151
+ const normalized = normalizeRule(pluginId, pluginName, rawRule, ruleIndex, { strictRuleId: !isLegacyPlugin }, errors, warnings);
152
+ if (normalized) {
153
+ if (seenRuleIds.has(normalized.id ?? normalized.name)) {
154
+ pushError(errors, pluginId, `Plugin '${pluginName}' defines duplicate rule id '${normalized.id ?? normalized.name}'. Rule ids must be unique within a plugin.`, { pluginName, ruleId: normalized.id ?? normalized.name, code: 'plugin-rule-id-duplicate' });
155
+ continue;
156
+ }
157
+ seenRuleIds.add(normalized.id ?? normalized.name);
158
+ normalizedRules.push(normalized);
159
+ }
160
+ }
161
+ if (normalizedRules.length === 0) {
162
+ pushError(errors, pluginId, `Plugin '${pluginName}' has no valid rules after validation.`, { pluginName, code: 'plugin-rules-empty' });
163
+ return { errors, warnings };
25
164
  }
26
- return undefined;
165
+ return {
166
+ plugin: {
167
+ name: pluginName,
168
+ apiVersion: hasExplicitApiVersion ? plugin.apiVersion : SUPPORTED_PLUGIN_API_VERSION,
169
+ capabilities,
170
+ rules: normalizedRules,
171
+ },
172
+ errors,
173
+ warnings,
174
+ };
27
175
  }
28
176
  function resolvePluginSpecifier(projectRoot, pluginId) {
29
177
  if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
@@ -44,31 +192,32 @@ function resolvePluginSpecifier(projectRoot, pluginId) {
44
192
  }
45
193
  export function loadPlugins(projectRoot, pluginIds) {
46
194
  if (!pluginIds || pluginIds.length === 0) {
47
- return { plugins: [], errors: [] };
195
+ return { plugins: [], errors: [], warnings: [] };
48
196
  }
49
197
  const loaded = [];
50
198
  const errors = [];
199
+ const warnings = [];
51
200
  for (const pluginId of pluginIds) {
52
201
  const resolved = resolvePluginSpecifier(projectRoot, pluginId);
53
202
  try {
54
203
  const mod = require(resolved);
55
- const plugin = normalizePluginExport(mod);
56
- if (!plugin) {
57
- errors.push({
58
- pluginId,
59
- message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
60
- });
204
+ const normalized = normalizePluginExport(mod);
205
+ const validation = validatePluginContract(pluginId, normalized);
206
+ errors.push(...validation.errors);
207
+ warnings.push(...validation.warnings);
208
+ if (!validation.plugin) {
61
209
  continue;
62
210
  }
63
- loaded.push({ id: pluginId, plugin });
211
+ loaded.push({ id: pluginId, plugin: validation.plugin });
64
212
  }
65
213
  catch (error) {
66
214
  errors.push({
67
215
  pluginId,
216
+ code: 'plugin-load-failed',
68
217
  message: error instanceof Error ? error.message : String(error),
69
218
  });
70
219
  }
71
220
  }
72
- return { plugins: loaded, errors };
221
+ return { plugins: loaded, errors, warnings };
73
222
  }
74
223
  //# sourceMappingURL=plugins.js.map
package/dist/printer.js CHANGED
@@ -131,6 +131,10 @@ function formatFixSuggestion(issue) {
131
131
  'Fix or remove the failing plugin in drift.config.*',
132
132
  'Validate plugin contract: export { name, rules[] } and detector functions',
133
133
  ],
134
+ 'plugin-warning': [
135
+ 'Review plugin validation warnings and align with the recommended contract',
136
+ 'Use explicit rule ids and valid severity/weight values',
137
+ ],
134
138
  };
135
139
  return suggestions[issue.rule] ?? ['Review and fix manually'];
136
140
  }
package/dist/review.js 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';
@@ -48,7 +48,7 @@ export async function generateReview(projectPath, baseRef) {
48
48
  ...baseReport,
49
49
  files: baseReport.files.map((file) => ({
50
50
  ...file,
51
- path: file.path.replace(tempDir, resolvedPath),
51
+ path: resolve(resolvedPath, relative(tempDir, file.path)),
52
52
  })),
53
53
  };
54
54
  const diff = computeDiff(remappedBase, currentReport, baseRef);
@@ -1,4 +1,4 @@
1
- import { hasIgnoreComment } from './shared.js';
1
+ import { hasIgnoreComment, getFileLines } from './shared.js';
2
2
  const TRIVIAL_COMMENT_PATTERNS = [
3
3
  { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
4
4
  { comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
@@ -31,7 +31,7 @@ function checkLineForContradiction(commentLine, nextLine, lineNumber, file) {
31
31
  }
32
32
  export function detectCommentContradiction(file) {
33
33
  const issues = [];
34
- const lines = file.getFullText().split('\n');
34
+ const lines = getFileLines(file);
35
35
  for (let i = 0; i < lines.length - 1; i++) {
36
36
  const commentLine = lines[i].trim();
37
37
  const nextLine = lines[i + 1];
@@ -1,5 +1,5 @@
1
1
  import { SyntaxKind } from 'ts-morph';
2
- import { hasIgnoreComment, getSnippet } from './shared.js';
2
+ import { hasIgnoreComment, getSnippet, collectFunctionLikes } from './shared.js';
3
3
  const COMPLEXITY_THRESHOLD = 10;
4
4
  const INCREMENT_KINDS = [
5
5
  SyntaxKind.IfStatement,
@@ -24,12 +24,7 @@ function getCyclomaticComplexity(fn) {
24
24
  }
25
25
  export function detectHighComplexity(file) {
26
26
  const issues = [];
27
- const fns = [
28
- ...file.getFunctions(),
29
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
30
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
31
- ...file.getClasses().flatMap((c) => c.getMethods()),
32
- ];
27
+ const fns = collectFunctionLikes(file);
33
28
  for (const fn of fns) {
34
29
  const complexity = getCyclomaticComplexity(fn);
35
30
  if (complexity > COMPLEXITY_THRESHOLD) {