@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.
- package/AGENTS.md +8 -1
- package/CHANGELOG.md +72 -0
- package/README.md +8 -8
- package/README.uk.md +5 -5
- 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/core.module.js +2 -2
- package/dist/os/core/core.module.js.map +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 +20 -1
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +76 -5
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
- package/dist/src/onboarding/agents-generate.js +11 -7
- package/dist/src/onboarding/agents-generate.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/nested-agents-templates.d.ts.map +1 -1
- package/dist/src/onboarding/nested-agents-templates.js +5 -2
- package/dist/src/onboarding/nested-agents-templates.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/docs/getting-started.md +1 -1
- package/docs/reference/profiles.md +8 -8
- 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/compass/handlers/tests/compass-policy.test.ts +2 -2
- package/os/core/core.module.ts +2 -2
- package/os/core/handlers/pinned-validate.ts +33 -7
- package/package.json +1 -1
- package/profiles/{godot-csharp.yaml → godot-game.yaml} +2 -2
- package/profiles/{knowledge-typescript-turborepo.yaml → knowledge.yaml} +1 -1
- package/profiles/{phaser-turborepo.yaml → phaser-game.yaml} +2 -2
- package/profiles/{typescript-turborepo.yaml → typescript.yaml} +2 -2
- package/src/config/forge-config.ts +83 -5
- package/src/onboarding/agents-generate.ts +16 -7
- package/src/onboarding/create.ts +4 -7
- package/src/onboarding/doctor.ts +70 -0
- package/src/onboarding/init.ts +19 -1
- package/src/onboarding/nested-agents-templates.ts +5 -2
- package/src/onboarding/upgrade.ts +4 -1
- package/src/tests/agents-generate-domain.test.ts +3 -3
- package/src/tests/create.test.ts +4 -4
- package/src/tests/doctor-profile-id-known.test.ts +237 -0
- package/src/tests/package-files.test.ts +53 -1
- package/src/tests/pinned-validate.test.ts +134 -0
- package/src/tests/profile-schema.test.ts +4 -4
- package/src/tests/scaffold-project.test.ts +3 -3
- 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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
63
|
-
|
|
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(
|
|
67
|
-
const BEHAVIORAL_LAYER_EXTENDED_TEMPLATE = path.join(
|
|
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 =
|
|
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
|
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);
|
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
305
|
-
await makeForgeYaml(tempDir, "profile: godot-
|
|
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
|
|
package/src/tests/create.test.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
+
});
|