@eduardbar/drift 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) 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/review-pr.yml +34 -41
  7. package/AGENTS.md +75 -251
  8. package/CHANGELOG.md +28 -0
  9. package/README.md +148 -41
  10. package/dist/benchmark.d.ts +1 -1
  11. package/dist/benchmark.js +71 -52
  12. package/dist/cli.js +243 -8
  13. package/dist/config.js +16 -2
  14. package/dist/diff.js +42 -50
  15. package/dist/doctor.d.ts +5 -0
  16. package/dist/doctor.js +133 -0
  17. package/dist/format.d.ts +17 -0
  18. package/dist/format.js +45 -0
  19. package/dist/guard-types.d.ts +57 -0
  20. package/dist/guard-types.js +2 -0
  21. package/dist/guard.d.ts +14 -0
  22. package/dist/guard.js +239 -0
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.js +4 -1
  25. package/dist/init.d.ts +15 -0
  26. package/dist/init.js +273 -0
  27. package/dist/map-cycles.d.ts +2 -0
  28. package/dist/map-cycles.js +34 -0
  29. package/dist/map-svg.d.ts +19 -0
  30. package/dist/map-svg.js +97 -0
  31. package/dist/map.js +78 -138
  32. package/dist/metrics.js +70 -55
  33. package/dist/output-metadata.d.ts +13 -0
  34. package/dist/output-metadata.js +17 -0
  35. package/dist/plugins-capabilities.d.ts +4 -0
  36. package/dist/plugins-capabilities.js +21 -0
  37. package/dist/plugins-messages.d.ts +10 -0
  38. package/dist/plugins-messages.js +16 -0
  39. package/dist/plugins-rules.d.ts +9 -0
  40. package/dist/plugins-rules.js +137 -0
  41. package/dist/plugins.d.ts +1 -1
  42. package/dist/plugins.js +45 -142
  43. package/dist/reporter-constants.d.ts +16 -0
  44. package/dist/reporter-constants.js +39 -0
  45. package/dist/reporter.d.ts +3 -3
  46. package/dist/reporter.js +35 -55
  47. package/dist/review.d.ts +2 -1
  48. package/dist/review.js +2 -1
  49. package/dist/rules/phase3-configurable.js +23 -15
  50. package/dist/saas/constants.d.ts +15 -0
  51. package/dist/saas/constants.js +48 -0
  52. package/dist/saas/dashboard.d.ts +8 -0
  53. package/dist/saas/dashboard.js +132 -0
  54. package/dist/saas/errors.d.ts +19 -0
  55. package/dist/saas/errors.js +37 -0
  56. package/dist/saas/helpers.d.ts +21 -0
  57. package/dist/saas/helpers.js +110 -0
  58. package/dist/saas/ingest.d.ts +3 -0
  59. package/dist/saas/ingest.js +249 -0
  60. package/dist/saas/organization.d.ts +5 -0
  61. package/dist/saas/organization.js +82 -0
  62. package/dist/saas/plan-change.d.ts +10 -0
  63. package/dist/saas/plan-change.js +15 -0
  64. package/dist/saas/store.d.ts +21 -0
  65. package/dist/saas/store.js +159 -0
  66. package/dist/saas/types.d.ts +191 -0
  67. package/dist/saas/types.js +2 -0
  68. package/dist/saas.d.ts +8 -218
  69. package/dist/saas.js +7 -761
  70. package/dist/sarif.d.ts +74 -0
  71. package/dist/sarif.js +122 -0
  72. package/dist/trust-advanced.d.ts +14 -0
  73. package/dist/trust-advanced.js +65 -0
  74. package/dist/trust-kpi-fs.d.ts +3 -0
  75. package/dist/trust-kpi-fs.js +141 -0
  76. package/dist/trust-kpi-parse.d.ts +7 -0
  77. package/dist/trust-kpi-parse.js +186 -0
  78. package/dist/trust-kpi-types.d.ts +16 -0
  79. package/dist/trust-kpi-types.js +2 -0
  80. package/dist/trust-kpi.d.ts +1 -3
  81. package/dist/trust-kpi.js +6 -266
  82. package/dist/trust-policy.d.ts +32 -0
  83. package/dist/trust-policy.js +160 -0
  84. package/dist/trust-render.d.ts +9 -0
  85. package/dist/trust-render.js +54 -0
  86. package/dist/trust-scoring.d.ts +9 -0
  87. package/dist/trust-scoring.js +208 -0
  88. package/dist/trust.d.ts +4 -32
  89. package/dist/trust.js +29 -432
  90. package/dist/types/app.d.ts +30 -0
  91. package/dist/types/app.js +2 -0
  92. package/dist/types/config.d.ts +25 -0
  93. package/dist/types/config.js +2 -0
  94. package/dist/types/core.d.ts +100 -0
  95. package/dist/types/core.js +2 -0
  96. package/dist/types/diff.d.ts +55 -0
  97. package/dist/types/diff.js +2 -0
  98. package/dist/types/plugin.d.ts +41 -0
  99. package/dist/types/plugin.js +2 -0
  100. package/dist/types/trust.d.ts +120 -0
  101. package/dist/types/trust.js +2 -0
  102. package/dist/types.d.ts +8 -365
  103. package/docs/release-notes-draft.md +40 -0
  104. package/docs/rules-catalog.md +49 -0
  105. package/docs/trust-core-release-checklist.md +37 -5
  106. package/package.json +3 -2
  107. package/packages/vscode-drift/src/code-actions.ts +1 -1
  108. package/schemas/drift-ai-output.v1.json +162 -0
  109. package/schemas/drift-report.v1.json +151 -0
  110. package/schemas/drift-trust.v1.json +131 -0
  111. package/scripts/smoke-repo.mjs +394 -0
  112. package/src/benchmark.ts +75 -53
  113. package/src/cli.ts +285 -13
  114. package/src/config.ts +19 -2
  115. package/src/diff.ts +57 -48
  116. package/src/doctor.ts +173 -0
  117. package/src/format.ts +81 -0
  118. package/src/guard-types.ts +64 -0
  119. package/src/guard.ts +324 -0
  120. package/src/index.ts +35 -0
  121. package/src/init.ts +298 -0
  122. package/src/map-cycles.ts +38 -0
  123. package/src/map-svg.ts +124 -0
  124. package/src/map.ts +111 -142
  125. package/src/metrics.ts +78 -59
  126. package/src/output-metadata.ts +30 -0
  127. package/src/plugins-capabilities.ts +36 -0
  128. package/src/plugins-messages.ts +35 -0
  129. package/src/plugins-rules.ts +296 -0
  130. package/src/plugins.ts +76 -283
  131. package/src/reporter-constants.ts +46 -0
  132. package/src/reporter.ts +64 -65
  133. package/src/review.ts +4 -2
  134. package/src/rules/phase3-configurable.ts +39 -26
  135. package/src/saas/constants.ts +56 -0
  136. package/src/saas/dashboard.ts +172 -0
  137. package/src/saas/errors.ts +45 -0
  138. package/src/saas/helpers.ts +140 -0
  139. package/src/saas/ingest.ts +278 -0
  140. package/src/saas/organization.ts +99 -0
  141. package/src/saas/plan-change.ts +19 -0
  142. package/src/saas/store.ts +172 -0
  143. package/src/saas/types.ts +216 -0
  144. package/src/saas.ts +49 -1031
  145. package/src/sarif.ts +232 -0
  146. package/src/trust-advanced.ts +99 -0
  147. package/src/trust-kpi-fs.ts +169 -0
  148. package/src/trust-kpi-parse.ts +219 -0
  149. package/src/trust-kpi-types.ts +19 -0
  150. package/src/trust-kpi.ts +8 -316
  151. package/src/trust-policy.ts +246 -0
  152. package/src/trust-render.ts +61 -0
  153. package/src/trust-scoring.ts +231 -0
  154. package/src/trust.ts +62 -576
  155. package/src/types/app.ts +30 -0
  156. package/src/types/config.ts +27 -0
  157. package/src/types/core.ts +105 -0
  158. package/src/types/diff.ts +61 -0
  159. package/src/types/plugin.ts +46 -0
  160. package/src/types/trust.ts +134 -0
  161. package/src/types.ts +78 -409
  162. package/tests/cli-sarif.test.ts +92 -0
  163. package/tests/format.test.ts +157 -0
  164. package/tests/new-features.test.ts +10 -2
  165. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  166. package/tests/sarif.test.ts +160 -0
  167. package/tests/trust-kpi.test.ts +31 -4
  168. package/tests/trust.test.ts +18 -0
package/dist/metrics.js CHANGED
@@ -25,6 +25,19 @@ const AI_RULES = new Set([
25
25
  'comment-contradiction',
26
26
  'ai-code-smell',
27
27
  ]);
28
+ const ISSUE_WEIGHT_PER_FILE = 20;
29
+ const DIMENSION_COUNT = 4;
30
+ const MAX_COMPLEXITY_RISK = 40;
31
+ const COMPLEXITY_RISK_PER_ISSUE = 10;
32
+ const MISSING_TESTS_RISK = 25;
33
+ const FREQUENT_CHANGE_THRESHOLD = 8;
34
+ const FREQUENT_CHANGE_RISK = 20;
35
+ const HIGH_DRIFT_THRESHOLD = 50;
36
+ const HIGH_DRIFT_RISK = 15;
37
+ const HOTSPOT_LIMIT = 10;
38
+ const LEVEL_CRITICAL_THRESHOLD = 75;
39
+ const LEVEL_HIGH_THRESHOLD = 55;
40
+ const LEVEL_MEDIUM_THRESHOLD = 30;
28
41
  function clamp(value, min, max) {
29
42
  return Math.max(min, Math.min(max, value));
30
43
  }
@@ -33,21 +46,20 @@ function listFilesRecursively(root) {
33
46
  return [];
34
47
  const out = [];
35
48
  const stack = [root];
49
+ const shouldSkipDirectory = (name) => name === 'node_modules' || name === 'dist' || name === '.git' || name === '.next' || name === 'build';
36
50
  while (stack.length > 0) {
37
51
  const current = stack.pop();
38
52
  const entries = readdirSync(current);
39
53
  for (const entry of entries) {
40
54
  const full = join(current, entry);
41
55
  const stat = statSync(full);
42
- if (stat.isDirectory()) {
43
- if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
44
- continue;
45
- }
46
- stack.push(full);
47
- }
48
- else {
56
+ if (!stat.isDirectory()) {
49
57
  out.push(full);
58
+ continue;
50
59
  }
60
+ if (shouldSkipDirectory(entry))
61
+ continue;
62
+ stack.push(full);
51
63
  }
52
64
  }
53
65
  return out;
@@ -83,7 +95,51 @@ function qualityFromIssues(totalFiles, issues, rules) {
83
95
  const count = issues.filter((issue) => rules.has(issue.rule)).length;
84
96
  if (totalFiles === 0)
85
97
  return 100;
86
- return clamp(100 - Math.round((count / totalFiles) * 20), 0, 100);
98
+ return clamp(100 - Math.round((count / totalFiles) * ISSUE_WEIGHT_PER_FILE), 0, 100);
99
+ }
100
+ function riskLevelFromScore(score) {
101
+ if (score >= LEVEL_CRITICAL_THRESHOLD)
102
+ return 'critical';
103
+ if (score >= LEVEL_HIGH_THRESHOLD)
104
+ return 'high';
105
+ if (score >= LEVEL_MEDIUM_THRESHOLD)
106
+ return 'medium';
107
+ return 'low';
108
+ }
109
+ function evaluateHotspot(targetPath, file) {
110
+ const complexityIssues = file.issues.filter((issue) => issue.rule === 'high-complexity' ||
111
+ issue.rule === 'deep-nesting' ||
112
+ issue.rule === 'large-function' ||
113
+ issue.rule === 'max-function-lines').length;
114
+ const changeFrequency = getCommitTouchCount(targetPath, file.path);
115
+ const hasTests = hasNearbyTest(targetPath, file.path);
116
+ const reasons = [];
117
+ let risk = 0;
118
+ if (complexityIssues > 0) {
119
+ risk += Math.min(MAX_COMPLEXITY_RISK, complexityIssues * COMPLEXITY_RISK_PER_ISSUE);
120
+ reasons.push('high complexity signals');
121
+ }
122
+ if (!hasTests) {
123
+ risk += MISSING_TESTS_RISK;
124
+ reasons.push('no nearby tests');
125
+ }
126
+ if (changeFrequency >= FREQUENT_CHANGE_THRESHOLD) {
127
+ risk += FREQUENT_CHANGE_RISK;
128
+ reasons.push('frequently changed file');
129
+ }
130
+ if (file.score >= HIGH_DRIFT_THRESHOLD) {
131
+ risk += HIGH_DRIFT_RISK;
132
+ reasons.push('high drift score');
133
+ }
134
+ return {
135
+ file: file.path,
136
+ driftScore: file.score,
137
+ complexityIssues,
138
+ hasNearbyTests: hasTests,
139
+ changeFrequency,
140
+ risk: clamp(risk, 0, 100),
141
+ reasons,
142
+ };
87
143
  }
88
144
  export function computeRepoQuality(targetPath, files) {
89
145
  const allIssues = files.flatMap((file) => file.issues);
@@ -105,63 +161,22 @@ export function computeRepoQuality(targetPath, files) {
105
161
  const overall = Math.round((dimensions.architecture +
106
162
  dimensions.complexity +
107
163
  dimensions['ai-patterns'] +
108
- dimensions.testing) / 4);
164
+ dimensions.testing) / DIMENSION_COUNT);
109
165
  return { overall, dimensions };
110
166
  }
111
167
  export function computeMaintenanceRisk(report) {
112
- const allFiles = report.files;
113
- const hotspots = allFiles
114
- .map((file) => {
115
- const complexityIssues = file.issues.filter((issue) => issue.rule === 'high-complexity' ||
116
- issue.rule === 'deep-nesting' ||
117
- issue.rule === 'large-function' ||
118
- issue.rule === 'max-function-lines').length;
119
- const changeFrequency = getCommitTouchCount(report.targetPath, file.path);
120
- const hasTests = hasNearbyTest(report.targetPath, file.path);
121
- const reasons = [];
122
- let risk = 0;
123
- if (complexityIssues > 0) {
124
- risk += Math.min(40, complexityIssues * 10);
125
- reasons.push('high complexity signals');
126
- }
127
- if (!hasTests) {
128
- risk += 25;
129
- reasons.push('no nearby tests');
130
- }
131
- if (changeFrequency >= 8) {
132
- risk += 20;
133
- reasons.push('frequently changed file');
134
- }
135
- if (file.score >= 50) {
136
- risk += 15;
137
- reasons.push('high drift score');
138
- }
139
- return {
140
- file: file.path,
141
- driftScore: file.score,
142
- complexityIssues,
143
- hasNearbyTests: hasTests,
144
- changeFrequency,
145
- risk: clamp(risk, 0, 100),
146
- reasons,
147
- };
148
- })
168
+ const hotspots = report.files
169
+ .map((file) => evaluateHotspot(report.targetPath, file))
149
170
  .filter((hotspot) => hotspot.risk > 0)
150
171
  .sort((a, b) => b.risk - a.risk)
151
- .slice(0, 10);
172
+ .slice(0, HOTSPOT_LIMIT);
152
173
  const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length;
153
174
  const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length;
154
- const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= 8).length;
175
+ const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= FREQUENT_CHANGE_THRESHOLD).length;
155
176
  const score = hotspots.length === 0
156
177
  ? 0
157
178
  : Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length);
158
- const level = score >= 75
159
- ? 'critical'
160
- : score >= 55
161
- ? 'high'
162
- : score >= 30
163
- ? 'medium'
164
- : 'low';
179
+ const level = riskLevelFromScore(score);
165
180
  return {
166
181
  score,
167
182
  level,
@@ -0,0 +1,13 @@
1
+ export declare const OUTPUT_SCHEMA: {
2
+ readonly report: "schemas/drift-report.v1.json";
3
+ readonly trust: "schemas/drift-trust.v1.json";
4
+ readonly ai: "schemas/drift-ai-output.v1.json";
5
+ };
6
+ type OutputMetadata = {
7
+ $schema: string;
8
+ toolVersion: string;
9
+ };
10
+ type JsonOutputWithMetadata<T extends object> = T & OutputMetadata;
11
+ export declare function withOutputMetadata<T extends object>(payload: T, schema: string): JsonOutputWithMetadata<T>;
12
+ export {};
13
+ //# sourceMappingURL=output-metadata.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const { version } = require('../package.json');
4
+ const TOOL_VERSION = version;
5
+ export const OUTPUT_SCHEMA = {
6
+ report: 'schemas/drift-report.v1.json',
7
+ trust: 'schemas/drift-trust.v1.json',
8
+ ai: 'schemas/drift-ai-output.v1.json',
9
+ };
10
+ export function withOutputMetadata(payload, schema) {
11
+ return {
12
+ ...payload,
13
+ $schema: schema,
14
+ toolVersion: TOOL_VERSION,
15
+ };
16
+ }
17
+ //# sourceMappingURL=output-metadata.js.map
@@ -0,0 +1,4 @@
1
+ import type { DriftPlugin } from './types.js';
2
+ import type { PluginValidationContext } from './plugins-rules.js';
3
+ export declare function validateCapabilities(capabilitiesCandidate: unknown, context: PluginValidationContext): DriftPlugin['capabilities'] | undefined;
4
+ //# sourceMappingURL=plugins-capabilities.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { pushError } from './plugins-messages.js';
2
+ export function validateCapabilities(capabilitiesCandidate, context) {
3
+ const { pluginId, pluginName, errors } = context;
4
+ if (capabilitiesCandidate === undefined)
5
+ return undefined;
6
+ if (!capabilitiesCandidate || typeof capabilitiesCandidate !== 'object' || Array.isArray(capabilitiesCandidate)) {
7
+ pushError(errors, pluginId, `Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`, { pluginName, code: 'plugin-capabilities-invalid' });
8
+ return undefined;
9
+ }
10
+ const entries = Object.entries(capabilitiesCandidate);
11
+ for (const [capabilityKey, capabilityValue] of entries) {
12
+ const capabilityType = typeof capabilityValue;
13
+ if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
14
+ pushError(errors, pluginId, `Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`, { pluginName, code: 'plugin-capabilities-value-invalid' });
15
+ }
16
+ }
17
+ if (errors.length > 0)
18
+ return undefined;
19
+ return capabilitiesCandidate;
20
+ }
21
+ //# sourceMappingURL=plugins-capabilities.js.map
@@ -0,0 +1,10 @@
1
+ import type { PluginLoadError, PluginLoadWarning } from './types.js';
2
+ type PluginMessageOptions = {
3
+ pluginName?: string;
4
+ ruleId?: string;
5
+ code?: string;
6
+ };
7
+ export declare function pushError(errors: PluginLoadError[], pluginId: string, message: string, options?: PluginMessageOptions): void;
8
+ export declare function pushWarning(warnings: PluginLoadWarning[], pluginId: string, message: string, options?: PluginMessageOptions): void;
9
+ export {};
10
+ //# sourceMappingURL=plugins-messages.d.ts.map
@@ -0,0 +1,16 @@
1
+ function pushLoadMessage(pluginId, message, options) {
2
+ return {
3
+ pluginId,
4
+ pluginName: options?.pluginName,
5
+ ruleId: options?.ruleId,
6
+ code: options?.code,
7
+ message,
8
+ };
9
+ }
10
+ export function pushError(errors, pluginId, message, options) {
11
+ errors.push(pushLoadMessage(pluginId, message, options));
12
+ }
13
+ export function pushWarning(warnings, pluginId, message, options) {
14
+ warnings.push(pushLoadMessage(pluginId, message, options));
15
+ }
16
+ //# sourceMappingURL=plugins-messages.js.map
@@ -0,0 +1,9 @@
1
+ import type { DriftPluginRule, PluginLoadError, PluginLoadWarning } from './types.js';
2
+ export type PluginValidationContext = {
3
+ pluginId: string;
4
+ pluginName: string;
5
+ errors: PluginLoadError[];
6
+ warnings: PluginLoadWarning[];
7
+ };
8
+ export declare function normalizeRules(rulesCandidate: unknown, isLegacyPlugin: boolean, context: PluginValidationContext): DriftPluginRule[] | undefined;
9
+ //# sourceMappingURL=plugins-rules.d.ts.map
@@ -0,0 +1,137 @@
1
+ import { pushError, pushWarning } from './plugins-messages.js';
2
+ const VALID_SEVERITIES = ['error', 'warning', 'info'];
3
+ const MAX_FIX_ARITY = 3;
4
+ const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/;
5
+ function resolveRawRuleId(rawRule) {
6
+ if (typeof rawRule.id === 'string')
7
+ return rawRule.id.trim();
8
+ if (typeof rawRule.name === 'string')
9
+ return rawRule.name.trim();
10
+ return '';
11
+ }
12
+ function ensureRuleId(rawRuleId, ruleIndex, context) {
13
+ if (rawRuleId)
14
+ return true;
15
+ pushError(context.errors, context.pluginId, `Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`, { pluginName: context.pluginName, code: 'plugin-rule-id-missing' });
16
+ return false;
17
+ }
18
+ function ensureDetectFunction(detect, context) {
19
+ if (typeof detect === 'function')
20
+ return true;
21
+ pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' is invalid. Expected 'detect(file, context)' function.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-invalid' });
22
+ return false;
23
+ }
24
+ function warnDetectArity(detect, context) {
25
+ if (detect.length <= 2)
26
+ return;
27
+ pushWarning(context.warnings, context.pluginId, `Rule '${context.ruleId}' detect() declares ${detect.length} parameters. Expected 1-2 parameters (file, context).`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-arity' });
28
+ }
29
+ function validateRuleIdentifierFormat(rawRuleId, strictRuleId, context) {
30
+ if (RULE_ID_REQUIRED.test(rawRuleId))
31
+ return;
32
+ const ruleLabel = rawRuleId || 'unknown-rule';
33
+ if (strictRuleId) {
34
+ pushError(context.errors, context.pluginId, `Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`, { pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' });
35
+ return;
36
+ }
37
+ pushWarning(context.warnings, context.pluginId, `Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: 1.`, { pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' });
38
+ }
39
+ function resolveRuleSeverity(rawSeverity, context) {
40
+ if (rawSeverity === undefined)
41
+ return undefined;
42
+ if (typeof rawSeverity === 'string' && VALID_SEVERITIES.includes(rawSeverity)) {
43
+ return rawSeverity;
44
+ }
45
+ pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid severity '${String(rawSeverity)}'. Allowed: error, warning, info.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-severity-invalid' });
46
+ return undefined;
47
+ }
48
+ function resolveRuleWeight(rawWeight, context) {
49
+ if (rawWeight === undefined)
50
+ return undefined;
51
+ if (typeof rawWeight === 'number' && Number.isFinite(rawWeight) && rawWeight >= 0 && rawWeight <= 100) {
52
+ return rawWeight;
53
+ }
54
+ pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid weight '${String(rawWeight)}'. Expected a finite number between 0 and 100.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-weight-invalid' });
55
+ return undefined;
56
+ }
57
+ function resolveRuleFix(rawFix, context) {
58
+ if (rawFix === undefined)
59
+ return undefined;
60
+ if (typeof rawFix !== 'function') {
61
+ pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid fix. Expected a function when provided.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-invalid' });
62
+ return undefined;
63
+ }
64
+ if (rawFix.length > MAX_FIX_ARITY) {
65
+ pushWarning(context.warnings, context.pluginId, `Rule '${context.ruleId}' fix() declares ${rawFix.length} parameters. Expected up to ${MAX_FIX_ARITY} (issue, file, context).`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-arity' });
66
+ }
67
+ return rawFix;
68
+ }
69
+ function normalizeRule(rawRule, context) {
70
+ const { pluginId, pluginName, ruleIndex, strictRuleId, errors, warnings } = context;
71
+ const rawRuleId = resolveRawRuleId(rawRule);
72
+ const messageContext = { pluginId, pluginName, ruleId: rawRuleId, errors, warnings };
73
+ if (!ensureRuleId(rawRuleId, ruleIndex, messageContext))
74
+ return undefined;
75
+ if (!ensureDetectFunction(rawRule.detect, messageContext))
76
+ return undefined;
77
+ validateRuleIdentifierFormat(rawRuleId, strictRuleId, messageContext);
78
+ warnDetectArity(rawRule.detect, messageContext);
79
+ const ruleValidationContext = { pluginId, pluginName, ruleId: rawRuleId, errors };
80
+ const severity = resolveRuleSeverity(rawRule.severity, ruleValidationContext);
81
+ const weight = resolveRuleWeight(rawRule.weight, ruleValidationContext);
82
+ const fix = resolveRuleFix(rawRule.fix, messageContext);
83
+ return {
84
+ id: rawRuleId,
85
+ name: rawRuleId,
86
+ detect: rawRule.detect,
87
+ severity,
88
+ weight,
89
+ fix,
90
+ };
91
+ }
92
+ function ensureUniqueRuleId(rule, seenRuleIds, context) {
93
+ const normalizedRuleId = rule.id ?? rule.name;
94
+ if (seenRuleIds.has(normalizedRuleId)) {
95
+ pushError(context.errors, context.pluginId, `Plugin '${context.pluginName}' defines duplicate rule id '${normalizedRuleId}'. Rule ids must be unique within a plugin.`, { pluginName: context.pluginName, ruleId: normalizedRuleId, code: 'plugin-rule-id-duplicate' });
96
+ return false;
97
+ }
98
+ seenRuleIds.add(normalizedRuleId);
99
+ return true;
100
+ }
101
+ function normalizeRulesArray(rulesCandidate, context, strictRuleId) {
102
+ const normalizedRules = [];
103
+ const seenRuleIds = new Set();
104
+ for (const [ruleIndex, rawRule] of rulesCandidate.entries()) {
105
+ if (!rawRule || typeof rawRule !== 'object') {
106
+ pushError(context.errors, context.pluginId, `Invalid rule at index ${ruleIndex} in plugin '${context.pluginName}'. Expected an object.`, { pluginName: context.pluginName, code: 'plugin-rule-shape-invalid' });
107
+ continue;
108
+ }
109
+ const normalized = normalizeRule(rawRule, {
110
+ pluginId: context.pluginId,
111
+ pluginName: context.pluginName,
112
+ ruleIndex,
113
+ strictRuleId,
114
+ errors: context.errors,
115
+ warnings: context.warnings,
116
+ });
117
+ if (!normalized)
118
+ continue;
119
+ if (ensureUniqueRuleId(normalized, seenRuleIds, context)) {
120
+ normalizedRules.push(normalized);
121
+ }
122
+ }
123
+ return normalizedRules;
124
+ }
125
+ export function normalizeRules(rulesCandidate, isLegacyPlugin, context) {
126
+ if (!Array.isArray(rulesCandidate)) {
127
+ pushError(context.errors, context.pluginId, `Invalid plugin '${context.pluginName}'. Expected 'rules' to be an array.`, { pluginName: context.pluginName, code: 'plugin-rules-not-array' });
128
+ return undefined;
129
+ }
130
+ const normalizedRules = normalizeRulesArray(rulesCandidate, context, !isLegacyPlugin);
131
+ if (normalizedRules.length === 0) {
132
+ pushError(context.errors, context.pluginId, `Plugin '${context.pluginName}' has no valid rules after validation.`, { pluginName: context.pluginName, code: 'plugin-rules-empty' });
133
+ return undefined;
134
+ }
135
+ return normalizedRules;
136
+ }
137
+ //# sourceMappingURL=plugins-rules.js.map
package/dist/plugins.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginLoadError, PluginLoadWarning, LoadedPlugin } from './types.js';
1
+ import type { LoadedPlugin, PluginLoadError, PluginLoadWarning } from './types.js';
2
2
  export declare function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
3
3
  plugins: LoadedPlugin[];
4
4
  errors: PluginLoadError[];
package/dist/plugins.js CHANGED
@@ -1,171 +1,72 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
+ import { normalizeRules } from './plugins-rules.js';
5
+ import { validateCapabilities } from './plugins-capabilities.js';
6
+ import { pushError, pushWarning } from './plugins-messages.js';
4
7
  const require = createRequire(import.meta.url);
5
- const VALID_SEVERITIES = ['error', 'warning', 'info'];
6
8
  const SUPPORTED_PLUGIN_API_VERSION = 1;
7
- const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/;
8
9
  function normalizePluginExport(mod) {
9
10
  if (mod && typeof mod === 'object' && 'default' in mod) {
10
11
  return mod.default ?? mod;
11
12
  }
12
13
  return mod;
13
14
  }
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
- }
15
+ function ensureObjectCandidate(pluginId, candidate, errors) {
16
+ if (candidate && typeof candidate === 'object') {
17
+ return candidate;
74
18
  }
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
- };
19
+ pushError(errors, pluginId, `Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`, { code: 'plugin-shape-invalid' });
20
+ return undefined;
95
21
  }
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;
22
+ function ensurePluginName(pluginId, plugin, errors) {
104
23
  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
- }
24
+ if (pluginName)
25
+ return pluginName;
26
+ pushError(errors, pluginId, `Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`, { code: 'plugin-name-missing' });
27
+ return undefined;
28
+ }
29
+ function validateApiVersion(plugin, context) {
30
+ const { pluginId, pluginName, errors, warnings } = context;
109
31
  const hasExplicitApiVersion = plugin.apiVersion !== undefined;
110
32
  const isLegacyPlugin = !hasExplicitApiVersion;
111
33
  if (isLegacyPlugin) {
112
34
  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' });
35
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true };
113
36
  }
114
- else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
37
+ if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
115
38
  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 };
39
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false };
117
40
  }
118
- else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
41
+ if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
119
42
  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 };
43
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false };
121
44
  }
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' });
45
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true };
46
+ }
47
+ function validatePluginContractData(pluginId, candidate) {
48
+ const errors = [];
49
+ const warnings = [];
50
+ const plugin = ensureObjectCandidate(pluginId, candidate, errors);
51
+ if (!plugin)
142
52
  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' });
53
+ const pluginName = ensurePluginName(pluginId, plugin, errors);
54
+ if (!pluginName)
55
+ return { errors, warnings };
56
+ const context = { pluginId, pluginName, errors, warnings };
57
+ const apiVersion = validateApiVersion(plugin, context);
58
+ if (!apiVersion.isSupported)
59
+ return { errors, warnings };
60
+ const capabilities = validateCapabilities(plugin.capabilities, context);
61
+ if (errors.length > 0)
62
+ return { errors, warnings };
63
+ const normalizedRules = normalizeRules(plugin.rules, apiVersion.isLegacyPlugin, context);
64
+ if (!normalizedRules)
163
65
  return { errors, warnings };
164
- }
165
66
  return {
166
67
  plugin: {
167
68
  name: pluginName,
168
- apiVersion: hasExplicitApiVersion ? plugin.apiVersion : SUPPORTED_PLUGIN_API_VERSION,
69
+ apiVersion: apiVersion.hasExplicitApiVersion ? plugin.apiVersion : SUPPORTED_PLUGIN_API_VERSION,
169
70
  capabilities,
170
71
  rules: normalizedRules,
171
72
  },
@@ -173,6 +74,9 @@ function validatePluginContract(pluginId, candidate) {
173
74
  warnings,
174
75
  };
175
76
  }
77
+ function validatePluginContract(pluginId, candidate) {
78
+ return validatePluginContractData(pluginId, candidate);
79
+ }
176
80
  function resolvePluginSpecifier(projectRoot, pluginId) {
177
81
  if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
178
82
  const abs = isAbsolute(pluginId) ? pluginId : resolve(projectRoot, pluginId);
@@ -205,9 +109,8 @@ export function loadPlugins(projectRoot, pluginIds) {
205
109
  const validation = validatePluginContract(pluginId, normalized);
206
110
  errors.push(...validation.errors);
207
111
  warnings.push(...validation.warnings);
208
- if (!validation.plugin) {
112
+ if (!validation.plugin)
209
113
  continue;
210
- }
211
114
  loaded.push({ id: pluginId, plugin: validation.plugin });
212
115
  }
213
116
  catch (error) {