@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,118 @@
1
+ // `--affected` — git-relative project selection.
2
+ //
3
+ // Resolves the set of project names whose files have changed since a
4
+ // given git ref. The diff is `<since>...HEAD` plus working-tree
5
+ // changes (uncommitted edits), so it captures everything you've
6
+ // touched on the current branch.
7
+ //
8
+ // We use `git diff --name-only <since>` which by default compares the
9
+ // `<since>` commit to the working tree — i.e. commits-from-`<since>`
10
+ // + index + unstaged. Matches Turbo's `[<since>]` filter semantics.
11
+
12
+ import path from 'node:path'
13
+ import { UserError } from '../util/index.js'
14
+ import { LOCKFILE_NAME } from './lockfile.js'
15
+ import type { ProjectMeta } from './workspace.js'
16
+
17
+ export interface AffectedArgs {
18
+ workspaceRoot: string
19
+ /** Git ref / commit / branch to compare against. Required. */
20
+ since: string
21
+ projects: readonly ProjectMeta[]
22
+ }
23
+
24
+ /**
25
+ * Return the set of project names whose files changed between
26
+ * `<since>` and the current working tree. Errors if `git` is missing
27
+ * or `<since>` doesn't resolve to a commit.
28
+ */
29
+ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
30
+ await verifyRef(args.workspaceRoot, args.since)
31
+
32
+ const proc = Bun.spawn({
33
+ // `--no-renames` is crucial for project-affected detection: with
34
+ // git's auto rename-detection on (the default in modern git), a
35
+ // cross-project `git mv` collapses to a single rename entry that
36
+ // surfaces only the destination path. We'd then miss flagging the
37
+ // source project as affected. Treating renames as delete+add gives
38
+ // us both halves so both projects get correctly marked.
39
+ cmd: ['git', 'diff', '--no-renames', '--name-only', args.since],
40
+ cwd: args.workspaceRoot,
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ })
44
+ const stdout = await new Response(proc.stdout).text()
45
+ const stderr = await new Response(proc.stderr).text()
46
+ const exit = await proc.exited
47
+ if (exit !== 0) {
48
+ throw new UserError(`git diff failed (exit ${exit}): ${stderr.trim()}`)
49
+ }
50
+
51
+ // vx-lock.json (workspace-root metadata) is excluded like a gitignored
52
+ // file: re-running `vx lock` must not mark every project affected.
53
+ const changed = stdout.split('\n').filter((s) => s.length > 0 && s !== LOCKFILE_NAME)
54
+ return projectsContaining(args.workspaceRoot, changed, args.projects)
55
+ }
56
+
57
+ /**
58
+ * Resolve the default base for `--affected` with no explicit value.
59
+ * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
60
+ * etc.), then falls back to `HEAD~1` which always exists once there
61
+ * are at least two commits.
62
+ */
63
+ export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
64
+ const probe = Bun.spawnSync({
65
+ cmd: ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
66
+ cwd: workspaceRoot,
67
+ stdout: 'pipe',
68
+ stderr: 'ignore',
69
+ })
70
+ const out = new TextDecoder().decode(probe.stdout).trim()
71
+ if (probe.exitCode === 0 && out.length > 0) return out
72
+ return 'HEAD~1'
73
+ }
74
+
75
+ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
76
+ const proc = Bun.spawnSync({
77
+ cmd: ['git', 'rev-parse', '--verify', '--quiet', ref],
78
+ cwd: workspaceRoot,
79
+ stdout: 'pipe',
80
+ stderr: 'pipe',
81
+ })
82
+ if (proc.exitCode !== 0) {
83
+ throw new UserError(
84
+ `git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`,
85
+ )
86
+ }
87
+ }
88
+
89
+ function projectsContaining(
90
+ workspaceRoot: string,
91
+ changedRelPaths: readonly string[],
92
+ projects: readonly ProjectMeta[],
93
+ ): Set<string> {
94
+ // Index projects by their (canonical) dir, then for each changed path walk
95
+ // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
96
+ // the DEEPEST containing project — so a nested project still wins over its
97
+ // parent, exactly as the prior longest-dir sort did — but this is
98
+ // O(files · path-depth) instead of O(files · projects): independent of the
99
+ // project count, which is what a big --affected diff on a 1000-project repo
100
+ // pays for.
101
+ const dirToName = new Map<string, string>()
102
+ for (const p of projects) dirToName.set(p.dir, p.name)
103
+ const out = new Set<string>()
104
+ for (const rel of changedRelPaths) {
105
+ let dir = path.resolve(workspaceRoot, rel)
106
+ for (;;) {
107
+ const name = dirToName.get(dir)
108
+ if (name !== undefined) {
109
+ out.add(name)
110
+ break
111
+ }
112
+ const parent = path.dirname(dir)
113
+ if (parent === dir) break // reached the filesystem root
114
+ dir = parent
115
+ }
116
+ }
117
+ return out
118
+ }
@@ -0,0 +1,155 @@
1
+ // Filter DSL — pnpm-style selectors for `-F / --filter`.
2
+ //
3
+ // <pattern> name glob (e.g. foo, @scope/*)
4
+ // ./<dir> packages whose dir is at or under <dir> (relative to workspace root)
5
+ // {<dir>} same as ./<dir>
6
+ // <pattern>... pattern + its transitive workspace dependencies
7
+ // ...<pattern> pattern + its transitive workspace dependents
8
+ // <pattern>^... only the transitive deps of pattern (excluding the matched package)
9
+ // !<pattern> exclude packages matching pattern from the selection
10
+ // [<since>] projects affected since the given git ref
11
+ // (Turbo-style; resolved upstream of applyFilters via
12
+ // `affectedProjects` since it needs FS + git access)
13
+ //
14
+ // Filters are evaluated in order. If any include filter is present, the base
15
+ // set is empty and matched/expanded packages are added. If only excludes are
16
+ // given, the base set is "all projects" and excluded packages are removed.
17
+
18
+ import path from 'node:path'
19
+ import type { PackageGraph } from './package-graph.js'
20
+ import type { ProjectMeta } from './workspace.js'
21
+
22
+ export interface ParsedFilter {
23
+ raw: string
24
+ negate: boolean
25
+ withDeps: boolean
26
+ withDependents: boolean
27
+ onlyDeps: boolean
28
+ isPath: boolean
29
+ /** Glob pattern (for name match) or absolute path (for path match). */
30
+ matcher: string
31
+ /**
32
+ * When non-undefined, this filter is a git-relative `[<since>]`
33
+ * selector. The matcher field is unused; the CLI resolves the ref
34
+ * to a concrete set of project names before calling applyFilters.
35
+ */
36
+ gitSince?: string
37
+ }
38
+
39
+ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
40
+ let s = raw
41
+ const negate = s.startsWith('!')
42
+ if (negate) s = s.slice(1)
43
+
44
+ const withDependents = s.startsWith('...')
45
+ if (withDependents) s = s.slice(3)
46
+
47
+ let onlyDeps = false
48
+ let withDeps = false
49
+ if (s.endsWith('^...')) {
50
+ onlyDeps = true
51
+ s = s.slice(0, -4)
52
+ } else if (s.endsWith('...')) {
53
+ withDeps = true
54
+ s = s.slice(0, -3)
55
+ }
56
+
57
+ // `[<since>]` git-relative selector. Suffix walks already ran above,
58
+ // so `[main]...` parses as `[main]` with withDeps=true.
59
+ if (s.startsWith('[') && s.endsWith(']')) {
60
+ return {
61
+ raw,
62
+ negate,
63
+ withDeps,
64
+ withDependents,
65
+ onlyDeps,
66
+ isPath: false,
67
+ matcher: '',
68
+ gitSince: s.slice(1, -1),
69
+ }
70
+ }
71
+
72
+ let isPath = false
73
+ let matcher = s
74
+ if (s.startsWith('./') || s === '.') {
75
+ isPath = true
76
+ matcher = path.resolve(workspaceRoot, s)
77
+ } else if (s.startsWith('{') && s.endsWith('}')) {
78
+ isPath = true
79
+ matcher = path.resolve(workspaceRoot, s.slice(1, -1))
80
+ }
81
+
82
+ return { raw, negate, withDeps, withDependents, onlyDeps, isPath, matcher }
83
+ }
84
+
85
+ function matchProjects(
86
+ filter: ParsedFilter,
87
+ projects: ProjectMeta[],
88
+ affectedByFilter: Map<ParsedFilter, Set<string>> | undefined,
89
+ ): string[] {
90
+ // `[<since>]` selectors are pre-resolved by the caller (the parser
91
+ // is pure; git access happens upstream). Use the provided set as
92
+ // the match set for this filter.
93
+ if (filter.gitSince !== undefined) {
94
+ return [...(affectedByFilter?.get(filter) ?? new Set())]
95
+ }
96
+ const out: string[] = []
97
+ if (filter.isPath) {
98
+ const prefix = filter.matcher + path.sep
99
+ for (const p of projects) {
100
+ if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
101
+ }
102
+ return out
103
+ }
104
+ if (!filter.matcher.includes('*')) {
105
+ for (const p of projects) {
106
+ if (p.name === filter.matcher) out.push(p.name)
107
+ }
108
+ return out
109
+ }
110
+ const glob = new Bun.Glob(filter.matcher)
111
+ for (const p of projects) {
112
+ if (glob.match(p.name)) out.push(p.name)
113
+ }
114
+ return out
115
+ }
116
+
117
+ export interface ApplyFiltersOptions {
118
+ filters: ParsedFilter[]
119
+ projects: ProjectMeta[]
120
+ graph: PackageGraph
121
+ /**
122
+ * Pre-resolved affected-project sets for each `[<since>]` filter.
123
+ * The caller (CLI / programmatic embedder) runs the git work and
124
+ * stuffs results in this map before calling applyFilters, which
125
+ * stays pure + sync.
126
+ */
127
+ affectedByFilter?: Map<ParsedFilter, Set<string>>
128
+ }
129
+
130
+ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
131
+ const allNames = opts.projects.map((p) => p.name)
132
+ const hasInclude = opts.filters.some((f) => !f.negate)
133
+ const selected = new Set<string>(hasInclude ? [] : allNames)
134
+
135
+ for (const f of opts.filters) {
136
+ const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
137
+ const expanded = new Set<string>()
138
+ for (const name of matched) {
139
+ if (!f.onlyDeps) expanded.add(name)
140
+ if (f.withDeps || f.onlyDeps) {
141
+ for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
142
+ }
143
+ if (f.withDependents) {
144
+ for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
145
+ }
146
+ }
147
+ if (f.negate) {
148
+ for (const name of expanded) selected.delete(name)
149
+ } else {
150
+ for (const name of expanded) selected.add(name)
151
+ }
152
+ }
153
+
154
+ return selected
155
+ }
@@ -0,0 +1,41 @@
1
+ import path from 'node:path'
2
+ import { xxh3 } from '../util/index.js'
3
+
4
+ // Every package-manager lockfile we know about, plus the workspace
5
+ // definition files. Whichever ones exist get folded into the
6
+ // fingerprint; missing ones are skipped. Hashed in declaration order
7
+ // for determinism — adding a new entry here changes the fingerprint
8
+ // for projects that have that file present, which is fine: that's the
9
+ // whole point of bumping CACHE_VERSION when the fingerprint surface
10
+ // expands.
11
+ const WORKSPACE_FINGERPRINT_FILES = [
12
+ // Lockfiles
13
+ 'pnpm-lock.yaml',
14
+ 'package-lock.json',
15
+ 'npm-shrinkwrap.json',
16
+ 'yarn.lock',
17
+ 'bun.lock',
18
+ 'bun.lockb',
19
+ // Workspace definition (pnpm); package.json is hashed per-project
20
+ // via projectPackageJsonHash, not here.
21
+ 'pnpm-workspace.yaml',
22
+ ]
23
+
24
+ /**
25
+ * One hash for the workspace as a whole, derived from whichever
26
+ * lockfile + workspace-definition files exist at the root. Folded
27
+ * into every task's cache key so any lockfile bump (`pnpm update`,
28
+ * `npm install`, `bun install`, …) or a workspace-shape change
29
+ * invalidates every cached entry. Coarse but correct.
30
+ */
31
+ export async function computeWorkspaceFingerprint(workspaceRoot: string): Promise<string> {
32
+ let h = 0n
33
+ for (const f of WORKSPACE_FINGERPRINT_FILES) {
34
+ const full = path.join(workspaceRoot, f)
35
+ const file = Bun.file(full)
36
+ if (!(await file.exists())) continue
37
+ h = xxh3(`${f}\0`, h)
38
+ h = xxh3(await file.bytes(), h)
39
+ }
40
+ return h.toString(16).padStart(16, '0')
41
+ }
@@ -0,0 +1,27 @@
1
+ // Module contract. Cross-module imports must come through here; see
2
+ // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
+
4
+ export { affectedProjects, defaultAffectedBase } from './affected.js'
5
+ export { applyFilters, parseFilter } from './filter.js'
6
+ export { computeWorkspaceFingerprint } from './fingerprint.js'
7
+ export { computeNestedProjectDirs } from './nested-dirs.js'
8
+ export { buildPackageGraph, type PackageGraph } from './package-graph.js'
9
+ export {
10
+ frozenProjectConfig,
11
+ LOCKFILE_NAME,
12
+ LOCKFILE_VERSION,
13
+ lockfilePath,
14
+ readLockfile,
15
+ writeLockfile,
16
+ type Lockfile,
17
+ type LockfileEntry,
18
+ } from './lockfile.js'
19
+ export { loadProjectConfig, loadWorkspaceConfig } from './project-loader.js'
20
+ export {
21
+ findWorkspaceRoot,
22
+ listProjects,
23
+ loadWorkspace,
24
+ resolveCacheDir,
25
+ type ProjectEntry,
26
+ type ProjectMeta,
27
+ } from './workspace.js'
@@ -0,0 +1,118 @@
1
+ // vx-lock.json — frozen resolved-config lockfile.
2
+ //
3
+ // `vx lock` evaluates every project's vx.config.* in the CURRENT
4
+ // environment and freezes the resolved objects (plus a content hash of
5
+ // each config file) into `vx-lock.json` at the workspace root. While the
6
+ // lock exists, runs load configs from it instead of evaluating —
7
+ // frozen-env semantics. See docs/design/config-lock-2026-06.md.
8
+ //
9
+ // Verification asymmetry (deliberate):
10
+ // - runs TRUST the lock: hash-only staleness check, eval-free.
11
+ // - `vx lock --check` AUDITS it: full re-evaluation + deep equality,
12
+ // which catches eval-time env drift that file hashes cannot see.
13
+
14
+ import path from 'node:path'
15
+ import type { ProjectConfig } from '../config.js'
16
+ import { relPosix, UserError } from '../util/index.js'
17
+ import { validateProjectConfig } from './project-loader.js'
18
+
19
+ export const LOCKFILE_NAME = 'vx-lock.json'
20
+ export const LOCKFILE_VERSION = 1
21
+
22
+ export interface LockfileEntry {
23
+ /** Workspace-root-relative POSIX path to the project's config file. */
24
+ configPath: string
25
+ /** xxh3 hex of the config file bytes at lock time. */
26
+ configHash: string
27
+ /** The resolved (post-evaluation) config object, JSON-normalized. */
28
+ config: ProjectConfig
29
+ }
30
+
31
+ export interface Lockfile {
32
+ version: number
33
+ projects: Record<string, LockfileEntry>
34
+ }
35
+
36
+ export function lockfilePath(root: string): string {
37
+ return path.join(root, LOCKFILE_NAME)
38
+ }
39
+
40
+ /**
41
+ * Read and shape-validate `vx-lock.json`. Returns `null` when no lock
42
+ * exists (the common case — the feature is opt-in). The lock is a
43
+ * user-editable file, so this is a system boundary: malformed content
44
+ * throws a `UserError` instead of crashing deeper in the run.
45
+ */
46
+ export async function readLockfile(root: string): Promise<Lockfile | null> {
47
+ const file = Bun.file(lockfilePath(root))
48
+ if (!(await file.exists())) return null
49
+ let parsed: unknown
50
+ try {
51
+ parsed = JSON.parse(await file.text())
52
+ } catch {
53
+ throw new UserError(`${LOCKFILE_NAME} is not valid JSON — re-run \`vx lock\` or delete it`)
54
+ }
55
+ if (typeof parsed !== 'object' || parsed === null) {
56
+ throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``)
57
+ }
58
+ const lock = parsed as { version?: unknown; projects?: unknown }
59
+ if (lock.version !== LOCKFILE_VERSION) {
60
+ throw new UserError(
61
+ `${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
62
+ `(this vx expects ${LOCKFILE_VERSION}) — re-run \`vx lock\``,
63
+ )
64
+ }
65
+ if (typeof lock.projects !== 'object' || lock.projects === null) {
66
+ throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
67
+ }
68
+ for (const [name, entry] of Object.entries(lock.projects)) {
69
+ const e = entry as Partial<LockfileEntry> | null
70
+ if (
71
+ !e ||
72
+ typeof e.configPath !== 'string' ||
73
+ typeof e.configHash !== 'string' ||
74
+ typeof e.config !== 'object' ||
75
+ e.config === null
76
+ ) {
77
+ throw new UserError(`${LOCKFILE_NAME}: entry for "${name}" is malformed — re-run \`vx lock\``)
78
+ }
79
+ }
80
+ return parsed as Lockfile
81
+ }
82
+
83
+ export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
84
+ await Bun.write(lockfilePath(root), `${JSON.stringify(lock, null, 2)}\n`)
85
+ }
86
+
87
+ /**
88
+ * Run-time config load from the lock: verify the config file's content
89
+ * hash against the lock entry, then return the FROZEN resolved config.
90
+ * No evaluation happens — this is the fast, eval-free trust path. A
91
+ * changed file or an unlocked project is a hard error: the lock's
92
+ * contract is "what runs is what was locked", and silently falling
93
+ * back to evaluation would break frozen-env semantics.
94
+ */
95
+ export async function frozenProjectConfig(
96
+ lock: Lockfile,
97
+ meta: { name: string; configPath: string },
98
+ root: string,
99
+ ): Promise<ProjectConfig> {
100
+ const rel = relPosix(root, meta.configPath)
101
+ const entry = lock.projects[meta.name]
102
+ if (!entry || entry.configPath !== rel) {
103
+ throw new UserError(
104
+ `${LOCKFILE_NAME} has no entry for "${meta.name}" (${rel}) — ` +
105
+ `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
106
+ )
107
+ }
108
+ // No staleness checks here, deliberately: --frozen runs after
109
+ // `vx lock --check` in any sane pipeline, and that audit re-
110
+ // evaluates everything — a per-file byte-hash re-check would be
111
+ // redundant work and a weaker guarantee pretending to add safety
112
+ // (it can't see import closures or env anyway). configHash stays
113
+ // in the file solely for --check's fast file-changed reporting.
114
+ // The lock is hand-editable; the stored config crosses the same
115
+ // boundary a freshly evaluated one does.
116
+ validateProjectConfig(entry.config, `${LOCKFILE_NAME} (${meta.name})`)
117
+ return entry.config
118
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path'
2
+ import type { ProjectEntry } from './workspace.js'
3
+
4
+ /**
5
+ * For each project, the absolute dirs of other projects that live
6
+ * underneath it. Used to enforce project-boundary isolation: a
7
+ * project's task cannot see files inside another project, even if its
8
+ * globs would otherwise match.
9
+ *
10
+ * Sort once, then scan: nested projects of `p` form a contiguous run
11
+ * immediately after `p` in the sorted order (any non-descendant breaks
12
+ * the prefix-match). O(P log P) instead of the naive O(P²) cross
13
+ * product. Stable for any project set with no dir collisions, which
14
+ * the workspace loader already enforces.
15
+ */
16
+ export function computeNestedProjectDirs(
17
+ entries: Array<Pick<ProjectEntry, 'name' | 'dir'>>,
18
+ ): Map<string, string[]> {
19
+ const result = new Map<string, string[]>()
20
+ if (entries.length === 0) return result
21
+ const sorted = [...entries].sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
22
+ for (let i = 0; i < sorted.length; i++) {
23
+ const p = sorted[i]!
24
+ const prefix = p.dir + path.sep
25
+ const nested: string[] = []
26
+ for (let j = i + 1; j < sorted.length; j++) {
27
+ const other = sorted[j]!
28
+ if (!other.dir.startsWith(prefix)) break
29
+ nested.push(other.dir)
30
+ }
31
+ result.set(p.name, nested)
32
+ }
33
+ return result
34
+ }
@@ -0,0 +1,148 @@
1
+ import type { ProjectMeta } from './workspace.js'
2
+
3
+ export interface PackageGraph {
4
+ /** Immediate workspace deps (sorted). */
5
+ directDeps: (name: string) => string[]
6
+ /** All transitive workspace deps for each project. */
7
+ transitiveDeps: (name: string) => string[]
8
+ /** All transitive workspace dependents (packages that depend on the named one). */
9
+ transitiveDependents: (name: string) => string[]
10
+ }
11
+
12
+ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
13
+ const byName = new Map<string, ProjectMeta>()
14
+ for (const p of projects) byName.set(p.name, p)
15
+
16
+ const directDeps = new Map<string, string[]>()
17
+ for (const p of projects) {
18
+ const seen = new Set<string>()
19
+ for (const field of [
20
+ 'dependencies',
21
+ 'devDependencies',
22
+ 'peerDependencies',
23
+ 'optionalDependencies',
24
+ ] as const) {
25
+ const obj = p.packageJson[field]
26
+ if (!obj) continue
27
+ for (const name of Object.keys(obj)) {
28
+ if (name === p.name) continue
29
+ if (byName.has(name)) seen.add(name)
30
+ }
31
+ }
32
+ directDeps.set(p.name, [...seen].sort())
33
+ }
34
+
35
+ // Reverse adjacency: who declares X as a workspace dep.
36
+ const directDependents = new Map<string, string[]>()
37
+ for (const [name, deps] of directDeps) {
38
+ for (const d of deps) {
39
+ const arr = directDependents.get(d)
40
+ if (arr) arr.push(name)
41
+ else directDependents.set(d, [name])
42
+ }
43
+ }
44
+ for (const arr of directDependents.values()) arr.sort()
45
+
46
+ // Set-union DFS closures are O(P²) entries on dense layered graphs
47
+ // (same disease the scheduler's reachOf had — 68 ms at 1090
48
+ // projects). Bitset closures swept in topo order are O(E·P/32),
49
+ // and indexing projects in sorted-name order means materializing a
50
+ // closure is a single ascending bit-scan — already sorted, no
51
+ // per-call sort. Package graphs (unlike task graphs) may legally
52
+ // contain cycles; the bitset sweep requires a DAG, so a cycle
53
+ // (detected by the Kahn pass not draining) falls back wholesale to
54
+ // the legacy DFS — byte-identical behavior for that rare case.
55
+ const names = [...byName.keys()].sort()
56
+ const index = new Map<string, number>()
57
+ for (let i = 0; i < names.length; i++) index.set(names[i]!, i)
58
+
59
+ function bitsetClosures(edges: Map<string, string[]>): Uint32Array | null {
60
+ const n = names.length
61
+ const words = (n + 31) >>> 5
62
+ const adj: number[][] = Array.from({ length: n }, () => [])
63
+ const indegree = new Uint32Array(n)
64
+ for (const [from, tos] of edges) {
65
+ const fi = index.get(from)!
66
+ for (const to of tos) {
67
+ const ti = index.get(to)
68
+ if (ti === undefined) continue
69
+ adj[fi]!.push(ti) // edge from → to; closure(from) ⊇ {to} ∪ closure(to)
70
+ indegree[ti]!++
71
+ }
72
+ }
73
+ const topo = new Int32Array(n)
74
+ let head = 0
75
+ let tail = 0
76
+ for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
77
+ while (head < tail) {
78
+ const v = topo[head++]!
79
+ for (const t of adj[v]!) if (--indegree[t]! === 0) topo[tail++] = t
80
+ }
81
+ if (tail < n) return null // cycle — caller falls back to DFS
82
+ const closure = new Uint32Array(n * words)
83
+ for (let t = tail - 1; t >= 0; t--) {
84
+ const i = topo[t]!
85
+ const base = i * words
86
+ for (const d of adj[i]!) {
87
+ closure[base + (d >>> 5)]! |= 1 << (d & 31)
88
+ const dbase = d * words
89
+ for (let w = 0; w < words; w++) closure[base + w]! |= closure[dbase + w]!
90
+ }
91
+ }
92
+ return closure
93
+ }
94
+
95
+ function legacyTransitive(
96
+ name: string,
97
+ edges: Map<string, string[]>,
98
+ cache: Map<string, string[]>,
99
+ stack: Set<string> = new Set(),
100
+ ): string[] {
101
+ const cached = cache.get(name)
102
+ if (cached) return cached
103
+ if (stack.has(name)) return []
104
+ stack.add(name)
105
+ const out = new Set<string>()
106
+ for (const d of edges.get(name) ?? []) {
107
+ out.add(d)
108
+ for (const t of legacyTransitive(d, edges, cache, stack)) out.add(t)
109
+ }
110
+ stack.delete(name)
111
+ const result = [...out].sort()
112
+ cache.set(name, result)
113
+ return result
114
+ }
115
+
116
+ function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
117
+ const closures = bitsetClosures(edges)
118
+ const memo = new Map<string, string[]>()
119
+ if (closures === null) {
120
+ return (name) => legacyTransitive(name, edges, memo)
121
+ }
122
+ const words = (names.length + 31) >>> 5
123
+ return (name) => {
124
+ const cached = memo.get(name)
125
+ if (cached) return cached
126
+ const i = index.get(name)
127
+ if (i === undefined) return []
128
+ const base = i * words
129
+ const out: string[] = []
130
+ for (let w = 0; w < words; w++) {
131
+ let v = closures[base + w]!
132
+ while (v !== 0) {
133
+ const bit = 31 - Math.clz32(v & -v)
134
+ out.push(names[(w << 5) + bit]!)
135
+ v &= v - 1
136
+ }
137
+ }
138
+ memo.set(name, out)
139
+ return out
140
+ }
141
+ }
142
+
143
+ return {
144
+ directDeps: (name) => directDeps.get(name) ?? [],
145
+ transitiveDeps: makeAccessor(directDeps),
146
+ transitiveDependents: makeAccessor(directDependents),
147
+ }
148
+ }