@warpgogol/forge 5.3.4 → 6.2.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/CHANGELOG.md +8 -0
- package/bin/cli.ts +7 -2
- package/dist/bin/cli.js +7 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/adr/adr.module.d.ts.map +1 -1
- package/dist/os/adr/adr.module.js +2 -0
- package/dist/os/adr/adr.module.js.map +1 -1
- package/dist/os/audit/audit.module.d.ts.map +1 -1
- package/dist/os/audit/audit.module.js +2 -0
- package/dist/os/audit/audit.module.js.map +1 -1
- package/dist/os/compass/compass.module.d.ts.map +1 -1
- package/dist/os/compass/compass.module.js +47 -15
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +2 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +40 -10
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-docs-validate.d.ts +13 -0
- package/dist/os/compass/handlers/compass-docs-validate.d.ts.map +1 -0
- package/dist/os/compass/handlers/compass-docs-validate.js +328 -0
- package/dist/os/compass/handlers/compass-docs-validate.js.map +1 -0
- package/dist/os/compass/handlers/summary-record.d.ts +11 -2
- package/dist/os/compass/handlers/summary-record.d.ts.map +1 -1
- package/dist/os/compass/handlers/summary-record.js +95 -15
- package/dist/os/compass/handlers/summary-record.js.map +1 -1
- package/dist/os/core/core.module.d.ts.map +1 -1
- package/dist/os/core/core.module.js +124 -73
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/core/handlers/file-size-lint.d.ts.map +1 -1
- package/dist/os/core/handlers/file-size-lint.js +2 -0
- package/dist/os/core/handlers/file-size-lint.js.map +1 -1
- package/dist/os/exploration/exploration.module.d.ts.map +1 -1
- package/dist/os/exploration/exploration.module.js +3 -0
- package/dist/os/exploration/exploration.module.js.map +1 -1
- package/dist/os/naming/naming-convention.d.ts.map +1 -1
- package/dist/os/naming/naming-convention.js +11 -1
- package/dist/os/naming/naming-convention.js.map +1 -1
- package/dist/os/naming/naming.module.d.ts.map +1 -1
- package/dist/os/naming/naming.module.js +9 -2
- package/dist/os/naming/naming.module.js.map +1 -1
- package/dist/os/notes/notes.module.d.ts.map +1 -1
- package/dist/os/notes/notes.module.js +17 -6
- package/dist/os/notes/notes.module.js.map +1 -1
- package/dist/os/plan/plan.module.d.ts.map +1 -1
- package/dist/os/plan/plan.module.js +2 -0
- package/dist/os/plan/plan.module.js.map +1 -1
- package/dist/os/plugin/plugin.module.d.ts.map +1 -1
- package/dist/os/plugin/plugin.module.js +9 -2
- package/dist/os/plugin/plugin.module.js.map +1 -1
- package/dist/os/program/program.module.d.ts.map +1 -1
- package/dist/os/program/program.module.js +19 -4
- package/dist/os/program/program.module.js.map +1 -1
- package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -1
- package/dist/os/queue/handlers/queue-validate.js +154 -7
- package/dist/os/queue/handlers/queue-validate.js.map +1 -1
- package/dist/os/queue/manifest.d.ts +17 -1
- package/dist/os/queue/manifest.d.ts.map +1 -1
- package/dist/os/queue/manifest.js +127 -6
- package/dist/os/queue/manifest.js.map +1 -1
- package/dist/os/queue/queue.module.d.ts.map +1 -1
- package/dist/os/queue/queue.module.js +32 -4
- package/dist/os/queue/queue.module.js.map +1 -1
- package/dist/os/queue/types.d.ts +100 -1
- package/dist/os/queue/types.d.ts.map +1 -1
- package/dist/os/queue/types.js +44 -0
- package/dist/os/queue/types.js.map +1 -1
- package/dist/os/rfc/handlers/implement-stamp.d.ts.map +1 -1
- package/dist/os/rfc/handlers/implement-stamp.js +30 -8
- package/dist/os/rfc/handlers/implement-stamp.js.map +1 -1
- package/dist/os/rfc/rfc.module.d.ts.map +1 -1
- package/dist/os/rfc/rfc.module.js +25 -6
- package/dist/os/rfc/rfc.module.js.map +1 -1
- package/dist/os/rfc/types.d.ts +12 -2
- package/dist/os/rfc/types.d.ts.map +1 -1
- package/dist/os/rfc/types.js +2 -2
- package/dist/os/rfc/types.js.map +1 -1
- package/dist/os/rfc/verification-evidence.d.ts +21 -0
- package/dist/os/rfc/verification-evidence.d.ts.map +1 -1
- package/dist/os/rfc/verification-evidence.js +134 -23
- package/dist/os/rfc/verification-evidence.js.map +1 -1
- package/dist/os/rfc/verification-refresh.d.ts.map +1 -1
- package/dist/os/rfc/verification-refresh.js +66 -37
- package/dist/os/rfc/verification-refresh.js.map +1 -1
- package/dist/os/session/handlers/save.d.ts.map +1 -1
- package/dist/os/session/handlers/save.js +42 -5
- package/dist/os/session/handlers/save.js.map +1 -1
- package/dist/os/session/session.module.d.ts.map +1 -1
- package/dist/os/session/session.module.js +5 -0
- package/dist/os/session/session.module.js.map +1 -1
- package/dist/os/spec/live-spec-merge.d.ts.map +1 -1
- package/dist/os/spec/live-spec-merge.js +55 -134
- package/dist/os/spec/live-spec-merge.js.map +1 -1
- package/dist/os/spec/live-spec-rebuild.d.ts +32 -0
- package/dist/os/spec/live-spec-rebuild.d.ts.map +1 -0
- package/dist/os/spec/live-spec-rebuild.js +272 -0
- package/dist/os/spec/live-spec-rebuild.js.map +1 -0
- package/dist/os/spec/live-spec-shared.d.ts +62 -0
- package/dist/os/spec/live-spec-shared.d.ts.map +1 -0
- package/dist/os/spec/live-spec-shared.js +315 -0
- package/dist/os/spec/live-spec-shared.js.map +1 -0
- package/dist/os/spec/live-spec-types.d.ts +17 -1
- package/dist/os/spec/live-spec-types.d.ts.map +1 -1
- package/dist/os/spec/live-spec-types.js +3 -0
- package/dist/os/spec/live-spec-types.js.map +1 -1
- package/dist/os/spec/live-spec-validate.d.ts.map +1 -1
- package/dist/os/spec/live-spec-validate.js +105 -2
- package/dist/os/spec/live-spec-validate.js.map +1 -1
- package/dist/os/spec/spec-materialize.d.ts +2 -0
- package/dist/os/spec/spec-materialize.d.ts.map +1 -1
- package/dist/os/spec/spec-materialize.js +45 -5
- package/dist/os/spec/spec-materialize.js.map +1 -1
- package/dist/os/spec/spec-schema.d.ts +19 -0
- package/dist/os/spec/spec-schema.d.ts.map +1 -1
- package/dist/os/spec/spec-schema.js +30 -0
- package/dist/os/spec/spec-schema.js.map +1 -1
- package/dist/os/spec/spec-status.d.ts +4 -0
- package/dist/os/spec/spec-status.d.ts.map +1 -1
- package/dist/os/spec/spec-status.js +19 -1
- package/dist/os/spec/spec-status.js.map +1 -1
- package/dist/os/spec/spec-validate.d.ts.map +1 -1
- package/dist/os/spec/spec-validate.js +55 -8
- package/dist/os/spec/spec-validate.js.map +1 -1
- package/dist/os/spec/spec.module.d.ts.map +1 -1
- package/dist/os/spec/spec.module.js +84 -8
- package/dist/os/spec/spec.module.js.map +1 -1
- package/dist/os/werkstatt/handlers/werkstatt-operation-validate.d.ts.map +1 -1
- package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js +10 -4
- package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js.map +1 -1
- package/dist/os/werkstatt/werkstatt.module.d.ts.map +1 -1
- package/dist/os/werkstatt/werkstatt.module.js +13 -4
- package/dist/os/werkstatt/werkstatt.module.js.map +1 -1
- package/dist/os/workflow/workflow.module.d.ts.map +1 -1
- package/dist/os/workflow/workflow.module.js +13 -5
- package/dist/os/workflow/workflow.module.js.map +1 -1
- package/dist/src/cli-flags.d.ts.map +1 -1
- package/dist/src/cli-flags.js +25 -5
- package/dist/src/cli-flags.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +3 -0
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +2 -0
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +2 -3
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +8 -3
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/pipeline-status.d.ts +14 -1
- package/dist/src/pipeline-status.d.ts.map +1 -1
- package/dist/src/pipeline-status.js +28 -8
- package/dist/src/pipeline-status.js.map +1 -1
- package/dist/src/types.d.ts +7 -1
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/generated-marker.d.ts +8 -0
- package/dist/src/utils/generated-marker.d.ts.map +1 -1
- package/dist/src/utils/generated-marker.js +25 -0
- package/dist/src/utils/generated-marker.js.map +1 -1
- package/dist/src/validators/skill-validate.d.ts.map +1 -1
- package/dist/src/validators/skill-validate.js +2 -4
- package/dist/src/validators/skill-validate.js.map +1 -1
- package/os/adr/adr.module.ts +2 -0
- package/os/audit/audit.module.ts +2 -0
- package/os/compass/compass.module.ts +49 -15
- package/os/compass/handlers/compass-audit-handler.ts +51 -10
- package/os/compass/handlers/compass-docs-validate.ts +361 -0
- package/os/compass/handlers/summary-record.ts +106 -15
- package/os/compass/handlers/tests/compass-audit-record.test.ts +2 -1
- package/os/compass/handlers/tests/compass-audit-validate.test.ts +92 -3
- package/os/compass/handlers/tests/compass-docs-validate.test.ts +203 -0
- package/os/compass/handlers/tests/compass-ledger-scope.test.ts +2 -1
- package/os/compass/handlers/tests/compass-policy.test.ts +7 -1
- package/os/compass/handlers/tests/summary-record.test.ts +127 -0
- package/os/core/core.module.ts +147 -75
- package/os/core/docs-archive.test.ts +136 -0
- package/os/core/handlers/file-size-lint.ts +2 -0
- package/os/exploration/exploration.module.ts +3 -0
- package/os/naming/naming-convention.ts +11 -1
- package/os/naming/naming.module.ts +9 -2
- package/os/notes/notes.module.ts +17 -6
- package/os/plan/plan-0000-template.md +150 -0
- package/os/plan/plan.module.ts +2 -0
- package/os/plugin/plugin.module.ts +9 -2
- package/os/program/program.module.ts +19 -4
- package/os/queue/decision-ledger.test.ts +163 -0
- package/os/queue/handlers/queue-validate.ts +190 -8
- package/os/queue/manifest.ts +201 -7
- package/os/queue/queue-validate.test.ts +303 -0
- package/os/queue/queue.module.ts +32 -4
- package/os/queue/types.ts +56 -1
- package/os/rfc/handlers/implement-stamp.ts +37 -8
- package/os/rfc/rfc-read-only-no-side-effects.test.ts +1 -0
- package/os/rfc/rfc.module.ts +27 -6
- package/os/rfc/types.ts +18 -3
- package/os/rfc/verification-emit.test.ts +305 -0
- package/os/rfc/verification-evidence.ts +149 -24
- package/os/rfc/verification-refresh.test.ts +222 -1
- package/os/rfc/verification-refresh.ts +74 -38
- package/os/session/handlers/save.ts +37 -5
- package/os/session/session.module.ts +5 -0
- package/os/spec/live-spec-idempotency.pbt.test.ts +222 -0
- package/os/spec/live-spec-list-show-validate.test.ts +10 -6
- package/os/spec/live-spec-merge.test.ts +187 -7
- package/os/spec/live-spec-merge.ts +71 -155
- package/os/spec/live-spec-rebuild.test.ts +317 -0
- package/os/spec/live-spec-rebuild.ts +356 -0
- package/os/spec/live-spec-shared.ts +378 -0
- package/os/spec/live-spec-types.ts +21 -1
- package/os/spec/live-spec-validate.test.ts +317 -0
- package/os/spec/live-spec-validate.ts +106 -2
- package/os/spec/spec-materialize.test.ts +192 -0
- package/os/spec/spec-materialize.ts +48 -4
- package/os/spec/spec-schema.ts +49 -0
- package/os/spec/spec-status.ts +24 -0
- package/os/spec/spec-validate.test.ts +66 -0
- package/os/spec/spec-validate.ts +62 -8
- package/os/spec/spec.module.ts +85 -8
- package/os/werkstatt/handlers/werkstatt-operation-validate.ts +10 -4
- package/os/werkstatt/werkstatt.module.ts +13 -4
- package/os/workflow/workflow.module.ts +13 -5
- package/package.json +1 -1
- package/skills/_shared/fo-pipeline-conventions.md +126 -1
- package/skills/fo/fo-doc-audit/SKILL.md +4 -4
- package/skills/fo/fo-explore/SKILL.md +2 -1
- package/skills/fo/fo-fix/SKILL.md +11 -3
- package/skills/fo/fo-handoff/SKILL.md +4 -6
- package/skills/fo/fo-idea-create-rfc/SKILL.md +2 -1
- package/skills/fo/fo-idea-enhance/SKILL.md +23 -4
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +86 -22
- package/skills/fo/fo-idea-implement/SKILL.md +53 -10
- package/skills/fo/fo-idea-plan/SKILL.md +28 -6
- package/skills/fo/fo-review/SKILL.md +69 -8
- package/skills/fo/fo-session-retro/SKILL.md +3 -2
- package/skills/fo/fo-step-commit/SKILL.md +2 -0
- package/skills/shared/grilling/SKILL.md +12 -0
- package/skills/shared/writing-great-skills/GLOSSARY.md +2 -0
- package/skills/shared/writing-great-skills/SKILL.md +2 -1
- package/src/cli-flags.ts +23 -5
- package/src/config/forge-config.ts +4 -0
- package/src/onboarding/doctor.ts +2 -3
- package/src/onboarding/init.ts +8 -3
- package/src/pipeline-status.ts +32 -8
- package/src/tests/cli-flags.test.ts +20 -0
- package/src/tests/cli-output.test.ts +13 -7
- package/src/tests/fixtures/agents-generate-business-before.txt +7 -7
- package/src/tests/implement-stamp.test.ts +38 -0
- package/src/tests/session-handlers.test.ts +33 -0
- package/src/types.ts +13 -1
- package/src/utils/generated-marker.ts +28 -0
- package/src/validators/skill-validate.ts +2 -4
- package/skills/fo/fo-idea-implement/ADR-FLOW.md +0 -179
- package/skills/fo/fo-review/AXES.md +0 -70
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for spec.live.validate — V-LS-06 (duplicate namespaced
|
|
4
|
+
headings), V-LS-07 (duplicate history RFCs), V-LS-08 (archive coverage) on
|
|
5
|
+
synthetic living-spec fixtures (RFC-1230).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not re-test V-LS-01..05 semantics beyond fixtures needed for isolation.</item>
|
|
8
|
+
</non-goals>
|
|
9
|
+
</MODULE_CONTRACT>
|
|
10
|
+
<CHANGE_SUMMARY>
|
|
11
|
+
<item>RFC-1230: initial unit tests for V-LS-06/07/08.</item>
|
|
12
|
+
<item>RFC-1230: review findings — scoped droppedSections to namespaced headings, warn on unreadable spec, fail-fast merge on corrupt frontmatter, CHANGE_SUMMARY dedupe</item>
|
|
13
|
+
<item>RFC-1234: V-LS-09 drift fixtures (AC-1..3); clean/fence fixtures now produced by runSpecLiveMerge so spec bytes are canonical under replay comparison.</item>
|
|
14
|
+
</CHANGE_SUMMARY>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
18
|
+
import fs from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import { runSpecLiveValidate } from "./live-spec-validate.ts";
|
|
22
|
+
import { runSpecLiveMerge } from "./live-spec-merge.ts";
|
|
23
|
+
import type { ForgeRuntimeContext } from "../../src/types.ts";
|
|
24
|
+
|
|
25
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
26
|
+
return {
|
|
27
|
+
workspaceRoot,
|
|
28
|
+
logger: {
|
|
29
|
+
section: () => {},
|
|
30
|
+
info: () => {},
|
|
31
|
+
warn: () => {},
|
|
32
|
+
error: () => {},
|
|
33
|
+
success: () => {},
|
|
34
|
+
},
|
|
35
|
+
dryRun: false,
|
|
36
|
+
outputFormat: "json",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function archivedRfc(id: string, liveSpec = true, design = "### Section\n\nBody.") {
|
|
41
|
+
return `---
|
|
42
|
+
id: ${id}
|
|
43
|
+
title: "Archived RFC ${id}"
|
|
44
|
+
status: implemented
|
|
45
|
+
kind: architecture
|
|
46
|
+
scope: workspace
|
|
47
|
+
owners:
|
|
48
|
+
- architecture
|
|
49
|
+
reviewers:
|
|
50
|
+
- human:test
|
|
51
|
+
createdAt: 2026-08-06
|
|
52
|
+
updatedAt: 2026-08-06
|
|
53
|
+
implementedAt: 2026-08-06
|
|
54
|
+
versionBump: patch
|
|
55
|
+
${liveSpec ? "liveSpec: true\n" : ""}packagesImpacted:
|
|
56
|
+
- packages/forge
|
|
57
|
+
commands:
|
|
58
|
+
proposed: []
|
|
59
|
+
added: []
|
|
60
|
+
changed: []
|
|
61
|
+
removed: []
|
|
62
|
+
appsImpacted: []
|
|
63
|
+
successSignals: []
|
|
64
|
+
nonGoals: []
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
# ${id}
|
|
68
|
+
|
|
69
|
+
## Design
|
|
70
|
+
|
|
71
|
+
${design}
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function specFixture(opts: {
|
|
76
|
+
domain: string;
|
|
77
|
+
historyEntries: string;
|
|
78
|
+
lastMergedRfc: string;
|
|
79
|
+
sections: string;
|
|
80
|
+
}) {
|
|
81
|
+
return `---
|
|
82
|
+
domain: ${opts.domain}
|
|
83
|
+
title: "Living Spec: ${opts.domain}"
|
|
84
|
+
lastMergedRfc: ${opts.lastMergedRfc}
|
|
85
|
+
updatedAt: 2026-08-06
|
|
86
|
+
createdAt: 2026-08-06
|
|
87
|
+
history:
|
|
88
|
+
${opts.historyEntries}---
|
|
89
|
+
|
|
90
|
+
# Living Spec: ${opts.domain}
|
|
91
|
+
|
|
92
|
+
## Overview
|
|
93
|
+
|
|
94
|
+
${opts.sections}
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ENTRY_9001 = " - rfc: RFC-9001\n mergedAt: 2026-08-06\n operation: created\n";
|
|
99
|
+
|
|
100
|
+
async function setupWorkspace(): Promise<string> {
|
|
101
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "spec-live-validate-"));
|
|
102
|
+
const rfcDir = path.join(tmpDir, "docs/rfcs/archive/implemented");
|
|
103
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
104
|
+
await fs.writeFile(path.join(rfcDir, "rfc-9001-merged.md"), archivedRfc("RFC-9001"));
|
|
105
|
+
await fs.writeFile(path.join(rfcDir, "rfc-9002-unmerged.md"), archivedRfc("RFC-9002"));
|
|
106
|
+
await fs.mkdir(path.join(tmpDir, "docs/specs/live"), { recursive: true });
|
|
107
|
+
return tmpDir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("spec.live.validate (V-LS-06/07/08)", () => {
|
|
111
|
+
let tmpDir: string;
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
tmpDir = await setupWorkspace();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(async () => {
|
|
118
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("emits V-LS-06 per extra occurrence of a duplicated namespaced heading", async () => {
|
|
122
|
+
await fs.writeFile(
|
|
123
|
+
path.join(tmpDir, "docs/specs/live/forge.md"),
|
|
124
|
+
specFixture({
|
|
125
|
+
domain: "forge",
|
|
126
|
+
lastMergedRfc: "RFC-9001",
|
|
127
|
+
historyEntries: ENTRY_9001,
|
|
128
|
+
sections: [
|
|
129
|
+
"### Alpha (RFC-9001)\n\na.",
|
|
130
|
+
"### Alpha (RFC-9001)\n\nstale.",
|
|
131
|
+
"### Alpha (RFC-9001)\n\nstalest.",
|
|
132
|
+
].join("\n\n"),
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
137
|
+
const v6 = result.data?.violations.filter((v) => v.rule === "V-LS-06") ?? [];
|
|
138
|
+
// 3 occurrences → 2 "extra occurrence" diagnostics.
|
|
139
|
+
expect(v6.length).toBe(2);
|
|
140
|
+
expect(v6[0]?.message).toContain("Alpha (RFC-9001)");
|
|
141
|
+
expect(v6[0]?.message).toContain("spec.live.rebuild");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("emits V-LS-07 once per duplicated history rfc", async () => {
|
|
145
|
+
await fs.writeFile(
|
|
146
|
+
path.join(tmpDir, "docs/specs/live/forge.md"),
|
|
147
|
+
specFixture({
|
|
148
|
+
domain: "forge",
|
|
149
|
+
lastMergedRfc: "RFC-9001",
|
|
150
|
+
historyEntries:
|
|
151
|
+
" - rfc: RFC-9001\n mergedAt: 2026-08-06\n operation: created\n" +
|
|
152
|
+
" - rfc: RFC-9001\n mergedAt: 2026-08-07\n operation: modified\n",
|
|
153
|
+
sections: "### Alpha (RFC-9001)\n\na.",
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
158
|
+
const v7 = result.data?.violations.filter((v) => v.rule === "V-LS-07") ?? [];
|
|
159
|
+
expect(v7.length).toBe(1);
|
|
160
|
+
expect(v7[0]?.message).toContain("RFC-9001");
|
|
161
|
+
expect(v7[0]?.message).toContain("spec.live.rebuild");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("emits V-LS-08 when an archived implemented liveSpec RFC is absent from history", async () => {
|
|
165
|
+
await fs.writeFile(
|
|
166
|
+
path.join(tmpDir, "docs/specs/live/forge.md"),
|
|
167
|
+
specFixture({
|
|
168
|
+
domain: "forge",
|
|
169
|
+
lastMergedRfc: "RFC-9001",
|
|
170
|
+
historyEntries: ENTRY_9001,
|
|
171
|
+
sections: "### Alpha (RFC-9001)\n\na.",
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
176
|
+
const v8 = result.data?.violations.filter((v) => v.rule === "V-LS-08") ?? [];
|
|
177
|
+
// RFC-9002 is archived+implemented+liveSpec but never merged into forge.md.
|
|
178
|
+
expect(v8.length).toBe(1);
|
|
179
|
+
expect(v8[0]?.message).toContain("RFC-9002");
|
|
180
|
+
expect(v8[0]?.message).toContain("spec.live.merge --id RFC-9002");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("emits V-LS-08 when the domain spec file is missing entirely", async () => {
|
|
184
|
+
// No spec file at all — both RFCs lack coverage for domain "forge".
|
|
185
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
186
|
+
const v8 = result.data?.violations.filter((v) => v.rule === "V-LS-08") ?? [];
|
|
187
|
+
expect(v8.length).toBe(2);
|
|
188
|
+
expect(v8[0]?.message).toContain("does not exist");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not emit V-LS-06 for ### (RFC-NNNN) lines inside fenced blocks (RFC-1232 AC-2)", async () => {
|
|
192
|
+
// The spec is produced by the real merge writer so V-LS-09 sees a
|
|
193
|
+
// byte-canonical file — fence-interior ### lines come from RFC-9001's
|
|
194
|
+
// Design and replay identically.
|
|
195
|
+
const fencedDesign = [
|
|
196
|
+
"### Alpha",
|
|
197
|
+
"",
|
|
198
|
+
"a.",
|
|
199
|
+
"",
|
|
200
|
+
"```md",
|
|
201
|
+
"### Alpha (RFC-9001)",
|
|
202
|
+
"### Alpha (RFC-9001)",
|
|
203
|
+
"```",
|
|
204
|
+
"",
|
|
205
|
+
"~~~",
|
|
206
|
+
"### Beta (RFC-9002)",
|
|
207
|
+
"~~~",
|
|
208
|
+
].join("\n");
|
|
209
|
+
await fs.writeFile(
|
|
210
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9001-merged.md"),
|
|
211
|
+
archivedRfc("RFC-9001", true, fencedDesign),
|
|
212
|
+
);
|
|
213
|
+
await fs.writeFile(
|
|
214
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9002-unmerged.md"),
|
|
215
|
+
archivedRfc("RFC-9002", true, "### Beta\n\nb."),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const ctx = makeContext(tmpDir);
|
|
219
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9001" } }, ctx);
|
|
220
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9002" } }, ctx);
|
|
221
|
+
|
|
222
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, ctx);
|
|
223
|
+
const v6 = result.data?.violations.filter((v) => v.rule === "V-LS-06") ?? [];
|
|
224
|
+
// Fence-interior occurrences do not count — both real headings are unique.
|
|
225
|
+
expect(v6).toEqual([]);
|
|
226
|
+
expect(result.exitCode).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("produces no V-LS-06/07/08 on a clean spec", async () => {
|
|
230
|
+
await fs.writeFile(
|
|
231
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9001-merged.md"),
|
|
232
|
+
archivedRfc("RFC-9001", true, "### Alpha\n\na."),
|
|
233
|
+
);
|
|
234
|
+
await fs.writeFile(
|
|
235
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9002-unmerged.md"),
|
|
236
|
+
archivedRfc("RFC-9002", true, "### Beta\n\nb."),
|
|
237
|
+
);
|
|
238
|
+
const ctx = makeContext(tmpDir);
|
|
239
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9001" } }, ctx);
|
|
240
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9002" } }, ctx);
|
|
241
|
+
|
|
242
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, ctx);
|
|
243
|
+
const dupOrGap = result.data?.violations.filter((v) =>
|
|
244
|
+
["V-LS-06", "V-LS-07", "V-LS-08"].includes(v.rule),
|
|
245
|
+
) ?? [];
|
|
246
|
+
expect(dupOrGap).toEqual([]);
|
|
247
|
+
expect(result.exitCode).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("spec.live.validate (V-LS-09 content drift — RFC-1234)", () => {
|
|
252
|
+
let tmpDir: string;
|
|
253
|
+
|
|
254
|
+
beforeEach(async () => {
|
|
255
|
+
tmpDir = await setupWorkspace();
|
|
256
|
+
await fs.writeFile(
|
|
257
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9001-merged.md"),
|
|
258
|
+
archivedRfc("RFC-9001", true, "### Alpha\n\na."),
|
|
259
|
+
);
|
|
260
|
+
await fs.writeFile(
|
|
261
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9002-unmerged.md"),
|
|
262
|
+
archivedRfc("RFC-9002", true, "### Beta\n\nb."),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
afterEach(async () => {
|
|
267
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
async function mergedSpec(): Promise<string> {
|
|
271
|
+
const ctx = makeContext(tmpDir);
|
|
272
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9001" } }, ctx);
|
|
273
|
+
await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9002" } }, ctx);
|
|
274
|
+
return path.join(tmpDir, "docs/specs/live/forge.md");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it("AC-1: emits a V-LS-09 error naming the domain and repair path on drift", async () => {
|
|
278
|
+
const specPath = await mergedSpec();
|
|
279
|
+
await fs.appendFile(specPath, "\nStale hand edit.\n");
|
|
280
|
+
|
|
281
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
282
|
+
const v9 = result.data?.violations.filter((v) => v.rule === "V-LS-09") ?? [];
|
|
283
|
+
expect(v9.length).toBe(1);
|
|
284
|
+
expect(v9[0]?.severity).toBe("error");
|
|
285
|
+
expect(v9[0]?.message).toContain("forge.md");
|
|
286
|
+
expect(v9[0]?.message).toContain("spec.live.rebuild --domain forge");
|
|
287
|
+
expect(result.exitCode).toBe(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("AC-2: emits no V-LS-09 when the spec matches its replay", async () => {
|
|
291
|
+
await mergedSpec();
|
|
292
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
293
|
+
const v9 = result.data?.violations.filter((v) => v.rule === "V-LS-09") ?? [];
|
|
294
|
+
expect(v9).toEqual([]);
|
|
295
|
+
expect(result.exitCode).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("AC-3: warns on an unreadable history RFC and still runs the drift comparison", async () => {
|
|
299
|
+
await mergedSpec();
|
|
300
|
+
// Deleting the RFC file leaves history[] pointing at an unreadable id —
|
|
301
|
+
// replay skips it, so the spec's RFC-9002 sections are unreproducible.
|
|
302
|
+
await fs.rm(path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-9002-unmerged.md"));
|
|
303
|
+
|
|
304
|
+
const result = await runSpecLiveValidate({ argv: [], flags: {} }, makeContext(tmpDir));
|
|
305
|
+
const v9warn = result.data?.violations.filter(
|
|
306
|
+
(v) => v.rule === "V-LS-09" && v.severity === "warning",
|
|
307
|
+
) ?? [];
|
|
308
|
+
expect(v9warn.length).toBe(1);
|
|
309
|
+
expect(v9warn[0]?.message).toContain("RFC-9002");
|
|
310
|
+
// The drift comparison still ran — the un-replayable body diverges.
|
|
311
|
+
const v9err = result.data?.violations.filter(
|
|
312
|
+
(v) => v.rule === "V-LS-09" && v.severity === "error",
|
|
313
|
+
) ?? [];
|
|
314
|
+
expect(v9err.length).toBe(1);
|
|
315
|
+
expect(result.exitCode).toBe(1);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
2
|
<MODULE_CONTRACT>
|
|
3
3
|
<purpose>spec.live.validate handler — validates all living specs in docs/specs/live/
|
|
4
|
-
with rules V-LS-01..
|
|
4
|
+
with rules V-LS-01..09 (RFC-0711, RFC-1230, RFC-1234). Detection layer only — repair routes
|
|
5
|
+
to spec.live.rebuild (V-LS-06/07/09) or spec.live.merge --id (V-LS-08), never hand-edits.</purpose>
|
|
5
6
|
<non-goals>
|
|
6
7
|
<item>Do not merge or list — use spec.live.merge / spec.live.list.</item>
|
|
8
|
+
<item>Do not repair — validators are read-only; spec.live.rebuild is the repair path.</item>
|
|
7
9
|
</non-goals>
|
|
8
10
|
</MODULE_CONTRACT>
|
|
9
11
|
<CHANGE_SUMMARY>
|
|
10
12
|
<item>RFC-0711: initial spec.live.validate handler with V-LS-01..05 rules.</item>
|
|
13
|
+
<item>RFC-1230: added V-LS-06 (duplicate namespaced headings), V-LS-07 (duplicate history RFCs), V-LS-08 (archive coverage — implemented liveSpec RFC absent from spec history).</item>
|
|
14
|
+
<item>RFC-1230: review findings — scoped droppedSections to namespaced headings, warn on unreadable spec, fail-fast merge on corrupt frontmatter, CHANGE_SUMMARY dedupe</item>
|
|
15
|
+
<item>RFC-1234: V-LS-09 content-drift rule — replays each spec's deduplicated history via the extracted projectLiveSpec core and compares committed bytes modulo updatedAt; LivingSpecViolation gains a severity channel (error findings drive the exit code, warning findings report unreadable history RFCs without failing the run).</item>
|
|
11
16
|
</CHANGE_SUMMARY>
|
|
12
17
|
*/
|
|
13
18
|
|
|
@@ -27,6 +32,8 @@ import type {
|
|
|
27
32
|
SpecLiveValidateResult,
|
|
28
33
|
LivingSpecHistoryEntry,
|
|
29
34
|
} from "./live-spec-types.ts";
|
|
35
|
+
import { deriveDomain, parseHeadings } from "./live-spec-shared.ts";
|
|
36
|
+
import { projectLiveSpec } from "./live-spec-rebuild.ts";
|
|
30
37
|
|
|
31
38
|
const LIVE_SPECS_DIR = "docs/specs/live";
|
|
32
39
|
|
|
@@ -44,8 +51,10 @@ export async function runSpecLiveValidate(
|
|
|
44
51
|
let specsChecked = 0;
|
|
45
52
|
|
|
46
53
|
const domains = new Set<string>();
|
|
54
|
+
const specsByDomain = new Map<string, { file: string; historyRfcIds: Set<string> }>();
|
|
47
55
|
|
|
48
56
|
const archivedRfcIds = new Set<string>();
|
|
57
|
+
const archivedImplementedLiveSpecRfcs: Array<{ id: string; domain: string }> = [];
|
|
49
58
|
const rfcFiles = await listRfcFiles(rfcDir);
|
|
50
59
|
for (const file of rfcFiles) {
|
|
51
60
|
const parsed = await readAndParseRfc(rfcDir, file);
|
|
@@ -55,6 +64,14 @@ export async function runSpecLiveValidate(
|
|
|
55
64
|
if (id && (status === "implemented" || status === "rejected" || status === "superseded")) {
|
|
56
65
|
archivedRfcIds.add(id);
|
|
57
66
|
}
|
|
67
|
+
// V-LS-08 tracks only RFCs physically under archive/implemented/ — an
|
|
68
|
+
// implemented-but-not-yet-archived RFC must not fire a false positive.
|
|
69
|
+
if (id && file.startsWith("archive/implemented/") && parsed.parsed.frontmatter["liveSpec"]) {
|
|
70
|
+
const liveSpecDomain = deriveDomain(parsed.parsed.frontmatter);
|
|
71
|
+
if (liveSpecDomain) {
|
|
72
|
+
archivedImplementedLiveSpecRfcs.push({ id, domain: liveSpecDomain });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
|
|
@@ -78,6 +95,8 @@ export async function runSpecLiveValidate(
|
|
|
78
95
|
const files = (await fs.readdir(liveSpecsDir)).map((e) => e.name);
|
|
79
96
|
const specFiles = files.filter((f) => f.endsWith(".md") && f !== "README.md");
|
|
80
97
|
|
|
98
|
+
const parsedSpecPaths: Array<{ file: string; filePath: string; domain: string }> = [];
|
|
99
|
+
|
|
81
100
|
for (const file of specFiles) {
|
|
82
101
|
specsChecked++;
|
|
83
102
|
const filePath = path.join(liveSpecsDir, file);
|
|
@@ -119,6 +138,7 @@ export async function runSpecLiveValidate(
|
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
const history = Array.isArray(fm["history"]) ? (fm["history"] as LivingSpecHistoryEntry[]) : [];
|
|
141
|
+
const historyRfcIds = new Set<string>();
|
|
122
142
|
for (const entry of history) {
|
|
123
143
|
if (entry.rfc && !archivedRfcIds.has(entry.rfc)) {
|
|
124
144
|
violations.push({
|
|
@@ -127,6 +147,44 @@ export async function runSpecLiveValidate(
|
|
|
127
147
|
domain,
|
|
128
148
|
});
|
|
129
149
|
}
|
|
150
|
+
if (entry.rfc) historyRfcIds.add(entry.rfc);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// V-LS-06: duplicate RFC-namespaced headings (###+ ending in ` (RFC-XXXX)`)
|
|
154
|
+
// — one diagnostic per extra occurrence. Full namespaced text is compared,
|
|
155
|
+
// so distinct RFCs never collide.
|
|
156
|
+
const headingCounts = new Map<string, number>();
|
|
157
|
+
for (const h of parseHeadings(content.slice(match[0].length))) {
|
|
158
|
+
if (!/\(RFC-\d{4}\)$/.test(h.text)) continue;
|
|
159
|
+
const seen = (headingCounts.get(h.text) ?? 0) + 1;
|
|
160
|
+
headingCounts.set(h.text, seen);
|
|
161
|
+
if (seen > 1) {
|
|
162
|
+
violations.push({
|
|
163
|
+
rule: "V-LS-06",
|
|
164
|
+
message: `${file}: duplicate section "${h.text}" (occurrence ${seen}) — repair via spec.live.rebuild`,
|
|
165
|
+
domain,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// V-LS-07: duplicate history[].rfc — one diagnostic per duplicated id.
|
|
171
|
+
const rfcCounts = new Map<string, number>();
|
|
172
|
+
for (const entry of history) {
|
|
173
|
+
if (!entry.rfc) continue;
|
|
174
|
+
rfcCounts.set(entry.rfc, (rfcCounts.get(entry.rfc) ?? 0) + 1);
|
|
175
|
+
}
|
|
176
|
+
for (const [rfcId, count] of rfcCounts) {
|
|
177
|
+
if (count > 1) {
|
|
178
|
+
violations.push({
|
|
179
|
+
rule: "V-LS-07",
|
|
180
|
+
message: `${file}: history contains ${count} entries for "${rfcId}" — repair via spec.live.rebuild`,
|
|
181
|
+
domain,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (domain) {
|
|
187
|
+
specsByDomain.set(domain, { file, historyRfcIds });
|
|
130
188
|
}
|
|
131
189
|
|
|
132
190
|
if (domain) {
|
|
@@ -138,10 +196,56 @@ export async function runSpecLiveValidate(
|
|
|
138
196
|
});
|
|
139
197
|
}
|
|
140
198
|
domains.add(domain);
|
|
199
|
+
parsedSpecPaths.push({ file, filePath, domain });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// V-LS-08 (coverage): every archived implemented RFC carrying liveSpec must
|
|
204
|
+
// appear in the history[] of its domain's living spec — also fires when that
|
|
205
|
+
// domain's spec file does not exist. Repair: spec.live.merge --id <rfc>.
|
|
206
|
+
for (const { id, domain } of archivedImplementedLiveSpecRfcs) {
|
|
207
|
+
const spec = specsByDomain.get(domain);
|
|
208
|
+
if (!spec) {
|
|
209
|
+
violations.push({
|
|
210
|
+
rule: "V-LS-08",
|
|
211
|
+
message: `${id}: living spec for domain "${domain}" does not exist — repair via spec.live.merge --id ${id}`,
|
|
212
|
+
domain,
|
|
213
|
+
});
|
|
214
|
+
} else if (!spec.historyRfcIds.has(id)) {
|
|
215
|
+
violations.push({
|
|
216
|
+
rule: "V-LS-08",
|
|
217
|
+
message: `${spec.file}: archived RFC "${id}" (liveSpec) is absent from history[] — repair via spec.live.merge --id ${id}`,
|
|
218
|
+
domain,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// V-LS-09 (content drift): replay each spec's deduplicated history via the
|
|
224
|
+
// same projection core rebuild uses, compare against committed bytes modulo
|
|
225
|
+
// updatedAt (RFC-1234 — DNA-58 closure for living specs). Specs that failed
|
|
226
|
+
// frontmatter parse are already fail-closed via V-LS-01 and skip here.
|
|
227
|
+
for (const { file, filePath, domain } of parsedSpecPaths) {
|
|
228
|
+
const proj = await projectLiveSpec(filePath, domain, rfcFiles, rfcDir);
|
|
229
|
+
if (proj.kind !== "projected") continue;
|
|
230
|
+
for (const rfcId of proj.unreadableRfcs) {
|
|
231
|
+
violations.push({
|
|
232
|
+
rule: "V-LS-09",
|
|
233
|
+
severity: "warning",
|
|
234
|
+
message: `${file}: history entry "${rfcId}" unreadable, not implemented, or lacks ## Design — excluded from replay`,
|
|
235
|
+
domain,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (proj.projection !== proj.content) {
|
|
239
|
+
violations.push({
|
|
240
|
+
rule: "V-LS-09",
|
|
241
|
+
severity: "error",
|
|
242
|
+
message: `${file}: spec content diverges from replay of history[] — repair via spec.live.rebuild --domain ${domain}`,
|
|
243
|
+
domain,
|
|
244
|
+
});
|
|
141
245
|
}
|
|
142
246
|
}
|
|
143
247
|
|
|
144
|
-
const hasFailures = violations.
|
|
248
|
+
const hasFailures = violations.some((v) => v.severity !== "warning");
|
|
145
249
|
const result: SpecLiveValidateResult = {
|
|
146
250
|
command: "spec.live.validate",
|
|
147
251
|
status: hasFailures ? "fail" : "pass",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Prove consumer-aware dependsOn filtering in spec.materialize (RFC-1240): foreign-owned nodes are excluded from the front and satisfy dependents; same-consumer and undeclared-identity nodes keep local gating.</purpose>
|
|
4
|
+
<non-goals><item>Do not duplicate spec.validate rule coverage — that lives in spec-validate.test.ts.</item></non-goals>
|
|
5
|
+
</MODULE_CONTRACT>
|
|
6
|
+
<CHANGE_SUMMARY><item>RFC-1240: initial coverage — foreign dep satisfaction, same-consumer gating, identity-missing skip, --consumer flag precedence, legacy snapshot compatibility.</item></CHANGE_SUMMARY>
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
13
|
+
import type { ForgeRuntimeContext } from "../../src/types.ts";
|
|
14
|
+
import { runSpecMaterialize } from "./spec-materialize.ts";
|
|
15
|
+
|
|
16
|
+
const roots: string[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function makeWorkspace(opts: { consumer?: string } = {}): Promise<string> {
|
|
23
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "spec-materialize-"));
|
|
24
|
+
roots.push(workspaceRoot);
|
|
25
|
+
await fs.mkdir(path.join(workspaceRoot, "docs/rfcs"), { recursive: true });
|
|
26
|
+
if (opts.consumer !== undefined) {
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(workspaceRoot, "forge.yaml"),
|
|
29
|
+
`schema: forge/config@1\nproject:\n name: test-ws\n consumer: ${opts.consumer}\npaths: {}\n`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return workspaceRoot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeSpec(workspaceRoot: string, id: string, rfcs: string): Promise<void> {
|
|
36
|
+
const specDir = path.join(workspaceRoot, "docs/specs", id);
|
|
37
|
+
await fs.mkdir(specDir, { recursive: true });
|
|
38
|
+
await fs.writeFile(path.join(specDir, "forge-spec.yaml"), `schema: forge/spec@1
|
|
39
|
+
id: ${id}
|
|
40
|
+
title: ${id}
|
|
41
|
+
version: 1.0.0
|
|
42
|
+
status: accepted
|
|
43
|
+
reviewers: []
|
|
44
|
+
sourceNote: test fixture
|
|
45
|
+
vendoredAt: 2026-10-09
|
|
46
|
+
documents: {}
|
|
47
|
+
decisions: []
|
|
48
|
+
rfcs:
|
|
49
|
+
${rfcs}waves:
|
|
50
|
+
- id: 1
|
|
51
|
+
name: Test
|
|
52
|
+
goal: Exercise consumer filtering
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MULTI_SPEC = ` - id: V02
|
|
57
|
+
title: Consumer scoped node
|
|
58
|
+
dependsOn: []
|
|
59
|
+
wave: 1
|
|
60
|
+
sources: []
|
|
61
|
+
consumers:
|
|
62
|
+
- personal-agent
|
|
63
|
+
- id: V04
|
|
64
|
+
title: Dependent node
|
|
65
|
+
dependsOn:
|
|
66
|
+
- V02
|
|
67
|
+
wave: 1
|
|
68
|
+
sources: []
|
|
69
|
+
- id: V05
|
|
70
|
+
title: Pinned node
|
|
71
|
+
dependsOn: []
|
|
72
|
+
wave: 1
|
|
73
|
+
sources: []
|
|
74
|
+
materializedAs: RFC-9001
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
function ctx(workspaceRoot: string): ForgeRuntimeContext {
|
|
78
|
+
return {
|
|
79
|
+
workspaceRoot,
|
|
80
|
+
dryRun: false,
|
|
81
|
+
outputFormat: "json",
|
|
82
|
+
logger: { section() {}, info() {}, warn() {}, error() {}, success() {} },
|
|
83
|
+
} as ForgeRuntimeContext;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("spec.materialize consumer filtering (RFC-1240)", () => {
|
|
87
|
+
it("treats a foreign-consumer dependency as satisfied and excludes the foreign node from the front", async () => {
|
|
88
|
+
const workspaceRoot = await makeWorkspace({ consumer: "werkstatt" });
|
|
89
|
+
await writeSpec(workspaceRoot, "multi", MULTI_SPEC);
|
|
90
|
+
const result = await runSpecMaterialize({ argv: [], flags: { spec: "multi" } }, ctx(workspaceRoot));
|
|
91
|
+
expect(result.exitCode).toBe(0);
|
|
92
|
+
const data = result.data as {
|
|
93
|
+
created: Array<{ node: string; rfc: string }>;
|
|
94
|
+
front: string[];
|
|
95
|
+
foreignOwned: string[];
|
|
96
|
+
};
|
|
97
|
+
expect(data.created).toHaveLength(1);
|
|
98
|
+
expect(data.created[0]!.node).toBe("V04");
|
|
99
|
+
expect(data.created[0]!.rfc).toBe("RFC-0001");
|
|
100
|
+
expect(data.foreignOwned).toEqual(["V02"]);
|
|
101
|
+
expect(data.front).toEqual([]);
|
|
102
|
+
// AC-5: pre-existing materializedAs pins survive the write-back untouched.
|
|
103
|
+
const rewritten = await fs.readFile(
|
|
104
|
+
path.join(workspaceRoot, "docs/specs/multi/forge-spec.yaml"), "utf8");
|
|
105
|
+
expect(rewritten).toContain("materializedAs: RFC-9001");
|
|
106
|
+
expect(rewritten).toContain("materializedAs: RFC-0001");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps same-consumer dependencies blocking until implemented", async () => {
|
|
110
|
+
const workspaceRoot = await makeWorkspace({ consumer: "werkstatt" });
|
|
111
|
+
await writeSpec(workspaceRoot, "same", ` - id: V02
|
|
112
|
+
title: Locally owned node
|
|
113
|
+
dependsOn: []
|
|
114
|
+
wave: 1
|
|
115
|
+
sources: []
|
|
116
|
+
consumers:
|
|
117
|
+
- werkstatt
|
|
118
|
+
- id: V04
|
|
119
|
+
title: Dependent node
|
|
120
|
+
dependsOn:
|
|
121
|
+
- V02
|
|
122
|
+
wave: 1
|
|
123
|
+
sources: []
|
|
124
|
+
`);
|
|
125
|
+
const result = await runSpecMaterialize({ argv: [], flags: { spec: "same", nodes: "V04" } }, ctx(workspaceRoot));
|
|
126
|
+
expect(result.exitCode).toBe(1);
|
|
127
|
+
const data = result.data as { skipped: Array<{ node: string; reason: string }> };
|
|
128
|
+
expect(data.skipped).toEqual([{ node: "V04", reason: "blocked by V02" }]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("reports 'consumer identity not configured' when consumers are declared without a local identity", async () => {
|
|
132
|
+
const workspaceRoot = await makeWorkspace();
|
|
133
|
+
await writeSpec(workspaceRoot, "noidentity", MULTI_SPEC);
|
|
134
|
+
const result = await runSpecMaterialize({ argv: [], flags: { spec: "noidentity", nodes: "V02" } }, ctx(workspaceRoot));
|
|
135
|
+
expect(result.exitCode).toBe(1);
|
|
136
|
+
const data = result.data as {
|
|
137
|
+
skipped: Array<{ node: string; reason: string }>;
|
|
138
|
+
foreignOwned: string[];
|
|
139
|
+
};
|
|
140
|
+
expect(data.skipped).toEqual([{ node: "V02", reason: "consumer identity not configured" }]);
|
|
141
|
+
expect(data.foreignOwned).toEqual(["V02"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("lets --consumer override forge.yaml and claims the node as locally owned", async () => {
|
|
145
|
+
const workspaceRoot = await makeWorkspace({ consumer: "werkstatt" });
|
|
146
|
+
await writeSpec(workspaceRoot, "flagwin", MULTI_SPEC);
|
|
147
|
+
const result = await runSpecMaterialize(
|
|
148
|
+
{ argv: [], flags: { spec: "flagwin", consumer: "personal-agent" } },
|
|
149
|
+
ctx(workspaceRoot),
|
|
150
|
+
);
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
152
|
+
const data = result.data as {
|
|
153
|
+
created: Array<{ node: string }>;
|
|
154
|
+
foreignOwned: string[];
|
|
155
|
+
};
|
|
156
|
+
expect(data.created.map((c) => c.node)).toEqual(["V02"]);
|
|
157
|
+
expect(data.foreignOwned).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("reports 'foreign-owned by <consumers>' when the local identity is set but not a member", async () => {
|
|
161
|
+
const workspaceRoot = await makeWorkspace({ consumer: "werkstatt" });
|
|
162
|
+
await writeSpec(workspaceRoot, "foreign", MULTI_SPEC);
|
|
163
|
+
const result = await runSpecMaterialize({ argv: [], flags: { spec: "foreign", nodes: "V02" } }, ctx(workspaceRoot));
|
|
164
|
+
expect(result.exitCode).toBe(1);
|
|
165
|
+
const data = result.data as { skipped: Array<{ node: string; reason: string }> };
|
|
166
|
+
expect(data.skipped).toEqual([{ node: "V02", reason: "foreign-owned by personal-agent" }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps consumer-free snapshots fully backward compatible", async () => {
|
|
170
|
+
const workspaceRoot = await makeWorkspace();
|
|
171
|
+
await writeSpec(workspaceRoot, "legacy", ` - id: V01
|
|
172
|
+
title: Plain node
|
|
173
|
+
dependsOn: []
|
|
174
|
+
wave: 1
|
|
175
|
+
sources: []
|
|
176
|
+
- id: V02
|
|
177
|
+
title: Dependent plain node
|
|
178
|
+
dependsOn:
|
|
179
|
+
- V01
|
|
180
|
+
wave: 1
|
|
181
|
+
sources: []
|
|
182
|
+
`);
|
|
183
|
+
const result = await runSpecMaterialize({ argv: [], flags: { spec: "legacy" } }, ctx(workspaceRoot));
|
|
184
|
+
expect(result.exitCode).toBe(0);
|
|
185
|
+
const data = result.data as {
|
|
186
|
+
created: Array<{ node: string }>;
|
|
187
|
+
foreignOwned: string[];
|
|
188
|
+
};
|
|
189
|
+
expect(data.created.map((c) => c.node)).toEqual(["V01"]);
|
|
190
|
+
expect(data.foreignOwned).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
});
|