@vzn/vx 0.0.90 → 0.0.92
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.
- package/README.md +6 -0
- package/package.json +14 -10
- package/src/cache/cache.ts +40 -1
- package/src/cache/key-fold.ts +17 -9
- package/src/cache/run-history.ts +11 -8
- package/src/cli/cache.ts +5 -2
- package/src/cli/help.ts +22 -0
- package/src/cli/info.ts +2 -2
- package/src/cli/init.ts +14 -3
- package/src/cli/last.ts +3 -2
- package/src/cli/lock.ts +6 -2
- package/src/cli/run.ts +3 -26
- package/src/cli/select.ts +12 -1
- package/src/cli/show.ts +10 -6
- package/src/cli/watch-fs.ts +248 -0
- package/src/cli/watch.ts +10 -241
- package/src/cli/why.ts +3 -2
- package/src/orchestrator/plugin-host.ts +9 -1
- package/src/orchestrator/plugin.ts +24 -3
- package/src/orchestrator/stable-keys.ts +13 -0
- package/src/workspace/migrate-scripts.ts +9 -0
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ rm -rf packages/*/dist
|
|
|
88
88
|
npx vx run build --all # restored from the cache, not rebuilt
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
<img src="https://vznjs.github.io/vx/demo.svg" width="760" alt="vx run ci --all on examples/basic: cold, three tasks run; again, three up-to-date.">
|
|
92
|
+
|
|
91
93
|
Or copy [`examples/basic`](examples/basic), a two-package starter CI
|
|
92
94
|
runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
|
|
93
95
|
[Configure](https://vznjs.github.io/vx/guides/configure/) ·
|
|
@@ -112,6 +114,10 @@ When you want configs of your own, one command writes them:
|
|
|
112
114
|
bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
|
|
113
115
|
```
|
|
114
116
|
|
|
117
|
+
The written configs derive the same cache keys, so what `turbo()` cached
|
|
118
|
+
still hits: [`examples/turbo`](examples/turbo) runs both steps on every
|
|
119
|
+
commit.
|
|
120
|
+
|
|
115
121
|
`turboCache()` and `nxCache()` keep the remote cache you have (Turbo's
|
|
116
122
|
`/v8/artifacts` API, Vercel's included, or Nx's `/v1/cache`). What each
|
|
117
123
|
Turbo and Nx feature is in vx, pinned by a test:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.92",
|
|
4
|
+
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.92",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.92",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.92",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.92"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -36,16 +36,20 @@
|
|
|
36
36
|
"type": "git",
|
|
37
37
|
"url": "git+https://github.com/vznjs/vx.git"
|
|
38
38
|
},
|
|
39
|
-
"homepage": "https://github.
|
|
39
|
+
"homepage": "https://vznjs.github.io/vx/",
|
|
40
40
|
"bugs": "https://github.com/vznjs/vx/issues",
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"keywords": [
|
|
43
|
+
"vx",
|
|
43
44
|
"monorepo",
|
|
44
45
|
"task-runner",
|
|
45
|
-
"build",
|
|
46
|
+
"build-cache",
|
|
47
|
+
"build-system",
|
|
46
48
|
"cache",
|
|
47
|
-
"bun",
|
|
48
49
|
"turborepo",
|
|
49
|
-
"nx"
|
|
50
|
+
"nx",
|
|
51
|
+
"bun",
|
|
52
|
+
"workspaces",
|
|
53
|
+
"ci"
|
|
50
54
|
]
|
|
51
55
|
}
|
package/src/cache/cache.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
span,
|
|
41
41
|
splitTaskId,
|
|
42
42
|
taskGlob,
|
|
43
|
+
xxh3hex,
|
|
43
44
|
} from '../util/index.js'
|
|
44
45
|
import {
|
|
45
46
|
ArchiveSecurityError,
|
|
@@ -655,7 +656,7 @@ export class Cache implements CacheLayer {
|
|
|
655
656
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
656
657
|
this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
|
|
657
658
|
this.outputs = new OutputIndex(this.db)
|
|
658
|
-
this.history = new RunHistory(this.db)
|
|
659
|
+
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
659
660
|
|
|
660
661
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
661
662
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -753,9 +754,47 @@ export class Cache implements CacheLayer {
|
|
|
753
754
|
input,
|
|
754
755
|
(f) => this.hashFile(f),
|
|
755
756
|
(f) => this.relFor(input.workspaceRoot, f),
|
|
757
|
+
(v) => this.digestValue(v),
|
|
756
758
|
)
|
|
757
759
|
}
|
|
758
760
|
|
|
761
|
+
private valueSalt: string | undefined
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The digest a value-bearing row holds (an env value, a runtime output,
|
|
765
|
+
* args after `--`): xxh3 under a random salt this store keeps. `vx why`,
|
|
766
|
+
* its JSON and MCP's `whyDidThisRerun` print it, and an unkeyed xxh3 in
|
|
767
|
+
* a public CI log let anyone confirm or brute-force a short secret
|
|
768
|
+
* (L-4). Salted, a digest still says whether a value changed, and says
|
|
769
|
+
* nothing without this cache's `schema_meta`. Asked for on a miss only.
|
|
770
|
+
*/
|
|
771
|
+
private digestValue(value: string): string {
|
|
772
|
+
this.valueSalt ??= this.loadValueSalt()
|
|
773
|
+
return xxh3hex(`${this.valueSalt}\0${value}`)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private loadValueSalt(): string {
|
|
777
|
+
const read = (): string | undefined =>
|
|
778
|
+
(
|
|
779
|
+
this.db.prepare("SELECT value FROM schema_meta WHERE key = 'value_salt'").get() as {
|
|
780
|
+
value: string
|
|
781
|
+
} | null
|
|
782
|
+
)?.value
|
|
783
|
+
const found = read()
|
|
784
|
+
if (found !== undefined) return found
|
|
785
|
+
const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex')
|
|
786
|
+
// A store this process may not write keeps the salt for the process:
|
|
787
|
+
// nothing it digests is persisted.
|
|
788
|
+
if (this.writeBlocked !== null || this.inspecting) return salt
|
|
789
|
+
// Two processes opening one new store: the first salt stands.
|
|
790
|
+
this.db
|
|
791
|
+
.prepare(
|
|
792
|
+
"INSERT INTO schema_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
|
|
793
|
+
)
|
|
794
|
+
.run(salt)
|
|
795
|
+
return read() ?? salt
|
|
796
|
+
}
|
|
797
|
+
|
|
759
798
|
// ctx is accepted but ignored — the local layer reads metadata from
|
|
760
799
|
// the entries row. It's part of the contract so LayeredCache can
|
|
761
800
|
// route metadata to `ingest()` on remote-hit without a separate API.
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -99,6 +99,12 @@ export async function foldKey(
|
|
|
99
99
|
input: CacheKeyInput,
|
|
100
100
|
hashFile: (file: string) => Promise<string>,
|
|
101
101
|
relOf: (file: string) => string,
|
|
102
|
+
/**
|
|
103
|
+
* The digest a value-bearing capture row holds. `Cache` keys it with a
|
|
104
|
+
* salt of its own (L-4); the default is for a caller that persists and
|
|
105
|
+
* prints nothing.
|
|
106
|
+
*/
|
|
107
|
+
digestValue: (value: string) => string = xxh3hex,
|
|
102
108
|
): Promise<string> {
|
|
103
109
|
// Seed-chained xxHash3: each step folds one field into the
|
|
104
110
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
@@ -124,16 +130,18 @@ export async function foldKey(
|
|
|
124
130
|
// DIGEST of each component, never the raw value. For value-bearing kinds
|
|
125
131
|
// (env, runtime, ws-runtime, forward) the payload is a secret or
|
|
126
132
|
// sensitive string (API keys via cache.inputs.env, runtime-command
|
|
127
|
-
// output, args after `--`), so we push `
|
|
128
|
-
// only needs to know whether a component CHANGED, which a
|
|
129
|
-
// preserves losslessly. cache.db must never hold plaintext
|
|
130
|
-
//
|
|
133
|
+
// output, args after `--`), so we push `digestValue(v)` — the diff
|
|
134
|
+
// consumer only needs to know whether a component CHANGED, which a
|
|
135
|
+
// digest preserves losslessly. cache.db must never hold plaintext
|
|
136
|
+
// secrets at rest, and `vx why` prints the digest, so it is salted: an
|
|
137
|
+
// unkeyed 64-bit xxh3 in a public CI log gave a short secret away to a
|
|
138
|
+
// brute force (L-4). The cache KEY (`h`) folds the plaintext separately below and is
|
|
131
139
|
// unaffected.
|
|
132
140
|
const forwarded = input.forwardArgs ?? []
|
|
133
141
|
h = xxh3(`forward-args:${forwarded.length}`, h)
|
|
134
142
|
for (const a of forwarded) h = xxh3(a, h)
|
|
135
143
|
if (cap && forwarded.length > 0) {
|
|
136
|
-
cap.push({ kind: 'forward', name: 'argv', hash:
|
|
144
|
+
cap.push({ kind: 'forward', name: 'argv', hash: digestValue(JSON.stringify(forwarded)) })
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
h = xxh3(`env-values:${input.envValues.length}`, h)
|
|
@@ -145,20 +153,20 @@ export async function foldKey(
|
|
|
145
153
|
for (const [n, v] of input.envValues) h = xxh3(v === undefined ? n : `${n}\0${v}`, h)
|
|
146
154
|
if (cap)
|
|
147
155
|
for (const [n, v] of input.envValues)
|
|
148
|
-
cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' :
|
|
156
|
+
cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : digestValue(v) })
|
|
149
157
|
|
|
150
158
|
const runtimeValues = input.runtimeValues ?? []
|
|
151
159
|
h = xxh3(`runtime-values:${runtimeValues.length}`, h)
|
|
152
160
|
for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
153
161
|
if (cap)
|
|
154
|
-
for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash:
|
|
162
|
+
for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: digestValue(o) })
|
|
155
163
|
|
|
156
164
|
const wsRuntimeValues = input.workspaceRuntimeValues ?? []
|
|
157
165
|
h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
|
|
158
166
|
for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
159
167
|
if (cap)
|
|
160
168
|
for (const [c, o] of wsRuntimeValues)
|
|
161
|
-
cap.push({ kind: 'ws-runtime', name: c, hash:
|
|
169
|
+
cap.push({ kind: 'ws-runtime', name: c, hash: digestValue(o) })
|
|
162
170
|
|
|
163
171
|
const upstream = [...input.upstreamHashes].sort()
|
|
164
172
|
h = xxh3(`upstream:${upstream.length}`, h)
|
|
@@ -174,7 +182,7 @@ export async function foldKey(
|
|
|
174
182
|
h = xxh3(`plugin:${pluginParts.length}`, h)
|
|
175
183
|
for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
|
|
176
184
|
if (cap) {
|
|
177
|
-
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash:
|
|
185
|
+
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
|
package/src/cache/run-history.ts
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// its statements over the store's handle; `Cache` delegates.
|
|
5
5
|
|
|
6
6
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
|
-
import { xxh3hex } from '../util/index.js'
|
|
8
7
|
import type { InvocationRecord, RunRecord } from './layer.js'
|
|
9
8
|
|
|
10
9
|
export class RunHistory {
|
|
11
10
|
private readonly insertRun: ReturnType<Database['prepare']>
|
|
12
11
|
private readonly insertInvocation: ReturnType<Database['prepare']>
|
|
13
12
|
|
|
14
|
-
constructor(
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly db: Database,
|
|
15
|
+
/** The store's salted value digest (L-4). */
|
|
16
|
+
private readonly digestValue: (value: string) => string,
|
|
17
|
+
) {
|
|
15
18
|
this.insertRun = this.db.prepare(`
|
|
16
19
|
INSERT INTO runs(
|
|
17
20
|
hash, project, task, status, exit_code, duration_ms, forward_args,
|
|
@@ -37,13 +40,13 @@ export class RunHistory {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
recordRun(run: RunRecord): void {
|
|
40
|
-
this.insertRun.run(...bindRun(run))
|
|
43
|
+
this.insertRun.run(...bindRun(run, this.digestValue))
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
44
47
|
if (runs.length === 0) return
|
|
45
48
|
if (runs.length === 1) {
|
|
46
|
-
this.insertRun.run(...bindRun(runs[0]
|
|
49
|
+
this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
|
|
47
50
|
return
|
|
48
51
|
}
|
|
49
52
|
// `bun:sqlite`'s `transaction()` returns a callable that wraps the
|
|
@@ -51,7 +54,7 @@ export class RunHistory {
|
|
|
51
54
|
// run that's one fsync instead of 200.
|
|
52
55
|
const insert = this.insertRun
|
|
53
56
|
const tx = this.db.transaction((batch: readonly RunRecord[]) => {
|
|
54
|
-
for (const r of batch) insert.run(...bindRun(r))
|
|
57
|
+
for (const r of batch) insert.run(...bindRun(r, this.digestValue))
|
|
55
58
|
})
|
|
56
59
|
tx(runs)
|
|
57
60
|
}
|
|
@@ -66,7 +69,7 @@ export class RunHistory {
|
|
|
66
69
|
const insertRun = this.insertRun
|
|
67
70
|
const insertInvocation = this.insertInvocation
|
|
68
71
|
this.db.transaction(() => {
|
|
69
|
-
for (const r of bundle.runs) insertRun.run(...bindRun(r))
|
|
72
|
+
for (const r of bundle.runs) insertRun.run(...bindRun(r, this.digestValue))
|
|
70
73
|
insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
71
74
|
})()
|
|
72
75
|
}
|
|
@@ -89,7 +92,7 @@ export class RunHistory {
|
|
|
89
92
|
* `insertRun` prepared statement (21 columns). Shared between the
|
|
90
93
|
* single and batched record paths.
|
|
91
94
|
*/
|
|
92
|
-
function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
95
|
+
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
93
96
|
return [
|
|
94
97
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
95
98
|
// NOT NULL invariant can't be violated from a call site.
|
|
@@ -102,7 +105,7 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
|
102
105
|
// A digest, as entry_inputs keeps: args after `--` carry tokens
|
|
103
106
|
// (`--token=…`), and cache.db holds no plaintext secret at rest; nothing
|
|
104
107
|
// reads the column but for whether the args changed (item 1091).
|
|
105
|
-
run.forwardArgs ?
|
|
108
|
+
run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
|
|
106
109
|
run.startedAt,
|
|
107
110
|
run.endedAt,
|
|
108
111
|
run.runId ?? null,
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
3
|
-
import { seeHelp } from './help.js'
|
|
3
|
+
import { flagHint, seeHelp } from './help.js'
|
|
4
4
|
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
5
5
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
6
6
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
@@ -112,7 +112,10 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
112
112
|
out.dryRun = true
|
|
113
113
|
} else {
|
|
114
114
|
const cd = parseCacheDirFlag(args, i)
|
|
115
|
-
if (cd === null)
|
|
115
|
+
if (cd === null)
|
|
116
|
+
return {
|
|
117
|
+
error: `unknown argument: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
|
|
118
|
+
}
|
|
116
119
|
if ('error' in cd) return { error: cd.error }
|
|
117
120
|
out.cacheDir = cd.cacheDir
|
|
118
121
|
i = cd.next
|
package/src/cli/help.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { nearest } from '../util/index.js'
|
|
2
|
+
|
|
1
3
|
export function printHelp(pluginCommands: readonly string[] = [], verb?: string): void {
|
|
2
4
|
process.stdout.write(verb === undefined ? helpText(pluginCommands) : verbHelpText(verb))
|
|
3
5
|
}
|
|
@@ -188,6 +190,26 @@ export function seeHelp(verb: string): string {
|
|
|
188
190
|
return ` (see \`vx ${verb} --help\`)`
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* `(did you mean --concurrency?)` for a flag within two edits of one `verb`
|
|
195
|
+
* accepts; empty for anything else (a positional included). A third edit
|
|
196
|
+
* is allowed only between flags that share their first five characters,
|
|
197
|
+
* so `--retries` reaches `--retry` while `--zzz` does not reach `--all`.
|
|
198
|
+
*/
|
|
199
|
+
export function flagHint(verb: string, arg: string): string {
|
|
200
|
+
if (!arg.startsWith('-')) return ''
|
|
201
|
+
const name = arg.replace(/=.*$/, '')
|
|
202
|
+
const flags = acceptedFlags(verb)
|
|
203
|
+
const best =
|
|
204
|
+
nearest(name, flags) ??
|
|
205
|
+
nearest(
|
|
206
|
+
name,
|
|
207
|
+
flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
|
|
208
|
+
3,
|
|
209
|
+
)
|
|
210
|
+
return best === undefined ? '' : ` (did you mean ${best}?)`
|
|
211
|
+
}
|
|
212
|
+
|
|
191
213
|
/**
|
|
192
214
|
* The documented `--flags` of one verb, read from the help text's
|
|
193
215
|
* sections headed `… (for <verb>):` so there is no second list to drift —
|
package/src/cli/info.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// too; this file parses the flags and renders the rows.
|
|
6
6
|
|
|
7
7
|
import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
|
|
8
|
-
import { seeHelp } from './help.js'
|
|
8
|
+
import { flagHint, seeHelp } from './help.js'
|
|
9
9
|
import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
10
10
|
import { formatBytes } from './format.js'
|
|
11
11
|
import { MIN_BUN } from '../util/index.js'
|
|
@@ -36,7 +36,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
|
36
36
|
i = cd.next
|
|
37
37
|
continue
|
|
38
38
|
}
|
|
39
|
-
return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
|
|
39
|
+
return { ...out, error: `unknown argument: ${a}${flagHint('info', a ?? '')}${seeHelp('info')}` }
|
|
40
40
|
}
|
|
41
41
|
return out
|
|
42
42
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// the scripts is the richer source, and `@vzn/vx-migrate` maps it.
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import { seeHelp } from './help.js'
|
|
7
|
+
import { flagHint, seeHelp } from './help.js'
|
|
8
|
+
import { isUserError, UserError } from '../util/index.js'
|
|
8
9
|
import {
|
|
9
10
|
applyMigration,
|
|
10
11
|
findWorkspaceRoot,
|
|
@@ -28,7 +29,8 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
28
29
|
if (a === '--dry') out.dry = true
|
|
29
30
|
else if (a === '--force') out.force = true
|
|
30
31
|
else if (a === '--mjs') out.mjs = true
|
|
31
|
-
else if (a.startsWith('-'))
|
|
32
|
+
else if (a.startsWith('-'))
|
|
33
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
|
|
32
34
|
else return { ...out, error: `unexpected argument: ${a}` }
|
|
33
35
|
}
|
|
34
36
|
return out
|
|
@@ -41,7 +43,16 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
41
43
|
return 1
|
|
42
44
|
}
|
|
43
45
|
const reads: LoadReads = new Map()
|
|
44
|
-
|
|
46
|
+
// "a workspace from nowhere" still needs a package.json to start from,
|
|
47
|
+
// and the lookup's own refusal named no next step.
|
|
48
|
+
const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
|
|
49
|
+
if (isUserError(err) && err.message.startsWith('Could not find a workspace root')) {
|
|
50
|
+
throw new UserError(
|
|
51
|
+
'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
throw err
|
|
55
|
+
})
|
|
45
56
|
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
46
57
|
// `init` reads scripts only; a runner's own config beside them is the
|
|
47
58
|
// richer source (dependsOn, inputs, outputs) and was ignored without a
|
package/src/cli/last.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
8
8
|
import { formatBytes } from './format.js'
|
|
9
|
-
import { seeHelp } from './help.js'
|
|
9
|
+
import { flagHint, seeHelp } from './help.js'
|
|
10
10
|
import {
|
|
11
11
|
exitSignal,
|
|
12
12
|
getInvocation,
|
|
@@ -62,7 +62,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
62
62
|
i = cd.next
|
|
63
63
|
continue
|
|
64
64
|
}
|
|
65
|
-
if (a.startsWith('-'))
|
|
65
|
+
if (a.startsWith('-'))
|
|
66
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
|
|
66
67
|
if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
67
68
|
out.runId = a
|
|
68
69
|
}
|
package/src/cli/lock.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
|
-
import { seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, seeHelp } from './help.js'
|
|
7
7
|
import { relPosix, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
@@ -29,7 +29,11 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
|
29
29
|
const out: LockArgs = { check: false }
|
|
30
30
|
for (const a of args) {
|
|
31
31
|
if (a === '--check') out.check = true
|
|
32
|
-
else
|
|
32
|
+
else
|
|
33
|
+
return {
|
|
34
|
+
check: false,
|
|
35
|
+
error: `unknown argument: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
return out
|
|
35
39
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { flagHint, seeHelp } from './help.js'
|
|
4
4
|
import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
|
|
5
5
|
import {
|
|
6
6
|
planRun,
|
|
@@ -15,13 +15,7 @@ import {
|
|
|
15
15
|
import type { ContinueMode } from '../graph/index.js'
|
|
16
16
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
17
17
|
import { findCwdProject, pickTask, resolveFilters } from './select.js'
|
|
18
|
-
import {
|
|
19
|
-
MAX_TIMEOUT_MS,
|
|
20
|
-
isUserError,
|
|
21
|
-
parseDecimalInt,
|
|
22
|
-
nearest,
|
|
23
|
-
machineParallelism,
|
|
24
|
-
} from '../util/index.js'
|
|
18
|
+
import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
25
19
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
26
20
|
|
|
27
21
|
export interface RunArgs {
|
|
@@ -343,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
343
337
|
}
|
|
344
338
|
out.report = fmt
|
|
345
339
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
346
|
-
return { ...out, error: `unknown flag: ${a}${
|
|
340
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('run', a)}${seeHelp('run')}` }
|
|
347
341
|
} else if (a !== undefined) {
|
|
348
342
|
out.tasks.push(a)
|
|
349
343
|
}
|
|
@@ -695,20 +689,3 @@ function printSummary(summary: RunResult): void {
|
|
|
695
689
|
function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
|
|
696
690
|
return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
|
|
697
691
|
}
|
|
698
|
-
|
|
699
|
-
/** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
|
|
700
|
-
function didYouMeanFlag(flag: string): string {
|
|
701
|
-
const name = flag.replace(/=.*$/, '')
|
|
702
|
-
const flags = documentedFlags('run')
|
|
703
|
-
// The usual two edits; a third only between flags that share their
|
|
704
|
-
// first five characters, so `--retries` reaches `--retry` (the i/y)
|
|
705
|
-
// while `--zzz` does not reach `--all` — a plain three-edit budget did.
|
|
706
|
-
const best =
|
|
707
|
-
nearest(name, flags) ??
|
|
708
|
-
nearest(
|
|
709
|
-
name,
|
|
710
|
-
flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
|
|
711
|
-
3,
|
|
712
|
-
)
|
|
713
|
-
return best === undefined ? '' : ` (did you mean ${best}?)`
|
|
714
|
-
}
|
package/src/cli/select.ts
CHANGED
|
@@ -413,9 +413,20 @@ function didYouMeanProject(
|
|
|
413
413
|
projects: Iterable<{ name: string }>,
|
|
414
414
|
): string {
|
|
415
415
|
const names = [...projects].map((p) => p.name)
|
|
416
|
+
// A scoped name typed without its scope (`vx-mcp` for `@vzn/vx-mcp`) is
|
|
417
|
+
// many edits from the whole name and none from the part after the `/`.
|
|
418
|
+
const byBare = new Map<string, string[]>()
|
|
419
|
+
for (const n of names) {
|
|
420
|
+
const bare = n.slice(n.indexOf('/') + 1)
|
|
421
|
+
if (bare !== n) byBare.set(bare, [...(byBare.get(bare) ?? []), n])
|
|
422
|
+
}
|
|
416
423
|
for (const pattern of unmatched) {
|
|
417
|
-
const
|
|
424
|
+
const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
|
|
425
|
+
const best = nearest(typed, names)
|
|
418
426
|
if (best !== undefined) return `. Did you mean ${best}?`
|
|
427
|
+
const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
|
|
428
|
+
const scoped = bare === undefined ? undefined : byBare.get(bare)
|
|
429
|
+
if (scoped?.length === 1) return `. Did you mean ${scoped[0]}?`
|
|
419
430
|
}
|
|
420
431
|
return ''
|
|
421
432
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
10
10
|
import { declaredTask } from '../graph/index.js'
|
|
11
|
-
import { seeHelp } from './help.js'
|
|
11
|
+
import { flagHint, seeHelp } from './help.js'
|
|
12
12
|
import { nearMatches, relPosix, UserError } from '../util/index.js'
|
|
13
13
|
import { loadCliProjects } from './workspace-config.js'
|
|
14
14
|
import {
|
|
@@ -33,7 +33,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
33
33
|
let format: string | undefined
|
|
34
34
|
if (a === '--format') format = args[++i] ?? ''
|
|
35
35
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
36
|
-
else if (a.startsWith('-'))
|
|
36
|
+
else if (a.startsWith('-'))
|
|
37
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
37
38
|
else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
38
39
|
else out.target = a
|
|
39
40
|
|
|
@@ -117,22 +118,25 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
117
118
|
const task = declaredTask(config, taskName)
|
|
118
119
|
if (task === undefined) {
|
|
119
120
|
throw new UserError(
|
|
120
|
-
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,
|
|
121
|
+
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
|
|
121
122
|
)
|
|
122
123
|
}
|
|
123
124
|
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
|
124
125
|
return 0
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Near misses by edit distance, plus partial names in either direction.
|
|
130
|
+
* `prefix` makes each a spec the user can paste (`app#build`, not `build`).
|
|
131
|
+
*/
|
|
132
|
+
function suggest(query: string, candidates: readonly string[], prefix = ''): string {
|
|
129
133
|
const q = query.toLowerCase()
|
|
130
134
|
const near = new Set(nearMatches(query, candidates))
|
|
131
135
|
for (const c of candidates) {
|
|
132
136
|
const n = c.toLowerCase()
|
|
133
137
|
if (n.includes(q) || q.includes(n)) near.add(c)
|
|
134
138
|
}
|
|
135
|
-
return near.size > 0 ? ` — did you mean ${[...near].join(', ')}?` : ''
|
|
139
|
+
return near.size > 0 ? ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?` : ''
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
function projectDir(root: string, meta: ProjectMeta): string {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// `vx watch`'s file-system side: the OS watcher and the proof it delivers,
|
|
2
|
+
// the stat poller that stands in where it cannot, and the clock both are
|
|
3
|
+
// judged against. Nothing here knows about tasks, cycles or the cache.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** Paths whose changes never trigger a re-run. */
|
|
9
|
+
export const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The file `armWatcher` writes under a watched directory to prove the
|
|
13
|
+
* watcher delivers. Intercepted by name before any other handling, so it
|
|
14
|
+
* can never trigger a cycle, and removed before "watching" is printed.
|
|
15
|
+
*/
|
|
16
|
+
export const WATCH_PROBE = '.vx-watch-probe'
|
|
17
|
+
|
|
18
|
+
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
19
|
+
export const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
20
|
+
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
21
|
+
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
22
|
+
|
|
23
|
+
/** Anything the loop needs to shut down at exit. */
|
|
24
|
+
export interface WatchHandle {
|
|
25
|
+
close(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ArmedWatcher {
|
|
29
|
+
watcher: fs.FSWatcher
|
|
30
|
+
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
31
|
+
ready: Promise<boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How often the fallback re-walks a watched tree. */
|
|
35
|
+
export const POLL_INTERVAL_MS = 250
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Directory names the fallback never descends into WHEN no better filter is
|
|
39
|
+
* supplied. The justification is that `makeWatchIgnore` already drops their
|
|
40
|
+
* EVENTS, so the walk buys nothing — a poller pays for it either way, and
|
|
41
|
+
* `node_modules` is the difference between a cheap fallback and one that
|
|
42
|
+
* re-stats 40 000 files four times a second.
|
|
43
|
+
*
|
|
44
|
+
* That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
|
|
45
|
+
* It used to carry a fourth name, `dist`, which the justification does NOT
|
|
46
|
+
* cover: `dist` is dropped only when a project DECLARES it as an output, and
|
|
47
|
+
* a project that does not declare it had its `dist/` sources silently
|
|
48
|
+
* invisible to `vx watch` on every host the poller exists for — a macOS
|
|
49
|
+
* sandbox, a network mount, a container bind — while the native watcher
|
|
50
|
+
* delivered them. Two watchers disagreeing about what an edit is (item 482).
|
|
51
|
+
* The real per-project answer is the caller's `skipDir`, below.
|
|
52
|
+
*/
|
|
53
|
+
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
57
|
+
*
|
|
58
|
+
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
59
|
+
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
60
|
+
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
61
|
+
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
62
|
+
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
63
|
+
* container bind mount fails the same way. Polling is slower and coarser,
|
|
64
|
+
* and it is the difference between `vx watch` working there and silently
|
|
65
|
+
* doing nothing.
|
|
66
|
+
*/
|
|
67
|
+
export function pollWatcher(
|
|
68
|
+
dir: string,
|
|
69
|
+
recursive: boolean,
|
|
70
|
+
onEvent: (filename: string) => void,
|
|
71
|
+
intervalMs = POLL_INTERVAL_MS,
|
|
72
|
+
/**
|
|
73
|
+
* Directories not worth descending into, by their path relative to `dir`.
|
|
74
|
+
* The watch loop passes its own event filter, so the poller skips exactly
|
|
75
|
+
* what the filter would drop anyway — every declared output container, not
|
|
76
|
+
* a hard-coded name. The default covers the unconditional segments alone.
|
|
77
|
+
*/
|
|
78
|
+
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
79
|
+
): WatchHandle {
|
|
80
|
+
let previous = new Map<string, number>()
|
|
81
|
+
let first = true
|
|
82
|
+
const scan = (): void => {
|
|
83
|
+
const current = new Map<string, number>()
|
|
84
|
+
const walk = (abs: string, rel: string): void => {
|
|
85
|
+
let entries: fs.Dirent[]
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
88
|
+
} catch {
|
|
89
|
+
return // vanished or unreadable: its files simply stop appearing
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.name === WATCH_PROBE) continue
|
|
93
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
94
|
+
if (e.isDirectory()) {
|
|
95
|
+
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (!e.isFile()) continue
|
|
99
|
+
try {
|
|
100
|
+
// The later of the two clocks, as `modifiedBefore` reads them: a
|
|
101
|
+
// replacement that carries the old file's mtime (`cp -p`, `rsync
|
|
102
|
+
// -a`, `mv` of a file stamped the same) moved nothing under mtime
|
|
103
|
+
// alone, and the poller never ran it where the native watcher did.
|
|
104
|
+
// A rename or a write moves ctime, and no process can set it.
|
|
105
|
+
const st = fs.statSync(path.join(abs, e.name))
|
|
106
|
+
current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
|
|
107
|
+
} catch {
|
|
108
|
+
// raced with a delete; the next scan settles it
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
walk(dir, '')
|
|
113
|
+
if (!first) {
|
|
114
|
+
for (const [rel, mtime] of current) {
|
|
115
|
+
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
116
|
+
}
|
|
117
|
+
for (const rel of previous.keys()) {
|
|
118
|
+
if (!current.has(rel)) onEvent(rel)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
previous = current
|
|
122
|
+
first = false
|
|
123
|
+
}
|
|
124
|
+
scan()
|
|
125
|
+
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
126
|
+
// is the only thing keeping `vx watch` alive.
|
|
127
|
+
const timer = setInterval(scan, intervalMs)
|
|
128
|
+
return {
|
|
129
|
+
close(): void {
|
|
130
|
+
clearInterval(timer)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
137
|
+
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
138
|
+
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
139
|
+
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
140
|
+
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
141
|
+
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
142
|
+
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
143
|
+
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
144
|
+
* is live for everything after it.
|
|
145
|
+
*
|
|
146
|
+
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
147
|
+
* removed before `ready` resolves.
|
|
148
|
+
*/
|
|
149
|
+
export function armWatcher(
|
|
150
|
+
dir: string,
|
|
151
|
+
recursive: boolean,
|
|
152
|
+
onEvent: (filename: string) => void,
|
|
153
|
+
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
154
|
+
): ArmedWatcher {
|
|
155
|
+
let markReady: (ok: boolean) => void = () => {}
|
|
156
|
+
const seen = new Promise<boolean>((resolve) => {
|
|
157
|
+
markReady = resolve
|
|
158
|
+
})
|
|
159
|
+
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
160
|
+
if (filename == null || typeof filename !== 'string') return
|
|
161
|
+
// An event naming the watched directory itself (macOS reports the
|
|
162
|
+
// directory a write landed in as its own item) carries nothing a key
|
|
163
|
+
// can see; the write's own event names the file.
|
|
164
|
+
if (filename === '' || filename === '.') return
|
|
165
|
+
if (filename === WATCH_PROBE) {
|
|
166
|
+
markReady(true)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
// Another arm's probe, seen by this recursive watcher under a nested
|
|
170
|
+
// directory (a nested project's, a member base inside a project): no
|
|
171
|
+
// key sees it, and passing it on ran a cycle, and restarted a dev
|
|
172
|
+
// server, with no edit made (item 1016). Only the watcher's own probe
|
|
173
|
+
// proves this stream live.
|
|
174
|
+
if (path.basename(filename) === WATCH_PROBE) return
|
|
175
|
+
onEvent(filename)
|
|
176
|
+
})
|
|
177
|
+
const probe = path.join(dir, WATCH_PROBE)
|
|
178
|
+
const ready = (async (): Promise<boolean> => {
|
|
179
|
+
// The probe is subject to the very race it detects: a write that lands
|
|
180
|
+
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
181
|
+
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
182
|
+
// re-written on a short backoff until its event arrives — the first write
|
|
183
|
+
// after the stream goes live is the one that proves it.
|
|
184
|
+
const deadline = Date.now() + timeoutMs
|
|
185
|
+
let ok = false
|
|
186
|
+
let step = 50
|
|
187
|
+
while (!ok) {
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(probe, String(Date.now()))
|
|
190
|
+
} catch {
|
|
191
|
+
break // an unwritable dir gets no proof; the watcher is kept
|
|
192
|
+
}
|
|
193
|
+
const remaining = deadline - Date.now()
|
|
194
|
+
if (remaining <= 0) break
|
|
195
|
+
const pause = new Promise<boolean>((resolve) => {
|
|
196
|
+
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
197
|
+
})
|
|
198
|
+
ok = await Promise.race([seen, pause])
|
|
199
|
+
step = Math.min(step * 2, 400)
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.unlinkSync(probe)
|
|
203
|
+
} catch {
|
|
204
|
+
// already gone
|
|
205
|
+
}
|
|
206
|
+
return ok
|
|
207
|
+
})()
|
|
208
|
+
return { watcher, ready }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
212
|
+
export function modifiedBefore(abs: string, t: number): boolean {
|
|
213
|
+
try {
|
|
214
|
+
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
215
|
+
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
216
|
+
// such an edit was "before the arm" and never ran (item 945). No
|
|
217
|
+
// process can set a ctime, and a rename or a write moves it.
|
|
218
|
+
const st = fs.statSync(abs)
|
|
219
|
+
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
227
|
+
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
228
|
+
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
229
|
+
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
230
|
+
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
231
|
+
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
232
|
+
* fine clock then judged an edit made right after the ready line as
|
|
233
|
+
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
234
|
+
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
235
|
+
* read off a file written here is on the mtime clock itself, and every
|
|
236
|
+
* later write's mtime is at or after it.
|
|
237
|
+
*/
|
|
238
|
+
export function fsClockNow(dir: string): number {
|
|
239
|
+
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
240
|
+
try {
|
|
241
|
+
fs.writeFileSync(stamp, '')
|
|
242
|
+
const at = fs.statSync(stamp).mtimeMs
|
|
243
|
+
fs.unlinkSync(stamp)
|
|
244
|
+
return at
|
|
245
|
+
} catch {
|
|
246
|
+
return Date.now()
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -50,12 +50,20 @@ import {
|
|
|
50
50
|
import type { ProjectConfig } from '../config.js'
|
|
51
51
|
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
52
52
|
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
53
|
+
import {
|
|
54
|
+
armWatcher,
|
|
55
|
+
fsClockNow,
|
|
56
|
+
IGNORED_SEGMENTS,
|
|
57
|
+
modifiedBefore,
|
|
58
|
+
pollWatcher,
|
|
59
|
+
POLL_INTERVAL_MS,
|
|
60
|
+
WATCH_PROBE_TIMEOUT_MS,
|
|
61
|
+
type WatchHandle,
|
|
62
|
+
} from './watch-fs.js'
|
|
53
63
|
|
|
54
64
|
/** Wait this long after the last filesystem event before re-running. */
|
|
55
65
|
const DEBOUNCE_MS = 150
|
|
56
66
|
|
|
57
|
-
/** Paths whose changes never trigger a re-run. */
|
|
58
|
-
const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
59
67
|
const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -167,13 +175,6 @@ function outputContainer(raw: string): string {
|
|
|
167
175
|
return prefix === '.' || prefix === '/' ? '' : prefix
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
/**
|
|
171
|
-
* The file `armWatcher` writes under a watched directory to prove the
|
|
172
|
-
* watcher delivers. Intercepted by name before any other handling, so it
|
|
173
|
-
* can never trigger a cycle, and removed before "watching" is printed.
|
|
174
|
-
*/
|
|
175
|
-
export const WATCH_PROBE = '.vx-watch-probe'
|
|
176
|
-
|
|
177
178
|
/**
|
|
178
179
|
* The subset of `paths` git ignores, asked once per judgement (one
|
|
179
180
|
* `git check-ignore` per debounce window that has candidates, never per
|
|
@@ -210,199 +211,6 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
210
211
|
return ignored
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
214
|
-
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
215
|
-
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
216
|
-
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
217
|
-
|
|
218
|
-
/** Anything the loop needs to shut down at exit. */
|
|
219
|
-
export interface WatchHandle {
|
|
220
|
-
close(): void
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export interface ArmedWatcher {
|
|
224
|
-
watcher: fs.FSWatcher
|
|
225
|
-
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
226
|
-
ready: Promise<boolean>
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** How often the fallback re-walks a watched tree. */
|
|
230
|
-
const POLL_INTERVAL_MS = 250
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Directory names the fallback never descends into WHEN no better filter is
|
|
234
|
-
* supplied. The justification is that `makeWatchIgnore` already drops their
|
|
235
|
-
* EVENTS, so the walk buys nothing — a poller pays for it either way, and
|
|
236
|
-
* `node_modules` is the difference between a cheap fallback and one that
|
|
237
|
-
* re-stats 40 000 files four times a second.
|
|
238
|
-
*
|
|
239
|
-
* That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
|
|
240
|
-
* It used to carry a fourth name, `dist`, which the justification does NOT
|
|
241
|
-
* cover: `dist` is dropped only when a project DECLARES it as an output, and
|
|
242
|
-
* a project that does not declare it had its `dist/` sources silently
|
|
243
|
-
* invisible to `vx watch` on every host the poller exists for — a macOS
|
|
244
|
-
* sandbox, a network mount, a container bind — while the native watcher
|
|
245
|
-
* delivered them. Two watchers disagreeing about what an edit is (item 482).
|
|
246
|
-
* The real per-project answer is the caller's `skipDir`, below.
|
|
247
|
-
*/
|
|
248
|
-
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
252
|
-
*
|
|
253
|
-
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
254
|
-
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
255
|
-
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
256
|
-
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
257
|
-
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
258
|
-
* container bind mount fails the same way. Polling is slower and coarser,
|
|
259
|
-
* and it is the difference between `vx watch` working there and silently
|
|
260
|
-
* doing nothing.
|
|
261
|
-
*/
|
|
262
|
-
export function pollWatcher(
|
|
263
|
-
dir: string,
|
|
264
|
-
recursive: boolean,
|
|
265
|
-
onEvent: (filename: string) => void,
|
|
266
|
-
intervalMs = POLL_INTERVAL_MS,
|
|
267
|
-
/**
|
|
268
|
-
* Directories not worth descending into, by their path relative to `dir`.
|
|
269
|
-
* The watch loop passes its own event filter, so the poller skips exactly
|
|
270
|
-
* what the filter would drop anyway — every declared output container, not
|
|
271
|
-
* a hard-coded name. The default covers the unconditional segments alone.
|
|
272
|
-
*/
|
|
273
|
-
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
274
|
-
): WatchHandle {
|
|
275
|
-
let previous = new Map<string, number>()
|
|
276
|
-
let first = true
|
|
277
|
-
const scan = (): void => {
|
|
278
|
-
const current = new Map<string, number>()
|
|
279
|
-
const walk = (abs: string, rel: string): void => {
|
|
280
|
-
let entries: fs.Dirent[]
|
|
281
|
-
try {
|
|
282
|
-
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
283
|
-
} catch {
|
|
284
|
-
return // vanished or unreadable: its files simply stop appearing
|
|
285
|
-
}
|
|
286
|
-
for (const e of entries) {
|
|
287
|
-
if (e.name === WATCH_PROBE) continue
|
|
288
|
-
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
289
|
-
if (e.isDirectory()) {
|
|
290
|
-
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
291
|
-
continue
|
|
292
|
-
}
|
|
293
|
-
if (!e.isFile()) continue
|
|
294
|
-
try {
|
|
295
|
-
// The later of the two clocks, as `modifiedBefore` reads them: a
|
|
296
|
-
// replacement that carries the old file's mtime (`cp -p`, `rsync
|
|
297
|
-
// -a`, `mv` of a file stamped the same) moved nothing under mtime
|
|
298
|
-
// alone, and the poller never ran it where the native watcher did.
|
|
299
|
-
// A rename or a write moves ctime, and no process can set it.
|
|
300
|
-
const st = fs.statSync(path.join(abs, e.name))
|
|
301
|
-
current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
|
|
302
|
-
} catch {
|
|
303
|
-
// raced with a delete; the next scan settles it
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
walk(dir, '')
|
|
308
|
-
if (!first) {
|
|
309
|
-
for (const [rel, mtime] of current) {
|
|
310
|
-
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
311
|
-
}
|
|
312
|
-
for (const rel of previous.keys()) {
|
|
313
|
-
if (!current.has(rel)) onEvent(rel)
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
previous = current
|
|
317
|
-
first = false
|
|
318
|
-
}
|
|
319
|
-
scan()
|
|
320
|
-
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
321
|
-
// is the only thing keeping `vx watch` alive.
|
|
322
|
-
const timer = setInterval(scan, intervalMs)
|
|
323
|
-
return {
|
|
324
|
-
close(): void {
|
|
325
|
-
clearInterval(timer)
|
|
326
|
-
},
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
332
|
-
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
333
|
-
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
334
|
-
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
335
|
-
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
336
|
-
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
337
|
-
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
338
|
-
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
339
|
-
* is live for everything after it.
|
|
340
|
-
*
|
|
341
|
-
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
342
|
-
* removed before `ready` resolves.
|
|
343
|
-
*/
|
|
344
|
-
export function armWatcher(
|
|
345
|
-
dir: string,
|
|
346
|
-
recursive: boolean,
|
|
347
|
-
onEvent: (filename: string) => void,
|
|
348
|
-
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
349
|
-
): ArmedWatcher {
|
|
350
|
-
let markReady: (ok: boolean) => void = () => {}
|
|
351
|
-
const seen = new Promise<boolean>((resolve) => {
|
|
352
|
-
markReady = resolve
|
|
353
|
-
})
|
|
354
|
-
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
355
|
-
if (filename == null || typeof filename !== 'string') return
|
|
356
|
-
// An event naming the watched directory itself (macOS reports the
|
|
357
|
-
// directory a write landed in as its own item) carries nothing a key
|
|
358
|
-
// can see; the write's own event names the file.
|
|
359
|
-
if (filename === '' || filename === '.') return
|
|
360
|
-
if (filename === WATCH_PROBE) {
|
|
361
|
-
markReady(true)
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
// Another arm's probe, seen by this recursive watcher under a nested
|
|
365
|
-
// directory (a nested project's, a member base inside a project): no
|
|
366
|
-
// key sees it, and passing it on ran a cycle, and restarted a dev
|
|
367
|
-
// server, with no edit made (item 1016). Only the watcher's own probe
|
|
368
|
-
// proves this stream live.
|
|
369
|
-
if (path.basename(filename) === WATCH_PROBE) return
|
|
370
|
-
onEvent(filename)
|
|
371
|
-
})
|
|
372
|
-
const probe = path.join(dir, WATCH_PROBE)
|
|
373
|
-
const ready = (async (): Promise<boolean> => {
|
|
374
|
-
// The probe is subject to the very race it detects: a write that lands
|
|
375
|
-
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
376
|
-
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
377
|
-
// re-written on a short backoff until its event arrives — the first write
|
|
378
|
-
// after the stream goes live is the one that proves it.
|
|
379
|
-
const deadline = Date.now() + timeoutMs
|
|
380
|
-
let ok = false
|
|
381
|
-
let step = 50
|
|
382
|
-
while (!ok) {
|
|
383
|
-
try {
|
|
384
|
-
fs.writeFileSync(probe, String(Date.now()))
|
|
385
|
-
} catch {
|
|
386
|
-
break // an unwritable dir gets no proof; the watcher is kept
|
|
387
|
-
}
|
|
388
|
-
const remaining = deadline - Date.now()
|
|
389
|
-
if (remaining <= 0) break
|
|
390
|
-
const pause = new Promise<boolean>((resolve) => {
|
|
391
|
-
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
392
|
-
})
|
|
393
|
-
ok = await Promise.race([seen, pause])
|
|
394
|
-
step = Math.min(step * 2, 400)
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
fs.unlinkSync(probe)
|
|
398
|
-
} catch {
|
|
399
|
-
// already gone
|
|
400
|
-
}
|
|
401
|
-
return ok
|
|
402
|
-
})()
|
|
403
|
-
return { watcher, ready }
|
|
404
|
-
}
|
|
405
|
-
|
|
406
214
|
/**
|
|
407
215
|
* The run flags a watch loop cannot honour, as the refusal line it prints; null
|
|
408
216
|
* when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
|
|
@@ -1516,45 +1324,6 @@ export function pendingAfterCycle(
|
|
|
1516
1324
|
return [abs, label]
|
|
1517
1325
|
}
|
|
1518
1326
|
|
|
1519
|
-
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
1520
|
-
export function modifiedBefore(abs: string, t: number): boolean {
|
|
1521
|
-
try {
|
|
1522
|
-
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
1523
|
-
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
1524
|
-
// such an edit was "before the arm" and never ran (item 945). No
|
|
1525
|
-
// process can set a ctime, and a rename or a write moves it.
|
|
1526
|
-
const st = fs.statSync(abs)
|
|
1527
|
-
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
1528
|
-
} catch {
|
|
1529
|
-
return false
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/**
|
|
1534
|
-
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
1535
|
-
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
1536
|
-
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
1537
|
-
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
1538
|
-
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
1539
|
-
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
1540
|
-
* fine clock then judged an edit made right after the ready line as
|
|
1541
|
-
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
1542
|
-
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
1543
|
-
* read off a file written here is on the mtime clock itself, and every
|
|
1544
|
-
* later write's mtime is at or after it.
|
|
1545
|
-
*/
|
|
1546
|
-
export function fsClockNow(dir: string): number {
|
|
1547
|
-
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
1548
|
-
try {
|
|
1549
|
-
fs.writeFileSync(stamp, '')
|
|
1550
|
-
const at = fs.statSync(stamp).mtimeMs
|
|
1551
|
-
fs.unlinkSync(stamp)
|
|
1552
|
-
return at
|
|
1553
|
-
} catch {
|
|
1554
|
-
return Date.now()
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
1327
|
/**
|
|
1559
1328
|
* The directory entries under a package glob's directory that can be
|
|
1560
1329
|
* members: directories (or links) whose name is not dotted and not
|
package/src/cli/why.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// no config evaluation, no re-hash.
|
|
7
7
|
|
|
8
8
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
9
|
-
import { seeHelp } from './help.js'
|
|
9
|
+
import { flagHint, seeHelp } from './help.js'
|
|
10
10
|
import { splitTaskId } from '../graph/index.js'
|
|
11
11
|
import {
|
|
12
12
|
cacheKeyDiff,
|
|
@@ -83,7 +83,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
83
83
|
i = cd.next
|
|
84
84
|
continue
|
|
85
85
|
}
|
|
86
|
-
if (a.startsWith('-'))
|
|
86
|
+
if (a.startsWith('-'))
|
|
87
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
|
|
87
88
|
if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
88
89
|
out.target = a
|
|
89
90
|
}
|
|
@@ -323,7 +323,15 @@ export function buildAdmission(
|
|
|
323
323
|
for (const plugin of answering) {
|
|
324
324
|
if (broken.has(plugin)) continue
|
|
325
325
|
try {
|
|
326
|
-
|
|
326
|
+
const verdict: unknown = plugin.admit!(task, ctx)
|
|
327
|
+
// An `async admit` answers a Promise: truthy, so it admitted every
|
|
328
|
+
// task and its policy never ran, and its rejection was unhandled and
|
|
329
|
+
// ended the run with a stack (H-16). Named once, admitting after.
|
|
330
|
+
if (verdict instanceof Promise) {
|
|
331
|
+
verdict.catch(() => {})
|
|
332
|
+
throw new Error('returned a Promise; admit is synchronous')
|
|
333
|
+
}
|
|
334
|
+
if (verdict !== false) continue
|
|
327
335
|
if (running.size > 0) return false
|
|
328
336
|
if (!overridden.has(plugin)) {
|
|
329
337
|
overridden.add(plugin)
|
|
@@ -130,8 +130,9 @@ export interface VxPlugin {
|
|
|
130
130
|
* Admission over the worker count: asked for every task about to start
|
|
131
131
|
* on this machine, with the tasks running here right now. Return `false`
|
|
132
132
|
* to hold it until something finishes (it is asked again then). Asked
|
|
133
|
-
* many times per run, so it must be cheap and synchronous; a throw
|
|
134
|
-
*
|
|
133
|
+
* many times per run, so it must be cheap and synchronous; a throw, or
|
|
134
|
+
* an answer that is a Promise, is reported once and the plugin admits
|
|
135
|
+
* from then on — a policy never
|
|
135
136
|
* breaks a run. Restore-tier hits and tasks on an executor pool hold no
|
|
136
137
|
* local resources and are never asked. When several plugins answer, all
|
|
137
138
|
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
@@ -144,7 +145,8 @@ export interface VxPlugin {
|
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* Verbs this plugin adds to the `vx` CLI, keyed by name. Consulted only
|
|
147
|
-
* for a verb core does not know —
|
|
148
|
+
* for a verb core does not know — a verb named like a core verb is
|
|
149
|
+
* refused at load — and only
|
|
148
150
|
* when the cwd is inside a workspace that declares the plugin. `vx help`
|
|
149
151
|
* lists them under "Plugin commands". A verb's exit code is the process
|
|
150
152
|
* exit code; a thrown `UserError` prints cleanly, like core's own.
|
|
@@ -348,6 +350,18 @@ export type PluginHookName =
|
|
|
348
350
|
| 'onRunStatus'
|
|
349
351
|
| 'onRunEnd'
|
|
350
352
|
|
|
353
|
+
// A Record so the compiler holds it to `PluginHookName` both ways: a name
|
|
354
|
+
// missing here, or one the type lacks, does not compile.
|
|
355
|
+
const HOOK_NAMES: Record<PluginHookName, true> = {
|
|
356
|
+
onRunStart: true,
|
|
357
|
+
onTaskStart: true,
|
|
358
|
+
onTaskStdout: true,
|
|
359
|
+
onTaskStderr: true,
|
|
360
|
+
onTaskComplete: true,
|
|
361
|
+
onRunStatus: true,
|
|
362
|
+
onRunEnd: true,
|
|
363
|
+
}
|
|
364
|
+
|
|
351
365
|
export interface PluginHookHandlers {
|
|
352
366
|
onRunStart: (info: RunStartInfo) => void | Promise<void>
|
|
353
367
|
onTaskStart: (node: TaskNode) => void | Promise<void>
|
|
@@ -508,6 +522,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
508
522
|
},
|
|
509
523
|
},
|
|
510
524
|
on(hook, handler) {
|
|
525
|
+
// A misspelt name ('taskComplete') subscribed a handler that never
|
|
526
|
+
// ran, and no word of it; a config file has no type-checker (H-16).
|
|
527
|
+
if (!Object.hasOwn(HOOK_NAMES, hook)) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
511
532
|
const fail = (err: unknown): void => {
|
|
512
533
|
if (disabled.has(plugin.name)) return
|
|
513
534
|
disabled.add(plugin.name)
|
|
@@ -340,6 +340,19 @@ export function dependsOnSiblingOutputs(
|
|
|
340
340
|
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
341
341
|
if (inputs === undefined || outputs.length === 0) return true
|
|
342
342
|
if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
|
|
343
|
+
// Nor where it WRITES inside another task's tree: restored ahead of
|
|
344
|
+
// that producer, its restore raced the producer's clean and extract
|
|
345
|
+
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
346
|
+
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
347
|
+
const own = cache.outputs.files
|
|
348
|
+
if (own.length > 0) {
|
|
349
|
+
const others = [...outputs]
|
|
350
|
+
for (const g of own) {
|
|
351
|
+
const i = others.indexOf(g)
|
|
352
|
+
if (i !== -1) others.splice(i, 1)
|
|
353
|
+
}
|
|
354
|
+
if (others.length > 0 && workspaceInputsReach(own, others.map(literalPrefix))) return true
|
|
355
|
+
}
|
|
343
356
|
}
|
|
344
357
|
// A root-anchored output is boundary-IGNORING by design, so it can land
|
|
345
358
|
// inside THIS task's own project dir — where an ordinary project-relative
|
|
@@ -116,6 +116,15 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
|
116
116
|
at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
|
|
117
117
|
}
|
|
118
118
|
if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
|
|
119
|
+
// The worker IS `build`'s work, and so is every group on the way to it:
|
|
120
|
+
// a `typecheck` behind `build: npm run typecheck` waited for `build`,
|
|
121
|
+
// and the run refused the cycle (D-16).
|
|
122
|
+
for (const name of [...seen, at.name]) {
|
|
123
|
+
const t = byName.get(name)!.task!
|
|
124
|
+
if (name !== 'build' && Array.isArray(t['dependsOn'])) {
|
|
125
|
+
t['dependsOn'] = (t['dependsOn'] as string[]).filter((d) => d !== 'build')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
119
128
|
const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
|
|
120
129
|
if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
|
|
121
130
|
if (!at.todos.includes(CACHE_TODO)) at.todos.push(CACHE_TODO)
|