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.
Files changed (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +68 -0
  3. package/dist/adapter/claude-code.d.ts +53 -0
  4. package/dist/adapter/claude-code.js +241 -0
  5. package/dist/adapter/commands.d.ts +19 -0
  6. package/dist/adapter/commands.js +162 -0
  7. package/dist/atomic.d.ts +6 -0
  8. package/dist/atomic.js +11 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +561 -0
  11. package/dist/config/config.d.ts +55 -0
  12. package/dist/config/config.js +199 -0
  13. package/dist/config/lifecycle.d.ts +34 -0
  14. package/dist/config/lifecycle.js +81 -0
  15. package/dist/config/lock.d.ts +43 -0
  16. package/dist/config/lock.js +141 -0
  17. package/dist/context.d.ts +59 -0
  18. package/dist/context.js +111 -0
  19. package/dist/errors.d.ts +21 -0
  20. package/dist/errors.js +25 -0
  21. package/dist/eval/case.d.ts +123 -0
  22. package/dist/eval/case.js +17 -0
  23. package/dist/eval/core-suite.d.ts +3 -0
  24. package/dist/eval/core-suite.js +431 -0
  25. package/dist/eval/runner.d.ts +75 -0
  26. package/dist/eval/runner.js +159 -0
  27. package/dist/eval/sandbox.d.ts +39 -0
  28. package/dist/eval/sandbox.js +143 -0
  29. package/dist/extension/manage.d.ts +65 -0
  30. package/dist/extension/manage.js +372 -0
  31. package/dist/extension/manifest.d.ts +36 -0
  32. package/dist/extension/manifest.js +164 -0
  33. package/dist/extension/resolve.d.ts +77 -0
  34. package/dist/extension/resolve.js +271 -0
  35. package/dist/graph/edge-schema.d.ts +55 -0
  36. package/dist/graph/edge-schema.js +0 -0
  37. package/dist/graph/edges.d.ts +22 -0
  38. package/dist/graph/edges.js +63 -0
  39. package/dist/graph/ids.d.ts +14 -0
  40. package/dist/graph/ids.js +38 -0
  41. package/dist/graph/lineage.d.ts +48 -0
  42. package/dist/graph/lineage.js +226 -0
  43. package/dist/graph/mutations.d.ts +108 -0
  44. package/dist/graph/mutations.js +356 -0
  45. package/dist/graph/nodes.d.ts +46 -0
  46. package/dist/graph/nodes.js +137 -0
  47. package/dist/graph/revision.d.ts +50 -0
  48. package/dist/graph/revision.js +75 -0
  49. package/dist/graph/schema.d.ts +54 -0
  50. package/dist/graph/schema.js +90 -0
  51. package/dist/index.d.ts +33 -0
  52. package/dist/index.js +33 -0
  53. package/dist/init.d.ts +47 -0
  54. package/dist/init.js +132 -0
  55. package/dist/lifecycle/engine.d.ts +75 -0
  56. package/dist/lifecycle/engine.js +146 -0
  57. package/dist/lifecycle/record.d.ts +18 -0
  58. package/dist/lifecycle/record.js +157 -0
  59. package/dist/lifecycle/run.d.ts +62 -0
  60. package/dist/lifecycle/run.js +167 -0
  61. package/dist/loader.d.ts +38 -0
  62. package/dist/loader.js +64 -0
  63. package/dist/pack/capabilities.d.ts +22 -0
  64. package/dist/pack/capabilities.js +31 -0
  65. package/dist/pack/locate.d.ts +22 -0
  66. package/dist/pack/locate.js +80 -0
  67. package/dist/pack/manifest.d.ts +34 -0
  68. package/dist/pack/manifest.js +168 -0
  69. package/dist/pack/resolve.d.ts +92 -0
  70. package/dist/pack/resolve.js +238 -0
  71. package/dist/project.d.ts +22 -0
  72. package/dist/project.js +37 -0
  73. package/dist/sync.d.ts +54 -0
  74. package/dist/sync.js +98 -0
  75. package/dist/validate.d.ts +23 -0
  76. package/dist/validate.js +32 -0
  77. package/dist/validation.d.ts +24 -0
  78. package/dist/validation.js +83 -0
  79. package/dist/version.d.ts +2 -0
  80. package/dist/version.js +8 -0
  81. package/dist/yaml.d.ts +12 -0
  82. package/dist/yaml.js +32 -0
  83. 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
+ };