@vzn/vx 0.0.651 → 0.0.652

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +2 -3
  2. package/package.json +5 -5
  3. package/schemas/show.json +14 -20
  4. package/src/bin.ts +12 -0
  5. package/src/cache/archive.ts +1 -1
  6. package/src/cache/cache.ts +19 -5
  7. package/src/cache/history-stream.ts +134 -0
  8. package/src/cache/history-writer.ts +76 -0
  9. package/src/cache/index.ts +2 -0
  10. package/src/cache/inputs.ts +74 -14
  11. package/src/cache/layer.ts +6 -6
  12. package/src/cache/lockfile-memo.ts +2 -1
  13. package/src/cache/run-history.ts +46 -17
  14. package/src/cache/schema.ts +4 -1
  15. package/src/cli/cache.ts +2 -2
  16. package/src/cli/help.ts +2 -2
  17. package/src/cli/index.ts +24 -0
  18. package/src/cli/info.ts +1 -1
  19. package/src/cli/last.ts +34 -2
  20. package/src/cli/plugin-templates.ts +36 -45
  21. package/src/cli/show.ts +7 -10
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch-set.ts +4 -4
  24. package/src/cli/watch.ts +11 -8
  25. package/src/config.ts +47 -30
  26. package/src/exec/executor.ts +40 -1
  27. package/src/exec/index.ts +1 -0
  28. package/src/exec/sandbox-paths.ts +3 -1
  29. package/src/exec/sandbox-runtime.ts +314 -29
  30. package/src/exec/task-proxy.ts +93 -0
  31. package/src/graph/definition.ts +16 -3
  32. package/src/graph/scheduler.ts +3 -1
  33. package/src/graph/task-graph.ts +24 -23
  34. package/src/history/failure-mode.ts +24 -1
  35. package/src/history/index.ts +1 -0
  36. package/src/history/metrics.ts +5 -5
  37. package/src/index.ts +4 -2
  38. package/src/orchestrator/admission.ts +1 -1
  39. package/src/orchestrator/command-windows.ts +1 -1
  40. package/src/orchestrator/deferred-outputs.ts +1 -1
  41. package/src/orchestrator/download-policy.ts +5 -7
  42. package/src/orchestrator/excluded-keys.ts +1 -1
  43. package/src/orchestrator/execute-task.ts +51 -18
  44. package/src/orchestrator/hit-restore.ts +5 -6
  45. package/src/orchestrator/keyed-projects.ts +28 -0
  46. package/src/orchestrator/local-shortcircuit.ts +2 -2
  47. package/src/orchestrator/miss-save.ts +27 -8
  48. package/src/orchestrator/persistent-task.ts +2 -0
  49. package/src/orchestrator/placement.ts +1 -1
  50. package/src/orchestrator/plan.ts +2 -2
  51. package/src/orchestrator/plugin-host.ts +6 -2
  52. package/src/orchestrator/plugin.ts +6 -0
  53. package/src/orchestrator/remote-prefetch.ts +1 -1
  54. package/src/orchestrator/run-failures.ts +61 -12
  55. package/src/orchestrator/run-keys.ts +1 -1
  56. package/src/orchestrator/run-records.ts +86 -67
  57. package/src/orchestrator/run-report.ts +1 -1
  58. package/src/orchestrator/run.ts +92 -19
  59. package/src/orchestrator/sandbox-request.ts +50 -7
  60. package/src/orchestrator/shell-verdict.ts +9 -0
  61. package/src/orchestrator/stable-keys.ts +17 -18
  62. package/src/orchestrator/task-artifacts.ts +1 -1
  63. package/src/orchestrator/task-hash.ts +4 -4
  64. package/src/orchestrator/telemetry.ts +5 -1
  65. package/src/report/events.ts +3 -3
  66. package/src/report/framed-output.ts +16 -7
  67. package/src/report/logger.ts +1 -1
  68. package/src/report/plain-output.ts +2 -0
  69. package/src/report/summary.ts +5 -5
  70. package/src/settings.ts +8 -8
  71. package/src/util/config-frame.ts +128 -6
  72. package/src/util/paths.ts +1 -1
  73. package/src/util/settle.ts +2 -1
  74. package/src/util/tail.ts +4 -1
  75. package/src/workspace/bracket-literal.ts +2 -2
  76. package/src/workspace/config-schema.ts +66 -61
  77. package/src/workspace/filter.ts +8 -1
  78. package/src/workspace/migration.ts +12 -6
@@ -268,13 +268,22 @@ const MEM_COL = 6 // "412MB", "1.1GB", "1023MB"
268
268
  const OUT_COL = 7 // `outputs`: "12MB", "900B"
269
269
  const WORKER_COL = 12 // `local`, or a plugin's name for the machine, cut at 12
270
270
 
271
+ // A row's padding, built once: `' '.repeat` per cell was ~7 ms of an
272
+ // 8,002-hit warm run's rows, the largest single cost of writing them.
273
+ const SPACES = Array.from({ length: 33 }, (_, n) => ' '.repeat(n))
274
+
275
+ /** `n` spaces (none below 1). */
276
+ function pad(n: number): string {
277
+ return n <= 0 ? '' : (SPACES[n] ?? ' '.repeat(n))
278
+ }
279
+
271
280
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
272
281
  function timeCell(ms: number | null, colors: ColorSupport): string {
273
282
  let raw = ms === null ? '' : formatElapsed(ms)
274
283
  // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
275
284
  if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
276
285
  // The time is the row's fact (value level); the cells around it are meta.
277
- return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('value', raw, colors)
286
+ return pad(TIME_COL - raw.length) + paint('value', raw, colors)
278
287
  }
279
288
 
280
289
  /**
@@ -284,7 +293,7 @@ function timeCell(ms: number | null, colors: ColorSupport): string {
284
293
  */
285
294
  function cell(text: string, color: Style | '', width: number, colors: ColorSupport): string {
286
295
  const painted = text === '' ? '' : paint(color === '' ? 'muted' : color, text, colors)
287
- return painted + ' '.repeat(Math.max(0, width - text.length))
296
+ return painted + pad(width - text.length)
288
297
  }
289
298
 
290
299
  /** The cache-shape glyph (task-shape for non-success). */
@@ -364,7 +373,7 @@ export function formatTaskRow(
364
373
  // `aborting` runs one into the cache cell, which is blank beside it.
365
374
  const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
366
375
  const usual = cells.usual ?? { text: '', painted: '' }
367
- const us = `${' '.repeat(Math.max(0, USUAL_COL - usual.text.length))}${usual.painted}`
376
+ const us = `${pad(USUAL_COL - usual.text.length)}${usual.painted}`
368
377
  const cpu =
369
378
  cells.cpu === undefined ? '' : cells.cpu === null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
370
379
  const mem =
@@ -379,8 +388,8 @@ export function formatTaskRow(
379
388
  return (
380
389
  ` ${glyph} ${timeCell(ms, colors)} ${us} ${st} ${ca} ` +
381
390
  `${right(cpu, CPU_COL, colors)} ${right(mem, MEM_COL, colors)} ${right(out, OUT_COL, colors)} ` +
382
- `${' '.repeat(Math.max(0, BLOCKED_COL - blocks.length))}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
383
- `${placeCell(worker, colors)}${' '.repeat(WORKER_COL - worker.length)} ${paintedId}`
391
+ `${pad(BLOCKED_COL - blocks.length)}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
392
+ `${placeCell(worker, colors)}${pad(WORKER_COL - worker.length)} ${paintedId}`
384
393
  )
385
394
  }
386
395
 
@@ -407,7 +416,7 @@ export interface RowCells {
407
416
  }
408
417
 
409
418
  function right(text: string, width: number, colors: ColorSupport): string {
410
- return ' '.repeat(Math.max(0, width - text.length)) + paint('muted', text, colors)
419
+ return pad(width - text.length) + paint('muted', text, colors)
411
420
  }
412
421
 
413
422
  /** `900B`, `412MB`, `1.1GB`: one width for a size, whatever it is. */
@@ -532,7 +541,7 @@ export function formatRecordedRow(
532
541
  id: `${r.project}#${r.task}`,
533
542
  projectName: r.project,
534
543
  taskName: r.task,
535
- config: r.cached === false ? {} : { cache: {} },
544
+ config: r.cached === false ? {} : { cache: true },
536
545
  } as unknown as TaskNode
537
546
  const o = {
538
547
  node,
@@ -373,7 +373,7 @@ export function defaultLogger(
373
373
  let slots: (WorkerSlot | null)[] = []
374
374
  const slotQueue: WorkerSlot[] = []
375
375
  let succeeded = 0
376
- // Executed with no `cache` block: never a miss, counted apart.
376
+ // Executed without `cache: true`: never a miss, counted apart.
377
377
  let noCache = 0
378
378
  let upToDate = 0
379
379
  let restoredLocal = 0
@@ -57,6 +57,8 @@ export function plainOutput(sink: Logger): Logger {
57
57
  if (text !== '' || chunk === '') sink.taskStderr(node, text)
58
58
  },
59
59
  taskComplete(node, outcome) {
60
+ // Nearly every task ends with no escape held: its keys go unbuilt.
61
+ if (held.size === 0) return sink.taskComplete(node, outcome)
60
62
  flush(node, `${node.id}\0out`, (n, c) => sink.taskStdout(n, c))
61
63
  flush(node, `${node.id}\0err`, (n, c) => sink.taskStderr(n, c))
62
64
  sink.taskComplete(node, outcome)
@@ -62,11 +62,11 @@ export interface SummaryStats {
62
62
  restoredRemote: number
63
63
  /** Ran here and saved to the cache: the legend's `↑`. */
64
64
  saved?: number
65
- /** Ran here with a `cache` block, succeeded, and saved nothing. */
65
+ /** Ran here with `cache: true`, succeeded, and saved nothing. */
66
66
  notSaved?: number
67
- /** Tasks that executed (success + failed) with a `cache` block — the misses. */
67
+ /** Tasks that executed (success + failed) with `cache: true` — the misses. */
68
68
  miss: number
69
- /** Executed with no `cache` block: never consulted the cache, not a miss. */
69
+ /** Executed without `cache: true`: never consulted the cache, not a miss. */
70
70
  noCache?: number
71
71
  /** Still to run (live section only; 0 in the final summary) — renders
72
72
  * as a gray ▱ remainder so the live meters FILL toward the final. */
@@ -582,7 +582,7 @@ function tableStatusColor(o: TaskOutcome): Style {
582
582
 
583
583
  // Local byte formatter — the orchestrator can't import cli/format.ts
584
584
  // (module boundary), and the footer only needs one compact form.
585
- /** Ran here with a `cache` block, succeeded, and its result was not saved. */
585
+ /** Ran here with `cache: true`, succeeded, and its result was not saved. */
586
586
  export function ranUnsaved(o: TaskOutcome): boolean {
587
587
  return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
588
588
  }
@@ -593,7 +593,7 @@ export function formatRunSummary(
593
593
  colors: ColorSupport = NO_COLOR,
594
594
  context?: RunContext,
595
595
  ): string[] {
596
- // Counted here, not in the Tally: a task with no `cache` block never
596
+ // Counted here, not in the Tally: a task without `cache: true` never
597
597
  // consulted the cache, so it is not a miss.
598
598
  const noCache = outcomes.filter(
599
599
  (o) =>
package/src/settings.ts CHANGED
@@ -118,7 +118,7 @@ export const SETTINGS: readonly Setting[] = [
118
118
  {
119
119
  path: 'cache.dir',
120
120
  kind: { type: 'string' },
121
- default: '.vx/cache, entries in ~/.vx/<repo>/cache',
121
+ default: '.vx/cache, entries in ~/.vx/<id>/cache',
122
122
  doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
123
123
  flag: { verb: 'run', name: '--cache-dir' },
124
124
  },
@@ -165,25 +165,25 @@ export const SETTINGS: readonly Setting[] = [
165
165
  flag: { verb: 'run', name: '--cache-download' },
166
166
  },
167
167
  {
168
- path: 'cache.inputStability.strategy',
168
+ path: 'inputs.stability.strategy',
169
169
  kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
170
170
  default: 'immutable',
171
171
  doc: 'What a write to an input during the command means for the save, for tasks without their own.',
172
- task: 'cache.inputStability.strategy',
172
+ task: 'inputs.stability.strategy',
173
173
  },
174
174
  {
175
- path: 'cache.inputStability.severity',
175
+ path: 'inputs.stability.severity',
176
176
  kind: { type: 'enum', values: ['warn', 'error'] },
177
177
  default: 'warn',
178
178
  doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
179
- task: 'cache.inputStability.severity',
179
+ task: 'inputs.stability.severity',
180
180
  },
181
181
  {
182
- path: 'cache.outputs.clean',
182
+ path: 'outputs.clean',
183
183
  kind: { type: 'bool' },
184
184
  default: 'true',
185
185
  doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
186
- task: 'cache.outputs.clean',
186
+ task: 'outputs.clean',
187
187
  env: false,
188
188
  },
189
189
  {
@@ -249,7 +249,7 @@ export const SETTINGS: readonly Setting[] = [
249
249
  ]
250
250
 
251
251
  /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
252
- export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
252
+ export const SETTING_GROUPS = ['run', 'cache', 'inputs', 'outputs', 'lock', 'watch'] as const
253
253
 
254
254
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
255
255
 
@@ -32,11 +32,25 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
32
32
  const rest = message.slice(head[0].length)
33
33
  // The field the refusal is about, past the path that leads to it:
34
34
  // `has unknown field "comand"`, or `tasks.build: \`cache\` is not allowed`.
35
- const named =
36
- colon === ':' ? /^`([^`]+)`/.exec(rest) : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
35
+ // A refused key is quoted: `tasks.b.exec.env.define: "A=B" is not …`.
36
+ const said =
37
+ colon === ':'
38
+ ? /^(?:`([^`]+)`|("(?:[^"\\\n]|\\.)*"))/.exec(rest)
39
+ : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
40
+ const named = said === null ? null : [said[0], said[1] ?? JSON.parse(said[2]!)]
41
+ // `plugins[2].name`: an element of an array, framed at the element (a
42
+ // plugin is a call, so no key inside it is searched).
43
+ const element = setting === undefined ? null : /^([^[]+)\[(\d+)\]/.exec(setting)
37
44
  const fieldPath =
38
- taskName === undefined ? (tasksPath ?? setting)?.split('.') : ['tasks', JSON.parse(taskName)]
39
- const segments = [...(fieldPath ?? []), ...(named === null ? [] : [named[1]!])]
45
+ taskName !== undefined
46
+ ? ['tasks', JSON.parse(taskName)]
47
+ : element !== null
48
+ ? element[1]!.split('.')
49
+ : (tasksPath ?? setting)?.split('.')
50
+ const segments = [
51
+ ...(fieldPath ?? []),
52
+ ...(named === null || element !== null ? [] : [named[1]!]),
53
+ ]
40
54
  if (segments.length === 0) return undefined
41
55
  let source: string
42
56
  try {
@@ -44,7 +58,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
44
58
  } catch {
45
59
  return undefined
46
60
  }
47
- const at = locate(source, segments)
61
+ const at = locate(source, segments, element === null ? undefined : Number(element[2]))
48
62
  if (at === undefined) return undefined
49
63
  return framed(file, source, at, message.slice(file.length), cwd)
50
64
  }
@@ -54,8 +68,70 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
54
68
  const POSITION =
55
69
  /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
56
70
 
71
+ // The loader's `Project config <file> threw: <message> (at <file>:<line>:<col>)`:
72
+ // the config's own throw, placed by its stack in the config itself.
73
+ const THREW =
74
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) threw: ([^\n]*?) \(at (\/[^\n]*?):(\d+):(\d+)\)([^]*)$/
75
+
76
+ // The loader's `<kind> config <file>: cannot find '<pkg>' …`: placed at the
77
+ // specifier. And `<kind> config at <file> did not export a default object`:
78
+ // at the `export default` that exported something else, when there is one.
79
+ const UNPROVIDED =
80
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s): (cannot find '([^'\n]+)'[^]*)$/
81
+ const NOT_OBJECT =
82
+ /^(?:Project|Workspace) config at (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) (did not export a default object[^]*)$/
83
+
84
+ /** The file, the source offset `find` places in it, and what follows `<file>:<line>:<col>`. */
85
+ function sourceFrame(
86
+ file: string,
87
+ find: (source: string) => number,
88
+ rest: string,
89
+ cwd: string,
90
+ ): string | undefined {
91
+ let source: string
92
+ try {
93
+ source = readFileSync(file, 'utf8')
94
+ } catch {
95
+ return undefined
96
+ }
97
+ const at = find(source)
98
+ if (at < 0) return undefined
99
+ const before = source.slice(0, at)
100
+ const line = before.split('\n').length
101
+ return framed(file, source, { line, col: at - before.lastIndexOf('\n') }, rest, cwd)
102
+ }
103
+
57
104
  function positionFrame(message: string, cwd: string): string | undefined {
58
- const pos = POSITION.exec(message)
105
+ const unprovided = UNPROVIDED.exec(message)
106
+ if (unprovided !== null) {
107
+ const [, file, said, pkg] = unprovided as unknown as [string, string, string, string]
108
+ const spec = new RegExp(
109
+ `(?<=\\b(?:from|import|import\\(|require\\()\\s*)(['"])${escapeRe(pkg)}\\1`,
110
+ )
111
+ return sourceFrame(file, (src) => spec.exec(src)?.index ?? -1, `: ${said}`, cwd)
112
+ }
113
+ const notObject = NOT_OBJECT.exec(message)
114
+ if (notObject !== null) {
115
+ const [, file, said] = notObject as unknown as [string, string, string]
116
+ return sourceFrame(
117
+ file,
118
+ (src) => /^export\s+default\b/m.exec(src)?.index ?? -1,
119
+ `: ${said}`,
120
+ cwd,
121
+ )
122
+ }
123
+ const threw = THREW.exec(message)
124
+ const pos =
125
+ threw !== null && threw[3] === threw[1]
126
+ ? [
127
+ threw[0],
128
+ threw[1],
129
+ threw[4],
130
+ threw[5],
131
+ // A cause's own `(at …)` names the same file: relative there too.
132
+ `: threw: ${threw[2]}${threw[6]!.replaceAll(threw[1]!, path.relative(cwd, threw[1]!))}`,
133
+ ]
134
+ : POSITION.exec(message)
59
135
  if (pos === null) return undefined
60
136
  const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
61
137
  let source: string
@@ -105,6 +181,8 @@ function framed(
105
181
  function locate(
106
182
  source: string,
107
183
  segments: readonly string[],
184
+ /** The element of the array the last segment holds; past its end, the key. */
185
+ index?: number,
108
186
  ): { line: number; col: number } | undefined {
109
187
  let pos = 0
110
188
  let end = source.length
@@ -137,6 +215,10 @@ function locate(
137
215
  i = next
138
216
  }
139
217
  if (found < 0) return undefined
218
+ if (index !== undefined && i === segments.length) {
219
+ const el = elementStart(source, pos, index)
220
+ if (el !== undefined) found = el
221
+ }
140
222
  const before = source.slice(0, found)
141
223
  const line = before.split('\n').length
142
224
  return { line, col: found - before.lastIndexOf('\n') }
@@ -172,6 +254,46 @@ function literalEnd(source: string, from: number): number {
172
254
  return source.length
173
255
  }
174
256
 
257
+ /** Where element `index` of the array literal at `from` (past blanks) starts, if it has one. */
258
+ function elementStart(source: string, from: number, index: number): number | undefined {
259
+ let i = from
260
+ while (i < source.length && /\s/.test(source[i]!)) i++
261
+ if (source[i] !== '[') return undefined
262
+ let depth = 0
263
+ let n = 0
264
+ // Each element starts at the first code past `[` or a top-level `,`.
265
+ let pending = true
266
+ for (i++; i < source.length; i++) {
267
+ const c = source[i]!
268
+ if (c === '/' && source[i + 1] === '/') {
269
+ i = source.indexOf('\n', i)
270
+ if (i < 0) return undefined
271
+ continue
272
+ }
273
+ if (c === '/' && source[i + 1] === '*') {
274
+ i = source.indexOf('*/', i + 2)
275
+ if (i < 0) return undefined
276
+ i++
277
+ continue
278
+ }
279
+ if (/\s/.test(c)) continue
280
+ if (depth === 0 && (c === ']' || c === ')' || c === '}')) return undefined
281
+ if (pending) {
282
+ if (n === index) return i
283
+ pending = false
284
+ }
285
+ if (c === '"' || c === "'" || c === '`') {
286
+ for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
287
+ } else if (c === '{' || c === '[' || c === '(') depth++
288
+ else if (c === '}' || c === ']' || c === ')') depth--
289
+ else if (c === ',' && depth === 0) {
290
+ n++
291
+ pending = true
292
+ }
293
+ }
294
+ return undefined
295
+ }
296
+
175
297
  function escapeRe(s: string): string {
176
298
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
177
299
  }
package/src/util/paths.ts CHANGED
@@ -202,7 +202,7 @@ export function isLiteralPattern(glob: string): boolean {
202
202
  }
203
203
 
204
204
  /**
205
- * The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
205
+ * The alphabet of a TASK glob (`inputs.files`, `outputs.files`,
206
206
  * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
207
207
  * need the POSITION of the first wildcard rather than a verdict. Every site
208
208
  * that asks "must this declaration be matched?" reads this one regex: item
@@ -51,7 +51,8 @@ export function killGraceMs(defaultMs: number): number {
51
51
  export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
52
52
  let timer: ReturnType<typeof setTimeout> | undefined
53
53
  const deadline = new Promise<false>((resolve) => {
54
- timer = setTimeout(() => resolve(false), ms)
54
+ // A delay past a 32-bit int fires at 1 ms: the longest the timer holds instead.
55
+ timer = setTimeout(() => resolve(false), Math.min(ms, MAX_TIMEOUT_MS))
55
56
  })
56
57
  try {
57
58
  return (await Promise.race([p.then(() => true), deadline])) !== false
package/src/util/tail.ts CHANGED
@@ -32,8 +32,11 @@ export function createTail(): Tail {
32
32
  return { chunks: [], chars: 0, dropped: 0 }
33
33
  }
34
34
 
35
- export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS): void {
35
+ export function appendTail(t: Tail, chunk: string, cap = PERSISTENT_TAIL_CHARS): void {
36
36
  if (chunk.length === 0) return
37
+ // Every bound below is `chars > limit`, which NaN never is: a NaN cap
38
+ // kept everything, the unbounded heap this exists to prevent.
39
+ const limit = Number.isNaN(cap) ? 0 : cap
37
40
  t.chunks.push(chunk)
38
41
  t.chars += chunk.length
39
42
  // Evict WHOLE chunks from the head — no concatenation until the flush
@@ -65,8 +65,8 @@ export function checkBracketLiterals(root: string, projects: Iterable<ProjectEnt
65
65
  const lists: [string, readonly string[] | undefined, string][] = [
66
66
  ['inputs.files', t.inputs?.files, p.dir],
67
67
  ['inputs.workspaceFiles', t.inputs?.workspaceFiles, root],
68
- ['cache.outputs.files', t.cache?.outputs?.files, p.dir],
69
- ['cache.outputs.workspaceFiles', t.cache?.outputs?.workspaceFiles, root],
68
+ ['outputs.files', t.outputs?.files, p.dir],
69
+ ['outputs.workspaceFiles', t.outputs?.workspaceFiles, root],
70
70
  ]
71
71
  for (const [field, globs, base] of lists) {
72
72
  for (const glob of globs ?? []) {