@echopath-labs/forgerail 0.1.0-alpha.3 → 0.1.0-alpha.4
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +34 -0
- package/CONTRIBUTING.md +68 -4
- package/README.md +126 -49
- package/README.zh-CN.md +131 -28
- package/SECURITY.md +48 -4
- package/SUPPORT.md +37 -0
- package/adapters/claude-code.json +6 -1
- package/adapters/codex.json +6 -0
- package/adapters/cursor.json +5 -0
- package/contracts/adoption-plan.schema.json +39 -18
- package/contracts/effective-profile.schema.json +4 -4
- package/contracts/host-adapter.schema.json +66 -4
- package/contracts/host-binding-receipt.schema.json +1 -1
- package/contracts/launch-contract.schema.json +38 -2
- package/contracts/profile-change-candidate.schema.json +1 -1
- package/contracts/return-receipt.schema.json +1 -1
- package/contracts/task-envelope.schema.json +1 -1
- package/directory/README.md +1 -1
- package/directory/release-notes-alpha4.md +9 -0
- package/directory/submission-candidate.json +4 -4
- package/docs/adoption.md +63 -26
- package/docs/adoption.zh-CN.md +62 -25
- package/docs/architecture-acceptance.md +1 -1
- package/docs/composable-autonomy.zh-CN.md +16 -22
- package/docs/installation.md +71 -40
- package/docs/installation.zh-CN.md +90 -31
- package/docs/release-alpha4.md +33 -0
- package/docs/release-alpha4.zh-CN.md +33 -0
- package/package.json +7 -3
- package/scripts/adoption-closeout-regressions.mjs +100 -0
- package/scripts/disposable-consumer.mjs +11 -18
- package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
- package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
- package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
- package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
- package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
- package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
- package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
- package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
- package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
- package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
- package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
- package/scripts/fixtures/open-source-docs/cases.json +65 -0
- package/scripts/forgerail.mjs +61 -16
- package/scripts/integrity-regressions.mjs +1261 -0
- package/scripts/lib/adoption.mjs +666 -51
- package/scripts/lib/bounded-read.mjs +80 -0
- package/scripts/lib/composition.mjs +77 -7
- package/scripts/lib/contracts.mjs +126 -40
- package/scripts/lib/diagnosis.mjs +146 -39
- package/scripts/shadow-comparison.mjs +52 -34
- package/scripts/validate-open-source-docs.mjs +132 -0
- package/scripts/validate-release.mjs +77 -13
- package/scripts/validate-universal-directory.mjs +5 -5
- package/skills/forgerail/references/adoption.md +2 -2
- package/skills/forgerail/references/contracts.md +2 -2
- package/scripts/lib/bundle.mjs +0 -77
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
cpSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
symlinkSync,
|
|
17
|
+
statSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, resolve, win32 } from "node:path";
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
|
+
import { adoptionWriteApprovalDigest, applyApprovedAdoptionWrite, loadHostAdapters, planAdoption, renderProposedWrite, resolveAdoptionWriteTarget } from "./lib/adoption.mjs";
|
|
25
|
+
import { createLaunchContract, resolveProfile, verifyReceipt } from "./lib/composition.mjs";
|
|
26
|
+
import { readJson, validateContract } from "./lib/contracts.mjs";
|
|
27
|
+
import { diagnoseWorkspace } from "./lib/diagnosis.mjs";
|
|
28
|
+
|
|
29
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
+
const bundleModulePath = resolve(root, "tools/lib/bundle.mjs");
|
|
31
|
+
const bundleModule = existsSync(bundleModulePath)
|
|
32
|
+
? await import(pathToFileURL(bundleModulePath).href)
|
|
33
|
+
: null;
|
|
34
|
+
const buildBundle = bundleModule?.buildBundle ?? null;
|
|
35
|
+
const pathIsConfined = bundleModule?.pathIsConfined ?? null;
|
|
36
|
+
const workspaceRoot = isPrivateLayout(root) ? resolve(root, "../..") : root;
|
|
37
|
+
const fixtureRoot = resolve(root, "scripts/fixtures/contracts");
|
|
38
|
+
const externalPluginNames = [
|
|
39
|
+
"forgerail-cross-workspace-orchestration",
|
|
40
|
+
"forgerail-github-rulesets",
|
|
41
|
+
"forgerail-release-safety",
|
|
42
|
+
"forgerail-thread-closure",
|
|
43
|
+
];
|
|
44
|
+
const nonPortableHostPaths = [
|
|
45
|
+
"Straße.md",
|
|
46
|
+
"Σ.md",
|
|
47
|
+
"FORGERAIL.md.",
|
|
48
|
+
"AGENTS.md.",
|
|
49
|
+
"CON",
|
|
50
|
+
"CON.md",
|
|
51
|
+
"NUL.txt",
|
|
52
|
+
"AUX",
|
|
53
|
+
"COM1.md",
|
|
54
|
+
"dir/PRN.txt",
|
|
55
|
+
"dir/name.",
|
|
56
|
+
"dir/...",
|
|
57
|
+
];
|
|
58
|
+
const hasExternalPackSources = externalPluginNames.every((name) =>
|
|
59
|
+
existsSync(resolve(root, "plugins", name)) || existsSync(resolve(root, "..", name)),
|
|
60
|
+
);
|
|
61
|
+
const evaluateShadowComparison = hasExternalPackSources
|
|
62
|
+
? (await import(pathToFileURL(resolve(root, "scripts/shadow-comparison.mjs")).href)).evaluateShadowComparison
|
|
63
|
+
: null;
|
|
64
|
+
const assertions = [];
|
|
65
|
+
const temporaryRoots = [];
|
|
66
|
+
|
|
67
|
+
function pass(id, operation) {
|
|
68
|
+
operation();
|
|
69
|
+
assertions.push(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function temporary(prefix, base = tmpdir()) {
|
|
73
|
+
const value = mkdtempSync(resolve(base, prefix));
|
|
74
|
+
temporaryRoots.push(value);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clone(value) {
|
|
79
|
+
return structuredClone(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPrivateLayout(path) {
|
|
83
|
+
return existsSync(resolve(path, "marketplace/.agents/plugins/marketplace.json"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function publicLayoutFixture(sourceRoot = root) {
|
|
87
|
+
const publicRoot = resolve(temporary("forgerail-public-layout-"), "forgerail");
|
|
88
|
+
cpSync(sourceRoot, publicRoot, { recursive: true, dereference: false });
|
|
89
|
+
if (isPrivateLayout(publicRoot)) {
|
|
90
|
+
mkdirSync(resolve(publicRoot, ".agents/plugins"), { recursive: true });
|
|
91
|
+
cpSync(
|
|
92
|
+
resolve(publicRoot, "marketplace/.agents/plugins/marketplace.json"),
|
|
93
|
+
resolve(publicRoot, ".agents/plugins/marketplace.json"),
|
|
94
|
+
);
|
|
95
|
+
rmSync(resolve(publicRoot, "marketplace"), { recursive: true, force: true });
|
|
96
|
+
for (const name of externalPluginNames) {
|
|
97
|
+
cpSync(resolve(sourceRoot, "..", name), resolve(publicRoot, "plugins", name), { recursive: true, dereference: false });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return publicRoot;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function privateLayoutFixture(sourceRoot = root) {
|
|
104
|
+
if (isPrivateLayout(sourceRoot)) return sourceRoot;
|
|
105
|
+
const ownerRoot = temporary("forgerail-private-owner-");
|
|
106
|
+
const privateRoot = resolve(ownerRoot, "forgerail");
|
|
107
|
+
cpSync(sourceRoot, privateRoot, { recursive: true, dereference: false });
|
|
108
|
+
mkdirSync(resolve(privateRoot, "marketplace/.agents/plugins"), { recursive: true });
|
|
109
|
+
cpSync(
|
|
110
|
+
resolve(sourceRoot, ".agents/plugins/marketplace.json"),
|
|
111
|
+
resolve(privateRoot, "marketplace/.agents/plugins/marketplace.json"),
|
|
112
|
+
);
|
|
113
|
+
rmSync(resolve(privateRoot, ".agents"), { recursive: true, force: true });
|
|
114
|
+
for (const name of externalPluginNames) {
|
|
115
|
+
cpSync(resolve(sourceRoot, "plugins", name), resolve(ownerRoot, name), { recursive: true, dereference: false });
|
|
116
|
+
}
|
|
117
|
+
rmSync(resolve(privateRoot, "plugins"), { recursive: true, force: true });
|
|
118
|
+
return privateRoot;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
pass("identifier-leading-punctuation-rejected", () => {
|
|
123
|
+
const profile = clone(readJson(resolve(fixtureRoot, "effective-profile-v2.valid.json")));
|
|
124
|
+
profile.profileId = "_invalid";
|
|
125
|
+
assert.equal(validateContract("effective-profile-v2", profile).valid, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pass("adoption-plan-identifier-policy-matches-schema", () => {
|
|
129
|
+
const plan = clone(readJson(resolve(fixtureRoot, "adoption-plan.single-host.valid.json")));
|
|
130
|
+
const schema = readJson(resolve(root, "contracts/adoption-plan.schema.json"));
|
|
131
|
+
const schemaPattern = new RegExp(schema.properties.planId.pattern);
|
|
132
|
+
plan.planId = "a";
|
|
133
|
+
assert.equal(validateContract("adoption-plan", plan).valid, false);
|
|
134
|
+
assert.equal(schemaPattern.test(plan.planId), false);
|
|
135
|
+
plan.planId = "a1";
|
|
136
|
+
assert.equal(validateContract("adoption-plan", plan).valid, true);
|
|
137
|
+
assert.equal(schemaPattern.test(plan.planId), true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pass("invalid-calendar-date-rejected", () => {
|
|
141
|
+
const reason = clone(readJson(resolve(fixtureRoot, "limited-reason.valid.json")));
|
|
142
|
+
reason.observedAt = "2026-02-31T12:00:00Z";
|
|
143
|
+
assert.equal(validateContract("limited-reason", reason).valid, false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pass("literal-four-digit-years-are-preserved", () => {
|
|
147
|
+
const reason = clone(readJson(resolve(fixtureRoot, "limited-reason.valid.json")));
|
|
148
|
+
for (const observedAt of ["0000-02-29T00:00:00Z", "0001-01-01T00:00:00Z", "0099-12-31T23:59:59+23:59"]) {
|
|
149
|
+
reason.observedAt = observedAt;
|
|
150
|
+
assert.equal(validateContract("limited-reason", reason).valid, true, observedAt);
|
|
151
|
+
}
|
|
152
|
+
reason.observedAt = "0001-02-29T00:00:00Z";
|
|
153
|
+
assert.equal(validateContract("limited-reason", reason).valid, false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pass("windows-and-parent-paths-rejected", () => {
|
|
157
|
+
const adapter = clone(readJson(resolve(fixtureRoot, "host-adapter.codex.valid.json")));
|
|
158
|
+
for (const path of ["..\\outside", "C:\\outside", "\\\\server\\share", "dir//AGENTS.md", "dir/./AGENTS.md", "dir/", ...nonPortableHostPaths]) {
|
|
159
|
+
adapter.bindingTarget = path;
|
|
160
|
+
assert.equal(validateContract("host-adapter", adapter).valid, false, path);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pass("adoption-rejects-noncanonical-relative-path-segments", () => {
|
|
165
|
+
const workspace = temporary("forgerail-adoption-noncanonical-path-");
|
|
166
|
+
for (const path of ["dir//AGENTS.md", "dir/./AGENTS.md", "dir/", ...nonPortableHostPaths]) {
|
|
167
|
+
assert.throws(() => resolveAdoptionWriteTarget(workspace, path), /unsafe/, path);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const pack = readJson(resolve(root, "packs/workspace-health-review.json"));
|
|
172
|
+
const profileInput = {
|
|
173
|
+
workspace: "fixture-workspace",
|
|
174
|
+
rules: [{ id: "git.primary-branch", value: "main", source: "AGENTS.md", precedence: 1, status: "confirmed" }],
|
|
175
|
+
packs: [{ id: pack.id, state: "required", reason: "Required for the fixture." }],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
pass("duplicate-profile-identities-rejected", () => {
|
|
179
|
+
assert.equal(resolveProfile(profileInput, [pack, pack]).valid, false);
|
|
180
|
+
assert.equal(resolveProfile({ ...profileInput, packs: [...profileInput.packs, ...profileInput.packs] }, [pack]).valid, false);
|
|
181
|
+
assert.equal(resolveProfile({ ...profileInput, rules: [...profileInput.rules, ...profileInput.rules] }, [pack]).valid, false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const resolved = resolveProfile(profileInput, [pack]);
|
|
185
|
+
assert.equal(resolved.valid, true, resolved.errors.join("; "));
|
|
186
|
+
const envelope = readJson(resolve(fixtureRoot, "task-envelope.valid.json"));
|
|
187
|
+
|
|
188
|
+
pass("profile-schema-encodes-pack-identity", () => {
|
|
189
|
+
const schema = readJson(resolve(root, "contracts/effective-profile.schema.json"));
|
|
190
|
+
assert.equal(schema.properties?.packs?.type, "object");
|
|
191
|
+
assert.equal(schema.properties?.packs?.propertyNames?.pattern, "^[a-z][a-z0-9-]+$");
|
|
192
|
+
assert.deepEqual(Object.keys(resolved.profile.packs), [pack.id]);
|
|
193
|
+
assert.deepEqual(resolved.profile.packs[pack.id], { state: "required", reason: "Required for the fixture." });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pass("required-pack-and-effective-profile-bound", () => {
|
|
197
|
+
const missing = createLaunchContract(resolved.profile, envelope, "Codex", [pack]);
|
|
198
|
+
assert.equal(missing.valid, false);
|
|
199
|
+
assert.ok(missing.errors.some((error) => error.includes("omits required pack")));
|
|
200
|
+
const included = createLaunchContract(resolved.profile, { ...envelope, packs: [pack.id] }, "Codex", [pack]);
|
|
201
|
+
assert.equal(included.valid, true, included.errors.join("; "));
|
|
202
|
+
assert.equal(Object.hasOwn(included.launch.effectiveProfile, "workspace"), false);
|
|
203
|
+
assert.equal(included.launch.envelope.ownerWorkspace, profileInput.workspace);
|
|
204
|
+
assert.match(included.launch.effectiveProfile.digest, /^[0-9a-f]{64}$/);
|
|
205
|
+
assert.deepEqual(Object.keys(included.launch.effectivePackManifests), [pack.id]);
|
|
206
|
+
assert.match(included.launch.effectivePackManifests[pack.id], /^[0-9a-f]{64}$/);
|
|
207
|
+
assert.deepEqual(included.launch.envelope.packs, {
|
|
208
|
+
[pack.id]: included.launch.effectivePackManifests[pack.id],
|
|
209
|
+
});
|
|
210
|
+
const omittedManifest = clone(included.launch);
|
|
211
|
+
delete omittedManifest.effectivePackManifests[pack.id];
|
|
212
|
+
const omittedManifestValidation = validateContract("launch", omittedManifest);
|
|
213
|
+
assert.equal(omittedManifestValidation.valid, false);
|
|
214
|
+
assert.ok(omittedManifestValidation.errors.some((error) => error.includes(`does not match requested Pack identity: ${pack.id}`)));
|
|
215
|
+
const missingRequestedDigest = clone(included.launch);
|
|
216
|
+
missingRequestedDigest.envelope.packs[pack.id] = null;
|
|
217
|
+
assert.equal(validateContract("launch", missingRequestedDigest).valid, false);
|
|
218
|
+
const changedPack = { ...pack, purpose: `${pack.purpose} Changed.` };
|
|
219
|
+
const changedManifest = createLaunchContract(resolved.profile, { ...envelope, packs: [pack.id] }, "Codex", [changedPack]);
|
|
220
|
+
assert.equal(changedManifest.valid, true, changedManifest.errors.join("; "));
|
|
221
|
+
assert.notEqual(changedManifest.launch.effectivePackManifests[pack.id], included.launch.effectivePackManifests[pack.id]);
|
|
222
|
+
const workspaceMismatch = createLaunchContract(resolved.profile, { ...envelope, ownerWorkspace: "other", packs: [pack.id] }, "Codex", [pack]);
|
|
223
|
+
assert.equal(workspaceMismatch.valid, false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pass("launch-schema-binds-requested-pack-identities-with-their-digests", () => {
|
|
227
|
+
const schema = readJson(resolve(root, "contracts/launch-contract.schema.json"));
|
|
228
|
+
const packs = schema.properties?.envelope?.properties?.packs;
|
|
229
|
+
assert.equal(packs?.type, "object");
|
|
230
|
+
assert.equal(packs?.propertyNames?.pattern, "^[a-z][a-z0-9-]+$");
|
|
231
|
+
assert.equal(packs?.additionalProperties?.pattern, "^[0-9a-f]{64}$");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pass("launch-composition-does-not-mutate-requested-pack-order", () => {
|
|
235
|
+
const secondPack = readJson(resolve(root, "packs/architecture-convergence-audit.json"));
|
|
236
|
+
const twoPackInput = {
|
|
237
|
+
...profileInput,
|
|
238
|
+
packs: [
|
|
239
|
+
{ id: pack.id, state: "enabled", reason: "Enabled for ordering regression." },
|
|
240
|
+
{ id: secondPack.id, state: "enabled", reason: "Enabled for ordering regression." },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
const twoPackProfile = resolveProfile(twoPackInput, [pack, secondPack]);
|
|
244
|
+
assert.equal(twoPackProfile.valid, true, twoPackProfile.errors.join("; "));
|
|
245
|
+
const requested = { ...envelope, packs: [pack.id, secondPack.id] };
|
|
246
|
+
const before = JSON.stringify(requested);
|
|
247
|
+
const launch = createLaunchContract(twoPackProfile.profile, requested, "Codex", [pack, secondPack]);
|
|
248
|
+
assert.equal(launch.valid, true, launch.errors.join("; "));
|
|
249
|
+
assert.equal(JSON.stringify(requested), before);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pass("malformed-receipt-fails-closed", () => {
|
|
253
|
+
const result = verifyReceipt({}, workspaceRoot);
|
|
254
|
+
assert.equal(result.valid, false);
|
|
255
|
+
assert.equal(result.closeout, "incomplete");
|
|
256
|
+
assert.ok(result.errors.length > 0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
pass("malformed-package-metadata-is-visible-and-private", () => {
|
|
260
|
+
const workspace = temporary("forgerail-malformed-package-");
|
|
261
|
+
writeFileSync(resolve(workspace, "package.json"), "{ invalid json\n");
|
|
262
|
+
const result = diagnoseWorkspace(workspace, root);
|
|
263
|
+
assert.ok(result.gaps.includes("package-metadata-malformed"));
|
|
264
|
+
assert.equal(Object.hasOwn(result, "workspacePath"), false);
|
|
265
|
+
assert.equal(JSON.stringify(result).includes(workspace), false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
pass("unusable-package-json-roots-are-visible-and-private", () => {
|
|
269
|
+
for (const [name, value] of [["null", "null\n"], ["array", "[]\n"], ["scalar", '"package"\n']]) {
|
|
270
|
+
const workspace = temporary(`forgerail-${name}-package-`);
|
|
271
|
+
writeFileSync(resolve(workspace, "package.json"), value);
|
|
272
|
+
const result = diagnoseWorkspace(workspace, root);
|
|
273
|
+
assert.ok(result.gaps.includes("package-metadata-malformed"));
|
|
274
|
+
assert.equal(result.evidence.find(({ id }) => id === "package-metadata")?.value.reason, "invalid-root-shape");
|
|
275
|
+
assert.equal(JSON.stringify(result).includes(workspace), false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pass("diagnosis-rejects-linked-host-and-package-content", () => {
|
|
280
|
+
const workspace = temporary("forgerail-diagnosis-linked-inputs-");
|
|
281
|
+
const outside = temporary("forgerail-diagnosis-linked-outside-");
|
|
282
|
+
writeFileSync(resolve(outside, "package.json"), JSON.stringify({ scripts: { outsideSentinel: "do-not-observe" } }));
|
|
283
|
+
writeFileSync(resolve(outside, "AGENTS.md"), "<!-- forgerail:binding:codex:v1:start -->\noutside\n<!-- forgerail:binding:codex:v1:end -->\n");
|
|
284
|
+
mkdirSync(resolve(outside, "docs"));
|
|
285
|
+
writeFileSync(resolve(outside, "docs/decision.md"), "# outside\n");
|
|
286
|
+
symlinkSync(resolve(outside, "package.json"), resolve(workspace, "package.json"));
|
|
287
|
+
symlinkSync(resolve(outside, "AGENTS.md"), resolve(workspace, "AGENTS.md"));
|
|
288
|
+
symlinkSync(resolve(outside, "docs"), resolve(workspace, "docs"));
|
|
289
|
+
const result = diagnoseWorkspace(workspace, root);
|
|
290
|
+
assert.ok(result.gaps.includes("package-metadata-unavailable"));
|
|
291
|
+
assert.ok(result.gaps.includes("host-binding-unavailable:codex"));
|
|
292
|
+
assert.equal(result.adoption.currentLevel, "plugin-only");
|
|
293
|
+
assert.equal(JSON.stringify(result).includes("outsideSentinel"), false);
|
|
294
|
+
assert.equal(result.inheritedHabits.some(({ source }) => source === "docs/"), false);
|
|
295
|
+
const codex = result.evidence.find(({ id }) => id === "host-adapters").value.find(({ id }) => id === "codex");
|
|
296
|
+
assert.equal(codex.observed, true);
|
|
297
|
+
assert.equal(codex.readState, "unsafe-symbolic-link");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
pass("diagnosis-requires-a-bounded-markdown-record", () => {
|
|
301
|
+
const empty = temporary("forgerail-diagnosis-empty-records-");
|
|
302
|
+
mkdirSync(resolve(empty, "docs/adr"), { recursive: true });
|
|
303
|
+
const emptyResult = diagnoseWorkspace(empty, root);
|
|
304
|
+
assert.equal(emptyResult.inheritedHabits.some(({ type }) => type === "markdown-adr"), false);
|
|
305
|
+
mkdirSync(resolve(empty, "docs/adr/fake.md"));
|
|
306
|
+
assert.equal(diagnoseWorkspace(empty, root).inheritedHabits.some(({ type }) => type === "markdown-adr"), false);
|
|
307
|
+
const outside = resolve(temporary("forgerail-diagnosis-markdown-outside-"), "outside.md");
|
|
308
|
+
writeFileSync(outside, "# Outside\n");
|
|
309
|
+
symlinkSync(outside, resolve(empty, "docs/adr/linked.md"));
|
|
310
|
+
assert.equal(diagnoseWorkspace(empty, root).inheritedHabits.some(({ type }) => type === "markdown-adr"), false);
|
|
311
|
+
writeFileSync(resolve(empty, "docs/adr/0001-decision.md"), "# Decision\n");
|
|
312
|
+
const populatedResult = diagnoseWorkspace(empty, root);
|
|
313
|
+
assert.equal(populatedResult.inheritedHabits.some(({ type, source }) => type === "markdown-adr" && source === "docs/adr/"), true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
pass("diagnosis-bounds-regular-file-content", () => {
|
|
317
|
+
const workspace = temporary("forgerail-diagnosis-oversized-");
|
|
318
|
+
writeFileSync(resolve(workspace, "package.json"), " ".repeat(4 * 1024 * 1024 + 1));
|
|
319
|
+
const result = diagnoseWorkspace(workspace, root);
|
|
320
|
+
assert.ok(result.gaps.includes("package-metadata-unavailable"));
|
|
321
|
+
assert.equal(result.evidence.find(({ id }) => id === "package-metadata")?.value.reason, "oversized");
|
|
322
|
+
assert.equal(JSON.stringify(result).includes(workspace), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (process.platform !== "win32") pass("diagnosis-rejects-fifos-without-blocking", () => {
|
|
326
|
+
for (const target of ["package.json", "AGENTS.md"]) {
|
|
327
|
+
const workspace = temporary(`forgerail-diagnosis-fifo-${target === "package.json" ? "package" : "host"}-`);
|
|
328
|
+
execFileSync("mkfifo", [resolve(workspace, target)]);
|
|
329
|
+
const execution = spawnSync(process.execPath, [
|
|
330
|
+
resolve(root, "scripts/forgerail.mjs"),
|
|
331
|
+
"diagnose",
|
|
332
|
+
"--workspace",
|
|
333
|
+
workspace,
|
|
334
|
+
], { encoding: "utf8", timeout: 2_000 });
|
|
335
|
+
assert.equal(execution.error, undefined, execution.error?.message);
|
|
336
|
+
assert.equal(execution.status, 0, execution.stderr);
|
|
337
|
+
const result = JSON.parse(execution.stdout);
|
|
338
|
+
const expectedGap = target === "package.json" ? "package-metadata-unavailable" : "host-binding-unavailable:codex";
|
|
339
|
+
assert.ok(result.gaps.includes(expectedGap));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
pass("adoption-closeout-regressions", () => {
|
|
344
|
+
execFileSync(process.execPath, ["--test", resolve(root, "scripts/adoption-closeout-regressions.mjs")], { encoding: "utf8", timeout: 30_000 });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
pass("host-registry-owns-future-adapter-diagnosis-and-templates", () => {
|
|
348
|
+
const pluginRoot = temporary("forgerail-future-adapter-plugin-");
|
|
349
|
+
cpSync(resolve(root, "adapters"), resolve(pluginRoot, "adapters"), { recursive: true });
|
|
350
|
+
cpSync(resolve(root, "templates"), resolve(pluginRoot, "templates"), { recursive: true });
|
|
351
|
+
const adapter = {
|
|
352
|
+
schemaVersion: "1.0",
|
|
353
|
+
id: "nova",
|
|
354
|
+
displayName: "Nova Agent",
|
|
355
|
+
status: "profile-only",
|
|
356
|
+
instructionDiscovery: "explicit-only",
|
|
357
|
+
skillDiscovery: "unknown",
|
|
358
|
+
bindingTarget: "NOVA.md",
|
|
359
|
+
detectionTargets: ["NOVA.md"],
|
|
360
|
+
bindingModes: ["thin-reference"],
|
|
361
|
+
bindingTemplates: { "thin-reference": "bindings/nova-thin.md" },
|
|
362
|
+
unmanagedBindingPolicy: "append-managed-block",
|
|
363
|
+
managedMarker: "forgerail:binding:nova:v1",
|
|
364
|
+
activationBoundary: "host-specific-verification-required",
|
|
365
|
+
verification: { mode: "profile-only", expectedSkills: [] },
|
|
366
|
+
limitations: ["Synthetic future-adapter regression."],
|
|
367
|
+
};
|
|
368
|
+
writeFileSync(resolve(pluginRoot, "adapters/nova.json"), `${JSON.stringify(adapter, null, 2)}\n`);
|
|
369
|
+
writeFileSync(resolve(pluginRoot, "templates/bindings/nova-thin.md"), "<!-- forgerail:binding:nova:v1:start -->\nFollow `FORGERAIL.md`.\n<!-- forgerail:binding:nova:v1:end -->\n");
|
|
370
|
+
const workspace = temporary("forgerail-future-adapter-workspace-");
|
|
371
|
+
writeFileSync(resolve(workspace, "NOVA.md"), "Nova instructions\n");
|
|
372
|
+
const plan = planAdoption(pluginRoot, workspace);
|
|
373
|
+
assert.deepEqual(Object.keys(plan.hostSelection.hosts), ["nova"]);
|
|
374
|
+
assert.equal(plan.proposedWrites.some(({ path }) => path === "NOVA.md"), true);
|
|
375
|
+
assert.equal(plan.proposedWrites.find(({ path }) => path === "NOVA.md")?.operation, "append-managed-block");
|
|
376
|
+
const diagnosis = diagnoseWorkspace(workspace, pluginRoot);
|
|
377
|
+
const hosts = diagnosis.evidence.find(({ id }) => id === "host-adapters").value;
|
|
378
|
+
assert.equal(hosts.some(({ id, target, observed }) => id === "nova" && target === "NOVA.md" && observed), true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
pass("host-registry-reports-missing-id-by-file", () => {
|
|
382
|
+
const pluginRoot = temporary("forgerail-missing-adapter-id-");
|
|
383
|
+
cpSync(resolve(root, "adapters"), resolve(pluginRoot, "adapters"), { recursive: true });
|
|
384
|
+
cpSync(resolve(root, "templates"), resolve(pluginRoot, "templates"), { recursive: true });
|
|
385
|
+
const path = resolve(pluginRoot, "adapters/codex.json");
|
|
386
|
+
const adapter = readJson(path);
|
|
387
|
+
delete adapter.id;
|
|
388
|
+
writeFileSync(path, `${JSON.stringify(adapter, null, 2)}\n`);
|
|
389
|
+
const registry = loadHostAdapters(pluginRoot);
|
|
390
|
+
assert.equal(registry.valid, false);
|
|
391
|
+
assert.equal(registry.errors.some((error) => error.startsWith("codex.json:")), true);
|
|
392
|
+
assert.equal(registry.errors.some((error) => error.includes("ReferenceError")), false);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
pass("host-registry-reports-malformed-json-by-file", () => {
|
|
396
|
+
const pluginRoot = temporary("forgerail-malformed-adapter-json-");
|
|
397
|
+
cpSync(resolve(root, "adapters"), resolve(pluginRoot, "adapters"), { recursive: true });
|
|
398
|
+
cpSync(resolve(root, "templates"), resolve(pluginRoot, "templates"), { recursive: true });
|
|
399
|
+
writeFileSync(resolve(pluginRoot, "adapters/codex.json"), "{ malformed\n");
|
|
400
|
+
const registry = loadHostAdapters(pluginRoot);
|
|
401
|
+
assert.equal(registry.valid, false);
|
|
402
|
+
assert.ok(registry.errors.includes("codex.json: host adapter is not valid JSON"));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
pass("host-registry-rejects-uncomposable-targets-and-template-markers", () => {
|
|
406
|
+
const makeRoot = (name) => {
|
|
407
|
+
const pluginRoot = temporary(name);
|
|
408
|
+
cpSync(resolve(root, "adapters"), resolve(pluginRoot, "adapters"), { recursive: true });
|
|
409
|
+
cpSync(resolve(root, "templates"), resolve(pluginRoot, "templates"), { recursive: true });
|
|
410
|
+
return pluginRoot;
|
|
411
|
+
};
|
|
412
|
+
const base = {
|
|
413
|
+
schemaVersion: "1.0",
|
|
414
|
+
id: "nova",
|
|
415
|
+
displayName: "Nova Agent",
|
|
416
|
+
status: "profile-only",
|
|
417
|
+
instructionDiscovery: "explicit-only",
|
|
418
|
+
skillDiscovery: "unknown",
|
|
419
|
+
bindingTarget: "NOVA.md",
|
|
420
|
+
detectionTargets: ["NOVA.md"],
|
|
421
|
+
bindingModes: ["thin-reference"],
|
|
422
|
+
bindingTemplates: { "thin-reference": "bindings/nova-thin.md" },
|
|
423
|
+
unmanagedBindingPolicy: "append-managed-block",
|
|
424
|
+
managedMarker: "forgerail:binding:nova:v1",
|
|
425
|
+
activationBoundary: "host-specific-verification-required",
|
|
426
|
+
verification: { mode: "profile-only", expectedSkills: [] },
|
|
427
|
+
limitations: ["Synthetic registry composability regression."],
|
|
428
|
+
};
|
|
429
|
+
for (const [target, expected] of [["AGENTS.md", "adapter"], ["agents.md", "adapter"], [".cursor", "adapter"], ["FORGERAIL.md", "reserved"], ["forgerail.md", "reserved"]]) {
|
|
430
|
+
const pluginRoot = makeRoot(`forgerail-colliding-adapter-${expected}-`);
|
|
431
|
+
writeFileSync(resolve(pluginRoot, "templates/bindings/nova-thin.md"), "<!-- forgerail:binding:nova:v1:start -->\nFollow `FORGERAIL.md`.\n<!-- forgerail:binding:nova:v1:end -->\n");
|
|
432
|
+
writeFileSync(resolve(pluginRoot, "adapters/nova.json"), `${JSON.stringify({ ...base, bindingTarget: target }, null, 2)}\n`);
|
|
433
|
+
const registry = loadHostAdapters(pluginRoot);
|
|
434
|
+
assert.equal(registry.valid, false);
|
|
435
|
+
assert.equal(registry.errors.some((error) => error.includes(expected === "adapter" ? "binding target conflicts with" : "reserved shared contract")), true);
|
|
436
|
+
}
|
|
437
|
+
const markerRoot = makeRoot("forgerail-wrong-template-marker-");
|
|
438
|
+
writeFileSync(resolve(markerRoot, "adapters/nova.json"), `${JSON.stringify({
|
|
439
|
+
...base,
|
|
440
|
+
bindingTemplates: { "thin-reference": "bindings/codex-thin.md" },
|
|
441
|
+
}, null, 2)}\n`);
|
|
442
|
+
const markerRegistry = loadHostAdapters(markerRoot);
|
|
443
|
+
assert.equal(markerRegistry.valid, false);
|
|
444
|
+
assert.equal(markerRegistry.errors.some((error) => error.includes("exactly one ordered forgerail:binding:nova:v1 boundary")), true);
|
|
445
|
+
for (const path of nonPortableHostPaths) {
|
|
446
|
+
for (const adapter of [
|
|
447
|
+
{ ...base, bindingTarget: path },
|
|
448
|
+
{ ...base, detectionTargets: [path] },
|
|
449
|
+
{ ...base, bindingTemplates: { "thin-reference": path } },
|
|
450
|
+
]) {
|
|
451
|
+
const invalidRoot = makeRoot("forgerail-nonportable-adapter-path-");
|
|
452
|
+
writeFileSync(resolve(invalidRoot, "adapters/nova.json"), `${JSON.stringify(adapter, null, 2)}\n`);
|
|
453
|
+
const invalidRegistry = loadHostAdapters(invalidRoot);
|
|
454
|
+
assert.equal(invalidRegistry.valid, false, path);
|
|
455
|
+
assert.equal(invalidRegistry.errors.some((error) => error.includes("invalid format")), true, path);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
pass("host-schema-expresses-runtime-status-semantics", () => {
|
|
461
|
+
const hostSchema = readJson(resolve(root, "contracts/host-adapter.schema.json"));
|
|
462
|
+
const planSchema = readJson(resolve(root, "contracts/adoption-plan.schema.json"));
|
|
463
|
+
const supported = hostSchema.allOf.find((rule) => rule.if?.properties?.status?.const === "supported");
|
|
464
|
+
const profileOnly = hostSchema.allOf.find((rule) => rule.if?.properties?.status?.const === "profile-only");
|
|
465
|
+
assert.equal(hostSchema.properties.bindingModes.contains.const, "thin-reference");
|
|
466
|
+
assert.equal(supported.then.properties.activationBoundary.const, "new-task-required");
|
|
467
|
+
assert.equal(supported.then.properties.verification.properties.mode.const, "new-task-discovery");
|
|
468
|
+
assert.equal(supported.then.properties.verification.properties.expectedSkills.minItems, 1);
|
|
469
|
+
assert.equal(profileOnly.then.properties.activationBoundary.const, "host-specific-verification-required");
|
|
470
|
+
assert.equal(profileOnly.then.properties.verification.properties.mode.const, "profile-only");
|
|
471
|
+
assert.equal(profileOnly.then.properties.verification.properties.expectedSkills.maxItems, 0);
|
|
472
|
+
const schemaPathPatterns = [
|
|
473
|
+
hostSchema.properties.bindingTarget.pattern,
|
|
474
|
+
hostSchema.properties.detectionTargets.items.pattern,
|
|
475
|
+
hostSchema.properties.bindingTemplates.properties["managed-block"].pattern,
|
|
476
|
+
hostSchema.properties.bindingTemplates.properties["thin-reference"].pattern,
|
|
477
|
+
planSchema.properties.hostSelection.properties.hosts.additionalProperties.properties.bindingTarget.pattern,
|
|
478
|
+
planSchema.properties.proposedWrites.items.properties.path.pattern,
|
|
479
|
+
].map((pattern) => new RegExp(pattern));
|
|
480
|
+
for (const pattern of schemaPathPatterns) {
|
|
481
|
+
assert.equal(pattern.test(".cursor/rules/forgerail.mdc"), true);
|
|
482
|
+
for (const path of nonPortableHostPaths) assert.equal(pattern.test(path), false, path);
|
|
483
|
+
}
|
|
484
|
+
const validPlan = readJson(resolve(fixtureRoot, "adoption-plan.single-host.valid.json"));
|
|
485
|
+
for (const path of nonPortableHostPaths) {
|
|
486
|
+
const invalidHost = clone(validPlan);
|
|
487
|
+
invalidHost.hostSelection.hosts.codex.bindingTarget = path;
|
|
488
|
+
assert.equal(validateContract("adoption-plan", invalidHost).valid, false, path);
|
|
489
|
+
const invalidWrite = clone(validPlan);
|
|
490
|
+
invalidWrite.proposedWrites[0].path = path;
|
|
491
|
+
invalidWrite.proposedWrites[0].approvalSha256 = adoptionWriteApprovalDigest(invalidWrite.proposedWrites[0]);
|
|
492
|
+
assert.equal(validateContract("adoption-plan", invalidWrite).valid, false, path);
|
|
493
|
+
}
|
|
494
|
+
const planHostRules = planSchema.properties.hostSelection.properties.hosts.additionalProperties.allOf;
|
|
495
|
+
assert.equal(planHostRules.find((rule) => rule.if?.properties?.status?.const === "supported").then.properties.verificationMode.const, "new-task-discovery");
|
|
496
|
+
assert.equal(planHostRules.find((rule) => rule.if?.properties?.status?.const === "profile-only").then.properties.verificationMode.const, "profile-only");
|
|
497
|
+
const invalidPlan = clone(readJson(resolve(fixtureRoot, "adoption-plan.single-host.valid.json")));
|
|
498
|
+
invalidPlan.hostSelection.hosts.codex.verificationMode = "profile-only";
|
|
499
|
+
assert.equal(validateContract("adoption-plan", invalidPlan).valid, false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
pass("host-registry-requires-one-template-per-binding-mode", () => {
|
|
503
|
+
const adapter = clone(readJson(resolve(fixtureRoot, "host-adapter.codex.valid.json")));
|
|
504
|
+
delete adapter.bindingTemplates["managed-block"];
|
|
505
|
+
assert.equal(validateContract("host-adapter", adapter).valid, false);
|
|
506
|
+
adapter.bindingTemplates["managed-block"] = "../outside.md";
|
|
507
|
+
assert.equal(validateContract("host-adapter", adapter).valid, false);
|
|
508
|
+
adapter.bindingTemplates["managed-block"] = "bindings/codex-compact.md";
|
|
509
|
+
adapter.unmanagedBindingPolicy = "unknown";
|
|
510
|
+
assert.equal(validateContract("host-adapter", adapter).valid, false);
|
|
511
|
+
adapter.unmanagedBindingPolicy = "append-managed-block";
|
|
512
|
+
adapter.bindingModes = ["managed-block"];
|
|
513
|
+
delete adapter.bindingTemplates["thin-reference"];
|
|
514
|
+
const managedOnly = validateContract("host-adapter", adapter);
|
|
515
|
+
assert.equal(managedOnly.valid, false);
|
|
516
|
+
assert.ok(managedOnly.errors.includes("hostAdapter must support thin-reference for all-Host adoption"));
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
pass("adoption-cli-rejects-missing-option-values", () => {
|
|
520
|
+
const workspace = temporary("forgerail-adoption-cli-options-");
|
|
521
|
+
writeFileSync(resolve(workspace, "AGENTS.md"), "Codex instructions\n");
|
|
522
|
+
for (const [option, argv] of [
|
|
523
|
+
["--host", ["adoption-plan", "--workspace", workspace, "--host"]],
|
|
524
|
+
["--host", ["adoption-plan", "--workspace", workspace, "--host", "--selection", "all-available"]],
|
|
525
|
+
["--selection", ["adoption-plan", "--workspace", workspace, "--selection"]],
|
|
526
|
+
]) {
|
|
527
|
+
const execution = spawnSync(process.execPath, [resolve(root, "scripts/forgerail.mjs"), ...argv], { encoding: "utf8" });
|
|
528
|
+
assert.equal(execution.status, 1);
|
|
529
|
+
assert.match(execution.stderr, new RegExp(`${option} requires a value`));
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
pass("adoption-cli-rejects-unknown-and-equals-style-options", () => {
|
|
534
|
+
const workspace = temporary("forgerail-adoption-cli-unknown-options-");
|
|
535
|
+
writeFileSync(resolve(workspace, "AGENTS.md"), "Codex instructions\n");
|
|
536
|
+
for (const argv of [
|
|
537
|
+
["adoption-plan", "--workspace", workspace, "--host=codex"],
|
|
538
|
+
["adoption-plan", "--workspace", workspace, "--selection=all-available"],
|
|
539
|
+
["adoption-plan", "--workspace", workspace, "--hosst", "codex"],
|
|
540
|
+
]) {
|
|
541
|
+
const execution = spawnSync(process.execPath, [resolve(root, "scripts/forgerail.mjs"), ...argv], { encoding: "utf8" });
|
|
542
|
+
assert.equal(execution.status, 1);
|
|
543
|
+
assert.match(execution.stderr, /option values must be provided separately|unknown option/);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
pass("adoption-rejects-symlink-target", () => {
|
|
548
|
+
const workspace = temporary("forgerail-adoption-");
|
|
549
|
+
const outside = resolve(temporary("forgerail-outside-"), "AGENTS.md");
|
|
550
|
+
writeFileSync(outside, "outside\n");
|
|
551
|
+
symlinkSync(outside, resolve(workspace, "AGENTS.md"));
|
|
552
|
+
assert.throws(() => planAdoption(root, workspace, ["codex"]), /symbolic link/);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
pass("adoption-rejects-dangling-symlink-target-and-ancestor", () => {
|
|
556
|
+
const finalWorkspace = temporary("forgerail-adoption-dangling-final-");
|
|
557
|
+
const finalOutside = resolve(temporary("forgerail-dangling-final-outside-"), "missing-AGENTS.md");
|
|
558
|
+
symlinkSync(finalOutside, resolve(finalWorkspace, "AGENTS.md"));
|
|
559
|
+
assert.throws(() => planAdoption(root, finalWorkspace, ["codex"]), /symbolic link/);
|
|
560
|
+
assert.equal(existsSync(finalOutside), false);
|
|
561
|
+
|
|
562
|
+
const ancestorWorkspace = temporary("forgerail-adoption-dangling-ancestor-");
|
|
563
|
+
const ancestorOutside = resolve(temporary("forgerail-dangling-ancestor-outside-"), "missing-rules");
|
|
564
|
+
mkdirSync(resolve(ancestorWorkspace, ".cursor"), { recursive: true });
|
|
565
|
+
symlinkSync(ancestorOutside, resolve(ancestorWorkspace, ".cursor/rules"));
|
|
566
|
+
assert.throws(() => planAdoption(root, ancestorWorkspace, ["cursor"]), /symbolic link/);
|
|
567
|
+
assert.equal(existsSync(resolve(ancestorOutside, "forgerail.mdc")), false);
|
|
568
|
+
|
|
569
|
+
const driftWorkspace = temporary("forgerail-adoption-dangling-drift-");
|
|
570
|
+
const plan = planAdoption(root, driftWorkspace, ["codex"]);
|
|
571
|
+
const driftOutside = resolve(temporary("forgerail-dangling-drift-outside-"), "missing-AGENTS.md");
|
|
572
|
+
symlinkSync(driftOutside, resolve(driftWorkspace, "AGENTS.md"));
|
|
573
|
+
assert.throws(() => renderProposedWrite(driftWorkspace, plan.proposedWrites[0]), /symbolic link/);
|
|
574
|
+
assert.throws(() => applyApprovedAdoptionWrite(driftWorkspace, plan.proposedWrites[0], plan.proposedWrites[0].approvalSha256), /symbolic link|changed before write/);
|
|
575
|
+
assert.equal(existsSync(driftOutside), false);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
pass("adoption-resolves-host-intent-through-the-registry", () => {
|
|
579
|
+
const explicitWorkspace = temporary("forgerail-adoption-explicit-host-");
|
|
580
|
+
const sharedClaude = resolve(temporary("forgerail-adoption-unselected-host-"), "CLAUDE.md");
|
|
581
|
+
writeFileSync(sharedClaude, "shared Claude instructions\n");
|
|
582
|
+
symlinkSync(sharedClaude, resolve(explicitWorkspace, "CLAUDE.md"));
|
|
583
|
+
const explicit = planAdoption(root, explicitWorkspace, ["codex"]);
|
|
584
|
+
assert.equal(explicit.hostSelection.mode, "explicit");
|
|
585
|
+
assert.deepEqual(Object.keys(explicit.hostSelection.hosts), ["codex"]);
|
|
586
|
+
assert.equal(explicit.strategy, "single-host-managed-block");
|
|
587
|
+
assert.equal(explicit.proposedWrites[0].path, "AGENTS.md");
|
|
588
|
+
|
|
589
|
+
const detectedWorkspace = temporary("forgerail-adoption-detected-hosts-");
|
|
590
|
+
writeFileSync(resolve(detectedWorkspace, "AGENTS.md"), "Codex instructions\n");
|
|
591
|
+
mkdirSync(resolve(detectedWorkspace, ".cursor"));
|
|
592
|
+
const detected = planAdoption(root, detectedWorkspace);
|
|
593
|
+
assert.equal(detected.hostSelection.mode, "all-detected");
|
|
594
|
+
assert.deepEqual(Object.keys(detected.hostSelection.hosts), ["codex", "cursor"]);
|
|
595
|
+
|
|
596
|
+
const cursorOnlyWorkspace = temporary("forgerail-adoption-detected-cursor-");
|
|
597
|
+
mkdirSync(resolve(cursorOnlyWorkspace, ".cursor"));
|
|
598
|
+
const cursorOnly = planAdoption(root, cursorOnlyWorkspace);
|
|
599
|
+
assert.deepEqual(Object.keys(cursorOnly.hostSelection.hosts), ["cursor"]);
|
|
600
|
+
assert.equal(cursorOnly.strategy, "shared-contract-with-thin-bindings");
|
|
601
|
+
assert.deepEqual(cursorOnly.proposedWrites.map(({ path }) => path), ["FORGERAIL.md", ".cursor/rules/forgerail.mdc"]);
|
|
602
|
+
|
|
603
|
+
const claudeOnlyWorkspace = temporary("forgerail-adoption-detected-claude-");
|
|
604
|
+
mkdirSync(resolve(claudeOnlyWorkspace, ".claude"));
|
|
605
|
+
const claudeOnly = planAdoption(root, claudeOnlyWorkspace);
|
|
606
|
+
assert.deepEqual(Object.keys(claudeOnly.hostSelection.hosts), ["claude-code"]);
|
|
607
|
+
assert.equal(claudeOnly.strategy, "shared-contract-with-thin-bindings");
|
|
608
|
+
assert.deepEqual(claudeOnly.proposedWrites.map(({ path }) => path), ["FORGERAIL.md", "CLAUDE.md"]);
|
|
609
|
+
|
|
610
|
+
const availableWorkspace = temporary("forgerail-adoption-available-hosts-");
|
|
611
|
+
const available = planAdoption(root, availableWorkspace, [], "lightweight-adoption", "all-available");
|
|
612
|
+
assert.equal(available.hostSelection.mode, "all-available");
|
|
613
|
+
assert.deepEqual(Object.keys(available.hostSelection.hosts), ["claude-code", "codex", "cursor"]);
|
|
614
|
+
assert.throws(() => planAdoption(root, availableWorkspace), /no registered host was detected/);
|
|
615
|
+
assert.throws(() => planAdoption(root, availableWorkspace, ["unknown-host"]), /unknown host adapter/);
|
|
616
|
+
assert.throws(
|
|
617
|
+
() => planAdoption(root, availableWorkspace, ["codex"], "lightweight-adoption", "all-available"),
|
|
618
|
+
/cannot be combined with --host/,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const malformed = clone(explicit);
|
|
622
|
+
malformed.hosts = [{ adapterId: "cursor" }];
|
|
623
|
+
const validation = validateContract("adoption-plan", malformed);
|
|
624
|
+
assert.equal(validation.valid, false);
|
|
625
|
+
assert.ok(validation.errors.includes("adoptionPlan.hosts is unsupported"));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
pass("adoption-schema-uses-one-identity-keyed-host-selection", () => {
|
|
629
|
+
const schema = readJson(resolve(root, "contracts/adoption-plan.schema.json"));
|
|
630
|
+
assert.deepEqual(schema.properties.hostSelection.required, ["mode", "hosts"]);
|
|
631
|
+
assert.equal(Object.hasOwn(schema.properties, "hosts"), false);
|
|
632
|
+
const hosts = schema.properties.hostSelection.properties.hosts;
|
|
633
|
+
assert.equal(hosts.type, "object");
|
|
634
|
+
assert.equal(hosts.minProperties, 1);
|
|
635
|
+
assert.equal(hosts.propertyNames.pattern, "^[a-z][a-z0-9-]+$");
|
|
636
|
+
assert.deepEqual(hosts.additionalProperties.required, ["status", "bindingTarget", "verificationMode"]);
|
|
637
|
+
assert.equal(hosts.additionalProperties.additionalProperties, false);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
pass("adoption-cli-defaults-to-detected-hosts", () => {
|
|
641
|
+
const workspace = temporary("forgerail-adoption-cli-detected-");
|
|
642
|
+
writeFileSync(resolve(workspace, "AGENTS.md"), "Codex instructions\n");
|
|
643
|
+
const output = execFileSync(process.execPath, [
|
|
644
|
+
resolve(root, "scripts/forgerail.mjs"),
|
|
645
|
+
"adoption-plan",
|
|
646
|
+
"--workspace",
|
|
647
|
+
workspace,
|
|
648
|
+
], { encoding: "utf8" });
|
|
649
|
+
const plan = JSON.parse(output);
|
|
650
|
+
assert.equal(plan.hostSelection.mode, "all-detected");
|
|
651
|
+
assert.deepEqual(Object.keys(plan.hostSelection.hosts), ["codex"]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
pass("adoption-rejects-windows-drive-relative-targets", () => {
|
|
655
|
+
const workspace = temporary("forgerail-adoption-drive-relative-");
|
|
656
|
+
assert.throws(() => resolveAdoptionWriteTarget(workspace, "C:AGENTS.md"), /unsafe/);
|
|
657
|
+
const adapter = readJson(resolve(root, "adapters/codex.json"));
|
|
658
|
+
assert.equal(validateContract("host-adapter", { ...adapter, bindingTarget: "C:AGENTS.md" }).valid, false);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
if (process.platform !== "win32") pass("adoption-rejects-non-regular-targets-before-read", () => {
|
|
662
|
+
for (const existing of [false, true]) {
|
|
663
|
+
const workspace = temporary(`forgerail-adoption-fifo-${existing ? "existing" : "create"}-`);
|
|
664
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
665
|
+
if (existing) writeFileSync(target, "existing instructions\n");
|
|
666
|
+
const write = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
667
|
+
rmSync(target, { force: true });
|
|
668
|
+
execFileSync("mkfifo", [target]);
|
|
669
|
+
assert.throws(
|
|
670
|
+
() => applyApprovedAdoptionWrite(workspace, write, write.approvalSha256),
|
|
671
|
+
/not a regular file|changed before write/,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const directoryWorkspace = temporary("forgerail-adoption-directory-target-");
|
|
676
|
+
const directoryWrite = planAdoption(root, directoryWorkspace, ["codex"]).proposedWrites[0];
|
|
677
|
+
mkdirSync(resolve(directoryWorkspace, "AGENTS.md"));
|
|
678
|
+
assert.throws(
|
|
679
|
+
() => applyApprovedAdoptionWrite(directoryWorkspace, directoryWrite, directoryWrite.approvalSha256),
|
|
680
|
+
/not a regular file|changed before write/,
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
pass("adoption-applies-only-approved-content", () => {
|
|
685
|
+
const workspace = temporary("forgerail-adoption-approved-content-");
|
|
686
|
+
const plan = planAdoption(root, workspace, ["codex", "cursor"]);
|
|
687
|
+
const write = clone(plan.proposedWrites.find(({ path }) => path === ".cursor/rules/forgerail.mdc"));
|
|
688
|
+
const approvedWriteDigest = write.approvalSha256;
|
|
689
|
+
write.content = `${write.content}\nunapproved\n`;
|
|
690
|
+
assert.throws(() => renderProposedWrite(workspace, write), /approved content digest does not match/);
|
|
691
|
+
assert.throws(() => applyApprovedAdoptionWrite(workspace, write, approvedWriteDigest), /approved write digest does not match/);
|
|
692
|
+
assert.equal(existsSync(resolve(workspace, ".cursor")), false);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
pass("adoption-binds-approved-write-metadata-before-destination-selection", () => {
|
|
696
|
+
for (const mutate of [
|
|
697
|
+
(write) => { write.workspaceSha256 = "0".repeat(64); },
|
|
698
|
+
(write) => { write.path = "UNAPPROVED.md"; },
|
|
699
|
+
(write) => { write.operation = "append-managed-block"; },
|
|
700
|
+
(write) => { write.managedMarker = "forgerail:binding:cursor:v1"; },
|
|
701
|
+
]) {
|
|
702
|
+
const workspace = temporary("forgerail-adoption-approved-metadata-");
|
|
703
|
+
const write = clone(planAdoption(root, workspace, ["codex"]).proposedWrites[0]);
|
|
704
|
+
const approvedWriteDigest = write.approvalSha256;
|
|
705
|
+
mutate(write);
|
|
706
|
+
assert.throws(() => applyApprovedAdoptionWrite(workspace, write, approvedWriteDigest), /approved write digest does not match/);
|
|
707
|
+
assert.deepEqual(readdirSync(workspace), []);
|
|
708
|
+
}
|
|
709
|
+
const workspace = temporary("forgerail-adoption-missing-approval-digest-");
|
|
710
|
+
const write = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
711
|
+
assert.throws(() => applyApprovedAdoptionWrite(workspace, write), /approved write digest does not match/);
|
|
712
|
+
assert.deepEqual(readdirSync(workspace), []);
|
|
713
|
+
|
|
714
|
+
const approvedWorkspace = temporary("forgerail-adoption-approved-workspace-");
|
|
715
|
+
const replayWorkspace = temporary("forgerail-adoption-replay-workspace-");
|
|
716
|
+
const approved = planAdoption(root, approvedWorkspace, ["codex"]).proposedWrites[0];
|
|
717
|
+
assert.throws(
|
|
718
|
+
() => applyApprovedAdoptionWrite(replayWorkspace, approved, approved.approvalSha256),
|
|
719
|
+
/approved write digest does not match/,
|
|
720
|
+
);
|
|
721
|
+
assert.deepEqual(readdirSync(approvedWorkspace), []);
|
|
722
|
+
assert.deepEqual(readdirSync(replayWorkspace), []);
|
|
723
|
+
|
|
724
|
+
const dynamicWorkspace = temporary("forgerail-adoption-dynamic-write-");
|
|
725
|
+
const stable = planAdoption(root, dynamicWorkspace, ["codex"]).proposedWrites[0];
|
|
726
|
+
let pathReads = 0;
|
|
727
|
+
const dynamic = { ...stable };
|
|
728
|
+
Object.defineProperty(dynamic, "path", {
|
|
729
|
+
enumerable: true,
|
|
730
|
+
get() {
|
|
731
|
+
pathReads += 1;
|
|
732
|
+
return pathReads === 1 ? stable.path : "UNAPPROVED.md";
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
const receipt = applyApprovedAdoptionWrite(dynamicWorkspace, dynamic, stable.approvalSha256);
|
|
736
|
+
assert.equal(receipt.path, "AGENTS.md");
|
|
737
|
+
assert.equal(existsSync(resolve(dynamicWorkspace, "AGENTS.md")), true);
|
|
738
|
+
assert.equal(existsSync(resolve(dynamicWorkspace, "UNAPPROVED.md")), false);
|
|
739
|
+
assert.equal(pathReads, 1);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
pass("adoption-rejects-approved-but-unsupported-operations", () => {
|
|
743
|
+
const workspace = temporary("forgerail-adoption-unsupported-operation-");
|
|
744
|
+
writeFileSync(resolve(workspace, "AGENTS.md"), "existing instructions\n");
|
|
745
|
+
const approved = clone(planAdoption(root, workspace, ["codex"]).proposedWrites[0]);
|
|
746
|
+
approved.operation = "delete";
|
|
747
|
+
approved.approvalSha256 = adoptionWriteApprovalDigest(approved);
|
|
748
|
+
assert.throws(
|
|
749
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256),
|
|
750
|
+
/operation is unsupported/,
|
|
751
|
+
);
|
|
752
|
+
assert.equal(readFileSync(resolve(workspace, "AGENTS.md"), "utf8"), "existing instructions\n");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
pass("adoption-binds-approved-workspace-directory-identity", () => {
|
|
756
|
+
const workspace = temporary("forgerail-adoption-workspace-identity-");
|
|
757
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
758
|
+
const originalWorkspace = `${workspace}-approved-inode`;
|
|
759
|
+
renameSync(workspace, originalWorkspace);
|
|
760
|
+
mkdirSync(workspace);
|
|
761
|
+
|
|
762
|
+
assert.throws(
|
|
763
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256),
|
|
764
|
+
/approved write digest does not match|workspace directory identity changed/,
|
|
765
|
+
);
|
|
766
|
+
assert.deepEqual(readdirSync(workspace), []);
|
|
767
|
+
assert.deepEqual(readdirSync(originalWorkspace), []);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
pass("adoption-keeps-workspace-path-bound-through-install", () => {
|
|
771
|
+
for (const hook of ["beforeInstall", "afterInstall"]) {
|
|
772
|
+
const workspace = temporary(`forgerail-adoption-write-path-${hook}-`);
|
|
773
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
774
|
+
const originalWorkspace = `${workspace}-approved-inode`;
|
|
775
|
+
assert.throws(
|
|
776
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
777
|
+
[hook]() {
|
|
778
|
+
renameSync(workspace, originalWorkspace);
|
|
779
|
+
mkdirSync(workspace);
|
|
780
|
+
},
|
|
781
|
+
}),
|
|
782
|
+
/workspace directory identity changed|target parent identity changed/,
|
|
783
|
+
);
|
|
784
|
+
assert.deepEqual(readdirSync(workspace), []);
|
|
785
|
+
assert.deepEqual(readdirSync(originalWorkspace), []);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
pass("adoption-creates-bounded-parents-and-replaces-atomically", () => {
|
|
790
|
+
const cursorWorkspace = temporary("forgerail-adoption-cursor-");
|
|
791
|
+
const multiHost = planAdoption(root, cursorWorkspace, ["codex", "cursor"]);
|
|
792
|
+
const cursorWrite = multiHost.proposedWrites.find(({ path }) => path === ".cursor/rules/forgerail.mdc");
|
|
793
|
+
const cursorReceipt = applyApprovedAdoptionWrite(cursorWorkspace, cursorWrite, cursorWrite.approvalSha256);
|
|
794
|
+
assert.equal(cursorReceipt.contentSha256, createHash("sha256").update(cursorWrite.content).digest("hex"));
|
|
795
|
+
assert.equal(readFileSync(resolve(cursorWorkspace, cursorWrite.path), "utf8"), cursorWrite.content);
|
|
796
|
+
assert.equal(readdirSync(resolve(cursorWorkspace, ".cursor/rules")).some((name) => name.startsWith(".forgerail-") && (name.endsWith(".tmp") || name.endsWith(".bak"))), false);
|
|
797
|
+
|
|
798
|
+
const existingWorkspace = temporary("forgerail-adoption-existing-");
|
|
799
|
+
writeFileSync(resolve(existingWorkspace, "AGENTS.md"), "existing instructions\n");
|
|
800
|
+
const plan = planAdoption(root, existingWorkspace, ["codex"]);
|
|
801
|
+
const approved = plan.proposedWrites[0];
|
|
802
|
+
const expected = renderProposedWrite(existingWorkspace, approved);
|
|
803
|
+
const receipt = applyApprovedAdoptionWrite(existingWorkspace, approved, approved.approvalSha256);
|
|
804
|
+
assert.equal(readFileSync(resolve(existingWorkspace, "AGENTS.md"), "utf8"), expected);
|
|
805
|
+
assert.equal(receipt.contentSha256, createHash("sha256").update(expected).digest("hex"));
|
|
806
|
+
assert.equal(readdirSync(existingWorkspace).some((name) => name.startsWith(".forgerail-") && (name.endsWith(".tmp") || name.endsWith(".bak"))), false);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
pass("adoption-preserves-registry-owned-unmanaged-binding-policy", () => {
|
|
810
|
+
const workspace = temporary("forgerail-adoption-unmanaged-cursor-");
|
|
811
|
+
mkdirSync(resolve(workspace, ".cursor/rules"), { recursive: true });
|
|
812
|
+
const target = resolve(workspace, ".cursor/rules/forgerail.mdc");
|
|
813
|
+
writeFileSync(target, "existing Cursor rules\n");
|
|
814
|
+
assert.throws(
|
|
815
|
+
() => planAdoption(root, workspace, ["cursor"]),
|
|
816
|
+
/already exists without a ForgeRail managed marker/,
|
|
817
|
+
);
|
|
818
|
+
assert.equal(readFileSync(target, "utf8"), "existing Cursor rules\n");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
pass("adoption-rejects-duplicate-managed-boundaries", () => {
|
|
822
|
+
const marker = "forgerail:binding:codex:v1";
|
|
823
|
+
for (const duplicate of ["start", "end"]) {
|
|
824
|
+
const workspace = temporary(`forgerail-adoption-duplicate-${duplicate}-`);
|
|
825
|
+
const start = `<!-- ${marker}:start -->`;
|
|
826
|
+
const end = `<!-- ${marker}:end -->`;
|
|
827
|
+
const content = duplicate === "start"
|
|
828
|
+
? `${start}\n${start}\nmanaged\n${end}\n`
|
|
829
|
+
: `${start}\nmanaged\n${end}\n${end}\n`;
|
|
830
|
+
writeFileSync(resolve(workspace, "AGENTS.md"), content);
|
|
831
|
+
assert.throws(() => planAdoption(root, workspace, ["codex"]), /duplicate managed markers/);
|
|
832
|
+
assert.equal(readFileSync(resolve(workspace, "AGENTS.md"), "utf8"), content);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
pass("adoption-plan-requires-one-workspace-identity", () => {
|
|
837
|
+
const workspace = temporary("forgerail-adoption-plan-workspace-identity-");
|
|
838
|
+
const plan = planAdoption(root, workspace, ["codex", "cursor"]);
|
|
839
|
+
const changed = clone(plan);
|
|
840
|
+
changed.proposedWrites[1].workspaceSha256 = "0".repeat(64);
|
|
841
|
+
changed.proposedWrites[1].approvalSha256 = adoptionWriteApprovalDigest(changed.proposedWrites[1]);
|
|
842
|
+
const validation = validateContract("adoption-plan", changed);
|
|
843
|
+
assert.equal(validation.valid, false);
|
|
844
|
+
assert.ok(validation.errors.includes("adoptionPlan.proposedWrites must share one workspace identity"));
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
pass("adoption-preserves-existing-binding-mode-across-umask", () => {
|
|
848
|
+
const workspace = temporary("forgerail-adoption-mode-");
|
|
849
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
850
|
+
writeFileSync(target, "existing instructions\n");
|
|
851
|
+
chmodSync(target, 0o664);
|
|
852
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
853
|
+
const priorUmask = process.umask(0o077);
|
|
854
|
+
try {
|
|
855
|
+
applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256);
|
|
856
|
+
} finally {
|
|
857
|
+
process.umask(priorUmask);
|
|
858
|
+
}
|
|
859
|
+
assert.equal(statSync(target).mode & 0o777, 0o664);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
pass("adoption-does-not-overwrite-concurrent-leaf-replacement", () => {
|
|
863
|
+
const workspace = temporary("forgerail-adoption-concurrent-leaf-");
|
|
864
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
865
|
+
const prior = resolve(workspace, "AGENTS.md.concurrent-prior");
|
|
866
|
+
writeFileSync(target, "existing instructions\n");
|
|
867
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
868
|
+
assert.throws(
|
|
869
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
870
|
+
beforeReplace() {
|
|
871
|
+
renameSync(target, prior);
|
|
872
|
+
writeFileSync(target, "concurrent user change\n");
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
/target changed before atomic replace/,
|
|
876
|
+
);
|
|
877
|
+
assert.equal(readFileSync(target, "utf8"), "concurrent user change\n");
|
|
878
|
+
assert.equal(readFileSync(prior, "utf8"), "existing instructions\n");
|
|
879
|
+
assert.equal(readdirSync(workspace).some((name) => name.startsWith(".forgerail-")), false);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
pass("adoption-restores-original-binding-when-installed-leaf-disappears", () => {
|
|
883
|
+
const workspace = temporary("forgerail-adoption-restore-missing-installed-");
|
|
884
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
885
|
+
writeFileSync(target, "existing instructions\n");
|
|
886
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
887
|
+
assert.throws(
|
|
888
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
889
|
+
afterInstall() { unlinkSync(target); },
|
|
890
|
+
}),
|
|
891
|
+
/target identity mismatch after write/,
|
|
892
|
+
);
|
|
893
|
+
assert.equal(readFileSync(target, "utf8"), "existing instructions\n");
|
|
894
|
+
assert.equal(readdirSync(workspace).some((name) => name.startsWith(".forgerail-")), false);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
pass("adoption-restores-original-binding-with-one-rename", () => {
|
|
898
|
+
const workspace = temporary("forgerail-adoption-atomic-restore-");
|
|
899
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
900
|
+
writeFileSync(target, "existing instructions\n");
|
|
901
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
902
|
+
assert.throws(
|
|
903
|
+
() => applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
904
|
+
afterInstall() { throw new Error("forced post-install failure"); },
|
|
905
|
+
}),
|
|
906
|
+
/forced post-install failure/,
|
|
907
|
+
);
|
|
908
|
+
assert.equal(readFileSync(target, "utf8"), "existing instructions\n");
|
|
909
|
+
assert.equal(readdirSync(workspace).some((name) => name.startsWith(".forgerail-")), false);
|
|
910
|
+
const implementation = readFileSync(resolve(root, "scripts/lib/adoption.mjs"), "utf8");
|
|
911
|
+
assert.doesNotMatch(implementation, /renameSync\(leaf, failed\)/);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
pass("adoption-preserves-primary-error-when-cwd-restoration-fails", () => {
|
|
915
|
+
const priorDirectory = process.cwd();
|
|
916
|
+
const callerDirectory = temporary("forgerail-adoption-removed-cwd-");
|
|
917
|
+
const workspace = temporary("forgerail-adoption-primary-error-");
|
|
918
|
+
const approved = planAdoption(root, workspace, ["codex", "cursor"]).proposedWrites.find(({ path }) => path === ".cursor/rules/forgerail.mdc");
|
|
919
|
+
let caught;
|
|
920
|
+
process.chdir(callerDirectory);
|
|
921
|
+
try {
|
|
922
|
+
applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
923
|
+
beforeInstall() {
|
|
924
|
+
rmSync(callerDirectory, { recursive: true, force: true });
|
|
925
|
+
throw new Error("primary adoption failure");
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
} catch (error) {
|
|
929
|
+
caught = error;
|
|
930
|
+
} finally {
|
|
931
|
+
process.chdir(priorDirectory);
|
|
932
|
+
}
|
|
933
|
+
assert.equal(caught?.message, "primary adoption failure");
|
|
934
|
+
assert.equal(existsSync(resolve(workspace, ".cursor")), false);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
pass("adoption-errors-name-retained-recovery-evidence", () => {
|
|
938
|
+
const workspace = temporary("forgerail-adoption-retained-recovery-");
|
|
939
|
+
const target = resolve(workspace, "AGENTS.md");
|
|
940
|
+
const displacedCandidate = resolve(workspace, "AGENTS.md.displaced-candidate");
|
|
941
|
+
writeFileSync(target, "original instructions\n");
|
|
942
|
+
const approved = planAdoption(root, workspace, ["codex"]).proposedWrites[0];
|
|
943
|
+
let caught;
|
|
944
|
+
try {
|
|
945
|
+
applyApprovedAdoptionWrite(workspace, approved, approved.approvalSha256, {
|
|
946
|
+
afterInstall() {
|
|
947
|
+
renameSync(target, displacedCandidate);
|
|
948
|
+
writeFileSync(target, "concurrent user content\n");
|
|
949
|
+
throw new Error("forced unsafe recovery");
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
} catch (error) {
|
|
953
|
+
caught = error;
|
|
954
|
+
}
|
|
955
|
+
assert.match(caught?.message ?? "", /^forced unsafe recovery; recovery evidence retained at \.forgerail-[0-9a-f]{24}\.bak$/);
|
|
956
|
+
const recoveryPath = caught.message.slice(caught.message.lastIndexOf(" at ") + 4);
|
|
957
|
+
assert.equal(readFileSync(resolve(workspace, recoveryPath), "utf8"), "original instructions\n");
|
|
958
|
+
assert.equal(readFileSync(target, "utf8"), "concurrent user content\n");
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
pass("invalid-pack-collections-fail-closed-with-structured-errors", () => {
|
|
962
|
+
for (const [field, value] of [["dependencies", {}], ["dependencies", null], ["conflicts", "other-pack"]]) {
|
|
963
|
+
const malformed = { ...clone(pack), [field]: value };
|
|
964
|
+
const profileResult = resolveProfile(profileInput, [malformed]);
|
|
965
|
+
assert.equal(profileResult.valid, false);
|
|
966
|
+
assert.ok(profileResult.errors.some((error) => error.includes(`${field} must be an array`)), profileResult.errors.join("; "));
|
|
967
|
+
const launchResult = createLaunchContract(resolved.profile, { ...envelope, packs: [pack.id] }, "Codex", [malformed]);
|
|
968
|
+
assert.equal(launchResult.valid, false);
|
|
969
|
+
assert.ok(launchResult.errors.some((error) => error.includes(`${field} must be an array`)), launchResult.errors.join("; "));
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
if (evaluateShadowComparison) pass("shadow-mutation-fails-coverage", () => {
|
|
974
|
+
assert.equal(evaluateShadowComparison().behaviorCoverageReady, true);
|
|
975
|
+
const mutated = evaluateShadowComparison({ "dirty-worktree-preservation": "" });
|
|
976
|
+
assert.equal(mutated.behaviorCoverageReady, false);
|
|
977
|
+
assert.ok(mutated.unresolved.includes("dirty-worktree-preservation"));
|
|
978
|
+
const weakenedBaseline = clone(readJson(resolve(root, "docs/agw-frozen-baseline.json")));
|
|
979
|
+
weakenedBaseline.behaviorAssertions.dirtyWorktreePreservation = [];
|
|
980
|
+
const missingBaselineEvidence = evaluateShadowComparison({}, weakenedBaseline);
|
|
981
|
+
assert.equal(missingBaselineEvidence.behaviorCoverageReady, false);
|
|
982
|
+
assert.ok(missingBaselineEvidence.unresolved.includes("dirty-worktree-preservation"));
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (!evaluateShadowComparison) assertions.push("source-only-shadow-regressions-not-installed");
|
|
986
|
+
|
|
987
|
+
if (buildBundle) pass("bundle-private-public-layouts-are-deterministic-and-safe", () => {
|
|
988
|
+
const bundleImplementation = readFileSync(resolve(root, "tools/lib/bundle.mjs"), "utf8");
|
|
989
|
+
assert.match(bundleImplementation, /O_NOFOLLOW/);
|
|
990
|
+
assert.match(bundleImplementation, /O_DIRECTORY/);
|
|
991
|
+
assert.match(bundleImplementation, /process\.chdir\(segment\)/);
|
|
992
|
+
assert.match(bundleImplementation, /readFileSync\(descriptor\)/);
|
|
993
|
+
assert.doesNotMatch(bundleImplementation, /copyFileSync/);
|
|
994
|
+
const privateRoot = privateLayoutFixture();
|
|
995
|
+
const privateOutput = resolve(temporary("forgerail-private-output-parent-"), "bundle");
|
|
996
|
+
const privateSecondOutput = resolve(temporary("forgerail-private-output-parent-"), "bundle");
|
|
997
|
+
const first = buildBundle(privateRoot, privateOutput);
|
|
998
|
+
const second = buildBundle(privateRoot, privateSecondOutput);
|
|
999
|
+
assert.equal(first.digest, second.digest);
|
|
1000
|
+
assert.deepEqual(first.files, second.files);
|
|
1001
|
+
assert.ok(first.files.every((item) => item.type === "file" && /^[0-7]{3}$/.test(item.mode)));
|
|
1002
|
+
|
|
1003
|
+
const publicRoot = publicLayoutFixture();
|
|
1004
|
+
writeFileSync(resolve(publicRoot, ".env"), "SHOULD_NOT_APPEAR=true\n");
|
|
1005
|
+
mkdirSync(resolve(publicRoot, "plugins/forgerail-release-safety/.git"), { recursive: true });
|
|
1006
|
+
writeFileSync(resolve(publicRoot, "plugins/forgerail-release-safety/.git/config"), "private\n");
|
|
1007
|
+
const publicOutput = resolve(temporary("forgerail-public-output-parent-"), "bundle");
|
|
1008
|
+
const publicResult = buildBundle(publicRoot, publicOutput);
|
|
1009
|
+
assert.equal(publicResult.digest, first.digest);
|
|
1010
|
+
assert.ok(publicResult.files.some((item) => item.path === "package-lock.json"));
|
|
1011
|
+
assert.ok(publicResult.files.some((item) => item.path === "plugins/forgerail/package-lock.json"));
|
|
1012
|
+
assert.equal(publicResult.files.some((item) => item.path.includes(".env") || item.path.includes("/.git/")), false);
|
|
1013
|
+
for (const item of publicResult.files) {
|
|
1014
|
+
const bytes = readFileSync(resolve(publicOutput, item.path));
|
|
1015
|
+
assert.equal(item.bytes, bytes.length, item.path);
|
|
1016
|
+
assert.equal(item.sha256, createHash("sha256").update(bytes).digest("hex"), item.path);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const reconstructedPrivateRoot = privateLayoutFixture(publicRoot);
|
|
1020
|
+
const reconstructedPrivateOutput = resolve(temporary("forgerail-reconstructed-private-output-parent-"), "bundle");
|
|
1021
|
+
const reconstructedPrivateResult = buildBundle(reconstructedPrivateRoot, reconstructedPrivateOutput);
|
|
1022
|
+
assert.equal(reconstructedPrivateResult.digest, first.digest);
|
|
1023
|
+
assert.deepEqual(reconstructedPrivateResult.files, first.files);
|
|
1024
|
+
|
|
1025
|
+
const unsafeRoot = resolve(temporary("forgerail-unsafe-layout-"), "forgerail");
|
|
1026
|
+
cpSync(publicRoot, unsafeRoot, { recursive: true, dereference: false });
|
|
1027
|
+
symlinkSync(resolve(unsafeRoot, "README.md"), resolve(unsafeRoot, "plugins/forgerail-release-safety/scripts/leak.mjs"));
|
|
1028
|
+
const unsafeOutput = resolve(temporary("forgerail-unsafe-output-parent-"), "bundle");
|
|
1029
|
+
assert.throws(() => buildBundle(unsafeRoot, unsafeOutput), /symbolic link/);
|
|
1030
|
+
assert.equal(existsSync(unsafeOutput), false);
|
|
1031
|
+
|
|
1032
|
+
const catalogSymlinkRoot = publicLayoutFixture();
|
|
1033
|
+
const catalogOutside = temporary("forgerail-catalog-outside-");
|
|
1034
|
+
mkdirSync(resolve(catalogOutside, "plugins"), { recursive: true });
|
|
1035
|
+
cpSync(
|
|
1036
|
+
resolve(catalogSymlinkRoot, ".agents/plugins/marketplace.json"),
|
|
1037
|
+
resolve(catalogOutside, "plugins/marketplace.json"),
|
|
1038
|
+
);
|
|
1039
|
+
rmSync(resolve(catalogSymlinkRoot, ".agents"), { recursive: true, force: true });
|
|
1040
|
+
symlinkSync(catalogOutside, resolve(catalogSymlinkRoot, ".agents"));
|
|
1041
|
+
const catalogSymlinkOutput = resolve(temporary("forgerail-catalog-symlink-output-"), "bundle");
|
|
1042
|
+
assert.throws(() => buildBundle(catalogSymlinkRoot, catalogSymlinkOutput), /symbolic link/);
|
|
1043
|
+
assert.equal(existsSync(catalogSymlinkOutput), false);
|
|
1044
|
+
|
|
1045
|
+
const escapedParentTarget = temporary("forgerail-escaped-output-parent-");
|
|
1046
|
+
const outputLink = resolve(temporary("forgerail-output-link-parent-"), "outside");
|
|
1047
|
+
symlinkSync(escapedParentTarget, outputLink);
|
|
1048
|
+
assert.throws(() => buildBundle(root, resolve(outputLink, "bundle")), /symbolic links|below the host temporary directory/);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
if (buildBundle && process.platform !== "win32") pass("bundle-rejects-non-regular-package-metadata-before-read", () => {
|
|
1052
|
+
const publicRoot = publicLayoutFixture();
|
|
1053
|
+
const packagePath = resolve(publicRoot, "package.json");
|
|
1054
|
+
rmSync(packagePath);
|
|
1055
|
+
execFileSync("mkfifo", [packagePath]);
|
|
1056
|
+
const output = resolve(temporary("forgerail-package-fifo-output-parent-"), "bundle");
|
|
1057
|
+
assert.throws(() => buildBundle(publicRoot, output), /not a regular file/);
|
|
1058
|
+
assert.equal(existsSync(output), false);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
if (buildBundle) pass("bundle-requires-an-explicit-package-publication-allowlist", () => {
|
|
1062
|
+
for (const files of [undefined, "scripts/"]) {
|
|
1063
|
+
const publicRoot = publicLayoutFixture();
|
|
1064
|
+
const packagePath = resolve(publicRoot, "package.json");
|
|
1065
|
+
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
1066
|
+
if (files === undefined) delete packageJson.files;
|
|
1067
|
+
else packageJson.files = files;
|
|
1068
|
+
writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
1069
|
+
const output = resolve(temporary("forgerail-missing-package-allowlist-output-parent-"), "bundle");
|
|
1070
|
+
assert.throws(() => buildBundle(publicRoot, output), /package\.json files must be an array/);
|
|
1071
|
+
assert.equal(existsSync(output), false);
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
if (buildBundle) pass("bundle-rejects-symlinked-allowlisted-ancestors-before-read", () => {
|
|
1076
|
+
const publicRoot = publicLayoutFixture();
|
|
1077
|
+
const scriptsRoot = resolve(publicRoot, "scripts");
|
|
1078
|
+
const replacementRoot = resolve(publicRoot, "scripts-source");
|
|
1079
|
+
cpSync(scriptsRoot, replacementRoot, { recursive: true, dereference: false });
|
|
1080
|
+
rmSync(scriptsRoot, { recursive: true, force: true });
|
|
1081
|
+
symlinkSync(replacementRoot, scriptsRoot);
|
|
1082
|
+
const output = resolve(temporary("forgerail-symlinked-allowlisted-output-parent-"), "bundle");
|
|
1083
|
+
assert.throws(() => buildBundle(publicRoot, output), /symbolic link|bound directory/);
|
|
1084
|
+
assert.equal(existsSync(output), false);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
if (buildBundle) pass("bundle-rejects-symlinked-external-plugin-roots", () => {
|
|
1088
|
+
for (const kind of ["root", "ancestor"]) {
|
|
1089
|
+
const publicRoot = publicLayoutFixture();
|
|
1090
|
+
if (kind === "root") {
|
|
1091
|
+
const pluginRoot = resolve(publicRoot, "plugins/forgerail-release-safety");
|
|
1092
|
+
const replacementRoot = resolve(publicRoot, "plugins/forgerail-release-safety-source");
|
|
1093
|
+
cpSync(pluginRoot, replacementRoot, { recursive: true, dereference: false });
|
|
1094
|
+
rmSync(pluginRoot, { recursive: true, force: true });
|
|
1095
|
+
symlinkSync(replacementRoot, pluginRoot);
|
|
1096
|
+
} else {
|
|
1097
|
+
const pluginsRoot = resolve(publicRoot, "plugins");
|
|
1098
|
+
const replacementRoot = resolve(publicRoot, "plugins-source");
|
|
1099
|
+
cpSync(pluginsRoot, replacementRoot, { recursive: true, dereference: false });
|
|
1100
|
+
rmSync(pluginsRoot, { recursive: true, force: true });
|
|
1101
|
+
symlinkSync(replacementRoot, pluginsRoot);
|
|
1102
|
+
}
|
|
1103
|
+
const output = resolve(temporary(`forgerail-symlinked-plugin-${kind}-output-parent-`), "bundle");
|
|
1104
|
+
assert.throws(() => buildBundle(publicRoot, output), /symbolic link/);
|
|
1105
|
+
assert.equal(existsSync(output), false);
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
if (buildBundle) pass("bundle-rejects-environment-and-npmrc-filename-families", () => {
|
|
1110
|
+
for (const path of [
|
|
1111
|
+
"scripts/.env.local",
|
|
1112
|
+
"docs/.env.production",
|
|
1113
|
+
"scripts/.npmrc.backup",
|
|
1114
|
+
"scripts/SECRET.KEY",
|
|
1115
|
+
"docs/config.PEM",
|
|
1116
|
+
"scripts/.ENV.production",
|
|
1117
|
+
]) {
|
|
1118
|
+
const publicRoot = publicLayoutFixture();
|
|
1119
|
+
writeFileSync(resolve(publicRoot, path), "PRIVATE_VALUE=should-not-project\n");
|
|
1120
|
+
const output = resolve(temporary("forgerail-sensitive-name-output-parent-"), "bundle");
|
|
1121
|
+
assert.throws(() => buildBundle(publicRoot, output), /path is not allowed/);
|
|
1122
|
+
assert.equal(existsSync(output), false);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
if (buildBundle) pass("bundle-applies-directory-denylist-case-insensitively", () => {
|
|
1127
|
+
for (const path of ["scripts/NODE_MODULES/private.txt", "docs/COVERAGE/private.txt", "scripts/.CACHE/private.txt"]) {
|
|
1128
|
+
const publicRoot = publicLayoutFixture();
|
|
1129
|
+
mkdirSync(dirname(resolve(publicRoot, path)), { recursive: true });
|
|
1130
|
+
writeFileSync(resolve(publicRoot, path), "private\n");
|
|
1131
|
+
const output = resolve(temporary("forgerail-denied-directory-output-parent-"), "bundle");
|
|
1132
|
+
assert.throws(() => buildBundle(publicRoot, output), /path is not allowed/);
|
|
1133
|
+
assert.equal(existsSync(output), false);
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
if (buildBundle) pass("bundle-reserves-output-without-replacing-a-concurrent-directory", () => {
|
|
1138
|
+
const publicRoot = publicLayoutFixture();
|
|
1139
|
+
const parent = temporary("forgerail-concurrent-output-parent-");
|
|
1140
|
+
const output = resolve(parent, "bundle");
|
|
1141
|
+
assert.throws(
|
|
1142
|
+
() => buildBundle(publicRoot, output, {
|
|
1143
|
+
beforeOutputReservation() { mkdirSync(output); },
|
|
1144
|
+
}),
|
|
1145
|
+
/EEXIST|file already exists/,
|
|
1146
|
+
);
|
|
1147
|
+
assert.equal(existsSync(output), true);
|
|
1148
|
+
assert.deepEqual(readdirSync(output), []);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
if (buildBundle) pass("bundle-rejects-symlinked-descendants-inside-the-reserved-output", () => {
|
|
1152
|
+
const publicRoot = publicLayoutFixture();
|
|
1153
|
+
const parent = temporary("forgerail-reserved-output-descendant-parent-");
|
|
1154
|
+
const escaped = temporary("forgerail-reserved-output-descendant-escaped-");
|
|
1155
|
+
const output = resolve(parent, "bundle");
|
|
1156
|
+
assert.throws(
|
|
1157
|
+
() => buildBundle(publicRoot, output, {
|
|
1158
|
+
afterOutputReservation() { symlinkSync(escaped, resolve(output, "docs")); },
|
|
1159
|
+
}),
|
|
1160
|
+
/output ancestor is not a bound directory/,
|
|
1161
|
+
);
|
|
1162
|
+
assert.equal(existsSync(output), false);
|
|
1163
|
+
assert.deepEqual(readdirSync(escaped), []);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
if (buildBundle) pass("bundle-rejects-output-inside-source-before-staging", () => {
|
|
1167
|
+
const publicRoot = publicLayoutFixture();
|
|
1168
|
+
const output = resolve(publicRoot, "docs/projection");
|
|
1169
|
+
const before = readdirSync(resolve(publicRoot, "docs")).sort();
|
|
1170
|
+
assert.throws(() => buildBundle(publicRoot, output), /output must not be inside the source tree/);
|
|
1171
|
+
assert.equal(existsSync(output), false);
|
|
1172
|
+
assert.deepEqual(readdirSync(resolve(publicRoot, "docs")).sort(), before);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
if (buildBundle) pass("bundle-rejects-output-inside-private-layout-external-plugin", () => {
|
|
1176
|
+
const privateRoot = privateLayoutFixture(publicLayoutFixture());
|
|
1177
|
+
const pluginRoot = resolve(privateRoot, "..", "forgerail-release-safety");
|
|
1178
|
+
const output = resolve(pluginRoot, "projection");
|
|
1179
|
+
const before = readdirSync(pluginRoot).sort();
|
|
1180
|
+
assert.throws(() => buildBundle(privateRoot, output), /external Plugin source tree/);
|
|
1181
|
+
assert.equal(existsSync(output), false);
|
|
1182
|
+
assert.deepEqual(readdirSync(pluginRoot).sort(), before);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
if (buildBundle) pass("bundle-rejects-duplicate-projection-targets", () => {
|
|
1186
|
+
for (const extra of [".agents/", "plugins/"]) {
|
|
1187
|
+
const publicRoot = publicLayoutFixture();
|
|
1188
|
+
const packagePath = resolve(publicRoot, "package.json");
|
|
1189
|
+
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
1190
|
+
packageJson.files.push(extra);
|
|
1191
|
+
writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
1192
|
+
const output = resolve(temporary("forgerail-duplicate-target-output-parent-"), "bundle");
|
|
1193
|
+
assert.throws(() => buildBundle(publicRoot, output), /duplicate bundle target/);
|
|
1194
|
+
assert.equal(existsSync(output), false);
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
if (buildBundle) pass("bundle-normalizes-marketplace-allowlist-before-exclusion", () => {
|
|
1199
|
+
for (const alias of ["./marketplace/", "marketplace//"]) {
|
|
1200
|
+
const privateRoot = privateLayoutFixture(publicLayoutFixture());
|
|
1201
|
+
const packagePath = resolve(privateRoot, "package.json");
|
|
1202
|
+
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
1203
|
+
packageJson.files = packageJson.files.map((entry) => entry === "marketplace/" ? alias : entry);
|
|
1204
|
+
writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
1205
|
+
const output = resolve(temporary("forgerail-marketplace-alias-output-parent-"), "bundle");
|
|
1206
|
+
const result = buildBundle(privateRoot, output);
|
|
1207
|
+
assert.equal(result.files.some(({ path }) => path.includes("/marketplace/") || path.startsWith("marketplace/")), false);
|
|
1208
|
+
assert.equal(result.files.filter(({ path }) => path === ".agents/plugins/marketplace.json").length, 1);
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
if (pathIsConfined) pass("bundle-rejects-cross-drive-relative-results", () => {
|
|
1213
|
+
assert.equal(pathIsConfined("D:\\forgerail", "C:\\Temp\\bundle", win32), false);
|
|
1214
|
+
assert.equal(pathIsConfined("D:\\forgerail", "D:\\forgerail\\nested", win32), true);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
if (buildBundle) pass("bundle-keeps-source-root-bound-for-entire-build", () => {
|
|
1218
|
+
const ownerRoot = temporary("forgerail-root-replacement-owner-");
|
|
1219
|
+
const publicRoot = resolve(ownerRoot, "forgerail");
|
|
1220
|
+
cpSync(publicLayoutFixture(), publicRoot, { recursive: true, dereference: false });
|
|
1221
|
+
const originalRoot = resolve(ownerRoot, "forgerail-original");
|
|
1222
|
+
const output = resolve(temporary("forgerail-root-replacement-output-parent-"), "bundle");
|
|
1223
|
+
assert.throws(
|
|
1224
|
+
() => buildBundle(publicRoot, output, {
|
|
1225
|
+
afterSourceEnumeration() {
|
|
1226
|
+
renameSync(publicRoot, originalRoot);
|
|
1227
|
+
cpSync(originalRoot, publicRoot, { recursive: true, dereference: false });
|
|
1228
|
+
},
|
|
1229
|
+
}),
|
|
1230
|
+
/source root identity changed during build/,
|
|
1231
|
+
);
|
|
1232
|
+
assert.equal(existsSync(output), false);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
if (buildBundle) pass("bundle-keeps-output-parent-bound-through-install", () => {
|
|
1236
|
+
const publicRoot = publicLayoutFixture();
|
|
1237
|
+
const ownerRoot = temporary("forgerail-output-parent-owner-");
|
|
1238
|
+
const outputParent = resolve(ownerRoot, "approved-parent");
|
|
1239
|
+
const movedParent = resolve(ownerRoot, "moved-parent");
|
|
1240
|
+
const output = resolve(outputParent, "bundle");
|
|
1241
|
+
mkdirSync(outputParent);
|
|
1242
|
+
assert.throws(
|
|
1243
|
+
() => buildBundle(publicRoot, output, {
|
|
1244
|
+
afterSourceEnumeration() {
|
|
1245
|
+
renameSync(outputParent, movedParent);
|
|
1246
|
+
symlinkSync(resolve(publicRoot, "docs"), outputParent);
|
|
1247
|
+
},
|
|
1248
|
+
}),
|
|
1249
|
+
/output parent identity changed during build/,
|
|
1250
|
+
);
|
|
1251
|
+
assert.equal(existsSync(resolve(publicRoot, "docs/bundle")), false);
|
|
1252
|
+
assert.equal(existsSync(resolve(movedParent, "bundle")), false);
|
|
1253
|
+
assert.equal(readdirSync(movedParent).some((name) => name.startsWith(".forgerail-bundle-")), false);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
if (!buildBundle) assertions.push("source-only-bundle-regressions-not-installed");
|
|
1257
|
+
|
|
1258
|
+
console.log(JSON.stringify({ valid: true, assertions, mutations: [], externalSideEffects: [] }, null, 2));
|
|
1259
|
+
} finally {
|
|
1260
|
+
for (const path of temporaryRoots.reverse()) rmSync(path, { recursive: true, force: true });
|
|
1261
|
+
}
|