@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,167 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Workspace discovery for nested AGENTS.md generation (RFC-0611).
4
+ Scans the project root for directories containing package.json and auto-detects
5
+ workspace type (app, service, package) by content markers.</purpose>
6
+ <non-goals>
7
+ <item>Do not scan node_modules, .git, dist, .turbo, .cache, .agents directories.</item>
8
+ <item>Do not read package.json contents — only check existence for workspace detection.</item>
9
+ <item>Do not add new workspace-type detection rules without an amending RFC.</item>
10
+ </non-goals>
11
+ </MODULE_CONTRACT>
12
+ <CHANGE_SUMMARY>
13
+ <item>RFC-0611: initial workspace discovery and type auto-detection.</item>
14
+ <item>RFC-0640: accept optional workspaceTypes from profile for profile-driven detection, falling back to hardcoded detection when absent.</item>
15
+ </CHANGE_SUMMARY>
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { hasGeneratedMarker } from "../utils/index.ts";
21
+ import type { ProfileWorkspaceType } from "../profiles/profile-schema.ts";
22
+
23
+ export type WorkspaceType = "app" | "service" | "package" | string;
24
+
25
+ export interface WorkspaceDir {
26
+ path: string;
27
+ type: WorkspaceType;
28
+ hasAgentsMd: boolean;
29
+ isGenerated: boolean;
30
+ }
31
+
32
+ const SKIP_DIRS = new Set([
33
+ "node_modules",
34
+ ".git",
35
+ "dist",
36
+ ".turbo",
37
+ ".cache",
38
+ ".agents",
39
+ ]);
40
+
41
+ export function detectWorkspaceType(
42
+ dirPath: string,
43
+ workspaceTypes?: ProfileWorkspaceType[],
44
+ ): WorkspaceType | null {
45
+ if (!fs.existsSync(path.join(dirPath, "package.json"))) return null;
46
+
47
+ // RFC-0640: try profile-driven detection first
48
+ if (workspaceTypes && workspaceTypes.length > 0) {
49
+ for (const wt of workspaceTypes) {
50
+ if (matchesWorkspaceType(dirPath, wt)) {
51
+ return wt.id;
52
+ }
53
+ }
54
+ // RFC-0640: when workspaceTypes is present, it fully replaces hardcoded detection
55
+ // Directories matching no profile-declared workspace type are not classified
56
+ return null;
57
+ }
58
+
59
+ // Fallback: hardcoded detection (software domain)
60
+ if (
61
+ fs.existsSync(path.join(dirPath, "astro.config.mjs")) ||
62
+ fs.existsSync(path.join(dirPath, "astro.config.js")) ||
63
+ fs.existsSync(path.join(dirPath, "astro.config.ts"))
64
+ ) {
65
+ return "app";
66
+ }
67
+
68
+ if (
69
+ fs.existsSync(path.join(dirPath, "Dockerfile")) ||
70
+ fs.existsSync(path.join(dirPath, "service.config.yaml")) ||
71
+ fs.existsSync(path.join(dirPath, "service.config.yml"))
72
+ ) {
73
+ return "service";
74
+ }
75
+
76
+ return "package";
77
+ }
78
+
79
+ function matchesWorkspaceType(dirPath: string, wt: ProfileWorkspaceType): boolean {
80
+ const { glob: globPattern, contains, packageJsonDep } = wt.detect;
81
+
82
+ if (globPattern) {
83
+ try {
84
+ const entries = fs.readdirSync(dirPath);
85
+ const regex = globToRegex(globPattern);
86
+ if (entries.some((e) => regex.test(e))) return true;
87
+ } catch {
88
+ // dir not readable
89
+ }
90
+ }
91
+
92
+ if (contains) {
93
+ const filePath = path.join(dirPath, contains);
94
+ if (fs.existsSync(filePath)) return true;
95
+ }
96
+
97
+ if (packageJsonDep) {
98
+ try {
99
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(dirPath, "package.json"), "utf8"));
100
+ const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
101
+ if (packageJsonDep in deps) return true;
102
+ } catch {
103
+ // package.json not readable
104
+ }
105
+ }
106
+
107
+ return false;
108
+ }
109
+
110
+ export function discoverWorkspaces(
111
+ workspaceRoot: string,
112
+ workspaceTypes?: ProfileWorkspaceType[],
113
+ ): WorkspaceDir[] {
114
+ const results: WorkspaceDir[] = [];
115
+
116
+ function scanDir(dirPath: string, depth: number): void {
117
+ if (depth > 5) return;
118
+
119
+ let entries: fs.Dirent[];
120
+ try {
121
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
122
+ } catch {
123
+ return;
124
+ }
125
+
126
+ for (const entry of entries) {
127
+ if (!entry.isDirectory()) continue;
128
+ if (SKIP_DIRS.has(entry.name)) continue;
129
+
130
+ const childPath = path.join(dirPath, entry.name);
131
+ const type = detectWorkspaceType(childPath, workspaceTypes);
132
+
133
+ if (type !== null) {
134
+ const agentsMdPath = path.join(childPath, "AGENTS.md");
135
+ const hasAgentsMd = fs.existsSync(agentsMdPath);
136
+ let isGenerated = false;
137
+ if (hasAgentsMd) {
138
+ try {
139
+ const content = fs.readFileSync(agentsMdPath, "utf8");
140
+ isGenerated = hasGeneratedMarker(content);
141
+ } catch {
142
+ // unreadable — treat as not generated
143
+ }
144
+ }
145
+
146
+ results.push({
147
+ path: path.relative(workspaceRoot, childPath),
148
+ type,
149
+ hasAgentsMd,
150
+ isGenerated,
151
+ });
152
+
153
+ scanDir(childPath, depth + 1);
154
+ } else {
155
+ scanDir(childPath, depth + 1);
156
+ }
157
+ }
158
+ }
159
+
160
+ scanDir(workspaceRoot, 0);
161
+ return results;
162
+ }
163
+
164
+ function globToRegex(pattern: string): RegExp {
165
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
166
+ return new RegExp(`^${escaped}$`);
167
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ profileInvariantSchema,
4
+ profileInvariantCheckSchema,
5
+ profileDevServerSchema,
6
+ profileReleaseSchema,
7
+ profilePrerequisiteSchema,
8
+ profileArtifactSchema,
9
+ UNIVERSAL_TERMINOLOGY_KEYS,
10
+ TERMINOLOGY_DEFAULTS,
11
+ } from "../profile-schema.ts";
12
+
13
+ describe("UNIVERSAL_TERMINOLOGY_KEYS", () => {
14
+ it("contains expected keys", () => {
15
+ expect(UNIVERSAL_TERMINOLOGY_KEYS).toContain("artifact");
16
+ expect(UNIVERSAL_TERMINOLOGY_KEYS).toContain("module");
17
+ expect(UNIVERSAL_TERMINOLOGY_KEYS).toContain("operator");
18
+ });
19
+ });
20
+
21
+ describe("TERMINOLOGY_DEFAULTS", () => {
22
+ it("has defaults for all universal keys", () => {
23
+ for (const key of UNIVERSAL_TERMINOLOGY_KEYS) {
24
+ expect(TERMINOLOGY_DEFAULTS[key]).toBeDefined();
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("profileArtifactSchema", () => {
30
+ it("accepts minimal artifact with id and extensions", () => {
31
+ const result = profileArtifactSchema.safeParse({
32
+ id: "html-pages",
33
+ extensions: [".html"],
34
+ });
35
+ expect(result.success).toBe(true);
36
+ });
37
+
38
+ it("accepts artifact with produce and validate", () => {
39
+ const result = profileArtifactSchema.safeParse({
40
+ id: "html-pages",
41
+ extensions: [".html"],
42
+ produce: { command: "astro build", output: "dist" },
43
+ validate: { command: "check", outputFormat: "json" },
44
+ determinism: { hashable: true, inputs: ["src"] },
45
+ });
46
+ expect(result.success).toBe(true);
47
+ });
48
+
49
+ it("rejects artifact without id", () => {
50
+ const result = profileArtifactSchema.safeParse({ extensions: [".html"] });
51
+ expect(result.success).toBe(false);
52
+ });
53
+
54
+ it("accepts artifact with empty extensions array", () => {
55
+ const result = profileArtifactSchema.safeParse({ id: "test", extensions: [] });
56
+ expect(result.success).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("profileInvariantCheckSchema", () => {
61
+ it("accepts filename-pattern kind", () => {
62
+ const result = profileInvariantCheckSchema.safeParse({
63
+ kind: "filename-pattern",
64
+ glob: "*.ts",
65
+ pattern: "^[a-z-]+\\.ts$",
66
+ });
67
+ expect(result.success).toBe(true);
68
+ });
69
+
70
+ it("accepts attribute-pattern kind with required fields", () => {
71
+ const result = profileInvariantCheckSchema.safeParse({
72
+ kind: "attribute-pattern",
73
+ elements: ["img"],
74
+ attribute: "src",
75
+ pattern: "^/assets/",
76
+ });
77
+ expect(result.success).toBe(true);
78
+ });
79
+
80
+ it("rejects attribute-pattern without elements", () => {
81
+ const result = profileInvariantCheckSchema.safeParse({
82
+ kind: "attribute-pattern",
83
+ attribute: "src",
84
+ pattern: "^/assets/",
85
+ });
86
+ expect(result.success).toBe(false);
87
+ });
88
+
89
+ it("rejects attribute-pattern with empty elements array", () => {
90
+ const result = profileInvariantCheckSchema.safeParse({
91
+ kind: "attribute-pattern",
92
+ elements: [],
93
+ attribute: "src",
94
+ pattern: "^/assets/",
95
+ });
96
+ expect(result.success).toBe(false);
97
+ });
98
+
99
+ it("accepts frontmatter-required with fields", () => {
100
+ const result = profileInvariantCheckSchema.safeParse({
101
+ kind: "frontmatter-required",
102
+ fields: ["title", "created"],
103
+ });
104
+ expect(result.success).toBe(true);
105
+ });
106
+
107
+ it("rejects frontmatter-required without fields", () => {
108
+ const result = profileInvariantCheckSchema.safeParse({
109
+ kind: "frontmatter-required",
110
+ });
111
+ expect(result.success).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("profileInvariantSchema", () => {
116
+ it("accepts valid invariant with id matching pattern", () => {
117
+ const result = profileInvariantSchema.safeParse({
118
+ id: "VIDEO-01",
119
+ rule: "All videos must have captions",
120
+ severity: "error",
121
+ });
122
+ expect(result.success).toBe(true);
123
+ });
124
+
125
+ it("rejects id not matching ^[A-Z]+-\\d+$ pattern", () => {
126
+ const result = profileInvariantSchema.safeParse({
127
+ id: "video-01",
128
+ rule: "test",
129
+ severity: "error",
130
+ });
131
+ expect(result.success).toBe(false);
132
+ });
133
+
134
+ it("rejects invalid severity", () => {
135
+ const result = profileInvariantSchema.safeParse({
136
+ id: "VIDEO-01",
137
+ rule: "test",
138
+ severity: "critical",
139
+ });
140
+ expect(result.success).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe("profileDevServerSchema", () => {
145
+ it("accepts minimal dev server with command", () => {
146
+ const result = profileDevServerSchema.safeParse({ command: "astro dev" });
147
+ expect(result.success).toBe(true);
148
+ });
149
+
150
+ it("accepts dev server with port and timeout", () => {
151
+ const result = profileDevServerSchema.safeParse({
152
+ command: "astro dev",
153
+ port: 4321,
154
+ readinessTimeout: 5000,
155
+ });
156
+ expect(result.success).toBe(true);
157
+ });
158
+
159
+ it("rejects empty command", () => {
160
+ const result = profileDevServerSchema.safeParse({ command: "" });
161
+ expect(result.success).toBe(false);
162
+ });
163
+
164
+ it("rejects negative port", () => {
165
+ const result = profileDevServerSchema.safeParse({ command: "dev", port: -1 });
166
+ expect(result.success).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe("profileReleaseSchema", () => {
171
+ it("accepts local release target", () => {
172
+ const result = profileReleaseSchema.safeParse({
173
+ target: "local",
174
+ outputDir: "dist",
175
+ });
176
+ expect(result.success).toBe(true);
177
+ });
178
+
179
+ it("accepts r2 release target with required fields", () => {
180
+ const result = profileReleaseSchema.safeParse({
181
+ target: "r2",
182
+ outputDir: "dist",
183
+ r2: { bucket: "my-bucket", accountId: "123" },
184
+ });
185
+ expect(result.success).toBe(true);
186
+ expect(result.success && result.data?.manifestName).toBe("release-manifest.json");
187
+ });
188
+
189
+ it("rejects invalid target", () => {
190
+ const result = profileReleaseSchema.safeParse({
191
+ target: "invalid",
192
+ outputDir: "dist",
193
+ });
194
+ expect(result.success).toBe(false);
195
+ });
196
+ });
197
+
198
+ describe("profilePrerequisiteSchema", () => {
199
+ it("accepts minimal prerequisite", () => {
200
+ const result = profilePrerequisiteSchema.safeParse({
201
+ id: "ffmpeg",
202
+ name: "FFmpeg",
203
+ check: "ffmpeg -version",
204
+ });
205
+ expect(result.success).toBe(true);
206
+ expect(result.success && result.data?.severity).toBe("error");
207
+ });
208
+
209
+ it("accepts warning severity", () => {
210
+ const result = profilePrerequisiteSchema.safeParse({
211
+ id: "ffmpeg",
212
+ name: "FFmpeg",
213
+ check: "ffmpeg -version",
214
+ severity: "warning",
215
+ });
216
+ expect(result.success).toBe(true);
217
+ });
218
+
219
+ it("rejects invalid severity", () => {
220
+ const result = profilePrerequisiteSchema.safeParse({
221
+ id: "ffmpeg",
222
+ name: "FFmpeg",
223
+ check: "ffmpeg -version",
224
+ severity: "critical",
225
+ });
226
+ expect(result.success).toBe(false);
227
+ });
228
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ loadStackProfile,
4
+ listStackProfiles,
5
+ detectStack,
6
+ stackProfileSchema,
7
+ } from "../stack-profile.ts";
8
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+
12
+ let tempDir: string;
13
+
14
+ beforeEach(async () => {
15
+ tempDir = await mkdtemp(join(tmpdir(), "forge-profile-"));
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await rm(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ const validProfileYaml = `
23
+ schema: forge/stack-profile@1
24
+ id: test-stack
25
+ displayName: Test Stack
26
+ detect:
27
+ anyOf:
28
+ - package.json
29
+ workspace:
30
+ dirs:
31
+ - packages
32
+ files:
33
+ - path: package.json
34
+ content: '{}'
35
+ install: []
36
+ `;
37
+
38
+ describe("stackProfileSchema", () => {
39
+ it("accepts a valid minimal profile", () => {
40
+ const result = stackProfileSchema.safeParse({
41
+ schema: "forge/stack-profile@1",
42
+ id: "test",
43
+ displayName: "Test",
44
+ detect: { anyOf: ["package.json"] },
45
+ workspace: { dirs: ["packages"], files: [] },
46
+ install: [],
47
+ });
48
+ expect(result.success).toBe(true);
49
+ });
50
+
51
+ it("rejects wrong schema literal", () => {
52
+ const result = stackProfileSchema.safeParse({
53
+ schema: "wrong",
54
+ id: "test",
55
+ displayName: "Test",
56
+ detect: { anyOf: ["package.json"] },
57
+ workspace: { dirs: ["packages"], files: [] },
58
+ });
59
+ expect(result.success).toBe(false);
60
+ });
61
+
62
+ it("rejects empty anyOf array", () => {
63
+ const result = stackProfileSchema.safeParse({
64
+ schema: "forge/stack-profile@1",
65
+ id: "test",
66
+ displayName: "Test",
67
+ detect: { anyOf: [] },
68
+ workspace: { dirs: ["packages"], files: [] },
69
+ });
70
+ expect(result.success).toBe(false);
71
+ });
72
+
73
+ it("accepts optional domain fields", () => {
74
+ const result = stackProfileSchema.safeParse({
75
+ schema: "forge/stack-profile@1",
76
+ id: "test",
77
+ displayName: "Test",
78
+ detect: { anyOf: ["package.json"] },
79
+ workspace: { dirs: ["packages"], files: [] },
80
+ install: [],
81
+ domain: "software",
82
+ terminology: { artifact: "module" },
83
+ register: "business",
84
+ });
85
+ expect(result.success).toBe(true);
86
+ });
87
+ });
88
+
89
+ describe("loadStackProfile", () => {
90
+ it("loads a valid YAML profile", async () => {
91
+ const profilePath = join(tempDir, "test.yaml");
92
+ await writeFile(profilePath, validProfileYaml, "utf8");
93
+ const profile = loadStackProfile(profilePath);
94
+ expect(profile.id).toBe("test-stack");
95
+ expect(profile.displayName).toBe("Test Stack");
96
+ });
97
+
98
+ it("throws on invalid YAML", async () => {
99
+ const profilePath = join(tempDir, "bad.yaml");
100
+ await writeFile(profilePath, "not: valid: yaml: [", "utf8");
101
+ expect(() => loadStackProfile(profilePath)).toThrow();
102
+ });
103
+
104
+ it("throws on schema validation failure", async () => {
105
+ const profilePath = join(tempDir, "invalid.yaml");
106
+ await writeFile(profilePath, "schema: wrong\nid: test\n", "utf8");
107
+ expect(() => loadStackProfile(profilePath)).toThrow();
108
+ });
109
+ });
110
+
111
+ describe("listStackProfiles", () => {
112
+ it("returns empty array when profiles dir missing", () => {
113
+ expect(listStackProfiles(join(tempDir, "nonexistent"))).toEqual([]);
114
+ });
115
+
116
+ it("lists all .yaml profiles in the directory", async () => {
117
+ const profilesDir = join(tempDir, "profiles");
118
+ await mkdir(profilesDir, { recursive: true });
119
+ await writeFile(join(profilesDir, "a.yaml"), validProfileYaml, "utf8");
120
+ await writeFile(
121
+ join(profilesDir, "b.yaml"),
122
+ validProfileYaml.replace("test-stack", "test-stack-b"),
123
+ "utf8",
124
+ );
125
+ await writeFile(join(profilesDir, ".hidden.yaml"), validProfileYaml, "utf8");
126
+
127
+ const profiles = listStackProfiles(tempDir);
128
+ expect(profiles).toHaveLength(2);
129
+ const ids = profiles.map((p) => p.id);
130
+ expect(ids).toContain("test-stack");
131
+ expect(ids).toContain("test-stack-b");
132
+ });
133
+ });
134
+
135
+ describe("detectStack", () => {
136
+ it("detects matching profile", async () => {
137
+ await writeFile(join(tempDir, "package.json"), "{}", "utf8");
138
+ const profiles = [
139
+ {
140
+ schema: "forge/stack-profile@1" as const,
141
+ id: "a",
142
+ displayName: "A",
143
+ detect: { anyOf: ["package.json"] },
144
+ workspace: { dirs: [], files: [] },
145
+ install: [],
146
+ },
147
+ {
148
+ schema: "forge/stack-profile@1" as const,
149
+ id: "b",
150
+ displayName: "B",
151
+ detect: { anyOf: ["nonexistent.file"] },
152
+ workspace: { dirs: [], files: [] },
153
+ install: [],
154
+ },
155
+ ];
156
+
157
+ const result = detectStack(tempDir, profiles);
158
+ expect(result?.id).toBe("a");
159
+ });
160
+
161
+ it("returns null when no profile matches", () => {
162
+ const profiles = [
163
+ {
164
+ schema: "forge/stack-profile@1" as const,
165
+ id: "x",
166
+ displayName: "X",
167
+ detect: { anyOf: ["nonexistent.file"] },
168
+ workspace: { dirs: [], files: [] },
169
+ install: [],
170
+ },
171
+ ];
172
+ expect(detectStack(tempDir, profiles)).toBeNull();
173
+ });
174
+
175
+ it("supports glob patterns in detect.anyOf", async () => {
176
+ await writeFile(join(tempDir, "astro.config.mjs"), "export default {}", "utf8");
177
+ const profiles = [
178
+ {
179
+ schema: "forge/stack-profile@1" as const,
180
+ id: "astro",
181
+ displayName: "Astro",
182
+ detect: { anyOf: ["astro.config.*"] },
183
+ workspace: { dirs: [], files: [] },
184
+ install: [],
185
+ },
186
+ ];
187
+ const result = detectStack(tempDir, profiles);
188
+ expect(result?.id).toBe("astro");
189
+ });
190
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveAllTerminology } from "../terminology-utils.ts";
3
+ import { defaultForgeConfig } from "../../config/forge-config.ts";
4
+ import { TERMINOLOGY_DEFAULTS } from "../profile-schema.ts";
5
+ import type { StackProfile } from "../stack-profile.ts";
6
+
7
+ function makeProfile(terminology?: Record<string, string>): StackProfile {
8
+ return {
9
+ schema: "forge/stack-profile@1",
10
+ id: "test-profile",
11
+ displayName: "Test",
12
+ detect: { anyOf: ["package.json"] },
13
+ workspace: { dirs: ["packages"], files: [] },
14
+ install: [],
15
+ terminology,
16
+ };
17
+ }
18
+
19
+ describe("resolveAllTerminology", () => {
20
+ it("returns universal defaults when no overrides exist", () => {
21
+ const config = defaultForgeConfig("test");
22
+ const profile = makeProfile();
23
+ const result = resolveAllTerminology(config, profile);
24
+ expect(result.artifact).toBe(TERMINOLOGY_DEFAULTS.artifact);
25
+ expect(result.operator).toBe(TERMINOLOGY_DEFAULTS.operator);
26
+ });
27
+
28
+ it("profile terminology overrides universal defaults", () => {
29
+ const config = defaultForgeConfig("test");
30
+ const profile = makeProfile({ artifact: "widget" });
31
+ const result = resolveAllTerminology(config, profile);
32
+ expect(result.artifact).toBe("widget");
33
+ expect(result.operator).toBe(TERMINOLOGY_DEFAULTS.operator);
34
+ });
35
+
36
+ it("project terminology overrides profile terminology", () => {
37
+ const config = defaultForgeConfig("test");
38
+ if (config.bindings) {
39
+ config.bindings.terminology = { artifact: "project-widget" };
40
+ }
41
+ const profile = makeProfile({ artifact: "profile-widget" });
42
+ const result = resolveAllTerminology(config, profile);
43
+ expect(result.artifact).toBe("project-widget");
44
+ });
45
+
46
+ it("handles undefined profile", () => {
47
+ const config = defaultForgeConfig("test");
48
+ const result = resolveAllTerminology(config, undefined);
49
+ expect(result.artifact).toBe(TERMINOLOGY_DEFAULTS.artifact);
50
+ });
51
+
52
+ it("handles undefined config bindings", () => {
53
+ const config = defaultForgeConfig("test");
54
+ delete config.bindings;
55
+ const profile = makeProfile({ artifact: "widget" });
56
+ const result = resolveAllTerminology(config, profile);
57
+ expect(result.artifact).toBe("widget");
58
+ });
59
+
60
+ it("merges all three layers correctly", () => {
61
+ const config = defaultForgeConfig("test");
62
+ if (config.bindings) {
63
+ config.bindings.terminology = { module: "project-module" };
64
+ }
65
+ const profile = makeProfile({ artifact: "profile-artifact", module: "profile-module" });
66
+ const result = resolveAllTerminology(config, profile);
67
+ expect(result.artifact).toBe("profile-artifact");
68
+ expect(result.module).toBe("project-module");
69
+ expect(result.operator).toBe(TERMINOLOGY_DEFAULTS.operator);
70
+ });
71
+ });