@vzn/vx 0.0.652 → 0.0.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -11,6 +11,7 @@ import path from 'node:path'
|
|
|
11
11
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
12
12
|
import {
|
|
13
13
|
executablePath,
|
|
14
|
+
gitCaseArgs,
|
|
14
15
|
gitEnv,
|
|
15
16
|
gitSpawnRefusal,
|
|
16
17
|
hasControl,
|
|
@@ -21,7 +22,13 @@ import {
|
|
|
21
22
|
relPosix,
|
|
22
23
|
UserError,
|
|
23
24
|
} from '../util/index.js'
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
gitTop,
|
|
27
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
28
|
+
workspaceFile,
|
|
29
|
+
workspaceFileBroken,
|
|
30
|
+
workspaceRootOf,
|
|
31
|
+
} from '../handoff.js'
|
|
25
32
|
import { canonicalDir } from './canonical-dir.js'
|
|
26
33
|
import { parseFilter } from './filter.js'
|
|
27
34
|
import { repoIdOf } from './repo-id.js'
|
|
@@ -86,6 +93,7 @@ export async function findWorkspaceRoot(
|
|
|
86
93
|
{ code: 'VX_E_NO_WORKSPACE', message: `${headline} — run \`vx init\` to create one` },
|
|
87
94
|
)
|
|
88
95
|
}
|
|
96
|
+
if (workspaceFileBroken(file)) await refuseNotAFile(file)
|
|
89
97
|
const root = workspaceRootOf(file)
|
|
90
98
|
if (uid !== 0 && root !== start && (await gitTop(start)) === null) {
|
|
91
99
|
// The link itself and the `.vx` holding it, not what a link names: a
|
|
@@ -110,6 +118,22 @@ export async function findWorkspaceRoot(
|
|
|
110
118
|
return root
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Refuses a config path that is no file: it marks a root or a project all
|
|
123
|
+
* the same, so skipping it ran nothing there and said nothing. Learned by
|
|
124
|
+
* `lstat` here, off the normal path.
|
|
125
|
+
*/
|
|
126
|
+
async function refuseNotAFile(file: string): Promise<never> {
|
|
127
|
+
const link = await stat(file).then(
|
|
128
|
+
() => false,
|
|
129
|
+
() => true,
|
|
130
|
+
)
|
|
131
|
+
throw new UserError(
|
|
132
|
+
`${file}: is ${link ? 'a broken link' : 'not a file'} — fix or remove it`,
|
|
133
|
+
'VX_E_CONFIG',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
/**
|
|
114
138
|
* The workspace's own cache directory (index, history, memos):
|
|
115
139
|
* `VX_CACHE_DIR`, else the workspace's `cache.dir`, else `.vx/cache`,
|
|
@@ -249,6 +273,8 @@ export interface ProjectConfigs {
|
|
|
249
273
|
readonly strayReviews: string[]
|
|
250
274
|
/** Absolute: config files a higher-precedence one beside them hides, the root's workspace config among them, by the one read. */
|
|
251
275
|
readonly shadowed: { file: string; read: string }[]
|
|
276
|
+
/** Absolute, sorted: the outermost directories holding their own `.vx/workspace.*`. */
|
|
277
|
+
readonly nestedWorkspaces: string[]
|
|
252
278
|
}
|
|
253
279
|
|
|
254
280
|
/**
|
|
@@ -265,11 +291,16 @@ export interface ProjectConfigs {
|
|
|
265
291
|
export async function findProjectConfigs(root: string): Promise<ProjectConfigs> {
|
|
266
292
|
const inGit = (await gitTop(root)) !== null
|
|
267
293
|
const rels = inGit ? await gitConfigs(root) : await scanConfigs(root)
|
|
268
|
-
|
|
269
|
-
|
|
294
|
+
// Each on disk, a broken link included, as the root walk takes one: git
|
|
295
|
+
// lists a tracked one deleted from the worktree, which marks no root.
|
|
296
|
+
const marks = rels.filter((rel) => {
|
|
270
297
|
const at = rel.lastIndexOf('/.vx/')
|
|
271
|
-
|
|
272
|
-
}
|
|
298
|
+
return at > 0 && WORKSPACE_RANK.has(rel.slice(at + 1))
|
|
299
|
+
})
|
|
300
|
+
const onDisk = await Promise.all(marks.map((rel) => entryAt(path.join(root, rel))))
|
|
301
|
+
const nested = marks
|
|
302
|
+
.filter((_, i) => onDisk[i])
|
|
303
|
+
.map((rel) => rel.slice(0, rel.lastIndexOf('/.vx/')))
|
|
273
304
|
if (inGit) nested.push(...(await unlistedNested(root, rels, nested)))
|
|
274
305
|
const byDir = new Map<string, string[]>()
|
|
275
306
|
const reviewed: string[] = []
|
|
@@ -286,8 +317,10 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
|
|
|
286
317
|
}
|
|
287
318
|
const shadowed: { file: string; read: string }[] = []
|
|
288
319
|
// The files among `names` (ranked), the first of them the one read: git
|
|
289
|
-
// lists a tracked config deleted from the worktree,
|
|
290
|
-
//
|
|
320
|
+
// lists a tracked config deleted from the worktree, which is skipped.
|
|
321
|
+
// Every other file is shadowed, said by `vx lock`. With no file, an entry
|
|
322
|
+
// by a config's name (a broken link, a directory) is refused, as the root
|
|
323
|
+
// walk refuses one: skipped, its project was in no lock and nothing said so.
|
|
291
324
|
const firstFile = async (
|
|
292
325
|
dir: string,
|
|
293
326
|
names: string[],
|
|
@@ -297,6 +330,9 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
|
|
|
297
330
|
const files = (
|
|
298
331
|
await Promise.all(names.map(async (n) => ((await isFile(path.join(dir, n))) ? n : null)))
|
|
299
332
|
).filter((n) => n !== null)
|
|
333
|
+
if (files.length === 0)
|
|
334
|
+
for (const n of names)
|
|
335
|
+
if (await entryAt(path.join(dir, n))) await refuseNotAFile(path.join(dir, n))
|
|
300
336
|
for (const n of files.slice(1))
|
|
301
337
|
shadowed.push({ file: path.join(dir, n), read: path.join(dir, files[0]!) })
|
|
302
338
|
return files[0] === undefined ? null : path.join(dir, files[0])
|
|
@@ -329,7 +365,12 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
|
|
|
329
365
|
)
|
|
330
366
|
.filter((f) => f !== null)
|
|
331
367
|
.sort()
|
|
332
|
-
|
|
368
|
+
// A project's outputs stop at each, as at a nested project (computeNestedProjectDirs).
|
|
369
|
+
const nestedWorkspaces = nested
|
|
370
|
+
.filter((n) => !nested.some((m) => n.startsWith(`${m}/`)))
|
|
371
|
+
.map((rel) => path.join(root, rel))
|
|
372
|
+
.sort()
|
|
373
|
+
return { configs, strayReviews, shadowed, nestedWorkspaces: [...new Set(nestedWorkspaces)] }
|
|
333
374
|
}
|
|
334
375
|
|
|
335
376
|
/**
|
|
@@ -368,6 +409,17 @@ async function unlistedNested(
|
|
|
368
409
|
return found.filter((d) => d !== null)
|
|
369
410
|
}
|
|
370
411
|
|
|
412
|
+
/** An entry by that name, whatever it is (`lstat`): a broken link counts. */
|
|
413
|
+
async function entryAt(p: string): Promise<boolean> {
|
|
414
|
+
try {
|
|
415
|
+
await lstat(p)
|
|
416
|
+
return true
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (isOutOfFds(err)) throw err
|
|
419
|
+
return false
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
371
423
|
async function isFile(p: string): Promise<boolean> {
|
|
372
424
|
try {
|
|
373
425
|
return (await stat(p)).isFile()
|
|
@@ -386,21 +438,26 @@ async function isFile(p: string): Promise<boolean> {
|
|
|
386
438
|
*/
|
|
387
439
|
async function gitConfigs(root: string): Promise<string[]> {
|
|
388
440
|
const [configs, index, untracked] = await Promise.all([
|
|
389
|
-
gitZ(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
441
|
+
gitZ(
|
|
442
|
+
root,
|
|
443
|
+
[
|
|
444
|
+
'ls-files',
|
|
445
|
+
'-z',
|
|
446
|
+
'--cached',
|
|
447
|
+
'--others',
|
|
448
|
+
'--exclude-standard',
|
|
449
|
+
'--',
|
|
450
|
+
':(glob)**/vx.config.*',
|
|
451
|
+
':(glob)**/.vx/workspace.*',
|
|
452
|
+
`:(glob)**/${REVIEW_FILE}`,
|
|
453
|
+
NOT_INSTALLS,
|
|
454
|
+
...NOT_VX_STATE,
|
|
455
|
+
],
|
|
456
|
+
true,
|
|
457
|
+
),
|
|
458
|
+
gitZ(root, ['ls-files', '-z', '--stage', '--', NOT_INSTALLS, NOT_IN_VX]),
|
|
402
459
|
// An embedded repository is the one untracked entry git lists as `dir/`.
|
|
403
|
-
gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', NOT_INSTALLS]),
|
|
460
|
+
gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', NOT_INSTALLS, NOT_IN_VX]),
|
|
404
461
|
])
|
|
405
462
|
// A submodule's `.git` is a file, an embedded repository's a directory.
|
|
406
463
|
const isRepo = (rel: string): Promise<boolean> =>
|
|
@@ -431,12 +488,32 @@ async function gitConfigs(root: string): Promise<string[]> {
|
|
|
431
488
|
*/
|
|
432
489
|
const NOT_INSTALLS = ':(exclude,glob)**/node_modules/**'
|
|
433
490
|
|
|
434
|
-
/**
|
|
435
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Nothing under a `.vx` directory is a project, as the walk outside git
|
|
493
|
+
* skips it: a config git listed in `.vx/cache/x` made one only in git.
|
|
494
|
+
*/
|
|
495
|
+
const NOT_IN_VX = ':(exclude,glob)**/.vx/**'
|
|
496
|
+
/** `NOT_IN_VX` that keeps `.vx/workspace.*`, which marks a nested workspace. */
|
|
497
|
+
const NOT_VX_STATE = [
|
|
498
|
+
':(exclude,glob)**/.vx/*/**',
|
|
499
|
+
':(exclude,glob)**/.vx/vx.config.*',
|
|
500
|
+
`:(exclude,glob)**/.vx/${REVIEW_FILE}`,
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
/** Whether root-relative POSIX `rel` lies under a `.vx` directory, where no project lives. */
|
|
504
|
+
export function inVxDir(rel: string): boolean {
|
|
505
|
+
return rel.startsWith('.vx/') || rel.includes('/.vx/')
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* `git <args>`'s NUL-separated output at `cwd`. `strict`: a path not valid
|
|
510
|
+
* UTF-8 is refused, as decoding turns it into one no file has.
|
|
511
|
+
*/
|
|
512
|
+
async function gitZ(cwd: string, args: string[], strict = false): Promise<string[]> {
|
|
436
513
|
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
|
|
437
514
|
try {
|
|
438
515
|
proc = Bun.spawn({
|
|
439
|
-
cmd: [executablePath('git'), ...args],
|
|
516
|
+
cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...args],
|
|
440
517
|
cwd,
|
|
441
518
|
env: gitEnv(),
|
|
442
519
|
stdin: 'ignore',
|
|
@@ -454,10 +531,53 @@ async function gitZ(cwd: string, args: string[]): Promise<string[]> {
|
|
|
454
531
|
])
|
|
455
532
|
if (code !== 0)
|
|
456
533
|
throw new UserError(`git ls-files failed (exit ${code}): ${err.trim()}`, 'VX_E_GIT')
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
534
|
+
const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(out)
|
|
535
|
+
// U+FFFD alone is cheap to look for; a valid name may hold it, so the bytes confirm.
|
|
536
|
+
if (strict && text.includes('\uFFFD')) {
|
|
537
|
+
let start = 0
|
|
538
|
+
for (let i = 0; i <= out.length; i++) {
|
|
539
|
+
if (i < out.length && out[i] !== 0) continue
|
|
540
|
+
const entry = out.subarray(start, i)
|
|
541
|
+
start = i + 1
|
|
542
|
+
if (!isUtf8(entry)) {
|
|
543
|
+
const slash = entry.lastIndexOf(0x2f)
|
|
544
|
+
throw notUtf8(slash === -1 ? entry : entry.subarray(0, slash))
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return text.split('\0').filter((p) => p !== '')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
552
|
+
|
|
553
|
+
function isUtf8(bytes: Uint8Array): boolean {
|
|
554
|
+
try {
|
|
555
|
+
strictUtf8.decode(bytes)
|
|
556
|
+
return true
|
|
557
|
+
} catch {
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** `rel`, a root-relative directory, with each byte that is not UTF-8 shown as `\xNN`. */
|
|
563
|
+
function notUtf8(rel: Uint8Array): UserError {
|
|
564
|
+
let shown = ''
|
|
565
|
+
for (let i = 0; i < rel.length;) {
|
|
566
|
+
const lead = rel[i]!
|
|
567
|
+
const n =
|
|
568
|
+
lead < 0x80 ? 1 : lead < 0xc2 ? 0 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf5 ? 4 : 0
|
|
569
|
+
const seq = rel.subarray(i, i + n)
|
|
570
|
+
if (n > 0 && isUtf8(seq)) {
|
|
571
|
+
shown += strictUtf8.decode(seq)
|
|
572
|
+
i += n
|
|
573
|
+
} else {
|
|
574
|
+
shown += `\\x${lead.toString(16).padStart(2, '0')}`
|
|
575
|
+
i++
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return new UserError(
|
|
579
|
+
`${printable(shown)}: the directory's name is not valid UTF-8, which no project path may hold — rename it`,
|
|
580
|
+
)
|
|
461
581
|
}
|
|
462
582
|
|
|
463
583
|
/** The config paths under `root` outside git, root-relative: none under `node_modules`, `.git` or `.vx`. */
|
|
@@ -475,17 +595,56 @@ async function scanConfigs(root: string): Promise<string[]> {
|
|
|
475
595
|
if (rel !== '' && entries.some((e) => e.name === '.vx') && (await workspaceFileIn(root, rel)))
|
|
476
596
|
return
|
|
477
597
|
const below: string[] = []
|
|
598
|
+
let lossy = false
|
|
478
599
|
for (const e of entries) {
|
|
479
600
|
const child = rel === '' ? e.name : `${rel}/${e.name}`
|
|
480
601
|
if (CONFIG_RANK.has(e.name) || e.name === REVIEW_FILE) out.push(child)
|
|
481
|
-
else if (e.isDirectory() && !SCAN_SKIPS.has(e.name))
|
|
602
|
+
else if (e.isDirectory() && !SCAN_SKIPS.has(e.name)) {
|
|
603
|
+
below.push(child)
|
|
604
|
+
// A name not valid UTF-8 decodes to one no directory has (a valid one may hold U+FFFD).
|
|
605
|
+
if (e.name.includes('\uFFFD')) lossy = true
|
|
606
|
+
}
|
|
482
607
|
}
|
|
608
|
+
if (lossy) await refuseNotUtf8(root, rel)
|
|
483
609
|
await Promise.all(below.map(walk))
|
|
484
610
|
}
|
|
485
611
|
await walk('')
|
|
486
612
|
return out
|
|
487
613
|
}
|
|
488
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Refuses a directory in `rel` whose name is not valid UTF-8 and that holds
|
|
617
|
+
* a config: walked by bytes, as no string path reaches it.
|
|
618
|
+
*/
|
|
619
|
+
async function refuseNotUtf8(root: string, rel: string): Promise<void> {
|
|
620
|
+
const dir = Buffer.from(rel === '' ? root : path.join(root, rel))
|
|
621
|
+
for (const name of await readdir(dir, { encoding: 'buffer' })) {
|
|
622
|
+
if (isUtf8(name)) continue
|
|
623
|
+
if (await holdsConfig(Buffer.concat([dir, Buffer.from('/'), name]))) {
|
|
624
|
+
throw notUtf8(rel === '' ? name : Buffer.concat([Buffer.from(`${rel}/`), name]))
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Bun 1.4.4 drops `withFileTypes` under `encoding: 'buffer'`, so each entry is an lstat.
|
|
630
|
+
async function holdsConfig(dir: Buffer): Promise<boolean> {
|
|
631
|
+
let names: Buffer[]
|
|
632
|
+
try {
|
|
633
|
+
names = await readdir(dir, { encoding: 'buffer' })
|
|
634
|
+
} catch (err) {
|
|
635
|
+
if (isOutOfFds(err)) throw err
|
|
636
|
+
return false
|
|
637
|
+
}
|
|
638
|
+
for (const name of names) {
|
|
639
|
+
const shown = name.toString()
|
|
640
|
+
if (CONFIG_RANK.has(shown)) return true
|
|
641
|
+
if (SCAN_SKIPS.has(shown)) continue
|
|
642
|
+
const at = Buffer.concat([dir, Buffer.from('/'), name])
|
|
643
|
+
if ((await lstat(at)).isDirectory() && (await holdsConfig(at))) return true
|
|
644
|
+
}
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
|
|
489
648
|
// `.vx` holds vx's state (the cache), which a checkout ignores.
|
|
490
649
|
const SCAN_SKIPS = new Set(['node_modules', '.git', '.vx'])
|
|
491
650
|
|
|
@@ -635,6 +794,7 @@ export function projectNameProblem(name: string): string | null {
|
|
|
635
794
|
return walk
|
|
636
795
|
}
|
|
637
796
|
if (f.withDeps || f.withDependents || f.onlyDeps || f.onlyDependents) return walk
|
|
797
|
+
if (f.within !== undefined) return "ends in '{…}', which a filter reads as a directory"
|
|
638
798
|
if (f.gitSince !== undefined)
|
|
639
799
|
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
640
800
|
if (f.tag === true) return "starts with 'tag:', which a filter reads as a tag selector"
|