@warpgogol/forge 2.8.1 → 2.9.0
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.
- package/dist/os/session/handlers/validate.d.ts.map +1 -1
- package/dist/os/session/handlers/validate.js +23 -1
- package/dist/os/session/handlers/validate.js.map +1 -1
- package/dist/os/session/types.d.ts +31 -1
- package/dist/os/session/types.d.ts.map +1 -1
- package/dist/os/session/types.js +7 -0
- package/dist/os/session/types.js.map +1 -1
- package/package.json +2 -1
- package/profiles/knowledge-typescript-turborepo.yaml +3 -0
- package/src/config/__tests__/resolve-terminology.test.ts +45 -0
- package/src/knowledge/__tests__/promote.test.ts +219 -0
- package/src/knowledge/__tests__/serialize.test.ts +192 -0
- package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
- package/src/migration-adapters/__tests__/registry.test.ts +131 -0
- package/src/migration-adapters/__tests__/types.test.ts +86 -0
- package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
- package/src/profiles/__tests__/profile-schema.test.ts +228 -0
- package/src/profiles/__tests__/stack-profile.test.ts +190 -0
- package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
- package/src/tests/profile-schema.test.ts +1 -1
- package/src/tests/scaffold-project.test.ts +2 -2
- package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
- package/src/utils/__tests__/fs-trash.test.ts +45 -0
- package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
|
@@ -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
|
+
});
|
|
@@ -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(
|
|
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:
|
|
@@ -90,7 +90,7 @@ test("forge.scaffold derives name from folder when --name omitted", async () =>
|
|
|
90
90
|
test("forge.scaffold fails on non-kebab-case name", async () => {
|
|
91
91
|
const result = await runScaffoldProject(
|
|
92
92
|
{ argv: [], flags: { profile: "phaser-turborepo", name: "MySite" } },
|
|
93
|
-
makeContext(tempDir),
|
|
93
|
+
{ ...makeContext(tempDir), forgeRoot: FORGE_ROOT },
|
|
94
94
|
);
|
|
95
95
|
expect(result.exitCode).toBe(1);
|
|
96
96
|
expect(result.data?.errors[0]).toContain("kebab-case");
|
|
@@ -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
|
+
});
|