@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
package/src/util/size.ts CHANGED
@@ -18,3 +18,36 @@ export function parseSize(input: string): number | null {
18
18
  u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
19
19
  return n * mult
20
20
  }
21
+
22
+ /**
23
+ * Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
24
+ * has always been case-insensitive; this matched only lowercase, so `30D`
25
+ * was rejected while `1GB` and `1gb` both worked).
26
+ */
27
+ export function parseDuration(input: string): number | null {
28
+ const m = input.match(/^(\d+)([smhd])$/i)
29
+ if (!m) return null
30
+ const n = parseDecimalInt(m[1]!)
31
+ if (n === null) return null
32
+ // Lowercase before the switch: with the /i flag an uppercase `M` would
33
+ // otherwise fall through to the days branch.
34
+ const unit = m[2]!.toLowerCase()
35
+ const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
36
+ return n * mult
37
+ }
38
+
39
+ /**
40
+ * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
41
+ * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache prune` and the
42
+ * run's `cacheRetention` line.
43
+ */
44
+ export function formatBytes(n: number): string {
45
+ if (n < 1024) return `${n} B`
46
+ const units = ['KB', 'MB', 'GB', 'TB']
47
+ let v = n / 1024
48
+ for (const u of units) {
49
+ if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
50
+ v /= 1024
51
+ }
52
+ return `${v.toFixed(0)} PB`
53
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The inverse of `taskId` (`graph/task-graph.ts`). Splits on the FIRST
3
+ * `#`, so a task name that itself contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`.
4
+ *
5
+ * This exists because it kept being written by hand as `id.split('#', 2)`,
6
+ * which is NOT the inverse — it discards everything after the second segment,
7
+ * so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while
8
+ * `parseDependencySpec` (the surface that decides what actually runs) has
9
+ * always split on the first `#`, so the query layer and the graph disagreed
10
+ * about the identity of the same task: a lookup either found nothing or, worse,
11
+ * answered with a different task's history.
12
+ *
13
+ * A `#`-containing task name is legal and pinned elsewhere in the suite, so
14
+ * this is reachable rather than theoretical. The cache's run history read
15
+ * the same rule from a private copy until item 646; one rule, one place.
16
+ */
17
+ export function splitTaskId(id: string): [project: string, task: string] {
18
+ const i = id.indexOf('#')
19
+ return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
20
+ }
@@ -3,7 +3,8 @@
3
3
  // The CPU profiler attributes a tight loop's cost unreliably and hides
4
4
  // where an `await` waited; a stage table answers "where did the warm run
5
5
  // go?" directly, and costs one boolean check per mark when off. Marks are
6
- // cumulative from process start; the table shows each stage's own share.
6
+ // cumulative from this module's load — process start and the imports ahead
7
+ // of it sit outside the table; the table shows each stage's own share.
7
8
 
8
9
  const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
9
10
  const t0 = Bun.nanoseconds()
@@ -58,6 +59,15 @@ export function printTimings(): void {
58
59
  ` ${label.padEnd(24)} ${(ns / 1e6).toFixed(1).padStart(8)}ms ${String(count).padStart(6)}`,
59
60
  )
60
61
  }
62
+ // The trap this table sets, said where it is sprung: a span's total is
63
+ // WALL summed per call, and the run's spans are taken under the
64
+ // scheduler's concurrency, so a span that overlaps other work reads
65
+ // far larger than the work it names. `output dirs` at 124 µs a task is
66
+ // a handful of `lstat`s (items 254 and 407). Compare spans to each
67
+ // other, and measure a suspect one in isolation before chasing it.
68
+ lines.push(
69
+ ' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
70
+ )
61
71
  }
62
72
  process.stderr.write(lines.join('\n') + '\n')
63
73
  }
@@ -0,0 +1,48 @@
1
+ // A spawn of a bare name walks PATH with a stat per entry — `sh` for every
2
+ // task, `git` for every git call: 200 tasks paid ~2,000 stats on a 100-package
3
+ // workspace for the same answer each time. The walk is done here once and
4
+ // the spawn is handed the absolute path.
5
+ //
6
+ // The PATH walked is vx's OWN, never a task's. A task's PATH starts with its
7
+ // project's and the workspace's `node_modules/.bin`, and `exec.env.define`
8
+ // may set it outright; the shell that parses the command and the git that
9
+ // enumerates the inputs are the machine's tools, not the task's, so a
10
+ // dependency that ships a `sh` bin must not become the interpreter of every
11
+ // command in its project. The task's PATH still decides what the COMMAND
12
+ // resolves, inside that shell.
13
+ //
14
+ // Memoized per PATH value rather than for the process's life: nothing in vx
15
+ // changes PATH mid-run, so a run pays one walk, and a process whose PATH does
16
+ // change (an embedder, a test) is answered for the PATH it has. A miss is
17
+ // not remembered: it ends the spawn that asked, and a long-lived process
18
+ // (`vx watch`) finds the tool once the user installs it.
19
+
20
+ let memoPath: string | undefined
21
+ const found = new Map<string, string>()
22
+
23
+ /**
24
+ * `name`'s absolute path on vx's own PATH. Throws Bun's own shape for a
25
+ * spawn that could not run (`code: 'ENOENT'`) when it is not there, so a
26
+ * call inside a spawn site's `try` reaches the same `isExecutableMissing`
27
+ * refusal the spawn's throw did.
28
+ */
29
+ export function executablePath(name: string): string {
30
+ const PATH = process.env['PATH'] ?? ''
31
+ if (PATH !== memoPath) {
32
+ found.clear()
33
+ memoPath = PATH
34
+ }
35
+ const hit = found.get(name)
36
+ if (hit !== undefined) return hit
37
+ const at = Bun.which(name, { PATH })
38
+ if (at === null) {
39
+ throw Object.assign(new Error(`Executable not found in $PATH: "${name}"`), {
40
+ code: 'ENOENT',
41
+ path: name,
42
+ })
43
+ }
44
+ // Absolute even for a relative PATH entry (Bun.which resolves it against
45
+ // this process's cwd), so it holds in whatever cwd the spawn is given.
46
+ found.set(name, at)
47
+ return at
48
+ }
@@ -6,12 +6,50 @@
6
6
  // index + unstaged — so it captures everything you touched and nothing
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
+ import { realpathSync, statSync } from 'node:fs'
9
10
  import path from 'node:path'
10
- import { UserError } from '../util/index.js'
11
+ import {
12
+ asTrees,
13
+ executablePath,
14
+ UserError,
15
+ gitSpawnRefusal,
16
+ isExecutableMissing,
17
+ taskGlob,
18
+ } from '../util/index.js'
11
19
  import { LOCKFILE_NAME } from './lockfile.js'
12
20
  import { configImportOwners } from './config-imports.js'
21
+ import { configImports } from './config-cache.js'
22
+ import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
13
23
  import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
14
- import type { ProjectMeta } from './workspace.js'
24
+ import type { PackageJson, ProjectMeta } from './workspace.js'
25
+
26
+ /**
27
+ * Every git call here goes through these two: a git that is not on PATH
28
+ * is one refusal naming the install (util `gitSpawnRefusal`), never the
29
+ * `ENOENT` stack `defaultAffectedBase` showed a minimal image
30
+ * (2026-09-16). A git that ran and failed is each caller's to read.
31
+ */
32
+ function spawnGitSync(
33
+ args: string[],
34
+ cwd: string,
35
+ stderr: 'pipe' | 'ignore' = 'pipe',
36
+ ): ReturnType<typeof Bun.spawnSync> {
37
+ try {
38
+ return Bun.spawnSync({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr })
39
+ } catch (err) {
40
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
41
+ throw err
42
+ }
43
+ }
44
+
45
+ function spawnGit(args: string[], cwd: string): Bun.Subprocess<'ignore', 'pipe', 'pipe'> {
46
+ try {
47
+ return Bun.spawn({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
48
+ } catch (err) {
49
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
50
+ throw err
51
+ }
52
+ }
15
53
 
16
54
  export interface AffectedArgs {
17
55
  workspaceRoot: string
@@ -22,18 +60,18 @@ export interface AffectedArgs {
22
60
  * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
23
61
  * these workspace-relative paths.
24
62
  *
25
- * A workspace-anchored glob is the documented escape hatch for shared files
26
- * that belong to NO project, so mapping changed paths to project directories
27
- * structurally cannot see it — the path resolves to no project and the run
28
- * selects nothing for a change that re-keyed the task.
29
- *
30
- * Answering needs the RESOLVED configs, which selection runs before loading;
31
- * that ordering is what left this open. It is resolved by asking only when
32
- * the question can matter: this is called ONLY with paths that belong to no
33
- * project, so a run whose every change is inside a project — the common case
34
- * — never invokes it and pays nothing.
63
+ * A workspace-anchored glob is the documented escape hatch for inputs
64
+ * outside the project, so mapping changed paths to project directories
65
+ * structurally cannot see it. Answering needs the RESOLVED configs, which
66
+ * selection runs before loading, so it is a callback, asked only when
67
+ * something changed. It is asked about EVERY changed path, the ones a
68
+ * project owns included: the glob may name a file inside another project
69
+ * (`schema.md` allows it), and asking only the paths no project owns
70
+ * left the declaring project out of a run its key called stale
71
+ * (item 954). The `--affected` sugar's graph walk has already staged every
72
+ * config, so there it costs nothing more.
35
73
  */
36
- workspaceGlobOwners?: (orphanPaths: readonly string[]) => Promise<Iterable<string>>
74
+ workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
37
75
  /**
38
76
  * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
39
77
  * answer for a change to one. Resolved lazily: loading the workspace
@@ -70,6 +108,16 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
70
108
  `git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
71
109
  )
72
110
  }
111
+ // `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
112
+ // the user read "did not resolve" about refs that both exist. `..` is
113
+ // illegal in a ref name (git-check-ref-format), so this refuses no ref.
114
+ const range = args.since.indexOf('..')
115
+ if (range >= 0) {
116
+ throw new UserError(
117
+ `git ref "${args.since}" is a range: ranges are not supported — pass the base alone ` +
118
+ `("${args.since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
119
+ )
120
+ }
73
121
  await verifyRef(args.workspaceRoot, args.since)
74
122
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
75
123
  // on a branch whose base has moved on, `git diff <base>` reports every
@@ -100,9 +148,15 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
100
148
  // non-ASCII / `"` / `\` character — the quoted string then resolves to no
101
149
  // project, while the cache-input enumeration (which uses `-z`) sees the
102
150
  // real name and re-keys the task. The two surfaces must agree.
151
+ //
152
+ // `--ignore-submodules=none` because a repository may ask git to hide its
153
+ // submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
154
+ // then an edit inside one, or a committed bump, was no change here while
155
+ // the task's key moved (item 951).
103
156
  gitPaths(args.workspaceRoot, [
104
157
  'diff',
105
158
  '--no-renames',
159
+ '--ignore-submodules=none',
106
160
  '--relative',
107
161
  '--name-only',
108
162
  '-z',
@@ -135,10 +189,25 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
135
189
  // says per project, so selection asks the plugin the same question, with
136
190
  // the bytes at the base ref and in the working tree. Its answer is
137
191
  // unioned with the path-owned projects below; only "cannot tell" widens.
192
+ if (await workspaceConfigChanged(args.workspaceRoot, changed)) {
193
+ return new Set(args.projects.map((p) => p.name))
194
+ }
138
195
  const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
139
196
  const claimedOwned = new Set<string>()
197
+ // A claim may also name a root file core does not fold (`turbo.json`,
198
+ // read by `turbo()`'s stages): it re-keys tasks through their resolved
199
+ // configs and belongs to no project (item 961). Only a changed ROOT name
200
+ // can be one, so a diff inside the projects never loads the claims.
201
+ const rootNamesChanged = changed.filter((p) => !p.includes('/') && !FINGERPRINT_SET.has(p))
202
+ let claims: FingerprintClaims | undefined
203
+ if (
204
+ (fingerprintChanged.length > 0 || rootNamesChanged.length > 0) &&
205
+ args.fingerprintClaims !== undefined
206
+ ) {
207
+ claims = await args.fingerprintClaims()
208
+ for (const file of rootNamesChanged) if (claims.files.has(file)) fingerprintChanged.push(file)
209
+ }
140
210
  if (fingerprintChanged.length > 0) {
141
- const claims = args.fingerprintClaims === undefined ? undefined : await args.fingerprintClaims()
142
211
  for (const file of fingerprintChanged) {
143
212
  if (claims === undefined || !claims.files.has(file)) {
144
213
  return new Set(args.projects.map((p) => p.name))
@@ -153,15 +222,23 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
153
222
  }
154
223
  }
155
224
 
156
- const { owned, orphans } = projectsContaining(args.workspaceRoot, changed, args.projects)
225
+ const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
157
226
  for (const name of claimedOwned) owned.add(name)
158
227
 
228
+ // A package the change REMOVED is no project now, so containment maps its
229
+ // paths to nothing, and the dependents walk sees only today's graph: a
230
+ // `git rm -r pkgs/lib`, or `lib` dropped from `workspaces`, re-keyed and
231
+ // broke `app#build` while `--affected` said nothing affected (item 959).
232
+ const dependents = await dependentsOfRemoved(args.workspaceRoot, base, changed, args.projects)
233
+ if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
234
+ for (const name of dependents) owned.add(name)
235
+
159
236
  // THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
160
237
  // Resolved-config hashing folds those values into the key, so the same
161
238
  // sentence above applies — input hashing sees it, so selection must. This
162
- // runs on the FULL changed set, not just `orphans`: the common shape is a
163
- // config reaching into ANOTHER project (`../../src/index.ts`), whose target
164
- // is owned and therefore never an orphan at all.
239
+ // runs on the FULL changed set, not just the paths no project owns: the
240
+ // common shape is a config reaching into ANOTHER project
241
+ // (`../../src/index.ts`), whose target is owned.
165
242
  for (const name of await configImportOwners({
166
243
  workspaceRoot: args.workspaceRoot,
167
244
  projects: args.projects,
@@ -171,11 +248,102 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
171
248
  owned.add(name)
172
249
  }
173
250
 
174
- if (orphans.length === 0 || args.workspaceGlobOwners === undefined) return owned
175
- for (const name of await args.workspaceGlobOwners(orphans)) owned.add(name)
251
+ if (changed.length === 0 || args.workspaceGlobOwners === undefined) return owned
252
+ for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
176
253
  return owned
177
254
  }
178
255
 
256
+ /**
257
+ * The projects that depend on a package the change removed: a
258
+ * `<dir>/package.json` the base had and the tree does not, read at the base
259
+ * for its name. `undefined` (every project) when the root manifest's
260
+ * `workspaces` moved: which packages left is a discovery at the base, and
261
+ * selection cannot tell cheaply. Only DELETED manifests are read at the
262
+ * base, one `git show` each; an edited one stays a project and is owned.
263
+ */
264
+ async function dependentsOfRemoved(
265
+ workspaceRoot: string,
266
+ base: string,
267
+ changed: readonly string[],
268
+ projects: readonly ProjectMeta[],
269
+ ): Promise<Set<string> | undefined> {
270
+ const out = new Set<string>()
271
+ const removed = new Set<string>()
272
+ for (const rel of changed) {
273
+ if (rel === 'package.json') {
274
+ const workspacesOf = (bytes: Uint8Array | null): string => {
275
+ if (bytes === null) return ''
276
+ try {
277
+ return JSON.stringify(
278
+ (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).workspaces ?? null,
279
+ )
280
+ } catch {
281
+ return 'unreadable'
282
+ }
283
+ }
284
+ const before = workspacesOf(await gitBytesAt(workspaceRoot, base, rel))
285
+ const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
286
+ if (before !== after) return undefined
287
+ continue
288
+ }
289
+ if (path.posix.basename(rel) !== 'package.json') continue
290
+ if ((await bytesOrNull(path.join(workspaceRoot, rel))) !== null) continue
291
+ const bytes = await gitBytesAt(workspaceRoot, base, rel)
292
+ if (bytes === null) continue
293
+ try {
294
+ const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
295
+ if (typeof name === 'string' && name !== '') removed.add(name)
296
+ } catch {
297
+ // A manifest that did not parse at the base named no package.
298
+ }
299
+ }
300
+ if (removed.size === 0) return out
301
+ for (const p of projects) {
302
+ const pkg = p.packageJson
303
+ for (const deps of [
304
+ pkg.dependencies,
305
+ pkg.devDependencies,
306
+ pkg.peerDependencies,
307
+ pkg.optionalDependencies,
308
+ ]) {
309
+ if (deps !== undefined && Object.keys(deps).some((d) => removed.has(d))) out.add(p.name)
310
+ }
311
+ }
312
+ return out
313
+ }
314
+
315
+ /**
316
+ * Whether `changed` holds the workspace config or a file it imports by
317
+ * relative specifier. Its plugins' `config` and `project` stages shape
318
+ * every project's resolved config, so such an edit can re-key any task,
319
+ * and selection cannot tell which: an edit that changed every key
320
+ * selected nothing (item 953). The fingerprint leaves the file out (its
321
+ * placement plugins must not split a cache between machines); a stage it
322
+ * installs re-keys through the resolved configs instead. Selection is not
323
+ * hashed, so widening here changes no key.
324
+ */
325
+ async function workspaceConfigChanged(
326
+ workspaceRoot: string,
327
+ changed: readonly string[],
328
+ ): Promise<boolean> {
329
+ if (changed.length === 0) return false
330
+ const set = new Set(changed)
331
+ if (WORKSPACE_CONFIG_FILENAMES.some((n) => set.has(n))) return true
332
+ const config = WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(workspaceRoot, n)).find((f) => {
333
+ try {
334
+ return statSync(f).isFile()
335
+ } catch {
336
+ return false
337
+ }
338
+ })
339
+ if (config === undefined) return false
340
+ const root = realpathSync(workspaceRoot)
341
+ for (const file of await configImports(config)) {
342
+ if (set.has(path.relative(root, file).split(path.sep).join('/'))) return true
343
+ }
344
+ return false
345
+ }
346
+
179
347
  /**
180
348
  * Does any `cache.inputs.workspaceFiles` entry in `globs` match `rel`?
181
349
  *
@@ -183,17 +351,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
183
351
  * with no positive glob nothing matches. Paths are workspace-root-relative and
184
352
  * POSIX-separated, which is the form both git enumeration and the glob
185
353
  * resolver already speak.
354
+ *
355
+ * `asTrees` on BOTH sides, because the resolver runs it on both and this
356
+ * has to answer the same question the KEY answers. It did not, and the
357
+ * gap decided whether a changed file rebuilt anything: with
358
+ * `workspaceFiles: ['./generated/**']` or the literal `['generated']` —
359
+ * five spellings in all — the resolver folds `generated/x.txt` into the
360
+ * project's key while a raw `Bun.Glob` matched none of them, so
361
+ * `--affected` left the project out of a run its own key says is stale
362
+ * (item 445). A mirror that normalizes differently from what it mirrors
363
+ * is not a mirror.
186
364
  */
187
365
  export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean {
188
- const positive: Bun.Glob[] = []
189
- const negative: Bun.Glob[] = []
366
+ const positive: string[] = []
367
+ const negative: string[] = []
190
368
  for (const entry of globs) {
191
- if (entry.startsWith('!')) negative.push(new Bun.Glob(entry.slice(1)))
192
- else positive.push(new Bun.Glob(entry))
369
+ if (entry.startsWith('!')) negative.push(entry.slice(1))
370
+ else positive.push(entry)
193
371
  }
194
372
  if (positive.length === 0) return false
195
- if (!positive.some((g) => g.match(rel))) return false
196
- return !negative.some((g) => g.match(rel))
373
+ if (!asTrees(positive).some((g) => taskGlob(g).match(rel))) return false
374
+ return !asTrees(negative).some((g) => taskGlob(g).match(rel))
197
375
  }
198
376
 
199
377
  /**
@@ -213,12 +391,7 @@ async function gitBytesAt(
213
391
  ref: string,
214
392
  file: string,
215
393
  ): Promise<Uint8Array | null> {
216
- const proc = Bun.spawn({
217
- cmd: ['git', 'show', '--end-of-options', `${ref}:./${file}`],
218
- cwd: workspaceRoot,
219
- stdout: 'pipe',
220
- stderr: 'pipe',
221
- })
394
+ const proc = spawnGit(['show', '--end-of-options', `${ref}:./${file}`], workspaceRoot)
222
395
  const [bytes, stderr, exit] = await Promise.all([
223
396
  new Response(proc.stdout).bytes(),
224
397
  new Response(proc.stderr).text(),
@@ -238,12 +411,7 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
238
411
 
239
412
  /** Run a NUL-separated path-listing git command from the workspace root. */
240
413
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
241
- const proc = Bun.spawn({
242
- cmd: ['git', ...cmd],
243
- cwd: workspaceRoot,
244
- stdout: 'pipe',
245
- stderr: 'pipe',
246
- })
414
+ const proc = spawnGit([...cmd], workspaceRoot)
247
415
  const stdout = await new Response(proc.stdout).text()
248
416
  const stderr = await new Response(proc.stderr).text()
249
417
  const exit = await proc.exited
@@ -256,41 +424,63 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
256
424
  /**
257
425
  * Resolve the default base for `--affected` with no explicit value.
258
426
  * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
259
- * etc.), then falls back to `HEAD~1` which always exists once there
260
- * are at least two commits.
427
+ * etc.), then falls back to `HEAD~1`. A clone with no `origin/HEAD` and
428
+ * no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
429
+ * all, and says so here rather than failing on a ref nobody typed.
261
430
  */
262
431
  export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
263
- const probe = Bun.spawnSync({
264
- cmd: ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
265
- cwd: workspaceRoot,
266
- stdout: 'pipe',
267
- stderr: 'ignore',
268
- })
432
+ const probe = spawnGitSync(
433
+ ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
434
+ workspaceRoot,
435
+ 'ignore',
436
+ )
269
437
  const out = new TextDecoder().decode(probe.stdout).trim()
270
438
  if (probe.exitCode === 0 && out.length > 0) return out
439
+ if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
440
+ throw new UserError(
441
+ '--affected has no base here: origin/HEAD is not set and HEAD has no parent to compare ' +
442
+ 'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
443
+ 'base: --affected=origin/main',
444
+ )
445
+ }
271
446
  return 'HEAD~1'
272
447
  }
273
448
 
449
+ /**
450
+ * Does `ref` name the commit HEAD is on? A base that IS HEAD can never
451
+ * mark anything affected — the shape of a single-branch clone whose
452
+ * `origin/HEAD` is the branch under test.
453
+ */
454
+ export function refIsHead(workspaceRoot: string, ref: string): boolean {
455
+ const head = revParse(workspaceRoot, 'HEAD')
456
+ return head !== undefined && head === revParse(workspaceRoot, ref)
457
+ }
458
+
459
+ /** The commit `ref` names, or undefined when it does not resolve here. */
460
+ function revParse(workspaceRoot: string, ref: string): string | undefined {
461
+ const proc = spawnGitSync(
462
+ ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
463
+ workspaceRoot,
464
+ 'ignore',
465
+ )
466
+ const sha = new TextDecoder().decode(proc.stdout).trim()
467
+ return proc.exitCode === 0 && sha.length > 0 ? sha : undefined
468
+ }
469
+
274
470
  /** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
275
471
  async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
276
- const proc = Bun.spawn({
277
- cmd: ['git', 'merge-base', '--end-of-options', ref, 'HEAD'],
278
- cwd: workspaceRoot,
279
- stdout: 'pipe',
280
- stderr: 'pipe',
281
- })
472
+ const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
282
473
  const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
283
474
  const sha = out.trim()
284
475
  return exit === 0 && sha.length > 0 ? sha : ref
285
476
  }
286
477
 
287
478
  async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
288
- const proc = Bun.spawnSync({
289
- cmd: ['git', 'rev-parse', '--verify', '--quiet', '--end-of-options', ref],
290
- cwd: workspaceRoot,
291
- stdout: 'pipe',
292
- stderr: 'pipe',
293
- })
479
+ const proc = spawnGitSync(
480
+ ['rev-parse', '--verify', '--quiet', '--end-of-options', ref],
481
+ workspaceRoot,
482
+ 'pipe',
483
+ )
294
484
  if (proc.exitCode === 0) return
295
485
  // `--verify --quiet` exits 1 for "that ref does not exist" and 128 for
296
486
  // "git could not run here at all" (not a repository, corrupt objects,
@@ -307,11 +497,19 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
307
497
  throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
308
498
  }
309
499
 
500
+ function isDirectory(abs: string): boolean {
501
+ try {
502
+ return statSync(abs).isDirectory()
503
+ } catch {
504
+ return false
505
+ }
506
+ }
507
+
310
508
  function projectsContaining(
311
509
  workspaceRoot: string,
312
510
  changedRelPaths: readonly string[],
313
511
  projects: readonly ProjectMeta[],
314
- ): { owned: Set<string>; orphans: string[] } {
512
+ ): Set<string> {
315
513
  // Index projects by their (canonical) dir, then for each changed path walk
316
514
  // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
317
515
  // the DEEPEST containing project — so a nested project still wins over its
@@ -322,9 +520,6 @@ function projectsContaining(
322
520
  const dirToName = new Map<string, string>()
323
521
  for (const p of projects) dirToName.set(p.dir, p.name)
324
522
  const owned = new Set<string>()
325
- // Paths that belong to no project. Only these can reach a task through a
326
- // workspace-anchored glob, so they are the exact set worth asking about.
327
- const orphans: string[] = []
328
523
  for (const rel of changedRelPaths) {
329
524
  let dir = path.resolve(workspaceRoot, rel)
330
525
  let hit = false
@@ -339,7 +534,20 @@ function projectsContaining(
339
534
  if (parent === dir) break // reached the filesystem root
340
535
  dir = parent
341
536
  }
342
- if (!hit) orphans.push(rel)
537
+ if (hit) continue
538
+ // A changed path that is a DIRECTORY on disk is a nested repository —
539
+ // a submodule whose checkout moved or is dirty (`vendor/sub`), an
540
+ // embedded repository left untracked (`vendor/nested/`) — because git
541
+ // reports nothing else as a directory. The workspace repository sees
542
+ // the nested one as that single path, so a change inside is a change
543
+ // to it, and every project under it is affected. Its own enumeration
544
+ // already keys those projects on their files (git-inputs.ts); without
545
+ // this, `--affected` after an edit inside selected none of them.
546
+ const abs = path.resolve(workspaceRoot, rel)
547
+ if (isDirectory(abs)) {
548
+ const prefix = abs + path.sep
549
+ for (const [dir, name] of dirToName) if (dir.startsWith(prefix)) owned.add(name)
550
+ }
343
551
  }
344
- return { owned, orphans }
552
+ return owned
345
553
  }