@vzn/vx-migrate 0.0.579 → 0.0.581
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 +1 -0
- package/package.json +2 -2
- package/src/nx/nx-map.ts +4 -1
- package/src/shared-outputs.ts +174 -35
- package/src/tracked-outputs.ts +36 -0
- package/src/turbo/turbo-map.ts +4 -1
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ Rules:
|
|
|
64
64
|
- Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
|
|
65
65
|
|
|
66
66
|
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — the task with a `^` edge keeps its cache (the first declared when none has one); the rest run uncached with a todo naming the keeper and the fix, their own output path. Same rule for Nx targets.
|
|
67
|
+
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. Same for Nx targets.
|
|
67
68
|
- **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
|
|
68
69
|
- `cache: false` or `persistent: true` → no `cache` block; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
|
|
69
70
|
- A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.581",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
42
|
+
"@vzn/vx": "^0.0.581"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/nx/nx-map.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { mapRunCommands, shellQuote } from '../nx-command.js'
|
|
|
28
28
|
import { scriptCommand, yarnPnp } from '../script-command.js'
|
|
29
29
|
import {
|
|
30
30
|
ownFileTakeBacks,
|
|
31
|
+
excludeSiblingOutputs,
|
|
32
|
+
excludeWorkspaceOutputs,
|
|
31
33
|
resolveSharedOutputs,
|
|
32
34
|
resolveSharedWorkspaceOutputs,
|
|
33
35
|
wildcardOutput,
|
|
@@ -557,11 +559,12 @@ export async function mapNxWorkspace(
|
|
|
557
559
|
dir: meta.dir,
|
|
558
560
|
importLines: [],
|
|
559
561
|
...(tags.length > 0 ? { tags } : {}),
|
|
560
|
-
tasks: resolveSharedOutputs(tasks),
|
|
562
|
+
tasks: excludeSiblingOutputs(resolveSharedOutputs(tasks)),
|
|
561
563
|
})
|
|
562
564
|
}
|
|
563
565
|
|
|
564
566
|
resolveAtomizedWorkspaceOutputs(root, projects, split)
|
|
567
|
+
excludeWorkspaceOutputs(root, projects)
|
|
565
568
|
// A migration leaves Nx behind, so `nx sync` is no advice there: what a
|
|
566
569
|
// generator keeps (the TypeScript one's tsconfig `references`) is the
|
|
567
570
|
// user's to keep. `nx()` runs with Nx installed.
|
package/src/shared-outputs.ts
CHANGED
|
@@ -230,6 +230,106 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
230
230
|
return tasks
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Two globs can match one path only when one's literal prefix is a
|
|
235
|
+
* directory of the other's (`packages/*\/dist/**` and `packages/a/dist/**`),
|
|
236
|
+
* so an indexed glob is a candidate only along its prefix's chain. Every
|
|
237
|
+
* pair was compared: 1,000 packages' builds and tests took 9 s to map.
|
|
238
|
+
* `candidates` is a superset of the indexed entries a glob can overlap.
|
|
239
|
+
*/
|
|
240
|
+
function prefixIndex(): {
|
|
241
|
+
index: (i: number, globs: readonly string[]) => void
|
|
242
|
+
candidates: (globs: readonly string[]) => number[]
|
|
243
|
+
} {
|
|
244
|
+
const at = new Map<string, number[]>()
|
|
245
|
+
const under = new Map<string, number[]>()
|
|
246
|
+
const chain = (g: string): string[] => {
|
|
247
|
+
const segs: string[] = []
|
|
248
|
+
for (const seg of g.split('/')) {
|
|
249
|
+
if (/[*?{}[\]()!]/.test(seg)) break
|
|
250
|
+
segs.push(seg)
|
|
251
|
+
}
|
|
252
|
+
return segs.map((_, i) => segs.slice(0, i + 1).join('/'))
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
index(i, globs) {
|
|
256
|
+
for (const g of globs) {
|
|
257
|
+
const c = chain(g)
|
|
258
|
+
const push = (m: Map<string, number[]>, k: string) => {
|
|
259
|
+
const list = m.get(k)
|
|
260
|
+
if (list === undefined) m.set(k, [i])
|
|
261
|
+
else if (list.at(-1) !== i) list.push(i)
|
|
262
|
+
}
|
|
263
|
+
push(at, c.at(-1) ?? '')
|
|
264
|
+
for (const k of ['', ...c]) push(under, k)
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
candidates(globs) {
|
|
268
|
+
const found = new Set<number>()
|
|
269
|
+
for (const g of globs) {
|
|
270
|
+
const c = chain(g)
|
|
271
|
+
for (const k of ['', ...c]) for (const i of at.get(k) ?? []) found.add(i)
|
|
272
|
+
for (const i of under.get(c.at(-1) ?? '') ?? []) found.add(i)
|
|
273
|
+
}
|
|
274
|
+
return [...found].sort((a, b) => a - b)
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A task's cached `inputs` block, when it has one with a `files` list. */
|
|
280
|
+
function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
|
|
281
|
+
const inputs = (t.task?.['cache'] as { inputs?: { files?: unknown } } | undefined)?.inputs
|
|
282
|
+
return Array.isArray(inputs?.files)
|
|
283
|
+
? (inputs as { files: string[]; workspaceFiles?: unknown })
|
|
284
|
+
: undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Core refuses a task whose inputs can match another task's declared
|
|
289
|
+
* outputs (`rules.upfrontKeys`, on by default, core X-54): such a key
|
|
290
|
+
* reads what a producer writes this run and cannot be known before it ran.
|
|
291
|
+
* Turbo and Nx default a task's inputs to every file of its package
|
|
292
|
+
* (`**\/*`), which matches a sibling build's `dist`. Each overlapping
|
|
293
|
+
* output is taken back with a `!` entry, in place: Turbo and Nx hash the
|
|
294
|
+
* files git tracks, and a build's outputs are not, so the adopted tool
|
|
295
|
+
* never keyed them either. A literal input another task writes cannot be
|
|
296
|
+
* taken back (core refuses a `!` over a literal input), so that task runs
|
|
297
|
+
* uncached with a todo. Committed files under an output are the tracked
|
|
298
|
+
* pass's (`spareTrackedOutputs`).
|
|
299
|
+
*/
|
|
300
|
+
export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
301
|
+
const writers = tasks.flatMap((t, index) => {
|
|
302
|
+
const c = cachedOutputs(t, index)
|
|
303
|
+
return c === null ? [] : [c]
|
|
304
|
+
})
|
|
305
|
+
if (writers.length === 0) return tasks
|
|
306
|
+
tasks.forEach((t, index) => {
|
|
307
|
+
const inputs = inputsOf(t)
|
|
308
|
+
if (inputs === undefined) return
|
|
309
|
+
const positive = inputs.files.filter((g) => typeof g === 'string' && !g.startsWith('!'))
|
|
310
|
+
for (const w of writers) {
|
|
311
|
+
if (w.index === index) continue
|
|
312
|
+
for (const out of w.files) {
|
|
313
|
+
if (inputs.files.includes(`!${out}`)) continue
|
|
314
|
+
const read = positive.filter((g) => outputsOverlap(g, out))
|
|
315
|
+
if (read.length === 0) continue
|
|
316
|
+
const literal = read.find((g) => isLiteralPattern(g))
|
|
317
|
+
if (literal !== undefined) {
|
|
318
|
+
delete t.task!['cache']
|
|
319
|
+
t.todos.push(
|
|
320
|
+
`reads ${JSON.stringify(literal)}, which ${JSON.stringify(w.name)} writes — vx keys a ` +
|
|
321
|
+
'task only on files no other task writes, so it runs uncached; read the source ' +
|
|
322
|
+
'instead in a vx.config to cache it',
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
inputs.files.push(`!${out}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
return tasks
|
|
331
|
+
}
|
|
332
|
+
|
|
233
333
|
/**
|
|
234
334
|
* The same across projects, for workspace outputs: cal.com's shared
|
|
235
335
|
* `post-install` writes `../../node_modules/@prisma/client/**` from every
|
|
@@ -255,41 +355,7 @@ export function resolveSharedWorkspaceOutputs(
|
|
|
255
355
|
? v.filter((f): f is string => typeof f === 'string' && !f.startsWith('!'))
|
|
256
356
|
: []
|
|
257
357
|
const kept: { id: string; project: string; ws: string[]; own: string[] }[] = []
|
|
258
|
-
|
|
259
|
-
// directory of the other's (`packages/*/dist/**` and `packages/a/dist/**`),
|
|
260
|
-
// so a kept glob is a candidate only along its prefix's chain. Every pair
|
|
261
|
-
// was compared: 1,000 packages' builds and tests took 9 s to map.
|
|
262
|
-
const at = new Map<string, number[]>()
|
|
263
|
-
const under = new Map<string, number[]>()
|
|
264
|
-
const chain = (g: string): string[] => {
|
|
265
|
-
const segs: string[] = []
|
|
266
|
-
for (const seg of g.split('/')) {
|
|
267
|
-
if (/[*?{}[\]()!]/.test(seg)) break
|
|
268
|
-
segs.push(seg)
|
|
269
|
-
}
|
|
270
|
-
return segs.map((_, i) => segs.slice(0, i + 1).join('/'))
|
|
271
|
-
}
|
|
272
|
-
const index = (i: number, globs: readonly string[]): void => {
|
|
273
|
-
for (const g of globs) {
|
|
274
|
-
const c = chain(g)
|
|
275
|
-
const push = (m: Map<string, number[]>, k: string) => {
|
|
276
|
-
const list = m.get(k)
|
|
277
|
-
if (list === undefined) m.set(k, [i])
|
|
278
|
-
else if (list.at(-1) !== i) list.push(i)
|
|
279
|
-
}
|
|
280
|
-
push(at, c.at(-1) ?? '')
|
|
281
|
-
for (const k of ['', ...c]) push(under, k)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
const candidates = (globs: readonly string[]): number[] => {
|
|
285
|
-
const found = new Set<number>()
|
|
286
|
-
for (const g of globs) {
|
|
287
|
-
const c = chain(g)
|
|
288
|
-
for (const k of ['', ...c]) for (const i of at.get(k) ?? []) found.add(i)
|
|
289
|
-
for (const i of under.get(c.at(-1) ?? '') ?? []) found.add(i)
|
|
290
|
-
}
|
|
291
|
-
return [...found].sort((a, b) => a - b)
|
|
292
|
-
}
|
|
358
|
+
const { index, candidates } = prefixIndex()
|
|
293
359
|
for (const p of projects) {
|
|
294
360
|
const rel = relPosix(root, p.dir)
|
|
295
361
|
for (const t of p.tasks) {
|
|
@@ -332,6 +398,79 @@ export function resolveSharedWorkspaceOutputs(
|
|
|
332
398
|
}
|
|
333
399
|
}
|
|
334
400
|
|
|
401
|
+
/**
|
|
402
|
+
* `excludeSiblingOutputs` for root-anchored `cache.inputs.workspaceFiles`:
|
|
403
|
+
* every other task's `outputs.workspaceFiles`, and its `outputs.files` at
|
|
404
|
+
* their workspace path, is taken back from a reader's workspace inputs
|
|
405
|
+
* (Turbo's `globalDependencies` and `$TURBO_ROOT$` entries, Nx's
|
|
406
|
+
* `{workspaceRoot}` filesets). Run after `resolveSharedWorkspaceOutputs`,
|
|
407
|
+
* over what stays cached.
|
|
408
|
+
*/
|
|
409
|
+
export function excludeWorkspaceOutputs(
|
|
410
|
+
root: string,
|
|
411
|
+
projects: readonly {
|
|
412
|
+
readonly name: string
|
|
413
|
+
readonly dir: string
|
|
414
|
+
readonly tasks: GeneratedTask[]
|
|
415
|
+
}[],
|
|
416
|
+
): void {
|
|
417
|
+
const positive = (v: unknown): string[] =>
|
|
418
|
+
Array.isArray(v)
|
|
419
|
+
? v.filter((f): f is string => typeof f === 'string' && !f.startsWith('!'))
|
|
420
|
+
: []
|
|
421
|
+
const writers: { task: GeneratedTask; id: string; globs: string[] }[] = []
|
|
422
|
+
const { index, candidates } = prefixIndex()
|
|
423
|
+
for (const p of projects) {
|
|
424
|
+
const rel = relPosix(root, p.dir)
|
|
425
|
+
for (const t of p.tasks) {
|
|
426
|
+
const outputs = (
|
|
427
|
+
t.task?.['cache'] as { outputs?: { files?: unknown; workspaceFiles?: unknown } } | undefined
|
|
428
|
+
)?.outputs
|
|
429
|
+
const globs = [
|
|
430
|
+
...positive(outputs?.workspaceFiles),
|
|
431
|
+
...positive(outputs?.files).map((g) =>
|
|
432
|
+
rel === '' ? g.replace(/^(\.\/)+/, '') : `${rel}/${g.replace(/^(\.\/)+/, '')}`,
|
|
433
|
+
),
|
|
434
|
+
]
|
|
435
|
+
if (globs.length === 0) continue
|
|
436
|
+
index(writers.length, globs)
|
|
437
|
+
writers.push({ task: t, id: `${p.name}#${t.name}`, globs })
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (writers.length === 0) return
|
|
441
|
+
for (const p of projects) {
|
|
442
|
+
for (const t of p.tasks) {
|
|
443
|
+
const inputs = (t.task?.['cache'] as { inputs?: { workspaceFiles?: unknown } } | undefined)
|
|
444
|
+
?.inputs
|
|
445
|
+
if (inputs === undefined || !Array.isArray(inputs.workspaceFiles)) continue
|
|
446
|
+
const files = inputs.workspaceFiles as string[]
|
|
447
|
+
const reads = positive(files)
|
|
448
|
+
if (reads.length === 0) continue
|
|
449
|
+
for (const i of candidates(reads)) {
|
|
450
|
+
const w = writers[i]!
|
|
451
|
+
if (w.task === t) continue
|
|
452
|
+
for (const out of w.globs) {
|
|
453
|
+
if (files.includes(`!${out}`)) continue
|
|
454
|
+
const read = reads.filter((g) => outputsOverlap(g, out))
|
|
455
|
+
if (read.length === 0) continue
|
|
456
|
+
const literal = read.find((g) => isLiteralPattern(g))
|
|
457
|
+
if (literal !== undefined) {
|
|
458
|
+
delete t.task!['cache']
|
|
459
|
+
t.todos.push(
|
|
460
|
+
`reads ${JSON.stringify(literal)}, which ${w.id} writes — vx keys a task only on ` +
|
|
461
|
+
'files no other task writes, so it runs uncached; read the source instead in a ' +
|
|
462
|
+
'vx.config to cache it',
|
|
463
|
+
)
|
|
464
|
+
break
|
|
465
|
+
}
|
|
466
|
+
files.push(`!${out}`)
|
|
467
|
+
}
|
|
468
|
+
if (t.task?.['cache'] === undefined) break
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
335
474
|
/** A `!` output with no positive beside it takes back nothing, and core refuses a list of them alone. */
|
|
336
475
|
export function takingBack(globs: readonly string[]): string[] {
|
|
337
476
|
return globs.some((g) => !g.startsWith('!')) ? [...globs] : []
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -178,6 +178,18 @@ function coveredTracked(globs: readonly string[], files: readonly string[]): str
|
|
|
178
178
|
return [...hit].sort()
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/** The first of `files` a `!` entry of `globs` takes back though a positive one reads it. */
|
|
182
|
+
function hidden(globs: unknown, files: readonly string[]): string | undefined {
|
|
183
|
+
if (!Array.isArray(globs) || files.length === 0) return undefined
|
|
184
|
+
const strings = globs.filter((g): g is string => typeof g === 'string')
|
|
185
|
+
const negative = strings.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
186
|
+
if (negative.length === 0) return undefined
|
|
187
|
+
const positive = strings.filter((g) => !g.startsWith('!'))
|
|
188
|
+
return files.find(
|
|
189
|
+
(f) => negative.some((n) => outputsOverlap(n, f)) && positive.some((g) => outputsOverlap(g, f)),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
181
193
|
/** Past this many, a task's take-backs cost its runs more than its cache saves. */
|
|
182
194
|
export const MAX_SPARED = 16
|
|
183
195
|
|
|
@@ -223,6 +235,30 @@ export function spareTrackedOutputs(
|
|
|
223
235
|
}
|
|
224
236
|
const spared = hits.flatMap(([, , hit]) => hit)
|
|
225
237
|
if (spared.length === 0) continue
|
|
238
|
+
// A committed file under these outputs is still a source, and the `!`
|
|
239
|
+
// entry `excludeSiblingOutputs` gave a reader over the whole output
|
|
240
|
+
// hides it from that reader's key: such a reader runs uncached (X-54).
|
|
241
|
+
const local = hits.flatMap(([key, , hit]) => (key === 'files' ? hit : []))
|
|
242
|
+
const rooted = hits.flatMap(([key, , hit]) =>
|
|
243
|
+
key === 'files' ? hit.map((f) => (rel === '' ? f : `${rel}/${f}`)) : hit,
|
|
244
|
+
)
|
|
245
|
+
for (const q of projects) {
|
|
246
|
+
for (const r of q.tasks) {
|
|
247
|
+
if (r === t) continue
|
|
248
|
+
const inputs = (r.task?.['cache'] as { inputs?: Outputs } | undefined)?.inputs
|
|
249
|
+
if (inputs === undefined) continue
|
|
250
|
+
const f =
|
|
251
|
+
(q === p ? hidden(inputs.files, local) : undefined) ??
|
|
252
|
+
hidden(inputs.workspaceFiles, rooted)
|
|
253
|
+
if (f === undefined) continue
|
|
254
|
+
delete r.task!['cache']
|
|
255
|
+
todos.push([
|
|
256
|
+
`${q.name}#${r.name}`,
|
|
257
|
+
`reads the committed ${f}, which its inputs take back with ${p.name}#${t.name}'s ` +
|
|
258
|
+
'outputs — task runs uncached; declare its inputs in a vx.config to cache it',
|
|
259
|
+
])
|
|
260
|
+
}
|
|
261
|
+
}
|
|
226
262
|
const shown = spared.slice(0, 3).join(', ') + (spared.length > 3 ? ', …' : '')
|
|
227
263
|
// Each `!` is matched against every output path on each save, clean
|
|
228
264
|
// and restore: 4,200 of them cost typescript-eslint's warm run 1.2 s.
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { scriptCommand, yarnPnp } from '../script-command.js'
|
|
|
19
19
|
import {
|
|
20
20
|
ownFileOutput,
|
|
21
21
|
ownFileTodo,
|
|
22
|
+
excludeSiblingOutputs,
|
|
23
|
+
excludeWorkspaceOutputs,
|
|
22
24
|
resolveSharedOutputs,
|
|
23
25
|
resolveSharedWorkspaceOutputs,
|
|
24
26
|
takingBack,
|
|
@@ -1375,7 +1377,7 @@ export async function mapTurboWorkspace(
|
|
|
1375
1377
|
npmScriptEnv(mapped.task, name, command === script, meta, opts)
|
|
1376
1378
|
tasks.push(mapped)
|
|
1377
1379
|
}
|
|
1378
|
-
resolveSharedOutputs(tasks)
|
|
1380
|
+
excludeSiblingOutputs(resolveSharedOutputs(tasks))
|
|
1379
1381
|
projects.push({ name: meta.name, dir: meta.dir, tasks })
|
|
1380
1382
|
}
|
|
1381
1383
|
|
|
@@ -1398,6 +1400,7 @@ export async function mapTurboWorkspace(
|
|
|
1398
1400
|
)
|
|
1399
1401
|
literalEnvGapsOnce(projects, notes)
|
|
1400
1402
|
resolveSharedWorkspaceOutputs(root, projects)
|
|
1403
|
+
excludeWorkspaceOutputs(root, projects)
|
|
1401
1404
|
pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
|
|
1402
1405
|
pruneOrphanPersistentNotes(projects, opts.persistentTodo)
|
|
1403
1406
|
return { projects, notes, globals }
|