balladeer 0.0.4 → 1.0.0
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/LICENSE +200 -5
- package/README.md +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +395 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
/**
|
|
6
|
+
* The touch map: which files each promise's verifier actually executed.
|
|
7
|
+
*
|
|
8
|
+
* A promise's scope surfaces are what somebody wrote down when they agreed it.
|
|
9
|
+
* They are a guess about which files matter, they age, and nobody rewrites them
|
|
10
|
+
* when a module moves. What a verifier executed is not a guess: it is the file
|
|
11
|
+
* list V8 recorded while the check ran. So an agent asking "which promises does
|
|
12
|
+
* this change touch" gets an answer measured from the repository in front of it
|
|
13
|
+
* rather than one somebody typed months ago.
|
|
14
|
+
*
|
|
15
|
+
* Two rules hold the whole feature up, and both are structural rather than
|
|
16
|
+
* remembered. The map is written under `.continuity/` and nothing in this
|
|
17
|
+
* command sends it anywhere: it names a customer's file tree, which is the one
|
|
18
|
+
* thing Balladeer promises never to receive. And every entry carries the digest
|
|
19
|
+
* of the verifier that produced it, so a map computed against a verifier that
|
|
20
|
+
* has since changed reads as stale rather than as fact.
|
|
21
|
+
*/
|
|
22
|
+
/** The directory a repository keeps its promises, packages and map in. */
|
|
23
|
+
export const CONTINUITY_DIRECTORY = ".continuity";
|
|
24
|
+
/** Where a promise's sealed files live. Everything under it is the verifier itself. */
|
|
25
|
+
export const PROMISE_TREE_ROOT = ".continuity/promises";
|
|
26
|
+
/** Where the sealed packages live, one JSON file per promise. */
|
|
27
|
+
export const PACKAGE_DIRECTORY = ".continuity/packages";
|
|
28
|
+
/** Where the scaffold leaves the readable draft each package was sealed from. */
|
|
29
|
+
export const DRAFT_DIRECTORY = ".continuity/drafts";
|
|
30
|
+
/** The map itself, beside the packages it was computed from. */
|
|
31
|
+
export const TOUCH_MAP_FILE = ".continuity/touch-map.json";
|
|
32
|
+
/** The document's own version, so a reader can refuse one it does not understand. */
|
|
33
|
+
export const TOUCH_MAP_SCHEMA = "balladeer-touch-map/v1";
|
|
34
|
+
/**
|
|
35
|
+
* What one map may hold.
|
|
36
|
+
*
|
|
37
|
+
* A repository with a thousand promises and a hundred thousand files would
|
|
38
|
+
* otherwise write a document nothing can open. The caps are stated in the
|
|
39
|
+
* document when they bite, because a map that quietly stopped early would be
|
|
40
|
+
* read as "nothing else is touched", which is the one thing it must never say
|
|
41
|
+
* by accident.
|
|
42
|
+
*/
|
|
43
|
+
export const TOUCH_MAP_LIMITS = {
|
|
44
|
+
/** Files one map names. */
|
|
45
|
+
files: 5000,
|
|
46
|
+
/** Promises listed under one file. */
|
|
47
|
+
promisesPerFile: 50,
|
|
48
|
+
/** Characters of one repository-relative path. */
|
|
49
|
+
path: 400,
|
|
50
|
+
/** Characters of one promise's claim, as `affected` prints it. */
|
|
51
|
+
claim: 200,
|
|
52
|
+
};
|
|
53
|
+
/** A promise id as the runner spells it. Anything else is not one of ours. */
|
|
54
|
+
const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
|
|
55
|
+
function readableString(value, limit) {
|
|
56
|
+
if (typeof value !== "string")
|
|
57
|
+
return undefined;
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
if (trimmed === "")
|
|
60
|
+
return undefined;
|
|
61
|
+
// Bounded, and stripped of anything that could reflow a terminal: this text
|
|
62
|
+
// came out of a file in the customer's repository and is about to be printed.
|
|
63
|
+
return trimmed.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The first sentence of a stored outcome, never crossing a paragraph break.
|
|
67
|
+
*
|
|
68
|
+
* The same rule the promise index uses, written out rather than imported: this
|
|
69
|
+
* package is published with no dependency of its own, and `affected` prints a
|
|
70
|
+
* claim from a file on the customer's disk with no server in the conversation.
|
|
71
|
+
*/
|
|
72
|
+
export function firstSentence(value) {
|
|
73
|
+
const paragraph = value
|
|
74
|
+
.trim()
|
|
75
|
+
.split(/\n\s*\n/)[0]
|
|
76
|
+
?.trim() ?? "";
|
|
77
|
+
if (!paragraph)
|
|
78
|
+
return "";
|
|
79
|
+
const match = /^[\s\S]*?[.!?](?=\s+[A-Z0-9"“(]|\s*$)/.exec(paragraph);
|
|
80
|
+
return (match?.[0] ?? paragraph).trim();
|
|
81
|
+
}
|
|
82
|
+
function claimOf(promise) {
|
|
83
|
+
const stored = readableString(promise?.oneSentenceOutcome, TOUCH_MAP_LIMITS.claim);
|
|
84
|
+
if (stored !== undefined)
|
|
85
|
+
return stored;
|
|
86
|
+
const outcome = readableString(promise?.observableOutcome, 4000);
|
|
87
|
+
if (outcome === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const derived = firstSentence(outcome);
|
|
90
|
+
if (derived === "")
|
|
91
|
+
return undefined;
|
|
92
|
+
return derived.length <= TOUCH_MAP_LIMITS.claim
|
|
93
|
+
? derived
|
|
94
|
+
: `${derived.slice(0, TOUCH_MAP_LIMITS.claim - 3).trimEnd()}...`;
|
|
95
|
+
}
|
|
96
|
+
function verifierTarget(value) {
|
|
97
|
+
const spec = value;
|
|
98
|
+
const executable = typeof spec?.executable === "string" ? spec.executable : undefined;
|
|
99
|
+
if (executable === undefined || executable === "")
|
|
100
|
+
return undefined;
|
|
101
|
+
if (!Array.isArray(spec?.args) || !spec.args.every((arg) => typeof arg === "string"))
|
|
102
|
+
return undefined;
|
|
103
|
+
return {
|
|
104
|
+
executable,
|
|
105
|
+
args: [...spec.args],
|
|
106
|
+
...(typeof spec.cwd === "string" ? { cwd: spec.cwd } : {}),
|
|
107
|
+
...(typeof spec.timeoutMs === "number" && Number.isFinite(spec.timeoutMs)
|
|
108
|
+
? { timeoutMs: spec.timeoutMs }
|
|
109
|
+
: {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Every promise this checkout has a sealed package for, in id order.
|
|
114
|
+
*
|
|
115
|
+
* It reads the package files and nothing else, so it works with no network, no
|
|
116
|
+
* credential, and no runner checkout. A file that is not a readable sealed
|
|
117
|
+
* package is skipped rather than reported: a half-written draft in that
|
|
118
|
+
* directory is not a promise, and refusing to map the other promises because of
|
|
119
|
+
* it would be worse than leaving it out.
|
|
120
|
+
*
|
|
121
|
+
* The claim is read from the draft the scaffold leaves beside the package when
|
|
122
|
+
* there is one, because that is the readable copy a person edits, and from the
|
|
123
|
+
* package itself when there is not.
|
|
124
|
+
*/
|
|
125
|
+
export function readLocalPackages(root) {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(join(root, PACKAGE_DIRECTORY));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const found = [];
|
|
134
|
+
for (const entry of entries.sort()) {
|
|
135
|
+
if (!entry.endsWith(".json"))
|
|
136
|
+
continue;
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(readFileSync(join(root, PACKAGE_DIRECTORY, entry), "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const promise = parsed.promise;
|
|
145
|
+
const promiseId = typeof promise?.id === "string" ? promise.id : undefined;
|
|
146
|
+
if (promiseId === undefined || !PROMISE_ID.test(promiseId))
|
|
147
|
+
continue;
|
|
148
|
+
const verifier = parsed.verifier;
|
|
149
|
+
const target = verifierTarget(verifier?.target);
|
|
150
|
+
const materials = Array.isArray(parsed.materials) ? parsed.materials : [];
|
|
151
|
+
const materialPaths = materials
|
|
152
|
+
.map((material) => material?.path)
|
|
153
|
+
.filter((path) => typeof path === "string")
|
|
154
|
+
.sort();
|
|
155
|
+
let draft;
|
|
156
|
+
try {
|
|
157
|
+
draft = JSON.parse(readFileSync(join(root, DRAFT_DIRECTORY, `${promiseId}.json`), "utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
draft = undefined;
|
|
161
|
+
}
|
|
162
|
+
const draftPromise = draft?.promise;
|
|
163
|
+
const title = readableString(draftPromise?.title, 160) ?? readableString(promise?.title, 160);
|
|
164
|
+
const claim = claimOf(draftPromise) ?? claimOf(promise);
|
|
165
|
+
found.push({
|
|
166
|
+
promiseId,
|
|
167
|
+
packageFile: `${PACKAGE_DIRECTORY}/${entry}`,
|
|
168
|
+
// A package with no runnable target is still a promise this checkout
|
|
169
|
+
// holds, and `touch-map` has to say it could not run rather than pretend
|
|
170
|
+
// it was never there.
|
|
171
|
+
target: target ?? { executable: "", args: [] },
|
|
172
|
+
materialPaths,
|
|
173
|
+
...(title === undefined ? {} : { title }),
|
|
174
|
+
...(claim === undefined ? {} : { claim }),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return found.sort((left, right) => left.promiseId.localeCompare(right.promiseId));
|
|
178
|
+
}
|
|
179
|
+
/** JSON with its object keys in sorted order, so the same input digests the same. */
|
|
180
|
+
export function canonicalJson(value) {
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
183
|
+
if (value !== null && typeof value === "object") {
|
|
184
|
+
const record = value;
|
|
185
|
+
const keys = Object.keys(record).sort();
|
|
186
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
187
|
+
}
|
|
188
|
+
return JSON.stringify(value) ?? "null";
|
|
189
|
+
}
|
|
190
|
+
function fileDigest(root, path) {
|
|
191
|
+
try {
|
|
192
|
+
return `sha256:${createHash("sha256")
|
|
193
|
+
.update(readFileSync(join(root, path)))
|
|
194
|
+
.digest("hex")}`;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// A locked material that is not there is a fact about the verifier as much
|
|
198
|
+
// as its bytes are, and it has to change the digest: a map computed while
|
|
199
|
+
// the fixture existed must not read current once somebody deleted it.
|
|
200
|
+
return "missing";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The digest a map entry was computed under.
|
|
205
|
+
*
|
|
206
|
+
* It covers exactly what decides which files a verifier executes: how the
|
|
207
|
+
* verifier is invoked, and the bytes of every file the package locks. It is
|
|
208
|
+
* taken from the files on disk rather than from the digests the package
|
|
209
|
+
* declares, so both ways a verifier can change move it: a reseal, and an edit
|
|
210
|
+
* nobody has sealed yet.
|
|
211
|
+
*
|
|
212
|
+
* It is deliberately not the package digest. That moves when somebody fixes a
|
|
213
|
+
* typo in the promise's title, and a map that read stale after a wording change
|
|
214
|
+
* would be rebuilt so often that nobody would trust the word.
|
|
215
|
+
*/
|
|
216
|
+
export function verifierDigest(root, pkg) {
|
|
217
|
+
const material = pkg.materialPaths.map((path) => ({ path, digest: fileDigest(root, path) }));
|
|
218
|
+
const body = canonicalJson({
|
|
219
|
+
target: {
|
|
220
|
+
executable: pkg.target.executable,
|
|
221
|
+
args: [...pkg.target.args],
|
|
222
|
+
cwd: pkg.target.cwd ?? ".",
|
|
223
|
+
},
|
|
224
|
+
materials: material,
|
|
225
|
+
});
|
|
226
|
+
return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
227
|
+
}
|
|
228
|
+
/** A path in the map's own spelling: repository-relative, forward slashes. */
|
|
229
|
+
export function repositoryPath(root, absolute) {
|
|
230
|
+
const relativePath = relative(root, absolute);
|
|
231
|
+
if (relativePath === "" || isAbsolute(relativePath))
|
|
232
|
+
return undefined;
|
|
233
|
+
const posix = relativePath.split(sep).join("/");
|
|
234
|
+
if (posix.startsWith("../"))
|
|
235
|
+
return undefined;
|
|
236
|
+
return posix;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a file this verifier executed belongs in the map.
|
|
240
|
+
*
|
|
241
|
+
* The promise tree is out, because those files are the verifier: a map that
|
|
242
|
+
* said "this promise touches its own verifier" would answer every question with
|
|
243
|
+
* itself. Installed dependencies are out too. They are executed by everything,
|
|
244
|
+
* they are nobody's change, and a map that carried them would be mostly
|
|
245
|
+
* `node_modules` and would blow the size cap before it reached the first file
|
|
246
|
+
* anybody edits.
|
|
247
|
+
*/
|
|
248
|
+
export function mappablePath(path) {
|
|
249
|
+
if (path.length > TOUCH_MAP_LIMITS.path)
|
|
250
|
+
return false;
|
|
251
|
+
if (path.startsWith(`${PROMISE_TREE_ROOT}/`))
|
|
252
|
+
return false;
|
|
253
|
+
return !path.split("/").includes("node_modules");
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The repository files one coverage document says were executed, before any
|
|
257
|
+
* exclusion.
|
|
258
|
+
*
|
|
259
|
+
* V8 records every script the process loaded, including Node's own internals
|
|
260
|
+
* and anything outside this repository. A script counts as executed when any of
|
|
261
|
+
* its ranges ran at least once, which is true of a module the verifier merely
|
|
262
|
+
* imported: importing it runs its top level, and a verifier that imports a file
|
|
263
|
+
* has read the behavior in it.
|
|
264
|
+
*
|
|
265
|
+
* The promise's own tree is still in this answer, because it is what proves the
|
|
266
|
+
* verifier ran at all. `mappablePath` is what takes it back out before anything
|
|
267
|
+
* is written down.
|
|
268
|
+
*/
|
|
269
|
+
export function executedPaths(root, document) {
|
|
270
|
+
const result = document?.result;
|
|
271
|
+
if (!Array.isArray(result))
|
|
272
|
+
return [];
|
|
273
|
+
const paths = new Set();
|
|
274
|
+
for (const script of result) {
|
|
275
|
+
const url = script?.url;
|
|
276
|
+
if (typeof url !== "string" || !url.startsWith("file://"))
|
|
277
|
+
continue;
|
|
278
|
+
const functions = script.functions;
|
|
279
|
+
if (!Array.isArray(functions))
|
|
280
|
+
continue;
|
|
281
|
+
const ran = functions.some((fn) => {
|
|
282
|
+
const ranges = fn?.ranges;
|
|
283
|
+
return (Array.isArray(ranges) &&
|
|
284
|
+
ranges.some((range) => {
|
|
285
|
+
const count = range?.count;
|
|
286
|
+
return typeof count === "number" && count > 0;
|
|
287
|
+
}));
|
|
288
|
+
});
|
|
289
|
+
if (!ran)
|
|
290
|
+
continue;
|
|
291
|
+
let absolute;
|
|
292
|
+
try {
|
|
293
|
+
absolute = fileURLToPath(url);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const path = repositoryPath(root, absolute);
|
|
299
|
+
if (path === undefined || path.length > TOUCH_MAP_LIMITS.path)
|
|
300
|
+
continue;
|
|
301
|
+
paths.add(path);
|
|
302
|
+
}
|
|
303
|
+
return [...paths].sort();
|
|
304
|
+
}
|
|
305
|
+
/** Every coverage document V8 left in one directory, unparsed ones skipped. */
|
|
306
|
+
export function readCoverageDirectory(directory) {
|
|
307
|
+
let entries;
|
|
308
|
+
try {
|
|
309
|
+
entries = readdirSync(directory);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
const documents = [];
|
|
315
|
+
for (const entry of entries.sort()) {
|
|
316
|
+
if (!entry.endsWith(".json"))
|
|
317
|
+
continue;
|
|
318
|
+
try {
|
|
319
|
+
const full = join(directory, entry);
|
|
320
|
+
if (!statSync(full).isFile())
|
|
321
|
+
continue;
|
|
322
|
+
documents.push(JSON.parse(readFileSync(full, "utf8")));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return documents;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The map document, assembled from what each verifier executed.
|
|
332
|
+
*
|
|
333
|
+
* Ordering is total and by name at every level, so two runs over an unchanged
|
|
334
|
+
* repository produce the same bytes and a diff shows a behavior change rather
|
|
335
|
+
* than a shuffle.
|
|
336
|
+
*/
|
|
337
|
+
export function buildTouchMap(input) {
|
|
338
|
+
const byPath = new Map();
|
|
339
|
+
let truncated = false;
|
|
340
|
+
for (const promise of input.covered) {
|
|
341
|
+
for (const path of promise.paths) {
|
|
342
|
+
const entries = byPath.get(path) ?? [];
|
|
343
|
+
if (entries.length >= TOUCH_MAP_LIMITS.promisesPerFile) {
|
|
344
|
+
truncated = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
entries.push({ promiseId: promise.promiseId, verifierDigest: promise.verifierDigest });
|
|
348
|
+
byPath.set(path, entries);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const paths = [...byPath.keys()].sort();
|
|
352
|
+
if (paths.length > TOUCH_MAP_LIMITS.files)
|
|
353
|
+
truncated = true;
|
|
354
|
+
const files = paths.slice(0, TOUCH_MAP_LIMITS.files).map((path) => ({
|
|
355
|
+
path,
|
|
356
|
+
promises: [...byPath.get(path)].sort((left, right) => left.promiseId.localeCompare(right.promiseId)),
|
|
357
|
+
}));
|
|
358
|
+
return {
|
|
359
|
+
schemaVersion: TOUCH_MAP_SCHEMA,
|
|
360
|
+
generatedAt: input.generatedAt,
|
|
361
|
+
truncated,
|
|
362
|
+
promises: [...input.covered]
|
|
363
|
+
.map((promise) => ({
|
|
364
|
+
promiseId: promise.promiseId,
|
|
365
|
+
verifierDigest: promise.verifierDigest,
|
|
366
|
+
files: promise.paths.length,
|
|
367
|
+
}))
|
|
368
|
+
.sort((left, right) => left.promiseId.localeCompare(right.promiseId)),
|
|
369
|
+
files,
|
|
370
|
+
unmapped: [...input.unmapped].sort((left, right) => left.promiseId.localeCompare(right.promiseId)),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** The document as it is written: two-space JSON with a trailing newline. */
|
|
374
|
+
export function serializeTouchMap(map) {
|
|
375
|
+
return `${JSON.stringify(map, null, 2)}\n`;
|
|
376
|
+
}
|
|
377
|
+
/** The same document with the timestamp removed, which is what "unchanged" means. */
|
|
378
|
+
export function touchMapBody(map) {
|
|
379
|
+
const { generatedAt: _ignored, ...rest } = map;
|
|
380
|
+
return canonicalJson(rest);
|
|
381
|
+
}
|
|
382
|
+
/** The map this checkout holds, or why there is nothing to read. */
|
|
383
|
+
export function readTouchMap(root) {
|
|
384
|
+
let text;
|
|
385
|
+
try {
|
|
386
|
+
text = readFileSync(join(root, TOUCH_MAP_FILE), "utf8");
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return { kind: "absent" };
|
|
390
|
+
}
|
|
391
|
+
let parsed;
|
|
392
|
+
try {
|
|
393
|
+
parsed = JSON.parse(text);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return { kind: "unreadable" };
|
|
397
|
+
}
|
|
398
|
+
if (parsed?.schemaVersion !== TOUCH_MAP_SCHEMA)
|
|
399
|
+
return { kind: "unreadable" };
|
|
400
|
+
if (!Array.isArray(parsed.files) || !Array.isArray(parsed.promises))
|
|
401
|
+
return { kind: "unreadable" };
|
|
402
|
+
return { kind: "map", map: parsed };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Which promises a set of paths touches, and which of those answers have aged.
|
|
406
|
+
*
|
|
407
|
+
* Staleness is per entry rather than per map. A repository where one verifier
|
|
408
|
+
* was repaired this morning still has a true answer for every other promise in
|
|
409
|
+
* it, and marking the whole map stale would throw those away.
|
|
410
|
+
*/
|
|
411
|
+
export function affectedPaths(map, paths, currentDigests, details) {
|
|
412
|
+
const byPath = new Map(map.files.map((file) => [file.path, file.promises]));
|
|
413
|
+
return paths.map((path) => {
|
|
414
|
+
const entries = byPath.get(path) ?? [];
|
|
415
|
+
return {
|
|
416
|
+
path,
|
|
417
|
+
promises: entries.map((entry) => {
|
|
418
|
+
const current = currentDigests.get(entry.promiseId);
|
|
419
|
+
const detail = details.get(entry.promiseId);
|
|
420
|
+
return {
|
|
421
|
+
promiseId: entry.promiseId,
|
|
422
|
+
// A promise whose package has gone is stale, not current: the map
|
|
423
|
+
// still names it and nothing on disk can confirm the answer.
|
|
424
|
+
stale: current === undefined || current !== entry.verifierDigest,
|
|
425
|
+
...(detail?.title === undefined ? {} : { title: detail.title }),
|
|
426
|
+
...(detail?.claim === undefined ? {} : { claim: detail.claim }),
|
|
427
|
+
};
|
|
428
|
+
}),
|
|
429
|
+
};
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The environment a verifier is measured in.
|
|
434
|
+
*
|
|
435
|
+
* The same closed allow-list the pinned runner gives a verifier, plus the two
|
|
436
|
+
* variables this measurement needs: where V8 writes coverage, and where a
|
|
437
|
+
* native-protocol verifier writes its verdict. It is an allow-list rather than
|
|
438
|
+
* a subtraction so a control-plane token, a cloud credential, or anything else
|
|
439
|
+
* this terminal happens to hold cannot reach a customer's verifier because
|
|
440
|
+
* somebody forgot to name it.
|
|
441
|
+
*/
|
|
442
|
+
export function touchMapEnvironment(environment, coverageDirectory, resultPath) {
|
|
443
|
+
const allowed = [
|
|
444
|
+
"PATH",
|
|
445
|
+
"HOME",
|
|
446
|
+
"TMPDIR",
|
|
447
|
+
"TMP",
|
|
448
|
+
"TEMP",
|
|
449
|
+
"LANG",
|
|
450
|
+
"LC_ALL",
|
|
451
|
+
"LC_CTYPE",
|
|
452
|
+
"TZ",
|
|
453
|
+
"SYSTEMROOT",
|
|
454
|
+
];
|
|
455
|
+
const clean = {};
|
|
456
|
+
for (const key of allowed) {
|
|
457
|
+
const value = environment[key];
|
|
458
|
+
if (typeof value === "string" && value.length > 0)
|
|
459
|
+
clean[key] = value;
|
|
460
|
+
}
|
|
461
|
+
clean.NODE_V8_COVERAGE = coverageDirectory;
|
|
462
|
+
if (resultPath !== undefined)
|
|
463
|
+
clean.BALLADEER_RESULT_PATH = resultPath;
|
|
464
|
+
return clean;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Whether this release can measure a verifier invoked that way.
|
|
468
|
+
*
|
|
469
|
+
* Node only, for now. Coverage is collected by the runtime executing the
|
|
470
|
+
* verifier, so every language needs its own collector, and a command that
|
|
471
|
+
* guessed would map a Python verifier to nothing and call it an empty answer.
|
|
472
|
+
* The others are named as unmapped instead, which is a thing a person can read.
|
|
473
|
+
*/
|
|
474
|
+
export function measurableWithNode(target) {
|
|
475
|
+
if (target.executable === "")
|
|
476
|
+
return false;
|
|
477
|
+
const name = target.executable.split(/[\\/]/).pop() ?? "";
|
|
478
|
+
return name === "node" || name === "node.exe" || target.executable === process.execPath;
|
|
479
|
+
}
|
|
480
|
+
/** The working directory a verifier runs in, or nothing when it leaves the repository. */
|
|
481
|
+
export function verifierCwd(root, target) {
|
|
482
|
+
const cwd = resolve(root, target.cwd ?? ".");
|
|
483
|
+
const inside = relative(root, cwd);
|
|
484
|
+
if (inside !== "" && (inside.startsWith("..") || isAbsolute(inside)))
|
|
485
|
+
return undefined;
|
|
486
|
+
return cwd;
|
|
487
|
+
}
|