@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.492",
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.492",
25
- "@vzn/vx-linux-arm64": "0.0.492",
26
- "@vzn/vx-darwin-x64": "0.0.492",
27
- "@vzn/vx-darwin-arm64": "0.0.492"
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(', ')}${didYouMeanProject(unmatched, projects)}`,
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${didYouMeanProject([f], projects)}\n`,
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
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}`
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 head = `${name} — ${dir}`
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
- const PROJECT_FIELDS = new Set(['tasks'])
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). `tags` had read as a typo of `tasks`.
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:
@@ -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 path selector names, git ranges aside. */
208
- function matchSelector(filter: ParsedFilter, projects: ProjectMeta[]): string[] {
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 {', ' tasks: {')
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