@warpgogol/forge 5.1.1 → 5.1.3

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 (106) hide show
  1. package/AGENTS.md +21 -2
  2. package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
  3. package/dist/os/adr/handlers/implement-stamp.js +32 -2
  4. package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
  5. package/dist/os/adr/types.d.ts +2 -1
  6. package/dist/os/adr/types.d.ts.map +1 -1
  7. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
  9. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  10. package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
  12. package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
  13. package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
  14. package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
  15. package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
  16. package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
  17. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  18. package/dist/os/compass/handlers/compass-inventory.js +20 -5
  19. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  20. package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
  21. package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
  22. package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
  23. package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
  24. package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
  25. package/dist/os/compass/handlers/compass-migrate.js +1 -1
  26. package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
  27. package/dist/os/compass/handlers/resolve-scan-root.js +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/os/naming/naming-convention.d.ts.map +1 -1
  32. package/dist/os/naming/naming-convention.js +2 -0
  33. package/dist/os/naming/naming-convention.js.map +1 -1
  34. package/dist/os/rfc/frontmatter-io.d.ts.map +1 -1
  35. package/dist/os/rfc/frontmatter-io.js +1 -0
  36. package/dist/os/rfc/frontmatter-io.js.map +1 -1
  37. package/dist/os/rfc/handlers/validate-rules.d.ts.map +1 -1
  38. package/dist/os/rfc/handlers/validate-rules.js +6 -1
  39. package/dist/os/rfc/handlers/validate-rules.js.map +1 -1
  40. package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
  41. package/dist/os/werkstatt/handlers/workspace-deps.js +4 -3
  42. package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
  43. package/dist/src/config/forge-config.d.ts +8 -1
  44. package/dist/src/config/forge-config.d.ts.map +1 -1
  45. package/dist/src/config/forge-config.js +52 -5
  46. package/dist/src/config/forge-config.js.map +1 -1
  47. package/dist/src/onboarding/create.d.ts.map +1 -1
  48. package/dist/src/onboarding/create.js +9 -10
  49. package/dist/src/onboarding/create.js.map +1 -1
  50. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  51. package/dist/src/onboarding/doctor.js +80 -0
  52. package/dist/src/onboarding/doctor.js.map +1 -1
  53. package/dist/src/onboarding/init.d.ts +1 -0
  54. package/dist/src/onboarding/init.d.ts.map +1 -1
  55. package/dist/src/onboarding/init.js +11 -0
  56. package/dist/src/onboarding/init.js.map +1 -1
  57. package/dist/src/onboarding/npm-token-check.d.ts +40 -0
  58. package/dist/src/onboarding/npm-token-check.d.ts.map +1 -0
  59. package/dist/src/onboarding/npm-token-check.js +104 -0
  60. package/dist/src/onboarding/npm-token-check.js.map +1 -0
  61. package/dist/src/onboarding/scaffold-project.d.ts.map +1 -1
  62. package/dist/src/onboarding/scaffold-project.js +10 -0
  63. package/dist/src/onboarding/scaffold-project.js.map +1 -1
  64. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  65. package/dist/src/onboarding/upgrade.js +4 -2
  66. package/dist/src/onboarding/upgrade.js.map +1 -1
  67. package/dist/src/profiles/profile-schema.d.ts +2 -0
  68. package/dist/src/profiles/profile-schema.d.ts.map +1 -1
  69. package/dist/src/profiles/profile-schema.js +1 -0
  70. package/dist/src/profiles/profile-schema.js.map +1 -1
  71. package/dist/src/profiles/stack-profile.d.ts +1 -0
  72. package/dist/src/profiles/stack-profile.d.ts.map +1 -1
  73. package/dist/src/profiles/stack-profile.js +2 -0
  74. package/dist/src/profiles/stack-profile.js.map +1 -1
  75. package/os/adr/handlers/implement-stamp.ts +34 -2
  76. package/os/adr/types.ts +3 -1
  77. package/os/compass/handlers/compass-audit-handler.ts +3 -0
  78. package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
  79. package/os/compass/handlers/compass-inventory-handler.ts +1 -0
  80. package/os/compass/handlers/compass-inventory.ts +20 -6
  81. package/os/compass/handlers/compass-migrate-handler.ts +1 -0
  82. package/os/compass/handlers/compass-migrate.ts +9 -2
  83. package/os/compass/handlers/resolve-scan-root.ts +1 -1
  84. package/os/core/handlers/pinned-validate.ts +33 -7
  85. package/os/naming/naming-convention.ts +2 -0
  86. package/os/rfc/frontmatter-io.ts +1 -0
  87. package/os/rfc/handlers/validate-rules.ts +8 -1
  88. package/os/werkstatt/handlers/workspace-deps.ts +4 -3
  89. package/package.json +1 -1
  90. package/profiles/site-workshop.yaml +654 -0
  91. package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +1 -1
  92. package/src/config/forge-config.ts +59 -5
  93. package/src/onboarding/create.ts +9 -9
  94. package/src/onboarding/doctor-token-check.test.ts +124 -0
  95. package/src/onboarding/doctor.ts +87 -0
  96. package/src/onboarding/init.ts +20 -1
  97. package/src/onboarding/npm-token-check.ts +140 -0
  98. package/src/onboarding/scaffold-project.ts +11 -0
  99. package/src/onboarding/site-workshop-profile.test.ts +84 -0
  100. package/src/onboarding/upgrade.ts +4 -1
  101. package/src/profiles/profile-schema.ts +2 -0
  102. package/src/profiles/stack-profile.ts +2 -0
  103. package/src/tests/adr-implement-stamp.test.ts +150 -0
  104. package/src/tests/doctor-profile-id-known.test.ts +237 -0
  105. package/src/tests/pinned-validate.test.ts +134 -0
  106. package/src/tests/profile-schema.test.ts +1 -1
@@ -0,0 +1,84 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the site-workshop stack profile (RFC-1125) — verifies the profile parses, scaffolds the workshop contract, and never scaffolds systems-cache inside the workshop root.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-1125: initial site-workshop profile tests (AC-1 evidence).</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect } from "vitest";
11
+ import { join } from "node:path";
12
+ import { listStackProfiles } from "../profiles/stack-profile.ts";
13
+
14
+ // Resolve forge root from this test file's location
15
+ const FORGE_ROOT = join(import.meta.dirname, "..", "..");
16
+
17
+ function loadSiteWorkshop() {
18
+ const profiles = listStackProfiles(FORGE_ROOT);
19
+ const profile = profiles.find((p) => p.id === "site-workshop");
20
+ expect(profile, "site-workshop profile must exist in packages/forge/profiles/").toBeDefined();
21
+ return profile!;
22
+ }
23
+
24
+ test("site-workshop profile parses and is discoverable", () => {
25
+ const profile = loadSiteWorkshop();
26
+ expect(profile.schema).toBe("forge/stack-profile@1");
27
+ expect(profile.displayName).toBeTruthy();
28
+ });
29
+
30
+ test("scaffolds kernel wiring, missions/, and governance dirs — but never systems-cache", () => {
31
+ const profile = loadSiteWorkshop();
32
+ const dirs = profile.workspace.dirs;
33
+ const filePaths = profile.workspace.files.map((f) => f.path);
34
+
35
+ // AC-1: kernel wiring + missions + governance dirs present
36
+ expect(dirs).toContain("missions");
37
+ expect(dirs).toContain("docs");
38
+ expect(dirs).toContain("tools");
39
+ expect(filePaths).toContain("tools/kernel.config.ts");
40
+ expect(filePaths).toContain("missions/.gitkeep");
41
+
42
+ // AC-1: systems-cache is a sibling (../systems-cache), never scaffolded inside root
43
+ expect(
44
+ dirs,
45
+ "systems-cache must not be a scaffolded dir — sibling convention per RFC-1125",
46
+ ).not.toContain("systems-cache");
47
+ expect(
48
+ filePaths.some((p) => p.startsWith("systems-cache")),
49
+ "no systems-cache files may be scaffolded — created by sternsystem.register on demand",
50
+ ).toBe(false);
51
+ });
52
+
53
+ test("scaffolded kernel.config.ts consumes the single-sourced WorkshopModuleMap", () => {
54
+ const profile = loadSiteWorkshop();
55
+ const kernelConfig = profile.workspace.files.find((f) => f.path === "tools/kernel.config.ts");
56
+ expect(kernelConfig, "profile must scaffold tools/kernel.config.ts").toBeDefined();
57
+ // AC-5: the scaffolded config spreads workshopModuleLoaders() — no hand-copied loader list
58
+ expect(kernelConfig!.content).toContain("workshopModuleLoaders");
59
+ expect(kernelConfig!.content).toContain("@warpgogol/werkstatt-engine/kernel/module-map");
60
+ expect(
61
+ kernelConfig!.content,
62
+ "kernel.config.ts must not embed per-module loader thunks — they live in WORKSHOP_MODULE_MAP",
63
+ ).not.toContain("createMissionModule");
64
+ });
65
+
66
+ test("install set includes engine, site plugin, and forge", () => {
67
+ const profile = loadSiteWorkshop();
68
+ const install = profile.install.join(" ");
69
+ expect(install).toContain("@warpgogol/werkstatt-engine");
70
+ expect(install).toContain("@warpgogol/werkstatt-site");
71
+ expect(install).toContain("@warpgogol/forge");
72
+ });
73
+
74
+ test("ships .npmrc token template and site-workshop nextSteps guidance", () => {
75
+ const profile = loadSiteWorkshop();
76
+ const npmrc = profile.workspace.files.find((f) => f.path === ".npmrc");
77
+ expect(npmrc, "profile must scaffold .npmrc for the private @warpgogol scope").toBeDefined();
78
+ expect(npmrc!.content).toContain("@warpgogol:registry");
79
+
80
+ // RFC-1125: token lifecycle + identity.bootstrap guidance lives in NEXT_STEPS.md
81
+ expect(profile.nextSteps, "profile must provide nextSteps for token/identity onboarding").toBeTruthy();
82
+ expect(profile.nextSteps).toContain("NPM_TOKEN");
83
+ expect(profile.nextSteps).toContain("identity.bootstrap");
84
+ });
@@ -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(
@@ -363,6 +363,7 @@ export const stackProfileDomainFieldsSchema = z.object({
363
363
  templates: z.array(profileTemplateSchema).optional(),
364
364
  scriptDir: z.string().min(1).optional(),
365
365
  rootAgentsMdTemplate: z.string().optional(),
366
+ nextSteps: z.string().optional(),
366
367
  compass: compassPolicyOverridesSchema.optional(),
367
368
  });
368
369
 
@@ -380,5 +381,6 @@ export interface StackProfileDomainFields {
380
381
  templates?: ProfileTemplate[];
381
382
  scriptDir?: string;
382
383
  rootAgentsMdTemplate?: string;
384
+ nextSteps?: string;
383
385
  compass?: CompassPolicyOverrides;
384
386
  }
@@ -80,6 +80,8 @@ export const stackProfileSchema = z.object({
80
80
  scriptDir: stackProfileDomainFieldsSchema.shape.scriptDir,
81
81
  // Profile-driven root AGENTS.md template
82
82
  rootAgentsMdTemplate: stackProfileDomainFieldsSchema.shape.rootAgentsMdTemplate,
83
+ // RFC-1125: profile-driven NEXT_STEPS.md content override
84
+ nextSteps: stackProfileDomainFieldsSchema.shape.nextSteps,
83
85
  // Profile-driven Compass file extensions
84
86
  compass: stackProfileDomainFieldsSchema.shape.compass,
85
87
  });
@@ -70,6 +70,48 @@ Test consequences.
70
70
  Test evolution.
71
71
  `;
72
72
 
73
+ const ADR_BODY_WITH_AC = (
74
+ id: string,
75
+ status: string,
76
+ createdAt: string,
77
+ acBlock: string,
78
+ ): string => `---
79
+ id: ${id}
80
+ title: "Test ADR"
81
+ status: ${status}
82
+ scope: workspace
83
+ decider: human:test
84
+ createdAt: ${createdAt}
85
+ updatedAt: ${createdAt}
86
+ ---
87
+
88
+ # ${id}: Test ADR
89
+
90
+ ## Context
91
+
92
+ Test context.
93
+
94
+ ## Decision
95
+
96
+ Test decision.
97
+
98
+ ## Justification
99
+
100
+ Test justification.
101
+
102
+ ## Consequences
103
+
104
+ Test consequences.
105
+
106
+ ## Evolution
107
+
108
+ Test evolution.
109
+
110
+ ## Acceptance criteria
111
+
112
+ ${acBlock}
113
+ `;
114
+
73
115
  async function makeGitRepo(dir: string): Promise<void> {
74
116
  execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
75
117
  execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
@@ -295,6 +337,114 @@ describe("adr.implement.stamp", () => {
295
337
  expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
296
338
  });
297
339
 
340
+ // ── ADR-IMP-02: acceptance criteria completeness (post-cutoff, mirrors AV-17) ──
341
+
342
+ it("rejects post-cutoff ADR with unchecked acceptance criteria", async () => {
343
+ await writeFile(
344
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
345
+ ADR_BODY_WITH_AC(
346
+ "ADR-0001",
347
+ "accepted",
348
+ "2026-09-10",
349
+ "- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
350
+ ),
351
+ );
352
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
353
+
354
+ const result = await runAdrImplementStamp(
355
+ makeInput("ADR-0001", sha),
356
+ makeContext(workspaceRoot),
357
+ );
358
+
359
+ expect(result.data?.status).toBe("fail");
360
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
361
+ expect(result.exitCode).toBe(1);
362
+ });
363
+
364
+ it("rejects post-cutoff ADR with checked criteria lacking evidence annotation", async () => {
365
+ await writeFile(
366
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
367
+ ADR_BODY_WITH_AC(
368
+ "ADR-0001",
369
+ "accepted",
370
+ "2026-09-10",
371
+ "- [x] AC-1: THE artifact SHALL exist",
372
+ ),
373
+ );
374
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
375
+
376
+ const result = await runAdrImplementStamp(
377
+ makeInput("ADR-0001", sha),
378
+ makeContext(workspaceRoot),
379
+ );
380
+
381
+ expect(result.data?.status).toBe("fail");
382
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
383
+ });
384
+
385
+ it("stamps post-cutoff ADR when all criteria are checked with evidence", async () => {
386
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
387
+ await writeFile(
388
+ adrPath,
389
+ ADR_BODY_WITH_AC(
390
+ "ADR-0001",
391
+ "accepted",
392
+ "2026-09-10",
393
+ "- [x] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
394
+ ),
395
+ );
396
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
397
+
398
+ const result = await runAdrImplementStamp(
399
+ makeInput("ADR-0001", sha),
400
+ makeContext(workspaceRoot),
401
+ );
402
+
403
+ expect(result.data?.status).toBe("pass");
404
+ expect(result.data?.data?.criteriaChecked).toBe(2);
405
+
406
+ const afterContent = await readFile(adrPath, "utf-8");
407
+ expect(afterContent).toContain("status: implemented");
408
+ });
409
+
410
+ it("stamps pre-cutoff ADR with unchecked criteria (AV-17 exempt)", async () => {
411
+ await writeFile(
412
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
413
+ ADR_BODY_WITH_AC(
414
+ "ADR-0001",
415
+ "accepted",
416
+ "2026-08-01",
417
+ "- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)",
418
+ ),
419
+ );
420
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
421
+
422
+ const result = await runAdrImplementStamp(
423
+ makeInput("ADR-0001", sha),
424
+ makeContext(workspaceRoot),
425
+ );
426
+
427
+ expect(result.data?.status).toBe("pass");
428
+ expect(result.data?.data?.criteriaChecked).toBeUndefined();
429
+ });
430
+
431
+ it("stamps post-cutoff ADR without an acceptance criteria section", async () => {
432
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
433
+ const body = ADR_BODY("ADR-0001", "accepted").replace(
434
+ "createdAt: 2026-08-01",
435
+ "createdAt: 2026-09-10",
436
+ );
437
+ await writeFile(adrPath, body);
438
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
439
+
440
+ const result = await runAdrImplementStamp(
441
+ makeInput("ADR-0001", sha),
442
+ makeContext(workspaceRoot),
443
+ );
444
+
445
+ expect(result.data?.status).toBe("pass");
446
+ });
447
+
298
448
  // ── Dry-run ──────────────────────────────────────────────────────────────────
299
449
 
300
450
  it("dry-run checks preconditions without mutating file", async () => {
@@ -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
+ });
@@ -0,0 +1,134 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { runPinnedValidate } from "../../os/core/handlers/pinned-validate.ts";
8
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ async function makeTempDir(): Promise<string> {
13
+ return mkdtemp(join(tmpdir(), "forge-pinned-validate-test-"));
14
+ }
15
+
16
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
17
+ return {
18
+ workspaceRoot,
19
+ logger: {
20
+ section: () => {},
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ success: () => {},
25
+ },
26
+ dryRun: false,
27
+ outputFormat: "json",
28
+ };
29
+ }
30
+
31
+ function makeInput(): ForgeCommandInput {
32
+ return { argv: [], flags: {} };
33
+ }
34
+
35
+ async function git(dir: string, args: string[]): Promise<void> {
36
+ await execFileAsync("git", args, { cwd: dir });
37
+ }
38
+
39
+ /**
40
+ * Scaffold a git repo with a committed pinned.yaml protecting docs/rfcs/
41
+ * and one committed RFC file. Returns the repo dir.
42
+ */
43
+ async function makeRepo(): Promise<string> {
44
+ const dir = await makeTempDir();
45
+ await git(dir, ["init"]);
46
+ await git(dir, ["config", "user.email", "test@example.com"]);
47
+ await git(dir, ["config", "user.name", "Test"]);
48
+ await mkdir(join(dir, ".forge"), { recursive: true });
49
+ await writeFile(
50
+ join(dir, ".forge", "pinned.yaml"),
51
+ "pinned:\n - path: docs/rfcs/\n mode: protect\n reason: RFC directory\n",
52
+ );
53
+ await mkdir(join(dir, "docs", "rfcs"), { recursive: true });
54
+ await writeFile(join(dir, "docs", "rfcs", "rfc-0001.md"), "# RFC 1\n");
55
+ await git(dir, ["add", "-A"]);
56
+ await git(dir, ["commit", "-m", "init"]);
57
+ return dir;
58
+ }
59
+
60
+ describe("runPinnedValidate", () => {
61
+ test("passes for intra-directory move inside a pinned dir (docs.archive)", async () => {
62
+ const dir = await makeRepo();
63
+ try {
64
+ // Simulate docs.archive: docs/rfcs/rfc-0001.md → docs/rfcs/archive/implemented/rfc-0001.md
65
+ await mkdir(join(dir, "docs", "rfcs", "archive", "implemented"), { recursive: true });
66
+ await git(dir, ["mv", "docs/rfcs/rfc-0001.md", "docs/rfcs/archive/implemented/rfc-0001.md"]);
67
+
68
+ const result = await runPinnedValidate(makeInput(), makeContext(dir));
69
+ expect(result.data?.status).toBe("pass");
70
+ expect(result.data?.violations).toHaveLength(0);
71
+ expect(result.exitCode).toBe(0);
72
+ } finally {
73
+ await rm(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("flags move out of a pinned dir", async () => {
78
+ const dir = await makeRepo();
79
+ try {
80
+ await mkdir(join(dir, "docs", "other"), { recursive: true });
81
+ await git(dir, ["mv", "docs/rfcs/rfc-0001.md", "docs/other/rfc-0001.md"]);
82
+
83
+ const result = await runPinnedValidate(makeInput(), makeContext(dir));
84
+ expect(result.data?.status).toBe("fail");
85
+ expect(result.data?.violations.length).toBeGreaterThan(0);
86
+ expect(
87
+ result.data?.violations.some(
88
+ (v) => v.path === "docs/rfcs/rfc-0001.md" && v.operation === "move",
89
+ ),
90
+ ).toBe(true);
91
+ } finally {
92
+ await rm(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("flags move into a pinned dir", async () => {
97
+ const dir = await makeRepo();
98
+ try {
99
+ // Source must be committed first — otherwise git records the staged
100
+ // result as A (add), not R (rename).
101
+ await writeFile(join(dir, "incoming.md"), "# incoming\n");
102
+ await git(dir, ["add", "incoming.md"]);
103
+ await git(dir, ["commit", "-m", "add incoming"]);
104
+ await git(dir, ["mv", "incoming.md", "docs/rfcs/incoming.md"]);
105
+
106
+ const result = await runPinnedValidate(makeInput(), makeContext(dir));
107
+ expect(result.data?.status).toBe("fail");
108
+ expect(
109
+ result.data?.violations.some(
110
+ (v) => v.path === "docs/rfcs/incoming.md" && v.operation === "move",
111
+ ),
112
+ ).toBe(true);
113
+ } finally {
114
+ await rm(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test("flags delete of a pinned file", async () => {
119
+ const dir = await makeRepo();
120
+ try {
121
+ await git(dir, ["rm", "docs/rfcs/rfc-0001.md"]);
122
+
123
+ const result = await runPinnedValidate(makeInput(), makeContext(dir));
124
+ expect(result.data?.status).toBe("fail");
125
+ expect(
126
+ result.data?.violations.some(
127
+ (v) => v.path === "docs/rfcs/rfc-0001.md" && v.operation === "delete",
128
+ ),
129
+ ).toBe(true);
130
+ } finally {
131
+ await rm(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ });
@@ -157,7 +157,7 @@ test("artifacts with missing required extensions field fails", () => {
157
157
 
158
158
  test("all shipped profiles parse without changes", () => {
159
159
  const profiles = listStackProfiles(FORGE_ROOT);
160
- expect(profiles.length).toBe(5);
160
+ expect(profiles.length).toBe(6);
161
161
  for (const profile of profiles) {
162
162
  expect(profile.id).toBeDefined();
163
163
  expect(profile.workspace.dirs.length).toBeGreaterThan(0);