@warpgogol/forge 5.3.3 → 5.3.4
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/compass/compass.module.js +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +5 -2
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/compass/handlers/summary-record.d.ts +6 -1
- package/dist/os/compass/handlers/summary-record.d.ts.map +1 -1
- package/dist/os/compass/handlers/summary-record.js +14 -5
- package/dist/os/compass/handlers/summary-record.js.map +1 -1
- package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts +18 -0
- package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts.map +1 -0
- package/dist/os/compass/handlers/tests/forge-yaml-fixture.js +64 -0
- package/dist/os/compass/handlers/tests/forge-yaml-fixture.js.map +1 -0
- package/dist/os/compass/policy.d.ts +6 -1
- package/dist/os/compass/policy.d.ts.map +1 -1
- package/dist/os/compass/policy.js +8 -2
- package/dist/os/compass/policy.js.map +1 -1
- package/dist/os/mission/handlers/archive.d.ts.map +1 -1
- package/dist/os/mission/handlers/archive.js +1 -2
- package/dist/os/mission/handlers/archive.js.map +1 -1
- package/dist/os/rfc/handlers/validate.js +1 -1
- package/dist/src/config/forge-config.d.ts +21 -3
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +36 -7
- 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 +26 -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 +49 -2
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +24 -8
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/validators/skill-validate.d.ts.map +1 -1
- package/dist/src/validators/skill-validate.js +42 -15
- package/dist/src/validators/skill-validate.js.map +1 -1
- package/os/compass/compass.module.ts +1 -1
- package/os/compass/handlers/compass-inventory.ts +6 -3
- package/os/compass/handlers/summary-record.ts +14 -5
- package/os/compass/handlers/tests/compass-policy.test.ts +15 -18
- package/os/compass/handlers/tests/compass-v2-contract.test.ts +38 -0
- package/os/compass/handlers/tests/forge-yaml-fixture.ts +69 -0
- package/os/compass/handlers/tests/summary-record.test.ts +25 -0
- package/os/compass/policy.ts +8 -2
- package/os/mission/handlers/archive.ts +1 -2
- package/os/rfc/handlers/validate.ts +1 -1
- package/package.json +1 -1
- package/skills/fo/fo-compass-annotate/SKILL.md +1 -1
- package/skills/fo/fo-handoff/SKILL.md +2 -2
- package/skills/fo/fo-idea-audit/SKILL.md +1 -1
- package/skills/fo/fo-idea-create-rfc/SKILL.md +1 -1
- package/skills/fo/fo-idea-implement/SKILL.md +4 -3
- package/skills/fo/fo-session-retro/SKILL.md +1 -1
- package/skills/fo/fo-session-save/SKILL.md +1 -1
- package/src/config/forge-config.ts +102 -57
- package/src/onboarding/create.ts +25 -9
- package/src/onboarding/doctor.ts +51 -2
- package/src/onboarding/upgrade.ts +36 -6
- package/src/tests/bindings-schema.test.ts +53 -1
- package/src/tests/doctor-bindings.test.ts +7 -2
- package/src/tests/doctor-skills-unmanaged.test.ts +145 -0
- package/src/tests/forge-config.test.ts +106 -2
- package/src/tests/skill-validate.test.ts +180 -0
- package/src/tests/upgrade.test.ts +60 -0
- package/src/validators/skill-validate.ts +43 -16
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for forge.doctor skills-unmanaged check (RFC-1226): SKILL.md-bearing dirs without .forge-managed are reported as warnings.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1226: initial tests — AC-1 warn on unmanaged, AC-2 pass when all marked, AC-3 skip non-skill dirs, AC-4 skip without config/skillsDir.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { runDoctor } from "../onboarding/doctor.ts";
|
|
15
|
+
import { buildSkillMarkerContent } from "../onboarding/skill-markers.ts";
|
|
16
|
+
import type { ForgeRuntimeContext, ForgeLogger } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
const mockLogger: ForgeLogger = {
|
|
19
|
+
section: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
warn: () => {},
|
|
22
|
+
error: () => {},
|
|
23
|
+
success: () => {},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const mockContext = (workspaceRoot: string): ForgeRuntimeContext => ({
|
|
27
|
+
workspaceRoot,
|
|
28
|
+
logger: mockLogger,
|
|
29
|
+
dryRun: false,
|
|
30
|
+
outputFormat: "json",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let tempDir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
tempDir = await mkdtemp(join(tmpdir(), "forge-doctor-unmanaged-test-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const FORGE_YAML = `schema: forge/config@1
|
|
44
|
+
project:
|
|
45
|
+
name: test
|
|
46
|
+
stack: []
|
|
47
|
+
packageManager: pnpm
|
|
48
|
+
paths:
|
|
49
|
+
rfcsDir: docs/rfcs
|
|
50
|
+
adrsDir: docs/adrs
|
|
51
|
+
plansDir: docs/plans
|
|
52
|
+
auditsDir: docs/audits
|
|
53
|
+
specsDir: docs/specs
|
|
54
|
+
skillsDir: .agents/skills
|
|
55
|
+
bindings:
|
|
56
|
+
schema: forge/bindings@1
|
|
57
|
+
commands:
|
|
58
|
+
validateRfc: null
|
|
59
|
+
validateAdr: null
|
|
60
|
+
implementStamp: null
|
|
61
|
+
typecheck: null
|
|
62
|
+
test: null
|
|
63
|
+
scopedBuild: null
|
|
64
|
+
specValidate: null
|
|
65
|
+
sessionSave: null
|
|
66
|
+
paths:
|
|
67
|
+
invariantsFile: null
|
|
68
|
+
compassDocs: []
|
|
69
|
+
reviewsDir: null
|
|
70
|
+
handoffsDir: null
|
|
71
|
+
sessionsDir: null
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const SKILL_MD = "---\nname: test-skill\ndescription: test\n---\n# Test\n";
|
|
75
|
+
|
|
76
|
+
async function writeSkill(dir: string, name: string, managed: boolean): Promise<void> {
|
|
77
|
+
const skillDir = join(dir, ".agents", "skills", name);
|
|
78
|
+
await mkdir(skillDir, { recursive: true });
|
|
79
|
+
await writeFile(join(skillDir, "SKILL.md"), SKILL_MD, "utf8");
|
|
80
|
+
if (managed) {
|
|
81
|
+
await writeFile(
|
|
82
|
+
join(skillDir, ".forge-managed"),
|
|
83
|
+
buildSkillMarkerContent(["SKILL.md"]),
|
|
84
|
+
"utf8",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findCheck(result: Awaited<ReturnType<typeof runDoctor>>) {
|
|
90
|
+
const check = result.data?.checks.find((c) => c.name === "skills-unmanaged");
|
|
91
|
+
expect(check, "skills-unmanaged check must be present").toBeDefined();
|
|
92
|
+
return check!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("AC-1: skills-unmanaged warns and names SKILL.md-bearing dirs without .forge-managed", async () => {
|
|
96
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
|
|
97
|
+
await mkdir(join(tempDir, ".agents", "skills"), { recursive: true });
|
|
98
|
+
await writeSkill(tempDir, "fo-managed-skill", true);
|
|
99
|
+
await writeSkill(tempDir, "ask-matt", false);
|
|
100
|
+
await writeSkill(tempDir, "tdd", false);
|
|
101
|
+
|
|
102
|
+
const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
|
|
103
|
+
const check = findCheck(result);
|
|
104
|
+
expect(check.status).toBe("warn");
|
|
105
|
+
expect(check.message).toContain("2 unmanaged skill dir(s)");
|
|
106
|
+
expect(check.message).toContain("ask-matt");
|
|
107
|
+
expect(check.message).toContain("tdd");
|
|
108
|
+
expect(check.message).not.toContain("fo-managed-skill");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("AC-2: skills-unmanaged passes when every SKILL.md-bearing dir carries .forge-managed", async () => {
|
|
112
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
|
|
113
|
+
await mkdir(join(tempDir, ".agents", "skills"), { recursive: true });
|
|
114
|
+
await writeSkill(tempDir, "fo-managed-skill", true);
|
|
115
|
+
await writeSkill(tempDir, "grilling", true);
|
|
116
|
+
|
|
117
|
+
const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
|
|
118
|
+
const check = findCheck(result);
|
|
119
|
+
expect(check.status).toBe("pass");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("AC-3: skills-unmanaged excludes dirs without SKILL.md regardless of markers", async () => {
|
|
123
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
|
|
124
|
+
const sharedDir = join(tempDir, ".agents", "skills", "_shared");
|
|
125
|
+
await mkdir(sharedDir, { recursive: true });
|
|
126
|
+
await writeFile(join(sharedDir, "notes.md"), "# support files\n", "utf8");
|
|
127
|
+
const knowledgeDir = join(tempDir, ".agents", "skills", "knowledge");
|
|
128
|
+
await mkdir(knowledgeDir, { recursive: true });
|
|
129
|
+
await writeFile(join(knowledgeDir, "learned-principles.md"), "# L2\n", "utf8");
|
|
130
|
+
|
|
131
|
+
const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
|
|
132
|
+
const check = findCheck(result);
|
|
133
|
+
expect(check.status).toBe("pass");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("AC-4: skills-unmanaged passes without scanning when forge.yaml or skillsDir is absent", async () => {
|
|
137
|
+
// No forge.yaml at all
|
|
138
|
+
const noConfig = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
|
|
139
|
+
expect(findCheck(noConfig).status).toBe("pass");
|
|
140
|
+
|
|
141
|
+
// forge.yaml present, skillsDir absent
|
|
142
|
+
await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
|
|
143
|
+
const noSkillsDir = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
|
|
144
|
+
expect(findCheck(noSkillsDir).status).toBe("pass");
|
|
145
|
+
});
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
<item>RFC-0391: initial config tests.</item>
|
|
7
7
|
<item>RFC-0538: added compass binding section tests.</item>
|
|
8
8
|
<item>RFC-0540: added CLI binding defaults, pm-runner, and package-manager-aware defaultForgeConfig tests.</item>
|
|
9
|
+
<item>RFC-1224: adrImplementStamp binding, schema passthrough retention, applyForgeYamlPatches tests.</item>
|
|
10
|
+
<item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
|
|
9
11
|
</CHANGE_SUMMARY>
|
|
10
12
|
*/
|
|
11
13
|
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
forgeConfigSchema,
|
|
21
23
|
forgeBindingsSchema,
|
|
22
24
|
resolveBinding,
|
|
25
|
+
applyForgeYamlPatches,
|
|
23
26
|
FORGE_CLI_BINDING_DEFAULTS,
|
|
24
27
|
resolvePmRunner,
|
|
25
28
|
applyCliBindingDefaults,
|
|
@@ -63,12 +66,13 @@ test("resolvePmRunner falls back to npx for unknown", () => {
|
|
|
63
66
|
expect(resolvePmRunner("")).toBe("npx");
|
|
64
67
|
});
|
|
65
68
|
|
|
66
|
-
test("FORGE_CLI_BINDING_DEFAULTS has
|
|
67
|
-
expect(FORGE_CLI_BINDING_DEFAULTS).toHaveLength(
|
|
69
|
+
test("FORGE_CLI_BINDING_DEFAULTS has 6 entries with correct keys", () => {
|
|
70
|
+
expect(FORGE_CLI_BINDING_DEFAULTS).toHaveLength(6);
|
|
68
71
|
const keys = FORGE_CLI_BINDING_DEFAULTS.map((e) => e.key);
|
|
69
72
|
expect(keys).toContain("commands.validateRfc");
|
|
70
73
|
expect(keys).toContain("commands.validateAdr");
|
|
71
74
|
expect(keys).toContain("commands.implementStamp");
|
|
75
|
+
expect(keys).toContain("commands.adrImplementStamp");
|
|
72
76
|
expect(keys).toContain("commands.specValidate");
|
|
73
77
|
expect(keys).toContain("commands.sessionSave");
|
|
74
78
|
});
|
|
@@ -134,6 +138,9 @@ test("applyCliBindingDefaults returns forge-CLI bindings non-null and stack bind
|
|
|
134
138
|
expect(commands.validateRfc).not.toBeNull();
|
|
135
139
|
expect(commands.validateAdr).not.toBeNull();
|
|
136
140
|
expect(commands.implementStamp).not.toBeNull();
|
|
141
|
+
expect(commands.adrImplementStamp).toBe(
|
|
142
|
+
"pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
|
|
143
|
+
);
|
|
137
144
|
expect(commands.specValidate).not.toBeNull();
|
|
138
145
|
expect(commands.sessionSave).not.toBeNull();
|
|
139
146
|
expect(commands.typecheck).toBeNull();
|
|
@@ -418,3 +425,100 @@ bindings:
|
|
|
418
425
|
expect(config.bindings?.compass?.fileExtensions).toEqual([".ts", ".astro"]);
|
|
419
426
|
expect(config.bindings?.compass?.testPatterns).toEqual(["*.test.ts", "*.spec.ts"]);
|
|
420
427
|
});
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// RFC-1224: schema passthrough + forge.yaml document patching
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
test("RFC-1224: schema retains adrImplementStamp and consumer-defined command keys", async () => {
|
|
434
|
+
const yaml = `schema: forge/config@1
|
|
435
|
+
project:
|
|
436
|
+
name: consumer-project
|
|
437
|
+
stack: []
|
|
438
|
+
packageManager: pnpm
|
|
439
|
+
paths:
|
|
440
|
+
rfcsDir: docs/rfcs
|
|
441
|
+
adrsDir: docs/adrs
|
|
442
|
+
plansDir: docs/plans
|
|
443
|
+
auditsDir: docs/audits
|
|
444
|
+
specsDir: docs/specs
|
|
445
|
+
skillsDir: .agents/skills
|
|
446
|
+
sessionsDir: docs/sessions
|
|
447
|
+
bindings:
|
|
448
|
+
schema: forge/bindings@1
|
|
449
|
+
commands:
|
|
450
|
+
validateRfc: null
|
|
451
|
+
adrImplementStamp: pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}
|
|
452
|
+
createRfc: pnpm exec forge rfc.create
|
|
453
|
+
manifestGenerate: pnpm exec forge manifest.generate
|
|
454
|
+
paths:
|
|
455
|
+
invariantsFile: null
|
|
456
|
+
compassDocs: []
|
|
457
|
+
reviewsDir: null
|
|
458
|
+
handoffsDir: null
|
|
459
|
+
sessionsDir: null
|
|
460
|
+
plugins:
|
|
461
|
+
- name: my-plugin
|
|
462
|
+
`;
|
|
463
|
+
await writeFile(join(tempDir, "forge.yaml"), yaml, "utf8");
|
|
464
|
+
const config = loadForgeConfig(tempDir);
|
|
465
|
+
|
|
466
|
+
// Promoted key resolves through the typed interface
|
|
467
|
+
expect(config.bindings?.commands.adrImplementStamp).toBe(
|
|
468
|
+
"pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
|
|
469
|
+
);
|
|
470
|
+
// Consumer-defined keys stay resolvable via passthrough
|
|
471
|
+
expect(resolveBinding(config, "commands.createRfc")).toBe("pnpm exec forge rfc.create");
|
|
472
|
+
expect(resolveBinding(config, "commands.manifestGenerate")).toBe(
|
|
473
|
+
"pnpm exec forge manifest.generate",
|
|
474
|
+
);
|
|
475
|
+
// Unknown top-level sections survive the schema parse
|
|
476
|
+
expect((config as unknown as Record<string, unknown>)["plugins"]).toEqual([
|
|
477
|
+
{ name: "my-plugin" },
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("RFC-1224: applyForgeYamlPatches preserves comments and consumer content", () => {
|
|
482
|
+
const raw = `schema: forge/config@1
|
|
483
|
+
# operator comment — must survive
|
|
484
|
+
project:
|
|
485
|
+
name: consumer-project
|
|
486
|
+
bindings:
|
|
487
|
+
schema: forge/bindings@1
|
|
488
|
+
commands:
|
|
489
|
+
validateRfc: null # inline comment
|
|
490
|
+
customKey: keep-me
|
|
491
|
+
plugins:
|
|
492
|
+
- name: my-plugin
|
|
493
|
+
forge:
|
|
494
|
+
syncedVersion: 0.1.0
|
|
495
|
+
`;
|
|
496
|
+
const patched = applyForgeYamlPatches(raw, [
|
|
497
|
+
{ path: ["forge", "syncedVersion"], value: "0.2.0" },
|
|
498
|
+
{ path: ["bindings", "commands", "adrImplementStamp"], value: "forge adr.implement.stamp" },
|
|
499
|
+
]);
|
|
500
|
+
|
|
501
|
+
expect(patched).not.toBeNull();
|
|
502
|
+
expect(patched).toContain("syncedVersion: 0.2.0");
|
|
503
|
+
expect(patched).toContain("adrImplementStamp: forge adr.implement.stamp");
|
|
504
|
+
expect(patched).toContain("customKey: keep-me");
|
|
505
|
+
expect(patched).toContain("# operator comment — must survive");
|
|
506
|
+
expect(patched).toContain("# inline comment");
|
|
507
|
+
expect(patched).toContain("my-plugin");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("RFC-1224: applyForgeYamlPatches creates missing intermediate maps", () => {
|
|
511
|
+
const patched = applyForgeYamlPatches("schema: forge/config@1\n", [
|
|
512
|
+
{ path: ["forge", "syncedVersion"], value: "0.2.0" },
|
|
513
|
+
{ path: ["bindings", "commands", "adrImplementStamp"], value: "x" },
|
|
514
|
+
]);
|
|
515
|
+
expect(patched).toContain("syncedVersion: 0.2.0");
|
|
516
|
+
expect(patched).toContain("adrImplementStamp: x");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("RFC-1224: applyForgeYamlPatches returns null on invalid YAML", () => {
|
|
520
|
+
const patched = applyForgeYamlPatches("a: [unclosed\n : :", [
|
|
521
|
+
{ path: ["a"], value: 1 },
|
|
522
|
+
]);
|
|
523
|
+
expect(patched).toBeNull();
|
|
524
|
+
});
|
|
@@ -259,3 +259,183 @@ describe("SKILL-21: knowledge layer token budget warnings (RFC-0661)", () => {
|
|
|
259
259
|
expect(skill21Warnings).toEqual([]);
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("RFC-1225: forge root + workspace config resolution", () => {
|
|
264
|
+
function createConsumerWorkspace(): { tmpDir: string; forgePkg: string } {
|
|
265
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill-consumer-"));
|
|
266
|
+
const forgePkg = path.join(tmpDir, "node_modules", "@warpgogol", "forge");
|
|
267
|
+
fs.mkdirSync(forgePkg, { recursive: true });
|
|
268
|
+
fs.writeFileSync(
|
|
269
|
+
path.join(forgePkg, "package.json"),
|
|
270
|
+
JSON.stringify({ name: "@warpgogol/forge", version: "9.9.9" }),
|
|
271
|
+
"utf8",
|
|
272
|
+
);
|
|
273
|
+
return { tmpDir, forgePkg };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
test("AC-1: consumer layout validates skills under node_modules/@warpgogol/forge", async () => {
|
|
277
|
+
const { FORGE_SKILLS } = await import("../registry.ts");
|
|
278
|
+
const { tmpDir, forgePkg } = createConsumerWorkspace();
|
|
279
|
+
try {
|
|
280
|
+
// Materialize one registry skill inside the installed package copy —
|
|
281
|
+
// the others stay absent and produce SKILL-01, which is honest.
|
|
282
|
+
const entry = FORGE_SKILLS.find((s) => !(s.knowledge && s.knowledge.length > 0))!;
|
|
283
|
+
const skillPath = path.join(forgePkg, entry.path);
|
|
284
|
+
fs.mkdirSync(path.dirname(skillPath), { recursive: true });
|
|
285
|
+
fs.writeFileSync(
|
|
286
|
+
skillPath,
|
|
287
|
+
`---\nname: ${entry.name}\ndescription: Fixture skill for consumer root resolution\ninvocation: ${entry.invocation}\ncategory: ${entry.category}\nconcerns: ${entry.concerns}\ndependsOn: []\nlanguagePolicy: ref(PREFERENCES.md)\n---\n\nBefore starting, read \`PREFERENCES.md\` at the repository root.\n`,
|
|
288
|
+
"utf8",
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const result = runSkillValidate({}, { workspaceRoot: tmpDir });
|
|
292
|
+
// The materialized skill was found → no SKILL-01 for it. Under the old
|
|
293
|
+
// hardcoded <workspaceRoot>/packages/forge path it could never exist.
|
|
294
|
+
const skill01 = result.violations.filter(
|
|
295
|
+
(v) => v.rule === "SKILL-01" && v.skill === entry.name,
|
|
296
|
+
);
|
|
297
|
+
expect(skill01).toEqual([]);
|
|
298
|
+
} finally {
|
|
299
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("AC-2: unresolvable forge root fails with a single SKILL-00", () => {
|
|
304
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill-noroot-"));
|
|
305
|
+
try {
|
|
306
|
+
const result = runSkillValidate({}, { workspaceRoot: tmpDir });
|
|
307
|
+
expect(result.status).toBe("fail");
|
|
308
|
+
expect(result.violations).toHaveLength(1);
|
|
309
|
+
expect(result.violations[0].rule).toBe("SKILL-00");
|
|
310
|
+
expect(result.violations[0].message).toContain("forge root not resolvable");
|
|
311
|
+
} finally {
|
|
312
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("AC-3: pack discovery reads forge.yaml from the workspace root", () => {
|
|
317
|
+
const { tmpDir } = createConsumerWorkspace();
|
|
318
|
+
try {
|
|
319
|
+
fs.writeFileSync(
|
|
320
|
+
path.join(tmpDir, "forge.yaml"),
|
|
321
|
+
`schema: forge/config@1
|
|
322
|
+
project:
|
|
323
|
+
name: consumer
|
|
324
|
+
stack: []
|
|
325
|
+
packageManager: pnpm
|
|
326
|
+
paths:
|
|
327
|
+
rfcsDir: docs/rfcs
|
|
328
|
+
adrsDir: docs/adrs
|
|
329
|
+
plansDir: docs/plans
|
|
330
|
+
auditsDir: docs/audits
|
|
331
|
+
specsDir: docs/specs
|
|
332
|
+
skillsDir: .agents/skills
|
|
333
|
+
sessionsDir: docs/sessions
|
|
334
|
+
skillPacks:
|
|
335
|
+
- prefix: wg
|
|
336
|
+
dir: packs/wg-skills
|
|
337
|
+
`,
|
|
338
|
+
"utf8",
|
|
339
|
+
);
|
|
340
|
+
const packDir = path.join(tmpDir, "packs", "wg-skills");
|
|
341
|
+
fs.mkdirSync(path.join(packDir, "wg-broken"), { recursive: true });
|
|
342
|
+
fs.writeFileSync(path.join(packDir, "forge.plugin.yaml"), "id: wg-skills\nversion: 1.0.0\n");
|
|
343
|
+
// Deliberately invalid pack skill — discovery proves itself by flagging it.
|
|
344
|
+
fs.writeFileSync(path.join(packDir, "wg-broken", "SKILL.md"), "# no frontmatter\n");
|
|
345
|
+
|
|
346
|
+
const result = runSkillValidate({}, { workspaceRoot: tmpDir });
|
|
347
|
+
const packViolations = result.violations.filter(
|
|
348
|
+
(v) => v.pack === "wg" && v.skill === "wg-broken",
|
|
349
|
+
);
|
|
350
|
+
expect(packViolations.length).toBeGreaterThan(0);
|
|
351
|
+
expect(packViolations.some((v) => v.rule === "SKILL-01")).toBe(true);
|
|
352
|
+
} finally {
|
|
353
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("AC-4: monorepo invocation resolves the real forge package root — no SKILL-01", async () => {
|
|
358
|
+
const { resolveForgeRoot } = await import("../config/forge-config.ts");
|
|
359
|
+
const root = process.cwd();
|
|
360
|
+
// cwd may be the repo root (packages/forge layout) or packages/forge
|
|
361
|
+
// itself (scoped test runs) — both must resolve to a root with skills/.
|
|
362
|
+
const forgeRoot = resolveForgeRoot(root);
|
|
363
|
+
expect(fs.existsSync(path.join(forgeRoot, "skills"))).toBe(true);
|
|
364
|
+
const result = runSkillValidate({}, { workspaceRoot: root });
|
|
365
|
+
const skill01 = result.violations.filter((v) => v.rule === "SKILL-01" && !v.pack);
|
|
366
|
+
expect(skill01).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("RFC-1227: SKILL-17 comment-region exemption", () => {
|
|
371
|
+
// Access the checker through a fabricated skill on disk? checkSkill17 is not
|
|
372
|
+
// exported — exercise it via a consumer fixture containing a registry skill
|
|
373
|
+
// whose SKILL.md carries the content under test.
|
|
374
|
+
async function fixtureRun(bodyLines: string[]): Promise<ReturnType<typeof runSkillValidate>> {
|
|
375
|
+
const { FORGE_SKILLS } = await import("../registry.ts");
|
|
376
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill17-"));
|
|
377
|
+
const forgePkg = path.join(tmpDir, "node_modules", "@warpgogol", "forge");
|
|
378
|
+
fs.mkdirSync(forgePkg, { recursive: true });
|
|
379
|
+
fs.writeFileSync(
|
|
380
|
+
path.join(forgePkg, "package.json"),
|
|
381
|
+
JSON.stringify({ name: "@warpgogol/forge", version: "9.9.9" }),
|
|
382
|
+
"utf8",
|
|
383
|
+
);
|
|
384
|
+
const entry = FORGE_SKILLS.find((s) => !(s.knowledge && s.knowledge.length > 0))!;
|
|
385
|
+
const skillPath = path.join(forgePkg, entry.path);
|
|
386
|
+
fs.mkdirSync(path.dirname(skillPath), { recursive: true });
|
|
387
|
+
fs.writeFileSync(
|
|
388
|
+
skillPath,
|
|
389
|
+
[
|
|
390
|
+
"---",
|
|
391
|
+
`name: ${entry.name}`,
|
|
392
|
+
"description: Fixture skill for SKILL-17 scope tests",
|
|
393
|
+
`invocation: ${entry.invocation}`,
|
|
394
|
+
`category: ${entry.category}`,
|
|
395
|
+
`concerns: ${entry.concerns}`,
|
|
396
|
+
"dependsOn: []",
|
|
397
|
+
"languagePolicy: ref(PREFERENCES.md)",
|
|
398
|
+
"---",
|
|
399
|
+
...bodyLines,
|
|
400
|
+
"Before starting, read `PREFERENCES.md` at the repository root.",
|
|
401
|
+
].join("\n"),
|
|
402
|
+
"utf8",
|
|
403
|
+
);
|
|
404
|
+
try {
|
|
405
|
+
return runSkillValidate({}, { workspaceRoot: tmpDir });
|
|
406
|
+
} finally {
|
|
407
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
test("AC-1: RFC ids inside HTML comment regions produce no SKILL-17 violation", async () => {
|
|
412
|
+
const result = await fixtureRun([
|
|
413
|
+
"<!--",
|
|
414
|
+
"<CHANGE_SUMMARY>",
|
|
415
|
+
" <item>RFC-1097: sweep — SKILL.md headers + classification fixes</item>",
|
|
416
|
+
"</CHANGE_SUMMARY>",
|
|
417
|
+
"-->",
|
|
418
|
+
"Run `forge doctor` to inspect bindings. <!-- operator note: see RFC-0553 -->",
|
|
419
|
+
]);
|
|
420
|
+
const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
|
|
421
|
+
expect(idViolations).toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("AC-2: RFC ids in instruction text still violate SKILL-17", async () => {
|
|
425
|
+
const result = await fixtureRun([
|
|
426
|
+
"Stamp the implementation via `rfc.implement.stamp` (RFC-0756).",
|
|
427
|
+
]);
|
|
428
|
+
const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
|
|
429
|
+
expect(idViolations.length).toBeGreaterThan(0);
|
|
430
|
+
expect(idViolations[0].message).toContain("RFC-0756");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("AC-4: the file-level disable marker still suppresses all SKILL-17 violations", async () => {
|
|
434
|
+
const result = await fixtureRun([
|
|
435
|
+
"<!-- skill-lint-disable SKILL-17 -->",
|
|
436
|
+
"Referencing RFC-0996 in body text is suppressed by the marker.",
|
|
437
|
+
]);
|
|
438
|
+
const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
|
|
439
|
+
expect(idViolations).toEqual([]);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<CHANGE_SUMMARY>
|
|
6
6
|
<item>RFC-0543: initial upgrade handler and VERSION sourcing tests.</item>
|
|
7
7
|
<item>RFC-0552: add test for Forge-vs-pack skill name conflict detection in upgrade.</item>
|
|
8
|
+
<item>RFC-1224: forge.yaml preservation test — consumer keys, comments, and unknown sections survive upgrade rewrites.</item>
|
|
9
|
+
<item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
|
|
8
10
|
</CHANGE_SUMMARY>
|
|
9
11
|
*/
|
|
10
12
|
|
|
@@ -21,6 +23,7 @@ import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-i
|
|
|
21
23
|
import { runInit } from "../onboarding/init.ts";
|
|
22
24
|
import {
|
|
23
25
|
loadForgeConfig,
|
|
26
|
+
resolveBinding,
|
|
24
27
|
FORGE_CLI_BINDING_DEFAULTS,
|
|
25
28
|
resolvePmRunner,
|
|
26
29
|
} from "../config/forge-config.ts";
|
|
@@ -206,6 +209,63 @@ test("forge.upgrade never overwrites a non-null operator-set binding", async ()
|
|
|
206
209
|
expect(addedKeys).toContain("commands.implementStamp");
|
|
207
210
|
expect(addedKeys).toContain("commands.specValidate");
|
|
208
211
|
expect(addedKeys).toContain("commands.sessionSave");
|
|
212
|
+
expect(addedKeys).toContain("commands.adrImplementStamp");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("RFC-1224: forge.upgrade preserves consumer-owned keys and comments in forge.yaml", async () => {
|
|
216
|
+
await setupForgeSource(tempDir, "0.2.0");
|
|
217
|
+
// Hand-write forge.yaml the way an operator would: a consumer-defined command
|
|
218
|
+
// binding, a schema-unknown top-level section, and comments.
|
|
219
|
+
const yaml = `schema: forge/config@1
|
|
220
|
+
# operator comment — must survive upgrade
|
|
221
|
+
project:
|
|
222
|
+
name: consumer-project
|
|
223
|
+
stack: []
|
|
224
|
+
packageManager: pnpm
|
|
225
|
+
paths:
|
|
226
|
+
rfcsDir: docs/rfcs
|
|
227
|
+
adrsDir: docs/adrs
|
|
228
|
+
plansDir: docs/plans
|
|
229
|
+
auditsDir: docs/audits
|
|
230
|
+
specsDir: docs/specs
|
|
231
|
+
skillsDir: .agents/skills
|
|
232
|
+
sessionsDir: docs/sessions
|
|
233
|
+
bindings:
|
|
234
|
+
schema: forge/bindings@1
|
|
235
|
+
commands:
|
|
236
|
+
validateRfc: pnpm exec forge rfc.validate --id {id} --json # inline comment
|
|
237
|
+
customRegistryCmd: custom registry command
|
|
238
|
+
paths:
|
|
239
|
+
invariantsFile: null
|
|
240
|
+
compassDocs: []
|
|
241
|
+
reviewsDir: null
|
|
242
|
+
handoffsDir: null
|
|
243
|
+
sessionsDir: null
|
|
244
|
+
plugins:
|
|
245
|
+
- name: my-plugin
|
|
246
|
+
forge:
|
|
247
|
+
syncedVersion: 0.1.0
|
|
248
|
+
`;
|
|
249
|
+
await writeFile(join(tempDir, "forge.yaml"), yaml, "utf8");
|
|
250
|
+
|
|
251
|
+
const result = await runUpgrade({ argv: [], flags: {} }, makeContext(tempDir, false));
|
|
252
|
+
|
|
253
|
+
expect(result.data?.status).toBe("pass");
|
|
254
|
+
const written = await readFile(join(tempDir, "forge.yaml"), "utf8");
|
|
255
|
+
// Synced version updated, operator content untouched
|
|
256
|
+
expect(written).toContain("syncedVersion: 0.2.0");
|
|
257
|
+
expect(written).toContain("customRegistryCmd: custom registry command");
|
|
258
|
+
expect(written).toContain("plugins:");
|
|
259
|
+
expect(written).toContain("my-plugin");
|
|
260
|
+
expect(written).toContain("# operator comment — must survive upgrade");
|
|
261
|
+
expect(written).toContain("# inline comment");
|
|
262
|
+
// Newly promoted forge default is added in place
|
|
263
|
+
expect(written).toContain("adrImplementStamp:");
|
|
264
|
+
|
|
265
|
+
const config = loadForgeConfig(tempDir);
|
|
266
|
+
expect(config.forge?.syncedVersion).toBe("0.2.0");
|
|
267
|
+
// Consumer-defined keys stay resolvable after passthrough parsing
|
|
268
|
+
expect(resolveBinding(config, "commands.customRegistryCmd")).toBe("custom registry command");
|
|
209
269
|
});
|
|
210
270
|
|
|
211
271
|
test("forge.upgrade refuses when forge.yaml is missing", async () => {
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
</non-goals>
|
|
7
7
|
</MODULE_CONTRACT>
|
|
8
8
|
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-1225: forgeRoot resolves via resolveForgeRoot (monorepo or npm-installed package); unresolvable root returns a single SKILL-00; pack discovery loads forge.yaml from workspaceRoot.</item>
|
|
10
|
+
<item>RFC-1227: SKILL-17 exempts HTML comment regions (file metadata written by the platform's own changelog tooling); pack-skill loop drops SKILL-17 (RFC-0553 scoped it to shipped canonical skills only).</item>
|
|
9
11
|
<item>RFC-0642: added SKILL-18 — forge skill instruction lines must not reference software-specific binding keys (typecheck, scopedBuild, test); use semantic keys (validate, produce, verify) instead.</item>
|
|
10
12
|
<item>RFC-0660: added SKILL-19 (knowledge entry schema validity) and SKILL-20 (entry identifier uniqueness) for structured knowledge files.</item>
|
|
11
13
|
<item>RFC-0661: added SKILL-21 (knowledge layer token budget warnings), refactored warning handling — warnings go to separate `warnings` array, not `violations`.</item>
|
|
@@ -67,14 +69,35 @@ const PREFERENCES_PATTERN = /Read `PREFERENCES\.md`/i;
|
|
|
67
69
|
export function runSkillValidate(_input: unknown, context: unknown): SkillValidateResult {
|
|
68
70
|
const ctx = context as { workspaceRoot?: string };
|
|
69
71
|
const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
|
|
70
|
-
|
|
72
|
+
|
|
73
|
+
// RFC-1225: skill sources live under the forge package root — monorepo
|
|
74
|
+
// packages/forge or the consumer's node_modules/@warpgogol/forge — while
|
|
75
|
+
// forge.yaml lives under the workspace root. An unresolvable forge root
|
|
76
|
+
// surfaces as a single SKILL-00, never N cascading SKILL-01 ghosts.
|
|
77
|
+
let forgeRoot: string;
|
|
78
|
+
try {
|
|
79
|
+
forgeRoot = resolveForgeRoot(workspaceRoot);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return {
|
|
82
|
+
command: "forge.skill.validate",
|
|
83
|
+
status: "fail",
|
|
84
|
+
violations: [
|
|
85
|
+
{
|
|
86
|
+
skill: "*",
|
|
87
|
+
rule: "SKILL-00",
|
|
88
|
+
message: `forge root not resolvable: ${(err as Error).message}`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
warnings: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
71
94
|
const skillsDir = path.join(forgeRoot, "skills");
|
|
72
95
|
|
|
73
|
-
// RFC-0539: Load config to discover declared skill packs
|
|
96
|
+
// RFC-0539: Load config to discover declared skill packs — from the
|
|
97
|
+
// workspace root, where forge.yaml actually lives (RFC-1225).
|
|
74
98
|
let packSkills: PackSkillEntry[] = [];
|
|
75
99
|
try {
|
|
76
|
-
const
|
|
77
|
-
const config = loadForgeConfig(forgeConfigRoot);
|
|
100
|
+
const config = loadForgeConfig(workspaceRoot, forgeRoot);
|
|
78
101
|
packSkills = discoverPackSkills(workspaceRoot, config);
|
|
79
102
|
} catch {
|
|
80
103
|
// Config not found or invalid — skip pack skill validation
|
|
@@ -493,14 +516,10 @@ export function runSkillValidate(_input: unknown, context: unknown): SkillValida
|
|
|
493
516
|
});
|
|
494
517
|
}
|
|
495
518
|
|
|
496
|
-
// SKILL-17
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
v.pack = entry.pack;
|
|
501
|
-
}
|
|
502
|
-
violations.push(...skill17Violations);
|
|
503
|
-
}
|
|
519
|
+
// SKILL-17 is intentionally absent here: RFC-0553 scopes the rule to
|
|
520
|
+
// shipped canonical skills (packages/forge/skills) — pack skills are
|
|
521
|
+
// project-domain by design (same asymmetry as SKILL-18). The check was
|
|
522
|
+
// dead code until RFC-1225 enabled pack validation.
|
|
504
523
|
}
|
|
505
524
|
|
|
506
525
|
// Check: every SKILL.md on disk has a registry entry
|
|
@@ -642,12 +661,20 @@ function checkSkill17(skillName: string, content: string): Violation[] {
|
|
|
642
661
|
return result;
|
|
643
662
|
}
|
|
644
663
|
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
664
|
+
const rawLines = content.split(/\r?\n/);
|
|
665
|
+
// RFC-1227: HTML comment regions (MODULE_CONTRACT, CHANGE_SUMMARY, inline
|
|
666
|
+
// notes) are file metadata, not agent instructions — the platform's own
|
|
667
|
+
// change-summarizer writes RFC ids there by convention. Blank them
|
|
668
|
+
// line-stably so instruction text keeps full enforcement and reported
|
|
669
|
+
// line numbers stay correct.
|
|
670
|
+
const stripped = content.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
|
|
671
|
+
const lines = stripped.split(/\r?\n/);
|
|
672
|
+
|
|
673
|
+
for (let i = 0; i < lines.length; i++) {
|
|
674
|
+
if (rawLines[i].includes(SKILL17_DISABLE_MARKER)) {
|
|
649
675
|
continue;
|
|
650
676
|
}
|
|
677
|
+
const line = lines[i];
|
|
651
678
|
|
|
652
679
|
for (const pattern of SKILL17_ID_PATTERNS) {
|
|
653
680
|
const re = new RegExp(pattern.source, pattern.flags);
|