@warpgogol/forge 5.1.0 → 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 (84) hide show
  1. package/AGENTS.md +8 -1
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +8 -8
  4. package/README.uk.md +5 -5
  5. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  6. package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
  7. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  8. package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
  9. package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
  10. package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
  11. package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
  12. package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
  13. package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
  14. package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
  15. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  16. package/dist/os/compass/handlers/compass-inventory.js +20 -5
  17. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  18. package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
  19. package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
  20. package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
  21. package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
  22. package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
  23. package/dist/os/compass/handlers/compass-migrate.js +1 -1
  24. package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
  25. package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
  26. package/dist/os/core/core.module.js +2 -2
  27. package/dist/os/core/core.module.js.map +1 -1
  28. package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
  29. package/dist/os/core/handlers/pinned-validate.js +26 -6
  30. package/dist/os/core/handlers/pinned-validate.js.map +1 -1
  31. package/dist/src/config/forge-config.d.ts +20 -1
  32. package/dist/src/config/forge-config.d.ts.map +1 -1
  33. package/dist/src/config/forge-config.js +76 -5
  34. package/dist/src/config/forge-config.js.map +1 -1
  35. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  36. package/dist/src/onboarding/agents-generate.js +11 -7
  37. package/dist/src/onboarding/agents-generate.js.map +1 -1
  38. package/dist/src/onboarding/create.d.ts.map +1 -1
  39. package/dist/src/onboarding/create.js +4 -8
  40. package/dist/src/onboarding/create.js.map +1 -1
  41. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  42. package/dist/src/onboarding/doctor.js +65 -0
  43. package/dist/src/onboarding/doctor.js.map +1 -1
  44. package/dist/src/onboarding/init.d.ts.map +1 -1
  45. package/dist/src/onboarding/init.js +11 -0
  46. package/dist/src/onboarding/init.js.map +1 -1
  47. package/dist/src/onboarding/nested-agents-templates.d.ts.map +1 -1
  48. package/dist/src/onboarding/nested-agents-templates.js +5 -2
  49. package/dist/src/onboarding/nested-agents-templates.js.map +1 -1
  50. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  51. package/dist/src/onboarding/upgrade.js +4 -2
  52. package/dist/src/onboarding/upgrade.js.map +1 -1
  53. package/docs/getting-started.md +1 -1
  54. package/docs/reference/profiles.md +8 -8
  55. package/os/compass/handlers/compass-audit-handler.ts +3 -0
  56. package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
  57. package/os/compass/handlers/compass-inventory-handler.ts +1 -0
  58. package/os/compass/handlers/compass-inventory.ts +20 -6
  59. package/os/compass/handlers/compass-migrate-handler.ts +1 -0
  60. package/os/compass/handlers/compass-migrate.ts +9 -2
  61. package/os/compass/handlers/resolve-scan-root.ts +1 -1
  62. package/os/compass/handlers/tests/compass-policy.test.ts +2 -2
  63. package/os/core/core.module.ts +2 -2
  64. package/os/core/handlers/pinned-validate.ts +33 -7
  65. package/package.json +1 -1
  66. package/profiles/{godot-csharp.yaml → godot-game.yaml} +2 -2
  67. package/profiles/{knowledge-typescript-turborepo.yaml → knowledge.yaml} +1 -1
  68. package/profiles/{phaser-turborepo.yaml → phaser-game.yaml} +2 -2
  69. package/profiles/{typescript-turborepo.yaml → typescript.yaml} +2 -2
  70. package/src/config/forge-config.ts +83 -5
  71. package/src/onboarding/agents-generate.ts +16 -7
  72. package/src/onboarding/create.ts +4 -7
  73. package/src/onboarding/doctor.ts +70 -0
  74. package/src/onboarding/init.ts +19 -1
  75. package/src/onboarding/nested-agents-templates.ts +5 -2
  76. package/src/onboarding/upgrade.ts +4 -1
  77. package/src/tests/agents-generate-domain.test.ts +3 -3
  78. package/src/tests/create.test.ts +4 -4
  79. package/src/tests/doctor-profile-id-known.test.ts +237 -0
  80. package/src/tests/package-files.test.ts +53 -1
  81. package/src/tests/pinned-validate.test.ts +134 -0
  82. package/src/tests/profile-schema.test.ts +4 -4
  83. package/src/tests/scaffold-project.test.ts +3 -3
  84. package/src/tests/stack-profile.test.ts +12 -12
@@ -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
  // ---------------------------------------------------------------------------
@@ -433,6 +487,30 @@ export function resolveForgeRoot(workspaceRoot: string): string {
433
487
  );
434
488
  }
435
489
 
490
+ /**
491
+ * Resolve the forge package root from inside the package's own modules.
492
+ * Handles both source layout (`src/<dir>/` → package root is two levels up)
493
+ * and compiled layout (`dist/src/<dir>/` → package root is three levels up)
494
+ * by walking up from `startDir` until a `package.json` is found.
495
+ *
496
+ * Use this for resolving package-shipped assets (templates, profiles) —
497
+ * `src/` and `profiles/` are both listed in package.json `files`, so they
498
+ * exist at the package root in the published tarball even though `tsc`
499
+ * never copies them into `dist/`.
500
+ */
501
+ export function resolveForgePackageRoot(startDir: string): string {
502
+ let dir = startDir;
503
+ for (let i = 0; i < 6; i += 1) {
504
+ if (fs.existsSync(path.join(dir, "package.json"))) {
505
+ return dir;
506
+ }
507
+ const parent = path.dirname(dir);
508
+ if (parent === dir) break;
509
+ dir = parent;
510
+ }
511
+ return startDir;
512
+ }
513
+
436
514
  // ---------------------------------------------------------------------------
437
515
  // Binding resolver — RFC-0393
438
516
  // ---------------------------------------------------------------------------
@@ -23,7 +23,7 @@ Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PU
23
23
  import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { parse as parseYaml } from "yaml";
26
- import { loadForgeConfig, resolveBinding, resolveForgeRoot } from "../config/forge-config.ts";
26
+ import { loadForgeConfig, resolveBinding, resolveForgePackageRoot, resolveForgeRoot } from "../config/forge-config.ts";
27
27
  import { resolveAllTerminology } from "../profiles/terminology-utils.ts";
28
28
  import { FORGE_SKILLS } from "../registry.ts";
29
29
  import { buildGeneratedHeader, hasGeneratedMarker, writeFileIfChanged } from "../utils/index.ts";
@@ -58,13 +58,22 @@ export interface TemplateContext {
58
58
  workspaceType?: string;
59
59
  }
60
60
 
61
- // RFC-0643: Root template paths
62
- const BUSINESS_ROOT_TEMPLATE = path.join(import.meta.dirname, "templates", "root-agents-business.md");
63
- const CREATIVE_ROOT_TEMPLATE = path.join(import.meta.dirname, "templates", "root-agents-creative.md");
61
+ // RFC-0643: Root template paths.
62
+ // Templates ship in the tarball under src/onboarding/templates/ but tsc never
63
+ // copies .md files into dist/ — resolve via the package root so both the source
64
+ // layout (src/onboarding/) and compiled layout (dist/src/onboarding/) work.
65
+ const TEMPLATES_DIR = path.join(
66
+ resolveForgePackageRoot(import.meta.dirname),
67
+ "src",
68
+ "onboarding",
69
+ "templates",
70
+ );
71
+ const BUSINESS_ROOT_TEMPLATE = path.join(TEMPLATES_DIR, "root-agents-business.md");
72
+ const CREATIVE_ROOT_TEMPLATE = path.join(TEMPLATES_DIR, "root-agents-creative.md");
64
73
 
65
74
  // Behavioral layer template paths
66
- const BEHAVIORAL_LAYER_CORE_TEMPLATE = path.join(import.meta.dirname, "templates", "behavioral-layer-core.md");
67
- const BEHAVIORAL_LAYER_EXTENDED_TEMPLATE = path.join(import.meta.dirname, "templates", "behavioral-layer-extended.md");
75
+ const BEHAVIORAL_LAYER_CORE_TEMPLATE = path.join(TEMPLATES_DIR, "behavioral-layer-core.md");
76
+ const BEHAVIORAL_LAYER_EXTENDED_TEMPLATE = path.join(TEMPLATES_DIR, "behavioral-layer-extended.md");
68
77
 
69
78
  export function selectRootTemplate(
70
79
  register: BehavioralRegister,
@@ -75,7 +84,7 @@ export function selectRootTemplate(
75
84
  const templateRel = profile.rootAgentsMdTemplate;
76
85
  // Path traversal guard: reject absolute paths and ..
77
86
  if (!path.isAbsolute(templateRel) && !templateRel.includes("..")) {
78
- const forgeRoot = path.resolve(import.meta.dirname, "..", "..");
87
+ const forgeRoot = resolveForgePackageRoot(import.meta.dirname);
79
88
  const profilesDir = path.join(forgeRoot, "profiles");
80
89
  const templatePath = path.resolve(profilesDir, templateRel);
81
90
  // Ensure resolved path is within profiles directory
@@ -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);
@@ -20,6 +20,7 @@ Sweep batch 4: 73 Compass headers on headerless engine files (certification, com
20
20
  */
21
21
 
22
22
  import { buildGeneratedHeader } from "../utils/index.ts";
23
+ import { resolveForgePackageRoot } from "../config/forge-config.ts";
23
24
  import type { ForgeConfig } from "../config/forge-config.ts";
24
25
  import type { WorkspaceDir, WorkspaceType } from "./workspace-discovery.ts";
25
26
  import type { ProfileWorkspaceType } from "../profiles/profile-schema.ts";
@@ -151,8 +152,10 @@ export function selectNestedTemplate(
151
152
  return fallback;
152
153
  }
153
154
 
154
- // Resolve relative to profiles/ directory (where profile YAMLs live)
155
- const forgeRoot = path.resolve(import.meta.dirname, "..", "..");
155
+ // Resolve relative to profiles/ directory (where profile YAMLs live).
156
+ // import.meta.dirname points at dist/src/onboarding/ in the compiled package —
157
+ // resolve the package root so profiles/ is found in both src and dist layouts.
158
+ const forgeRoot = resolveForgePackageRoot(import.meta.dirname);
156
159
  const profilesDir = path.join(forgeRoot, "profiles");
157
160
  const templatePath = path.resolve(profilesDir, templateRel);
158
161
 
@@ -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(
@@ -147,7 +147,7 @@ test("creative template contains {{dynamicSections}} marker", () => {
147
147
 
148
148
  test("selectRootTemplate with profile rootAgentsMdTemplate loads profile template", () => {
149
149
  const profile = {
150
- id: "godot-csharp",
150
+ id: "godot-game",
151
151
  rootAgentsMdTemplate: "root-agents-godot.md",
152
152
  } as StackProfile;
153
153
  const content = selectRootTemplate("creative", profile);
@@ -301,8 +301,8 @@ test("agents-generate with creative register uses creative template", async () =
301
301
  expect(rootDetail?.register).toBe("creative");
302
302
  });
303
303
 
304
- test("agents-generate with godot-csharp profile generates Godot-specific AGENTS.md", async () => {
305
- await makeForgeYaml(tempDir, "profile: godot-csharp");
304
+ test("agents-generate with godot-game profile generates Godot-specific AGENTS.md", async () => {
305
+ await makeForgeYaml(tempDir, "profile: godot-game");
306
306
  const ctx = makeContext(tempDir);
307
307
  ctx.dryRun = true;
308
308
 
@@ -290,9 +290,9 @@ test("forge create --in-place derives name from folder name when --name omitted"
290
290
  expect(pkgJson.name).toBe("my-derived-project");
291
291
  }, 120000);
292
292
 
293
- test("forge create --profile godot-csharp writes compass.fileExtensions into forge.yaml", async () => {
293
+ test("forge create --profile godot-game writes compass.fileExtensions into forge.yaml", async () => {
294
294
  const result = await runCreate(
295
- { argv: [], flags: { "in-place": true, profile: "godot-csharp", name: "my-game" } },
295
+ { argv: [], flags: { "in-place": true, profile: "godot-game", name: "my-game" } },
296
296
  makeContext(tempDir),
297
297
  );
298
298
  expect(result.exitCode).toBe(0);
@@ -346,9 +346,9 @@ test("forge create --in-place pins pre-user-prompt-wrapper.mjs in pinned.yaml (R
346
346
  expect(pinnedYaml).toContain("pre-user-prompt-wrapper.mjs");
347
347
  }, 120000);
348
348
 
349
- test("forge create --profile phaser-turborepo writes compass.fileExtensions into forge.yaml", async () => {
349
+ test("forge create --profile phaser-game writes compass.fileExtensions into forge.yaml", async () => {
350
350
  const result = await runCreate(
351
- { argv: [], flags: { "in-place": true, profile: "phaser-turborepo", name: "my-phaser-game" } },
351
+ { argv: [], flags: { "in-place": true, profile: "phaser-game", name: "my-phaser-game" } },
352
352
  makeContext(tempDir),
353
353
  );
354
354
  expect(result.exitCode).toBe(0);
@@ -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
+ });