@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,117 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for note.link.validate command handler (RFC-0808).</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-0808: initial note.link.validate tests.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { runNoteLinkValidate } from "../../../src/validators/note-link-validate.ts";
15
+
16
+ function createTempDir(): string {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), "forge-note-link-test-"));
18
+ }
19
+
20
+ function writeFile(dir: string, relPath: string, content: string): void {
21
+ const fullPath = path.join(dir, relPath);
22
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
23
+ fs.writeFileSync(fullPath, content);
24
+ }
25
+
26
+ describe("note.link.validate", () => {
27
+ let tempDir: string;
28
+
29
+ beforeEach(() => {
30
+ tempDir = createTempDir();
31
+ });
32
+
33
+ afterEach(() => {
34
+ fs.rmSync(tempDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("detects broken wikilinks", () => {
38
+ writeFile(
39
+ tempDir,
40
+ "vault/note-1.md",
41
+ "---\ntitle: Note 1\n---\nLink to [[note-2]] and [[missing-note]].",
42
+ );
43
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
44
+
45
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
46
+
47
+ expect(result.data!.violations).toHaveLength(1);
48
+ expect(result.data!.violations[0].rule).toBe("NOTE-01");
49
+ expect(result.data!.violations[0].file).toBe("vault/note-1.md");
50
+ expect(result.data!.violations[0].message).toContain("missing-note");
51
+ expect(result.exitCode).toBe(1);
52
+ });
53
+
54
+ it("passes when all links resolve", () => {
55
+ writeFile(tempDir, "vault/note-1.md", "---\ntitle: Note 1\n---\nLink to [[note-2]].");
56
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
57
+
58
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
59
+
60
+ expect(result.data!.violations).toHaveLength(0);
61
+ expect(result.exitCode).toBe(0);
62
+ });
63
+
64
+ it("resolves aliases", () => {
65
+ writeFile(
66
+ tempDir,
67
+ "vault/note-1.md",
68
+ "---\ntitle: Note 1\naliases: [alias-1]\n---\nLink to [[alias-1]].",
69
+ );
70
+
71
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
72
+
73
+ expect(result.data!.violations).toHaveLength(0);
74
+ expect(result.exitCode).toBe(0);
75
+ });
76
+
77
+ it("handles empty vault", () => {
78
+ fs.mkdirSync(path.join(tempDir, "vault"), { recursive: true });
79
+
80
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
81
+
82
+ expect(result.data!.violations).toHaveLength(0);
83
+ expect(result.exitCode).toBe(0);
84
+ });
85
+
86
+ it("handles missing vault directory", () => {
87
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
88
+
89
+ expect(result.data!.violations).toHaveLength(0);
90
+ expect(result.exitCode).toBe(0);
91
+ });
92
+
93
+ it("supports --path flag for scoping", () => {
94
+ writeFile(tempDir, "vault/chapters/ch-1.md", "---\ntitle: Ch 1\n---\nLink to [[ch-2]].");
95
+ writeFile(tempDir, "vault/chapters/ch-2.md", "---\ntitle: Ch 2\n---\nBack to [[ch-1]].");
96
+ writeFile(tempDir, "vault/appendix/app-a.md", "---\ntitle: App A\n---\nLink to [[missing]].");
97
+
98
+ const result = runNoteLinkValidate({ flags: { path: "chapters" } }, { workspaceRoot: tempDir });
99
+
100
+ expect(result.data!.violations).toHaveLength(0);
101
+ expect(result.exitCode).toBe(0);
102
+ });
103
+
104
+ it("handles link aliases with pipe and hash", () => {
105
+ writeFile(
106
+ tempDir,
107
+ "vault/note-1.md",
108
+ "---\ntitle: Note 1\n---\nLink to [[note-2|display]] and [[note-2#section]].",
109
+ );
110
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nContent.");
111
+
112
+ const result = runNoteLinkValidate({ flags: {} }, { workspaceRoot: tempDir });
113
+
114
+ expect(result.data!.violations).toHaveLength(0);
115
+ expect(result.exitCode).toBe(0);
116
+ });
117
+ });
@@ -0,0 +1,94 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for note.orphan.detect command handler (RFC-0808).</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-0808: initial note.orphan.detect tests.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { runNoteOrphanDetect } from "../../../src/validators/note-orphan-detect.ts";
15
+
16
+ function createTempDir(): string {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), "forge-note-orphan-test-"));
18
+ }
19
+
20
+ function writeFile(dir: string, relPath: string, content: string): void {
21
+ const fullPath = path.join(dir, relPath);
22
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
23
+ fs.writeFileSync(fullPath, content);
24
+ }
25
+
26
+ describe("note.orphan.detect", () => {
27
+ let tempDir: string;
28
+
29
+ beforeEach(() => {
30
+ tempDir = createTempDir();
31
+ });
32
+
33
+ afterEach(() => {
34
+ fs.rmSync(tempDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("detects orphan notes with zero inbound links", () => {
38
+ writeFile(tempDir, "vault/note-1.md", "---\ntitle: Note 1\n---\nLink to [[note-2]].");
39
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
40
+ writeFile(tempDir, "vault/orphan.md", "---\ntitle: Orphan\n---\nNobody links to me.");
41
+
42
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
43
+
44
+ expect(result.data!.orphans).toHaveLength(1);
45
+ expect(result.data!.orphans[0].file).toBe("vault/orphan.md");
46
+ expect(result.data!.orphans[0].inboundLinks).toBe(0);
47
+ expect(result.data!.orphans[0].severity).toBe("warning");
48
+ expect(result.exitCode).toBe(0);
49
+ });
50
+
51
+ it("does not report notes with inbound links", () => {
52
+ writeFile(tempDir, "vault/note-1.md", "---\ntitle: Note 1\n---\nLink to [[note-2]].");
53
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
54
+
55
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
56
+
57
+ expect(result.data!.orphans).toHaveLength(0);
58
+ expect(result.exitCode).toBe(0);
59
+ });
60
+
61
+ it("handles empty vault", () => {
62
+ fs.mkdirSync(path.join(tempDir, "vault"), { recursive: true });
63
+
64
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
65
+
66
+ expect(result.data!.orphans).toHaveLength(0);
67
+ expect(result.exitCode).toBe(0);
68
+ });
69
+
70
+ it("handles missing vault directory", () => {
71
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
72
+
73
+ expect(result.data!.orphans).toHaveLength(0);
74
+ expect(result.exitCode).toBe(0);
75
+ });
76
+
77
+ it("always exits zero (warnings, not errors)", () => {
78
+ writeFile(tempDir, "vault/orphan.md", "---\ntitle: Orphan\n---\nNobody links to me.");
79
+
80
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
81
+
82
+ expect(result.data!.orphans).toHaveLength(1);
83
+ expect(result.exitCode).toBe(0);
84
+ });
85
+
86
+ it("does not count self-links as inbound", () => {
87
+ writeFile(tempDir, "vault/note-1.md", "---\ntitle: Note 1\n---\nLink to [[note-1]].");
88
+
89
+ const result = runNoteOrphanDetect({ flags: {} }, { workspaceRoot: tempDir });
90
+
91
+ expect(result.data!.orphans).toHaveLength(1);
92
+ expect(result.data!.orphans[0].file).toBe("vault/note-1.md");
93
+ });
94
+ });
@@ -0,0 +1,226 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for forge.package.health validator — engines, CI workflow, extract config, devDeps completeness.</purpose>
4
+ </MODULE_CONTRACT>
5
+ */
6
+
7
+ import { test, expect, beforeEach, afterEach } from "vitest";
8
+ import { join } from "node:path";
9
+ import { writeFileSync, mkdirSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../src/types.ts";
12
+ import { runPackageHealth } from "./package-health.ts";
13
+
14
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
15
+ return {
16
+ workspaceRoot,
17
+ logger: {
18
+ section: () => {},
19
+ info: () => {},
20
+ warn: () => {},
21
+ error: () => {},
22
+ success: () => {},
23
+ },
24
+ dryRun: false,
25
+ outputFormat: "pretty",
26
+ };
27
+ }
28
+
29
+ function makeInput(): ForgeCommandInput {
30
+ return { argv: [], flags: {} };
31
+ }
32
+
33
+ function writePkgJson(dir: string, pkg: Record<string, unknown>) {
34
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
35
+ }
36
+
37
+ let tmpDir: string;
38
+
39
+ beforeEach(() => {
40
+ tmpDir = join(
41
+ tmpdir(),
42
+ `forge-pkg-health-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
43
+ );
44
+ mkdirSync(tmpDir, { recursive: true });
45
+ });
46
+
47
+ afterEach(() => {
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ test("passes when all published packages are healthy", async () => {
52
+ const rootPkg = {
53
+ name: "test-workspace",
54
+ private: true,
55
+ engines: { node: ">=24 <25" },
56
+ };
57
+ writePkgJson(tmpDir, rootPkg);
58
+
59
+ const pkgDir = join(tmpDir, "packages", "my-pkg");
60
+ mkdirSync(pkgDir, { recursive: true });
61
+ writePkgJson(pkgDir, {
62
+ name: "@test/my-pkg",
63
+ version: "1.0.0",
64
+ private: false,
65
+ type: "module",
66
+ engines: { node: ">=24 <25" },
67
+ scripts: { build: "tsc --noEmit" },
68
+ devDependencies: { typescript: "^5.0.0" },
69
+ });
70
+ mkdirSync(join(pkgDir, ".github", "workflows"), { recursive: true });
71
+ writeFileSync(join(pkgDir, ".github", "workflows", "ci.yml"), "name: CI\n");
72
+ writeFileSync(join(pkgDir, "extract.config.yaml"), "source: .\n");
73
+
74
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
75
+ expect(result.exitCode).toBe(0);
76
+ expect(result.data?.passed).toBe(true);
77
+ expect(result.data?.packagesChecked).toBe(1);
78
+ expect(result.data?.violations).toHaveLength(0);
79
+ });
80
+
81
+ test("skips private packages", async () => {
82
+ writePkgJson(tmpDir, {
83
+ name: "test-workspace",
84
+ private: true,
85
+ engines: { node: ">=24 <25" },
86
+ });
87
+
88
+ const pkgDir = join(tmpDir, "packages", "private-pkg");
89
+ mkdirSync(pkgDir, { recursive: true });
90
+ writePkgJson(pkgDir, {
91
+ name: "@test/private-pkg",
92
+ version: "1.0.0",
93
+ private: true,
94
+ });
95
+
96
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
97
+ expect(result.exitCode).toBe(0);
98
+ expect(result.data?.packagesChecked).toBe(0);
99
+ expect(result.data?.passed).toBe(true);
100
+ });
101
+
102
+ test("reports PKG-HEALTH-01 when engines.node is missing", async () => {
103
+ writePkgJson(tmpDir, {
104
+ name: "test-workspace",
105
+ private: true,
106
+ engines: { node: ">=24 <25" },
107
+ });
108
+
109
+ const pkgDir = join(tmpDir, "packages", "no-engines");
110
+ mkdirSync(pkgDir, { recursive: true });
111
+ writePkgJson(pkgDir, {
112
+ name: "@test/no-engines",
113
+ version: "1.0.0",
114
+ private: false,
115
+ type: "module",
116
+ scripts: {},
117
+ });
118
+ mkdirSync(join(pkgDir, ".github", "workflows"), { recursive: true });
119
+ writeFileSync(join(pkgDir, ".github", "workflows", "ci.yml"), "name: CI\n");
120
+ writeFileSync(join(pkgDir, "extract.config.yaml"), "source: .\n");
121
+
122
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
123
+ expect(result.exitCode).toBe(1);
124
+ expect(result.data?.passed).toBe(false);
125
+ const v01 = result.data?.violations.find((v) => v.ruleId === "PKG-HEALTH-01");
126
+ expect(v01).toBeDefined();
127
+ expect(v01?.severity).toBe("error");
128
+ expect(v01?.packageName).toBe("@test/no-engines");
129
+ });
130
+
131
+ test("reports PKG-HEALTH-02 when CI workflow is missing", async () => {
132
+ writePkgJson(tmpDir, {
133
+ name: "test-workspace",
134
+ private: true,
135
+ engines: { node: ">=24 <25" },
136
+ });
137
+
138
+ const pkgDir = join(tmpDir, "packages", "no-ci");
139
+ mkdirSync(pkgDir, { recursive: true });
140
+ writePkgJson(pkgDir, {
141
+ name: "@test/no-ci",
142
+ version: "1.0.0",
143
+ private: false,
144
+ type: "module",
145
+ engines: { node: ">=24 <25" },
146
+ scripts: {},
147
+ });
148
+ writeFileSync(join(pkgDir, "extract.config.yaml"), "source: .\n");
149
+
150
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
151
+ expect(result.exitCode).toBe(1);
152
+ const v02 = result.data?.violations.find((v) => v.ruleId === "PKG-HEALTH-02");
153
+ expect(v02).toBeDefined();
154
+ expect(v02?.severity).toBe("error");
155
+ });
156
+
157
+ test("reports PKG-HEALTH-03 when extract.config.yaml is missing", async () => {
158
+ writePkgJson(tmpDir, {
159
+ name: "test-workspace",
160
+ private: true,
161
+ engines: { node: ">=24 <25" },
162
+ });
163
+
164
+ const pkgDir = join(tmpDir, "packages", "no-extract");
165
+ mkdirSync(pkgDir, { recursive: true });
166
+ writePkgJson(pkgDir, {
167
+ name: "@test/no-extract",
168
+ version: "1.0.0",
169
+ private: false,
170
+ type: "module",
171
+ engines: { node: ">=24 <25" },
172
+ scripts: {},
173
+ });
174
+ mkdirSync(join(pkgDir, ".github", "workflows"), { recursive: true });
175
+ writeFileSync(join(pkgDir, ".github", "workflows", "ci.yml"), "name: CI\n");
176
+
177
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
178
+ const v03 = result.data?.violations.find((v) => v.ruleId === "PKG-HEALTH-03");
179
+ expect(v03).toBeDefined();
180
+ expect(v03?.severity).toBe("warning");
181
+ // Warning only — should still pass
182
+ expect(result.exitCode).toBe(0);
183
+ });
184
+
185
+ test("reports PKG-HEALTH-04 when script tool is not in devDependencies", async () => {
186
+ writePkgJson(tmpDir, {
187
+ name: "test-workspace",
188
+ private: true,
189
+ engines: { node: ">=24 <25" },
190
+ });
191
+
192
+ const pkgDir = join(tmpDir, "packages", "hoisted-dep");
193
+ mkdirSync(pkgDir, { recursive: true });
194
+ writePkgJson(pkgDir, {
195
+ name: "@test/hoisted-dep",
196
+ version: "1.0.0",
197
+ private: false,
198
+ type: "module",
199
+ engines: { node: ">=24 <25" },
200
+ scripts: {
201
+ lint: 'pnpm exec eslint "src/**/*.ts"',
202
+ test: "vitest run",
203
+ },
204
+ devDependencies: {},
205
+ });
206
+ mkdirSync(join(pkgDir, ".github", "workflows"), { recursive: true });
207
+ writeFileSync(join(pkgDir, ".github", "workflows", "ci.yml"), "name: CI\n");
208
+ writeFileSync(join(pkgDir, "extract.config.yaml"), "source: .\n");
209
+
210
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
211
+ expect(result.exitCode).toBe(1);
212
+ const v04s = result.data?.violations.filter((v) => v.ruleId === "PKG-HEALTH-04");
213
+ expect(v04s).toHaveLength(2);
214
+ const depNames = v04s?.map((v) => v.message.match(/"([^"]+)"/)?.[1]);
215
+ expect(depNames).toContain("eslint");
216
+ expect(depNames).toContain("vitest");
217
+ });
218
+
219
+ test("handles no packages/ directory gracefully", async () => {
220
+ writePkgJson(tmpDir, { name: "empty", private: true });
221
+
222
+ const result = await runPackageHealth(makeInput(), makeContext(tmpDir));
223
+ expect(result.exitCode).toBe(0);
224
+ expect(result.data?.packagesChecked).toBe(0);
225
+ expect(result.data?.passed).toBe(true);
226
+ });
@@ -0,0 +1,231 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>forge.package.health — validate all published packages (private: false) for standalone extraction readiness. Checks engines.node, embedded CI workflow, extract.config.yaml, and devDependencies completeness (no hoisted tool deps).</purpose>
4
+ <non-goals>
5
+ <item>Do not import from @warpgogol/* — this module is portable.</item>
6
+ <item>Do not modify files — read-only validation.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>Initial: forge.package.health validator — engines, CI workflow, extract config, devDeps completeness.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import type {
17
+ ForgeCommandInput,
18
+ ForgeCommandResult,
19
+ ForgeRuntimeContext,
20
+ } from "../../../src/types.ts";
21
+
22
+ export interface PackageHealthViolation {
23
+ ruleId: string;
24
+ packageName: string;
25
+ severity: "error" | "warning";
26
+ message: string;
27
+ file?: string;
28
+ fixHint?: string;
29
+ }
30
+
31
+ export interface PackageHealthResult {
32
+ command: "forge.package.health";
33
+ packagesChecked: number;
34
+ violations: PackageHealthViolation[];
35
+ passed: boolean;
36
+ }
37
+
38
+ const KNOWN_SCRIPT_TOOLS: Record<string, string> = {
39
+ eslint: "eslint",
40
+ prettier: "prettier",
41
+ vitest: "vitest",
42
+ tsc: "typescript",
43
+ tsx: "tsx",
44
+ turbo: "turbo",
45
+ biome: "@biomejs/biome",
46
+ };
47
+
48
+ function extractToolNames(scripts: Record<string, string>): Set<string> {
49
+ const tools = new Set<string>();
50
+ for (const scriptValue of Object.values(scripts)) {
51
+ for (const [toolName, depName] of Object.entries(KNOWN_SCRIPT_TOOLS)) {
52
+ if (scriptValue.includes(toolName) || scriptValue.includes(`pnpm exec ${toolName}`)) {
53
+ tools.add(depName);
54
+ }
55
+ }
56
+ }
57
+ return tools;
58
+ }
59
+
60
+ function readJsonFile(filePath: string): Record<string, unknown> | null {
61
+ try {
62
+ const raw = fs.readFileSync(filePath, "utf8");
63
+ return JSON.parse(raw) as Record<string, unknown>;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export async function runPackageHealth(
70
+ _input: ForgeCommandInput,
71
+ context: ForgeRuntimeContext,
72
+ ): Promise<ForgeCommandResult<PackageHealthResult>> {
73
+ const { workspaceRoot, logger } = context;
74
+ const packagesDir = path.join(workspaceRoot, "packages");
75
+
76
+ if (!fs.existsSync(packagesDir)) {
77
+ return {
78
+ data: {
79
+ command: "forge.package.health",
80
+ packagesChecked: 0,
81
+ violations: [],
82
+ passed: true,
83
+ },
84
+ exitCode: 0,
85
+ summary: "No packages/ directory found — nothing to check.",
86
+ };
87
+ }
88
+
89
+ const rootPkg = readJsonFile(path.join(workspaceRoot, "package.json"));
90
+ const rootEngines = rootPkg?.["engines"] as Record<string, string> | undefined;
91
+ const rootNodeRange = rootEngines?.["node"];
92
+
93
+ const violations: PackageHealthViolation[] = [];
94
+ let packagesChecked = 0;
95
+
96
+ const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue;
99
+ if (entry.name.startsWith(".")) continue;
100
+
101
+ const pkgDir = path.join(packagesDir, entry.name);
102
+ const pkgJsonPath = path.join(pkgDir, "package.json");
103
+ const pkg = readJsonFile(pkgJsonPath);
104
+ if (!pkg) continue;
105
+
106
+ const isPrivate = pkg["private"] === true;
107
+ if (isPrivate) continue;
108
+
109
+ packagesChecked++;
110
+ const packageName = String(pkg["name"] ?? entry.name);
111
+
112
+ // CHECK 1: engines.node exists and matches root
113
+ const pkgEngines = pkg["engines"] as Record<string, string> | undefined;
114
+ const pkgNodeRange = pkgEngines?.["node"];
115
+ if (!pkgNodeRange) {
116
+ violations.push({
117
+ ruleId: "PKG-HEALTH-01",
118
+ packageName,
119
+ severity: "error",
120
+ message: `Missing engines.node in package.json. Root requires "${rootNodeRange ?? ">=24 <25"}".`,
121
+ file: pkgJsonPath,
122
+ fixHint: `Add "engines": { "node": "${rootNodeRange ?? ">=24 <25"}" } to package.json.`,
123
+ });
124
+ } else if (rootNodeRange && pkgNodeRange !== rootNodeRange) {
125
+ violations.push({
126
+ ruleId: "PKG-HEALTH-01",
127
+ packageName,
128
+ severity: "warning",
129
+ message: `engines.node is "${pkgNodeRange}" but root is "${rootNodeRange}".`,
130
+ file: pkgJsonPath,
131
+ fixHint: `Align engines.node with root: "${rootNodeRange}".`,
132
+ });
133
+ }
134
+
135
+ // CHECK 2: .github/workflows/ci.yml exists inside package
136
+ const ciWorkflowPath = path.join(pkgDir, ".github", "workflows", "ci.yml");
137
+ if (!fs.existsSync(ciWorkflowPath)) {
138
+ violations.push({
139
+ ruleId: "PKG-HEALTH-02",
140
+ packageName,
141
+ severity: "error",
142
+ message: "Missing .github/workflows/ci.yml — standalone extraction repo will have no CI.",
143
+ file: pkgDir,
144
+ fixHint: "Create .github/workflows/ci.yml inside the package directory with Node 24 setup.",
145
+ });
146
+ }
147
+
148
+ // CHECK 3: extract.config.yaml exists
149
+ const extractConfigPath = path.join(pkgDir, "extract.config.yaml");
150
+ if (!fs.existsSync(extractConfigPath)) {
151
+ violations.push({
152
+ ruleId: "PKG-HEALTH-03",
153
+ packageName,
154
+ severity: "warning",
155
+ message: "Missing extract.config.yaml — package cannot be extracted via repo-extract.",
156
+ file: pkgDir,
157
+ fixHint: "Create extract.config.yaml with git remote and extraction settings.",
158
+ });
159
+ }
160
+
161
+ // CHECK 4: devDependencies completeness — tools used in scripts must be declared
162
+ const scripts = (pkg["scripts"] ?? {}) as Record<string, string>;
163
+ const devDeps = (pkg["devDependencies"] ?? {}) as Record<string, string>;
164
+ const deps = (pkg["dependencies"] ?? {}) as Record<string, string>;
165
+ const allDeclared = { ...devDeps, ...deps };
166
+
167
+ const usedTools = extractToolNames(scripts);
168
+ for (const toolDep of usedTools) {
169
+ if (!(toolDep in allDeclared)) {
170
+ violations.push({
171
+ ruleId: "PKG-HEALTH-04",
172
+ packageName,
173
+ severity: "error",
174
+ message: `Script uses "${toolDep}" but it is not in devDependencies or dependencies. It relies on monorepo hoisting and will fail standalone.`,
175
+ file: pkgJsonPath,
176
+ fixHint: `Add "${toolDep}" to devDependencies in package.json.`,
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ const errors = violations.filter((v) => v.severity === "error");
183
+ const warnings = violations.filter((v) => v.severity === "warning");
184
+ const passed = errors.length === 0;
185
+
186
+ if (context.outputFormat === "pretty") {
187
+ if (packagesChecked === 0) {
188
+ logger.info("forge.package.health: no published packages found.");
189
+ } else {
190
+ for (const v of violations) {
191
+ if (v.severity === "error") {
192
+ logger.error(` [${v.ruleId}] ${v.packageName}: ${v.message}`);
193
+ } else {
194
+ logger.warn(` [${v.ruleId}] ${v.packageName}: ${v.message}`);
195
+ }
196
+ }
197
+ if (passed && warnings.length === 0) {
198
+ logger.success(
199
+ `forge.package.health: all ${packagesChecked} published package(s) healthy.`,
200
+ );
201
+ } else if (passed) {
202
+ logger.success(
203
+ `forge.package.health: ${packagesChecked} package(s) checked, ${warnings.length} warning(s).`,
204
+ );
205
+ } else {
206
+ logger.error(
207
+ `forge.package.health: ${errors.length} error(s), ${warnings.length} warning(s) across ${packagesChecked} package(s).`,
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ return {
214
+ data: {
215
+ command: "forge.package.health",
216
+ packagesChecked,
217
+ violations,
218
+ passed,
219
+ },
220
+ exitCode: passed ? 0 : 1,
221
+ summary: passed
222
+ ? warnings.length > 0
223
+ ? `All ${packagesChecked} package(s) passed with ${warnings.length} warning(s).`
224
+ : `All ${packagesChecked} package(s) healthy.`
225
+ : `${errors.length} error(s) found across ${packagesChecked} package(s).`,
226
+ nextSteps: errors.map((e) => ({
227
+ action: e.fixHint ?? e.message,
228
+ kind: "required" as const,
229
+ })),
230
+ };
231
+ }