@opum-ai/lore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,929 @@
|
|
|
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
|
+
}
|