@vzn/vx 0.0.140 → 0.0.142

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.140",
3
+ "version": "0.0.142",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
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.140",
21
- "@vzn/vx-linux-arm64": "0.0.140",
22
- "@vzn/vx-darwin-x64": "0.0.140",
23
- "@vzn/vx-darwin-arm64": "0.0.140"
20
+ "@vzn/vx-linux-x64": "0.0.142",
21
+ "@vzn/vx-linux-arm64": "0.0.142",
22
+ "@vzn/vx-darwin-x64": "0.0.142",
23
+ "@vzn/vx-darwin-arm64": "0.0.142"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -38,7 +38,7 @@
38
38
  // otherwise silently loses the claim rather than acting on it.
39
39
 
40
40
  import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
41
- import { mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
41
+ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
42
42
  import path from 'node:path'
43
43
  import { UserError } from '../util/index.js'
44
44
  import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
@@ -193,6 +193,13 @@ export interface PackArgs {
193
193
  outputs: ReadonlyMap<string, string>
194
194
  /** The producing execution's usage, when the runner reported it. */
195
195
  exec?: ExecUsage | undefined
196
+ /**
197
+ * The project directory a symlinked output must resolve inside. vx reads
198
+ * outputs outside the task's sandbox, so a link a sandboxed task planted
199
+ * to a file it could not read (another project's, the user's home) packed
200
+ * that file into the artifact and the remote (L-23).
201
+ */
202
+ within?: string
196
203
  }
197
204
 
198
205
  /** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
@@ -206,11 +213,12 @@ export interface ArtifactPlan {
206
213
  * mtime) and lay out the tar — stdout first, the outputs, the sidecar
207
214
  * last — with its exact size, so the caller can choose the one-call pack
208
215
  * for a small artifact and the streamed one for a large one before a
209
- * byte is read. A symlinked output is stored as its target's content, as
210
- * it always was.
216
+ * byte is read. A symlinked output is stored as its target's content,
217
+ * when that target is inside `within`.
211
218
  */
212
219
  export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
213
220
  const meta: MetaFile = { version: 1, files: {} }
221
+ let withinReal: Promise<string> | undefined
214
222
  if (args.key !== undefined) meta.key = args.key
215
223
  const exec = usageOf(args.exec)
216
224
  if (exec !== undefined) meta.exec = exec
@@ -221,14 +229,31 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
221
229
  // directory, or a dangling one, has no bytes to pack: refuse loudly
222
230
  // rather than store an entry that restores to nothing.
223
231
  const shown = name.startsWith('outputs/') ? name.slice('outputs/'.length) : name
224
- const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
232
+ const dangling = (err: NodeJS.ErrnoException): never => {
225
233
  if (err.code === 'ENOENT') {
226
234
  throw new UserError(
227
235
  `output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
228
236
  )
229
237
  }
230
238
  throw err
231
- })
239
+ }
240
+ // lstat, so a regular file costs no second call; only a link is
241
+ // resolved, and its body is read from the path this check resolved.
242
+ let src = abs
243
+ let st = await lstat(abs)
244
+ if (st.isSymbolicLink()) {
245
+ src = await realpath(abs).catch(dangling)
246
+ if (args.within !== undefined) {
247
+ withinReal ??= realpath(args.within)
248
+ const root = await withinReal
249
+ if (!src.startsWith(root + path.sep)) {
250
+ throw new UserError(
251
+ `output ${shown} is a symlink to ${src}, outside the project: vx packs a symlinked output as its target's bytes, and a project's outputs come from its own directory — emit a file there, or take it back with a '!' entry`,
252
+ )
253
+ }
254
+ }
255
+ st = await stat(src).catch(dangling)
256
+ }
232
257
  if (!st.isFile()) {
233
258
  throw new UserError(
234
259
  `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
@@ -240,7 +265,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
240
265
  meta.files[name] = [mode, Math.floor(st.mtimeMs)]
241
266
  return {
242
267
  name,
243
- abs,
268
+ abs: src,
244
269
  size: st.size,
245
270
  mode,
246
271
  // ustar's octal field holds no sign: an mtime before 1970 made the
@@ -1304,6 +1304,7 @@ export class Cache implements CacheLayer {
1304
1304
  stdout: args.entry.stdout ?? '',
1305
1305
  outputs: this.outputsOf(args),
1306
1306
  exec: usageOfEntry(args.entry),
1307
+ within: args.projectDir,
1307
1308
  })
1308
1309
  if (plan.size > this.artifactCeiling) {
1309
1310
  throw new Error(
@@ -8,7 +8,8 @@
8
8
  // <pattern>^... only the transitive deps of pattern (excluding the matched package)
9
9
  // ...^<pattern> only the transitive dependents of pattern (excluding the matched package)
10
10
  // !<pattern> exclude packages matching pattern from the selection
11
- // [<since>] projects affected since the given git ref
11
+ // [<since>] projects affected since the given git ref; after a name
12
+ // or {dir} selector, the selected ones only (D-44)
12
13
  // (Turbo-style; resolved upstream of applyFilters via
13
14
  // `affectedProjects` since it needs FS + git access)
14
15
  //
@@ -34,8 +35,9 @@ export interface ParsedFilter {
34
35
  matcher: string
35
36
  /**
36
37
  * When non-undefined, this filter is a git-relative `[<since>]`
37
- * selector. The matcher field is unused; the CLI resolves the ref
38
- * to a concrete set of project names before calling applyFilters.
38
+ * selector: bare (`matcher` empty), or narrowing a name or `{dir}`
39
+ * selector (`@scope/*[main]`, D-44). The CLI resolves the ref to a
40
+ * concrete set of project names before calling applyFilters.
39
41
  */
40
42
  gitSince?: string
41
43
  /** A path form carrying a glob (`./packages/*`): matched over the root-relative project dir. */
@@ -82,6 +84,18 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
82
84
  }
83
85
  }
84
86
 
87
+ // `<selector>[<since>]`: the selected packages that changed since the
88
+ // ref (D-44). Turbo and pnpm read `@scope/*[HEAD]` and `{./apps/*}[main]`
89
+ // so; vx read the whole as one name glob and matched nothing.
90
+ let gitSince: string | undefined
91
+ // An unbraced path keeps its brackets: `./packages/[abc]` is a glob
92
+ // class, and Turbo takes a directory with a ref only as `{dir}[ref]`.
93
+ const scoped = /^(.+)\[([^\]]+)\]$/.exec(s)
94
+ if (scoped !== null && !scoped[1]!.startsWith('./') && scoped[1] !== '.') {
95
+ s = scoped[1]!
96
+ gitSince = scoped[2]!
97
+ }
98
+
85
99
  // `...`, `!`, `^...`: the operators with no project between them. An
86
100
  // empty name glob matched nothing and was hinted "Did you mean a?", and
87
101
  // an empty exclude (`!$UNSET`) excluded nothing, so every project ran
@@ -120,6 +134,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
120
134
  isPath,
121
135
  matcher,
122
136
  ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
137
+ ...(gitSince !== undefined ? { gitSince } : {}),
123
138
  }
124
139
  }
125
140
 
@@ -132,8 +147,15 @@ function matchProjects(
132
147
  // is pure; git access happens upstream). Use the provided set as
133
148
  // the match set for this filter.
134
149
  if (filter.gitSince !== undefined) {
135
- return [...(affectedByFilter?.get(filter) ?? new Set())]
150
+ const changed = affectedByFilter?.get(filter) ?? new Set<string>()
151
+ if (filter.matcher === '') return [...changed]
152
+ return matchSelector(filter, projects).filter((name) => changed.has(name))
136
153
  }
154
+ return matchSelector(filter, projects)
155
+ }
156
+
157
+ /** The projects a name or path selector names, git ranges aside. */
158
+ function matchSelector(filter: ParsedFilter, projects: ProjectMeta[]): string[] {
137
159
  const out: string[] = []
138
160
  if (filter.isPath) {
139
161
  // A path naming a project's own directory is that project, as Turbo and