@vzn/vx 0.0.492 → 0.0.493
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/select.ts +27 -2
- package/src/cli/show.ts +6 -3
- package/src/config.ts +5 -0
- package/src/workspace/config-schema.ts +11 -3
- package/src/workspace/filter.ts +42 -5
- package/src/workspace/migration.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.493",
|
|
4
4
|
"description": "The fastest 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.493",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.493",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.493",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.493"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/select.ts
CHANGED
|
@@ -254,6 +254,16 @@ export async function resolveFilters(
|
|
|
254
254
|
return { error: err instanceof Error ? err.message : String(err) }
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
+
// A tag lives in the config, so a `tag:` selector needs the staged load
|
|
258
|
+
// (a `project` plugin may have given or edited the tags).
|
|
259
|
+
let tags: Map<string, readonly string[]> | undefined
|
|
260
|
+
if (parsed.some((f) => f.tag === true)) {
|
|
261
|
+
try {
|
|
262
|
+
tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return { error: err instanceof Error ? err.message : String(err) }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
257
267
|
const graph = buildPackageGraph(projects, edges)
|
|
258
268
|
// The graph a run reusing this discovery builds is this one when no task
|
|
259
269
|
// edge went into it.
|
|
@@ -290,6 +300,7 @@ export async function resolveFilters(
|
|
|
290
300
|
projects,
|
|
291
301
|
graph,
|
|
292
302
|
affectedByFilter,
|
|
303
|
+
...(tags !== undefined ? { tags } : {}),
|
|
293
304
|
// A `[<since>]` selector matching nothing is the ordinary "nothing
|
|
294
305
|
// changed" outcome, reported below; only a name/path pattern that
|
|
295
306
|
// matched nothing is worth flagging as a probable typo.
|
|
@@ -332,14 +343,14 @@ export async function resolveFilters(
|
|
|
332
343
|
// the patterns are in the error, and the nearest project name is the
|
|
333
344
|
// hint a typo needs.
|
|
334
345
|
return {
|
|
335
|
-
error: `no projects matched filter(s): ${raw.join(', ')}${
|
|
346
|
+
error: `no projects matched filter(s): ${raw.join(', ')}${didYouMean(unmatched, projects, tags)}`,
|
|
336
347
|
}
|
|
337
348
|
}
|
|
338
349
|
// Something matched, so the run proceeds; a pattern that matched nothing
|
|
339
350
|
// alongside it is still worth a line — it is probably a typo.
|
|
340
351
|
for (const f of unmatched)
|
|
341
352
|
process.stderr.write(
|
|
342
|
-
`vx: filter "${f}" matched no projects${
|
|
353
|
+
`vx: filter "${f}" matched no projects${didYouMean([f], projects, tags)}\n`,
|
|
343
354
|
)
|
|
344
355
|
let staged: Map<string, ProjectEntry> | undefined
|
|
345
356
|
if (stagedPromise !== undefined) {
|
|
@@ -447,6 +458,20 @@ export async function pickTask(
|
|
|
447
458
|
}
|
|
448
459
|
}
|
|
449
460
|
|
|
461
|
+
/** The hint for the first unmatched pattern: a near tag for a `tag:` one, else a near project name. */
|
|
462
|
+
function didYouMean(
|
|
463
|
+
unmatched: readonly string[],
|
|
464
|
+
projects: Iterable<{ name: string }>,
|
|
465
|
+
tags: ReadonlyMap<string, readonly string[]> | undefined,
|
|
466
|
+
): string {
|
|
467
|
+
const first = unmatched[0]?.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
|
|
468
|
+
if (first?.startsWith('tag:') !== true) return didYouMeanProject(unmatched, projects)
|
|
469
|
+
const known = [...new Set([...(tags?.values() ?? [])].flat())].sort()
|
|
470
|
+
if (known.length === 0) return '. No project declares tags'
|
|
471
|
+
const best = nearest(first.slice(4), known)
|
|
472
|
+
return best !== undefined ? `. Did you mean tag:${best}?` : `. Tags: ${listed(known)}`
|
|
473
|
+
}
|
|
474
|
+
|
|
450
475
|
/** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
|
|
451
476
|
function didYouMeanProject(
|
|
452
477
|
unmatched: readonly string[],
|
package/src/cli/show.ts
CHANGED
|
@@ -224,12 +224,13 @@ function renderList(
|
|
|
224
224
|
const rows = metas.map((meta) => ({
|
|
225
225
|
name: meta.name,
|
|
226
226
|
dir: projectDir(root, meta),
|
|
227
|
+
tags: projects.get(meta.name)?.config.tags ?? [],
|
|
227
228
|
tasks: Object.keys(projects.get(meta.name)?.config.tasks ?? {}),
|
|
228
229
|
configured: meta.configPath !== null,
|
|
229
230
|
}))
|
|
230
231
|
if (format === 'json') {
|
|
231
232
|
return `${JSON.stringify(
|
|
232
|
-
rows.map(({ name, dir, tasks }) => ({ name, dir, tasks })),
|
|
233
|
+
rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })),
|
|
233
234
|
null,
|
|
234
235
|
2,
|
|
235
236
|
)}\n`
|
|
@@ -245,7 +246,8 @@ function renderList(
|
|
|
245
246
|
: n > 0
|
|
246
247
|
? `${count} (no vx config; from plugins)`
|
|
247
248
|
: '(no vx config)'
|
|
248
|
-
|
|
249
|
+
const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
|
|
250
|
+
return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
|
|
249
251
|
})
|
|
250
252
|
return `${lines.join('\n')}\n`
|
|
251
253
|
}
|
|
@@ -263,7 +265,8 @@ function renderProject(
|
|
|
263
265
|
const shown = shownConfig(config)
|
|
264
266
|
return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
|
|
265
267
|
}
|
|
266
|
-
const
|
|
268
|
+
const tags = config?.tags ?? []
|
|
269
|
+
const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}`
|
|
267
270
|
const tasks = Object.entries(config?.tasks ?? {})
|
|
268
271
|
if (tasks.length === 0)
|
|
269
272
|
return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
|
package/src/config.ts
CHANGED
|
@@ -130,6 +130,11 @@ const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, neve
|
|
|
130
130
|
void _pluginHooksMatch
|
|
131
131
|
|
|
132
132
|
export interface ProjectConfig {
|
|
133
|
+
/**
|
|
134
|
+
* Labels for selection: `--filter tag:<name>` picks the projects that
|
|
135
|
+
* carry one. Not in any cache key: a tag changes no task's behaviour.
|
|
136
|
+
*/
|
|
137
|
+
tags?: readonly string[]
|
|
133
138
|
/** Tasks declared by this project, keyed by task name. */
|
|
134
139
|
tasks?: Record<string, TaskConfig>
|
|
135
140
|
}
|
|
@@ -287,6 +287,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
287
287
|
// The top level too: `task:` (singular) loaded as a project with no
|
|
288
288
|
// tasks and every request against it said "no projects declare".
|
|
289
289
|
assertKnownFields(config, PROJECT_FIELDS, configPath)
|
|
290
|
+
const tags: unknown = config.tags
|
|
291
|
+
if (
|
|
292
|
+
tags !== undefined &&
|
|
293
|
+
(!Array.isArray(tags) || tags.some((t) => typeof t !== 'string' || t.trim() === ''))
|
|
294
|
+
) {
|
|
295
|
+
throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
|
|
296
|
+
}
|
|
290
297
|
const tasks = config.tasks
|
|
291
298
|
if (tasks === undefined) return
|
|
292
299
|
if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
|
|
@@ -728,7 +735,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
728
735
|
// discarded, so the task hashes as if the field were never written and vx
|
|
729
736
|
// serves a stale artifact — the same reasoning `sandbox` already
|
|
730
737
|
// encodes. A new field must be added here deliberately.
|
|
731
|
-
|
|
738
|
+
// `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
|
|
739
|
+
// from both.
|
|
740
|
+
const PROJECT_FIELDS = new Set(['tasks', 'tags'])
|
|
732
741
|
const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
|
|
733
742
|
const EXEC_FIELDS = new Set([
|
|
734
743
|
'command',
|
|
@@ -867,7 +876,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
867
876
|
},
|
|
868
877
|
],
|
|
869
878
|
// A package's turbo.json and Nx's project.json keys, written into a
|
|
870
|
-
// project's vx.config (D-56).
|
|
879
|
+
// project's vx.config (D-56).
|
|
871
880
|
[
|
|
872
881
|
PROJECT_FIELDS,
|
|
873
882
|
{
|
|
@@ -875,7 +884,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
875
884
|
extends: 'an imported module spread into `tasks` (a vx.config is code; nothing is inherited)',
|
|
876
885
|
implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
|
|
877
886
|
name: 'the package.json `name` (a project is named by its package)',
|
|
878
|
-
tags: '`--filter` (a name glob or a directory) to select projects',
|
|
879
887
|
// A package.json's, turbo.json's and project.json's keys, and a
|
|
880
888
|
// task's fields one level too high (D-99).
|
|
881
889
|
scripts:
|
package/src/workspace/filter.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// ./<dir> the package at <dir>, else the packages under it (relative to workspace root)
|
|
5
5
|
// {<dir>} same as ./<dir>
|
|
6
6
|
// // the workspace-root project only (Turbo's name for the root)
|
|
7
|
+
// tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
|
|
7
8
|
// <pattern>... pattern + its transitive workspace dependencies
|
|
8
9
|
// ...<pattern> pattern + its transitive workspace dependents
|
|
9
10
|
// <pattern>^... only the transitive deps of pattern (excluding the matched package)
|
|
@@ -55,6 +56,8 @@ export interface ParsedFilter {
|
|
|
55
56
|
pathRoot?: string
|
|
56
57
|
/** `//`: the project at `matcher` itself, never the ones under it. */
|
|
57
58
|
exactDir?: true
|
|
59
|
+
/** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
|
|
60
|
+
tag?: true
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
@@ -141,6 +144,24 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
if (s.startsWith('tag:')) {
|
|
148
|
+
const tag = s.slice(4)
|
|
149
|
+
if (tag === '') throw new UserError(`filter "${raw}" names no tag`)
|
|
150
|
+
return {
|
|
151
|
+
raw,
|
|
152
|
+
negate,
|
|
153
|
+
withDeps,
|
|
154
|
+
withDependents,
|
|
155
|
+
onlyDeps,
|
|
156
|
+
onlyDependents,
|
|
157
|
+
isPath: false,
|
|
158
|
+
matcher: tag,
|
|
159
|
+
tag: true,
|
|
160
|
+
...(gitSince !== undefined ? { gitSince } : {}),
|
|
161
|
+
...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
let isPath = false
|
|
145
166
|
let matcher = s
|
|
146
167
|
let pathGlob: Bun.Glob | undefined
|
|
@@ -187,6 +208,7 @@ function matchProjects(
|
|
|
187
208
|
projects: ProjectMeta[],
|
|
188
209
|
affectedByFilter: Map<ParsedFilter, Set<string>> | undefined,
|
|
189
210
|
graph: PackageGraph,
|
|
211
|
+
tags: ProjectTags | undefined,
|
|
190
212
|
): string[] {
|
|
191
213
|
// `[<since>]` selectors are pre-resolved by the caller (the parser
|
|
192
214
|
// is pure; git access happens upstream). Use the provided set as
|
|
@@ -199,14 +221,24 @@ function matchProjects(
|
|
|
199
221
|
for (const d of graph.transitiveDependents(name)) changed.add(d)
|
|
200
222
|
}
|
|
201
223
|
if (filter.matcher === '') return [...changed]
|
|
202
|
-
return matchSelector(filter, projects).filter((name) => changed.has(name))
|
|
224
|
+
return matchSelector(filter, projects, tags).filter((name) => changed.has(name))
|
|
203
225
|
}
|
|
204
|
-
return matchSelector(filter, projects)
|
|
226
|
+
return matchSelector(filter, projects, tags)
|
|
205
227
|
}
|
|
206
228
|
|
|
207
|
-
/** The projects a name or
|
|
208
|
-
function matchSelector(
|
|
229
|
+
/** The projects a name, path or tag selector names, git ranges aside. */
|
|
230
|
+
function matchSelector(
|
|
231
|
+
filter: ParsedFilter,
|
|
232
|
+
projects: ProjectMeta[],
|
|
233
|
+
tags: ProjectTags | undefined,
|
|
234
|
+
): string[] {
|
|
209
235
|
const out: string[] = []
|
|
236
|
+
if (filter.tag === true) {
|
|
237
|
+
const re = compileNameGlob(filter.matcher)
|
|
238
|
+
for (const p of projects)
|
|
239
|
+
if (tags?.get(p.name)?.some((t) => re.test(t)) === true) out.push(p.name)
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
210
242
|
if (filter.isPath) {
|
|
211
243
|
// A path naming a project's own directory is that project, as Turbo and
|
|
212
244
|
// pnpm read it: `.` is the root project, never every project under the
|
|
@@ -268,10 +300,15 @@ function compileNameGlob(pattern: string): RegExp {
|
|
|
268
300
|
return new RegExp(`^${escaped}$`)
|
|
269
301
|
}
|
|
270
302
|
|
|
303
|
+
/** Each project's config `tags`, by project name. */
|
|
304
|
+
type ProjectTags = ReadonlyMap<string, readonly string[]>
|
|
305
|
+
|
|
271
306
|
export interface ApplyFiltersOptions {
|
|
272
307
|
filters: ParsedFilter[]
|
|
273
308
|
projects: ProjectMeta[]
|
|
274
309
|
graph: PackageGraph
|
|
310
|
+
/** Read by `tag:` selectors; a project absent here carries no tag. */
|
|
311
|
+
tags?: ProjectTags
|
|
275
312
|
/**
|
|
276
313
|
* Pre-resolved affected-project sets for each `[<since>]` filter.
|
|
277
314
|
* The caller (CLI / programmatic embedder) runs the git work and
|
|
@@ -304,7 +341,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
304
341
|
// taken in argv order, so `onNoMatch` names the filters as typed.
|
|
305
342
|
const excludes: Set<string>[] = []
|
|
306
343
|
for (const f of opts.filters) {
|
|
307
|
-
const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph)
|
|
344
|
+
const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph, opts.tags)
|
|
308
345
|
if (matched.length === 0) opts.onNoMatch?.(f)
|
|
309
346
|
const expanded = new Set<string>()
|
|
310
347
|
for (const name of matched) {
|
|
@@ -203,6 +203,8 @@ export interface GeneratedProject {
|
|
|
203
203
|
/** absolute project dir */
|
|
204
204
|
dir: string
|
|
205
205
|
importLines: string[]
|
|
206
|
+
/** The project's `tags` (an Nx project's); none written when empty. */
|
|
207
|
+
tags?: readonly string[]
|
|
206
208
|
tasks: GeneratedTask[]
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -599,7 +601,9 @@ function renderConfigFile(
|
|
|
599
601
|
]
|
|
600
602
|
if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
|
|
601
603
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
602
|
-
lines.push('', 'export default {'
|
|
604
|
+
lines.push('', 'export default {')
|
|
605
|
+
if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
|
|
606
|
+
lines.push(' tasks: {')
|
|
603
607
|
for (const t of p.tasks) {
|
|
604
608
|
// A reason can quote a manifest's own text (an Nx `env` key), and a
|
|
605
609
|
// line break there ended the comment: the rest was code in the file
|