@stupify/cli 0.0.2 → 0.0.3

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 (63) hide show
  1. package/README.md +60 -0
  2. package/dist/analysis.d.ts +14 -0
  3. package/dist/analysis.js +276 -0
  4. package/dist/batcher.d.ts +3 -0
  5. package/dist/batcher.js +142 -0
  6. package/dist/cache.d.ts +2 -0
  7. package/dist/cache.js +59 -0
  8. package/dist/candidate-context.d.ts +2 -0
  9. package/dist/candidate-context.js +40 -0
  10. package/dist/checks.d.ts +3 -0
  11. package/dist/checks.js +131 -0
  12. package/dist/command.d.ts +2 -0
  13. package/dist/command.js +183 -0
  14. package/dist/constants.d.ts +4 -0
  15. package/dist/constants.js +53 -0
  16. package/dist/counter-scout.d.ts +14 -0
  17. package/dist/counter-scout.js +97 -0
  18. package/dist/diff.d.ts +1 -0
  19. package/dist/diff.js +10 -0
  20. package/dist/experiment.d.ts +1 -0
  21. package/dist/experiment.js +225 -0
  22. package/dist/git.d.ts +8 -0
  23. package/dist/git.js +219 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/model.d.ts +24 -0
  27. package/dist/model.js +281 -0
  28. package/dist/prompts.d.ts +5 -0
  29. package/dist/prompts.js +197 -0
  30. package/dist/render.d.ts +3 -0
  31. package/dist/render.js +101 -0
  32. package/dist/repomix-provider.d.ts +4 -0
  33. package/dist/repomix-provider.js +145 -0
  34. package/dist/sem-provider.d.ts +2 -0
  35. package/dist/sem-provider.js +221 -0
  36. package/dist/stupify.d.ts +2 -0
  37. package/dist/stupify.js +387 -0
  38. package/dist/trace.d.ts +29 -0
  39. package/dist/trace.js +64 -0
  40. package/dist/types.d.ts +236 -0
  41. package/dist/types.js +6 -0
  42. package/package.json +42 -5
  43. package/src/analysis.ts +408 -0
  44. package/src/batcher.ts +198 -0
  45. package/src/cache.ts +65 -0
  46. package/src/candidate-context.ts +43 -0
  47. package/src/checks.ts +132 -0
  48. package/src/command.ts +218 -0
  49. package/src/constants.ts +56 -0
  50. package/src/counter-scout.ts +119 -0
  51. package/src/diff.ts +9 -0
  52. package/src/experiment.ts +317 -0
  53. package/src/git.ts +228 -0
  54. package/src/index.ts +1 -0
  55. package/src/model.ts +360 -0
  56. package/src/prompts.ts +234 -0
  57. package/src/render.ts +107 -0
  58. package/src/repomix-provider.ts +163 -0
  59. package/src/sem-provider.ts +255 -0
  60. package/src/stupify.ts +598 -0
  61. package/src/trace.ts +103 -0
  62. package/src/types.ts +264 -0
  63. package/bin/stupify.mjs +0 -3
package/src/stupify.ts ADDED
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ auditCandidates,
6
+ countPromptTokens,
7
+ findingsAuditRequest,
8
+ runFindingsAudit,
9
+ scoutBatch,
10
+ scoutSemChanges,
11
+ } from "./analysis.ts";
12
+ import { batchDiff } from "./batcher.ts";
13
+ import { candidateContexts } from "./candidate-context.ts";
14
+ import { enabledChecks } from "./checks.ts";
15
+ import { parseCommand } from "./command.ts";
16
+ import { MODEL_REGISTRY } from "./constants.ts";
17
+ import { counterScoutTargets } from "./counter-scout.ts";
18
+ import { readDiffFromStdin } from "./diff.ts";
19
+ import { runExperiment } from "./experiment.ts";
20
+ import {
21
+ netDiffForCommit,
22
+ netDiffForRecentCommits,
23
+ netDiffFromStdin,
24
+ netDiffSince,
25
+ } from "./git.ts";
26
+ import { loadLocalModels, type LocalModel } from "./model.ts";
27
+ import { emptyContextPack, entityContextsFromChanges, repomixContextPack } from "./repomix-provider.ts";
28
+ import { helpText, renderReport } from "./render.ts";
29
+ import { semChangeSetForCommand } from "./sem-provider.ts";
30
+ import { createTracer, trace } from "./trace.ts";
31
+ import type {
32
+ AnalysisReport,
33
+ AnalyzeCommand,
34
+ AuditReviewResult,
35
+ AuditReviewStats,
36
+ DebugTarget,
37
+ FindingsResult,
38
+ NetDiff,
39
+ SemCandidate,
40
+ SemChangeSet,
41
+ SemContext,
42
+ TraceEvent,
43
+ } from "./types.ts";
44
+
45
+ const SEM_SCOUT_CHUNK_SIZE = 200;
46
+
47
+ export async function main(argv = process.argv.slice(2)): Promise<number> {
48
+ const startedAt = Date.now();
49
+ try {
50
+ const command = parseCommand(argv);
51
+ if (command.kind === "help") {
52
+ console.log(helpText());
53
+ return 0;
54
+ }
55
+ if (command.kind === "experiment") {
56
+ const outputDir = await runExperiment(command.configPath);
57
+ console.log(`Experiment results written to ${outputDir}`);
58
+ return 0;
59
+ }
60
+
61
+ const checks = enabledChecks(command.checkIds);
62
+ const report =
63
+ command.engine === "sem"
64
+ ? await runSemEngine(command, checks, startedAt)
65
+ : await runRawDiffEngine(command, checks, startedAt);
66
+
67
+ console.log(renderReport(report, command));
68
+ return 0;
69
+ } catch (error) {
70
+ console.error(error instanceof Error ? error.message : String(error));
71
+ return 1;
72
+ }
73
+ }
74
+
75
+ async function runRawDiffEngine(
76
+ command: AnalyzeCommand,
77
+ checks: ReturnType<typeof enabledChecks>,
78
+ startedAt: number,
79
+ ): Promise<AnalysisReport> {
80
+ const { value: diff, ms: diffMs } = await trace.trace("net.diff", () =>
81
+ netDiffForCommand(command),
82
+ );
83
+
84
+ printRunPlan(
85
+ command,
86
+ diff,
87
+ checks.map((check) => check.id),
88
+ );
89
+
90
+ const { value: models, ms: modelMs } = await trace.trace(
91
+ "model.load",
92
+ () => loadLocalModels(command.model),
93
+ );
94
+ const { scoutModel, auditModel } = models;
95
+
96
+ const batches = batchDiff(diff.text);
97
+ const { value: candidatePointers, ms: searchMs } = await trace.trace(
98
+ "search.total",
99
+ async () => {
100
+ const pointers: string[] = [];
101
+ for (const batch of batches) {
102
+ const { value: candidates } = await trace.trace(
103
+ "search.batch",
104
+ () => scoutBatch(scoutModel, batch, checks, diff.label),
105
+ { fields: { batch: batch.id } },
106
+ );
107
+ pointers.push(...candidates);
108
+ }
109
+ return pointers;
110
+ },
111
+ );
112
+
113
+ const contexts = candidateContexts(batches, candidatePointers);
114
+ const auditedContexts = contexts;
115
+ const { value: result, ms: auditMs } = await trace.trace(
116
+ "audit.candidates",
117
+ () => auditCandidates(auditModel, diff, auditedContexts, checks),
118
+ { fields: { candidates: auditedContexts.length } },
119
+ );
120
+
121
+ return {
122
+ run: {
123
+ mode: command.kind,
124
+ engine: command.engine,
125
+ auditContext: command.auditContext,
126
+ auditPrompt: command.auditPrompt,
127
+ modelId: command.model,
128
+ checkIds: checks.map((check) => check.id),
129
+ sourceId: diff.id,
130
+ label: diff.label,
131
+ stats: diff.stats,
132
+ batchesScanned: batches.length,
133
+ candidateCount: new Set(candidatePointers).size,
134
+ entitiesScanned: 0,
135
+ auditedCandidateCount: auditedContexts.length,
136
+ scoutModelCalls: batches.length,
137
+ auditModelCalls: auditedContexts.length > 0 ? 1 : 0,
138
+ warnings: [],
139
+ timingsMs: {
140
+ diff: diffMs,
141
+ modelLoad: modelMs,
142
+ search: searchMs,
143
+ audit: auditMs,
144
+ total: Date.now() - startedAt,
145
+ },
146
+ debugTargets: command.debugTargets ? [] : undefined,
147
+ },
148
+ result,
149
+ };
150
+ }
151
+
152
+ async function runSemEngine(
153
+ command: AnalyzeCommand,
154
+ checks: ReturnType<typeof enabledChecks>,
155
+ startedAt: number,
156
+ ): Promise<AnalysisReport> {
157
+ const traceEvents: TraceEvent[] = [];
158
+ const t = createTracer({
159
+ onEvent: (event) => {
160
+ traceEvents.push(event);
161
+ debugSemTrace(command, event);
162
+ },
163
+ });
164
+
165
+ const { value: changeSet, ms: diffMs } = await t.trace(
166
+ "entity.diff",
167
+ () => semChangeSetForCommand(command),
168
+ {
169
+ count: (v) => v.summary.total,
170
+ detail: (v) => `${v.summary.fileCount} files`,
171
+ },
172
+ );
173
+
174
+ printSemRunPlan(
175
+ command,
176
+ changeSet,
177
+ checks.map((check) => check.id),
178
+ );
179
+
180
+ const { value: models, ms: modelMs } = await t.trace(
181
+ "model.load",
182
+ () => loadLocalModels(command.model),
183
+ {
184
+ count: () => 2,
185
+ detail: () => "scout+audit",
186
+ },
187
+ );
188
+ const { scoutModel, auditModel } = models;
189
+
190
+ try {
191
+ const candidateBatches = chunkSemChangeSet(changeSet);
192
+ const { value: candidates, ms: searchMs } = await t.trace(
193
+ "scout.total",
194
+ async () =>
195
+ candidateBatches.length === 0
196
+ ? []
197
+ : command.scout === "counter"
198
+ ? counterScoutTargets(changeSet, checks, command.maxCandidates)
199
+ : scoutSemBatches(
200
+ scoutModel,
201
+ candidateBatches,
202
+ checks,
203
+ command,
204
+ traceEvents,
205
+ t,
206
+ ),
207
+ {
208
+ count: (v) => v.length,
209
+ detail: () => `${command.scout} scout ${candidateBatches.length} batches`,
210
+ },
211
+ );
212
+
213
+ const { value: contexts, ms: contextMs } = await t.trace(
214
+ "context.select",
215
+ async () => entityContextsFromChanges(candidates, changeSet.changes),
216
+ {
217
+ fields: { candidates: candidates.length },
218
+ count: (v) => v.length,
219
+ detail: (v) => `${new Set(v.map((context) => context.filePath).filter(Boolean)).size} files`,
220
+ },
221
+ );
222
+
223
+ const auditBatches = chunkSemContexts(contexts, command.auditBatchSize);
224
+ const { value: result, ms: auditMs } = await t.trace(
225
+ "audit.total",
226
+ () =>
227
+ findingsAuditBatches(
228
+ auditModel,
229
+ changeSet,
230
+ auditBatches,
231
+ checks,
232
+ traceEvents,
233
+ t,
234
+ command,
235
+ ),
236
+ {
237
+ count: (v) => v.findings.length,
238
+ detail: (v) =>
239
+ `${auditBatches.length} batches targets=${v.stats.totalTargets} clean=${v.stats.clean} uncertain=${v.stats.uncertain} invalid=${v.stats.invalid}`,
240
+ },
241
+ );
242
+
243
+ return {
244
+ run: {
245
+ mode: command.kind,
246
+ engine: command.engine,
247
+ auditContext: command.auditContext,
248
+ auditPrompt: command.auditPrompt,
249
+ modelId: command.model,
250
+ checkIds: checks.map((check) => check.id),
251
+ sourceId: changeSet.id,
252
+ label: changeSet.label,
253
+ stats: {
254
+ filesChanged: changeSet.summary.fileCount,
255
+ additions: changeSet.summary.added,
256
+ deletions: changeSet.summary.deleted,
257
+ },
258
+ batchesScanned: 0,
259
+ entitiesScanned: changeSet.summary.total,
260
+ candidateCount: candidates.length,
261
+ targetsByCheck: countTargetsByCheck(candidates),
262
+ auditedCandidateCount: contexts.length,
263
+ scoutModelCalls: traceEvents.filter((event) => event.name === "scout.batch").length,
264
+ auditModelCalls: result.auditModelCalls,
265
+ timingsMs: {
266
+ diff: diffMs,
267
+ modelLoad: modelMs,
268
+ search: searchMs,
269
+ audit: auditMs + contextMs,
270
+ total: Date.now() - startedAt,
271
+ },
272
+ warnings: [],
273
+ auditStats: result.stats,
274
+ debugTargets: command.debugTargets ? debugTargetsFromContexts(contexts, changeSet.label) : undefined,
275
+ traceEvents,
276
+ },
277
+ result,
278
+ };
279
+ } finally {
280
+ await changeSet.cleanup();
281
+ }
282
+ }
283
+
284
+ async function findingsAuditBatches(
285
+ model: LocalModel,
286
+ changeSet: SemChangeSet,
287
+ batches: readonly (readonly SemContext[])[],
288
+ checks: ReturnType<typeof enabledChecks>,
289
+ traceEvents: TraceEvent[],
290
+ t: ReturnType<typeof createTracer>,
291
+ command: AnalyzeCommand,
292
+ ): Promise<FindingsResult & { stats: AuditReviewStats; auditModelCalls: number }> {
293
+ const findings = [];
294
+ const stats = { totalTargets: 0, finding: 0, clean: 0, uncertain: 0, invalid: 0 };
295
+ const limiter = new ConcurrencyLimiter(command.auditConcurrency);
296
+ for (const [index, batch] of batches.entries()) {
297
+ const result = await findingsAuditBatch(
298
+ model,
299
+ changeSet,
300
+ batch,
301
+ checks,
302
+ traceEvents,
303
+ command,
304
+ limiter,
305
+ `${index + 1}/${batches.length}`,
306
+ );
307
+ findings.push(...result.findings);
308
+ stats.totalTargets += result.stats.totalTargets;
309
+ stats.finding += result.stats.finding;
310
+ stats.clean += result.stats.clean;
311
+ stats.uncertain += result.stats.uncertain;
312
+ stats.invalid += result.stats.invalid;
313
+ }
314
+ return {
315
+ findings,
316
+ summary:
317
+ findings.length === 0
318
+ ? "No clear judgment-offload signal found."
319
+ : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
320
+ stats,
321
+ auditModelCalls: traceEvents.filter((event) => event.name === "audit.batch").length,
322
+ };
323
+ }
324
+
325
+ async function findingsAuditBatch(
326
+ model: LocalModel,
327
+ changeSet: SemChangeSet,
328
+ batch: readonly SemContext[],
329
+ checks: ReturnType<typeof enabledChecks>,
330
+ traceEvents: TraceEvent[],
331
+ command: AnalyzeCommand,
332
+ limiter: ConcurrencyLimiter,
333
+ batchLabel: string,
334
+ ): Promise<AuditReviewResult> {
335
+ const { value: pack, ms: contextMs } = await trace.trace(
336
+ "context.pack",
337
+ () =>
338
+ command.auditContext === "none"
339
+ ? Promise.resolve(emptyContextPack())
340
+ : repomixContextPack(changeSet.contextCwd, batch, changeSet.changes),
341
+ { fields: { candidates: batch.length } },
342
+ );
343
+ const request = findingsAuditRequest(changeSet, batch, pack, checks, command.auditPrompt);
344
+ const inputTokens = await countPromptTokens(model, request.prompt);
345
+ const contextEvent = {
346
+ name: "context.pack",
347
+ ms: contextMs,
348
+ count: pack.filePaths.length,
349
+ detail: `batch=${batchLabel} input_tokens=${inputTokens} pack_tokens=${pack.totalTokens} chars=${pack.totalCharacters}`,
350
+ };
351
+ traceEvents.push(contextEvent);
352
+ debugSemTrace(command, contextEvent);
353
+
354
+ if (inputTokens > command.maxAuditInputTokens) {
355
+ if (batch.length <= 1) {
356
+ throw new Error(`Findings audit input has ${inputTokens} tokens, above max ${command.maxAuditInputTokens}.`);
357
+ }
358
+ const splitAt = Math.ceil(batch.length / 2);
359
+ const splitEvent = {
360
+ name: "audit.split",
361
+ ms: 0,
362
+ count: batch.length,
363
+ detail: `batch=${batchLabel} input_tokens=${inputTokens} max=${command.maxAuditInputTokens}`,
364
+ };
365
+ traceEvents.push(splitEvent);
366
+ debugSemTrace(command, splitEvent);
367
+ const [left, right] = await Promise.all([
368
+ findingsAuditBatch(
369
+ model,
370
+ changeSet,
371
+ batch.slice(0, splitAt),
372
+ checks,
373
+ traceEvents,
374
+ command,
375
+ limiter,
376
+ `${batchLabel}.1`,
377
+ ),
378
+ findingsAuditBatch(
379
+ model,
380
+ changeSet,
381
+ batch.slice(splitAt),
382
+ checks,
383
+ traceEvents,
384
+ command,
385
+ limiter,
386
+ `${batchLabel}.2`,
387
+ ),
388
+ ]);
389
+ return combineAuditResults(left, right);
390
+ }
391
+
392
+ const { value: result, ms: auditMs } = await trace.trace(
393
+ "audit.batch",
394
+ () => limiter.run(() => runFindingsAudit(model, changeSet, batch, pack, checks, request)),
395
+ { fields: { candidates: batch.length } },
396
+ );
397
+ const event = {
398
+ name: "audit.batch",
399
+ ms: auditMs,
400
+ count: result.findings.length,
401
+ detail: `batch=${batchLabel} candidates=${batch.length} input_tokens=${inputTokens} targets=${result.stats.totalTargets} clean=${result.stats.clean} uncertain=${result.stats.uncertain} invalid=${result.stats.invalid}`,
402
+ };
403
+ traceEvents.push(event);
404
+ debugSemTrace(command, event);
405
+ return result;
406
+ }
407
+
408
+ function combineAuditResults(
409
+ left: AuditReviewResult,
410
+ right: AuditReviewResult,
411
+ ): AuditReviewResult {
412
+ const findings = [...left.findings, ...right.findings];
413
+ return {
414
+ findings,
415
+ summary:
416
+ findings.length === 0
417
+ ? "No clear judgment-offload signal found."
418
+ : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
419
+ stats: {
420
+ totalTargets: left.stats.totalTargets + right.stats.totalTargets,
421
+ finding: left.stats.finding + right.stats.finding,
422
+ clean: left.stats.clean + right.stats.clean,
423
+ uncertain: left.stats.uncertain + right.stats.uncertain,
424
+ invalid: left.stats.invalid + right.stats.invalid,
425
+ },
426
+ };
427
+ }
428
+
429
+ function countTargetsByCheck(candidates: readonly SemCandidate[]): Record<string, number> {
430
+ const counts: Record<string, number> = {};
431
+ for (const candidate of candidates) {
432
+ counts[candidate.checkId] = (counts[candidate.checkId] ?? 0) + 1;
433
+ }
434
+ return counts;
435
+ }
436
+
437
+ function debugTargetsFromContexts(
438
+ contexts: readonly SemContext[],
439
+ sourceLabel: string,
440
+ ): readonly DebugTarget[] {
441
+ return contexts.map((context) => ({
442
+ targetId: context.targetId,
443
+ checkId: context.checkId,
444
+ entityId: context.entityId,
445
+ entityKind: context.entityKind,
446
+ changeKind: context.changeKind,
447
+ scoutReason: context.reason,
448
+ sourceLabel,
449
+ }));
450
+ }
451
+
452
+ class ConcurrencyLimiter {
453
+ private active = 0;
454
+ private readonly queue: Array<() => void> = [];
455
+
456
+ constructor(private readonly max: number) {}
457
+
458
+ async run<T>(task: () => Promise<T>): Promise<T> {
459
+ if (this.active >= this.max) {
460
+ await new Promise<void>((resolve) => this.queue.push(resolve));
461
+ }
462
+ this.active += 1;
463
+ try {
464
+ return await task();
465
+ } finally {
466
+ this.active -= 1;
467
+ this.queue.shift()?.();
468
+ }
469
+ }
470
+ }
471
+
472
+ async function scoutSemBatches(
473
+ model: LocalModel,
474
+ batches: readonly SemChangeSet[],
475
+ checks: ReturnType<typeof enabledChecks>,
476
+ command: AnalyzeCommand,
477
+ traceEvents: TraceEvent[],
478
+ t: ReturnType<typeof createTracer>,
479
+ ): Promise<readonly SemCandidate[]> {
480
+ const candidates: SemCandidate[] = [];
481
+ const seen = new Set<string>();
482
+ const targetsByCheck = new Map<string, number>();
483
+ const maxTargetsPerCheck = 6;
484
+ for (const [index, batch] of batches.entries()) {
485
+ if (candidates.length >= command.maxCandidates) break;
486
+ const remaining: number = command.maxCandidates - candidates.length;
487
+ const { value: batchCandidates } = await t.trace(
488
+ "scout.batch",
489
+ async () => scoutSemChanges(model, batch, checks, remaining),
490
+ {
491
+ fields: { entities: batch.changes.length },
492
+ count: (v) => v.length,
493
+ detail: (v) =>
494
+ `batch=${index + 1}/${batches.length} entities=${batch.changes.length} remaining=${remaining}`,
495
+ },
496
+ );
497
+ for (const candidate of batchCandidates) {
498
+ const key = `${candidate.entityId}\u0000${candidate.checkId}`;
499
+ if (seen.has(key)) continue;
500
+ const checkCount = targetsByCheck.get(candidate.checkId) ?? 0;
501
+ if (checkCount >= maxTargetsPerCheck) continue;
502
+ seen.add(key);
503
+ targetsByCheck.set(candidate.checkId, checkCount + 1);
504
+ candidates.push({
505
+ ...candidate,
506
+ targetId: `t${String(candidates.length + 1).padStart(3, "0")}`,
507
+ });
508
+ if (candidates.length >= command.maxCandidates) break;
509
+ }
510
+ }
511
+ return candidates;
512
+ }
513
+
514
+ function debugSemTrace(command: AnalyzeCommand, event: TraceEvent): void {
515
+ if (!command.debugSem) return;
516
+ const parts = [`trace ${event.name}`, `${event.ms}ms`];
517
+ if (event.count !== undefined) parts.push(`count=${event.count}`);
518
+ if (event.detail) parts.push(event.detail);
519
+ console.error(parts.join(" "));
520
+ }
521
+
522
+ function chunkSemChangeSet(changeSet: SemChangeSet): readonly SemChangeSet[] {
523
+ const chunks: SemChangeSet[] = [];
524
+ for (
525
+ let index = 0;
526
+ index < changeSet.changes.length;
527
+ index += SEM_SCOUT_CHUNK_SIZE
528
+ ) {
529
+ const changes = changeSet.changes.slice(
530
+ index,
531
+ index + SEM_SCOUT_CHUNK_SIZE,
532
+ );
533
+ chunks.push({
534
+ ...changeSet,
535
+ label: `${changeSet.label} batch ${chunks.length + 1}`,
536
+ changes,
537
+ summary: {
538
+ ...changeSet.summary,
539
+ fileCount: new Set(changes.map((change) => change.filePath)).size,
540
+ total: changes.length,
541
+ },
542
+ });
543
+ }
544
+ return chunks;
545
+ }
546
+
547
+ function chunkSemContexts(
548
+ contexts: readonly SemContext[],
549
+ chunkSize: number,
550
+ ): readonly (readonly SemContext[])[] {
551
+ const chunks: SemContext[][] = [];
552
+ for (let index = 0; index < contexts.length; index += chunkSize) {
553
+ chunks.push(contexts.slice(index, index + chunkSize));
554
+ }
555
+ return chunks;
556
+ }
557
+
558
+ function printRunPlan(
559
+ command: AnalyzeCommand,
560
+ diff: NetDiff,
561
+ checkIds: readonly string[],
562
+ ): void {
563
+ if (command.json) return;
564
+ console.error("🧙 stupify 🪄");
565
+ console.error(`Window: ${diff.label}`);
566
+ console.error(
567
+ `Diff: ${diff.stats.filesChanged} files changed, ${diff.stats.additions} added, ${diff.stats.deletions} deleted`,
568
+ );
569
+ console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
570
+ console.error(`Checks: ${checkIds.join(", ")}`);
571
+ }
572
+
573
+ function printSemRunPlan(
574
+ command: AnalyzeCommand,
575
+ changeSet: SemChangeSet,
576
+ checkIds: readonly string[],
577
+ ): void {
578
+ if (command.json) return;
579
+ console.error("🧙 stupify 🪄");
580
+ console.error(`Window: ${changeSet.label}`);
581
+ console.error(
582
+ `Sem: ${changeSet.summary.fileCount} files, ${changeSet.summary.total} changed entities`,
583
+ );
584
+ console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
585
+ console.error(`Checks: ${checkIds.join(", ")}`);
586
+ }
587
+
588
+ async function netDiffForCommand(command: AnalyzeCommand): Promise<NetDiff> {
589
+ if (command.kind === "since") return netDiffSince(command.since);
590
+ if (command.kind === "stdin")
591
+ return netDiffFromStdin(await readDiffFromStdin());
592
+ if (command.kind === "commit") return netDiffForCommit(command.commit);
593
+ return netDiffForRecentCommits(command.count);
594
+ }
595
+
596
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
597
+ process.exitCode = await main();
598
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ export type TraceFields = Record<string, string | number | boolean | null | undefined>;
4
+
5
+ export type Tracer = {
6
+ trace<T>(span: string, fn: () => Promise<T>, options?: SpanTraceOptions<T>): Promise<{ value: T; ms: number }>;
7
+ trace<T>(span: string, fn: () => T, options?: SpanTraceOptions<T>): { value: T; ms: number };
8
+ };
9
+
10
+ export type SpanTraceEvent = Readonly<{
11
+ name: string;
12
+ ms: number;
13
+ count?: number;
14
+ detail?: string;
15
+ }>;
16
+
17
+ export type SpanTraceOptions<T> = Readonly<{
18
+ fields?: TraceFields;
19
+ count?: (value: T) => number;
20
+ detail?: (value: T) => string;
21
+ }>;
22
+
23
+ export type CreateTracerOptions = {
24
+ enabled?: boolean;
25
+ writeLine?: (line: string) => void;
26
+ onEvent?: (event: SpanTraceEvent) => void;
27
+ };
28
+
29
+ export function createTracer(options?: CreateTracerOptions): Tracer {
30
+ const enabled = options?.enabled ?? true;
31
+ const writeLine = options?.writeLine ?? ((line) => process.stderr.write(line + "\n"));
32
+ const onEvent = options?.onEvent;
33
+ const nowMs = () => performance.now();
34
+
35
+ function emit(span: string, durationMs: number, fields?: TraceFields) {
36
+ if (!enabled) return;
37
+ const payload: Record<string, unknown> = { span, ms: Math.round(durationMs) };
38
+ for (const [k, v] of Object.entries(fields ?? {})) {
39
+ if (v !== undefined) payload[k] = v;
40
+ }
41
+ writeLine(`trace ${JSON.stringify(payload)}`);
42
+ }
43
+
44
+ function trace<T>(
45
+ span: string,
46
+ fn: () => Promise<T>,
47
+ options?: SpanTraceOptions<T>,
48
+ ): Promise<{ value: T; ms: number }>;
49
+ function trace<T>(span: string, fn: () => T, options?: SpanTraceOptions<T>): { value: T; ms: number };
50
+ function trace<T>(
51
+ span: string,
52
+ fn: (() => T) | (() => Promise<T>),
53
+ options?: SpanTraceOptions<T>,
54
+ ): Promise<{ value: T; ms: number }> | { value: T; ms: number } {
55
+ const startedAtMs = nowMs();
56
+ try {
57
+ const out = fn();
58
+ if (isPromiseLike(out)) {
59
+ return (async () => {
60
+ let durationMs: number | undefined;
61
+ try {
62
+ const value = await out;
63
+ durationMs = nowMs() - startedAtMs;
64
+ const event: SpanTraceEvent = {
65
+ name: span,
66
+ ms: Math.round(durationMs),
67
+ count: options?.count?.(value),
68
+ detail: options?.detail?.(value),
69
+ };
70
+ onEvent?.(event);
71
+ return { value, ms: event.ms };
72
+ } finally {
73
+ durationMs ??= nowMs() - startedAtMs;
74
+ emit(span, durationMs, options?.fields);
75
+ }
76
+ })();
77
+ }
78
+
79
+ const durationMs = nowMs() - startedAtMs;
80
+ emit(span, durationMs, options?.fields);
81
+ const event: SpanTraceEvent = {
82
+ name: span,
83
+ ms: Math.round(durationMs),
84
+ count: options?.count?.(out),
85
+ detail: options?.detail?.(out),
86
+ };
87
+ onEvent?.(event);
88
+ return { value: out, ms: event.ms };
89
+ } catch (error) {
90
+ const durationMs = nowMs() - startedAtMs;
91
+ emit(span, durationMs, options?.fields);
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ return { trace };
97
+ }
98
+
99
+ export const trace: Tracer = createTracer();
100
+
101
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
102
+ return typeof value === "object" && value !== null && "then" in value;
103
+ }