@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,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* managed-block.ts — regenerate the `<!-- lore:tasks:begin -->…<!-- lore:tasks:end -->` managed
|
|
3
|
+
* region of a `Story`/`Spec` doc from live Backlog.md data (LORE-22, [ADR-0008]).
|
|
4
|
+
*
|
|
5
|
+
* A `Story` owns a set of Backlog tasks via its `tasks:` frontmatter list. So a reader sees live
|
|
6
|
+
* task status without leaving the doc, lore maintains a **managed region** — a small GFM table
|
|
7
|
+
* between two HTML-comment sentinels — that `lore sync` rewrites and `lore check` verifies without
|
|
8
|
+
* writing. This module is the shared pure engine behind both; the command layer (LORE-24+) reads the
|
|
9
|
+
* file bytes, calls the LORE-21 adapter's `viewTask(id)` once per linked task id to build the
|
|
10
|
+
* {@link ManagedTaskRow rows}, invokes {@link regenerateTaskBlock}, and writes (or diffs) the result.
|
|
11
|
+
*
|
|
12
|
+
* ### Why a surgical string splice, not parse→stringify (the ADR-0008 amendment)
|
|
13
|
+
*
|
|
14
|
+
* [ADR-0008] §Decision describes building the block as mdast and re-serializing the document with a
|
|
15
|
+
* frozen `remark-stringify`/`remark-gfm` config. lore, however, deliberately ships **no markdown
|
|
16
|
+
* serializer** — the only markdown dependency is `mdast-util-from-markdown` (a parser); there is no
|
|
17
|
+
* `remark-stringify`/`mdast-util-to-markdown` (ADR-0001 packaging constraint). Re-emitting the whole
|
|
18
|
+
* document through a stringifier would also reflow the author's untouched prose (list markers,
|
|
19
|
+
* emphasis, wrapping), which ADR-0008 §7 itself forbids. So this engine follows the settled lore
|
|
20
|
+
* pattern ({@link rewriteInbound}, {@link generateIndexes}): **parse only to locate**, then splice a
|
|
21
|
+
* frozen-format table STRING over the byte range strictly between the two marker nodes, copying every
|
|
22
|
+
* other byte — frontmatter, editor modeline, and prose — verbatim. (ADR-0008's mdast prescription is
|
|
23
|
+
* thus superseded on the serializer mechanism only; every guarantee it makes — structural location,
|
|
24
|
+
* marker validation, byte-identity, bounded blast radius — is preserved here.)
|
|
25
|
+
*
|
|
26
|
+
* ### Structural location (what mdast buys over {@link generateIndexes}'s literal splice)
|
|
27
|
+
*
|
|
28
|
+
* Unlike {@link locateManagedBlock}'s `indexOf` scan, the markers are found **structurally**: the
|
|
29
|
+
* document is parsed with `fromMarkdown` and only the direct children of the root that are `html`
|
|
30
|
+
* nodes matching the canonical sentinel are candidates (ADR-0008 §1). A sentinel that appears inside
|
|
31
|
+
* a fenced code block, a blockquote, or any other container is part of *that* node — never a
|
|
32
|
+
* top-level `html` node — so it is never mistaken for a real marker. This is exactly the ambiguity
|
|
33
|
+
* `generateIndexes`'s string splice documents as its one limitation.
|
|
34
|
+
*
|
|
35
|
+
* ### Guarantees
|
|
36
|
+
*
|
|
37
|
+
* - **Byte-identical on no change (AC#1).** Location is structural, row order is the caller's
|
|
38
|
+
* (deterministic) order, links come from the canonical `filePathRelative`, and the table is a
|
|
39
|
+
* frozen string — so regenerating an already-current block reproduces the exact same bytes, and a
|
|
40
|
+
* no-op `lore sync` touches zero bytes (the drift gate stays trustworthy). Splicing is a fixpoint.
|
|
41
|
+
* - **Correct, portable links (AC#2).** Each row link is computed from the task's `filePathRelative`
|
|
42
|
+
* (the JSON's canonical repo-relative path) via {@link normalizeLink} — never reconstructed from
|
|
43
|
+
* the display id, which is upper-cased while the filename is lower-cased and carries the title with
|
|
44
|
+
* spaces (ADR-0008 §5). A linked id whose file is absent on the current branch is tolerated: its
|
|
45
|
+
* row renders the id as plain text rather than a broken link, and never errors.
|
|
46
|
+
* - **Boundary safety.** Only the bytes between the markers change; the markers themselves and every
|
|
47
|
+
* byte before `begin`/after `end` pass through untouched. Malformed markers are a hard
|
|
48
|
+
* {@link LoreError} (`validation`, exit 6) — lore refuses to guess and never writes a partial block.
|
|
49
|
+
*
|
|
50
|
+
* Per the core contract (lore-design §2.1) this module is pure: a string (plus rows) in, a string or
|
|
51
|
+
* a typed {@link LoreError} out — no filesystem, no spawn, no clock, no `process.exit`. Input is
|
|
52
|
+
* expected to be LF-normalized (as every lore read path normalizes it — concept.ts `normalizeInput`).
|
|
53
|
+
*
|
|
54
|
+
* ### The generic sibling ({@link upsertManagedBlock})
|
|
55
|
+
*
|
|
56
|
+
* {@link regenerateTaskBlock} owns one fixed region and *requires* an author-placed marker pair.
|
|
57
|
+
* {@link upsertManagedBlock} is the **insert-or-update** engine for lore-owned blocks that lore must
|
|
58
|
+
* be able to add to a file that has never carried them — `lore agents`'s `CLAUDE.md` nudge. It shares
|
|
59
|
+
* this module's structural, whitespace-tolerant location and fail-loud malformed-marker validation,
|
|
60
|
+
* differing only in that a total absence of markers is an insert, not an error.
|
|
61
|
+
*
|
|
62
|
+
* [ADR-0008]: ../../docs/adr/0008-managed-block-remark-ast.md
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { Nodes, Root } from "mdast";
|
|
66
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
67
|
+
import { LoreError, singleLine } from "../errors";
|
|
68
|
+
import { normalizeLink } from "./links";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The HTML-comment sentinels bounding the managed task region (lore-design §6.2). HTML comments are
|
|
72
|
+
* invisible in every target renderer (GitHub, Obsidian, MkDocs, Docusaurus), and the exact
|
|
73
|
+
* `lore:tasks:*` tokens never occur in authored prose. Exported so `lore sync`/`lore check` and any
|
|
74
|
+
* scaffolder emit and match the one canonical spelling. The parallel `lore:index:*` block that
|
|
75
|
+
* {@link generateIndexes} owns uses the same shape.
|
|
76
|
+
*/
|
|
77
|
+
export const TASK_BLOCK_BEGIN = "<!-- lore:tasks:begin -->";
|
|
78
|
+
export const TASK_BLOCK_END = "<!-- lore:tasks:end -->";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Match a top-level `html` node's value against a marker sentinel, tolerant of whitespace both inside
|
|
82
|
+
* the comment (`<!--lore:tasks:begin-->` and the canonical spaced form both match) and *around* it —
|
|
83
|
+
* the node value is trimmed before matching, because `mdast-util-from-markdown` keeps a marker line's
|
|
84
|
+
* leading indent (1–3 spaces) and trailing spaces in the `html` node's `value`, and an invisible
|
|
85
|
+
* trailing space must not make a visibly-correct marker read as "missing". The node must still be
|
|
86
|
+
* *exactly* the marker comment; a node carrying other text (or both markers on one line) matches
|
|
87
|
+
* neither, and is surfaced by {@link findMarkers}'s validation rather than silently paired.
|
|
88
|
+
*/
|
|
89
|
+
const BEGIN_MARKER = /^<!--\s*lore:tasks:begin\s*-->$/;
|
|
90
|
+
const END_MARKER = /^<!--\s*lore:tasks:end\s*-->$/;
|
|
91
|
+
|
|
92
|
+
/** The frozen table header row (single-space cell padding, ADR-0008 §Decision item 4). */
|
|
93
|
+
const TABLE_HEADER = "| Task | Title | Status |";
|
|
94
|
+
/** The frozen GFM delimiter row (compact, three dashes per column). */
|
|
95
|
+
const TABLE_DELIMITER = "|---|---|---|";
|
|
96
|
+
/** The frozen paragraph emitted in place of the table when a doc links no tasks (ADR-0008 §3). */
|
|
97
|
+
const NO_TASKS_PARAGRAPH = "_No linked tasks._";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One task's row data, already resolved by the caller from a `backlog task view <id> --json` read
|
|
101
|
+
* (the LORE-21 adapter's {@link BacklogTaskDetail}). The engine renders these verbatim in the given
|
|
102
|
+
* order — it does not sort, fetch, or resolve; the caller supplies the ADR-0008 §4 order (the doc's
|
|
103
|
+
* `tasks:` frontmatter list, with any out-of-band tasks appended in `task-N` numeric order).
|
|
104
|
+
*/
|
|
105
|
+
export interface ManagedTaskRow {
|
|
106
|
+
/** The display-cased identity (`"LORE-42"`), shown as the row's link text verbatim (never a filename source). */
|
|
107
|
+
readonly id: string;
|
|
108
|
+
/** The task title, taken verbatim from the JSON (cell-normalized on render, never reflowed). */
|
|
109
|
+
readonly title: string;
|
|
110
|
+
/** The raw configured status string (`"Done"`, `"In Progress"`), verbatim from the JSON. */
|
|
111
|
+
readonly status: string;
|
|
112
|
+
/**
|
|
113
|
+
* The task file's **repo-relative** path (`filePathRelative`, e.g. `backlog/tasks/lore-42 - x.md`),
|
|
114
|
+
* or `null` when the task is not yet written to disk / absent on the current branch. A non-null
|
|
115
|
+
* value is the link target (ADR-0008 §5); `null` is tolerated — the row renders the id as plain text.
|
|
116
|
+
*/
|
|
117
|
+
readonly file: string | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Options for {@link regenerateTaskBlock}. */
|
|
121
|
+
export interface RegenerateTaskBlockOptions {
|
|
122
|
+
/**
|
|
123
|
+
* The **repo-relative** path of the doc being regenerated (`docs/stories/bulk-archive.md`). It
|
|
124
|
+
* anchors each row link's relative computation against the task's repo-relative `filePathRelative`,
|
|
125
|
+
* so a `docs/`-rooted story links a `backlog/`-rooted task as `../../backlog/tasks/…` (both operands
|
|
126
|
+
* must share the repo-relative coordinate space — {@link normalizeLink}'s precondition).
|
|
127
|
+
*/
|
|
128
|
+
readonly docPath: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A located marker: the mdast node and its `[start, end)` source byte offsets. */
|
|
132
|
+
interface Marker {
|
|
133
|
+
readonly start: number;
|
|
134
|
+
readonly end: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A top-level `html` node whose (trimmed) value contains marker-like text but does not *exactly*
|
|
139
|
+
* match either the begin or end sentinel — most commonly a begin/end pair that CommonMark's HTML-block
|
|
140
|
+
* rules collapse onto a single line with no separating newline (`<!-- label:begin --><!-- label:end
|
|
141
|
+
* -->`), which `mdast-util-from-markdown` parses as ONE `html` node whose value equals neither anchored
|
|
142
|
+
* pattern (LORE-156). Surfaced distinctly from "no markers at all" so this detected-but-malformed case
|
|
143
|
+
* is never read as a genuinely absent block.
|
|
144
|
+
*/
|
|
145
|
+
interface MalformedMarkerNode {
|
|
146
|
+
readonly span: Marker;
|
|
147
|
+
readonly value: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Strip the `^`/`$` anchors from an exact marker-matching `RegExp` (as built for {@link BEGIN_MARKER}/
|
|
152
|
+
* {@link END_MARKER} or the per-label patterns in {@link locateLabeledMarkers}), returning a pattern
|
|
153
|
+
* that matches the same marker text anywhere within a string rather than requiring the whole (trimmed)
|
|
154
|
+
* string to be exactly the marker. Used by {@link collectMarkerSpans} to recognize marker text that is
|
|
155
|
+
* present but not a clean, standalone sentinel (LORE-156).
|
|
156
|
+
*/
|
|
157
|
+
function loosenMarkerPattern(anchored: RegExp): RegExp {
|
|
158
|
+
return new RegExp(anchored.source.replace(/^\^/, "").replace(/\$$/, ""));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Regenerate the managed task region of `content` from `rows`, returning the new full file bytes.
|
|
163
|
+
*
|
|
164
|
+
* The document is parsed only to locate the two top-level `html` marker nodes; the frozen table is
|
|
165
|
+
* built as a string and spliced over the bytes strictly between them, so frontmatter, editor
|
|
166
|
+
* modeline, and every line of prose outside the markers are preserved byte-for-byte. Regenerating an
|
|
167
|
+
* already-current block reproduces identical bytes (AC#1), so the command layer can treat "no byte
|
|
168
|
+
* difference" as a genuine no-op.
|
|
169
|
+
*
|
|
170
|
+
* @param content the doc's full raw bytes (LF-normalized), including frontmatter and the markers.
|
|
171
|
+
* @param rows the linked tasks, in the caller's ADR-0008 §4 render order (may be empty).
|
|
172
|
+
* @param options {@link RegenerateTaskBlockOptions.docPath} — the doc's repo-relative path for links.
|
|
173
|
+
* @returns the new full file bytes with the region replaced.
|
|
174
|
+
* @throws LoreError `validation` (exit 6) when the markers are missing, duplicated, unbalanced, or
|
|
175
|
+
* crossed — lore refuses to guess and never writes a partial or corrupted block (ADR-0008 §2).
|
|
176
|
+
*/
|
|
177
|
+
export function regenerateTaskBlock(
|
|
178
|
+
content: string,
|
|
179
|
+
rows: readonly ManagedTaskRow[],
|
|
180
|
+
options: RegenerateTaskBlockOptions,
|
|
181
|
+
): string {
|
|
182
|
+
const { begin, end } = findMarkers(content);
|
|
183
|
+
const table = buildTable(rows, options.docPath);
|
|
184
|
+
// Replace only the bytes between the markers with `\n{table}\n`; the begin node ends just after its
|
|
185
|
+
// `-->` (its trailing newline is not part of the node), so this reproduces
|
|
186
|
+
// `<!-- …begin -->\n{table}\n<!-- …end -->` — a fixpoint over already-generated bytes.
|
|
187
|
+
return `${content.slice(0, begin.end)}\n${table}\n${content.slice(end.start)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Scan `content` for top-level `html` marker nodes, returning the source spans of those matching
|
|
192
|
+
* `beginMarker` / `endMarker`, plus any top-level `html` nodes that carry marker-like text without
|
|
193
|
+
* being a clean, standalone sentinel (`malformed`). The document is parsed with `fromMarkdown` and
|
|
194
|
+
* only the root's **direct** `html` children are candidates, so a sentinel nested in a code fence or
|
|
195
|
+
* blockquote is never a marker; the node value is trimmed before matching because mdast keeps a marker
|
|
196
|
+
* line's leading indent and trailing spaces in the `html` node's `value`. The one shared primitive
|
|
197
|
+
* behind both {@link findMarkers} (the fixed `lore:tasks` region) and {@link locateLabeledMarkers} (any
|
|
198
|
+
* labeled block), so the two never drift on how a marker is located — each applies its own validation
|
|
199
|
+
* to the spans this returns.
|
|
200
|
+
*
|
|
201
|
+
* `malformed` exists for LORE-156: when a begin and end marker sit on one line with no separating
|
|
202
|
+
* newline (`<!-- label:begin --><!-- label:end -->`), CommonMark's HTML-block rules make
|
|
203
|
+
* `fromMarkdown` collapse them into a single `html` node whose trimmed value equals neither anchored
|
|
204
|
+
* pattern — so without this check the node is silently skipped and the pair reads as "0 begins, 0
|
|
205
|
+
* ends", indistinguishable from a genuinely marker-free document. A node lands in `malformed` when its
|
|
206
|
+
* trimmed value doesn't exactly match `beginMarker`/`endMarker` but does contain one of them as a
|
|
207
|
+
* substring (a non-anchored, "loose" version of the same pattern) — callers surface this distinctly
|
|
208
|
+
* from a true absence so a detected-but-malformed pair is never mistaken for "no block yet".
|
|
209
|
+
*/
|
|
210
|
+
function collectMarkerSpans(
|
|
211
|
+
content: string,
|
|
212
|
+
beginMarker: RegExp,
|
|
213
|
+
endMarker: RegExp,
|
|
214
|
+
): { begins: Marker[]; ends: Marker[]; malformed: MalformedMarkerNode[] } {
|
|
215
|
+
const tree: Root = fromMarkdown(content);
|
|
216
|
+
const begins: Marker[] = [];
|
|
217
|
+
const ends: Marker[] = [];
|
|
218
|
+
const malformed: MalformedMarkerNode[] = [];
|
|
219
|
+
const looseBegin = loosenMarkerPattern(beginMarker);
|
|
220
|
+
const looseEnd = loosenMarkerPattern(endMarker);
|
|
221
|
+
for (const node of tree.children) {
|
|
222
|
+
if (node.type !== "html") {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const span = offsetsOf(node);
|
|
226
|
+
if (span === null) {
|
|
227
|
+
continue; // defensive: a parsed html node always carries offsets
|
|
228
|
+
}
|
|
229
|
+
const value = node.value.trim();
|
|
230
|
+
if (beginMarker.test(value)) {
|
|
231
|
+
begins.push(span);
|
|
232
|
+
} else if (endMarker.test(value)) {
|
|
233
|
+
ends.push(span);
|
|
234
|
+
} else if (looseBegin.test(value) || looseEnd.test(value)) {
|
|
235
|
+
malformed.push({ span, value });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { begins, ends, malformed };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Locate the single balanced pair of top-level marker nodes in `content`, validating ADR-0008 §2.
|
|
243
|
+
* The document is parsed with `fromMarkdown` and only the root's **direct** `html` children are
|
|
244
|
+
* candidates, so a sentinel nested in a code fence or blockquote is never a marker.
|
|
245
|
+
*
|
|
246
|
+
* @throws LoreError `validation` when there is not exactly one begin and one end marker at top level,
|
|
247
|
+
* or the end precedes the begin (missing / duplicated / unbalanced / crossed).
|
|
248
|
+
*/
|
|
249
|
+
function findMarkers(content: string): { begin: Marker; end: Marker } {
|
|
250
|
+
const { begins, ends, malformed } = collectMarkerSpans(content, BEGIN_MARKER, END_MARKER);
|
|
251
|
+
|
|
252
|
+
if (malformed.length > 0) {
|
|
253
|
+
throw markerError(
|
|
254
|
+
`found marker text that is not a clean, standalone \`${TASK_BLOCK_BEGIN}\`/\`${TASK_BLOCK_END}\` sentinel on its own line (commonly a begin/end pair placed on the same line with no separating newline): \`${malformed[0]?.value ?? ""}\``,
|
|
255
|
+
{ begins: begins.length, ends: ends.length, malformed: malformed.length },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (begins.length === 0 || ends.length === 0) {
|
|
259
|
+
throw markerError(
|
|
260
|
+
`the managed task region is missing (need one \`${TASK_BLOCK_BEGIN}\` and one \`${TASK_BLOCK_END}\` at the document top level)`,
|
|
261
|
+
{ begins: begins.length, ends: ends.length },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (begins.length > 1 || ends.length > 1) {
|
|
265
|
+
throw markerError(
|
|
266
|
+
`the managed task region is duplicated (found ${begins.length} begin and ${ends.length} end markers; expected exactly one of each)`,
|
|
267
|
+
{ begins: begins.length, ends: ends.length },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const begin = begins[0];
|
|
271
|
+
const end = ends[0];
|
|
272
|
+
if (begin === undefined || end === undefined) {
|
|
273
|
+
throw markerError("the managed task region is missing", { begins: begins.length, ends: ends.length }); // unreachable given the counts above; narrows the element access without a non-null assertion
|
|
274
|
+
}
|
|
275
|
+
if (end.start < begin.end) {
|
|
276
|
+
throw markerError("the managed task markers are crossed (the end marker precedes the begin marker)", {
|
|
277
|
+
begin: begin.start,
|
|
278
|
+
end: end.start,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return { begin, end };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Build the fail-loud "malformed managed-block markers" error for the fixed `lore:tasks` region
|
|
286
|
+
* (ADR-0008 §2 → `validation`, exit 6). A thin specialization of {@link labeledMarkerError} so the
|
|
287
|
+
* tasks-block and generic-block diagnostics share one wording/shape and cannot drift.
|
|
288
|
+
*/
|
|
289
|
+
function markerError(reason: string, input: Record<string, unknown>): LoreError {
|
|
290
|
+
return labeledMarkerError("lore:tasks", reason, input);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The `lore:tasks` region's full `[start, end)` byte span (markers included), located
|
|
295
|
+
* **structurally** — the same {@link collectMarkerSpans} mdast scan {@link findMarkers} uses — or
|
|
296
|
+
* `null` when the document carries no `lore:tasks` markers at all (most docs; only a linked
|
|
297
|
+
* `Story`/`Spec` do). Exported for `core/replace.ts`'s managed-region registry, which must protect
|
|
298
|
+
* exactly the span {@link regenerateTaskBlock} would rewrite.
|
|
299
|
+
*
|
|
300
|
+
* Deliberately **not** `indexes.ts`'s `locateManagedBlock` literal `indexOf` scan: that scan is safe
|
|
301
|
+
* for `lore:index` only because the marker text never occurs outside a real index block in practice,
|
|
302
|
+
* but `lore:tasks:begin`/`:end` are routinely *cited* in this project's own prose and fenced code
|
|
303
|
+
* examples documenting the format — a literal scan misfires on those (a false "duplicated"/"unmatched"
|
|
304
|
+
* validation error, or worse, silently treating a prose citation as a real block) (LORE-73). The
|
|
305
|
+
* structural, top-level-`html`-node-only location this module already uses for `lore sync`/`lore
|
|
306
|
+
* check` has no such ambiguity: a sentinel inside a code fence or blockquote is never a marker.
|
|
307
|
+
*
|
|
308
|
+
* @throws LoreError `validation` when markers are present but malformed (duplicated, unmatched, or
|
|
309
|
+
* crossed) — the same fail-loud contract {@link findMarkers} enforces. Total absence is `null`, not
|
|
310
|
+
* an error: a file with no `lore:tasks` block has nothing to protect.
|
|
311
|
+
*/
|
|
312
|
+
export function locateTaskBlock(content: string): { start: number; end: number } | null {
|
|
313
|
+
const { begins, ends, malformed } = collectMarkerSpans(content, BEGIN_MARKER, END_MARKER);
|
|
314
|
+
if (begins.length === 0 && ends.length === 0 && malformed.length === 0) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const { begin, end } = findMarkers(content);
|
|
318
|
+
return { start: begin.start, end: end.end };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Build the frozen table string (no leading/trailing newline) for `rows`, or the "no linked tasks"
|
|
323
|
+
* paragraph when there are none. The format is fixed byte-for-byte so identical input yields identical
|
|
324
|
+
* output: a header row, a compact GFM delimiter, and one `| [id](link) | title | status |` row each.
|
|
325
|
+
*/
|
|
326
|
+
function buildTable(rows: readonly ManagedTaskRow[], docPath: string): string {
|
|
327
|
+
if (rows.length === 0) {
|
|
328
|
+
return NO_TASKS_PARAGRAPH;
|
|
329
|
+
}
|
|
330
|
+
const lines = [TABLE_HEADER, TABLE_DELIMITER];
|
|
331
|
+
for (const row of rows) {
|
|
332
|
+
lines.push(renderRow(row, docPath));
|
|
333
|
+
}
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render one task as a table row. The id becomes the link text; its target is the canonical relative
|
|
339
|
+
* link to the task file ({@link normalizeLink} over the repo-relative `docPath` and `file`). When the
|
|
340
|
+
* file is absent — `null`, or the empty string a not-yet-written task can carry — the id is rendered
|
|
341
|
+
* as plain text: the task still appears, marked, rather than linking to a broken `..md` target or
|
|
342
|
+
* erroring (ADR-0008 §5 tolerance).
|
|
343
|
+
*/
|
|
344
|
+
function renderRow(row: ManagedTaskRow, docPath: string): string {
|
|
345
|
+
const label = escapeLinkText(row.id);
|
|
346
|
+
const taskCell = row.file === null || row.file === "" ? label : `[${label}](${normalizeLink(docPath, row.file)})`;
|
|
347
|
+
return `| ${taskCell} | ${cell(row.title)} | ${cell(row.status)} |`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Normalize one JSON value into safe GFM **table-cell** text. Four deterministic defenses keep the
|
|
352
|
+
* table well-formed and byte-stable regardless of the source string:
|
|
353
|
+
*
|
|
354
|
+
* - **single-line** ({@link singleLine}) — a title carrying a newline would otherwise split the row;
|
|
355
|
+
* - **escape `\`** — done *first*, before the `|` escape below. A pre-existing literal backslash
|
|
356
|
+
* immediately before a pipe (e.g. a title `x\|y`) would otherwise combine with the pipe-escape's
|
|
357
|
+
* inserted backslash into CommonMark's `\\` (an escaped backslash) followed by a live, cell-splitting
|
|
358
|
+
* `|` — silently adding a column. Doubling backslashes first, before any escaping backslashes are
|
|
359
|
+
* introduced, means the later steps' own backslashes are never mistaken for source content and never
|
|
360
|
+
* re-escaped.
|
|
361
|
+
* - **escape `|`** — an unescaped pipe would open a spurious extra column;
|
|
362
|
+
* - **neutralize the comment sentinels** (`<!--`/`-->` → entities) — a value literally containing an
|
|
363
|
+
* end marker cannot then be mistaken for the region boundary (defense-in-depth beyond the structural
|
|
364
|
+
* location; matches {@link generateIndexes}'s `linkText`). The entities render identically.
|
|
365
|
+
*/
|
|
366
|
+
function cell(text: string): string {
|
|
367
|
+
return singleLine(text)
|
|
368
|
+
.replace(/\\/g, "\\\\")
|
|
369
|
+
.replace(/\|/g, "\\|")
|
|
370
|
+
.replace(/<!--/g, "<!--")
|
|
371
|
+
.replace(/-->/g, "-->");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Cell text for the id used as link **text**: additionally escape `[`/`]` so they cannot break the `[text](…)` syntax. */
|
|
375
|
+
function escapeLinkText(id: string): string {
|
|
376
|
+
return cell(id).replace(/[[\]]/g, (c) => `\\${c}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A node's `[start, end)` source byte offsets, or `null` when position info is absent (defensive). */
|
|
380
|
+
function offsetsOf(node: Nodes): Marker | null {
|
|
381
|
+
const position = node.position;
|
|
382
|
+
if (position?.start.offset === undefined || position.end.offset === undefined) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
return { start: position.start.offset, end: position.end.offset };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Insert or refresh a generic lore-managed block delimited by `<!-- {label}:begin -->` …
|
|
390
|
+
* `<!-- {label}:end -->`, returning the new full file bytes.
|
|
391
|
+
*
|
|
392
|
+
* This is the **insert-or-update** sibling of {@link regenerateTaskBlock}. That engine owns the
|
|
393
|
+
* fixed `lore:tasks` region and deliberately *requires* an author-placed marker pair (a missing
|
|
394
|
+
* pair is a hard error), because a Story's task block only ever regenerates in a doc that already
|
|
395
|
+
* declares it. `lore agents`'s `CLAUDE.md` nudge is the opposite shape: lore must be able to add
|
|
396
|
+
* the block to a file that has never seen it. So this function tolerates a total absence of markers
|
|
397
|
+
* (it appends the block) while keeping every other guarantee of {@link regenerateTaskBlock} —
|
|
398
|
+
* structural, whitespace-tolerant location and fail-loud validation of a malformed pair.
|
|
399
|
+
*
|
|
400
|
+
* `label` names the region (`"lore:agents"`); `body` is the pre-rendered inner content, with no
|
|
401
|
+
* surrounding newlines (the engine adds exactly one on each side).
|
|
402
|
+
*
|
|
403
|
+
* - **No markers present** → append the block after the file's existing content, which is preserved
|
|
404
|
+
* **byte-for-byte** (including an unrelated managed block like Backlog.md's, and any trailing
|
|
405
|
+
* whitespace); only the separation needed to guarantee a blank line before the block is added. An
|
|
406
|
+
* empty or whitespace-only file yields the block alone. If the existing content ends inside an
|
|
407
|
+
* unterminated code fence or `<!--` comment — which would swallow the appended markers so they are
|
|
408
|
+
* not at the top level — this is a fail-loud `validation` error rather than a silent, duplicating
|
|
409
|
+
* append (a later run, finding no top-level markers, would otherwise append a *second* block).
|
|
410
|
+
* - **Exactly one balanced pair** → splice `\n{body}\n` between the markers, copying every other
|
|
411
|
+
* byte. The insert and update forms converge on the same canonical bytes, so regenerating an
|
|
412
|
+
* already-current block reproduces byte-identical output (idempotent) — the command layer can
|
|
413
|
+
* treat "no byte difference" as a genuine no-op. The result is re-located the same way the insert
|
|
414
|
+
* form is: a `body` that itself contains marker-like text, or disrupts top-level parsing (e.g. an
|
|
415
|
+
* unterminated code fence), is a fail-loud `validation` error rather than silently corrupted content.
|
|
416
|
+
* - **Malformed** (a lone begin/end, duplicated markers, or a crossed pair) → a `validation`
|
|
417
|
+
* {@link LoreError} (exit 6); lore refuses to guess and never writes a partial block.
|
|
418
|
+
*
|
|
419
|
+
* Input is expected LF-normalized (the caller normalizes on read, as every lore read path does).
|
|
420
|
+
*/
|
|
421
|
+
export function upsertManagedBlock(content: string, options: { label: string; body: string }): string {
|
|
422
|
+
const { label, body } = options;
|
|
423
|
+
const block = `<!-- ${label}:begin -->\n${body}\n<!-- ${label}:end -->`;
|
|
424
|
+
const located = locateLabeledMarkers(content, label);
|
|
425
|
+
if (located !== null) {
|
|
426
|
+
// Update: replace only the bytes strictly between the markers with `\n{body}\n` — a fixpoint over
|
|
427
|
+
// already-current bytes (the begin node ends just after its `-->`, matching the insert form below).
|
|
428
|
+
const updated = `${content.slice(0, located.begin.end)}\n${body}\n${content.slice(located.end.start)}`;
|
|
429
|
+
// The splice must still parse as a single clean top-level marker pair. If `body` itself contains
|
|
430
|
+
// marker-like text, or opens an unterminated code fence/comment that swallows a marker, the
|
|
431
|
+
// structure breaks — re-locate in the result and fail loud instead of silently returning corrupted
|
|
432
|
+
// content (mirrors the insert branch's post-condition check below).
|
|
433
|
+
if (locateLabeledMarkers(updated, label) === null) {
|
|
434
|
+
throw labeledMarkerError(
|
|
435
|
+
label,
|
|
436
|
+
"the updated body disrupts the document's top-level marker structure, so the block can no longer be located",
|
|
437
|
+
{ label },
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return updated;
|
|
441
|
+
}
|
|
442
|
+
// Insert: append the block, preserving `content` verbatim (only the blank-line separation is added).
|
|
443
|
+
const inserted = appendBlock(content, block);
|
|
444
|
+
// The append must land at the document top level. If `content` ends inside an unterminated code
|
|
445
|
+
// fence or `<!--` comment, the appended markers are parsed *inside* that construct and are not
|
|
446
|
+
// top-level nodes — a re-run would find none and append again, multiplying the block. Detect that
|
|
447
|
+
// by re-locating in the result and fail loud instead of silently duplicating.
|
|
448
|
+
if (locateLabeledMarkers(inserted, label) === null) {
|
|
449
|
+
throw labeledMarkerError(
|
|
450
|
+
label,
|
|
451
|
+
"the document ends inside an unterminated code fence or `<!--` comment, so the block cannot be appended at the top level",
|
|
452
|
+
{ label },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return inserted;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Append `block` after `content`, preserving `content` byte-for-byte and adding only the separation
|
|
460
|
+
* needed to guarantee a blank line before the block. An empty or whitespace-only `content` yields the
|
|
461
|
+
* block alone (no leading blank lines on an otherwise-empty file).
|
|
462
|
+
*/
|
|
463
|
+
function appendBlock(content: string, block: string): string {
|
|
464
|
+
if (!/\S/.test(content)) {
|
|
465
|
+
return `${block}\n`;
|
|
466
|
+
}
|
|
467
|
+
const separator = content.endsWith("\n\n") ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
468
|
+
return `${content}${separator}${block}\n`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Escape a literal for safe embedding in a `RegExp`. The label is lore-internal, but the matcher stays robust. */
|
|
472
|
+
function escapeRegExp(literal: string): string {
|
|
473
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Locate the balanced `<!-- {label}:begin -->` / `<!-- {label}:end -->` marker pair in `content`, or
|
|
478
|
+
* `null` when the file carries neither marker (the insert case {@link upsertManagedBlock} needs).
|
|
479
|
+
* Shares {@link collectMarkerSpans} with {@link findMarkers} (so marker *location* never drifts
|
|
480
|
+
* between the two); the one behavioral difference is that a *total* absence returns `null` instead of
|
|
481
|
+
* throwing — a marker pair present yet malformed (a lone begin/end, duplicated, crossed, or collapsed
|
|
482
|
+
* onto one line with no separating newline — LORE-156) is still a fail-loud `validation` error, never
|
|
483
|
+
* read as "no block yet" (which would make {@link upsertManagedBlock} append a second, duplicate block
|
|
484
|
+
* alongside the untouched malformed pair).
|
|
485
|
+
*
|
|
486
|
+
* @throws LoreError `validation` when markers are present but malformed.
|
|
487
|
+
*/
|
|
488
|
+
function locateLabeledMarkers(content: string, label: string): { begin: Marker; end: Marker } | null {
|
|
489
|
+
const beginMarker = new RegExp(`^<!--\\s*${escapeRegExp(label)}:begin\\s*-->$`);
|
|
490
|
+
const endMarker = new RegExp(`^<!--\\s*${escapeRegExp(label)}:end\\s*-->$`);
|
|
491
|
+
const { begins, ends, malformed } = collectMarkerSpans(content, beginMarker, endMarker);
|
|
492
|
+
|
|
493
|
+
if (malformed.length > 0) {
|
|
494
|
+
// Marker text is present but not a clean, standalone sentinel — most commonly a begin/end pair
|
|
495
|
+
// mdast collapsed onto one line (LORE-156). This must never be read as "no block yet": returning
|
|
496
|
+
// null here would make the caller append a fresh block after the untouched malformed pair,
|
|
497
|
+
// silently duplicating it.
|
|
498
|
+
throw labeledMarkerError(
|
|
499
|
+
label,
|
|
500
|
+
`found marker text that is not a clean, standalone \`<!-- ${label}:begin -->\`/\`<!-- ${label}:end -->\` sentinel on its own line (commonly a begin/end pair placed on the same line with no separating newline): \`${malformed[0]?.value ?? ""}\``,
|
|
501
|
+
{ malformed: malformed.length },
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (begins.length === 0 && ends.length === 0) {
|
|
505
|
+
return null; // no block yet — the caller inserts one
|
|
506
|
+
}
|
|
507
|
+
if (begins.length !== 1 || ends.length !== 1) {
|
|
508
|
+
throw labeledMarkerError(
|
|
509
|
+
label,
|
|
510
|
+
`expected exactly one \`<!-- ${label}:begin -->\` and one \`<!-- ${label}:end -->\`, found ${begins.length} begin and ${ends.length} end`,
|
|
511
|
+
{ begins: begins.length, ends: ends.length },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const begin = begins[0];
|
|
515
|
+
const end = ends[0];
|
|
516
|
+
if (begin === undefined || end === undefined) {
|
|
517
|
+
return null; // unreachable given the counts above; narrows the element access without a non-null assertion
|
|
518
|
+
}
|
|
519
|
+
if (end.start < begin.end) {
|
|
520
|
+
throw labeledMarkerError(label, "the markers are crossed (the end marker precedes the begin marker)", {
|
|
521
|
+
begin: begin.start,
|
|
522
|
+
end: end.start,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return { begin, end };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Build the fail-loud "malformed managed-block markers" error for a labeled block (`validation`,
|
|
530
|
+
* exit 6). The one builder behind both the generic and `lore:tasks`-specific ({@link markerError})
|
|
531
|
+
* diagnostics, so their wording and shape stay in lockstep.
|
|
532
|
+
*/
|
|
533
|
+
function labeledMarkerError(label: string, reason: string, input: Record<string, unknown>): LoreError {
|
|
534
|
+
return new LoreError(
|
|
535
|
+
"validation",
|
|
536
|
+
`cannot regenerate the \`${label}\` block: ${reason}`,
|
|
537
|
+
`place exactly one \`<!-- ${label}:begin -->\` and one \`<!-- ${label}:end -->\` on their own lines, in order, at the top level of the doc`,
|
|
538
|
+
input,
|
|
539
|
+
);
|
|
540
|
+
}
|