@warpgogol/forge 2.8.1 → 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.
@@ -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
+ });
@@ -24,13 +24,16 @@ function readPackageFiles(): string[] {
24
24
 
25
25
  test("package.json files array includes src/onboarding/templates/", () => {
26
26
  const files = readPackageFiles();
27
- expect(files).toContain("src/onboarding/templates/");
27
+ const hasTemplates = files.some(
28
+ (f) => f === "src/onboarding/templates/" || f === "src/onboarding/templates/*" || f === "src/",
29
+ );
30
+ expect(hasTemplates).toBe(true);
28
31
  });
29
32
 
30
33
  test("all template files in src/onboarding/templates/ are covered by files array", () => {
31
34
  const files = readPackageFiles();
32
35
  const hasTemplatesGlob = files.some(
33
- (f) => f === "src/onboarding/templates/" || f === "src/onboarding/templates/*",
36
+ (f) => f === "src/onboarding/templates/" || f === "src/onboarding/templates/*" || f === "src/",
34
37
  );
35
38
  expect(hasTemplatesGlob).toBe(true);
36
39
  });
@@ -157,7 +157,7 @@ test("artifacts with missing required extensions field fails", () => {
157
157
 
158
158
  test("all shipped profiles parse without changes", () => {
159
159
  const profiles = listStackProfiles(FORGE_ROOT);
160
- expect(profiles.length).toBe(3);
160
+ expect(profiles.length).toBe(4);
161
161
  for (const profile of profiles) {
162
162
  expect(profile.id).toBeDefined();
163
163
  expect(profile.workspace.dirs.length).toBeGreaterThan(0);
@@ -74,7 +74,7 @@ test("forge.scaffold fails on missing --profile", async () => {
74
74
  test("forge.scaffold derives name from folder when --name omitted", async () => {
75
75
  const result = await runScaffoldProject(
76
76
  { argv: [], flags: { profile: "phaser-turborepo" } },
77
- makeContext(tempDir),
77
+ { ...makeContext(tempDir), forgeRoot: FORGE_ROOT },
78
78
  );
79
79
  // Name is now derived from the folder name (consistent with forge.create)
80
80
  // The temp dir name may not be kebab-case, so we accept either:
@@ -162,6 +162,8 @@ test("listStackProfiles finds all shipped profiles", () => {
162
162
  test("all shipped profiles include @warpgogol/forge in install steps or package.json template", () => {
163
163
  const profiles = listStackProfiles(FORGE_ROOT);
164
164
  for (const profile of profiles) {
165
+ // knowledge-typescript-turborepo uses @warpgogol/werkstatt-knowledge plugin, not forge
166
+ if (profile.id === "knowledge-typescript-turborepo") continue;
165
167
  const hasForgeInInstall = profile.install.some((cmd) => cmd.includes("@warpgogol/forge"));
166
168
  const pkgFile = profile.workspace.files.find((f) => f.path === "package.json");
167
169
  const hasForgeInPkg = pkgFile?.content.includes("@warpgogol/forge") ?? false;
@@ -172,6 +174,8 @@ test("all shipped profiles include @warpgogol/forge in install steps or package.
172
174
  test("all shipped profiles include operator-profile.md in .gitignore content", () => {
173
175
  const profiles = listStackProfiles(FORGE_ROOT);
174
176
  for (const profile of profiles) {
177
+ // knowledge-typescript-turborepo uses a different plugin and gitignore template
178
+ if (profile.id === "knowledge-typescript-turborepo") continue;
175
179
  const gitignoreFile = profile.workspace.files.find((f) => f.path === ".gitignore");
176
180
  expect(gitignoreFile).toBeDefined();
177
181
  expect(gitignoreFile?.content).toContain("operator-profile.md");
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { writeFileIfChanged } from "../fs-idempotent.ts";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, rm, readFile, writeFile, mkdir } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+
7
+ let tempDir: string;
8
+
9
+ beforeEach(async () => {
10
+ tempDir = await mkdtemp(join(tmpdir(), "forge-idempotent-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("writeFileIfChanged", () => {
18
+ it("writes new file and returns 'written'", async () => {
19
+ const filePath = join(tempDir, "new.txt");
20
+ const result = await writeFileIfChanged(filePath, "hello");
21
+ expect(result).toBe("written");
22
+ expect(await readFile(filePath, "utf8")).toBe("hello");
23
+ });
24
+
25
+ it("returns 'unchanged' when content is identical", async () => {
26
+ const filePath = join(tempDir, "same.txt");
27
+ await writeFile(filePath, "same content", "utf8");
28
+ const result = await writeFileIfChanged(filePath, "same content");
29
+ expect(result).toBe("unchanged");
30
+ });
31
+
32
+ it("writes when string content differs", async () => {
33
+ const filePath = join(tempDir, "change.txt");
34
+ await writeFile(filePath, "old", "utf8");
35
+ const result = await writeFileIfChanged(filePath, "new");
36
+ expect(result).toBe("written");
37
+ expect(await readFile(filePath, "utf8")).toBe("new");
38
+ });
39
+
40
+ it("writes to nested paths when parent dirs exist", async () => {
41
+ await mkdir(join(tempDir, "sub", "dir"), { recursive: true });
42
+ const filePath = join(tempDir, "sub", "dir", "file.txt");
43
+ const result = await writeFileIfChanged(filePath, "nested");
44
+ expect(result).toBe("written");
45
+ expect(await readFile(filePath, "utf8")).toBe("nested");
46
+ });
47
+
48
+ it("handles Uint8Array content", async () => {
49
+ const filePath = join(tempDir, "binary.bin");
50
+ const data = new Uint8Array([1, 2, 3, 4, 5]);
51
+ const result = await writeFileIfChanged(filePath, data);
52
+ expect(result).toBe("written");
53
+ const buf = await readFile(filePath);
54
+ expect(new Uint8Array(buf)).toEqual(data);
55
+ });
56
+
57
+ it("returns 'unchanged' for identical Uint8Array content", async () => {
58
+ const filePath = join(tempDir, "binary.bin");
59
+ const data = new Uint8Array([10, 20, 30]);
60
+ await writeFileIfChanged(filePath, data);
61
+ const result = await writeFileIfChanged(filePath, data);
62
+ expect(result).toBe("unchanged");
63
+ });
64
+
65
+ it("writes when Uint8Array content differs", async () => {
66
+ const filePath = join(tempDir, "binary.bin");
67
+ await writeFileIfChanged(filePath, new Uint8Array([1, 2, 3]));
68
+ const result = await writeFileIfChanged(filePath, new Uint8Array([4, 5, 6]));
69
+ expect(result).toBe("written");
70
+ const buf = await readFile(filePath);
71
+ expect(new Uint8Array(buf)).toEqual(new Uint8Array([4, 5, 6]));
72
+ });
73
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { trashPath } from "../fs-trash.ts";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { existsSync } from "node:fs";
7
+
8
+ let tempDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await mkdtemp(join(tmpdir(), "forge-trash-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(tempDir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("trashPath", () => {
19
+ it("does not throw when path does not exist", async () => {
20
+ const nonExistent = join(tempDir, "nope.txt");
21
+ expect(existsSync(nonExistent)).toBe(false);
22
+ await expect(trashPath(nonExistent)).resolves.toBeUndefined();
23
+ });
24
+
25
+ it("trashes a file", async () => {
26
+ const filePath = join(tempDir, "to-trash.txt");
27
+ await writeFile(filePath, "content", "utf8");
28
+ expect(existsSync(filePath)).toBe(true);
29
+
30
+ await trashPath(filePath);
31
+
32
+ expect(existsSync(filePath)).toBe(false);
33
+ });
34
+
35
+ it("trashes a directory", async () => {
36
+ const dirPath = join(tempDir, "to-trash-dir");
37
+ await mkdir(dirPath, { recursive: true });
38
+ await writeFile(join(dirPath, "inner.txt"), "inner", "utf8");
39
+ expect(existsSync(dirPath)).toBe(true);
40
+
41
+ await trashPath(dirPath);
42
+
43
+ expect(existsSync(dirPath)).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { runNoteOrphanDetect } from "../note-orphan-detect.ts";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+
7
+ let tempDir: string;
8
+
9
+ beforeEach(async () => {
10
+ tempDir = await mkdtemp(join(tmpdir(), "forge-orphan-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ async function createVaultFile(vaultDir: string, relPath: string, content: string) {
18
+ const fullPath = join(vaultDir, relPath);
19
+ const dir = join(fullPath, "..");
20
+ await mkdir(dir, { recursive: true });
21
+ await writeFile(fullPath, content, "utf8");
22
+ }
23
+
24
+ describe("runNoteOrphanDetect", () => {
25
+ it("returns empty when vault directory not found", () => {
26
+ const result = runNoteOrphanDetect(
27
+ { flags: { "vault-dir": "nonexistent" } },
28
+ { workspaceRoot: tempDir },
29
+ );
30
+ expect(result.data?.count).toBe(0);
31
+ expect(result.data?.orphans).toEqual([]);
32
+ expect(result.exitCode).toBe(0);
33
+ });
34
+
35
+ it("detects orphan note with zero inbound links", async () => {
36
+ const vaultDir = join(tempDir, "vault");
37
+ await mkdir(vaultDir, { recursive: true });
38
+ await createVaultFile(vaultDir, "linked.md", "# Linked\n\n[[target]]");
39
+ await createVaultFile(vaultDir, "target.md", "# Target");
40
+ await createVaultFile(vaultDir, "orphan.md", "# Orphan — nobody links to me");
41
+
42
+ const result = runNoteOrphanDetect(
43
+ { flags: { "vault-dir": "vault" } },
44
+ { workspaceRoot: tempDir },
45
+ );
46
+
47
+ const orphanFiles = result.data?.orphans.map((o) => o.file) ?? [];
48
+ expect(orphanFiles).toContain("vault/orphan.md");
49
+ expect(orphanFiles).not.toContain("vault/target.md");
50
+ });
51
+
52
+ it("does not flag notes that are linked via alias", async () => {
53
+ const vaultDir = join(tempDir, "vault");
54
+ await mkdir(vaultDir, { recursive: true });
55
+ await createVaultFile(vaultDir, "note.md", "---\naliases: [MyAlias]\n---\n# Note");
56
+ await createVaultFile(vaultDir, "linker.md", "# Linker\n\n[[MyAlias]]");
57
+
58
+ const result = runNoteOrphanDetect(
59
+ { flags: { "vault-dir": "vault" } },
60
+ { workspaceRoot: tempDir },
61
+ );
62
+
63
+ const orphanFiles = result.data?.orphans.map((o) => o.file) ?? [];
64
+ expect(orphanFiles).not.toContain("note.md");
65
+ });
66
+
67
+ it("counts self-links correctly (self-link does not count as inbound)", async () => {
68
+ const vaultDir = join(tempDir, "vault");
69
+ await mkdir(vaultDir, { recursive: true });
70
+ await createVaultFile(vaultDir, "self.md", "# Self\n\n[[self]]");
71
+
72
+ const result = runNoteOrphanDetect(
73
+ { flags: { "vault-dir": "vault" } },
74
+ { workspaceRoot: tempDir },
75
+ );
76
+
77
+ const orphanFiles = result.data?.orphans.map((o) => o.file) ?? [];
78
+ expect(orphanFiles).toContain("vault/self.md");
79
+ });
80
+
81
+ it("always returns exitCode 0 (warnings, not errors)", async () => {
82
+ const vaultDir = join(tempDir, "vault");
83
+ await mkdir(vaultDir, { recursive: true });
84
+ await createVaultFile(vaultDir, "orphan.md", "# Orphan");
85
+
86
+ const result = runNoteOrphanDetect(
87
+ { flags: { "vault-dir": "vault" } },
88
+ { workspaceRoot: tempDir },
89
+ );
90
+
91
+ expect(result.exitCode).toBe(0);
92
+ expect(result.data?.count).toBeGreaterThan(0);
93
+ });
94
+ });