@vzn/vx 0.0.633 → 0.0.635

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 (53) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +11 -2
  3. package/docs/cli.md +192 -56
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +6 -3
  6. package/docs/patterns.md +8 -5
  7. package/docs/schema.md +48 -2
  8. package/docs/security.md +6 -0
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/error.json +5 -1
  13. package/schemas/last.json +5 -1
  14. package/schemas/plan.json +4 -0
  15. package/schemas/show.json +2 -1
  16. package/schemas/why.json +4 -1
  17. package/skills/vx/SKILL.md +7 -1
  18. package/src/cache/cache.ts +60 -5
  19. package/src/cache/inputs.ts +66 -30
  20. package/src/cache/layered-cache.ts +3 -1
  21. package/src/cli/docs.ts +9 -3
  22. package/src/cli/help.ts +2 -1
  23. package/src/cli/init.ts +9 -2
  24. package/src/cli/last.ts +9 -1
  25. package/src/cli/plan-format.ts +3 -1
  26. package/src/cli/plugin-templates.ts +1 -1
  27. package/src/cli/run.ts +17 -8
  28. package/src/cli/select.ts +17 -11
  29. package/src/cli/show.ts +2 -2
  30. package/src/config.ts +23 -2
  31. package/src/graph/priorities.ts +72 -6
  32. package/src/graph/scheduler.ts +31 -7
  33. package/src/graph/task-graph.ts +75 -20
  34. package/src/index.ts +6 -1
  35. package/src/orchestrator/execute-task.ts +30 -1
  36. package/src/orchestrator/framed-output.ts +27 -1
  37. package/src/orchestrator/history.ts +75 -1
  38. package/src/orchestrator/index.ts +1 -0
  39. package/src/orchestrator/path-links.ts +5 -3
  40. package/src/orchestrator/plugin.ts +3 -1
  41. package/src/orchestrator/prepare.ts +15 -12
  42. package/src/orchestrator/remote-prefetch.ts +8 -2
  43. package/src/orchestrator/run-failures.ts +20 -3
  44. package/src/orchestrator/run.ts +24 -2
  45. package/src/orchestrator/sandbox-fix.ts +123 -0
  46. package/src/orchestrator/task-hash.ts +7 -1
  47. package/src/util/errors.ts +9 -2
  48. package/src/util/paths.ts +32 -0
  49. package/src/workspace/affected.ts +16 -4
  50. package/src/workspace/config-schema.ts +30 -2
  51. package/src/workspace/lockfile.ts +29 -2
  52. package/src/workspace/migration.ts +51 -4
  53. package/src/workspace/project-loader.ts +3 -1
@@ -0,0 +1,123 @@
1
+ // The `exec.sandbox.allow` entries that would have granted what a failed
2
+ // task was denied, so the report names the line to add (CLI, JSON, MCP)
3
+ // rather than leaving the user to turn absolute paths into grants. Built on
4
+ // the failure path only: a passing run never calls it.
5
+
6
+ import { lstatSync } from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import type { SandboxViolation } from '../exec/index.js'
10
+ import type { TaskOutcome } from '../graph/index.js'
11
+ import { isLiteralPattern } from '../util/index.js'
12
+
13
+ /** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
14
+ type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
15
+ type Kind = Exclude<keyof SandboxFix, 'withheld'>
16
+
17
+ // Where credentials live. A denial here is never turned into a grant: an
18
+ // agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
19
+ // get its grant suggested and applied (Security, 2026-10-12).
20
+ const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
21
+ const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
22
+
23
+ function isCredential(abs: string): boolean {
24
+ const base = path.basename(abs)
25
+ if (base === '.env' || base.startsWith('.env.')) return true
26
+ const rel = path.relative(os.homedir(), abs)
27
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
28
+ return (
29
+ CREDENTIAL_FILES.includes(rel) ||
30
+ CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
31
+ )
32
+ }
33
+
34
+ /**
35
+ * A path as a grant spells it: project-relative inside the project, `~/…`
36
+ * under home, else absolute.
37
+ */
38
+ function grantPath(abs: string, projectDir: string): string {
39
+ const rel = path.relative(projectDir, abs)
40
+ if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
41
+ const home = os.homedir()
42
+ return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
43
+ }
44
+
45
+ /**
46
+ * A write grant covers the top directory the write landed in: granting
47
+ * `dist/a.js` alone fails the run again on `dist/b.js`. Not when the task
48
+ * reads that directory as an input: the grant would let it write its own
49
+ * inputs, so it names the path alone.
50
+ */
51
+ function writeGrant(grant: string, isDir: boolean, inputs: readonly string[]): string {
52
+ const slash = grant.indexOf('/')
53
+ const exact = isDir ? `${grant}/` : grant
54
+ if (grant.startsWith('~/') || path.isAbsolute(grant) || slash === -1) return exact
55
+ const top = grant.slice(0, slash)
56
+ return inputs.some((g) => readsDir(g, top)) ? exact : `${top}/`
57
+ }
58
+
59
+ /** Whether input glob `g` can match under top directory `top`. */
60
+ function readsDir(g: string, top: string): boolean {
61
+ if (g.startsWith('!')) return false
62
+ const first = g.replace(/^\.\//, '').split('/')[0] ?? ''
63
+ return first === top || !isLiteralPattern(first)
64
+ }
65
+
66
+ function kindOf(v: SandboxViolation): Kind | undefined {
67
+ const can = v.ignorable ?? []
68
+ if (can.length === 1) return can[0]
69
+ // Linux reports a refused `openat` as either: the flags the parse drops
70
+ // decide. A file the host has was hidden from a read; one it lacks was
71
+ // being created.
72
+ if (can.includes('read') && can.includes('write') && v.path !== undefined)
73
+ return lstatSync(v.path, { throwIfNoEntry: false }) !== undefined ? 'read' : 'write'
74
+ return undefined
75
+ }
76
+
77
+ export function sandboxFix(
78
+ violations: readonly SandboxViolation[],
79
+ projectDir: string,
80
+ mask: (text: string) => string = (t) => t,
81
+ /** The task's `cache.inputs.files`: a write under one is granted exactly. */
82
+ inputs: readonly string[] = [],
83
+ ): SandboxFix | undefined {
84
+ const fix: Record<Kind | 'withheld', Set<string>> = {
85
+ read: new Set(),
86
+ write: new Set(),
87
+ network: new Set(),
88
+ systemInfo: new Set(),
89
+ withheld: new Set(),
90
+ }
91
+ for (const v of violations) {
92
+ if (v.hint === true || v.target === undefined) continue
93
+ const kind = kindOf(v)
94
+ if (kind === undefined) continue
95
+ if (kind === 'network') fix.network.add(v.target.replace(/:\d+$/, ''))
96
+ else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
97
+ else if (v.path === undefined) continue
98
+ else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
99
+ else if (kind === 'read') fix.read.add(grantPath(v.path, projectDir))
100
+ else {
101
+ const isDir =
102
+ v.line.startsWith('mkdir') ||
103
+ lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
104
+ fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
105
+ }
106
+ }
107
+ const out: SandboxFix = {}
108
+ for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
109
+ if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
110
+ return Object.keys(out).length > 0 ? out : undefined
111
+ }
112
+
113
+ /**
114
+ * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
115
+ * or undefined when every denial was a withheld credential path.
116
+ */
117
+ export function sandboxFixLine({ withheld: _, ...fix }: SandboxFix): string | undefined {
118
+ if (Object.keys(fix).length === 0) return undefined
119
+ const parts = Object.entries(fix).map(
120
+ ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
121
+ )
122
+ return `exec.sandbox.allow: { ${parts.join(', ')} }`
123
+ }
@@ -238,11 +238,15 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
238
238
  * input changed and changed BACK while the command ran matched its digest
239
239
  * again, and the entry saved the other content's output under the key (item
240
240
  * 1015). A write before it — an upstream's — is judged by content, as before.
241
+ * `writesByBytes` (`cache.rewrittenInputs: 'compare'`) judges a write
242
+ * during the command by content too: the task says the same bytes are its
243
+ * own identical rewrite (tsdown's package.json), not an edit undone.
241
244
  */
242
245
  export async function movedInput(
243
246
  facts: readonly InputFact[],
244
247
  cache: CacheLayer,
245
248
  commandFrom?: number,
249
+ writesByBytes = false,
246
250
  ): Promise<string | undefined> {
247
251
  const suspects: InputFact[] = []
248
252
  for (const f of facts) {
@@ -260,7 +264,9 @@ export async function movedInput(
260
264
  // an even one, FAT's tick), one during the command included
261
265
  // (`racyWindowMs`).
262
266
  if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
263
- return f.path
267
+ if (!writesByBytes) return f.path
268
+ suspects.push(f)
269
+ continue
264
270
  }
265
271
  if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
266
272
  }
@@ -62,9 +62,16 @@ export function wantsJson(argv: readonly string[]): boolean {
62
62
  return false
63
63
  }
64
64
 
65
- /** The JSON a verb that answers JSON prints for a refusal: one line. */
65
+ /**
66
+ * The JSON a verb that answers JSON prints for a refusal: one line. A
67
+ * core code (`VX_E_…`) links its section of cli.md, which says the fix;
68
+ * `vx docs <code>` prints the same section offline. A plugin's code has none.
69
+ */
66
70
  export function errorDocument(code: string, message: string): string {
67
- return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
71
+ const docs = code.startsWith('VX_E_')
72
+ ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
73
+ : {}
74
+ return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
68
75
  }
69
76
 
70
77
  /**
package/src/util/paths.ts CHANGED
@@ -195,6 +195,30 @@ export const EXTGLOB = /[!@+*?]\(/
195
195
  * else: one site that forgot is a route directory that keys nothing.
196
196
  */
197
197
  export function taskGlob(pattern: string): Bun.Glob {
198
+ const memo = memoFor(taskGlobs)
199
+ let glob = memo.get(pattern)
200
+ if (glob === undefined) memo.set(pattern, (glob = compileTaskGlob(pattern)))
201
+ return glob
202
+ }
203
+
204
+ /**
205
+ * Compiled once per pattern: a Turbo-mapped config repeats one 400-entry
206
+ * list across two thousand tasks, and the graph's overlap checks compiled
207
+ * each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
208
+ * comes from a config, so the set is bounded by what the configs say. Kept
209
+ * per `Bun.Glob` in force: the playground and a test swap it, and a glob
210
+ * built by one is not the other's.
211
+ */
212
+ const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
213
+ const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
214
+
215
+ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
216
+ let memo = memos.get(Bun.Glob)
217
+ if (memo === undefined) memos.set(Bun.Glob, (memo = new Map()))
218
+ return memo
219
+ }
220
+
221
+ function compileTaskGlob(pattern: string): Bun.Glob {
198
222
  // A `]` with no `[` before it is already literal to `Bun.Glob`.
199
223
  const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
200
224
  const glob = new Bun.Glob(source)
@@ -228,6 +252,14 @@ export function taskGlob(pattern: string): Bun.Glob {
228
252
  * `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
229
253
  */
230
254
  export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
255
+ const memo = memoFor(anyTaskGlobs)
256
+ const key = rawPatterns.join('\0')
257
+ let any = memo.get(key)
258
+ if (any === undefined) memo.set(key, (any = compileAnyTaskGlob(rawPatterns)))
259
+ return any
260
+ }
261
+
262
+ function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
231
263
  const patterns = rawPatterns.flatMap(braceAlternatives)
232
264
  if (patterns.length === 0) return () => false
233
265
  const sources = patterns.map(regExpSource)
@@ -173,6 +173,7 @@ export async function affectedChanges(
173
173
  if (since.length === 0 || since.startsWith('-')) {
174
174
  throw new UserError(
175
175
  `git ref "${since}" is not a ref: a base cannot be empty or start with "-".`,
176
+ 'VX_E_AFFECTED_BASE',
176
177
  )
177
178
  }
178
179
  // `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
@@ -183,6 +184,7 @@ export async function affectedChanges(
183
184
  throw new UserError(
184
185
  `git ref "${since}" is a range: ranges are not supported — pass the base alone ` +
185
186
  `("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
187
+ 'VX_E_AFFECTED_BASE',
186
188
  )
187
189
  }
188
190
  // `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
@@ -191,6 +193,7 @@ export async function affectedChanges(
191
193
  if (since.startsWith('^')) {
192
194
  throw new UserError(
193
195
  `git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
196
+ 'VX_E_AFFECTED_BASE',
194
197
  )
195
198
  }
196
199
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
@@ -556,12 +559,13 @@ async function gitBlobsAt(
556
559
  proc.exited,
557
560
  ])
558
561
  if (exit !== 0) {
559
- throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`)
562
+ throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
560
563
  }
561
564
  let at = 0
562
565
  for (const file of batched) {
563
566
  const eol = out.indexOf(0x0a, at)
564
- if (eol === -1) throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`)
567
+ if (eol === -1)
568
+ throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`, 'VX_E_GIT')
565
569
  const header = new TextDecoder().decode(out.subarray(at, eol))
566
570
  at = eol + 1
567
571
  // `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
@@ -636,7 +640,10 @@ async function gitBytesAt(
636
640
  // "exists on disk but not in <ref>" and "path does not exist in <ref>"
637
641
  // are the file being absent at the ref; anything else is git failing.
638
642
  if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
639
- throw new UserError(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
643
+ throw new UserError(
644
+ `git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`,
645
+ 'VX_E_GIT',
646
+ )
640
647
  }
641
648
 
642
649
  async function bytesOrNull(file: string): Promise<Uint8Array | null> {
@@ -654,7 +661,7 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
654
661
  const stderr = await new Response(proc.stderr).text()
655
662
  const exit = await proc.exited
656
663
  if (exit !== 0) {
657
- throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`)
664
+ throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
658
665
  }
659
666
  return stdout.split('\0').filter((s) => s.length > 0)
660
667
  }
@@ -724,6 +731,7 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
724
731
  '--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
725
732
  'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
726
733
  'base: --affected=origin/main',
734
+ 'VX_E_AFFECTED_BASE',
727
735
  )
728
736
  }
729
737
  return 'HEAD~1'
@@ -742,6 +750,7 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
742
750
  if (revParse(workspaceRoot, 'HEAD') === undefined) {
743
751
  return new UserError(
744
752
  '--affected has no base here: this repository has no commit yet. Commit first, or run without --affected.',
753
+ 'VX_E_AFFECTED_BASE',
745
754
  )
746
755
  }
747
756
  return undefined
@@ -832,6 +841,7 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
832
841
  throw new UserError(
833
842
  `git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
834
843
  `workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
844
+ 'VX_E_AFFECTED_BASE',
835
845
  )
836
846
  }
837
847
  return ref
@@ -857,6 +867,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
857
867
  throw new UserError(
858
868
  `git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
859
869
  (stderr.length > 0 ? `: ${stderr}` : ''),
870
+ 'VX_E_GIT',
860
871
  )
861
872
  }
862
873
  // A shallow clone (CI's checkout fetches one commit by default) has no
@@ -868,6 +879,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
868
879
  (isShallow
869
880
  ? ' This clone is shallow: fetch the history the base needs (`git fetch --unshallow`, or `fetch-depth: 0` on actions/checkout).'
870
881
  : ''),
882
+ 'VX_E_AFFECTED_BASE',
871
883
  )
872
884
  }
873
885
 
@@ -56,9 +56,18 @@ const WORKSPACE_FIELDS = new Set([
56
56
  'affectedBase',
57
57
  'cacheScope',
58
58
  'rules',
59
+ 'schedule',
59
60
  'plugins',
60
61
  ])
61
62
 
63
+ type Schedule = NonNullable<WorkspaceConfig['schedule']>
64
+ const SCHEDULES = {
65
+ 'most-work': true,
66
+ 'critical-path': true,
67
+ 'direct-dependents': true,
68
+ 'ready-order': true,
69
+ } satisfies Record<Schedule, true>
70
+
62
71
  const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
63
72
 
64
73
  function validateRules(rules: unknown, configPath: string): void {
@@ -127,6 +136,11 @@ function validateRetention(retention: unknown, configPath: string): void {
127
136
  }
128
137
  }
129
138
 
139
+ /** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
140
+ export function isSkippedPlugin(p: unknown): p is undefined | null | false {
141
+ return p === undefined || p === null || p === false
142
+ }
143
+
130
144
  export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
131
145
  // No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
132
146
  // objects of functions by design, the file is evaluated in-process on
@@ -184,13 +198,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
184
198
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
185
199
  }
186
200
  if (config.rules !== undefined) validateRules(config.rules, configPath)
201
+ if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
202
+ throw new UserError(
203
+ `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
204
+ .map((k) => `'${k}'`)
205
+ .join(', ')}`,
206
+ )
207
+ }
187
208
  if (config.plugins !== undefined) {
188
209
  if (!Array.isArray(config.plugins)) {
189
210
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
190
211
  }
191
212
  const verbOwners = new Map<string, string>()
192
213
  const fileClaimants = new Map<string, { name: string; at: number }>()
193
- for (const [i, p] of config.plugins.entries()) {
214
+ for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
215
+ // A factory that declines returns nothing, and `cond && plugin()` is
216
+ // false: both are skipped, as Vite skips them.
217
+ if (isSkippedPlugin(p)) continue
194
218
  if (p === null || typeof p !== 'object') {
195
219
  // Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
196
220
  // a vx plugin is the value its package's function returns.
@@ -644,6 +668,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
644
668
  )
645
669
  }
646
670
  assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
671
+ const rewritten = (cache as { rewrittenInputs?: unknown }).rewrittenInputs
672
+ if (rewritten !== undefined && rewritten !== 'withhold' && rewritten !== 'compare') {
673
+ throw new UserError(`${where}.cache.rewrittenInputs must be 'withhold' or 'compare'`)
674
+ }
647
675
  const inputs = (cache as { inputs?: unknown }).inputs
648
676
  const outputs = (cache as { outputs?: unknown }).outputs
649
677
  // A present field of the wrong shape (`outputs: 'dist'`) was told it
@@ -880,7 +908,7 @@ const EXEC_FIELDS = new Set([
880
908
  ])
881
909
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
882
910
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
883
- const CACHE_FIELDS = new Set(['inputs', 'outputs'])
911
+ const CACHE_FIELDS = new Set(['inputs', 'outputs', 'rewrittenInputs'])
884
912
  const CACHE_INPUT_FIELDS = new Set([
885
913
  'files',
886
914
  'workspaceFiles',
@@ -36,6 +36,8 @@ export interface LockfileEntry {
36
36
  export interface Lockfile {
37
37
  version: number
38
38
  projects: Record<string, LockfileEntry>
39
+ /** Written by `writeLockfile` from the bytes; never read off the object. */
40
+ validated?: string
39
41
  }
40
42
 
41
43
  export function lockfilePath(root: string): string {
@@ -100,12 +102,33 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
100
102
  }
101
103
  }
102
104
  lockDigests.set(parsed, xxh3(text).toString(16))
105
+ const stamped = VALIDATED_LINE.exec(text)
106
+ if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
107
+ selfValidated.add(parsed)
108
+ }
103
109
  return parsed as Lockfile
104
110
  }
105
111
 
106
112
  /** The digest of the bytes each lock `readLockfile` returned was parsed from. */
107
113
  const lockDigests = new WeakMap<object, string>()
108
114
 
115
+ /**
116
+ * The lock's own record that this vx validated its entries: `vx lock`
117
+ * evaluates and validates every config, then writes a digest of the bytes
118
+ * it wrote, under vx's version (validation's rules are vx's alone, so Bun's
119
+ * is left out and does not churn a committed lock), as the last field. A lock
120
+ * whose digest matches is validated already, and a cold `--frozen` run
121
+ * skips the ~30 ms of validating 1,090 entries before its first task. An
122
+ * edit, a merge, or another vx version leaves it unmatched, and the
123
+ * entries are validated as before.
124
+ */
125
+ const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
126
+ const selfValidated = new WeakSet<object>()
127
+
128
+ function validatedDigest(body: string): string {
129
+ return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
130
+ }
131
+
109
132
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
110
133
  // Written beside its name and renamed over it: `Bun.write` truncates in
111
134
  // place, so a `--frozen` run, or `vx watch --frozen` woken by the write
@@ -114,7 +137,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
114
137
  const file = lockfilePath(root)
115
138
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
116
139
  try {
117
- await Bun.write(tmp, `${JSON.stringify(lock, null, 2)}\n`)
140
+ const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
141
+ await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
118
142
  await rename(tmp, file)
119
143
  } catch (err) {
120
144
  // Best effort: where the write was refused the temp cannot be named
@@ -187,7 +211,10 @@ export async function frozenProjectConfigs(
187
211
  // asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
188
212
  // config load, where the eval-cache path it replaces is cheaper (X-181).
189
213
  // A scoped run still validates, and remembers, only what it loads.
190
- if (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null) {
214
+ if (
215
+ selfValidated.has(lock) ||
216
+ (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
217
+ ) {
191
218
  return entries.map((e) => e.config)
192
219
  }
193
220
  const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
@@ -377,6 +377,18 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
377
377
  // One reason, its tasks: on a Turbo template the same two-line cache TODO
378
378
  // printed five times and the persistent one eight (the first-five-minutes
379
379
  // walk, 2026-09-28), and the list read as noise.
380
+ // A file .gitignore drops is lost at the first commit, unnoticed: vueuse
381
+ // ignores `packages/**/*.mjs`, so `--mjs` wrote configs git never saw.
382
+ const ignored = gitIgnored(
383
+ root,
384
+ files.map((f) => f.relPath),
385
+ )
386
+ const ignoredNote =
387
+ ignored.length === 0
388
+ ? []
389
+ : [
390
+ `.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): add ${[...new Set(ignored.map((f) => `!${path.posix.basename(f)}`))].join(' and ')} to .gitignore, or commit them with git add -f`,
391
+ ]
380
392
  const todos = new Map<string, string[]>()
381
393
  let todoCount = 0
382
394
  let clean = 0
@@ -423,7 +435,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
423
435
  args.unmapped === true
424
436
  ? `${verb}: no package.json scripts became tasks.`
425
437
  : `${verb}: no package.json scripts to turn into tasks.`,
426
- ...[...noMembers, ...(args.notes ?? [])].map((n) => `note: ${n}`),
438
+ ...[...noMembers, ...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
427
439
  hasWorkspaceFile
428
440
  ? `${workspaceName} already exists.`
429
441
  : dry
@@ -438,7 +450,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
438
450
  )
439
451
  } else {
440
452
  report.push(`${verb}: ${source} → ${configName}`)
441
- for (const n of args.notes ?? []) report.push(`note: ${n}`)
453
+ for (const n of [...(args.notes ?? []), ...ignoredNote]) report.push(`note: ${n}`)
442
454
  for (const n of plan.headerNotes) report.push(`note: ${n}`)
443
455
  report.push(
444
456
  '',
@@ -470,7 +482,10 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
470
482
  // A run of tasks none of which caches is never a hit, and a first try
471
483
  // that runs twice to see the cache work saw it run twice.
472
484
  if (!cached && [...todos.keys()].some(isCacheTodo)) {
473
- report.push('', 'no task caches yet: add the cache block a TODO shows, and a second run hits')
485
+ report.push(
486
+ '',
487
+ 'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
488
+ )
474
489
  }
475
490
  const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
476
491
  // The line is the next thing to type, so it names what that run would
@@ -495,6 +510,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
495
510
  ...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
496
511
  ...(empty ? noMembers : []),
497
512
  ...(args.notes ?? []),
513
+ ...ignoredNote,
498
514
  ...plan.headerNotes,
499
515
  ...plan.notes,
500
516
  ],
@@ -508,6 +524,28 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
508
524
  return 0
509
525
  }
510
526
 
527
+ /** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
528
+ function gitIgnored(root: string, relPaths: readonly string[]): string[] {
529
+ if (relPaths.length === 0) return []
530
+ try {
531
+ const r = Bun.spawnSync({
532
+ cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
533
+ cwd: root,
534
+ stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
535
+ stdout: 'pipe',
536
+ stderr: 'ignore',
537
+ })
538
+ // 0: some ignored; 1: none; anything else (no work tree): nothing to say.
539
+ if (r.exitCode !== 0) return []
540
+ return r.stdout
541
+ .toString()
542
+ .split('\0')
543
+ .filter((p) => p !== '')
544
+ } catch {
545
+ return []
546
+ }
547
+ }
548
+
511
549
  function inGitWorkTree(root: string): boolean {
512
550
  try {
513
551
  return (
@@ -660,7 +698,16 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
660
698
  : IDENT.test(t.name)
661
699
  ? t.name
662
700
  : quoteTsLiteral(t.name)
663
- lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
701
+ const rendered = renderValue(t.task, ' ')
702
+ // The suggested block sits in the task, commented: deleting `// `
703
+ // after setting the real globs caches it, no copying from the TODO.
704
+ const block =
705
+ t.task['cache'] === undefined
706
+ ? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
707
+ : undefined
708
+ lines.push(
709
+ ` ${key}: ${block === undefined ? rendered : rendered.replace('{\n', `{\n // ${block},\n`)},`,
710
+ )
664
711
  }
665
712
  lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
666
713
  return lines.join('\n')
@@ -2,7 +2,7 @@ import { existsSync, realpathSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
4
  import { UserError, xxh3hex } from '../util/index.js'
5
- import { validateProjectConfig, validateWorkspace } from './config-schema.js'
5
+ import { isSkippedPlugin, validateProjectConfig, validateWorkspace } from './config-schema.js'
6
6
  import {
7
7
  beginEvalRound,
8
8
  builtinsChangedBy,
@@ -1163,6 +1163,8 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1163
1163
  // config at all (D-6).
1164
1164
  assertDefaultObject(mod, 'Workspace', configPath)
1165
1165
  validateWorkspace(mod, configPath)
1166
+ if (mod.plugins?.some(isSkippedPlugin) === true)
1167
+ mod = { ...mod, plugins: mod.plugins.filter((p) => !isSkippedPlugin(p)) }
1166
1168
  // Set by a load that succeeded: a failed one may have left nothing in
1167
1169
  // the registry, and its fix must not be refused.
1168
1170
  if (since === undefined) workspaceLoadedAt.set(configPath, startedAt)