@intentius/chant 0.94.0 → 0.95.1
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/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -4
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +37 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +57 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +23 -7
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +12 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +17 -6
- package/src/cli/mcp/server.test.ts +14 -0
- package/src/cli/mcp/server.ts +10 -4
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +61 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/checks/boxes.test.ts +1 -0
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/declaration.schema.json +57 -1
- package/src/workspace/declaration.ts +104 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/reason-codes.ts +3 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +20 -12
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +31 -1
- package/src/workspace/status-stewards.ts +39 -6
- package/src/workspace/status.schema.json +49 -4
- package/src/workspace/status.ts +22 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-member cache behind `chant workspace graph` (#2876, ws-059).
|
|
3
|
+
*
|
|
4
|
+
* ws-018 made chant the composer for declared workspaces, and a viewer
|
|
5
|
+
* rereads the composed graph on every refresh. Without a cache every read
|
|
6
|
+
* starts every member's chant again, so this module keeps each member's
|
|
7
|
+
* source read on disk and serves it while nothing the read depends on has
|
|
8
|
+
* changed.
|
|
9
|
+
*
|
|
10
|
+
* An entry is served only when every part of its key still matches:
|
|
11
|
+
*
|
|
12
|
+
* 1. The read is a source read. A member command line holding `--live`,
|
|
13
|
+
* `--overlay` or `--traffic` observes an account, which changes with
|
|
14
|
+
* nothing on disk to notice, so it is never cached or even stamped.
|
|
15
|
+
* 2. The member's stamp ({@link memberStamp}) is unchanged. It covers the
|
|
16
|
+
* member's files and the install state its toolchain resolves through.
|
|
17
|
+
* With `--at` the tree is the commit's, which never changes, so the commit
|
|
18
|
+
* id stands in for the files.
|
|
19
|
+
* 3. The toolchain is unchanged: the real path of its `bin/chant` and its
|
|
20
|
+
* package version, plus its source files when it is a checkout rather
|
|
21
|
+
* than an install.
|
|
22
|
+
* 4. The member's command line and the ambient environment are unchanged,
|
|
23
|
+
* since `--env`, build parameters and `buildParams` env mappings all
|
|
24
|
+
* reach the answer. A few variables a shell changes on its own are left
|
|
25
|
+
* out ({@link VOLATILE_ENV}); any other difference is a miss.
|
|
26
|
+
*
|
|
27
|
+
* A stamp that can't be taken caches nothing. So does a stamp that moved
|
|
28
|
+
* between the start and the end of the read, a member whose read failed, and
|
|
29
|
+
* a working-tree read whose newest file is younger than
|
|
30
|
+
* {@link FRESH_WINDOW_MS}: some file systems keep mtimes to the second, so an
|
|
31
|
+
* edit in the same tick as the read could otherwise hide behind an equal
|
|
32
|
+
* stamp.
|
|
33
|
+
*
|
|
34
|
+
* Entries live outside the workspace, in the user's cache directory
|
|
35
|
+
* ({@link graphCacheDir}), because a read never changes the workspace it
|
|
36
|
+
* reads (the conformance kit checks exactly that, #2679). One JSON file per
|
|
37
|
+
* key, written to a temporary name and renamed into place so concurrent
|
|
38
|
+
* readers never see half an entry. Each workspace holds at most
|
|
39
|
+
* {@link MAX_ENTRIES} entries and {@link MAX_BYTES} bytes; the least recently
|
|
40
|
+
* used go first.
|
|
41
|
+
*
|
|
42
|
+
* The key never looks at how a member is read, only at what the read takes
|
|
43
|
+
* in, so a member read through a generated reader project caches by its own
|
|
44
|
+
* directory's stamp like any other.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
48
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { homedir } from "node:os";
|
|
50
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
51
|
+
import type { ParsedArgs } from "../cli/registry";
|
|
52
|
+
import { readMemberIr } from "./compose-graph";
|
|
53
|
+
import { memberArgv, type MemberPlan, type RunUnit, type Toolchain, type UnitResult } from "./member-commands";
|
|
54
|
+
|
|
55
|
+
/** The version of the entry format. An entry of any other version is a miss. */
|
|
56
|
+
export const CACHE_FORMAT = 1;
|
|
57
|
+
|
|
58
|
+
/** Overrides where chant keeps caches: `$CHANT_CACHE_DIR`, else `$XDG_CACHE_HOME/chant`, else `~/.cache/chant`. */
|
|
59
|
+
export const CACHE_DIR_ENV = "CHANT_CACHE_DIR";
|
|
60
|
+
|
|
61
|
+
export const MAX_ENTRIES = 512;
|
|
62
|
+
export const MAX_BYTES = 128 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** A working-tree read whose newest file is younger than this is not stored. */
|
|
65
|
+
export const FRESH_WINDOW_MS = 2000;
|
|
66
|
+
|
|
67
|
+
/** Member flags that make a read observe an account. Such a read is never cached. */
|
|
68
|
+
export const LIVE_FLAGS = ["--live", "--overlay", "--traffic"] as const;
|
|
69
|
+
|
|
70
|
+
/** Environment variables a shell or terminal changes on its own, left out of the key. */
|
|
71
|
+
export const VOLATILE_ENV: readonly (string | RegExp)[] = [
|
|
72
|
+
"_",
|
|
73
|
+
"PWD",
|
|
74
|
+
"OLDPWD",
|
|
75
|
+
"SHLVL",
|
|
76
|
+
"COLUMNS",
|
|
77
|
+
"LINES",
|
|
78
|
+
"WINDOWID",
|
|
79
|
+
/^TERM/,
|
|
80
|
+
/^ITERM_/,
|
|
81
|
+
/^TMUX/,
|
|
82
|
+
/^SSH_/,
|
|
83
|
+
/^VSCODE_/,
|
|
84
|
+
/^npm_/,
|
|
85
|
+
/^VITEST/,
|
|
86
|
+
/^__/,
|
|
87
|
+
CACHE_DIR_ENV,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** Directories no stamp walks into, whatever their depth. */
|
|
91
|
+
const SKIPPED = new Set(["node_modules", "dist", ".git"]);
|
|
92
|
+
|
|
93
|
+
/** Files, looked up from a member's directory to the file-system root, whose change means the install moved. */
|
|
94
|
+
const INSTALL_FILES = [
|
|
95
|
+
join("node_modules", ".package-lock.json"),
|
|
96
|
+
"package-lock.json",
|
|
97
|
+
"npm-shrinkwrap.json",
|
|
98
|
+
"yarn.lock",
|
|
99
|
+
"pnpm-lock.yaml",
|
|
100
|
+
"bun.lock",
|
|
101
|
+
"bun.lockb",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** Whether a member command line is a source read, and so may be cached. */
|
|
105
|
+
export function isCacheableArgv(argv: readonly string[]): boolean {
|
|
106
|
+
return !argv.some((a) => LIVE_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sha256(text: string): string {
|
|
110
|
+
return createHash("sha256").update(text).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface Stamp {
|
|
114
|
+
/** `sha256:<hex>`. */
|
|
115
|
+
value: string;
|
|
116
|
+
/** The newest mtime among the files stamped, in ms; 0 for a revision stamp. */
|
|
117
|
+
newest: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every regular file under `abs`, as `<relative path>\0<mtime ms>\0<size>`
|
|
122
|
+
* lines sorted by path. Directories named `node_modules`, `dist` or `.git`,
|
|
123
|
+
* every dot-directory, and the directories in `exclude` (relative to `abs`)
|
|
124
|
+
* are left out. Throws when the directory can't be read.
|
|
125
|
+
*/
|
|
126
|
+
function fileLines(abs: string, exclude: readonly string[]): { lines: string[]; newest: number } {
|
|
127
|
+
const excluded = new Set(exclude.map((e) => resolve(abs, e)));
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
let newest = 0;
|
|
130
|
+
const walk = (at: string): void => {
|
|
131
|
+
for (const e of readdirSync(at, { withFileTypes: true })) {
|
|
132
|
+
const path = join(at, e.name);
|
|
133
|
+
if (e.isDirectory()) {
|
|
134
|
+
if (SKIPPED.has(e.name) || e.name.startsWith(".") || excluded.has(path)) continue;
|
|
135
|
+
walk(path);
|
|
136
|
+
} else if (e.isFile()) {
|
|
137
|
+
const st = statSync(path);
|
|
138
|
+
lines.push(`${relative(abs, path).split(sep).join("/")}\0${st.mtimeMs}\0${st.size}`);
|
|
139
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
walk(abs);
|
|
144
|
+
lines.sort();
|
|
145
|
+
return { lines, newest };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The install files found from `abs` up to the file-system root, with their mtime and size. */
|
|
149
|
+
function installLines(abs: string): string[] {
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
for (let dir = resolve(abs); ; dir = dirname(dir)) {
|
|
152
|
+
for (const name of INSTALL_FILES) {
|
|
153
|
+
const path = join(dir, name);
|
|
154
|
+
try {
|
|
155
|
+
const st = statSync(path);
|
|
156
|
+
if (st.isFile()) lines.push(`${path}\0${st.mtimeMs}\0${st.size}`);
|
|
157
|
+
} catch {
|
|
158
|
+
// Not here.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (dirname(dir) === dir) break;
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A member's stamp: its files and the install state around it. `abs` is the
|
|
168
|
+
* member's directory on disk, `exclude` the directories inside it that are
|
|
169
|
+
* other members' (set for member `.`). With `at`, the commit id stands in for
|
|
170
|
+
* the files, since a commit's tree never changes. Returns `undefined` when the
|
|
171
|
+
* stamp can't be taken, and then nothing is cached.
|
|
172
|
+
*/
|
|
173
|
+
export function memberStamp(abs: string, exclude: readonly string[] = [], at?: { commit: string; dir: string }): Stamp | undefined {
|
|
174
|
+
try {
|
|
175
|
+
const install = installLines(abs);
|
|
176
|
+
if (at) return { value: `sha256:${sha256(["at", at.commit, at.dir, ...install].join("\n"))}`, newest: 0 };
|
|
177
|
+
const { lines, newest } = fileLines(abs, exclude);
|
|
178
|
+
return { value: `sha256:${sha256(["files", ...lines, "install", ...install].join("\n"))}`, newest };
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What distinguishes one chant from another for the key: the real path of its
|
|
186
|
+
* `bin/chant`, the version its package declares, and, for a chant that isn't
|
|
187
|
+
* installed under a `node_modules` directory (a source checkout), the stamp
|
|
188
|
+
* of its package's `src`, since a checkout changes without a version bump.
|
|
189
|
+
*/
|
|
190
|
+
export function toolchainStamp(toolchain: Toolchain): string {
|
|
191
|
+
const pkgDir = dirname(dirname(toolchain.identity));
|
|
192
|
+
let version = "";
|
|
193
|
+
try {
|
|
194
|
+
version = String((JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")) as { version?: unknown }).version ?? "");
|
|
195
|
+
} catch {
|
|
196
|
+
// A bin outside a package: its path alone names it.
|
|
197
|
+
}
|
|
198
|
+
const installed = toolchain.identity.split(sep).includes("node_modules");
|
|
199
|
+
let source = "";
|
|
200
|
+
if (!installed && existsSync(join(pkgDir, "src"))) {
|
|
201
|
+
source = memberStamp(join(pkgDir, "src"))?.value ?? `unstamped:${Date.now()}`;
|
|
202
|
+
}
|
|
203
|
+
return `${toolchain.identity}\0${version}\0${source}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What a kind's reader adds to the key: its graph block, and the installed version of the package supplying it. */
|
|
207
|
+
export function readerStamp(reader: { lexicon: string; config: unknown; packageDir: string }): string {
|
|
208
|
+
let version = "";
|
|
209
|
+
try {
|
|
210
|
+
version = String((JSON.parse(readFileSync(join(reader.packageDir, "package.json"), "utf-8")) as { version?: unknown }).version ?? "");
|
|
211
|
+
} catch {
|
|
212
|
+
// An unreadable manifest keys on the directory alone.
|
|
213
|
+
}
|
|
214
|
+
return JSON.stringify([reader.lexicon, reader.config, reader.packageDir, version]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The ambient environment, less {@link VOLATILE_ENV}, as one digest. */
|
|
218
|
+
export function environmentStamp(env: NodeJS.ProcessEnv = process.env): string {
|
|
219
|
+
const keep = Object.keys(env)
|
|
220
|
+
.filter((k) => !VOLATILE_ENV.some((v) => (typeof v === "string" ? v === k : v.test(k))))
|
|
221
|
+
.sort();
|
|
222
|
+
return sha256(keep.map((k) => `${k}=${env[k] ?? ""}`).join("\0"));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface KeyParts {
|
|
226
|
+
member: string;
|
|
227
|
+
dir: string;
|
|
228
|
+
stamp: string;
|
|
229
|
+
toolchain: string;
|
|
230
|
+
argv: readonly string[];
|
|
231
|
+
env: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function cacheKey(parts: KeyParts): string {
|
|
235
|
+
return sha256(JSON.stringify([CACHE_FORMAT, parts.member, parts.dir, parts.stamp, parts.toolchain, parts.argv, parts.env]));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One stored read. */
|
|
239
|
+
export interface CacheEntry {
|
|
240
|
+
format: number;
|
|
241
|
+
key: string;
|
|
242
|
+
member: string;
|
|
243
|
+
stamp: string;
|
|
244
|
+
/** The chant version the read reported, or null. */
|
|
245
|
+
chant: string | null;
|
|
246
|
+
/** The member's `chant graph --format ir` output. */
|
|
247
|
+
stdout: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface GraphCache {
|
|
251
|
+
dir: string;
|
|
252
|
+
get(key: string): CacheEntry | undefined;
|
|
253
|
+
put(entry: CacheEntry): void;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The directory holding the cache of the workspace rooted at `root`:
|
|
258
|
+
* `<cache dir>/workspace-graph/<first 16 hex digits of sha256(real path of root)>`,
|
|
259
|
+
* where the cache dir is `$CHANT_CACHE_DIR`, else `$XDG_CACHE_HOME/chant`,
|
|
260
|
+
* else `~/.cache/chant`.
|
|
261
|
+
*/
|
|
262
|
+
export function graphCacheDir(root: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
263
|
+
const base = env[CACHE_DIR_ENV] || join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "chant");
|
|
264
|
+
let real = resolve(root);
|
|
265
|
+
try {
|
|
266
|
+
real = realpathSync(real);
|
|
267
|
+
} catch {
|
|
268
|
+
// A root that can't be resolved keys on the path as given.
|
|
269
|
+
}
|
|
270
|
+
return join(base, "workspace-graph", sha256(real).slice(0, 16));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The cache for the workspace rooted at `root` (absolute, on disk). Nothing is
|
|
275
|
+
* created until the first `put`. Every failure to read or write is a miss or
|
|
276
|
+
* a skipped store, never an error: the cache can only make a read faster.
|
|
277
|
+
*/
|
|
278
|
+
export function openGraphCache(root: string, env: NodeJS.ProcessEnv = process.env): GraphCache {
|
|
279
|
+
const dir = graphCacheDir(root, env);
|
|
280
|
+
const file = (key: string) => join(dir, `${key}.json`);
|
|
281
|
+
return {
|
|
282
|
+
dir,
|
|
283
|
+
get(key) {
|
|
284
|
+
try {
|
|
285
|
+
const entry = JSON.parse(readFileSync(file(key), "utf-8")) as CacheEntry;
|
|
286
|
+
if (entry.format !== CACHE_FORMAT || entry.key !== key || typeof entry.stdout !== "string") return undefined;
|
|
287
|
+
const now = new Date();
|
|
288
|
+
utimesSync(file(key), now, now);
|
|
289
|
+
return entry;
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
put(entry) {
|
|
295
|
+
try {
|
|
296
|
+
mkdirSync(dir, { recursive: true });
|
|
297
|
+
const tmp = join(dir, `.${entry.key}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
298
|
+
writeFileSync(tmp, JSON.stringify(entry));
|
|
299
|
+
renameSync(tmp, file(entry.key));
|
|
300
|
+
prune(dir);
|
|
301
|
+
} catch {
|
|
302
|
+
// A cache that can't be written costs the next read a run, nothing more.
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Drop the least recently used entries until the bounds hold. */
|
|
309
|
+
function prune(dir: string): void {
|
|
310
|
+
const entries = readdirSync(dir)
|
|
311
|
+
.filter((n) => n.endsWith(".json") && !n.startsWith("."))
|
|
312
|
+
.map((n) => {
|
|
313
|
+
try {
|
|
314
|
+
const st = statSync(join(dir, n));
|
|
315
|
+
return { path: join(dir, n), mtime: st.mtimeMs, size: st.size };
|
|
316
|
+
} catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
.filter((e): e is { path: string; mtime: number; size: number } => e !== undefined)
|
|
321
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
322
|
+
let bytes = 0;
|
|
323
|
+
entries.forEach((e, i) => {
|
|
324
|
+
bytes += e.size;
|
|
325
|
+
if (i >= MAX_ENTRIES || bytes > MAX_BYTES) rmSync(e.path, { force: true });
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Reading a plan through the cache ─────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/** A member read the cache answered, or one it will store once it has run. */
|
|
332
|
+
interface Pending {
|
|
333
|
+
key: string;
|
|
334
|
+
stamp: Stamp;
|
|
335
|
+
abs: string;
|
|
336
|
+
exclude: string[];
|
|
337
|
+
at?: { commit: string; dir: string };
|
|
338
|
+
member: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface CacheSplit {
|
|
342
|
+
/** The members the cache answered, as results a run would have produced. */
|
|
343
|
+
hits: UnitResult[];
|
|
344
|
+
/** Each member's stamp, by unit id: served or about to be read. */
|
|
345
|
+
stamps: Map<string, string>;
|
|
346
|
+
pending: Map<string, Pending>;
|
|
347
|
+
/** When the lookup started; a file younger than this less {@link FRESH_WINDOW_MS} keeps a read out of the cache. */
|
|
348
|
+
started: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Look every member of a `graph` plan up in the cache. `at` is the commit
|
|
353
|
+
* being read, or null for the working tree. Members with a cacheable command
|
|
354
|
+
* line whose stamp can be taken end up in `hits` or in `pending`.
|
|
355
|
+
*/
|
|
356
|
+
export function splitCached(plan: MemberPlan, args: ParsedArgs, cache: GraphCache, at: string | null, argvFor: (unit: RunUnit) => string[] = (u) => memberArgv("graph", u, args)): CacheSplit {
|
|
357
|
+
const started = Date.now();
|
|
358
|
+
const env = environmentStamp();
|
|
359
|
+
const hits: UnitResult[] = [];
|
|
360
|
+
const stamps = new Map<string, string>();
|
|
361
|
+
const pending = new Map<string, Pending>();
|
|
362
|
+
for (const group of plan.groups) {
|
|
363
|
+
const tool = toolchainStamp(group.toolchain);
|
|
364
|
+
for (const unit of group.units) {
|
|
365
|
+
if (unit.group) continue;
|
|
366
|
+
const argv = argvFor(unit);
|
|
367
|
+
if (!isCacheableArgv(argv)) continue;
|
|
368
|
+
// A member read through a generated reader project (#2874) is also keyed on
|
|
369
|
+
// the kind's graph block and the version of the package that reads it.
|
|
370
|
+
const reader = unit.reader ? readerStamp(unit.reader) : "";
|
|
371
|
+
const abs = unit.dir === "." ? plan.workspace.root : join(plan.workspace.root, ...unit.dir.split("/"));
|
|
372
|
+
const revision = at ? { commit: at, dir: unit.dir } : undefined;
|
|
373
|
+
const stamp = memberStamp(abs, unit.exclude, revision);
|
|
374
|
+
if (!stamp) continue;
|
|
375
|
+
stamps.set(unit.id, stamp.value);
|
|
376
|
+
const key = cacheKey({ member: unit.member, dir: unit.dir, stamp: stamp.value, toolchain: `${tool}\0${reader}`, argv, env });
|
|
377
|
+
const entry = cache.get(key);
|
|
378
|
+
if (entry) {
|
|
379
|
+
hits.push({ unit, toolchain: group.toolchain, chant: entry.chant, mode: "member-run", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, exitCode: 0, stdout: entry.stdout, stderr: "" });
|
|
380
|
+
} else {
|
|
381
|
+
pending.set(unit.id, { key, stamp, abs, exclude: unit.exclude, ...(revision ? { at: revision } : {}), member: unit.member });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { hits, stamps, pending, started };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The plan without the units in `ids`; a toolchain left with no units is dropped. */
|
|
389
|
+
export function withoutUnits(plan: MemberPlan, ids: ReadonlySet<string>): MemberPlan {
|
|
390
|
+
if (ids.size === 0) return plan;
|
|
391
|
+
const groups = plan.groups.map((g) => ({ ...g, units: g.units.filter((u) => !ids.has(u.id)) })).filter((g) => g.units.length > 0);
|
|
392
|
+
return { ...plan, groups };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Store the reads that may be cached: the member exited 0, printed an IR, and
|
|
397
|
+
* its stamp is the same after the read as before it. A working-tree read of a
|
|
398
|
+
* file younger than the window is not stored.
|
|
399
|
+
*/
|
|
400
|
+
export function storeReads(split: CacheSplit, results: readonly UnitResult[], cache: GraphCache): void {
|
|
401
|
+
for (const r of results) {
|
|
402
|
+
const p = split.pending.get(r.id);
|
|
403
|
+
if (!p || r.exitCode !== 0 || "reason" in readMemberIr(r.stdout)) continue;
|
|
404
|
+
if (!p.at && p.stamp.newest > split.started - FRESH_WINDOW_MS) continue;
|
|
405
|
+
const after = memberStamp(p.abs, p.exclude, p.at);
|
|
406
|
+
if (!after || after.value !== p.stamp.value) continue;
|
|
407
|
+
cache.put({ format: CACHE_FORMAT, key: p.key, member: p.member, stamp: p.stamp.value, chant: r.chant, stdout: r.stdout });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -30,10 +30,12 @@ import { tmpdir } from "node:os";
|
|
|
30
30
|
import { dirname, join, resolve } from "node:path";
|
|
31
31
|
import { formatError } from "../cli/format";
|
|
32
32
|
import type { CommandContext, ParsedArgs } from "../cli/registry";
|
|
33
|
-
import { composeWorkspaceGraph, readMemberIr, type ComposeInput, type WorkspaceGraph } from "./compose-graph";
|
|
34
|
-
import {
|
|
33
|
+
import { composeWorkspaceGraph, readMemberIr, type ComposedMember, type ComposeInput, type WorkspaceGraph } from "./compose-graph";
|
|
34
|
+
import { openGraphCache, splitCached, storeReads, withoutUnits } from "./graph-cache";
|
|
35
|
+
import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type ErrorLocation } from "./declaration";
|
|
35
36
|
import { describePlan, emitDocument, executePlan, memberStatus, planJson, planMembers, type MemberPlan, type Toolchain, type UnitResult } from "./member-commands";
|
|
36
37
|
import { loadKindRegistry } from "./kinds";
|
|
38
|
+
import { prepareKindReaders, type PreparedReaders } from "./kind-readers";
|
|
37
39
|
import { recordLinkRows } from "./record-assets";
|
|
38
40
|
import { RecordReadError } from "./records";
|
|
39
41
|
import { workingTree } from "./tree";
|
|
@@ -45,11 +47,18 @@ export const GRAPH_CONTRACT_VERSION = 1;
|
|
|
45
47
|
/** `$id` of the JSON Schema for the document, shipped beside this file. */
|
|
46
48
|
export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/graph/v1/graph.schema.json";
|
|
47
49
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Why the graph couldn't be read at all: the declaration's codes, `--at`'s
|
|
52
|
+
* included, and `live-at-revision` for `--live` with `--at` (#2875).
|
|
53
|
+
*/
|
|
54
|
+
export const GRAPH_ERROR_CODES = [...WORKSPACE_ERROR_CODES, "live-at-revision"] as const;
|
|
55
|
+
export type GraphErrorCode = (typeof GRAPH_ERROR_CODES)[number];
|
|
56
|
+
|
|
57
|
+
/** Why `--live` and `--at` don't go together (#2875). */
|
|
58
|
+
const LIVE_AT_REVISION = "--live reads the account as it stands now, and --at reads the source at a revision; chant workspace graph takes one of them";
|
|
50
59
|
|
|
51
60
|
const USAGE =
|
|
52
|
-
"chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
61
|
+
"chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--live [--overlay] [--traffic <level>]] [--no-cache] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
53
62
|
|
|
54
63
|
interface Head {
|
|
55
64
|
$schema: string;
|
|
@@ -59,7 +68,7 @@ interface Head {
|
|
|
59
68
|
|
|
60
69
|
export type GraphDocument =
|
|
61
70
|
| (Head & { at: string | null } & WorkspaceGraph)
|
|
62
|
-
| (Head & { error: { code:
|
|
71
|
+
| (Head & { error: { code: GraphErrorCode; message: string; location: ErrorLocation | null } });
|
|
63
72
|
|
|
64
73
|
export interface GraphQuery {
|
|
65
74
|
/** Where the walk up to the declaration starts. */
|
|
@@ -67,7 +76,7 @@ export interface GraphQuery {
|
|
|
67
76
|
at?: string;
|
|
68
77
|
/** Only these members. */
|
|
69
78
|
members?: string[];
|
|
70
|
-
/** The flags each member's `chant graph` gets (`--env`). */
|
|
79
|
+
/** The flags each member's `chant graph` gets (`--env`, and `--live`, `--overlay` and `--traffic`, #2875). */
|
|
71
80
|
args?: Partial<ParsedArgs>;
|
|
72
81
|
/** The chant for members with none of their own; the running chant by default. */
|
|
73
82
|
reader?: Toolchain;
|
|
@@ -92,6 +101,8 @@ export interface GraphQuery {
|
|
|
92
101
|
* here (#2674).
|
|
93
102
|
*/
|
|
94
103
|
inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
|
|
104
|
+
/** Read every member, leaving the per-member cache unread and unwritten (`--no-cache`, #2876). */
|
|
105
|
+
noCache?: boolean;
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
export interface GraphResult {
|
|
@@ -149,7 +160,21 @@ export function exportRevision(located: LocatedWorkspace, memberDirs: string[]):
|
|
|
149
160
|
return out;
|
|
150
161
|
}
|
|
151
162
|
|
|
152
|
-
function compose(
|
|
163
|
+
function compose(
|
|
164
|
+
plan: MemberPlan,
|
|
165
|
+
results: UnitResult[],
|
|
166
|
+
only: string[] | undefined,
|
|
167
|
+
declarationMembers: { name: string; dir: string; kind: string }[],
|
|
168
|
+
live = false,
|
|
169
|
+
): { inputs: ComposeInput[]; failed: boolean } {
|
|
170
|
+
// A member that ran was read live when --live was given, and says when (#2875).
|
|
171
|
+
const read = (member: ComposedMember, r: UnitResult): ComposedMember => {
|
|
172
|
+
if (live) {
|
|
173
|
+
member.live = true;
|
|
174
|
+
if (r.finishedAt) member.readAt = r.finishedAt;
|
|
175
|
+
}
|
|
176
|
+
return member;
|
|
177
|
+
};
|
|
153
178
|
const byId = new Map(results.map((r) => [r.id, r]));
|
|
154
179
|
const inputs: ComposeInput[] = [];
|
|
155
180
|
let failed = plan.unreadable.length > 0;
|
|
@@ -165,18 +190,18 @@ function compose(plan: MemberPlan, results: UnitResult[], only: string[] | undef
|
|
|
165
190
|
if (r.exitCode !== 0) {
|
|
166
191
|
failed = true;
|
|
167
192
|
const tail = r.stderr.trim().split("\n").slice(-5).join("\n");
|
|
168
|
-
inputs.push({ member: memberStatus(m.name, m.dir, m.kind, "failed", { code: "command-failed", message: `chant graph exited ${r.exitCode}${tail ? `: ${tail}` : ""}` }, r.chant) });
|
|
193
|
+
inputs.push({ member: read(memberStatus(m.name, m.dir, m.kind, "failed", { code: "command-failed", message: `chant graph exited ${r.exitCode}${tail ? `: ${tail}` : ""}` }, r.chant), r) });
|
|
169
194
|
continue;
|
|
170
195
|
}
|
|
171
|
-
const
|
|
172
|
-
if ("reason" in
|
|
196
|
+
const ir = readMemberIr(r.stdout);
|
|
197
|
+
if ("reason" in ir) {
|
|
173
198
|
failed = true;
|
|
174
|
-
inputs.push({ member: memberStatus(m.name, m.dir, m.kind, "failed",
|
|
199
|
+
inputs.push({ member: read(memberStatus(m.name, m.dir, m.kind, "failed", ir.reason, r.chant), r) });
|
|
175
200
|
continue;
|
|
176
201
|
}
|
|
177
|
-
const member = memberStatus(m.name, m.dir, m.kind, "composed", null, r.chant);
|
|
178
|
-
member.irVersion =
|
|
179
|
-
inputs.push({ member, ir:
|
|
202
|
+
const member = read(memberStatus(m.name, m.dir, m.kind, "composed", null, r.chant), r);
|
|
203
|
+
member.irVersion = ir.irVersion;
|
|
204
|
+
inputs.push({ member, ir: ir.ir });
|
|
180
205
|
}
|
|
181
206
|
return { inputs, failed };
|
|
182
207
|
}
|
|
@@ -184,33 +209,54 @@ function compose(plan: MemberPlan, results: UnitResult[], only: string[] | undef
|
|
|
184
209
|
/** Plan a graph read without running anything, for `--dry-run`. Throws a {@link WorkspaceReadError}. */
|
|
185
210
|
export function planGraph(query: GraphQuery): MemberPlan {
|
|
186
211
|
const located = locateWorkspace(query.cwd, query.at);
|
|
187
|
-
readDeclaration(located.tree, "", { rootChant: true });
|
|
188
|
-
|
|
212
|
+
const declaration = readDeclaration(located.tree, "", { rootChant: true });
|
|
213
|
+
const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
|
|
214
|
+
return planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree, kinds });
|
|
189
215
|
}
|
|
190
216
|
|
|
191
217
|
/** Read and compose the graph, and build the document. Never throws a {@link WorkspaceReadError}. */
|
|
192
218
|
export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
|
|
193
219
|
const head: Head = { $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: GRAPH_CONTRACT_VERSION, chant: readerVersion() };
|
|
194
220
|
let exported: string | undefined;
|
|
221
|
+
let readers: PreparedReaders | undefined;
|
|
222
|
+
if (query.args?.live && query.at !== undefined) {
|
|
223
|
+
return { doc: { ...head, error: { code: "live-at-revision", message: LIVE_AT_REVISION, location: null } }, failed: true };
|
|
224
|
+
}
|
|
195
225
|
try {
|
|
196
226
|
const located = locateWorkspace(query.cwd, query.at);
|
|
197
227
|
const declaration = readDeclaration(located.tree, "", { rootChant: true });
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported) });
|
|
202
|
-
}
|
|
228
|
+
// Kinds are read before planning: a package kind with a graph block runs (#2874).
|
|
229
|
+
const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
|
|
230
|
+
let plan = planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree, kinds });
|
|
203
231
|
const args = (query.args ?? {}) as ParsedArgs;
|
|
204
|
-
|
|
205
|
-
|
|
232
|
+
// Members whose last source read still stands are answered from the cache (#2876).
|
|
233
|
+
// A composites read runs every member's component graph as well, so it reads everything.
|
|
234
|
+
const cache = query.noCache || query.components ? undefined : openGraphCache(located.rootOnDisk);
|
|
235
|
+
const split = cache ? splitCached(plan, args, cache, located.at) : undefined;
|
|
236
|
+
const served = new Set(split?.hits.map((h) => h.id));
|
|
237
|
+
if (located.at !== null && withoutUnits(plan, served).groups.length > 0) {
|
|
238
|
+
exported = exportRevision(located, withoutUnits(plan, served).groups.flatMap((g) => g.units.map((u) => u.dir)));
|
|
239
|
+
plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported), kinds });
|
|
240
|
+
}
|
|
241
|
+
// Only the members that run get a reader project; a served member needs none.
|
|
242
|
+
const toRun = withoutUnits(plan, served);
|
|
243
|
+
readers = prepareKindReaders(toRun, exported ?? located.rootOnDisk);
|
|
244
|
+
const [ran, components] = await Promise.all([
|
|
245
|
+
executePlan(toRun, args),
|
|
206
246
|
query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
|
|
207
247
|
]);
|
|
248
|
+
if (cache && split) storeReads(split, ran, cache);
|
|
249
|
+
const results = [...(split?.hits ?? []), ...ran];
|
|
208
250
|
if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
|
|
209
251
|
for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
210
252
|
for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
211
|
-
const { inputs, failed } = compose(plan, results, query.members, declaration.members);
|
|
253
|
+
const { inputs, failed } = compose(plan, results, query.members, declaration.members, !!args.live);
|
|
254
|
+
for (const { member } of inputs) {
|
|
255
|
+
if (member.status !== "composed") continue;
|
|
256
|
+
member.cached = served.has(member.name);
|
|
257
|
+
member.stamp = split?.stamps.get(member.name) ?? null;
|
|
258
|
+
}
|
|
212
259
|
// Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
|
|
213
|
-
const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
|
|
214
260
|
const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds });
|
|
215
261
|
let recordsFailed = false;
|
|
216
262
|
if (query.kind !== undefined) {
|
|
@@ -233,6 +279,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
|
|
|
233
279
|
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
234
280
|
return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
|
|
235
281
|
} finally {
|
|
282
|
+
readers?.cleanup();
|
|
236
283
|
if (exported) rmSync(dirname(exported), { recursive: true, force: true });
|
|
237
284
|
}
|
|
238
285
|
}
|
|
@@ -257,6 +304,25 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
|
|
|
257
304
|
if (!args.dryRun) return (await import("./composites")).runWorkspaceComposites(ctx, cwd);
|
|
258
305
|
}
|
|
259
306
|
|
|
307
|
+
// The live read (#2875): the flags mean what they mean to a member's own
|
|
308
|
+
// `chant graph`, where --overlay and --traffic only act on a live read and
|
|
309
|
+
// --live needs an environment. Here they are refused rather than ignored.
|
|
310
|
+
if (!args.composites && args.intent === undefined) {
|
|
311
|
+
if ((args.overlay || args.traffic !== undefined) && !args.live) {
|
|
312
|
+
console.error(formatError({ message: "--overlay and --traffic act on a live read; add --live --env <env>", hint: USAGE }));
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
if (args.live && args.at !== undefined) {
|
|
316
|
+
emitDocument({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: GRAPH_CONTRACT_VERSION, chant: readerVersion(), error: { code: "live-at-revision", message: LIVE_AT_REVISION, location: null } }, args.output);
|
|
317
|
+
console.error(formatError({ message: `live-at-revision: ${LIVE_AT_REVISION}`, hint: USAGE }));
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
if (args.live && !args.env) {
|
|
321
|
+
console.error(formatError({ message: "--live needs an environment, as each member's chant graph --live does: --live --env <env>", hint: USAGE }));
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
260
326
|
// The intent graph over one region (#2651) is its own document.
|
|
261
327
|
if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
|
|
262
328
|
|
|
@@ -278,6 +344,7 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
|
|
|
278
344
|
at: args.at,
|
|
279
345
|
members: args.members,
|
|
280
346
|
args,
|
|
347
|
+
...(args.noCache ? { noCache: true } : {}),
|
|
281
348
|
...(args.kind !== undefined ? { kind: resolve(args.kind) } : {}),
|
|
282
349
|
onStderr: (text) => process.stderr.write(text),
|
|
283
350
|
});
|