@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.
Files changed (57) hide show
  1. package/AGENTS.md +7 -0
  2. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  3. package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
  4. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  5. package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
  6. package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
  7. package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
  8. package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
  9. package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
  10. package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
  11. package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
  12. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  13. package/dist/os/compass/handlers/compass-inventory.js +20 -5
  14. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  15. package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
  16. package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
  17. package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
  18. package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
  19. package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
  20. package/dist/os/compass/handlers/compass-migrate.js +1 -1
  21. package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
  22. package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
  23. package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
  24. package/dist/os/core/handlers/pinned-validate.js +26 -6
  25. package/dist/os/core/handlers/pinned-validate.js.map +1 -1
  26. package/dist/src/config/forge-config.d.ts +8 -1
  27. package/dist/src/config/forge-config.d.ts.map +1 -1
  28. package/dist/src/config/forge-config.js +52 -5
  29. package/dist/src/config/forge-config.js.map +1 -1
  30. package/dist/src/onboarding/create.d.ts.map +1 -1
  31. package/dist/src/onboarding/create.js +4 -8
  32. package/dist/src/onboarding/create.js.map +1 -1
  33. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  34. package/dist/src/onboarding/doctor.js +65 -0
  35. package/dist/src/onboarding/doctor.js.map +1 -1
  36. package/dist/src/onboarding/init.d.ts.map +1 -1
  37. package/dist/src/onboarding/init.js +11 -0
  38. package/dist/src/onboarding/init.js.map +1 -1
  39. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  40. package/dist/src/onboarding/upgrade.js +4 -2
  41. package/dist/src/onboarding/upgrade.js.map +1 -1
  42. package/os/compass/handlers/compass-audit-handler.ts +3 -0
  43. package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
  44. package/os/compass/handlers/compass-inventory-handler.ts +1 -0
  45. package/os/compass/handlers/compass-inventory.ts +20 -6
  46. package/os/compass/handlers/compass-migrate-handler.ts +1 -0
  47. package/os/compass/handlers/compass-migrate.ts +9 -2
  48. package/os/compass/handlers/resolve-scan-root.ts +1 -1
  49. package/os/core/handlers/pinned-validate.ts +33 -7
  50. package/package.json +1 -1
  51. package/src/config/forge-config.ts +59 -5
  52. package/src/onboarding/create.ts +4 -7
  53. package/src/onboarding/doctor.ts +70 -0
  54. package/src/onboarding/init.ts +19 -1
  55. package/src/onboarding/upgrade.ts +4 -1
  56. package/src/tests/doctor-profile-id-known.test.ts +237 -0
  57. 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<{ relPath: string; operation: PinnedViolation["operation"] }> = [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/forge",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
- profile: z.string().optional(),
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
- const profileId = rawData["profile"] as string | undefined;
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
- const profiles = listStackProfiles(forgeRoot);
393
- loadedProfile = profiles.find((p) => p.id === profileId);
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 — profile not loaded
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
  // ---------------------------------------------------------------------------
@@ -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-0643: strip loaded profile object before serializing — forge.yaml stores profile id (string), not the full profile
352
- const configToSerialize = { ...config } as unknown as Record<string, unknown>;
353
- if (typeof configToSerialize["profile"] === "object") {
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
  }
@@ -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);
@@ -60,7 +60,11 @@ export function runInit(
60
60
  context: unknown,
61
61
  domainFields?: InitDomainFields,
62
62
  ): InitResult {
63
- const ctx = context as { workspaceRoot?: string; forgeRoot?: string };
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
+ });