@opum-ai/lore 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,929 +0,0 @@
1
- /**
2
- * commands/link.ts — `lore link <id> <taskId…> [--no-back-ref]` and its inverse `lore unlink`
3
- * (LORE-24, [ADR-0009] §1–§2).
4
- *
5
- * The thin, side-effecting layer that wires the two independent, single-purpose coupling
6
- * references ADR-0009 defines: the concept's own `tasks:` frontmatter list (doc → task, edited
7
- * here directly) and the queryable `doc:<conceptId>` Backlog label (task → doc, edited through
8
- * the LORE-21 {@link BacklogAdapter}) plus the free-text `--doc` display annotation.
9
- *
10
- * `link` validates every task id exists (`adapter.viewTask`) **before** writing anything, so a
11
- * bad id never leaves a partial edit. `unlink` does not: per cli-surface's exit table it has no
12
- * task-not-found case — a task already deleted from Backlog is tolerated, the doc-side reference
13
- * is still cleaned up, and the back-reference edit is simply skipped for that id.
14
- *
15
- * `unlink --allow-missing` tolerates `id` itself not resolving to a live concept — the recovery
16
- * path for a concept relocated **outside** `lore rename` (`git mv`, an IDE refactor, a hand edit):
17
- * `lore link <newId> <taskId>` only ever adds its own label, with no notion of a previous id to
18
- * remove, so the old `doc:<id>` label and stale `--doc` entry would otherwise be permanently
19
- * un-cleanable (see ADR-0009 §2). In this mode there is no concept file to write `tasks:` on, so
20
- * only the Backlog-side label/`--doc` removal runs, computed directly from the given `id` string;
21
- * the case-collision guard still applies (a *live* concept whose id collides with `id` is still
22
- * protected, since Backlog's label store can't tell the two apart either).
23
- *
24
- * `--doc` is a SET/REPLACE accumulator (backlog-cli-contract §2.4): repassing it replaces the
25
- * task's whole `documentation:` array, so both commands read the task's current array — freshly,
26
- * right before the edit, never reusing an earlier snapshot (see below) — and compute the full
27
- * desired array: `link` never clobbers an existing unrelated doc reference, and `unlink` never
28
- * disturbs a *different* doc's reference on a multiply-referenced task. When removal would leave
29
- * the array empty, `unlink` omits `--doc` entirely: Backlog's CLI cannot clear it via an empty
30
- * value (§2.4), so the stale annotation cosmetically lingers until the next `lore link` — an
31
- * accepted ADR-0009 tradeoff, not a bug this command works around.
32
- *
33
- * **Per-task back-reference edits are independent, freshly-read, and run sequentially.** The
34
- * doc-side `tasks:` write never depends on any Backlog edit succeeding (existence is already
35
- * validated up front for `link`; `unlink`'s doc-side removal needs no Backlog round-trip at all),
36
- * so a single edit failure is caught and its per-task outcome recorded (`backRef: "failed"`)
37
- * rather than aborting the rest or leaving an opaque, uncaught exception. When any edit (or the
38
- * `backlog/` commit) failed, the command throws a `drift` (exit 6) `LoreError` instead of emitting
39
- * a success-shaped envelope (LORE-58) — cli-contract §4's "stdout parses or stays silent" invariant
40
- * applies to every command uniformly, including a *partial* failure here, so stdout stays empty and
41
- * the per-task detail moves into the `ErrorEnvelope`'s `input` field on stderr; the failure is never
42
- * silently swallowed, and a transient Backlog error on one task id never blocks or corrupts the
43
- * others. This is the
44
- * ADR-0009 "two references can disagree" tradeoff made visible and reported rather than an
45
- * all-or-nothing transaction lore cannot actually provide across two independent systems (a local
46
- * file write and N Backlog subprocess calls). Each edit re-reads its task **fresh** right before
47
- * writing (never the up-front existence-check's snapshot), closing a race where the task changed
48
- * out-of-band in between — and every edit runs **one at a time**, never concurrently: ADR-0012 §5
49
- * is a locked decision that `lore` does not run concurrent mutating Backlog commands within one
50
- * invocation, so a multi-task `link`/`unlink` serializes its `editTask` calls (see
51
- * {@link runSequentially}) even though each one's *outcome* is still independent of the others'.
52
- *
53
- * [ADR-0009]: ../../docs/adr/0009-story-task-coupling-reconciliation.md
54
- */
55
-
56
- import { join } from "node:path";
57
- import {
58
- type BacklogAdapter,
59
- type BacklogTaskDetail,
60
- bunBacklogSpawn,
61
- createBacklogAdapter,
62
- } from "../adapters/backlog";
63
- import { type BundleGraph, conceptNotInBundle, loadBundle, toRefList } from "../core/bundle";
64
- import { type Concept, idFromPath, serializeConcept } from "../core/concept";
65
- import { loadProfile, type Profile } from "../core/profile";
66
- import { DOCS_DIR } from "../core/scaffold";
67
- import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
68
- import { emit, type OutputContext, type Renderable } from "../output";
69
- import { type BacklogCommitResult, commitBacklogFiles, type GitSpawn, renderBacklogCommitLine } from "../state";
70
- import { assertNotReservedStem, parseCommandArgs, usage } from "./args";
71
- // The pure worker-pool + shared cap live in the neutral `./concurrency` module (LORE-233), not
72
- // `./reconcile-shared` — `reconcile-shared.ts` already imports `verifiedViewTask`/`dedupeTaskIds`/
73
- // `defaultAdapter` FROM this file, so importing back from it here would create a
74
- // `link -> reconcile-shared -> link` cycle.
75
- import { mapWithConcurrency, TASK_DETAILS_CONCURRENCY } from "./concurrency";
76
- import { writeFileOverwriting } from "./fswrite";
77
-
78
- /** Options shared by {@link runLink} and {@link runUnlink}; `root`, the streams, and `adapter` are injectable for tests. */
79
- export interface LinkOptions {
80
- /** The repo root the `docs/` bundle resolves against. */
81
- root: string;
82
- /** The resolved output mode/color (from `output.ts`). */
83
- output: OutputContext;
84
- /** The command's normalized positional + flag tokens from Commander. */
85
- args: readonly string[];
86
- /** stdout sink; defaults to `process.stdout`. */
87
- stdout?: Writer;
88
- /** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
89
- stderr?: Writer;
90
- /** The Backlog adapter; defaults to the real `backlog` binary on PATH. Injected in tests so they touch no subprocess. */
91
- adapter?: BacklogAdapter;
92
- /** The git-write seam (`state.ts`) for committing `backlog/` after a back-reference edit; defaults to the real `git` binary. Injected in tests. */
93
- gitSpawn?: GitSpawn;
94
- }
95
-
96
- /** The parsed form of `link`/`unlink`'s arguments. `allowMissing` is `unlink`-only (see {@link parseLinkArgs}). */
97
- interface LinkArgs {
98
- /** The concept id (or path) being linked/unlinked. */
99
- id: string;
100
- /** One or more Backlog task ids. */
101
- taskIds: string[];
102
- /** `--no-back-ref`: skip the Backlog-side label/`--doc` edit entirely. */
103
- noBackRef: boolean;
104
- /** `unlink --allow-missing`: tolerate `id` not resolving to a live concept — see {@link runUnlink}. Always `false` for `link`. */
105
- allowMissing: boolean;
106
- }
107
-
108
- /** One task's outcome in a {@link LinkReport}. */
109
- export interface LinkedTask {
110
- /** The task id as given (case as typed; Backlog accepts either case on input). */
111
- readonly task: string;
112
- /** Whether the concept's `tasks:` frontmatter gained this id or already carried it. */
113
- readonly status: "added" | "already-linked";
114
- /** Whether the task's `doc:<conceptId>` label was written, already present, the edit was skipped (`--no-back-ref`), or the edit failed. */
115
- readonly backRef: "added" | "already-present" | "skipped" | "failed";
116
- /** A one-line reason, present only when `backRef` is `"failed"`. */
117
- readonly error?: string;
118
- }
119
-
120
- /** The `link.result` payload. */
121
- export interface LinkReport {
122
- /** The concept's repo-relative path. */
123
- readonly concept: string;
124
- /** Every task id passed, in argument order, deduplicated case-insensitively. */
125
- readonly tasks: readonly LinkedTask[];
126
- /** Whether the concept file was written (false when every id was already linked). */
127
- readonly changed: boolean;
128
- /** The `backlog/` commit outcome — `{committed: false, files: []}` when no back-reference edit was made (`--no-back-ref`, or every id already linked). */
129
- readonly backlogCommit: BacklogCommitResult;
130
- }
131
-
132
- /** One task's outcome in an {@link UnlinkReport}. */
133
- export interface UnlinkedTask {
134
- /** The task id as given. */
135
- readonly task: string;
136
- /** Whether the concept's `tasks:` frontmatter lost this id or never carried it — always `"not-linked"` under `--allow-missing` (there is no `tasks:` list to check). */
137
- readonly status: "removed" | "not-linked";
138
- /** Whether the task's `doc:<conceptId>` label was removed, was already absent, the edit was skipped (`--no-back-ref`, or the task no longer exists in Backlog), or the edit failed. */
139
- readonly backRef: "removed" | "already-absent" | "skipped" | "failed";
140
- /** A one-line reason, present only when `backRef` is `"failed"`. */
141
- readonly error?: string;
142
- }
143
-
144
- /** The `unlink.result` payload. */
145
- export interface UnlinkReport {
146
- /** The concept's repo-relative path — reconstructed from the given id (`docs/<id>.md`) under `--allow-missing`, since there is then no live concept to read a real path from. */
147
- readonly concept: string;
148
- /** Every task id passed, in argument order, deduplicated case-insensitively. */
149
- readonly tasks: readonly UnlinkedTask[];
150
- /** Whether the concept file was written; always `false` under `--allow-missing` (no concept file exists to write). */
151
- readonly changed: boolean;
152
- /** The `backlog/` commit outcome — `{committed: false, files: []}` when no back-reference edit was made (`--no-back-ref`, or every id already absent). */
153
- readonly backlogCommit: BacklogCommitResult;
154
- }
155
-
156
- /**
157
- * Run `lore link`: add every `taskId` to the concept's `tasks:` frontmatter (case-insensitive
158
- * dedup, stored lowercase per ADR-0009 §1) and, unless `--no-back-ref`, record the back-reference
159
- * on each task — a `doc:<conceptId>` label plus the concept's repo-relative path via `--doc`
160
- * (preserving any other existing `documentation` entry). Every task id is validated to exist
161
- * (`not_found`, exit 3) before any write, so a bad id never leaves a partial edit; the doc write
162
- * never depends on that validation succeeding for anything BUT existence, so an individual
163
- * back-reference edit failing afterward cannot corrupt or block it — see the module doc.
164
- *
165
- * @returns `0` when every back-reference edit (if any ran) succeeded, after emitting the
166
- * `link.result` envelope. When at least one edit (or the `backlog/` commit) failed, throws a
167
- * `drift` (exit 6) {@link LoreError} instead — see {@link backRefFailure}.
168
- */
169
- export async function runLink(options: LinkOptions): Promise<number> {
170
- const { concept, id, taskIds, noBackRef, docsRoot, profile } = await prepare(options, "link");
171
- if (concept === undefined) {
172
- // Unreachable: `--allow-missing` is `unlink`-only (see `parseLinkArgs`), so `prepare` always
173
- // resolves `id` to a live concept or throws `not_found` for `link`.
174
- throw conceptNotInBundle(id);
175
- }
176
- const adapter = options.adapter ?? defaultAdapter(options.root);
177
- const docPath = repoRelativePath(concept.path);
178
- const label = backRefLabel(concept.id);
179
-
180
- // Validate every task exists BEFORE any write — a missing id fails the whole command loud,
181
- // rather than leaving the doc half-linked. Reads are independent so they run concurrently, but
182
- // bounded to TASK_DETAILS_CONCURRENCY in flight at once (`mapWithConcurrency`, LORE-233) rather
183
- // than firing the entire task-id list as one `Promise.allSettled(...map(...))` burst — a large
184
- // id list would otherwise spawn that many `backlog task view` subprocesses simultaneously
185
- // (process/file-descriptor exhaustion). Each outcome is written into `detailResults` by its
186
- // ORIGINAL index (never push order, which would follow completion order under the pool), so a
187
- // rejection never races ahead of an earlier id's not-found: the FIRST invalid id in argument
188
- // order — not-found or a genuine read failure — is still what gets reported, decided by the same
189
- // in-order scan below after every read has settled, byte-for-byte the same contract
190
- // `Promise.allSettled` gave. `verifiedViewTask` (LORE-177) also rejects a detail whose own `id`
191
- // doesn't match the requested `taskId` — surfaced here as a rejected settle, reported identically
192
- // to a genuine read failure.
193
- const detailResults: PromiseSettledResult<BacklogTaskDetail | null>[] = new Array(taskIds.length);
194
- await mapWithConcurrency(
195
- taskIds.map((taskId, index) => ({ taskId, index })),
196
- TASK_DETAILS_CONCURRENCY,
197
- async ({ taskId, index }) => {
198
- try {
199
- detailResults[index] = { status: "fulfilled", value: await verifiedViewTask(adapter, taskId) };
200
- } catch (reason) {
201
- detailResults[index] = { status: "rejected", reason };
202
- }
203
- },
204
- );
205
- for (let i = 0; i < taskIds.length; i++) {
206
- const taskId = taskIds[i] as string;
207
- const result = detailResults[i] as PromiseSettledResult<BacklogTaskDetail | null>;
208
- if (result.status === "rejected") {
209
- throw result.reason instanceof Error ? result.reason : new Error(String(result.reason));
210
- }
211
- if (result.value === null) {
212
- throw new LoreError("not_found", `task "${taskId}" does not exist`, "check the task id and try again", {
213
- taskId,
214
- });
215
- }
216
- }
217
-
218
- const existingTasks = toRefList(concept.frontmatter.tasks);
219
- const tasks: LinkedTask[] = taskIds.map((taskId) => {
220
- const alreadyLinked = containsCaseInsensitive(existingTasks, taskId);
221
- return { task: taskId, status: alreadyLinked ? "already-linked" : "added", backRef: "skipped" };
222
- });
223
- const nextTasks = [...existingTasks, ...tasks.filter((t) => t.status === "added").map((t) => t.task.toLowerCase())];
224
-
225
- const changed = writeTasksIfChanged(docsRoot, concept, existingTasks, nextTasks, profile);
226
-
227
- let anyBackRefFailed = false;
228
- // The candidate `backlog/` task file paths this run's commit stages — never a bundle-wide sweep.
229
- // Populated inside the loop after either a successful edit, or an "already-present" no-edit
230
- // outcome whose file might still carry a prior run's uncommitted drift (LORE-121); either way,
231
- // `commitBacklogFiles`'s own `git status` (scoped to exactly these paths) is what decides which
232
- // of them, if any, are actually dirty and worth staging — a failed edit never reaches either push,
233
- // so it stays excluded (see the "partial back-ref failure" test).
234
- const editedFiles: string[] = [];
235
- if (!noBackRef) {
236
- const outcomes = await runSequentially(taskIds, async (taskId) => {
237
- // Re-read fresh right before editing (not the up-front validation snapshot): matches
238
- // runUnlink's freshness and closes a narrow race where the task changed out-of-band
239
- // between the existence check above and this edit. `verifiedViewTask` (LORE-177) also
240
- // refuses a detail whose own `id` doesn't match `taskId` — never used to compute
241
- // `desiredDocs`/labels below, which would otherwise borrow another task's data.
242
- const detail = await verifiedViewTask(adapter, taskId);
243
- if (detail === null) {
244
- throw new Error(`task "${taskId}" no longer exists in Backlog`);
245
- }
246
- const wasPresent = hasLabel(detail, label);
247
- // Matched case-insensitively, like `hasLabel` and `removeBackRefs`'s `hadDoc` — an existing
248
- // documentation entry that differs from `docPath` only by case (a hand-edit or out-of-band
249
- // move) already reflects this link, so it must not be treated as changed (which would force
250
- // an unnecessary edit) nor fall through to `addDoc` appending a casing-variant duplicate.
251
- const docChanged = !containsCaseInsensitive(detail.documentation, docPath);
252
- if (wasPresent && !docChanged) {
253
- // Both the label and --doc already reflect this link, so there is no Backlog edit to make
254
- // — but the task's file can still be dirty and uncommitted on disk if a PRIOR `lore link`
255
- // run already applied that same edit and then its own `commitBacklogFiles` call failed
256
- // (e.g. a rejected pre-commit hook, LORE-121). Recording the path here, even though this
257
- // run makes no edit, lets `commitBacklogFiles`'s own `git status` (scoped to exactly this
258
- // path) decide whether there is real drift to stage and commit: a clean file reports
259
- // nothing dirty and stays a true no-op (AC#3), while a dirty one gets picked up and
260
- // committed by this retry (AC#1/#2) instead of silently no-opping forever.
261
- if (detail.file) {
262
- editedFiles.push(detail.file);
263
- }
264
- return "already-present" as const;
265
- }
266
- const desiredDocs = addDoc(detail.documentation, docPath);
267
- await adapter.editTask(taskId, { addLabels: [label], doc: desiredDocs });
268
- if (detail.file) {
269
- // Only after a successful editTask, and only with a usable path — a truthy guard (not just
270
- // `!== null`) so a `""` filePathRelative is skipped too, never passed on as an empty git
271
- // pathspec (which `git status --` rejects). No path → nothing to commit here; `lore sync`'s
272
- // catch-all sweep still picks up any stray dirt later.
273
- editedFiles.push(detail.file);
274
- }
275
- return "added" as const;
276
- });
277
- outcomes.forEach((outcome, i) => {
278
- const entry = tasks[i] as LinkedTask;
279
- if (outcome.status === "fulfilled") {
280
- tasks[i] = { ...entry, backRef: outcome.value };
281
- } else {
282
- anyBackRefFailed = true;
283
- tasks[i] = { ...entry, backRef: "failed", error: describeError(outcome.reason) };
284
- }
285
- });
286
- }
287
-
288
- // Commit whatever of this run's candidate task files (`editedFiles`) turns out to actually be
289
- // dirty — lore is the sole committer of `backlog/` (ADR-0012, design §3.6), so a `link` no longer
290
- // leaves an edit (this run's, or a prior run's uncommitted drift, LORE-121) sitting uncommitted
291
- // until the next `lore sync`. Scoped to `editedFiles`, so a `--no-back-ref` run (empty) commits
292
- // nothing, a genuinely clean run finds nothing dirty among its candidates and stays a true no-op,
293
- // and an unrelated dirty `backlog/` edit to some OTHER task's file is never swept in (ADR-0012 §1).
294
- const backlogCommit = await commitBacklogFiles(editedFiles, options, LINK_COMMIT_MESSAGE);
295
- const report: LinkReport = { concept: docPath, tasks, changed, backlogCommit };
296
- // A captured commit failure (backlogCommit.error) is drift too — routed through the same
297
- // ErrorEnvelope as a failed edit (LORE-58), never the success envelope on a nonzero exit.
298
- if (anyBackRefFailed || backlogCommit.error !== undefined) {
299
- throw backRefFailure("link", report);
300
- }
301
- emit(reportRenderable("link.result", report, renderTaskReport), options.output, options.stdout);
302
- return EXIT_OK;
303
- }
304
-
305
- /**
306
- * Run `lore unlink`: remove every `taskId` from the concept's `tasks:` frontmatter and, unless
307
- * `--no-back-ref`, remove the matching `doc:<conceptId>` label and shrink `--doc` on each task.
308
- * Unlike {@link runLink}, a task id no longer present in Backlog is tolerated (cli-surface's
309
- * unlink exit table has no task-not-found case) — the doc-side reference is still cleaned up and
310
- * the back-reference edit is skipped for that id. The doc-side write needs no Backlog round-trip
311
- * at all, so it never depends on any back-reference edit's outcome.
312
- *
313
- * With `--allow-missing`, `id` itself may not resolve to a live concept ({@link prepare} returns
314
- * `concept: undefined`): there is then no `tasks:` frontmatter to write (`changed` is always
315
- * `false`, every task's doc-side `status` is `"not-linked"`), and only the Backlog-side label/
316
- * `--doc` removal runs, computed straight from `id` — see the module doc.
317
- *
318
- * @returns `0` when every back-reference edit (if any ran) succeeded, after emitting the
319
- * `unlink.result` envelope. When at least one edit (or the `backlog/` commit) failed, throws a
320
- * `drift` (exit 6) {@link LoreError} instead — see {@link backRefFailure}.
321
- */
322
- export async function runUnlink(options: LinkOptions): Promise<number> {
323
- const { concept, id, taskIds, noBackRef, docsRoot, profile } = await prepare(options, "unlink");
324
- const adapter = options.adapter ?? defaultAdapter(options.root);
325
- const docPath = concept !== undefined ? repoRelativePath(concept.path) : `${DOCS_DIR}/${id}.md`;
326
- const label = backRefLabel(concept?.id ?? id);
327
-
328
- let tasks: UnlinkedTask[];
329
- let changed = false;
330
- if (concept !== undefined) {
331
- const existingTasks = toRefList(concept.frontmatter.tasks);
332
- tasks = taskIds.map((taskId) => {
333
- const wasLinked = containsCaseInsensitive(existingTasks, taskId);
334
- return { task: taskId, status: wasLinked ? "removed" : "not-linked", backRef: "skipped" };
335
- });
336
- const removedLower = new Set(tasks.filter((t) => t.status === "removed").map((t) => t.task.toLowerCase()));
337
- const nextTasks = existingTasks.filter((t) => !removedLower.has(t.toLowerCase()));
338
- // Write the doc-side removal FIRST — mirrors runLink's order. The doc write needs no Backlog
339
- // round-trip and never depends on any back-reference edit's outcome, so committing it before
340
- // the per-task Backlog edits means a failure on the Backlog side can never strand it (the
341
- // reverse order would leave already-applied Backlog mutations unreported if this write failed).
342
- changed = writeTasksIfChanged(docsRoot, concept, existingTasks, nextTasks, profile);
343
- } else {
344
- // --allow-missing, id doesn't resolve: no concept file exists to carry a tasks: list at all.
345
- tasks = taskIds.map((taskId) => ({ task: taskId, status: "not-linked", backRef: "skipped" }));
346
- }
347
-
348
- let anyBackRefFailed = false;
349
- let editedFiles: readonly string[] = [];
350
- if (!noBackRef) {
351
- const removal = await removeBackRefs(adapter, taskIds, label, docPath);
352
- editedFiles = removal.editedFiles;
353
- removal.outcomes.forEach((outcome, i) => {
354
- const entry = tasks[i] as UnlinkedTask;
355
- if (outcome.status === "fulfilled") {
356
- tasks[i] = { ...entry, backRef: outcome.value };
357
- } else {
358
- anyBackRefFailed = true;
359
- tasks[i] = { ...entry, backRef: "failed", error: describeError(outcome.reason) };
360
- }
361
- });
362
- }
363
-
364
- const backlogCommit = await commitBacklogFiles(editedFiles, options, UNLINK_COMMIT_MESSAGE);
365
- const report: UnlinkReport = { concept: docPath, tasks, changed, backlogCommit };
366
- // A captured commit failure (backlogCommit.error) is drift too — routed through the same
367
- // ErrorEnvelope as a failed edit (LORE-58), never the success envelope on a nonzero exit.
368
- if (anyBackRefFailed || backlogCommit.error !== undefined) {
369
- throw backRefFailure("unlink", report);
370
- }
371
- emit(reportRenderable("unlink.result", report, renderTaskReport), options.output, options.stdout);
372
- return EXIT_OK;
373
- }
374
-
375
- /**
376
- * Remove `label`/`docPath` from every `taskId`'s Backlog record — the per-task removal loop shared
377
- * by {@link runUnlink}'s normal and `--allow-missing` (bare-id) paths, which differ only in how
378
- * `label`/`docPath` were derived, not in how the removal itself works.
379
- */
380
- async function removeBackRefs(
381
- adapter: BacklogAdapter,
382
- taskIds: readonly string[],
383
- label: string,
384
- docPath: string,
385
- ): Promise<{
386
- readonly outcomes: readonly PromiseSettledResult<"removed" | "already-absent" | "skipped">[];
387
- readonly editedFiles: readonly string[];
388
- }> {
389
- // The candidate task files for the caller's scoped commit — populated after either a successful
390
- // `editTask`, or an "already-absent" no-edit outcome whose file might still carry a prior run's
391
- // uncommitted drift (LORE-121's pattern, applied here per LORE-179); either way,
392
- // `commitBacklogFiles`'s own `git status` (scoped to exactly these paths) is what decides which
393
- // of them, if any, are actually dirty and worth staging (mirrors runLink's editedFiles).
394
- const editedFiles: string[] = [];
395
- const outcomes = await runSequentially(taskIds, async (taskId) => {
396
- // `verifiedViewTask` (LORE-177) refuses a detail whose own `id` doesn't match `taskId` — never
397
- // used below to decide `hadLabel`/`hadDoc` or compute `removeDoc`'s result, which would
398
- // otherwise borrow (and remove from) another task's documentation entirely.
399
- const detail = await verifiedViewTask(adapter, taskId);
400
- if (detail === null) {
401
- return "skipped" as const; // the task no longer exists in Backlog — nothing to clean up
402
- }
403
- const hadLabel = hasLabel(detail, label);
404
- // Matched case-insensitively, like `hasLabel` — necessary for `--allow-missing`, whose
405
- // `docPath` is *reconstructed* from the given id, not read from a live concept's real path, so
406
- // it may not match the originally-stored casing exactly. Safe because `assertNoLabelCaseCollision`
407
- // already ran up front and ruled out any other concept whose id (and so doc path) could
408
- // case-collide, so a case-insensitive match here can't strip a different concept's real entry.
409
- const hadDoc = containsCaseInsensitive(detail.documentation, docPath);
410
- if (!hadLabel && !hadDoc) {
411
- // Neither the label nor the doc entry is present, so there is no Backlog edit to make — but
412
- // the task's file can still be dirty and uncommitted on disk if a PRIOR `lore unlink` run
413
- // already applied this exact removal and then its own `commitBacklogFiles` call failed (e.g.
414
- // a rejected pre-commit hook — LORE-121's pattern, LORE-179). Recording the path here, even
415
- // though this run makes no edit, lets `commitBacklogFiles`'s own `git status` (scoped to
416
- // exactly this path) decide whether there is real drift to stage and commit: a clean file
417
- // reports nothing dirty and stays a true no-op (AC#3), while a dirty one gets picked up and
418
- // committed by this retry (AC#1) instead of silently no-opping forever.
419
- if (detail.file) {
420
- editedFiles.push(detail.file);
421
- }
422
- return "already-absent" as const; // nothing to remove — skip the edit entirely
423
- }
424
- // An empty `desiredDocs` is not special-cased: the real adapter's `--doc` accumulator
425
- // (`for (const doc of patch.doc ?? [])`) sends zero flags for `[]`, identical to `undefined` —
426
- // Backlog is left with whatever it already had (it cannot clear `--doc` via an empty value,
427
- // contract §2.4), so a stale annotation cosmetically lingers either way.
428
- await adapter.editTask(taskId, { removeLabels: [label], doc: removeDoc(detail.documentation, docPath) });
429
- if (detail.file) {
430
- editedFiles.push(detail.file);
431
- }
432
- return "removed" as const;
433
- });
434
- return { outcomes, editedFiles };
435
- }
436
-
437
- /** One task's outcome after {@link moveBackRefs} moves its back-reference to a concept's new id/path. */
438
- export interface MovedBackRef {
439
- /** The task id, as given. */
440
- readonly task: string;
441
- /** Whether the label/`--doc` already reflected the new id/path, were moved, or the move failed. */
442
- readonly backRef: "moved" | "already-current" | "failed";
443
- /** A one-line reason, present only when `backRef` is `"failed"`. */
444
- readonly error?: string;
445
- }
446
-
447
- /**
448
- * Move every `taskId`'s back-reference from a concept's old id/path to its new one — the
449
- * Backlog-side half of `lore rename` keeping ADR-0009 §2's coupling intact across a move, called
450
- * by `commands/rename.ts` (this file is the single owner of the `doc:<conceptId>` label contract).
451
- * A task with neither the old label nor the old doc path (or one no longer present in Backlog) is
452
- * already current and its edit is skipped entirely — this also covers a task linked with
453
- * `--no-back-ref` (or one whose label was stripped by hand): it never had a back-reference for the
454
- * old id, so *moving* one never introduces a new one it didn't already have. Otherwise mirrors
455
- * `runLink`/`runUnlink`'s per-task resilience: edits run sequentially, never concurrently
456
- * (ADR-0012 §5), and a single task's failure is caught and reported without blocking the rest.
457
- *
458
- * Reads through {@link verifiedViewTask} (LORE-183), never a raw `adapter.viewTask` call: a
459
- * mismatched/ambiguous adapter detail is refused rather than trusted to compute the `editTask`
460
- * write below, which would otherwise borrow another task's labels/documentation while still
461
- * writing under the REQUESTED `taskId` — the same hazard class this function already guards
462
- * against for a genuinely-missing task. The refusal surfaces through the same per-task `"failed"`
463
- * outcome as any other rejected edit, degrading gracefully rather than corrupting the doc list.
464
- */
465
- export async function moveBackRefs(
466
- adapter: BacklogAdapter,
467
- taskIds: readonly string[],
468
- oldConceptId: string,
469
- newConceptId: string,
470
- oldDocPath: string,
471
- newDocPath: string,
472
- ): Promise<{ readonly outcomes: readonly MovedBackRef[]; readonly editedFiles: readonly string[] }> {
473
- const oldLabel = backRefLabel(oldConceptId);
474
- const newLabel = backRefLabel(newConceptId);
475
- // The candidate task files for the caller's scoped commit — populated after a successful
476
- // `editTask` (a `"moved"` outcome), AND after the "already fully migrated" no-edit outcome below
477
- // whose file might still carry a prior run's uncommitted drift (LORE-121's pattern, applied here
478
- // per LORE-179); `commitBacklogFiles`'s own `git status` (scoped to exactly these paths) is what
479
- // decides which of them, if any, are actually dirty and worth staging. The OTHER `already-current`
480
- // outcome (no trace of a back-ref at all — never linked) contributes nothing: unlike a completed
481
- // migration, no prior run of *this* move could ever have applied an edit here, so there is no
482
- // drift of this kind for it to hide. A `failed` edit likewise contributes nothing.
483
- const editedFiles: string[] = [];
484
- const settled = await runSequentially(taskIds, async (taskId) => {
485
- const detail = await verifiedViewTask(adapter, taskId);
486
- if (detail === null) {
487
- return "already-current" as const; // the task no longer exists in Backlog — nothing to move
488
- }
489
- // `hasLabel`'s case-insensitive match can't distinguish "old" from "new" when a rename is
490
- // case-only (oldLabel/newLabel are then the SAME domain, just differently cased) — testing them
491
- // independently would find the one stored label under both names and wrongly conclude both are
492
- // present. Use an exact match for "is the new label already correct" and a case-insensitive
493
- // scan (excluding anything already exactly the new label) for "is there a stale label to
494
- // remove" — this handles a case-only rename (fixes the stored label's casing) and a normal
495
- // rename that already separately carries the new label (still removes the stale old one)
496
- // identically and correctly.
497
- const hasExactNewLabel = detail.labels.includes(newLabel);
498
- const staleLabel = detail.labels.find((l) => l.toLowerCase() === oldLabel.toLowerCase() && l !== newLabel);
499
- const hasOldDoc = detail.documentation.includes(oldDocPath);
500
- const hasNewDoc = detail.documentation.includes(newDocPath);
501
- if (!hasExactNewLabel && staleLabel === undefined && !hasOldDoc && !hasNewDoc) {
502
- // No trace of this concept's back-reference at all, old or new — the task was never given
503
- // one (e.g. linked with `--no-back-ref`) or had it stripped by hand. There is nothing to
504
- // *move*; unlike `runLink`, moving never introduces a back-reference that wasn't already
505
- // present under the old id, so this is left alone rather than newly adding one.
506
- return "already-current" as const;
507
- }
508
- if (hasExactNewLabel && staleLabel === undefined && hasNewDoc && !hasOldDoc) {
509
- // Already fully migrated to the new label/doc, so there is no Backlog edit to make — but the
510
- // task's file can still be dirty and uncommitted on disk if a PRIOR `lore rename` run already
511
- // applied this exact move and then its own `commitBacklogFiles` call failed (e.g. a rejected
512
- // pre-commit hook — LORE-121's pattern, LORE-179). Recording the path here, even though this
513
- // run makes no edit, lets `commitBacklogFiles`'s own `git status` (scoped to exactly this
514
- // path) decide whether there is real drift to stage and commit: a clean file reports nothing
515
- // dirty and stays a true no-op (AC#3), while a dirty one gets picked up and committed by this
516
- // retry (AC#2) instead of silently no-opping forever.
517
- if (detail.file) {
518
- editedFiles.push(detail.file);
519
- }
520
- return "already-current" as const; // already fully migrated — nothing to move
521
- }
522
- const docs = detail.documentation.filter((d) => d !== oldDocPath);
523
- if (!docs.includes(newDocPath)) {
524
- docs.push(newDocPath);
525
- }
526
- await adapter.editTask(taskId, {
527
- addLabels: hasExactNewLabel ? undefined : [newLabel],
528
- removeLabels: staleLabel !== undefined ? [staleLabel] : undefined,
529
- doc: docs,
530
- });
531
- if (detail.file) {
532
- editedFiles.push(detail.file);
533
- }
534
- return "moved" as const;
535
- });
536
- const outcomes = taskIds.map((task, i): MovedBackRef => {
537
- const outcome = settled[i] as PromiseSettledResult<"moved" | "already-current">;
538
- if (outcome.status === "fulfilled") {
539
- return { task, backRef: outcome.value };
540
- }
541
- return { task, backRef: "failed" as const, error: describeError(outcome.reason) };
542
- });
543
- return { outcomes, editedFiles };
544
- }
545
-
546
- // ── Shared setup ───────────────────────────────────────────────────────────────
547
-
548
- /**
549
- * The default {@link BacklogAdapter}: the real `backlog` binary resolved from PATH, spawned in
550
- * `root` so a non-default root routes writes to the right project. Shared with `commands/rename.ts`,
551
- * which needs the same adapter to move a renamed concept's back-references (see
552
- * {@link moveBackRefs}) — this file is the single owner of the `doc:<conceptId>` coupling contract
553
- * (ADR-0009 §2), so any command that touches it goes through this module rather than re-deriving
554
- * the label/adapter rules itself.
555
- */
556
- export function defaultAdapter(root: string): BacklogAdapter {
557
- return createBacklogAdapter(bunBacklogSpawn(undefined, root));
558
- }
559
-
560
- /**
561
- * Call `adapter.viewTask(taskId)` and verify the returned detail's own `id` matches the requested
562
- * `taskId` case-insensitively before trusting it (originally introduced alongside
563
- * `reconcile-shared.ts`'s `resolveTaskDetails`, LORE-122). A misbehaving or ambiguous adapter
564
- * handing back a DIFFERENT task's detail must never be trusted to decide a label/`--doc` edit or
565
- * the `tasks:` pre-write check: every one of this module's `viewTask` consumers — the pre-write
566
- * existence check, the back-reference edit's fresh re-read, `unlink`'s removal read, and
567
- * `moveBackRefs`'s move read (LORE-183; the last of these was an unguarded gap until then) — uses
568
- * the RETURNED detail's own `title`/`status`/`labels`/`documentation` to decide what to write, so
569
- * a mismatch left unchecked would silently borrow another task's data while still writing under
570
- * the REQUESTED `taskId`. Exported so `commands/tasks.ts`'s `resolveRollup` (LORE-125) AND
571
- * `reconcile-shared.ts`'s `resolveTaskDetails` (LORE-183) share this exact guard as the ONE place
572
- * the comparison/`LoreError` lives, instead of each hand-maintaining its own byte-identical copy.
573
- *
574
- * Returns the verified detail, or `null` when the task genuinely doesn't exist (unchanged from
575
- * `adapter.viewTask`'s own contract) — a mismatch is a THIRD outcome, always a thrown `LoreError`
576
- * `not_found`, never folded into the `null` case (so a caller can't mistake "wrong task" for
577
- * "no task" and silently skip a back-reference cleanup it should have refused outright instead).
578
- */
579
- export async function verifiedViewTask(adapter: BacklogAdapter, taskId: string): Promise<BacklogTaskDetail | null> {
580
- const detail = await adapter.viewTask(taskId);
581
- if (detail === null) {
582
- return null;
583
- }
584
- if (detail.id.toLowerCase() !== taskId.toLowerCase()) {
585
- throw new LoreError(
586
- "not_found",
587
- `task "${taskId}" resolved to a different task ("${detail.id}") — refusing to use it`,
588
- "this points at a Backlog adapter bug or an id collision, not a missing task — verify the task id with `backlog task view` and report the mismatch",
589
- { taskId, resolvedId: detail.id },
590
- );
591
- }
592
- return detail;
593
- }
594
-
595
- /** The `git`-authored commit message for `lore link`'s `backlog/` writes (its `doc:` label additions). */
596
- const LINK_COMMIT_MESSAGE = "chore(backlog): add doc back-references (lore link)";
597
-
598
- /** The `git`-authored commit message for `lore unlink`'s `backlog/` writes (its `doc:` label removals). */
599
- const UNLINK_COMMIT_MESSAGE = "chore(backlog): remove doc back-references (lore unlink)";
600
-
601
- /**
602
- * Everything {@link runLink}/{@link runUnlink} need after parsing and loading the bundle.
603
- * `concept` is `undefined` only for `unlink --allow-missing` when `id` doesn't resolve to a live
604
- * concept — `id` is always the resolved (post-`idFromPath`) id, needed either way.
605
- */
606
- interface Prepared {
607
- readonly concept: Concept | undefined;
608
- readonly id: string;
609
- readonly taskIds: string[];
610
- readonly noBackRef: boolean;
611
- readonly docsRoot: string;
612
- readonly profile: Profile;
613
- }
614
-
615
- /**
616
- * Parse arguments, load the bundle, and resolve the concept — shared by both commands. Advisories
617
- * are flushed immediately after `loadBundle`, before the lookup that can throw `not_found`, so a
618
- * load warning is never lost on the failing path. `link` (and `unlink` without `--allow-missing`)
619
- * always resolve `id` to a live concept or throw `not_found`; `unlink --allow-missing` tolerates a
620
- * miss and returns `concept: undefined` instead (see {@link runUnlink}).
621
- */
622
- async function prepare(options: LinkOptions, command: "link" | "unlink"): Promise<Prepared> {
623
- const parsed = parseLinkArgs(options.args, command);
624
- const id = idFromPath(parsed.id);
625
- // Reserved-hub-stem is checked unconditionally: the doc-side tasks: write always happens
626
- // (--no-back-ref only skips the Backlog side), so index.md/log.md is never a safe target either
627
- // way. The comma/case-collision guards below are Backlog-label concerns, so they gate on
628
- // `!noBackRef` — --no-back-ref never sends a doc: label, so neither problem can occur on that path.
629
- assertNotReservedStem(id, command);
630
- if (!parsed.noBackRef) {
631
- assertNoCommaInId(id, command);
632
- }
633
- const docsRoot = join(options.root, DOCS_DIR);
634
- const advisories = new WarningCollector();
635
- const profile = loadProfile({ root: options.root });
636
- const graph = loadBundle(docsRoot, { warnings: advisories, profile });
637
- advisories.flush({ color: options.output.color, stderr: options.stderr });
638
-
639
- const concept = graph.concepts.get(id);
640
- if (concept === undefined) {
641
- if (parsed.allowMissing) {
642
- // The case-collision guard still applies: Backlog's own label store can't distinguish `id`
643
- // from a *live* concept whose id collides with it case-insensitively, so removing `id`'s
644
- // label could otherwise strip that live concept's real back-reference.
645
- if (!parsed.noBackRef) {
646
- assertNoLabelCaseCollision(graph, id, id, command);
647
- }
648
- return {
649
- concept: undefined,
650
- id,
651
- taskIds: dedupeTaskIds(parsed.taskIds),
652
- noBackRef: parsed.noBackRef,
653
- docsRoot,
654
- profile,
655
- };
656
- }
657
- throw conceptNotInBundle(id);
658
- }
659
- if (!parsed.noBackRef) {
660
- assertNoLabelCaseCollision(graph, concept.id, concept.id, command);
661
- }
662
- return { concept, id, taskIds: dedupeTaskIds(parsed.taskIds), noBackRef: parsed.noBackRef, docsRoot, profile };
663
- }
664
-
665
- /**
666
- * Reject a concept id containing a comma as a `<action>` principal — a `usage` error. Backlog's
667
- * `--add-label`/`--remove-label` have no escape for an embedded comma (backlog-cli-contract §2.4;
668
- * `commaJoin` in `adapters/backlog.ts` now rejects one outright rather than silently splitting it
669
- * into two labels), so a comma-bearing id could never get a working `doc:` back-reference. Failing
670
- * loud here, once, up front (callers only call this when a back-ref edit will actually be
671
- * attempted) gives one clear reason instead of every per-task `editTask` call failing forever and
672
- * reporting `drift` on every future invocation for that concept. Shared by `link`/`unlink`
673
- * (`commands/link.ts`) and `lore rename` (`commands/rename.ts`, moving a linked concept's back-ref).
674
- */
675
- export function assertNoCommaInId(id: string, action: string): void {
676
- if (id.includes(",")) {
677
- throw usage(
678
- `cannot ${action} "${id}": a concept id containing a comma cannot be encoded as a Backlog doc: label`,
679
- "Backlog's --add-label/--remove-label have no escape for an embedded comma — rename the concept so its id contains no comma",
680
- { id },
681
- );
682
- }
683
- }
684
-
685
- /**
686
- * Reject a `<candidateId>` that collides case-insensitively with another concept's id already in
687
- * `graph` (`conflict`, exit 5) — `excludeId` is the id to ignore (the concept's own current id, so
688
- * it never "collides with itself"; for `lore rename` this is the *old* id, since the concept is
689
- * still keyed under it in the graph passed in). Concept ids are case-sensitive in the graph
690
- * (`buildGraph`'s lookup is a plain `Map`), so two such concepts are legitimately distinct nodes —
691
- * but Backlog's own `--add-label`/`--remove-label` de-dup case-insensitively in its label store
692
- * (backlog-cli-contract §2.4), so no encoding lore sends can give them independently addressable
693
- * `doc:` back-references. Rather than silently let one concept's unlink (or a rename onto a
694
- * colliding id) strip or entangle the other's real back-reference, refuse the operation outright.
695
- * Shared by `link`/`unlink` (`commands/link.ts`) and `lore rename` (`commands/rename.ts`).
696
- */
697
- export function assertNoLabelCaseCollision(
698
- graph: BundleGraph,
699
- candidateId: string,
700
- excludeId: string,
701
- action: string,
702
- ): void {
703
- for (const other of graph.concepts.values()) {
704
- if (other.id !== excludeId && other.id.toLowerCase() === candidateId.toLowerCase()) {
705
- throw new LoreError(
706
- "conflict",
707
- `cannot ${action} "${candidateId}": concept "${other.id}" has an id differing only by case`,
708
- "Backlog's own doc: label store de-dups case-insensitively, so these two concepts cannot have independent back-references — rename one so their ids are case-distinct",
709
- { id: candidateId, collidesWith: other.id },
710
- );
711
- }
712
- }
713
- }
714
-
715
- /**
716
- * Deduplicate task ids case-insensitively, keeping the first-seen casing and argument order.
717
- * Exported for `commands/rename.ts`, which needs the same dedup applied to a concept's `tasks:`
718
- * frontmatter list before {@link moveBackRefs} — that list isn't schema-enforced unique, so a
719
- * hand-edited case-duplicate (`["lore-1", "LORE-1"]`) would otherwise drive a redundant Backlog
720
- * round trip and a duplicate report row.
721
- */
722
- export function dedupeTaskIds(taskIds: readonly string[]): string[] {
723
- const seen = new Set<string>();
724
- const out: string[] = [];
725
- for (const taskId of taskIds) {
726
- const key = taskId.toLowerCase();
727
- if (!seen.has(key)) {
728
- seen.add(key);
729
- out.push(taskId);
730
- }
731
- }
732
- return out;
733
- }
734
-
735
- /** The concept's repo-relative path (`docs/stories/x.md`), the display value stored via `--doc`. */
736
- function repoRelativePath(conceptPath: string): string {
737
- return `${DOCS_DIR}/${conceptPath}`;
738
- }
739
-
740
- /**
741
- * The queryable back-reference label (ADR-0009 §2): `doc:<conceptId>`, case-preserved. Concept ids
742
- * are case-sensitive throughout the graph (`buildGraph`'s lookup is a plain, case-sensitive `Map`),
743
- * so two concepts differing only by case are distinct nodes; lowercasing here would collapse them
744
- * onto the same label and let unlinking one strip the other's real back-reference.
745
- */
746
- function backRefLabel(conceptId: string): string {
747
- return `doc:${conceptId}`;
748
- }
749
-
750
- /** Whether `list` contains `value`, matched case-insensitively — the shared comparison `tasks:`/label membership always uses. */
751
- function containsCaseInsensitive(list: readonly string[], value: string): boolean {
752
- return list.some((item) => item.toLowerCase() === value.toLowerCase());
753
- }
754
-
755
- /** Whether a task's labels already carry `label`, matched case-insensitively (Backlog's own label de-dup). */
756
- function hasLabel(detail: BacklogTaskDetail, label: string): boolean {
757
- return containsCaseInsensitive(detail.labels, label);
758
- }
759
-
760
- /**
761
- * The desired full `documentation` array after adding `docPath` (SET/REPLACE-safe: preserves every
762
- * other entry). Matched case-insensitively, like `removeDoc` — an existing entry differing from
763
- * `docPath` only by case already covers it, so it is left as-is rather than gaining a duplicate.
764
- */
765
- function addDoc(existing: readonly string[], docPath: string): string[] {
766
- return containsCaseInsensitive(existing, docPath) ? [...existing] : [...existing, docPath];
767
- }
768
-
769
- /**
770
- * The desired full `documentation` array after removing `docPath` (SET/REPLACE-safe: preserves
771
- * every other entry). Matched case-insensitively — see {@link removeBackRefs}'s `hadDoc` comment
772
- * for why this is safe.
773
- */
774
- function removeDoc(existing: readonly string[], docPath: string): string[] {
775
- return existing.filter((d) => d.toLowerCase() !== docPath.toLowerCase());
776
- }
777
-
778
- /**
779
- * Write the concept's `tasks:` frontmatter when it changed, returning whether a write happened.
780
- * Serializes under the active profile so an already-canonical concept's other frontmatter and body
781
- * round-trip byte-for-byte and the `tasks:` edit is the only diff (ADR-0011).
782
- */
783
- function writeTasksIfChanged(
784
- docsRoot: string,
785
- concept: Concept,
786
- existingTasks: readonly string[],
787
- nextTasks: readonly string[],
788
- profile: Profile,
789
- ): boolean {
790
- if (sameList(existingTasks, nextTasks)) {
791
- return false;
792
- }
793
- const updated: Concept = { ...concept, frontmatter: { ...concept.frontmatter, tasks: [...nextTasks] } };
794
- writeFileOverwriting(
795
- join(docsRoot, concept.path),
796
- serializeConcept(updated, { profile }),
797
- repoRelativePath(concept.path),
798
- );
799
- return true;
800
- }
801
-
802
- /** Whether two string lists carry the same elements in the same order. */
803
- function sameList(a: readonly string[], b: readonly string[]): boolean {
804
- return a.length === b.length && a.every((v, i) => v === b[i]);
805
- }
806
-
807
- // ── Argument parsing ───────────────────────────────────────────────────────────
808
-
809
- /**
810
- * Parse `link`/`unlink`'s tokens into `<id> <taskId…>` and `--no-back-ref`, via the shared
811
- * {@link parseCommandArgs} parser (mirrors `commands/rename.ts`/`commands/supersede.ts`'s
812
- * parsers). Positional arity is validated here since it differs per command (a variadic task-id
813
- * tail, not a fixed count).
814
- */
815
- function parseLinkArgs(args: readonly string[], command: "link" | "unlink"): LinkArgs {
816
- const { positionals, flags } = parseCommandArgs(args, command);
817
-
818
- const id = positionals[0];
819
- if (id === undefined) {
820
- throw usage(`\`lore ${command}\` needs a concept id`, `run \`lore ${command} <id> <taskId…>\``);
821
- }
822
- const taskIds = positionals.slice(1);
823
- if (taskIds.length === 0) {
824
- throw usage(
825
- `\`lore ${command}\` needs at least one task id`,
826
- `pass one or more task ids, e.g. \`lore ${command} ${id} task-42\``,
827
- );
828
- }
829
- return { id, taskIds, noBackRef: flags.has("no-back-ref"), allowMissing: flags.has("allow-missing") };
830
- }
831
-
832
- // ── Output ─────────────────────────────────────────────────────────────────────
833
-
834
- /** Build a `Renderable` for a link/unlink report — pretty and plain share a layout (no color; no severities). */
835
- function reportRenderable<T>(kind: string, data: T, render: (data: T) => string): Renderable<T> {
836
- return { kind, data, pretty: render, plain: render };
837
- }
838
-
839
- /** The shape {@link renderTaskReport} needs — both {@link LinkReport} and {@link UnlinkReport} satisfy it structurally. */
840
- interface TaskReportLike {
841
- readonly concept: string;
842
- readonly changed: boolean;
843
- readonly backlogCommit: BacklogCommitResult;
844
- readonly tasks: readonly {
845
- readonly task: string;
846
- readonly status: string;
847
- readonly backRef: string;
848
- readonly error?: string;
849
- }[];
850
- }
851
-
852
- /**
853
- * One line per task's doc-side + back-ref outcome (with its error, if any), the concept-write
854
- * line, then the `backlog/` commit line if one was made. Shared by `link` and `unlink` — the two
855
- * reports render identically. Both halves of each task line are named explicitly (`tasks:` for
856
- * the concept's own frontmatter list, `back-ref` for the Backlog `doc:<conceptId>` label/`--doc`
857
- * side) rather than the old bare `(doc)` qualifier, which read as unexplained shorthand without
858
- * already knowing the `doc:` label convention (LORE-259).
859
- */
860
- function renderTaskReport(data: TaskReportLike): string {
861
- const lines = data.tasks.map((t) => {
862
- const suffix = t.error !== undefined ? ` (${t.error})` : "";
863
- return `${t.task}: tasks: ${t.status}, back-ref: ${t.backRef}${suffix}`;
864
- });
865
- lines.push(`${data.concept}: ${data.changed ? "updated" : "unchanged"}`);
866
- const commitLine = renderBacklogCommitLine(data.backlogCommit);
867
- if (commitLine !== undefined) {
868
- lines.push(commitLine);
869
- }
870
- return lines.join("\n");
871
- }
872
-
873
- /**
874
- * Build the `drift` {@link LoreError} `runLink`/`runUnlink` throw instead of emitting a
875
- * success-shaped stdout envelope when any per-task back-reference edit or the `backlog/` commit
876
- * failed (LORE-58). The doc-side write and any successful back-reference edits already happened
877
- * and are never undone — only how the *outcome* is reported changes: routed through the standard
878
- * `--json` `ErrorEnvelope` on stderr (cli-contract §5.2) with empty stdout, uniformly with every
879
- * other `lore` command's nonzero exit, instead of a partial-failure envelope on stdout. `input`
880
- * carries the same per-task detail the old stdout report did (`concept`/`changed`/`tasks`/
881
- * `backlogCommit`), so a caller loses no diagnostic granularity, just its location.
882
- */
883
- function backRefFailure(kind: "link" | "unlink", report: TaskReportLike): LoreError {
884
- const failedTasks = report.tasks.filter((t) => t.backRef === "failed");
885
- const commitFailed = report.backlogCommit.error !== undefined;
886
- const summary = [
887
- failedTasks.length > 0 ? `${failedTasks.length} of ${report.tasks.length} task back-reference edit(s)` : undefined,
888
- commitFailed ? "the backlog/ commit" : undefined,
889
- ]
890
- .filter((part): part is string => part !== undefined)
891
- .join(" and ");
892
- const reasons = failedTasks.map((t) => `${t.task}: ${t.error ?? "unknown error"}`);
893
- if (commitFailed) {
894
- reasons.push(`backlog/ commit: ${report.backlogCommit.error}`);
895
- }
896
- return new LoreError(
897
- "drift",
898
- `\`lore ${kind}\`: ${summary} failed`,
899
- `${reasons.join("; ")} — re-run \`lore ${kind}\` with the same arguments to retry the failure(s)`,
900
- { concept: report.concept, changed: report.changed, tasks: report.tasks, backlogCommit: report.backlogCommit },
901
- );
902
- }
903
-
904
- /**
905
- * Run `fn` over `items` one at a time — never concurrently — collecting each result as a
906
- * {@link PromiseSettledResult}, exactly like `Promise.allSettled` would, but serialized: ADR-0012
907
- * §5 is a locked decision that `lore` does not run concurrent mutating Backlog commands within one
908
- * invocation. A failure on one item is still caught and does not stop the rest from running (the
909
- * per-task independence the round-1 fix established); only the *concurrency* is removed.
910
- */
911
- async function runSequentially<T>(
912
- items: readonly string[],
913
- fn: (item: string) => Promise<T>,
914
- ): Promise<PromiseSettledResult<T>[]> {
915
- const results: PromiseSettledResult<T>[] = [];
916
- for (const item of items) {
917
- try {
918
- results.push({ status: "fulfilled", value: await fn(item) });
919
- } catch (reason) {
920
- results.push({ status: "rejected", reason });
921
- }
922
- }
923
- return results;
924
- }
925
-
926
- /** A one-line message for a rejected `editTask` call, for the report's `error` field. */
927
- function describeError(reason: unknown): string {
928
- return reason instanceof Error ? reason.message : String(reason);
929
- }