@warpgogol/forge 2.8.0 → 2.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +23 -13
- package/README.uk.md +19 -12
- package/dist/os/session/handlers/validate.d.ts.map +1 -1
- package/dist/os/session/handlers/validate.js +23 -1
- package/dist/os/session/handlers/validate.js.map +1 -1
- package/dist/os/session/types.d.ts +31 -1
- package/dist/os/session/types.d.ts.map +1 -1
- package/dist/os/session/types.js +7 -0
- package/dist/os/session/types.js.map +1 -1
- package/os/adr/adr.module.ts +158 -0
- package/os/adr/frontmatter-io.ts +80 -0
- package/os/adr/handlers/archive.ts +244 -0
- package/os/adr/handlers/implement-stamp.ts +331 -0
- package/os/adr/handlers/list-create.ts +207 -0
- package/os/adr/handlers/validate.test.ts +232 -0
- package/os/adr/handlers/validate.ts +429 -0
- package/os/adr/index.ts +33 -0
- package/os/adr/types.ts +141 -0
- package/os/audit/audit.module.ts +48 -0
- package/os/audit/frontmatter-io.ts +84 -0
- package/os/audit/handlers/archive.ts +239 -0
- package/os/audit/index.ts +23 -0
- package/os/audit/types.ts +29 -0
- package/os/compass/compass.module.ts +186 -0
- package/os/compass/handlers/compass-audit-handler.ts +382 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
- package/os/compass/handlers/compass-inventory-handler.ts +294 -0
- package/os/compass/handlers/compass-inventory.ts +521 -0
- package/os/compass/handlers/git-revision.ts +137 -0
- package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
- package/os/compass/handlers/resolve-scan-root.ts +79 -0
- package/os/compass/index.ts +33 -0
- package/os/core/core.module.ts +948 -0
- package/os/core/handlers/assets-check.ts +122 -0
- package/os/core/handlers/assets-helpers.ts +174 -0
- package/os/core/handlers/assets-list.ts +99 -0
- package/os/core/handlers/build.ts +155 -0
- package/os/core/handlers/determinism-check.ts +354 -0
- package/os/core/handlers/dev.ts +127 -0
- package/os/core/handlers/invariant-engine.test.ts +681 -0
- package/os/core/handlers/knowledge-compact.ts +248 -0
- package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
- package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
- package/os/core/handlers/note-link-validate.test.ts +117 -0
- package/os/core/handlers/note-orphan-detect.test.ts +94 -0
- package/os/core/handlers/package-health.test.ts +226 -0
- package/os/core/handlers/package-health.ts +231 -0
- package/os/core/handlers/pinned-check.ts +200 -0
- package/os/core/handlers/pinned-init.ts +333 -0
- package/os/core/handlers/pinned-types.ts +50 -0
- package/os/core/handlers/pinned-validate.ts +301 -0
- package/os/core/handlers/profile-resolve.ts +92 -0
- package/os/core/handlers/release-prepare.ts +294 -0
- package/os/core/handlers/release-publish.ts +211 -0
- package/os/core/handlers/validate.ts +262 -0
- package/os/core/handlers/workspace-deps.ts +55 -0
- package/os/core/index.ts +13 -0
- package/os/exploration/exploration.module.ts +90 -0
- package/os/exploration/frontmatter-io.ts +77 -0
- package/os/exploration/handlers/archive.ts +159 -0
- package/os/exploration/handlers/list.ts +77 -0
- package/os/exploration/handlers/show.ts +107 -0
- package/os/exploration/index.ts +25 -0
- package/os/exploration/types.ts +65 -0
- package/os/mission/handlers/archive.test.ts +391 -0
- package/os/mission/handlers/archive.ts +453 -0
- package/os/mission/index.ts +1 -0
- package/os/mission/mission.module.ts +48 -0
- package/os/mission/types.ts +41 -0
- package/os/naming/index.ts +14 -0
- package/os/naming/naming-convention.test.ts +163 -0
- package/os/naming/naming-convention.ts +378 -0
- package/os/naming/naming.module.ts +36 -0
- package/os/notes/index.ts +10 -0
- package/os/notes/notes.module.ts +109 -0
- package/os/plan/frontmatter-io.ts +82 -0
- package/os/plan/handlers/archive.ts +238 -0
- package/os/plan/index.ts +23 -0
- package/os/plan/plan.module.ts +48 -0
- package/os/plan/types.ts +29 -0
- package/os/program/discovery.ts +294 -0
- package/os/program/handlers/complete.ts +419 -0
- package/os/program/handlers/lease.ts +433 -0
- package/os/program/handlers/seal.ts +332 -0
- package/os/program/handlers/validate.ts +213 -0
- package/os/program/lease.ts +156 -0
- package/os/program/program.module.ts +212 -0
- package/os/program/schemas.ts +218 -0
- package/os/program/state.ts +264 -0
- package/os/rfc/acceptance.ts +344 -0
- package/os/rfc/decision-log.ts +310 -0
- package/os/rfc/dna-trace.ts +280 -0
- package/os/rfc/frontmatter-io.test.ts +132 -0
- package/os/rfc/frontmatter-io.ts +130 -0
- package/os/rfc/handlers/archive.ts +300 -0
- package/os/rfc/handlers/check.ts +238 -0
- package/os/rfc/handlers/implement-stamp.ts +515 -0
- package/os/rfc/handlers/index-graph.ts +237 -0
- package/os/rfc/handlers/lifecycle.test.ts +127 -0
- package/os/rfc/handlers/lifecycle.ts +249 -0
- package/os/rfc/handlers/list-create.ts +296 -0
- package/os/rfc/handlers/pipeline-status.ts +173 -0
- package/os/rfc/handlers/shared.ts +121 -0
- package/os/rfc/handlers/supersede-propose.ts +241 -0
- package/os/rfc/handlers/validate-rules.test.ts +891 -0
- package/os/rfc/handlers/validate-rules.ts +984 -0
- package/os/rfc/handlers/validate.ts +166 -0
- package/os/rfc/handlers.ts +19 -0
- package/os/rfc/index.ts +97 -0
- package/os/rfc/rfc.module.ts +416 -0
- package/os/rfc/types.ts +596 -0
- package/os/rfc/verification-evidence.ts +244 -0
- package/os/session/atif-parser.ts +166 -0
- package/os/session/frontmatter-io.ts +163 -0
- package/os/session/handlers/archive.ts +288 -0
- package/os/session/handlers/list.ts +174 -0
- package/os/session/handlers/save.ts +348 -0
- package/os/session/handlers/validate.ts +250 -0
- package/os/session/index.ts +50 -0
- package/os/session/session.module.ts +126 -0
- package/os/session/types.ts +203 -0
- package/os/spec/live-spec-list-show-validate.test.ts +257 -0
- package/os/spec/live-spec-list.ts +78 -0
- package/os/spec/live-spec-merge.test.ts +261 -0
- package/os/spec/live-spec-merge.ts +413 -0
- package/os/spec/live-spec-show.ts +117 -0
- package/os/spec/live-spec-types.ts +93 -0
- package/os/spec/live-spec-validate.ts +170 -0
- package/os/spec/spec-materialize.ts +390 -0
- package/os/spec/spec-schema.ts +174 -0
- package/os/spec/spec-status.ts +284 -0
- package/os/spec/spec-validate.ts +513 -0
- package/os/spec/spec.module.ts +135 -0
- package/os/werkstatt/handlers/lock.ts +168 -0
- package/os/werkstatt/handlers/schema.ts +47 -0
- package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
- package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
- package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
- package/os/werkstatt/index.ts +39 -0
- package/os/werkstatt/werkstatt.module.ts +62 -0
- package/os/workflow/handlers.ts +304 -0
- package/os/workflow/index.ts +28 -0
- package/os/workflow/types.ts +99 -0
- package/os/workflow/workflow.module.ts +54 -0
- package/package.json +3 -3
- package/profiles/godot-csharp.yaml +4 -4
- package/profiles/knowledge-typescript-turborepo.yaml +110 -0
- package/profiles/phaser-turborepo.yaml +5 -5
- package/skills/_shared/fo-session-summary.md +132 -16
- package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
- package/skills/fo/fo-doc-audit/SKILL.md +6 -6
- package/skills/fo/fo-handoff/SKILL.md +5 -0
- package/skills/fo/fo-idea/SKILL.md +1 -1
- package/skills/fo/fo-review/SKILL.md +1 -1
- package/skills/fo/fo-session-retro/SKILL.md +8 -0
- package/skills/fo/fo-session-save/SKILL.md +8 -0
- package/src/cli-output.ts +59 -0
- package/src/config/__tests__/resolve-terminology.test.ts +45 -0
- package/src/config/forge-config.ts +524 -0
- package/src/forge-module.ts +39 -0
- package/src/index.ts +172 -0
- package/src/knowledge/__tests__/promote.test.ts +219 -0
- package/src/knowledge/__tests__/serialize.test.ts +192 -0
- package/src/knowledge/budgets.ts +202 -0
- package/src/knowledge/compact.ts +405 -0
- package/src/knowledge/index.ts +53 -0
- package/src/knowledge/parse.ts +227 -0
- package/src/knowledge/promote.ts +156 -0
- package/src/knowledge/schema.ts +112 -0
- package/src/knowledge/serialize.ts +72 -0
- package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
- package/src/migration-adapters/__tests__/registry.test.ts +131 -0
- package/src/migration-adapters/__tests__/types.test.ts +86 -0
- package/src/migration-adapters/git-utils.ts +71 -0
- package/src/migration-adapters/ignored-files.ts +221 -0
- package/src/migration-adapters/index.ts +21 -0
- package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
- package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
- package/src/migration-adapters/registry.ts +61 -0
- package/src/migration-adapters/types.ts +68 -0
- package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
- package/src/onboarding/agents-generate.ts +443 -0
- package/src/onboarding/create.ts +427 -0
- package/src/onboarding/doctor.ts +1297 -0
- package/src/onboarding/init.ts +337 -0
- package/src/onboarding/invariant-engine.ts +448 -0
- package/src/onboarding/memory-scaffold.ts +189 -0
- package/src/onboarding/nested-agents-generate.ts +93 -0
- package/src/onboarding/nested-agents-templates.ts +233 -0
- package/src/onboarding/profile-validate.ts +139 -0
- package/src/onboarding/scaffold-project.ts +299 -0
- package/src/onboarding/scaffold.ts +143 -0
- package/src/onboarding/upgrade.ts +558 -0
- package/src/onboarding/workspace-discovery.ts +167 -0
- package/src/profiles/__tests__/profile-schema.test.ts +228 -0
- package/src/profiles/__tests__/stack-profile.test.ts +190 -0
- package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
- package/src/profiles/profile-schema.ts +350 -0
- package/src/profiles/stack-profile.ts +188 -0
- package/src/profiles/terminology-utils.ts +36 -0
- package/src/registry.ts +185 -0
- package/src/skill-schema.ts +37 -0
- package/src/tests/acceptance-criteria.test.ts +99 -0
- package/src/tests/adr-implement-stamp.test.ts +368 -0
- package/src/tests/agents-generate-domain.test.ts +317 -0
- package/src/tests/agents-generate.test.ts +458 -0
- package/src/tests/bindings-schema.test.ts +314 -0
- package/src/tests/budgets.test.ts +213 -0
- package/src/tests/cli-output.test.ts +196 -0
- package/src/tests/compact.test.ts +796 -0
- package/src/tests/create.test.ts +289 -0
- package/src/tests/doctor-autonomy.test.ts +98 -0
- package/src/tests/doctor-bindings.test.ts +286 -0
- package/src/tests/doctor-domain.test.ts +113 -0
- package/src/tests/exploration-handlers.test.ts +256 -0
- package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
- package/src/tests/forge-config.test.ts +420 -0
- package/src/tests/fs-atomic.test.ts +96 -0
- package/src/tests/fs.test.ts +103 -0
- package/src/tests/generated-marker.test.ts +161 -0
- package/src/tests/hash.test.ts +37 -0
- package/src/tests/implement-stamp.test.ts +505 -0
- package/src/tests/init-bindings.test.ts +183 -0
- package/src/tests/knowledge-parse.test.ts +307 -0
- package/src/tests/knowledge-pbt.test.ts +164 -0
- package/src/tests/memory-scaffold.test.ts +192 -0
- package/src/tests/migration-adapters.test.ts +480 -0
- package/src/tests/package-files.test.ts +54 -0
- package/src/tests/pinned-check.test.ts +194 -0
- package/src/tests/pinned-init.test.ts +126 -0
- package/src/tests/profile-schema.test.ts +194 -0
- package/src/tests/profile-validate.test.ts +120 -0
- package/src/tests/program-lease.test.ts +245 -0
- package/src/tests/program-paths-extended.test.ts +117 -0
- package/src/tests/program-paths.test.ts +98 -0
- package/src/tests/program-property.test.ts +139 -0
- package/src/tests/program-schemas.test.ts +445 -0
- package/src/tests/program-spec-node.test.ts +250 -0
- package/src/tests/promote.test.ts +481 -0
- package/src/tests/registry.test.ts +73 -0
- package/src/tests/scaffold-project.test.ts +150 -0
- package/src/tests/session-handlers.test.ts +503 -0
- package/src/tests/session-pbt.test.ts +190 -0
- package/src/tests/skill-schema.test.ts +146 -0
- package/src/tests/skill-validate-knowledge.test.ts +26 -0
- package/src/tests/skill-validate.test.ts +254 -0
- package/src/tests/stack-profile.test.ts +221 -0
- package/src/tests/string-utils.test.ts +42 -0
- package/src/tests/upgrade.test.ts +428 -0
- package/src/tests/werkstatt-lock.test.ts +343 -0
- package/src/tests/workspace-discovery-domain.test.ts +112 -0
- package/src/tests/workspace-discovery.test.ts +135 -0
- package/src/types.ts +221 -0
- package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
- package/src/utils/__tests__/fs-trash.test.ts +45 -0
- package/src/utils/fs-atomic.ts +93 -0
- package/src/utils/fs-idempotent.ts +41 -0
- package/src/utils/fs-trash-sync.ts +37 -0
- package/src/utils/fs-trash.ts +24 -0
- package/src/utils/fs.ts +74 -0
- package/src/utils/generated-marker.ts +166 -0
- package/src/utils/hash.ts +19 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/string-utils.ts +19 -0
- package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
- package/src/validators/note-frontmatter-validate.ts +142 -0
- package/src/validators/note-link-validate.ts +145 -0
- package/src/validators/note-orphan-detect.ts +146 -0
- package/src/validators/port-validate.ts +97 -0
- package/src/validators/skill-validate.ts +831 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for spec.live.merge handler — covers creation, modification,
|
|
4
|
+
conflict detection, dry-run, no-op cases (RFC-0711).</purpose>
|
|
5
|
+
<non-goals>
|
|
6
|
+
<item>Do not test docs.archive integration — covered separately.</item>
|
|
7
|
+
</non-goals>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
<CHANGE_SUMMARY>
|
|
10
|
+
<item>RFC-0711: initial unit tests for spec.live.merge.</item>
|
|
11
|
+
</CHANGE_SUMMARY>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import { runSpecLiveMerge } from "./live-spec-merge.ts";
|
|
19
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
20
|
+
|
|
21
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
22
|
+
return {
|
|
23
|
+
workspaceRoot,
|
|
24
|
+
logger: {
|
|
25
|
+
section: () => {},
|
|
26
|
+
info: () => {},
|
|
27
|
+
warn: () => {},
|
|
28
|
+
error: () => {},
|
|
29
|
+
success: () => {},
|
|
30
|
+
},
|
|
31
|
+
dryRun: false,
|
|
32
|
+
outputFormat: "json",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SAMPLE_RFC = `---
|
|
37
|
+
id: RFC-9001
|
|
38
|
+
title: "Test RFC for living specs"
|
|
39
|
+
status: implemented
|
|
40
|
+
kind: architecture
|
|
41
|
+
scope: workspace
|
|
42
|
+
owners:
|
|
43
|
+
- architecture
|
|
44
|
+
reviewers:
|
|
45
|
+
- human:test
|
|
46
|
+
createdAt: 2026-08-06
|
|
47
|
+
updatedAt: 2026-08-06
|
|
48
|
+
implementedAt: 2026-08-06
|
|
49
|
+
versionBump: patch
|
|
50
|
+
liveSpec: true
|
|
51
|
+
packagesImpacted:
|
|
52
|
+
- packages/forge
|
|
53
|
+
commands:
|
|
54
|
+
proposed: []
|
|
55
|
+
added: []
|
|
56
|
+
changed: []
|
|
57
|
+
removed: []
|
|
58
|
+
appsImpacted: []
|
|
59
|
+
successSignals: []
|
|
60
|
+
nonGoals: []
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# RFC-9001: Test RFC for living specs
|
|
64
|
+
|
|
65
|
+
## Context
|
|
66
|
+
|
|
67
|
+
Some context.
|
|
68
|
+
|
|
69
|
+
## Design
|
|
70
|
+
|
|
71
|
+
### CLI surface
|
|
72
|
+
|
|
73
|
+
\`\`\`sh
|
|
74
|
+
pnpm exec werkstatt run spec.live.merge --id RFC-9001
|
|
75
|
+
\`\`\`
|
|
76
|
+
|
|
77
|
+
### TypeScript contracts
|
|
78
|
+
|
|
79
|
+
Some types.
|
|
80
|
+
|
|
81
|
+
### File system responsibilities
|
|
82
|
+
|
|
83
|
+
Some files.
|
|
84
|
+
|
|
85
|
+
## Rollout
|
|
86
|
+
|
|
87
|
+
Some rollout.
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const SAMPLE_RFC_NO_LIVE_SPEC = `---
|
|
91
|
+
id: RFC-9002
|
|
92
|
+
title: "Test RFC without liveSpec"
|
|
93
|
+
status: implemented
|
|
94
|
+
kind: architecture
|
|
95
|
+
scope: workspace
|
|
96
|
+
owners:
|
|
97
|
+
- architecture
|
|
98
|
+
reviewers:
|
|
99
|
+
- human:test
|
|
100
|
+
createdAt: 2026-08-06
|
|
101
|
+
updatedAt: 2026-08-06
|
|
102
|
+
implementedAt: 2026-08-06
|
|
103
|
+
versionBump: patch
|
|
104
|
+
packagesImpacted:
|
|
105
|
+
- packages/forge
|
|
106
|
+
commands:
|
|
107
|
+
proposed: []
|
|
108
|
+
added: []
|
|
109
|
+
changed: []
|
|
110
|
+
removed: []
|
|
111
|
+
appsImpacted: []
|
|
112
|
+
successSignals: []
|
|
113
|
+
nonGoals: []
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
# RFC-9002: Test RFC without liveSpec
|
|
117
|
+
|
|
118
|
+
## Design
|
|
119
|
+
|
|
120
|
+
### Something
|
|
121
|
+
|
|
122
|
+
Content.
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const SAMPLE_RFC_REJECTED = `---
|
|
126
|
+
id: RFC-9003
|
|
127
|
+
title: "Rejected RFC with liveSpec"
|
|
128
|
+
status: rejected
|
|
129
|
+
kind: architecture
|
|
130
|
+
scope: workspace
|
|
131
|
+
owners:
|
|
132
|
+
- architecture
|
|
133
|
+
reviewers:
|
|
134
|
+
- human:test
|
|
135
|
+
createdAt: 2026-08-06
|
|
136
|
+
updatedAt: 2026-08-06
|
|
137
|
+
versionBump: patch
|
|
138
|
+
liveSpec: true
|
|
139
|
+
packagesImpacted:
|
|
140
|
+
- packages/forge
|
|
141
|
+
commands:
|
|
142
|
+
proposed: []
|
|
143
|
+
added: []
|
|
144
|
+
changed: []
|
|
145
|
+
removed: []
|
|
146
|
+
appsImpacted: []
|
|
147
|
+
successSignals: []
|
|
148
|
+
nonGoals: []
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
# RFC-9003: Rejected RFC with liveSpec
|
|
152
|
+
|
|
153
|
+
## Design
|
|
154
|
+
|
|
155
|
+
### Rejected heading
|
|
156
|
+
|
|
157
|
+
Content.
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
async function setupWorkspace(): Promise<string> {
|
|
161
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "spec-live-test-"));
|
|
162
|
+
const rfcDir = path.join(tmpDir, "docs/rfcs");
|
|
163
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
164
|
+
await fs.writeFile(path.join(rfcDir, "rfc-9001-test-rfc-for-living-specs.md"), SAMPLE_RFC);
|
|
165
|
+
await fs.writeFile(path.join(rfcDir, "rfc-9002-test-rfc-without-livespec.md"), SAMPLE_RFC_NO_LIVE_SPEC);
|
|
166
|
+
await fs.writeFile(path.join(rfcDir, "rfc-9003-rejected-rfc-with-livespec.md"), SAMPLE_RFC_REJECTED);
|
|
167
|
+
return tmpDir;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function cleanupWorkspace(tmpDir: string): Promise<void> {
|
|
171
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
describe("spec.live.merge", () => {
|
|
175
|
+
let tmpDir: string;
|
|
176
|
+
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
tmpDir = await setupWorkspace();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
afterEach(async () => {
|
|
182
|
+
await cleanupWorkspace(tmpDir);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("creates a new living spec when none exists", async () => {
|
|
186
|
+
const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
|
|
187
|
+
const result = await runSpecLiveMerge(input, makeContext(tmpDir));
|
|
188
|
+
|
|
189
|
+
expect(result.exitCode).toBe(0);
|
|
190
|
+
expect(result.data?.operation).toBe("created");
|
|
191
|
+
expect(result.data?.domain).toBe("forge");
|
|
192
|
+
expect(result.data?.deltas.length).toBeGreaterThan(0);
|
|
193
|
+
expect(result.data?.conflicts.length).toBe(0);
|
|
194
|
+
|
|
195
|
+
const specFile = path.join(tmpDir, "docs/specs/live/forge.md");
|
|
196
|
+
const content = await fs.readFile(specFile, "utf-8");
|
|
197
|
+
expect(content).toContain("domain: forge");
|
|
198
|
+
expect(content).toContain("lastMergedRfc: RFC-9001");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("modifies an existing living spec", async () => {
|
|
202
|
+
// First merge creates the spec
|
|
203
|
+
const input1: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
|
|
204
|
+
await runSpecLiveMerge(input1, makeContext(tmpDir));
|
|
205
|
+
|
|
206
|
+
// Second merge modifies it
|
|
207
|
+
const input2: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001" } };
|
|
208
|
+
const result = await runSpecLiveMerge(input2, makeContext(tmpDir));
|
|
209
|
+
|
|
210
|
+
expect(result.exitCode).toBe(0);
|
|
211
|
+
expect(result.data?.operation).toBe("modified");
|
|
212
|
+
expect(result.data?.domain).toBe("forge");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("skips RFCs without liveSpec field (no-op)", async () => {
|
|
216
|
+
const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9002" } };
|
|
217
|
+
const result = await runSpecLiveMerge(input, makeContext(tmpDir));
|
|
218
|
+
|
|
219
|
+
expect(result.exitCode).toBe(0);
|
|
220
|
+
expect(result.data?.deltas.length).toBe(0);
|
|
221
|
+
expect(result.data?.domain).toBe("");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("rejects non-implemented RFCs", async () => {
|
|
225
|
+
const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9003" } };
|
|
226
|
+
const result = await runSpecLiveMerge(input, makeContext(tmpDir));
|
|
227
|
+
|
|
228
|
+
expect(result.exitCode).toBe(1);
|
|
229
|
+
expect(result.summary).toContain("rejected");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("supports --dry-run without writing files", async () => {
|
|
233
|
+
const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9001", "dry-run": true } };
|
|
234
|
+
const ctx = makeContext(tmpDir);
|
|
235
|
+
ctx.dryRun = true;
|
|
236
|
+
const result = await runSpecLiveMerge(input, ctx);
|
|
237
|
+
|
|
238
|
+
expect(result.exitCode).toBe(0);
|
|
239
|
+
expect(result.data?.dryRun).toBe(true);
|
|
240
|
+
expect(result.data?.operation).toBe("created");
|
|
241
|
+
|
|
242
|
+
const specFile = path.join(tmpDir, "docs/specs/live/forge.md");
|
|
243
|
+
await expect(fs.access(specFile)).rejects.toThrow();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns error when --id is missing", async () => {
|
|
247
|
+
const input: ForgeCommandInput = { argv: [], flags: {} };
|
|
248
|
+
const result = await runSpecLiveMerge(input, makeContext(tmpDir));
|
|
249
|
+
|
|
250
|
+
expect(result.exitCode).toBe(1);
|
|
251
|
+
expect(result.summary).toContain("--id");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("returns error when RFC is not found", async () => {
|
|
255
|
+
const input: ForgeCommandInput = { argv: [], flags: { id: "RFC-9999" } };
|
|
256
|
+
const result = await runSpecLiveMerge(input, makeContext(tmpDir));
|
|
257
|
+
|
|
258
|
+
expect(result.exitCode).toBe(1);
|
|
259
|
+
expect(result.summary).toContain("not found");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>spec.live.merge handler — extracts deltas from an RFC's ## Design section,
|
|
4
|
+
classifies them as ADDED/MODIFIED/REMOVED, applies to a living spec, handles conflicts
|
|
5
|
+
with all-or-nothing semantics, and writes atomically (RFC-0711).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not handle docs.archive integration — that is in core.module.ts.</item>
|
|
8
|
+
<item>Do not validate living specs — that is spec.live.validate.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-0711: initial spec.live.merge handler with delta extraction, classification, conflict detection, and atomic writes.</item>
|
|
13
|
+
</CHANGE_SUMMARY>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import YAML from "yaml";
|
|
20
|
+
import type {
|
|
21
|
+
ForgeCommandInput,
|
|
22
|
+
ForgeCommandResult,
|
|
23
|
+
ForgeRuntimeContext,
|
|
24
|
+
} from "../../src/types.ts";
|
|
25
|
+
import { writeFileIfChanged, buildGeneratedHeader } from "../../src/utils/index.ts";
|
|
26
|
+
import { parseRfcFile, listRfcFiles } from "../rfc/frontmatter-io.ts";
|
|
27
|
+
import { RFC_DIR } from "../rfc/types.ts";
|
|
28
|
+
import type {
|
|
29
|
+
LivingSpec,
|
|
30
|
+
LivingSpecHistoryEntry,
|
|
31
|
+
DeltaOperation,
|
|
32
|
+
DeltaConflict,
|
|
33
|
+
SpecLiveMergeResult,
|
|
34
|
+
} from "./live-spec-types.ts";
|
|
35
|
+
|
|
36
|
+
const LIVE_SPECS_DIR = "docs/specs/live";
|
|
37
|
+
|
|
38
|
+
interface ParsedHeading {
|
|
39
|
+
level: number;
|
|
40
|
+
text: string;
|
|
41
|
+
body: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractDesignSection(rfcBody: string): string {
|
|
45
|
+
const designMatch = rfcBody.match(/^##\s+Design\s*$/m);
|
|
46
|
+
if (!designMatch) return "";
|
|
47
|
+
const startIndex = designMatch.index! + designMatch[0].length;
|
|
48
|
+
const nextH2Match = rfcBody.slice(startIndex).match(/^##\s+/m);
|
|
49
|
+
if (!nextH2Match) {
|
|
50
|
+
return rfcBody.slice(startIndex).trim();
|
|
51
|
+
}
|
|
52
|
+
return rfcBody.slice(startIndex, startIndex + nextH2Match.index!).trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseHeadings(content: string): ParsedHeading[] {
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
const headings: ParsedHeading[] = [];
|
|
58
|
+
let currentHeading: ParsedHeading | null = null;
|
|
59
|
+
let currentBody: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const headingMatch = line.match(/^(#{3,})\s+(.+)$/);
|
|
63
|
+
if (headingMatch) {
|
|
64
|
+
if (currentHeading) {
|
|
65
|
+
currentHeading.body = currentBody.join("\n").trim();
|
|
66
|
+
headings.push(currentHeading);
|
|
67
|
+
}
|
|
68
|
+
currentHeading = {
|
|
69
|
+
level: headingMatch[1].length,
|
|
70
|
+
text: headingMatch[2].trim(),
|
|
71
|
+
body: "",
|
|
72
|
+
};
|
|
73
|
+
currentBody = [];
|
|
74
|
+
} else if (currentHeading) {
|
|
75
|
+
currentBody.push(line);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (currentHeading) {
|
|
79
|
+
currentHeading.body = currentBody.join("\n").trim();
|
|
80
|
+
headings.push(currentHeading);
|
|
81
|
+
}
|
|
82
|
+
return headings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deriveDomain(frontmatter: Record<string, unknown>): string | null {
|
|
86
|
+
const liveSpec = frontmatter["liveSpec"];
|
|
87
|
+
if (typeof liveSpec === "string" && liveSpec.length > 0) {
|
|
88
|
+
return liveSpec;
|
|
89
|
+
}
|
|
90
|
+
if (liveSpec === true) {
|
|
91
|
+
const packagesImpacted = frontmatter["packagesImpacted"];
|
|
92
|
+
if (Array.isArray(packagesImpacted) && packagesImpacted.length > 0) {
|
|
93
|
+
const firstPkg = String(packagesImpacted[0]);
|
|
94
|
+
return firstPkg.replace(/^packages\//, "");
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseLivingSpec(content: string): LivingSpec | null {
|
|
102
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
const fm = (YAML.parse(match[1]!) ?? {}) as Record<string, unknown>;
|
|
105
|
+
return {
|
|
106
|
+
domain: String(fm["domain"] ?? ""),
|
|
107
|
+
title: String(fm["title"] ?? ""),
|
|
108
|
+
lastMergedRfc: String(fm["lastMergedRfc"] ?? ""),
|
|
109
|
+
updatedAt: String(fm["updatedAt"] ?? ""),
|
|
110
|
+
createdAt: String(fm["createdAt"] ?? ""),
|
|
111
|
+
history: (Array.isArray(fm["history"]) ? fm["history"] : []) as LivingSpecHistoryEntry[],
|
|
112
|
+
body: match[2] ?? "",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function serializeLivingSpec(spec: LivingSpec): string {
|
|
117
|
+
const fm: Record<string, unknown> = {
|
|
118
|
+
domain: spec.domain,
|
|
119
|
+
title: spec.title,
|
|
120
|
+
lastMergedRfc: spec.lastMergedRfc,
|
|
121
|
+
updatedAt: spec.updatedAt,
|
|
122
|
+
createdAt: spec.createdAt,
|
|
123
|
+
history: spec.history,
|
|
124
|
+
};
|
|
125
|
+
const fmStr = YAML.stringify(fm).trimEnd();
|
|
126
|
+
return `---\n${fmStr}\n---\n\n${spec.body}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingInSpec(spec: LivingSpec, headingText: string): ParsedHeading | null {
|
|
130
|
+
const headings = parseHeadings(spec.body);
|
|
131
|
+
return headings.find((h) => h.text === headingText) ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyDeltasToSpecBody(
|
|
135
|
+
specBody: string,
|
|
136
|
+
deltas: ParsedHeading[],
|
|
137
|
+
operations: DeltaOperation[],
|
|
138
|
+
): string {
|
|
139
|
+
let body = specBody;
|
|
140
|
+
for (const delta of deltas) {
|
|
141
|
+
const op = operations.find((o) => o.heading === delta.text);
|
|
142
|
+
if (!op) continue;
|
|
143
|
+
|
|
144
|
+
if (op.type === "added") {
|
|
145
|
+
body = `${body}\n\n${"#".repeat(delta.level)} ${delta.text}\n\n${delta.body}`;
|
|
146
|
+
} else if (op.type === "modified") {
|
|
147
|
+
const headingRegex = new RegExp(
|
|
148
|
+
`(${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n)([\\s\\S]*?)(?=#{3,}|$)`,
|
|
149
|
+
);
|
|
150
|
+
body = body.replace(headingRegex, `$1\n${delta.body}\n`);
|
|
151
|
+
} else if (op.type === "removed") {
|
|
152
|
+
const headingRegex = new RegExp(
|
|
153
|
+
`\\n*${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n[\\s\\S]*?(?=#{3,}|$)`,
|
|
154
|
+
);
|
|
155
|
+
body = body.replace(headingRegex, "\n");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return body.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function escapeRegex(str: string): string {
|
|
162
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findRfcFile(rfcDir: string, rfcId: string): Promise<string | null> {
|
|
166
|
+
return (async () => {
|
|
167
|
+
const files = await listRfcFiles(rfcDir);
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
const basename = path.basename(file);
|
|
170
|
+
if (basename.toLowerCase().startsWith(rfcId.toLowerCase())) {
|
|
171
|
+
return file;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
})();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function runSpecLiveMerge(
|
|
179
|
+
input: ForgeCommandInput,
|
|
180
|
+
context: ForgeRuntimeContext,
|
|
181
|
+
): Promise<ForgeCommandResult<SpecLiveMergeResult>> {
|
|
182
|
+
const { workspaceRoot, logger, outputFormat } = context;
|
|
183
|
+
const rfcId = String(input.flags["id"] ?? "");
|
|
184
|
+
const dryRun = context.dryRun || input.flags["dry-run"] === true;
|
|
185
|
+
|
|
186
|
+
if (!rfcId) {
|
|
187
|
+
return {
|
|
188
|
+
data: {
|
|
189
|
+
command: "spec.live.merge",
|
|
190
|
+
domain: "",
|
|
191
|
+
operation: "modified",
|
|
192
|
+
deltas: [],
|
|
193
|
+
conflicts: [],
|
|
194
|
+
dryRun,
|
|
195
|
+
},
|
|
196
|
+
exitCode: 1,
|
|
197
|
+
summary: "spec.live.merge: --id flag is required",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const rfcDir = path.join(workspaceRoot, RFC_DIR);
|
|
202
|
+
const rfcFile = await findRfcFile(rfcDir, rfcId);
|
|
203
|
+
|
|
204
|
+
if (!rfcFile) {
|
|
205
|
+
return {
|
|
206
|
+
data: {
|
|
207
|
+
command: "spec.live.merge",
|
|
208
|
+
domain: "",
|
|
209
|
+
operation: "modified",
|
|
210
|
+
deltas: [],
|
|
211
|
+
conflicts: [],
|
|
212
|
+
dryRun,
|
|
213
|
+
},
|
|
214
|
+
exitCode: 1,
|
|
215
|
+
summary: `spec.live.merge: RFC ${rfcId} not found in ${RFC_DIR}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rfcContent = await fs.readFile(path.join(rfcDir, rfcFile), "utf-8");
|
|
220
|
+
const parsed = parseRfcFile(rfcContent);
|
|
221
|
+
const fm = parsed.frontmatter;
|
|
222
|
+
|
|
223
|
+
const status = String(fm["status"] ?? "").trim();
|
|
224
|
+
if (status !== "implemented") {
|
|
225
|
+
return {
|
|
226
|
+
data: {
|
|
227
|
+
command: "spec.live.merge",
|
|
228
|
+
domain: "",
|
|
229
|
+
operation: "modified",
|
|
230
|
+
deltas: [],
|
|
231
|
+
conflicts: [],
|
|
232
|
+
dryRun,
|
|
233
|
+
},
|
|
234
|
+
exitCode: 1,
|
|
235
|
+
summary: `spec.live.merge: RFC ${rfcId} has status "${status}", must be "implemented"`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const domain = deriveDomain(fm);
|
|
240
|
+
if (!domain) {
|
|
241
|
+
return {
|
|
242
|
+
data: {
|
|
243
|
+
command: "spec.live.merge",
|
|
244
|
+
domain: "",
|
|
245
|
+
operation: "modified",
|
|
246
|
+
deltas: [],
|
|
247
|
+
conflicts: [],
|
|
248
|
+
dryRun,
|
|
249
|
+
},
|
|
250
|
+
exitCode: 0,
|
|
251
|
+
summary: `spec.live.merge: RFC ${rfcId} has no liveSpec field — skipping (no-op)`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const designSection = extractDesignSection(parsed.body);
|
|
256
|
+
if (!designSection) {
|
|
257
|
+
return {
|
|
258
|
+
data: {
|
|
259
|
+
command: "spec.live.merge",
|
|
260
|
+
domain,
|
|
261
|
+
operation: "modified",
|
|
262
|
+
deltas: [],
|
|
263
|
+
conflicts: [],
|
|
264
|
+
dryRun,
|
|
265
|
+
},
|
|
266
|
+
exitCode: 0,
|
|
267
|
+
summary: `spec.live.merge: RFC ${rfcId} has no ## Design section — skipping (no-op)`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const rfcHeadings = parseHeadings(designSection);
|
|
272
|
+
const liveSpecsDir = path.join(workspaceRoot, LIVE_SPECS_DIR);
|
|
273
|
+
const specFilePath = path.join(liveSpecsDir, `${domain}.md`);
|
|
274
|
+
|
|
275
|
+
let existingSpec: LivingSpec | null = null;
|
|
276
|
+
if (existsSync(specFilePath)) {
|
|
277
|
+
const specContent = await fs.readFile(specFilePath, "utf-8");
|
|
278
|
+
existingSpec = parseLivingSpec(specContent);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const operations: DeltaOperation[] = [];
|
|
282
|
+
const conflicts: DeltaConflict[] = [];
|
|
283
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
284
|
+
|
|
285
|
+
if (!existingSpec) {
|
|
286
|
+
for (const heading of rfcHeadings) {
|
|
287
|
+
operations.push({ type: "added", heading: heading.text, rfc: rfcId });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const header = buildGeneratedHeader({
|
|
291
|
+
filePath: specFilePath,
|
|
292
|
+
ownerCommand: "spec.live.merge",
|
|
293
|
+
commandPrefix: "pnpm exec werkstatt run",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const newSpec: LivingSpec = {
|
|
297
|
+
domain,
|
|
298
|
+
title: `Living Spec: ${domain}`,
|
|
299
|
+
lastMergedRfc: rfcId,
|
|
300
|
+
updatedAt: today,
|
|
301
|
+
createdAt: today,
|
|
302
|
+
history: [{ rfc: rfcId, mergedAt: today, operation: "created" }],
|
|
303
|
+
body: `${header}# Living Spec: ${domain}\n\n## Overview\n\n${designSection}`,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (!dryRun) {
|
|
307
|
+
await fs.mkdir(liveSpecsDir, { recursive: true });
|
|
308
|
+
await writeFileIfChanged(specFilePath, serializeLivingSpec(newSpec));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result: SpecLiveMergeResult = {
|
|
312
|
+
command: "spec.live.merge",
|
|
313
|
+
domain,
|
|
314
|
+
operation: "created",
|
|
315
|
+
deltas: operations,
|
|
316
|
+
conflicts: [],
|
|
317
|
+
dryRun,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (outputFormat === "pretty") {
|
|
321
|
+
logger.success(
|
|
322
|
+
`spec.live.merge: created living spec for domain "${domain}" from ${rfcId} (${operations.length} deltas)${dryRun ? " [dry-run]" : ""}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { data: result, exitCode: 0, summary: `spec.live.merge: created ${domain} from ${rfcId}` };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const heading of rfcHeadings) {
|
|
330
|
+
const existing = findHeadingInSpec(existingSpec, heading.text);
|
|
331
|
+
if (existing) {
|
|
332
|
+
let lastRfc: string | undefined;
|
|
333
|
+
for (let i = existingSpec.history.length - 1; i >= 0; i--) {
|
|
334
|
+
const entry = existingSpec.history[i]!;
|
|
335
|
+
if (entry.operation !== "removed") {
|
|
336
|
+
lastRfc = entry.rfc;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (lastRfc && lastRfc !== rfcId) {
|
|
341
|
+
conflicts.push({
|
|
342
|
+
heading: heading.text,
|
|
343
|
+
existingRfc: lastRfc,
|
|
344
|
+
newRfc: rfcId,
|
|
345
|
+
resolution: "pending",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
operations.push({ type: "modified", heading: heading.text, rfc: rfcId });
|
|
349
|
+
} else {
|
|
350
|
+
operations.push({ type: "added", heading: heading.text, rfc: rfcId });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (conflicts.length > 0) {
|
|
355
|
+
const result: SpecLiveMergeResult = {
|
|
356
|
+
command: "spec.live.merge",
|
|
357
|
+
domain,
|
|
358
|
+
operation: "modified",
|
|
359
|
+
deltas: operations,
|
|
360
|
+
conflicts,
|
|
361
|
+
dryRun,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
if (outputFormat === "pretty") {
|
|
365
|
+
logger.error(
|
|
366
|
+
`spec.live.merge: ${conflicts.length} conflict(s) detected — merge aborted (all-or-nothing)`,
|
|
367
|
+
);
|
|
368
|
+
for (const c of conflicts) {
|
|
369
|
+
logger.error(` conflict: heading "${c.heading}" last modified by ${c.existingRfc}, now ${c.newRfc}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
data: result,
|
|
375
|
+
exitCode: 1,
|
|
376
|
+
summary: `spec.live.merge: ${conflicts.length} conflict(s) — merge aborted`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const updatedBody = applyDeltasToSpecBody(existingSpec.body, rfcHeadings, operations);
|
|
381
|
+
|
|
382
|
+
const updatedSpec: LivingSpec = {
|
|
383
|
+
...existingSpec,
|
|
384
|
+
lastMergedRfc: rfcId,
|
|
385
|
+
updatedAt: today,
|
|
386
|
+
history: [
|
|
387
|
+
...existingSpec.history,
|
|
388
|
+
{ rfc: rfcId, mergedAt: today, operation: "modified" },
|
|
389
|
+
],
|
|
390
|
+
body: updatedBody,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
if (!dryRun) {
|
|
394
|
+
await writeFileIfChanged(specFilePath, serializeLivingSpec(updatedSpec));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const result: SpecLiveMergeResult = {
|
|
398
|
+
command: "spec.live.merge",
|
|
399
|
+
domain,
|
|
400
|
+
operation: "modified",
|
|
401
|
+
deltas: operations,
|
|
402
|
+
conflicts: [],
|
|
403
|
+
dryRun,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
if (outputFormat === "pretty") {
|
|
407
|
+
logger.success(
|
|
408
|
+
`spec.live.merge: modified living spec for domain "${domain}" from ${rfcId} (${operations.length} deltas)${dryRun ? " [dry-run]" : ""}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { data: result, exitCode: 0, summary: `spec.live.merge: modified ${domain} from ${rfcId}` };
|
|
413
|
+
}
|