@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.
- package/LICENSE +21 -0
- package/README.md +500 -0
- package/bin/autor3search-javascript.js +4 -0
- package/package.json +50 -0
- package/src/adapters/bench/index.js +31 -0
- package/src/adapters/bench/vitest.js +128 -0
- package/src/adapters/driver-child.js +72 -0
- package/src/adapters/driver-hooks.js +18 -0
- package/src/adapters/driver.js +192 -0
- package/src/adapters/gates/index.js +64 -0
- package/src/adapters/gates/lint.js +46 -0
- package/src/adapters/gates/test.js +27 -0
- package/src/adapters/gates/typecheck.js +98 -0
- package/src/adapters/gates/util.js +45 -0
- package/src/adapters/vitest-shim.js +51 -0
- package/src/bench/parse.js +120 -0
- package/src/bench/set.js +101 -0
- package/src/bench/stats.js +443 -0
- package/src/cli/cmd-baseline.js +136 -0
- package/src/cli/cmd-doctor.js +38 -0
- package/src/cli/cmd-eval.js +231 -0
- package/src/cli/cmd-init.js +136 -0
- package/src/cli/cmd-profile.js +38 -0
- package/src/cli/cmd-report.js +96 -0
- package/src/cli/cmd-status.js +68 -0
- package/src/cli/cmd-stop.js +98 -0
- package/src/cli/cmd-version.js +31 -0
- package/src/cli/context.js +98 -0
- package/src/cli/main.js +62 -0
- package/src/config.js +209 -0
- package/src/discover.js +239 -0
- package/src/doctor.js +282 -0
- package/src/duration.js +75 -0
- package/src/freeze.js +234 -0
- package/src/gitx.js +115 -0
- package/src/measure.js +127 -0
- package/src/pipeline.js +391 -0
- package/src/profile.js +100 -0
- package/src/results.js +124 -0
- package/src/runner.js +198 -0
- package/src/scope.js +92 -0
- package/src/state/index.js +312 -0
- package/src/state/lock.js +189 -0
- package/src/state/stop.js +56 -0
- package/src/verdict.js +214 -0
- 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
|
+
}
|