@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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/supersede.ts — `lore supersede <oldId> <newId> [--rewrite-links] [--dry-run]`.
|
|
3
|
+
*
|
|
4
|
+
* The thin, side-effecting layer that records a supersession relationship between two existing
|
|
5
|
+
* concepts (cli-surface §supersede, the third of LORE-35's refactoring commands; LORE-35 AC: the
|
|
6
|
+
* supersede half). Unlike {@link runRename}, it **preserves the old file** as history — nothing
|
|
7
|
+
* moves or is deleted — and only edits frontmatter:
|
|
8
|
+
*
|
|
9
|
+
* - on the **old** concept: `status: superseded` + `superseded_by: <newId>` (the successor, bare id);
|
|
10
|
+
* - on the **new** concept: `supersedes: <oldId>`, **appended** to any existing entry (a concept may
|
|
11
|
+
* supersede several) rather than clobbering it.
|
|
12
|
+
*
|
|
13
|
+
* Both writes go through {@link serializeConcept} under the **active profile** (so the `status` value
|
|
14
|
+
* is validated against the project's own profile, not just the default — a custom `status` enum that
|
|
15
|
+
* forbids `superseded` fails fast here rather than slipping through to break the next `lore validate`),
|
|
16
|
+
* in canonical key order with the frozen YAML config, so an already-canonical concept's other
|
|
17
|
+
* frontmatter and its whole body round-trip byte-for-byte and the wiring is the only diff (ADR-0011).
|
|
18
|
+
*
|
|
19
|
+
* With `--rewrite-links` it additionally repoints **inbound body links** to the successor via the
|
|
20
|
+
* shared pure {@link rewriteInbound} engine in place-only (`move:false`) mode, with two restrictions
|
|
21
|
+
* that distinguish supersede from rename (whose machinery it reuses):
|
|
22
|
+
*
|
|
23
|
+
* - `rewriteFrontmatterRefs:false` — because the old file is **preserved**, a third party's
|
|
24
|
+
* `supersedes`/`superseded_by`/`specs` ref to it remains a true historical record; repointing it
|
|
25
|
+
* would fabricate a relationship that never happened. Only navigational body links are redirected.
|
|
26
|
+
* - `exclude` the two **principals** and the machine-owned `index.md`/`log.md` hubs — the old doc's
|
|
27
|
+
* own (historical) body links and the new doc's legitimate links *to* its predecessor must stay
|
|
28
|
+
* intact (else the successor links to itself), and a generated hub is never hand-rewritten (its
|
|
29
|
+
* file listing is unchanged, since supersede moves nothing).
|
|
30
|
+
*
|
|
31
|
+
* Every retargeted link is still repointed to the successor — but when its visible text still names
|
|
32
|
+
* the OLD id (e.g. `[ADR-0005](…)`, now pointing at ADR-0006 — a supersession doc frequently cites
|
|
33
|
+
* its predecessor by name to explain the change), that would silently leave the prose and the link
|
|
34
|
+
* disagreeing. The engine flags each such {@link LinkTextMismatch} in the plan; this command renders
|
|
35
|
+
* one stderr `warning:` line per mismatch via {@link renderLinkTextMismatchWarning} (LORE-262) — the
|
|
36
|
+
* retarget and the exit code are unaffected, so this is purely advisory.
|
|
37
|
+
*
|
|
38
|
+
* Validation lives here, because the engine's `move:false` path checks only that `oldId` exists (its
|
|
39
|
+
* conflict guard is move-only): both ids must name concepts (`not_found`, exit 3); neither may be a
|
|
40
|
+
* reserved hub name (`usage`, exit 2); and the old concept must not already be superseded —
|
|
41
|
+
* `status: superseded` (any case) **or** an already-recorded `superseded_by` — which would otherwise
|
|
42
|
+
* be silently overwritten (`conflict`, exit 5). All file I/O is here
|
|
43
|
+
* ({@link writeFileOverwriting}, overwrite in place); every link/ref judgement stays pure in
|
|
44
|
+
* `core/rewrite.ts`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { join, posix } from "node:path";
|
|
48
|
+
import { conceptNotInBundle, loadBundle, resolveRef, UNREADABLE_DIRECTORY_WARNING } from "../core/bundle";
|
|
49
|
+
import { type Concept, idFromPath, serializeConcept } from "../core/concept";
|
|
50
|
+
import { loadProfile } from "../core/profile";
|
|
51
|
+
import { renderLinkTextMismatchWarning, rewriteInbound } from "../core/rewrite";
|
|
52
|
+
import { DOCS_DIR, RESERVED_STEMS } from "../core/scaffold";
|
|
53
|
+
import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
|
|
54
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
55
|
+
import { assertNotReservedStem, parseCommandArgs, usage } from "./args";
|
|
56
|
+
import { writeFileOverwriting } from "./fswrite";
|
|
57
|
+
|
|
58
|
+
/** The frontmatter `status` value that marks a concept superseded — the lifecycle signal we set and detect. */
|
|
59
|
+
const SUPERSEDED_STATUS = "superseded";
|
|
60
|
+
|
|
61
|
+
/** Options for {@link runSupersede}; `root` and the streams are injectable for tests. */
|
|
62
|
+
export interface SupersedeOptions {
|
|
63
|
+
/** The repo root the `docs/` bundle resolves against. */
|
|
64
|
+
root: string;
|
|
65
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
66
|
+
output: OutputContext;
|
|
67
|
+
/** The command's normalized positional + flag tokens from Commander. */
|
|
68
|
+
args: readonly string[];
|
|
69
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
70
|
+
stdout?: Writer;
|
|
71
|
+
/** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
|
|
72
|
+
stderr?: Writer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The parsed form of `lore supersede`'s arguments. */
|
|
76
|
+
interface SupersedeArgs {
|
|
77
|
+
/** The concept id (or path) being superseded. */
|
|
78
|
+
oldId: string;
|
|
79
|
+
/** The successor concept id (or path). */
|
|
80
|
+
newId: string;
|
|
81
|
+
/** `--rewrite-links`: also repoint inbound body links to the successor. */
|
|
82
|
+
rewriteLinks: boolean;
|
|
83
|
+
/** `--dry-run`: report what would change, write nothing. */
|
|
84
|
+
dryRun: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One written file, for the report. */
|
|
88
|
+
interface ChangedFile {
|
|
89
|
+
/** Repo-relative POSIX path of the written file. */
|
|
90
|
+
readonly path: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The `supersede.result` payload: the wired relationship and every file written. */
|
|
94
|
+
export interface SupersedeReport {
|
|
95
|
+
/** The superseded concept's repo-relative path. */
|
|
96
|
+
readonly old: string;
|
|
97
|
+
/** The successor concept's repo-relative path. */
|
|
98
|
+
readonly new: string;
|
|
99
|
+
/** Every file written (the principals that changed, plus any repointed inbound files), ascending. */
|
|
100
|
+
readonly files: readonly ChangedFile[];
|
|
101
|
+
/** How many files changed (== `files.length`). */
|
|
102
|
+
readonly filesChanged: number;
|
|
103
|
+
/** Whether any inbound body link was actually repointed (not merely whether `--rewrite-links` was passed). */
|
|
104
|
+
readonly rewroteLinks: boolean;
|
|
105
|
+
/** Whether this was a `--dry-run` (nothing was written). */
|
|
106
|
+
readonly dryRun: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run `lore supersede`: parse the arguments, load the bundle and active profile, validate both
|
|
111
|
+
* concepts exist and the old one is not already superseded, wire the supersession frontmatter both
|
|
112
|
+
* ways, optionally repoint inbound body links to the successor, write the changed files (unless
|
|
113
|
+
* `--dry-run`), emit the `supersede.result`, and return `0`. A bad flag / self-supersede / reserved
|
|
114
|
+
* id throws a `usage` {@link LoreError} (exit `2`); a missing id a `not_found` (exit `3`); an
|
|
115
|
+
* already-superseded old id a `conflict` (exit `5`).
|
|
116
|
+
*/
|
|
117
|
+
export function runSupersede(options: SupersedeOptions): number {
|
|
118
|
+
const parsed = parseSupersedeArgs(options.args);
|
|
119
|
+
const oldId = idFromPath(parsed.oldId);
|
|
120
|
+
const newId = idFromPath(parsed.newId);
|
|
121
|
+
if (oldId === newId) {
|
|
122
|
+
throw new LoreError("usage", "a concept cannot supersede itself", "pass a different successor id", {
|
|
123
|
+
id: oldId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
assertNotReservedStem(oldId, "supersede");
|
|
127
|
+
assertNotReservedStem(newId, "supersede");
|
|
128
|
+
|
|
129
|
+
const docsRoot = join(options.root, DOCS_DIR);
|
|
130
|
+
const advisories = new WarningCollector();
|
|
131
|
+
const profile = loadProfile({ root: options.root });
|
|
132
|
+
const graph = loadBundle(docsRoot, { warnings: advisories, profile });
|
|
133
|
+
// Flushed immediately (not at the end, as this command previously did) so a skipped-directory
|
|
134
|
+
// warning naming the exact path/reason survives on the fail-loud `--rewrite-links` path below it
|
|
135
|
+
// feeds (LORE-82), mirroring how `context.ts`/`graph.ts` flush before a load-warning-explained
|
|
136
|
+
// not_found throw.
|
|
137
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
138
|
+
|
|
139
|
+
// The command owns all validation: the engine's `move:false` path checks only that `oldId` exists.
|
|
140
|
+
const oldConcept = graph.concepts.get(oldId);
|
|
141
|
+
if (oldConcept === undefined) {
|
|
142
|
+
throw conceptNotInBundle(oldId);
|
|
143
|
+
}
|
|
144
|
+
const newConcept = graph.concepts.get(newId);
|
|
145
|
+
if (newConcept === undefined) {
|
|
146
|
+
throw conceptNotInBundle(newId);
|
|
147
|
+
}
|
|
148
|
+
assertNotAlreadySuperseded(oldConcept);
|
|
149
|
+
|
|
150
|
+
// Optionally repoint inbound body links to the successor. The principals and the machine-owned
|
|
151
|
+
// hubs are excluded (the engine never even parses them), and frontmatter refs are left intact —
|
|
152
|
+
// the old file is preserved, so a ref to it is valid history, not a dead pointer.
|
|
153
|
+
const writes = new Map<string, string>();
|
|
154
|
+
let rewroteLinks = false;
|
|
155
|
+
if (parsed.rewriteLinks) {
|
|
156
|
+
// rewriteInbound can only repoint the inbound links it can SEE — a directory `loadBundle` had
|
|
157
|
+
// to skip (unreadable) may hide a concept that links to `oldId`, so committing this rewrite
|
|
158
|
+
// would silently report success while leaving that concept's link stale/broken. Refuse rather
|
|
159
|
+
// than guess: the graph is not the complete bundle, so no rewrite over it is safe to commit
|
|
160
|
+
// (LORE-82). Gated on `--rewrite-links` specifically — without it, supersede only edits the two
|
|
161
|
+
// principals' own frontmatter, which has no dependency on the rest of the bundle being visible.
|
|
162
|
+
if (advisories.has(UNREADABLE_DIRECTORY_WARNING)) {
|
|
163
|
+
throw new LoreError(
|
|
164
|
+
"validation",
|
|
165
|
+
"the bundle graph is incomplete: an unreadable directory was skipped while loading it",
|
|
166
|
+
"fix filesystem permissions on the directory named in the warning above and retry — --rewrite-links cannot safely repoint inbound links without a complete view of the bundle",
|
|
167
|
+
{ docsRoot },
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const plan = rewriteInbound(graph, oldId, newId, {
|
|
171
|
+
move: false,
|
|
172
|
+
rewriteFrontmatterRefs: false,
|
|
173
|
+
exclude: excludedFromRewrite(graph, oldConcept.path, newConcept.path),
|
|
174
|
+
profile,
|
|
175
|
+
});
|
|
176
|
+
for (const w of plan.writes) {
|
|
177
|
+
writes.set(w.path, w.bytes);
|
|
178
|
+
}
|
|
179
|
+
rewroteLinks = writes.size > 0;
|
|
180
|
+
|
|
181
|
+
// Every retargeted inbound link is still repointed exactly as before (LORE-262 AC#2 — no
|
|
182
|
+
// regression); a link whose visible text still names the OLD id is additionally called out as a
|
|
183
|
+
// stderr warning so the author can review the prose, rather than the mismatch shipping silently
|
|
184
|
+
// (LORE-262 AC#1). A FRESH collector, not `advisories` — that one was already flushed above
|
|
185
|
+
// (LORE-82's ordering), and `flush()` is non-draining, so reusing it here would re-print the
|
|
186
|
+
// earlier bundle-load warnings a second time.
|
|
187
|
+
if (plan.textMismatches.length > 0) {
|
|
188
|
+
const mismatchWarnings = new WarningCollector();
|
|
189
|
+
for (const mismatch of plan.textMismatches) {
|
|
190
|
+
mismatchWarnings.add(renderLinkTextMismatchWarning(mismatch));
|
|
191
|
+
}
|
|
192
|
+
mismatchWarnings.flush({ color: options.output.color, stderr: options.stderr });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Wire the principals' frontmatter (cloned, never mutating the graph snapshot) and serialize under
|
|
197
|
+
// the active profile. The old doc always changes (it gains the lifecycle keys); the new doc is
|
|
198
|
+
// written only when its `supersedes` actually changes, so a no-op append is not reported as a write.
|
|
199
|
+
writes.set(oldConcept.path, serializeConcept(wireOld(oldConcept, newId), { profile }));
|
|
200
|
+
const wiredNew = wireNew(newConcept, oldId, graph);
|
|
201
|
+
if (wiredNew !== null) {
|
|
202
|
+
writes.set(newConcept.path, serializeConcept(wiredNew, { profile }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sorted = new Map([...writes].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)));
|
|
206
|
+
|
|
207
|
+
if (!parsed.dryRun) {
|
|
208
|
+
for (const [path, bytes] of sorted) {
|
|
209
|
+
writeFileOverwriting(join(docsRoot, path), bytes, `${DOCS_DIR}/${path}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const report = buildReport(oldConcept, newConcept, sorted, { rewroteLinks, dryRun: parsed.dryRun });
|
|
214
|
+
emit(reportRenderable(report), options.output, options.stdout);
|
|
215
|
+
// Load advisories were already flushed right after loadBundle (LORE-82), not repeated here
|
|
216
|
+
// (flush is non-draining — a second call would re-print the same warnings).
|
|
217
|
+
return EXIT_OK;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The concept ids `--rewrite-links` must never touch: the two principals (the command wires their
|
|
222
|
+
* frontmatter itself) and every machine-owned `index.md`/`log.md` hub in the bundle (regenerated by
|
|
223
|
+
* `lore sync`, not hand-rewritten — and since supersede moves nothing, their listings are unchanged).
|
|
224
|
+
*/
|
|
225
|
+
function excludedFromRewrite(
|
|
226
|
+
graph: { concepts: ReadonlyMap<string, Concept> },
|
|
227
|
+
oldPath: string,
|
|
228
|
+
newPath: string,
|
|
229
|
+
): ReadonlySet<string> {
|
|
230
|
+
const ids = new Set<string>([idFromPath(oldPath), idFromPath(newPath)]);
|
|
231
|
+
for (const id of graph.concepts.keys()) {
|
|
232
|
+
if (RESERVED_STEMS.has(posix.basename(id))) {
|
|
233
|
+
ids.add(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return ids;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Frontmatter wiring ─────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The old concept with its supersession frontmatter set: `status: superseded` and
|
|
243
|
+
* `superseded_by: <newId>` (the bare-id successor, lore's canonical ref form). Returns a clone — the
|
|
244
|
+
* graph snapshot is never mutated. The body is carried over verbatim, so re-serializing the
|
|
245
|
+
* already-canonical concept changes only these two keys. Safe to overwrite `superseded_by`
|
|
246
|
+
* unconditionally because {@link assertNotAlreadySuperseded} has already rejected a concept that
|
|
247
|
+
* carries one.
|
|
248
|
+
*/
|
|
249
|
+
function wireOld(concept: Concept, newId: string): Concept {
|
|
250
|
+
return {
|
|
251
|
+
...concept,
|
|
252
|
+
frontmatter: { ...concept.frontmatter, status: SUPERSEDED_STATUS, superseded_by: newId },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The new concept with `oldId` appended to its `supersedes`, or `null` when that is a no-op (the
|
|
258
|
+
* concept already references the old one) — so an unchanged successor is neither rewritten nor
|
|
259
|
+
* counted. A concept may supersede several, so an existing entry is **preserved**: an absent
|
|
260
|
+
* `supersedes` becomes the single bare id `oldId`; an existing scalar or list gains `oldId`
|
|
261
|
+
* (normalized to a list when adding a second). Membership is decided by resolving each existing entry
|
|
262
|
+
* to a concept id via the bundle's own {@link resolveRef}, so a path-form entry that already names
|
|
263
|
+
* the old concept is not duplicated as a bare id.
|
|
264
|
+
*/
|
|
265
|
+
function wireNew(concept: Concept, oldId: string, graph: { concepts: ReadonlyMap<string, Concept> }): Concept | null {
|
|
266
|
+
const dir = posix.dirname(concept.path);
|
|
267
|
+
const existing = concept.frontmatter.supersedes;
|
|
268
|
+
const next = appendSupersedes(existing, oldId, dir, graph.concepts);
|
|
269
|
+
if (next === existing) {
|
|
270
|
+
return null; // already references the old concept — nothing to write
|
|
271
|
+
}
|
|
272
|
+
return { ...concept, frontmatter: { ...concept.frontmatter, supersedes: next } };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Compute the new `supersedes` value, appending `oldId` (bare id) unless it is already referenced. */
|
|
276
|
+
function appendSupersedes(
|
|
277
|
+
existing: unknown,
|
|
278
|
+
oldId: string,
|
|
279
|
+
dir: string,
|
|
280
|
+
byId: ReadonlyMap<string, Concept>,
|
|
281
|
+
): string | string[] {
|
|
282
|
+
if (existing === undefined || existing === null) {
|
|
283
|
+
return oldId;
|
|
284
|
+
}
|
|
285
|
+
const list = Array.isArray(existing) ? existing : [existing];
|
|
286
|
+
if (list.some((item) => typeof item === "string" && resolveRef(item, dir, byId) === oldId)) {
|
|
287
|
+
return existing as string | string[]; // already references the old concept — preserve as-is
|
|
288
|
+
}
|
|
289
|
+
return [...list, oldId];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Validation ─────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Reject superseding a concept that is already superseded (`conflict`, exit 5). "Already superseded"
|
|
296
|
+
* is either the `status: superseded` lifecycle signal (matched case-insensitively, since `status` is
|
|
297
|
+
* a free-form string) **or** an already-recorded `superseded_by` — the structured field
|
|
298
|
+
* {@link wireOld} would otherwise silently overwrite, discarding the concept's real recorded
|
|
299
|
+
* successor.
|
|
300
|
+
*/
|
|
301
|
+
function assertNotAlreadySuperseded(oldConcept: Concept): void {
|
|
302
|
+
if (hasRecordedSuccessor(oldConcept) || statusIsSuperseded(oldConcept)) {
|
|
303
|
+
throw new LoreError(
|
|
304
|
+
"conflict",
|
|
305
|
+
`concept "${oldConcept.id}" is already superseded`,
|
|
306
|
+
"a superseded concept cannot be superseded again; clear its `status`/`superseded_by` first if this is intentional",
|
|
307
|
+
{ id: oldConcept.id },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Whether the concept already records a successor (`superseded_by` set to a non-empty value). */
|
|
313
|
+
function hasRecordedSuccessor(concept: Concept): boolean {
|
|
314
|
+
const value = concept.frontmatter.superseded_by;
|
|
315
|
+
if (value === undefined || value === null) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
return !(Array.isArray(value) && value.length === 0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Whether the concept's `status` is `superseded`, matched case-insensitively (status is free-form text). */
|
|
322
|
+
function statusIsSuperseded(concept: Concept): boolean {
|
|
323
|
+
const status = concept.frontmatter.status;
|
|
324
|
+
return typeof status === "string" && status.trim().toLowerCase() === SUPERSEDED_STATUS;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Parse `supersede`'s tokens into its two positionals (`<oldId> <newId>`), `--rewrite-links`, and
|
|
331
|
+
* `--dry-run`, via the shared {@link parseCommandArgs} parser (mirrors
|
|
332
|
+
* `commands/rename.ts`/`commands/link.ts`'s parsers). Positional arity is validated here since it
|
|
333
|
+
* differs per command.
|
|
334
|
+
*/
|
|
335
|
+
function parseSupersedeArgs(args: readonly string[]): SupersedeArgs {
|
|
336
|
+
const { positionals, flags } = parseCommandArgs(args, "supersede");
|
|
337
|
+
|
|
338
|
+
const oldId = positionals[0];
|
|
339
|
+
if (oldId === undefined) {
|
|
340
|
+
throw usage("`lore supersede` needs an old and a new id", "run `lore supersede <oldId> <newId>`");
|
|
341
|
+
}
|
|
342
|
+
const newId = positionals[1];
|
|
343
|
+
if (newId === undefined) {
|
|
344
|
+
throw usage(
|
|
345
|
+
"`lore supersede` needs a successor id",
|
|
346
|
+
"pass the successor id, e.g. `lore supersede adr/0007-old adr/0012-new`",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (positionals.length > 2) {
|
|
350
|
+
throw usage(
|
|
351
|
+
`unexpected argument "${positionals[2]}"`,
|
|
352
|
+
"pass exactly an old and a new id; scope nothing else (supersede wires the whole bundle)",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return { oldId, newId, rewriteLinks: flags.has("rewrite-links"), dryRun: flags.has("dry-run") };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/** Assemble the {@link SupersedeReport} from the principals and the merged writes (repo-relative display paths). */
|
|
361
|
+
function buildReport(
|
|
362
|
+
oldConcept: Concept,
|
|
363
|
+
newConcept: Concept,
|
|
364
|
+
writes: Map<string, string>,
|
|
365
|
+
flags: { rewroteLinks: boolean; dryRun: boolean },
|
|
366
|
+
): SupersedeReport {
|
|
367
|
+
const files = [...writes.keys()].map((path) => ({ path: `${DOCS_DIR}/${path}` }));
|
|
368
|
+
return {
|
|
369
|
+
old: `${DOCS_DIR}/${oldConcept.path}`,
|
|
370
|
+
new: `${DOCS_DIR}/${newConcept.path}`,
|
|
371
|
+
files,
|
|
372
|
+
filesChanged: files.length,
|
|
373
|
+
rewroteLinks: flags.rewroteLinks,
|
|
374
|
+
dryRun: flags.dryRun,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The per-result-type rendering bundle for `supersede` (output.ts dispatches on the mode). */
|
|
379
|
+
function reportRenderable(data: SupersedeReport): Renderable<SupersedeReport> {
|
|
380
|
+
return {
|
|
381
|
+
kind: "supersede.result",
|
|
382
|
+
data,
|
|
383
|
+
pretty: (report) => render(report),
|
|
384
|
+
plain: (report) => render(report),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The supersession line, one line per other changed file, then a summary. (No color: no severities.) */
|
|
389
|
+
function render(data: SupersedeReport): string {
|
|
390
|
+
const verb = data.dryRun ? "would supersede" : "superseded";
|
|
391
|
+
const lines = [`${verb} ${data.old} -> ${data.new}`];
|
|
392
|
+
for (const file of data.files) {
|
|
393
|
+
if (file.path !== data.old && file.path !== data.new) {
|
|
394
|
+
lines.push(`${data.dryRun ? "would update" : "updated"} ${file.path}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const noun = data.filesChanged === 1 ? "file" : "files";
|
|
398
|
+
lines.push(`${data.filesChanged} ${noun} changed${data.dryRun ? " (dry-run)" : ""}`);
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|