@eduardbar/drift 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  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 +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. 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,10 +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';
9
- export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
10
- export type { SaasPolicy, SaasStore, SaasSummary, SaasSnapshot, IngestOptions, } from './saas.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';
11
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -2,7 +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';
7
- export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
9
+ export { DEFAULT_SAAS_POLICY, defaultSaasStorePath, resolveSaasPolicy, SaasActorRequiredError, SaasPermissionError, getRequiredRoleForOperation, assertSaasPermission, getSaasEffectiveLimits, getOrganizationEffectiveLimits, changeOrganizationPlan, listOrganizationPlanChanges, getOrganizationUsageSnapshot, ingestSnapshotFromReport, listSaasSnapshots, getSaasSummary, generateSaasDashboardHtml, } from './saas.js';
8
10
  //# sourceMappingURL=index.js.map
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) {
@@ -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 NESTING_THRESHOLD = 3;
4
4
  const PARAMS_THRESHOLD = 4;
5
5
  const NESTING_KINDS = new Set([
@@ -29,12 +29,7 @@ function getMaxNestingDepth(fn) {
29
29
  }
30
30
  export function detectDeepNesting(file) {
31
31
  const issues = [];
32
- const fns = [
33
- ...file.getFunctions(),
34
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
35
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
36
- ...file.getClasses().flatMap((c) => c.getMethods()),
37
- ];
32
+ const fns = collectFunctionLikes(file);
38
33
  for (const fn of fns) {
39
34
  const depth = getMaxNestingDepth(fn);
40
35
  if (depth > NESTING_THRESHOLD) {
@@ -55,12 +50,7 @@ export function detectDeepNesting(file) {
55
50
  }
56
51
  export function detectTooManyParams(file) {
57
52
  const issues = [];
58
- const fns = [
59
- ...file.getFunctions(),
60
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
61
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
62
- ...file.getClasses().flatMap((c) => c.getMethods()),
63
- ];
53
+ const fns = collectFunctionLikes(file);
64
54
  for (const fn of fns) {
65
55
  const paramCount = fn.getParameters().length;
66
56
  if (paramCount > PARAMS_THRESHOLD) {
@@ -1,5 +1,5 @@
1
1
  import { SyntaxKind } from 'ts-morph';
2
- import { hasIgnoreComment, getSnippet, getFunctionLikeLines } from './shared.js';
2
+ import { hasIgnoreComment, getSnippet, getFunctionLikeLines, collectFunctionLikes, getFileLines } from './shared.js';
3
3
  const LARGE_FILE_THRESHOLD = 300;
4
4
  const LARGE_FUNCTION_THRESHOLD = 50;
5
5
  const SNIPPET_TRUNCATE_SHORT = 60;
@@ -22,12 +22,7 @@ export function detectLargeFile(file) {
22
22
  }
23
23
  export function detectLargeFunctions(file) {
24
24
  const issues = [];
25
- const fns = [
26
- ...file.getFunctions(),
27
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
28
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
29
- ...file.getClasses().flatMap((c) => c.getMethods()),
30
- ];
25
+ const fns = collectFunctionLikes(file);
31
26
  for (const fn of fns) {
32
27
  const lines = getFunctionLikeLines(fn);
33
28
  const startLine = fn.getStartLineNumber();
@@ -64,7 +59,7 @@ export function detectDebugLeftovers(file) {
64
59
  });
65
60
  }
66
61
  }
67
- const lines = file.getFullText().split('\n');
62
+ const lines = getFileLines(file);
68
63
  lines.forEach((lineContent, i) => {
69
64
  if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
70
65
  if (hasIgnoreComment(file, i + 1))
@@ -83,11 +78,16 @@ export function detectDebugLeftovers(file) {
83
78
  }
84
79
  export function detectDeadCode(file) {
85
80
  const issues = [];
81
+ const identifierCounts = new Map();
82
+ for (const id of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
83
+ const text = id.getText();
84
+ identifierCounts.set(text, (identifierCounts.get(text) ?? 0) + 1);
85
+ }
86
86
  for (const imp of file.getImportDeclarations()) {
87
87
  for (const named of imp.getNamedImports()) {
88
88
  const name = named.getName();
89
- const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter((id) => id.getText() === name && id !== named.getNameNode());
90
- if (refs.length === 0) {
89
+ const refsCount = Math.max(0, (identifierCounts.get(name) ?? 0) - 1);
90
+ if (refsCount === 0) {
91
91
  issues.push({
92
92
  rule: 'dead-code',
93
93
  severity: 'warning',
@@ -1,5 +1,7 @@
1
1
  import { SourceFile, Node, FunctionDeclaration, ArrowFunction, FunctionExpression, MethodDeclaration } from 'ts-morph';
2
2
  export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration;
3
+ export declare function getFileLines(file: SourceFile): string[];
4
+ export declare function collectFunctionLikes(file: SourceFile): FunctionLike[];
3
5
  export declare function hasIgnoreComment(file: SourceFile, line: number): boolean;
4
6
  export declare function isFileIgnored(file: SourceFile): boolean;
5
7
  export declare function getSnippet(node: Node, file: SourceFile): string;
@@ -1,5 +1,29 @@
1
- export function hasIgnoreComment(file, line) {
1
+ import { SyntaxKind, } from 'ts-morph';
2
+ const fileLinesCache = new WeakMap();
3
+ const functionLikesCache = new WeakMap();
4
+ export function getFileLines(file) {
5
+ const cached = fileLinesCache.get(file);
6
+ if (cached)
7
+ return cached;
2
8
  const lines = file.getFullText().split('\n');
9
+ fileLinesCache.set(file, lines);
10
+ return lines;
11
+ }
12
+ export function collectFunctionLikes(file) {
13
+ const cached = functionLikesCache.get(file);
14
+ if (cached)
15
+ return cached;
16
+ const fns = [
17
+ ...file.getFunctions(),
18
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
19
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
20
+ ...file.getClasses().flatMap((c) => c.getMethods()),
21
+ ];
22
+ functionLikesCache.set(file, fns);
23
+ return fns;
24
+ }
25
+ export function hasIgnoreComment(file, line) {
26
+ const lines = getFileLines(file);
3
27
  const currentLine = lines[line - 1] ?? '';
4
28
  const prevLine = lines[line - 2] ?? '';
5
29
  if (/\/\/\s*drift-ignore\b/.test(currentLine))
@@ -9,12 +33,12 @@ export function hasIgnoreComment(file, line) {
9
33
  return false;
10
34
  }
11
35
  export function isFileIgnored(file) {
12
- const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n'); // drift-ignore
36
+ const firstLines = getFileLines(file).slice(0, 10).join('\n'); // drift-ignore
13
37
  return /\/\/\s*drift-ignore-file\b/.test(firstLines);
14
38
  }
15
39
  export function getSnippet(node, file) {
16
40
  const startLine = node.getStartLineNumber();
17
- const lines = file.getFullText().split('\n');
41
+ const lines = getFileLines(file);
18
42
  return lines
19
43
  .slice(Math.max(0, startLine - 1), startLine + 1)
20
44
  .join('\n')