@warpgogol/forge 5.1.1 → 5.1.3
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/AGENTS.md +21 -2
- package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
- package/dist/os/adr/handlers/implement-stamp.js +32 -2
- package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
- package/dist/os/adr/types.d.ts +2 -1
- package/dist/os/adr/types.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +20 -5
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
- package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
- package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-migrate.js +1 -1
- package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
- package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
- package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
- package/dist/os/core/handlers/pinned-validate.js +26 -6
- package/dist/os/core/handlers/pinned-validate.js.map +1 -1
- package/dist/os/naming/naming-convention.d.ts.map +1 -1
- package/dist/os/naming/naming-convention.js +2 -0
- package/dist/os/naming/naming-convention.js.map +1 -1
- package/dist/os/rfc/frontmatter-io.d.ts.map +1 -1
- package/dist/os/rfc/frontmatter-io.js +1 -0
- package/dist/os/rfc/frontmatter-io.js.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.d.ts.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.js +6 -1
- package/dist/os/rfc/handlers/validate-rules.js.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.js +4 -3
- package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +8 -1
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +52 -5
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/create.d.ts.map +1 -1
- package/dist/src/onboarding/create.js +9 -10
- package/dist/src/onboarding/create.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +80 -0
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts +1 -0
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +11 -0
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/npm-token-check.d.ts +40 -0
- package/dist/src/onboarding/npm-token-check.d.ts.map +1 -0
- package/dist/src/onboarding/npm-token-check.js +104 -0
- package/dist/src/onboarding/npm-token-check.js.map +1 -0
- package/dist/src/onboarding/scaffold-project.d.ts.map +1 -1
- package/dist/src/onboarding/scaffold-project.js +10 -0
- package/dist/src/onboarding/scaffold-project.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +4 -2
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/profiles/profile-schema.d.ts +2 -0
- package/dist/src/profiles/profile-schema.d.ts.map +1 -1
- package/dist/src/profiles/profile-schema.js +1 -0
- package/dist/src/profiles/profile-schema.js.map +1 -1
- package/dist/src/profiles/stack-profile.d.ts +1 -0
- package/dist/src/profiles/stack-profile.d.ts.map +1 -1
- package/dist/src/profiles/stack-profile.js +2 -0
- package/dist/src/profiles/stack-profile.js.map +1 -1
- package/os/adr/handlers/implement-stamp.ts +34 -2
- package/os/adr/types.ts +3 -1
- package/os/compass/handlers/compass-audit-handler.ts +3 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory-handler.ts +1 -0
- package/os/compass/handlers/compass-inventory.ts +20 -6
- package/os/compass/handlers/compass-migrate-handler.ts +1 -0
- package/os/compass/handlers/compass-migrate.ts +9 -2
- package/os/compass/handlers/resolve-scan-root.ts +1 -1
- package/os/core/handlers/pinned-validate.ts +33 -7
- package/os/naming/naming-convention.ts +2 -0
- package/os/rfc/frontmatter-io.ts +1 -0
- package/os/rfc/handlers/validate-rules.ts +8 -1
- package/os/werkstatt/handlers/workspace-deps.ts +4 -3
- package/package.json +1 -1
- package/profiles/site-workshop.yaml +654 -0
- package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +1 -1
- package/src/config/forge-config.ts +59 -5
- package/src/onboarding/create.ts +9 -9
- package/src/onboarding/doctor-token-check.test.ts +124 -0
- package/src/onboarding/doctor.ts +87 -0
- package/src/onboarding/init.ts +20 -1
- package/src/onboarding/npm-token-check.ts +140 -0
- package/src/onboarding/scaffold-project.ts +11 -0
- package/src/onboarding/site-workshop-profile.test.ts +84 -0
- package/src/onboarding/upgrade.ts +4 -1
- package/src/profiles/profile-schema.ts +2 -0
- package/src/profiles/stack-profile.ts +2 -0
- package/src/tests/adr-implement-stamp.test.ts +150 -0
- package/src/tests/doctor-profile-id-known.test.ts +237 -0
- package/src/tests/pinned-validate.test.ts +134 -0
- package/src/tests/profile-schema.test.ts +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for the site-workshop stack profile (RFC-1125) — verifies the profile parses, scaffolds the workshop contract, and never scaffolds systems-cache inside the workshop root.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1125: initial site-workshop profile tests (AC-1 evidence).</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect } from "vitest";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { listStackProfiles } from "../profiles/stack-profile.ts";
|
|
13
|
+
|
|
14
|
+
// Resolve forge root from this test file's location
|
|
15
|
+
const FORGE_ROOT = join(import.meta.dirname, "..", "..");
|
|
16
|
+
|
|
17
|
+
function loadSiteWorkshop() {
|
|
18
|
+
const profiles = listStackProfiles(FORGE_ROOT);
|
|
19
|
+
const profile = profiles.find((p) => p.id === "site-workshop");
|
|
20
|
+
expect(profile, "site-workshop profile must exist in packages/forge/profiles/").toBeDefined();
|
|
21
|
+
return profile!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("site-workshop profile parses and is discoverable", () => {
|
|
25
|
+
const profile = loadSiteWorkshop();
|
|
26
|
+
expect(profile.schema).toBe("forge/stack-profile@1");
|
|
27
|
+
expect(profile.displayName).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("scaffolds kernel wiring, missions/, and governance dirs — but never systems-cache", () => {
|
|
31
|
+
const profile = loadSiteWorkshop();
|
|
32
|
+
const dirs = profile.workspace.dirs;
|
|
33
|
+
const filePaths = profile.workspace.files.map((f) => f.path);
|
|
34
|
+
|
|
35
|
+
// AC-1: kernel wiring + missions + governance dirs present
|
|
36
|
+
expect(dirs).toContain("missions");
|
|
37
|
+
expect(dirs).toContain("docs");
|
|
38
|
+
expect(dirs).toContain("tools");
|
|
39
|
+
expect(filePaths).toContain("tools/kernel.config.ts");
|
|
40
|
+
expect(filePaths).toContain("missions/.gitkeep");
|
|
41
|
+
|
|
42
|
+
// AC-1: systems-cache is a sibling (../systems-cache), never scaffolded inside root
|
|
43
|
+
expect(
|
|
44
|
+
dirs,
|
|
45
|
+
"systems-cache must not be a scaffolded dir — sibling convention per RFC-1125",
|
|
46
|
+
).not.toContain("systems-cache");
|
|
47
|
+
expect(
|
|
48
|
+
filePaths.some((p) => p.startsWith("systems-cache")),
|
|
49
|
+
"no systems-cache files may be scaffolded — created by sternsystem.register on demand",
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("scaffolded kernel.config.ts consumes the single-sourced WorkshopModuleMap", () => {
|
|
54
|
+
const profile = loadSiteWorkshop();
|
|
55
|
+
const kernelConfig = profile.workspace.files.find((f) => f.path === "tools/kernel.config.ts");
|
|
56
|
+
expect(kernelConfig, "profile must scaffold tools/kernel.config.ts").toBeDefined();
|
|
57
|
+
// AC-5: the scaffolded config spreads workshopModuleLoaders() — no hand-copied loader list
|
|
58
|
+
expect(kernelConfig!.content).toContain("workshopModuleLoaders");
|
|
59
|
+
expect(kernelConfig!.content).toContain("@warpgogol/werkstatt-engine/kernel/module-map");
|
|
60
|
+
expect(
|
|
61
|
+
kernelConfig!.content,
|
|
62
|
+
"kernel.config.ts must not embed per-module loader thunks — they live in WORKSHOP_MODULE_MAP",
|
|
63
|
+
).not.toContain("createMissionModule");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("install set includes engine, site plugin, and forge", () => {
|
|
67
|
+
const profile = loadSiteWorkshop();
|
|
68
|
+
const install = profile.install.join(" ");
|
|
69
|
+
expect(install).toContain("@warpgogol/werkstatt-engine");
|
|
70
|
+
expect(install).toContain("@warpgogol/werkstatt-site");
|
|
71
|
+
expect(install).toContain("@warpgogol/forge");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("ships .npmrc token template and site-workshop nextSteps guidance", () => {
|
|
75
|
+
const profile = loadSiteWorkshop();
|
|
76
|
+
const npmrc = profile.workspace.files.find((f) => f.path === ".npmrc");
|
|
77
|
+
expect(npmrc, "profile must scaffold .npmrc for the private @warpgogol scope").toBeDefined();
|
|
78
|
+
expect(npmrc!.content).toContain("@warpgogol:registry");
|
|
79
|
+
|
|
80
|
+
// RFC-1125: token lifecycle + identity.bootstrap guidance lives in NEXT_STEPS.md
|
|
81
|
+
expect(profile.nextSteps, "profile must provide nextSteps for token/identity onboarding").toBeTruthy();
|
|
82
|
+
expect(profile.nextSteps).toContain("NPM_TOKEN");
|
|
83
|
+
expect(profile.nextSteps).toContain("identity.bootstrap");
|
|
84
|
+
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
FORGE_CLI_BINDING_DEFAULTS,
|
|
33
33
|
resolveForgeRoot,
|
|
34
34
|
loadForgeConfig,
|
|
35
|
+
serializeForgeConfig,
|
|
35
36
|
resolvePmRunner,
|
|
36
37
|
resolvePmInstall,
|
|
37
38
|
type ForgeConfig,
|
|
@@ -311,8 +312,10 @@ function updateSyncedVersion(
|
|
|
311
312
|
config.forge = { syncedVersion: version };
|
|
312
313
|
|
|
313
314
|
// Write the full config back to forge.yaml
|
|
315
|
+
// RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
|
|
316
|
+
// never the resolved StackProfile object, never drops an unresolvable id.
|
|
314
317
|
const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
|
|
315
|
-
fs.writeFileSync(forgeYamlPath, stringifyYaml(config), "utf8");
|
|
318
|
+
fs.writeFileSync(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)), "utf8");
|
|
316
319
|
}
|
|
317
320
|
|
|
318
321
|
export async function runUpgrade(
|
|
@@ -363,6 +363,7 @@ export const stackProfileDomainFieldsSchema = z.object({
|
|
|
363
363
|
templates: z.array(profileTemplateSchema).optional(),
|
|
364
364
|
scriptDir: z.string().min(1).optional(),
|
|
365
365
|
rootAgentsMdTemplate: z.string().optional(),
|
|
366
|
+
nextSteps: z.string().optional(),
|
|
366
367
|
compass: compassPolicyOverridesSchema.optional(),
|
|
367
368
|
});
|
|
368
369
|
|
|
@@ -380,5 +381,6 @@ export interface StackProfileDomainFields {
|
|
|
380
381
|
templates?: ProfileTemplate[];
|
|
381
382
|
scriptDir?: string;
|
|
382
383
|
rootAgentsMdTemplate?: string;
|
|
384
|
+
nextSteps?: string;
|
|
383
385
|
compass?: CompassPolicyOverrides;
|
|
384
386
|
}
|
|
@@ -80,6 +80,8 @@ export const stackProfileSchema = z.object({
|
|
|
80
80
|
scriptDir: stackProfileDomainFieldsSchema.shape.scriptDir,
|
|
81
81
|
// Profile-driven root AGENTS.md template
|
|
82
82
|
rootAgentsMdTemplate: stackProfileDomainFieldsSchema.shape.rootAgentsMdTemplate,
|
|
83
|
+
// RFC-1125: profile-driven NEXT_STEPS.md content override
|
|
84
|
+
nextSteps: stackProfileDomainFieldsSchema.shape.nextSteps,
|
|
83
85
|
// Profile-driven Compass file extensions
|
|
84
86
|
compass: stackProfileDomainFieldsSchema.shape.compass,
|
|
85
87
|
});
|
|
@@ -70,6 +70,48 @@ Test consequences.
|
|
|
70
70
|
Test evolution.
|
|
71
71
|
`;
|
|
72
72
|
|
|
73
|
+
const ADR_BODY_WITH_AC = (
|
|
74
|
+
id: string,
|
|
75
|
+
status: string,
|
|
76
|
+
createdAt: string,
|
|
77
|
+
acBlock: string,
|
|
78
|
+
): string => `---
|
|
79
|
+
id: ${id}
|
|
80
|
+
title: "Test ADR"
|
|
81
|
+
status: ${status}
|
|
82
|
+
scope: workspace
|
|
83
|
+
decider: human:test
|
|
84
|
+
createdAt: ${createdAt}
|
|
85
|
+
updatedAt: ${createdAt}
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# ${id}: Test ADR
|
|
89
|
+
|
|
90
|
+
## Context
|
|
91
|
+
|
|
92
|
+
Test context.
|
|
93
|
+
|
|
94
|
+
## Decision
|
|
95
|
+
|
|
96
|
+
Test decision.
|
|
97
|
+
|
|
98
|
+
## Justification
|
|
99
|
+
|
|
100
|
+
Test justification.
|
|
101
|
+
|
|
102
|
+
## Consequences
|
|
103
|
+
|
|
104
|
+
Test consequences.
|
|
105
|
+
|
|
106
|
+
## Evolution
|
|
107
|
+
|
|
108
|
+
Test evolution.
|
|
109
|
+
|
|
110
|
+
## Acceptance criteria
|
|
111
|
+
|
|
112
|
+
${acBlock}
|
|
113
|
+
`;
|
|
114
|
+
|
|
73
115
|
async function makeGitRepo(dir: string): Promise<void> {
|
|
74
116
|
execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
|
|
75
117
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
|
|
@@ -295,6 +337,114 @@ describe("adr.implement.stamp", () => {
|
|
|
295
337
|
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
|
|
296
338
|
});
|
|
297
339
|
|
|
340
|
+
// ── ADR-IMP-02: acceptance criteria completeness (post-cutoff, mirrors AV-17) ──
|
|
341
|
+
|
|
342
|
+
it("rejects post-cutoff ADR with unchecked acceptance criteria", async () => {
|
|
343
|
+
await writeFile(
|
|
344
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
345
|
+
ADR_BODY_WITH_AC(
|
|
346
|
+
"ADR-0001",
|
|
347
|
+
"accepted",
|
|
348
|
+
"2026-09-10",
|
|
349
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
353
|
+
|
|
354
|
+
const result = await runAdrImplementStamp(
|
|
355
|
+
makeInput("ADR-0001", sha),
|
|
356
|
+
makeContext(workspaceRoot),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(result.data?.status).toBe("fail");
|
|
360
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
361
|
+
expect(result.exitCode).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("rejects post-cutoff ADR with checked criteria lacking evidence annotation", async () => {
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
367
|
+
ADR_BODY_WITH_AC(
|
|
368
|
+
"ADR-0001",
|
|
369
|
+
"accepted",
|
|
370
|
+
"2026-09-10",
|
|
371
|
+
"- [x] AC-1: THE artifact SHALL exist",
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
375
|
+
|
|
376
|
+
const result = await runAdrImplementStamp(
|
|
377
|
+
makeInput("ADR-0001", sha),
|
|
378
|
+
makeContext(workspaceRoot),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(result.data?.status).toBe("fail");
|
|
382
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("stamps post-cutoff ADR when all criteria are checked with evidence", async () => {
|
|
386
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
387
|
+
await writeFile(
|
|
388
|
+
adrPath,
|
|
389
|
+
ADR_BODY_WITH_AC(
|
|
390
|
+
"ADR-0001",
|
|
391
|
+
"accepted",
|
|
392
|
+
"2026-09-10",
|
|
393
|
+
"- [x] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
397
|
+
|
|
398
|
+
const result = await runAdrImplementStamp(
|
|
399
|
+
makeInput("ADR-0001", sha),
|
|
400
|
+
makeContext(workspaceRoot),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(result.data?.status).toBe("pass");
|
|
404
|
+
expect(result.data?.data?.criteriaChecked).toBe(2);
|
|
405
|
+
|
|
406
|
+
const afterContent = await readFile(adrPath, "utf-8");
|
|
407
|
+
expect(afterContent).toContain("status: implemented");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("stamps pre-cutoff ADR with unchecked criteria (AV-17 exempt)", async () => {
|
|
411
|
+
await writeFile(
|
|
412
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
413
|
+
ADR_BODY_WITH_AC(
|
|
414
|
+
"ADR-0001",
|
|
415
|
+
"accepted",
|
|
416
|
+
"2026-08-01",
|
|
417
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
421
|
+
|
|
422
|
+
const result = await runAdrImplementStamp(
|
|
423
|
+
makeInput("ADR-0001", sha),
|
|
424
|
+
makeContext(workspaceRoot),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(result.data?.status).toBe("pass");
|
|
428
|
+
expect(result.data?.data?.criteriaChecked).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("stamps post-cutoff ADR without an acceptance criteria section", async () => {
|
|
432
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
433
|
+
const body = ADR_BODY("ADR-0001", "accepted").replace(
|
|
434
|
+
"createdAt: 2026-08-01",
|
|
435
|
+
"createdAt: 2026-09-10",
|
|
436
|
+
);
|
|
437
|
+
await writeFile(adrPath, body);
|
|
438
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
439
|
+
|
|
440
|
+
const result = await runAdrImplementStamp(
|
|
441
|
+
makeInput("ADR-0001", sha),
|
|
442
|
+
makeContext(workspaceRoot),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(result.data?.status).toBe("pass");
|
|
446
|
+
});
|
|
447
|
+
|
|
298
448
|
// ── Dry-run ──────────────────────────────────────────────────────────────────
|
|
299
449
|
|
|
300
450
|
it("dry-run checks preconditions without mutating file", async () => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for RFC-1118 fail-closed handling of unresolvable forge.yaml profile id — profile-id-known doctor check, declared-id preservation, serializeForgeConfig, init re-detect guard.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1118: initial tests — AC-1..AC-8 plus corrupted-object recovery.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { runDoctor } from "../onboarding/doctor.ts";
|
|
16
|
+
import { runUpgrade } from "../onboarding/upgrade.ts";
|
|
17
|
+
import { runInit } from "../onboarding/init.ts";
|
|
18
|
+
import { loadForgeConfig, serializeForgeConfig } from "../config/forge-config.ts";
|
|
19
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../types.ts";
|
|
20
|
+
|
|
21
|
+
let tempDir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tempDir = await mkdtemp(join(tmpdir(), "doctor-profile-id-known-"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeContext(): ForgeRuntimeContext {
|
|
32
|
+
return {
|
|
33
|
+
workspaceRoot: tempDir,
|
|
34
|
+
logger: {
|
|
35
|
+
section: () => {},
|
|
36
|
+
success: () => {},
|
|
37
|
+
warn: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
info: () => {},
|
|
40
|
+
},
|
|
41
|
+
dryRun: false,
|
|
42
|
+
outputFormat: "json",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FORGE_YAML_BASE = `schema: "forge/config@1"
|
|
47
|
+
project:
|
|
48
|
+
name: test-project
|
|
49
|
+
stack: []
|
|
50
|
+
packageManager: pnpm
|
|
51
|
+
paths:
|
|
52
|
+
rfcsDir: docs/rfcs
|
|
53
|
+
adrsDir: docs/adrs
|
|
54
|
+
plansDir: docs/plans
|
|
55
|
+
auditsDir: docs/audits
|
|
56
|
+
specsDir: docs/specs
|
|
57
|
+
skillsDir: .agents/skills
|
|
58
|
+
sessionsDir: docs/sessions
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
async function writeForgeYaml(extra = ""): Promise<void> {
|
|
62
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML_BASE + extra, "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function minimalProfile(id: string, marker: string): string {
|
|
66
|
+
return `schema: forge/stack-profile@1
|
|
67
|
+
id: ${id}
|
|
68
|
+
displayName: ${id} profile
|
|
69
|
+
detect:
|
|
70
|
+
anyOf:
|
|
71
|
+
- ${marker}
|
|
72
|
+
workspace:
|
|
73
|
+
dirs:
|
|
74
|
+
- docs
|
|
75
|
+
files: []
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fake installed forge: packages/forge/package.json + profiles/<id>.yaml.
|
|
80
|
+
// resolveForgeRoot(workspaceRoot) picks it up via the monorepo path.
|
|
81
|
+
async function writeFakeForgeRoot(
|
|
82
|
+
version: string,
|
|
83
|
+
profiles: Array<{ id: string; marker: string }>,
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const forgeRoot = join(tempDir, "packages", "forge");
|
|
86
|
+
await mkdir(join(forgeRoot, "profiles"), { recursive: true });
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(forgeRoot, "package.json"),
|
|
89
|
+
JSON.stringify({ name: "@warpgogol/forge", version }) + "\n",
|
|
90
|
+
"utf8",
|
|
91
|
+
);
|
|
92
|
+
for (const p of profiles) {
|
|
93
|
+
await writeFile(
|
|
94
|
+
join(forgeRoot, "profiles", `${p.id}.yaml`),
|
|
95
|
+
minimalProfile(p.id, p.marker),
|
|
96
|
+
"utf8",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return forgeRoot;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findCheck(result: Awaited<ReturnType<typeof runDoctor>>, name: string) {
|
|
103
|
+
return result.data?.checks.find((c) => c.name === name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// AC-1: unresolvable declared id → profile-id-known fails, names id + version
|
|
107
|
+
test("doctor fails profile-id-known when declared profile id is absent from installed catalog", async () => {
|
|
108
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
109
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
110
|
+
|
|
111
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
112
|
+
const result = await runDoctor(input, makeContext());
|
|
113
|
+
|
|
114
|
+
const check = findCheck(result, "profile-id-known");
|
|
115
|
+
expect(check, "profile-id-known check must exist in doctor output").toBeDefined();
|
|
116
|
+
expect(check!.status).toBe("fail");
|
|
117
|
+
expect(check!.message).toContain("bogus-id");
|
|
118
|
+
expect(check!.message).toContain("9.9.9");
|
|
119
|
+
expect(result.exitCode).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// AC-2: ForgeConfig exposes profileDeclaredId + profileResolution
|
|
123
|
+
test("loadForgeConfig exposes profileDeclaredId and profileResolution", async () => {
|
|
124
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
125
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
126
|
+
|
|
127
|
+
const config = loadForgeConfig(tempDir);
|
|
128
|
+
expect(config.profileDeclaredId).toBe("bogus-id");
|
|
129
|
+
expect(config.profileResolution).toBe("unknown");
|
|
130
|
+
expect(config.profile).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// AC-3: no profile field → pass
|
|
134
|
+
test("doctor passes profile-id-known when no profile is declared", async () => {
|
|
135
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
136
|
+
await writeForgeYaml();
|
|
137
|
+
|
|
138
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
139
|
+
const result = await runDoctor(input, makeContext());
|
|
140
|
+
|
|
141
|
+
const check = findCheck(result, "profile-id-known");
|
|
142
|
+
expect(check!.status).toBe("pass");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// AC-4: unresolvable id survives re-serialization verbatim
|
|
146
|
+
test("forge upgrade preserves an unresolvable declared profile id verbatim", async () => {
|
|
147
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
148
|
+
{ id: "known-profile", marker: "marker-known.txt" },
|
|
149
|
+
]);
|
|
150
|
+
await writeForgeYaml(`profile: bogus-id\nforge:\n syncedVersion: "0.0.0"\n`);
|
|
151
|
+
|
|
152
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
153
|
+
const ctx = { ...makeContext(), forgeRoot };
|
|
154
|
+
const result = await runUpgrade(input, ctx);
|
|
155
|
+
expect(result.exitCode).toBe(0);
|
|
156
|
+
|
|
157
|
+
const written = parseYaml(await readFile(join(tempDir, "forge.yaml"), "utf8")) as Record<
|
|
158
|
+
string,
|
|
159
|
+
unknown
|
|
160
|
+
>;
|
|
161
|
+
expect(
|
|
162
|
+
written["profile"],
|
|
163
|
+
"re-serialized forge.yaml must retain the declared id string, not drop it",
|
|
164
|
+
).toBe("bogus-id");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// AC-6: resolvable id serializes as the id string, not the StackProfile object
|
|
168
|
+
test("forge upgrade writes the profile id string, not the resolved object", async () => {
|
|
169
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
170
|
+
{ id: "known-profile", marker: "marker-known.txt" },
|
|
171
|
+
]);
|
|
172
|
+
await writeForgeYaml(`profile: known-profile\nforge:\n syncedVersion: "0.0.0"\n`);
|
|
173
|
+
|
|
174
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
175
|
+
const ctx = { ...makeContext(), forgeRoot };
|
|
176
|
+
const result = await runUpgrade(input, ctx);
|
|
177
|
+
expect(result.exitCode).toBe(0);
|
|
178
|
+
|
|
179
|
+
const written = parseYaml(await readFile(join(tempDir, "forge.yaml"), "utf8")) as Record<
|
|
180
|
+
string,
|
|
181
|
+
unknown
|
|
182
|
+
>;
|
|
183
|
+
expect(
|
|
184
|
+
typeof written["profile"],
|
|
185
|
+
"profile must serialize as the declared id string, not the StackProfile object",
|
|
186
|
+
).toBe("string");
|
|
187
|
+
expect(written["profile"]).toBe("known-profile");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// AC-7: unreadable catalog → warn, not fail
|
|
191
|
+
test("doctor warns (not fails) when the profile catalog cannot be read", async () => {
|
|
192
|
+
// No fake forge root — resolveForgeRoot falls back to a nonexistent path.
|
|
193
|
+
await writeForgeYaml(`profile: bogus-id\n`);
|
|
194
|
+
|
|
195
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
196
|
+
const result = await runDoctor(input, makeContext());
|
|
197
|
+
|
|
198
|
+
const check = findCheck(result, "profile-id-known");
|
|
199
|
+
expect(check!.status).toBe("warn");
|
|
200
|
+
expect(check!.message).toContain("bogus-id");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// AC-8: init --from never overwrites a declared profile id
|
|
204
|
+
test("forge init preserves a declared profile id against conflicting detection", async () => {
|
|
205
|
+
const forgeRoot = await writeFakeForgeRoot("9.9.9", [
|
|
206
|
+
{ id: "detected-y", marker: "marker-y.txt" },
|
|
207
|
+
]);
|
|
208
|
+
await writeForgeYaml(`profile: declared-x\n`);
|
|
209
|
+
|
|
210
|
+
// --from directory that detects as "detected-y"
|
|
211
|
+
const fromDir = join(tempDir, "from-src");
|
|
212
|
+
await mkdir(fromDir, { recursive: true });
|
|
213
|
+
await writeFile(join(fromDir, "marker-y.txt"), "x", "utf8");
|
|
214
|
+
|
|
215
|
+
const result = runInit({ flags: { from: fromDir } }, { workspaceRoot: tempDir, forgeRoot });
|
|
216
|
+
|
|
217
|
+
const written = await readFile(join(tempDir, "forge.yaml"), "utf8");
|
|
218
|
+
expect(written).toContain("profile: declared-x");
|
|
219
|
+
expect(
|
|
220
|
+
result.skipped.some((s) => s.includes("declared-x") && s.includes("detected-y")),
|
|
221
|
+
"init must surface a warning that the declared id was preserved over the detection result",
|
|
222
|
+
).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Corrupted state recovery: object-form profile (written by the old bug) heals to the declared id
|
|
226
|
+
test("loadForgeConfig recovers profileDeclaredId from a corrupted object-form profile", async () => {
|
|
227
|
+
await writeFakeForgeRoot("9.9.9", [{ id: "known-profile", marker: "marker-known.txt" }]);
|
|
228
|
+
await writeForgeYaml(`profile:\n id: known-profile\n displayName: known-profile profile\n`);
|
|
229
|
+
|
|
230
|
+
const config = loadForgeConfig(tempDir);
|
|
231
|
+
expect(config.profileDeclaredId).toBe("known-profile");
|
|
232
|
+
expect(config.profileResolution).toBe("resolved");
|
|
233
|
+
|
|
234
|
+
// Re-serialization heals the field back to the id string
|
|
235
|
+
const serialized = serializeForgeConfig(config);
|
|
236
|
+
expect(serialized["profile"]).toBe("known-profile");
|
|
237
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { runPinnedValidate } from "../../os/core/handlers/pinned-validate.ts";
|
|
8
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
async function makeTempDir(): Promise<string> {
|
|
13
|
+
return mkdtemp(join(tmpdir(), "forge-pinned-validate-test-"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
17
|
+
return {
|
|
18
|
+
workspaceRoot,
|
|
19
|
+
logger: {
|
|
20
|
+
section: () => {},
|
|
21
|
+
info: () => {},
|
|
22
|
+
warn: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
success: () => {},
|
|
25
|
+
},
|
|
26
|
+
dryRun: false,
|
|
27
|
+
outputFormat: "json",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeInput(): ForgeCommandInput {
|
|
32
|
+
return { argv: [], flags: {} };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function git(dir: string, args: string[]): Promise<void> {
|
|
36
|
+
await execFileAsync("git", args, { cwd: dir });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scaffold a git repo with a committed pinned.yaml protecting docs/rfcs/
|
|
41
|
+
* and one committed RFC file. Returns the repo dir.
|
|
42
|
+
*/
|
|
43
|
+
async function makeRepo(): Promise<string> {
|
|
44
|
+
const dir = await makeTempDir();
|
|
45
|
+
await git(dir, ["init"]);
|
|
46
|
+
await git(dir, ["config", "user.email", "test@example.com"]);
|
|
47
|
+
await git(dir, ["config", "user.name", "Test"]);
|
|
48
|
+
await mkdir(join(dir, ".forge"), { recursive: true });
|
|
49
|
+
await writeFile(
|
|
50
|
+
join(dir, ".forge", "pinned.yaml"),
|
|
51
|
+
"pinned:\n - path: docs/rfcs/\n mode: protect\n reason: RFC directory\n",
|
|
52
|
+
);
|
|
53
|
+
await mkdir(join(dir, "docs", "rfcs"), { recursive: true });
|
|
54
|
+
await writeFile(join(dir, "docs", "rfcs", "rfc-0001.md"), "# RFC 1\n");
|
|
55
|
+
await git(dir, ["add", "-A"]);
|
|
56
|
+
await git(dir, ["commit", "-m", "init"]);
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("runPinnedValidate", () => {
|
|
61
|
+
test("passes for intra-directory move inside a pinned dir (docs.archive)", async () => {
|
|
62
|
+
const dir = await makeRepo();
|
|
63
|
+
try {
|
|
64
|
+
// Simulate docs.archive: docs/rfcs/rfc-0001.md → docs/rfcs/archive/implemented/rfc-0001.md
|
|
65
|
+
await mkdir(join(dir, "docs", "rfcs", "archive", "implemented"), { recursive: true });
|
|
66
|
+
await git(dir, ["mv", "docs/rfcs/rfc-0001.md", "docs/rfcs/archive/implemented/rfc-0001.md"]);
|
|
67
|
+
|
|
68
|
+
const result = await runPinnedValidate(makeInput(), makeContext(dir));
|
|
69
|
+
expect(result.data?.status).toBe("pass");
|
|
70
|
+
expect(result.data?.violations).toHaveLength(0);
|
|
71
|
+
expect(result.exitCode).toBe(0);
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("flags move out of a pinned dir", async () => {
|
|
78
|
+
const dir = await makeRepo();
|
|
79
|
+
try {
|
|
80
|
+
await mkdir(join(dir, "docs", "other"), { recursive: true });
|
|
81
|
+
await git(dir, ["mv", "docs/rfcs/rfc-0001.md", "docs/other/rfc-0001.md"]);
|
|
82
|
+
|
|
83
|
+
const result = await runPinnedValidate(makeInput(), makeContext(dir));
|
|
84
|
+
expect(result.data?.status).toBe("fail");
|
|
85
|
+
expect(result.data?.violations.length).toBeGreaterThan(0);
|
|
86
|
+
expect(
|
|
87
|
+
result.data?.violations.some(
|
|
88
|
+
(v) => v.path === "docs/rfcs/rfc-0001.md" && v.operation === "move",
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
} finally {
|
|
92
|
+
await rm(dir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("flags move into a pinned dir", async () => {
|
|
97
|
+
const dir = await makeRepo();
|
|
98
|
+
try {
|
|
99
|
+
// Source must be committed first — otherwise git records the staged
|
|
100
|
+
// result as A (add), not R (rename).
|
|
101
|
+
await writeFile(join(dir, "incoming.md"), "# incoming\n");
|
|
102
|
+
await git(dir, ["add", "incoming.md"]);
|
|
103
|
+
await git(dir, ["commit", "-m", "add incoming"]);
|
|
104
|
+
await git(dir, ["mv", "incoming.md", "docs/rfcs/incoming.md"]);
|
|
105
|
+
|
|
106
|
+
const result = await runPinnedValidate(makeInput(), makeContext(dir));
|
|
107
|
+
expect(result.data?.status).toBe("fail");
|
|
108
|
+
expect(
|
|
109
|
+
result.data?.violations.some(
|
|
110
|
+
(v) => v.path === "docs/rfcs/incoming.md" && v.operation === "move",
|
|
111
|
+
),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
} finally {
|
|
114
|
+
await rm(dir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("flags delete of a pinned file", async () => {
|
|
119
|
+
const dir = await makeRepo();
|
|
120
|
+
try {
|
|
121
|
+
await git(dir, ["rm", "docs/rfcs/rfc-0001.md"]);
|
|
122
|
+
|
|
123
|
+
const result = await runPinnedValidate(makeInput(), makeContext(dir));
|
|
124
|
+
expect(result.data?.status).toBe("fail");
|
|
125
|
+
expect(
|
|
126
|
+
result.data?.violations.some(
|
|
127
|
+
(v) => v.path === "docs/rfcs/rfc-0001.md" && v.operation === "delete",
|
|
128
|
+
),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
} finally {
|
|
131
|
+
await rm(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -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(6);
|
|
161
161
|
for (const profile of profiles) {
|
|
162
162
|
expect(profile.id).toBeDefined();
|
|
163
163
|
expect(profile.workspace.dirs.length).toBeGreaterThan(0);
|