@opum-ai/lore 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,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
- }
@@ -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
- }