@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
package/src/profile.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Runs the declared benchmarks under V8's CPU and heap profilers and reports
3
+ * the hot spots.
4
+ *
5
+ * The point is to give an agent real data on where time actually goes, rather
6
+ * than have it guess from reading source. A .cpuprofile is a documented V8
7
+ * JSON structure, so the hot-spot table is computed here directly — no pprof
8
+ * equivalent is needed — and the raw files stay on disk for a human to open.
9
+ */
10
+ import { readFile } from 'node:fs/promises'
11
+ import { basename, join } from 'node:path'
12
+ import { runProfiled } from './adapters/driver.js'
13
+ import { benchFiles } from './discover.js'
14
+ import { parseDuration } from './duration.js'
15
+
16
+ /** Where profiles are written, relative to the repository root. */
17
+ export const PROFILE_DIR = '.autor3search/profiles'
18
+
19
+ /** V8's synthetic frames: never code an agent can optimize. */
20
+ const SYNTHETIC = new Set(['(root)', '(program)', '(idle)', '(garbage collector)'])
21
+
22
+ /**
23
+ * The functions with the most self time in a .cpuprofile.
24
+ *
25
+ * Self time is what matters for optimization: a function high in total time
26
+ * may simply be a caller of the real hot spot. Self time per node is the sum
27
+ * of the timeDeltas (microseconds) attributed to the samples that name it —
28
+ * `samples` and `timeDeltas` are parallel arrays, each samples[i] a node id
29
+ * and timeDeltas[i] the microseconds charged to it.
30
+ *
31
+ * @param {object} cpuProfile parsed .cpuprofile JSON
32
+ * @param {number} limit
33
+ * @returns {{name: string, file: string, selfMs: number, pct: number}[]}
34
+ */
35
+ export function topFunctions(cpuProfile, limit) {
36
+ const byId = new Map((cpuProfile.nodes ?? []).map((n) => [n.id, n]))
37
+ const selfMicros = new Map()
38
+
39
+ const samples = cpuProfile.samples ?? []
40
+ const deltas = cpuProfile.timeDeltas ?? []
41
+ for (let i = 0; i < samples.length; i++) {
42
+ const id = samples[i]
43
+ selfMicros.set(id, (selfMicros.get(id) ?? 0) + (deltas[i] ?? 0))
44
+ }
45
+
46
+ const entries = [...selfMicros.entries()]
47
+ .map(([id, micros]) => {
48
+ const frame = byId.get(id)?.callFrame ?? {}
49
+ return {
50
+ name: frame.functionName || '(anonymous)',
51
+ file: frame.url ? basename(frame.url) : '',
52
+ selfMs: micros / 1000,
53
+ micros,
54
+ }
55
+ })
56
+ .filter((entry) => !SYNTHETIC.has(entry.name))
57
+
58
+ const total = entries.reduce((a, e) => a + e.micros, 0)
59
+ if (total === 0) return []
60
+
61
+ return entries
62
+ .map(({ micros, ...rest }) => ({ ...rest, pct: (micros / total) * 100 }))
63
+ .sort((a, b) => b.selfMs - a.selfMs)
64
+ .slice(0, limit)
65
+ }
66
+
67
+ /**
68
+ * Profiles each bench file in turn, one output directory per file.
69
+ *
70
+ * One directory per file rather than one combined profile, because a merged
71
+ * profile cannot say which benchmark a hot function belongs to — which is the
72
+ * question the agent is asking.
73
+ *
74
+ * @param {string} root
75
+ * @param {object} cfg loaded run configuration
76
+ * @param {{iterations?: number, top?: number}} [opts]
77
+ * @returns {Promise<{file: string, cpuProfile: string, heapProfile: string, top: object[]}[]>}
78
+ */
79
+ export async function profile(root, cfg, opts = {}) {
80
+ const files = await benchFiles(root)
81
+ if (files.length === 0) throw new Error(`no *.bench.* files found in ${root}`)
82
+
83
+ const reports = []
84
+ for (const file of files) {
85
+ const outDir = join(root, PROFILE_DIR, basename(file).replace(/\.bench\.[^.]+$/, ''))
86
+ const written = await runProfiled(root, {
87
+ benchFiles: [file],
88
+ benchmarks: cfg.benchmarks,
89
+ outDir,
90
+ iterations: opts.iterations ?? 2000,
91
+ timeoutMs: parseDuration(cfg.timeout),
92
+ })
93
+ let cpu = {}
94
+ if (written.cpuProfile) {
95
+ cpu = JSON.parse(await readFile(written.cpuProfile, 'utf8').catch(() => '{}'))
96
+ }
97
+ reports.push({ file, ...written, top: topFunctions(cpu, opts.top ?? 15) })
98
+ }
99
+ return reports
100
+ }
package/src/results.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Reads and appends the experiment log.
3
+ *
4
+ * results.tsv is HARNESS-OWNED and lives inside the repository so a human can
5
+ * read it in the morning. It is a log, not part of the metric: nothing here
6
+ * feeds a verdict.
7
+ */
8
+ import { appendFile, readFile, stat } from 'node:fs/promises'
9
+
10
+ /** Log location, relative to the repository root. */
11
+ export const RESULTS_PATH = 'results.tsv'
12
+
13
+ /**
14
+ * The first line of every log file. Column order must be kept in step with
15
+ * COLUMNS, formatRow and parseRow.
16
+ */
17
+ export const HEADER = 'commit\tscore\tbest_bench_delta\tbytes_delta\tstatus\tdescription'
18
+
19
+ const COLUMN_COUNT = 6
20
+
21
+ /**
22
+ * The longest description written verbatim. `-desc` has no length cap of its
23
+ * own, and an agent pasting something large (a stack trace, a diff) would
24
+ * otherwise produce a line long enough to break every future load — after
25
+ * which `report` fails on the whole file, not just that line, until a human
26
+ * edits it by hand.
27
+ */
28
+ export const MAX_DESCRIPTION_LEN = 256
29
+
30
+ /**
31
+ * Appends one row, creating the file with a header when needed.
32
+ *
33
+ * @param {string} path
34
+ * @param {{commit: string, score: number, bestBenchDelta: number, bytesDelta: number, status: string, description: string}} row
35
+ */
36
+ export async function appendRow(path, row) {
37
+ const exists = await stat(path).then(
38
+ () => true,
39
+ () => false,
40
+ )
41
+ const line = [
42
+ clean(row.commit),
43
+ format(row.score, 4),
44
+ format(row.bestBenchDelta, 2),
45
+ format(row.bytesDelta, 2),
46
+ clean(row.status),
47
+ truncate(clean(row.description)),
48
+ ].join('\t')
49
+ await appendFile(path, `${exists ? '' : `${HEADER}\n`}${line}\n`, 'utf8')
50
+ }
51
+
52
+ /**
53
+ * Reads every row written by appendRow. A missing file is not an error — it
54
+ * is a run that has not recorded an experiment yet.
55
+ *
56
+ * @param {string} path
57
+ * @returns {Promise<object[]>}
58
+ */
59
+ export async function loadRows(path) {
60
+ let text
61
+ try {
62
+ text = await readFile(path, 'utf8')
63
+ } catch (err) {
64
+ if (err.code === 'ENOENT') return []
65
+ throw new Error(`read ${path}: ${err.message}`, { cause: err })
66
+ }
67
+
68
+ const rows = []
69
+ const lines = text.split('\n')
70
+ for (const [i, line] of lines.entries()) {
71
+ if (line.trim() === '') continue
72
+ if (i === 0 && line === HEADER) continue
73
+ rows.push(parseRow(path, i + 1, line))
74
+ }
75
+ return rows
76
+ }
77
+
78
+ function parseRow(path, lineNo, line) {
79
+ const parts = line.split('\t')
80
+ if (parts.length !== COLUMN_COUNT) {
81
+ throw new Error(`${path}:${lineNo}: expected ${COLUMN_COUNT} columns, got ${parts.length}`)
82
+ }
83
+ return {
84
+ commit: parts[0],
85
+ score: number(path, lineNo, 'score', parts[1]),
86
+ bestBenchDelta: number(path, lineNo, 'best_bench_delta', parts[2]),
87
+ bytesDelta: number(path, lineNo, 'bytes_delta', parts[3]),
88
+ status: parts[4],
89
+ description: parts[5],
90
+ }
91
+ }
92
+
93
+ function number(path, lineNo, field, text) {
94
+ const n = Number(text)
95
+ if (text.trim() === '' || Number.isNaN(n)) {
96
+ throw new Error(`${path}:${lineNo}: ${field} ${JSON.stringify(text)} is not a number`)
97
+ }
98
+ return n
99
+ }
100
+
101
+ /** Makes a field safe for a tab-separated single-line record. */
102
+ function clean(s) {
103
+ return String(s ?? '')
104
+ .replace(/[\t\r\n]/g, ' ')
105
+ .trim()
106
+ }
107
+
108
+ /**
109
+ * Caps s at MAX_DESCRIPTION_LEN characters. Counting characters rather than
110
+ * bytes means a multi-byte character is never split in half.
111
+ */
112
+ function truncate(s) {
113
+ const chars = [...s]
114
+ return chars.length <= MAX_DESCRIPTION_LEN ? s : `${chars.slice(0, MAX_DESCRIPTION_LEN).join('')}...`
115
+ }
116
+
117
+ /**
118
+ * Formats a number with fixed precision. A non-finite value (a gate failure
119
+ * has no score) is written as zero rather than "NaN", which loadRows would
120
+ * refuse to read back.
121
+ */
122
+ function format(n, digits) {
123
+ return (Number.isFinite(n) ? n : 0).toFixed(digits)
124
+ }
package/src/runner.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Executes subprocesses with a timeout, captured output and group-kill
3
+ * semantics.
4
+ *
5
+ * Every child is spawned into its OWN PROCESS GROUP (detached: true) and
6
+ * killed by group. This is not a nicety: `vitest` forks worker processes to
7
+ * run benchmarks, so a vitest killed without taking its group with it leaves
8
+ * those workers running — burning CPU, and corrupting every later measurement
9
+ * on the machine, including ones belonging to other runs.
10
+ */
11
+ import { spawn } from 'node:child_process'
12
+ import { formatDuration } from './duration.js'
13
+
14
+ /** Bytes retained per stream before output is truncated. */
15
+ export const OUTPUT_CAP = 4 * 1024 * 1024
16
+
17
+ /** Grace period between SIGTERM and SIGKILL when tearing a group down. */
18
+ const KILL_GRACE_MS = 10_000
19
+
20
+ export class Runner {
21
+ /**
22
+ * @param {string} dir working directory for every command
23
+ * @param {number} timeoutMs bound on each command
24
+ * @param {{write(s: string): void} | null} [log] receives the command line and its output
25
+ * @param {{killGraceMs?: number}} [opts] killGraceMs shortens the SIGTERM->SIGKILL
26
+ * grace period; the default is right for production, and tests override it so the
27
+ * escalation path can be covered without a ten-second wait.
28
+ */
29
+ constructor(dir, timeoutMs, log = null, opts = {}) {
30
+ this.dir = dir
31
+ this.timeoutMs = timeoutMs
32
+ this.log = log
33
+ this.killGraceMs = opts.killGraceMs ?? KILL_GRACE_MS
34
+ }
35
+
36
+ /**
37
+ * Runs one command to completion.
38
+ *
39
+ * A non-zero exit is a RESULT, not an exception — callers turn it into a
40
+ * verdict. Only a command that could not be started at all throws.
41
+ *
42
+ * @param {string} command
43
+ * @param {string[]} args
44
+ * @param {{env?: NodeJS.ProcessEnv, cwd?: string, signal?: AbortSignal}} [opts]
45
+ * @returns {Promise<Result>}
46
+ */
47
+ run(command, args, opts = {}) {
48
+ return new Promise((resolve, reject) => {
49
+ const started = Date.now()
50
+ const child = spawn(command, args, {
51
+ cwd: opts.cwd ?? this.dir,
52
+ env: opts.env ?? process.env,
53
+ detached: true,
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ })
56
+
57
+ const stdout = new Capped(OUTPUT_CAP)
58
+ const stderr = new Capped(OUTPUT_CAP)
59
+ child.stdout.on('data', (chunk) => stdout.write(chunk))
60
+ child.stderr.on('data', (chunk) => stderr.write(chunk))
61
+
62
+ let timedOut = false
63
+ let killTimer = null
64
+ const timer = setTimeout(() => {
65
+ timedOut = true
66
+ killGroup(child.pid, 'SIGTERM')
67
+ // `??=` guards against the abort path having already scheduled the
68
+ // escalation (both a timeout and an abort can fire for the same
69
+ // child); reuse that timer rather than orphaning it.
70
+ killTimer ??= setTimeout(() => killGroup(child.pid, 'SIGKILL'), this.killGraceMs)
71
+ killTimer.unref()
72
+ }, this.timeoutMs)
73
+ timer.unref()
74
+
75
+ const onAbort = () => {
76
+ timedOut = true
77
+ killGroup(child.pid, 'SIGTERM')
78
+ // Escalate exactly as the timeout path does. Without this, a child
79
+ // that IGNORES SIGTERM survives the abort until the unrelated timeout
80
+ // timer happens to fire — measured at 12.9s with a 3s timeout, and up
81
+ // to the full 15m default in a real run. An abort is meant to be the
82
+ // fast path, so it must not depend on the slow one as its backstop.
83
+ killTimer ??= setTimeout(() => killGroup(child.pid, 'SIGKILL'), this.killGraceMs)
84
+ killTimer.unref()
85
+ }
86
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
87
+
88
+ const cleanup = () => {
89
+ clearTimeout(timer)
90
+ if (killTimer) clearTimeout(killTimer)
91
+ opts.signal?.removeEventListener('abort', onAbort)
92
+ }
93
+
94
+ child.on('error', (err) => {
95
+ cleanup()
96
+ reject(new Error(`run ${command} ${args.join(' ')}: ${err.message}`, { cause: err }))
97
+ })
98
+
99
+ child.on('close', (code, signal) => {
100
+ cleanup()
101
+ const result = new Result({
102
+ argv: [command, ...args],
103
+ stdout: stdout.text(),
104
+ stderr: stderr.text(),
105
+ // A process killed by a signal has a null exit code; report it as
106
+ // non-zero so ok() is false rather than accidentally true.
107
+ exitCode: code ?? (signal ? -1 : 0),
108
+ timedOut,
109
+ durationMs: Date.now() - started,
110
+ })
111
+ this.#writeLog(result)
112
+ resolve(result)
113
+ })
114
+ })
115
+ }
116
+
117
+ #writeLog(result) {
118
+ if (!this.log) return
119
+ this.log.write(
120
+ `\n$ ${result.argv.join(' ')}\n(dir=${this.dir} exit=${result.exitCode} ` +
121
+ `timedOut=${result.timedOut} took=${formatDuration(result.durationMs)})\n`,
122
+ )
123
+ this.log.write(result.stdout)
124
+ this.log.write(result.stderr)
125
+ }
126
+ }
127
+
128
+ /** The outcome of one subprocess. */
129
+ export class Result {
130
+ constructor(fields) {
131
+ Object.assign(this, fields)
132
+ }
133
+
134
+ /** Reports a clean, in-time run. */
135
+ ok() {
136
+ return this.exitCode === 0 && !this.timedOut
137
+ }
138
+
139
+ /**
140
+ * The last n lines of stderr, falling back to stdout when stderr is empty.
141
+ * Used to put an actionable excerpt in a FAIL message without flooding an
142
+ * unattended agent's context with the whole transcript.
143
+ */
144
+ tail(n) {
145
+ const source = this.stderr.trim() === '' ? this.stdout : this.stderr
146
+ const lines = source.replace(/\n+$/, '').split('\n')
147
+ return lines.slice(Math.max(0, lines.length - Math.max(0, n))).join('\n')
148
+ }
149
+ }
150
+
151
+ /** Retains at most `limit` bytes, then drops the rest and records that. */
152
+ class Capped {
153
+ constructor(limit) {
154
+ this.limit = limit
155
+ this.chunks = []
156
+ this.length = 0
157
+ this.truncated = false
158
+ }
159
+
160
+ write(chunk) {
161
+ const remaining = this.limit - this.length
162
+ if (remaining <= 0) {
163
+ this.truncated = true
164
+ return
165
+ }
166
+ if (chunk.length > remaining) {
167
+ this.chunks.push(chunk.subarray(0, remaining))
168
+ this.length = this.limit
169
+ this.truncated = true
170
+ return
171
+ }
172
+ this.chunks.push(chunk)
173
+ this.length += chunk.length
174
+ }
175
+
176
+ text() {
177
+ const text = Buffer.concat(this.chunks).toString('utf8')
178
+ return this.truncated ? `${text}\n[output truncated at 4MB]\n` : text
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Signals a child's whole process group. A negative pid addresses the group.
184
+ * Failures are swallowed: the group is already gone in the common case, and a
185
+ * teardown that throws would mask the real result being reported.
186
+ */
187
+ function killGroup(pid, signal) {
188
+ if (!pid) return
189
+ try {
190
+ process.kill(-pid, signal)
191
+ } catch {
192
+ try {
193
+ process.kill(pid, signal)
194
+ } catch {
195
+ // Already exited.
196
+ }
197
+ }
198
+ }
package/src/scope.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Decides which files an agent is allowed to modify.
3
+ *
4
+ * Patterns are globs ("src/**", "lib/**\/*.js") — the shape a JavaScript
5
+ * project already expects, and the only shape accepted. A leading "./" is
6
+ * tolerated; anything else is handed to picomatch verbatim, so a pattern
7
+ * this harness does not understand matches nothing rather than more.
8
+ */
9
+ import picomatch from 'picomatch'
10
+
11
+ /**
12
+ * Compiles scope patterns into a matcher over repository-relative paths.
13
+ *
14
+ * A blank or whitespace-only pattern is SKIPPED rather than treated as the
15
+ * repository root, so a stray empty entry in a config list matches nothing
16
+ * instead of silently granting root-level access.
17
+ *
18
+ * @param {string[]} patterns
19
+ * @returns {{ match(rel: string): boolean }}
20
+ */
21
+ export function createMatcher(patterns) {
22
+ if (patterns != null && !Array.isArray(patterns)) {
23
+ // A bare string would be iterated CHARACTER BY CHARACTER by the loop
24
+ // below, silently degrading "**" into two one-character patterns. A
25
+ // security gate must fail closed and loudly, not quietly narrow itself.
26
+ throw new TypeError(`scope patterns must be an array, got ${typeof patterns}`)
27
+ }
28
+
29
+ const compiled = []
30
+ for (const raw of patterns ?? []) {
31
+ if (typeof raw !== 'string') continue
32
+ const trimmed = raw.trim()
33
+ if (trimmed === '') continue
34
+ // dot is deliberately LEFT OFF. With `dot: true`, the default "**" would
35
+ // also match dot-files and dot-directories, so an agent scoped to the
36
+ // whole repository could write .npmrc (redirecting the package registry)
37
+ // or .github/workflows/*.yml (arbitrary CI execution) and have the change
38
+ // accepted. A user who genuinely wants one in scope names it
39
+ // explicitly — picomatch still matches a literal dot written in the
40
+ // pattern, so `scope: [".github/**"]` works while `**` does not reach it.
41
+ compiled.push(picomatch(normalisePattern(trimmed)))
42
+ }
43
+
44
+ return {
45
+ match(rel) {
46
+ const path = normalisePath(rel)
47
+ if (path === null) return false
48
+ return compiled.some((isMatch) => isMatch(path))
49
+ },
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Rewrites a pattern into the glob picomatch will see: normalises backslashes
55
+ * and strips a leading "./". Nothing else is rewritten — a pattern means what
56
+ * picomatch says it means, with no dialect of this project's own on top.
57
+ */
58
+ function normalisePattern(p) {
59
+ return p.replace(/\\/g, '/').replace(/^\.\//, '')
60
+ }
61
+
62
+ /**
63
+ * Normalises a candidate path, returning null for anything that can never be
64
+ * in scope no matter what the patterns say.
65
+ *
66
+ * The null cases have to be rejected EXPLICITLY, because they would otherwise
67
+ * be admitted: "**" matches every string handed to it, so the one pattern
68
+ * meaning "the whole repository" would also be the one meaning "anywhere on
69
+ * the disk". Nothing produces such a path today — callers pass the output of
70
+ * `git diff --name-only` and `git ls-files`, which are always root-relative —
71
+ * so this is the gate refusing to depend on that staying true.
72
+ */
73
+ function normalisePath(rel) {
74
+ if (typeof rel !== 'string' || rel === '') return null
75
+ const slashed = rel.replace(/\\/g, '/')
76
+ if (slashed.startsWith('/') || /^[A-Za-z]:\//.test(slashed)) return null
77
+
78
+ const parts = []
79
+ for (const part of slashed.split('/')) {
80
+ if (part === '' || part === '.') continue
81
+ if (part === '..') {
82
+ // A ".." that cannot be cancelled by a preceding segment climbs out of
83
+ // the repository root.
84
+ if (parts.length === 0) return null
85
+ parts.pop()
86
+ continue
87
+ }
88
+ parts.push(part)
89
+ }
90
+ if (parts.length === 0) return null
91
+ return parts.join('/')
92
+ }