@vzn/vx 0.0.525 → 0.0.527

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.525",
3
+ "version": "0.0.527",
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.525",
25
- "@vzn/vx-linux-arm64": "0.0.525",
26
- "@vzn/vx-darwin-x64": "0.0.525",
27
- "@vzn/vx-darwin-arm64": "0.0.525"
24
+ "@vzn/vx-linux-x64": "0.0.527",
25
+ "@vzn/vx-linux-arm64": "0.0.527",
26
+ "@vzn/vx-darwin-x64": "0.0.527",
27
+ "@vzn/vx-darwin-arm64": "0.0.527"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -158,13 +158,20 @@ function trailingCommentStart(command: string): number {
158
158
  /**
159
159
  * The command a task runs with the args after `--` appended, shell-quoted.
160
160
  * They go before a trailing comment: appended after it, `echo args: # show`
161
- * ran without them and said nothing (item 1060).
161
+ * ran without them and said nothing (item 1060). Trailing blanks go first.
162
162
  */
163
163
  export function withForwardArgs(command: string, args: readonly string[] | undefined): string {
164
164
  if (!args || args.length === 0) return command
165
165
  const quoted = args.map(shellQuote).join(' ')
166
166
  let comment = trailingCommentStart(command)
167
- if (comment < 0) return `${command} ${quoted}`
167
+ if (comment < 0) {
168
+ // A command that ends in a newline (a template literal's closing line)
169
+ // ran the args as a command of their own: `--watch: not found`. Kept
170
+ // when an odd run of backslashes ends it: that escapes what follows.
171
+ const body = command.trimEnd()
172
+ const escaped = (/\\+$/.exec(body)?.[0].length ?? 0) % 2 === 1
173
+ return `${escaped ? command : body} ${quoted}`
174
+ }
168
175
  // A comment-only last line after a commented line: the args go before
169
176
  // the earliest comment, or they land inside it (`echo one # c\n# two`).
170
177
  for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
@@ -169,17 +169,21 @@ export async function applyProjectHooks(
169
169
  * deleting each other's outputs). A violation is reported
170
170
  * against the LAST plugin that ran: usually the one whose edit made it so,
171
171
  * but an earlier plugin's edit that a later one left in place is blamed on
172
- * the later one, since nothing is checked between plugins.
172
+ * the later one, since the structure is not checked between plugins.
173
+ * Each node's task config is (`afterEach`), like the `project` stage's.
173
174
  */
174
175
  export async function applyGraphHooks(
175
176
  plugins: readonly VxPlugin[],
176
177
  nodes: Map<string, TaskNode>,
177
178
  ctx: GraphHookContext,
179
+ /** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
180
+ afterEach?: (plugin: VxPlugin) => void,
178
181
  ): Promise<void> {
179
182
  let last: VxPlugin | undefined
180
183
  for (const plugin of plugins) {
181
184
  if (plugin.graph === undefined) continue
182
185
  await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
186
+ afterEach?.(plugin)
183
187
  last = plugin
184
188
  }
185
189
  if (last === undefined) return
@@ -39,6 +39,7 @@ import {
39
39
  resolveCacheDir,
40
40
  resolveStoreRoot,
41
41
  type ProjectEntry,
42
+ validateProjectConfig,
42
43
  } from '../workspace/index.js'
43
44
  import {
44
45
  buildTaskGraph,
@@ -61,6 +62,7 @@ import {
61
62
  discoverProjects,
62
63
  gitOfDiscovery,
63
64
  graphOfDiscovery,
65
+ isDefaultBuild,
64
66
  loadProjects,
65
67
  loadWorkspacePlugins,
66
68
  type LoadedProjects,
@@ -597,12 +599,30 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
597
599
  )
598
600
  mark('git enumeration')
599
601
  if (hasHook(plugins, 'graph')) {
600
- await applyGraphHooks(plugins, nodes, {
601
- workspaceRoot,
602
- cacheDir,
603
- warn: (m) => log.status(m),
604
- requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
605
- })
602
+ // A hook edits each node's config in place, as `project` does, so the
603
+ // edit is held to what the loader accepts from a user: a misspelled
604
+ // `exec` field ran as an empty command and failed with no reason.
605
+ const configPaths = new Map(projectMetas.map((m) => [m.name, m.configPath]))
606
+ await applyGraphHooks(
607
+ plugins,
608
+ nodes,
609
+ {
610
+ workspaceRoot,
611
+ cacheDir,
612
+ warn: (m) => log.status(m),
613
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
614
+ },
615
+ (plugin) => {
616
+ for (const n of nodes.values()) {
617
+ if (isDefaultBuild(n.config)) continue
618
+ const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
619
+ validateProjectConfig(
620
+ { tasks: { [n.taskName]: n.config } },
621
+ `${where} (after plugin '${plugin.name}')`,
622
+ )
623
+ }
624
+ },
625
+ )
606
626
  }
607
627
  if (hasHook(plugins, 'key')) {
608
628
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
@@ -74,7 +74,9 @@ const decoder = new TextDecoder()
74
74
  * `^task` edges vanish, upstream hashes drop out of the cache key (stale
75
75
  * hits), and a second cache dir appears under the member. Claiming is decided
76
76
  * with the same globs `loadWorkspace` applies, so "the root that claims me"
77
- * and "the root that lists me as a project" cannot diverge.
77
+ * and "the root that lists me as a project" cannot diverge. An outer root
78
+ * that lists both the claimer and the claimed member outranks the claimer:
79
+ * from the claimer's own directory the walk reaches the outer root too.
78
80
  *
79
81
  * When no candidate claims `start` — a standalone package, or a subdirectory
80
82
  * of a single-project repo — the nearest candidate wins (the root itself IS
@@ -92,6 +94,9 @@ export async function findWorkspaceRoot(
92
94
  // The nearest manifest with workspace globs of its own, once passed: an
93
95
  // outer root claims the tree only by listing this directory itself.
94
96
  let inner: string | null = null
97
+ // The root that claims `start`, and the members it claimed it through.
98
+ let claimed: string | null = null
99
+ let members: string[] = []
95
100
  while (true) {
96
101
  let globs: string[] | null
97
102
  try {
@@ -105,12 +110,23 @@ export async function findWorkspaceRoot(
105
110
  }
106
111
  if (globs !== null) {
107
112
  nearest ??= dir
108
- if (claimsMember(dir, inner === null ? below : [inner], globs)) return dir
113
+ if (claimed !== null) {
114
+ // An outer root that lists both the claimer and the member owns
115
+ // them: `packages/**` holding `packages/inner` (with `workspaces:
116
+ // ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
117
+ // `inner` to the outer root, two roots and two keys for one tree.
118
+ if (claimsMember(dir, [claimed], globs) && claimsMember(dir, members, globs)) claimed = dir
119
+ } else if (claimsMember(dir, inner === null ? below : [inner], globs)) {
120
+ claimed = dir
121
+ members = inner === null ? [...below] : [inner]
122
+ }
109
123
  // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
110
124
  // not. Walking past it, `apps/inner` resolved to the outer workspace
111
125
  // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
112
126
  // two caches for one tree (item 990). Already read: `reads` holds it.
113
- if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) return dir
127
+ if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
128
+ return claimed ?? dir
129
+ }
114
130
  }
115
131
  const parent = path.dirname(dir)
116
132
  if (parent === dir) break
@@ -126,6 +142,7 @@ export async function findWorkspaceRoot(
126
142
  if (inner === null && globs !== null && globs.length > 0) inner = dir
127
143
  dir = parent
128
144
  }
145
+ if (claimed !== null) return claimed
129
146
  if (nearest !== null) return nearest
130
147
  throw new UserError(
131
148
  `Could not find a workspace root in any parent of ${start} ` +