@vzn/vx 0.0.370 → 0.0.371
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/package.json +5 -5
- package/src/cache/cache.ts +27 -11
- package/src/cache/schema.ts +11 -5
- package/src/cli/select.ts +9 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/prepare.ts +4 -1
- package/src/orchestrator/projects.ts +15 -0
- package/src/orchestrator/run.ts +16 -1
- package/src/workspace/affected.ts +8 -4
- package/src/workspace/config-eval.ts +9 -1
- package/src/workspace/project-loader.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.371",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.371",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.371",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.371",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.371"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -223,7 +223,16 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
223
223
|
// the last entry's bytes on disk under a green run (item 886). A row
|
|
224
224
|
// is current only with the inode and ctime recorded after the save
|
|
225
225
|
// or restore that wrote it. The cache KEY is unchanged.
|
|
226
|
-
|
|
226
|
+
// v29: entries.stdout moved to entry_stdout. SQLite rewrites a whole
|
|
227
|
+
// record on UPDATE, so the run-end `accessed_at` bump rewrote each
|
|
228
|
+
// hit's stored stdout (up to 16 MB) with its overflow pages: 200
|
|
229
|
+
// hits of 1 MB cost the close 125-150 ms. The cache KEY is
|
|
230
|
+
// unchanged.
|
|
231
|
+
export const SCHEMA_VERSION = 'v29'
|
|
232
|
+
|
|
233
|
+
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
234
|
+
const SELECT_ENTRY =
|
|
235
|
+
"SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
|
|
227
236
|
|
|
228
237
|
/** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
|
|
229
238
|
function schemaOrdinal(version: string): number {
|
|
@@ -439,6 +448,8 @@ export class Cache implements CacheLayer {
|
|
|
439
448
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
440
449
|
private readonly deleteEntryRow: ReturnType<Database['prepare']>
|
|
441
450
|
private readonly selectEntry: ReturnType<Database['prepare']>
|
|
451
|
+
private readonly upsertStdout: ReturnType<Database['prepare']>
|
|
452
|
+
private readonly deleteStdout: ReturnType<Database['prepare']>
|
|
442
453
|
private readonly entryExists: ReturnType<Database['prepare']>
|
|
443
454
|
private readonly bumpAccessed: ReturnType<Database['prepare']>
|
|
444
455
|
private readonly touched = new Set<string>()
|
|
@@ -675,7 +686,7 @@ export class Cache implements CacheLayer {
|
|
|
675
686
|
}
|
|
676
687
|
if (found === SCHEMA_VERSION) return null
|
|
677
688
|
this.db.exec(
|
|
678
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
|
|
689
|
+
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
|
|
679
690
|
)
|
|
680
691
|
this.db
|
|
681
692
|
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
@@ -697,10 +708,9 @@ export class Cache implements CacheLayer {
|
|
|
697
708
|
|
|
698
709
|
this.deleteEntryRow = this.db.prepare('DELETE FROM entries WHERE hash = ?')
|
|
699
710
|
this.insertEntry = this.db.prepare(`
|
|
700
|
-
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes,
|
|
701
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
711
|
+
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes)
|
|
712
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
702
713
|
ON CONFLICT(hash) DO UPDATE SET
|
|
703
|
-
stdout = excluded.stdout,
|
|
704
714
|
project = excluded.project,
|
|
705
715
|
task = excluded.task,
|
|
706
716
|
command = excluded.command,
|
|
@@ -711,12 +721,15 @@ export class Cache implements CacheLayer {
|
|
|
711
721
|
cpu_ms = excluded.cpu_ms,
|
|
712
722
|
peak_rss_bytes = excluded.peak_rss_bytes
|
|
713
723
|
`)
|
|
714
|
-
this.
|
|
715
|
-
|
|
716
|
-
|
|
724
|
+
this.upsertStdout = this.db.prepare(
|
|
725
|
+
'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
|
|
726
|
+
)
|
|
727
|
+
this.deleteStdout = this.db.prepare('DELETE FROM entry_stdout WHERE hash = ?')
|
|
728
|
+
this.selectEntry = this.db.prepare(`${SELECT_ENTRY} WHERE e.hash = ?`)
|
|
729
|
+
// `has` asks only whether the row is there, with no stdout joined. A
|
|
717
730
|
// column off the index, so the table row is still read and a corrupt
|
|
718
731
|
// table refuses here as it does on `get` (`SELECT 1` answers from the
|
|
719
|
-
// index alone)
|
|
732
|
+
// index alone).
|
|
720
733
|
this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
|
|
721
734
|
this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
|
|
722
735
|
// INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
|
|
@@ -961,7 +974,7 @@ export class Cache implements CacheLayer {
|
|
|
961
974
|
const placeholders = chunk.map(() => '?').join(',')
|
|
962
975
|
rows.push(
|
|
963
976
|
...(this.db
|
|
964
|
-
.query(
|
|
977
|
+
.query(`${SELECT_ENTRY} WHERE e.hash IN (${placeholders})`)
|
|
965
978
|
.all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
|
|
966
979
|
)
|
|
967
980
|
}
|
|
@@ -1580,6 +1593,8 @@ export class Cache implements CacheLayer {
|
|
|
1580
1593
|
// (not the per-run path) so a warm all-cache-hit run — which never
|
|
1581
1594
|
// saves — writes none of them.
|
|
1582
1595
|
const insertEntry = this.insertEntry
|
|
1596
|
+
const upsertStdout = this.upsertStdout
|
|
1597
|
+
const deleteStdout = this.deleteStdout
|
|
1583
1598
|
const outputs = this.outputs
|
|
1584
1599
|
const insertEntryInput = this.insertEntryInput
|
|
1585
1600
|
const inputComponents = meta.inputComponents
|
|
@@ -1629,7 +1644,6 @@ export class Cache implements CacheLayer {
|
|
|
1629
1644
|
0,
|
|
1630
1645
|
meta.durationMs,
|
|
1631
1646
|
totalBytes,
|
|
1632
|
-
stdoutText,
|
|
1633
1647
|
now,
|
|
1634
1648
|
now,
|
|
1635
1649
|
// From the artifact, on both paths: a save indexes what it just
|
|
@@ -1637,6 +1651,8 @@ export class Cache implements CacheLayer {
|
|
|
1637
1651
|
scanned.exec?.cpuMs ?? null,
|
|
1638
1652
|
scanned.exec?.peakRssBytes ?? null,
|
|
1639
1653
|
)
|
|
1654
|
+
if (stdoutText === '') deleteStdout.run(hash)
|
|
1655
|
+
else upsertStdout.run(hash, stdoutText)
|
|
1640
1656
|
outputs.replaceFileRows(hash, outputFileRows)
|
|
1641
1657
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
1642
1658
|
// re-save's rows are identical — keep the first set, skip the rest.
|
package/src/cache/schema.ts
CHANGED
|
@@ -18,10 +18,8 @@ export function createTables(db: Database): void {
|
|
|
18
18
|
`)
|
|
19
19
|
|
|
20
20
|
db.exec(`
|
|
21
|
-
-- The queryable index: command, exit_code, duration, size
|
|
22
|
-
-- timestamps
|
|
23
|
-
-- carries so it survives a remote round trip; a local hit
|
|
24
|
-
-- replays it from here.
|
|
21
|
+
-- The queryable index: command, exit_code, duration, size and
|
|
22
|
+
-- timestamps; stdout in entry_stdout below.
|
|
25
23
|
CREATE TABLE IF NOT EXISTS entries (
|
|
26
24
|
hash TEXT PRIMARY KEY,
|
|
27
25
|
project TEXT NOT NULL,
|
|
@@ -30,13 +28,21 @@ export function createTables(db: Database): void {
|
|
|
30
28
|
exit_code INTEGER NOT NULL,
|
|
31
29
|
duration_ms INTEGER NOT NULL,
|
|
32
30
|
size_bytes INTEGER NOT NULL,
|
|
33
|
-
stdout TEXT NOT NULL DEFAULT '',
|
|
34
31
|
created_at INTEGER NOT NULL,
|
|
35
32
|
accessed_at INTEGER NOT NULL,
|
|
36
33
|
-- v26: the producing execution's usage, from the artifact's sidecar.
|
|
37
34
|
cpu_ms INTEGER,
|
|
38
35
|
peak_rss_bytes INTEGER
|
|
39
36
|
);
|
|
37
|
+
-- v29: an entry's stdout, which the <hash>.tar.zst artifact also
|
|
38
|
+
-- carries so it survives a remote round trip; a local hit replays it
|
|
39
|
+
-- from here. Its own table: an UPDATE rewrites a whole record, and the
|
|
40
|
+
-- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
|
|
41
|
+
CREATE TABLE IF NOT EXISTS entry_stdout (
|
|
42
|
+
hash TEXT PRIMARY KEY,
|
|
43
|
+
stdout TEXT NOT NULL,
|
|
44
|
+
FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
45
|
+
);
|
|
40
46
|
CREATE TABLE IF NOT EXISTS runs (
|
|
41
47
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
48
|
-- '' when the outcome derived no cache key (skipped / persistent).
|
package/src/cli/select.ts
CHANGED
|
@@ -26,7 +26,12 @@ import type { ProjectConfig } from '../config.js'
|
|
|
26
26
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
27
27
|
import { parseDependencySpec } from '../graph/index.js'
|
|
28
28
|
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
claimedAffected,
|
|
31
|
+
fingerprintClaims,
|
|
32
|
+
gitOfDiscovery,
|
|
33
|
+
keepDiscoveryGraph,
|
|
34
|
+
} from '../orchestrator/index.js'
|
|
30
35
|
import {
|
|
31
36
|
type CliLoadOptions,
|
|
32
37
|
discoverCliProjects,
|
|
@@ -251,6 +256,9 @@ export async function resolveFilters(
|
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
const graph = buildPackageGraph(projects, edges)
|
|
259
|
+
// The graph a run reusing this discovery builds is this one when no task
|
|
260
|
+
// edge went into it.
|
|
261
|
+
if (edges === undefined) keepDiscoveryGraph(projects, graph)
|
|
254
262
|
|
|
255
263
|
// Resolve every `[<since>]` filter against git before the pure
|
|
256
264
|
// applyFilters pass runs. One spawn per distinct ref — usually
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import {
|
|
58
58
|
discoverProjects,
|
|
59
59
|
gitOfDiscovery,
|
|
60
|
+
graphOfDiscovery,
|
|
60
61
|
loadProjects,
|
|
61
62
|
loadWorkspacePlugins,
|
|
62
63
|
type LoadedProjects,
|
|
@@ -223,7 +224,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
223
224
|
// those configs are evaluated. Side effect, deliberate and
|
|
224
225
|
// Turbo-like: a broken config in an unrelated package no longer
|
|
225
226
|
// fails a scoped run — it surfaces when that package enters scope.
|
|
226
|
-
const packageGraph =
|
|
227
|
+
const packageGraph =
|
|
228
|
+
(reused !== undefined ? graphOfDiscovery(reused.projects) : undefined) ??
|
|
229
|
+
buildPackageGraph(projectMetas)
|
|
227
230
|
// Seeds: explicit scope, plus anchored pkg#task targets (which bypass
|
|
228
231
|
// scope by design). With no explicit scope, bare task names fan out
|
|
229
232
|
// across the whole workspace — but when EVERY spec is anchored, the
|
|
@@ -67,6 +67,21 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
|
|
|
67
67
|
return discoveredGit.get(projects)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The package graph a selection pass built over a discovery, by that
|
|
72
|
+
* discovery's array, when it folded no task edge in: the graph a run that
|
|
73
|
+
* reuses the discovery (`RunOptions.discovered`) would build again.
|
|
74
|
+
*/
|
|
75
|
+
const discoveredGraph = new WeakMap<readonly ProjectMeta[], PackageGraph>()
|
|
76
|
+
|
|
77
|
+
export function graphOfDiscovery(projects: readonly ProjectMeta[]): PackageGraph | undefined {
|
|
78
|
+
return discoveredGraph.get(projects)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: PackageGraph): void {
|
|
82
|
+
discoveredGraph.set(projects, graph)
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/**
|
|
71
86
|
* The workspace's projects: core's discovery, then the plugin `discover`
|
|
72
87
|
* stage. With no plugin declaring it, `listProjects` alone.
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -544,7 +544,21 @@ async function runOnBus(
|
|
|
544
544
|
// (`signals.ts`); the handlers are removed in the finally below so
|
|
545
545
|
// repeated run() calls (test suites) never stack listeners.
|
|
546
546
|
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
547
|
-
|
|
547
|
+
// A server that ends on its own while the graph still runs is said at
|
|
548
|
+
// once: its dependants were failing against it, and the end of the run
|
|
549
|
+
// was the first word of it (C-69). Not once the graph is done (the end
|
|
550
|
+
// of the run and the keep-alive wait say it) nor under a stop.
|
|
551
|
+
let graphDone = false
|
|
552
|
+
const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
|
|
553
|
+
override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
|
|
554
|
+
super.set(id, child)
|
|
555
|
+
void child.exited.then((code) => {
|
|
556
|
+
if (code !== 0 && !graphDone && !stopRun.signal.aborted)
|
|
557
|
+
log.status(`vx: ${id} exited with code ${code} while the run went on`)
|
|
558
|
+
})
|
|
559
|
+
return this
|
|
560
|
+
}
|
|
561
|
+
})()
|
|
548
562
|
// One stop for the run: an embedder's `RunOptions.signal` and a process
|
|
549
563
|
// signal both abort it. The scheduler stops dispatching (it reads the
|
|
550
564
|
// signal), the children are torn down, and run() returns through its own
|
|
@@ -951,6 +965,7 @@ async function runOnBus(
|
|
|
951
965
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
952
966
|
restoreTier: shortCircuit.restoreTier,
|
|
953
967
|
})
|
|
968
|
+
graphDone = true
|
|
954
969
|
|
|
955
970
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
956
971
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -125,8 +125,11 @@ export interface FingerprintClaims {
|
|
|
125
125
|
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
126
126
|
// Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
|
|
127
127
|
// vx already diffs from the merge base (three dots' meaning), and the
|
|
128
|
-
// working tree it diffs to holds HEAD (D-117).
|
|
129
|
-
|
|
128
|
+
// working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
|
|
129
|
+
// diffs from A itself (G-145). Any other range is refused.
|
|
130
|
+
const headRange = /^(.+?)(\.{2,3})HEAD$/s.exec(args.since)
|
|
131
|
+
const since = headRange?.[1] ?? args.since
|
|
132
|
+
const fromMergeBase = headRange?.[2] !== '..'
|
|
130
133
|
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
131
134
|
// is opaque — but an option-like value is not: `--output=<path>` is a
|
|
132
135
|
// real `git diff` option and an arbitrary file write. This is a security
|
|
@@ -155,8 +158,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
155
158
|
// CI `--affected`), and hides your own edit when the base later landed
|
|
156
159
|
// byte-identical content. Turbo and Nx both diff from the merge base;
|
|
157
160
|
// when there is none (unrelated histories, a detached probe) the ref
|
|
158
|
-
// itself is the base, as before.
|
|
159
|
-
|
|
161
|
+
// itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
|
|
162
|
+
// one form that diffs from A itself.
|
|
163
|
+
const base = fromMergeBase ? await mergeBase(args.workspaceRoot, since) : since
|
|
160
164
|
|
|
161
165
|
const [diffed, untracked] = await Promise.all([
|
|
162
166
|
// `--no-renames` is crucial for project-affected detection: with
|
|
@@ -139,6 +139,7 @@ self.onmessage = async (e) => {
|
|
|
139
139
|
ok: true,
|
|
140
140
|
nonJson,
|
|
141
141
|
json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
|
|
142
|
+
fn: typeof mod === 'function',
|
|
142
143
|
changed: changed(),
|
|
143
144
|
})
|
|
144
145
|
} catch (err) {
|
|
@@ -158,12 +159,16 @@ self.onmessage = async (e) => {
|
|
|
158
159
|
}
|
|
159
160
|
`
|
|
160
161
|
|
|
162
|
+
const FUNCTION_EXPORT = (): void => {}
|
|
163
|
+
|
|
161
164
|
const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
|
|
162
165
|
|
|
163
166
|
interface WorkerReply {
|
|
164
167
|
id: number
|
|
165
168
|
ok: boolean
|
|
166
169
|
json: string | null
|
|
170
|
+
/** The default export is a function, which JSON cannot carry back. */
|
|
171
|
+
fn?: boolean
|
|
167
172
|
nonJson: NonJsonValue[]
|
|
168
173
|
name: string
|
|
169
174
|
message: string
|
|
@@ -327,7 +332,10 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
327
332
|
})
|
|
328
333
|
const [nonJson] = reply.nonJson
|
|
329
334
|
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
330
|
-
|
|
335
|
+
// A function stands in for the one the config exported, so the caller's
|
|
336
|
+
// refusal names it as the in-process load's does.
|
|
337
|
+
if (reply.json === null) return reply.fn === true ? FUNCTION_EXPORT : null
|
|
338
|
+
return JSON.parse(reply.json) as unknown
|
|
331
339
|
} finally {
|
|
332
340
|
// In the `finally`, not after the await: a REJECTED evaluation — a config
|
|
333
341
|
// with a typo, the common case while editing — would otherwise skip the
|
|
@@ -37,12 +37,21 @@ export const WORKSPACE_CONFIG_FILENAMES = [
|
|
|
37
37
|
'vx.workspace.cjs',
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
+
/** What a function default export is told; the playground says the same. */
|
|
41
|
+
const EXPORTED_A_FUNCTION =
|
|
42
|
+
'it exports a function, and vx reads the object itself — export what the function returns'
|
|
43
|
+
|
|
40
44
|
/** Project configs already loaded in this process, by absolute path. */
|
|
41
45
|
const loadedConfigs = new Set<string>()
|
|
42
46
|
|
|
43
47
|
function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
|
|
44
48
|
if (!mod || typeof mod !== 'object') {
|
|
45
|
-
|
|
49
|
+
// Vite's `defineConfig(() => ({ … }))` shape: vx reads the object, and
|
|
50
|
+
// the bare refusal did not say what was there instead (D-110).
|
|
51
|
+
throw new UserError(
|
|
52
|
+
`${kind} config at ${configPath} did not export a default object` +
|
|
53
|
+
(typeof mod === 'function' ? `: ${EXPORTED_A_FUNCTION}` : ''),
|
|
54
|
+
)
|
|
46
55
|
}
|
|
47
56
|
}
|
|
48
57
|
|