@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.
- package/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/core/reconcile.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* reconcile.ts — roll a `Story`/`Spec`'s `tasks:`-linked Backlog statuses up into one derived
|
|
3
|
-
* doc `status` (LORE-23, [ADR-0009] §3).
|
|
4
|
-
*
|
|
5
|
-
* A Story's `status` frontmatter is not authored once and left stale — it is **recomputed** from
|
|
6
|
-
* the live statuses of the tasks it links, so a reader (or `lore check`'s drift gate) never sees a
|
|
7
|
-
* doc claiming `done` while a linked task is still open. This module is the shared pure engine
|
|
8
|
-
* behind that computation: the command layer (LORE-24+) resolves each linked task id to its raw
|
|
9
|
-
* `status` string (the LORE-21 adapter's `viewTask`/`listTasks`) and reads the project's ordered
|
|
10
|
-
* status flow from `backlog/config.yml` (`statuses:`), then calls {@link reconcileStatus}. `lore
|
|
11
|
-
* sync` writes the result; `lore check` diffs it against the persisted `status` and never writes
|
|
12
|
-
* (ADR-0007).
|
|
13
|
-
*
|
|
14
|
-
* Per-repo `[reconcile.overrides]` (`.lore/config.toml`, LORE-26) lets a project map a specific
|
|
15
|
-
* Backlog status straight to a {@link ReconciledStatus}, bypassing `statusFlow` position entirely —
|
|
16
|
-
* the escape hatch for a status an ordered flow cannot classify unambiguously (see
|
|
17
|
-
* {@link reconcileStatus}'s `overrides` parameter).
|
|
18
|
-
*
|
|
19
|
-
* Per the core contract (lore-design §2.1) this module is pure: two string arrays (plus an optional
|
|
20
|
-
* overrides map) in, a derived status (or `null`) or a typed {@link LoreError} out — no filesystem,
|
|
21
|
-
* no spawn, no clock. Reading `backlog/config.yml` and resolving each task's live status are
|
|
22
|
-
* command-layer concerns, kept out of this engine so it stays a single deterministic function over
|
|
23
|
-
* already-resolved data.
|
|
24
|
-
*
|
|
25
|
-
* [ADR-0009]: ../../docs/adr/0009-story-task-coupling-reconciliation.md
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { LoreError } from "../errors";
|
|
29
|
-
|
|
30
|
-
/** The three derived rollup values (ADR-0009 §3, backlog-cli-contract.md §3.2). */
|
|
31
|
-
export type ReconciledStatus = "todo" | "in-progress" | "done";
|
|
32
|
-
|
|
33
|
-
/** The closed set {@link ReconciledStatus} draws from, for validating a `[reconcile.overrides]` target. */
|
|
34
|
-
const RECONCILED_STATUSES: readonly ReconciledStatus[] = ["todo", "in-progress", "done"];
|
|
35
|
-
|
|
36
|
-
/** Where one task's status falls in the project's ordered {@link StatusFlow}. */
|
|
37
|
-
type StatusPosition = "not-started" | "active" | "terminal";
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Per-repo `[reconcile.overrides]` (`.lore/config.toml`, `config.ts`'s {@link ReconcileConfig.overrides}):
|
|
41
|
-
* a raw Backlog status string → the {@link ReconciledStatus} it should contribute to the rollup,
|
|
42
|
-
* **bypassing** {@link StatusFlow} position entirely for that status (ADR-0009 §3). `config.ts` parses
|
|
43
|
-
* this as an unvalidated `Record<string, string>` — "reconcile.ts owns the rollup-status vocabulary and
|
|
44
|
-
* its semantics" (config.ts) — so {@link reconcileStatus} is where an out-of-vocabulary target value is
|
|
45
|
-
* caught.
|
|
46
|
-
*/
|
|
47
|
-
export type StatusOverrides = Readonly<Record<string, string>>;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* A project's status set, **ordered** exactly as configured (`backlog/config.yml` `statuses:` /
|
|
51
|
-
* `backlog config get statuses`) — never the hardcoded `["To Do", "In Progress", "Done"]` default
|
|
52
|
-
* (backlog-cli-contract.md §3.1). Index `0` is the not-started state; the last index is the
|
|
53
|
-
* terminal ("done") state; everything between is an active/started state (`In Progress`,
|
|
54
|
-
* `Review`, `Testing`, `Blocked`, …). Reading this from config is a command-layer concern — this
|
|
55
|
-
* engine only consumes the resolved list. Must carry **at least two** entries: a single-entry flow
|
|
56
|
-
* cannot distinguish "not started" from "terminal" ({@link reconcileStatus} rejects it).
|
|
57
|
-
*/
|
|
58
|
-
export type StatusFlow = readonly string[];
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Roll `taskStatuses` — the raw `status` string of every task a Story links via its `tasks:`
|
|
62
|
-
* frontmatter — up into one {@link ReconciledStatus}, per `statusFlow`'s config-driven ordering
|
|
63
|
-
* (ADR-0009 §3):
|
|
64
|
-
*
|
|
65
|
-
* - `taskStatuses` empty (no linked tasks) → `null`: a narrative-only doc's authored `status` is
|
|
66
|
-
* never overwritten (AC#2) — the caller leaves the doc's existing `status` untouched.
|
|
67
|
-
* - every linked task's status is `statusFlow`'s **last** (terminal) entry → `"done"`.
|
|
68
|
-
* - any linked task's status is **neither** first nor last (an active/started state) →
|
|
69
|
-
* `"in-progress"`.
|
|
70
|
-
* - otherwise (tasks exist, none active, not all terminal) → `"todo"`.
|
|
71
|
-
*
|
|
72
|
-
* The three rules are applied by elimination, in that order, exactly as backlog-cli-contract.md
|
|
73
|
-
* §3.2 states it. One corner case follows directly from the literal rule and is intentional, not
|
|
74
|
-
* a bug: a Story linking only a `Done` task and a `To Do` task (no task in an explicit mid-flow
|
|
75
|
-
* status) rolls up to `"todo"`, not `"in-progress"` — "in-progress" is defined purely by the
|
|
76
|
-
* presence of an active-state task, not by partial completion among terminal/not-started tasks.
|
|
77
|
-
*
|
|
78
|
-
* @param taskStatuses the raw configured `status` string of every linked task (AC#1: any custom
|
|
79
|
-
* flow, not just the three defaults), in any order — order does not affect the rollup.
|
|
80
|
-
* @param statusFlow the project's ordered status set, resolved from Backlog config.
|
|
81
|
-
* @param overrides per-repo `[reconcile.overrides]` (default `{}`): a status matching a key here
|
|
82
|
-
* contributes its mapped {@link ReconciledStatus} directly, bypassing `statusFlow` position
|
|
83
|
-
* entirely — the escape hatch for a status a strict ordered flow cannot classify unambiguously
|
|
84
|
-
* (a bespoke `Cancelled`/`Won't Fix` state, or one a team added without reordering `statuses:`).
|
|
85
|
-
* Takes precedence over position even when the status is *also* present in `statusFlow`.
|
|
86
|
-
* @returns the rolled-up status, or `null` when there are no linked tasks.
|
|
87
|
-
* @throws LoreError `validation` when `statusFlow` has fewer than two entries, is empty, or
|
|
88
|
-
* carries a duplicate entry (an ambiguous flow lore cannot classify against — ADR-0009 "must
|
|
89
|
-
* report rather than guess"), when an override's target is not one of `todo`/`in-progress`/
|
|
90
|
-
* `done`, or when a task's status is not present in `statusFlow` **and** has no override (a
|
|
91
|
-
* config/task drift lore refuses to guess past).
|
|
92
|
-
*/
|
|
93
|
-
export function reconcileStatus(
|
|
94
|
-
taskStatuses: readonly string[],
|
|
95
|
-
statusFlow: StatusFlow,
|
|
96
|
-
overrides: StatusOverrides = {},
|
|
97
|
-
): ReconciledStatus | null {
|
|
98
|
-
if (taskStatuses.length === 0) {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
validateStatusFlow(statusFlow);
|
|
102
|
-
const validatedOverrides = validateOverrides(overrides);
|
|
103
|
-
const positions = taskStatuses.map((status) => classify(status, statusFlow, validatedOverrides));
|
|
104
|
-
if (positions.every((position) => position === "terminal")) {
|
|
105
|
-
return "done";
|
|
106
|
-
}
|
|
107
|
-
if (positions.some((position) => position === "active")) {
|
|
108
|
-
return "in-progress";
|
|
109
|
-
}
|
|
110
|
-
return "todo";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Validate `statusFlow`/`overrides` up front, without needing any task data — the fail-fast half of
|
|
115
|
-
* {@link reconcileStatus} exposed on its own so a caller resolving many tasks per invocation (e.g.
|
|
116
|
-
* `lore sync`, one Backlog subprocess round-trip per linked task) can catch a semantically-broken
|
|
117
|
-
* config (a degenerate flow, an out-of-vocabulary override target) **before** spending any of that
|
|
118
|
-
* work — `reconcileStatus` itself only reaches this validation once real task data is in hand,
|
|
119
|
-
* which is too late for that fail-fast property alone. Re-validates the same inputs
|
|
120
|
-
* `reconcileStatus` will validate again per call; see that function's own note on why the
|
|
121
|
-
* redundancy is accepted rather than threading a pre-validated value through its signature.
|
|
122
|
-
*
|
|
123
|
-
* @throws LoreError `validation` — see {@link reconcileStatus}'s throws for the exact conditions.
|
|
124
|
-
*/
|
|
125
|
-
export function validateReconcileInputs(statusFlow: StatusFlow, overrides: StatusOverrides = {}): void {
|
|
126
|
-
validateStatusFlow(statusFlow);
|
|
127
|
-
validateOverrides(overrides);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Reject a `statusFlow` lore cannot classify against unambiguously: fewer than two entries (with
|
|
132
|
-
* only one entry, index `0` is simultaneously the not-started **and** the terminal position — the
|
|
133
|
-
* two roles {@link classify} treats as distinct would silently collapse to the same index) or
|
|
134
|
-
* carrying a duplicate entry (an entry's position — and so its not-started/active/terminal
|
|
135
|
-
* classification — would depend on which occurrence is meant).
|
|
136
|
-
*/
|
|
137
|
-
function validateStatusFlow(statusFlow: StatusFlow): void {
|
|
138
|
-
if (statusFlow.length < 2) {
|
|
139
|
-
throw new LoreError(
|
|
140
|
-
"validation",
|
|
141
|
-
`cannot reconcile status: the project's configured status flow has ${statusFlow.length} ${statusFlow.length === 1 ? "entry" : "entries"} (need at least 2 to distinguish "not started" from "terminal")`,
|
|
142
|
-
'set `statuses:` in `backlog/config.yml` to an ordered list of at least two statuses (e.g. ["To Do", "In Progress", "Done"])',
|
|
143
|
-
{ statusFlow },
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
const seen = new Set<string>();
|
|
147
|
-
for (const status of statusFlow) {
|
|
148
|
-
if (seen.has(status)) {
|
|
149
|
-
throw new LoreError(
|
|
150
|
-
"validation",
|
|
151
|
-
`cannot reconcile status: the project's configured status flow has a duplicate entry ${JSON.stringify(status)}`,
|
|
152
|
-
"each entry in `backlog/config.yml`'s `statuses:` must be unique so its position in the flow is unambiguous",
|
|
153
|
-
{ statusFlow },
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
seen.add(status);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Validate a `[reconcile.overrides]` map and return it keyed for safe, exhaustive lookup: a `Map`
|
|
162
|
-
* (not the input `Record`) so a status string that happens to name an `Object.prototype` member
|
|
163
|
-
* (`constructor`, `toString`, …) can never resolve to an inherited value instead of a real miss —
|
|
164
|
-
* the same class of hazard `config.ts`'s `asStringMap` guards on the write side.
|
|
165
|
-
*
|
|
166
|
-
* @throws LoreError `validation` naming the offending status and target when a target is not one
|
|
167
|
-
* of {@link RECONCILED_STATUSES} — `config.ts` deliberately leaves this vocabulary check to
|
|
168
|
-
* reconcile.ts (its own header comment), so a bad `.lore/config.toml` value is caught here.
|
|
169
|
-
*/
|
|
170
|
-
function validateOverrides(overrides: StatusOverrides): ReadonlyMap<string, ReconciledStatus> {
|
|
171
|
-
const validated = new Map<string, ReconciledStatus>();
|
|
172
|
-
for (const [status, target] of Object.entries(overrides)) {
|
|
173
|
-
if (!isReconciledStatus(target)) {
|
|
174
|
-
throw new LoreError(
|
|
175
|
-
"validation",
|
|
176
|
-
`cannot reconcile status: [reconcile.overrides] maps ${JSON.stringify(status)} to ${JSON.stringify(target)}, which is not a valid rollup status`,
|
|
177
|
-
`set [reconcile.overrides] "${status}" in .lore/config.toml to one of: ${RECONCILED_STATUSES.join(", ")}`,
|
|
178
|
-
{ status, target, valid: RECONCILED_STATUSES },
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
validated.set(status, target);
|
|
182
|
-
}
|
|
183
|
-
return validated;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Narrow an override's raw string target to {@link ReconciledStatus}, for {@link validateOverrides}. */
|
|
187
|
-
function isReconciledStatus(value: string): value is ReconciledStatus {
|
|
188
|
-
return (RECONCILED_STATUSES as readonly string[]).includes(value);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Classify one task's raw `status` string, checking `overrides` before falling back to its index in
|
|
193
|
-
* `statusFlow`: the first entry is not-started, the last is terminal, everything between is active.
|
|
194
|
-
* Matching is exact-string (Backlog status labels are canonical configured strings, verbatim in the
|
|
195
|
-
* `--json` payload — not user-typed free text lore case-folds elsewhere).
|
|
196
|
-
*
|
|
197
|
-
* @throws LoreError `validation` when `status` has no override and is absent from `statusFlow` entirely.
|
|
198
|
-
*/
|
|
199
|
-
function classify(
|
|
200
|
-
status: string,
|
|
201
|
-
statusFlow: StatusFlow,
|
|
202
|
-
overrides: ReadonlyMap<string, ReconciledStatus>,
|
|
203
|
-
): StatusPosition {
|
|
204
|
-
const override = overrides.get(status);
|
|
205
|
-
if (override !== undefined) {
|
|
206
|
-
return positionForOverride(override);
|
|
207
|
-
}
|
|
208
|
-
const index = statusFlow.indexOf(status);
|
|
209
|
-
if (index === -1) {
|
|
210
|
-
throw new LoreError(
|
|
211
|
-
"validation",
|
|
212
|
-
`cannot reconcile status: task status ${JSON.stringify(status)} is not in the project's configured status flow (${statusFlow.map((s) => JSON.stringify(s)).join(", ")}) and has no [reconcile.overrides] entry`,
|
|
213
|
-
"the task's status must match one of `backlog/config.yml`'s `statuses:` exactly, or add a `[reconcile.overrides]` entry for it in .lore/config.toml",
|
|
214
|
-
{ status, statusFlow },
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
if (index === statusFlow.length - 1) {
|
|
218
|
-
return "terminal";
|
|
219
|
-
}
|
|
220
|
-
if (index === 0) {
|
|
221
|
-
return "not-started";
|
|
222
|
-
}
|
|
223
|
-
return "active";
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Map an override's validated target directly to the {@link StatusPosition} the aggregation rule expects. */
|
|
227
|
-
function positionForOverride(target: ReconciledStatus): StatusPosition {
|
|
228
|
-
switch (target) {
|
|
229
|
-
case "done":
|
|
230
|
-
return "terminal";
|
|
231
|
-
case "in-progress":
|
|
232
|
-
return "active";
|
|
233
|
-
case "todo":
|
|
234
|
-
return "not-started";
|
|
235
|
-
}
|
|
236
|
-
}
|
package/src/core/replace.ts
DELETED
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* replace.ts — the **pure** engine behind `lore replace`'s find-and-replace, with one
|
|
3
|
-
* inviolable rule: a lore-**managed region is never touched** (cli-surface §replace, AC#1).
|
|
4
|
-
*
|
|
5
|
-
* `lore replace` is a refactoring convenience — literal or regex find/replace across one doc
|
|
6
|
-
* or the whole bundle. The danger it must design around is that a doc is not all author prose:
|
|
7
|
-
* parts of it are **machine-owned**, regenerated wholesale by `lore sync` from an external
|
|
8
|
-
* source of truth. A blind text replace that edited inside such a region would either be
|
|
9
|
-
* silently reverted on the next `sync` (at best) or corrupt the region's structure so that
|
|
10
|
-
* regeneration's drift gate trips (at worst). So a match that overlaps a managed region is left
|
|
11
|
-
* untouched and uncounted; only author-owned bytes are rewritten ({@link applyReplacement}).
|
|
12
|
-
*
|
|
13
|
-
* ### One pass over the whole document
|
|
14
|
-
*
|
|
15
|
-
* The matcher runs over the **entire** document and each match is kept or skipped by whether its
|
|
16
|
-
* byte span overlaps a managed region. Running over the whole text (rather than over each
|
|
17
|
-
* author-owned gap in isolation) is what makes regex anchors and zero-width assertions bind to the
|
|
18
|
-
* real document — `^`/`$`/`\b`/lookaround see the true start/end and neighbors, not a gap boundary
|
|
19
|
-
* next to a managed block. `$1`/`$&`/`` $` ``/`$'`/`$<name>` substitutions are expanded explicitly
|
|
20
|
-
* ({@link expandTemplate}, byte-for-byte with `String.prototype.replace`) so the single pass keeps
|
|
21
|
-
* full regex-replacement semantics; in literal mode the replacement is inserted verbatim ($ is not
|
|
22
|
-
* special).
|
|
23
|
-
*
|
|
24
|
-
* ### What counts as a managed region
|
|
25
|
-
*
|
|
26
|
-
* The bundle has two kinds of in-file managed region, both registered in
|
|
27
|
-
* {@link MANAGED_REGION_LOCATORS}: the `<!-- lore:index:begin -->` … `<!-- lore:index:end -->`
|
|
28
|
-
* listing block that {@link generateIndexes} owns in every `index.md` (lore-design §6.2), and the
|
|
29
|
-
* `<!-- lore:tasks:begin -->` … `<!-- lore:tasks:end -->` task table that `managed-block.ts` owns in
|
|
30
|
-
* a `Story`/`Spec` doc (LORE-22). {@link managedRanges} locates each registered region with **that
|
|
31
|
-
* region's own owner's** location logic — so `replace` protects exactly the span `lore sync` would
|
|
32
|
-
* regenerate, and a refactor can never land in bytes `sync` later reverts. Each locator is a fail-loud
|
|
33
|
-
* `validation` error on a malformed layout (a duplicated marker pair, or an unmatched/crossed begin)
|
|
34
|
-
* rather than a guessed span (LORE-86) — `replace` never has to guess which bytes were meant to stay
|
|
35
|
-
* protected either. A future managed-block kind is likewise a one-entry addition to the registry. The
|
|
36
|
-
* fully machine-generated `log.md` has no in-file markers; it is excluded by the command layer's
|
|
37
|
-
* discovery, not here (this engine sees only the bytes it is handed).
|
|
38
|
-
*
|
|
39
|
-
* Two different location strategies, deliberately not unified into one: `lore:index`'s
|
|
40
|
-
* {@link locateManagedBlock} is a literal `indexOf` scan — safe there only because that marker text
|
|
41
|
-
* never occurs outside a real index block in practice. `lore:tasks:begin`/`:end`, however, are
|
|
42
|
-
* routinely *cited* in this project's own prose and fenced code examples documenting the format, so a
|
|
43
|
-
* literal scan misfires on those (a false "duplicated"/"unmatched" error, or worse, silently
|
|
44
|
-
* protecting prose that merely mentions the syntax); {@link locateTaskBlock} instead reuses
|
|
45
|
-
* `managed-block.ts`'s structural, mdast-based marker location (the same one `lore sync`/`lore check`
|
|
46
|
-
* already trust), where a sentinel inside a code fence or blockquote is never mistaken for a real
|
|
47
|
-
* marker (LORE-73).
|
|
48
|
-
*
|
|
49
|
-
* Per the core contract (lore-design §2.1) everything here is pure: text in, `{ text, count }` out,
|
|
50
|
-
* or a {@link LoreError} out — `usage` for an unusable pattern (empty, invalid regex, or one that
|
|
51
|
-
* can match the empty string), or `validation` when a managed region's markers are malformed
|
|
52
|
-
* (LORE-86, propagated from whichever locator owns that region) — no filesystem, no printing, no
|
|
53
|
-
* flags, no `process.exit`. The command layer ({@link commands/replace}) discovers and reads the
|
|
54
|
-
* files, compiles the pattern **once**, and applies it per file; a `validation` error aborts before
|
|
55
|
-
* any file is written (`commands/replace.ts` only writes after every target has been read and
|
|
56
|
-
* rewritten).
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
|
-
import { LoreError } from "../errors";
|
|
60
|
-
import { INDEX_BLOCK_BEGIN, INDEX_BLOCK_END, locateManagedBlock } from "./indexes";
|
|
61
|
-
import { locateTaskBlock, TASK_BLOCK_BEGIN, TASK_BLOCK_END } from "./managed-block";
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* A half-open `[start, end)` byte range within a file's text. Used for the spans
|
|
65
|
-
* {@link managedRanges} protects from replacement.
|
|
66
|
-
*/
|
|
67
|
-
export interface TextRange {
|
|
68
|
-
/** Inclusive start offset. */
|
|
69
|
-
readonly start: number;
|
|
70
|
-
/** Exclusive end offset. */
|
|
71
|
-
readonly end: number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The result of applying a {@link Replacer} to one file. */
|
|
75
|
-
export interface ReplaceResult {
|
|
76
|
-
/** The rewritten text (identical to the input when `count` is 0). */
|
|
77
|
-
readonly text: string;
|
|
78
|
-
/** How many matches were replaced — counted **only outside** managed regions. */
|
|
79
|
-
readonly count: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Options for {@link compileReplacer}/{@link replaceInText}. */
|
|
83
|
-
export interface ReplaceOptions {
|
|
84
|
-
/** Treat `find` as a JavaScript regular expression (with `$1`/`$&` substitution in `replace`). */
|
|
85
|
-
readonly regex?: boolean;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* A compiled find-and-replace, ready to apply to any number of files. `compile once, apply per file`
|
|
90
|
-
* is what lets the command validate the pattern a **single** time up front (so a bad pattern fails
|
|
91
|
-
* even when discovery finds zero files) instead of only when the per-file loop happens to run.
|
|
92
|
-
*/
|
|
93
|
-
export type Replacer = (text: string) => ReplaceResult;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* One entry in {@link MANAGED_REGION_LOCATORS}: the location function for one managed-region kind,
|
|
97
|
-
* paired with `duplicateProbe` — a synthetic, individually-well-formed *duplicated* instance of that
|
|
98
|
-
* region (its marker pair written out twice) used only by the LORE-194 regression test
|
|
99
|
-
* (`replace.test.ts`) to pin the throw-on-duplicate contract documented below. Requiring a probe on
|
|
100
|
-
* every entry is what makes a new registration prove the contract rather than merely promise it.
|
|
101
|
-
*/
|
|
102
|
-
interface ManagedRegionLocator {
|
|
103
|
-
readonly locate: (text: string) => TextRange | null;
|
|
104
|
-
readonly duplicateProbe: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The location functions that find each kind of lore-managed region, one per marker pair. The single
|
|
109
|
-
* registry every managed-region consumer skips, so a new managed block is protected by adding one
|
|
110
|
-
* entry rather than threading a new special case through `replace`. Each locator is owned by (and
|
|
111
|
-
* imported from) the module that owns that region's markers ({@link indexes}, {@link managed-block}),
|
|
112
|
-
* so `replace` always agrees with `lore sync`/`lore check` on a region's exact extent — including
|
|
113
|
-
* which location *strategy* (literal scan vs. structural) is safe for that marker pair.
|
|
114
|
-
*
|
|
115
|
-
* **Contract (LORE-194): every locator MUST throw a {@link LoreError} — never return the first
|
|
116
|
-
* span — when its marker occurs more than once, even when each occurrence is individually a
|
|
117
|
-
* well-formed block.** {@link assertNoInjectedMarker}'s LORE-162 guarantee (a `` $` ``/`$'` expansion
|
|
118
|
-
* that copies an existing marker into the rewritten result is rejected, not silently written) depends
|
|
119
|
-
* entirely on this: it re-runs {@link managedRanges} — i.e. every locator here — over the *rewritten*
|
|
120
|
-
* text and trusts that a newly-duplicated marker surfaces as a thrown error. A locator that instead
|
|
121
|
-
* returned the first span on duplication would make that re-validation pass silently, reopening
|
|
122
|
-
* LORE-162 for that marker kind with no existing test failing. {@link locateManagedBlock} and
|
|
123
|
-
* {@link locateTaskBlock} both satisfy this today; a new entry must too, proven by its
|
|
124
|
-
* `duplicateProbe` (see `locatorThrowsOnDuplicate` and the registry-iterating test in
|
|
125
|
-
* `replace.test.ts`) rather than left as an incidental property nothing checks.
|
|
126
|
-
*/
|
|
127
|
-
export const MANAGED_REGION_LOCATORS: ReadonlyArray<ManagedRegionLocator> = [
|
|
128
|
-
{
|
|
129
|
-
locate: (text) => locateManagedBlock(text, INDEX_BLOCK_BEGIN, INDEX_BLOCK_END),
|
|
130
|
-
duplicateProbe: `${INDEX_BLOCK_BEGIN}\na\n${INDEX_BLOCK_END}\n${INDEX_BLOCK_BEGIN}\nb\n${INDEX_BLOCK_END}`,
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
locate: (text) => locateTaskBlock(text),
|
|
134
|
-
duplicateProbe: `${TASK_BLOCK_BEGIN}\na\n${TASK_BLOCK_END}\n${TASK_BLOCK_BEGIN}\nb\n${TASK_BLOCK_END}`,
|
|
135
|
-
},
|
|
136
|
-
];
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Whether `locate` obeys the throw-on-duplicate contract documented on {@link MANAGED_REGION_LOCATORS}
|
|
140
|
-
* for its own `duplicateProbe`: throwing a {@link LoreError} (not returning a span, and not returning
|
|
141
|
-
* `null`) when the probe's marker occurs twice. Exported only for the LORE-194 regression test in
|
|
142
|
-
* `replace.test.ts` — both to pin the two current registry entries and to prove the check itself flags
|
|
143
|
-
* a first-span-on-duplicate violation, via a synthetic locator that deliberately breaks the contract.
|
|
144
|
-
*/
|
|
145
|
-
export function locatorThrowsOnDuplicate(locate: (text: string) => TextRange | null, duplicateProbe: string): boolean {
|
|
146
|
-
try {
|
|
147
|
-
locate(duplicateProbe);
|
|
148
|
-
} catch (cause) {
|
|
149
|
-
return cause instanceof LoreError;
|
|
150
|
-
}
|
|
151
|
-
return false; // returned a span (or null) instead of throwing — contract violated
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** A representative probe carrying word boundaries, line ends, digits, and punctuation, for the zero-width guard. */
|
|
155
|
-
const ZERO_WIDTH_PROBE = "a b1\n.x-_/2";
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Every lore-managed region in `text`, as merged, ascending `[start, end)` ranges. Each registered
|
|
159
|
-
* locator finds its own region kind ({@link MANAGED_REGION_LOCATORS}) so `replace` and `lore
|
|
160
|
-
* sync`/`check` agree byte-for-byte on a region's extent. Ranges from different marker kinds are
|
|
161
|
-
* merged so any overlap/touch collapses, leaving a clean ordered partition for
|
|
162
|
-
* {@link applyReplacement}.
|
|
163
|
-
*
|
|
164
|
-
* @throws LoreError `validation` when a registered region's markers are malformed (duplicated,
|
|
165
|
-
* unmatched, or crossed) — propagated from whichever locator owns that region, rather than
|
|
166
|
-
* guessing which bytes to protect (LORE-86).
|
|
167
|
-
*/
|
|
168
|
-
export function managedRanges(text: string): TextRange[] {
|
|
169
|
-
const ranges: TextRange[] = [];
|
|
170
|
-
for (const { locate } of MANAGED_REGION_LOCATORS) {
|
|
171
|
-
const bounds = locate(text);
|
|
172
|
-
if (bounds !== null) {
|
|
173
|
-
ranges.push(bounds);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return mergeRanges(ranges);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Compile a find/replace into a reusable {@link Replacer}, validating the pattern up front: an empty
|
|
181
|
-
* `find`, an invalid regex, or a pattern that can match the **empty string** (`x*`, `a?`, `.*`,
|
|
182
|
-
* `\b`, `^`, `(?:)`) is a `usage` {@link LoreError} — a zero-width global match would otherwise
|
|
183
|
-
* "replace" between every character and mass-corrupt a file. Literal mode matches `find` as plain
|
|
184
|
-
* text and inserts `replace` verbatim ($ is not special); regex mode honors `$1`/`$&`/`` $` ``/`$'`/
|
|
185
|
-
* `$<name>` substitutions.
|
|
186
|
-
*/
|
|
187
|
-
export function compileReplacer(find: string, replace: string, options: ReplaceOptions = {}): Replacer {
|
|
188
|
-
if (find === "") {
|
|
189
|
-
throw new LoreError("usage", "the find pattern is empty", "pass a non-empty string (or regex) to search for");
|
|
190
|
-
}
|
|
191
|
-
const matcher = options.regex ? compileRegex(find) : new RegExp(escapeRegExp(find), "g");
|
|
192
|
-
assertNonEmptyMatching(matcher);
|
|
193
|
-
const expand: (match: RegExpMatchArray, source: string) => string = options.regex
|
|
194
|
-
? (match, source) => expandTemplate(replace, match, source)
|
|
195
|
-
: () => replace;
|
|
196
|
-
return (text: string): ReplaceResult => applyReplacement(text, matcher, expand);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Find-and-replace within `text` (a single-shot {@link compileReplacer} + apply), returning the
|
|
201
|
-
* rewritten text and the count of matches replaced **outside** managed regions (AC#1). Equivalent to
|
|
202
|
-
* `String.prototype.replace` over the whole document, except matches overlapping a managed region are
|
|
203
|
-
* left untouched and uncounted.
|
|
204
|
-
*/
|
|
205
|
-
export function replaceInText(
|
|
206
|
-
text: string,
|
|
207
|
-
find: string,
|
|
208
|
-
replace: string,
|
|
209
|
-
options: ReplaceOptions = {},
|
|
210
|
-
): ReplaceResult {
|
|
211
|
-
return compileReplacer(find, replace, options)(text);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Apply a compiled `matcher` to the whole `text`, splicing in `expand(match)` for each match whose
|
|
216
|
-
* byte span does **not** overlap a managed region and leaving every other byte verbatim. Because the
|
|
217
|
-
* matcher runs over the full document, anchors/assertions bind to the real document; because skipped
|
|
218
|
-
* matches simply fall inside the verbatim slices, managed regions pass through untouched.
|
|
219
|
-
*
|
|
220
|
-
* `overlapsManaged` only screens the ORIGINAL matched span, before `expand` runs — in regex mode the
|
|
221
|
-
* `` $` ``/`$'` template tokens ({@link expandTemplate}) copy the document's own prefix/suffix
|
|
222
|
-
* verbatim, so a match sitting entirely outside a managed region can still expand into a splice that
|
|
223
|
-
* carries an existing managed-block marker into its result. That would leave two copies of the marker
|
|
224
|
-
* in the rewritten document with no owner able to agree on the region's extent (LORE-86's invariant,
|
|
225
|
-
* broken from the other direction). So once the full `result` is assembled, it is re-validated with
|
|
226
|
-
* {@link managedRanges} — the same locators every managed region already trusts — before being
|
|
227
|
-
* returned (LORE-162); a violation there is surfaced as a `usage` error (this replacement's pattern is
|
|
228
|
-
* unusable against this document) rather than the `validation` error `managedRanges` would normally
|
|
229
|
-
* throw for a document that was ALREADY malformed on disk.
|
|
230
|
-
*/
|
|
231
|
-
function applyReplacement(
|
|
232
|
-
text: string,
|
|
233
|
-
matcher: RegExp,
|
|
234
|
-
expand: (match: RegExpMatchArray, source: string) => string,
|
|
235
|
-
): ReplaceResult {
|
|
236
|
-
const ranges = managedRanges(text);
|
|
237
|
-
const overlapsManaged = (start: number, length: number): boolean =>
|
|
238
|
-
ranges.some((r) => start < r.end && start + length > r.start);
|
|
239
|
-
|
|
240
|
-
let result = "";
|
|
241
|
-
let last = 0;
|
|
242
|
-
let count = 0;
|
|
243
|
-
for (const match of text.matchAll(matcher)) {
|
|
244
|
-
const start = match.index ?? 0;
|
|
245
|
-
const matched = match[0];
|
|
246
|
-
if (matched.length === 0) {
|
|
247
|
-
// A zero-width pattern that slipped past the compile-time probe (rare): refuse rather than
|
|
248
|
-
// loop forever or splice between characters. Validation up front makes this practically dead.
|
|
249
|
-
throw zeroWidthError();
|
|
250
|
-
}
|
|
251
|
-
if (overlapsManaged(start, matched.length)) {
|
|
252
|
-
continue; // a match inside (or crossing into) a managed region — leave it, don't count it
|
|
253
|
-
}
|
|
254
|
-
result += text.slice(last, start) + expand(match, text);
|
|
255
|
-
last = start + matched.length;
|
|
256
|
-
count++;
|
|
257
|
-
}
|
|
258
|
-
result += text.slice(last);
|
|
259
|
-
if (count === 0) {
|
|
260
|
-
return { text, count };
|
|
261
|
-
}
|
|
262
|
-
assertNoInjectedMarker(result);
|
|
263
|
-
return { text: result, count };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Re-validate a rewritten document against {@link managedRanges} — the same structural/literal
|
|
268
|
-
* locators {@link MANAGED_REGION_LOCATORS} registers everywhere else — to catch a `` $` ``/`$'`
|
|
269
|
-
* expansion that spliced a managed-block marker into the result, duplicating (or otherwise
|
|
270
|
-
* malforming) it (LORE-162). `text` was already validated once, before the replacement loop ran, so
|
|
271
|
-
* any failure here was newly introduced by splicing `expand`'s output in, never a pre-existing
|
|
272
|
-
* condition of the input document — re-thrown as a `usage` error (this replacement is what's
|
|
273
|
-
* unusable) instead of `managedRanges`' own `validation` error (which reads as "the input document is
|
|
274
|
-
* malformed").
|
|
275
|
-
*/
|
|
276
|
-
function assertNoInjectedMarker(result: string): void {
|
|
277
|
-
try {
|
|
278
|
-
managedRanges(result);
|
|
279
|
-
} catch (cause) {
|
|
280
|
-
if (cause instanceof LoreError) {
|
|
281
|
-
throw new LoreError(
|
|
282
|
-
"usage",
|
|
283
|
-
`this replacement would leave a lore-managed block marker duplicated or malformed in the result: ${cause.message}`,
|
|
284
|
-
"rewrite the pattern/replacement so it cannot copy a managed-block marker into the output (e.g. keep $` /$' from spanning a managed region), or narrow --in to skip the affected file",
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
throw cause;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Compile a user regex as global, mapping a syntax error to a `usage` {@link LoreError} (exit 2). */
|
|
292
|
-
function compileRegex(pattern: string): RegExp {
|
|
293
|
-
try {
|
|
294
|
-
return new RegExp(pattern, "g");
|
|
295
|
-
} catch (cause) {
|
|
296
|
-
throw new LoreError(
|
|
297
|
-
"usage",
|
|
298
|
-
`invalid regex "${pattern}": ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
299
|
-
"fix the regular expression, or drop --regex to match it as a literal string",
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Reject a pattern that can match the empty string, before it is ever applied. A global zero-width
|
|
306
|
-
* match would make `replace` insert the replacement between every character (mass corruption), which
|
|
307
|
-
* the empty-`find` guard exists to prevent but a regex like `x*`/`\b` slips past. Detected by probing
|
|
308
|
-
* the matcher against the empty string and a representative {@link ZERO_WIDTH_PROBE} (word
|
|
309
|
-
* boundaries, a line end, digits, punctuation); a zero-length match in either is fatal. `matchAll`
|
|
310
|
-
* clones the regex, so probing does not disturb the matcher's later use.
|
|
311
|
-
*/
|
|
312
|
-
function assertNonEmptyMatching(matcher: RegExp): void {
|
|
313
|
-
for (const probe of ["", ZERO_WIDTH_PROBE]) {
|
|
314
|
-
for (const match of probe.matchAll(matcher)) {
|
|
315
|
-
if (match[0].length === 0) {
|
|
316
|
-
throw zeroWidthError();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** The `usage` {@link LoreError} for a pattern that can match the empty string. */
|
|
323
|
-
function zeroWidthError(): LoreError {
|
|
324
|
-
return new LoreError(
|
|
325
|
-
"usage",
|
|
326
|
-
"the pattern can match an empty string, which would replace between every character",
|
|
327
|
-
"refine the pattern so every match has length (e.g. require at least one character)",
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Expand a regex replacement `template` against one `match` mostly as `String.prototype.replace`
|
|
333
|
-
* does: `$$`→`$`, `$&`→the match, `` $` ``→the prefix, `$'`→the suffix, `$<name>`→a named group, and
|
|
334
|
-
* `$n`/`$nn`→a numbered group (greedy two digits when that group exists, else one digit plus the
|
|
335
|
-
* literal digit; an out-of-range number is left literal). This is what lets the single whole-document
|
|
336
|
-
* pass keep native substitution semantics while still skipping managed regions.
|
|
337
|
-
*
|
|
338
|
-
* One deliberate divergence: `$<name>` for a name the regex never declares is left as the literal
|
|
339
|
-
* token `$<name>`, whether the regex has no named groups at all (this matches native behavior) or has
|
|
340
|
-
* *other* named groups but not this one (native silently substitutes `""` there; this engine does not,
|
|
341
|
-
* because an unresolvable token silently deleting document text is worse than leaving it visibly
|
|
342
|
-
* unresolved — LORE-163).
|
|
343
|
-
*/
|
|
344
|
-
function expandTemplate(template: string, match: RegExpMatchArray, source: string): string {
|
|
345
|
-
const groups = match.length - 1;
|
|
346
|
-
const index = match.index ?? 0;
|
|
347
|
-
return template.replace(/\$(\$|&|`|'|<[^>]*>|\d\d?)/g, (whole, selector: string): string => {
|
|
348
|
-
if (selector === "$") {
|
|
349
|
-
return "$";
|
|
350
|
-
}
|
|
351
|
-
if (selector === "&") {
|
|
352
|
-
return match[0];
|
|
353
|
-
}
|
|
354
|
-
if (selector === "`") {
|
|
355
|
-
return source.slice(0, index);
|
|
356
|
-
}
|
|
357
|
-
if (selector === "'") {
|
|
358
|
-
return source.slice(index + match[0].length);
|
|
359
|
-
}
|
|
360
|
-
if (selector.startsWith("<")) {
|
|
361
|
-
const name = selector.slice(1, -1);
|
|
362
|
-
// Only substitute when this regex actually declares a named group of this name — whether it
|
|
363
|
-
// participated in the match or not. `match.groups` carries a key for every named group the
|
|
364
|
-
// *pattern* declares (undefined when that particular group didn't participate), so `in` tells
|
|
365
|
-
// "declared" apart from "declared but empty". An undeclared name (no named groups at all, or
|
|
366
|
-
// named groups that don't include this one) leaves the token as literal text, matching what
|
|
367
|
-
// `String.prototype.replace` does when the regex has zero named groups — and, by design, also
|
|
368
|
-
// when it has some but not this one, so an unresolvable `$<name>` is never silently deleted.
|
|
369
|
-
if (match.groups !== undefined && name in match.groups) {
|
|
370
|
-
return match.groups[name] ?? "";
|
|
371
|
-
}
|
|
372
|
-
return whole;
|
|
373
|
-
}
|
|
374
|
-
// Numeric: greedily prefer the two-digit group, then fall back to one digit + the literal digit.
|
|
375
|
-
const n = Number.parseInt(selector, 10);
|
|
376
|
-
if (n >= 1 && n <= groups) {
|
|
377
|
-
return match[n] ?? "";
|
|
378
|
-
}
|
|
379
|
-
if (selector.length === 2) {
|
|
380
|
-
const first = Number.parseInt(selector[0] as string, 10);
|
|
381
|
-
if (first >= 1 && first <= groups) {
|
|
382
|
-
return `${match[first] ?? ""}${selector[1]}`;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return whole; // an out-of-range group number is left literal, as String.replace does
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Escape a literal string for use as a `RegExp` source (every regex metacharacter neutralized). */
|
|
390
|
-
function escapeRegExp(literal: string): string {
|
|
391
|
-
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Merge overlapping/touching ranges into a sorted, non-overlapping list (ascending by start).
|
|
396
|
-
* Exported because it is the general invariant {@link managedRanges} relies on now that the marker
|
|
397
|
-
* registry holds more than one kind: two different managed blocks (e.g. a `lore:tasks` block
|
|
398
|
-
* adjacent to an `index` block) can produce ranges that touch or nest, and a clean partition for
|
|
399
|
-
* {@link applyReplacement} needs them collapsed. A nested range (fully inside another) is absorbed; a
|
|
400
|
-
* touching one (`start === prev.end`) is joined so no zero-width author gap is left between them.
|
|
401
|
-
*/
|
|
402
|
-
export function mergeRanges(ranges: TextRange[]): TextRange[] {
|
|
403
|
-
if (ranges.length <= 1) {
|
|
404
|
-
return ranges;
|
|
405
|
-
}
|
|
406
|
-
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
407
|
-
const merged: TextRange[] = [];
|
|
408
|
-
for (const range of sorted) {
|
|
409
|
-
const last = merged[merged.length - 1];
|
|
410
|
-
if (last !== undefined && range.start <= last.end) {
|
|
411
|
-
if (range.end > last.end) {
|
|
412
|
-
merged[merged.length - 1] = { start: last.start, end: range.end };
|
|
413
|
-
}
|
|
414
|
-
} else {
|
|
415
|
-
merged.push(range);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return merged;
|
|
419
|
-
}
|