@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
@@ -23,6 +23,7 @@ import {
23
23
  UserError,
24
24
  BUN_GLOB_WILDCARDS,
25
25
  EXTGLOB,
26
+ braceAlternatives,
26
27
  braceRange,
27
28
  BRACE_RANGE_LIMIT,
28
29
  anyTaskGlob,
@@ -115,7 +116,7 @@ const oneOf = (values: readonly string[]): string =>
115
116
 
116
117
  function validateInputStability(stability: unknown, at: string): void {
117
118
  if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
118
- throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
119
+ throw new UserError(`${at} must be an object — \`stability: { strategy: 'mutable' }\``)
119
120
  }
120
121
  assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
121
122
  const { strategy, severity } = stability as Record<string, unknown>
@@ -128,13 +129,13 @@ function validateInputStability(stability: unknown, at: string): void {
128
129
  }
129
130
 
130
131
  /**
131
- * `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY`, which override every
132
- * task's `cache.inputStability` for the run: each undefined when unset or
132
+ * `VX_INPUTS_STABILITY_STRATEGY` and `_SEVERITY`, which override every
133
+ * task's `inputs.stability` for the run: each undefined when unset or
133
134
  * empty, refused when it names no value.
134
135
  */
135
136
  export function inputStabilityEnv(): InputStability {
136
- const strategy = settingEnv('cache.inputStability.strategy') as InputStability['strategy']
137
- const severity = settingEnv('cache.inputStability.severity') as InputStability['severity']
137
+ const strategy = settingEnv('inputs.stability.strategy') as InputStability['strategy']
138
+ const severity = settingEnv('inputs.stability.severity') as InputStability['severity']
138
139
  return {
139
140
  ...(strategy !== undefined ? { strategy } : {}),
140
141
  ...(severity !== undefined ? { severity } : {}),
@@ -179,15 +180,17 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
179
180
  }
180
181
  return
181
182
  }
182
- if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
183
+ if (at === 'run.rules.dependencies') validateDependencyRules(value, where, configPath)
183
184
  else validateSettingValue(value, at, where)
184
185
  }
185
186
 
186
187
  const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
187
188
 
188
- function validateDependencyRules(value: unknown, where: string): void {
189
+ function validateDependencyRules(value: unknown, where: string, configPath: string): void {
189
190
  const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
190
191
  if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
192
+ // The whole path inside one code span, as the frame reads a setting.
193
+ const at = (i: number, rest = '') => `${configPath}: \`run.rules.dependencies[${i}]${rest}\``
191
194
  const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
192
195
  const matchable = (v: string, field: string) => {
193
196
  const why = ruleSelectorProblem(v)
@@ -198,24 +201,24 @@ function validateDependencyRules(value: unknown, where: string): void {
198
201
  )
199
202
  }
200
203
  for (const [i, rule] of (value as unknown[]).entries()) {
201
- const at = `${where}[${i}]`
202
204
  if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
203
- throw new UserError(`${at} must be { from, allow?, deny? }`)
204
- assertKnownFields(rule, RULE_FIELDS, at)
205
+ throw new UserError(`${at(i)} must be { from, allow?, deny? }`)
206
+ assertKnownFields(rule, RULE_FIELDS, at(i))
205
207
  const { from, allow, deny } = rule as Record<string, unknown>
206
- if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
207
- matchable(from as string, `${at}.from`)
208
+ if (!selector(from)) throw new UserError(`${at(i, '.from')} must be a selector like 'tag:app'`)
209
+ matchable(from as string, at(i, '.from'))
208
210
  for (const [k, list] of [
209
211
  ['allow', allow],
210
212
  ['deny', deny],
211
213
  ] as const) {
214
+ const field = at(i, `.${k}`)
212
215
  if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
213
- throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
214
- for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, `${at}.${k}[${j}]`)
216
+ throw new UserError(`${field} must be an array of selectors like 'tag:lib'`)
217
+ for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, at(i, `.${k}[${j}]`))
215
218
  }
216
219
  // A rule that says nothing reads as a constraint and checks nothing.
217
220
  if (allow === undefined && deny === undefined)
218
- throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
221
+ throw new UserError(`${at(i)} names neither \`allow\` nor \`deny\``)
219
222
  }
220
223
  }
221
224
 
@@ -231,6 +234,8 @@ function ruleSelectorProblem(s: string): string | null {
231
234
  // The root's own name, which the filter reads as the root's directory.
232
235
  if (s === ROOT_NAME) return null
233
236
  if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
237
+ // The filter parser refuses one, which the catch below would read as a walk.
238
+ if (hasControl(s)) return 'holds a control character'
234
239
  const walk = "starts or ends with '...', which a filter reads as a dependency walk"
235
240
  let f: ReturnType<typeof parseFilter>
236
241
  try {
@@ -512,12 +517,14 @@ export function validateProjectConfig(
512
517
  config: ProjectConfig,
513
518
  configPath: string,
514
519
  ownConfig: string | null = configPath,
520
+ /** The caller refused non-JSON values already and passes a JSON copy (project-loader.ts). */
521
+ jsonCopy = false,
515
522
  ): void {
516
523
  // Before the schema, on the object as it stands: the key folds its JSON,
517
524
  // so a value JSON cannot carry is refused here and in the config worker
518
525
  // alike (json-data.ts). A plugin's `project` edit and a lock entry cross
519
526
  // this line too.
520
- const [nonJson] = nonJsonPaths(config)
527
+ const [nonJson] = jsonCopy ? [] : nonJsonPaths(config)
521
528
  if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
522
529
  // The top level too: `task:` (singular) loaded as a project with no
523
530
  // tasks and every request against it said "no projects declare".
@@ -590,9 +597,18 @@ export function validateProjectConfig(
590
597
  const exec = (task as { exec?: unknown }).exec
591
598
  const dependsOn = (task as { dependsOn?: unknown }).dependsOn
592
599
  const cache = (task as { cache?: unknown }).cache
600
+ if (cache !== undefined && typeof cache !== 'boolean') {
601
+ throw new UserError(`${where}.cache must be true or false`)
602
+ }
593
603
  if (exec !== undefined) {
594
604
  if (typeof exec !== 'object' || exec === null) {
595
- throw new UserError(`${where}.exec must be an object with a \`command\` string`)
605
+ // A command written as the exec itself, as the task-as-a-string
606
+ // refusal above spells its fix.
607
+ throw new UserError(
608
+ typeof exec === 'string'
609
+ ? `${where}.exec must be an object — a command is { exec: { command: ${JSON.stringify(exec)} } }`
610
+ : `${where}.exec must be an object with a \`command\` string`,
611
+ )
596
612
  }
597
613
  assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
598
614
  const sandbox = (exec as { sandbox?: unknown }).sandbox
@@ -623,9 +639,9 @@ export function validateProjectConfig(
623
639
  // Documented and never held: an uncached 'only' task loaded, and the
624
640
  // REAPI executor refused it at run time for want of described inputs
625
641
  // (item 1001).
626
- if (remote === 'only' && cache === undefined) {
642
+ if (remote === 'only' && cache !== true) {
627
643
  throw new UserError(
628
- `${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
644
+ `${where}.exec.remote 'only' needs \`cache: true\`: its inputs are what a worker reproduces ` +
629
645
  `and its key is the address of its remote record`,
630
646
  )
631
647
  }
@@ -643,11 +659,33 @@ export function validateProjectConfig(
643
659
  )
644
660
  }
645
661
  }
662
+ const worker = (exec as { worker?: unknown }).worker
663
+ if (worker !== undefined && (typeof worker !== 'string' || worker.trim().length === 0)) {
664
+ throw new UserError(`${where}.exec.worker must be a worker type's name (or omitted)`)
665
+ }
646
666
  const env = (exec as { env?: unknown }).env
647
667
  if (env !== undefined) {
648
668
  if (typeof env !== 'object' || env === null) {
649
669
  throw new UserError(`${where}.exec.env must be an object (or omitted)`)
650
670
  }
671
+ // `env: { NODE_ENV: 'production' }` (Turbo's and Nx's shape): name
672
+ // the level the variable belongs at, not only the allowed fields.
673
+ const plain = Array.isArray(env)
674
+ ? undefined
675
+ : Object.entries(env).find(
676
+ ([k, v]) =>
677
+ !ENV_FIELDS.has(k) &&
678
+ // `passthrough` is a typo of a field, not a variable.
679
+ nearest(k, ENV_FIELDS) === undefined &&
680
+ ['string', 'number', 'boolean'].includes(typeof v),
681
+ )
682
+ if (plain !== undefined) {
683
+ throw new UserError(
684
+ `${where}.exec.env has unknown field "${plain[0]}" ` +
685
+ `(allowed: ${[...ENV_FIELDS].sort().join(', ')}) — vx spells it ` +
686
+ `exec.env.define: { ${plain[0]}: ${JSON.stringify(String(plain[1]))} }`,
687
+ )
688
+ }
651
689
  // `env: { set: {...} }` loaded and defined nothing — the one level
652
690
  // without this check until 2026-09-10 (tests/schema-unknown-keys).
653
691
  assertKnownFields(env, ENV_FIELDS, `${where}.exec.env`)
@@ -712,7 +750,7 @@ export function validateProjectConfig(
712
750
  throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
713
751
  }
714
752
  if (interactive === true) {
715
- if (cache !== undefined) {
753
+ if (cache === true) {
716
754
  throw new UserError(
717
755
  `${where}: \`cache\` is not allowed on an interactive task — what it does ` +
718
756
  `depends on what is typed, which no key holds`,
@@ -765,7 +803,7 @@ export function validateProjectConfig(
765
803
  )
766
804
  }
767
805
  }
768
- if (cache !== undefined) {
806
+ if (cache === true) {
769
807
  throw new UserError(
770
808
  `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
771
809
  `don't terminate, so there's no exit to cache`,
@@ -801,9 +839,12 @@ export function validateProjectConfig(
801
839
  }
802
840
  if (dependsOn !== undefined) {
803
841
  if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
842
+ // One name, not in a list: the fix is that list.
804
843
  throw new UserError(
805
- `${where}.dependsOn must be an array of strings ` +
806
- `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
844
+ typeof dependsOn === 'string'
845
+ ? `${where}.dependsOn must be an array — write dependsOn: [${JSON.stringify(dependsOn)}]`
846
+ : `${where}.dependsOn must be an array of strings ` +
847
+ `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
807
848
  )
808
849
  }
809
850
  }
@@ -919,6 +960,10 @@ export function validateProjectConfig(
919
960
  throw err
920
961
  }
921
962
  }
963
+ // The selection starts empty and a `!` entry only subtracts, so a
964
+ // list of negations folded no dependency and the key stopped moving
965
+ // with every upstream, as a negation-only inputs.files does.
966
+ assertTaskFiltersNotNegationOnly(taskFilters, `${where}.inputs.tasks`)
922
967
  assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
923
968
  }
924
969
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
@@ -943,16 +988,8 @@ export function validateProjectConfig(
943
988
  validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
944
989
  }
945
990
  }
946
- if (cache !== undefined) {
947
- if (typeof cache !== 'object' || cache === null) {
948
- throw new UserError(
949
- `${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
950
- )
951
- }
952
- assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
953
- const stability = (cache as { inputStability?: unknown }).inputStability
954
- if (stability !== undefined)
955
- validateInputStability(stability, `${where}.cache.inputStability`)
991
+ const outputs = (task as { outputs?: unknown }).outputs
992
+ if (cache === true) {
956
993
  // The key is the task's inputs, and a group runs nothing to cache.
957
994
  if (exec === undefined) {
958
995
  throw new UserError(
@@ -964,23 +1001,38 @@ export function validateProjectConfig(
964
1001
  `${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
965
1002
  )
966
1003
  }
967
- const outputs = (cache as { outputs?: unknown }).outputs
968
- if (outputs === undefined || outputs === null) {
969
- throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
1004
+ if (outputs === undefined) {
1005
+ throw new UserError(
1006
+ `${where}: \`cache\` needs \`outputs\` — what the task produces, \`outputs: { files: [...] }\` (\`[]\` for none)`,
1007
+ )
1008
+ }
1009
+ }
1010
+ const stability = (inputs as { stability?: unknown } | undefined)?.stability
1011
+ if (stability !== undefined) {
1012
+ validateInputStability(stability, `${where}.inputs.stability`)
1013
+ if (exec === undefined) {
1014
+ throw new UserError(
1015
+ `${where}.inputs.stability needs \`exec\` — a task with no command runs nothing that could write its inputs`,
1016
+ )
1017
+ }
1018
+ }
1019
+ if (outputs !== undefined) {
1020
+ if (outputs === null || typeof outputs !== 'object') {
1021
+ throw new UserError(`${where}.outputs must be an object — \`outputs: { files: [...] }\``)
970
1022
  }
971
- if (typeof outputs !== 'object') {
1023
+ assertKnownFields(outputs, OUTPUT_FIELDS, `${where}.outputs`)
1024
+ if (exec === undefined) {
972
1025
  throw new UserError(
973
- `${where}.cache.outputs must be an object — \`outputs: { files: [...] }\``,
1026
+ `${where}.outputs needs \`exec\` — a task with no command writes nothing`,
974
1027
  )
975
1028
  }
976
- assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
977
1029
  const clean = (outputs as { clean?: unknown }).clean
978
1030
  if (clean !== undefined && typeof clean !== 'boolean') {
979
- throw new UserError(`${where}.cache.outputs.clean must be true or false`)
1031
+ throw new UserError(`${where}.outputs.clean must be true or false`)
980
1032
  }
981
1033
  const outFiles = (outputs as { files?: unknown }).files
982
1034
  if (!Array.isArray(outFiles)) {
983
- throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
1035
+ throw new UserError(`${where}.outputs.files must be an array of glob strings`)
984
1036
  }
985
1037
  // Reject zero-length strings and absolute paths up front.
986
1038
  // Both reach `resolveOutputs` as undefined behavior; the error
@@ -988,26 +1040,26 @@ export function validateProjectConfig(
988
1040
  // at the user's config. Fail loud at load time.
989
1041
  for (const g of outFiles as unknown[]) {
990
1042
  if (typeof g !== 'string' || g.length === 0) {
991
- throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
1043
+ throw new UserError(`${where}.outputs.files must be an array of non-empty strings`)
992
1044
  }
993
1045
  if (isAbsoluteGlob(g)) {
994
1046
  throw new UserError(
995
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
1047
+ `${where}.outputs.files: absolute paths are not allowed (got "${g}") — ` +
996
1048
  `outputs must be project-relative globs`,
997
1049
  )
998
1050
  }
999
1051
  if (hasParentSegment(g)) {
1000
1052
  throw new UserError(
1001
- `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1053
+ `${where}.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1002
1054
  `outputs must stay within the project (vx clears a task's outputs before it runs, ` +
1003
1055
  `so a glob that escapes the project would delete files outside it)`,
1004
1056
  )
1005
1057
  }
1006
- assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
1007
- assertNoForeignToken(g, `${where}.cache.outputs.files`)
1058
+ assertPosixGlob(g, `${where}.outputs.files`, 'project')
1059
+ assertNoForeignToken(g, `${where}.outputs.files`)
1008
1060
  if (namesDirItself(g)) {
1009
1061
  throw new UserError(
1010
- `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
1062
+ `${where}.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
1011
1063
  `name the directory the task writes, such as "dist/**"`,
1012
1064
  )
1013
1065
  }
@@ -1016,31 +1068,31 @@ export function validateProjectConfig(
1016
1068
  // a "corrupt artifact", dropped and run again.
1017
1069
  if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
1018
1070
  throw new UserError(
1019
- `${where}.cache.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
1071
+ `${where}.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
1020
1072
  `artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
1021
1073
  )
1022
1074
  }
1023
1075
  // A `!` entry takes a path back from the outputs (A-44): it is not
1024
1076
  // cleaned, saved or restored, and it stays an input.
1025
- assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
1077
+ assertNotDoubleNegated(g, `${where}.outputs.files`)
1026
1078
  // Taken back by a `!`, the file is never cleaned or restored.
1027
1079
  const own = g.startsWith('!')
1028
1080
  ? undefined
1029
1081
  : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
1030
1082
  if (own !== undefined) {
1031
1083
  throw new UserError(
1032
- `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
1084
+ `${where}.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
1033
1085
  `task's outputs before it runs and restores them on a hit, so the project would lose ` +
1034
1086
  `its config. Name the directory the task writes, such as "dist/**", ` +
1035
1087
  `or take the file back with "!${own}".`,
1036
1088
  )
1037
1089
  }
1038
1090
  }
1039
- assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
1040
- assertNoLiteralTakenBack(outFiles as string[], `${where}.cache.outputs.files`, OUTPUT_LOST)
1091
+ assertOutputsNotNegationOnly(outFiles as string[], `${where}.outputs.files`)
1092
+ assertNoLiteralTakenBack(outFiles as string[], `${where}.outputs.files`, OUTPUT_LOST)
1041
1093
  const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
1042
1094
  if (wsOutputs !== undefined) {
1043
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
1095
+ validateWorkspaceGlobs(wsOutputs, `${where}.outputs.workspaceFiles`, false)
1044
1096
  }
1045
1097
  }
1046
1098
  }
@@ -1055,7 +1107,7 @@ export function validateProjectConfig(
1055
1107
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
1056
1108
  // from both.
1057
1109
  const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
1058
- const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
1110
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'outputs', 'cache'])
1059
1111
  const EXEC_FIELDS = new Set([
1060
1112
  'command',
1061
1113
  'env',
@@ -1066,10 +1118,10 @@ const EXEC_FIELDS = new Set([
1066
1118
  'remote',
1067
1119
  'sandbox',
1068
1120
  'platforms',
1121
+ 'worker',
1069
1122
  ])
1070
1123
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
1071
1124
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
1072
- const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
1073
1125
  const INPUT_FIELDS = new Set([
1074
1126
  'files',
1075
1127
  'workspaceFiles',
@@ -1077,8 +1129,9 @@ const INPUT_FIELDS = new Set([
1077
1129
  'tasks',
1078
1130
  'runtime',
1079
1131
  'workspaceRuntime',
1132
+ 'stability',
1080
1133
  ])
1081
- const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1134
+ const OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1082
1135
 
1083
1136
  /**
1084
1137
  * Another runner's spelling of a field vx has under another name, by the
@@ -1090,8 +1143,10 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1090
1143
  [
1091
1144
  TASK_FIELDS,
1092
1145
  {
1093
- outputs: '`cache.outputs.files`',
1094
1146
  env: '`inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
1147
+ // wireit's script keys.
1148
+ output: '`outputs.files`',
1149
+ dependencies: "`dependsOn` (an upstream's key goes into this one)",
1095
1150
  passThroughEnv: '`exec.env.passThrough`',
1096
1151
  command: '`exec.command`',
1097
1152
  cmd: '`exec.command`',
@@ -1128,18 +1183,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1128
1183
  shell: 'no field: `command` always runs in a shell',
1129
1184
  },
1130
1185
  ],
1131
- // wireit's `files` / `output` and include-style spellings written into
1132
- // a cache block, refused with no word on where they go (D-118).
1133
- [
1134
- CACHE_FIELDS,
1135
- {
1136
- files: "the task's `inputs.files`",
1137
- output: '`outputs.files`',
1138
- env: "the task's `inputs.env`",
1139
- dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
1140
- enabled: 'no field: a task with a `cache` block caches, one without runs every time',
1141
- },
1142
- ],
1143
1186
  [
1144
1187
  INPUT_FIELDS,
1145
1188
  {
@@ -1151,7 +1194,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1151
1194
  },
1152
1195
  ],
1153
1196
  [
1154
- CACHE_OUTPUT_FIELDS,
1197
+ OUTPUT_FIELDS,
1155
1198
  {
1156
1199
  globs: '`files`',
1157
1200
  include: '`files`',
@@ -1176,12 +1219,11 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1176
1219
  sourceRoot: "a task's `inputs.files`",
1177
1220
  projectType: 'no field: vx runs every project alike',
1178
1221
  ...Object.fromEntries(
1179
- ['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
1222
+ ['dependsOn', 'inputs', 'outputs', 'cache', 'exec', 'description'].map((k) => [
1180
1223
  k,
1181
1224
  `\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
1182
1225
  ]),
1183
1226
  ),
1184
- outputs: '`tasks.<name>.cache.outputs.files`',
1185
1227
  env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1186
1228
  },
1187
1229
  ],
@@ -1252,7 +1294,7 @@ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefin
1252
1294
  for (const [field, own] of [
1253
1295
  ['exec', EXEC_FIELDS],
1254
1296
  ['inputs', INPUT_FIELDS],
1255
- ['cache', CACHE_FIELDS],
1297
+ ['outputs', OUTPUT_FIELDS],
1256
1298
  ] as const) {
1257
1299
  if (own.has(key)) return `\`${field}.${key}\``
1258
1300
  }
@@ -1273,7 +1315,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1273
1315
  if (!allowed.has(key)) {
1274
1316
  // The nearest accepted spelling first: the list says what the level
1275
1317
  // takes, the hint says which one was meant.
1276
- const foreign = FOREIGN_FIELDS.get(allowed)?.[key] ?? nestedHome(allowed, key)
1318
+ // Own keys only: `constructor` read Object's and printed its source.
1319
+ const spellings = FOREIGN_FIELDS.get(allowed)
1320
+ const foreign =
1321
+ (spellings !== undefined && Object.hasOwn(spellings, key) ? spellings[key] : undefined) ??
1322
+ nestedHome(allowed, key)
1277
1323
  const near =
1278
1324
  nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
1279
1325
  throw new UserError(
@@ -1383,14 +1429,25 @@ function assertNoForeignToken(glob: string, where: string): void {
1383
1429
  * and names both.
1384
1430
  */
1385
1431
  function assertNoGluedGlobstar(glob: string, where: string): void {
1432
+ if (gluesGlobstar(glob)) throw gluedGlobstar(glob, `"${glob}"`, where)
1433
+ // Split on `{` `}` `,` alone, `src/{a,**}.ts` passed and expanded to
1434
+ // `src/**.ts`, one level.
1435
+ const alt = braceAlternatives(glob).find(gluesGlobstar)
1436
+ if (alt !== undefined) throw gluedGlobstar(alt, `"${glob}" expands to "${alt}": that`, where)
1437
+ }
1438
+
1439
+ function gluesGlobstar(glob: string): boolean {
1386
1440
  const body = glob.startsWith('!') ? glob.slice(1) : glob
1387
- if (!body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))) return
1441
+ return body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))
1442
+ }
1443
+
1444
+ function gluedGlobstar(glob: string, subject: string, where: string): UserError {
1388
1445
  const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
1389
1446
  const one = glob.replace(/\*{2,}/g, (run, at: number) =>
1390
1447
  /[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
1391
1448
  )
1392
- throw new UserError(
1393
- `${where}: "${glob}" glues ** to other text in one path segment, which vx's glob engine ` +
1449
+ return new UserError(
1450
+ `${where}: ${subject} glues ** to other text in one path segment, which vx's glob engine ` +
1394
1451
  `reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
1395
1452
  `for one level.`,
1396
1453
  )
@@ -1538,7 +1595,7 @@ function assertInputGlob(glob: string, where: string): void {
1538
1595
  if (glob.replace(/^!/, '').split('/').includes('.git')) {
1539
1596
  throw new UserError(
1540
1597
  `${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
1541
- `inputs are the repository's files`,
1598
+ `inputs are the workspace's files`,
1542
1599
  )
1543
1600
  }
1544
1601
  }
@@ -1662,8 +1719,8 @@ function isEnvName(name: unknown): name is string {
1662
1719
  * A task name `dependsOn`, `inputs.tasks` and the CLI can name. Each
1663
1720
  * refused shape loaded and could not be referenced: `x#y` read as project
1664
1721
  * `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
1665
- * under `--all` as `a#` and `vx run a#` refused it, and a padded name was
1666
- * trimmed away by the reader (item 1000).
1722
+ * under `--all` as `a#` and `vx run a#` refused it, `-x` as a flag, and a
1723
+ * padded name was trimmed away by the reader (item 1000).
1667
1724
  */
1668
1725
  function assertTaskName(name: string, configPath: string): void {
1669
1726
  const why = taskNameProblem(name)
@@ -1686,9 +1743,11 @@ export function taskNameProblem(name: string): string | null {
1686
1743
  ? "holds '*', which makes it a pattern"
1687
1744
  : name.startsWith('^') || name.startsWith('!')
1688
1745
  ? `starts with '${name[0]}', which names dependencies' tasks or negates`
1689
- : hasControl(name)
1690
- ? 'holds a control character'
1691
- : null
1746
+ : name.startsWith('-')
1747
+ ? "starts with '-', which the CLI reads as a flag"
1748
+ : hasControl(name)
1749
+ ? 'holds a control character'
1750
+ : null
1692
1751
  }
1693
1752
 
1694
1753
  /**
@@ -1699,6 +1758,9 @@ export function taskNameProblem(name: string): string | null {
1699
1758
  function tagProblem(tag: string): string | null {
1700
1759
  if (tag.trim() !== tag) return 'has surrounding whitespace'
1701
1760
  if (tag.includes('*')) return "holds '*', which makes it a pattern"
1761
+ // Refused by the caller in its own words; the filter parser refuses one
1762
+ // too, which the catch below would read as a walk.
1763
+ if (hasControl(tag)) return null
1702
1764
  const walk = "ends in '...', which a filter reads as a dependency walk"
1703
1765
  let f: ReturnType<typeof parseFilter>
1704
1766
  try {
@@ -1737,6 +1799,18 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
1737
1799
  )
1738
1800
  }
1739
1801
 
1802
+ function assertTaskFiltersNotNegationOnly(filters: readonly string[], where: string): void {
1803
+ if (filters.length === 0) return
1804
+ if (filters.some((f) => !f.startsWith('!'))) return
1805
+ throw new UserError(
1806
+ `${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(filters)}) — ` +
1807
+ `negations only subtract from the dependencies a positive entry already selected, so this ` +
1808
+ `task would fold no upstream hash and its cache key would stop tracking its dependencies. ` +
1809
+ `Add the positive entry you meant, e.g. ['*', '^*', ${JSON.stringify(filters[0])}], ` +
1810
+ `or use [] to decouple on purpose.`,
1811
+ )
1812
+ }
1813
+
1740
1814
  /**
1741
1815
  * A `!` entry subtracts wherever it sits in the list, so a file named
1742
1816
  * outright after it (`['src/**', '!src/gen/**', 'src/gen/keep.ts']`) never
@@ -1828,6 +1902,7 @@ const SANDBOX_FIELDS = new Set([
1828
1902
  'ignore',
1829
1903
  'weakerWhenNested',
1830
1904
  'weakerNetworkIsolation',
1905
+ 'hermetic',
1831
1906
  ])
1832
1907
  const GRANT_PATH_FIELDS = ['read', 'write'] as const
1833
1908
  const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
@@ -1855,6 +1930,19 @@ function assertStringArray(v: unknown, where: string): void {
1855
1930
  }
1856
1931
  }
1857
1932
 
1933
+ /**
1934
+ * Only `~` and `~/…` mean the home: `~root/x/` was read as `$HOME/root/x`,
1935
+ * and vx created that directory on the host.
1936
+ */
1937
+ function assertOwnHome(v: readonly string[], where: string): void {
1938
+ const other = v.find((p) => p.startsWith('~') && p !== '~' && !p.startsWith('~/'))
1939
+ if (other === undefined) return
1940
+ throw new UserError(
1941
+ `${where}: ${JSON.stringify(other)} names a user's home, which vx does not expand — ` +
1942
+ `write '~/…' for your own home, or an absolute path`,
1943
+ )
1944
+ }
1945
+
1858
1946
  /**
1859
1947
  * Validate a `sandbox: {...}` block — one capability shape, whatever the
1860
1948
  * platform ends up doing with it. Unknown keys are refused rather than
@@ -1871,7 +1959,7 @@ function validateSandbox(sandbox: unknown, where: string): void {
1871
1959
  assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
1872
1960
  const obj = sandbox as Record<string, unknown>
1873
1961
 
1874
- for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
1962
+ for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation', 'hermetic']) {
1875
1963
  if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
1876
1964
  throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
1877
1965
  }
@@ -1887,7 +1975,22 @@ function validateSandbox(sandbox: unknown, where: string): void {
1887
1975
  for (const f of GRANT_PATH_FIELDS) {
1888
1976
  // Patterns are allowed here: macOS matches them natively, Linux
1889
1977
  // expands them against the filesystem at resolve time.
1890
- if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
1978
+ if (g[f] === undefined) continue
1979
+ assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
1980
+ assertOwnHome(g[f] as string[], `${where}.sandbox.allow.${f}`)
1981
+ }
1982
+ // SRT's Linux observer reports a write's path with each control
1983
+ // character turned into a space, so a write a grant holding one covers
1984
+ // read as a violation of the path spelled with a space, and a real
1985
+ // write there reads the same: no match passes one and not the other.
1986
+ // Reads are traced by strace, which escapes them.
1987
+ const ctl = ((g['write'] as string[] | undefined) ?? []).find(hasControl)
1988
+ if (ctl !== undefined) {
1989
+ throw new UserError(
1990
+ `${where}.sandbox.allow.write: ${JSON.stringify(ctl)} holds a control character, ` +
1991
+ `which the Linux sandbox's report of a write does not keep (it spells a tab as a ` +
1992
+ `space), so a write under it cannot be told from a refused one — rename it`,
1993
+ )
1891
1994
  }
1892
1995
  for (const f of GRANT_NAME_FIELDS) {
1893
1996
  if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
@@ -1944,7 +2047,10 @@ function validateSandbox(sandbox: unknown, where: string): void {
1944
2047
  // Patterns, not prefixes — `ignore` is vx's own filter over lines it
1945
2048
  // already holds, so a glob costs nothing and works on every platform.
1946
2049
  for (const f of IGNORE_FIELDS) {
1947
- if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
2050
+ if (g[f] === undefined) continue
2051
+ assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
2052
+ if (f === 'read' || f === 'write')
2053
+ assertOwnHome(g[f] as string[], `${where}.sandbox.ignore.${f}`)
1948
2054
  }
1949
2055
  }
1950
2056
  }
@@ -11,8 +11,8 @@
11
11
  // changed lockfile's base digests are there, so the base side is never
12
12
  // parsed. `vx lock --check` holds it to a fresh derivation.
13
13
 
14
- import { rename, rm } from 'node:fs/promises'
15
14
  import path from 'node:path'
15
+ import type { PendingWrite } from '../util/index.js'
16
16
  import type { ProjectMeta } from './workspace.js'
17
17
  import { lockKey } from './lockfile.js'
18
18
  import { type LoadReads, readOnce } from './load-reads.js'
@@ -90,19 +90,12 @@ function dependenciesLockText(lock: DependenciesLock): string {
90
90
  return `${JSON.stringify(lock)}\n`
91
91
  }
92
92
 
93
- /** Written beside its name and renamed over it, as `writeLockfile`: a reader never sees half a file. */
94
- export async function writeDependenciesLock(root: string, lock: DependenciesLock): Promise<void> {
95
- const file = path.join(root, DEPENDENCIES_LOCK_NAME)
96
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
97
- try {
98
- await Bun.write(tmp, dependenciesLockText(lock))
99
- await rename(tmp, file)
100
- } catch (err) {
101
- await rm(tmp, { force: true }).catch(() => {})
102
- const e = err as NodeJS.ErrnoException
103
- if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
104
- if (e.path === tmp) e.path = file
105
- throw err
93
+ /** The dependency lock's file and bytes, for `writeAllOrNone`. */
94
+ export function dependenciesLockWrite(root: string, lock: DependenciesLock): PendingWrite {
95
+ return {
96
+ file: path.join(root, DEPENDENCIES_LOCK_NAME),
97
+ text: dependenciesLockText(lock),
98
+ shown: DEPENDENCIES_LOCK_NAME,
106
99
  }
107
100
  }
108
101