@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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph.ts — shape the bundle graph into an exportable model (and DOT text).
|
|
3
|
+
*
|
|
4
|
+
* `lore graph` surfaces the cross-link graph for orientation: concepts as nodes,
|
|
5
|
+
* OKF cross-links and frontmatter refs as edges, each carrying a chars/4 token
|
|
6
|
+
* estimate (cli-surface §graph). This module is the pure shaping layer between
|
|
7
|
+
* the in-memory {@link BundleGraph} and what the command emits — it turns the
|
|
8
|
+
* graph (optionally narrowed to a {@link subgraph} id-set) into the serializable
|
|
9
|
+
* {@link GraphExport} the `--json` envelope carries, and renders that same model
|
|
10
|
+
* as Graphviz {@link toDot} text. It reads the graph and returns data; the
|
|
11
|
+
* command owns I/O and flag parsing (design §2.1).
|
|
12
|
+
*
|
|
13
|
+
* The token estimates are the bundle's own {@link BundleGraph.tokenEstimate}
|
|
14
|
+
* (memoized, chars/4 over canonical serialized bytes — *not* a real tokenizer);
|
|
15
|
+
* this layer only sums and presents them. The total is summed over the
|
|
16
|
+
* **included** nodes, so a subgraph reports the budget of exactly what it shows.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type BundleGraph, type EdgeKind, frontmatterScalar } from "./bundle";
|
|
20
|
+
import { compareCodeUnits } from "./order";
|
|
21
|
+
import type { WorkspaceRecordProvenance, WorkspaceResultScope } from "./workspace-contract";
|
|
22
|
+
import type { WorkspaceProjectedLink } from "./workspace-projection";
|
|
23
|
+
|
|
24
|
+
/** One concept in the exported graph. */
|
|
25
|
+
export interface GraphNode {
|
|
26
|
+
/** The concept id (bundle-root-relative, e.g. `adr/0010-x`). */
|
|
27
|
+
readonly id: string;
|
|
28
|
+
/** The concept's resolved `type` (mirrors `frontmatter.type`). */
|
|
29
|
+
readonly type: string;
|
|
30
|
+
/**
|
|
31
|
+
* The concept's optional `title` frontmatter, via {@link frontmatterScalar}: a
|
|
32
|
+
* finite number or boolean value is coerced to its string form, and the field
|
|
33
|
+
* is omitted only for a missing/empty/whitespace/non-scalar value.
|
|
34
|
+
*/
|
|
35
|
+
readonly title?: string;
|
|
36
|
+
/** This concept's chars/4 token estimate over its canonical serialized bytes. */
|
|
37
|
+
readonly tokenEstimate: number;
|
|
38
|
+
/** Complete locator-free provenance in explicit workspace mode. */
|
|
39
|
+
readonly provenance?: WorkspaceRecordProvenance;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One directed reference in the exported graph (mirrors a {@link Edge}, with `dangling` made explicit). */
|
|
43
|
+
export interface GraphEdge {
|
|
44
|
+
/** The source concept id. */
|
|
45
|
+
readonly from: string;
|
|
46
|
+
/** The resolved target concept id, or `null` when the reference dangles. */
|
|
47
|
+
readonly to: string | null;
|
|
48
|
+
/** Which kind of reference produced this edge (`link`/`specs`/`supersedes`/`superseded_by`). */
|
|
49
|
+
readonly kind: EdgeKind;
|
|
50
|
+
/** The reference as parsed (link destination / frontmatter value), for diagnostics. */
|
|
51
|
+
readonly target: string;
|
|
52
|
+
/** `true` when the reference resolves to no concept in the bundle (`to === null`). */
|
|
53
|
+
readonly dangling: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The `graph.export` payload: the nodes, the edges among them, and the token budget. */
|
|
57
|
+
export interface GraphExport {
|
|
58
|
+
/** The root concept id when the export is a subgraph; omitted for a whole-bundle export. */
|
|
59
|
+
readonly root?: string;
|
|
60
|
+
/** The hop radius when the subgraph was bounded by `--depth`; omitted when unbounded or whole-bundle. */
|
|
61
|
+
readonly depth?: number;
|
|
62
|
+
/** Every included concept, in ascending id order. */
|
|
63
|
+
readonly nodes: readonly GraphNode[];
|
|
64
|
+
/** Every reference whose source is included and whose target is included or dangling, in graph order. */
|
|
65
|
+
readonly edges: readonly GraphEdge[];
|
|
66
|
+
/** The summed chars/4 token estimate over the included nodes (labeled a heuristic, not a tokenizer). */
|
|
67
|
+
readonly tokenEstimate: number;
|
|
68
|
+
/** Explicit selected workspace scope; absent for repository-local output. */
|
|
69
|
+
readonly workspace?: WorkspaceResultScope;
|
|
70
|
+
/** Exact manifest-authored cross-repository links in workspace mode. */
|
|
71
|
+
readonly workspaceLinks?: readonly WorkspaceProjectedLink[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Options for {@link buildGraphExport}. */
|
|
75
|
+
export interface GraphExportOptions {
|
|
76
|
+
/**
|
|
77
|
+
* The concept ids to include — typically a {@link subgraph} result. When omitted,
|
|
78
|
+
* the whole bundle is exported. Ids not present in the bundle are ignored.
|
|
79
|
+
*/
|
|
80
|
+
readonly include?: ReadonlySet<string>;
|
|
81
|
+
/** The root concept id, recorded on the result when this is a subgraph export. */
|
|
82
|
+
readonly root?: string;
|
|
83
|
+
/** The hop radius, recorded on the result when the subgraph was bounded. */
|
|
84
|
+
readonly depth?: number;
|
|
85
|
+
readonly workspace?: WorkspaceResultScope;
|
|
86
|
+
readonly provenanceById?: ReadonlyMap<string, WorkspaceRecordProvenance>;
|
|
87
|
+
readonly workspaceLinks?: readonly WorkspaceProjectedLink[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Shape a {@link BundleGraph} into a {@link GraphExport}, optionally narrowed to
|
|
92
|
+
* an `include` id-set (a {@link subgraph}).
|
|
93
|
+
*
|
|
94
|
+
* Nodes are the bundle's concepts (in their already-sorted id order) filtered to
|
|
95
|
+
* the included set, each annotated with its `type`, optional `title`, and token
|
|
96
|
+
* estimate. Edges are the **closure** of the included set: an edge is kept when
|
|
97
|
+
* its `from` is included **and** its `to` is included *or* dangling — so a
|
|
98
|
+
* subgraph keeps its internal links and its members' broken links, but drops
|
|
99
|
+
* edges that lead to concepts outside the requested radius (a bounded `--depth`
|
|
100
|
+
* is an explicit cut, not a leak). For a whole-bundle export every edge
|
|
101
|
+
* qualifies. The token total is summed over exactly the included nodes, so it
|
|
102
|
+
* equals the whole-bundle estimate for a full export and the subgraph's budget
|
|
103
|
+
* otherwise.
|
|
104
|
+
*/
|
|
105
|
+
export function buildGraphExport(graph: BundleGraph, options: GraphExportOptions = {}): GraphExport {
|
|
106
|
+
const { include, root, depth } = options;
|
|
107
|
+
// Drive node iteration by the *smaller* of the two: the whole concept map for a
|
|
108
|
+
// full export (already ascending-id order), or just the included ids — sorted to
|
|
109
|
+
// preserve that order — for a subgraph, so a bounded `--depth` query costs
|
|
110
|
+
// O(N_sub log N_sub), not O(N_total).
|
|
111
|
+
const ids = include === undefined ? graph.concepts.keys() : [...include].sort(compareCodeUnits);
|
|
112
|
+
const nodes: GraphNode[] = [];
|
|
113
|
+
let tokenEstimate = 0;
|
|
114
|
+
for (const id of ids) {
|
|
115
|
+
const concept = graph.concepts.get(id);
|
|
116
|
+
if (concept === undefined) {
|
|
117
|
+
continue; // an include id that names no concept — ignored, never an error
|
|
118
|
+
}
|
|
119
|
+
const tokens = graph.tokenEstimate(id);
|
|
120
|
+
tokenEstimate += tokens;
|
|
121
|
+
const title = frontmatterScalar(concept.frontmatter.title);
|
|
122
|
+
nodes.push({
|
|
123
|
+
id,
|
|
124
|
+
type: concept.type,
|
|
125
|
+
...(title !== undefined ? { title } : {}),
|
|
126
|
+
tokenEstimate: tokens,
|
|
127
|
+
...(options.provenanceById?.get(id) !== undefined ? { provenance: options.provenanceById.get(id) } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Edge closure: for a full export every edge qualifies (per the Edge contract
|
|
132
|
+
// `from` is always a concept and `to` is null-or-concept), so no membership
|
|
133
|
+
// filtering is needed; for a subgraph, keep an edge whose `from` is included and
|
|
134
|
+
// whose `to` is included or dangling — the `include` set already equals the node
|
|
135
|
+
// id-set, so no second set is built.
|
|
136
|
+
const edges: GraphEdge[] = [];
|
|
137
|
+
for (const edge of graph.edges) {
|
|
138
|
+
if (include !== undefined && (!include.has(edge.from) || (edge.to !== null && !include.has(edge.to)))) {
|
|
139
|
+
continue; // leads outside the requested subgraph — an explicit radius cut
|
|
140
|
+
}
|
|
141
|
+
edges.push({ from: edge.from, to: edge.to, kind: edge.kind, target: edge.target, dangling: edge.to === null });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...(root !== undefined ? { root } : {}),
|
|
146
|
+
...(depth !== undefined ? { depth } : {}),
|
|
147
|
+
nodes,
|
|
148
|
+
edges,
|
|
149
|
+
tokenEstimate,
|
|
150
|
+
...(options.workspace !== undefined ? { workspace: options.workspace } : {}),
|
|
151
|
+
...(options.workspaceLinks !== undefined ? { workspaceLinks: options.workspaceLinks } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render a {@link GraphExport} as Graphviz DOT — a `digraph` with one statement
|
|
157
|
+
* per node (labeled with its title or id) and one per **resolved** edge (labeled
|
|
158
|
+
* with its kind). Dangling edges are omitted: they have no target node to draw
|
|
159
|
+
* to, and the `--json` model already carries them for tooling that needs the
|
|
160
|
+
* broken-link signal (`lore check` is the dedicated reporter). Output is
|
|
161
|
+
* deterministic — nodes in the export's id order, edges in graph order — so
|
|
162
|
+
* `lore graph --dot | dot -Tpng` is stable across runs.
|
|
163
|
+
*/
|
|
164
|
+
export function toDot(data: GraphExport): string {
|
|
165
|
+
const lines = ["digraph lore {"];
|
|
166
|
+
for (const node of data.nodes) {
|
|
167
|
+
lines.push(` ${quote(node.id)} [label=${quote(node.title ?? node.id)}];`);
|
|
168
|
+
}
|
|
169
|
+
for (const edge of data.edges) {
|
|
170
|
+
if (edge.to === null) {
|
|
171
|
+
continue; // no target node to draw to
|
|
172
|
+
}
|
|
173
|
+
lines.push(` ${quote(edge.from)} -> ${quote(edge.to)} [label=${quote(edge.kind)}];`);
|
|
174
|
+
}
|
|
175
|
+
lines.push("}");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Quote a string as a DOT double-quoted ID (an escString, per the Graphviz
|
|
181
|
+
* language spec). Graphviz's lexer (`lib/cgraph/scan.l`) recognizes exactly
|
|
182
|
+
* two escapes inside a quoted string — `\"` for a literal quote and `\\` for
|
|
183
|
+
* a literal backslash — and otherwise **drops** a backslash that precedes
|
|
184
|
+
* any other character (so an *unescaped* `\` is not safe passthrough; it is
|
|
185
|
+
* silently eaten, or worse, combines with the following character). A literal
|
|
186
|
+
* backslash therefore must be doubled to `\\` so it survives the round trip,
|
|
187
|
+
* and that doubling must happen **first**, before any escape sequence this
|
|
188
|
+
* function injects (`\"`, `\n`) — otherwise the backslash the doubling itself
|
|
189
|
+
* introduces would be doubled again. A raw line break cannot survive inside a
|
|
190
|
+
* quoted ID without splitting `toDot()`'s one-statement-per-line output
|
|
191
|
+
* across two physical lines, so an embedded newline (`value` is
|
|
192
|
+
* bundle-controlled — a concept id or frontmatter scalar, either of which can
|
|
193
|
+
* carry one) is rewritten to the two-character escape `\n` — the same escape
|
|
194
|
+
* Graphviz itself uses to force a line break inside a label.
|
|
195
|
+
*/
|
|
196
|
+
function quote(value: string): string {
|
|
197
|
+
return `"${value
|
|
198
|
+
.replace(/\\/g, "\\\\")
|
|
199
|
+
.replace(/\r\n|\r|\n/g, "\\n")
|
|
200
|
+
.replace(/"/g, '\\"')}"`;
|
|
201
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* indexes.ts — the bundle's `index.md` files as **graph-derived**, byte-stable navigable hubs (LORE-29).
|
|
3
|
+
*
|
|
4
|
+
* Where [`log.ts`](./log.ts) derives `log.md` from git history, this module derives every
|
|
5
|
+
* `index.md` from the bundle graph: the reserved root entry point plus one local hub per
|
|
6
|
+
* sub-directory, each listing its immediate children (concepts + child indexes) as portable
|
|
7
|
+
* markdown links (okf-conformance: "index bodies link children as a navigable hub").
|
|
8
|
+
*
|
|
9
|
+
* ### What it owns, and what it deliberately does not
|
|
10
|
+
*
|
|
11
|
+
* Index files are **hand-authored documents with one machine-owned region** — exactly the
|
|
12
|
+
* managed-block discipline of lore-design §6.2, where everything outside the region is the
|
|
13
|
+
* author's and is never touched. {@link generateIndexes} regenerates only the
|
|
14
|
+
* `<!-- lore:index:begin -->…<!-- lore:index:end -->` listing block, by **string-splicing it
|
|
15
|
+
* into the existing file's raw bytes**. It never round-trips the file through
|
|
16
|
+
* {@link serializeConcept}: doing so would canonicalize frontmatter and, worse, drop the root
|
|
17
|
+
* index's in-fence editor modeline (an accepted js-yaml round-trip limitation, ADR-0011 §2). So
|
|
18
|
+
* the root index's `okf_version` frontmatter, its modeline, and all curated prose survive
|
|
19
|
+
* byte-for-byte; only the listing block changes.
|
|
20
|
+
*
|
|
21
|
+
* Two responsibilities stay with their owners, not here:
|
|
22
|
+
*
|
|
23
|
+
* - **Root index *creation* and `okf_version`** belong to `scaffold.ts` / `lore init`
|
|
24
|
+
* (okf-conformance: the bundle-root `index.md` is the only file carrying `okf_version`; a
|
|
25
|
+
* sub-index carries none, AC#2). `generateIndexes` only *maintains the region* of a root index
|
|
26
|
+
* that already exists; it never invents the frontmatter. A **sub**-index that does not yet
|
|
27
|
+
* exist is synthesized frontmatter-free.
|
|
28
|
+
* - **Writing files and reading their current bytes** is the command layer's (`lore sync`,
|
|
29
|
+
* LORE-26). The current on-disk index bytes enter through the injected {@link GenerateIndexesOptions.existing}
|
|
30
|
+
* seam — the same determinism boundary `log.ts` draws with its `GitAdapter`: core stays pure
|
|
31
|
+
* (no filesystem, no clock), and tests inject a fixed map rather than touching disk.
|
|
32
|
+
*
|
|
33
|
+
* ### Byte-stability (AC#1)
|
|
34
|
+
*
|
|
35
|
+
* The output is a pure function of `(g.concepts, existing)`: directory sets, child lists, and the
|
|
36
|
+
* listing are all sorted with {@link compareCodeUnits}, so the same bundle always yields the same
|
|
37
|
+
* bytes. Splicing is a **fixpoint** — re-running over already-generated bytes replaces the region
|
|
38
|
+
* with an identical region, so a no-change `lore sync` is a byte-level no-op and `lore check`'s
|
|
39
|
+
* regenerate-and-compare drift gate stays trustworthy (`index.md` is gated, unlike the
|
|
40
|
+
* git-derived `log.md`; see [log.ts](./log.ts)).
|
|
41
|
+
*
|
|
42
|
+
* ### A note for the link gate (LORE-27)
|
|
43
|
+
*
|
|
44
|
+
* A generated hub links to child-directory indexes (`[adr](adr/index.md)`), but those sub-indexes
|
|
45
|
+
* are frontmatter-free (AC#2) and so are **not** concepts in the {@link BundleGraph} — the root
|
|
46
|
+
* index *is* a concept (it carries `okf_version`), so `bundle.ts` extracts those links as edges that
|
|
47
|
+
* resolve to `null` (dangling). That is expected: when `lore check`'s broken-link reporting lands
|
|
48
|
+
* (LORE-27), it must treat a reserved `index.md`/`log.md` link target as resolving to the file, not
|
|
49
|
+
* flag a freshly-generated, correct hub as broken.
|
|
50
|
+
*
|
|
51
|
+
* Per the core contract (lore-design §2.1) this module is pure: no filesystem, no spawn, no clock.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { posix } from "node:path";
|
|
55
|
+
import { LoreError, singleLine } from "../errors";
|
|
56
|
+
import { type BundleGraph, frontmatterScalar } from "./bundle";
|
|
57
|
+
import type { Concept } from "./concept";
|
|
58
|
+
import { encodePathSegments } from "./links";
|
|
59
|
+
import { compareCodeUnits } from "./order";
|
|
60
|
+
|
|
61
|
+
/** The reserved index file name — the per-directory navigable hub (one per dir, plus the bundle root). */
|
|
62
|
+
const INDEX_FILE = "index.md";
|
|
63
|
+
|
|
64
|
+
/** Reserved file names excluded from a directory's child listing (they are structure, not content). */
|
|
65
|
+
const RESERVED_FILES: ReadonlySet<string> = new Set([INDEX_FILE, "log.md"]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The HTML-comment markers bounding the machine-owned listing block. HTML comments are invisible
|
|
69
|
+
* in every target renderer (GitHub, Obsidian, MkDocs, Docusaurus), and the exact `lore:index:*`
|
|
70
|
+
* sentinels never occur in authored prose, so a literal find is safe (no AST needed here; the
|
|
71
|
+
* remark/mdast unification of all managed regions is LORE-22's `managed-block.ts`). The `:begin`/
|
|
72
|
+
* `:end` shape mirrors the `<!-- lore:tasks:begin -->…<!-- lore:tasks:end -->` task block
|
|
73
|
+
* (lore-design §6.2). Exported so `lore sync`/`lore check` (LORE-26/27) match the same block.
|
|
74
|
+
*/
|
|
75
|
+
export const INDEX_BLOCK_BEGIN = "<!-- lore:index:begin -->";
|
|
76
|
+
export const INDEX_BLOCK_END = "<!-- lore:index:end -->";
|
|
77
|
+
|
|
78
|
+
/** Options for {@link generateIndexes}. */
|
|
79
|
+
export interface GenerateIndexesOptions {
|
|
80
|
+
/**
|
|
81
|
+
* The current raw bytes of each index file, keyed by bundle-relative path (e.g. `"index.md"`,
|
|
82
|
+
* `"adr/index.md"`) — the injected determinism seam (lore-design §8). The command layer reads
|
|
83
|
+
* these from disk; tests inject a fixed map. A present entry has its listing region spliced in
|
|
84
|
+
* place (or appended if it carries no markers yet), preserving every other byte — frontmatter,
|
|
85
|
+
* modeline, and prose. An absent entry means the index file does not exist, so a minimal
|
|
86
|
+
* frontmatter-free hub is synthesized. Default: empty (every index synthesized fresh).
|
|
87
|
+
*/
|
|
88
|
+
readonly existing?: ReadonlyMap<string, string>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Regenerate every bundle `index.md` from the graph, returning a map of bundle-relative path →
|
|
93
|
+
* new bytes (the same path space as {@link BundleGraph} concept ids/paths, e.g. `"adr/index.md"`).
|
|
94
|
+
*
|
|
95
|
+
* An index is produced for the bundle root and for every directory that (transitively) contains a
|
|
96
|
+
* concept, so the hub is navigable from the root down. Each index lists, sorted by link target:
|
|
97
|
+
* its immediate child concepts (excluding the reserved `index.md`/`log.md`) and a link into each
|
|
98
|
+
* immediate child directory's index.
|
|
99
|
+
*
|
|
100
|
+
* Pure and total: identical `(g, options)` always yield identical output, and splicing is a
|
|
101
|
+
* fixpoint (AC#1). Existing files keep all bytes outside the listing block (AC#2: a sub-index is
|
|
102
|
+
* never given frontmatter); see the module header for the seam and ownership boundaries.
|
|
103
|
+
*/
|
|
104
|
+
export function generateIndexes(g: BundleGraph, options: GenerateIndexesOptions = {}): Map<string, string> {
|
|
105
|
+
const existing = options.existing ?? new Map<string, string>();
|
|
106
|
+
|
|
107
|
+
// Group concepts by their directory, excluding reserved files (an index/log is bundle structure,
|
|
108
|
+
// not a listable child — and the root index is itself a concept we must not list under itself).
|
|
109
|
+
const conceptsByDir = new Map<string, Concept[]>();
|
|
110
|
+
for (const concept of g.concepts.values()) {
|
|
111
|
+
if (RESERVED_FILES.has(posix.basename(concept.path))) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const dir = dirOf(concept.path);
|
|
115
|
+
const bucket = conceptsByDir.get(dir);
|
|
116
|
+
if (bucket === undefined) {
|
|
117
|
+
conceptsByDir.set(dir, [concept]);
|
|
118
|
+
} else {
|
|
119
|
+
bucket.push(concept);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Every directory that needs an index: each dir holding a concept, plus all of its ancestors up
|
|
124
|
+
// to the root (so an intermediate dir with concepts only deeper still gets a hub that links down).
|
|
125
|
+
const indexDirs = liveIndexDirs(g);
|
|
126
|
+
|
|
127
|
+
// Immediate child directories of each index dir (only dirs that themselves get an index).
|
|
128
|
+
const childDirs = new Map<string, string[]>();
|
|
129
|
+
for (const dir of indexDirs) {
|
|
130
|
+
if (dir === "") {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const parent = parentDir(dir);
|
|
134
|
+
const bucket = childDirs.get(parent);
|
|
135
|
+
if (bucket === undefined) {
|
|
136
|
+
childDirs.set(parent, [dir]);
|
|
137
|
+
} else {
|
|
138
|
+
bucket.push(dir);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = new Map<string, string>();
|
|
143
|
+
for (const dir of indexDirs) {
|
|
144
|
+
const indexPath = dir === "" ? INDEX_FILE : `${dir}/${INDEX_FILE}`;
|
|
145
|
+
const listing = buildListing(conceptsByDir.get(dir) ?? [], childDirs.get(dir) ?? []);
|
|
146
|
+
const block = `${INDEX_BLOCK_BEGIN}\n${listing}\n${INDEX_BLOCK_END}`;
|
|
147
|
+
result.set(indexPath, render(existing.get(indexPath), block, dir));
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every directory that needs a **live** index for `g`: the bundle root, every directory holding a
|
|
154
|
+
* concept directly (excluding the reserved `index.md`/`log.md`), plus each such directory's
|
|
155
|
+
* ancestors up to the root — so an intermediate directory with concepts only deeper still counts as
|
|
156
|
+
* live (it gets a hub that links down to them). This is exactly {@link generateIndexes}'s own
|
|
157
|
+
* `indexDirs` derivation, factored out so {@link orphanedIndexPaths} can compare against the same
|
|
158
|
+
* live set without duplicating (and risking drift from) the ancestor-walk logic.
|
|
159
|
+
*/
|
|
160
|
+
function liveIndexDirs(g: BundleGraph): Set<string> {
|
|
161
|
+
const dirs = new Set<string>([""]);
|
|
162
|
+
for (const concept of g.concepts.values()) {
|
|
163
|
+
if (RESERVED_FILES.has(posix.basename(concept.path))) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const ancestor of ancestorsInclusive(dirOf(concept.path))) {
|
|
167
|
+
dirs.add(ancestor);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return dirs;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The bundle-relative paths of every `index.md` present in `existing` whose directory {@link
|
|
175
|
+
* generateIndexes} did **not** regenerate for `g` — a directory holding no concept, directly or via
|
|
176
|
+
* any descendant (LORE-150). This is the stale-index detection `generateIndexes` itself never
|
|
177
|
+
* performed: it derives `indexDirs` solely from the live graph and simply never emits an entry for a
|
|
178
|
+
* directory outside that set, so a caller iterating only its *returned* map has no way to tell "this
|
|
179
|
+
* on-disk index was never regenerated because its directory is orphaned" apart from "this on-disk
|
|
180
|
+
* index was never regenerated because nothing has changed" — both look identical from that map
|
|
181
|
+
* alone. Diffing `existing`'s keys against the live set here makes that distinction explicit and
|
|
182
|
+
* queryable, so `lore sync` (the one caller with a `existing` map sourced from disk) can report an
|
|
183
|
+
* orphan instead of silently leaving it untouched and unmentioned.
|
|
184
|
+
*
|
|
185
|
+
* `generateIndexes`'s own signature and behavior are deliberately unchanged (its returned map is
|
|
186
|
+
* still keyed only by live directories, exactly as before) — this is a separate, additive query so
|
|
187
|
+
* every existing caller (`lore rename`'s `mergeIndexWrites`, which has its own established
|
|
188
|
+
* empty-listing strategy for a directory a rename itself empties) keeps compiling and behaving
|
|
189
|
+
* identically; only `lore sync` needs to ask this new question.
|
|
190
|
+
*
|
|
191
|
+
* Pure and total, sorted with {@link compareCodeUnits} (matching {@link generateIndexes}'s own
|
|
192
|
+
* ordering) so a caller's report is byte-stable regardless of `existing`'s iteration order.
|
|
193
|
+
*/
|
|
194
|
+
export function orphanedIndexPaths(g: BundleGraph, existing: ReadonlyMap<string, string>): string[] {
|
|
195
|
+
const live = liveIndexDirs(g);
|
|
196
|
+
const livePaths = new Set<string>();
|
|
197
|
+
for (const dir of live) {
|
|
198
|
+
livePaths.add(dir === "" ? INDEX_FILE : `${dir}/${INDEX_FILE}`);
|
|
199
|
+
}
|
|
200
|
+
return [...existing.keys()].filter((path) => !livePaths.has(path)).sort(compareCodeUnits);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The sorted listing lines for one directory: its immediate child concepts and child-directory
|
|
205
|
+
* indexes, each `- [title](link)`, ordered by link target with {@link compareCodeUnits} so the
|
|
206
|
+
* block is byte-stable regardless of map iteration order. An empty directory (no listable children)
|
|
207
|
+
* yields an empty string, so the block is still well-formed (just the two markers around nothing).
|
|
208
|
+
*/
|
|
209
|
+
function buildListing(concepts: readonly Concept[], childDirSet: readonly string[]): string {
|
|
210
|
+
const entries: Array<{ link: string; title: string }> = [];
|
|
211
|
+
|
|
212
|
+
for (const concept of concepts) {
|
|
213
|
+
const target = posix.basename(concept.path);
|
|
214
|
+
entries.push({ link: encodePathSegments(target), title: conceptTitle(concept) });
|
|
215
|
+
}
|
|
216
|
+
for (const childDir of childDirSet) {
|
|
217
|
+
const name = posix.basename(childDir);
|
|
218
|
+
entries.push({ link: `${encodePathSegments(name)}/${INDEX_FILE}`, title: name });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return entries
|
|
222
|
+
.sort((a, b) => compareCodeUnits(a.link, b.link))
|
|
223
|
+
.map((e) => `- [${linkText(e.title)}](${e.link})`)
|
|
224
|
+
.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sanitize an untrusted title (a concept's frontmatter `title` or a file/dir base name) into safe
|
|
229
|
+
* markdown **link text**. Three defenses, all of which keep the rendered text readable:
|
|
230
|
+
*
|
|
231
|
+
* - **Single-line** ({@link singleLine}, as `log.ts` does for commit subjects) — a title carrying a
|
|
232
|
+
* newline (a YAML block scalar) would otherwise split the entry out of the list.
|
|
233
|
+
* - **Escape `\`** — done *first*, before the `[`/`]` escape below. A pre-existing literal backslash
|
|
234
|
+
* immediately before a bracket (e.g. a title `Plan \]B\[`) would otherwise combine with the
|
|
235
|
+
* bracket-escape's inserted backslash into CommonMark's `\\` (an escaped backslash) followed by a
|
|
236
|
+
* live, link-syntax bracket — shifting text into a real `[text](link)` boundary (same class of bug
|
|
237
|
+
* as {@link cell}, LORE-154). Doubling backslashes first means the later step's own backslashes are
|
|
238
|
+
* never mistaken for source content and never re-escaped.
|
|
239
|
+
* - **Escape `[` / `]`** — an unbalanced bracket would truncate or break the `[text](link)` syntax,
|
|
240
|
+
* leaving the entry as literal text that links nowhere.
|
|
241
|
+
* - **Neutralize HTML-comment sentinels** (`<!--` / `-->` → entities) — a title literally containing
|
|
242
|
+
* the managed-block end marker would otherwise be written *inside* the generated block and, on the
|
|
243
|
+
* next run, be mistaken for the region boundary — corrupting the file and breaking the AC#1
|
|
244
|
+
* fixpoint. The entities render identically (and, as a bonus, a stray `<!--` no longer starts a
|
|
245
|
+
* real HTML comment that hides text).
|
|
246
|
+
*/
|
|
247
|
+
function linkText(title: string): string {
|
|
248
|
+
return singleLine(title)
|
|
249
|
+
.replace(/\\/g, "\\\\")
|
|
250
|
+
.replace(/[[\]]/g, (c) => `\\${c}`)
|
|
251
|
+
.replace(/<!--/g, "<!--")
|
|
252
|
+
.replace(/-->/g, "-->");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A concept's display title: its frontmatter `title` via the shared {@link frontmatterScalar}
|
|
257
|
+
* (a non-empty string verbatim, or a finite number/boolean coerced to its string form) when that
|
|
258
|
+
* yields a value, else the file's base name (the id's last segment). Falling back to the file
|
|
259
|
+
* name keeps the listing total for a title-less or unknown-type concept (OKF tolerance) without
|
|
260
|
+
* inventing prose. Sharing `frontmatterScalar` keeps this in lockstep with `graph.ts`/`query.ts`/
|
|
261
|
+
* `context.ts`, so the same concept never shows one title in the generated index and another
|
|
262
|
+
* everywhere else (LORE-244).
|
|
263
|
+
*/
|
|
264
|
+
function conceptTitle(concept: Concept): string {
|
|
265
|
+
return frontmatterScalar(concept.frontmatter.title) ?? posix.basename(concept.path, ".md");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Produce the final index bytes for one directory, given the directory's current raw bytes (or
|
|
270
|
+
* `undefined` if absent) and the freshly built listing `block`.
|
|
271
|
+
*
|
|
272
|
+
* - **Existing file with the markers** → the block (begin…end inclusive) is replaced in place; all
|
|
273
|
+
* other bytes are preserved verbatim (frontmatter, modeline, prose), so only the listing changes.
|
|
274
|
+
* - **Existing file without markers** → the block is appended after the body, separated by one
|
|
275
|
+
* blank line, with a single trailing newline; nothing the author wrote is altered.
|
|
276
|
+
* - **Absent or blank file** → a minimal, frontmatter-free hub is synthesized (`# <title>` + the
|
|
277
|
+
* block). An existing-but-empty/whitespace-only file is treated like an absent one so a
|
|
278
|
+
* `touch`-ed or blanked index still gets a heading rather than a heading-less, blank-line-led stub.
|
|
279
|
+
* The root index is never synthesized with `okf_version` here (that is `lore init`'s job); a
|
|
280
|
+
* synthesized root simply carries the block under a heading.
|
|
281
|
+
*
|
|
282
|
+
* Every branch ends in exactly one trailing newline, and re-rendering the output is a **fixpoint**
|
|
283
|
+
* (AC#1): the replace branch absorbs a block left at end-of-file by re-adding the newline only when
|
|
284
|
+
* nothing follows the block.
|
|
285
|
+
*/
|
|
286
|
+
function render(current: string | undefined, block: string, dir: string): string {
|
|
287
|
+
if (current === undefined || current.trim() === "") {
|
|
288
|
+
return `# ${headingFor(dir)}\n\n${block}\n`;
|
|
289
|
+
}
|
|
290
|
+
const bounds = blockBounds(current);
|
|
291
|
+
if (bounds !== null) {
|
|
292
|
+
const tail = current.slice(bounds.end);
|
|
293
|
+
const spliced = current.slice(0, bounds.start) + block + tail;
|
|
294
|
+
// A block sitting at end-of-file (a normal append, or a truncated region replaced to EOF) leaves
|
|
295
|
+
// no trailing newline; add exactly one. When prose follows the block, `tail` already carries it
|
|
296
|
+
// (newline included), so this is a no-op and the result stays a fixpoint.
|
|
297
|
+
return tail === "" ? `${spliced}\n` : spliced;
|
|
298
|
+
}
|
|
299
|
+
// No managed region yet: append it, normalizing to exactly one blank-line separator and one
|
|
300
|
+
// trailing newline so a second run (which now finds the markers) is a byte-level fixpoint.
|
|
301
|
+
return `${current.replace(/\n+$/, "")}\n\n${block}\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Locate the managed block in `content` as a `[start, end)` byte range, or `null` when no begin
|
|
306
|
+
* marker is present (an unmanaged file the caller appends to). See {@link locateManagedBlock} for
|
|
307
|
+
* the full contract, including the malformed-layout cases that are a fail-loud `validation` error
|
|
308
|
+
* (LORE-86).
|
|
309
|
+
*/
|
|
310
|
+
function blockBounds(content: string): { start: number; end: number } | null {
|
|
311
|
+
return locateManagedBlock(content, INDEX_BLOCK_BEGIN, INDEX_BLOCK_END);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The generic managed-block locator behind {@link blockBounds}, exported as the **one** rule for
|
|
316
|
+
* where a lore-managed region lives in raw text — `[start, end)` spanning the `begin` marker to the
|
|
317
|
+
* `end` marker (markers included), or `null` when no `begin` is present (an unmanaged file the
|
|
318
|
+
* caller appends to).
|
|
319
|
+
*
|
|
320
|
+
* A well-formed file carries exactly one `begin` and one `end`, with the `end` at or after the
|
|
321
|
+
* `begin`. Anything else is a **malformed layout** — a duplicated pair (both markers appear more
|
|
322
|
+
* than once, e.g. a 3-way-merge artifact) or an unmatched `begin` (no `end` after it, e.g. an
|
|
323
|
+
* interrupted write, or an `end` marker that precedes it) — and is a fail-loud `validation`
|
|
324
|
+
* {@link LoreError} (exit 6) rather than a guessed range. This module previously collapsed a
|
|
325
|
+
* duplicated pair to its first-begin→last-end span and extended an unmatched begin to end-of-file,
|
|
326
|
+
* both to keep regeneration converging to a byte-level fixpoint even from a corrupted file — but a
|
|
327
|
+
* duplicated pair's collapse silently deleted any hand-authored prose sitting between the two
|
|
328
|
+
* blocks (LORE-86), and a guessed range is exactly the ambiguity {@link findMarkers} in
|
|
329
|
+
* `managed-block.ts` already refuses for the sibling `lore:tasks` block. This function now matches
|
|
330
|
+
* that same fail-loud contract instead of guessing.
|
|
331
|
+
*
|
|
332
|
+
* Exported so every consumer that must agree on a managed region's extent (and its validation) uses
|
|
333
|
+
* the *same* rule: index regeneration ({@link generateIndexes}) splices it, and `lore replace`
|
|
334
|
+
* (LORE-35) protects it. Sharing this is what keeps `replace` from editing a span that `lore sync`
|
|
335
|
+
* would later regenerate.
|
|
336
|
+
*
|
|
337
|
+
* The unavoidable cost of literal markers (no AST): a begin/end the *author* wrote in prose — e.g. a
|
|
338
|
+
* doc demonstrating this very format — is treated as a real marker occurrence, contributing to the
|
|
339
|
+
* duplicate count above. That ambiguity is what LORE-22's mdast-based `managed-block.ts` resolves
|
|
340
|
+
* structurally; here it remains a documented limitation of the string splice.
|
|
341
|
+
*/
|
|
342
|
+
export function locateManagedBlock(content: string, begin: string, end: string): { start: number; end: number } | null {
|
|
343
|
+
const begins = allIndicesOf(content, begin);
|
|
344
|
+
if (begins.length === 0) {
|
|
345
|
+
return null; // no managed block yet — the caller appends one
|
|
346
|
+
}
|
|
347
|
+
const ends = allIndicesOf(content, end);
|
|
348
|
+
if (begins.length > 1 || ends.length > 1) {
|
|
349
|
+
throw managedBlockError(
|
|
350
|
+
begin,
|
|
351
|
+
end,
|
|
352
|
+
`the managed block is duplicated (found ${begins.length} begin and ${ends.length} end markers; expected exactly one of each)`,
|
|
353
|
+
{ begins: begins.length, ends: ends.length },
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const start = begins[0] as number;
|
|
357
|
+
if (ends.length === 0) {
|
|
358
|
+
throw managedBlockError(begin, end, "the begin marker has no matching end marker after it", {
|
|
359
|
+
begins: begins.length,
|
|
360
|
+
ends: ends.length,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const endStart = ends[0] as number;
|
|
364
|
+
if (endStart < start + begin.length) {
|
|
365
|
+
throw managedBlockError(begin, end, "the markers are crossed (the end marker precedes the begin marker)", {
|
|
366
|
+
begin: start,
|
|
367
|
+
end: endStart,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return { start, end: endStart + end.length };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Every (non-overlapping) occurrence of `marker` in `content`, in ascending order. */
|
|
374
|
+
function allIndicesOf(content: string, marker: string): number[] {
|
|
375
|
+
const indices: number[] = [];
|
|
376
|
+
let from = 0;
|
|
377
|
+
for (;;) {
|
|
378
|
+
const at = content.indexOf(marker, from);
|
|
379
|
+
if (at === -1) {
|
|
380
|
+
return indices;
|
|
381
|
+
}
|
|
382
|
+
indices.push(at);
|
|
383
|
+
from = at + marker.length;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the fail-loud "malformed managed-block markers" error {@link locateManagedBlock} throws
|
|
389
|
+
* (`validation`, exit 6) — the same category and shape `managed-block.ts`'s marker validation uses
|
|
390
|
+
* for the sibling `lore:tasks` block, so both engines refuse to guess in the same voice.
|
|
391
|
+
*/
|
|
392
|
+
function managedBlockError(begin: string, end: string, reason: string, input: Record<string, unknown>): LoreError {
|
|
393
|
+
return new LoreError(
|
|
394
|
+
"validation",
|
|
395
|
+
`cannot locate the managed block bounded by \`${begin}\` / \`${end}\`: ${reason}`,
|
|
396
|
+
`place exactly one \`${begin}\` and one \`${end}\` on their own lines, in that order`,
|
|
397
|
+
input,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The synthesized heading for a brand-new index: the directory's base name, or `index` for the root. */
|
|
402
|
+
function headingFor(dir: string): string {
|
|
403
|
+
return dir === "" ? "index" : posix.basename(dir);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The directory of a bundle-relative concept path, normalized to `""` for the bundle root.
|
|
408
|
+
* `posix.dirname("x.md")` is `"."`; we use `""` for the root so the root index path is `index.md`
|
|
409
|
+
* (not `./index.md`) and ancestor walking terminates cleanly.
|
|
410
|
+
*/
|
|
411
|
+
function dirOf(path: string): string {
|
|
412
|
+
const dir = posix.dirname(path);
|
|
413
|
+
return dir === "." ? "" : dir;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The parent of a non-root bundle directory (`"a/b/c"` → `"a/b"`, `"a"` → `""`). */
|
|
417
|
+
function parentDir(dir: string): string {
|
|
418
|
+
const slash = dir.lastIndexOf("/");
|
|
419
|
+
return slash === -1 ? "" : dir.slice(0, slash);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Every directory from `dir` up to (and including) the root `""`, e.g. `"a/b"` → `["a/b", "a", ""]`.
|
|
424
|
+
* Used to ensure an intermediate directory that holds no concept directly — only in a descendant —
|
|
425
|
+
* still gets an index that links down toward it.
|
|
426
|
+
*/
|
|
427
|
+
function ancestorsInclusive(dir: string): string[] {
|
|
428
|
+
const chain: string[] = [];
|
|
429
|
+
let current = dir;
|
|
430
|
+
while (current !== "") {
|
|
431
|
+
chain.push(current);
|
|
432
|
+
current = parentDir(current);
|
|
433
|
+
}
|
|
434
|
+
chain.push("");
|
|
435
|
+
return chain;
|
|
436
|
+
}
|