@warpgogol/forge 5.1.1 → 5.1.2
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/AGENTS.md +7 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +20 -5
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
- package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
- package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.js +1 -1
- package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
- package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
- package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
- package/dist/os/core/handlers/pinned-validate.js +26 -6
- package/dist/os/core/handlers/pinned-validate.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +8 -1
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +52 -5
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/create.d.ts.map +1 -1
- package/dist/src/onboarding/create.js +4 -8
- package/dist/src/onboarding/create.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +65 -0
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +11 -0
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +4 -2
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/os/compass/handlers/compass-audit-handler.ts +3 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory.ts +20 -6
- package/os/compass/handlers/compass-migrate-handler.ts +1 -0
- package/os/compass/handlers/compass-migrate.ts +9 -2
- package/os/compass/handlers/resolve-scan-root.ts +1 -1
- package/os/core/handlers/pinned-validate.ts +33 -7
- package/package.json +1 -1
- package/src/config/forge-config.ts +59 -5
- package/src/onboarding/create.ts +4 -7
- package/src/onboarding/doctor.ts +70 -0
- package/src/onboarding/init.ts +19 -1
- package/src/onboarding/upgrade.ts +4 -1
- package/src/tests/doctor-profile-id-known.test.ts +237 -0
- package/src/tests/pinned-validate.test.ts +134 -0
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
import {
|
|
34
34
|
loadPinnedManifest,
|
|
35
35
|
isPinned,
|
|
36
|
+
isIntraDirMove,
|
|
36
37
|
PINNED_MANIFEST_PATH,
|
|
37
38
|
PinnedManifestMalformedError,
|
|
38
39
|
} from "./pinned-check.ts";
|
|
@@ -51,11 +52,18 @@ const AUDIT_LOG_PATH = path.join(".forge", "pinned-audit.log");
|
|
|
51
52
|
* Git status codes:
|
|
52
53
|
* D = deleted, R = renamed (move), M = modified, A = added, C = copied
|
|
53
54
|
* Format: "R100\tdocs/old.md\tdocs/new.md" or "D\tdocs/file.md"
|
|
55
|
+
*
|
|
56
|
+
* R/C entries keep their destination in `destPath` so the violation check can
|
|
57
|
+
* apply the isIntraDirMove exemption (same rule the archive handlers use).
|
|
54
58
|
*/
|
|
55
59
|
function parseGitNameStatus(
|
|
56
60
|
output: string,
|
|
57
|
-
): Array<{ relPath: string; operation: PinnedViolation["operation"] }> {
|
|
58
|
-
const entries: Array<{
|
|
61
|
+
): Array<{ relPath: string; operation: PinnedViolation["operation"]; destPath?: string }> {
|
|
62
|
+
const entries: Array<{
|
|
63
|
+
relPath: string;
|
|
64
|
+
operation: PinnedViolation["operation"];
|
|
65
|
+
destPath?: string;
|
|
66
|
+
}> = [];
|
|
59
67
|
const lines = output.trim().split("\n").filter(Boolean);
|
|
60
68
|
|
|
61
69
|
for (const line of lines) {
|
|
@@ -69,10 +77,7 @@ function parseGitNameStatus(
|
|
|
69
77
|
if (statusCode.startsWith("D")) {
|
|
70
78
|
entries.push({ relPath: oldPath, operation: "delete" });
|
|
71
79
|
} else if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
|
|
72
|
-
entries.push({ relPath: oldPath, operation: "move" });
|
|
73
|
-
if (newPath !== oldPath) {
|
|
74
|
-
entries.push({ relPath: newPath, operation: "move" });
|
|
75
|
-
}
|
|
80
|
+
entries.push({ relPath: oldPath, operation: "move", destPath: newPath });
|
|
76
81
|
} else if (statusCode.startsWith("M")) {
|
|
77
82
|
entries.push({ relPath: oldPath, operation: "modify" });
|
|
78
83
|
}
|
|
@@ -228,7 +233,28 @@ export async function runPinnedValidate(
|
|
|
228
233
|
|
|
229
234
|
// Check each changed file against the manifest
|
|
230
235
|
const allViolations: PinnedViolation[] = [];
|
|
231
|
-
for (const { relPath, operation } of changedFiles) {
|
|
236
|
+
for (const { relPath, operation, destPath } of changedFiles) {
|
|
237
|
+
if (operation === "move" && destPath) {
|
|
238
|
+
// Exempt intra-directory moves — the file stays inside the same pinned
|
|
239
|
+
// dir (e.g. docs.archive moves docs/rfcs/x.md → docs/rfcs/archive/…/x.md).
|
|
240
|
+
// Same exemption the archive handlers apply via isIntraDirMove.
|
|
241
|
+
if (isIntraDirMove(manifest, relPath, destPath)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (const movePath of new Set([relPath, destPath])) {
|
|
245
|
+
const moveEntry = isPinned(manifest, movePath);
|
|
246
|
+
if (moveEntry) {
|
|
247
|
+
allViolations.push({
|
|
248
|
+
path: movePath,
|
|
249
|
+
mode: moveEntry.mode,
|
|
250
|
+
operation,
|
|
251
|
+
reason: moveEntry.reason,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
232
258
|
const entry = isPinned(manifest, relPath);
|
|
233
259
|
if (entry) {
|
|
234
260
|
// For protect mode, only delete and move are violations — modify is allowed
|
package/package.json
CHANGED
|
@@ -197,7 +197,10 @@ export const forgeConfigSchema = z.object({
|
|
|
197
197
|
})
|
|
198
198
|
.optional(),
|
|
199
199
|
/** RFC-0643: profile id — when present, loadForgeConfig loads the corresponding profiles/<id>.yaml */
|
|
200
|
-
|
|
200
|
+
// RFC-1118: accept the corrupted object form (whole StackProfile written by a
|
|
201
|
+
// previous buggy serialize) so loadForgeConfig can recover the declared id —
|
|
202
|
+
// a hard schema throw here would deadlock `forge upgrade`, the healing path.
|
|
203
|
+
profile: z.union([z.string(), z.object({ id: z.string().min(1) }).passthrough()]).optional(),
|
|
201
204
|
/** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
|
|
202
205
|
independentVersionPackages: z.array(z.string()).optional(),
|
|
203
206
|
});
|
|
@@ -230,6 +233,10 @@ export interface ForgeConfig {
|
|
|
230
233
|
forge?: { syncedVersion: string | null };
|
|
231
234
|
/** RFC-0643: loaded stack profile — present when forge.yaml has a `profile` field */
|
|
232
235
|
profile?: StackProfile;
|
|
236
|
+
/** RFC-1118: raw `profile:` id string from forge.yaml — preserved verbatim even when unresolvable */
|
|
237
|
+
profileDeclaredId?: string;
|
|
238
|
+
/** RFC-1118: resolution outcome for the declared `profile:` id */
|
|
239
|
+
profileResolution?: "resolved" | "unknown" | "absent" | "catalog-unavailable";
|
|
233
240
|
/** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
|
|
234
241
|
independentVersionPackages?: string[];
|
|
235
242
|
}
|
|
@@ -382,21 +389,45 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
|
|
|
382
389
|
}
|
|
383
390
|
|
|
384
391
|
const rawData = result.data as Record<string, unknown>;
|
|
385
|
-
|
|
392
|
+
// RFC-1118: recover the declared id even when a previous buggy serialize wrote
|
|
393
|
+
// the whole StackProfile object into forge.yaml (object with an `id` field).
|
|
394
|
+
const rawProfile = rawData["profile"];
|
|
395
|
+
const profileId =
|
|
396
|
+
typeof rawProfile === "string"
|
|
397
|
+
? rawProfile
|
|
398
|
+
: rawProfile && typeof rawProfile === "object" && "id" in rawProfile
|
|
399
|
+
? String((rawProfile as { id: unknown }).id)
|
|
400
|
+
: undefined;
|
|
386
401
|
|
|
387
402
|
// RFC-0643: load stack profile when `profile` field is present in forge.yaml
|
|
388
403
|
let loadedProfile: StackProfile | undefined;
|
|
404
|
+
let catalogAvailable = false;
|
|
389
405
|
if (profileId) {
|
|
390
406
|
try {
|
|
391
407
|
const forgeRoot = forgeRootOverride ?? resolveForgeRoot(workspaceRoot);
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
// RFC-1118: listStackProfiles returns [] on a missing profiles/ dir — an
|
|
409
|
+
// unreadable catalog (forge not installed, fallback path) must surface as
|
|
410
|
+
// "catalog-unavailable", not "unknown" (which would fail doctor spuriously).
|
|
411
|
+
if (fs.existsSync(path.join(forgeRoot, "profiles"))) {
|
|
412
|
+
const profiles = listStackProfiles(forgeRoot);
|
|
413
|
+
catalogAvailable = true;
|
|
414
|
+
loadedProfile = profiles.find((p) => p.id === profileId);
|
|
415
|
+
}
|
|
394
416
|
} catch {
|
|
395
|
-
// forge root not resolvable —
|
|
417
|
+
// forge root not resolvable — catalog unavailable, resolvability undetermined
|
|
396
418
|
}
|
|
397
419
|
}
|
|
398
420
|
|
|
399
421
|
const config = { ...rawData } as unknown as ForgeConfig;
|
|
422
|
+
// RFC-1118: preserve the declared id and its resolution status — never silently drop it
|
|
423
|
+
config.profileDeclaredId = profileId;
|
|
424
|
+
config.profileResolution = !profileId
|
|
425
|
+
? "absent"
|
|
426
|
+
: loadedProfile
|
|
427
|
+
? "resolved"
|
|
428
|
+
: catalogAvailable
|
|
429
|
+
? "unknown"
|
|
430
|
+
: "catalog-unavailable";
|
|
400
431
|
if (loadedProfile) {
|
|
401
432
|
config.profile = loadedProfile;
|
|
402
433
|
} else {
|
|
@@ -406,6 +437,29 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
|
|
|
406
437
|
return config;
|
|
407
438
|
}
|
|
408
439
|
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Serializer — RFC-1118: forge.yaml stores the declared profile id (string),
|
|
442
|
+
// never the resolved StackProfile object, and never drops a declared id.
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export function serializeForgeConfig(config: ForgeConfig): Record<string, unknown> {
|
|
446
|
+
const out = { ...config } as unknown as Record<string, unknown>;
|
|
447
|
+
// Runtime-only fields must not be persisted
|
|
448
|
+
delete out["profileDeclaredId"];
|
|
449
|
+
delete out["profileResolution"];
|
|
450
|
+
// Prefer the preserved declared id; fall back to a raw string `profile`
|
|
451
|
+
// (hand-built configs that never went through loadForgeConfig).
|
|
452
|
+
const declared =
|
|
453
|
+
config.profileDeclaredId ??
|
|
454
|
+
(typeof config.profile === "string" ? (config.profile as unknown as string) : undefined);
|
|
455
|
+
if (declared) {
|
|
456
|
+
out["profile"] = declared;
|
|
457
|
+
} else {
|
|
458
|
+
delete out["profile"];
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
409
463
|
// ---------------------------------------------------------------------------
|
|
410
464
|
// Forge root resolver — the single place that decides monorepo vs npm-installed
|
|
411
465
|
// ---------------------------------------------------------------------------
|
package/src/onboarding/create.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { runAgentsGenerate } from "./agents-generate.ts";
|
|
|
32
32
|
import { scaffoldMemoryLayer } from "./memory-scaffold.ts";
|
|
33
33
|
import {
|
|
34
34
|
loadForgeConfig,
|
|
35
|
+
serializeForgeConfig,
|
|
35
36
|
resolveForgeRoot,
|
|
36
37
|
resolvePackageManager,
|
|
37
38
|
applyCliBindingDefaults,
|
|
@@ -348,13 +349,9 @@ export async function runCreate(
|
|
|
348
349
|
if (config.bindings) {
|
|
349
350
|
config.bindings.commands = applyCliBindingDefaults(pm);
|
|
350
351
|
}
|
|
351
|
-
// RFC-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const profileObj = configToSerialize["profile"] as { id?: string };
|
|
355
|
-
configToSerialize["profile"] = profileObj.id ?? configToSerialize["profile"];
|
|
356
|
-
}
|
|
357
|
-
fs.writeFileSync(forgeYamlPath, stringifyYaml(configToSerialize), "utf8");
|
|
352
|
+
// RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
|
|
353
|
+
// never the resolved StackProfile object, never drops an unresolvable id.
|
|
354
|
+
fs.writeFileSync(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)), "utf8");
|
|
358
355
|
} catch {
|
|
359
356
|
// Post-processing is best-effort — init already wrote a valid config
|
|
360
357
|
}
|
package/src/onboarding/doctor.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { checkMemoryLayerHealth } from "./memory-scaffold.ts";
|
|
|
50
50
|
import { checkInvariants } from "./invariant-engine.ts";
|
|
51
51
|
import type { InvariantViolation } from "./invariant-engine.ts";
|
|
52
52
|
import { execSync } from "node:child_process";
|
|
53
|
+
import fs from "node:fs";
|
|
53
54
|
import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
|
|
54
55
|
|
|
55
56
|
interface DoctorCheck {
|
|
@@ -1023,6 +1024,72 @@ function checkDomainInfo(domainReport: DomainReport): DoctorCheck {
|
|
|
1023
1024
|
};
|
|
1024
1025
|
}
|
|
1025
1026
|
|
|
1027
|
+
// RFC-1118: fail-closed diagnostic for an unresolvable `profile:` id.
|
|
1028
|
+
// A declared id absent from the installed catalog is a hard failure —
|
|
1029
|
+
// never silently dropped. An unreadable catalog is a warning, not a fail.
|
|
1030
|
+
function checkProfileIdKnown(workspaceRoot: string, forgeRoot: string): DoctorCheck {
|
|
1031
|
+
const forgeYamlPath = join(workspaceRoot, "forge.yaml");
|
|
1032
|
+
if (!fs.existsSync(forgeYamlPath)) {
|
|
1033
|
+
return {
|
|
1034
|
+
name: "profile-id-known",
|
|
1035
|
+
status: "pass",
|
|
1036
|
+
message: "no forge.yaml — nothing to resolve",
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
let config: ReturnType<typeof loadForgeConfig>;
|
|
1040
|
+
try {
|
|
1041
|
+
config = loadForgeConfig(workspaceRoot, forgeRoot);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
// forge.yaml exists but is unreadable/schema-invalid — a real defect, fail-closed
|
|
1044
|
+
return {
|
|
1045
|
+
name: "profile-id-known",
|
|
1046
|
+
status: "fail",
|
|
1047
|
+
message: `forge.yaml unreadable: ${(err as Error).message.split("\n")[0]}`,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
switch (config.profileResolution) {
|
|
1052
|
+
case "unknown": {
|
|
1053
|
+
let version = "unknown";
|
|
1054
|
+
try {
|
|
1055
|
+
const pkg = JSON.parse(fs.readFileSync(join(forgeRoot, "package.json"), "utf8")) as {
|
|
1056
|
+
version?: string;
|
|
1057
|
+
};
|
|
1058
|
+
version = pkg.version ?? "unknown";
|
|
1059
|
+
} catch {
|
|
1060
|
+
// version unreadable — message still names the declared id
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
name: "profile-id-known",
|
|
1064
|
+
status: "fail",
|
|
1065
|
+
message:
|
|
1066
|
+
`profile "${config.profileDeclaredId}" not in installed @warpgogol/forge v${version} catalog — ` +
|
|
1067
|
+
`renamed/removed id or forge upgrade required`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
case "catalog-unavailable":
|
|
1071
|
+
return {
|
|
1072
|
+
name: "profile-id-known",
|
|
1073
|
+
status: "warn",
|
|
1074
|
+
message:
|
|
1075
|
+
`profile "${config.profileDeclaredId}" declared but the installed profile catalog cannot be read ` +
|
|
1076
|
+
`(forge root unresolvable) — resolvability undetermined`,
|
|
1077
|
+
};
|
|
1078
|
+
case "resolved":
|
|
1079
|
+
return {
|
|
1080
|
+
name: "profile-id-known",
|
|
1081
|
+
status: "pass",
|
|
1082
|
+
message: `profile "${config.profileDeclaredId}" resolved`,
|
|
1083
|
+
};
|
|
1084
|
+
default:
|
|
1085
|
+
return {
|
|
1086
|
+
name: "profile-id-known",
|
|
1087
|
+
status: "pass",
|
|
1088
|
+
message: "no profile declared",
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1026
1093
|
// RFC-0664: Memory layer health check
|
|
1027
1094
|
function checkMemoryLayer(workspaceRoot: string): DoctorCheck {
|
|
1028
1095
|
const health = checkMemoryLayerHealth(workspaceRoot);
|
|
@@ -1222,6 +1289,9 @@ export async function runDoctor(
|
|
|
1222
1289
|
// RFC-0640: Domain info check
|
|
1223
1290
|
checks.push(checkDomainInfo(domainReport));
|
|
1224
1291
|
|
|
1292
|
+
// RFC-1118: profile identity check — fail-closed on unresolvable declared id
|
|
1293
|
+
checks.push(checkProfileIdKnown(workspaceRoot, forgeRoot));
|
|
1294
|
+
|
|
1225
1295
|
// RFC-0675: Enforce invariants using the invariant engine
|
|
1226
1296
|
if (domainReport.invariants.length > 0) {
|
|
1227
1297
|
const profiles = listStackProfiles(forgeRoot);
|
package/src/onboarding/init.ts
CHANGED
|
@@ -60,7 +60,11 @@ export function runInit(
|
|
|
60
60
|
context: unknown,
|
|
61
61
|
domainFields?: InitDomainFields,
|
|
62
62
|
): InitResult {
|
|
63
|
-
const ctx = context as {
|
|
63
|
+
const ctx = context as {
|
|
64
|
+
workspaceRoot?: string;
|
|
65
|
+
forgeRoot?: string;
|
|
66
|
+
logger?: { warn?: (msg: string) => void };
|
|
67
|
+
};
|
|
64
68
|
const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
|
|
65
69
|
const contextForgeRoot = ctx?.forgeRoot;
|
|
66
70
|
|
|
@@ -115,6 +119,20 @@ export function runInit(
|
|
|
115
119
|
} catch {
|
|
116
120
|
config = defaultForgeConfig(path.basename(workspaceRoot));
|
|
117
121
|
}
|
|
122
|
+
// RFC-1118: a declared `profile:` is never overwritten by re-detection.
|
|
123
|
+
// If --from detection disagrees with the declared id, surface a warning —
|
|
124
|
+
// the declared id stays verbatim in forge.yaml.
|
|
125
|
+
if (
|
|
126
|
+
detection?.profile &&
|
|
127
|
+
config.profileDeclaredId &&
|
|
128
|
+
detection.profile !== config.profileDeclaredId
|
|
129
|
+
) {
|
|
130
|
+
const msg =
|
|
131
|
+
`profile: declared id "${config.profileDeclaredId}" preserved — ` +
|
|
132
|
+
`detected "${detection.profile}" not written (forge.yaml already exists)`;
|
|
133
|
+
skipped.push(msg);
|
|
134
|
+
ctx?.logger?.warn?.(msg);
|
|
135
|
+
}
|
|
118
136
|
} else {
|
|
119
137
|
const projectName = path.basename(workspaceRoot);
|
|
120
138
|
config = defaultForgeConfig(projectName);
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
FORGE_CLI_BINDING_DEFAULTS,
|
|
33
33
|
resolveForgeRoot,
|
|
34
34
|
loadForgeConfig,
|
|
35
|
+
serializeForgeConfig,
|
|
35
36
|
resolvePmRunner,
|
|
36
37
|
resolvePmInstall,
|
|
37
38
|
type ForgeConfig,
|
|
@@ -311,8 +312,10 @@ function updateSyncedVersion(
|
|
|
311
312
|
config.forge = { syncedVersion: version };
|
|
312
313
|
|
|
313
314
|
// Write the full config back to forge.yaml
|
|
315
|
+
// RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
|
|
316
|
+
// never the resolved StackProfile object, never drops an unresolvable id.
|
|
314
317
|
const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
|
|
315
|
-
fs.writeFileSync(forgeYamlPath, stringifyYaml(config), "utf8");
|
|
318
|
+
fs.writeFileSync(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)), "utf8");
|
|
316
319
|
}
|
|
317
320
|
|
|
318
321
|
export async function runUpgrade(
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for RFC-1118 fail-closed handling of unresolvable forge.yaml profile id — profile-id-known doctor check, declared-id preservation, serializeForgeConfig, init re-detect guard.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1118: initial tests — AC-1..AC-8 plus corrupted-object recovery.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { runDoctor } from "../onboarding/doctor.ts";
|
|
16
|
+
import { runUpgrade } from "../onboarding/upgrade.ts";
|
|
17
|
+
import { runInit } from "../onboarding/init.ts";
|
|
18
|
+
import { loadForgeConfig, serializeForgeConfig } from "../config/forge-config.ts";
|
|
19
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../types.ts";
|
|
20
|
+
|
|
21
|
+
let tempDir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tempDir = await mkdtemp(join(tmpdir(), "doctor-profile-id-known-"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeContext(): ForgeRuntimeContext {
|
|
32
|
+
return {
|
|
33
|
+
workspaceRoot: tempDir,
|
|
34
|
+
logger: {
|
|
35
|
+
section: () => {},
|
|
36
|
+
success: () => {},
|
|
37
|
+
warn: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
info: () => {},
|
|
40
|
+
},
|
|
41
|
+
dryRun: false,
|
|
42
|
+
outputFormat: "json",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FORGE_YAML_BASE = `schema: "forge/config@1"
|
|
47
|
+
project:
|
|
48
|
+
name: test-project
|
|
49
|
+
stack: []
|
|
50
|
+
packageManager: pnpm
|
|
51
|
+
paths:
|
|
52
|
+
rfcsDir: docs/rfcs
|
|
53
|
+
adrsDir: docs/adrs
|
|
54
|
+
plansDir: docs/plans
|
|
55
|
+
auditsDir: docs/audits
|
|
56
|
+
specsDir: docs/specs
|
|
57
|
+
skillsDir: .agents/skills
|
|
58
|
+
sessionsDir: docs/sessions
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
async function writeForgeYaml(extra = ""): Promise<void> {
|
|
62
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML_BASE + extra, "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function minimalProfile(id: string, marker: string): string {
|
|
66
|
+
return `schema: forge/stack-profile@1
|
|
67
|
+
id: ${id}
|
|
68
|
+
displayName: ${id} profile
|
|
69
|
+
detect:
|
|
70
|
+
anyOf:
|
|
71
|
+
- ${marker}
|
|
72
|
+
workspace:
|
|
73
|
+
dirs:
|
|
74
|
+
- docs
|
|
75
|
+
files: []
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fake installed forge: packages/forge/package.json + profiles/<id>.yaml.
|
|
80
|
+
// resolveForgeRoot(workspaceRoot) picks it up via the monorepo path.
|
|
81
|
+
async function writeFakeForgeRoot(
|
|
82
|
+
version: string,
|
|
83
|
+
profiles: Array<{ id: string; marker: string }>,
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const forgeRoot = join(tempDir, "packages", "forge");
|
|
86
|
+
await mkdir(join(forgeRoot, "profiles"), { recursive: true });
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(forgeRoot, "package.json"),
|
|
89
|
+
JSON.stringify({ name: "@warpgogol/forge", version }) + "\n",
|
|
90
|
+
"utf8",
|
|
91
|
+
);
|
|
92
|
+
for (const p of profiles) {
|
|
93
|
+
await writeFile(
|
|
94
|
+
join(forgeRoot, "profiles", `${p.id}.yaml`),
|
|
95
|
+
minimalProfile(p.id, p.marker),
|
|
96
|
+
"utf8",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return forgeRoot;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findCheck(result: Awaited<ReturnType<typeof runDoctor>>, name: string) {
|
|
103
|
+
return result.data?.checks.find((c) => c.name === name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// AC-1: unresolvable declared id → profile-id-known fails, names id + version
|
|
107
|
+
test("doctor fails profile-id-known when declared profile id is absent from installed catalog", async () => {
|
|
108
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
109
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
110
|
+
|
|
111
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
112
|
+
const result = await runDoctor(input, makeContext());
|
|
113
|
+
|
|
114
|
+
const check = findCheck(result, "profile-id-known");
|
|
115
|
+
expect(check, "profile-id-known check must exist in doctor output").toBeDefined();
|
|
116
|
+
expect(check!.status).toBe("fail");
|
|
117
|
+
expect(check!.message).toContain("bogus-id");
|
|
118
|
+
expect(check!.message).toContain("9.9.9");
|
|
119
|
+
expect(result.exitCode).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// AC-2: ForgeConfig exposes profileDeclaredId + profileResolution
|
|
123
|
+
test("loadForgeConfig exposes profileDeclaredId and profileResolution", async () => {
|
|
124
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
125
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
126
|
+
|
|
127
|
+
const config = loadForgeConfig(tempDir);
|
|
128
|
+
expect(config.profileDeclaredId).toBe("bogus-id");
|
|
129
|
+
expect(config.profileResolution).toBe("unknown");
|
|
130
|
+
expect(config.profile).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// AC-3: no profile field → pass
|
|
134
|
+
test("doctor passes profile-id-known when no profile is declared", async () => {
|
|
135
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
136
|
+
await writeForgeYaml();
|
|
137
|
+
|
|
138
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
139
|
+
const result = await runDoctor(input, makeContext());
|
|
140
|
+
|
|
141
|
+
const check = findCheck(result, "profile-id-known");
|
|
142
|
+
expect(check!.status).toBe("pass");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// AC-4: unresolvable id survives re-serialization verbatim
|
|
146
|
+
test("forge upgrade preserves an unresolvable declared profile id verbatim", async () => {
|
|
147
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
148
|
+
{ id: "known-profile", marker: "marker-known.txt" },
|
|
149
|
+
]);
|
|
150
|
+
await writeForgeYaml(`profile: bogus-id\nforge:\n syncedVersion: "0.0.0"\n`);
|
|
151
|
+
|
|
152
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
153
|
+
const ctx = { ...makeContext(), forgeRoot };
|
|
154
|
+
const result = await runUpgrade(input, ctx);
|
|
155
|
+
expect(result.exitCode).toBe(0);
|
|
156
|
+
|
|
157
|
+
const written = parseYaml(await readFile(join(tempDir, "forge.yaml"), "utf8")) as Record<
|
|
158
|
+
string,
|
|
159
|
+
unknown
|
|
160
|
+
>;
|
|
161
|
+
expect(
|
|
162
|
+
written["profile"],
|
|
163
|
+
"re-serialized forge.yaml must retain the declared id string, not drop it",
|
|
164
|
+
).toBe("bogus-id");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// AC-6: resolvable id serializes as the id string, not the StackProfile object
|
|
168
|
+
test("forge upgrade writes the profile id string, not the resolved object", async () => {
|
|
169
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
170
|
+
{ id: "known-profile", marker: "marker-known.txt" },
|
|
171
|
+
]);
|
|
172
|
+
await writeForgeYaml(`profile: known-profile\nforge:\n syncedVersion: "0.0.0"\n`);
|
|
173
|
+
|
|
174
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
175
|
+
const ctx = { ...makeContext(), forgeRoot };
|
|
176
|
+
const result = await runUpgrade(input, ctx);
|
|
177
|
+
expect(result.exitCode).toBe(0);
|
|
178
|
+
|
|
179
|
+
const written = parseYaml(await readFile(join(tempDir, "forge.yaml"), "utf8")) as Record<
|
|
180
|
+
string,
|
|
181
|
+
unknown
|
|
182
|
+
>;
|
|
183
|
+
expect(
|
|
184
|
+
typeof written["profile"],
|
|
185
|
+
"profile must serialize as the declared id string, not the StackProfile object",
|
|
186
|
+
).toBe("string");
|
|
187
|
+
expect(written["profile"]).toBe("known-profile");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// AC-7: unreadable catalog → warn, not fail
|
|
191
|
+
test("doctor warns (not fails) when the profile catalog cannot be read", async () => {
|
|
192
|
+
// No fake forge root — resolveForgeRoot falls back to a nonexistent path.
|
|
193
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
194
|
+
|
|
195
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
196
|
+
const result = await runDoctor(input, makeContext());
|
|
197
|
+
|
|
198
|
+
const check = findCheck(result, "profile-id-known");
|
|
199
|
+
expect(check!.status).toBe("warn");
|
|
200
|
+
expect(check!.message).toContain("bogus-id");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// AC-8: init --from never overwrites a declared profile id
|
|
204
|
+
test("forge init preserves a declared profile id against conflicting detection", async () => {
|
|
205
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
206
|
+
{ id: "detected-y", marker: "marker-y.txt" },
|
|
207
|
+
]);
|
|
208
|
+
await writeForgeYaml(`profile: declared-x\n`);
|
|
209
|
+
|
|
210
|
+
// --from directory that detects as "detected-y"
|
|
211
|
+
const fromDir = join(tempDir, "from-src");
|
|
212
|
+
await mkdir(fromDir, { recursive: true });
|
|
213
|
+
await writeFile(join(fromDir, "marker-y.txt"), "x", "utf8");
|
|
214
|
+
|
|
215
|
+
const result = runInit({ flags: { from: fromDir } }, { workspaceRoot: tempDir, forgeRoot });
|
|
216
|
+
|
|
217
|
+
const written = await readFile(join(tempDir, "forge.yaml"), "utf8");
|
|
218
|
+
expect(written).toContain("profile: declared-x");
|
|
219
|
+
expect(
|
|
220
|
+
result.skipped.some((s) => s.includes("declared-x") && s.includes("detected-y")),
|
|
221
|
+
"init must surface a warning that the declared id was preserved over the detection result",
|
|
222
|
+
).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Corrupted state recovery: object-form profile (written by the old bug) heals to the declared id
|
|
226
|
+
test("loadForgeConfig recovers profileDeclaredId from a corrupted object-form profile", async () => {
|
|
227
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
228
|
+
await writeForgeYaml(`profile:\n id: known-profile\n displayName: known-profile profile\n`);
|
|
229
|
+
|
|
230
|
+
const config = loadForgeConfig(tempDir);
|
|
231
|
+
expect(config.profileDeclaredId).toBe("known-profile");
|
|
232
|
+
expect(config.profileResolution).toBe("resolved");
|
|
233
|
+
|
|
234
|
+
// Re-serialization heals the field back to the id string
|
|
235
|
+
const serialized = serializeForgeConfig(config);
|
|
236
|
+
expect(serialized["profile"]).toBe("known-profile");
|
|
237
|
+
});
|