@vzn/vx-dte 0.0.0 → 0.0.650

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/src/outputs.ts ADDED
@@ -0,0 +1,117 @@
1
+ // The files a task's declared outputs match in a checkout: what a worker
2
+ // clears before it runs the task, so an earlier task's files under them
3
+ // are not saved as this one's.
4
+
5
+ import { lstat, readdir, realpath, stat } from 'node:fs/promises'
6
+ import path from 'node:path'
7
+ import { outputDirPrefixes, restorableOutput } from '@vzn/vx'
8
+
9
+ /**
10
+ * The files declared outputs name, workspace-relative: `files` under the
11
+ * project, `workspaceFiles` under the root. A `!` glob drops what it matches.
12
+ */
13
+ export async function outputFiles(
14
+ root: string,
15
+ project: string,
16
+ outputs: { readonly files: readonly string[]; readonly workspaceFiles: readonly string[] },
17
+ ): Promise<string[]> {
18
+ const found = new Set<string>()
19
+ // Core's own matcher, so a glob means here what it means to a restore.
20
+ const scan = async (base: string, globs: readonly string[], scope: 'project' | 'workspace') => {
21
+ if (globs.length === 0) return
22
+ const wanted = restorableOutput(globs, scope)
23
+ const intoModules = globs.some((g) => g.includes('node_modules'))
24
+ const add = (rel: string) => found.add(base === '' ? rel : `${base}/${rel}`)
25
+ const walk = async (rel: string): Promise<void> => {
26
+ const at = path.join(root, base, rel)
27
+ const entries = await readdir(at, { withFileTypes: true }).catch(
28
+ async (err: NodeJS.ErrnoException) => {
29
+ // A literal output names a file: its prefix is that file. ENOTDIR
30
+ // also means a FILE above it (`dist/x.js` with `dist` a file): no
31
+ // path there, and clearing one threw (2026-10-25).
32
+ // quiet: a path that is not there is no output
33
+ if (err.code === 'ENOTDIR' && wanted(rel) && (await lstat(at).catch(() => null))) add(rel)
34
+ return []
35
+ },
36
+ )
37
+ for (const e of entries) {
38
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
39
+ if (e.isDirectory()) {
40
+ if (e.name === '.git' || (e.name === 'node_modules' && !intoModules)) continue
41
+ await walk(child)
42
+ } else if ((e.isFile() || e.isSymbolicLink()) && wanted(child)) {
43
+ add(child)
44
+ }
45
+ }
46
+ }
47
+ /**
48
+ * Whether the tree at `rel` is where it says, under `base`: the walk
49
+ * follows a link only at its start (a nested one is an entry), and a
50
+ * worker deletes what it names. `dist -> ../victim` for `dist/**`
51
+ * deleted a file out of the project, and `dist -> .git` the repository,
52
+ * as core measured (`containedIn`, `refuseDirLinkedOut`), which refuses
53
+ * the task's save there too.
54
+ */
55
+ const lies = async (realBase: string | undefined, rel: string): Promise<boolean> => {
56
+ const at = path.join(root, base, rel)
57
+ // quiet: nothing there is nothing to walk
58
+ const st = await stat(at).catch(() => undefined)
59
+ if (st === undefined) return true
60
+ const dir = st.isDirectory() ? rel : path.dirname(rel)
61
+ // quiet: a tree that will not resolve is left alone
62
+ const real = await realpath(path.join(root, base, dir)).catch(() => undefined)
63
+ if (real === undefined || realBase === undefined) return false
64
+ if (real === path.join(realBase, dir)) return true
65
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) return false
66
+ const parts = real.slice(realBase.length + 1).split(path.sep)
67
+ return !parts.some((p) => p.toLowerCase() === '.git') && parts[0]?.toLowerCase() !== '.vx'
68
+ }
69
+ // Only the trees the globs can reach: walking the whole project for
70
+ // each task cost a worker 450 ms a task in a project of 9000 output
71
+ // dirs (2026-10-25). Root-anchored or escaping globs keep the walk.
72
+ const dirs = outputDirPrefixes(globs.filter((g) => !g.startsWith('!'))) ?? ['']
73
+ // quiet: a base that will not resolve holds no outputs; `lies` refuses it
74
+ const realBase = await realpath(path.join(root, base)).catch(() => undefined)
75
+ await Promise.all(dirs.map(async (d) => (await lies(realBase, d)) && walk(d)))
76
+ }
77
+ await scan(project, outputs.files, 'project')
78
+ await scan('', outputs.workspaceFiles, 'workspace')
79
+ return [...found].sort()
80
+ }
81
+
82
+ /**
83
+ * Of the files under a task's outputs, those an earlier task left
84
+ * (`cleared`: neither tracked, a nested project's, nor an upstream's) and
85
+ * those an upstream's outputs name (`held`). The held ones were read
86
+ * against every cleared one: 20,000 stale files cost a worker a second a
87
+ * task (2026-10-25).
88
+ */
89
+ export function leftUnder(
90
+ under: readonly string[],
91
+ tracked: ReadonlySet<string>,
92
+ nested: readonly string[],
93
+ upstream: readonly ((file: string) => boolean)[],
94
+ ): { cleared: string[]; held: string[] } {
95
+ const cleared: string[] = []
96
+ const held: string[] = []
97
+ for (const f of under) {
98
+ if (upstream.some((up) => up(f))) held.push(f)
99
+ else if (!tracked.has(f) && !nested.some((n) => f.startsWith(`${n}/`))) cleared.push(f)
100
+ }
101
+ return { cleared, held }
102
+ }
103
+
104
+ /** Whether declared outputs name `file` (workspace-relative), as `outputFiles` would find it. */
105
+ export function names(
106
+ project: string,
107
+ outputs: { readonly files: readonly string[]; readonly workspaceFiles: readonly string[] },
108
+ ): (file: string) => boolean {
109
+ const inProject =
110
+ outputs.files.length === 0 ? () => false : restorableOutput(outputs.files, 'project')
111
+ const inRoot =
112
+ outputs.workspaceFiles.length === 0
113
+ ? () => false
114
+ : restorableOutput(outputs.workspaceFiles, 'workspace')
115
+ const prefix = project === '' ? '' : `${project}/`
116
+ return (file) => inRoot(file) || (file.startsWith(prefix) && inProject(file.slice(prefix.length)))
117
+ }