@vzn/vx 0.0.651 → 0.0.653

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.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -13,6 +13,19 @@ import path from 'node:path'
13
13
  const HEAD =
14
14
  /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (?:(tasks\.[^\s:]+)(:?)|`([^`\n]+)`|task name ("(?:[^"\\\n]|\\.)*")))? /
15
15
 
16
+ /**
17
+ * A quoted name as the source spells it. A glob refusal quotes the glob
18
+ * raw, so `"app/\\[id\\]"` is no JSON string: parsed alone, vx lock crashed
19
+ * on it in place of the refusal.
20
+ */
21
+ function unquote(quoted: string): string {
22
+ try {
23
+ return JSON.parse(quoted) as string
24
+ } catch {
25
+ return quoted.slice(1, -1)
26
+ }
27
+ }
28
+
16
29
  /**
17
30
  * The message with its file named at `line:col`, relative to `cwd`, and a
18
31
  * frame of the source under it; undefined when the message names no
@@ -32,11 +45,25 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
32
45
  const rest = message.slice(head[0].length)
33
46
  // The field the refusal is about, past the path that leads to it:
34
47
  // `has unknown field "comand"`, or `tasks.build: \`cache\` is not allowed`.
35
- const named =
36
- colon === ':' ? /^`([^`]+)`/.exec(rest) : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
48
+ // A refused key is quoted: `tasks.b.exec.env.define: "A=B" is not …`.
49
+ const said =
50
+ colon === ':'
51
+ ? /^(?:`([^`]+)`|("(?:[^"\\\n]|\\.)*"))/.exec(rest)
52
+ : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
53
+ const named = said === null ? null : [said[0], said[1] ?? unquote(said[2]!)]
54
+ // `plugins[2].name`: an element of an array, framed at the element (a
55
+ // plugin is a call, so no key inside it is searched).
56
+ const element = setting === undefined ? null : /^([^[]+)\[(\d+)\]/.exec(setting)
37
57
  const fieldPath =
38
- taskName === undefined ? (tasksPath ?? setting)?.split('.') : ['tasks', JSON.parse(taskName)]
39
- const segments = [...(fieldPath ?? []), ...(named === null ? [] : [named[1]!])]
58
+ taskName !== undefined
59
+ ? ['tasks', unquote(taskName)]
60
+ : element !== null
61
+ ? element[1]!.split('.')
62
+ : (tasksPath ?? setting)?.split('.')
63
+ const segments = [
64
+ ...(fieldPath ?? []),
65
+ ...(named === null || element !== null ? [] : [named[1]!]),
66
+ ]
40
67
  if (segments.length === 0) return undefined
41
68
  let source: string
42
69
  try {
@@ -44,7 +71,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
44
71
  } catch {
45
72
  return undefined
46
73
  }
47
- const at = locate(source, segments)
74
+ const at = locate(source, segments, element === null ? undefined : Number(element[2]))
48
75
  if (at === undefined) return undefined
49
76
  return framed(file, source, at, message.slice(file.length), cwd)
50
77
  }
@@ -54,8 +81,71 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
54
81
  const POSITION =
55
82
  /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
56
83
 
84
+ // The loader's `Project config <file> threw: <message> (at <file>:<line>:<col>)`:
85
+ // the config's own throw, placed by its stack in the config itself.
86
+ const THREW =
87
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) threw: ([^\n]*?) \(at (\/[^\n]*?):(\d+):(\d+)\)([^]*)$/
88
+
89
+ // The loader's `<kind> config <file>: cannot find '<pkg>' …`: placed at the
90
+ // specifier. And `<kind> config at <file> did not export a default object`:
91
+ // at the `export default` that exported something else, when there is one.
92
+ const UNPROVIDED =
93
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s): (cannot find '([^'\n]+)'[^]*)$/
94
+ const NOT_OBJECT =
95
+ /^(?:Project|Workspace) config at (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) (did not export a default object[^]*)$/
96
+
97
+ /** The file, the source offset `find` places in it, and what follows `<file>:<line>:<col>`. */
98
+ function sourceFrame(
99
+ file: string,
100
+ find: (source: string) => number,
101
+ rest: string,
102
+ cwd: string,
103
+ ): string | undefined {
104
+ let source: string
105
+ try {
106
+ source = readFileSync(file, 'utf8')
107
+ } catch {
108
+ return undefined
109
+ }
110
+ const at = find(source)
111
+ if (at < 0) return undefined
112
+ const before = source.slice(0, at)
113
+ const line = before.split('\n').length
114
+ return framed(file, source, { line, col: at - before.lastIndexOf('\n') }, rest, cwd)
115
+ }
116
+
57
117
  function positionFrame(message: string, cwd: string): string | undefined {
58
- const pos = POSITION.exec(message)
118
+ const unprovided = UNPROVIDED.exec(message)
119
+ if (unprovided !== null) {
120
+ const [, file, said, pkg] = unprovided as unknown as [string, string, string, string]
121
+ const spec = new RegExp(
122
+ `(?<=\\b(?:from|import|import\\(|require\\()\\s*)(['"])${escapeRe(pkg)}\\1`,
123
+ )
124
+ return sourceFrame(file, (src) => spec.exec(src)?.index ?? -1, `: ${said}`, cwd)
125
+ }
126
+ const notObject = NOT_OBJECT.exec(message)
127
+ if (notObject !== null) {
128
+ const [, file, said] = notObject as unknown as [string, string, string]
129
+ return sourceFrame(
130
+ file,
131
+ // No default: at the named export written instead, when there is one.
132
+ (src) => (/^export\s+default\b/m.exec(src) ?? /^export\b/m.exec(src))?.index ?? -1,
133
+ `: ${said}`,
134
+ cwd,
135
+ )
136
+ }
137
+ const threw = THREW.exec(message)
138
+ const pos =
139
+ threw !== null && threw[3] === threw[1]
140
+ ? [
141
+ threw[0],
142
+ threw[1],
143
+ threw[4],
144
+ threw[5],
145
+ // A cause's own `(at …)` names the same file: relative there too.
146
+ `: threw: ${threw[2]}${threw[6]!.replaceAll(threw[1]!, path.relative(cwd, threw[1]!))}`,
147
+ ]
148
+ : POSITION.exec(message)
59
149
  if (pos === null) return undefined
60
150
  const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
61
151
  let source: string
@@ -105,6 +195,8 @@ function framed(
105
195
  function locate(
106
196
  source: string,
107
197
  segments: readonly string[],
198
+ /** The element of the array the last segment holds; past its end, the key. */
199
+ index?: number,
108
200
  ): { line: number; col: number } | undefined {
109
201
  let pos = 0
110
202
  let end = source.length
@@ -137,6 +229,10 @@ function locate(
137
229
  i = next
138
230
  }
139
231
  if (found < 0) return undefined
232
+ if (index !== undefined && i === segments.length) {
233
+ const el = elementStart(source, pos, index)
234
+ if (el !== undefined) found = el
235
+ }
140
236
  const before = source.slice(0, found)
141
237
  const line = before.split('\n').length
142
238
  return { line, col: found - before.lastIndexOf('\n') }
@@ -172,6 +268,46 @@ function literalEnd(source: string, from: number): number {
172
268
  return source.length
173
269
  }
174
270
 
271
+ /** Where element `index` of the array literal at `from` (past blanks) starts, if it has one. */
272
+ function elementStart(source: string, from: number, index: number): number | undefined {
273
+ let i = from
274
+ while (i < source.length && /\s/.test(source[i]!)) i++
275
+ if (source[i] !== '[') return undefined
276
+ let depth = 0
277
+ let n = 0
278
+ // Each element starts at the first code past `[` or a top-level `,`.
279
+ let pending = true
280
+ for (i++; i < source.length; i++) {
281
+ const c = source[i]!
282
+ if (c === '/' && source[i + 1] === '/') {
283
+ i = source.indexOf('\n', i)
284
+ if (i < 0) return undefined
285
+ continue
286
+ }
287
+ if (c === '/' && source[i + 1] === '*') {
288
+ i = source.indexOf('*/', i + 2)
289
+ if (i < 0) return undefined
290
+ i++
291
+ continue
292
+ }
293
+ if (/\s/.test(c)) continue
294
+ if (depth === 0 && (c === ']' || c === ')' || c === '}')) return undefined
295
+ if (pending) {
296
+ if (n === index) return i
297
+ pending = false
298
+ }
299
+ if (c === '"' || c === "'" || c === '`') {
300
+ for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
301
+ } else if (c === '{' || c === '[' || c === '(') depth++
302
+ else if (c === '}' || c === ']' || c === ')') depth--
303
+ else if (c === ',' && depth === 0) {
304
+ n++
305
+ pending = true
306
+ }
307
+ }
308
+ return undefined
309
+ }
310
+
175
311
  function escapeRe(s: string): string {
176
312
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
177
313
  }
@@ -85,11 +85,27 @@ export function nearMatches(name: string, candidates: Iterable<string>, limit =
85
85
  if (out.length >= limit) break
86
86
  if (out.includes(c)) continue
87
87
  const n = c.toLowerCase()
88
- if (n.includes(q) || q.includes(n)) out.push(c)
88
+ if (n.includes(q) || wordIn(q, n)) out.push(c)
89
89
  }
90
90
  return out.slice(0, limit)
91
91
  }
92
92
 
93
+ /**
94
+ * `name` stands in `query` as a whole word (`build` in `build-and-test`):
95
+ * a bare substring offered `ui` for `guide` and `a` for anything.
96
+ */
97
+ function wordIn(query: string, name: string): boolean {
98
+ for (let i = query.indexOf(name); i !== -1; i = query.indexOf(name, i + 1)) {
99
+ const end = i + name.length
100
+ if (
101
+ (i === 0 || !/[a-z0-9]/.test(query[i - 1]!)) &&
102
+ (end === query.length || !/[a-z0-9]/.test(query[end]!))
103
+ )
104
+ return true
105
+ }
106
+ return false
107
+ }
108
+
93
109
  /**
94
110
  * The names to offer when no one name is near: sorted, the first `limit`,
95
111
  * then a count. A typo past two edits named nothing to pick from (M-56).
@@ -86,16 +86,22 @@ export function errorCode(err: unknown): string {
86
86
  * Does this argv ask for JSON (`--format json`, `--format=json`,
87
87
  * `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
88
88
  * reader of stdout gets an answer, not nothing. Args after `--` are the
89
- * task's, not vx's.
89
+ * task's, not vx's. Each flag's LAST value counts, as every verb's parser
90
+ * keeps it: `--format json --format pretty` prints pretty. `--dry` alone is
91
+ * run's pretty plan, and a switch beside `--format` elsewhere.
90
92
  */
91
93
  export function wantsJson(argv: readonly string[]): boolean {
94
+ let format: string | undefined
95
+ let dry: string | undefined
92
96
  for (let i = 0; i < argv.length; i++) {
93
97
  const a = argv[i]!
94
- if (a === '--') return false
95
- if (a === '--format=json' || a === '--dry=json') return true
96
- if (a === '--format' && argv[i + 1] === 'json') return true
98
+ if (a === '--') break
99
+ if (a === '--format') format = argv[++i]
100
+ else if (a.startsWith('--format=')) format = a.slice('--format='.length)
101
+ else if (a === '--dry') dry = 'pretty'
102
+ else if (a.startsWith('--dry=')) dry = a.slice('--dry='.length)
97
103
  }
98
- return false
104
+ return format === 'json' || dry === 'json'
99
105
  }
100
106
 
101
107
  /**
@@ -185,9 +191,18 @@ export function isOutOfFds(err: unknown): err is NodeJS.ErrnoException {
185
191
  export const OUT_OF_FDS_HINT =
186
192
  'the process is out of file descriptors; raise the limit (ulimit -n 4096) and re-run'
187
193
 
188
- /** A file system refusing a write for a reason no code path caused: permission or space. */
194
+ /**
195
+ * `ENAMETOOLONG`: a path past what the file system takes (PATH_MAX, 4096
196
+ * bytes on Linux; NAME_MAX for one name). The path is the user's: an
197
+ * output that deep failed its save as an internal error.
198
+ */
199
+ function isNameTooLong(err: unknown): err is NodeJS.ErrnoException {
200
+ return err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENAMETOOLONG'
201
+ }
202
+
203
+ /** A file system refusing a path for a reason no code path caused: permission, space or length. */
189
204
  export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
190
- return isPermissionError(err) || isDiskFull(err)
205
+ return isPermissionError(err) || isDiskFull(err) || isNameTooLong(err)
191
206
  }
192
207
 
193
208
  export const DISK_FULL_HINT = 'the disk that path is on is full'
@@ -205,6 +220,7 @@ const READ_SYSCALLS = new Set(['scandir', 'stat', 'lstat', 'readlink', 'read', '
205
220
  export function fsRefusalHint(err: NodeJS.ErrnoException): string {
206
221
  const { syscall } = err
207
222
  if (isDiskFull(err)) return DISK_FULL_HINT
223
+ if (isNameTooLong(err)) return 'that path is longer than the file system allows; shorten it'
208
224
  // A listing told to be a write sent the user to chmod the wrong thing.
209
225
  return READ_SYSCALLS.has(syscall ?? '') ? READ_PERMISSION_HINT : PERMISSION_HINT
210
226
  }
@@ -0,0 +1,60 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import path from 'node:path'
4
+ import { UserError } from './errors.js'
5
+
6
+ export const OWN_DIR_HINT =
7
+ 'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
8
+
9
+ /** Cache directories this process already found to be vx's own, with the workspace root asked for. */
10
+ const passed = new Set<string>()
11
+
12
+ /**
13
+ * Refuses a cache directory that resolves to the home directory, the file
14
+ * system root, or a directory holding the workspace or a repository:
15
+ * compared unresolved, a `.vx/cache` linked to `~` or `..` passed, the run
16
+ * wrote its index and config bundle there, and `vx cache clean` deleted
17
+ * every entry (the home directory; the workspace's `.git` and config). The
18
+ * config bundle asks first and the cache's open again: one realpath, as a
19
+ * pass is kept for the process; a refusal is decided afresh each time. A
20
+ * directory not there yet is made as named, by its caller.
21
+ */
22
+ export function refuseForeignCacheDir(cacheDir: string, workspaceRoot: string | undefined): void {
23
+ const memo = `${path.resolve(cacheDir)}\0${workspaceRoot ?? ''}`
24
+ if (passed.has(memo)) return
25
+ let real: string
26
+ try {
27
+ real = realpathSync(cacheDir)
28
+ } catch {
29
+ return
30
+ }
31
+ const home = process.env['HOME'] || homedir()
32
+ let what: string | undefined
33
+ if (real === path.parse(real).root) what = 'the file system root'
34
+ else if (sameDir(real, home)) what = 'the home directory'
35
+ else if (
36
+ (workspaceRoot !== undefined && !path.relative(real, workspaceRoot).startsWith('..')) ||
37
+ existsSync(path.join(real, '.git'))
38
+ )
39
+ what = 'a directory holding the workspace or a repository'
40
+ if (what === undefined) {
41
+ passed.add(memo)
42
+ return
43
+ }
44
+ throw new UserError(
45
+ `cache directory ${cacheDir} resolves to ${real}, ${what}: vx would keep its index there and \`vx cache clean\` would delete from it. ${OWN_DIR_HINT}`,
46
+ 'VX_E_CONFIG',
47
+ )
48
+ }
49
+
50
+ function sameDir(a: string, b: string): boolean {
51
+ if (a === path.resolve(b)) return true
52
+ // A home named through a link: stats only when the names could agree.
53
+ if (path.basename(a) !== path.basename(b)) return false
54
+ try {
55
+ const [x, y] = [statSync(a), statSync(b)]
56
+ return x.ino === y.ino && x.dev === y.dev
57
+ } catch {
58
+ return false
59
+ }
60
+ }
@@ -4,6 +4,8 @@
4
4
  // knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
5
5
  // and that a directory holding a tracked file is not ignored.
6
6
 
7
+ import { existsSync } from 'node:fs'
8
+ import path from 'node:path'
7
9
  import { gitEnv } from './hide-env.js'
8
10
  import { isLiteralPattern, normalizeGlob } from './paths.js'
9
11
  import { executablePath } from './which.js'
@@ -37,6 +39,56 @@ export function ignoreProbes(glob: string): { paths: string[]; deep: boolean; di
37
39
 
38
40
  const CHECK_IGNORE = ['check-ignore', '--stdin', '-z']
39
41
 
42
+ /**
43
+ * Per directory git runs in, whether its `core.ignorecase=true` is false on
44
+ * disk: a `.git` copied from macOS to Linux keeps the setting, and git then
45
+ * takes a new `a.txt` beside the tracked `A.txt` for the tracked file and
46
+ * lists it nowhere. Asked only where git's config was read anyway.
47
+ */
48
+ const caseLies = new Map<string, boolean>()
49
+
50
+ /**
51
+ * Whether `core.ignorecase=true`, which git in `dir` reported, is false on
52
+ * disk: one stat of `probe` (an existing path) with its name's case
53
+ * swapped, once per `dir`. A name with no cased letter cannot tell, so git
54
+ * is trusted.
55
+ */
56
+ export function ignoreCaseLies(dir: string, probe: string): boolean {
57
+ const at = path.resolve(dir)
58
+ let lies = caseLies.get(at)
59
+ if (lies === undefined) {
60
+ const name = path.basename(probe)
61
+ const lower = name.toLowerCase()
62
+ const twin = lower === name ? name.toUpperCase() : lower
63
+ lies = twin !== name && !existsSync(path.join(path.dirname(probe), twin))
64
+ caseLies.set(at, lies)
65
+ }
66
+ return lies
67
+ }
68
+
69
+ /**
70
+ * What a git spawn in `cwd` prepends so a wrong `core.ignorecase` hides no
71
+ * file: `-c core.ignorecase=false` at or under a directory `ignoreCaseLies`
72
+ * found lying (the disk there tells case apart, so the override is git's
73
+ * own answer for any repository on it), else nothing.
74
+ */
75
+ export function gitCaseArgs(cwd: string): readonly string[] {
76
+ if (caseLies.size === 0) return []
77
+ const at = path.resolve(cwd)
78
+ for (const [dir, lies] of caseLies)
79
+ if (lies && (at === dir || at.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)))
80
+ return CASE_SENSITIVE
81
+ return []
82
+ }
83
+
84
+ const CASE_SENSITIVE = ['-c', 'core.ignorecase=false']
85
+
86
+ // git reads each line as a pathspec, so `:top/dist/` was asked as
87
+ // `top/dist/`. `./` keeps it the path it is (this command refuses
88
+ // `:(literal)`), and git echoes each path back as given.
89
+ const stdinOf = (paths: readonly string[]): Buffer =>
90
+ Buffer.from(paths.map((p) => `./${p}\0`).join(''))
91
+
40
92
  function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
41
93
  // 0: some ignored; 1: none; anything else (no work tree): undecided.
42
94
  if (exitCode === 1) return new Set()
@@ -45,7 +97,8 @@ function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
45
97
  new TextDecoder()
46
98
  .decode(stdout)
47
99
  .split('\0')
48
- .filter((p) => p !== ''),
100
+ .filter((p) => p !== '')
101
+ .map((p) => p.slice(2)),
49
102
  )
50
103
  }
51
104
 
@@ -57,10 +110,10 @@ export async function checkIgnore(
57
110
  if (paths.length === 0) return new Set()
58
111
  try {
59
112
  const proc = Bun.spawn({
60
- cmd: [executablePath('git'), ...CHECK_IGNORE],
113
+ cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...CHECK_IGNORE],
61
114
  env: gitEnv(),
62
115
  cwd,
63
- stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
116
+ stdin: stdinOf(paths),
64
117
  stdout: 'pipe',
65
118
  stderr: 'ignore',
66
119
  })
@@ -76,10 +129,10 @@ export function checkIgnoreSync(cwd: string, paths: readonly string[]): Set<stri
76
129
  if (paths.length === 0) return new Set()
77
130
  try {
78
131
  const r = Bun.spawnSync({
79
- cmd: [executablePath('git'), ...CHECK_IGNORE],
132
+ cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...CHECK_IGNORE],
80
133
  env: gitEnv(),
81
134
  cwd,
82
- stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
135
+ stdin: stdinOf(paths),
83
136
  stdout: 'pipe',
84
137
  stderr: 'ignore',
85
138
  })
@@ -99,6 +152,7 @@ export function listedSync(cwd: string, dirs: readonly string[]): string[] | nul
99
152
  const r = Bun.spawnSync({
100
153
  cmd: [
101
154
  executablePath('git'),
155
+ ...gitCaseArgs(cwd),
102
156
  'ls-files',
103
157
  '-z',
104
158
  '--cached',
package/src/util/index.ts CHANGED
@@ -75,6 +75,7 @@ export {
75
75
  dieOf,
76
76
  exitClaimedBySignal,
77
77
  killGraceMs,
78
+ POST_EXIT_DRAIN_MS,
78
79
  settleWithin,
79
80
  teardownTimeoutMs,
80
81
  testTimerMs,
@@ -101,18 +102,30 @@ export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
101
102
  export {
102
103
  checkIgnore,
103
104
  checkIgnoreSync,
105
+ gitCaseArgs,
106
+ ignoreCaseLies,
104
107
  ignoreProbes,
105
108
  listedSync,
106
109
  wholeIgnored,
107
110
  } from './git-ignored.js'
111
+ export { OWN_DIR_HINT, refuseForeignCacheDir } from './foreign-cache-dir.js'
108
112
  export { realPath, realpathOf } from './real-path.js'
109
- export { IGNORE_ALL, ignoreDir, refuseSymlink, writeNoFollow } from './write-file.js'
113
+ export {
114
+ IGNORE_ALL,
115
+ ignoreDir,
116
+ type PendingWrite,
117
+ refuseSymlink,
118
+ writeAllOrNone,
119
+ writeNoFollow,
120
+ } from './write-file.js'
110
121
  export {
111
122
  heldSecrets,
123
+ holdSecret,
112
124
  maskedCommand,
113
125
  maskedStreams,
114
126
  maskedLine,
115
127
  MASKED,
128
+ maskStreams,
116
129
  secretMask,
117
130
  secretNamed,
118
131
  type SecretMask,
@@ -134,3 +147,4 @@ export {
134
147
  parseDependencySpec,
135
148
  type DependencySpec,
136
149
  } from './dependency-spec.js'
150
+ export { stripEscapes } from './plain-text.js'
package/src/util/paths.ts CHANGED
@@ -202,7 +202,7 @@ export function isLiteralPattern(glob: string): boolean {
202
202
  }
203
203
 
204
204
  /**
205
- * The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
205
+ * The alphabet of a TASK glob (`inputs.files`, `outputs.files`,
206
206
  * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
207
207
  * need the POSITION of the first wildcard rather than a verdict. Every site
208
208
  * that asks "must this declaration be matched?" reads this one regex: item
@@ -306,13 +306,50 @@ function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => bo
306
306
  if (patterns.length === 0) return () => false
307
307
  const sources = patterns.map(regExpSource)
308
308
  const globs = patterns.map(taskGlob)
309
- if (sources.every((r) => r !== null)) {
310
- const re = new RegExp(`^(?:${sources.join('|')})$`, 'u')
309
+ if (!sources.every((r) => r !== null)) return (rel) => globs.some((g) => g.match(rel))
310
+ // Each `**/` alternative opened with its own prefix, retried at every
311
+ // slash of the path: 1000 `**/fileN.ts` globs took 24 s over 200,000
312
+ // paths. One shared prefix before their alternation takes 50 ms.
313
+ const deep: string[] = []
314
+ const rest: string[] = []
315
+ for (const s of sources as string[]) {
316
+ if (s.startsWith(LEADING_GLOBSTAR)) deep.push(s.slice(LEADING_GLOBSTAR.length))
317
+ else rest.push(s)
318
+ }
319
+ const res = alternations(rest)
320
+ for (let i = 0; i < deep.length; i += ALTERNATIVES_PER_REGEXP) {
321
+ const part = deep.slice(i, i + ALTERNATIVES_PER_REGEXP)
322
+ res.push(new RegExp(`^${LEADING_GLOBSTAR}(?:${part.join('|')})$`, 'u'))
323
+ }
324
+ if (res.length === 1) {
325
+ const re = res[0]!
311
326
  return (rel) => (rel.endsWith('/') ? globs.some((g) => g.match(rel)) : re.test(rel))
312
327
  }
313
- return (rel) => globs.some((g) => g.match(rel))
328
+ return (rel) =>
329
+ rel.endsWith('/') ? globs.some((g) => g.match(rel)) : res.some((re) => re.test(rel))
330
+ }
331
+
332
+ /**
333
+ * Alternatives per compiled RegExp. Bun's engine falls off a cliff past a
334
+ * few thousand: over 200k paths, 2000 literal-file alternatives took 32 ms,
335
+ * 4000 took 482 ms and 8000 took 15 s, so a project listing 4000 files in
336
+ * `inputs.files` paid that on every key. Below the cliff, the cost of
337
+ * splitting is flat.
338
+ */
339
+ const ALTERNATIVES_PER_REGEXP = 1000
340
+
341
+ function alternations(sources: readonly string[]): RegExp[] {
342
+ const out: RegExp[] = []
343
+ for (let i = 0; i < sources.length; i += ALTERNATIVES_PER_REGEXP) {
344
+ const part = sources.slice(i, i + ALTERNATIVES_PER_REGEXP)
345
+ out.push(new RegExp(`^(?:${part.join('|')})$`, 'u'))
346
+ }
347
+ return out
314
348
  }
315
349
 
350
+ /** What {@link regExpSource} writes for a leading `**` segment. */
351
+ const LEADING_GLOBSTAR = '(?:[^]*/)?'
352
+
316
353
  const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
317
354
 
318
355
  /**
@@ -336,7 +373,7 @@ function regExpSource(pattern: string): string | null {
336
373
  // more names (`src/**` does not match `src`).
337
374
  if (segments.length === 1) out += '[^]*'
338
375
  else if (last) out += '/[^]+'
339
- else out += i === 0 ? '(?:[^]*/)?' : '/(?:[^]*/)?'
376
+ else out += i === 0 ? LEADING_GLOBSTAR : `/${LEADING_GLOBSTAR}`
340
377
  continue
341
378
  }
342
379
  if (seg === '' || seg === '.' || seg === '..' || seg.includes('**')) return null
@@ -535,9 +572,15 @@ function classEnd(pattern: string, i: number): number {
535
572
  return end === -1 ? i : end
536
573
  }
537
574
 
575
+ // A `\x` is one character to both scans: it opens, closes and cuts nothing,
576
+ // and stays in its alternative for the glob to read. Expansion turned off
577
+ // at any escape, `{src,app/\[id\]}` named neither tree.
538
578
  function expandBraces(pattern: string, slashOnly: boolean): string[] {
539
- if (pattern.includes('\\')) return [pattern]
540
579
  for (let open = 0; open < pattern.length; open++) {
580
+ if (pattern[open] === '\\') {
581
+ open++
582
+ continue
583
+ }
541
584
  if (pattern[open] === '[') {
542
585
  open = classEnd(pattern, open)
543
586
  continue
@@ -548,7 +591,8 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
548
591
  let close = -1
549
592
  for (let i = open; i < pattern.length && close === -1; i++) {
550
593
  const c = pattern[i]
551
- if (c === '[') i = classEnd(pattern, i)
594
+ if (c === '\\') i++
595
+ else if (c === '[') i = classEnd(pattern, i)
552
596
  else if (c === '{') depth++
553
597
  else if (c === '}' && --depth === 0) close = i
554
598
  else if (c === ',' && depth === 1) cuts.push(i)
@@ -0,0 +1,19 @@
1
+ // A string escape (OSC, DCS, SOS, PM, APC) through its terminator, or to the
2
+ // line's end when it has none: Bun.stripANSI drops an unterminated one with
3
+ // everything after it, so a stray title escape ate the failure reason
4
+ // printed below it. An ESC that is not ST ends it too and is left for
5
+ // Bun.stripANSI, as a terminal aborts the string there. C1 openers are not
6
+ // looked for: output is decoded UTF-8, where an 8-bit C1 byte reads as
7
+ // U+FFFD, and scanning for them doubled the cost on non-Latin-1 text.
8
+ // eslint-disable-next-line no-control-regex -- ESC is the point
9
+ const STRING_OPENER = /\x1b[\]P_^X]/
10
+ // eslint-disable-next-line no-control-regex -- ESC is the point
11
+ const STRING_ESCAPE = /\x1b[\]P_^X][^\x07\x1b\x9c\n]*(?:\x07|\x1b\\|\x9c)?/g
12
+
13
+ /** `text` without terminal escapes, never losing a line to an unterminated one. */
14
+ export function stripEscapes(text: string): string {
15
+ // Tested first, after the cheaper ESC search: the replace alone cost half
16
+ // again as much on coloured text, the regex alone 3x on plain text.
17
+ if (text.indexOf('\x1b') === -1 || !STRING_OPENER.test(text)) return Bun.stripANSI(text)
18
+ return Bun.stripANSI(text.replace(STRING_ESCAPE, ''))
19
+ }