pactwright 0.0.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/LICENSE +201 -0
- package/README.md +68 -0
- package/dist/adapter/claude-code.d.ts +53 -0
- package/dist/adapter/claude-code.js +241 -0
- package/dist/adapter/commands.d.ts +19 -0
- package/dist/adapter/commands.js +162 -0
- package/dist/atomic.d.ts +6 -0
- package/dist/atomic.js +11 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +561 -0
- package/dist/config/config.d.ts +55 -0
- package/dist/config/config.js +199 -0
- package/dist/config/lifecycle.d.ts +34 -0
- package/dist/config/lifecycle.js +81 -0
- package/dist/config/lock.d.ts +43 -0
- package/dist/config/lock.js +141 -0
- package/dist/context.d.ts +59 -0
- package/dist/context.js +111 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +25 -0
- package/dist/eval/case.d.ts +123 -0
- package/dist/eval/case.js +17 -0
- package/dist/eval/core-suite.d.ts +3 -0
- package/dist/eval/core-suite.js +431 -0
- package/dist/eval/runner.d.ts +75 -0
- package/dist/eval/runner.js +159 -0
- package/dist/eval/sandbox.d.ts +39 -0
- package/dist/eval/sandbox.js +143 -0
- package/dist/extension/manage.d.ts +65 -0
- package/dist/extension/manage.js +372 -0
- package/dist/extension/manifest.d.ts +36 -0
- package/dist/extension/manifest.js +164 -0
- package/dist/extension/resolve.d.ts +77 -0
- package/dist/extension/resolve.js +271 -0
- package/dist/graph/edge-schema.d.ts +55 -0
- package/dist/graph/edge-schema.js +0 -0
- package/dist/graph/edges.d.ts +22 -0
- package/dist/graph/edges.js +63 -0
- package/dist/graph/ids.d.ts +14 -0
- package/dist/graph/ids.js +38 -0
- package/dist/graph/lineage.d.ts +48 -0
- package/dist/graph/lineage.js +226 -0
- package/dist/graph/mutations.d.ts +108 -0
- package/dist/graph/mutations.js +356 -0
- package/dist/graph/nodes.d.ts +46 -0
- package/dist/graph/nodes.js +137 -0
- package/dist/graph/revision.d.ts +50 -0
- package/dist/graph/revision.js +75 -0
- package/dist/graph/schema.d.ts +54 -0
- package/dist/graph/schema.js +90 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +132 -0
- package/dist/lifecycle/engine.d.ts +75 -0
- package/dist/lifecycle/engine.js +146 -0
- package/dist/lifecycle/record.d.ts +18 -0
- package/dist/lifecycle/record.js +157 -0
- package/dist/lifecycle/run.d.ts +62 -0
- package/dist/lifecycle/run.js +167 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +64 -0
- package/dist/pack/capabilities.d.ts +22 -0
- package/dist/pack/capabilities.js +31 -0
- package/dist/pack/locate.d.ts +22 -0
- package/dist/pack/locate.js +80 -0
- package/dist/pack/manifest.d.ts +34 -0
- package/dist/pack/manifest.js +168 -0
- package/dist/pack/resolve.d.ts +92 -0
- package/dist/pack/resolve.js +238 -0
- package/dist/project.d.ts +22 -0
- package/dist/project.js +37 -0
- package/dist/sync.d.ts +54 -0
- package/dist/sync.js +98 -0
- package/dist/validate.d.ts +23 -0
- package/dist/validate.js +32 -0
- package/dist/validation.d.ts +24 -0
- package/dist/validation.js +83 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/yaml.d.ts +12 -0
- package/dist/yaml.js +32 -0
- package/package.json +65 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tempSibling } from "../atomic.js";
|
|
3
|
+
import { loadConfig, rewriteConfig, } from "../config/config.js";
|
|
4
|
+
import { EXTENSION_ID_PATTERN, loadLock } from "../config/lock.js";
|
|
5
|
+
import { locatePackage } from "../pack/locate.js";
|
|
6
|
+
import { resolveDesiredState, serialiseLock } from "../pack/resolve.js";
|
|
7
|
+
import { projectPaths } from "../project.js";
|
|
8
|
+
import { validateProject } from "../validate.js";
|
|
9
|
+
import { loadExtensionManifest } from "./manifest.js";
|
|
10
|
+
import { resolveExtensionsBestEffort } from "./resolve.js";
|
|
11
|
+
const failure = (root, problems) => ({
|
|
12
|
+
ok: false,
|
|
13
|
+
root,
|
|
14
|
+
changes: [],
|
|
15
|
+
githubProfiles: [],
|
|
16
|
+
preserved: [],
|
|
17
|
+
problems,
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* `add project-intelligence` resolves `@pactwright/project-intelligence`;
|
|
21
|
+
* the explicit package form is also valid (Distribution §4).
|
|
22
|
+
*/
|
|
23
|
+
function parseSpec(spec) {
|
|
24
|
+
if (spec.startsWith("@")) {
|
|
25
|
+
const slash = spec.indexOf("/");
|
|
26
|
+
const id = slash === -1 ? "" : spec.slice(slash + 1);
|
|
27
|
+
if (!EXTENSION_ID_PATTERN.test(id)) {
|
|
28
|
+
return {
|
|
29
|
+
code: "invalid-extension-id",
|
|
30
|
+
message: `"${spec}" does not name an extension: the part after "/" must be a valid extension id`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { id, source: spec };
|
|
34
|
+
}
|
|
35
|
+
if (!EXTENSION_ID_PATTERN.test(spec)) {
|
|
36
|
+
return { code: "invalid-extension-id", message: `"${spec}" is not a valid extension id` };
|
|
37
|
+
}
|
|
38
|
+
return { id: spec, source: `@pactwright/${spec}` };
|
|
39
|
+
}
|
|
40
|
+
function withExtensions(config, extensions) {
|
|
41
|
+
return { ...config, extensions };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Writes config and lock atomically (temp sibling + rename, config first).
|
|
45
|
+
* `config` is `undefined` when only the lock changes, so desired state is
|
|
46
|
+
* left exactly as the user wrote it. Returns a Problem rather than throwing
|
|
47
|
+
* when either file is absent, keeping the result idiom the callers rely on.
|
|
48
|
+
*/
|
|
49
|
+
function writeDesiredState(root, config, lockText) {
|
|
50
|
+
const paths = projectPaths(root);
|
|
51
|
+
let previousConfig;
|
|
52
|
+
let previousLock;
|
|
53
|
+
try {
|
|
54
|
+
previousConfig = readFileSync(paths.config, "utf8");
|
|
55
|
+
previousLock = readFileSync(paths.lock, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const path = error.path ?? paths.lock;
|
|
59
|
+
return { code: "missing-file", message: "file not found", path };
|
|
60
|
+
}
|
|
61
|
+
const writeAll = (entries) => {
|
|
62
|
+
for (const [target, content] of entries) {
|
|
63
|
+
const temp = tempSibling(target);
|
|
64
|
+
writeFileSync(temp, content, "utf8");
|
|
65
|
+
renameSync(temp, target);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const written = [];
|
|
69
|
+
const previous = [];
|
|
70
|
+
if (config !== undefined) {
|
|
71
|
+
// Only the `extensions:` block ever changes, so the rest of the file —
|
|
72
|
+
// including whatever the team wrote in comments — is carried across.
|
|
73
|
+
written.push([paths.config, rewriteConfig(previousConfig, config)]);
|
|
74
|
+
previous.push([paths.config, previousConfig]);
|
|
75
|
+
}
|
|
76
|
+
written.push([paths.lock, lockText]);
|
|
77
|
+
previous.push([paths.lock, previousLock]);
|
|
78
|
+
writeAll(written);
|
|
79
|
+
return { restore: () => writeAll(previous) };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Enables an extension (Distribution §4): resolve the package, resolve and
|
|
83
|
+
* enable missing dependencies first, validate the complete required
|
|
84
|
+
* capability union, then record the exact state in config and lock. Fails
|
|
85
|
+
* before any write when compatibility is incomplete; rolls both files back
|
|
86
|
+
* if the resulting project state does not validate.
|
|
87
|
+
*
|
|
88
|
+
* "Missing" means not enabled, not merely absent: a dependency the
|
|
89
|
+
* configuration already names but has disabled is enabled too. So adding an
|
|
90
|
+
* extension that is itself already enabled still repairs a disabled
|
|
91
|
+
* dependency underneath it, at any depth, and reports what it enabled rather
|
|
92
|
+
* than `unchanged`.
|
|
93
|
+
*
|
|
94
|
+
* Because the walk crosses already-enabled dependencies to reach what is
|
|
95
|
+
* below them, an add whose enabled dependency is itself broken reports that
|
|
96
|
+
* problem rather than `unchanged`. The write would have failed on it anyway;
|
|
97
|
+
* saying so up front is the more truthful answer.
|
|
98
|
+
*/
|
|
99
|
+
export function addExtension(root, spec) {
|
|
100
|
+
const paths = projectPaths(root);
|
|
101
|
+
const parsed = parseSpec(spec);
|
|
102
|
+
if ("code" in parsed)
|
|
103
|
+
return failure(paths.root, [parsed]);
|
|
104
|
+
const config = loadConfig(paths.config);
|
|
105
|
+
if (config.value === undefined)
|
|
106
|
+
return failure(paths.root, config.problems);
|
|
107
|
+
const proposed = { ...config.value.extensions };
|
|
108
|
+
const added = [];
|
|
109
|
+
const problems = [];
|
|
110
|
+
// Enable the requested extension, then walk its manifest dependencies and
|
|
111
|
+
// enable every one that is not already enabled — whether it is absent from
|
|
112
|
+
// the configuration or configured and disabled (Distribution §4). A
|
|
113
|
+
// dependency the configuration already names is located by its recorded
|
|
114
|
+
// source, never by the conventional package name: sources may be paths,
|
|
115
|
+
// and resolution treats a divergence as `extension-package-mismatch`. An
|
|
116
|
+
// unconfigured dependency falls back to `@pactwright/<id>`.
|
|
117
|
+
//
|
|
118
|
+
// `visited` makes termination independent of the enabled flags, so a
|
|
119
|
+
// dependency cycle among configured extensions stops here; reporting the
|
|
120
|
+
// cycle stays with `resolveDesiredState` below.
|
|
121
|
+
const queue = [parsed];
|
|
122
|
+
const visited = new Set();
|
|
123
|
+
while (queue.length > 0) {
|
|
124
|
+
const { id, source } = queue.shift();
|
|
125
|
+
if (visited.has(id))
|
|
126
|
+
continue;
|
|
127
|
+
visited.add(id);
|
|
128
|
+
const existing = Object.hasOwn(proposed, id) ? proposed[id] : undefined;
|
|
129
|
+
const located = locatePackage(paths.root, existing?.source ?? source, "extension");
|
|
130
|
+
if (typeof located !== "string") {
|
|
131
|
+
problems.push({
|
|
132
|
+
...located,
|
|
133
|
+
code: located.code === "pack-not-exported" ? "extension-not-exported" : "extension-not-found",
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const manifest = loadExtensionManifest(located);
|
|
138
|
+
if (manifest.value === undefined) {
|
|
139
|
+
problems.push(...manifest.problems);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (existing === undefined) {
|
|
143
|
+
proposed[id] = { enabled: true, source };
|
|
144
|
+
added.push(id);
|
|
145
|
+
}
|
|
146
|
+
else if (!existing.enabled) {
|
|
147
|
+
proposed[id] = { ...existing, enabled: true };
|
|
148
|
+
added.push(id);
|
|
149
|
+
}
|
|
150
|
+
// Every dependency is enqueued, enabled or not: stopping at an enabled one
|
|
151
|
+
// would hide whatever sits beneath it, and a disabled dependency two hops
|
|
152
|
+
// down is exactly what needs repairing. `visited` is what bounds the walk.
|
|
153
|
+
for (const dep of manifest.value.dependencies) {
|
|
154
|
+
const configured = Object.hasOwn(proposed, dep) ? proposed[dep] : undefined;
|
|
155
|
+
queue.push({ id: dep, source: configured?.source ?? `@pactwright/${dep}` });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (problems.length > 0)
|
|
159
|
+
return failure(paths.root, problems);
|
|
160
|
+
if (added.length === 0) {
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
root: paths.root,
|
|
164
|
+
changes: [{ id: parsed.id, action: "unchanged" }],
|
|
165
|
+
githubProfiles: [],
|
|
166
|
+
preserved: [],
|
|
167
|
+
problems: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const desired = resolveDesiredState({
|
|
171
|
+
root: paths.root,
|
|
172
|
+
config: withExtensions(config.value, proposed),
|
|
173
|
+
});
|
|
174
|
+
if (desired.value === undefined)
|
|
175
|
+
return failure(paths.root, desired.problems);
|
|
176
|
+
const written = writeDesiredState(paths.root, withExtensions(config.value, proposed), serialiseLock(desired.value.lock));
|
|
177
|
+
if ("code" in written)
|
|
178
|
+
return failure(paths.root, [written]);
|
|
179
|
+
const report = validateProject({ root: paths.root });
|
|
180
|
+
if (!report.ok) {
|
|
181
|
+
written.restore();
|
|
182
|
+
return failure(paths.root, report.problems);
|
|
183
|
+
}
|
|
184
|
+
const byId = new Map(desired.value.extensions.map((e) => [e.id, e]));
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
root: paths.root,
|
|
188
|
+
changes: added.sort().map((id) => ({
|
|
189
|
+
id,
|
|
190
|
+
action: "added",
|
|
191
|
+
...(byId.get(id) === undefined ? {} : { version: byId.get(id).manifest.version }),
|
|
192
|
+
})),
|
|
193
|
+
githubProfiles: added
|
|
194
|
+
.map((id) => byId.get(id)?.manifest.githubProfile)
|
|
195
|
+
.filter((profile) => profile !== undefined)
|
|
196
|
+
.sort(),
|
|
197
|
+
preserved: [],
|
|
198
|
+
problems: [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Removes an extension (Distribution §4). Blocked while an enabled
|
|
203
|
+
* extension still depends on it. Canonical graph data owned by the
|
|
204
|
+
* extension is never deleted — it is reported as preserved, and the user
|
|
205
|
+
* chooses separately whether to delete it.
|
|
206
|
+
*
|
|
207
|
+
* Removal is the remedy for a broken extension set, so nothing about that set
|
|
208
|
+
* being broken may block it: the dependant scan runs best effort, and when the
|
|
209
|
+
* remaining configuration still does not resolve the lock is derived from its
|
|
210
|
+
* previous contents rather than re-resolved. Both degradations are reported.
|
|
211
|
+
*/
|
|
212
|
+
export function removeExtension(root, id) {
|
|
213
|
+
const paths = projectPaths(root);
|
|
214
|
+
const config = loadConfig(paths.config);
|
|
215
|
+
if (config.value === undefined)
|
|
216
|
+
return failure(paths.root, config.problems);
|
|
217
|
+
if (!Object.hasOwn(config.value.extensions, id)) {
|
|
218
|
+
return failure(paths.root, [
|
|
219
|
+
{ code: "extension-not-configured", message: `extension "${id}" is not configured` },
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
// Deliberately best effort: `remove` is the remedy for a broken extension
|
|
223
|
+
// set, so it must not be blocked by that set being broken. An extension
|
|
224
|
+
// left incompatible by a runtime bump, or whose package was uninstalled,
|
|
225
|
+
// fails every other command — including the remove that would fix it.
|
|
226
|
+
// Nothing here decides the write: the proposed `resolveDesiredState` below
|
|
227
|
+
// still refuses any state that does not resolve, so every blind spot in
|
|
228
|
+
// this scan fails closed.
|
|
229
|
+
const scan = resolveExtensionsBestEffort({ root: paths.root, config: config.value });
|
|
230
|
+
const removed = scan.extensions.find((e) => e.id === id);
|
|
231
|
+
const dependants = scan.extensions
|
|
232
|
+
.filter((e) => e.id !== id && e.config.enabled && e.manifest.dependencies.includes(id))
|
|
233
|
+
.map((e) => e.id)
|
|
234
|
+
.sort();
|
|
235
|
+
if (dependants.length > 0) {
|
|
236
|
+
return failure(paths.root, [
|
|
237
|
+
{
|
|
238
|
+
code: "extension-required-by",
|
|
239
|
+
message: `extension "${id}" cannot be removed: enabled extension${dependants.length === 1 ? "" : "s"} ${dependants.map((d) => `"${d}"`).join(", ")} still depend${dependants.length === 1 ? "s" : ""} on it`,
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
// Read before the write: `writeDesiredState` replaces the lock. When the
|
|
244
|
+
// manifest could not be loaded the lock still records the exact version
|
|
245
|
+
// that was resolved, so the report stays truthful.
|
|
246
|
+
const previousLock = loadLock(paths.lock);
|
|
247
|
+
const previousVersion = removed?.manifest.version ?? previousLock.value?.extensions[id]?.version;
|
|
248
|
+
const proposed = { ...config.value.extensions };
|
|
249
|
+
delete proposed[id];
|
|
250
|
+
const desired = resolveDesiredState({
|
|
251
|
+
root: paths.root,
|
|
252
|
+
config: withExtensions(config.value, proposed),
|
|
253
|
+
});
|
|
254
|
+
// Resolving afresh is the happy path. When the state left behind still does
|
|
255
|
+
// not resolve — a runtime bump breaks every extension at once, so removing
|
|
256
|
+
// one of them cannot fix the others — fall back to the lock already on
|
|
257
|
+
// disk, minus this entry. That lock is by definition the last state that
|
|
258
|
+
// did resolve, so nothing is recorded that was never resolvable, and the
|
|
259
|
+
// command that repairs a broken set stays available while the set is broken.
|
|
260
|
+
const degraded = [];
|
|
261
|
+
let lockText;
|
|
262
|
+
if (desired.value !== undefined) {
|
|
263
|
+
lockText = serialiseLock(desired.value.lock);
|
|
264
|
+
}
|
|
265
|
+
else if (previousLock.value !== undefined) {
|
|
266
|
+
const rest = { ...previousLock.value.extensions };
|
|
267
|
+
delete rest[id];
|
|
268
|
+
lockText = serialiseLock({ ...previousLock.value, extensions: rest });
|
|
269
|
+
degraded.push({
|
|
270
|
+
code: "lock-not-re-resolved",
|
|
271
|
+
message: `extension "${id}" was removed, but the remaining configuration does not resolve, so the lock was derived from its previous contents rather than re-resolved; fix the reported problems and run \`pactwright extension upgrade\` to re-lock`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
return failure(paths.root, desired.problems);
|
|
276
|
+
}
|
|
277
|
+
const written = writeDesiredState(paths.root, withExtensions(config.value, proposed), lockText);
|
|
278
|
+
if ("code" in written)
|
|
279
|
+
return failure(paths.root, [written]);
|
|
280
|
+
// Preserved user-authored canonical data: records whose types the removed
|
|
281
|
+
// extension registered. `pactwright validate` reports them as unknown
|
|
282
|
+
// types until the user deletes them or re-enables the extension. The
|
|
283
|
+
// restore handle is deliberately unused: a removal is *expected* to leave
|
|
284
|
+
// records validate rejects, so `report.ok` is never consulted here.
|
|
285
|
+
const ownedTypes = new Set(removed === undefined ? [] : removed.manifest.nodeTypes);
|
|
286
|
+
const report = validateProject({ root: paths.root });
|
|
287
|
+
const preserved = removed === undefined
|
|
288
|
+
? []
|
|
289
|
+
: report.problems
|
|
290
|
+
.map((p) => p.path)
|
|
291
|
+
.filter((p) => p !== undefined)
|
|
292
|
+
.filter((p) => [...ownedTypes].some((type) => p.includes(`/${type}-`)))
|
|
293
|
+
.sort();
|
|
294
|
+
// `preserved` is defined by attribution, so with no manifest there is no
|
|
295
|
+
// ownership fact and the only honest list is empty. A bare `[]` would read
|
|
296
|
+
// as "nothing was left behind", which cannot be supported, so the removal
|
|
297
|
+
// succeeds and says why the inventory is missing.
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
root: paths.root,
|
|
301
|
+
changes: [
|
|
302
|
+
{
|
|
303
|
+
id,
|
|
304
|
+
action: "removed",
|
|
305
|
+
...(previousVersion === undefined ? {} : { previousVersion }),
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
githubProfiles: [],
|
|
309
|
+
preserved,
|
|
310
|
+
problems: [
|
|
311
|
+
...(removed === undefined
|
|
312
|
+
? [
|
|
313
|
+
{
|
|
314
|
+
code: "extension-manifest-unavailable",
|
|
315
|
+
message: `extension "${id}" was removed, but its manifest could not be read, so the records it owned could not be listed; run \`pactwright validate\` to see records left without a registered type`,
|
|
316
|
+
},
|
|
317
|
+
]
|
|
318
|
+
: []),
|
|
319
|
+
...degraded,
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Upgrades an extension (Distribution §15): re-resolves the configured
|
|
325
|
+
* package, validates the complete dependency graph and capability union,
|
|
326
|
+
* and updates the lock. The configuration is desired state and does not
|
|
327
|
+
* change; canonical Project Graph state is never reinterpreted.
|
|
328
|
+
*/
|
|
329
|
+
export function upgradeExtension(root, id) {
|
|
330
|
+
const paths = projectPaths(root);
|
|
331
|
+
const config = loadConfig(paths.config);
|
|
332
|
+
if (config.value === undefined)
|
|
333
|
+
return failure(paths.root, config.problems);
|
|
334
|
+
if (!Object.hasOwn(config.value.extensions, id)) {
|
|
335
|
+
return failure(paths.root, [
|
|
336
|
+
{ code: "extension-not-configured", message: `extension "${id}" is not configured` },
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
const previousLock = loadLock(paths.lock);
|
|
340
|
+
const previousVersion = previousLock.value?.extensions[id]?.version;
|
|
341
|
+
const desired = resolveDesiredState({ root: paths.root, config: config.value });
|
|
342
|
+
if (desired.value === undefined)
|
|
343
|
+
return failure(paths.root, desired.problems);
|
|
344
|
+
const next = desired.value.extensions.find((e) => e.id === id);
|
|
345
|
+
// The configuration is desired state and cannot change on an upgrade, so
|
|
346
|
+
// only the lock is written. A lock that does not validate is rolled back:
|
|
347
|
+
// §15 requires an upgrade to satisfy every enabled dependant *before* the
|
|
348
|
+
// lock file changes, so a failed upgrade must leave no trace.
|
|
349
|
+
const written = writeDesiredState(paths.root, undefined, serialiseLock(desired.value.lock));
|
|
350
|
+
if ("code" in written)
|
|
351
|
+
return failure(paths.root, [written]);
|
|
352
|
+
const report = validateProject({ root: paths.root });
|
|
353
|
+
if (!report.ok) {
|
|
354
|
+
written.restore();
|
|
355
|
+
return failure(paths.root, report.problems);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
root: paths.root,
|
|
360
|
+
changes: [
|
|
361
|
+
{
|
|
362
|
+
id,
|
|
363
|
+
action: "upgraded",
|
|
364
|
+
...(next === undefined ? {} : { version: next.manifest.version }),
|
|
365
|
+
...(previousVersion === undefined ? {} : { previousVersion }),
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
githubProfiles: [],
|
|
369
|
+
preserved: [],
|
|
370
|
+
problems: [],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ParseResult } from "../config/config.js";
|
|
2
|
+
/** File name of an extension manifest at the extension package root. */
|
|
3
|
+
export declare const EXTENSION_MANIFEST_FILE = "extension.yml";
|
|
4
|
+
/**
|
|
5
|
+
* A versioned extension manifest (Distribution §5). The manifest declares
|
|
6
|
+
* runtime compatibility, extension dependencies, graph contribution, command
|
|
7
|
+
* namespaces, required agent capabilities and the GitHub profile. It
|
|
8
|
+
* contains no project-specific configuration.
|
|
9
|
+
*/
|
|
10
|
+
export interface ExtensionManifest {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly package: string;
|
|
13
|
+
readonly version: string;
|
|
14
|
+
/** Compatible runtime: an exact version or a `^x.y.z` caret range. */
|
|
15
|
+
readonly pactwright: string;
|
|
16
|
+
/** Ids of extensions this extension requires, in declaration order. */
|
|
17
|
+
readonly dependencies: readonly string[];
|
|
18
|
+
/** Node types this extension owns and registers in the Project Graph. */
|
|
19
|
+
readonly nodeTypes: readonly string[];
|
|
20
|
+
/** Edge types this extension owns; shared core relations are reused, not redeclared. */
|
|
21
|
+
readonly edgeTypes: readonly string[];
|
|
22
|
+
/** Command namespaces the extension registers (`runtime.namespace` or `runtime.namespaces`). */
|
|
23
|
+
readonly namespaces: readonly string[];
|
|
24
|
+
/** Agent capabilities the selected pack must provide while this extension is enabled. */
|
|
25
|
+
readonly agentCapabilities: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Declared GitHub profile: logical automation/projection requirements.
|
|
28
|
+
* Metadata only in this checkpoint — nothing acts on it until GitHub
|
|
29
|
+
* provisioning exists.
|
|
30
|
+
*/
|
|
31
|
+
readonly githubProfile?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Parses extension manifest data; structural checks only, no filesystem access. */
|
|
34
|
+
export declare function parseExtensionManifest(raw: unknown, path: string): ParseResult<ExtensionManifest>;
|
|
35
|
+
/** Loads and validates the manifest at `<dir>/extension.yml`. */
|
|
36
|
+
export declare function loadExtensionManifest(dir: string): ParseResult<ExtensionManifest>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join as joinPath } from "node:path";
|
|
3
|
+
import { EXTENSION_ID_PATTERN } from "../config/lock.js";
|
|
4
|
+
import { CAPABILITY_PATTERN } from "../pack/capabilities.js";
|
|
5
|
+
import { COMPAT_PATTERN, PACKAGE_NAME_PATTERN, VERSION_PATTERN } from "../pack/manifest.js";
|
|
6
|
+
import { Checker, expectRecord, expectString, rejectUnknownKeys, requireKeys, } from "../validation.js";
|
|
7
|
+
import { readYamlFile } from "../yaml.js";
|
|
8
|
+
/** File name of an extension manifest at the extension package root. */
|
|
9
|
+
export const EXTENSION_MANIFEST_FILE = "extension.yml";
|
|
10
|
+
function parseTokenList(c, raw, label, pattern, kind) {
|
|
11
|
+
const out = [];
|
|
12
|
+
if (raw === undefined)
|
|
13
|
+
return out;
|
|
14
|
+
if (!Array.isArray(raw)) {
|
|
15
|
+
c.fail("invalid-type", `${label} must be a list`);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
raw.forEach((item, index) => {
|
|
19
|
+
const text = expectString(c, item, `${label}[${index}]`);
|
|
20
|
+
if (text === undefined)
|
|
21
|
+
return;
|
|
22
|
+
if (!pattern.test(text)) {
|
|
23
|
+
c.fail("invalid-value", `${label}[${index}] "${text}" is not a valid ${kind}`);
|
|
24
|
+
}
|
|
25
|
+
else if (out.includes(text)) {
|
|
26
|
+
c.fail("duplicate-value", `${label} lists "${text}" more than once`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
out.push(text);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** Parses extension manifest data; structural checks only, no filesystem access. */
|
|
35
|
+
export function parseExtensionManifest(raw, path) {
|
|
36
|
+
const c = new Checker(path);
|
|
37
|
+
const root = expectRecord(c, raw, "extension");
|
|
38
|
+
if (root === undefined) {
|
|
39
|
+
c.fail("invalid-type", "extension manifest must be a mapping");
|
|
40
|
+
return { value: undefined, problems: c.problems };
|
|
41
|
+
}
|
|
42
|
+
requireKeys(c, root, "extension", ["id", "package", "version", "pactwright"]);
|
|
43
|
+
rejectUnknownKeys(c, root, "extension", [
|
|
44
|
+
"id",
|
|
45
|
+
"package",
|
|
46
|
+
"version",
|
|
47
|
+
"pactwright",
|
|
48
|
+
"dependencies",
|
|
49
|
+
"graph",
|
|
50
|
+
"runtime",
|
|
51
|
+
"agent_capabilities",
|
|
52
|
+
"github",
|
|
53
|
+
]);
|
|
54
|
+
const id = expectString(c, root["id"], "extension.id");
|
|
55
|
+
if (id !== undefined && !EXTENSION_ID_PATTERN.test(id)) {
|
|
56
|
+
c.fail("invalid-extension-id", `extension.id "${id}" is not a valid extension id`);
|
|
57
|
+
}
|
|
58
|
+
const pkg = expectString(c, root["package"], "extension.package");
|
|
59
|
+
if (pkg !== undefined && (pkg.length > 214 || !PACKAGE_NAME_PATTERN.test(pkg))) {
|
|
60
|
+
c.fail("invalid-value", `extension.package must be a lowercase npm package name (optionally scoped), found "${pkg}"`);
|
|
61
|
+
}
|
|
62
|
+
const version = expectString(c, root["version"], "extension.version");
|
|
63
|
+
if (version !== undefined && !VERSION_PATTERN.test(version)) {
|
|
64
|
+
c.fail("invalid-value", `extension.version must be x.y.z, found "${version}"`);
|
|
65
|
+
}
|
|
66
|
+
const pactwright = expectString(c, root["pactwright"], "extension.pactwright");
|
|
67
|
+
if (pactwright !== undefined && !COMPAT_PATTERN.test(pactwright)) {
|
|
68
|
+
c.fail("invalid-value", `extension.pactwright must be x.y.z or ^x.y.z, found "${pactwright}"`);
|
|
69
|
+
}
|
|
70
|
+
let dependencies = [];
|
|
71
|
+
if (root["dependencies"] !== undefined) {
|
|
72
|
+
const record = expectRecord(c, root["dependencies"], "extension.dependencies");
|
|
73
|
+
if (record !== undefined) {
|
|
74
|
+
requireKeys(c, record, "extension.dependencies", ["extensions"]);
|
|
75
|
+
rejectUnknownKeys(c, record, "extension.dependencies", ["extensions"]);
|
|
76
|
+
dependencies = parseTokenList(c, record["extensions"], "extension.dependencies.extensions", EXTENSION_ID_PATTERN, "extension id");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let nodeTypes = [];
|
|
80
|
+
let edgeTypes = [];
|
|
81
|
+
if (root["graph"] !== undefined) {
|
|
82
|
+
const record = expectRecord(c, root["graph"], "extension.graph");
|
|
83
|
+
if (record !== undefined) {
|
|
84
|
+
rejectUnknownKeys(c, record, "extension.graph", ["node_types", "edge_types"]);
|
|
85
|
+
nodeTypes = parseTokenList(c, record["node_types"], "extension.graph.node_types", EXTENSION_ID_PATTERN, "node type");
|
|
86
|
+
edgeTypes = parseTokenList(c, record["edge_types"], "extension.graph.edge_types", EXTENSION_ID_PATTERN, "edge type");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Distribution §5 uses both `runtime.namespace: x` and `runtime.namespaces: [x, y]`.
|
|
90
|
+
let namespaces = [];
|
|
91
|
+
if (root["runtime"] !== undefined) {
|
|
92
|
+
const record = expectRecord(c, root["runtime"], "extension.runtime");
|
|
93
|
+
if (record !== undefined) {
|
|
94
|
+
rejectUnknownKeys(c, record, "extension.runtime", ["namespace", "namespaces"]);
|
|
95
|
+
if (record["namespace"] !== undefined && record["namespaces"] !== undefined) {
|
|
96
|
+
c.fail("invalid-value", "extension.runtime declares both namespace and namespaces; use one form");
|
|
97
|
+
}
|
|
98
|
+
else if (record["namespace"] !== undefined) {
|
|
99
|
+
const namespace = expectString(c, record["namespace"], "extension.runtime.namespace");
|
|
100
|
+
if (namespace !== undefined && !EXTENSION_ID_PATTERN.test(namespace)) {
|
|
101
|
+
c.fail("invalid-value", `extension.runtime.namespace "${namespace}" is not a valid command namespace`);
|
|
102
|
+
}
|
|
103
|
+
else if (namespace !== undefined) {
|
|
104
|
+
namespaces = [namespace];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
namespaces = parseTokenList(c, record["namespaces"], "extension.runtime.namespaces", EXTENSION_ID_PATTERN, "command namespace");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const agentCapabilities = parseTokenList(c, root["agent_capabilities"], "extension.agent_capabilities", CAPABILITY_PATTERN, "capability name");
|
|
113
|
+
let githubProfile;
|
|
114
|
+
if (root["github"] !== undefined) {
|
|
115
|
+
const record = expectRecord(c, root["github"], "extension.github");
|
|
116
|
+
if (record !== undefined) {
|
|
117
|
+
requireKeys(c, record, "extension.github", ["profile"]);
|
|
118
|
+
rejectUnknownKeys(c, record, "extension.github", ["profile"]);
|
|
119
|
+
githubProfile = expectString(c, record["profile"], "extension.github.profile");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!c.ok ||
|
|
123
|
+
id === undefined ||
|
|
124
|
+
pkg === undefined ||
|
|
125
|
+
version === undefined ||
|
|
126
|
+
pactwright === undefined) {
|
|
127
|
+
return { value: undefined, problems: c.problems };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
value: {
|
|
131
|
+
id,
|
|
132
|
+
package: pkg,
|
|
133
|
+
version,
|
|
134
|
+
pactwright,
|
|
135
|
+
dependencies,
|
|
136
|
+
nodeTypes,
|
|
137
|
+
edgeTypes,
|
|
138
|
+
namespaces,
|
|
139
|
+
agentCapabilities,
|
|
140
|
+
...(githubProfile === undefined ? {} : { githubProfile }),
|
|
141
|
+
},
|
|
142
|
+
problems: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Loads and validates the manifest at `<dir>/extension.yml`. */
|
|
146
|
+
export function loadExtensionManifest(dir) {
|
|
147
|
+
const manifestPath = joinPath(dir, EXTENSION_MANIFEST_FILE);
|
|
148
|
+
if (!existsSync(manifestPath)) {
|
|
149
|
+
return {
|
|
150
|
+
value: undefined,
|
|
151
|
+
problems: [
|
|
152
|
+
{
|
|
153
|
+
code: "extension-not-found",
|
|
154
|
+
message: `no ${EXTENSION_MANIFEST_FILE} found`,
|
|
155
|
+
path: manifestPath,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const read = readYamlFile(manifestPath);
|
|
161
|
+
if (read.problems.length > 0)
|
|
162
|
+
return { value: undefined, problems: read.problems };
|
|
163
|
+
return parseExtensionManifest(read.value, manifestPath);
|
|
164
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ConfigExtension, PactwrightConfig } from "../config/config.js";
|
|
2
|
+
import type { LockFile } from "../config/lock.js";
|
|
3
|
+
import type { Problem } from "../errors.js";
|
|
4
|
+
import { type EdgeSchema, type EdgeSchemaRegistry } from "../graph/edge-schema.js";
|
|
5
|
+
import { type NodeSchema, type NodeSchemaRegistry } from "../graph/schema.js";
|
|
6
|
+
import { type ExtensionManifest } from "./manifest.js";
|
|
7
|
+
/**
|
|
8
|
+
* Command namespaces the runtime owns; no extension may register them.
|
|
9
|
+
* Includes commands that arrive in later checkpoints so an extension cannot
|
|
10
|
+
* squat on a future core surface.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RESERVED_NAMESPACES: readonly ["agent-pack", "context", "eval", "extension", "github", "help", "init", "lifecycle", "sync", "upgrade", "validate", "version"];
|
|
13
|
+
/** One extension resolved from configuration to exact state. */
|
|
14
|
+
export interface ResolvedExtension {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
/** Absolute extension package root: where `extension.yml` lives. */
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
readonly config: ConfigExtension;
|
|
19
|
+
readonly manifest: ExtensionManifest;
|
|
20
|
+
/** Hash of the resolved manifest, recorded in the lock. */
|
|
21
|
+
readonly hash: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ResolveExtensionsOptions {
|
|
24
|
+
readonly root: string;
|
|
25
|
+
readonly config: PactwrightConfig;
|
|
26
|
+
/** Defaults to the running runtime's version. */
|
|
27
|
+
readonly runtimeVersion?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolves every configured extension (Distribution §§4–5): locate the
|
|
31
|
+
* package, load and validate its manifest, check runtime compatibility,
|
|
32
|
+
* dependency completeness, namespace registration and graph-type ownership.
|
|
33
|
+
* Disabled extensions resolve too — their graph types stay registered so
|
|
34
|
+
* existing records keep their meaning — but only enabled extensions
|
|
35
|
+
* contribute namespaces, capabilities and behaviour. Never throws; returns
|
|
36
|
+
* every problem found in one pass.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveExtensions(options: ResolveExtensionsOptions): {
|
|
39
|
+
value: readonly ResolvedExtension[] | undefined;
|
|
40
|
+
problems: readonly Problem[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Resolution without the all-or-nothing gate: every extension that could be
|
|
44
|
+
* resolved, alongside every problem found. An entry here may itself be the
|
|
45
|
+
* subject of a reported problem (`incompatible-runtime`, a package mismatch,
|
|
46
|
+
* a duplicate type or a cycle), and an extension that could not be located,
|
|
47
|
+
* whose manifest failed to load, or whose declared id disagrees with its
|
|
48
|
+
* configuration key is absent entirely.
|
|
49
|
+
*
|
|
50
|
+
* Use it only for reporting and for decisions that fail closed elsewhere —
|
|
51
|
+
* never to decide behaviour. `removeExtension` needs it because the command
|
|
52
|
+
* that repairs a broken extension set must not be blocked by that set being
|
|
53
|
+
* broken.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveExtensionsBestEffort(options: ResolveExtensionsOptions): {
|
|
56
|
+
readonly extensions: readonly ResolvedExtension[];
|
|
57
|
+
readonly problems: readonly Problem[];
|
|
58
|
+
};
|
|
59
|
+
/** Manifests of the enabled extensions, the set that contributes behaviour. */
|
|
60
|
+
export declare function enabledManifests(extensions: readonly ResolvedExtension[]): readonly ExtensionManifest[];
|
|
61
|
+
/** The lock entries recording exactly this resolved extension set (Distribution §6). */
|
|
62
|
+
export declare function extensionLockEntries(extensions: readonly ResolvedExtension[]): LockFile["extensions"];
|
|
63
|
+
/**
|
|
64
|
+
* Graph schemas contributed by the resolved extensions (enabled or not, so
|
|
65
|
+
* records owned by a disabled extension keep their meaning). The manifest
|
|
66
|
+
* registers type names only; contributed schemas are structural — permissive
|
|
67
|
+
* endpoints, no extra required fields — and owned by the extension id.
|
|
68
|
+
*/
|
|
69
|
+
export declare function extensionSchemas(extensions: readonly ResolvedExtension[]): {
|
|
70
|
+
readonly nodes: readonly NodeSchema[];
|
|
71
|
+
readonly edges: readonly EdgeSchema[];
|
|
72
|
+
};
|
|
73
|
+
/** The core registries extended with every type the resolved extensions register. */
|
|
74
|
+
export declare function composedRegistries(extensions: readonly ResolvedExtension[]): {
|
|
75
|
+
readonly nodes: NodeSchemaRegistry;
|
|
76
|
+
readonly edges: EdgeSchemaRegistry;
|
|
77
|
+
};
|