@vzn/vx 0.0.632 → 0.0.634
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 +14 -10
- package/docs/caching.md +2250 -0
- package/docs/cli.md +3283 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +174 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2151 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/error.json +5 -1
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +4 -0
- package/skills/vx/SKILL.md +13 -3
- package/src/cache/cache.ts +556 -122
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +71 -30
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +173 -0
- package/src/cli/help.ts +14 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +44 -8
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/run.ts +8 -2
- package/src/cli/upgrade.ts +18 -6
- package/src/config.ts +9 -0
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +17 -4
- package/src/graph/task-graph.ts +56 -18
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +26 -8
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +30 -3
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (item 1020). A save that fails in EVERY layer still throws, so the
|
|
13
13
|
// caller's own "cache save failed" line stays the one word on it.
|
|
14
14
|
|
|
15
|
+
import { statSync } from 'node:fs'
|
|
15
16
|
import type {
|
|
16
17
|
Cache,
|
|
17
18
|
CacheEntry,
|
|
@@ -248,6 +249,14 @@ export class ChainedCache implements CacheLayer {
|
|
|
248
249
|
return this.owner(hash).outputsPath(hash)
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/** A layer that cannot say keeps a file at `outputsPath`, or nothing. */
|
|
253
|
+
artifactSize(hash: string): number | undefined {
|
|
254
|
+
const owner = this.owner(hash)
|
|
255
|
+
return owner.artifactSize !== undefined
|
|
256
|
+
? owner.artifactSize(hash)
|
|
257
|
+
: statSync(owner.outputsPath(hash), { throwIfNoEntry: false })?.size
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
prune(options: PruneOptions): Promise<PruneResult> {
|
|
252
261
|
return this.layers[0]!.prune(options)
|
|
253
262
|
}
|
package/src/cache/inputs.ts
CHANGED
|
@@ -92,6 +92,11 @@ const ALWAYS_IGNORE = [
|
|
|
92
92
|
// extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
|
|
93
93
|
// 60 MB mid-write under the same race (2026-09-29).
|
|
94
94
|
'**/.????????????????-????????.tmp/**',
|
|
95
|
+
// vx's own restore temp (archive.ts), live only while an artifact lands
|
|
96
|
+
// beside its target. A key taken meanwhile listed it, the rename removed
|
|
97
|
+
// it, and every task reading that directory was withheld its save
|
|
98
|
+
// (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
|
|
99
|
+
'**/.vx-tmp-*',
|
|
95
100
|
]
|
|
96
101
|
|
|
97
102
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
@@ -214,7 +219,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
214
219
|
// Dedupe: when the project dir IS the workspace root (or a glob
|
|
215
220
|
// overlaps), the same absolute path can arrive via both lists —
|
|
216
221
|
// it must contribute to the key exactly once.
|
|
217
|
-
if (wsFiles.length > 0) files =
|
|
222
|
+
if (wsFiles.length > 0) files = mergeSorted(projectFiles, wsFiles)
|
|
218
223
|
}
|
|
219
224
|
// Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
|
|
220
225
|
// run (profiled 2026-09-09); a task declaring neither skips the fan-out.
|
|
@@ -280,7 +285,6 @@ function resolveWorkspaceFiles(args: {
|
|
|
280
285
|
}
|
|
281
286
|
if (positive.length === 0) return undefined
|
|
282
287
|
|
|
283
|
-
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
284
288
|
// A path the task's own outputs take back with `!` is no output, so it
|
|
285
289
|
// stays an input (A-44).
|
|
286
290
|
const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
|
|
@@ -305,40 +309,44 @@ function resolveWorkspaceFiles(args: {
|
|
|
305
309
|
// The memo is valid for the snapshot it was computed over: a task that
|
|
306
310
|
// wrote workspace outputs mid-run replaces the partition, and the next
|
|
307
311
|
// caller sees a different array and scans again.
|
|
308
|
-
|
|
309
|
-
args.memo === undefined
|
|
310
|
-
? undefined
|
|
311
|
-
: JSON.stringify([
|
|
312
|
-
positive,
|
|
313
|
-
negative,
|
|
314
|
-
args.ownWorkspaceOutputs,
|
|
315
|
-
args.ownOutputs,
|
|
316
|
-
args.projectRel,
|
|
317
|
-
])
|
|
312
|
+
let isExcluded: ((rel: string) => boolean) | undefined
|
|
318
313
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
319
|
-
const
|
|
314
|
+
const excludedByDeclaration = (rel: string): boolean =>
|
|
315
|
+
(isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
316
|
+
const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
|
|
320
317
|
const listing: InputListing = {
|
|
321
318
|
root: args.workspaceRoot,
|
|
322
319
|
listed: gitFiles,
|
|
323
|
-
isInput: (rel) =>
|
|
320
|
+
isInput: (rel) =>
|
|
321
|
+
(isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
|
|
322
|
+
!excludedByDeclaration(rel) &&
|
|
323
|
+
!own(rel),
|
|
324
324
|
nested: () => false,
|
|
325
325
|
...reachOf(positive),
|
|
326
326
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
// Two levels: the declaration's files over the snapshot, shared by every
|
|
328
|
+
// task that declares it, then the task's own outputs taken out. A
|
|
329
|
+
// Turbo-mapped repo gives two thousand tasks one 400-entry list, and a
|
|
330
|
+
// memo keyed by the project too scanned, resolved and sorted the 4,000
|
|
331
|
+
// files it matched once per task (astro, 2026-10-09).
|
|
332
|
+
const declared = JSON.stringify([positive, negative])
|
|
333
|
+
let base: Promise<string[]> | undefined
|
|
334
|
+
const hit = args.memo?.get(declared)
|
|
335
|
+
if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
|
|
336
|
+
if (base === undefined) {
|
|
337
|
+
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
338
|
+
base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
|
|
339
|
+
args.memo?.set(declared, { snapshot: gitFiles, result: base })
|
|
330
340
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
args
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
341
|
-
return { files: result, listing }
|
|
341
|
+
const ownsAny =
|
|
342
|
+
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
343
|
+
splitNegations(args.ownOutputs).positive.length > 0
|
|
344
|
+
const files = base.then((all) => {
|
|
345
|
+
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
346
|
+
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
347
|
+
return kept
|
|
348
|
+
})
|
|
349
|
+
return { files, listing }
|
|
342
350
|
}
|
|
343
351
|
|
|
344
352
|
async function resolveWorkspaceFilesOver(
|
|
@@ -347,7 +355,6 @@ async function resolveWorkspaceFilesOver(
|
|
|
347
355
|
positive: readonly string[],
|
|
348
356
|
isPositive: (rel: string) => boolean,
|
|
349
357
|
excluded: (rel: string) => boolean,
|
|
350
|
-
undecodable: ReadonlySet<string> | undefined,
|
|
351
358
|
): Promise<string[]> {
|
|
352
359
|
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
353
360
|
// carries its own copy of the filter-over-git-set design, so the same
|
|
@@ -366,13 +373,47 @@ async function resolveWorkspaceFilesOver(
|
|
|
366
373
|
if (unmatchedLiterals.size > 0) {
|
|
367
374
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
368
375
|
}
|
|
369
|
-
refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
370
376
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
371
377
|
// tracked file necessarily exists on disk.
|
|
372
378
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
373
379
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
374
380
|
}
|
|
375
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The sorted union of two path lists, each once. Both sides arrive sorted
|
|
384
|
+
* and distinct, so one merge does what a Set and a sort of the
|
|
385
|
+
* concatenation did: 3.7 s of an astro plan, where 2,000 tasks each joined
|
|
386
|
+
* a few project files to the same 4,000 workspace files (2026-10-09). A
|
|
387
|
+
* side out of order takes the general path.
|
|
388
|
+
*/
|
|
389
|
+
function mergeSorted(a: readonly string[], b: readonly string[]): string[] {
|
|
390
|
+
if (a.length === 0 && isStrictlySorted(b)) return b as string[]
|
|
391
|
+
if (!isStrictlySorted(a) || !isStrictlySorted(b)) return [...new Set([...a, ...b])].sort()
|
|
392
|
+
const out: string[] = []
|
|
393
|
+
let i = 0
|
|
394
|
+
let j = 0
|
|
395
|
+
while (i < a.length && j < b.length) {
|
|
396
|
+
const x = a[i]!
|
|
397
|
+
const y = b[j]!
|
|
398
|
+
if (x <= y) {
|
|
399
|
+
out.push(x)
|
|
400
|
+
i++
|
|
401
|
+
if (x === y) j++
|
|
402
|
+
} else {
|
|
403
|
+
out.push(y)
|
|
404
|
+
j++
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
while (i < a.length) out.push(a[i++]!)
|
|
408
|
+
while (j < b.length) out.push(b[j++]!)
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isStrictlySorted(list: readonly string[]): boolean {
|
|
413
|
+
for (let i = 1; i < list.length; i++) if (!(list[i - 1]! < list[i]!)) return false
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
|
|
376
417
|
/** A project-relative matcher asked of root-relative paths: false outside the project. */
|
|
377
418
|
function underProject(
|
|
378
419
|
projectRel: string,
|
package/src/cache/layer.ts
CHANGED
|
@@ -267,7 +267,7 @@ export interface InvocationRecord {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/**
|
|
270
|
-
* One cache-key component
|
|
270
|
+
* One cache-key component of an `entry_inputs` row (v34: one row per entry) — keyed by
|
|
271
271
|
* the cache-entry HASH it belongs to, not a run. Written inside the
|
|
272
272
|
* entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
|
|
273
273
|
* cache HIT does not save, so it persists nothing (warm runs are free).
|
|
@@ -730,12 +730,16 @@ export interface CacheLayer {
|
|
|
730
730
|
*/
|
|
731
731
|
hashFile(filePath: string): Promise<string>
|
|
732
732
|
/**
|
|
733
|
-
* Absolute path
|
|
733
|
+
* Absolute path a file-backed artifact for a hash has —
|
|
734
734
|
* `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
|
|
735
|
-
* not the artifact exists
|
|
736
|
-
* `
|
|
735
|
+
* not the artifact exists: a small artifact lives in the index, not
|
|
736
|
+
* in a file (`artifactSize` is its size). Exposed for telemetry /
|
|
737
|
+
* dashboards; `restoreOutputs` is the canonical way to materialize
|
|
738
|
+
* the bytes.
|
|
737
739
|
*/
|
|
738
740
|
outputsPath(hash: string): string
|
|
741
|
+
/** The stored artifact's compressed size, wherever it lives; undefined when none is stored. */
|
|
742
|
+
artifactSize?(hash: string): number | undefined
|
|
739
743
|
prune(options: PruneOptions): Promise<PruneResult>
|
|
740
744
|
close(): void
|
|
741
745
|
}
|
|
@@ -529,6 +529,10 @@ export class LayeredCache implements CacheLayer {
|
|
|
529
529
|
return this.local.outputsPath(hash)
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
artifactSize(hash: string): number | undefined {
|
|
533
|
+
return this.local.artifactSize(hash)
|
|
534
|
+
}
|
|
535
|
+
|
|
532
536
|
hashFile(filePath: string): Promise<string> {
|
|
533
537
|
return this.local.hashFile(filePath)
|
|
534
538
|
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The index's tables, one concern: what a cache.db holds. `Cache` opens the
|
|
2
2
|
// file, checks `SCHEMA_VERSION` and creates these; every statement against
|
|
3
3
|
// them lives in the store that owns the table (file-hashes, config-evals,
|
|
4
|
-
// output-index, history) or in cache.ts for `entries` and
|
|
4
|
+
// output-index, history) or in cache.ts for `entries`, `entry_inputs` and
|
|
5
|
+
// `artifacts`.
|
|
5
6
|
|
|
6
7
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
8
|
|
|
@@ -255,22 +256,18 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
255
256
|
CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
|
|
256
257
|
CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
|
|
257
258
|
CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
|
|
258
|
-
-- v22 (Tier 3): the input-fingerprint moat
|
|
259
|
-
--
|
|
260
|
-
--
|
|
261
|
-
--
|
|
262
|
-
--
|
|
263
|
-
--
|
|
264
|
-
--
|
|
265
|
-
--
|
|
266
|
-
--
|
|
267
|
-
-- rows in sync with entries (a prune sweeps them automatically).
|
|
259
|
+
-- v22 (Tier 3): the input-fingerprint moat: each cache ENTRY's key
|
|
260
|
+
-- components (NOT a run's). Written inside the entry-save transaction,
|
|
261
|
+
-- only on a miss/save, so a warm all-cache-hit run writes nothing
|
|
262
|
+
-- here. v34: one row per entry, the components a JSON array of
|
|
263
|
+
-- [kind, name, hash] in (kind, name) code-unit order. A row per
|
|
264
|
+
-- component cost a b-tree insert each, and a task with 50 input
|
|
265
|
+
-- files spent ~0.5 ms a save on them; every reader loads the whole
|
|
266
|
+
-- set by entry hash and diffs it app-side anyway. ON DELETE CASCADE
|
|
267
|
+
-- keeps it in sync with entries (a prune sweeps it automatically).
|
|
268
268
|
CREATE TABLE IF NOT EXISTS ${store}.entry_inputs (
|
|
269
|
-
entry_hash TEXT
|
|
270
|
-
|
|
271
|
-
name TEXT NOT NULL,
|
|
272
|
-
hash TEXT NOT NULL,
|
|
273
|
-
PRIMARY KEY (entry_hash, kind, name),
|
|
269
|
+
entry_hash TEXT PRIMARY KEY,
|
|
270
|
+
components TEXT NOT NULL,
|
|
274
271
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
275
272
|
);
|
|
276
273
|
-- v32: what belongs to the entries rather than to one workspace: the
|
|
@@ -280,9 +277,38 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
280
277
|
key TEXT PRIMARY KEY,
|
|
281
278
|
value TEXT NOT NULL
|
|
282
279
|
);
|
|
280
|
+
${artifactTables(store)}
|
|
283
281
|
`)
|
|
284
282
|
}
|
|
285
283
|
|
|
284
|
+
/**
|
|
285
|
+
* v33: an artifact of at most `INLINE_MAX` compressed bytes lives here, its
|
|
286
|
+
* exact `<hash>.tar.zst` bytes, written in the transaction that writes its
|
|
287
|
+
* rows. No foreign key and outside every drop a `SCHEMA_VERSION` reset makes:
|
|
288
|
+
* the artifact is the record and the entries only its inventory, so a reset
|
|
289
|
+
* drops the rows and keeps these, as it keeps the files. `artifacts_meta`
|
|
290
|
+
* holds the table's own layout version (`'layout'`), the one thing that may
|
|
291
|
+
* drop it. `at` is the last use a row-less sweep judges, as a file's mtime;
|
|
292
|
+
* it sits before `bytes` so reading it never walks a blob's overflow pages.
|
|
293
|
+
*/
|
|
294
|
+
export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
|
|
295
|
+
db.exec(artifactTables(store))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// In the entry tables' exec, so an open runs no statement more for them.
|
|
299
|
+
function artifactTables(store: 'main' | 'store'): string {
|
|
300
|
+
return `
|
|
301
|
+
CREATE TABLE IF NOT EXISTS ${store}.artifacts (
|
|
302
|
+
hash TEXT PRIMARY KEY,
|
|
303
|
+
at INTEGER NOT NULL,
|
|
304
|
+
bytes BLOB NOT NULL
|
|
305
|
+
) WITHOUT ROWID;
|
|
306
|
+
CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
|
|
307
|
+
key TEXT PRIMARY KEY,
|
|
308
|
+
value TEXT NOT NULL
|
|
309
|
+
);`
|
|
310
|
+
}
|
|
311
|
+
|
|
286
312
|
/**
|
|
287
313
|
* A `WHERE <column> …` test against many hashes, as one statement whatever
|
|
288
314
|
* their number. A list of `?` is a new statement per length, compiled each
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The pages `vx docs` searches: core's reference, imported as text so the
|
|
2
|
+
// compiled binary carries them and an npm install reads them from the
|
|
3
|
+
// package (package.json `files`). Loaded only by `vx docs`.
|
|
4
|
+
import caching from '../../docs/caching.md' with { type: 'text' }
|
|
5
|
+
import cli from '../../docs/cli.md' with { type: 'text' }
|
|
6
|
+
import execution from '../../docs/execution.md' with { type: 'text' }
|
|
7
|
+
import features from '../../docs/features.md' with { type: 'text' }
|
|
8
|
+
import patterns from '../../docs/patterns.md' with { type: 'text' }
|
|
9
|
+
import schema from '../../docs/schema.md' with { type: 'text' }
|
|
10
|
+
import security from '../../docs/security.md' with { type: 'text' }
|
|
11
|
+
|
|
12
|
+
/** Page name (its file, no `.md`) → markdown. */
|
|
13
|
+
export const PAGES: Readonly<Record<string, string>> = {
|
|
14
|
+
cli,
|
|
15
|
+
schema,
|
|
16
|
+
caching,
|
|
17
|
+
execution,
|
|
18
|
+
patterns,
|
|
19
|
+
security,
|
|
20
|
+
features,
|
|
21
|
+
}
|
package/src/cli/docs.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
|
|
2
|
+
// reference offline, for an agent that cannot reach the site: each page is
|
|
3
|
+
// cut at its headings, and the sections holding every query word print
|
|
4
|
+
// whole, best first.
|
|
5
|
+
|
|
6
|
+
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
|
+
|
|
8
|
+
const SITE = 'https://vznjs.github.io/vx/'
|
|
9
|
+
const DEFAULT_LIMIT = 3
|
|
10
|
+
|
|
11
|
+
interface DocsArgs {
|
|
12
|
+
terms: string[]
|
|
13
|
+
limit: number
|
|
14
|
+
json: boolean
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseDocsArgs(args: readonly string[]): DocsArgs {
|
|
19
|
+
const out: DocsArgs = { terms: [], limit: DEFAULT_LIMIT, json: false }
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const a = args[i]!
|
|
22
|
+
if (a === '--format' || a.startsWith('--format=')) {
|
|
23
|
+
const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'docs')
|
|
24
|
+
if (typeof fv === 'object') return { ...out, error: fv.error }
|
|
25
|
+
out.json = fv === 'json'
|
|
26
|
+
} else if (a === '--limit' || a.startsWith('--limit=')) {
|
|
27
|
+
const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
|
|
28
|
+
const n = Number(v)
|
|
29
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
30
|
+
return {
|
|
31
|
+
...out,
|
|
32
|
+
error: `--limit takes a whole number above 0 (got ${v ?? 'nothing'})${seeHelp('docs')}`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
out.limit = n
|
|
36
|
+
} else if (a.startsWith('-')) {
|
|
37
|
+
return { ...out, error: `${refusedWord(a)}: ${a}${flagHint('docs', a)}${seeHelp('docs')}` }
|
|
38
|
+
} else out.terms.push(...words(a))
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DocsHit {
|
|
44
|
+
page: string
|
|
45
|
+
heading: string
|
|
46
|
+
url: string
|
|
47
|
+
text: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function words(s: string): string[] {
|
|
51
|
+
return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
|
|
55
|
+
function anchor(heading: string): string {
|
|
56
|
+
return heading
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^\p{L}\p{N} _-]/gu, '')
|
|
59
|
+
.replace(/ /g, '-')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A page's URL on the site; `features.md` is served as `all-features/`. */
|
|
63
|
+
function pageUrl(page: string): string {
|
|
64
|
+
return `${SITE}${page === 'features' ? 'all-features' : page}/`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Section {
|
|
68
|
+
page: string
|
|
69
|
+
heading: string
|
|
70
|
+
text: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Cut a page at its headings (outside code fences); the text before the first is the page's own. */
|
|
74
|
+
function sections(page: string, md: string): Section[] {
|
|
75
|
+
const out: Section[] = []
|
|
76
|
+
let heading = ''
|
|
77
|
+
let lines: string[] = []
|
|
78
|
+
let fence = false
|
|
79
|
+
const flush = () => {
|
|
80
|
+
const text = lines.join('\n').trim()
|
|
81
|
+
if (text !== '') out.push({ page, heading, text })
|
|
82
|
+
}
|
|
83
|
+
for (const line of md.split('\n')) {
|
|
84
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence
|
|
85
|
+
const h = fence ? null : /^#{1,4} (.+)$/.exec(line)
|
|
86
|
+
if (h !== null) {
|
|
87
|
+
flush()
|
|
88
|
+
heading = h[1]!.replace(/`/g, '').trim()
|
|
89
|
+
lines = [line]
|
|
90
|
+
} else lines.push(line)
|
|
91
|
+
}
|
|
92
|
+
flush()
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function count(hay: string, term: string): number {
|
|
97
|
+
let n = 0
|
|
98
|
+
for (let i = hay.indexOf(term); i !== -1; i = hay.indexOf(term, i + term.length)) n++
|
|
99
|
+
return n
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The sections holding every term, best first: a term in the heading
|
|
104
|
+
* outweighs any count in the text. A section titled by the one word asked
|
|
105
|
+
* (an error code) is the answer alone.
|
|
106
|
+
*/
|
|
107
|
+
export function searchDocs(
|
|
108
|
+
pages: Readonly<Record<string, string>>,
|
|
109
|
+
terms: readonly string[],
|
|
110
|
+
limit: number,
|
|
111
|
+
): DocsHit[] {
|
|
112
|
+
const scored: { s: Section; score: number; order: number }[] = []
|
|
113
|
+
let order = 0
|
|
114
|
+
for (const [page, md] of Object.entries(pages)) {
|
|
115
|
+
for (const s of sections(page, md)) {
|
|
116
|
+
order++
|
|
117
|
+
const head = s.heading.toLowerCase()
|
|
118
|
+
const body = s.text.toLowerCase()
|
|
119
|
+
let score = 0
|
|
120
|
+
let all = true
|
|
121
|
+
for (const t of terms) {
|
|
122
|
+
const inBody = count(body, t)
|
|
123
|
+
if (inBody === 0) all = false
|
|
124
|
+
score += (head.includes(t) ? 10 : 0) + Math.min(inBody, 5)
|
|
125
|
+
}
|
|
126
|
+
if (all) scored.push({ s, score, order })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
scored.sort((a, b) => b.score - a.score || a.order - b.order)
|
|
130
|
+
const exact =
|
|
131
|
+
terms.length === 1 ? scored.filter(({ s }) => words(s.heading).join(' ') === terms[0]) : []
|
|
132
|
+
return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
|
|
133
|
+
page: s.page,
|
|
134
|
+
heading: s.heading,
|
|
135
|
+
url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
|
|
136
|
+
text: s.text,
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function docsCmd(args: readonly string[]): Promise<number> {
|
|
141
|
+
const parsed = parseDocsArgs(args)
|
|
142
|
+
if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
|
|
143
|
+
if (parsed.terms.length === 0) {
|
|
144
|
+
return refuse(
|
|
145
|
+
'docs',
|
|
146
|
+
args,
|
|
147
|
+
`name what to look for, as in \`vx docs cache inputs\`${seeHelp('docs')}`,
|
|
148
|
+
'VX_E_USAGE',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const { PAGES } = await import('./docs-corpus.js')
|
|
152
|
+
const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
|
|
153
|
+
const query = parsed.terms.join(' ')
|
|
154
|
+
if (parsed.json) {
|
|
155
|
+
process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
|
|
156
|
+
return 0
|
|
157
|
+
}
|
|
158
|
+
if (hits.length === 0) {
|
|
159
|
+
process.stdout.write(
|
|
160
|
+
`vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
|
|
161
|
+
)
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
process.stdout.write(
|
|
165
|
+
hits
|
|
166
|
+
.map(
|
|
167
|
+
(h) =>
|
|
168
|
+
`── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
|
|
169
|
+
)
|
|
170
|
+
.join('\n'),
|
|
171
|
+
)
|
|
172
|
+
return 0
|
|
173
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -108,13 +108,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
108
108
|
' vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]',
|
|
109
109
|
' vx watch [OPTIONS] TASK [-- forwarded-args...]',
|
|
110
110
|
' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
111
|
-
' vx lock [--check]',
|
|
112
|
-
' vx init [--dry] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
|
|
111
|
+
' vx lock [--check] [--format pretty|json]',
|
|
112
|
+
' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
|
|
113
113
|
' vx upgrade [TAG]',
|
|
114
114
|
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
115
115
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
116
116
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
117
117
|
' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--format pretty|json] [--cache-dir <path>]',
|
|
118
|
+
' vx docs <query> [--limit N] [--format pretty|json]',
|
|
118
119
|
' vx completions bash|zsh|fish',
|
|
119
120
|
' vx help [VERB]',
|
|
120
121
|
' vx version',
|
|
@@ -241,6 +242,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
241
242
|
' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
|
|
242
243
|
' runs @vzn/vx-migrate, which asks native or keep.',
|
|
243
244
|
' --dry Print the generated files instead of writing them.',
|
|
245
|
+
' --format json With --dry: the plan as one JSON document (schemas/init.json).',
|
|
244
246
|
' --force Overwrite existing vx.config.* files.',
|
|
245
247
|
' --mjs Write .mjs files (no type import) instead of .ts.',
|
|
246
248
|
' --native Turbo or Nx: write a vx.config.ts per package (no question).',
|
|
@@ -256,6 +258,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
256
258
|
' every flag of each. `eval "$(vx completions bash)"`,',
|
|
257
259
|
' or write it where the shell loads completions from.',
|
|
258
260
|
'',
|
|
261
|
+
'Docs:',
|
|
262
|
+
' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
|
|
263
|
+
' patterns, security, features): the sections holding every',
|
|
264
|
+
' word of the query print whole, best first, with their URL;',
|
|
265
|
+
' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
|
|
266
|
+
' --limit N How many sections (default 3).',
|
|
267
|
+
' --format json {query, hits: [{page, heading, url, text}]}.',
|
|
268
|
+
'',
|
|
259
269
|
'Config lock:',
|
|
260
270
|
' vx lock Evaluate every vx.config in the current environment and',
|
|
261
271
|
' freeze the resolved objects into vx-lock.json. Plain runs',
|
|
@@ -264,7 +274,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
264
274
|
' vx lock --check Audit the lock: file-changed hash report PLUS a full',
|
|
265
275
|
' re-evaluation compared against the frozen objects. Exits 1',
|
|
266
276
|
' on drift (e.g. configs that read env vars). Run it before',
|
|
267
|
-
' `vx run --frozen` in CI.',
|
|
277
|
+
' `vx run --frozen` in CI. With --format json it prints',
|
|
278
|
+
' {upToDate, audited, notAudited, problems}.',
|
|
268
279
|
'',
|
|
269
280
|
...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
|
|
270
281
|
].join('\n')
|
package/src/cli/index.ts
CHANGED
|
@@ -109,6 +109,8 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
109
109
|
return await (await import('./cache.js')).cacheCmd(rest)
|
|
110
110
|
case 'lock':
|
|
111
111
|
return await (await import('./lock.js')).lockCmd(rest)
|
|
112
|
+
case 'docs':
|
|
113
|
+
return await (await import('./docs.js')).docsCmd(rest)
|
|
112
114
|
case 'init':
|
|
113
115
|
return await (await import('./init.js')).initCmd(rest)
|
|
114
116
|
case 'upgrade':
|