@stupify/cli 0.0.15 → 0.1.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 (74) hide show
  1. package/.review/CORPUS.md +73 -0
  2. package/.review/REVIEW-PROMPT.md +52 -0
  3. package/.review/RUBRIC.md +46 -0
  4. package/LICENSE +1 -1
  5. package/README.md +41 -39
  6. package/package.json +24 -25
  7. package/src/cli.ts +358 -0
  8. package/src/review-sweep.ts +492 -0
  9. package/dist/analysis.d.ts +0 -16
  10. package/dist/analysis.js +0 -165
  11. package/dist/cache.d.ts +0 -2
  12. package/dist/cache.js +0 -57
  13. package/dist/checks.d.ts +0 -4
  14. package/dist/checks.js +0 -228
  15. package/dist/command.d.ts +0 -2
  16. package/dist/command.js +0 -147
  17. package/dist/constants.d.ts +0 -4
  18. package/dist/constants.js +0 -53
  19. package/dist/counter-scout.d.ts +0 -21
  20. package/dist/counter-scout.js +0 -167
  21. package/dist/diff.d.ts +0 -1
  22. package/dist/diff.js +0 -10
  23. package/dist/doctor.d.ts +0 -4
  24. package/dist/doctor.js +0 -131
  25. package/dist/git.d.ts +0 -12
  26. package/dist/git.js +0 -298
  27. package/dist/hooks.d.ts +0 -3
  28. package/dist/hooks.js +0 -117
  29. package/dist/index.d.ts +0 -1
  30. package/dist/index.js +0 -1
  31. package/dist/model.d.ts +0 -11
  32. package/dist/model.js +0 -296
  33. package/dist/prompts.d.ts +0 -8
  34. package/dist/prompts.js +0 -89
  35. package/dist/render.d.ts +0 -3
  36. package/dist/render.js +0 -151
  37. package/dist/repomix-provider.d.ts +0 -12
  38. package/dist/repomix-provider.js +0 -196
  39. package/dist/search-bench.d.ts +0 -1
  40. package/dist/search-bench.js +0 -677
  41. package/dist/search-profile.d.ts +0 -6
  42. package/dist/search-profile.js +0 -73
  43. package/dist/sem-provider.d.ts +0 -2
  44. package/dist/sem-provider.js +0 -252
  45. package/dist/stupify.d.ts +0 -38
  46. package/dist/stupify.js +0 -474
  47. package/dist/trace.d.ts +0 -31
  48. package/dist/trace.js +0 -86
  49. package/dist/types.d.ts +0 -328
  50. package/dist/types.js +0 -6
  51. package/dist/ui.d.ts +0 -34
  52. package/dist/ui.js +0 -143
  53. package/src/analysis.ts +0 -220
  54. package/src/cache.ts +0 -63
  55. package/src/checks.ts +0 -231
  56. package/src/command.ts +0 -173
  57. package/src/constants.ts +0 -56
  58. package/src/counter-scout.ts +0 -195
  59. package/src/diff.ts +0 -9
  60. package/src/doctor.ts +0 -140
  61. package/src/git.ts +0 -306
  62. package/src/hooks.ts +0 -134
  63. package/src/index.ts +0 -1
  64. package/src/model.ts +0 -367
  65. package/src/prompts.ts +0 -100
  66. package/src/render.ts +0 -154
  67. package/src/repomix-provider.ts +0 -219
  68. package/src/search-bench.ts +0 -783
  69. package/src/search-profile.ts +0 -89
  70. package/src/sem-provider.ts +0 -297
  71. package/src/stupify.ts +0 -571
  72. package/src/trace.ts +0 -126
  73. package/src/types.ts +0 -348
  74. package/src/ui.ts +0 -187
package/src/stupify.ts DELETED
@@ -1,571 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { realpathSync } from "node:fs";
4
- import { fileURLToPath } from "node:url";
5
- import { countPromptTokens, runSearch, searchRequest, type SearchRequest } from "./analysis.ts";
6
- import { searchChecks } from "./checks.ts";
7
- import { parseCommand } from "./command.ts";
8
- import { counterScoutPlan } from "./counter-scout.ts";
9
- import { runDoctor } from "./doctor.ts";
10
- import { runHookCommand } from "./hooks.ts";
11
- import { firstRunModelBootstrap, loadLocalModel } from "./model.ts";
12
- import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.ts";
13
- import { helpText, renderSearchRun } from "./render.ts";
14
- import {
15
- effectiveMaxCandidates,
16
- effectiveMaxSearchInputTokens,
17
- effectiveRepomixConfig,
18
- effectiveSearchChecks,
19
- loadSearchProfile,
20
- } from "./search-profile.ts";
21
- import { semChangeSetForCommand } from "./sem-provider.ts";
22
- import { createTracer } from "./trace.ts";
23
- import { createCliUi, type CliUi } from "./ui.ts";
24
- import type { CounterScoutPlan } from "./counter-scout.ts";
25
- import type { SearchCommand, SearchMatch, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
26
-
27
- export async function main(argv = process.argv.slice(2)): Promise<number> {
28
- const startedAt = Date.now();
29
- let ui = createCliUi();
30
- try {
31
- const command = parseCommand(argv);
32
- if (command.kind === "help") {
33
- ui.writeStdout(helpText());
34
- return 0;
35
- }
36
- if (command.kind === "hook") {
37
- ui.writeStdout(await runHookCommand(command.action));
38
- return 0;
39
- }
40
- if (command.kind === "doctor") {
41
- const result = await runDoctor();
42
- ui.writeStdout(result.text);
43
- return result.exitCode;
44
- }
45
- if (command.kind === "bench-search") {
46
- const { runSearchBench } = await import("./search-bench.ts");
47
- ui.writeStdout(await runSearchBench(command.configPath));
48
- return 0;
49
- }
50
-
51
- ui = createCliUi({ quiet: command.json });
52
- const run = await runSearchCommand(command, startedAt, ui);
53
- ui.writeStdout(renderSearchRun(run, command));
54
- return 0;
55
- } catch (error) {
56
- ui.error(error instanceof Error ? error.message : String(error), { force: true });
57
- return 1;
58
- }
59
- }
60
-
61
- export async function runSearchCommand(command: SearchCommand, startedAt: number, ui = createCliUi({ quiet: command.json })): Promise<SearchRunJson> {
62
- const activeSpans = new Map<string, ReturnType<CliUi["spinner"]>>();
63
- const t = createTracer({
64
- writeLine: () => undefined,
65
- onEvent: (event) => {
66
- if (command.json) return;
67
- if (event.phase === "start") {
68
- activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
69
- return;
70
- }
71
-
72
- const active = activeSpans.get(event.name);
73
- activeSpans.delete(event.name);
74
- const message = event.phase === "error"
75
- ? formatErrorStep(event.name, event.ms)
76
- : formatStep(event.name, event.ms, event.count, event.detail);
77
- if (!active) {
78
- if (event.phase === "error") ui.error(message);
79
- else ui.step(message);
80
- return;
81
- }
82
- if (event.phase === "error") active.error(message);
83
- else active.stop(message);
84
- },
85
- });
86
-
87
- const profile = await loadSearchProfile(command.searchProfilePath);
88
- const checks = profile ? effectiveSearchChecks(command.checkIds, profile) : searchChecks(command.checkIds);
89
- const patternIds = checks.map((check) => check.id);
90
- const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
91
- const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
92
- printRunPlan(command, patternIds, ui);
93
- const { value: changeSet } = await t.trace(
94
- "entity.diff",
95
- () => semChangeSetForCommand(command),
96
- {
97
- count: (v) => v.summary.total,
98
- detail: (v) => `${v.summary.fileCount} files`,
99
- },
100
- );
101
-
102
- try {
103
- const scoutPlan = counterScoutPlan(changeSet, checks, maxCandidates);
104
- if (!command.json) ui.step(scoutPlanLine(scoutPlan, changeSet.summary.total));
105
- const candidates = scoutPlan.targets;
106
- const contexts = entityContextsFromChanges(candidates, changeSet.changes);
107
- const targetsByPattern = countTargetsByPattern(contexts);
108
- const targetsPreview = previewTargets(contexts);
109
- if (contexts.length === 0) {
110
- return {
111
- schemaVersion: "search.v1",
112
- mode: "search",
113
- source: command.source,
114
- model: { id: command.model },
115
- patterns: patternIds,
116
- stats: {
117
- elapsedMs: Date.now() - startedAt,
118
- modelCalls: 0,
119
- committers: changeSet.committers,
120
- skipped: true,
121
- skipReason: "no_candidates",
122
- filesChanged: changeSet.summary.fileCount,
123
- entitiesScanned: changeSet.summary.total,
124
- candidates: 0,
125
- searchTargets: 0,
126
- repomixFiles: 0,
127
- repomixTokens: 0,
128
- profileId: profile?.id,
129
- targetsByPattern,
130
- targetsPreview,
131
- },
132
- matches: [],
133
- };
134
- }
135
-
136
- const baseRepomixConfig = effectiveRepomixConfig(repomixSearchConfig(), profile);
137
- const initialPack = profile?.context === "sem"
138
- ? emptyContextPack()
139
- : await t.trace(
140
- "context.pack",
141
- () => repomixContextPack(changeSet.contextCwd, contexts, changeSet.changes, baseRepomixConfig),
142
- {
143
- count: (v) => v.filePaths.length,
144
- detail: (v) => `${v.totalTokens} tokens`,
145
- },
146
- ).then((result) => result.value);
147
- const packedFiles = new Set(initialPack.filePaths);
148
- const searchContexts = profile?.context === "sem"
149
- ? contexts
150
- : contexts.filter((context) => context.filePath && packedFiles.has(context.filePath));
151
- if (!command.json) ui.step(targetPlanLine(searchContexts, contexts.length, countTargetsByPattern(searchContexts)));
152
- if (searchContexts.length === 0) {
153
- return {
154
- schemaVersion: "search.v1",
155
- mode: "search",
156
- source: command.source,
157
- model: { id: command.model },
158
- patterns: patternIds,
159
- stats: {
160
- elapsedMs: Date.now() - startedAt,
161
- modelCalls: 0,
162
- committers: changeSet.committers,
163
- skipped: true,
164
- skipReason: "no_candidates",
165
- filesChanged: changeSet.summary.fileCount,
166
- entitiesScanned: changeSet.summary.total,
167
- candidates: contexts.length,
168
- searchTargets: 0,
169
- repomixFiles: initialPack.filePaths.length,
170
- repomixTokens: initialPack.totalTokens,
171
- repomixConfig: initialPack.config,
172
- profileId: profile?.id,
173
- targetsByPattern,
174
- targetsPreview,
175
- },
176
- matches: [],
177
- };
178
- }
179
- const pack = profile?.context === "sem" || searchContexts.length === contexts.length
180
- ? initialPack
181
- : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
182
- const { value: batches } = await t.trace(
183
- "search.batches",
184
- () => buildSearchBatches({
185
- command,
186
- changeSet,
187
- contexts: searchContexts,
188
- initialPack: pack,
189
- checks,
190
- profile,
191
- includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
192
- maxSearchInputTokens,
193
- baseRepomixConfig,
194
- }),
195
- {
196
- startDetail: `${searchContexts.length} targets`,
197
- count: (result) => result.batches.length,
198
- detail: (result) => result.wasSplit
199
- ? `${result.skippedTargets} oversized targets skipped`
200
- : `${result.estimatedInputTokens} estimated tokens`,
201
- },
202
- );
203
-
204
- if (batches.batches.length === 0) {
205
- return {
206
- schemaVersion: "search.v1",
207
- mode: "search",
208
- source: command.source,
209
- model: { id: command.model },
210
- patterns: patternIds,
211
- stats: {
212
- elapsedMs: Date.now() - startedAt,
213
- modelCalls: 0,
214
- inputTokens: batches.estimatedInputTokens,
215
- inputTokenCap: maxSearchInputTokens,
216
- committers: changeSet.committers,
217
- skipped: true,
218
- skipReason: "input_too_large",
219
- filesChanged: changeSet.summary.fileCount,
220
- entitiesScanned: changeSet.summary.total,
221
- candidates: contexts.length,
222
- searchTargets: searchContexts.length,
223
- repomixFiles: pack.filePaths.length,
224
- repomixTokens: pack.totalTokens,
225
- repomixConfig: pack.config,
226
- searchBatches: 0,
227
- skippedTargets: batches.skippedTargets,
228
- profileId: profile?.id,
229
- targetsByPattern: countTargetsByPattern(searchContexts),
230
- targetsPreview: previewTargets(searchContexts),
231
- },
232
- matches: [],
233
- };
234
- }
235
-
236
- if (batches.wasSplit && !command.json) {
237
- ui.warn(`Search input is large; queued ${batches.batches.length} smaller batches for ${searchContexts.length} targets (${maxSearchInputTokens} token cap).`);
238
- if (batches.skippedTargets > 0) {
239
- ui.warn(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
240
- }
241
- } else if (!command.json) {
242
- ui.step(`Search: ${searchContexts.length} targets in ${batches.batches.length} model batch (${maxSearchInputTokens} token cap)`);
243
- }
244
-
245
- const modelPath = await firstRunModelBootstrap(command.model, ui);
246
- const model = await loadLocalModel(modelPath, command.model, "scout", ui);
247
- const matches = [];
248
- let modelCalls = 0;
249
- let inputTokens = 0;
250
- let exactSkippedTargets = batches.skippedTargets;
251
- for (const batch of batches.batches) {
252
- const { value: batchInputTokens } = await t.trace(
253
- "prompt.tokens",
254
- () => countPromptTokens(model, batch.request.prompt),
255
- {
256
- startDetail: `${batch.contexts.length} targets`,
257
- count: (tokens) => tokens,
258
- },
259
- );
260
- inputTokens += batchInputTokens;
261
- if (batchInputTokens > maxSearchInputTokens) {
262
- exactSkippedTargets += batch.contexts.length;
263
- if (!command.json) {
264
- ui.warn(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
265
- }
266
- continue;
267
- }
268
- const { value } = await t.trace(
269
- "search.model",
270
- () => runSearch(model, batch.request),
271
- {
272
- startDetail: `${batch.contexts.length} targets`,
273
- count: (v) => v.length,
274
- },
275
- );
276
- modelCalls += 1;
277
- matches.push(...withCheckWhy(value, checks));
278
- }
279
- const uniqueMatches = dedupeMatches(matches);
280
-
281
- return {
282
- schemaVersion: "search.v1",
283
- mode: "search",
284
- source: command.source,
285
- model: { id: command.model },
286
- patterns: patternIds,
287
- stats: {
288
- elapsedMs: Date.now() - startedAt,
289
- modelCalls,
290
- inputTokens,
291
- inputTokenCap: maxSearchInputTokens,
292
- committers: changeSet.committers,
293
- filesChanged: changeSet.summary.fileCount,
294
- entitiesScanned: changeSet.summary.total,
295
- candidates: contexts.length,
296
- searchTargets: searchContexts.length,
297
- repomixFiles: pack.filePaths.length,
298
- repomixTokens: pack.totalTokens,
299
- repomixConfig: pack.config,
300
- searchBatches: batches.batches.length,
301
- skippedTargets: exactSkippedTargets,
302
- profileId: profile?.id,
303
- targetsByPattern: countTargetsByPattern(searchContexts),
304
- targetsPreview: previewTargets(searchContexts),
305
- },
306
- matches: uniqueMatches,
307
- };
308
- } finally {
309
- await changeSet.cleanup();
310
- }
311
- }
312
-
313
- function dedupeMatches<T extends { targetId: string; patternId: string; proof: string }>(matches: readonly T[]): readonly T[] {
314
- const seen = new Set<string>();
315
- return matches.filter((match) => {
316
- const key = `${match.patternId}\n${match.proof.trim()}`;
317
- if (seen.has(key)) return false;
318
- seen.add(key);
319
- return true;
320
- });
321
- }
322
-
323
- function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyCheck[]): readonly SearchMatch[] {
324
- const checksById = new Map(checks.map((check) => [check.id, check]));
325
- return matches.map((match) => ({
326
- ...match,
327
- checkWhy: checksById.get(match.patternId)?.why,
328
- }));
329
- }
330
-
331
- type SearchBatch = Readonly<{
332
- contexts: readonly SemContext[];
333
- pack: SemContextPack;
334
- request: SearchRequest;
335
- estimatedInputTokens: number;
336
- }>;
337
-
338
- async function buildSearchBatches(input: Readonly<{
339
- command: SearchCommand;
340
- changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
341
- contexts: readonly SemContext[];
342
- initialPack: SemContextPack;
343
- checks: readonly StupifyCheck[];
344
- profile: SearchProfile | null;
345
- includeCounterReasonInPrompt: boolean;
346
- maxSearchInputTokens: number;
347
- baseRepomixConfig: Parameters<typeof repomixContextPack>[3];
348
- }>): Promise<Readonly<{
349
- batches: readonly SearchBatch[];
350
- estimatedInputTokens: number;
351
- skippedTargets: number;
352
- wasSplit: boolean;
353
- }>> {
354
- const first = makeSearchBatch(input, input.contexts, input.initialPack);
355
- if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
356
- return {
357
- batches: [first],
358
- estimatedInputTokens: first.estimatedInputTokens,
359
- skippedTargets: 0,
360
- wasSplit: false,
361
- };
362
- }
363
-
364
- const batches: SearchBatch[] = [];
365
- let skippedTargets = 0;
366
- let currentContexts: readonly SemContext[] = [];
367
- let currentBatch: SearchBatch | null = null;
368
-
369
- for (const context of input.contexts) {
370
- const candidateContexts = [...currentContexts, context];
371
- const candidateBatch = await makeSearchBatchWithPack(input, candidateContexts);
372
- if (candidateBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
373
- currentContexts = candidateContexts;
374
- currentBatch = candidateBatch;
375
- continue;
376
- }
377
-
378
- if (currentBatch) {
379
- batches.push(currentBatch);
380
- currentContexts = [];
381
- currentBatch = null;
382
- }
383
-
384
- const singleBatch = candidateContexts.length === 1
385
- ? candidateBatch
386
- : await makeSearchBatchWithPack(input, [context]);
387
- if (singleBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
388
- currentContexts = [context];
389
- currentBatch = singleBatch;
390
- } else {
391
- skippedTargets += 1;
392
- }
393
- }
394
-
395
- if (currentBatch) batches.push(currentBatch);
396
-
397
- return {
398
- batches,
399
- estimatedInputTokens: first.estimatedInputTokens,
400
- skippedTargets,
401
- wasSplit: true,
402
- };
403
- }
404
-
405
- function makeSearchBatch(
406
- input: Readonly<{
407
- changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
408
- checks: readonly StupifyCheck[];
409
- profile: SearchProfile | null;
410
- includeCounterReasonInPrompt: boolean;
411
- }>,
412
- contexts: readonly SemContext[],
413
- pack: SemContextPack,
414
- ): SearchBatch {
415
- const request = buildSearchRequest(
416
- input.changeSet,
417
- contexts,
418
- pack,
419
- input.checks,
420
- input.profile,
421
- input.includeCounterReasonInPrompt,
422
- );
423
- return {
424
- contexts,
425
- pack,
426
- request,
427
- estimatedInputTokens: estimatePromptTokens(request.prompt),
428
- };
429
- }
430
-
431
- async function makeSearchBatchWithPack(
432
- input: Readonly<{
433
- command: SearchCommand;
434
- changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
435
- checks: readonly StupifyCheck[];
436
- profile: SearchProfile | null;
437
- includeCounterReasonInPrompt: boolean;
438
- baseRepomixConfig: Parameters<typeof repomixContextPack>[3];
439
- }>,
440
- contexts: readonly SemContext[],
441
- ): Promise<SearchBatch> {
442
- const pack = input.profile?.context === "sem"
443
- ? emptyContextPack()
444
- : await repomixContextPack(input.changeSet.contextCwd, contexts, input.changeSet.changes, input.baseRepomixConfig);
445
- return makeSearchBatch(input, contexts, pack);
446
- }
447
-
448
- function buildSearchRequest(
449
- changeSet: Parameters<typeof searchRequest>[0]["changeSet"],
450
- contexts: Parameters<typeof searchRequest>[0]["contexts"],
451
- pack: SemContextPack,
452
- patterns: readonly StupifyCheck[],
453
- profile: SearchProfile | null,
454
- includeCounterReasonInPrompt: boolean,
455
- ) {
456
- return searchRequest({
457
- changeSet,
458
- contexts,
459
- pack,
460
- patterns,
461
- includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
462
- });
463
- }
464
-
465
- function printRunPlan(
466
- command: SearchCommand,
467
- patternIds: readonly string[],
468
- ui: CliUi,
469
- ): void {
470
- if (command.json) return;
471
- ui.intro("stupify");
472
- ui.note(
473
- [
474
- `Search: ${sourceLabel(command)}`,
475
- `Patterns: ${patternIds.join(", ")}`,
476
- ].join("\n"),
477
- "Run",
478
- );
479
- }
480
-
481
- function formatStartStep(name: string, detail?: string): string {
482
- if (name === "entity.diff") return "Diff: running sem over the selected git range";
483
- if (name === "context.pack") return "Context: packing selected target files with Repomix";
484
- if (name === "search.batches") return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
485
- if (name === "prompt.tokens") return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
486
- if (name === "search.model") return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
487
- return `${name}: working`;
488
- }
489
-
490
- function formatStep(name: string, ms: number, count?: number, detail?: string): string {
491
- if (name === "entity.diff") return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
492
- if (name === "context.pack") return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
493
- if (name === "search.batches") return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
494
- if (name === "prompt.tokens") return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
495
- if (name === "search.model") return `Model: ${count ?? 0} matches (${ms}ms)`;
496
- return `${name}: ${ms}ms`;
497
- }
498
-
499
- function formatErrorStep(name: string, ms: number): string {
500
- if (name === "entity.diff") return `Diff failed after ${ms}ms`;
501
- if (name === "context.pack") return `Context packing failed after ${ms}ms`;
502
- if (name === "search.batches") return `Search batch preparation failed after ${ms}ms`;
503
- if (name === "prompt.tokens") return `Token counting failed after ${ms}ms`;
504
- if (name === "search.model") return `Model search failed after ${ms}ms`;
505
- return `${name} failed after ${ms}ms`;
506
- }
507
-
508
- function scoutPlanLine(plan: CounterScoutPlan, entitiesScanned: number): string {
509
- if (plan.targets.length === 0) {
510
- return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
511
- }
512
-
513
- return [
514
- `Scout: deterministic counters scanned ${entitiesScanned} entities`,
515
- `${plan.totalSignals} counter signals`,
516
- `selected ${plan.targets.length}/${plan.totalSignals} target/check pairs (cap ${plan.maxTargets}, not exhaustive)`,
517
- ].join("; ");
518
- }
519
-
520
- function targetPlanLine(
521
- searchContexts: readonly SemContext[],
522
- selectedTargets: number,
523
- targetsByPattern: Record<string, number>,
524
- ): string {
525
- const retained = searchContexts.length === selectedTargets
526
- ? `${searchContexts.length} selected targets`
527
- : `${searchContexts.length}/${selectedTargets} selected targets retained after context packing`;
528
- return `Targets: model will inspect ${retained}; ${formatCounts(targetsByPattern)}`;
529
- }
530
-
531
- function formatCounts(counts: Record<string, number>): string {
532
- const entries = Object.entries(counts).filter(([, count]) => count > 0);
533
- if (entries.length === 0) return "no target/check pairs";
534
- return entries.map(([id, count]) => `${id}=${count}`).join(", ");
535
- }
536
-
537
- function sourceLabel(command: SearchCommand): string {
538
- if (command.kind === "since") return `since ${command.since}`;
539
- if (command.kind === "commit") return `commit ${command.commit}`;
540
- if (command.kind === "commits") return `last ${command.count} commits`;
541
- if (command.kind === "staged") return "staged changes";
542
- return "stdin diff";
543
- }
544
-
545
- function estimatePromptTokens(prompt: string): number {
546
- return Math.ceil(prompt.length / 3);
547
- }
548
-
549
- function countTargetsByPattern(contexts: readonly SemContext[]): Record<string, number> {
550
- const counts: Record<string, number> = {};
551
- for (const context of contexts) counts[context.checkId] = (counts[context.checkId] ?? 0) + 1;
552
- return counts;
553
- }
554
-
555
- function previewTargets(contexts: readonly SemContext[]) {
556
- return contexts.map((context) => ({
557
- targetId: context.targetId,
558
- patternId: context.checkId,
559
- entityKind: context.entityKind || undefined,
560
- sourceKind: context.filePath ? pathKind(context.filePath) : undefined,
561
- }));
562
- }
563
-
564
- function pathKind(filePath: string): string {
565
- const ext = filePath.split(".").pop();
566
- return ext && ext !== filePath ? ext : "unknown";
567
- }
568
-
569
- if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
570
- process.exitCode = await main();
571
- }
package/src/trace.ts DELETED
@@ -1,126 +0,0 @@
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
- phase: "start" | "end" | "error";
13
- ms: number;
14
- count?: number;
15
- detail?: string;
16
- }>;
17
-
18
- export type SpanTraceOptions<T> = Readonly<{
19
- fields?: TraceFields;
20
- startDetail?: string | (() => string);
21
- count?: (value: T) => number;
22
- detail?: (value: T) => string;
23
- }>;
24
-
25
- export type CreateTracerOptions = {
26
- enabled?: boolean;
27
- writeLine?: (line: string) => void;
28
- onEvent?: (event: SpanTraceEvent) => void;
29
- };
30
-
31
- export function createTracer(options?: CreateTracerOptions): Tracer {
32
- const enabled = options?.enabled ?? true;
33
- const writeLine = options?.writeLine ?? ((line) => process.stderr.write(line + "\n"));
34
- const onEvent = options?.onEvent;
35
- const nowMs = () => performance.now();
36
-
37
- function emit(span: string, durationMs: number, fields?: TraceFields) {
38
- if (!enabled) return;
39
- const payload: Record<string, unknown> = { span, ms: Math.round(durationMs) };
40
- for (const [k, v] of Object.entries(fields ?? {})) {
41
- if (v !== undefined) payload[k] = v;
42
- }
43
- writeLine(`trace ${JSON.stringify(payload)}`);
44
- }
45
-
46
- function trace<T>(
47
- span: string,
48
- fn: () => Promise<T>,
49
- options?: SpanTraceOptions<T>,
50
- ): Promise<{ value: T; ms: number }>;
51
- function trace<T>(span: string, fn: () => T, options?: SpanTraceOptions<T>): { value: T; ms: number };
52
- function trace<T>(
53
- span: string,
54
- fn: (() => T) | (() => Promise<T>),
55
- options?: SpanTraceOptions<T>,
56
- ): Promise<{ value: T; ms: number }> | { value: T; ms: number } {
57
- const startedAtMs = nowMs();
58
- onEvent?.({
59
- name: span,
60
- phase: "start",
61
- ms: 0,
62
- detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
63
- });
64
- try {
65
- const out = fn();
66
- if (isPromiseLike(out)) {
67
- return (async () => {
68
- let durationMs: number | undefined;
69
- try {
70
- const value = await out;
71
- durationMs = nowMs() - startedAtMs;
72
- const event: SpanTraceEvent = {
73
- name: span,
74
- phase: "end",
75
- ms: Math.round(durationMs),
76
- count: options?.count?.(value),
77
- detail: options?.detail?.(value),
78
- };
79
- onEvent?.(event);
80
- return { value, ms: event.ms };
81
- } catch (error) {
82
- durationMs = nowMs() - startedAtMs;
83
- onEvent?.({
84
- name: span,
85
- phase: "error",
86
- ms: Math.round(durationMs),
87
- });
88
- throw error;
89
- } finally {
90
- durationMs ??= nowMs() - startedAtMs;
91
- emit(span, durationMs, options?.fields);
92
- }
93
- })();
94
- }
95
-
96
- const durationMs = nowMs() - startedAtMs;
97
- emit(span, durationMs, options?.fields);
98
- const event: SpanTraceEvent = {
99
- name: span,
100
- phase: "end",
101
- ms: Math.round(durationMs),
102
- count: options?.count?.(out),
103
- detail: options?.detail?.(out),
104
- };
105
- onEvent?.(event);
106
- return { value: out, ms: event.ms };
107
- } catch (error) {
108
- const durationMs = nowMs() - startedAtMs;
109
- onEvent?.({
110
- name: span,
111
- phase: "error",
112
- ms: Math.round(durationMs),
113
- });
114
- emit(span, durationMs, options?.fields);
115
- throw error;
116
- }
117
- }
118
-
119
- return { trace };
120
- }
121
-
122
- export const trace: Tracer = createTracer();
123
-
124
- function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
125
- return typeof value === "object" && value !== null && "then" in value;
126
- }