@warpgogol/forge 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/os/session/handlers/validate.d.ts.map +1 -1
  5. package/dist/os/session/handlers/validate.js +23 -1
  6. package/dist/os/session/handlers/validate.js.map +1 -1
  7. package/dist/os/session/types.d.ts +31 -1
  8. package/dist/os/session/types.d.ts.map +1 -1
  9. package/dist/os/session/types.js +7 -0
  10. package/dist/os/session/types.js.map +1 -1
  11. package/os/adr/adr.module.ts +158 -0
  12. package/os/adr/frontmatter-io.ts +80 -0
  13. package/os/adr/handlers/archive.ts +244 -0
  14. package/os/adr/handlers/implement-stamp.ts +331 -0
  15. package/os/adr/handlers/list-create.ts +207 -0
  16. package/os/adr/handlers/validate.test.ts +232 -0
  17. package/os/adr/handlers/validate.ts +429 -0
  18. package/os/adr/index.ts +33 -0
  19. package/os/adr/types.ts +141 -0
  20. package/os/audit/audit.module.ts +48 -0
  21. package/os/audit/frontmatter-io.ts +84 -0
  22. package/os/audit/handlers/archive.ts +239 -0
  23. package/os/audit/index.ts +23 -0
  24. package/os/audit/types.ts +29 -0
  25. package/os/compass/compass.module.ts +186 -0
  26. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  27. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  28. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  29. package/os/compass/handlers/compass-inventory.ts +521 -0
  30. package/os/compass/handlers/git-revision.ts +137 -0
  31. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  32. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  33. package/os/compass/index.ts +33 -0
  34. package/os/core/core.module.ts +948 -0
  35. package/os/core/handlers/assets-check.ts +122 -0
  36. package/os/core/handlers/assets-helpers.ts +174 -0
  37. package/os/core/handlers/assets-list.ts +99 -0
  38. package/os/core/handlers/build.ts +155 -0
  39. package/os/core/handlers/determinism-check.ts +354 -0
  40. package/os/core/handlers/dev.ts +127 -0
  41. package/os/core/handlers/invariant-engine.test.ts +681 -0
  42. package/os/core/handlers/knowledge-compact.ts +248 -0
  43. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  44. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  45. package/os/core/handlers/note-link-validate.test.ts +117 -0
  46. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  47. package/os/core/handlers/package-health.test.ts +226 -0
  48. package/os/core/handlers/package-health.ts +231 -0
  49. package/os/core/handlers/pinned-check.ts +200 -0
  50. package/os/core/handlers/pinned-init.ts +333 -0
  51. package/os/core/handlers/pinned-types.ts +50 -0
  52. package/os/core/handlers/pinned-validate.ts +301 -0
  53. package/os/core/handlers/profile-resolve.ts +92 -0
  54. package/os/core/handlers/release-prepare.ts +294 -0
  55. package/os/core/handlers/release-publish.ts +211 -0
  56. package/os/core/handlers/validate.ts +262 -0
  57. package/os/core/handlers/workspace-deps.ts +55 -0
  58. package/os/core/index.ts +13 -0
  59. package/os/exploration/exploration.module.ts +90 -0
  60. package/os/exploration/frontmatter-io.ts +77 -0
  61. package/os/exploration/handlers/archive.ts +159 -0
  62. package/os/exploration/handlers/list.ts +77 -0
  63. package/os/exploration/handlers/show.ts +107 -0
  64. package/os/exploration/index.ts +25 -0
  65. package/os/exploration/types.ts +65 -0
  66. package/os/mission/handlers/archive.test.ts +391 -0
  67. package/os/mission/handlers/archive.ts +453 -0
  68. package/os/mission/index.ts +1 -0
  69. package/os/mission/mission.module.ts +48 -0
  70. package/os/mission/types.ts +41 -0
  71. package/os/naming/index.ts +14 -0
  72. package/os/naming/naming-convention.test.ts +163 -0
  73. package/os/naming/naming-convention.ts +378 -0
  74. package/os/naming/naming.module.ts +36 -0
  75. package/os/notes/index.ts +10 -0
  76. package/os/notes/notes.module.ts +109 -0
  77. package/os/plan/frontmatter-io.ts +82 -0
  78. package/os/plan/handlers/archive.ts +238 -0
  79. package/os/plan/index.ts +23 -0
  80. package/os/plan/plan.module.ts +48 -0
  81. package/os/plan/types.ts +29 -0
  82. package/os/program/discovery.ts +294 -0
  83. package/os/program/handlers/complete.ts +419 -0
  84. package/os/program/handlers/lease.ts +433 -0
  85. package/os/program/handlers/seal.ts +332 -0
  86. package/os/program/handlers/validate.ts +213 -0
  87. package/os/program/lease.ts +156 -0
  88. package/os/program/program.module.ts +212 -0
  89. package/os/program/schemas.ts +218 -0
  90. package/os/program/state.ts +264 -0
  91. package/os/rfc/acceptance.ts +344 -0
  92. package/os/rfc/decision-log.ts +310 -0
  93. package/os/rfc/dna-trace.ts +280 -0
  94. package/os/rfc/frontmatter-io.test.ts +132 -0
  95. package/os/rfc/frontmatter-io.ts +130 -0
  96. package/os/rfc/handlers/archive.ts +300 -0
  97. package/os/rfc/handlers/check.ts +238 -0
  98. package/os/rfc/handlers/implement-stamp.ts +515 -0
  99. package/os/rfc/handlers/index-graph.ts +237 -0
  100. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  101. package/os/rfc/handlers/lifecycle.ts +249 -0
  102. package/os/rfc/handlers/list-create.ts +296 -0
  103. package/os/rfc/handlers/pipeline-status.ts +173 -0
  104. package/os/rfc/handlers/shared.ts +121 -0
  105. package/os/rfc/handlers/supersede-propose.ts +241 -0
  106. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  107. package/os/rfc/handlers/validate-rules.ts +984 -0
  108. package/os/rfc/handlers/validate.ts +166 -0
  109. package/os/rfc/handlers.ts +19 -0
  110. package/os/rfc/index.ts +97 -0
  111. package/os/rfc/rfc.module.ts +416 -0
  112. package/os/rfc/types.ts +596 -0
  113. package/os/rfc/verification-evidence.ts +244 -0
  114. package/os/session/atif-parser.ts +166 -0
  115. package/os/session/frontmatter-io.ts +163 -0
  116. package/os/session/handlers/archive.ts +288 -0
  117. package/os/session/handlers/list.ts +174 -0
  118. package/os/session/handlers/save.ts +348 -0
  119. package/os/session/handlers/validate.ts +250 -0
  120. package/os/session/index.ts +50 -0
  121. package/os/session/session.module.ts +126 -0
  122. package/os/session/types.ts +203 -0
  123. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  124. package/os/spec/live-spec-list.ts +78 -0
  125. package/os/spec/live-spec-merge.test.ts +261 -0
  126. package/os/spec/live-spec-merge.ts +413 -0
  127. package/os/spec/live-spec-show.ts +117 -0
  128. package/os/spec/live-spec-types.ts +93 -0
  129. package/os/spec/live-spec-validate.ts +170 -0
  130. package/os/spec/spec-materialize.ts +390 -0
  131. package/os/spec/spec-schema.ts +174 -0
  132. package/os/spec/spec-status.ts +284 -0
  133. package/os/spec/spec-validate.ts +513 -0
  134. package/os/spec/spec.module.ts +135 -0
  135. package/os/werkstatt/handlers/lock.ts +168 -0
  136. package/os/werkstatt/handlers/schema.ts +47 -0
  137. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  138. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  139. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  140. package/os/werkstatt/index.ts +39 -0
  141. package/os/werkstatt/werkstatt.module.ts +62 -0
  142. package/os/workflow/handlers.ts +304 -0
  143. package/os/workflow/index.ts +28 -0
  144. package/os/workflow/types.ts +99 -0
  145. package/os/workflow/workflow.module.ts +54 -0
  146. package/package.json +3 -3
  147. package/profiles/godot-csharp.yaml +4 -4
  148. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  149. package/profiles/phaser-turborepo.yaml +5 -5
  150. package/skills/_shared/fo-session-summary.md +132 -16
  151. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  152. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  153. package/skills/fo/fo-handoff/SKILL.md +5 -0
  154. package/skills/fo/fo-idea/SKILL.md +1 -1
  155. package/skills/fo/fo-review/SKILL.md +1 -1
  156. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  157. package/skills/fo/fo-session-save/SKILL.md +8 -0
  158. package/src/cli-output.ts +59 -0
  159. package/src/config/__tests__/resolve-terminology.test.ts +45 -0
  160. package/src/config/forge-config.ts +524 -0
  161. package/src/forge-module.ts +39 -0
  162. package/src/index.ts +172 -0
  163. package/src/knowledge/__tests__/promote.test.ts +219 -0
  164. package/src/knowledge/__tests__/serialize.test.ts +192 -0
  165. package/src/knowledge/budgets.ts +202 -0
  166. package/src/knowledge/compact.ts +405 -0
  167. package/src/knowledge/index.ts +53 -0
  168. package/src/knowledge/parse.ts +227 -0
  169. package/src/knowledge/promote.ts +156 -0
  170. package/src/knowledge/schema.ts +112 -0
  171. package/src/knowledge/serialize.ts +72 -0
  172. package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
  173. package/src/migration-adapters/__tests__/registry.test.ts +131 -0
  174. package/src/migration-adapters/__tests__/types.test.ts +86 -0
  175. package/src/migration-adapters/git-utils.ts +71 -0
  176. package/src/migration-adapters/ignored-files.ts +221 -0
  177. package/src/migration-adapters/index.ts +21 -0
  178. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  179. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  180. package/src/migration-adapters/registry.ts +61 -0
  181. package/src/migration-adapters/types.ts +68 -0
  182. package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
  183. package/src/onboarding/agents-generate.ts +443 -0
  184. package/src/onboarding/create.ts +427 -0
  185. package/src/onboarding/doctor.ts +1297 -0
  186. package/src/onboarding/init.ts +337 -0
  187. package/src/onboarding/invariant-engine.ts +448 -0
  188. package/src/onboarding/memory-scaffold.ts +189 -0
  189. package/src/onboarding/nested-agents-generate.ts +93 -0
  190. package/src/onboarding/nested-agents-templates.ts +233 -0
  191. package/src/onboarding/profile-validate.ts +139 -0
  192. package/src/onboarding/scaffold-project.ts +299 -0
  193. package/src/onboarding/scaffold.ts +143 -0
  194. package/src/onboarding/upgrade.ts +558 -0
  195. package/src/onboarding/workspace-discovery.ts +167 -0
  196. package/src/profiles/__tests__/profile-schema.test.ts +228 -0
  197. package/src/profiles/__tests__/stack-profile.test.ts +190 -0
  198. package/src/profiles/__tests__/terminology-utils.test.ts +71 -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 +54 -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 +221 -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/__tests__/fs-idempotent.test.ts +73 -0
  256. package/src/utils/__tests__/fs-trash.test.ts +45 -0
  257. package/src/utils/fs-atomic.ts +93 -0
  258. package/src/utils/fs-idempotent.ts +41 -0
  259. package/src/utils/fs-trash-sync.ts +37 -0
  260. package/src/utils/fs-trash.ts +24 -0
  261. package/src/utils/fs.ts +74 -0
  262. package/src/utils/generated-marker.ts +166 -0
  263. package/src/utils/hash.ts +19 -0
  264. package/src/utils/index.ts +29 -0
  265. package/src/utils/string-utils.ts +19 -0
  266. package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
  267. package/src/validators/note-frontmatter-validate.ts +142 -0
  268. package/src/validators/note-link-validate.ts +145 -0
  269. package/src/validators/note-orphan-detect.ts +146 -0
  270. package/src/validators/port-validate.ts +97 -0
  271. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,343 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ acquireLock,
7
+ releaseLock,
8
+ heartbeatLock,
9
+ readAllLocks,
10
+ isLockStale,
11
+ removeStaleLock,
12
+ } from "../../os/werkstatt/handlers/lock.ts";
13
+ import { werkstattLockSchema } from "../../os/werkstatt/handlers/schema.ts";
14
+
15
+ async function makeTempDir(): Promise<string> {
16
+ return mkdtemp(join(tmpdir(), "forge-werkstatt-test-"));
17
+ }
18
+
19
+ function makeValidLock(overrides: Partial<Record<string, unknown>> = {}) {
20
+ const now = new Date().toISOString();
21
+ return {
22
+ schemaVersion: "1.0.0",
23
+ scope: "test-scope",
24
+ operationId: "op-001",
25
+ command: "test.command",
26
+ owner: "test-owner",
27
+ pid: process.pid,
28
+ startedAt: now,
29
+ heartbeatAt: now,
30
+ timeoutSeconds: 900,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe("werkstattLockSchema", () => {
36
+ test("accepts a valid lock object", () => {
37
+ const lock = makeValidLock();
38
+ expect(() => werkstattLockSchema.parse(lock)).not.toThrow();
39
+ });
40
+
41
+ test("rejects wrong schemaVersion", () => {
42
+ const lock = makeValidLock({ schemaVersion: "2.0.0" });
43
+ expect(() => werkstattLockSchema.parse(lock)).toThrow();
44
+ });
45
+
46
+ test("rejects empty scope", () => {
47
+ const lock = makeValidLock({ scope: "" });
48
+ expect(() => werkstattLockSchema.parse(lock)).toThrow();
49
+ });
50
+
51
+ test("rejects negative timeoutSeconds", () => {
52
+ const lock = makeValidLock({ timeoutSeconds: -1 });
53
+ expect(() => werkstattLockSchema.parse(lock)).toThrow();
54
+ });
55
+
56
+ test("rejects non-integer pid", () => {
57
+ const lock = makeValidLock({ pid: 1.5 });
58
+ expect(() => werkstattLockSchema.parse(lock)).toThrow();
59
+ });
60
+
61
+ test("rejects non-datetime startedAt", () => {
62
+ const lock = makeValidLock({ startedAt: "not-a-date" });
63
+ expect(() => werkstattLockSchema.parse(lock)).toThrow();
64
+ });
65
+ });
66
+
67
+ describe("isLockStale", () => {
68
+ test("returns false for a fresh lock held by current process", () => {
69
+ const lock = makeValidLock();
70
+ expect(isLockStale(lock as never, new Date())).toBe(false);
71
+ });
72
+
73
+ test("returns true when heartbeat exceeds timeout", () => {
74
+ const oldHeartbeat = new Date(Date.now() - 1000 * 960).toISOString();
75
+ const lock = makeValidLock({ heartbeatAt: oldHeartbeat, timeoutSeconds: 900 });
76
+ expect(isLockStale(lock as never, new Date())).toBe(true);
77
+ });
78
+
79
+ test("returns true when pid is not alive", () => {
80
+ const lock = makeValidLock({ pid: 999999 });
81
+ expect(isLockStale(lock as never, new Date())).toBe(true);
82
+ });
83
+
84
+ test("returns false at exact boundary (heartbeatAge == timeout)", () => {
85
+ const now = new Date();
86
+ const heartbeat = new Date(now.getTime() - 900 * 1000);
87
+ const lock = makeValidLock({ heartbeatAt: heartbeat.toISOString(), timeoutSeconds: 900 });
88
+ expect(isLockStale(lock as never, now)).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("acquireLock", () => {
93
+ test("creates a lock file on disk", async () => {
94
+ const dir = await makeTempDir();
95
+ try {
96
+ const lock = await acquireLock(dir, "test-scope", "op-001", "test.cmd", "owner");
97
+ expect(lock.scope).toBe("test-scope");
98
+ expect(lock.pid).toBe(process.pid);
99
+ const files = await readdir(join(dir, ".werkstatt", "locks"));
100
+ expect(files).toContain("test-scope.lock.json");
101
+ } finally {
102
+ await rm(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test("throws when lock is held by a different live process", async () => {
107
+ const dir = await makeTempDir();
108
+ try {
109
+ const otherPidLock = makeValidLock({
110
+ scope: "blocked-scope",
111
+ operationId: "op-001",
112
+ pid: process.ppid,
113
+ });
114
+ const locksDir = join(dir, ".werkstatt", "locks");
115
+ await mkdir(locksDir, { recursive: true });
116
+ await writeFile(
117
+ join(locksDir, "blocked-scope.lock.json"),
118
+ JSON.stringify(otherPidLock, null, 2),
119
+ );
120
+ await expect(
121
+ acquireLock(dir, "blocked-scope", "op-002", "test.cmd", "owner"),
122
+ ).rejects.toThrow(/held by/);
123
+ } finally {
124
+ await rm(dir, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test("is re-entrant for same PID (increments depth)", async () => {
129
+ const dir = await makeTempDir();
130
+ try {
131
+ const lock1 = await acquireLock(dir, "reen-scope", "op-001", "outer.cmd", "owner");
132
+ expect(lock1.depth).toBeUndefined();
133
+ const lock2 = await acquireLock(dir, "reen-scope", "op-002", "inner.cmd", "owner");
134
+ expect(lock2.depth).toBe(2);
135
+ // Re-entrant acquire preserves original operationId and command
136
+ expect(lock2.operationId).toBe("op-001");
137
+ expect(lock2.command).toBe("outer.cmd");
138
+ // Lock file still exists
139
+ const files = await readdir(join(dir, ".werkstatt", "locks"));
140
+ expect(files).toContain("reen-scope.lock.json");
141
+ // Inner release decrements but does not delete
142
+ await releaseLock(dir, "reen-scope");
143
+ const filesAfterInner = await readdir(join(dir, ".werkstatt", "locks"));
144
+ expect(filesAfterInner).toContain("reen-scope.lock.json");
145
+ // Outer release deletes
146
+ await releaseLock(dir, "reen-scope");
147
+ const filesAfterOuter = await readdir(join(dir, ".werkstatt", "locks"));
148
+ expect(filesAfterOuter).not.toContain("reen-scope.lock.json");
149
+ } finally {
150
+ await rm(dir, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test("overwrites a stale lock", async () => {
155
+ const dir = await makeTempDir();
156
+ try {
157
+ const staleLock = makeValidLock({
158
+ scope: "stale-scope",
159
+ operationId: "old-op",
160
+ pid: 999999,
161
+ heartbeatAt: new Date(Date.now() - 2000 * 1000).toISOString(),
162
+ });
163
+ const locksDir = join(dir, ".werkstatt", "locks");
164
+ await mkdir(locksDir, { recursive: true });
165
+ await writeFile(join(locksDir, "stale-scope.lock.json"), JSON.stringify(staleLock, null, 2));
166
+
167
+ const lock = await acquireLock(dir, "stale-scope", "new-op", "test.cmd", "owner");
168
+ expect(lock.operationId).toBe("new-op");
169
+ } finally {
170
+ await rm(dir, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ test("sanitizes scope with special characters", async () => {
175
+ const dir = await makeTempDir();
176
+ try {
177
+ await acquireLock(dir, "scope/with/slashes", "op-001", "test.cmd", "owner");
178
+ const files = await readdir(join(dir, ".werkstatt", "locks"));
179
+ expect(files).toContain("scope_with_slashes.lock.json");
180
+ } finally {
181
+ await rm(dir, { recursive: true, force: true });
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("releaseLock", () => {
187
+ test("removes the lock file", async () => {
188
+ const dir = await makeTempDir();
189
+ try {
190
+ await acquireLock(dir, "release-scope", "op-001", "test.cmd", "owner");
191
+ await releaseLock(dir, "release-scope");
192
+ const files = await readdir(join(dir, ".werkstatt", "locks"));
193
+ expect(files).not.toContain("release-scope.lock.json");
194
+ } finally {
195
+ await rm(dir, { recursive: true, force: true });
196
+ }
197
+ });
198
+
199
+ test("does not throw when lock file does not exist", async () => {
200
+ const dir = await makeTempDir();
201
+ try {
202
+ await expect(releaseLock(dir, "nonexistent")).resolves.toBeUndefined();
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test("decrements depth for re-entrant lock instead of deleting", async () => {
209
+ const dir = await makeTempDir();
210
+ try {
211
+ await acquireLock(dir, "depth-scope", "op-001", "outer.cmd", "owner");
212
+ await acquireLock(dir, "depth-scope", "op-002", "inner.cmd", "owner");
213
+ await releaseLock(dir, "depth-scope");
214
+ const locks = await readAllLocks(dir);
215
+ const lock = locks.find((l) => l.scope === "depth-scope");
216
+ expect(lock).toBeDefined();
217
+ expect(lock!.depth).toBe(1);
218
+ } finally {
219
+ await rm(dir, { recursive: true, force: true });
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("heartbeatLock", () => {
225
+ test("updates heartbeatAt on disk", async () => {
226
+ const dir = await makeTempDir();
227
+ try {
228
+ const original = await acquireLock(dir, "hb-scope", "op-001", "test.cmd", "owner");
229
+ await new Promise((r) => setTimeout(r, 50));
230
+ await heartbeatLock(dir, "hb-scope");
231
+ const locks = await readAllLocks(dir);
232
+ const updated = locks.find((l) => l.scope === "hb-scope");
233
+ expect(updated).toBeDefined();
234
+ expect(updated!.heartbeatAt).not.toBe(original.heartbeatAt);
235
+ } finally {
236
+ await rm(dir, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ test("does nothing when lock file does not exist", async () => {
241
+ const dir = await makeTempDir();
242
+ try {
243
+ await expect(heartbeatLock(dir, "nonexistent")).resolves.toBeUndefined();
244
+ } finally {
245
+ await rm(dir, { recursive: true, force: true });
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("readAllLocks", () => {
251
+ test("returns empty array when no locks directory exists", async () => {
252
+ const dir = await makeTempDir();
253
+ try {
254
+ const locks = await readAllLocks(dir);
255
+ expect(locks).toEqual([]);
256
+ } finally {
257
+ await rm(dir, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ test("returns all valid locks with stale flag", async () => {
262
+ const dir = await makeTempDir();
263
+ try {
264
+ await acquireLock(dir, "active-scope", "op-001", "test.cmd", "owner");
265
+ const staleLock = makeValidLock({
266
+ scope: "stale-scope",
267
+ operationId: "stale-op",
268
+ pid: 999999,
269
+ heartbeatAt: new Date(Date.now() - 2000 * 1000).toISOString(),
270
+ });
271
+ const locksDir = join(dir, ".werkstatt", "locks");
272
+ await writeFile(join(locksDir, "stale-scope.lock.json"), JSON.stringify(staleLock, null, 2));
273
+
274
+ const locks = await readAllLocks(dir);
275
+ expect(locks).toHaveLength(2);
276
+ const active = locks.find((l) => l.scope === "active-scope");
277
+ const stale = locks.find((l) => l.scope === "stale-scope");
278
+ expect(active?.stale).toBe(false);
279
+ expect(stale?.stale).toBe(true);
280
+ } finally {
281
+ await rm(dir, { recursive: true, force: true });
282
+ }
283
+ });
284
+
285
+ test("skips corrupt lock files", async () => {
286
+ const dir = await makeTempDir();
287
+ try {
288
+ await acquireLock(dir, "good-scope", "op-001", "test.cmd", "owner");
289
+ const locksDir = join(dir, ".werkstatt", "locks");
290
+ await writeFile(join(locksDir, "corrupt.lock.json"), "{ not valid json");
291
+
292
+ const locks = await readAllLocks(dir);
293
+ expect(locks).toHaveLength(1);
294
+ expect(locks[0].scope).toBe("good-scope");
295
+ } finally {
296
+ await rm(dir, { recursive: true, force: true });
297
+ }
298
+ });
299
+ });
300
+
301
+ describe("removeStaleLock", () => {
302
+ test("removes a stale lock and returns true", async () => {
303
+ const dir = await makeTempDir();
304
+ try {
305
+ const staleLock = makeValidLock({
306
+ scope: "stale-remove",
307
+ pid: 999999,
308
+ heartbeatAt: new Date(Date.now() - 2000 * 1000).toISOString(),
309
+ });
310
+ const locksDir = join(dir, ".werkstatt", "locks");
311
+ await mkdir(locksDir, { recursive: true });
312
+ await writeFile(join(locksDir, "stale-remove.lock.json"), JSON.stringify(staleLock, null, 2));
313
+
314
+ const removed = await removeStaleLock(dir, "stale-remove");
315
+ expect(removed).toBe(true);
316
+ const files = await readdir(locksDir);
317
+ expect(files).not.toContain("stale-remove.lock.json");
318
+ } finally {
319
+ await rm(dir, { recursive: true, force: true });
320
+ }
321
+ });
322
+
323
+ test("returns false for a non-stale lock", async () => {
324
+ const dir = await makeTempDir();
325
+ try {
326
+ await acquireLock(dir, "active-remove", "op-001", "test.cmd", "owner");
327
+ const removed = await removeStaleLock(dir, "active-remove");
328
+ expect(removed).toBe(false);
329
+ } finally {
330
+ await rm(dir, { recursive: true, force: true });
331
+ }
332
+ });
333
+
334
+ test("returns false when lock does not exist", async () => {
335
+ const dir = await makeTempDir();
336
+ try {
337
+ const removed = await removeStaleLock(dir, "nonexistent");
338
+ expect(removed).toBe(false);
339
+ } finally {
340
+ await rm(dir, { recursive: true, force: true });
341
+ }
342
+ });
343
+ });
@@ -0,0 +1,112 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for profile-driven workspace detection (RFC-0640).</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-0640: initial profile-driven workspace detection tests.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { discoverWorkspaces, detectWorkspaceType } from "../onboarding/workspace-discovery.ts";
15
+ import type { ProfileWorkspaceType } from "../profiles/profile-schema.ts";
16
+
17
+ let tempDir: string;
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await mkdtemp(join(tmpdir(), "ws-domain-test-"));
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await rm(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ test("detectWorkspaceType uses profile workspaceTypes when provided", async () => {
28
+ await mkdir(join(tempDir, "scene-dir"), { recursive: true });
29
+ await writeFile(join(tempDir, "scene-dir", "package.json"), "{}");
30
+ await writeFile(join(tempDir, "scene-dir", "scene.scene"), "{}");
31
+
32
+ const workspaceTypes: ProfileWorkspaceType[] = [
33
+ {
34
+ id: "scene",
35
+ detect: { glob: "*.scene" },
36
+ },
37
+ ];
38
+
39
+ expect(detectWorkspaceType(join(tempDir, "scene-dir"), workspaceTypes)).toBe("scene");
40
+ });
41
+
42
+ test("detectWorkspaceType returns null when workspaceTypes provided but no match", async () => {
43
+ await mkdir(join(tempDir, "pkg-dir"), { recursive: true });
44
+ await writeFile(join(tempDir, "pkg-dir", "package.json"), "{}");
45
+
46
+ const workspaceTypes: ProfileWorkspaceType[] = [
47
+ {
48
+ id: "scene",
49
+ detect: { glob: "*.scene" },
50
+ },
51
+ ];
52
+
53
+ expect(detectWorkspaceType(join(tempDir, "pkg-dir"), workspaceTypes)).toBeNull();
54
+ });
55
+
56
+ test("detectWorkspaceType uses hardcoded detection when workspaceTypes absent", async () => {
57
+ await mkdir(join(tempDir, "app-dir"), { recursive: true });
58
+ await writeFile(join(tempDir, "app-dir", "package.json"), "{}");
59
+ await writeFile(join(tempDir, "app-dir", "astro.config.mjs"), "export default {}");
60
+
61
+ expect(detectWorkspaceType(join(tempDir, "app-dir"))).toBe("app");
62
+ });
63
+
64
+ test("detectWorkspaceType profile contains marker detection", async () => {
65
+ await mkdir(join(tempDir, "video-dir"), { recursive: true });
66
+ await writeFile(join(tempDir, "video-dir", "package.json"), "{}");
67
+ await writeFile(join(tempDir, "video-dir", "video.config.yaml"), "test");
68
+
69
+ const workspaceTypes: ProfileWorkspaceType[] = [
70
+ {
71
+ id: "video",
72
+ detect: { contains: "video.config.yaml" },
73
+ },
74
+ ];
75
+
76
+ expect(detectWorkspaceType(join(tempDir, "video-dir"), workspaceTypes)).toBe("video");
77
+ });
78
+
79
+ test("detectWorkspaceType profile packageJsonDep detection", async () => {
80
+ await mkdir(join(tempDir, "phaser-dir"), { recursive: true });
81
+ await writeFile(
82
+ join(tempDir, "phaser-dir", "package.json"),
83
+ JSON.stringify({ dependencies: { phaser: "^3.0.0" } }),
84
+ );
85
+
86
+ const workspaceTypes: ProfileWorkspaceType[] = [
87
+ {
88
+ id: "game",
89
+ detect: { packageJsonDep: "phaser" },
90
+ },
91
+ ];
92
+
93
+ expect(detectWorkspaceType(join(tempDir, "phaser-dir"), workspaceTypes)).toBe("game");
94
+ });
95
+
96
+ test("discoverWorkspaces passes workspaceTypes through to detection", async () => {
97
+ await mkdir(join(tempDir, "packages", "scene-pkg"), { recursive: true });
98
+ await writeFile(join(tempDir, "packages", "scene-pkg", "package.json"), "{}");
99
+ await writeFile(join(tempDir, "packages", "scene-pkg", "scene.scene"), "{}");
100
+
101
+ const workspaceTypes: ProfileWorkspaceType[] = [
102
+ {
103
+ id: "scene",
104
+ detect: { glob: "*.scene" },
105
+ },
106
+ ];
107
+
108
+ const results = discoverWorkspaces(tempDir, workspaceTypes);
109
+ const sceneWs = results.find((w) => w.path === "packages/scene-pkg");
110
+ expect(sceneWs).toBeDefined();
111
+ expect(sceneWs?.type).toBe("scene");
112
+ });
@@ -0,0 +1,135 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for workspace discovery and type auto-detection (RFC-0611).</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-0611: initial workspace discovery tests.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { discoverWorkspaces, detectWorkspaceType } from "../onboarding/workspace-discovery.ts";
15
+
16
+ let tempDir: string;
17
+
18
+ beforeEach(async () => {
19
+ tempDir = await mkdtemp(join(tmpdir(), "ws-discovery-test-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ test("detectWorkspaceType returns null for directory without package.json", () => {
27
+ expect(detectWorkspaceType(tempDir)).toBeNull();
28
+ });
29
+
30
+ test("detectWorkspaceType returns package for directory with only package.json", async () => {
31
+ await writeFile(join(tempDir, "package.json"), "{}");
32
+ expect(detectWorkspaceType(tempDir)).toBe("package");
33
+ });
34
+
35
+ test("detectWorkspaceType returns app for directory with astro.config.mjs", async () => {
36
+ await writeFile(join(tempDir, "package.json"), "{}");
37
+ await writeFile(join(tempDir, "astro.config.mjs"), "export default {}");
38
+ expect(detectWorkspaceType(tempDir)).toBe("app");
39
+ });
40
+
41
+ test("detectWorkspaceType returns app for directory with astro.config.ts", async () => {
42
+ await writeFile(join(tempDir, "package.json"), "{}");
43
+ await writeFile(join(tempDir, "astro.config.ts"), "export default {}");
44
+ expect(detectWorkspaceType(tempDir)).toBe("app");
45
+ });
46
+
47
+ test("detectWorkspaceType returns service for directory with Dockerfile", async () => {
48
+ await writeFile(join(tempDir, "package.json"), "{}");
49
+ await writeFile(join(tempDir, "Dockerfile"), "FROM node:20");
50
+ expect(detectWorkspaceType(tempDir)).toBe("service");
51
+ });
52
+
53
+ test("detectWorkspaceType returns service for directory with service.config.yaml", async () => {
54
+ await writeFile(join(tempDir, "package.json"), "{}");
55
+ await writeFile(join(tempDir, "service.config.yaml"), "name: test");
56
+ expect(detectWorkspaceType(tempDir)).toBe("service");
57
+ });
58
+
59
+ test("detectWorkspaceType app takes precedence over service", async () => {
60
+ await writeFile(join(tempDir, "package.json"), "{}");
61
+ await writeFile(join(tempDir, "astro.config.mjs"), "export default {}");
62
+ await writeFile(join(tempDir, "Dockerfile"), "FROM node:20");
63
+ expect(detectWorkspaceType(tempDir)).toBe("app");
64
+ });
65
+
66
+ test("discoverWorkspaces finds nested workspace directories", async () => {
67
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
68
+ await writeFile(join(tempDir, "packages", "my-pkg", "package.json"), "{}");
69
+
70
+ await mkdir(join(tempDir, "apps", "my-app"), { recursive: true });
71
+ await writeFile(join(tempDir, "apps", "my-app", "package.json"), "{}");
72
+ await writeFile(join(tempDir, "apps", "my-app", "astro.config.mjs"), "export default {}");
73
+
74
+ await mkdir(join(tempDir, "services", "my-svc"), { recursive: true });
75
+ await writeFile(join(tempDir, "services", "my-svc", "package.json"), "{}");
76
+ await writeFile(join(tempDir, "services", "my-svc", "Dockerfile"), "FROM node:20");
77
+
78
+ const workspaces = discoverWorkspaces(tempDir);
79
+
80
+ expect(workspaces).toHaveLength(3);
81
+ const paths = workspaces.map((w) => w.path);
82
+ expect(paths).toContain("packages/my-pkg");
83
+ expect(paths).toContain("apps/my-app");
84
+ expect(paths).toContain("services/my-svc");
85
+
86
+ const pkg = workspaces.find((w) => w.path === "packages/my-pkg");
87
+ expect(pkg?.type).toBe("package");
88
+ expect(pkg?.hasAgentsMd).toBe(false);
89
+
90
+ const app = workspaces.find((w) => w.path === "apps/my-app");
91
+ expect(app?.type).toBe("app");
92
+
93
+ const svc = workspaces.find((w) => w.path === "services/my-svc");
94
+ expect(svc?.type).toBe("service");
95
+ });
96
+
97
+ test("discoverWorkspaces skips node_modules, .git, dist, .turbo, .cache, .agents", async () => {
98
+ for (const skip of ["node_modules", ".git", "dist", ".turbo", ".cache", ".agents"]) {
99
+ await mkdir(join(tempDir, skip, "sub"), { recursive: true });
100
+ await writeFile(join(tempDir, skip, "sub", "package.json"), "{}");
101
+ }
102
+
103
+ const workspaces = discoverWorkspaces(tempDir);
104
+ expect(workspaces).toHaveLength(0);
105
+ });
106
+
107
+ test("discoverWorkspaces detects generated AGENTS.md", async () => {
108
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
109
+ await writeFile(join(tempDir, "packages", "my-pkg", "package.json"), "{}");
110
+ await writeFile(
111
+ join(tempDir, "packages", "my-pkg", "AGENTS.md"),
112
+ "<!--\n GENERATED. Do not change this line unless the file contains project specific changes.\n-->\n# Agent Guide\n",
113
+ );
114
+
115
+ const workspaces = discoverWorkspaces(tempDir);
116
+ expect(workspaces).toHaveLength(1);
117
+ expect(workspaces[0].hasAgentsMd).toBe(true);
118
+ expect(workspaces[0].isGenerated).toBe(true);
119
+ });
120
+
121
+ test("discoverWorkspaces detects hand-written AGENTS.md", async () => {
122
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
123
+ await writeFile(join(tempDir, "packages", "my-pkg", "package.json"), "{}");
124
+ await writeFile(join(tempDir, "packages", "my-pkg", "AGENTS.md"), "# My custom guide\n");
125
+
126
+ const workspaces = discoverWorkspaces(tempDir);
127
+ expect(workspaces).toHaveLength(1);
128
+ expect(workspaces[0].hasAgentsMd).toBe(true);
129
+ expect(workspaces[0].isGenerated).toBe(false);
130
+ });
131
+
132
+ test("discoverWorkspaces returns empty for project with no workspaces", () => {
133
+ const workspaces = discoverWorkspaces(tempDir);
134
+ expect(workspaces).toHaveLength(0);
135
+ });