@autor3search/javascript 0.2.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +500 -0
  3. package/bin/autor3search-javascript.js +4 -0
  4. package/package.json +50 -0
  5. package/src/adapters/bench/index.js +31 -0
  6. package/src/adapters/bench/vitest.js +128 -0
  7. package/src/adapters/driver-child.js +72 -0
  8. package/src/adapters/driver-hooks.js +18 -0
  9. package/src/adapters/driver.js +192 -0
  10. package/src/adapters/gates/index.js +64 -0
  11. package/src/adapters/gates/lint.js +46 -0
  12. package/src/adapters/gates/test.js +27 -0
  13. package/src/adapters/gates/typecheck.js +98 -0
  14. package/src/adapters/gates/util.js +45 -0
  15. package/src/adapters/vitest-shim.js +51 -0
  16. package/src/bench/parse.js +120 -0
  17. package/src/bench/set.js +101 -0
  18. package/src/bench/stats.js +443 -0
  19. package/src/cli/cmd-baseline.js +136 -0
  20. package/src/cli/cmd-doctor.js +38 -0
  21. package/src/cli/cmd-eval.js +231 -0
  22. package/src/cli/cmd-init.js +136 -0
  23. package/src/cli/cmd-profile.js +38 -0
  24. package/src/cli/cmd-report.js +96 -0
  25. package/src/cli/cmd-status.js +68 -0
  26. package/src/cli/cmd-stop.js +98 -0
  27. package/src/cli/cmd-version.js +31 -0
  28. package/src/cli/context.js +98 -0
  29. package/src/cli/main.js +62 -0
  30. package/src/config.js +209 -0
  31. package/src/discover.js +239 -0
  32. package/src/doctor.js +282 -0
  33. package/src/duration.js +75 -0
  34. package/src/freeze.js +234 -0
  35. package/src/gitx.js +115 -0
  36. package/src/measure.js +127 -0
  37. package/src/pipeline.js +391 -0
  38. package/src/profile.js +100 -0
  39. package/src/results.js +124 -0
  40. package/src/runner.js +198 -0
  41. package/src/scope.js +92 -0
  42. package/src/state/index.js +312 -0
  43. package/src/state/lock.js +189 -0
  44. package/src/state/stop.js +56 -0
  45. package/src/verdict.js +214 -0
  46. package/templates/program.md +264 -0
@@ -0,0 +1,443 @@
1
+ /**
2
+ * The statistics behind every verdict. The estimators follow the method
3
+ * described by golang.org/x/perf/benchmath, implemented here directly; that
4
+ * package is cited for the method, not depended on.
5
+ *
6
+ * The estimator is deliberately distribution-free ("assume nothing"):
7
+ * benchmark timings are not normal, they are right-skewed with occasional
8
+ * large outliers from GC pauses and scheduler preemption, and a mean with a
9
+ * t-interval would be pulled around by exactly those outliers.
10
+ */
11
+
12
+ /** Confidence level for the reported median interval. */
13
+ export const CONFIDENCE = 0.95
14
+
15
+ /** Rejection threshold for the significance test. */
16
+ export const ALPHA = 0.05
17
+
18
+ /**
19
+ * The median. Uses the average of the two middle values at even length,
20
+ * which is the R-7 quantile at 0.5 and matches benchmath.
21
+ *
22
+ * @param {number[]} values
23
+ * @returns {number}
24
+ */
25
+ export function median(values) {
26
+ const v = [...values].sort((a, b) => a - b)
27
+ const mid = v.length >> 1
28
+ return v.length % 2 === 1 ? v[mid] : (v[mid - 1] + v[mid]) / 2
29
+ }
30
+
31
+ /**
32
+ * C(n, k), multiplying and dividing in step so the intermediate value stays
33
+ * near the result rather than overflowing through a factorial.
34
+ *
35
+ * @param {number} n
36
+ * @param {number} k
37
+ * @returns {number}
38
+ */
39
+ export function binom(n, k) {
40
+ if (k < 0 || k > n) return 0
41
+ const kk = Math.min(k, n - k)
42
+ let c = 1
43
+ for (let i = 0; i < kk; i++) c = (c * (n - i)) / (i + 1)
44
+ return c
45
+ }
46
+
47
+ /**
48
+ * Summarises one sample: the median, and a distribution-free confidence
49
+ * interval built from order statistics.
50
+ *
51
+ * The interval [x(k), x(n-1-k)] has coverage 1 - 2*P(Bin(n, 1/2) <= k). The
52
+ * widest available interval — the whole range, at k = 0 — has coverage
53
+ * 1 - 2/2^n, which only reaches 95% once n >= 6. Below that NO interval
54
+ * achieves the requested confidence, so rather than quietly reporting the
55
+ * range as though it did, the bounds are reported as infinite and a warning
56
+ * says why. benchmath raises the same warning, and its documentation says it
57
+ * should be shown to the user, so it is carried out of here rather than
58
+ * dropped.
59
+ *
60
+ * @param {number[]} values
61
+ * @param {number} [confidence]
62
+ * @returns {{center: number, lo: number, hi: number, warnings: string[]}}
63
+ */
64
+ export function summary(values, confidence = CONFIDENCE) {
65
+ const n = values.length
66
+ if (n === 0) throw new Error('summary needs at least one observation')
67
+
68
+ const sorted = [...values].sort((a, b) => a - b)
69
+ const center = median(sorted)
70
+ const warnings = []
71
+
72
+ // Largest k whose interval still covers at least `confidence`.
73
+ //
74
+ // The cumulative binomial is accumulated in LOG SPACE, carrying log C(n,k)
75
+ // incrementally. The direct form — binom(n, k) / 2 ** n — overflows to
76
+ // Infinity at n = 1024 and then silently yields 0 and NaN, so the loop exits
77
+ // on a comparison against NaN and returns a k that is neither correct nor
78
+ // conservative, with no warning. Carrying the log also makes this O(n)
79
+ // rather than O(n^2), since binom is no longer recomputed per iteration.
80
+ const logThreshold = Math.log((1 - confidence) / 2)
81
+ let logCoefficient = 0 // log C(n, 0)
82
+ let logCumulative = logCoefficient - n * Math.LN2 // log P(Bin(n, 1/2) <= 0)
83
+ let best = logCumulative <= logThreshold ? 0 : -1
84
+ for (let k = 1; k <= (n - 1) >> 1; k++) {
85
+ logCoefficient += Math.log(n - k + 1) - Math.log(k)
86
+ logCumulative = logAddExp(logCumulative, logCoefficient - n * Math.LN2)
87
+ if (logCumulative <= logThreshold) best = k
88
+ else break
89
+ }
90
+
91
+ if (best < 0) {
92
+ warnings.push(
93
+ `confidence interval requires at least 6 observations at ${(confidence * 100).toFixed(0)}% ` +
94
+ `confidence; got ${n}, so the interval around the reported median is unbounded — raise count`,
95
+ )
96
+ return { center, lo: -Infinity, hi: Infinity, warnings }
97
+ }
98
+ return { center, lo: sorted[best], hi: sorted[n - 1 - best], warnings }
99
+ }
100
+
101
+ /**
102
+ * log(exp(a) + exp(b)), computed so the larger term never leaves log space.
103
+ * Summing the probabilities directly would underflow to zero for the tiny
104
+ * per-term values that arise at large n.
105
+ */
106
+ function logAddExp(a, b) {
107
+ const max = Math.max(a, b)
108
+ if (max === -Infinity) return max
109
+ return max + Math.log1p(Math.exp(Math.min(a, b) - max))
110
+ }
111
+
112
+ /**
113
+ * The smallest two-sided p-value the Mann-Whitney U test can return for
114
+ * samples of size n1 and n2: 2 / C(n1+n2, n1).
115
+ *
116
+ * The two samples can be maximally separated and the test still only reaches
117
+ * this, because it is the fraction of orderings at least as extreme as the
118
+ * observed one. It reproduces benchmath's generated table exactly (0.3333 at
119
+ * n=2, 0.1000 at 3, 0.02857 at 4, 0.00794 at 5) without duplicating it.
120
+ *
121
+ * @param {number} n1
122
+ * @param {number} n2
123
+ * @returns {number}
124
+ */
125
+ export function minAchievableP(n1, n2) {
126
+ if (n1 < 1 || n2 < 1) return 1
127
+ const p = 2 / binom(n1 + n2, n1)
128
+ return p > 1 ? 1 : p
129
+ }
130
+
131
+ /**
132
+ * The smallest number of rounds per side at which the U test can produce a
133
+ * p-value below alpha, or 0 when no practical count does. The search stops at
134
+ * 50, far past any sensible benchmark budget.
135
+ *
136
+ * @param {number} alpha
137
+ * @returns {number}
138
+ */
139
+ export function countForAlpha(alpha) {
140
+ for (let n = 2; n <= 50; n++) {
141
+ if (minAchievableP(n, n) < alpha) return n
142
+ }
143
+ return 0
144
+ }
145
+
146
+ /**
147
+ * Largest sample size for which the exact test is enumerated. Above this the
148
+ * normal approximation is used. The default count of 10 rounds per side sits
149
+ * exactly at this limit, so a default run gets the exact test.
150
+ */
151
+ export const EXACT_LIMIT = 10
152
+
153
+ /**
154
+ * Two-sided Mann-Whitney U test (a rank-sum test).
155
+ *
156
+ * Chosen over a t-test because it assumes nothing about the shape of the
157
+ * distribution. Benchmark timings are right-skewed with occasional large
158
+ * outliers from GC pauses and scheduler preemption; a t-test's normality
159
+ * assumption is not merely unmet, it is unmet in the direction that
160
+ * manufactures false significance.
161
+ *
162
+ * @param {number[]} a
163
+ * @param {number[]} b
164
+ * @returns {{p: number, n1: number, n2: number, exact: boolean, warnings: string[]}}
165
+ */
166
+ export function mannWhitneyU(a, b) {
167
+ const n1 = a.length
168
+ const n2 = b.length
169
+ if (n1 < 2 || n2 < 2) {
170
+ throw new Error(`Mann-Whitney needs at least 2 observations per side, got ${n1}/${n2}`)
171
+ }
172
+
173
+ const warnings = []
174
+ const { rankSumA, tieGroups, allTied } = rank(a, b)
175
+
176
+ if (allTied) {
177
+ warnings.push(
178
+ 'the two samples are indistinguishable — every observation is identical, so no test can separate them',
179
+ )
180
+ return { p: 1, n1, n2, exact: false, warnings }
181
+ }
182
+
183
+ // U1 is the count of (a, b) pairs in which a wins, derived from the rank sum.
184
+ const u1 = rankSumA - (n1 * (n1 + 1)) / 2
185
+ const u2 = n1 * n2 - u1
186
+ const u = Math.min(u1, u2)
187
+
188
+ const exact = n1 <= EXACT_LIMIT && n2 <= EXACT_LIMIT && tieGroups.length === 0
189
+ const p = exact ? exactP(n1, n2, u) : normalP(n1, n2, u, tieGroups)
190
+
191
+ // The floor below which this pair of sample sizes cannot reach, however far
192
+ // apart the samples are. Surfaced here so a caller can tell "no difference"
193
+ // from "this experiment was never able to show one".
194
+ if (minAchievableP(n1, n2) >= ALPHA) {
195
+ warnings.push(
196
+ `with ${n1}/${n2} observations the test cannot produce a p-value below ` +
197
+ `${minAchievableP(n1, n2).toFixed(5)}, so it can never reach alpha=${ALPHA} however large the ` +
198
+ `difference is — raise count to at least ${countForAlpha(ALPHA)}`,
199
+ )
200
+ }
201
+ return { p: Math.min(1, p), n1, n2, exact, warnings }
202
+ }
203
+
204
+ /**
205
+ * Assigns midranks across the pooled samples and returns the rank sum of a,
206
+ * along with the sizes of every tie group (used by the tie correction).
207
+ */
208
+ function rank(a, b) {
209
+ const pooled = [
210
+ ...a.map((value) => ({ value, fromA: true })),
211
+ ...b.map((value) => ({ value, fromA: false })),
212
+ ].sort((x, y) => x.value - y.value)
213
+
214
+ let rankSumA = 0
215
+ const tieGroups = []
216
+ for (let i = 0; i < pooled.length; ) {
217
+ let j = i
218
+ while (j + 1 < pooled.length && pooled[j + 1].value === pooled[i].value) j++
219
+ const size = j - i + 1
220
+ // Midrank: the average of the 1-based ranks this tie group spans.
221
+ const midrank = (i + 1 + (j + 1)) / 2
222
+ for (let k = i; k <= j; k++) {
223
+ if (pooled[k].fromA) rankSumA += midrank
224
+ }
225
+ if (size > 1) tieGroups.push(size)
226
+ i = j + 1
227
+ }
228
+ return { rankSumA, tieGroups, allTied: tieGroups.length === 1 && tieGroups[0] === pooled.length }
229
+ }
230
+
231
+ /**
232
+ * Exact two-sided p by enumerating the null distribution of U.
233
+ *
234
+ * counts[u] is the number of ways to arrange n1 items among n1+n2 positions
235
+ * that produce statistic u, from the recurrence
236
+ * N(n1, n2, u) = N(n1-1, n2, u - n2) + N(n1, n2-1, u)
237
+ * evaluated as a rolling table over u. At n1 = n2 = 10 this is 100 * 101
238
+ * table updates, which is instant.
239
+ */
240
+ function exactP(n1, n2, u) {
241
+ const maxU = n1 * n2
242
+
243
+ // f[j][x] = the number of arrangements of i A's and j B's whose statistic is
244
+ // x, rolled forward over i. The recurrence is
245
+ //
246
+ // f(i, j, x) = f(i-1, j, x-j) + f(i, j-1, x)
247
+ //
248
+ // Place an A last and it sits after all j B's, contributing j to the
249
+ // statistic; place a B last and it contributes nothing.
250
+ //
251
+ // The tempting shortcut — letting each of the n1 A's independently take any
252
+ // value in 0..n2 — is WRONG: it counts ordered compositions, (n2+1)^n1
253
+ // rather than C(n1+n2, n1) arrangements (25,937,424,601 instead of 184,756
254
+ // at n1=n2=10). It agrees with this one only at u=0, where a single
255
+ // arrangement is possible either way, so a test that checks only the
256
+ // p-value floor cannot tell the two apart while every intermediate p-value
257
+ // is wrong.
258
+ let f = Array.from({ length: n2 + 1 }, () => new Float64Array(maxU + 1))
259
+ for (let j = 0; j <= n2; j++) f[j][0] = 1 // i=0: all B's, statistic 0
260
+ for (let i = 1; i <= n1; i++) {
261
+ const next = Array.from({ length: n2 + 1 }, () => new Float64Array(maxU + 1))
262
+ for (let j = 0; j <= n2; j++) {
263
+ for (let x = 0; x <= maxU; x++) {
264
+ let v = x - j >= 0 ? f[j][x - j] : 0 // f(i-1, j, x-j)
265
+ if (j > 0) v += next[j - 1][x] // f(i, j-1, x)
266
+ next[j][x] = v
267
+ }
268
+ }
269
+ f = next
270
+ }
271
+
272
+ const total = binom(n1 + n2, n1)
273
+ let cumulative = 0
274
+ for (let x = 0; x <= u; x++) cumulative += f[n2][x]
275
+ return Math.min(1, (2 * cumulative) / total)
276
+ }
277
+
278
+ /**
279
+ * Normal approximation with the standard tie correction. Used above the exact
280
+ * limit and whenever the pooled samples contain ties.
281
+ */
282
+ function normalP(n1, n2, u, tieGroups) {
283
+ const n = n1 + n2
284
+ let tieTerm = 0
285
+ for (const t of tieGroups) tieTerm += t ** 3 - t
286
+ const variance = ((n1 * n2) / 12) * (n + 1 - tieTerm / (n * (n - 1)))
287
+ if (variance <= 0) return 1
288
+ const z = (u - (n1 * n2) / 2) / Math.sqrt(variance)
289
+ return Math.min(1, 2 * normalCdf(-Math.abs(z)))
290
+ }
291
+
292
+ /** Standard normal CDF, via the complementary error function. */
293
+ function normalCdf(z) {
294
+ return 0.5 * erfc(-z / Math.SQRT2)
295
+ }
296
+
297
+ /**
298
+ * Complementary error function. Numerical Recipes' Chebyshev approximation,
299
+ * accurate to about 1.2e-7 relative — far tighter than any p-value here is
300
+ * interpreted to.
301
+ */
302
+ function erfc(x) {
303
+ const z = Math.abs(x)
304
+ const t = 2 / (2 + z)
305
+ const ty = 4 * t - 2
306
+ const coefficients = [
307
+ -1.3026537197817094, 6.4196979235649026e-1, 1.9476473204185836e-2, -9.561514786808631e-3,
308
+ -9.46595344482036e-4, 3.66839497852761e-4, 4.2523324806907e-5, -2.0278578112534e-5,
309
+ -1.624290004647e-6, 1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8, 6.529054439e-9,
310
+ 5.059343495e-9, -9.91364156e-10, -2.27365122e-10, 9.6467911e-11, 2.394038e-12,
311
+ -6.886027e-12, 8.94487e-13, 3.13092e-13, -1.12708e-13, 3.81e-16, 7.106e-15,
312
+ ]
313
+ let d = 0
314
+ let dd = 0
315
+ for (let j = coefficients.length - 1; j > 0; j--) {
316
+ const tmp = d
317
+ d = ty * d - dd + coefficients[j]
318
+ dd = tmp
319
+ }
320
+ const result = t * Math.exp(-z * z + 0.5 * (coefficients[0] + ty * d) - dd)
321
+ return x >= 0 ? result : 2 - result
322
+ }
323
+
324
+ /**
325
+ * @typedef {object} Delta
326
+ * @property {string} name
327
+ * @property {string} unit
328
+ * @property {number} baseCenter median of the baseline observations
329
+ * @property {number} candCenter median of the candidate observations
330
+ * @property {number} ratio candCenter / baseCenter; below 1 is faster
331
+ * @property {number} pctChange (ratio - 1) * 100
332
+ * @property {number} p Mann-Whitney two-sided p-value
333
+ * @property {number} alpha rejection threshold, uncorrected
334
+ * @property {boolean} significant p < alpha, with NO multiple-comparison correction
335
+ * @property {number} nBase
336
+ * @property {number} nCand
337
+ * @property {string[]} warnings
338
+ */
339
+
340
+ /**
341
+ * Compares one benchmark's unit across two measurement sets.
342
+ *
343
+ * `significant` here always means "significant at the raw, uncorrected
344
+ * alpha". That is the honest statistic a human or an agent should read. A
345
+ * Bonferroni correction that gates a keep/reject decision is applied
346
+ * elsewhere, on top of this — it is a decision threshold, not a
347
+ * redefinition of the word.
348
+ *
349
+ * @param {import('./set.js').BenchSet} base
350
+ * @param {import('./set.js').BenchSet} cand
351
+ * @param {string} name
352
+ * @param {string} unit
353
+ * @returns {Delta}
354
+ */
355
+ export function compare(base, cand, name, unit) {
356
+ const bv = base.values(name, unit)
357
+ if (!bv) throw new Error(`baseline has no ${unit} for ${name}`)
358
+ const cv = cand.values(name, unit)
359
+ if (!cv) throw new Error(`candidate has no ${unit} for ${name}`)
360
+ if (bv.length < 2 || cv.length < 2) {
361
+ throw new Error(`${name}: need at least 2 observations per side, got ${bv.length}/${cv.length}`)
362
+ }
363
+
364
+ const bSum = summary(bv)
365
+ const cSum = summary(cv)
366
+ if (bSum.center === 0) {
367
+ throw new Error(`${name}: baseline ${unit} median is zero, cannot form a ratio`)
368
+ }
369
+ const test = mannWhitneyU(bv, cv)
370
+ const ratio = cSum.center / bSum.center
371
+
372
+ return {
373
+ name,
374
+ unit,
375
+ baseCenter: bSum.center,
376
+ candCenter: cSum.center,
377
+ ratio,
378
+ pctChange: (ratio - 1) * 100,
379
+ p: test.p,
380
+ alpha: ALPHA,
381
+ significant: test.p < ALPHA,
382
+ nBase: test.n1,
383
+ nCand: test.n2,
384
+ // Deduplicated: the two summaries warn about the same sample size in the
385
+ // same words, and printing that twice per benchmark buries the distinct
386
+ // warnings among duplicates.
387
+ warnings: [...new Set([...bSum.warnings, ...cSum.warnings, ...test.warnings])],
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Compares every benchmark present in both sets, sorted by name.
393
+ *
394
+ * Strict in one direction: a benchmark measured at baseline but missing from
395
+ * the candidate is an error, not a skip. A benchmark that disappears cannot
396
+ * be checked for regressions — which is exactly how an agent would hide one.
397
+ * A benchmark the candidate added is ignored rather than an error, because a
398
+ * new benchmark cannot have regressed against a baseline that never ran it.
399
+ *
400
+ * @param {import('./set.js').BenchSet} base
401
+ * @param {import('./set.js').BenchSet} cand
402
+ * @param {string} unit
403
+ * @returns {Delta[]}
404
+ */
405
+ export function compareAll(base, cand, unit) {
406
+ const out = []
407
+ const missing = []
408
+ for (const name of base.names()) {
409
+ if (!base.has(name, unit)) continue
410
+ if (!cand.has(name, unit)) {
411
+ missing.push(name)
412
+ continue
413
+ }
414
+ out.push(compare(base, cand, name, unit))
415
+ }
416
+ if (missing.length > 0) {
417
+ throw new Error(
418
+ `benchmark(s) measured at baseline but missing from the candidate: ${missing.sort().join(', ')} — ` +
419
+ `a benchmark that disappears cannot be checked for regressions`,
420
+ )
421
+ }
422
+ if (out.length === 0) throw new Error('no benchmark appears in both baseline and candidate')
423
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
424
+ }
425
+
426
+ /**
427
+ * The geometric mean of the deltas' ratios — the single score the agent
428
+ * optimizes. Below 1 is an overall speedup.
429
+ *
430
+ * Computed in log space so a long list of small ratios cannot underflow.
431
+ *
432
+ * @param {Delta[]} deltas
433
+ * @returns {number}
434
+ */
435
+ export function geoMean(deltas) {
436
+ if (deltas.length === 0) throw new Error('geoMean of an empty delta set')
437
+ let sum = 0
438
+ for (const d of deltas) {
439
+ if (!(d.ratio > 0)) throw new Error(`${d.name}: non-positive ratio ${d.ratio}`)
440
+ sum += Math.log(d.ratio)
441
+ }
442
+ return Math.exp(sum / deltas.length)
443
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Creates the run branch, freezes the tests and benchmarks, and pins the
3
+ * commit this run measures against.
4
+ *
5
+ * Everything it writes lands OUTSIDE the repository, under the run's state
6
+ * directory — see src/state/index.js for why.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { readFile, rm } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { parseArgs } from 'node:util'
12
+ import { CONFIG_PATH } from '../config.js'
13
+ import { baseNames, benchmarks, frozenFiles } from '../discover.js'
14
+ import * as freeze from '../freeze.js'
15
+ import * as gitx from '../gitx.js'
16
+ import { RESULTS_PATH, loadRows } from '../results.js'
17
+ import {
18
+ BASELINE_FILE,
19
+ BRANCH_PREFIX,
20
+ WORKTREE_NAME,
21
+ benchPattern,
22
+ ensureSecureDir,
23
+ linkNodeModules,
24
+ saveBaseline,
25
+ stateDir,
26
+ validTag,
27
+ } from '../state/index.js'
28
+ import { expandSingleDashFlags, loadRepoConfig, resolveRepo } from './context.js'
29
+
30
+ export async function runBaseline(args, io) {
31
+ const { values } = parseArgs({
32
+ args: expandSingleDashFlags(args, ['tag']),
33
+ options: {
34
+ C: { type: 'string', default: '.' },
35
+ tag: { type: 'string', default: defaultTag() },
36
+ force: { type: 'boolean', default: false },
37
+ },
38
+ allowPositionals: false,
39
+ })
40
+
41
+ const tag = values.tag
42
+ validTag(tag) // before the tag ever reaches a filesystem path
43
+ const root = await resolveRepo(values.C)
44
+ const cfg = await loadRepoConfig(root)
45
+
46
+ if (!(await gitx.isClean(root))) {
47
+ io.err.write(
48
+ 'the working tree has uncommitted changes.\n\n' +
49
+ 'baseline refuses a dirty tree because a baseline pinned against what is on disk, rather than\n' +
50
+ 'what is in git, would not be reproducible — the pinned worktree could never be recreated.\n' +
51
+ 'Commit or stash first.\n',
52
+ )
53
+ return 2
54
+ }
55
+
56
+ const rows = await loadRows(join(root, RESULTS_PATH))
57
+ if (rows.length > 0 && !values.force) {
58
+ io.err.write(
59
+ `${RESULTS_PATH} already holds ${rows.length} experiment row(s) from an earlier run. Starting a new\n` +
60
+ `baseline over them would mix two runs' results in one log. Move it aside, or pass --force to\n` +
61
+ `start anyway.\n`,
62
+ )
63
+ return 2
64
+ }
65
+
66
+ const branch = `${BRANCH_PREFIX}${tag}`
67
+ if (await gitx.branchExists(root, branch)) {
68
+ io.err.write(
69
+ `branch ${branch} already exists — that tag has been used, and a run's state directory is keyed\n` +
70
+ `by tag, so reusing it would silently mix two runs' frozen copies and baseline records. Pick\n` +
71
+ `another -tag.\n`,
72
+ )
73
+ return 2
74
+ }
75
+
76
+ const found = await benchmarks(root)
77
+ if (found.length === 0) {
78
+ io.err.write(`no benchmarks found in ${root}: run 'autor3search-javascript init' and read what it says\n`)
79
+ return 2
80
+ }
81
+
82
+ const originalBranch = await gitx.currentBranch(root)
83
+ await gitx.createBranch(root, branch)
84
+
85
+ // From here on, any failure must undo the branch, or a retry under the same
86
+ // tag is permanently blocked by a branch nothing finished creating.
87
+ try {
88
+ const dir = await ensureSecureDir(await stateDir(root, tag))
89
+
90
+ const declared = cfg.benchmarks.length > 0 ? cfg.benchmarks : baseNames(found)
91
+ const toFreeze = await frozenFiles(root, cfg.unfreeze)
92
+ const manifest = await freeze.snapshot(root, join(dir, freeze.STORE_DIR), toFreeze)
93
+ await freeze.saveManifest(join(dir, freeze.MANIFEST_PATH), manifest)
94
+
95
+ const commit = await gitx.headCommit(root)
96
+ const worktree = join(dir, WORKTREE_NAME)
97
+ await rm(worktree, { recursive: true, force: true })
98
+ await gitx.addWorktree(root, worktree, commit)
99
+ // A worktree checks out only tracked files, and node_modules is normally
100
+ // gitignored — link it in so the pinned baseline side can resolve the
101
+ // bench runner at all. See linkNodeModules for why this lives here.
102
+ await linkNodeModules(root, worktree)
103
+
104
+ await saveBaseline(join(dir, BASELINE_FILE), {
105
+ tag,
106
+ branch,
107
+ commit,
108
+ measureCommit: commit,
109
+ createdAt: new Date().toISOString(),
110
+ benchmarks: declared,
111
+ pattern: benchPattern(declared),
112
+ configSha256: createHash('sha256').update(await readFile(join(root, CONFIG_PATH))).digest('hex'),
113
+ })
114
+
115
+ io.out.write(`run branch ${branch} (checked out)\n`)
116
+ io.out.write(`baseline ${commit}\n`)
117
+ io.out.write(`benchmarks ${declared.join(', ')}\n`)
118
+ io.out.write(`frozen ${toFreeze.length} test and bench file(s)\n`)
119
+ io.out.write(`worktree ${worktree}\n`)
120
+ io.out.write(`\nstart the agent: point it at program.md\n`)
121
+ io.out.write(`to stop the run: autor3search-javascript stop\n`)
122
+ return 0
123
+ } catch (err) {
124
+ await gitx.checkout(root, originalBranch).catch(() => {})
125
+ await gitx.deleteBranch(root, branch).catch(() => {})
126
+ io.err.write(`baseline failed, run branch ${branch} removed: ${err.message}\n`)
127
+ return 2
128
+ }
129
+ }
130
+
131
+ /** Today as a short slug, e.g. "sep7" — the shape the README's examples use. */
132
+ function defaultTag() {
133
+ const now = new Date()
134
+ const month = now.toLocaleString('en-US', { month: 'short' }).toLowerCase()
135
+ return `${month}${now.getDate()}`
136
+ }
@@ -0,0 +1,38 @@
1
+ /** Prints the machine-fitness report. Always exits 0 — it informs, never blocks. */
2
+ import { parseArgs } from 'node:util'
3
+ import { SEVERITY, check } from '../doctor.js'
4
+
5
+ const LABEL = { [SEVERITY.OK]: 'ok ', [SEVERITY.WARN]: 'warn', [SEVERITY.FAIL]: 'FAIL', [SEVERITY.NA]: 'n/a ' }
6
+
7
+ /**
8
+ * @param {string[]} args
9
+ * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
10
+ * @returns {Promise<number>}
11
+ */
12
+ export async function runDoctor(args, io) {
13
+ const { values } = parseArgs({
14
+ args,
15
+ options: { C: { type: 'string', default: '.' } },
16
+ allowPositionals: false,
17
+ })
18
+
19
+ const findings = await check(values.C)
20
+ for (const f of findings) io.out.write(`${LABEL[f.severity]} ${f.name.padEnd(14)} ${f.detail}\n`)
21
+
22
+ const failures = findings.filter((f) => f.severity === SEVERITY.FAIL).length
23
+ const warnings = findings.filter((f) => f.severity === SEVERITY.WARN).length
24
+ io.out.write('\n')
25
+ if (failures > 0) {
26
+ io.out.write(`${failures} problem(s) will stop a run from working at all — fix those first.\n`)
27
+ } else if (warnings > 0) {
28
+ io.out.write(
29
+ `${warnings} warning(s): this machine can measure, but expect noisier numbers. Raise min_effect_pct\n` +
30
+ `if experiments look erratic — it costs you only wins smaller than the noise you cannot measure anyway.\n`,
31
+ )
32
+ } else {
33
+ io.out.write('this machine looks fit to measure.\n')
34
+ }
35
+ // Deliberately 0 regardless: whether to accept a noisy machine is the
36
+ // human's decision, not the harness's.
37
+ return 0
38
+ }