@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/cli.js CHANGED
@@ -14,6 +14,7 @@ import { printConsole, printDiff } from './printer.js';
14
14
  import { loadConfig } from './config.js';
15
15
  import { extractFilesAtRef, cleanupTempDir } from './git.js';
16
16
  import { computeDiff } from './diff.js';
17
+ import { runGuard } from './guard.js';
17
18
  import { generateHtmlReport } from './report.js';
18
19
  import { generateBadge } from './badge.js';
19
20
  import { emitCIAnnotations, printCISummary } from './ci.js';
@@ -24,6 +25,11 @@ import { generateArchitectureMap } from './map.js';
24
25
  import { changeOrganizationPlan, generateSaasDashboardHtml, getOrganizationEffectiveLimits, getOrganizationUsageSnapshot, getSaasSummary, ingestSnapshotFromReport, listOrganizationPlanChanges, } from './saas.js';
25
26
  import { buildTrustReport, explainTrustGatePolicy, formatTrustGatePolicyExplanation, formatTrustJson, renderTrustOutput, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, detectBranchName, } from './trust.js';
26
27
  import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js';
28
+ import { runBenchmarkCli } from './benchmark.js';
29
+ import { runInit, INIT_PRESETS } from './init.js';
30
+ import { runDoctor } from './doctor.js';
31
+ import { resolveOutputFormat } from './format.js';
32
+ import { toSarif, diffToSarif } from './sarif.js';
27
33
  const program = new Command();
28
34
  function parseOptionalPositiveInt(rawValue, flagName) {
29
35
  if (rawValue == null)
@@ -51,6 +57,77 @@ function addResourceOptions(command) {
51
57
  .option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
52
58
  .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode');
53
59
  }
60
+ function parseOptionalNumber(rawValue, flagName) {
61
+ if (rawValue == null)
62
+ return undefined;
63
+ const value = Number(rawValue);
64
+ if (!Number.isFinite(value)) {
65
+ throw new Error(`${flagName} must be a valid number`);
66
+ }
67
+ return value;
68
+ }
69
+ function parseBySeverity(rawValue) {
70
+ if (rawValue == null)
71
+ return undefined;
72
+ const spec = rawValue.trim();
73
+ if (!spec) {
74
+ throw new Error('--by-severity must not be empty. Expected format: error=0,warning=2,info=5');
75
+ }
76
+ const thresholds = {};
77
+ const seen = new Set();
78
+ for (const segment of spec.split(',')) {
79
+ const pair = segment.trim();
80
+ if (!pair)
81
+ continue;
82
+ const equalIndex = pair.indexOf('=');
83
+ if (equalIndex <= 0 || equalIndex === pair.length - 1) {
84
+ throw new Error(`Invalid --by-severity entry '${pair}'. Expected key=value (e.g. warning=2).`);
85
+ }
86
+ const key = pair.slice(0, equalIndex).trim().toLowerCase();
87
+ const rawThreshold = pair.slice(equalIndex + 1).trim();
88
+ if (key !== 'error' && key !== 'warning' && key !== 'info') {
89
+ throw new Error(`Invalid --by-severity key '${key}'. Allowed keys: error, warning, info.`);
90
+ }
91
+ if (seen.has(key)) {
92
+ throw new Error(`Duplicate --by-severity key '${key}'.`);
93
+ }
94
+ const threshold = Number(rawThreshold);
95
+ if (!Number.isFinite(threshold)) {
96
+ throw new Error(`Invalid --by-severity value for '${key}': '${rawThreshold}'. Must be a valid number.`);
97
+ }
98
+ const severityKey = key;
99
+ thresholds[severityKey] = threshold;
100
+ seen.add(severityKey);
101
+ }
102
+ if (seen.size === 0) {
103
+ throw new Error('--by-severity must include at least one threshold. Example: error=0,warning=2');
104
+ }
105
+ return thresholds;
106
+ }
107
+ function formatSigned(value) {
108
+ return value > 0 ? `+${value}` : `${value}`;
109
+ }
110
+ function printGuardSummary(result) {
111
+ const modeLabel = result.mode === 'diff' ? `diff (${result.baseRef ?? 'unknown base'})` : 'baseline';
112
+ const statusLabel = result.passed ? 'PASS' : 'FAIL';
113
+ process.stdout.write('\n');
114
+ process.stdout.write(`Guard mode: ${modeLabel}\n`);
115
+ process.stdout.write(`Result: ${statusLabel}\n`);
116
+ process.stdout.write(`Score delta: ${formatSigned(result.metrics.scoreDelta)}\n`);
117
+ process.stdout.write(`Total issues delta: ${formatSigned(result.metrics.totalIssuesDelta)}\n`);
118
+ process.stdout.write(`Severity delta: error=${formatSigned(result.metrics.severityDelta.error)}, warning=${formatSigned(result.metrics.severityDelta.warning)}, info=${formatSigned(result.metrics.severityDelta.info)}\n`);
119
+ if (result.mode === 'baseline' && result.baselinePath) {
120
+ process.stdout.write(`Baseline file: ${result.baselinePath}\n`);
121
+ }
122
+ if (result.checks.length === 0) {
123
+ process.stdout.write('Checks: none configured\n');
124
+ return;
125
+ }
126
+ process.stdout.write('Checks:\n');
127
+ for (const check of result.checks) {
128
+ process.stdout.write(` - [${check.passed ? 'PASS' : 'FAIL'}] ${check.id}: ${check.message} (actual=${check.actual}, limit=${check.limit})\n`);
129
+ }
130
+ }
54
131
  function parseTrustGateOverrides(options) {
55
132
  const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined;
56
133
  if (options.minTrust && Number.isNaN(cliMinTrust)) {
@@ -95,6 +172,7 @@ addResourceOptions(program
95
172
  .command('scan [path]', { isDefault: true })
96
173
  .description('Scan a directory for vibe coding drift')
97
174
  .option('-o, --output <file>', 'Write report to a Markdown file')
175
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
98
176
  .option('--json', 'Output raw JSON report')
99
177
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
100
178
  .option('--fix', 'Show fix suggestions for each issue')
@@ -106,15 +184,33 @@ addResourceOptions(program
106
184
  const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
107
185
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
108
186
  const report = buildReport(resolvedPath, files);
109
- if (options.ai) {
187
+ const format = resolveOutputFormat({
188
+ command: 'scan',
189
+ format: options.format,
190
+ supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
191
+ legacyAliases: [
192
+ { flag: 'json', used: options.json, mapsTo: 'json' },
193
+ { flag: 'ai', used: options.ai, mapsTo: 'ai' },
194
+ ],
195
+ onWarning: (message) => process.stderr.write(`${message}\n`),
196
+ });
197
+ if (format === 'sarif') {
198
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
199
+ return;
200
+ }
201
+ if (format === 'ai') {
110
202
  const aiOutput = formatAIOutput(report);
111
203
  process.stdout.write(JSON.stringify(aiOutput, null, 2));
112
204
  return;
113
205
  }
114
- if (options.json) {
206
+ if (format === 'json') {
115
207
  process.stdout.write(JSON.stringify(report, null, 2));
116
208
  return;
117
209
  }
210
+ if (format === 'markdown') {
211
+ process.stdout.write(`${formatMarkdown(report)}\n`);
212
+ return;
213
+ }
118
214
  printConsole(report, { showFix: options.fix });
119
215
  if (options.output) {
120
216
  const md = formatMarkdown(report);
@@ -128,9 +224,31 @@ addResourceOptions(program
128
224
  process.exit(1);
129
225
  }
130
226
  }));
227
+ program
228
+ .command('init')
229
+ .description('Initialize drift configuration with presets and scaffolding')
230
+ .option('--preset <type>', `Scaffold config with preset: ${INIT_PRESETS.join(', ')}`)
231
+ .option('--ci', 'Generate GitHub Actions workflow for drift review')
232
+ .option('--baseline', 'Create drift-baseline.json with current project score')
233
+ .action(async (options) => {
234
+ const projectRoot = resolve('.');
235
+ try {
236
+ await runInit(projectRoot, {
237
+ preset: options.preset,
238
+ ci: options.ci,
239
+ baseline: options.baseline,
240
+ });
241
+ }
242
+ catch (err) {
243
+ const message = err instanceof Error ? err.message : String(err);
244
+ process.stderr.write(`\n Error: ${message}\n\n`);
245
+ process.exit(1);
246
+ }
247
+ });
131
248
  addResourceOptions(program
132
249
  .command('diff [ref]')
133
250
  .description('Compare current state against a git ref (default: HEAD~1)')
251
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
134
252
  .option('--json', 'Output raw JSON diff')
135
253
  .action(async (ref, options) => {
136
254
  const baseRef = ref ?? 'HEAD~1';
@@ -139,6 +257,13 @@ addResourceOptions(program
139
257
  let tempDir;
140
258
  try {
141
259
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`);
260
+ const format = resolveOutputFormat({
261
+ command: 'diff',
262
+ format: options.format,
263
+ supported: ['console', 'json', 'sarif'],
264
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
265
+ onWarning: (message) => process.stderr.write(`${message}\n`),
266
+ });
142
267
  // Scan current state
143
268
  const config = await loadConfig(projectPath);
144
269
  const currentFiles = analyzeProject(projectPath, config, analysisOptions);
@@ -157,7 +282,10 @@ addResourceOptions(program
157
282
  })),
158
283
  };
159
284
  const diff = computeDiff(remappedBase, currentReport, baseRef);
160
- if (options.json) {
285
+ if (format === 'sarif') {
286
+ process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`);
287
+ }
288
+ else if (format === 'json') {
161
289
  process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
162
290
  }
163
291
  else {
@@ -174,21 +302,81 @@ addResourceOptions(program
174
302
  cleanupTempDir(tempDir);
175
303
  }
176
304
  }));
305
+ addResourceOptions(program
306
+ .command('guard [path]')
307
+ .description('Evaluate drift guard thresholds against diff or baseline')
308
+ .option('--base <ref>', 'Git base ref for diff guard mode')
309
+ .option('--baseline <file>', 'Baseline file path (default: drift-baseline.json)')
310
+ .option('--budget <n>', 'Allowed score delta budget')
311
+ .option('--by-severity <spec>', 'Severity thresholds: error=0,warning=2,info=5')
312
+ .option('--json', 'Output raw JSON guard result')
313
+ .action(async (targetPath, options) => {
314
+ try {
315
+ const resolvedPath = resolve(targetPath ?? '.');
316
+ const budget = parseOptionalNumber(options.budget, '--budget');
317
+ const bySeverity = parseBySeverity(options.bySeverity);
318
+ const result = await runGuard(resolvedPath, {
319
+ baseRef: options.base,
320
+ baselinePath: options.baseline,
321
+ budget,
322
+ bySeverity,
323
+ analysis: resolveAnalysisOptions(options),
324
+ });
325
+ if (options.json) {
326
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
327
+ }
328
+ else {
329
+ printGuardSummary(result);
330
+ }
331
+ if (!result.passed) {
332
+ process.exit(1);
333
+ }
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ process.stderr.write(`\n Error: ${message}\n\n`);
338
+ process.exit(1);
339
+ }
340
+ }));
341
+ program
342
+ .command('benchmark')
343
+ .description('Run benchmark harness for scan/review/trust commands')
344
+ .allowUnknownOption(true)
345
+ .action(async () => {
346
+ await runBenchmarkCli(process.argv.slice(3));
347
+ });
177
348
  program
178
349
  .command('review')
179
350
  .description('Review drift against a base ref and output PR markdown')
180
351
  .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
352
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
181
353
  .option('--json', 'Output structured review JSON')
182
354
  .option('--comment', 'Output markdown comment body')
183
355
  .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
184
356
  .action(async (options) => {
185
357
  try {
186
358
  const review = await generateReview(resolve('.'), options.base);
187
- if (options.json) {
359
+ const format = resolveOutputFormat({
360
+ command: 'review',
361
+ format: options.format,
362
+ supported: ['console', 'json', 'markdown', 'sarif'],
363
+ legacyAliases: [
364
+ { flag: 'json', used: options.json, mapsTo: 'json' },
365
+ { flag: 'comment', used: options.comment, mapsTo: 'markdown' },
366
+ ],
367
+ onWarning: (message) => process.stderr.write(`${message}\n`),
368
+ });
369
+ if (format === 'sarif') {
370
+ process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`);
371
+ }
372
+ else if (format === 'json') {
188
373
  process.stdout.write(JSON.stringify(review, null, 2) + '\n');
189
374
  }
375
+ else if (format === 'markdown') {
376
+ process.stdout.write(`${review.markdown}\n`);
377
+ }
190
378
  else {
191
- process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n');
379
+ process.stdout.write(`${review.summary}\n\n${review.markdown}\n`);
192
380
  }
193
381
  const failOn = options.failOn ? Number(options.failOn) : undefined;
194
382
  if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
@@ -205,6 +393,7 @@ addResourceOptions(program
205
393
  .command('trust [path]')
206
394
  .description('Compute merge trust baseline from drift signals')
207
395
  .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
396
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
208
397
  .option('--json', 'Output structured trust JSON')
209
398
  .option('--markdown', 'Output trust report as markdown (PR comment ready)')
210
399
  .option('-o, --output <file>', 'Write trust output to file')
@@ -284,7 +473,22 @@ addResourceOptions(program
284
473
  snapshots,
285
474
  },
286
475
  });
287
- const rendered = `${renderTrustOutput(trust, options)}\n`;
476
+ const format = resolveOutputFormat({
477
+ command: 'trust',
478
+ format: options.format,
479
+ supported: ['console', 'json', 'markdown', 'sarif'],
480
+ legacyAliases: [
481
+ { flag: 'json', used: options.json, mapsTo: 'json' },
482
+ { flag: 'markdown', used: options.markdown, mapsTo: 'markdown' },
483
+ ],
484
+ onWarning: (message) => process.stderr.write(`${message}\n`),
485
+ });
486
+ const rendered = format === 'sarif'
487
+ ? `${JSON.stringify(toSarif(report), null, 2)}\n`
488
+ : `${renderTrustOutput(trust, {
489
+ json: format === 'json',
490
+ markdown: format === 'markdown',
491
+ })}\n`;
288
492
  process.stdout.write(rendered);
289
493
  if (options.output) {
290
494
  const outPath = resolve(options.output);
@@ -378,6 +582,20 @@ program
378
582
  process.exit(1);
379
583
  }
380
584
  });
585
+ program
586
+ .command('doctor')
587
+ .description('Run project environment diagnostics')
588
+ .option('--json', 'Output structured doctor JSON')
589
+ .action(async (opts) => {
590
+ try {
591
+ await runDoctor(process.cwd(), { json: opts.json });
592
+ }
593
+ catch (err) {
594
+ const message = err instanceof Error ? err.message : String(err);
595
+ process.stderr.write(`\n Error: ${message}\n\n`);
596
+ process.exitCode = 1;
597
+ }
598
+ });
381
599
  program
382
600
  .command('kpi <path>')
383
601
  .description('Aggregate trust KPIs from trust JSON artifacts')
@@ -442,14 +660,31 @@ addResourceOptions(program
442
660
  addResourceOptions(program
443
661
  .command('ci [path]')
444
662
  .description('Emit GitHub Actions annotations and step summary')
663
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
664
+ .option('--json', 'Output raw JSON report (legacy alias for --format json)')
445
665
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
446
666
  .action(async (targetPath, options) => {
447
667
  const resolvedPath = resolve(targetPath ?? '.');
448
668
  const config = await loadConfig(resolvedPath);
449
669
  const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
450
670
  const report = buildReport(resolvedPath, files);
451
- emitCIAnnotations(report);
452
- printCISummary(report);
671
+ const format = resolveOutputFormat({
672
+ command: 'ci',
673
+ format: options.format,
674
+ supported: ['console', 'json', 'sarif'],
675
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
676
+ onWarning: (message) => process.stderr.write(`${message}\n`),
677
+ });
678
+ if (format === 'sarif') {
679
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
680
+ }
681
+ else if (format === 'json') {
682
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
683
+ }
684
+ else {
685
+ emitCIAnnotations(report);
686
+ printCISummary(report);
687
+ }
453
688
  const minScore = Number(options.minScore);
454
689
  if (minScore > 0 && report.totalScore > minScore) {
455
690
  process.exit(1);
package/dist/config.js CHANGED
@@ -1,6 +1,19 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
+ function normalizeLegacyConfig(config) {
5
+ if (config.modules !== undefined) {
6
+ return config;
7
+ }
8
+ const legacyModules = config.moduleBoundaries ?? config.boundaries;
9
+ if (!legacyModules || legacyModules.length === 0) {
10
+ return config;
11
+ }
12
+ return {
13
+ ...config,
14
+ modules: legacyModules,
15
+ };
16
+ }
4
17
  /**
5
18
  * Load drift.config.ts / .js / .json from the given project root.
6
19
  * Returns undefined if no config file is found.
@@ -23,13 +36,14 @@ export async function loadConfig(projectRoot) {
23
36
  const ext = candidate.split('.').pop();
24
37
  if (ext === 'json') {
25
38
  const { readFileSync } = await import('node:fs');
26
- return JSON.parse(readFileSync(candidate, 'utf-8'));
39
+ const rawConfig = JSON.parse(readFileSync(candidate, 'utf-8'));
40
+ return normalizeLegacyConfig(rawConfig);
27
41
  }
28
42
  // .ts / .js — dynamic import via file URL
29
43
  const fileUrl = pathToFileURL(resolve(candidate)).href;
30
44
  const mod = await import(fileUrl);
31
45
  const config = mod.default ?? mod;
32
- return config;
46
+ return normalizeLegacyConfig(config);
33
47
  }
34
48
  catch { // drift-ignore
35
49
  // drift-ignore: catch-swallow — config is optional; load failure is non-fatal
package/dist/diff.js CHANGED
@@ -8,6 +8,43 @@ function normalizeIssueText(value) {
8
8
  .replace(/\s+/g, ' ')
9
9
  .trim();
10
10
  }
11
+ const SNIPPET_PREFIX_LENGTH = 80;
12
+ function strictIssueKey(i) {
13
+ return `${i.rule}:${i.line}:${i.column}`;
14
+ }
15
+ function normalizedIssueKey(i) {
16
+ const normalizedMessage = normalizeIssueText(i.message);
17
+ const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, SNIPPET_PREFIX_LENGTH);
18
+ return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`;
19
+ }
20
+ function buildIssueIndex(issues, getKey, skip) {
21
+ const index = new Map();
22
+ for (const [idx, issue] of issues.entries()) {
23
+ if (skip?.has(idx))
24
+ continue;
25
+ const key = getKey(issue);
26
+ const bucket = index.get(key);
27
+ if (bucket)
28
+ bucket.push(idx);
29
+ else
30
+ index.set(key, [idx]);
31
+ }
32
+ return index;
33
+ }
34
+ function matchIssues(currentIssues, index, state, getKey) {
35
+ for (const [currentIndex, issue] of currentIssues.entries()) {
36
+ if (state.matchedCurrentIndexes.has(currentIndex))
37
+ continue;
38
+ const bucket = index.get(getKey(issue));
39
+ if (!bucket || bucket.length === 0)
40
+ continue;
41
+ const matchedIndex = bucket.shift();
42
+ if (matchedIndex === undefined)
43
+ continue;
44
+ state.matchedBaseIndexes.add(matchedIndex);
45
+ state.matchedCurrentIndexes.add(currentIndex);
46
+ }
47
+ }
11
48
  /**
12
49
  * Compute the diff between two DriftReports.
13
50
  *
@@ -26,58 +63,13 @@ function computeFileDiff(filePath, baseFile, currentFile) {
26
63
  const scoreDelta = scoreAfter - scoreBefore;
27
64
  const baseIssues = baseFile?.issues ?? [];
28
65
  const currentIssues = currentFile?.issues ?? [];
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
66
  const matchedBaseIndexes = new Set();
36
67
  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
- }
68
+ const matchState = { matchedBaseIndexes, matchedCurrentIndexes };
69
+ const baseStrictIndex = buildIssueIndex(baseIssues, strictIssueKey);
70
+ matchIssues(currentIssues, baseStrictIndex, matchState, strictIssueKey);
71
+ const baseNormalizedIndex = buildIssueIndex(baseIssues, normalizedIssueKey, matchedBaseIndexes);
72
+ matchIssues(currentIssues, baseNormalizedIndex, matchState, normalizedIssueKey);
81
73
  const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index));
82
74
  const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index));
83
75
  if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
@@ -0,0 +1,5 @@
1
+ export interface DoctorOptions {
2
+ json?: boolean;
3
+ }
4
+ export declare function runDoctor(projectPath: string, options?: DoctorOptions): Promise<number>;
5
+ //# sourceMappingURL=doctor.d.ts.map
package/dist/doctor.js ADDED
@@ -0,0 +1,133 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import kleur from 'kleur';
4
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
5
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', 'coverage']);
6
+ const DECIMAL_RADIX = 10;
7
+ const MIN_SUPPORTED_NODE_MAJOR = 18;
8
+ const LOW_MEMORY_SOURCE_FILE_THRESHOLD = 500;
9
+ const DRIFT_CONFIG_CANDIDATES = [
10
+ 'drift.config.ts',
11
+ 'drift.config.js',
12
+ 'drift.config.mjs',
13
+ 'drift.config.cjs',
14
+ 'drift.config.json',
15
+ ];
16
+ function parseNodeMajor(version) {
17
+ const parsed = Number.parseInt(version.replace(/^v/, '').split('.')[0] ?? '0', DECIMAL_RADIX);
18
+ return Number.isFinite(parsed) ? parsed : 0;
19
+ }
20
+ function detectDriftConfig(projectPath) {
21
+ for (const candidate of DRIFT_CONFIG_CANDIDATES) {
22
+ if (existsSync(join(projectPath, candidate))) {
23
+ return candidate;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ function countSourceFiles(projectPath) {
29
+ let total = 0;
30
+ const stack = [projectPath];
31
+ while (stack.length > 0) {
32
+ const currentDir = stack.pop();
33
+ if (!currentDir)
34
+ continue;
35
+ const entries = readdirSync(currentDir, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
38
+ stack.push(join(currentDir, entry.name));
39
+ continue;
40
+ }
41
+ if (entry.isDirectory()) {
42
+ continue;
43
+ }
44
+ if (!entry.isFile())
45
+ continue;
46
+ const lastDot = entry.name.lastIndexOf('.');
47
+ if (lastDot === -1)
48
+ continue;
49
+ const extension = entry.name.slice(lastDot);
50
+ if (SOURCE_EXTENSIONS.has(extension)) {
51
+ total += 1;
52
+ }
53
+ }
54
+ }
55
+ return total;
56
+ }
57
+ function buildDoctorReport(projectPath) {
58
+ const nodeMajor = parseNodeMajor(process.version);
59
+ const packageJsonPath = join(projectPath, 'package.json');
60
+ const packageJsonFound = existsSync(packageJsonPath);
61
+ let esm = false;
62
+ if (packageJsonFound) {
63
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
64
+ esm = parsed.type === 'module';
65
+ }
66
+ const sourceFilesCount = countSourceFiles(projectPath);
67
+ return {
68
+ targetPath: projectPath,
69
+ node: {
70
+ version: process.version,
71
+ major: nodeMajor,
72
+ supported: nodeMajor >= MIN_SUPPORTED_NODE_MAJOR,
73
+ },
74
+ project: {
75
+ packageJsonFound,
76
+ esm,
77
+ tsconfigFound: existsSync(join(projectPath, 'tsconfig.json')),
78
+ sourceFilesCount,
79
+ lowMemorySuggested: sourceFilesCount > LOW_MEMORY_SOURCE_FILE_THRESHOLD,
80
+ driftConfigFile: detectDriftConfig(projectPath),
81
+ },
82
+ };
83
+ }
84
+ function printConsoleReport(report) {
85
+ const icons = {
86
+ check: kleur.green('✓'),
87
+ warn: kleur.yellow('⚠'),
88
+ error: kleur.red('✗'),
89
+ info: kleur.cyan('ℹ'),
90
+ };
91
+ process.stdout.write('\n');
92
+ process.stdout.write(`${kleur.bold().white('drift doctor')} ${kleur.gray('- environment diagnostics')}\n\n`);
93
+ const nodeStatus = report.node.supported
94
+ ? `${icons.check} ${kleur.green('Node runtime supported')}`
95
+ : `${icons.warn} ${kleur.yellow('Node runtime below recommended minimum (>=18)')}`;
96
+ process.stdout.write(`${nodeStatus} ${kleur.gray(`(${report.node.version})`)}\n`);
97
+ if (report.project.packageJsonFound) {
98
+ process.stdout.write(`${icons.check} package.json found\n`);
99
+ process.stdout.write(`${icons.info} ESM mode: ${report.project.esm ? kleur.green('yes') : kleur.yellow('no')}\n`);
100
+ }
101
+ else {
102
+ process.stdout.write(`${icons.warn} package.json not found\n`);
103
+ process.stdout.write(`${icons.info} ESM mode: ${kleur.gray('unknown')}\n`);
104
+ }
105
+ if (report.project.tsconfigFound) {
106
+ process.stdout.write(`${icons.check} tsconfig.json found\n`);
107
+ }
108
+ else {
109
+ process.stdout.write(`${icons.warn} tsconfig.json not found\n`);
110
+ }
111
+ process.stdout.write(`${icons.info} Source files (.ts/.tsx/.js/.jsx): ${report.project.sourceFilesCount}\n`);
112
+ if (report.project.lowMemorySuggested) {
113
+ process.stdout.write(`${icons.warn} Large codebase detected, consider ${kleur.bold('--low-memory')}\n`);
114
+ }
115
+ if (report.project.driftConfigFile) {
116
+ process.stdout.write(`${icons.check} Drift config: ${report.project.driftConfigFile}\n`);
117
+ }
118
+ else {
119
+ process.stdout.write(`${icons.warn} Drift config not found (drift.config.*)\n`);
120
+ }
121
+ process.stdout.write('\n');
122
+ }
123
+ export async function runDoctor(projectPath, options) {
124
+ const report = buildDoctorReport(projectPath);
125
+ if (options?.json) {
126
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
127
+ }
128
+ else {
129
+ printConsoleReport(report);
130
+ }
131
+ return 0;
132
+ }
133
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,17 @@
1
+ declare const UNIFIED_FORMAT_VALUES: readonly ["console", "json", "markdown", "ai", "sarif"];
2
+ type UnifiedOutputFormat = (typeof UNIFIED_FORMAT_VALUES)[number];
3
+ type LegacyAlias = {
4
+ flag: string;
5
+ used?: boolean;
6
+ mapsTo: UnifiedOutputFormat;
7
+ };
8
+ interface ResolveOutputFormatOptions {
9
+ command: string;
10
+ format?: string;
11
+ supported: readonly UnifiedOutputFormat[];
12
+ legacyAliases?: LegacyAlias[];
13
+ onWarning?: (message: string) => void;
14
+ }
15
+ export declare function resolveOutputFormat(options: ResolveOutputFormatOptions): UnifiedOutputFormat;
16
+ export {};
17
+ //# sourceMappingURL=format.d.ts.map