@warpgogol/forge 2.7.0 → 2.8.1

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 (268) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/src/config/forge-config.d.ts +1 -1
  5. package/dist/src/config/forge-config.d.ts.map +1 -1
  6. package/dist/src/config/forge-config.js +2 -2
  7. package/dist/src/config/forge-config.js.map +1 -1
  8. package/dist/src/onboarding/agents-generate.d.ts +2 -1
  9. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  10. package/dist/src/onboarding/agents-generate.js +24 -4
  11. package/dist/src/onboarding/agents-generate.js.map +1 -1
  12. package/dist/src/profiles/profile-schema.d.ts +2 -0
  13. package/dist/src/profiles/profile-schema.d.ts.map +1 -1
  14. package/dist/src/profiles/profile-schema.js +1 -0
  15. package/dist/src/profiles/profile-schema.js.map +1 -1
  16. package/dist/src/profiles/stack-profile.d.ts +1 -0
  17. package/dist/src/profiles/stack-profile.d.ts.map +1 -1
  18. package/dist/src/profiles/stack-profile.js +2 -0
  19. package/dist/src/profiles/stack-profile.js.map +1 -1
  20. package/os/adr/adr.module.ts +158 -0
  21. package/os/adr/frontmatter-io.ts +80 -0
  22. package/os/adr/handlers/archive.ts +244 -0
  23. package/os/adr/handlers/implement-stamp.ts +331 -0
  24. package/os/adr/handlers/list-create.ts +207 -0
  25. package/os/adr/handlers/validate.test.ts +232 -0
  26. package/os/adr/handlers/validate.ts +429 -0
  27. package/os/adr/index.ts +33 -0
  28. package/os/adr/types.ts +141 -0
  29. package/os/audit/audit.module.ts +48 -0
  30. package/os/audit/frontmatter-io.ts +84 -0
  31. package/os/audit/handlers/archive.ts +239 -0
  32. package/os/audit/index.ts +23 -0
  33. package/os/audit/types.ts +29 -0
  34. package/os/compass/compass.module.ts +186 -0
  35. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  36. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  37. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  38. package/os/compass/handlers/compass-inventory.ts +521 -0
  39. package/os/compass/handlers/git-revision.ts +137 -0
  40. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  41. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  42. package/os/compass/index.ts +33 -0
  43. package/os/core/core.module.ts +948 -0
  44. package/os/core/handlers/assets-check.ts +122 -0
  45. package/os/core/handlers/assets-helpers.ts +174 -0
  46. package/os/core/handlers/assets-list.ts +99 -0
  47. package/os/core/handlers/build.ts +155 -0
  48. package/os/core/handlers/determinism-check.ts +354 -0
  49. package/os/core/handlers/dev.ts +127 -0
  50. package/os/core/handlers/invariant-engine.test.ts +681 -0
  51. package/os/core/handlers/knowledge-compact.ts +248 -0
  52. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  53. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  54. package/os/core/handlers/note-link-validate.test.ts +117 -0
  55. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  56. package/os/core/handlers/package-health.test.ts +226 -0
  57. package/os/core/handlers/package-health.ts +231 -0
  58. package/os/core/handlers/pinned-check.ts +200 -0
  59. package/os/core/handlers/pinned-init.ts +333 -0
  60. package/os/core/handlers/pinned-types.ts +50 -0
  61. package/os/core/handlers/pinned-validate.ts +301 -0
  62. package/os/core/handlers/profile-resolve.ts +92 -0
  63. package/os/core/handlers/release-prepare.ts +294 -0
  64. package/os/core/handlers/release-publish.ts +211 -0
  65. package/os/core/handlers/validate.ts +262 -0
  66. package/os/core/handlers/workspace-deps.ts +55 -0
  67. package/os/core/index.ts +13 -0
  68. package/os/exploration/exploration.module.ts +90 -0
  69. package/os/exploration/frontmatter-io.ts +77 -0
  70. package/os/exploration/handlers/archive.ts +159 -0
  71. package/os/exploration/handlers/list.ts +77 -0
  72. package/os/exploration/handlers/show.ts +107 -0
  73. package/os/exploration/index.ts +25 -0
  74. package/os/exploration/types.ts +65 -0
  75. package/os/mission/handlers/archive.test.ts +391 -0
  76. package/os/mission/handlers/archive.ts +453 -0
  77. package/os/mission/index.ts +1 -0
  78. package/os/mission/mission.module.ts +48 -0
  79. package/os/mission/types.ts +41 -0
  80. package/os/naming/index.ts +14 -0
  81. package/os/naming/naming-convention.test.ts +163 -0
  82. package/os/naming/naming-convention.ts +378 -0
  83. package/os/naming/naming.module.ts +36 -0
  84. package/os/notes/index.ts +10 -0
  85. package/os/notes/notes.module.ts +109 -0
  86. package/os/plan/frontmatter-io.ts +82 -0
  87. package/os/plan/handlers/archive.ts +238 -0
  88. package/os/plan/index.ts +23 -0
  89. package/os/plan/plan.module.ts +48 -0
  90. package/os/plan/types.ts +29 -0
  91. package/os/program/discovery.ts +294 -0
  92. package/os/program/handlers/complete.ts +419 -0
  93. package/os/program/handlers/lease.ts +433 -0
  94. package/os/program/handlers/seal.ts +332 -0
  95. package/os/program/handlers/validate.ts +213 -0
  96. package/os/program/lease.ts +156 -0
  97. package/os/program/program.module.ts +212 -0
  98. package/os/program/schemas.ts +218 -0
  99. package/os/program/state.ts +264 -0
  100. package/os/rfc/acceptance.ts +344 -0
  101. package/os/rfc/decision-log.ts +310 -0
  102. package/os/rfc/dna-trace.ts +280 -0
  103. package/os/rfc/frontmatter-io.test.ts +132 -0
  104. package/os/rfc/frontmatter-io.ts +130 -0
  105. package/os/rfc/handlers/archive.ts +300 -0
  106. package/os/rfc/handlers/check.ts +238 -0
  107. package/os/rfc/handlers/implement-stamp.ts +515 -0
  108. package/os/rfc/handlers/index-graph.ts +237 -0
  109. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  110. package/os/rfc/handlers/lifecycle.ts +249 -0
  111. package/os/rfc/handlers/list-create.ts +296 -0
  112. package/os/rfc/handlers/pipeline-status.ts +173 -0
  113. package/os/rfc/handlers/shared.ts +121 -0
  114. package/os/rfc/handlers/supersede-propose.ts +241 -0
  115. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  116. package/os/rfc/handlers/validate-rules.ts +984 -0
  117. package/os/rfc/handlers/validate.ts +166 -0
  118. package/os/rfc/handlers.ts +19 -0
  119. package/os/rfc/index.ts +97 -0
  120. package/os/rfc/rfc.module.ts +416 -0
  121. package/os/rfc/types.ts +596 -0
  122. package/os/rfc/verification-evidence.ts +244 -0
  123. package/os/session/atif-parser.ts +166 -0
  124. package/os/session/frontmatter-io.ts +163 -0
  125. package/os/session/handlers/archive.ts +288 -0
  126. package/os/session/handlers/list.ts +174 -0
  127. package/os/session/handlers/save.ts +348 -0
  128. package/os/session/handlers/validate.ts +250 -0
  129. package/os/session/index.ts +50 -0
  130. package/os/session/session.module.ts +126 -0
  131. package/os/session/types.ts +203 -0
  132. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  133. package/os/spec/live-spec-list.ts +78 -0
  134. package/os/spec/live-spec-merge.test.ts +261 -0
  135. package/os/spec/live-spec-merge.ts +413 -0
  136. package/os/spec/live-spec-show.ts +117 -0
  137. package/os/spec/live-spec-types.ts +93 -0
  138. package/os/spec/live-spec-validate.ts +170 -0
  139. package/os/spec/spec-materialize.ts +390 -0
  140. package/os/spec/spec-schema.ts +174 -0
  141. package/os/spec/spec-status.ts +284 -0
  142. package/os/spec/spec-validate.ts +513 -0
  143. package/os/spec/spec.module.ts +135 -0
  144. package/os/werkstatt/handlers/lock.ts +168 -0
  145. package/os/werkstatt/handlers/schema.ts +47 -0
  146. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  147. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  148. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  149. package/os/werkstatt/index.ts +39 -0
  150. package/os/werkstatt/werkstatt.module.ts +62 -0
  151. package/os/workflow/handlers.ts +304 -0
  152. package/os/workflow/index.ts +28 -0
  153. package/os/workflow/types.ts +99 -0
  154. package/os/workflow/workflow.module.ts +54 -0
  155. package/package.json +3 -3
  156. package/profiles/godot-csharp.yaml +5 -4
  157. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  158. package/profiles/phaser-turborepo.yaml +5 -5
  159. package/profiles/root-agents-godot.md +133 -0
  160. package/skills/_shared/fo-session-summary.md +132 -16
  161. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  162. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  163. package/skills/fo/fo-handoff/SKILL.md +5 -0
  164. package/skills/fo/fo-idea/SKILL.md +1 -1
  165. package/skills/fo/fo-review/SKILL.md +1 -1
  166. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  167. package/skills/fo/fo-session-save/SKILL.md +8 -0
  168. package/src/cli-output.ts +59 -0
  169. package/src/config/forge-config.ts +524 -0
  170. package/src/forge-module.ts +39 -0
  171. package/src/index.ts +172 -0
  172. package/src/knowledge/budgets.ts +202 -0
  173. package/src/knowledge/compact.ts +405 -0
  174. package/src/knowledge/index.ts +53 -0
  175. package/src/knowledge/parse.ts +227 -0
  176. package/src/knowledge/promote.ts +156 -0
  177. package/src/knowledge/schema.ts +112 -0
  178. package/src/knowledge/serialize.ts +72 -0
  179. package/src/migration-adapters/git-utils.ts +71 -0
  180. package/src/migration-adapters/ignored-files.ts +221 -0
  181. package/src/migration-adapters/index.ts +21 -0
  182. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  183. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  184. package/src/migration-adapters/registry.ts +61 -0
  185. package/src/migration-adapters/types.ts +68 -0
  186. package/src/onboarding/agents-generate.ts +443 -0
  187. package/src/onboarding/create.ts +427 -0
  188. package/src/onboarding/doctor.ts +1297 -0
  189. package/src/onboarding/init.ts +337 -0
  190. package/src/onboarding/invariant-engine.ts +448 -0
  191. package/src/onboarding/memory-scaffold.ts +189 -0
  192. package/src/onboarding/nested-agents-generate.ts +93 -0
  193. package/src/onboarding/nested-agents-templates.ts +233 -0
  194. package/src/onboarding/profile-validate.ts +139 -0
  195. package/src/onboarding/scaffold-project.ts +299 -0
  196. package/src/onboarding/scaffold.ts +143 -0
  197. package/src/onboarding/upgrade.ts +558 -0
  198. package/src/onboarding/workspace-discovery.ts +167 -0
  199. package/src/profiles/profile-schema.ts +350 -0
  200. package/src/profiles/stack-profile.ts +188 -0
  201. package/src/profiles/terminology-utils.ts +36 -0
  202. package/src/registry.ts +185 -0
  203. package/src/skill-schema.ts +37 -0
  204. package/src/tests/acceptance-criteria.test.ts +99 -0
  205. package/src/tests/adr-implement-stamp.test.ts +368 -0
  206. package/src/tests/agents-generate-domain.test.ts +317 -0
  207. package/src/tests/agents-generate.test.ts +458 -0
  208. package/src/tests/bindings-schema.test.ts +314 -0
  209. package/src/tests/budgets.test.ts +213 -0
  210. package/src/tests/cli-output.test.ts +196 -0
  211. package/src/tests/compact.test.ts +796 -0
  212. package/src/tests/create.test.ts +289 -0
  213. package/src/tests/doctor-autonomy.test.ts +98 -0
  214. package/src/tests/doctor-bindings.test.ts +286 -0
  215. package/src/tests/doctor-domain.test.ts +113 -0
  216. package/src/tests/exploration-handlers.test.ts +256 -0
  217. package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
  218. package/src/tests/forge-config.test.ts +420 -0
  219. package/src/tests/fs-atomic.test.ts +96 -0
  220. package/src/tests/fs.test.ts +103 -0
  221. package/src/tests/generated-marker.test.ts +161 -0
  222. package/src/tests/hash.test.ts +37 -0
  223. package/src/tests/implement-stamp.test.ts +505 -0
  224. package/src/tests/init-bindings.test.ts +183 -0
  225. package/src/tests/knowledge-parse.test.ts +307 -0
  226. package/src/tests/knowledge-pbt.test.ts +164 -0
  227. package/src/tests/memory-scaffold.test.ts +192 -0
  228. package/src/tests/migration-adapters.test.ts +480 -0
  229. package/src/tests/package-files.test.ts +51 -0
  230. package/src/tests/pinned-check.test.ts +194 -0
  231. package/src/tests/pinned-init.test.ts +126 -0
  232. package/src/tests/profile-schema.test.ts +194 -0
  233. package/src/tests/profile-validate.test.ts +120 -0
  234. package/src/tests/program-lease.test.ts +245 -0
  235. package/src/tests/program-paths-extended.test.ts +117 -0
  236. package/src/tests/program-paths.test.ts +98 -0
  237. package/src/tests/program-property.test.ts +139 -0
  238. package/src/tests/program-schemas.test.ts +445 -0
  239. package/src/tests/program-spec-node.test.ts +250 -0
  240. package/src/tests/promote.test.ts +481 -0
  241. package/src/tests/registry.test.ts +73 -0
  242. package/src/tests/scaffold-project.test.ts +150 -0
  243. package/src/tests/session-handlers.test.ts +503 -0
  244. package/src/tests/session-pbt.test.ts +190 -0
  245. package/src/tests/skill-schema.test.ts +146 -0
  246. package/src/tests/skill-validate-knowledge.test.ts +26 -0
  247. package/src/tests/skill-validate.test.ts +254 -0
  248. package/src/tests/stack-profile.test.ts +217 -0
  249. package/src/tests/string-utils.test.ts +42 -0
  250. package/src/tests/upgrade.test.ts +428 -0
  251. package/src/tests/werkstatt-lock.test.ts +343 -0
  252. package/src/tests/workspace-discovery-domain.test.ts +112 -0
  253. package/src/tests/workspace-discovery.test.ts +135 -0
  254. package/src/types.ts +221 -0
  255. package/src/utils/fs-atomic.ts +93 -0
  256. package/src/utils/fs-idempotent.ts +41 -0
  257. package/src/utils/fs-trash-sync.ts +37 -0
  258. package/src/utils/fs-trash.ts +24 -0
  259. package/src/utils/fs.ts +74 -0
  260. package/src/utils/generated-marker.ts +166 -0
  261. package/src/utils/hash.ts +19 -0
  262. package/src/utils/index.ts +29 -0
  263. package/src/utils/string-utils.ts +19 -0
  264. package/src/validators/note-frontmatter-validate.ts +142 -0
  265. package/src/validators/note-link-validate.ts +145 -0
  266. package/src/validators/note-orphan-detect.ts +146 -0
  267. package/src/validators/port-validate.ts +97 -0
  268. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,368 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ import { runAdrImplementStamp } from "../../os/adr/handlers/implement-stamp.ts";
8
+ import type { ForgeCommandInput, ForgeRuntimeContext, ForgeLogger } from "../../src/types.ts";
9
+
10
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
11
+
12
+ const noopLogger: ForgeLogger = {
13
+ section: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ success: () => {},
18
+ };
19
+
20
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
21
+ return {
22
+ workspaceRoot,
23
+ logger: noopLogger,
24
+ dryRun: false,
25
+ outputFormat: "json",
26
+ };
27
+ }
28
+
29
+ function makeInput(id: string, commit: string, dryRun?: boolean): ForgeCommandInput {
30
+ return {
31
+ argv: [],
32
+ flags: {
33
+ id,
34
+ "implementation-commit": commit,
35
+ ...(dryRun ? { "dry-run": true } : {}),
36
+ },
37
+ };
38
+ }
39
+
40
+ const ADR_BODY = (id: string, status: string): string => `---
41
+ id: ${id}
42
+ title: "Test ADR"
43
+ status: ${status}
44
+ scope: workspace
45
+ decider: human:test
46
+ createdAt: 2026-08-01
47
+ updatedAt: 2026-08-01
48
+ ---
49
+
50
+ # ${id}: Test ADR
51
+
52
+ ## Context
53
+
54
+ Test context.
55
+
56
+ ## Decision
57
+
58
+ Test decision.
59
+
60
+ ## Justification
61
+
62
+ Test justification.
63
+
64
+ ## Consequences
65
+
66
+ Test consequences.
67
+
68
+ ## Evolution
69
+
70
+ Test evolution.
71
+ `;
72
+
73
+ async function makeGitRepo(dir: string): Promise<void> {
74
+ execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
75
+ execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
76
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, timeout: 5000 });
77
+ }
78
+
79
+ async function commitAll(dir: string, message: string): Promise<string> {
80
+ execFileSync("git", ["add", "-A"], { cwd: dir, timeout: 5000 });
81
+ execFileSync("git", ["commit", "-m", message], { cwd: dir, timeout: 5000 });
82
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, timeout: 5000 }).toString().trim();
83
+ }
84
+
85
+ // ─── Tests ────────────────────────────────────────────────────────────────────
86
+
87
+ describe("adr.implement.stamp", () => {
88
+ let workspaceRoot: string;
89
+
90
+ beforeEach(async () => {
91
+ workspaceRoot = await mkdtemp(join(tmpdir(), "adr-stamp-test-"));
92
+ await mkdir(join(workspaceRoot, "docs", "adrs"), { recursive: true });
93
+ await makeGitRepo(workspaceRoot);
94
+ });
95
+
96
+ afterEach(async () => {
97
+ await rm(workspaceRoot, { recursive: true, force: true });
98
+ });
99
+
100
+ // ── ADR-IMP-01: status checks ───────────────────────────────────────────────
101
+
102
+ it("rejects ADR with status superseded", async () => {
103
+ await writeFile(
104
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
105
+ ADR_BODY("ADR-0001", "superseded"),
106
+ );
107
+ const sha = await commitAll(workspaceRoot, "initial");
108
+
109
+ const result = await runAdrImplementStamp(
110
+ makeInput("ADR-0001", sha),
111
+ makeContext(workspaceRoot),
112
+ );
113
+
114
+ expect(result.data?.status).toBe("fail");
115
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
116
+ expect(result.exitCode).toBe(1);
117
+ });
118
+
119
+ it("rejects ADR with status reviewing", async () => {
120
+ await writeFile(
121
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
122
+ ADR_BODY("ADR-0001", "reviewing"),
123
+ );
124
+ const sha = await commitAll(workspaceRoot, "initial");
125
+
126
+ const result = await runAdrImplementStamp(
127
+ makeInput("ADR-0001", sha),
128
+ makeContext(workspaceRoot),
129
+ );
130
+
131
+ expect(result.data?.status).toBe("fail");
132
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
133
+ });
134
+
135
+ it("rejects ADR with status implemented (already done)", async () => {
136
+ await writeFile(
137
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
138
+ ADR_BODY("ADR-0001", "implemented"),
139
+ );
140
+ const sha = await commitAll(workspaceRoot, "initial");
141
+
142
+ const result = await runAdrImplementStamp(
143
+ makeInput("ADR-0001", sha),
144
+ makeContext(workspaceRoot),
145
+ );
146
+
147
+ expect(result.data?.status).toBe("fail");
148
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
149
+ });
150
+
151
+ it("rejects ADR not found", async () => {
152
+ await writeFile(join(workspaceRoot, "dummy.txt"), "test");
153
+ const sha = await commitAll(workspaceRoot, "initial");
154
+
155
+ const result = await runAdrImplementStamp(
156
+ makeInput("ADR-9999", sha),
157
+ makeContext(workspaceRoot),
158
+ );
159
+
160
+ expect(result.data?.status).toBe("fail");
161
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
162
+ });
163
+
164
+ // ── ADR-IMP-03: commit validation ───────────────────────────────────────────
165
+
166
+ it("rejects commit not reachable from HEAD", async () => {
167
+ await writeFile(
168
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
169
+ ADR_BODY("ADR-0001", "accepted"),
170
+ );
171
+ await commitAll(workspaceRoot, "initial commit ADR-0001");
172
+
173
+ // Create a divergent commit
174
+ await writeFile(join(workspaceRoot, "dummy.txt"), "test");
175
+ const sha1 = await commitAll(workspaceRoot, "first");
176
+ execFileSync("git", ["reset", "--hard", "HEAD~1"], { cwd: workspaceRoot, timeout: 5000 });
177
+ await writeFile(join(workspaceRoot, "other.txt"), "test");
178
+ await commitAll(workspaceRoot, "second");
179
+
180
+ const result = await runAdrImplementStamp(
181
+ makeInput("ADR-0001", sha1),
182
+ makeContext(workspaceRoot),
183
+ );
184
+
185
+ expect(result.data?.status).toBe("fail");
186
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-03")).toBe(true);
187
+ });
188
+
189
+ it("rejects commit that does not reference ADR id", async () => {
190
+ await writeFile(
191
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
192
+ ADR_BODY("ADR-0001", "accepted"),
193
+ );
194
+ await commitAll(workspaceRoot, "add ADR");
195
+
196
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
197
+ const sha = await commitAll(workspaceRoot, "add code without ADR reference");
198
+
199
+ const result = await runAdrImplementStamp(
200
+ makeInput("ADR-0001", sha),
201
+ makeContext(workspaceRoot),
202
+ );
203
+
204
+ expect(result.data?.status).toBe("fail");
205
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-03")).toBe(true);
206
+ });
207
+
208
+ it("passes when commit message references ADR id", async () => {
209
+ await writeFile(
210
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
211
+ ADR_BODY("ADR-0001", "accepted"),
212
+ );
213
+ await commitAll(workspaceRoot, "add ADR-0001");
214
+
215
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
216
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
217
+
218
+ const result = await runAdrImplementStamp(
219
+ makeInput("ADR-0001", sha),
220
+ makeContext(workspaceRoot),
221
+ );
222
+
223
+ expect(result.data?.status).toBe("pass");
224
+ expect(result.exitCode).toBe(0);
225
+ });
226
+
227
+ it("passes when commit changed files contain ADR slug", async () => {
228
+ await writeFile(
229
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
230
+ ADR_BODY("ADR-0001", "accepted"),
231
+ );
232
+ await commitAll(workspaceRoot, "add ADR-0001");
233
+
234
+ // Commit touches the ADR file itself (slug "adr-0001" is in the path)
235
+ await writeFile(
236
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
237
+ ADR_BODY("ADR-0001", "accepted") + "\n<!-- update -->\n",
238
+ );
239
+ const sha = await commitAll(workspaceRoot, "update adr-0001 file");
240
+
241
+ const result = await runAdrImplementStamp(
242
+ makeInput("ADR-0001", sha),
243
+ makeContext(workspaceRoot),
244
+ );
245
+
246
+ expect(result.data?.status).toBe("pass");
247
+ });
248
+
249
+ // ── ADR-IMP-04: file cleanliness ─────────────────────────────────────────────
250
+
251
+ it("rejects when ADR file has uncommitted changes", async () => {
252
+ await writeFile(
253
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
254
+ ADR_BODY("ADR-0001", "accepted"),
255
+ );
256
+ const sha = await commitAll(workspaceRoot, "add ADR-0001");
257
+
258
+ // Make uncommitted change to the ADR file
259
+ await writeFile(
260
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
261
+ ADR_BODY("ADR-0001", "accepted") + "\n<!-- dirty -->\n",
262
+ );
263
+
264
+ const result = await runAdrImplementStamp(
265
+ makeInput("ADR-0001", sha),
266
+ makeContext(workspaceRoot),
267
+ );
268
+
269
+ expect(result.data?.status).toBe("fail");
270
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-04")).toBe(true);
271
+ });
272
+
273
+ // ── ADR-IMP-05: concurrent lock ──────────────────────────────────────────────
274
+
275
+ it("rejects when a concurrent lock is held", async () => {
276
+ await writeFile(
277
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
278
+ ADR_BODY("ADR-0001", "accepted"),
279
+ );
280
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
281
+
282
+ // Pre-create the lock file to simulate a concurrent stamp
283
+ await mkdir(join(workspaceRoot, ".adr-locks"), { recursive: true });
284
+ await writeFile(
285
+ join(workspaceRoot, ".adr-locks", "adr-0001.lock"),
286
+ JSON.stringify({ pid: 99999, acquiredAt: new Date().toISOString() }),
287
+ );
288
+
289
+ const result = await runAdrImplementStamp(
290
+ makeInput("ADR-0001", sha),
291
+ makeContext(workspaceRoot),
292
+ );
293
+
294
+ expect(result.data?.status).toBe("fail");
295
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
296
+ });
297
+
298
+ // ── Dry-run ──────────────────────────────────────────────────────────────────
299
+
300
+ it("dry-run checks preconditions without mutating file", async () => {
301
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
302
+ await writeFile(adrPath, ADR_BODY("ADR-0001", "accepted"));
303
+ await commitAll(workspaceRoot, "add ADR-0001");
304
+
305
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
306
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
307
+
308
+ const originalContent = await readFile(adrPath, "utf-8");
309
+
310
+ const result = await runAdrImplementStamp(
311
+ makeInput("ADR-0001", sha, true),
312
+ makeContext(workspaceRoot),
313
+ );
314
+
315
+ expect(result.data?.status).toBe("pass");
316
+ expect(result.data?.data?.adrId).toBe("ADR-0001");
317
+
318
+ // File should not be mutated
319
+ const afterContent = await readFile(adrPath, "utf-8");
320
+ expect(afterContent).toBe(originalContent);
321
+ });
322
+
323
+ // ── Atomic stamp: accepted → implemented ─────────────────────────────────────
324
+
325
+ it("atomically stamps accepted ADR as implemented", async () => {
326
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
327
+ await writeFile(adrPath, ADR_BODY("ADR-0001", "accepted"));
328
+ await commitAll(workspaceRoot, "add ADR-0001");
329
+
330
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
331
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
332
+
333
+ const result = await runAdrImplementStamp(
334
+ makeInput("ADR-0001", sha),
335
+ makeContext(workspaceRoot),
336
+ );
337
+
338
+ expect(result.data?.status).toBe("pass");
339
+ expect(result.data?.data?.adrId).toBe("ADR-0001");
340
+ expect(result.data?.data?.implementationCommit).toBe(sha);
341
+ expect(result.exitCode).toBe(0);
342
+
343
+ // Verify file was mutated
344
+ const afterContent = await readFile(adrPath, "utf-8");
345
+ expect(afterContent).toContain("status: implemented");
346
+ expect(afterContent).toContain("implementedAt:");
347
+ expect(afterContent).toContain("updatedAt:");
348
+ });
349
+
350
+ // ── Post-hoc ADR: proposed → implemented ─────────────────────────────────────
351
+
352
+ it("stamps proposed ADR as implemented (post-hoc)", async () => {
353
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0002-test.md");
354
+ await writeFile(adrPath, ADR_BODY("ADR-0002", "proposed"));
355
+ const sha = await commitAll(workspaceRoot, "add ADR-0002");
356
+
357
+ const result = await runAdrImplementStamp(
358
+ makeInput("ADR-0002", sha),
359
+ makeContext(workspaceRoot),
360
+ );
361
+
362
+ expect(result.data?.status).toBe("pass");
363
+ expect(result.data?.data?.adrId).toBe("ADR-0002");
364
+
365
+ const afterContent = await readFile(adrPath, "utf-8");
366
+ expect(afterContent).toContain("status: implemented");
367
+ });
368
+ });
@@ -0,0 +1,317 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for RFC-0643: per-domain AGENTS.md template generation —
4
+ terminology substitution, register selection, nested template selection, fallback,
5
+ path traversal rejection, details field, and regression.</purpose>
6
+ </MODULE_CONTRACT>
7
+ <CHANGE_SUMMARY>
8
+ <item>RFC-0643: initial domain template tests — terminology substitution, register selection, nested templates, fallback, path traversal, details field, regression.</item>
9
+ </CHANGE_SUMMARY>
10
+ */
11
+
12
+ import { test, expect, beforeEach, afterEach } from "vitest";
13
+ import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import {
17
+ runAgentsGenerate,
18
+ substituteTemplate,
19
+ selectRootTemplate,
20
+ } from "../onboarding/agents-generate.ts";
21
+ import { selectNestedTemplate } from "../onboarding/nested-agents-templates.ts";
22
+ import type { ForgeRuntimeContext } from "../types.ts";
23
+ import type { ProfileWorkspaceType } from "../profiles/profile-schema.ts";
24
+ import type { StackProfile } from "../profiles/stack-profile.ts";
25
+
26
+ const silentLogger = {
27
+ section: () => {},
28
+ info: () => {},
29
+ warn: () => {},
30
+ error: () => {},
31
+ success: () => {},
32
+ };
33
+
34
+ const FORGE_ROOT = join(import.meta.dirname, "..", "..");
35
+ const _WORKSPACE_ROOT = join(FORGE_ROOT, "..", "..");
36
+
37
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
38
+ return {
39
+ workspaceRoot,
40
+ logger: silentLogger as never,
41
+ dryRun: false,
42
+ outputFormat: "json",
43
+ forgeRoot: FORGE_ROOT,
44
+ };
45
+ }
46
+
47
+ let tempDir: string;
48
+
49
+ beforeEach(async () => {
50
+ tempDir = await mkdtemp(join(tmpdir(), "agents-gen-domain-test-"));
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await rm(tempDir, { recursive: true, force: true });
55
+ });
56
+
57
+ async function makeForgeYaml(dir: string, extra?: string): Promise<void> {
58
+ const lines = [
59
+ "schema: forge/config@1",
60
+ "project:",
61
+ " name: test-project",
62
+ " stack: [typescript]",
63
+ " packageManager: pnpm",
64
+ "paths:",
65
+ " rfcsDir: docs/rfcs",
66
+ " adrsDir: docs/adrs",
67
+ " plansDir: docs/plans",
68
+ " auditsDir: docs/audits",
69
+ " specsDir: docs/specs",
70
+ " skillsDir: .agents/skills",
71
+ "bindings:",
72
+ " schema: forge/bindings@1",
73
+ " commands:",
74
+ " validateRfc: pnpm exec forge rfc.validate",
75
+ " validateAdr: pnpm exec forge adr.validate",
76
+ " typecheck: null",
77
+ " test: null",
78
+ " scopedBuild: null",
79
+ " implementStamp: pnpm exec forge rfc.implement.stamp",
80
+ " specValidate: pnpm exec forge spec.validate",
81
+ " sessionSave: pnpm exec forge session.save",
82
+ " paths:",
83
+ " invariantsFile: docs/architecture-dna.md",
84
+ " reviewsDir: docs/reviews",
85
+ " handoffsDir: docs/handoffs",
86
+ " sessionsDir: docs/sessions",
87
+ " compassDocs: [docs/requirements.xml]",
88
+ "forge:",
89
+ " syncedVersion: 0.1.2",
90
+ ];
91
+ if (extra) lines.push(extra);
92
+ await writeFile(join(dir, "forge.yaml"), lines.join("\n"), "utf8");
93
+ }
94
+
95
+ // --- substituteTemplate tests ---
96
+
97
+ test("substituteTemplate replaces {{terminology.artifact}} with 'game'", () => {
98
+ const result = substituteTemplate("Each {{terminology.artifact}} is a workspace.", {
99
+ artifact: "game",
100
+ });
101
+ expect(result).toBe("Each game is a workspace.");
102
+ });
103
+
104
+ test("substituteTemplate replaces unknown key with key name itself", () => {
105
+ const result = substituteTemplate("The {{terminology.unknown}} is here.", {});
106
+ expect(result).toBe("The unknown is here.");
107
+ });
108
+
109
+ test("substituteTemplate leaves content without placeholders unchanged", () => {
110
+ const content = "No placeholders here.";
111
+ expect(substituteTemplate(content, { artifact: "game" })).toBe(content);
112
+ });
113
+
114
+ test("substituteTemplate replaces multiple placeholders in one pass", () => {
115
+ const result = substituteTemplate(
116
+ "The {{terminology.operator}} creates {{terminology.artifactPlural}}.",
117
+ { operator: "developer", artifactPlural: "games" },
118
+ );
119
+ expect(result).toBe("The developer creates games.");
120
+ });
121
+
122
+ // --- selectRootTemplate tests ---
123
+
124
+ test("selectRootTemplate('business') returns business template content", () => {
125
+ const content = selectRootTemplate("business");
126
+ expect(content).toContain("{{projectName}}");
127
+ expect(content).toContain("{{rfcsDir}}");
128
+ });
129
+
130
+ test("selectRootTemplate('creative') returns creative template content", () => {
131
+ const content = selectRootTemplate("creative");
132
+ expect(content).toContain("{{projectName}}");
133
+ expect(content).toContain("{{rfcsDir}}");
134
+ });
135
+
136
+ test("business template contains {{terminology.key}} placeholders", () => {
137
+ const content = selectRootTemplate("business");
138
+ // Templates use {{projectName}} etc. — no {{terminology.*}} needed in static prose
139
+ // but the template should contain {{dynamicSections}} marker
140
+ expect(content).toContain("{{dynamicSections}}");
141
+ });
142
+
143
+ test("creative template contains {{dynamicSections}} marker", () => {
144
+ const content = selectRootTemplate("creative");
145
+ expect(content).toContain("{{dynamicSections}}");
146
+ });
147
+
148
+ test("selectRootTemplate with profile rootAgentsMdTemplate loads profile template", () => {
149
+ const profile = {
150
+ id: "godot-csharp",
151
+ rootAgentsMdTemplate: "root-agents-godot.md",
152
+ } as StackProfile;
153
+ const content = selectRootTemplate("creative", profile);
154
+ expect(content).toContain("Godot 4.x with .NET / C#");
155
+ expect(content).toContain("{{dynamicSections}}");
156
+ });
157
+
158
+ test("selectRootTemplate with missing rootAgentsMdTemplate falls back to register template", () => {
159
+ const profile = {
160
+ id: "test-profile",
161
+ rootAgentsMdTemplate: "nonexistent-template.md",
162
+ } as StackProfile;
163
+ const content = selectRootTemplate("business", profile);
164
+ expect(content).toContain("{{projectName}}");
165
+ expect(content).toContain("{{rfcsDir}}");
166
+ });
167
+
168
+ test("selectRootTemplate rejects path traversal in rootAgentsMdTemplate", () => {
169
+ const profile = {
170
+ id: "evil-profile",
171
+ rootAgentsMdTemplate: "../../../etc/passwd",
172
+ } as StackProfile;
173
+ const content = selectRootTemplate("business", profile);
174
+ expect(content).toContain("{{projectName}}");
175
+ expect(content).not.toContain("root:");
176
+ });
177
+
178
+ test("selectRootTemplate rejects absolute path in rootAgentsMdTemplate", () => {
179
+ const profile = {
180
+ id: "evil-profile",
181
+ rootAgentsMdTemplate: "/etc/passwd",
182
+ } as StackProfile;
183
+ const content = selectRootTemplate("business", profile);
184
+ expect(content).toContain("{{projectName}}");
185
+ expect(content).not.toContain("root:");
186
+ });
187
+
188
+ // --- selectNestedTemplate tests ---
189
+
190
+ test("selectNestedTemplate without agentsMdTemplate uses fallback", () => {
191
+ const wsType: ProfileWorkspaceType = {
192
+ id: "package",
193
+ detect: { glob: "package.json" },
194
+ };
195
+ const profile = { id: "forge-shell" } as StackProfile;
196
+ const result = selectNestedTemplate(wsType, profile, {}, "FALLBACK");
197
+ expect(result).toBe("FALLBACK");
198
+ });
199
+
200
+ test("selectNestedTemplate without profile uses fallback", () => {
201
+ const wsType: ProfileWorkspaceType = {
202
+ id: "game",
203
+ detect: { glob: "*.ts" },
204
+ agentsMdTemplate: "some-template.md",
205
+ };
206
+ const result = selectNestedTemplate(wsType, undefined, {}, "FALLBACK");
207
+ expect(result).toBe("FALLBACK");
208
+ });
209
+
210
+ test("selectNestedTemplate rejects path traversal (..) with fallback", () => {
211
+ const wsType: ProfileWorkspaceType = {
212
+ id: "evil",
213
+ detect: { glob: "*" },
214
+ agentsMdTemplate: "../../../etc/passwd",
215
+ };
216
+ const profile = { id: "evil-profile" } as StackProfile;
217
+ const result = selectNestedTemplate(wsType, profile, {}, "FALLBACK");
218
+ expect(result).toBe("FALLBACK");
219
+ });
220
+
221
+ test("selectNestedTemplate rejects absolute path with fallback", () => {
222
+ const wsType: ProfileWorkspaceType = {
223
+ id: "evil",
224
+ detect: { glob: "*" },
225
+ agentsMdTemplate: "/etc/passwd",
226
+ };
227
+ const profile = { id: "evil-profile" } as StackProfile;
228
+ const result = selectNestedTemplate(wsType, profile, {}, "FALLBACK");
229
+ expect(result).toBe("FALLBACK");
230
+ });
231
+
232
+ // --- Integration: --json output has details field ---
233
+
234
+ test("agents-generate --json output has details field with domain, register, workspaceType", async () => {
235
+ await makeForgeYaml(tempDir);
236
+ const ctx = makeContext(tempDir);
237
+ ctx.dryRun = true;
238
+
239
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, ctx);
240
+ expect(result.exitCode).toBe(0);
241
+ expect(result.data?.details).toBeDefined();
242
+ expect(Array.isArray(result.data?.details)).toBe(true);
243
+ expect(result.data?.details?.length).toBeGreaterThan(0);
244
+ const rootDetail = result.data?.details?.find((d) => d.path === "AGENTS.md");
245
+ expect(rootDetail).toBeDefined();
246
+ expect(rootDetail?.register).toBe("business");
247
+ });
248
+
249
+ test("agents-generate --json output generated field is string[]", async () => {
250
+ await makeForgeYaml(tempDir);
251
+ const ctx = makeContext(tempDir);
252
+ ctx.dryRun = true;
253
+
254
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, ctx);
255
+ expect(result.exitCode).toBe(0);
256
+ expect(result.data?.generated).toEqual(["AGENTS.md"]);
257
+ // generated is string[], not object array
258
+ expect(typeof result.data?.generated?.[0]).toBe("string");
259
+ });
260
+
261
+ // --- Regression: no profile → identical output ---
262
+
263
+ test("agents-generate with no profile generates identical AGENTS.md (regression)", async () => {
264
+ await makeForgeYaml(tempDir);
265
+ const ctx = makeContext(tempDir);
266
+ ctx.dryRun = true;
267
+
268
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, ctx);
269
+ const content = result.data?.renderedFiles?.["AGENTS.md"];
270
+ expect(content).toBeDefined();
271
+
272
+ const goldenFixture = await readFile(
273
+ join(import.meta.dirname, "fixtures", "agents-generate-business-before.txt"),
274
+ "utf8",
275
+ );
276
+ expect(content).toBe(goldenFixture);
277
+ });
278
+
279
+ // --- Integration: creative register uses creative template ---
280
+
281
+ test("agents-generate with creative register uses creative template", async () => {
282
+ await makeForgeYaml(tempDir);
283
+ await writeFile(
284
+ join(tempDir, "PREFERENCES.md"),
285
+ "---\naiLanguage: en\ndocumentationLanguage: en\nregister: creative\n---\n",
286
+ "utf8",
287
+ );
288
+
289
+ const ctx = makeContext(tempDir);
290
+ ctx.dryRun = true;
291
+
292
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, ctx);
293
+ expect(result.exitCode).toBe(0);
294
+ const content = result.data?.renderedFiles?.["AGENTS.md"];
295
+ expect(content).toBeDefined();
296
+ expect(content).toContain("Agent Guide: test-project");
297
+ // Creative register should have extended behavioral layer
298
+ expect(content).toContain("Extended behavioral layer (creative register)");
299
+ // Details should show creative register
300
+ const rootDetail = result.data?.details?.find((d) => d.path === "AGENTS.md");
301
+ expect(rootDetail?.register).toBe("creative");
302
+ });
303
+
304
+ test("agents-generate with godot-csharp profile generates Godot-specific AGENTS.md", async () => {
305
+ await makeForgeYaml(tempDir, "profile: godot-csharp");
306
+ const ctx = makeContext(tempDir);
307
+ ctx.dryRun = true;
308
+
309
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, ctx);
310
+ expect(result.exitCode).toBe(0);
311
+ const content = result.data?.renderedFiles?.["AGENTS.md"];
312
+ expect(content).toBeDefined();
313
+ expect(content).toContain("Godot 4.x with .NET / C#");
314
+ expect(content).toContain("dotnet build");
315
+ expect(content).toContain("Scenes and Resources");
316
+ expect(content).toContain("Definition of Done");
317
+ });