@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Helpers shared by the gates and by src/doctor.js. */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { readdir } from 'node:fs/promises'
|
|
4
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves a package entry point from inside the MEASURED repository, so the
|
|
8
|
+
* repository's own installed tool versions are used — never this harness's.
|
|
9
|
+
* A repository pinned to an older TypeScript must be typechecked by that
|
|
10
|
+
* TypeScript, or the gate would report errors its own build never sees.
|
|
11
|
+
*
|
|
12
|
+
* @returns {string|null} an absolute path, or null when it does not resolve
|
|
13
|
+
*/
|
|
14
|
+
export function resolveFrom(dir, specifier) {
|
|
15
|
+
try {
|
|
16
|
+
// resolve() FIRST. createRequire throws on a relative path, which the
|
|
17
|
+
// catch below turns into null — indistinguishable from "the tool is not
|
|
18
|
+
// installed". That silent false negative already bit once: `doctor -C .`
|
|
19
|
+
// reported vitest missing in repositories that had it. Guarding here
|
|
20
|
+
// rather than at each call site means no future caller can hit it.
|
|
21
|
+
return createRequire(join(resolve(dir), 'noop.js')).resolve(specifier)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every source file under dir with one of the given extensions, repo-relative. */
|
|
28
|
+
export async function walkSources(dir, exts) {
|
|
29
|
+
const skipDirs = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', '.git'])
|
|
30
|
+
const out = []
|
|
31
|
+
async function visit(absolute) {
|
|
32
|
+
const entries = await readdir(absolute, { withFileTypes: true }).catch(() => [])
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const child = join(absolute, entry.name)
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (skipDirs.has(entry.name) || entry.name.startsWith('.')) continue
|
|
37
|
+
await visit(child)
|
|
38
|
+
} else if (entry.isFile() && exts.some((e) => entry.name.endsWith(e))) {
|
|
39
|
+
out.push(relative(dir, child).split(sep).join('/'))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await visit(dir)
|
|
44
|
+
return out.sort()
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stands in for the `vitest` module inside the standalone driver child.
|
|
3
|
+
*
|
|
4
|
+
* The real `bench` throws outside a Vitest runner, so a driver that wants to
|
|
5
|
+
* import a bench file for any purpose OTHER than timing — allocation
|
|
6
|
+
* sampling, CPU profiling — has to supply its own. Registrations land on a
|
|
7
|
+
* global the child reads back; a module-level array would be invisible across
|
|
8
|
+
* the loader boundary.
|
|
9
|
+
*/
|
|
10
|
+
const registry = () => {
|
|
11
|
+
globalThis.__a3sTasks ??= []
|
|
12
|
+
globalThis.__a3sSuite ??= []
|
|
13
|
+
return globalThis
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Registers one benchmark. `bench.skip` and `bench.only` register too — the
|
|
17
|
+
* driver measures allocation and CPU, where skipping is not meaningful. */
|
|
18
|
+
export function bench(name, fn) {
|
|
19
|
+
const g = registry()
|
|
20
|
+
g.__a3sTasks.push({ name: String(name), path: [...g.__a3sSuite, String(name)].join(' > '), fn })
|
|
21
|
+
}
|
|
22
|
+
bench.skip = bench
|
|
23
|
+
bench.only = bench
|
|
24
|
+
bench.todo = () => {}
|
|
25
|
+
|
|
26
|
+
/** Opens a naming scope, so a task's path matches how Vitest names it. */
|
|
27
|
+
export function describe(name, fn) {
|
|
28
|
+
const g = registry()
|
|
29
|
+
g.__a3sSuite.push(String(name))
|
|
30
|
+
try {
|
|
31
|
+
fn?.()
|
|
32
|
+
} finally {
|
|
33
|
+
g.__a3sSuite.pop()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
describe.skip = describe
|
|
37
|
+
describe.only = describe
|
|
38
|
+
|
|
39
|
+
export const suite = describe
|
|
40
|
+
|
|
41
|
+
/** No-ops, so a bench file that also imports these still loads. */
|
|
42
|
+
export const beforeAll = (fn) => fn
|
|
43
|
+
export const afterAll = () => {}
|
|
44
|
+
export const beforeEach = () => {}
|
|
45
|
+
export const afterEach = () => {}
|
|
46
|
+
export const test = () => {}
|
|
47
|
+
export const it = () => {}
|
|
48
|
+
export const expect = () => {
|
|
49
|
+
throw new Error('expect() is not available inside the autor3search driver')
|
|
50
|
+
}
|
|
51
|
+
export default { bench, describe, suite }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns Vitest's benchmark JSON report into a BenchSet.
|
|
3
|
+
*
|
|
4
|
+
* The format belongs to Vitest, not to us, so this module validates what it
|
|
5
|
+
* receives and fails LOUDLY on anything it does not recognise. A silently
|
|
6
|
+
* changed reporter shape that yielded an empty set would surface downstream
|
|
7
|
+
* as "no benchmarks matched the pattern" — which reads like a user
|
|
8
|
+
* configuration error and is not one. See test/fixtures/README.md, which
|
|
9
|
+
* records the real, captured shape (confirmed against Vitest 2.1.9):
|
|
10
|
+
*
|
|
11
|
+
* { files: [ { filepath, groups: [ { fullName, benchmarks: [
|
|
12
|
+
* { name, median, mean, hz, samples, ... }, ...
|
|
13
|
+
* ] }, ... ] }, ... ] }
|
|
14
|
+
*
|
|
15
|
+
* That shape is captured with `vitest bench --run --outputJson=<file>` — NOT
|
|
16
|
+
* the generic `--reporter=json --outputFile=<file>`, which fails because
|
|
17
|
+
* `bench` has no built-in reporter registered under the name "json"; see
|
|
18
|
+
* test/fixtures/README.md for the discrepancy.
|
|
19
|
+
*
|
|
20
|
+
* One parse contributes ONE observation per benchmark: the median Vitest
|
|
21
|
+
* reports for that task. Tinybench computes it over the samples taken inside
|
|
22
|
+
* a single process invocation, which is exactly the per-round statistic the
|
|
23
|
+
* scoring core needs — see the spec's section 3.2 for why the individual
|
|
24
|
+
* samples must never reach the significance test. There is deliberately no
|
|
25
|
+
* fallback to `mean` or to a median computed over `samples` if `median` is
|
|
26
|
+
* absent — see `timingMs` below for why.
|
|
27
|
+
*/
|
|
28
|
+
import { BenchSet, UNIT_TIME } from './set.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Vitest reports task timings (min/max/mean/median/period/pNN) in
|
|
32
|
+
* milliseconds; the scoring core uses seconds.
|
|
33
|
+
*/
|
|
34
|
+
const MS_TO_SEC = 1e-3
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object|string} payload the reporter's JSON, parsed or raw
|
|
38
|
+
* @returns {BenchSet}
|
|
39
|
+
*/
|
|
40
|
+
export function parseVitestBench(payload) {
|
|
41
|
+
const report = typeof payload === 'string' ? parseJson(payload) : payload
|
|
42
|
+
|
|
43
|
+
const files = report?.files
|
|
44
|
+
if (!Array.isArray(files)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`unrecognised Vitest benchmark report: expected an object with a "files" array, got ` +
|
|
47
|
+
`${excerpt(report)}. Re-capture test/fixtures/vitest-bench.json and update src/bench/parse.js.`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const set = new BenchSet()
|
|
52
|
+
let found = 0
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
for (const group of file?.groups ?? []) {
|
|
55
|
+
for (const task of group?.benchmarks ?? []) {
|
|
56
|
+
const name = task?.name
|
|
57
|
+
if (typeof name !== 'string') {
|
|
58
|
+
throw new Error(`unrecognised Vitest benchmark entry, missing a string name: ${excerpt(task)}`)
|
|
59
|
+
}
|
|
60
|
+
const ms = timingMs(task)
|
|
61
|
+
if (ms === null) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`benchmark ${JSON.stringify(name)} reported no "median", which is the only timing this ` +
|
|
64
|
+
`parser accepts: ${excerpt(task)}`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
set.record(taskPath(file, group, name), name, UNIT_TIME, ms * MS_TO_SEC)
|
|
68
|
+
found++
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (found === 0) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'the Vitest benchmark report contained no benchmarks — check that the bench files declare ' +
|
|
76
|
+
'bench() tasks and that the name filter is not excluding all of them',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return set
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The task's timing in milliseconds: tinybench's own reported median.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately no fallback. A median-over-`samples` fallback was tried and
|
|
86
|
+
* removed: Vitest 2.1.9's --outputJson writer hardcodes `samples: []`
|
|
87
|
+
* regardless of `benchmark.includeSamples`, so that branch was provably dead
|
|
88
|
+
* code. A mean fallback was removed for a different reason — the mean is a
|
|
89
|
+
* DIFFERENT estimator, outlier-sensitive in exactly the way benchmark timings
|
|
90
|
+
* punish, so silently substituting it would change what the score means
|
|
91
|
+
* without saying so. If a future Vitest stops reporting a median, this throws
|
|
92
|
+
* and names the benchmark, which is the honest failure.
|
|
93
|
+
*/
|
|
94
|
+
function timingMs(task) {
|
|
95
|
+
return Number.isFinite(task?.median) ? task.median : null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The full task path, matching how Vitest names a benchmark: the group's
|
|
100
|
+
* fullName (which already includes the file and any enclosing describes),
|
|
101
|
+
* then the leaf name.
|
|
102
|
+
*/
|
|
103
|
+
function taskPath(file, group, name) {
|
|
104
|
+
const prefix = group?.fullName ?? file?.filepath ?? ''
|
|
105
|
+
return prefix === '' ? name : `${prefix} > ${name}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseJson(text) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(`parse Vitest benchmark report: ${err.message}: ${excerpt(text)}`, { cause: err })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A short, safe excerpt of an unexpected payload, for the error message. */
|
|
117
|
+
function excerpt(value) {
|
|
118
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
|
119
|
+
return (text ?? String(value)).slice(0, 300)
|
|
120
|
+
}
|
package/src/bench/set.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The container for benchmark measurements: every observation of every unit,
|
|
3
|
+
* for every benchmark, in the order it was observed.
|
|
4
|
+
*
|
|
5
|
+
* One measured ROUND contributes exactly one observation per benchmark per
|
|
6
|
+
* unit. See src/measure.js and the spec's section 3.2 for why the
|
|
7
|
+
* per-iteration samples inside a round are never recorded here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The scored unit: seconds per operation. */
|
|
11
|
+
export const UNIT_TIME = 'sec/op'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The hint unit: approximate bytes allocated per operation. Never scored,
|
|
15
|
+
* and never able to trip the regression guard. See src/adapters/driver.js.
|
|
16
|
+
*/
|
|
17
|
+
export const UNIT_BYTES = 'bytes/op'
|
|
18
|
+
|
|
19
|
+
export class BenchSet {
|
|
20
|
+
constructor() {
|
|
21
|
+
/** @type {Map<string, {name: string, base: string, metrics: Map<string, {unit: string, values: number[]}>}>} */
|
|
22
|
+
this.series = new Map()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Records one observation.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} name full task path, e.g. "x.bench.js > parse > big"
|
|
29
|
+
* @param {string} base leaf benchmark name, what config.benchmarks selects on
|
|
30
|
+
* @param {string} unit
|
|
31
|
+
* @param {number} value
|
|
32
|
+
*/
|
|
33
|
+
record(name, base, unit, value) {
|
|
34
|
+
let ser = this.series.get(name)
|
|
35
|
+
if (!ser) {
|
|
36
|
+
ser = { name, base, metrics: new Map() }
|
|
37
|
+
this.series.set(name, ser)
|
|
38
|
+
}
|
|
39
|
+
let metric = ser.metrics.get(unit)
|
|
40
|
+
if (!metric) {
|
|
41
|
+
metric = { unit, values: [] }
|
|
42
|
+
ser.metrics.set(unit, metric)
|
|
43
|
+
}
|
|
44
|
+
metric.values.push(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {string[]} every benchmark name, sorted. */
|
|
48
|
+
names() {
|
|
49
|
+
return [...this.series.keys()].sort()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @returns {string[]} every base name, sorted and deduplicated. */
|
|
53
|
+
bases() {
|
|
54
|
+
return [...new Set([...this.series.values()].map((s) => s.base))].sort()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @returns {number[] | null} a defensive copy of the observations, so a
|
|
59
|
+
* caller that sorts or mutates the result cannot corrupt the stored data.
|
|
60
|
+
*/
|
|
61
|
+
values(name, unit) {
|
|
62
|
+
const metric = this.series.get(name)?.metrics.get(unit)
|
|
63
|
+
return metric ? [...metric.values] : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Answers the question `values` is usually asked only to answer, without
|
|
68
|
+
* its defensive copy.
|
|
69
|
+
*/
|
|
70
|
+
has(name, unit) {
|
|
71
|
+
return this.series.get(name)?.metrics.has(unit) ?? false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Appends every observation in other into this set, preserving order. */
|
|
75
|
+
add(other) {
|
|
76
|
+
for (const name of other.names()) {
|
|
77
|
+
const ser = other.series.get(name)
|
|
78
|
+
for (const [unit, metric] of ser.metrics) {
|
|
79
|
+
for (const value of metric.values) this.record(name, ser.base, unit, value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns a new, independent set holding the subset whose base names appear
|
|
86
|
+
* in `bases`. An empty list selects everything. Selecting on the base is
|
|
87
|
+
* what lets a config say "parse" and still match "parse > big".
|
|
88
|
+
*/
|
|
89
|
+
selectByBase(bases) {
|
|
90
|
+
const want = bases.length === 0 ? null : new Set(bases)
|
|
91
|
+
const out = new BenchSet()
|
|
92
|
+
for (const name of this.names()) {
|
|
93
|
+
const ser = this.series.get(name)
|
|
94
|
+
if (want && !want.has(ser.base)) continue
|
|
95
|
+
for (const [unit, metric] of ser.metrics) {
|
|
96
|
+
for (const value of metric.values) out.record(name, ser.base, unit, value)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
}
|