@ybotok/loam 0.0.0 → 0.1.0-beta.1

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,1297 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { emitJson, emitJsonError, fail, repoPath, reportNoConfig } from "../core/json.js";
6
+ import { gatesArchive } from "../core/issue.js";
7
+ import { acquireDocsLock, clearCommitIntent, DocsBusyError, InterruptedCommitError, message, NotUtf8Error, planWrite, quietPruneEmptyParents, quietRm, readUtf8, recoverInterruptedCommit, rollbackError, rollbackStaged, snapshotDir, SnapshotClobberError, stageWrites, swapStaged, writeCommitIntent, writeSnapshot, SNAPSHOT_DIR, } from "../core/staging.js";
8
+ import { elementService, loadFile, loadSource, maskSource, matchBrace, scanModel, serviceOf, } from "../core/likec4.js";
9
+ import { featurePaths, featureSpecPaths, featureSpecServices, featuresDir as featuresRoot, landscapePath as landscapeFile, missingFeatureMessage, resolveFeature, servicePaths, SPEC_AXES, } from "../core/repo.js";
10
+ import { readOpenapi } from "../core/openapi.js";
11
+ import { mergeOpenapiPaths, OpenapiMergeError, stripOpenapiRemovalMarkers, } from "../core/openapi-merge.js";
12
+ import { featureCoherence, livingMergeConflicts, unknownDeltaServices } from "../core/coherence.js";
13
+ import { findingJson } from "../core/report.js";
14
+ import { parseRequirements, serializeRequirements, applyRequirementDelta, isRequirementsHeading, sectionHeadings, splitRequirementsSection, } from "../core/spec.js";
15
+ /**
16
+ * A refusal or failure with its stable `--json` code attached. Thrown from the
17
+ * plan phase (nothing written yet) and from the commit's rollback path (whose
18
+ * message says whether the rollback held). Anything ELSE that escapes runArchive
19
+ * is a bug, and the action handler reports it as `internal`.
20
+ */
21
+ class ArchiveFailure extends Error {
22
+ code;
23
+ constructor(code, msg) {
24
+ super(msg);
25
+ this.code = code;
26
+ }
27
+ }
28
+ export function registerArchive(program) {
29
+ program
30
+ .command("archive")
31
+ .argument("<featureId>", "feature id, e.g. FEAT-101")
32
+ .description("Merge a shipped feature's deltas into the living specs + model, then archive it")
33
+ .option("--approve", "archive despite GATING coherence issues (may corrupt the living docs); advisory warnings never block")
34
+ .option("--dry-run", "print the whole merge plan and write nothing")
35
+ .option("--json", "emit the machine contract instead of the human view")
36
+ .action(async (featureId, opts) => {
37
+ const json = opts.json === true;
38
+ try {
39
+ await runArchive(featureId, opts);
40
+ }
41
+ catch (err) {
42
+ // Plan-phase failures happen before any write; commit-phase failures are
43
+ // rolled back by runArchive, which says so in the message it throws.
44
+ // A file loam cannot decode is one it cannot merge, and that is the same
45
+ // answer as any other merge it could not compute: nothing was written.
46
+ fail(json, archiveErrorCode(err), `archive ${featureId} failed: ${message(err)}`);
47
+ }
48
+ });
49
+ }
50
+ function archiveErrorCode(err) {
51
+ if (err instanceof ArchiveFailure)
52
+ return err.code;
53
+ if (err instanceof NotUtf8Error)
54
+ return "merge-failed";
55
+ if (err instanceof InterruptedCommitError)
56
+ return "commit-interrupted";
57
+ return "internal";
58
+ }
59
+ /**
60
+ * An Issue as the `--json` envelope spells it — the Finding shape, minus details.
61
+ * `gates` is always present and already resolved: a consumer must not have to
62
+ * re-implement the severity default to know what blocks archive.
63
+ */
64
+ function issueJson(i) {
65
+ return {
66
+ severity: i.severity,
67
+ code: i.code,
68
+ gates: gatesArchive(i),
69
+ ...(i.subject === undefined ? {} : { subject: i.subject }),
70
+ message: i.message,
71
+ };
72
+ }
73
+ /** The failure envelope plus the issues that caused it, so a caller need not re-run validate. */
74
+ function refuseJson(code, msg, issues) {
75
+ emitJsonError(code, msg, { issues: issues.map(issueJson) });
76
+ }
77
+ /**
78
+ * The same envelope for a refusal computed from `Finding`s rather than
79
+ * `Issue`s — the plan-time checks whose codes are not coherence's (they belong
80
+ * to the LIVING documents, not to the feature) and so are not `IssueCode`s.
81
+ *
82
+ * `gates: true` is asserted rather than derived: every finding listed in a
83
+ * refusal is a reason archive stopped, which is what the field means. Nothing
84
+ * advisory reaches here.
85
+ */
86
+ function refuseFindings(code, msg, findings) {
87
+ emitJsonError(code, msg, { issues: findings.map((f) => ({ ...findingJson(f), gates: true })) });
88
+ }
89
+ /**
90
+ * Take the docs repo's advisory lock for the WHOLE plan+commit window, then
91
+ * archive.
92
+ *
93
+ * The window is the point. Two archives that overlap do not fight over a
94
+ * rename: they each read the living landscape, each splice their additions into
95
+ * the bytes they read, and the second write replaces the first — both exit 0,
96
+ * and `validate` stays green over a landscape that is missing one feature's
97
+ * architecture, because a document with fewer elements is not an invalid one.
98
+ * That is the only silent-loss path in loam that a later command cannot even
99
+ * detect, so it is closed at the coarsest possible granularity: one writer per
100
+ * docs repo, refusing rather than queueing, because a CLI that blocks for an
101
+ * unknown time is worse for an agent than one that says `docs-busy`.
102
+ */
103
+ async function runArchive(featureId, opts) {
104
+ const json = opts.json === true;
105
+ const config = await loadConfig();
106
+ if (!config) {
107
+ reportNoConfig(json);
108
+ return;
109
+ }
110
+ let release;
111
+ try {
112
+ release = await acquireDocsLock(config.docsDir);
113
+ }
114
+ catch (err) {
115
+ if (err instanceof DocsBusyError)
116
+ throw new ArchiveFailure("docs-busy", err.message);
117
+ throw err;
118
+ }
119
+ try {
120
+ await archiveLocked(config, featureId, opts);
121
+ }
122
+ finally {
123
+ await release();
124
+ }
125
+ }
126
+ async function archiveLocked(config, featureId, opts) {
127
+ const dryRun = opts.dryRun === true;
128
+ const json = opts.json === true;
129
+ // All prose goes through here so `--json` keeps stdout a single JSON document.
130
+ const say = (line = "") => {
131
+ if (!json)
132
+ console.log(line);
133
+ };
134
+ // Before anything is read: a commit that was killed between two renames left
135
+ // the living docs half-written, and every other command in loam would have
136
+ // planned against them as if they were the truth. Under the same lock, so the
137
+ // repair cannot race a writer. It refuses when the half-merged files have been
138
+ // edited since — that is a human's call, not a merge's.
139
+ const recovered = await recoverInterruptedCommit(config.docsDir);
140
+ if (recovered !== null && !json)
141
+ sayRecovery(recovered);
142
+ const featuresDir = featuresRoot(config.docsDir);
143
+ const feature = await resolveFeature(config.docsDir, featureId, "exclude");
144
+ if (!feature) {
145
+ fail(json, "unknown-target", await missingFeatureMessage(config.docsDir, featureId));
146
+ return;
147
+ }
148
+ // The raw argument's last appearance: from here on the feature answers only to
149
+ // its canonical id — tags, coherence, and the self-exclusion scans all match
150
+ // on `id`, and `archive FEAT-5-slug` must plan exactly like `archive FEAT-5`.
151
+ const { id, dirName, dir: featureDir } = feature;
152
+ // Parse delta.likec4 ONCE for the whole run: the coherence gate and the
153
+ // architecture merge below read the same file, and loading it spins up a
154
+ // fresh Langium workspace each time. Nothing writes it in between.
155
+ const deltaLikec4 = featurePaths(featureDir).delta;
156
+ const deltaDoc = existsSync(deltaLikec4) ? await loadFile(deltaLikec4) : undefined;
157
+ const deltaServices = await featureSpecServices(featureDir);
158
+ // Git conflict markers in a LIVING document this merge would rewrite, before
159
+ // anything else is asked of it. Both sides of somebody's merge are in the
160
+ // file, so nothing it says is anyone's text — and the requirements rewrite
161
+ // deletes whichever marker lines fall inside the section it owns, turning a
162
+ // conflict anyone can see into a file nobody can tell is wrong. Checked ahead
163
+ // of the coherence gate for `inspectLandscape`'s reason: a conflicted
164
+ // document parses, so every verdict downstream of it is an opinion about half
165
+ // of two people's work.
166
+ //
167
+ // --approve does not apply, exactly as with the strayed-requirement refusal
168
+ // below: --approve overrides judgments about the FEATURE, and this is a fact
169
+ // about the living document.
170
+ const conflicted = await livingMergeConflicts(config.docsDir, deltaServices);
171
+ if (conflicted.length > 0) {
172
+ const msg = `archive ${id} — BLOCKED: ${conflicted.length} living document(s) still hold git conflict markers`;
173
+ if (json) {
174
+ refuseFindings("merge-failed", msg, conflicted);
175
+ return;
176
+ }
177
+ console.error(`${msg}:`);
178
+ for (const f of conflicted)
179
+ console.error(` ✗ ${f.message}`);
180
+ console.error(`\n--approve does not override this — the loss is mechanical, not a judgment call.`);
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ // Gate: GATING issues block the archive. Severity and gating are two
185
+ // different questions (issue.ts): errors gate because the merge would write
186
+ // something wrong, and the rare warning marked `gates` blocks too — the
187
+ // document is legal but the merge would drop authored content. Advisory
188
+ // warnings are printed (and carried into --json) but never block, matching
189
+ // validate's own doctrine (report.ts: "warnings never gate"). --approve
190
+ // overrides the gating issues ONLY, and must say exactly which ones it is
191
+ // walking past. A dry run is gated too: a plan for a merge that would be
192
+ // refused describes nothing that will happen.
193
+ const issues = await featureCoherence(config.docsDir, featureDir, id, deltaDoc);
194
+ const gating = issues.filter(gatesArchive);
195
+ const advisory = issues.filter((i) => !gatesArchive(i));
196
+ if (gating.length > 0 && !opts.approve) {
197
+ const msg = `archive ${id} — BLOCKED: not coherent (${gating.length} gating issue(s), ${advisory.length} advisory warning(s))`;
198
+ if (json) {
199
+ refuseJson("not-coherent", msg, issues);
200
+ return;
201
+ }
202
+ console.error(`${msg}:`);
203
+ for (const i of issues)
204
+ console.error(` ${i.severity === "error" ? "✗" : "⚠"} ${i.message}`);
205
+ console.error(`\nFix the gating issues (advisory warnings never block), or re-run with --approve to override them (may corrupt the living docs).`);
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+ if (gating.length > 0) {
210
+ say(`⚠ archiving despite ${gating.length} gating issue(s) (--approve):`);
211
+ for (const i of gating)
212
+ say(` ${i.severity === "error" ? "✗" : "⚠"} ${i.message}`);
213
+ say();
214
+ }
215
+ if (advisory.length > 0) {
216
+ say(`⚠ ${advisory.length} warning(s) (non-blocking):`);
217
+ for (const i of advisory)
218
+ say(` ⚠ ${i.message}`);
219
+ say();
220
+ }
221
+ // A `specs/<svc>/` addressed to a service that exists nowhere. `validate`
222
+ // graded this and archive did not, which is the half that costs something:
223
+ // `specs/<svc>/` is what the merge MATERIALISES `services/<svc>/` from, so a
224
+ // one-character slip in `--touches` archived at exit 0 and left the fleet a
225
+ // living service nobody meant to adopt. The set comes from coherence.ts's
226
+ // `unknownDeltaServices` — the same function validate reads — so the two
227
+ // gates cannot drift into disagreeing about which ids are real.
228
+ //
229
+ // Gated exactly like a coherence error, --approve included: which service you
230
+ // meant is a judgment about the FEATURE. A feature that genuinely introduces
231
+ // one has a better escape hatch than --approve anyway — tag its element in
232
+ // delta.likec4, which is also what makes the fleet map true afterwards.
233
+ const unknownServices = await unknownDeltaServices(config.docsDir, featureDir, id, deltaDoc);
234
+ if (unknownServices.length > 0 && !opts.approve) {
235
+ const msg = `archive ${id} — BLOCKED: ${unknownServices.length} per-service delta(s) address a service that does not exist`;
236
+ if (json) {
237
+ refuseFindings("not-coherent", msg, unknownServices);
238
+ return;
239
+ }
240
+ console.error(`${msg}:`);
241
+ for (const f of unknownServices)
242
+ console.error(` ✗ ${f.message}`);
243
+ console.error(`\nFix the id, or introduce the service in this feature's delta.likec4 — or re-run with --approve to create it anyway.`);
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+ if (unknownServices.length > 0) {
248
+ say(`⚠ archiving despite ${unknownServices.length} unknown service(s) (--approve) — this merge creates them:`);
249
+ for (const f of unknownServices)
250
+ say(` ✗ ${f.message}`);
251
+ say();
252
+ }
253
+ // A LIVING requirement outside `## Requirements` is a merge loam cannot do
254
+ // correctly: the rewrite replaces only the requirements RUN inside that
255
+ // section (rewriteRequirementsRun) while parseRequirements collects from
256
+ // every section — so the strayed requirement keeps its authored copy in the
257
+ // preserved prose AND lands again in the rewritten run, and the next
258
+ // archive's MODIFIED replaces only one of them. Refused rather than
259
+ // repaired: excising blocks from prose programmatically would be archive
260
+ // editing text it does not understand. --approve does not apply — it
261
+ // overrides judgments about the FEATURE, and this is neither.
262
+ const strayed = [];
263
+ for (const svc of deltaServices) {
264
+ for (const axis of SPEC_AXES) {
265
+ if (!existsSync(featureSpecPaths(featureDir, svc)[axis.key]))
266
+ continue;
267
+ const livingPath = servicePaths(config.docsDir, svc)[axis.key];
268
+ if (!existsSync(livingPath))
269
+ continue;
270
+ for (const r of parseRequirements(await readUtf8(livingPath))) {
271
+ // The ONE definition of the heading (spec.ts): the guard and the rewrite
272
+ // boundary match the same way, so they cannot disagree about "outside".
273
+ if (r.section !== undefined && isRequirementsHeading(r.section))
274
+ continue;
275
+ const where = r.section === undefined ? "above every heading" : `under '${r.section}'`;
276
+ strayed.push({
277
+ severity: "error",
278
+ code: "living.requirement-outside-requirements",
279
+ subject: svc,
280
+ message: `${svc}: living requirement '${r.name}' sits ${where} in ${repoPath(config.docsDir, livingPath)} — the merge rewrites only '## Requirements', so it would land in the file twice. Re-home it under '## Requirements' first, then re-run.`,
281
+ });
282
+ }
283
+ }
284
+ }
285
+ if (strayed.length > 0) {
286
+ const msg = `archive ${id} — BLOCKED: ${strayed.length} living requirement(s) outside '## Requirements'`;
287
+ if (json) {
288
+ refuseJson("living-outside-requirements", msg, strayed);
289
+ return;
290
+ }
291
+ console.error(`${msg}:`);
292
+ for (const i of strayed)
293
+ console.error(` ✗ ${i.message}`);
294
+ console.error(`\n--approve does not override this — the duplication is mechanical, not a judgment call.`);
295
+ process.exitCode = 1;
296
+ return;
297
+ }
298
+ // Pre-flight: the archive destination must be free, or the final move would fail
299
+ // after the living docs were already rewritten.
300
+ const archiveDir = join(featuresDir, "archive");
301
+ const archiveDest = join(archiveDir, dirName);
302
+ if (existsSync(archiveDest)) {
303
+ fail(json, "archive-exists", `archive ${id} — BLOCKED: features/archive/${dirName} already exists. Remove or rename it, then re-run.`);
304
+ return;
305
+ }
306
+ say(`archive ${id}${dryRun ? " (dry run)" : ""}\n`);
307
+ // PLAN — compute every merge in memory. Nothing is written until the whole plan
308
+ // succeeds, so a failure on any axis leaves the living docs untouched.
309
+ const writes = [];
310
+ // Warnings born in the plan itself (openapi.op-modified,
311
+ // openapi.component-modified) — printed with the plan and carried into the
312
+ // --json envelope beside the coherence warnings.
313
+ const planWarns = [];
314
+ // Gating issues born in the plan itself (openapi.ref-unresolved). The gate
315
+ // ran before the plan, but only the computed merge can see these; they block
316
+ // the same way, and --approve overrides them the same way.
317
+ const planGates = [];
318
+ const openapiRemovals = [];
319
+ /** Services this feature introduces on the architecture axis alone — filled by the landscape merge below. */
320
+ const architectureServices = new Set();
321
+ // 1. Requirements merge — apply ADDED/MODIFIED/REMOVED into each living service
322
+ // spec. ONE code path for the pair of requirement-carrying files: the business
323
+ // spec.md and the architecture arch.spec.md ride the same delta algebra, the
324
+ // same prose-preserving rewrite and the same guards, parameterized by filename
325
+ // (SPEC_AXES) — a fork here would be two places the merge could disagree.
326
+ for (const svc of deltaServices) {
327
+ for (const axis of SPEC_AXES) {
328
+ const deltaPath = featureSpecPaths(featureDir, svc)[axis.key];
329
+ if (!existsSync(deltaPath))
330
+ continue;
331
+ const deltaReqs = parseRequirements(await readUtf8(deltaPath));
332
+ const livingPath = servicePaths(config.docsDir, svc)[axis.key];
333
+ if (!existsSync(livingPath)) {
334
+ // New service (or first arch spec) — create the living file from the
335
+ // ADDED/MODIFIED requirements.
336
+ const created = applyRequirementDelta([], deltaReqs);
337
+ if (created.length === 0) {
338
+ say(` ${axis.label}: ${svc} — nothing to merge (delta leaves no requirements), no living ${axis.file} created`);
339
+ continue;
340
+ }
341
+ const heading = axis.key === "spec" ? svc : `${svc} — architecture`;
342
+ const frontmatter = `---\nservice: ${svc}\nstatus: draft\n---\n\n# ${heading}\n\n`;
343
+ writes.push(planWrite(livingPath, `${frontmatter}## Requirements\n\n${serializeRequirements(created)}`));
344
+ say(` ${axis.label}: ${svc} — created living ${axis.file} (${created.length} requirement(s))`);
345
+ continue;
346
+ }
347
+ const livingText = await readUtf8(livingPath);
348
+ // TWO `## Requirements` headings would put the rewrite's one-section
349
+ // invariant to a choice it must not make: the run of the first would be
350
+ // rewritten while the second survived verbatim in the tail — and its
351
+ // requirements, collected by parseRequirements, would land in the run TOO.
352
+ // Mechanical, like a model-less landscape, so merge-failed, not --approve.
353
+ const reqHeadings = sectionHeadings(livingText).filter((h) => isRequirementsHeading(h.text));
354
+ if (reqHeadings.length > 1) {
355
+ throw new ArchiveFailure("merge-failed", `living ${axis.file} for ${svc} has ${reqHeadings.length} '## Requirements' headings (lines ${reqHeadings.map((h) => h.line).join(", ")}) — the merge rewrites ONE requirements section and cannot choose; merge them into one, then re-run`);
356
+ }
357
+ const merged = applyRequirementDelta(parseRequirements(livingText), deltaReqs);
358
+ writes.push(planWrite(livingPath, rewriteRequirementsRun(livingText, merged)));
359
+ const c = summarize(deltaReqs);
360
+ say(` ${axis.label}: ${svc} ← +${c.ADDED} ~${c.MODIFIED} -${c.REMOVED} (now ${merged.length} total)`);
361
+ }
362
+ }
363
+ // 1b. OpenAPI merge — fold the feature's openapi deltas into the living service APIs.
364
+ for (const svc of deltaServices) {
365
+ const featOpenapi = featureSpecPaths(featureDir, svc).openapi;
366
+ if (!existsSync(featOpenapi))
367
+ continue;
368
+ const featText = await readUtf8(featOpenapi);
369
+ const livingOpenapi = servicePaths(config.docsDir, svc).openapi;
370
+ const featDoc = await readOpenapi(featOpenapi);
371
+ const ops = featDoc.ops.filter((op) => !op.remove).map((op) => op.id);
372
+ // An `x-loam-remove: true` written at PATH level retires nothing: the marker
373
+ // addresses one operation, and beside the methods there is no operation for
374
+ // it to address. The merge now strips it on every branch, so the living
375
+ // contract is safe either way — but the author asked for a removal that will
376
+ // not happen, and silence there is how a retired endpoint stayed live.
377
+ // Gated like the other plan-visible breaches, --approve and all.
378
+ for (const path of featDoc.pathLevelRemovals) {
379
+ planGates.push({
380
+ severity: "error",
381
+ code: "openapi.remove-marker-path-level",
382
+ subject: svc,
383
+ message: `${svc}: '${path}' carries x-loam-remove at PATH level, beside the methods — a removal marker names ONE operation, so this retires nothing and is not a contract key either. Move it inside the operation you are retiring (with its operationId), or delete it.`,
384
+ });
385
+ }
386
+ if (!existsSync(livingOpenapi)) {
387
+ // A removal against a non-existent contract is gated by coherence. Keep
388
+ // the feature-only marker out of living docs even under --approve — and
389
+ // ask the DOCUMENT, not the operation reader: a marker with no
390
+ // operationId is invisible to `operations()`, so gating the strip on
391
+ // "does the reader see a removal" let exactly that marker through into a
392
+ // living contract, published to every consumer of the fleet.
393
+ const content = stripOpenapiRemovalMarkers(featText, svc);
394
+ writes.push(planWrite(livingOpenapi, content));
395
+ say(` openapi: ${svc} — created (${ops.join(", ")})`);
396
+ }
397
+ else {
398
+ let merge;
399
+ try {
400
+ merge = mergeOpenapiPaths(await readUtf8(livingOpenapi), featText, svc);
401
+ }
402
+ catch (err) {
403
+ if (err instanceof OpenapiMergeError)
404
+ throw new ArchiveFailure("merge-failed", err.message);
405
+ throw err;
406
+ }
407
+ const { text, modified, pathItemModified, removed, quoted, componentsModified, unresolved } = merge;
408
+ if (text !== null) {
409
+ writes.push(planWrite(livingOpenapi, text));
410
+ say(` openapi: ${svc} — merged (${ops.join(", ")})`);
411
+ }
412
+ if (removed.length > 0) {
413
+ openapiRemovals.push({ service: svc, operations: removed });
414
+ for (const label of removed)
415
+ say(` - removes ${label}`);
416
+ }
417
+ // Said out loud, because "the plan wrote less than my delta spells" is
418
+ // the one thing a reader cannot infer from a merged file. Not a warning:
419
+ // quoting the contract around your change is correct authoring, and
420
+ // leaving the quote alone is the correct merge.
421
+ for (const label of quoted)
422
+ say(` · quotes ${label} — unchanged since it was pinned, left as living has it`);
423
+ for (const label of modified) {
424
+ planWarns.push({
425
+ severity: "warn",
426
+ code: "openapi.op-modified",
427
+ subject: svc,
428
+ message: `${svc}: the delta redefines ${label}, which the living OpenAPI already has — the merge overwrites the living operation wholesale`,
429
+ });
430
+ say(` ⚠ overwrites ${label} — the living definition differs`);
431
+ }
432
+ for (const label of pathItemModified) {
433
+ planWarns.push({
434
+ severity: "warn",
435
+ code: "openapi.path-item-modified",
436
+ subject: svc,
437
+ message: `${svc}: the delta redefines the path-level key ${label}, which the living OpenAPI already has — the merge overwrites it wholesale, and it applies to EVERY operation on that path, including ones this feature never mentions`,
438
+ });
439
+ say(` ⚠ overwrites path-level ${label} — the living definition differs`);
440
+ }
441
+ for (const comp of componentsModified) {
442
+ planWarns.push({
443
+ severity: "warn",
444
+ code: "openapi.component-modified",
445
+ subject: svc,
446
+ message: `${svc}: the merged operations carry component '${comp}', which the living OpenAPI already defines differently — the merge overwrites the living component wholesale`,
447
+ });
448
+ say(` ⚠ overwrites component ${comp} — the living definition differs`);
449
+ }
450
+ for (const u of unresolved) {
451
+ planGates.push({
452
+ severity: "error",
453
+ code: "openapi.ref-unresolved",
454
+ subject: svc,
455
+ message: `${svc}: $ref '${u.ref}' (referenced from ${u.from}) resolves in neither the feature's openapi.yaml nor the living one — the merged document would carry a dangling reference`,
456
+ });
457
+ }
458
+ }
459
+ }
460
+ // 2. Architecture merge — fold the feature's tagged elements/relationships into the living landscape.
461
+ const landscapePath = landscapeFile(config.docsDir);
462
+ if (deltaDoc !== undefined) {
463
+ const delta = deltaDoc;
464
+ if (delta.errors.length > 0) {
465
+ // --approve overrides loam's JUDGMENT about coherence, never its ability to
466
+ // read an axis. Skipping here would silently drop one merge axis in the one
467
+ // command engineered against quiet partial merges — same rule as an
468
+ // unparseable landscape or openapi: the plan stops before anything is written.
469
+ throw new ArchiveFailure("merge-failed", `delta.likec4 has ${delta.errors.length} parse error(s) — the architecture axis cannot be merged; fix it (\`loam validate --feature ${id}\`) or delete the file`);
470
+ }
471
+ const newEls = delta.elements.filter((e) => e.tags.includes(id));
472
+ const newRels = delta.relationships.filter((r) => r.tags.includes(id));
473
+ if (existsSync(landscapePath)) {
474
+ const plan = await planLandscapeMerge(landscapePath, deltaLikec4, delta.elements, newEls, newRels, id);
475
+ // A service can arrive on the ARCHITECTURE axis alone: an element this
476
+ // merge ADDS, carrying a `metadata { service }` binding, with no
477
+ // `specs/<svc>/` anywhere in the feature. It is a service the fleet gate
478
+ // will demand a directory for the moment this merge lands, so it owes the
479
+ // same warning as one arriving with a requirement delta — and until it
480
+ // did, the closing "complete + current" line printed over a landscape
481
+ // this very archive had just made red. Read off the ADDED elements, not
482
+ // the tagged ones: an element the living landscape already had is not
483
+ // arriving, and one that is never merged is not there to demand anything.
484
+ for (const e of plan.addedEls) {
485
+ if (e.service !== undefined)
486
+ architectureServices.add(e.service);
487
+ }
488
+ writes.push(...plan.writes);
489
+ say(`\n architecture: merged into landscape.likec4 — +${plan.addedEls.length} element(s), +${plan.addedRels.length} relationship(s)`);
490
+ for (const e of plan.addedEls)
491
+ say(` + ${e.title} (${e.kind})`);
492
+ for (const r of plan.addedRels) {
493
+ say(` + ${titleOf(delta.elements, r.source)} -> ${titleOf(delta.elements, r.target)} "${r.title ?? ""}"`);
494
+ }
495
+ }
496
+ else {
497
+ say(`\n architecture: no landscape.likec4 — ${newEls.length} element(s) not merged`);
498
+ }
499
+ }
500
+ // A service this archive BRINGS INTO EXISTENCE arrives without the one file
501
+ // `validate` demands of every service: its own model.likec4. The merge cannot
502
+ // write it — the delta's tagged subtree is a landscape-level box, not a
503
+ // container model, and inventing a plausible one is the kind of quiet fiction
504
+ // the rest of loam exists to prevent — so the archive says so instead, and
505
+ // stops claiming the docs are complete. Non-gating: the feature is coherent
506
+ // and the merge is correct; what is missing is the next step, and refusing
507
+ // here would make onboarding a new service impossible in one command.
508
+ const newServices = [...new Set([...deltaServices, ...architectureServices])].filter((svc) => !existsSync(servicePaths(config.docsDir, svc).dir));
509
+ for (const svc of newServices) {
510
+ if (existsSync(servicePaths(config.docsDir, svc).model))
511
+ continue;
512
+ // Two shapes of the same debt: a service with a requirement delta gets its
513
+ // directory from this merge, one that arrives only in the landscape gets no
514
+ // directory at all — and `validate --all` fails the second harder
515
+ // (`landscape.service-unmodelled` names the binding with nothing behind it).
516
+ const creates = deltaServices.includes(svc)
517
+ ? `this archive creates services/${svc}/, but nothing writes services/${svc}/model.likec4`
518
+ : `this archive puts '${svc}' in the landscape, but the fleet has no services/${svc}/ at all`;
519
+ planWarns.push({
520
+ severity: "warn",
521
+ code: "service.no-model",
522
+ subject: svc,
523
+ message: `${svc}: ${creates} — 'loam validate --all' will report the service as incomplete until it exists. Run 'loam adopt --service ${svc}' from the service repo, or write the model by hand.`,
524
+ });
525
+ }
526
+ // Gate on what only the plan could see: a merged operation pointing at a
527
+ // component that exists nowhere, a removal marker addressing no operation.
528
+ // Same doctrine as the coherence gate — a judgment call --approve overrides
529
+ // (unlike the mechanical merge-failed refusals), and a dry run is gated too.
530
+ // Checked after the whole plan so the refusal costs nothing: no write has
531
+ // happened yet either way.
532
+ if (planGates.length > 0 && !opts.approve) {
533
+ const msg = `archive ${id} — BLOCKED: ${planGates.length} issue(s) in the OpenAPI merge`;
534
+ if (json) {
535
+ refuseJson("not-coherent", msg, [...issues, ...planWarns, ...planGates]);
536
+ return;
537
+ }
538
+ console.error(`${msg}:`);
539
+ for (const i of planGates)
540
+ console.error(` ✗ ${i.message}`);
541
+ console.error(`\nFix them in the feature's openapi.yaml — or re-run with --approve to merge anyway.`);
542
+ process.exitCode = 1;
543
+ return;
544
+ }
545
+ if (planGates.length > 0) {
546
+ say(`\n ⚠ archiving despite ${planGates.length} OpenAPI merge issue(s) (--approve):`);
547
+ for (const i of planGates)
548
+ say(` ✗ ${i.message}`);
549
+ }
550
+ // The plan as data, verb decided now — after the commit everything would read
551
+ // as an update. The `--json` payload is identical for a dry run and the real
552
+ // thing except for `archived`; what WOULD happen is what DOES happen.
553
+ const warnings = [...advisory, ...planWarns];
554
+ const overridden = opts.approve === true ? [...gating, ...planGates] : [];
555
+ const plan = writes.map((w) => ({
556
+ path: repoPath(config.docsDir, w.path),
557
+ action: existsSync(w.path) ? "update" : "create",
558
+ }));
559
+ plan.push({ path: `features/${dirName}`, action: "move", to: `features/archive/${dirName}` });
560
+ const payload = (archived) => ({
561
+ feature: id,
562
+ archived,
563
+ path: repoPath(config.docsDir, archiveDest),
564
+ plan,
565
+ warnings: warnings.map(issueJson),
566
+ overridden: overridden.map(issueJson),
567
+ openapiRemovals,
568
+ // Present only when this run found an interrupted commit and dealt with it:
569
+ // an agent that sees the docs change under it deserves to be told why, and
570
+ // the absent field is the ordinary case.
571
+ ...(recovered === null ? {} : { recovered }),
572
+ });
573
+ if (dryRun) {
574
+ if (json)
575
+ emitJson(payload(false));
576
+ else
577
+ printPlan(config.docsDir, writes, dirName);
578
+ return;
579
+ }
580
+ // COMMIT — the whole plan computed cleanly. Stage every new version beside its
581
+ // target, snapshot what is about to be overwritten, then swap them into place.
582
+ // Staging touches the filesystem — a read-only `architecture/`, a full disk,
583
+ // a target whose pre-image cannot be read. None of that is a bug in loam, and
584
+ // reporting `internal` sends the reader looking for one; it is the same
585
+ // answer as any other merge that could not be computed: nothing was written.
586
+ let staged;
587
+ try {
588
+ staged = await stageWrites(writes);
589
+ }
590
+ catch (err) {
591
+ throw new ArchiveFailure("merge-failed", `${message(err)} — nothing was written`);
592
+ }
593
+ let snapshot = false;
594
+ let createdArchiveDir;
595
+ try {
596
+ await writeSnapshot(featureDir, config.docsDir, id, dirName, staged);
597
+ snapshot = true;
598
+ // The journal, fsynced, BEFORE the first rename: swapStaged is N renames and
599
+ // only each one of them is atomic, so a kill between two used to leave a
600
+ // half-merged repo that nothing could name — `doctor` and `status` called it
601
+ // healthy and `validate --all` blamed the delta. Written from `staged`, so
602
+ // it records the same digests the swaps are about to produce.
603
+ await writeCommitIntent(config.docsDir, { command: "archive", restore: "before", feature: id, moveFrom: featureDir, moveTo: archiveDest }, staged);
604
+ await swapStaged(staged);
605
+ createdArchiveDir = await mkdir(archiveDir, { recursive: true });
606
+ await rename(featureDir, archiveDest);
607
+ // Last: while it exists, the commit is in flight.
608
+ await clearCommitIntent(config.docsDir);
609
+ }
610
+ catch (err) {
611
+ if (err instanceof SnapshotClobberError) {
612
+ // Nothing swapped — the refusal happens before the journal is even
613
+ // written — so this only takes the temp files away. Its own code, because
614
+ // the answer is not "re-run": a previous archive of this feature is still
615
+ // sitting in the living docs.
616
+ await rollbackStaged(staged);
617
+ throw new ArchiveFailure("commit-interrupted", err.message);
618
+ }
619
+ // Everything this run made, unmade: the swapped files, the snapshot inside the
620
+ // feature that is staying put, and features/archive/ if we are the ones who
621
+ // created it (mkdir reports nothing when it was already there).
622
+ const failures = await rollbackStaged(staged);
623
+ // The rollback decided the outcome, so the journal has nothing left to
624
+ // describe — including on rollback-incomplete, where the files it names are
625
+ // the ones the message tells a human to look at.
626
+ await clearCommitIntent(config.docsDir);
627
+ // The snapshot goes only when the rollback HELD. On rollback-incomplete it
628
+ // holds the only on-disk pre-images of the very files the message tells the
629
+ // reader to repair by hand — a MODIFIED requirement's previous text appears
630
+ // nowhere else. Retaining it is safe: the next archive of this feature reads
631
+ // it before it would replace it, and refuses if the living docs have moved.
632
+ if (snapshot && failures.length === 0)
633
+ await quietRm(snapshotDir(featureDir));
634
+ // `features/archive/` is shared the moment it exists: another archive can
635
+ // have moved a whole feature into it between our mkdir and this rollback,
636
+ // and a recursive remove of "the directory we created" took that feature —
637
+ // snapshot and all — with it, on OUR failure path, in silence. Empty
638
+ // directories only, stopping at the first that is not.
639
+ if (createdArchiveDir !== undefined)
640
+ await quietPruneEmptyParents(archiveDir, createdArchiveDir);
641
+ // The code is a caller's answer to "can I trust the repo?": merge-failed
642
+ // means yes (rolled back), rollback-incomplete means look at it by hand.
643
+ const wrapped = rollbackError(err, failures);
644
+ const kept = snapshot && failures.length > 0
645
+ ? ` Pre-images of the overwritten files are kept in features/${dirName}/${SNAPSHOT_DIR}/files/.`
646
+ : "";
647
+ throw new ArchiveFailure(failures.length > 0 ? "rollback-incomplete" : "merge-failed", wrapped.message + kept);
648
+ }
649
+ if (json) {
650
+ emitJson(payload(true));
651
+ return;
652
+ }
653
+ console.log(`\n archived: features/${dirName} → features/archive/${dirName}`);
654
+ console.log(` snapshot: features/archive/${dirName}/${SNAPSHOT_DIR}/ — \`loam unarchive ${id}\` puts it back`);
655
+ // The closing line is a claim about the whole docs repo, and it is the line a
656
+ // reader stops at. It may be printed only when this archive left nothing the
657
+ // next `validate --all` will fail on — a service with no model, or a gate the
658
+ // caller told it to merge past. Printing it over either is how a red fleet
659
+ // gets reported as a finished one.
660
+ const incomplete = planWarns.filter((w) => w.code === "service.no-model");
661
+ if (incomplete.length === 0 && overridden.length === 0) {
662
+ console.log(" living spec + landscape are now complete + current.");
663
+ return;
664
+ }
665
+ for (const w of incomplete)
666
+ console.log(` ⚠ ${w.message}`);
667
+ if (overridden.length > 0) {
668
+ console.log(` ⚠ merged past ${overridden.length} gating issue(s) with --approve — the living docs carry them now; \`loam validate --all\` says what they cost.`);
669
+ }
670
+ }
671
+ /**
672
+ * What the recovery did, before this command's own output — a docs repo that
673
+ * changed under the caller is the first thing they have to be told, not a
674
+ * footnote. Shared by archive and unarchive through the same CommitRecovery.
675
+ */
676
+ export function sayRecovery(r) {
677
+ const what = `an interrupted \`loam ${r.command} ${r.feature}\``;
678
+ if (r.outcome === "completed") {
679
+ console.log(`⚠ ${what} had in fact finished — cleared its commit record.\n`);
680
+ return;
681
+ }
682
+ if (r.outcome === "consistent") {
683
+ console.log(`⚠ ${what} was rolled back before it wrote anything — cleared its commit record.\n`);
684
+ return;
685
+ }
686
+ // An interrupted archive is UNDONE and an interrupted unarchive is FINISHED —
687
+ // the merged text a restore was replacing is written down nowhere, so there is
688
+ // nothing to go back to. Say which happened; the paths are the same either way.
689
+ const what_ = r.command === "archive" ? "put them back from" : "finished them from";
690
+ console.log(`⚠ ${what} left ${r.repaired.length} file(s) half-written; ${what_} its snapshot:`);
691
+ for (const p of r.repaired)
692
+ console.log(` ↩ ${p}`);
693
+ console.log("");
694
+ }
695
+ /** The full plan, as files: what a dry run shows instead of doing. */
696
+ function printPlan(docsDir, writes, dirName) {
697
+ console.log(`\n plan — ${writes.length} file(s):`);
698
+ for (const w of writes) {
699
+ const verb = existsSync(w.path) ? "update" : "create";
700
+ console.log(` ${verb} ${repoPath(docsDir, w.path)}`);
701
+ }
702
+ console.log(` move features/${dirName} → features/archive/${dirName}`);
703
+ console.log("\n dry run — nothing was written.");
704
+ }
705
+ /**
706
+ * Plan the merge of the feature's new elements + relationships into the living
707
+ * landscape's `model { ... }` block (preserving the rest of the file).
708
+ *
709
+ * The additions are SPLICED from delta.likec4 as authored — byte for byte,
710
+ * technology, style, icons, links, metadata, nested children and all — never
711
+ * re-serialized from the parsed model: a rebuild keeps only the fields loam
712
+ * models, and every field it forgets is authored detail destroyed. The one
713
+ * edit on the way over is dropping the feature's own tag — the additions are
714
+ * baseline now (SCHEMA.md documents the drop as why unarchive restores bytes
715
+ * instead of recomputing) — and a construct the strip empties goes with it.
716
+ *
717
+ * Placement is deterministic and SERVICE-GROUPED (the spot* helpers below):
718
+ * a top-level addition lands beside what already belongs to its service, so
719
+ * two concurrent archives touching different services usually splice on
720
+ * different lines and git merges their PRs — the single shared append point
721
+ * every archive used to hit made two concurrent archives conflict BY
722
+ * CONSTRUCTION. A NESTED element lands inside its parent's block — inside the
723
+ * living parent when the landscape already has it, riding verbatim inside the
724
+ * spliced parent when the parent is new from the same delta — never as a flat
725
+ * dotted id at top level, which LikeC4 rejects. The additions carry no marker
726
+ * comment: which archive added what is the git history's to say.
727
+ *
728
+ * Existence is checked semantically against the parsed landscape (by element
729
+ * id/title and by edge identity), so re-archiving is idempotent and title
730
+ * strings appearing elsewhere in the source cause no false skips. Assumes the
731
+ * delta reuses the landscape's element identifiers for existing services. A
732
+ * title match whose two sides are BOTH bound to different services is not an
733
+ * existence hit but a collision, and refuses the archive (see the guard).
734
+ *
735
+ * Splicing is text surgery, so the computed result is PARSED before it is
736
+ * returned: a merged landscape LikeC4 rejects refuses the archive at plan time
737
+ * (merge-failed, nothing written — the unparseable-delta discipline) instead
738
+ * of landing in the living docs.
739
+ */
740
+ async function planLandscapeMerge(landscapePath, deltaPath, deltaElements, newEls, newRels, featureId) {
741
+ const text = await readUtf8(landscapePath);
742
+ const land = await loadFile(landscapePath);
743
+ if (land.errors.length > 0) {
744
+ throw new ArchiveFailure("merge-failed", `landscape.likec4 has ${land.errors.length} error(s) — fix it before archiving`);
745
+ }
746
+ const haveIds = new Set(land.elements.map((e) => e.id));
747
+ // The title join needs the matched element back, not just membership: the
748
+ // cross-service guard below compares service BINDINGS across the join.
749
+ const byTitle = new Map();
750
+ const seeTitle = (el) => {
751
+ byTitle.set(el.title, [...(byTitle.get(el.title) ?? []), el]);
752
+ };
753
+ for (const el of land.elements)
754
+ seeTitle(el);
755
+ const addedEls = [];
756
+ for (const e of newEls) {
757
+ if (haveIds.has(e.id))
758
+ continue;
759
+ const sameTitle = byTitle.get(e.title);
760
+ if (sameTitle !== undefined) {
761
+ // A title match is the id-less fallback join, and skipping on it is only
762
+ // safe when the two sides could be the same box. When both sides carry an
763
+ // explicit `metadata { service }` binding and every binding disagrees,
764
+ // they are provably DIFFERENT services' boxes sharing a title ('API',
765
+ // 'Database') — the skip would silently drop the addition, and any delta
766
+ // edge into it would then refuse the whole archive at the parse net
767
+ // below with a message about nothing. Refuse here instead, at plan time,
768
+ // naming both sides.
769
+ if (e.service !== undefined && sameTitle.every((m) => m.service !== undefined && m.service !== e.service)) {
770
+ const m = sameTitle[0];
771
+ throw new ArchiveFailure("merge-failed", `the delta's '${e.id}' (bound to service '${e.service}') shares the title '${e.title}' with '${m.id}' (bound to service '${m.service}') — a title join across services would silently drop the addition; ` +
772
+ `retitle one of them, or reuse the id '${m.id}' if they really are the same element`);
773
+ }
774
+ // KNOWN (narrowed by the guard above): with EITHER side unbound the title
775
+ // join stays trusting — the unbound title-fallback is the legal legacy
776
+ // pattern — so a cross-service collision hiding behind an unbound element
777
+ // is still silently skipped here. Scoping titles per service is backlog.
778
+ continue;
779
+ }
780
+ haveIds.add(e.id);
781
+ seeTitle(e);
782
+ addedEls.push(e);
783
+ }
784
+ // Edges are matched by COUNT, not by membership: two edges the model cannot tell
785
+ // apart are still two edges, and dropping the second one silently loses a call
786
+ // the author drew. An edge already in the landscape consumes one delta edge of
787
+ // the same identity, which is what keeps re-archiving idempotent.
788
+ const have = new Map();
789
+ for (const r of land.relationships) {
790
+ const k = relKey(land.elements, r);
791
+ have.set(k, (have.get(k) ?? 0) + 1);
792
+ }
793
+ const addedRels = [];
794
+ for (const r of newRels) {
795
+ const k = relKey(deltaElements, r);
796
+ const n = have.get(k) ?? 0;
797
+ if (n > 0) {
798
+ have.set(k, n - 1);
799
+ continue;
800
+ }
801
+ addedRels.push(r);
802
+ }
803
+ if (addedEls.length === 0 && addedRels.length === 0)
804
+ return { writes: [], addedEls, addedRels };
805
+ const deltaText = await readUtf8(deltaPath);
806
+ const scan = scanModel(deltaText);
807
+ if (scan === null) {
808
+ throw new ArchiveFailure("merge-failed", "delta.likec4 has no model block — nothing to splice the additions from");
809
+ }
810
+ // Everything below either locates a declaration's authored bytes or refuses.
811
+ // `spliced` remembers the delta ranges already carried over, so a child whose
812
+ // parent is itself new is recognised as riding inside the parent's text and
813
+ // is never inserted twice.
814
+ const byId = new Map(scan.elements.map((e) => [e.id, e]));
815
+ const spliced = [];
816
+ const rides = (start, end) => spliced.some((r) => start >= r.start && end <= r.end);
817
+ // The living model, scanned on MASKED source (scanModel) and RE-SCANNED
818
+ // after every splice: each addition lands in the text exactly as the
819
+ // previous one left it, so an archive of N additions produces the same
820
+ // bytes as N single-addition archives run back to back. Placement then
821
+ // composes per statement — the unit the order-independence argument below
822
+ // is stated in — instead of per archive, where a batch computed against a
823
+ // stale layout could interleave with itself. Masking matters — a `model {`
824
+ // spelled inside a comment or string above the real block must not capture
825
+ // the match, or every top-level addition lands inside the comment (legal
826
+ // LikeC4, so the parse net below would pass a landscape containing none of
827
+ // the architecture).
828
+ let content = text;
829
+ // Service binding for placement: living elements first (they win a shared
830
+ // id), the delta's after — a spliced statement's id resolves to its service
831
+ // the moment its bytes land in the scan.
832
+ const bindEls = [...land.elements, ...deltaElements.filter((d) => !land.elements.some((l) => l.id === d.id))];
833
+ let livingScan = scanModel(content);
834
+ let stmts = livingScan === null ? [] : topStatements(livingScan, bindEls);
835
+ const rescan = () => {
836
+ livingScan = scanModel(content);
837
+ stmts = livingScan === null ? [] : topStatements(livingScan, bindEls);
838
+ };
839
+ const requireModel = () => {
840
+ if (livingScan === null)
841
+ throw new ArchiveFailure("merge-failed", "landscape.likec4 has no model block");
842
+ return livingScan;
843
+ };
844
+ const applyAt = (at, insert) => {
845
+ content = content.slice(0, at) + insert + content.slice(at);
846
+ rescan();
847
+ };
848
+ const applyTop = (spot, block) => {
849
+ // A bare spot shares its line with other text (the closing brace, or a
850
+ // statement the block displaces) — it brings its own newlines; a
851
+ // line-start spot slots between lines.
852
+ applyAt(spot.at, spot.bare ? `\n${block}\n` : `${block}\n`);
853
+ };
854
+ // Placement walks the scanned statement layout, so a declaration the scan
855
+ // cannot see is one placement would blindly splice around — LikeC4 accepts
856
+ // two declarations on one line, but scanModel's statement head runs to the
857
+ // end of the line, so the second rides invisibly inside the first: an
858
+ // element bound to its service would miss its neighborhood, and a bodyless
859
+ // parent gaining a body could wrap the wrong declaration. Refuse
860
+ // mechanically (the parse-net discipline) instead of splicing blind.
861
+ if (livingScan !== null) {
862
+ const seen = new Set(livingScan.elements.map((e) => e.id));
863
+ const invisible = land.elements.find((e) => !seen.has(e.id));
864
+ if (invisible !== undefined) {
865
+ throw new ArchiveFailure("merge-failed", `landscape.likec4 declares '${invisible.id}' in a form placement cannot locate — most often two declarations sharing one line; give each its own line, then re-run`);
866
+ }
867
+ if (livingScan.rels.length < land.relationships.length) {
868
+ throw new ArchiveFailure("merge-failed", `landscape.likec4 declares ${land.relationships.length} relationship(s) but placement can locate only ${livingScan.rels.length} — most often two statements sharing one line; give each its own line, then re-run`);
869
+ }
870
+ }
871
+ const livingParentOf = (parentId) => {
872
+ if (livingScan === null)
873
+ return null;
874
+ const direct = livingScan.elements.find((e) => e.id === parentId);
875
+ if (direct !== undefined)
876
+ return direct;
877
+ // The delta may spell an existing element under its own id; the title is
878
+ // the stable cross-namespace name (the same rule the existence check uses).
879
+ const title = deltaElements.find((e) => e.id === parentId)?.title;
880
+ const livingId = title === undefined ? undefined : land.elements.find((e) => e.title === title)?.id;
881
+ if (livingId === undefined)
882
+ return null;
883
+ return livingScan.elements.find((e) => e.id === livingId) ?? null;
884
+ };
885
+ // Ancestors first: a spliced parent covers its children before they are seen.
886
+ const depth = (id) => id.split(".").length;
887
+ const sortedEls = [...addedEls].sort((a, b) => depth(a.id) - depth(b.id) || (byId.get(a.id)?.start ?? 0) - (byId.get(b.id)?.start ?? 0));
888
+ for (const e of sortedEls) {
889
+ const src = byId.get(e.id);
890
+ if (src === undefined) {
891
+ throw new ArchiveFailure("merge-failed", `cannot locate '${e.id}' in delta.likec4 — the landscape merge splices authored source, and this declaration was not found`);
892
+ }
893
+ if (rides(src.start, src.end))
894
+ continue;
895
+ const dot = e.id.lastIndexOf(".");
896
+ if (dot === -1) {
897
+ const spot = elementSpot(content, stmts, requireModel().close, e.id, elementService(e));
898
+ applyTop(spot, spliceSource(deltaText, src, featureId, " "));
899
+ spliced.push({ start: src.start, end: src.end });
900
+ continue;
901
+ }
902
+ const parentId = e.id.slice(0, dot);
903
+ const parent = livingParentOf(parentId);
904
+ if (parent === null) {
905
+ throw new ArchiveFailure("merge-failed", `'${e.id}' nests under '${parentId}', which is neither in the living landscape nor added by this delta — there is nowhere to insert it`);
906
+ }
907
+ const nested = nestedInsert(content, parent, spliceSource(deltaText, src, featureId, parent.indent + " "));
908
+ applyAt(nested.at, nested.insert);
909
+ spliced.push({ start: src.start, end: src.end });
910
+ }
911
+ // Relationships: match each parsed addition back to its statement in the
912
+ // delta source — full identity (endpoints, title, op, tags), consumed one
913
+ // statement per addition so duplicates stay duplicates.
914
+ const relKeyOf = (source, target, title, op, tags = []) => JSON.stringify([source, target, title ?? "", op ?? "", [...tags].sort()]);
915
+ const pool = new Map();
916
+ for (const s of scan.rels) {
917
+ const k = relKeyOf(s.source, s.target, s.title, s.op, s.tags);
918
+ pool.set(k, [...(pool.get(k) ?? []), s]);
919
+ }
920
+ for (const r of addedRels) {
921
+ const s = pool.get(relKeyOf(r.source, r.target, r.title, r.op, r.tags))?.shift();
922
+ if (s === undefined) {
923
+ throw new ArchiveFailure("merge-failed", `cannot locate the '${r.source} -> ${r.target}' relationship in delta.likec4 — the landscape merge splices authored source, and no matching declaration was found`);
924
+ }
925
+ if (rides(s.start, s.end))
926
+ continue;
927
+ const key = relSortKey(deltaElements, r);
928
+ const spot = relSpot(content, stmts, requireModel().close, serviceOf(deltaElements, r.source), key);
929
+ applyTop(spot, spliceSource(deltaText, s, featureId, " "));
930
+ }
931
+ // The safety net: prove the computed landscape parses before anything is
932
+ // written. Splice bugs — and legal inputs the living document cannot absorb,
933
+ // like a kind its specification never declares — refuse here, at plan time,
934
+ // instead of corrupting the one file the whole fleet reads.
935
+ const check = await loadSource(content);
936
+ if (check.errors.length > 0) {
937
+ const detail = check.errors
938
+ .slice(0, 3)
939
+ .map((e) => (typeof e.line === "number" ? `L${e.line}: ${e.message}` : e.message))
940
+ .join("; ");
941
+ throw new ArchiveFailure("merge-failed", `the merged landscape would not parse (${check.errors.length} error(s): ${detail}) — nothing was written. ` +
942
+ `The delta's additions do not fit the living landscape as authored — most often an element kind or tag ` +
943
+ `its specification block does not declare; fix the landscape's specification or the delta, then re-run`);
944
+ }
945
+ return { writes: [planWrite(landscapePath, content)], addedEls, addedRels };
946
+ }
947
+ /**
948
+ * A declaration's authored source, ready to land in the living landscape:
949
+ * byte-verbatim except that the feature's own tag is stripped and the
950
+ * indentation is rebased from where the block sat in the delta to where it
951
+ * lands. A construct the strip leaves empty goes too — a line that held only
952
+ * the tag disappears whole, and `x = kind 'y' { #FEAT-1 }` lands as
953
+ * `x = kind 'y'`, not as a pair of empty braces.
954
+ */
955
+ function spliceSource(deltaText, decl, featureId, targetIndent) {
956
+ const block = deltaText.slice(decl.start, decl.end);
957
+ return reindent(stripFeatureTag(block, featureId), decl.indent, targetIndent);
958
+ }
959
+ function stripFeatureTag(block, featureId) {
960
+ const { code } = maskSource(block);
961
+ const esc = featureId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
962
+ const removals = [];
963
+ for (const m of block.matchAll(new RegExp(`#${esc}(?![\\w-])`, "g"))) {
964
+ // Only tags in CODE count — a description quoting "#FEAT-1" is content.
965
+ if (code.slice(m.index, m.index + m[0].length) !== m[0])
966
+ continue;
967
+ let s = m.index;
968
+ let e = s + m[0].length;
969
+ // Take the whitespace on ONE side with the token — trailing first, so
970
+ // `#FEAT-1 #critical` leaves `#critical` at its own indent, and a token at
971
+ // the end of a line does not leave a trailing space behind.
972
+ if (block[e] === " " || block[e] === "\t") {
973
+ while (block[e] === " " || block[e] === "\t")
974
+ e += 1;
975
+ }
976
+ else {
977
+ while (s > 0 && (block[s - 1] === " " || block[s - 1] === "\t"))
978
+ s -= 1;
979
+ }
980
+ removals.push([s, e]);
981
+ }
982
+ if (removals.length === 0)
983
+ return block;
984
+ // A line the strip emptied disappears whole, newline included.
985
+ for (let lineStart = 0; lineStart < block.length;) {
986
+ const nl = block.indexOf("\n", lineStart);
987
+ const lineEnd = nl === -1 ? block.length : nl + 1;
988
+ if (removals.some(([s, e]) => s >= lineStart && e <= lineEnd) &&
989
+ /^\s*$/.test(residual(block, lineStart, nl === -1 ? block.length : nl, removals))) {
990
+ removals.push([lineStart, lineEnd]);
991
+ }
992
+ lineStart = lineEnd;
993
+ }
994
+ // A body the strip emptied goes too, braces and all — but only one the strip
995
+ // emptied: braces that were authored empty are authored bytes and survive.
996
+ for (let at = code.indexOf("{"); at !== -1; at = code.indexOf("{", at + 1)) {
997
+ const closeAt = matchBrace(code, at);
998
+ if (closeAt === -1)
999
+ continue;
1000
+ if (!removals.some(([s, e]) => s > at && e <= closeAt))
1001
+ continue;
1002
+ if (!/^\s*$/.test(residual(block, at + 1, closeAt, removals)))
1003
+ continue;
1004
+ let s = at;
1005
+ while (s > 0 && /\s/.test(block[s - 1]))
1006
+ s -= 1;
1007
+ removals.push([s, closeAt + 1]);
1008
+ }
1009
+ return applyRemovals(block, removals);
1010
+ }
1011
+ /** The text of [from, to) with the removal ranges cut out. */
1012
+ function residual(text, from, to, removals) {
1013
+ let out = "";
1014
+ for (let i = from; i < to; i += 1) {
1015
+ if (!removals.some(([s, e]) => i >= s && i < e))
1016
+ out += text[i];
1017
+ }
1018
+ return out;
1019
+ }
1020
+ function applyRemovals(text, removals) {
1021
+ const sorted = [...removals].sort((a, b) => a[0] - b[0] || b[1] - a[1]);
1022
+ let out = "";
1023
+ let i = 0;
1024
+ for (const [s, e] of sorted) {
1025
+ if (s > i)
1026
+ out += text.slice(i, s);
1027
+ if (e > i)
1028
+ i = e;
1029
+ }
1030
+ return out + text.slice(i);
1031
+ }
1032
+ /**
1033
+ * Rebase a block's indentation: the `base` its lines carried in the delta
1034
+ * becomes `target`. The first line arrives with no leading whitespace (the
1035
+ * scanner's span starts at the declaration itself), so it only gains `target`.
1036
+ */
1037
+ function reindent(block, base, target) {
1038
+ return block
1039
+ .split("\n")
1040
+ .map((line, k) => {
1041
+ const body = k === 0 ? line : line.startsWith(base) ? line.slice(base.length) : line;
1042
+ return body.length === 0 ? "" : target + body;
1043
+ })
1044
+ .join("\n");
1045
+ }
1046
+ /**
1047
+ * Where a child block enters an existing living parent: before the closing
1048
+ * brace of the parent's body, on its own line at the parent's inner indent —
1049
+ * and a parent that never had a body gains one around the child.
1050
+ */
1051
+ function nestedInsert(text, parent, block) {
1052
+ if (parent.bodyOpen === -1) {
1053
+ return { at: parent.end, insert: ` {\n${block}\n${parent.indent}}` };
1054
+ }
1055
+ const lineStart = text.lastIndexOf("\n", parent.bodyClose - 1) + 1;
1056
+ if (/^[ \t]*$/.test(text.slice(lineStart, parent.bodyClose))) {
1057
+ return { at: lineStart, insert: `${block}\n` };
1058
+ }
1059
+ // `{ ... }` on one line — break the brace onto its own line to make room.
1060
+ return { at: parent.bodyClose, insert: `\n${block}\n${parent.indent}` };
1061
+ }
1062
+ /**
1063
+ * What makes two edges the same edge. Endpoints are compared by TITLE, which is
1064
+ * stable across the delta's and the landscape's id namespaces.
1065
+ *
1066
+ * An edge that carries an `op` IS that call, whatever it is titled — retitling it
1067
+ * must not merge a second copy. An edge with no `op` has only its title. The two
1068
+ * live in separate namespaces because they are separate things: an op-less edge
1069
+ * titled `authorizePayment` is not the edge whose operationId is authorizePayment,
1070
+ * and keying on `op ?? title` quietly merged only one of them.
1071
+ */
1072
+ function relKey(els, r) {
1073
+ const src = titleOf(els, r.source);
1074
+ const tgt = titleOf(els, r.target);
1075
+ return JSON.stringify(r.op !== undefined ? ["op", src, tgt, r.op] : ["title", src, tgt, r.title ?? ""]);
1076
+ }
1077
+ /** The model's top-level statements in document order, joined to the parsed elements for services. */
1078
+ function topStatements(scan, els) {
1079
+ const topEls = scan.elements.filter((e) => !e.id.includes("."));
1080
+ const bound = new Map(els.map((e) => [e.id, elementService(e)]));
1081
+ const stmts = topEls.map((e) => ({
1082
+ kind: "element",
1083
+ id: e.id,
1084
+ key: "",
1085
+ services: [bound.get(e.id) ?? e.id],
1086
+ start: e.start,
1087
+ end: e.end,
1088
+ }));
1089
+ for (const r of scan.rels) {
1090
+ // A rel declared inside an element's body is not a top-level statement —
1091
+ // anchoring after it would splice into the parent's block.
1092
+ if (topEls.some((e) => r.start > e.start && r.start < e.end))
1093
+ continue;
1094
+ stmts.push({
1095
+ kind: "rel",
1096
+ id: "",
1097
+ key: relSortKey(els, r),
1098
+ services: [serviceOf(els, r.source), serviceOf(els, r.target)],
1099
+ start: r.start,
1100
+ end: r.end,
1101
+ });
1102
+ }
1103
+ return stmts.sort((a, b) => a.start - b.start);
1104
+ }
1105
+ /**
1106
+ * The order of loam-inserted relationships that share a landing region:
1107
+ * (source title, target title, op, title) as one comparable string. Titles,
1108
+ * not ids — the delta's and the landscape's id namespaces differ, and the
1109
+ * same edge must sort identically as today's addition and as an existing
1110
+ * statement in the next archive's scan.
1111
+ */
1112
+ function relSortKey(els, r) {
1113
+ return JSON.stringify([titleOf(els, r.source), titleOf(els, r.target), r.op ?? "", r.title ?? ""]);
1114
+ }
1115
+ /**
1116
+ * The trailing walks move in PLACEMENT UNITS, never single statements. A unit
1117
+ * is a service neighborhood: a contiguous run of top-level elements resolving
1118
+ * to one service, or a contiguous cluster of relationships grown around the
1119
+ * statements touching one — keyed by its LEADING statement's id/key. Anchored
1120
+ * inserts extend a neighborhood without changing its head, so the insertion
1121
+ * sort the trailing region maintains is over neighborhood heads, which
1122
+ * archive order cannot reshuffle. Comparing per statement instead let an
1123
+ * anchored insert legally sit a LOWER id/key after a higher one (its anchor
1124
+ * decides where it lives, not its own sort key), and a later trailing walk
1125
+ * then stopped at different statements depending on which archive had run
1126
+ * first — order-DEPENDENT bytes for features touching disjoint services, the
1127
+ * exact regime the scheme guarantees.
1128
+ */
1129
+ function elementSpot(text, stmts, close, id, service) {
1130
+ let anchor;
1131
+ for (const s of stmts) {
1132
+ if (s.kind === "element" && s.services.includes(service))
1133
+ anchor = s;
1134
+ }
1135
+ if (anchor !== undefined)
1136
+ return spotAfter(text, close, anchor.end);
1137
+ let i = stmts.length - 1;
1138
+ let before;
1139
+ while (i >= 0 && stmts[i].kind === "rel") {
1140
+ before = stmts[i];
1141
+ i -= 1;
1142
+ }
1143
+ while (i >= 0 && stmts[i].kind === "element") {
1144
+ // The same-service run ending here is one unit: skip it whole or stop
1145
+ // before it whole — an anchored member may carry any id, the run's
1146
+ // LEADING id is its sort key.
1147
+ let j = i;
1148
+ while (j > 0 && stmts[j - 1].kind === "element" && stmts[j - 1].services[0] === stmts[i].services[0])
1149
+ j -= 1;
1150
+ if (!(stmts[j].id > id))
1151
+ break;
1152
+ before = stmts[j];
1153
+ i = j - 1;
1154
+ }
1155
+ return before !== undefined ? beforeSpot(text, before) : closeSpot(text, close);
1156
+ }
1157
+ /**
1158
+ * Each top-level statement's cluster, as the index of the cluster's leading
1159
+ * statement. A relationship whose SOURCE's service some earlier relationship
1160
+ * in the same contiguous stretch already touches is an anchored join — it
1161
+ * belongs to that neighborhood wherever its own key would sort. One whose
1162
+ * source's service nothing before it touches opens a new cluster; sharing
1163
+ * only a TARGET does not join (every trailing edge into a busy hub would
1164
+ * otherwise dissolve into one giant cluster, and the trailing sort with it).
1165
+ * Recomputed from bytes alone, so the next archive sees the same units this
1166
+ * one placed.
1167
+ */
1168
+ function relClusterHeads(stmts) {
1169
+ const heads = stmts.map((_, i) => i);
1170
+ let head = -1;
1171
+ let services = null;
1172
+ for (let i = 0; i < stmts.length; i += 1) {
1173
+ const s = stmts[i];
1174
+ if (s.kind !== "rel") {
1175
+ head = -1;
1176
+ services = null;
1177
+ continue;
1178
+ }
1179
+ if (services !== null && services.has(s.services[0])) {
1180
+ for (const svc of s.services)
1181
+ services.add(svc);
1182
+ }
1183
+ else {
1184
+ head = i;
1185
+ services = new Set(s.services);
1186
+ }
1187
+ heads[i] = head;
1188
+ }
1189
+ return heads;
1190
+ }
1191
+ function relSpot(text, stmts, close, sourceService, key) {
1192
+ const heads = relClusterHeads(stmts);
1193
+ let anchorAt = -1;
1194
+ for (let i = 0; i < stmts.length; i += 1) {
1195
+ const s = stmts[i];
1196
+ if (s.kind === "rel" && s.services.includes(sourceService))
1197
+ anchorAt = i;
1198
+ }
1199
+ if (anchorAt !== -1) {
1200
+ // Past the anchor, order among the cluster's OWN members is by key — two
1201
+ // edges anchored to the same statement must order by key, not by which
1202
+ // archived first — but the walk never leaves the cluster: the next
1203
+ // cluster is another neighborhood's, and stepping over it would depend
1204
+ // on which archive put it there.
1205
+ let last = stmts[anchorAt];
1206
+ for (let j = anchorAt + 1; j < stmts.length; j += 1) {
1207
+ const s = stmts[j];
1208
+ if (s.kind !== "rel" || heads[j] !== heads[anchorAt] || s.key >= key)
1209
+ break;
1210
+ last = s;
1211
+ }
1212
+ return spotAfter(text, close, last.end);
1213
+ }
1214
+ let i = stmts.length - 1;
1215
+ let before;
1216
+ while (i >= 0 && stmts[i].kind === "rel") {
1217
+ const j = heads[i];
1218
+ if (!(stmts[j].key > key))
1219
+ break;
1220
+ before = stmts[j];
1221
+ i = j - 1;
1222
+ }
1223
+ return before !== undefined ? beforeSpot(text, before) : closeSpot(text, close);
1224
+ }
1225
+ /**
1226
+ * Insertion before an existing statement: at the start of its line when only
1227
+ * whitespace precedes it there, at the statement itself — bare, displacing it
1228
+ * onto a fresh line — when other text shares the line (a statement written on
1229
+ * the `model {` line, say). The unguarded line start spliced the block BEFORE
1230
+ * the model keyword, and the parse net refused a perfectly legal landscape.
1231
+ */
1232
+ function beforeSpot(text, before) {
1233
+ const ls = lineStartAt(text, before.start);
1234
+ if (/^[ \t]*$/.test(text.slice(ls, before.start)))
1235
+ return { at: ls, bare: false };
1236
+ return { at: before.start, bare: true };
1237
+ }
1238
+ /** The line following the statement that ends at `end` — clamped to the closing brace's own spot. */
1239
+ function spotAfter(text, close, end) {
1240
+ const nl = text.indexOf("\n", end);
1241
+ const at = nl === -1 ? text.length : nl + 1;
1242
+ return at > close ? closeSpot(text, close) : { at, bare: false };
1243
+ }
1244
+ /** The last resort: directly before the model's closing brace. */
1245
+ function closeSpot(text, close) {
1246
+ const ls = lineStartAt(text, close);
1247
+ if (/^[ \t]*$/.test(text.slice(ls, close)))
1248
+ return { at: ls, bare: false };
1249
+ return { at: close, bare: true };
1250
+ }
1251
+ function lineStartAt(text, at) {
1252
+ return text.lastIndexOf("\n", at - 1) + 1;
1253
+ }
1254
+ /**
1255
+ * Rewrite ONLY the requirements run of a living spec. Byte-for-byte preserved:
1256
+ * everything before the first requirement inside `## Requirements` (the intro,
1257
+ * the heading line, prose under the heading) and everything from the section's
1258
+ * end onward (the next `## ` heading to EOF) — the old cut was a substring
1259
+ * `indexOf("\n## Requirements")`, a prefix match that also hit
1260
+ * `## Requirements Extra` and silently destroyed every section after the
1261
+ * requirements. Prose BETWEEN requirements is body text of whatever is open
1262
+ * above it (parseRequirements attributes it to the previous requirement's last
1263
+ * scenario, or its text) and survives inside the re-serialized run, framing
1264
+ * normalized. `merged` must contain every living requirement — runArchive's
1265
+ * stray guard refuses any document whose requirements sit outside the section,
1266
+ * using the same heading definition, before this output is ever written.
1267
+ */
1268
+ function rewriteRequirementsRun(text, merged) {
1269
+ const s = splitRequirementsSection(text);
1270
+ // No `## Requirements` at all: the stray guard has already refused any doc
1271
+ // whose requirements live elsewhere, so this one has none — open the section.
1272
+ if (s === null)
1273
+ return `${text.trimEnd()}\n\n## Requirements\n\n${serializeRequirements(merged)}`;
1274
+ const body = serializeRequirements(merged);
1275
+ // head/tail are raw slices; only the run's own framing is normalized. When
1276
+ // the section held no requirements yet, the glue supplies the blank line the
1277
+ // author never had reason to write.
1278
+ const headGlue = s.run !== "" || s.head.endsWith("\n\n") ? "" : s.head.endsWith("\n") ? "\n" : "\n\n";
1279
+ const tailGlue = s.tail === "" ? "" : "\n";
1280
+ return s.head + headGlue + body + tailGlue + s.tail;
1281
+ }
1282
+ function summarize(reqs) {
1283
+ const c = { ADDED: 0, MODIFIED: 0, REMOVED: 0 };
1284
+ for (const r of reqs) {
1285
+ if (r.kind === "ADDED")
1286
+ c.ADDED += 1;
1287
+ else if (r.kind === "MODIFIED")
1288
+ c.MODIFIED += 1;
1289
+ else if (r.kind === "REMOVED")
1290
+ c.REMOVED += 1;
1291
+ }
1292
+ return c;
1293
+ }
1294
+ function titleOf(elements, id) {
1295
+ return elements.find((e) => e.id === id)?.title ?? id;
1296
+ }
1297
+ //# sourceMappingURL=archive.js.map