@vzn/vx 0.0.29 → 0.0.31
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/cli/run.ts +7 -0
- package/src/cli/select.ts +33 -6
- package/src/orchestrator/options.ts +9 -0
- package/src/orchestrator/prepare.ts +41 -3
- package/src/orchestrator/run.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.31",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.31",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.31",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.31"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/run.ts
CHANGED
|
@@ -461,6 +461,7 @@ export async function resolveRunOptions(
|
|
|
461
461
|
const anchoredTasks = tasks.filter((t) => t.includes('#'))
|
|
462
462
|
let projects: string[] | undefined
|
|
463
463
|
let staged: RunOptions['staged']
|
|
464
|
+
let selectedByDiff = false
|
|
464
465
|
if (bareTasks.length === 0) {
|
|
465
466
|
projects = undefined
|
|
466
467
|
} else if (filterStrings.length > 0) {
|
|
@@ -480,6 +481,7 @@ export async function resolveRunOptions(
|
|
|
480
481
|
projects = []
|
|
481
482
|
} else {
|
|
482
483
|
projects = resolved.names
|
|
484
|
+
selectedByDiff = resolved.byDiff
|
|
483
485
|
// The graph walk staged every config; the run reuses those entries
|
|
484
486
|
// instead of evaluating and staging them a second time.
|
|
485
487
|
staged = resolved.staged
|
|
@@ -515,6 +517,7 @@ export async function resolveRunOptions(
|
|
|
515
517
|
opts.excludeDependencies = parsed.excludeDependencies
|
|
516
518
|
}
|
|
517
519
|
if (projects !== undefined) opts.projects = projects
|
|
520
|
+
if (selectedByDiff) opts.selectedByDiff = true
|
|
518
521
|
if (staged !== undefined) opts.staged = staged
|
|
519
522
|
if (parsed.retries !== undefined) opts.retries = parsed.retries
|
|
520
523
|
if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
|
|
@@ -582,6 +585,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
582
585
|
return 1
|
|
583
586
|
}
|
|
584
587
|
if (plan.tasks.length === 0) {
|
|
588
|
+
if (opts.selectedByDiff === true) {
|
|
589
|
+
process.stderr.write(`vx run: no affected project declares task(s): ${tasks.join(', ')}.\n`)
|
|
590
|
+
return 0
|
|
591
|
+
}
|
|
585
592
|
process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
|
|
586
593
|
return 1
|
|
587
594
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import readline from 'node:readline/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import fs from 'node:fs'
|
|
8
9
|
import {
|
|
9
10
|
affectedProjects,
|
|
10
11
|
refIsHead,
|
|
@@ -115,18 +116,40 @@ async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
|
|
|
115
116
|
export async function findCwdProject(cwd: string): Promise<string | null> {
|
|
116
117
|
const projects = await loadWorkspaceProjects(cwd)
|
|
117
118
|
const abs = path.resolve(cwd)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
const within = (dirOf: (p: ProjectMeta) => string): string | null => {
|
|
120
|
+
let best: string | null = null
|
|
121
|
+
let bestLength = -1
|
|
122
|
+
for (const p of projects) {
|
|
123
|
+
const dir = dirOf(p)
|
|
124
|
+
if ((abs === dir || abs.startsWith(dir + path.sep)) && dir.length > bestLength) {
|
|
125
|
+
best = p.name
|
|
126
|
+
bestLength = dir.length
|
|
127
|
+
}
|
|
122
128
|
}
|
|
129
|
+
return best
|
|
123
130
|
}
|
|
124
|
-
|
|
131
|
+
// The cwd is a real path (the kernel's), and a member discovery reached
|
|
132
|
+
// through a link keeps the link's (`packages/b -> ../ext/b`, item 987),
|
|
133
|
+
// so inside such a member nothing matched and a run from there was "not
|
|
134
|
+
// inside a project" (item 1025). Only then are the members realpathed:
|
|
135
|
+
// one syscall each, paid only by a run no plain match could place.
|
|
136
|
+
return (
|
|
137
|
+
within((p) => p.dir) ??
|
|
138
|
+
within((p) => {
|
|
139
|
+
try {
|
|
140
|
+
return fs.realpathSync(p.dir)
|
|
141
|
+
} catch {
|
|
142
|
+
return p.dir
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
)
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
export type FilterResolution =
|
|
128
149
|
| {
|
|
129
150
|
names: string[]
|
|
151
|
+
/** An include filter was a git diff (`[ref]`, `--affected`): `RunOptions.selectedByDiff`. */
|
|
152
|
+
byDiff: boolean
|
|
130
153
|
/** The staged load the graph walk needed, for the run to reuse (`RunOptions.staged`). */
|
|
131
154
|
staged?: ReadonlyMap<string, ProjectEntry>
|
|
132
155
|
}
|
|
@@ -269,7 +292,11 @@ export async function resolveFilters(
|
|
|
269
292
|
staged = undefined
|
|
270
293
|
}
|
|
271
294
|
}
|
|
272
|
-
return {
|
|
295
|
+
return {
|
|
296
|
+
names: [...selected].sort(),
|
|
297
|
+
byDiff: parsed.some((f) => !f.negate && f.gitSince !== undefined),
|
|
298
|
+
...(staged !== undefined ? { staged } : {}),
|
|
299
|
+
}
|
|
273
300
|
}
|
|
274
301
|
|
|
275
302
|
export interface PickedTask {
|
|
@@ -20,6 +20,15 @@ export interface RunOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
tasks: readonly string[]
|
|
22
22
|
projects?: string[]
|
|
23
|
+
/**
|
|
24
|
+
* `projects` came from a git diff (`--affected`, a `[ref]` filter), so
|
|
25
|
+
* which projects hold a bare task name depends on what changed: a name
|
|
26
|
+
* the changed projects do not declare is not a typo when the workspace
|
|
27
|
+
* declares it elsewhere, and a run with nothing left to do is clean.
|
|
28
|
+
* Judged against the scope, a docs-only commit turned
|
|
29
|
+
* `vx run test --affected` red (item 1024).
|
|
30
|
+
*/
|
|
31
|
+
selectedByDiff?: boolean
|
|
23
32
|
/**
|
|
24
33
|
* Configs a selection pass in this same process already loaded and
|
|
25
34
|
* staged with the same `cacheDir` and `frozen` (the CLI walks every
|
|
@@ -131,11 +131,14 @@ export interface PreparedRun {
|
|
|
131
131
|
* user's task names were resolved against `projects`. Typically
|
|
132
132
|
* a typo'd task name; `run()` treats this as a CI footgun and
|
|
133
133
|
* returns NOT-ok.
|
|
134
|
+
* - `'none-affected'` — as `'no-tasks-declared'`, but the scope
|
|
135
|
+
* came from a diff and every name is declared somewhere else in the
|
|
136
|
+
* workspace: nothing changed that runs them, a clean outcome.
|
|
134
137
|
* - `'empty-graph'` — `requested` was non-empty but the
|
|
135
138
|
* graph builder still produced no nodes. Defensive; unreachable
|
|
136
139
|
* under current `buildTaskGraph` semantics.
|
|
137
140
|
*/
|
|
138
|
-
empty: null | 'no-tasks-declared' | 'empty-graph'
|
|
141
|
+
empty: null | 'no-tasks-declared' | 'none-affected' | 'empty-graph'
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
/**
|
|
@@ -286,7 +289,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
286
289
|
: [...projects.keys()]
|
|
287
290
|
|
|
288
291
|
const requested = expandRequested(options.tasks, candidateProjects, projects)
|
|
289
|
-
|
|
292
|
+
let unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
|
|
293
|
+
if (
|
|
294
|
+
options.selectedByDiff === true &&
|
|
295
|
+
projects.size < projectsWithConfigs.length &&
|
|
296
|
+
unresolvedTasks.some((t) => !t.includes('#'))
|
|
297
|
+
) {
|
|
298
|
+
unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
|
|
299
|
+
loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
290
302
|
|
|
291
303
|
// Cache seam precedence: an EXPLICITLY injected remote layer
|
|
292
304
|
// (RunOptions.remoteCache — a distribution agent or daemon that already
|
|
@@ -369,7 +381,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
369
381
|
gitFilesCache,
|
|
370
382
|
hashCache,
|
|
371
383
|
workspaceProjectCount: projectMetas.length,
|
|
372
|
-
empty:
|
|
384
|
+
empty:
|
|
385
|
+
options.selectedByDiff === true && unresolvedTasks.length === 0
|
|
386
|
+
? 'none-affected'
|
|
387
|
+
: 'no-tasks-declared',
|
|
373
388
|
}
|
|
374
389
|
}
|
|
375
390
|
|
|
@@ -460,6 +475,29 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
460
475
|
}
|
|
461
476
|
}
|
|
462
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The names in `unresolved` no project in the whole workspace declares: an
|
|
480
|
+
* anchored `pkg#task` stays as it is, a bare name leaves when a config the
|
|
481
|
+
* diff's scope left out declares it. `load` evaluates those configs; one
|
|
482
|
+
* failing to load leaves every name as it was, so the guard still speaks.
|
|
483
|
+
*/
|
|
484
|
+
async function declaredNowhere(
|
|
485
|
+
unresolved: readonly string[],
|
|
486
|
+
load: () => Promise<LoadedProjects>,
|
|
487
|
+
): Promise<string[]> {
|
|
488
|
+
let all: LoadedProjects
|
|
489
|
+
try {
|
|
490
|
+
all = await load()
|
|
491
|
+
} catch {
|
|
492
|
+
return [...unresolved]
|
|
493
|
+
}
|
|
494
|
+
const declared = new Set<string>()
|
|
495
|
+
for (const p of all.projects.values()) {
|
|
496
|
+
for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
|
|
497
|
+
}
|
|
498
|
+
return unresolved.filter((t) => t.includes('#') || !declared.has(t))
|
|
499
|
+
}
|
|
500
|
+
|
|
463
501
|
/**
|
|
464
502
|
* Refuses the first `^name` in `unproven` that no project in the whole
|
|
465
503
|
* workspace declares. `load` evaluates the configs a scoped run left out;
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -259,6 +259,12 @@ async function runOnBus(
|
|
|
259
259
|
prepared.cache.close()
|
|
260
260
|
return { ok: false, outcomes: [] }
|
|
261
261
|
}
|
|
262
|
+
if (prepared.empty === 'none-affected') {
|
|
263
|
+
log.status(`No affected project declares task(s): ${options.tasks.join(', ')}.`)
|
|
264
|
+
await teardown()
|
|
265
|
+
prepared.cache.close()
|
|
266
|
+
return { ok: true, outcomes: [] }
|
|
267
|
+
}
|
|
262
268
|
if (prepared.empty !== null) {
|
|
263
269
|
// `no-tasks-declared` is almost always a typo in CI; we surface
|
|
264
270
|
// a clear message and return NOT-ok so the script exits 1.
|